初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
// DO NOT IMPORT window.config HERE!
// to make sure the error handler always works, we should never import `window.config`, because
// some user's custom template breaks it.
import {showGlobalErrorMessage, processWindowErrorEvent} from './modules/errors.ts';
// A module should not be imported twice, otherwise there will be bugs when a module has its internal states.
// A real example is "generateElemId" in "utils/dom.ts", if it is imported twice in different module scopes,
// It will generate duplicate IDs (ps: don't try to use "random" to fix, it is just a real example to show the importance of "do not import a module twice")
if (!window._globalHandlerErrors?._inited) {
if (!window.config) {
showGlobalErrorMessage(`Gitea JavaScript code couldn't run correctly, please check your custom templates`);
}
// we added an event handler for window error at the very beginning of <script> of page head the
// handler calls `_globalHandlerErrors.push` (array method) to record all errors occur before
// this init then in this init, we can collect all error events and show them.
for (const e of (window._globalHandlerErrors as Iterable<ErrorEvent & PromiseRejectionEvent>) || []) {
processWindowErrorEvent(e);
}
// then, change _globalHandlerErrors to an object with push method, to process further error
// events directly
window._globalHandlerErrors = {_inited: true, push: (e: ErrorEvent & PromiseRejectionEvent) => processWindowErrorEvent(e)} as any;
}
@@ -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>
`;
}
+832
View File
@@ -0,0 +1,832 @@
<script setup lang="ts">
import {computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import ActionStatusIcon from './ActionStatusIcon.vue';
import WorkflowGraph from './WorkflowGraph.vue';
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
import {formatDatetime, formatDatetimeISO} from '../utils/time.ts';
import {POST} from '../modules/fetch.ts';
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, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
import {
type ActionRunViewStore,
collectCallerChildJobs,
createLogLineMessage,
type LogLine,
type LogLineCommand,
parseLogLineCommand,
} from './ActionRunView.ts';
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
const rect = el.getBoundingClientRect();
// only check whether bottom is in viewport, because the log element can be a log group which is usually tall
return 0 <= rect.bottom && rect.bottom <= window.innerHeight + extraViewPortHeight;
}
type Step = {
summary: string,
duration: string,
status: ActionsStatus,
}
type JobStepState = {
cursor: string|null,
expanded: boolean,
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
}
type StepContainerElement = HTMLElement & {
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
// then the following batches of logs should still use the same group (active logs container).
// maybe it can be refactored to decouple from the HTML element in the future.
_stepLogsActiveContainer?: HTMLElement;
}
type LocaleStorageOptions = {
autoScroll: boolean;
expandRunning: boolean;
actionsLogShowSeconds: boolean;
actionsLogShowTimestamps: boolean;
};
type CurrentJob = {
title: string;
detail: string;
steps: Array<Step>;
};
type JobData = {
artifacts: Array<ActionsArtifact>;
state: {
run: ActionsRun;
currentJob: CurrentJob;
},
logs: {
stepsLog?: Array<{
step: number;
cursor: string | null;
started: number;
lines: LogLine[];
}>;
},
};
defineOptions({
name: 'ActionRunJobView',
});
const props = defineProps<{
store: ActionRunViewStore,
jobId: number;
actionsViewUrl: string;
locale: Record<string, any>;
}>();
const store = props.store;
const {currentRun: run} = toRefs(store.viewData);
const defaultViewOptions: LocaleStorageOptions = {
autoScroll: true,
expandRunning: false,
actionsLogShowSeconds: false,
actionsLogShowTimestamps: false,
};
const savedViewOptions = localUserSettings.getJsonObject('actions-view-options', defaultViewOptions);
const {autoScroll, expandRunning, actionsLogShowSeconds, actionsLogShowTimestamps} = savedViewOptions;
// internal state
let loadingAbortController: AbortController | null = null;
let intervalID: IntervalId | null = null;
const currentJobStepsStates = ref<Array<JobStepState>>([]);
const menuVisible = ref(false);
const isFullScreen = ref(false);
const timeVisible = ref<Record<string, boolean>>({
'log-time-stamp': actionsLogShowTimestamps,
'log-time-seconds': actionsLogShowSeconds,
});
const optionAlwaysAutoScroll = ref(autoScroll);
const optionAlwaysExpandRunning = ref(expandRunning);
const currentJob = ref<CurrentJob>({
title: '',
detail: '',
steps: [] as Array<Step>,
});
const stepsContainer = ref<HTMLElement | null>(null);
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
// Reusable workflow caller view: when the selected job is a caller node, the right pane
// shows the children list rather than step logs (callers don't run on a runner).
const selectedJob = computed<ActionsJob | undefined>(() => (run.value.jobs || []).find((it) => it.id === props.jobId));
const isCallerJob = computed(() => Boolean(selectedJob.value?.isReusableCaller));
const callerChildJobs = computed<ActionsJob[]>(() => {
if (!isCallerJob.value) return [];
return collectCallerChildJobs(run.value.jobs || [], props.jobId);
});
watch(optionAlwaysAutoScroll, () => {
saveLocaleStorageOptions();
});
watch(optionAlwaysExpandRunning, () => {
saveLocaleStorageOptions();
});
onMounted(async () => {
// load job data and then auto-reload periodically
// need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
await loadJob();
// auto-scroll to the bottom of the log group when it is opened
// "toggle" event doesn't bubble, so we need to use 'click' event delegation to handle it
addDelegatedEventListener(elStepsContainer(), 'click', 'summary.job-log-group-summary', (el, _) => {
if (!optionAlwaysAutoScroll.value) return;
const elJobLogGroup = el.closest('details.job-log-group') as HTMLDetailsElement;
setTimeout(() => {
if (elJobLogGroup.open && !isLogElementInViewport(elJobLogGroup)) {
elJobLogGroup.scrollIntoView({behavior: 'smooth', block: 'end'});
}
}, 0);
});
intervalID = setInterval(() => void loadJob(), 1000);
document.body.addEventListener('click', closeDropdown);
void hashChangeListener();
window.addEventListener('hashchange', hashChangeListener);
});
onBeforeUnmount(() => {
document.body.removeEventListener('click', closeDropdown);
window.removeEventListener('hashchange', hashChangeListener);
// clear the interval timer when the component is unmounted
// even our page is rendered once, not spa style
if (intervalID) {
clearInterval(intervalID);
intervalID = null;
}
});
function saveLocaleStorageOptions() {
const opts: LocaleStorageOptions = {
autoScroll: optionAlwaysAutoScroll.value,
expandRunning: optionAlwaysExpandRunning.value,
actionsLogShowSeconds: timeVisible.value['log-time-seconds'],
actionsLogShowTimestamps: timeVisible.value['log-time-stamp'],
};
localUserSettings.setJsonObject('actions-view-options', opts);
}
// get the job step logs container ('.job-step-logs')
function getJobStepLogsContainer(stepIndex: number): StepContainerElement {
return jobStepLogs.value[stepIndex] as StepContainerElement;
}
// get the active logs container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
function getActiveLogsContainer(stepIndex: number): StepContainerElement {
const el = getJobStepLogsContainer(stepIndex);
return el._stepLogsActiveContainer ?? el;
}
// begin a log group
function beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
const el = getJobStepLogsContainer(stepIndex);
// Using "summary + details" is the best way to create a log group because it has built-in support for "toggle" and "accessibility".
// And it makes users can use "Ctrl+F" to search the logs without opening all log groups.
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
createLogLine(stepIndex, startTime, line, cmd),
);
const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'});
const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'},
elJobLogGroupSummary,
elJobLogList,
);
el.append(elJobLogGroup);
el._stepLogsActiveContainer = elJobLogList;
}
// end a log group
function endLogGroup(stepIndex: number) {
const el = getJobStepLogsContainer(stepIndex);
el._stepLogsActiveContainer = undefined;
}
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;
if (currentJobStepsStates.value[idx].expanded) {
void loadJobForce(); // try to load the data immediately instead of waiting for next timer interval
} else if (currentJob.value.steps[idx].status === 'running') {
currentJobStepsStates.value[idx].manuallyCollapsed = true;
}
}
function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand | null) {
const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
String(line.index),
);
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
formatDatetime(line.timestamp * 1000), // for "Show timestamps"
);
const logMsg = createLogLineMessage(line, cmd);
const seconds = Math.floor(line.timestamp - startTime);
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
`${seconds}s`, // for "Show seconds"
);
toggleElem(logTimeStamp, timeVisible.value['log-time-stamp']);
toggleElem(logTimeSeconds, timeVisible.value['log-time-seconds']);
const lineClass = cmd?.name ? `job-log-line log-line-${cmd.name}` : 'job-log-line';
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: lineClass},
lineNum, logTimeStamp, logMsg, logTimeSeconds,
);
}
function shouldAutoScroll(stepIndex: number): boolean {
if (!optionAlwaysAutoScroll.value) return false;
const el = getJobStepLogsContainer(stepIndex);
// if the logs container is empty, then auto-scroll if the step is expanded
if (!el.lastChild) return currentJobStepsStates.value[stepIndex].expanded;
// use extraViewPortHeight to tolerate some extra "virtual view port" height (for example: the last line is partially visible)
return isLogElementInViewport(el.lastChild as Element, {extraViewPortHeight: 5});
}
function appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
for (const line of logLines) {
const cmd = parseLogLineCommand(line);
switch (cmd?.name) {
case 'hidden':
continue;
case 'group':
beginLogGroup(stepIndex, startTime, line, cmd);
continue;
case 'endgroup':
endLogGroup(stepIndex);
continue;
}
// the active logs container may change during the loop, for example: entering and leaving a group
const el = getActiveLogsContainer(stepIndex);
el.append(createLogLine(stepIndex, startTime, line, cmd));
}
}
// "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();
}
async function loadJobForce() {
loadingAbortController?.abort();
loadingAbortController = null;
await loadJob();
}
async function loadJob() {
if (loadingAbortController) return;
const abortController = new AbortController();
loadingAbortController = abortController;
try {
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
// Use consistent "store" operations to load/update the view data
store.viewData.runArtifacts = runJobResp.artifacts || [];
store.viewData.currentRun = runJobResp.state.run;
currentJob.value = runJobResp.state.currentJob;
const jobLogs = runJobResp.logs.stepsLog ?? [];
// sync the currentJobStepsStates to store the job step states
for (let i = 0; i < currentJob.value.steps.length; i++) {
const autoExpand = optionAlwaysExpandRunning.value && currentJob.value.steps[i].status === 'running';
if (!currentJobStepsStates.value[i]) {
// initial states for job steps
currentJobStepsStates.value[i] = {cursor: null, expanded: autoExpand, manuallyCollapsed: false};
} else {
// if the step is not manually collapsed by user, then auto-expand it if option is enabled
if (autoExpand && !currentJobStepsStates.value[i].manuallyCollapsed) {
currentJobStepsStates.value[i].expanded = true;
}
}
}
await nextTick();
// find the step indexes that need to auto-scroll
const autoScrollStepIndexes = new Map<number, boolean>();
for (const stepLogs of jobLogs) {
if (autoScrollStepIndexes.has(stepLogs.step)) continue;
autoScrollStepIndexes.set(stepLogs.step, shouldAutoScroll(stepLogs.step));
}
// append logs to the UI
for (const stepLogs of jobLogs) {
// save the cursor, it will be passed to backend next time
currentJobStepsStates.value[stepLogs.step].cursor = stepLogs.cursor;
appendLogs(stepLogs.step, stepLogs.started, stepLogs.lines);
}
// auto-scroll to the last log line of the last step
let autoScrollJobStepElement: StepContainerElement | undefined;
for (let stepIndex = 0; stepIndex < currentJob.value.steps.length; stepIndex++) {
if (!autoScrollStepIndexes.get(stepIndex)) continue;
autoScrollJobStepElement = getJobStepLogsContainer(stepIndex);
}
const lastLogElem = autoScrollJobStepElement?.lastElementChild;
if (lastLogElem && !isLogElementInViewport(lastLogElem)) {
lastLogElem.scrollIntoView({behavior: 'smooth', block: 'end'});
}
// clear the interval timer if the job is done
if (run.value.done && intervalID) {
clearInterval(intervalID);
intervalID = null;
}
} catch (e) {
// avoid network error while unloading page, and ignore "abort" error
if (e instanceof TypeError || abortController.signal.aborted) return;
throw e;
} finally {
if (loadingAbortController === abortController) loadingAbortController = null;
}
}
function isDone(status: ActionsStatus) {
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
}
function isExpandable(status: ActionsStatus) {
return ['success', 'running', 'failure', 'cancelled'].includes(status);
}
function closeDropdown() {
if (menuVisible.value) menuVisible.value = false;
}
function elStepsContainer(): HTMLElement {
return stepsContainer.value as HTMLElement;
}
function toggleTimeDisplay(type: 'seconds' | 'stamp') {
timeVisible.value[`log-time-${type}`] = !timeVisible.value[`log-time-${type}`];
for (const el of elStepsContainer().querySelectorAll(`.log-time-${type}`)) {
toggleElem(el, timeVisible.value[`log-time-${type}`]);
}
saveLocaleStorageOptions();
}
function toggleFullScreenMode() {
isFullScreen.value = !isFullScreen.value;
toggleFullScreen(document.querySelector('.action-view-right')!, isFullScreen.value, '.action-view-body');
}
async function hashChangeListener() {
const selectedLogStep = window.location.hash;
if (!selectedLogStep) return;
const [_, step, _line] = selectedLogStep.split('-');
const stepNum = Number(step);
if (!currentJobStepsStates.value[stepNum]) return;
if (!currentJobStepsStates.value[stepNum].expanded && currentJobStepsStates.value[stepNum].cursor === null) {
currentJobStepsStates.value[stepNum].expanded = true;
// need to await for load job if the step log is loaded for the first time
// so logline can be selected by querySelector
await loadJob();
}
await nextTick();
const logLine = elStepsContainer().querySelector(selectedLogStep);
if (!logLine) return;
logLine.querySelector<HTMLAnchorElement>('.line-num')!.click();
}
</script>
<template>
<div class="job-info-header">
<div class="job-info-header-left gt-ellipsis">
<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">
{{ 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="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>
<a class="item" @click="toggleTimeDisplay('seconds')">
<i class="icon"><SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
{{ locale.showLogSeconds }}
</a>
<a class="item" @click="toggleTimeDisplay('stamp')">
<i class="icon"><SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
{{ locale.showTimeStamps }}
</a>
<a class="item" @click="toggleFullScreenMode()">
<i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
{{ locale.showFullScreen }}
</a>
<div class="divider"/>
<a class="item" @click="optionAlwaysAutoScroll = !optionAlwaysAutoScroll">
<i class="icon"><SvgIcon :name="optionAlwaysAutoScroll ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
{{ locale.logsAlwaysAutoScroll }}
</a>
<a class="item" @click="optionAlwaysExpandRunning = !optionAlwaysExpandRunning">
<i class="icon"><SvgIcon :name="optionAlwaysExpandRunning ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
{{ locale.logsAlwaysExpandRunning }}
</a>
<div class="divider"/>
<a :class="['item', !currentJob.steps.length ? 'disabled' : '']" :href="run.link + '/jobs/' + jobId + '/logs'" download>
<i class="icon"><SvgIcon name="octicon-download"/></i>
{{ locale.downloadLogs }}
</a>
</div>
</div>
</div>
</div>
<!-- Caller (reusable workflow) view: render the direct children's dependency graph,
mirroring the run summary's WorkflowGraph but scoped to this caller's subtree.
The caller's name + uses path + status all live in job-info-header above. -->
<div class="caller-children-container" v-if="isCallerJob">
<WorkflowGraph
v-if="callerChildJobs.length > 0"
:store="store"
:jobs="callerChildJobs"
:run-link="run.link"
:workflow-id="`${run.workflowID}#caller-${props.jobId}`"
:locale="locale"
/>
</div>
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
<div class="job-step-container" ref="stepsContainer" v-show="!isCallerJob && currentJob.steps.length">
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
<div
class="job-step-summary"
@click.stop="isExpandable(jobStep.status) && toggleStepLogs(stepIdx)"
:class="[currentJobStepsStates[stepIdx].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']"
>
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
currentJobStepsStates[i].cursor === null means the log is loaded for the first time
-->
<SvgIcon
v-if="isDone(run.status) && currentJobStepsStates[stepIdx].expanded && currentJobStepsStates[stepIdx].cursor === null"
name="gitea-running"
class="rotate-clockwise"
/>
<SvgIcon
v-else
name="octicon-chevron-right"
class="tw-mr-2 step-summary-chevron"
:class="{'tw-invisible': !isExpandable(jobStep.status)}"
/>
<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,
use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
<div class="job-step-logs" :ref="(el) => jobStepLogs[stepIdx] = el as StepContainerElement" v-show="currentJobStepsStates[stepIdx].expanded"/>
</div>
</div>
</template>
<style scoped>
/* begin fomantic dropdown menu overrides */
.action-view-right .ui.dropdown .menu {
background: var(--color-console-menu-bg);
border-color: var(--color-console-menu-border);
}
.action-view-right .ui.dropdown .menu > .item {
color: var(--color-console-fg);
}
.action-view-right .ui.dropdown .menu > .item:hover {
color: var(--color-console-fg);
background: var(--color-console-hover-bg);
}
.action-view-right .ui.dropdown .menu > .item:active {
color: var(--color-console-fg);
background: var(--color-console-active-bg);
}
.action-view-right .ui.dropdown .menu > .divider {
border-top-color: var(--color-console-menu-border);
}
.action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after {
background: var(--color-console-menu-bg);
box-shadow: -1px -1px 0 0 var(--color-console-menu-border);
}
/* end fomantic dropdown menu overrides */
.job-info-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px;
position: sticky;
top: 0;
height: 60px;
z-index: 1; /* above .job-step-container */
background: var(--color-console-bg);
border-radius: 3px;
}
.job-info-header:has(+ .job-step-container),
.job-info-header:has(+ .caller-children-container) {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
.job-info-header .job-info-header-title {
color: var(--color-console-fg);
font-size: 16px;
margin: 0;
}
.job-info-header .job-info-header-detail {
color: var(--color-console-fg-subtle);
font-size: 12px;
}
.job-info-header-left {
flex: 1;
min-width: 0;
}
.job-info-header-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.job-info-header-uses {
display: inline-flex !important;
align-items: baseline;
gap: 4px;
min-width: 0;
}
.caller-children-container {
flex: 1;
display: flex;
flex-direction: column;
border-top: 1px solid var(--color-console-border);
color: var(--color-console-fg);
}
.job-step-container {
max-height: 100%;
border-radius: 0 0 var(--border-radius) var(--border-radius);
border-top: 1px solid var(--color-console-border);
z-index: 0;
}
.job-step-container .job-step-summary {
padding: 5px 10px;
display: flex;
align-items: center;
gap: 8px;
border-radius: var(--border-radius);
}
.job-step-container .job-step-summary.step-expandable {
cursor: pointer;
}
.job-step-container .job-step-summary.step-expandable:hover {
color: var(--color-console-fg);
background: var(--color-console-hover-bg);
}
.job-step-container .job-step-summary .step-summary-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-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 {
color: var(--color-console-fg);
background-color: var(--color-console-active-bg);
position: sticky;
top: 60px;
/* workaround ansi_up issue related to faintStyle generating a CSS stacking context via `opacity`
inline style which caused such elements to render above the .job-step-summary header. */
z-index: 1;
}
</style>
<style> /* eslint-disable-line vue-scoped-css/enforce-style-type */
/* some elements are not managed by vue, so we need to use global style */
.job-step-section {
margin: 10px;
}
.job-step-section .job-step-logs {
font-family: var(--fonts-monospace);
margin: 8px 0;
font-size: 12px;
}
.job-step-section .job-step-logs .job-log-line {
display: flex;
}
.job-log-line:hover,
.job-log-line:target {
background-color: var(--color-console-hover-bg);
}
.job-log-line:target {
scroll-margin-top: 95px;
}
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
.job-log-line .line-num, .log-time-seconds {
width: 48px;
color: var(--color-text-light-3);
text-align: right;
user-select: none;
}
.job-log-line:target > .line-num {
color: var(--color-primary);
text-decoration: underline;
}
.log-time-seconds {
padding-right: 2px;
}
.job-log-line .log-time,
.log-time-stamp {
color: var(--color-text-light-3);
margin-left: 10px;
white-space: nowrap;
}
.job-step-logs .job-log-line .log-msg {
flex: 1;
white-space: break-spaces;
margin-left: 10px;
overflow-wrap: anywhere;
}
.job-step-logs .log-msg a {
color: var(--color-console-link) !important;
text-decoration: underline;
}
.job-step-logs .job-log-line .log-cmd-command {
color: var(--color-ansi-blue);
}
.job-step-logs .log-msg-label {
font-weight: var(--font-weight-semibold);
}
.job-step-logs .log-line-error {
background: var(--color-error-bg);
}
.job-step-logs .log-line-warning {
background: var(--color-warning-bg);
}
.job-step-logs .log-line-notice {
background: var(--color-info-bg);
}
.job-step-logs .log-line-debug {
background: var(--color-secondary-alpha-30);
}
.job-step-logs .log-cmd-error > .log-msg-label {
color: var(--color-error-text);
}
.job-step-logs .log-cmd-warning > .log-msg-label {
color: var(--color-warning-text);
}
.job-step-logs .log-cmd-notice > .log-msg-label {
color: var(--color-info-text);
}
.job-step-logs .log-cmd-debug > .log-msg-label {
color: var(--color-violet);
}
/* selectors here are intentionally exact to only match fullscreen */
.full.height > .action-view-right {
width: 100%;
height: 100%;
padding: 0;
border-radius: 0;
}
.full.height > .action-view-right > .job-info-header {
border-radius: 0;
}
.full.height > .action-view-right > .job-step-container {
height: calc(100% - 60px);
border-radius: 0;
}
.job-log-group .job-log-list .job-log-line .log-msg {
margin-left: 2em;
}
.job-log-group-summary {
cursor: pointer;
position: relative;
display: list-item;
list-style: disclosure-closed inside;
padding-left: 58px; /* line-num gutter (48px) + log-msg margin (10px), so the marker sits in the content column */
}
.job-log-group[open] > .job-log-group-summary {
list-style-type: disclosure-open;
}
.job-log-group-summary > .job-log-line {
position: absolute;
inset: 0;
z-index: -1; /* sit behind the disclosure marker */
overflow: hidden;
}
.job-log-group-summary > .job-log-line .log-msg {
margin-left: 21px;
}
</style>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import ActionStatusIcon from './ActionStatusIcon.vue';
import WorkflowGraph from './WorkflowGraph.vue';
import type {ActionRunViewStore} from "./ActionRunView.ts";
import {computed, onBeforeUnmount, onMounted, toRefs} from "vue";
defineOptions({
name: 'ActionRunSummaryView',
});
const props = defineProps<{
store: ActionRunViewStore;
locale: Record<string, any>;
}>();
const locale = props.locale;
const {currentRun: run} = toRefs(props.store.viewData);
const isRerun = computed(() => run.value.runAttempt > 1);
// The summary's dependency graph is the workflow's top-level shape: a reusable caller
// renders as a single node, its expanded children belong to the caller's own detail page.
const topLevelJobs = computed(() => (run.value.jobs || []).filter((j) => !j.parentJobID));
const triggerUser = computed(() => {
const currentAttempt = run.value.attempts.find((attempt) => attempt.current);
if (currentAttempt) {
return {name: currentAttempt.triggerUserName, link: currentAttempt.triggerUserLink};
}
const pusher = run.value.commit.pusher;
return pusher.displayName ? {name: pusher.displayName, link: pusher.link} : null;
});
onMounted(async () => {
await props.store.startPollingCurrentRun();
});
onBeforeUnmount(() => {
props.store.stopPollingCurrentRun();
});
</script>
<template>
<div class="action-run-summary-view">
<div class="action-run-summary-block">
<div class="flex-text-block">
<span>{{ isRerun ? locale.rerun : locale.triggeredVia.replace('%s', run.triggerEvent) }}</span>
<template v-if="triggerUser">
<span></span>
<a v-if="triggerUser.link" class="muted" :href="triggerUser.link">{{ triggerUser.name }}</a>
<span v-else class="muted">{{ triggerUser.name }}</span>
</template>
<span></span>
<relative-time :datetime="run.triggeredAt || ''" prefix=""/>
</div>
<div class="flex-text-block">
<ActionStatusIcon :locale-status="locale.status[run.status]" :status="run.status" :size="16" icon-variant="circle-fill"/>
<span>{{ locale.status[run.status] }}</span> <span>{{ locale.totalDuration }} {{ run.duration || '' }}</span>
</div>
</div>
<WorkflowGraph
v-if="topLevelJobs.length > 0"
:store="store"
:jobs="topLevelJobs"
:run-link="run.link"
:workflow-id="run.workflowID"
:locale="locale"
/>
</div>
</template>
<style scoped>
.action-run-summary-view {
flex: 1;
display: flex;
flex-direction: column;
color: var(--color-text-light-1);
}
.action-run-summary-block {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 12px;
border-bottom: 1px solid var(--color-secondary);
border-radius: var(--border-radius) var(--border-radius) 0 0;
background: var(--color-box-header);
}
</style>
@@ -0,0 +1,33 @@
import {createLogLineMessage, parseLogLineCommand} from './ActionRunView.ts';
test('LogLineMessage', () => {
const cases = {
'normal message': '<span class="log-msg">normal message</span>',
'##[group] foo': '<span class="log-msg log-cmd-group"> foo</span>',
'::group::foo': '<span class="log-msg log-cmd-group">foo</span>',
'##[endgroup]': '<span class="log-msg log-cmd-endgroup"></span>',
'::endgroup::': '<span class="log-msg log-cmd-endgroup"></span>',
'##[error] foo': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo</span></span>',
'##[warning] foo': '<span class="log-msg log-cmd-warning"><span class="log-msg-label">Warning:</span><span> foo</span></span>',
'##[notice] foo': '<span class="log-msg log-cmd-notice"><span class="log-msg-label">Notice:</span><span> foo</span></span>',
'##[debug] foo': '<span class="log-msg log-cmd-debug"><span class="log-msg-label">Debug:</span><span> foo</span></span>',
'::error::foo': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo</span></span>',
'::warning file=test.js,line=1::foo': '<span class="log-msg log-cmd-warning"><span class="log-msg-label">Warning:</span><span> foo</span></span>',
'::notice::foo': '<span class="log-msg log-cmd-notice"><span class="log-msg-label">Notice:</span><span> foo</span></span>',
'::debug::foo': '<span class="log-msg log-cmd-debug"><span class="log-msg-label">Debug:</span><span> foo</span></span>',
'##[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
'[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
// hidden is special, it is actually skipped before creating
'##[add-matcher]foo': '<span class="log-msg log-cmd-hidden">foo</span>',
'::add-matcher::foo': '<span class="log-msg log-cmd-hidden">foo</span>',
'::remove-matcher foo::': '<span class="log-msg log-cmd-hidden"> foo::</span>', // not correctly parsed, but we don't need it
};
for (const [input, html] of Object.entries(cases)) {
const line = {index: 0, timestamp: 0, message: input};
const cmd = parseLogLineCommand(line);
const el = createLogLineMessage(line, cmd);
expect(el.outerHTML).toBe(html);
}
});
+206
View File
@@ -0,0 +1,206 @@
import {createElementFromAttrs} from '../utils/dom.ts';
import {renderAnsi} from '../render/ansi.ts';
import {reactive} from 'vue';
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
import type {IntervalId} from '../types.ts';
import {POST} from '../modules/fetch.ts';
// How GitHub Actions logs work:
// * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...."
// * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc.
// * The reported logs are the processed logs.
// HOWEVER: Gitea runner does not completely process those commands. Many works are done by the frontend at the moment.
const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
'::group::': 'group',
'##[group]': 'group',
'::endgroup::': 'endgroup',
'##[endgroup]': 'endgroup',
'##[error]': 'error',
'##[warning]': 'warning',
'##[notice]': 'notice',
'##[debug]': 'debug',
'##[command]': 'command',
'[command]': 'command',
// https://github.com/actions/toolkit/blob/master/docs/commands.md
// https://github.com/actions/runner/blob/main/docs/adrs/0276-problem-matchers.md#registration
'::add-matcher::': 'hidden',
'##[add-matcher]': 'hidden',
'::remove-matcher': 'hidden', // it has arguments
};
// Pattern for ::cmd:: and ::cmd args:: format (args are stripped for display)
const LogLineCmdPattern = /^::(error|warning|notice|debug)(?:\s[^:]*)?::/;
export type LogLine = {
index: number;
timestamp: number;
message: string;
};
export type LogLineCommandName = 'group' | 'endgroup' | 'command' | 'error' | 'warning' | 'notice' | 'debug' | 'hidden';
export type LogLineCommand = {
name: LogLineCommandName,
prefix: string,
};
export function parseLogLineCommand(line: LogLine): LogLineCommand | null {
// TODO: in the future it can be refactored to be a general parser that can parse arguments, drop the "prefix match"
for (const prefix of Object.keys(LogLinePrefixCommandMap)) {
if (line.message.startsWith(prefix)) {
return {name: LogLinePrefixCommandMap[prefix], prefix};
}
}
// Handle ::cmd:: and ::cmd args:: format (runner may pass these through raw)
const match = LogLineCmdPattern.exec(line.message);
if (match) {
return {name: match[1] as LogLineCommandName, prefix: match[0]};
}
return null;
}
const LogLineLabelMap: Partial<Record<LogLineCommandName, string>> = {
'error': 'Error',
'warning': 'Warning',
'notice': 'Notice',
'debug': 'Debug',
};
export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
const logMsgAttrs = {class: 'log-msg'};
if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd.name}`; // make it easier to add styles to some commands like "error"
// TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
// it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
const msgContent = cmd ? line.message.substring(cmd.prefix.length) : line.message;
const logMsg = createElementFromAttrs('span', logMsgAttrs);
const label = cmd ? LogLineLabelMap[cmd.name] : null;
if (label) {
logMsg.append(createElementFromAttrs('span', {class: 'log-msg-label'}, `${label}:`));
const msgSpan = document.createElement('span');
msgSpan.innerHTML = ` ${renderAnsi(msgContent.trimStart())}`;
logMsg.append(msgSpan);
} else {
logMsg.innerHTML = renderAnsi(msgContent);
}
return logMsg;
}
// buildJobsByParentJobID groups jobs by their parentJobID (0 = top level).
// Useful for rendering the reusable-workflow caller/child tree in the sidebar.
export function buildJobsByParentJobID(jobs: ActionsJob[]): Map<number, ActionsJob[]> {
const childrenByParent = new Map<number, ActionsJob[]>();
for (const job of jobs) {
const parentID = job.parentJobID || 0;
const existing = childrenByParent.get(parentID);
if (existing) {
existing.push(job);
} else {
childrenByParent.set(parentID, [job]);
}
}
return childrenByParent;
}
// collectCallerChildJobs returns the direct children of a caller job.
export function collectCallerChildJobs(jobs: ActionsJob[], callerJobID: number): ActionsJob[] {
if (!callerJobID) return [];
return buildJobsByParentJobID(jobs).get(callerJobID) || [];
}
export function createEmptyActionsRun(): ActionsRun {
return {
repoId: 0,
link: '',
viewLink: '',
title: '',
titleHTML: '',
status: '' as ActionsStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
canCancel: false,
canApprove: false,
canRerun: false,
canRerunFailed: false,
canDeleteArtifact: false,
done: false,
workflowID: '',
workflowLink: '',
isSchedule: false,
runAttempt: 0,
attempts: [],
duration: '',
triggeredAt: 0,
triggerEvent: '',
jobs: [] as Array<ActionsJob>,
commit: {
localeCommit: '',
localePushedBy: '',
shortSHA: '',
link: '',
pusher: {
displayName: '',
link: '',
},
branch: {
name: '',
link: '',
isDeleted: false,
},
},
};
}
export function createActionRunViewStore(viewUrl: string) {
let loadingAbortController: AbortController | null = null;
let intervalID: IntervalId | null = null;
const viewData = reactive({
currentRun: createEmptyActionsRun(),
runArtifacts: [] as Array<ActionsArtifact>,
});
const loadCurrentRun = async () => {
if (loadingAbortController) return;
const abortController = new AbortController();
loadingAbortController = abortController;
try {
const resp = await POST(viewUrl, {signal: abortController.signal, data: {}});
const runResp = await resp.json();
if (loadingAbortController !== abortController) return;
viewData.runArtifacts = runResp.artifacts || [];
viewData.currentRun = runResp.state.run;
// clear the interval timer if the job is done
if (viewData.currentRun.done && intervalID) {
clearInterval(intervalID);
intervalID = null;
}
} catch (e) {
// avoid network error while unloading page, and ignore "abort" error
if (e instanceof TypeError || abortController.signal.aborted) return;
throw e;
} finally {
if (loadingAbortController === abortController) loadingAbortController = null;
}
};
return {
viewData,
async startPollingCurrentRun() {
await loadCurrentRun();
intervalID = setInterval(() => loadCurrentRun(), 1000);
},
async forceReloadCurrentRun() {
loadingAbortController?.abort();
loadingAbortController = null;
await loadCurrentRun();
},
stopPollingCurrentRun() {
if (!intervalID) return;
clearInterval(intervalID);
intervalID = null;
},
};
}
export type ActionRunViewStore = ReturnType<typeof createActionRunViewStore>;
@@ -0,0 +1,33 @@
<!-- 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 {SvgIcon} from '../svg.ts';
const props = withDefaults(defineProps<{
status: 'success' | 'skipped' | 'waiting' | 'blocked' | 'running' | 'failure' | 'cancelled' | 'cancelling' | 'unknown',
size?: number,
className?: string,
localeStatus?: string,
iconVariant?: 'circle-fill' | '',
}>(), {
size: 16,
className: '',
localeStatus: undefined,
iconVariant: '',
});
const circleFill = props.iconVariant === 'circle-fill';
</script>
<template>
<span :data-tooltip-content="localeStatus ?? status" v-if="status">
<SvgIcon :name="circleFill ? 'octicon-check-circle-fill' : 'octicon-check'" 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-stop" class="tw-text-yellow" :size="size" :class="className" v-else-if="status === 'cancelling'"/>
<SvgIcon :name="circleFill ? 'octicon-x-circle-fill' : 'octicon-x'" class="tw-text-red" :size="size" :class="className" v-else/><!-- failure, unknown -->
</span>
</template>
+208
View File
@@ -0,0 +1,208 @@
<script lang="ts" setup>
import {computed, onBeforeUnmount, onMounted} from 'vue';
import tippy, {createSingleton} from 'tippy.js';
import type {CreateSingletonInstance, Instance} from 'tippy.js';
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: {months: string[]; days: string[]; on: string; more: string; less: string};
noDataText: string;
tooltipUnit: string;
};
}>();
const colorRange = [
'var(--color-secondary-alpha-60)',
'var(--color-primary-light-4)',
'var(--color-primary-light-2)',
'var(--color-primary)',
'var(--color-primary-dark-2)',
'var(--color-primary-dark-4)',
];
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;
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 {months, on} = props.locale.heatMapLocale;
const {noDataText, tooltipUnit} = props.locale;
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 = `${months[cursor.getMonth()]} ${cursor.getDate()}, ${cursor.getFullYear()}`;
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};
});
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(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
if (queryDate && queryDate === clickedDate) {
params.delete('date');
} else {
params.set('date', clickedDate);
}
params.delete('page');
const newSearch = params.toString();
window.location.search = newSearch.length ? `?${newSearch}` : '';
}
</script>
<template>
<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>
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import {getIssueColorClass, getIssueIcon} from '../features/issue.ts';
import {computed} from 'vue';
import type {Issue} from '../types.ts';
const props = defineProps<{
issue?: Issue | null,
renderedLabels?: string,
errorMessage?: string,
}>();
const createdAt = computed(() => {
if (!props.issue) return '';
return new Date(props.issue.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
});
const body = computed(() => {
if (!props.issue) return '';
const body = props.issue.body.replace(/\n+/g, ' ');
return body.length > 85 ? `${body.substring(0, 85)}` : body;
});
</script>
<template>
<div class="tw-p-4">
<div v-if="issue" class="tw-flex tw-flex-col tw-gap-2">
<div class="tw-text-12">
<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)"/>
<a :href="issue.html_url" class="issue-title tw-font-semibold tw-break-anywhere muted">
{{ issue.title }}
<span class="index">#{{ issue.number }}</span>
</a>
</div>
<div v-if="body">{{ body }}</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="issue.labels.length" v-html="renderedLabels"/>
</div>
<div v-else>
{{ errorMessage }}
</div>
</div>
</template>
+630
View File
@@ -0,0 +1,630 @@
<script lang="ts">
import {nextTick, defineComponent} from 'vue';
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import type {SvgName} from '../svg.ts';
const {appSubUrl, assetUrlPrefix, pageData} = window.config;
type DashboardRepo = {
id: number,
link: string,
full_name: string,
archived: boolean,
fork: boolean,
mirror: boolean,
template: boolean,
private: boolean,
internal: boolean,
latest_commit_status_state?: CommitStatus,
latest_commit_status_state_link?: string,
locale_latest_commit_status_state?: string,
};
type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning' | 'skipped';
type CommitStatusMap = {
[status in CommitStatus]: {
name: SvgName,
color: string,
};
};
// make sure this matches templates/repo/commit_status.tmpl
const commitStatus: CommitStatusMap = {
pending: {name: 'octicon-dot-fill', color: 'tw-text-yellow'},
success: {name: 'octicon-check', color: 'tw-text-green'},
error: {name: 'gitea-exclamation', color: 'tw-text-red'},
failure: {name: 'octicon-x', color: 'tw-text-red'},
warning: {name: 'gitea-exclamation', color: 'tw-text-yellow'},
skipped: {name: 'octicon-skip', color: 'tw-text-text-light'},
};
export default defineComponent({
components: {SvgIcon},
data() {
const params = new URLSearchParams(window.location.search);
const tab = params.get('repo-search-tab') || 'repos';
const reposFilter = params.get('repo-search-filter') || 'all';
const privateFilter = params.get('repo-search-private') || 'both';
const archivedFilter = params.get('repo-search-archived') || 'unarchived';
const searchQuery = params.get('repo-search-query') || '';
const page = Number(params.get('repo-search-page')) || 1;
return {
tab,
repos: [] as DashboardRepo[],
reposTotalCount: null as number | null,
reposFilter,
archivedFilter,
privateFilter,
page,
finalPage: 1,
searchQuery,
isLoading: false,
staticPrefix: assetUrlPrefix,
counts: {} as Record<string, number>,
repoTypes: {
all: {
searchMode: '',
},
forks: {
searchMode: 'fork',
},
mirrors: {
searchMode: 'mirror',
},
sources: {
searchMode: 'source',
},
collaborative: {
searchMode: 'collaborative',
},
} as Record<string, {searchMode: string}>,
textArchivedFilterTitles: {} as Record<string, string>,
textPrivateFilterTitles: {} as Record<string, string>,
organizations: [] as Array<{name: string, full_name: string, num_repos: number, org_visibility: string}>,
isOrganization: true,
canCreateOrganization: false,
organizationsTotalCount: 0,
organizationId: 0,
searchLimit: 0,
uid: 0,
teamId: 0,
isMirrorsEnabled: false,
isStarsEnabled: false,
canCreateMigrations: false,
textNoOrg: '',
textNoRepo: '',
textRepository: '',
textOrganization: '',
textMyRepos: '',
textNewRepo: '',
textSearchRepos: '',
textFilter: '',
textShowArchived: '',
textShowPrivate: '',
textShowBothArchivedUnarchived: '',
textShowOnlyUnarchived: '',
textShowOnlyArchived: '',
textShowBothPrivatePublic: '',
textShowOnlyPublic: '',
textShowOnlyPrivate: '',
textAll: '',
textSources: '',
textForks: '',
textMirrors: '',
textCollaborative: '',
textFirstPage: '',
textPreviousPage: '',
textNextPage: '',
textLastPage: '',
textMyOrgs: '',
textNewOrg: '',
textOrgVisibilityLimited: '',
textOrgVisibilityPrivate: '',
subUrl: appSubUrl,
...pageData.dashboardRepoList,
activeIndex: -1, // don't select anything at load, first cursor down will select
};
},
computed: {
showMoreReposLink() {
return this.repos.length > 0 && this.repos.length < this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
},
searchURL() {
return `${this.subUrl}/repo/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=${this.searchQuery
}&page=${this.page}&limit=${this.searchLimit}&mode=${this.repoTypes[this.reposFilter].searchMode
}${this.archivedFilter === 'archived' ? '&archived=true' : ''}${this.archivedFilter === 'unarchived' ? '&archived=false' : ''
}${this.privateFilter === 'private' ? '&is_private=true' : ''}${this.privateFilter === 'public' ? '&is_private=false' : ''
}`;
},
repoTypeCount() {
return this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
},
checkboxArchivedFilterTitle() {
return this.textArchivedFilterTitles[this.archivedFilter];
},
checkboxArchivedFilterProps() {
return {checked: this.archivedFilter === 'archived', indeterminate: this.archivedFilter === 'both'};
},
checkboxPrivateFilterTitle() {
return this.textPrivateFilterTitles[this.privateFilter];
},
checkboxPrivateFilterProps() {
return {checked: this.privateFilter === 'private', indeterminate: this.privateFilter === 'both'};
},
},
mounted() {
const el = document.querySelector('#dashboard-repo-list')!;
this.changeReposFilter(this.reposFilter);
fomanticQuery(el.querySelector('.ui.dropdown')!).dropdown();
this.textArchivedFilterTitles = {
'archived': this.textShowOnlyArchived,
'unarchived': this.textShowOnlyUnarchived,
'both': this.textShowBothArchivedUnarchived,
};
this.textPrivateFilterTitles = {
'private': this.textShowOnlyPrivate,
'public': this.textShowOnlyPublic,
'both': this.textShowBothPrivatePublic,
};
},
methods: {
changeTab(tab: string) {
this.tab = tab;
this.updateHistory();
},
changeReposFilter(filter: string) {
this.reposFilter = filter;
this.repos = [];
this.page = 1;
this.counts[`${filter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
this.searchRepos();
},
updateHistory() {
const params = new URLSearchParams(window.location.search);
if (this.tab === 'repos') {
params.delete('repo-search-tab');
} else {
params.set('repo-search-tab', this.tab);
}
if (this.reposFilter === 'all') {
params.delete('repo-search-filter');
} else {
params.set('repo-search-filter', this.reposFilter);
}
if (this.privateFilter === 'both') {
params.delete('repo-search-private');
} else {
params.set('repo-search-private', this.privateFilter);
}
if (this.archivedFilter === 'unarchived') {
params.delete('repo-search-archived');
} else {
params.set('repo-search-archived', this.archivedFilter);
}
if (this.searchQuery === '') {
params.delete('repo-search-query');
} else {
params.set('repo-search-query', this.searchQuery);
}
if (this.page === 1) {
params.delete('repo-search-page');
} else {
params.set('repo-search-page', `${this.page}`);
}
const queryString = params.toString();
if (queryString) {
window.history.replaceState({}, '', `?${queryString}`);
} else {
window.history.replaceState({}, '', window.location.pathname);
}
},
toggleArchivedFilter() {
if (this.archivedFilter === 'unarchived') {
this.archivedFilter = 'archived';
} else if (this.archivedFilter === 'archived') {
this.archivedFilter = 'both';
} else { // including both
this.archivedFilter = 'unarchived';
}
this.page = 1;
this.repos = [];
this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
this.searchRepos();
},
togglePrivateFilter() {
if (this.privateFilter === 'both') {
this.privateFilter = 'public';
} else if (this.privateFilter === 'public') {
this.privateFilter = 'private';
} else { // including private
this.privateFilter = 'both';
}
this.page = 1;
this.repos = [];
this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
this.searchRepos();
},
async changePage(page: number) {
if (this.isLoading) return;
this.page = page;
if (this.page > this.finalPage) {
this.page = this.finalPage;
}
if (this.page < 1) {
this.page = 1;
}
this.repos = [];
this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
await this.searchRepos();
},
async searchRepos() {
this.isLoading = true;
const searchedMode = this.repoTypes[this.reposFilter].searchMode;
const searchedURL = this.searchURL;
const searchedQuery = this.searchQuery;
let response, json;
try {
const firstLoad = this.reposTotalCount === null;
if (!this.reposTotalCount) {
const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`;
response = await GET(totalCountSearchURL);
this.reposTotalCount = parseInt(response.headers.get('X-Total-Count') ?? '0');
}
if (firstLoad && this.reposTotalCount) {
nextTick(() => {
// MDN: If there's no focused element, this is the Document.body or Document.documentElement.
if ((document.activeElement === document.body || document.activeElement === document.documentElement)) {
(this.$refs.search as HTMLInputElement).focus({preventScroll: true});
}
});
}
response = await GET(searchedURL);
json = await response.json();
} catch {
if (searchedURL === this.searchURL) {
this.isLoading = false;
}
return;
}
if (searchedURL === this.searchURL) {
this.repos = json.data.map((webSearchRepo: any) => {
return {
...webSearchRepo.repository,
latest_commit_status_state: webSearchRepo.latest_commit_status?.State, // if latest_commit_status is null, it means there is no commit status
latest_commit_status_state_link: webSearchRepo.latest_commit_status?.TargetURL,
locale_latest_commit_status_state: webSearchRepo.locale_latest_commit_status,
};
});
const count = Number(response.headers.get('X-Total-Count'));
if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') {
this.reposTotalCount = count;
}
this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = count;
this.finalPage = Math.ceil(count / this.searchLimit);
this.updateHistory();
this.isLoading = false;
}
},
repoIcon(repo: DashboardRepo) {
if (repo.fork) {
return 'octicon-repo-forked';
} else if (repo.mirror) {
return 'octicon-mirror';
} else if (repo.template) {
return `octicon-repo-template`;
} else if (repo.private) {
return 'octicon-lock';
} else if (repo.internal) {
return 'octicon-repo';
}
return 'octicon-repo';
},
statusIcon(status: CommitStatus) {
return commitStatus[status].name;
},
statusColor(status: CommitStatus) {
return commitStatus[status].color;
},
async reposFilterKeyControl(e: KeyboardEvent) {
if (e.isComposing) return;
switch (e.key) {
case 'Enter':
document.querySelector<HTMLAnchorElement>('.repo-owner-name-list li.active a')?.click();
break;
case 'ArrowUp':
if (this.activeIndex > 0) {
this.activeIndex--;
} else if (this.page > 1) {
await this.changePage(this.page - 1);
this.activeIndex = this.searchLimit - 1;
}
break;
case 'ArrowDown':
if (this.activeIndex < this.repos.length - 1) {
this.activeIndex++;
} else if (this.page < this.finalPage) {
this.activeIndex = 0;
await this.changePage(this.page + 1);
}
break;
case 'ArrowRight':
if (this.page < this.finalPage) {
await this.changePage(this.page + 1);
}
break;
case 'ArrowLeft':
if (this.page > 1) {
await this.changePage(this.page - 1);
}
break;
}
if (this.activeIndex === -1 || this.activeIndex > this.repos.length - 1) {
this.activeIndex = 0;
}
},
},
});
</script>
<template>
<div>
<div v-if="!isOrganization" class="ui two item menu">
<a :class="{item: true, active: tab === 'repos'}" @click="changeTab('repos')">{{ textRepository }}</a>
<a :class="{item: true, active: tab === 'organizations'}" @click="changeTab('organizations')">{{ textOrganization }}</a>
</div>
<div v-show="tab === 'repos'" class="ui tab active list dashboard-repos">
<h4 class="ui top attached header tw-flex tw-items-center">
<div class="tw-flex-1 tw-flex tw-items-center">
{{ textMyRepos }}
<span v-if="reposTotalCount" class="ui grey label tw-ml-2">{{ reposTotalCount }}</span>
</div>
<a class="tw-flex tw-items-center muted" :href="subUrl + '/repo/create' + (isOrganization ? '?org=' + organizationId : '')" :data-tooltip-content="textNewRepo">
<svg-icon name="octicon-plus"/>
</a>
</h4>
<div v-if="!reposTotalCount" class="ui attached segment">
<div v-if="!isLoading" class="empty-repo-or-org">
<svg-icon name="octicon-git-branch" :size="24"/>
<p>{{ textNoRepo }}</p>
</div>
<!-- using the loading indicator here will cause more (unnecessary) page flickers, so at the moment, not use the loading indicator -->
<!-- <div v-else class="is-loading loading-icon-2px tw-min-h-16"/> -->
</div>
<div v-else class="ui attached segment repos-search">
<div class="ui small fluid action left icon input">
<input type="search" spellcheck="false" maxlength="255" @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="search" @keydown="reposFilterKeyControl" :placeholder="textSearchRepos">
<i class="icon loading-icon-3px" :class="{'is-loading': isLoading}"><svg-icon name="octicon-search" :size="16"/></i>
<div class="ui dropdown icon button" :title="textFilter">
<svg-icon name="octicon-filter" :size="16"/>
<div class="menu">
<a class="item" @click="toggleArchivedFilter()">
<div class="ui checkbox" ref="checkboxArchivedFilter" :title="checkboxArchivedFilterTitle">
<!--the "tw-pointer-events-none" is necessary to prevent the checkbox from handling user's input,
otherwise if the "input" handles click event for intermediate status, it breaks the internal state-->
<input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxArchivedFilterProps">
<label>
<svg-icon name="octicon-archive" :size="16" class="tw-mr-1"/>
{{ textShowArchived }}
</label>
</div>
</a>
<a class="item" @click="togglePrivateFilter()">
<div class="ui checkbox" ref="checkboxPrivateFilter" :title="checkboxPrivateFilterTitle">
<input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxPrivateFilterProps">
<label>
<svg-icon name="octicon-lock" :size="16" class="tw-mr-1"/>
{{ textShowPrivate }}
</label>
</div>
</a>
</div>
</div>
</div>
<overflow-menu class="ui secondary pointing tabular borderless menu repos-filter">
<div class="overflow-menu-items tw-justify-center">
<a class="item" tabindex="0" :class="{active: reposFilter === 'all'}" @click="changeReposFilter('all')">
{{ textAll }}
<div v-show="reposFilter === 'all'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
</a>
<a class="item" tabindex="0" :class="{active: reposFilter === 'sources'}" @click="changeReposFilter('sources')">
{{ textSources }}
<div v-show="reposFilter === 'sources'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
</a>
<a class="item" tabindex="0" :class="{active: reposFilter === 'forks'}" @click="changeReposFilter('forks')">
{{ textForks }}
<div v-show="reposFilter === 'forks'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
</a>
<a class="item" tabindex="0" :class="{active: reposFilter === 'mirrors'}" @click="changeReposFilter('mirrors')" v-if="isMirrorsEnabled">
{{ textMirrors }}
<div v-show="reposFilter === 'mirrors'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
</a>
<a class="item" tabindex="0" :class="{active: reposFilter === 'collaborative'}" @click="changeReposFilter('collaborative')">
{{ textCollaborative }}
<div v-show="reposFilter === 'collaborative'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
</a>
</div>
</overflow-menu>
</div>
<div v-if="repos.length" class="ui attached table segment tw-rounded-b">
<ul class="repo-owner-name-list">
<li class="tw-flex tw-items-center tw-py-2" v-for="(repo, index) in repos" :class="{'active': index === activeIndex}" :key="repo.id">
<a class="repo-list-link muted" :href="repo.link">
<svg-icon :name="repoIcon(repo)" :size="16" class="repo-list-icon"/>
<div class="tw-inline-block tw-truncate">{{ repo.full_name }}</div>
<div v-if="repo.archived">
<svg-icon name="octicon-archive" :size="16"/>
</div>
</a>
<a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link || undefined" :data-tooltip-content="repo.locale_latest_commit_status_state">
<!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl -->
<svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class="'tw-ml-2 commit-status icon ' + statusColor(repo.latest_commit_status_state)" :size="16"/>
</a>
</li>
</ul>
<div v-if="showMoreReposLink" class="tw-text-center">
<div class="divider tw-my-0"/>
<div class="ui borderless pagination menu narrow tw-my-2">
<a
class="item navigation tw-py-1" :class="{'disabled': page === 1}"
@click="changePage(1)" :title="textFirstPage"
>
<svg-icon name="gitea-double-chevron-left" :size="16" class="tw-mr-1"/>
</a>
<a
class="item navigation tw-py-1" :class="{'disabled': page === 1}"
@click="changePage(page - 1)" :title="textPreviousPage"
>
<svg-icon name="octicon-chevron-left" :size="16" class="tw-mr-1"/>
</a>
<a class="active item tw-py-1">{{ page }}</a>
<a
class="item navigation" :class="{'disabled': page === finalPage}"
@click="changePage(page + 1)" :title="textNextPage"
>
<svg-icon name="octicon-chevron-right" :size="16" class="tw-ml-1"/>
</a>
<a
class="item navigation tw-py-1" :class="{'disabled': page === finalPage}"
@click="changePage(finalPage)" :title="textLastPage"
>
<svg-icon name="gitea-double-chevron-right" :size="16" class="tw-ml-1"/>
</a>
</div>
</div>
</div>
</div>
<div v-if="!isOrganization" v-show="tab === 'organizations'" class="ui tab active list dashboard-orgs">
<h4 class="ui top attached header tw-flex tw-items-center">
<div class="tw-flex-1 tw-flex tw-items-center">
{{ textMyOrgs }}
<span class="ui grey label tw-ml-2">{{ organizationsTotalCount }}</span>
</div>
<a class="tw-flex tw-items-center muted" v-if="canCreateOrganization" :href="subUrl + '/org/create'" :data-tooltip-content="textNewOrg">
<svg-icon name="octicon-plus"/>
</a>
</h4>
<div v-if="!organizations.length" class="ui attached segment">
<div class="empty-repo-or-org">
<svg-icon name="octicon-organization" :size="24"/>
<p>{{ textNoOrg }}</p>
</div>
</div>
<div v-else class="ui attached table segment tw-rounded-b">
<ul class="repo-owner-name-list">
<li class="tw-flex tw-items-center tw-py-2" v-for="org in organizations" :key="org.name">
<a class="repo-list-link muted" :href="subUrl + '/' + encodeURIComponent(org.name)">
<svg-icon name="octicon-organization" :size="16" class="repo-list-icon"/>
<div class="tw-inline-block tw-truncate">{{ org.full_name ? `${org.full_name} (${org.name})` : org.name }}</div>
<div><!-- div to prevent underline of label on hover -->
<span class="ui tiny basic label" v-if="org.org_visibility !== 'public'">
{{ org.org_visibility === 'limited' ? textOrgVisibilityLimited: textOrgVisibilityPrivate }}
</span>
</div>
</a>
<div class="tw-text-grey-light tw-flex tw-items-center tw-ml-2">
{{ org.num_repos }}
<svg-icon name="octicon-repo" :size="16" class="tw-ml-1 tw-mt-0.5"/>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped>
ul {
list-style: none;
margin: 0;
padding-left: 0;
}
ul li {
padding: 0 10px;
}
ul li:not(:last-child) {
border-bottom: 1px solid var(--color-secondary);
}
.repos-search {
padding-bottom: 0 !important;
}
.repos-filter {
margin-top: 0 !important;
border-bottom-width: 0 !important;
}
.repos-filter .item {
padding-left: 6px !important;
padding-right: 6px !important;
}
.repo-list-link {
min-width: 0; /* for text truncation */
display: flex;
align-items: center;
flex: 1;
gap: 0.5rem;
}
.repo-list-link .svg {
color: var(--color-text-light-2);
}
.repo-list-icon {
min-width: 16px;
margin-right: 2px;
}
/* octicon-mirror has no padding inside the SVG */
.repo-list-icon.octicon-mirror {
width: 14px;
min-width: 14px;
margin-left: 1px;
margin-right: 3px;
}
.repo-owner-name-list li.active {
background: var(--color-hover);
}
.empty-repo-or-org {
margin-top: 1em;
text-align: center;
color: var(--color-placeholder-text);
}
.empty-repo-or-org p {
margin: 1em auto;
}
</style>
@@ -0,0 +1,333 @@
<script lang="ts">
import {defineComponent} from 'vue';
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {generateElemId} from '../utils/dom.ts';
type Commit = {
id: string,
hovered: boolean,
selected: boolean,
summary: string,
committer_or_author_name: string,
time: string,
short_sha: string,
}
type CommitListResult = {
commits: Array<Commit>,
last_review_commit_sha: string,
locale: Record<string, string>,
}
export default defineComponent({
components: {SvgIcon},
data: () => {
const el = document.querySelector('#diff-commit-select')!;
return {
menuVisible: false,
isLoading: false,
queryParams: el.getAttribute('data-queryparams'),
issueLink: el.getAttribute('data-issuelink'),
locale: {
filter_changes_by_commit: el.getAttribute('data-filter_changes_by_commit'),
} as Record<string, string>,
mergeBase: el.getAttribute('data-merge-base'),
commits: [] as Array<Commit>,
hoverActivated: false,
lastReviewCommitSha: '' as string | null,
uniqueIdMenu: generateElemId('diff-commit-selector-menu-'),
uniqueIdShowAll: generateElemId('diff-commit-selector-show-all-'),
};
},
computed: {
commitsSinceLastReview() {
if (this.lastReviewCommitSha) {
return this.commits.length - this.commits.findIndex((x) => x.id === this.lastReviewCommitSha) - 1;
}
return 0;
},
},
mounted() {
document.body.addEventListener('click', this.onBodyClick);
this.$el.addEventListener('keydown', this.onKeyDown);
this.$el.addEventListener('keyup', this.onKeyUp);
},
unmounted() {
document.body.removeEventListener('click', this.onBodyClick);
this.$el.removeEventListener('keydown', this.onKeyDown);
this.$el.removeEventListener('keyup', this.onKeyUp);
},
methods: {
onBodyClick(event: MouseEvent) {
// close this menu on click outside of this element when the dropdown is currently visible opened
if (this.$el.contains(event.target)) return;
if (this.menuVisible) {
this.toggleMenu();
}
},
onKeyDown(event: KeyboardEvent) {
if (!this.menuVisible) return;
const item = document.activeElement as HTMLElement;
if (!this.$el.contains(item)) return;
switch (event.key) {
case 'ArrowDown': // select next element
event.preventDefault();
this.focusElem(item.nextElementSibling as HTMLElement, item);
break;
case 'ArrowUp': // select previous element
event.preventDefault();
this.focusElem(item.previousElementSibling as HTMLElement, item);
break;
case 'Escape': // close menu
event.preventDefault();
item.tabIndex = -1;
this.toggleMenu();
break;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
const item = document.activeElement; // try to highlight the selected commits
const commitIdx = item?.matches('.item') ? item.getAttribute('data-commit-idx') : null;
if (commitIdx) this.highlight(this.commits[Number(commitIdx)]);
}
},
onKeyUp(event: KeyboardEvent) {
if (!this.menuVisible) return;
const item = document.activeElement;
if (!this.$el.contains(item)) return;
if (event.key === 'Shift' && this.hoverActivated) {
// shift is not pressed anymore -> deactivate hovering and reset hovered and selected
this.hoverActivated = false;
for (const commit of this.commits) {
commit.hovered = false;
commit.selected = false;
}
}
},
highlight(commit: Commit) {
if (!this.hoverActivated) return;
const indexSelected = this.commits.findIndex((x) => x.selected);
const indexCurrentElem = this.commits.findIndex((x) => x.id === commit.id);
for (const [idx, commit] of this.commits.entries()) {
commit.hovered = Math.min(indexSelected, indexCurrentElem) <= idx && idx <= Math.max(indexSelected, indexCurrentElem);
}
},
/** Focus given element */
focusElem(elem: HTMLElement, prevElem: HTMLElement) {
if (elem) {
elem.tabIndex = 0;
if (prevElem) prevElem.tabIndex = -1;
elem.focus();
}
},
/** Opens our menu, loads commits before opening */
async toggleMenu() {
this.menuVisible = !this.menuVisible;
// load our commits when the menu is not yet visible (it'll be toggled after loading)
// and we got no commits
if (!this.commits.length && this.menuVisible && !this.isLoading) {
this.isLoading = true;
try {
await this.fetchCommits();
} finally {
this.isLoading = false;
}
}
// set correct tabindex to allow easier navigation
this.$nextTick(() => {
if (this.menuVisible) {
this.focusElem(this.$refs.showAllChanges as HTMLElement, this.$refs.expandBtn as HTMLElement);
} else {
this.focusElem(this.$refs.expandBtn as HTMLElement, this.$refs.showAllChanges as HTMLElement);
}
});
},
/** Load the commits to show in this dropdown */
async fetchCommits() {
const resp = await GET(`${this.issueLink}/commits/list`);
const results = await resp.json() as CommitListResult;
this.commits.push(...results.commits.map((x) => {
x.hovered = false;
return x;
}));
this.commits.reverse();
this.lastReviewCommitSha = results.last_review_commit_sha || null;
if (this.lastReviewCommitSha && !this.commits.some((x) => x.id === this.lastReviewCommitSha)) {
// the lastReviewCommit is not available (probably due to a force push)
// reset the last review commit sha
this.lastReviewCommitSha = null;
}
Object.assign(this.locale, results.locale);
},
showAllChanges() {
window.location.assign(`${this.issueLink}/files${this.queryParams}`);
},
/** Called when user clicks on since last review */
changesSinceLastReviewClick() {
window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1)!.id}${this.queryParams}`);
},
/** Clicking on a single commit opens this specific commit */
commitClicked(commitId: string, newWindow = false) {
const url = `${this.issueLink}/commits/${commitId}${this.queryParams}`;
if (newWindow) {
window.open(url);
} else {
window.location.assign(url);
}
},
/**
* When a commit is clicked while holding Shift, it enables range selection.
* - The range selection is a half-open, half-closed range, meaning it excludes the start commit but includes the end commit.
* - The start of the commit range is always the previous commit of the first clicked commit.
* - If the first commit in the list is clicked, the mergeBase will be used as the start of the range instead.
* - The second Shift-click defines the end of the range.
* - Once both are selected, the diff view for the selected commit range will open.
*/
commitClickedShift(commit: Commit) {
this.hoverActivated = !this.hoverActivated;
commit.selected = true;
// Second click -> determine our range and open links accordingly
if (!this.hoverActivated) {
// since at least one commit is selected, we can determine the range
// find all selected commits and generate a link
const firstSelected = this.commits.findIndex((x) => x.selected);
const lastSelected = this.commits.findLastIndex((x) => x.selected);
let beforeCommitID: string | null = null;
if (firstSelected === 0) {
beforeCommitID = this.mergeBase;
} else {
beforeCommitID = this.commits[firstSelected - 1].id;
}
const afterCommitID = this.commits[lastSelected].id;
if (firstSelected === lastSelected) {
// if the start and end are the same, we show this single commit
window.location.assign(`${this.issueLink}/commits/${afterCommitID}${this.queryParams}`);
} else if (beforeCommitID === this.mergeBase && afterCommitID === this.commits.at(-1)!.id) {
// if the first commit is selected and the last commit is selected, we show all commits
window.location.assign(`${this.issueLink}/files${this.queryParams}`);
} else {
window.location.assign(`${this.issueLink}/files/${beforeCommitID}..${afterCommitID}${this.queryParams}`);
}
}
},
},
});
</script>
<template>
<div class="ui scrolling dropdown custom diff-commit-selector">
<button
ref="expandBtn"
class="ui tiny basic button"
@click.stop="toggleMenu()"
:data-tooltip-content="locale.filter_changes_by_commit"
aria-haspopup="true"
:aria-label="locale.filter_changes_by_commit"
:aria-controls="uniqueIdMenu"
:aria-activedescendant="uniqueIdShowAll"
>
<svg-icon name="octicon-git-commit"/>
</button>
<!-- this dropdown is not managed by Fomantic UI, so it needs some classes like "transition" explicitly -->
<div class="left menu transition" :id="uniqueIdMenu" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak :aria-expanded="menuVisible ? 'true': 'false'">
<div class="loading-indicator is-loading" v-if="isLoading"/>
<div v-if="!isLoading" class="item" :id="uniqueIdShowAll" ref="showAllChanges" role="menuitem" @keydown.enter="showAllChanges()" @click="showAllChanges()">
<div class="gt-ellipsis">
{{ locale.show_all_commits }}
</div>
<div class="gt-ellipsis tw-text-text-light-2 tw-mb-0">
{{ locale.stats_num_commits }}
</div>
</div>
<!-- only show the show changes since last review if there is a review AND we are commits ahead of the last review -->
<div
v-if="lastReviewCommitSha != null"
class="item" role="menuitem"
:class="{disabled: !commitsSinceLastReview}"
@keydown.enter="changesSinceLastReviewClick()"
@click="changesSinceLastReviewClick()"
>
<div class="gt-ellipsis">
{{ locale.show_changes_since_your_last_review }}
</div>
<div class="gt-ellipsis tw-text-text-light-2">
{{ commitsSinceLastReview }} commits
</div>
</div>
<span v-if="!isLoading" class="info tw-text-text-light-2">{{ locale.select_commit_hold_shift_for_range }}</span>
<template v-for="(commit, idx) in commits" :key="commit.id">
<div
class="item" role="menuitem"
:class="{selected: commit.selected, hovered: commit.hovered}"
:data-commit-idx="idx"
@keydown.enter.exact="commitClicked(commit.id)"
@keydown.enter.shift.exact="commitClickedShift(commit)"
@mouseover.shift="highlight(commit)"
@click.exact="commitClicked(commit.id)"
@click.ctrl.exact="commitClicked(commit.id, true)"
@click.meta.exact="commitClicked(commit.id, true)"
@click.shift.exact.stop.prevent="commitClickedShift(commit)"
>
<div class="tw-flex-1 tw-flex tw-flex-col tw-gap-1">
<div class="gt-ellipsis commit-list-summary">
{{ commit.summary }}
</div>
<div class="gt-ellipsis tw-text-text-light-2">
{{ commit.committer_or_author_name }}
<span class="text right">
<!-- TODO: make this respect the PreferredTimestampTense setting -->
<relative-time prefix="" :datetime="commit.time" data-tooltip-content data-tooltip-interactive="true">{{ commit.time }}</relative-time>
</span>
</div>
</div>
<div class="tw-font-mono">
{{ commit.short_sha }}
</div>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.ui.dropdown.diff-commit-selector .menu {
margin-top: 0.25em;
overflow-x: hidden;
max-height: 450px;
}
.ui.dropdown.diff-commit-selector .menu .loading-indicator {
height: 200px;
width: 350px;
}
.ui.dropdown.diff-commit-selector .menu > .item,
.ui.dropdown.diff-commit-selector .menu > .info {
display: flex;
flex-direction: row;
line-height: 1.4;
gap: 0.25em;
padding: 7px 14px !important;
}
.ui.dropdown.diff-commit-selector .menu > .item:not(:first-child),
.ui.dropdown.diff-commit-selector .menu > .info:not(:first-child) {
border-top: 1px solid var(--color-secondary) !important;
}
.ui.dropdown.diff-commit-selector .menu > .item:focus {
background: var(--color-active);
}
.ui.dropdown.diff-commit-selector .menu > .item.hovered {
background-color: var(--color-small-accent);
}
.ui.dropdown.diff-commit-selector .menu > .item.selected {
background-color: var(--color-accent);
}
.ui.dropdown.diff-commit-selector .menu .commit-list-summary {
max-width: min(380px, 96vw);
}
</style>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts" setup>
import DiffFileTreeItem from './DiffFileTreeItem.vue';
import {toggleElem} from '../utils/dom.ts';
import {diffTreeStore} from '../modules/diff-file.ts';
import {setFileFolding} from '../features/file-fold.ts';
import {onMounted, onUnmounted} from 'vue';
import {localUserSettings} from '../modules/user-settings.ts';
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
const store = diffTreeStore();
onMounted(() => {
// Default to true if unset
store.fileTreeIsVisible = localUserSettings.getBoolean(LOCAL_STORAGE_KEY, true);
document.querySelector('.diff-toggle-file-tree-button')!.addEventListener('click', toggleVisibility);
hashChangeListener();
window.addEventListener('hashchange', hashChangeListener);
});
onUnmounted(() => {
document.querySelector('.diff-toggle-file-tree-button')!.removeEventListener('click', toggleVisibility);
window.removeEventListener('hashchange', hashChangeListener);
});
function hashChangeListener() {
store.selectedItem = window.location.hash;
expandSelectedFile();
}
function expandSelectedFile() {
// expand file if the selected file is folded
if (store.selectedItem) {
const box = document.querySelector(store.selectedItem);
const folded = box?.getAttribute('data-folded') === 'true';
if (folded) setFileFolding(box, box.querySelector('.fold-file')!, false);
}
}
function toggleVisibility() {
updateVisibility(!store.fileTreeIsVisible);
}
function updateVisibility(visible: boolean) {
store.fileTreeIsVisible = visible;
localUserSettings.setBoolean(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
updateState(store.fileTreeIsVisible);
}
function updateState(visible: boolean) {
const btn = document.querySelector('.diff-toggle-file-tree-button')!;
const [toShow, toHide] = btn.querySelectorAll('.icon');
const tree = document.querySelector('#diff-file-tree')!;
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text')!;
btn.setAttribute('data-tooltip-content', newTooltip);
toggleElem(tree, visible);
toggleElem(toShow, !visible);
toggleElem(toHide, visible);
}
</script>
<template>
<!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
<DiffFileTreeItem v-for="item in store.diffFileTree.TreeRoot.Children" :key="item.FullName" :item="item"/>
</div>
</template>
<style scoped>
.diff-file-tree-items {
display: flex;
flex-direction: column;
gap: 1px;
margin-right: .5rem;
}
</style>
+106
View File
@@ -0,0 +1,106 @@
<script lang="ts" setup>
import {SvgIcon, type SvgName} from '../svg.ts';
import {shallowRef} from 'vue';
import {type DiffStatus, type DiffTreeEntry, diffTreeStore} from '../modules/diff-file.ts';
const props = defineProps<{
item: DiffTreeEntry,
}>();
const store = diffTreeStore();
const collapsed = shallowRef(props.item.IsViewed);
function getIconForDiffStatus(pType: DiffStatus) {
const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = {
'': {name: 'octicon-blocked', classes: ['tw-text-red']}, // unknown case
'added': {name: 'octicon-diff-added', classes: ['tw-text-green']},
'modified': {name: 'octicon-diff-modified', classes: ['tw-text-yellow']},
'deleted': {name: 'octicon-diff-removed', classes: ['tw-text-red']},
'renamed': {name: 'octicon-diff-renamed', classes: ['tw-text-teal']},
'copied': {name: 'octicon-diff-renamed', classes: ['tw-text-green']},
'typechange': {name: 'octicon-diff-modified', classes: ['tw-text-green']}, // there is no octicon for copied, so renamed should be ok
};
return diffTypes[pType] ?? diffTypes[''];
}
</script>
<template>
<template v-if="item.EntryMode === 'tree'">
<div class="item-directory" :class="{ 'viewed': item.IsViewed }" :title="item.DisplayName" @click.stop="collapsed = !collapsed">
<!-- directory -->
<SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/>
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="tw-contents" v-html="collapsed ? store.folderIcon : store.folderOpenIcon"/>
<span class="gt-ellipsis">{{ item.DisplayName }}</span>
</div>
<div v-show="!collapsed" class="sub-items">
<DiffFileTreeItem v-for="childItem in item.Children" :key="childItem.DisplayName" :item="childItem"/>
</div>
</template>
<a
v-else
class="item-file" :class="{ 'selected': store.selectedItem === '#diff-' + item.NameHash, 'viewed': item.IsViewed }"
:title="item.DisplayName" :href="'#diff-' + item.NameHash"
>
<!-- file -->
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="tw-contents" v-html="item.FileIcon"/>
<span class="gt-ellipsis tw-flex-1">{{ item.DisplayName }}</span>
<SvgIcon
:name="getIconForDiffStatus(item.DiffStatus).name"
:class="getIconForDiffStatus(item.DiffStatus).classes"
/>
</a>
</template>
<style scoped>
a,
a:hover {
text-decoration: none;
color: var(--color-text);
}
.sub-items {
display: flex;
flex-direction: column;
gap: 1px;
margin-left: 13px;
border-left: 1px solid var(--color-secondary);
}
.sub-items .item-file {
padding-left: 18px;
}
.item-file.selected {
color: var(--color-text);
background: var(--color-active);
border-radius: 4px;
}
.item-file.viewed,
.item-directory.viewed {
color: var(--color-text-light-3);
}
.item-directory {
user-select: none;
}
.item-file,
.item-directory {
display: flex;
align-items: center;
gap: 0.25em;
padding: 6px;
}
.item-file:hover,
.item-directory:hover {
color: var(--color-text);
background: var(--color-hover);
border-radius: 4px;
cursor: pointer;
}
</style>
@@ -0,0 +1,263 @@
<script lang="ts" setup>
import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import {toggleElem} from '../utils/dom.ts';
const props = defineProps<{
mergeFormProps: any, // TODO: this is a huge object, need to be refactored in the future
}>();
const mergeStyleManuallyMerged = 'manually-merged';
const mergeForm = props.mergeFormProps;
const mergeTitleFieldValue = shallowRef('');
const mergeMessageFieldValue = shallowRef('');
const deleteBranchAfterMerge = shallowRef(false);
const autoMergeWhenSucceed = shallowRef(false);
const mergeStyle = shallowRef('');
const mergeStyleDetail = shallowRef({
hideMergeMessageTexts: false,
textDoMerge: '',
mergeTitleFieldText: '',
mergeMessageFieldText: '',
hideAutoMerge: false,
});
const mergeStyleAllowedCount = shallowRef(0);
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';
return 'primary';
});
const forceMerge = computed(() => {
return mergeForm.canMergeNow && !mergeForm.allOverridableChecksOk;
});
watch(mergeStyle, (val) => {
mergeStyleDetail.value = mergeForm.mergeStyles.find((e: any) => e.name === val);
for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
}
});
onMounted(() => {
mergeStyleAllowedCount.value = mergeForm.mergeStyles.reduce((v: any, msd: any) => v + (msd.allowed ? 1 : 0), 0);
let mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed && e.name === mergeForm.defaultMergeStyle)?.name;
if (!mergeStyle) mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed)?.name;
switchMergeStyle(mergeStyle, !mergeForm.canMergeNow);
document.addEventListener('mouseup', hideMergeStyleMenu);
});
onUnmounted(() => {
document.removeEventListener('mouseup', hideMergeStyleMenu);
});
function hideMergeStyleMenu() {
showMergeStyleMenu.value = false;
}
function toggleActionForm(show: boolean) {
showActionForm.value = show;
if (!show) return;
deleteBranchAfterMerge.value = mergeForm.defaultDeleteBranchAfterMerge;
mergeTitleFieldValue.value = mergeStyleDetail.value.mergeTitleFieldText;
mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText;
}
function switchMergeStyle(name: string, autoMerge = false) {
mergeStyle.value = name;
autoMergeWhenSucceed.value = autoMerge;
}
function clearMergeMessage() {
mergeMessageFieldValue.value = mergeForm.defaultMergeMessage;
}
</script>
<template>
<!--
if this component is shown, either the user is an admin (can do a merge without checks), or they are a writer who has the permission to do a merge
if the user is a writer and can't do a merge now (canMergeNow==false), then only show the Auto Merge for them
How to test the UI manually:
* Method 1: manually set some variables in pull.tmpl, eg: {{$notAllOverridableChecksOk = true}} {{$canMergeNow = false}}
* Method 2: make a protected branch, then set state=pending/success :
curl -X POST ${root_url}/api/v1/repos/${owner}/${repo}/statuses/${sha} \
-H "accept: application/json" -H "authorization: Basic $base64_auth" -H "Content-Type: application/json" \
-d '{"context": "test/context", "description": "description", "state": "${state}", "target_url": "http://localhost"}'
-->
<div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="mergeForm.hasPendingPullRequestMerge" v-html="mergeForm.hasPendingPullRequestMergeTip" class="ui info message"/>
<!-- another similar form is in pull.tmpl (manual merge)-->
<form class="ui form form-fetch-action" v-if="showActionForm" :action="mergeForm.baseLink+'/merge'" method="post">
<input type="hidden" name="head_commit_id" v-model="mergeForm.pullHeadCommitID">
<input type="hidden" name="merge_when_checks_succeed" v-model="autoMergeWhenSucceed">
<input type="hidden" name="force_merge" v-model="forceMerge">
<template v-if="!mergeStyleDetail.hideMergeMessageTexts">
<div class="field">
<input type="text" name="merge_title_field" v-model="mergeTitleFieldValue">
</div>
<div class="field">
<textarea name="merge_message_field" rows="5" :placeholder="mergeForm.mergeMessageFieldPlaceHolder" v-model="mergeMessageFieldValue"/>
<template v-if="mergeMessageFieldValue !== mergeForm.defaultMergeMessage">
<button @click.prevent="clearMergeMessage" class="btn tw-mt-1 tw-p-1 interact-fg" :data-tooltip-content="mergeForm.textClearMergeMessageHint">
{{ mergeForm.textClearMergeMessage }}
</button>
</template>
</div>
</template>
<div class="field" v-if="mergeStyle === mergeStyleManuallyMerged">
<input type="text" name="merge_commit_id" :placeholder="mergeForm.textMergeCommitId">
</div>
<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" type="button" @click="toggleActionForm(false)">
{{ mergeForm.textCancel }}
</button>
<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="mergeSelectStyleClass" @click="toggleActionForm(true)">
<button class="ui button">
<svg-icon name="octicon-git-merge"/>
<span class="button-text">
{{ mergeStyleDetail.textDoMerge }}
<template v-if="autoMergeWhenSucceed">
{{ mergeForm.textAutoMergeButtonWhenSucceed }}
</template>
</span>
</button>
<div class="ui dropdown icon button" @click.stop="showMergeStyleMenu = !showMergeStyleMenu">
<svg-icon name="octicon-triangle-down" :size="14"/>
<div class="menu" :class="{'show':showMergeStyleMenu}">
<template v-for="msd in mergeForm.mergeStyles">
<!-- if can merge now, show one action "merge now", and an action "auto merge when succeed" -->
<div class="item" v-if="msd.allowed && mergeForm.canMergeNow" :key="msd.name" @click.stop="switchMergeStyle(msd.name)">
<div class="action-text">
{{ msd.textDoMerge }}
</div>
<div v-if="!msd.hideAutoMerge" class="auto-merge-small" @click.stop="switchMergeStyle(msd.name, true)">
<svg-icon name="octicon-clock" :size="14"/>
<div class="auto-merge-tip">
{{ mergeForm.textAutoMergeWhenSucceed }}
</div>
</div>
</div>
<!-- if can NOT merge now, only show one action "auto merge when succeed" -->
<div class="item" v-if="msd.allowed && !mergeForm.canMergeNow && !msd.hideAutoMerge" :key="msd.name" @click.stop="switchMergeStyle(msd.name, true)">
<div class="action-text">
{{ msd.textDoMerge }} {{ mergeForm.textAutoMergeButtonWhenSucceed }}
</div>
</div>
</template>
</div>
</div>
</div>
<!-- the cancel auto merge button -->
<form v-if="mergeForm.hasPendingPullRequestMerge" :action="mergeForm.baseLink+'/cancel_auto_merge'" method="post" class="tw-ml-4">
<button class="ui button">
{{ mergeForm.textAutoMergeCancelSchedule }}
</button>
</form>
</div>
</div>
</template>
<style scoped>
/* to keep UI the same, at the moment we are still using some Fomantic UI styles, but we do not use their scripts, so we need to fine tune some styles */
.ui.dropdown .menu.show {
display: block;
}
.ui.checkbox label {
cursor: pointer;
}
/* make the dropdown list left-aligned */
.ui.merge-button {
position: relative;
}
.ui.merge-button .ui.dropdown {
position: static;
}
.ui.merge-button > .ui.dropdown:last-child > .menu:not(.left) {
left: 0;
right: auto;
}
.ui.merge-button .ui.dropdown .menu > .item {
display: flex;
align-items: stretch;
padding: 0 !important; /* polluted by semantic.css: .ui.dropdown .menu > .item { !important } */
}
/* merge style list item */
.action-text {
padding: 0.8rem;
flex: 1
}
.auto-merge-small {
width: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.auto-merge-small .auto-merge-tip {
display: none;
left: 38px;
top: -1px;
bottom: -1px;
position: absolute;
align-items: center;
color: var(--color-text);
background-color: var(--color-info-bg);
border: 1px solid var(--color-info-border);
border-left: none;
padding-right: 1rem;
}
.auto-merge-small:hover {
color: var(--color-text);
background-color: var(--color-info-bg);
border: 1px solid var(--color-info-border);
}
.auto-merge-small:hover .auto-merge-tip {
display: flex;
}
</style>
+499
View File
@@ -0,0 +1,499 @@
<script setup lang="ts">
import {SvgIcon} from '../svg.ts';
import ActionStatusIcon from './ActionStatusIcon.vue';
import {computed, ref, toRefs} from 'vue';
import {POST, DELETE} from '../modules/fetch.ts';
import ActionRunSummaryView from './ActionRunSummaryView.vue';
import ActionRunJobView from './ActionRunJobView.vue';
import type {ActionsJob, ActionsRunAttempt} from '../modules/gitea-actions.ts';
import {buildJobsByParentJobID, createActionRunViewStore} from './ActionRunView.ts';
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
defineOptions({
name: 'RepoActionView',
});
const props = defineProps<{
jobId: number;
actionsViewUrl: string;
locale: Record<string, any>;
}>();
const locale = props.locale;
const store = createActionRunViewStore(props.actionsViewUrl);
const {currentRun: run, runArtifacts: artifacts} = toRefs(store.viewData);
type JobListItem = {
job: ActionsJob;
depth: number;
hasChildren: boolean;
};
// Caller jobs default to collapsed. Membership in this set means "user has manually expanded this caller"
const expandedJobIDs = ref(new Set<number>());
function toggleExpandedJob(jobID: number) {
const next = new Set(expandedJobIDs.value);
if (next.has(jobID)) {
next.delete(jobID);
} else {
next.add(jobID);
}
expandedJobIDs.value = next;
}
// When a child job is currently selected, force-expand the chain of caller ancestors
const forcedExpandedJobIDs = computed(() => {
const expanded = new Set<number>();
if (!props.jobId) return expanded;
const jobsByID = new Map((run.value.jobs || []).map((job) => [job.id, job]));
let cur = jobsByID.get(props.jobId);
while (cur?.parentJobID) {
expanded.add(cur.parentJobID);
cur = jobsByID.get(cur.parentJobID);
}
return expanded;
});
function isJobCollapsed(jobID: number) {
return !expandedJobIDs.value.has(jobID) && !forcedExpandedJobIDs.value.has(jobID);
}
const visibleJobListItems = computed<JobListItem[]>(() => {
const jobs = [...(run.value.jobs || [])].sort((a, b) => a.id - b.id);
const childrenByParent = buildJobsByParentJobID(jobs);
const result: JobListItem[] = [];
const stack: Array<{job: ActionsJob; depth: number}> = [];
const top = childrenByParent.get(0) || [];
for (let i = top.length - 1; i >= 0; i--) stack.push({job: top[i], depth: 0});
while (stack.length > 0) {
const {job, depth} = stack.pop()!;
const children = childrenByParent.get(job.id) || [];
const hasChildren = children.length > 0;
result.push({job, depth, hasChildren});
if (hasChildren && isJobCollapsed(job.id)) continue;
for (let i = children.length - 1; i >= 0; i--) stack.push({job: children[i], depth: depth + 1});
}
return result;
});
function formatAttemptTitle(attempt: ActionsRunAttempt) {
return attempt.latest ? `${locale.latestAttempt} #${attempt.attempt}` : `${locale.attempt} #${attempt.attempt}`;
}
function formatCurrentAttemptTitle(attempt: ActionsRunAttempt) {
return attempt.latest ? `${locale.latest} #${attempt.attempt}` : formatAttemptTitle(attempt);
}
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`);
}
function approveRun() {
POST(`${run.value.link}/approve`);
}
async function deleteArtifact(name: string) {
if (!window.confirm(locale.confirmDeleteArtifact.replace('%s', name))) return;
await DELETE(buildArtifactLink(name));
await store.forceReloadCurrentRun();
}
</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">
<div class="action-info-summary">
<div class="action-info-summary-title">
<ActionStatusIcon :locale-status="locale.status[run.status]" :status="run.status" :size="20" icon-variant="circle-fill"/>
<!-- eslint-disable-next-line vue/no-v-html -->
<h2 class="action-info-summary-title-text" v-html="run.titleHTML"/>
</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 small compact button tw-text-red" @click="cancelRun()" v-else-if="run.canCancel">
{{ locale.cancel }}
</button>
<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 }}
</button>
<div class="ui basic small compact dropdown icon button">
<SvgIcon name="octicon-triangle-down" :size="14"/>
<div class="menu">
<div class="item link-action" :data-url="`${run.link}/rerun`">
{{ locale.rerun_all }}
</div>
</div>
</div>
</div>
<button v-else class="ui basic small compact button link-action" :data-url="`${run.link}/rerun`">
{{ 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 v-if="run.workflowLink" class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>
<b v-else>{{ run.workflowID }}</b>
:
</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 -->
<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>
<div class="flex-items-block action-view-sidebar-list">
<div
class="item job-brief-item"
:class="{'selected': props.jobId === item.job.id}"
:style="{paddingLeft: `${10 + item.depth * 16}px`}"
v-for="item in visibleJobListItems"
:key="item.job.id"
>
<a class="tw-contents silenced" :href="item.job.link">
<ActionStatusIcon :locale-status="locale.status[item.job.status]" :status="item.job.status" icon-variant="circle-fill"/>
<span class="tw-min-w-0 gt-ellipsis">{{ item.job.name }}</span>
<SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-rerun-button tw-cursor-pointer link-action interact-fg" :data-url="`${run.link}/jobs/${item.job.id}/rerun`" v-if="item.job.canRerun"/>
<span class="job-duration">{{ item.job.duration }}</span>
</a>
<button
v-if="item.hasChildren"
type="button"
class="job-brief-toggle"
:class="{'collapsed': isJobCollapsed(item.job.id)}"
@click="toggleExpandedJob(item.job.id)"
:title="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
:aria-label="isJobCollapsed(item.job.id) ? locale.expandCallerJobs : locale.collapseCallerJobs"
:aria-expanded="!isJobCollapsed(item.job.id)"
>
<SvgIcon name="octicon-chevron-down" :size="14"/>
</button>
</div>
</div>
<!-- artifacts list -->
<template v-if="artifacts.length > 0">
<div class="ui divider"/>
<div class="left-list-header">{{ locale.artifactsTitle }} ({{ artifacts.length }})</div>
<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 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" class="silenced" @click="deleteArtifact(artifact.name)">
<SvgIcon name="octicon-trash"/>
</a>
</template>
<span v-else class="flex-text-block tw-flex-1 tw-text-text-light-2">
<SvgIcon name="octicon-file-removed"/>
<span class="tw-flex-1 gt-ellipsis">{{ artifact.name }}</span>
<span class="ui label tw-flex-shrink-0">{{ locale.artifactExpired }}</span>
</span>
</div>
</div>
</template>
<!-- run details -->
<div class="ui divider"/>
<div class="left-list-header">{{ locale.runDetails }}</div>
<div class="flex-items-block action-view-sidebar-list">
<div class="item">
<a 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>
</div>
</div>
</div>
<div class="action-view-right">
<ActionRunSummaryView
v-if="!props.jobId"
:store="store"
:locale="locale"
/>
<ActionRunJobView
v-else
:store="store"
:locale="locale"
:actions-view-url="props.actionsViewUrl"
:job-id="props.jobId"
/>
</div>
</div>
</div>
</template>
<style scoped>
.action-view-body {
padding-top: 12px;
padding-bottom: 12px;
display: flex;
gap: 12px;
}
/* ================ */
/* action view header */
.action-view-header {
margin-top: 8px;
}
.action-info-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.action-info-summary-title {
display: flex;
align-items: center;
gap: 0.5em;
}
.action-info-summary-title-text {
font-size: 20px;
margin: 0;
flex: 1;
overflow-wrap: anywhere;
}
.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 */
.action-view-left {
width: 30%;
max-width: 400px;
position: sticky;
top: 12px;
/* about 12px top padding + 12px bottom padding + 37px footer height,
TODO: need to use JS to calculate the height for better scrolling experience*/
max-height: calc(100vh - 62px);
overflow-y: auto;
background: var(--color-body);
z-index: 2; /* above .job-info-header */
}
@media (max-width: 767.98px) {
.action-view-left {
position: static; /* can not sticky because multiple jobs would overlap into right view */
max-height: unset;
}
}
.left-list-header {
font-size: 13px;
font-weight: var(--font-weight-semibold);
color: var(--color-text-light-2);
}
.action-view-sidebar-list {
margin: var(--gap-block) 0;
}
.action-view-sidebar-list:first-child {
margin-top: 0;
}
.action-view-sidebar-list > .item {
padding: 6px 10px;
border-radius: var(--border-radius);
}
.action-view-sidebar-list > .item:hover {
background-color: var(--color-hover);
}
.action-view-sidebar-list > .item.selected {
font-weight: var(--font-weight-bold);
background-color: var(--color-active);
}
.job-brief-toggle {
border: none;
padding: 0;
background: transparent;
cursor: pointer;
color: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
/* the icon is always chevron-down; flip to chevron-up when expanded */
transition: transform 0.15s ease;
/* sit right after the job name; rerun/duration float to the right via auto-margin */
order: 1;
}
.job-brief-toggle:not(.collapsed) {
transform: rotate(180deg);
}
/* push rerun/duration to the right edge; only one is visible at a time (hover swap),
the visible one absorbs the free space via auto-margin */
.action-view-sidebar-list > .item .job-rerun-button,
.action-view-sidebar-list > .item .job-duration {
order: 2;
margin-left: auto;
}
/* the re-run button replaces the duration on hover or job-link focus */
.action-view-sidebar-list > .item .job-rerun-button {
display: none;
}
.action-view-sidebar-list > .item:hover .job-rerun-button,
.action-view-sidebar-list > .item:has(a:focus) .job-rerun-button {
display: inline-flex;
}
/* only swap out the duration when a re-run button exists to take its place */
.action-view-sidebar-list > .item:hover .job-rerun-button ~ .job-duration,
.action-view-sidebar-list > .item:has(a:focus) .job-rerun-button ~ .job-duration {
display: none;
}
/* ================ */
/* action view right */
.action-view-right {
flex: 1;
color: var(--color-console-fg-subtle);
max-height: 100%;
width: 70%;
display: flex;
flex-direction: column;
border: 1px solid var(--color-console-border);
border-radius: var(--border-radius);
background: var(--color-console-bg);
}
/* begin fomantic button overrides */
.action-view-right .ui.button,
.action-view-right .ui.button:focus {
background: transparent;
color: var(--color-console-fg-subtle);
}
.action-view-right .ui.button:hover {
background: var(--color-console-hover-bg);
color: var(--color-console-fg);
}
.action-view-right .ui.button:active {
background: var(--color-console-active-bg);
color: var(--color-console-fg);
}
/* end fomantic button overrides */
@media (max-width: 767.98px) {
.action-view-body {
flex-direction: column;
}
.action-view-left, .action-view-right {
width: 100%;
}
.action-view-left {
max-width: none;
}
}
</style>
@@ -0,0 +1,107 @@
<script lang="ts" setup>
import {VueBarGraph} from 'vue-bar-graph';
import {computed, onMounted, shallowRef, useTemplateRef, type ShallowRef} from 'vue';
const colors = shallowRef({
barColor: 'green',
textColor: 'black',
textAltColor: 'white',
});
type ActivityAuthorData = {
avatar_link: string;
commits: number;
home_link: string;
login: string;
name: string;
}
const activityTopAuthors: Array<ActivityAuthorData> = window.config.pageData.repoActivityTopAuthors || [];
const graphPoints = computed(() => {
return activityTopAuthors.map((item) => {
return {
value: item.commits,
label: item.name,
};
});
});
const graphAuthors = computed(() => {
return activityTopAuthors.map((item, idx: number) => {
return {
position: idx + 1,
...item,
};
});
});
const graphWidth = computed(() => {
return activityTopAuthors.length * 40;
});
const styleElement = useTemplateRef('styleElement') as Readonly<ShallowRef<HTMLDivElement>>;
const altStyleElement = useTemplateRef('altStyleElement') as Readonly<ShallowRef<HTMLDivElement>>;
onMounted(() => {
const refStyle = window.getComputedStyle(styleElement.value);
const refAltStyle = window.getComputedStyle(altStyleElement.value);
colors.value = {
barColor: refStyle.backgroundColor,
textColor: refStyle.color,
textAltColor: refAltStyle.color,
};
});
</script>
<template>
<div>
<div class="activity-bar-graph tw-w-0 tw-h-0" ref="styleElement"/>
<div class="activity-bar-graph-alt tw-w-0 tw-h-0" ref="altStyleElement"/>
<vue-bar-graph
:points="graphPoints"
:show-x-axis="true"
:show-y-axis="false"
:show-values="true"
:width="graphWidth"
:bar-color="colors.barColor"
:text-color="colors.textColor"
:text-alt-color="colors.textAltColor"
:height="100"
:label-height="20"
>
<template #label="opt">
<g v-for="(author, idx) in graphAuthors" :key="author.position">
<a
v-if="opt.bar.index === idx && author.home_link"
:href="author.home_link"
>
<image
:x="`${opt.bar.midPoint - 10}px`"
:y="`${opt.bar.yLabel}px`"
height="20"
width="20"
:href="author.avatar_link"
/>
</a>
<image
v-else-if="opt.bar.index === idx"
:x="`${opt.bar.midPoint - 10}px`"
:y="`${opt.bar.yLabel}px`"
height="20"
width="20"
:href="author.avatar_link"
/>
</g>
</template>
<template #title="opt">
<tspan v-for="(author, idx) in graphAuthors" :key="author.position">
<tspan v-if="opt.bar.index === idx">
{{ author.name }}
</tspan>
</tspan>
</template>
</vue-bar-graph>
</div>
</template>
@@ -0,0 +1,290 @@
<script lang="ts">
import {defineComponent, nextTick} from 'vue';
import {SvgIcon} from '../svg.ts';
import {showErrorToast} from '../modules/toast.ts';
import {GET} from '../modules/fetch.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import type {GitRefType} from '../types.ts';
type ListItem = {
selected: boolean;
refShortName: string;
refType: GitRefType;
rssFeedLink: string;
};
type SelectedTab = 'branches' | 'tags';
type TabLoadingStates = Record<SelectedTab, '' | 'loading' | 'done'>
export default defineComponent({
components: {SvgIcon},
props: {
elRoot: {
type: HTMLElement,
required: true,
},
},
data() {
const shouldShowTabBranches = this.elRoot.getAttribute('data-show-tab-branches') === 'true';
return {
allItems: [] as ListItem[],
selectedTab: (shouldShowTabBranches ? 'branches' : 'tags') as SelectedTab,
searchTerm: '',
menuVisible: false,
activeItemIndex: 0,
tabLoadingStates: {} as TabLoadingStates,
textReleaseCompare: this.elRoot.getAttribute('data-text-release-compare')!,
textBranches: this.elRoot.getAttribute('data-text-branches')!,
textTags: this.elRoot.getAttribute('data-text-tags')!,
textFilterBranch: this.elRoot.getAttribute('data-text-filter-branch')!,
textFilterTag: this.elRoot.getAttribute('data-text-filter-tag')!,
textDefaultBranchLabel: this.elRoot.getAttribute('data-text-default-branch-label')!,
textCreateTag: this.elRoot.getAttribute('data-text-create-tag')!,
textCreateBranch: this.elRoot.getAttribute('data-text-create-branch')!,
textCreateRefFrom: this.elRoot.getAttribute('data-text-create-ref-from')!,
textNoResults: this.elRoot.getAttribute('data-text-no-results')!,
textViewAllBranches: this.elRoot.getAttribute('data-text-view-all-branches')!,
textViewAllTags: this.elRoot.getAttribute('data-text-view-all-tags')!,
currentRepoDefaultBranch: this.elRoot.getAttribute('data-current-repo-default-branch')!,
currentRepoLink: this.elRoot.getAttribute('data-current-repo-link')!,
currentTreePath: this.elRoot.getAttribute('data-current-tree-path')!,
currentRefType: this.elRoot.getAttribute('data-current-ref-type') as GitRefType,
currentRefShortName: this.elRoot.getAttribute('data-current-ref-short-name')!,
refLinkTemplate: this.elRoot.getAttribute('data-ref-link-template')!,
refFormActionTemplate: this.elRoot.getAttribute('data-ref-form-action-template')!,
dropdownFixedText: this.elRoot.getAttribute('data-dropdown-fixed-text')!,
showTabBranches: shouldShowTabBranches,
showTabTags: this.elRoot.getAttribute('data-show-tab-tags') === 'true',
allowCreateNewRef: this.elRoot.getAttribute('data-allow-create-new-ref') === 'true',
showViewAllRefsEntry: this.elRoot.getAttribute('data-show-view-all-refs-entry') === 'true',
enableFeed: this.elRoot.getAttribute('data-enable-feed') === 'true',
};
},
computed: {
searchFieldPlaceholder() {
return this.selectedTab === 'branches' ? this.textFilterBranch : this.textFilterTag;
},
filteredItems(): ListItem[] {
const searchTermLower = this.searchTerm.toLowerCase();
const items = this.allItems.filter((item: ListItem) => {
const typeMatched = (this.selectedTab === 'branches' && item.refType === 'branch') || (this.selectedTab === 'tags' && item.refType === 'tag');
if (!typeMatched) return false;
if (!this.searchTerm) return true; // match all
return item.refShortName.toLowerCase().includes(searchTermLower);
});
// TODO: fix this anti-pattern: side-effects-in-computed-properties
this.activeItemIndex = !items.length && this.showCreateNewRef ? 0 : -1; // eslint-disable-line vue/no-side-effects-in-computed-properties
return items;
},
showNoResults() {
if (this.tabLoadingStates[this.selectedTab] !== 'done') return false;
return !this.filteredItems.length && !this.showCreateNewRef;
},
showCreateNewRef() {
if (!this.allowCreateNewRef || !this.searchTerm) {
return false;
}
return !this.allItems.filter((item: ListItem) => {
return item.refShortName === this.searchTerm; // FIXME: not quite right here, it mixes "branch" and "tag" names
}).length;
},
createNewRefFormActionUrl() {
return `${this.currentRepoLink}/branches/_new/${this.currentRefType}/${pathEscapeSegments(this.currentRefShortName!)}`;
},
},
watch: {
menuVisible(visible: boolean) {
if (!visible) return;
this.focusSearchField();
this.loadTabItems();
},
},
beforeMount() {
document.body.addEventListener('click', (e) => {
if (this.$el.contains(e.target)) return;
if (this.menuVisible) this.menuVisible = false;
});
},
mounted() {
if (this.refFormActionTemplate) {
// if the selector is used in a form and needs to change the form action,
// make a mock item and select it to update the form action
const item: ListItem = {selected: true, refType: this.currentRefType, refShortName: this.currentRefShortName, rssFeedLink: ''};
this.selectItem(item);
}
},
methods: {
selectItem(item: ListItem) {
this.menuVisible = false;
if (this.refFormActionTemplate) {
this.currentRefType = item.refType;
this.currentRefShortName = item.refShortName;
let actionLink = this.refFormActionTemplate;
actionLink = actionLink.replace('{RepoLink}', this.currentRepoLink);
actionLink = actionLink.replace('{RefType}', pathEscapeSegments(item.refType));
actionLink = actionLink.replace('{RefShortName}', pathEscapeSegments(item.refShortName));
this.$el.closest('form').action = actionLink;
} else {
let link = this.refLinkTemplate;
link = link.replace('{RepoLink}', this.currentRepoLink);
link = link.replace('{RefType}', pathEscapeSegments(item.refType));
link = link.replace('{RefShortName}', pathEscapeSegments(item.refShortName));
link = link.replace('{TreePath}', pathEscapeSegments(this.currentTreePath));
window.location.href = link;
}
},
createNewRef() {
(this.$refs.createNewRefForm as HTMLFormElement)?.submit();
},
focusSearchField() {
nextTick(() => {
(this.$refs.searchField as HTMLInputElement).focus();
});
},
getSelectedIndexInFiltered() {
for (let i = 0; i < this.filteredItems.length; ++i) {
if (this.filteredItems[i].selected) return i;
}
return -1;
},
getActiveItem() {
const el = this.$refs[`listItem${this.activeItemIndex}`] as Array<HTMLDivElement>;
return el?.length ? el[0] : null;
},
keydown(e: KeyboardEvent) {
if (e.isComposing) return;
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
if (this.activeItemIndex === -1) {
this.activeItemIndex = this.getSelectedIndexInFiltered();
}
const nextIndex = e.key === 'ArrowDown' ? this.activeItemIndex + 1 : this.activeItemIndex - 1;
if (nextIndex < 0) {
return;
}
if (nextIndex + (this.showCreateNewRef ? 0 : 1) > this.filteredItems.length) {
return;
}
this.activeItemIndex = nextIndex;
this.getActiveItem()!.scrollIntoView({block: 'nearest'});
} else if (e.key === 'Enter') {
e.preventDefault();
this.getActiveItem()?.click();
} else if (e.key === 'Escape') {
e.preventDefault();
this.menuVisible = false;
}
},
handleTabSwitch(selectedTab: SelectedTab) {
this.selectedTab = selectedTab;
this.focusSearchField();
this.loadTabItems();
},
async loadTabItems() {
const tab = this.selectedTab;
if (this.tabLoadingStates[tab] === 'loading' || this.tabLoadingStates[tab] === 'done') return;
const refType = this.selectedTab === 'branches' ? 'branch' : 'tag';
this.tabLoadingStates[tab] = 'loading';
try {
const url = refType === 'branch' ? `${this.currentRepoLink}/branches/list` : `${this.currentRepoLink}/tags/list`;
const resp = await GET(url);
const {results} = await resp.json();
for (const refShortName of results) {
const item: ListItem = {
refType,
refShortName,
selected: refType === this.currentRefType && refShortName === this.currentRefShortName,
rssFeedLink: `${this.currentRepoLink}/rss/${refType}/${pathEscapeSegments(refShortName)}`,
};
this.allItems.push(item);
}
this.tabLoadingStates[tab] = 'done';
} catch (e) {
this.tabLoadingStates[tab] = '';
showErrorToast(`Network error when fetching items for ${tab}, error: ${e}`);
console.error(e);
}
},
},
});
</script>
<template>
<div class="ui dropdown custom branch-selector-dropdown ellipsis-text-items">
<div tabindex="0" class="ui compact button branch-dropdown-button" @click="menuVisible = !menuVisible">
<span class="flex-text-block gt-ellipsis">
<template v-if="dropdownFixedText">{{ dropdownFixedText }}</template>
<template v-else>
<svg-icon v-if="currentRefType === 'tag'" name="octicon-tag"/>
<svg-icon v-else-if="currentRefType === 'branch'" name="octicon-git-branch"/>
<svg-icon v-else name="octicon-git-commit"/>
<strong ref="dropdownRefName" class="tw-inline-block gt-ellipsis">{{ currentRefShortName }}</strong>
</template>
</span>
<svg-icon name="octicon-triangle-down" :size="14" class="dropdown icon"/>
</div>
<div class="menu transition" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak>
<div class="ui icon search input">
<i class="icon"><svg-icon name="octicon-filter" :size="16"/></i>
<input name="search" ref="searchField" autocomplete="off" v-model="searchTerm" @keydown="keydown($event)" :placeholder="searchFieldPlaceholder">
</div>
<div v-if="showTabBranches" class="branch-tag-tab">
<a class="branch-tag-item muted" :class="{active: selectedTab === 'branches'}" href="#" @click="handleTabSwitch('branches')">
<svg-icon name="octicon-git-branch" :size="16" class="tw-mr-1"/>{{ textBranches }}
</a>
<a v-if="showTabTags" class="branch-tag-item muted" :class="{active: selectedTab === 'tags'}" href="#" @click="handleTabSwitch('tags')">
<svg-icon name="octicon-tag" :size="16" class="tw-mr-1"/>{{ textTags }}
</a>
</div>
<div class="branch-tag-divider"/>
<div class="scrolling menu" ref="scrollContainer">
<svg-icon name="octicon-rss" symbol-id="svg-symbol-octicon-rss"/>
<div class="loading-indicator is-loading" v-if="tabLoadingStates[selectedTab] === 'loading'"/>
<div v-for="(item, index) in filteredItems" :key="item.refShortName" class="item" :class="{selected: item.selected, active: activeItemIndex === index}" @click="selectItem(item)" :ref="'listItem' + index">
{{ item.refShortName }}
<div class="ui label" v-if="item.refType === 'branch' && item.refShortName === currentRepoDefaultBranch">
{{ textDefaultBranchLabel }}
</div>
<a v-if="enableFeed && selectedTab === 'branches'" role="button" class="rss-icon" target="_blank" @click.stop :href="item.rssFeedLink">
<!-- creating a lot of Vue component is pretty slow, so we use a static SVG here -->
<svg width="14" height="14" class="svg octicon-rss"><use href="#svg-symbol-octicon-rss"/></svg>
</a>
</div>
<div class="item" v-if="showCreateNewRef" :class="{active: activeItemIndex === filteredItems.length}" :ref="'listItem' + filteredItems.length" @click="createNewRef()">
<div v-if="selectedTab === 'tags'">
<svg-icon name="octicon-tag" class="tw-mr-1"/>
<span v-text="textCreateTag.replace('%s', searchTerm)"/>
</div>
<div v-else>
<svg-icon name="octicon-git-branch" class="tw-mr-1"/>
<span v-text="textCreateBranch.replace('%s', searchTerm)"/>
</div>
<div class="tw-text-xs">
{{ textCreateRefFrom.replace('%s', currentRefShortName) }}
</div>
<form ref="createNewRefForm" method="post" :action="createNewRefFormActionUrl">
<input type="hidden" name="new_branch_name" :value="searchTerm">
<input type="hidden" name="create_tag" :value="String(selectedTab === 'tags')">
<input type="hidden" name="current_path" :value="currentTreePath">
</form>
</div>
</div>
<div class="message" v-if="showNoResults">
{{ textNoResults }}
</div>
<template v-if="showViewAllRefsEntry">
<div class="divider tw-m-0"/>
<a v-if="selectedTab === 'branches'" class="item" :href="currentRepoLink + '/branches'">{{ textViewAllBranches }}</a>
<a v-if="selectedTab === 'tags'" class="item" :href="currentRepoLink + '/tags'">{{ textViewAllTags }}</a>
</template>
</div>
</div>
</template>
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import {
Chart,
Legend,
LinearScale,
TimeScale,
PointElement,
LineElement,
Filler,
type ChartOptions,
type ChartData,
} from 'chart.js';
import {GET} from '../modules/fetch.ts';
import {Line as ChartLine} from 'vue-chartjs';
import {
startDaysBetween,
firstStartDateAfterDate,
fillEmptyStartDaysWithZeroes,
type DayData,
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';
const {pageData} = window.config;
Chart.defaults.color = chartJsColors.text;
Chart.defaults.borderColor = chartJsColors.border;
Chart.register(
TimeScale,
LinearScale,
Legend,
PointElement,
LineElement,
Filler,
);
defineProps<{
locale: {
loadingTitle: string;
loadingTitleFailed: string;
loadingInfo: string;
};
}>();
const isLoading = shallowRef(false);
const errorText = shallowRef('');
const repoLink = pageData.repoLink!;
const data = shallowRef<DayData[]>([]);
onMounted(() => {
fetchGraphData();
});
async function fetchGraphData() {
isLoading.value = true;
try {
let response: Response;
do {
response = await GET(`${repoLink}/activity/code-frequency/data`);
if (response.status === 202) {
await sleep(1000); // wait for 1 second before retrying
}
} while (response.status === 202);
if (response.ok) {
const dayDataObject: DayDataObject = await response.json();
const weekValues = Object.values(dayDataObject);
const start = weekValues[0].week;
const end = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(start, end);
data.value = fillEmptyStartDaysWithZeroes(startDays, dayDataObject);
errorText.value = '';
} else {
errorText.value = response.statusText;
}
} catch (err) {
errorText.value = errorMessage(err);
} finally {
isLoading.value = false;
}
}
function toGraphData(data: Array<Record<string, any>>): ChartData<'line'> {
return {
datasets: [
{
data: data.map((i) => ({x: i.week, y: i.additions})),
pointRadius: 0,
pointHitRadius: 0,
fill: true,
label: 'Additions',
backgroundColor: chartJsColors['additions'],
borderWidth: 0,
tension: 0.3,
},
{
data: data.map((i) => ({x: i.week, y: -i.deletions})),
pointRadius: 0,
pointHitRadius: 0,
fill: true,
label: 'Deletions',
backgroundColor: chartJsColors['deletions'],
borderWidth: 0,
tension: 0.3,
},
],
};
}
const options: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
},
},
scales: {
x: {
type: 'time',
grid: {
display: false,
},
time: {
minUnit: 'month',
},
ticks: {
maxRotation: 0,
maxTicksLimit: 12,
},
},
y: {
ticks: {
maxTicksLimit: 6,
},
},
},
};
</script>
<template>
<div>
<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">
<div v-if="isLoading || errorText !== ''" class="tw-m-auto">
<div v-if="isLoading">
<SvgIcon name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
{{ locale.loadingInfo }}
</div>
<div v-else class="tw-text-red">
<SvgIcon name="octicon-x-circle-fill"/>
{{ errorText }}
</div>
</div>
<ChartLine
v-memo="data" v-if="data.length !== 0"
:data="toGraphData(data)" :options="options"
/>
</div>
</div>
</template>
<style scoped>
.main-graph {
height: 440px;
}
</style>
+471
View File
@@ -0,0 +1,471 @@
<script lang="ts">
import {defineComponent, type PropType} from 'vue';
import {SvgIcon} from '../svg.ts';
import dayjs from 'dayjs';
import {
Chart,
Title,
BarElement,
LinearScale,
TimeScale,
PointElement,
LineElement,
Filler,
type ChartOptions,
type ChartData,
type Plugin,
} from 'chart.js';
import {GET} from '../modules/fetch.ts';
import zoomPlugin from 'chartjs-plugin-zoom';
import {Line as ChartLine} from 'vue-chartjs';
import {
startDaysBetween,
firstStartDateAfterDate,
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';
import {pathEscapeSegments} from '../utils/url.ts';
const customEventListener: Plugin = {
id: 'customEventListener',
afterEvent: (chart, args, opts) => {
// event will be replayed from chart.update when reset zoom,
// so we need to check whether args.replay is true to avoid call loops
if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) {
chart.resetZoom();
opts.instance.updateOtherCharts(args.event, true);
}
},
};
type LineOptions = ChartOptions<'line'> & {
plugins?: {
customEventListener?: {
chartType: string;
instance: unknown;
};
};
}
Chart.defaults.color = chartJsColors.text;
Chart.defaults.borderColor = chartJsColors.border;
Chart.register(
TimeScale,
LinearScale,
BarElement,
Title,
PointElement,
LineElement,
Filler,
zoomPlugin,
customEventListener,
);
type ContributorsData = {
total: {
weeks: Record<string, any>,
},
[other: string]: Record<string, Record<string, any>>,
}
export default defineComponent({
components: {ChartLine, SvgIcon},
props: {
locale: {
type: Object as PropType<Record<string, any>>,
required: true,
},
repoLink: {
type: String,
required: true,
},
repoDefaultBranchName: {
type: String,
required: true,
},
},
data: () => ({
isLoading: false,
errorText: '',
totalStats: {} as Record<string, any>,
sortedContributors: {} as Record<string, any>,
type: 'commits',
contributorsStats: {} as Record<string, any>,
xAxisStart: null as number | null,
xAxisEnd: null as number | null,
xAxisMin: null as number | null,
xAxisMax: null as number | null,
}),
mounted() {
this.fetchGraphData();
fomanticQuery('#repo-contributors').dropdown({
onChange: (val: string) => {
this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd;
this.type = val;
this.sortContributors();
},
});
},
methods: {
sortContributors() {
const contributors: Record<string, any> = this.filterContributorWeeksByDateRange();
const criteria = `total_${this.type}`;
this.sortedContributors = Object.values(contributors)
.filter((contributor) => contributor[criteria] !== 0)
.sort((a, b) => a[criteria] > b[criteria] ? -1 : a[criteria] === b[criteria] ? 0 : 1)
.slice(0, 100);
},
getContributorSearchQuery(contributorEmail: string) {
const min = dayjs(this.xAxisMin).format('YYYY-MM-DD');
const max = dayjs(this.xAxisMax).format('YYYY-MM-DD');
const params = new URLSearchParams({
'q': `after:${min}, before:${max}, author:${contributorEmail}`,
});
return `${this.repoLink}/commits/branch/${pathEscapeSegments(this.repoDefaultBranchName)}/search?${params.toString()}`;
},
async fetchGraphData() {
this.isLoading = true;
try {
let response: Response;
do {
response = await GET(`${this.repoLink}/activity/contributors/data`);
if (response.status === 202) {
await sleep(1000); // wait for 1 second before retrying
}
} while (response.status === 202);
if (response.ok) {
const data = await response.json() as ContributorsData;
const {total, ...other} = data;
// below line might be deleted if we are sure go produces map always sorted by keys
total.weeks = Object.fromEntries(Object.entries(total.weeks).sort());
const weekValues = Object.values(total.weeks);
this.xAxisStart = weekValues[0].week;
this.xAxisEnd = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(this.xAxisStart, this.xAxisEnd);
total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks);
this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd;
this.contributorsStats = {};
for (const [email, user] of Object.entries(other)) {
user.weeks = fillEmptyStartDaysWithZeroes(startDays, user.weeks);
this.contributorsStats[email] = user;
}
this.sortContributors();
this.totalStats = total;
this.errorText = '';
} else {
this.errorText = response.statusText;
}
} catch (err) {
this.errorText = errorMessage(err);
} finally {
this.isLoading = false;
}
},
filterContributorWeeksByDateRange() {
const filteredData: Record<string, any> = {};
const data = this.contributorsStats;
for (const key of Object.keys(data)) {
const user = data[key];
user.total_commits = 0;
user.total_additions = 0;
user.total_deletions = 0;
user.max_contribution_type = 0;
const filteredWeeks = user.weeks.filter((week: Record<string, number>) => {
const oneWeek = 7 * 24 * 60 * 60 * 1000;
if (week.week >= this.xAxisMin! - oneWeek && week.week <= this.xAxisMax! + oneWeek) {
user.total_commits += week.commits;
user.total_additions += week.additions;
user.total_deletions += week.deletions;
if (week[this.type] > user.max_contribution_type) {
user.max_contribution_type = week[this.type];
}
return true;
}
return false;
});
// this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722
// for details.
user.max_contribution_type += 1;
filteredData[key] = {...user, weeks: filteredWeeks, email: key};
}
return filteredData;
},
maxMainGraph() {
// This method calculates maximum value for Y value of the main graph. If the number
// of maximum contributions for selected contribution type is 15.955 it is probably
// better to round it up to 20.000.This method is responsible for doing that.
// Normally, chartjs handles this automatically, but it will resize the graph when you
// zoom, pan etc. I think resizing the graph makes it harder to compare things visually.
const maxValue = Math.max(
...this.totalStats.weeks.map((o: Record<string, any>) => o[this.type]),
);
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
if (coefficient % 1 === 0) return maxValue;
return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
},
maxContributorGraph() {
// Similar to maxMainGraph method this method calculates maximum value for Y value
// for contributors' graph. If I let chartjs do this for me, it will choose different
// maxY value for each contributors' graph which again makes it harder to compare.
const maxValue = Math.max(
...this.sortedContributors.map((c: Record<string, any>) => c.max_contribution_type),
);
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
if (coefficient % 1 === 0) return maxValue;
return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
},
toGraphData(data: Array<Record<string, any>>): ChartData<'line'> {
return {
datasets: [
{
data: data.map((i) => ({x: i.week, y: i[this.type]})),
pointRadius: 0,
pointHitRadius: 0,
fill: 'start',
backgroundColor: chartJsColors[this.type],
borderWidth: 0,
tension: 0.3,
},
],
};
},
updateOtherCharts({chart}: {chart: Chart}, reset: boolean = false) {
const minVal = Number(chart.options.scales?.x?.min);
const maxVal = Number(chart.options.scales?.x?.max);
if (reset) {
this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd;
this.sortContributors();
} else if (minVal) {
this.xAxisMin = minVal;
this.xAxisMax = maxVal;
this.sortContributors();
}
},
getOptions(type: string): LineOptions {
return {
responsive: true,
maintainAspectRatio: false,
animation: false,
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'],
plugins: {
title: {
display: type === 'main',
text: this.locale.chartZoomHint,
position: 'top',
align: 'center',
},
customEventListener: {
chartType: type,
instance: this,
},
zoom: {
pan: {
enabled: true,
modifierKey: 'shift',
mode: 'x',
threshold: 20,
onPanComplete: this.updateOtherCharts,
},
limits: {
x: {
// Check https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/options.html#scale-limits
// to know what each option means
min: 'original',
max: 'original',
// number of milliseconds in 2 weeks. Minimum x range will be 2 weeks when you zoom on the graph
minRange: 2 * 7 * 24 * 60 * 60 * 1000,
},
},
zoom: {
drag: {
enabled: type === 'main',
},
pinch: {
enabled: type === 'main',
},
mode: 'x',
onZoomComplete: this.updateOtherCharts,
},
},
},
scales: {
x: {
min: this.xAxisMin ?? undefined,
max: this.xAxisMax ?? undefined,
type: 'time',
grid: {
display: false,
},
time: {
minUnit: 'month',
},
ticks: {
maxRotation: 0,
maxTicksLimit: type === 'main' ? 12 : 6,
},
},
y: {
min: 0,
max: type === 'main' ? this.maxMainGraph() : this.maxContributorGraph(),
ticks: {
maxTicksLimit: type === 'main' ? 6 : 4,
},
},
},
};
},
},
});
</script>
<template>
<div>
<div class="ui header flex-left-right">
<div>
<relative-time
v-if="xAxisMin && xAxisMin > 0"
format="datetime"
year="numeric"
month="short"
day="numeric"
weekday=""
:datetime="new Date(xAxisMin)"
>
{{ new Date(xAxisMin) }}
</relative-time>
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }}
<relative-time
v-if="xAxisMax && xAxisMax > 0"
format="datetime"
year="numeric"
month="short"
day="numeric"
weekday=""
:datetime="new Date(xAxisMax)"
>
{{ new Date(xAxisMax) }}
</relative-time>
</div>
<div>
<!-- Contribution type -->
<div class="ui floating dropdown jump" id="repo-contributors">
<div class="ui basic compact button">
<span class="not-mobile">{{ locale.filterLabel }}</span> <strong>{{ locale.contributionType[type] }}</strong>
<svg-icon name="octicon-triangle-down" :size="14"/>
</div>
<div class="left menu">
<div :class="['item', {'selected': type === 'commits'}]" data-value="commits">
{{ locale.contributionType.commits }}
</div>
<div :class="['item', {'selected': type === 'additions'}]" data-value="additions">
{{ locale.contributionType.additions }}
</div>
<div :class="['item', {'selected': type === 'deletions'}]" data-value="deletions">
{{ locale.contributionType.deletions }}
</div>
</div>
</div>
</div>
</div>
<div class="tw-flex ui segment main-graph">
<div v-if="isLoading || errorText !== ''" class="tw-m-auto">
<div v-if="isLoading">
<SvgIcon name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
{{ locale.loadingInfo }}
</div>
<div v-else class="tw-text-red">
<SvgIcon name="octicon-x-circle-fill"/>
{{ errorText }}
</div>
</div>
<ChartLine
v-memo="[totalStats.weeks, type]" v-if="Object.keys(totalStats).length !== 0"
:data="toGraphData(totalStats.weeks)" :options="getOptions('main')"
/>
</div>
<div class="contributor-grid">
<div
v-for="(contributor, index) in sortedContributors"
:key="index"
v-memo="[sortedContributors, type]"
>
<div class="ui top attached header tw-flex tw-flex-1">
<b class="ui right">#{{ index + 1 }}</b>
<a :href="contributor.home_link">
<img loading="lazy" class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link" alt="">
</a>
<div class="tw-ml-2">
<a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a>
<h4 v-else class="contributor-name">
{{ contributor.name }}
</h4>
<p class="tw-text-12 tw-flex tw-gap-1">
<strong v-if="contributor.total_commits">
<a class="silenced" :href="getContributorSearchQuery(contributor.email)">
{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}
</a>
</strong>
<strong v-if="contributor.total_additions" class="tw-text-green">{{ contributor.total_additions.toLocaleString() }}++ </strong>
<strong v-if="contributor.total_deletions" class="tw-text-red">
{{ contributor.total_deletions.toLocaleString() }}--</strong>
</p>
</div>
</div>
<div class="ui attached segment">
<div>
<ChartLine
:data="toGraphData(contributor.weeks)"
:options="getOptions('contributor')"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.main-graph {
height: 260px;
padding-top: 2px;
}
.contributor-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.contributor-grid > * {
min-width: 0;
}
@media (max-width: 991.98px) {
.contributor-grid {
grid-template-columns: repeat(1, 1fr);
}
}
.contributor-name {
margin-bottom: 0;
}
</style>
+233
View File
@@ -0,0 +1,233 @@
<script lang="ts" setup>
import {ref, computed, watch, nextTick, useTemplateRef, onMounted, onUnmounted, type ShallowRef} from 'vue';
import {generateElemId} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
import {filterRepoFilesWeighted} from '../features/repo-findfile.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {SvgIcon} from '../svg.ts';
import {throttle} from 'throttle-debounce';
const props = defineProps({
repoLink: { type: String, required: true },
currentRefNameSubURL: { type: String, required: true },
treeListUrl: { type: String, required: true },
noResultsText: { type: String, required: true },
placeholder: { type: String, required: true },
});
const refElemInput = useTemplateRef('searchInput') as Readonly<ShallowRef<HTMLInputElement>>;
const refElemPopup = useTemplateRef('searchPopup') as Readonly<ShallowRef<HTMLDivElement>>;
const searchQuery = ref('');
const allFiles = ref<string[]>([]);
const selectedIndex = ref(0);
const isLoadingFileList = ref(false);
const hasLoadedFileList = ref(false);
const showPopup = computed(() => searchQuery.value.length > 0);
const filteredFiles = computed(() => {
if (!searchQuery.value) return [];
return filterRepoFilesWeighted(allFiles.value, searchQuery.value);
});
const applySearchQuery = throttle(300, () => {
searchQuery.value = refElemInput.value.value;
selectedIndex.value = 0;
});
const handleSearchInput = () => {
loadFileListForSearch();
applySearchQuery();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.isComposing) return;
if (e.key === 'Escape') {
clearSearch();
nextTick(() => refElemInput.value.blur());
return;
}
if (!searchQuery.value || filteredFiles.value.length === 0) return;
const handleSelectedItem = (idx: number) => {
e.preventDefault();
selectedIndex.value = idx;
const el = refElemPopup.value.querySelector(`.file-search-results > :nth-child(${idx+1} of .item)`);
el?.scrollIntoView({ block: 'nearest', behavior: 'instant' });
};
if (e.key === 'ArrowDown') {
handleSelectedItem(Math.min(selectedIndex.value + 1, filteredFiles.value.length - 1));
} else if (e.key === 'ArrowUp') {
handleSelectedItem(Math.max(selectedIndex.value - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault();
const selectedFile = filteredFiles.value[selectedIndex.value];
if (selectedFile) {
handleSearchResultClick(selectedFile.matchResult.join(''));
}
}
};
const clearSearch = () => {
searchQuery.value = '';
refElemInput.value.value = '';
};
const handleClickOutside = (e: MouseEvent) => {
if (!searchQuery.value) return;
const target = e.target as HTMLElement;
const clickInside = refElemInput.value.contains(target) || refElemPopup.value.contains(target);
if (!clickInside) clearSearch();
};
const loadFileListForSearch = async () => {
if (hasLoadedFileList.value || isLoadingFileList.value) return;
isLoadingFileList.value = true;
try {
const response = await GET(props.treeListUrl);
allFiles.value = await response.json();
hasLoadedFileList.value = true;
} finally {
isLoadingFileList.value = false;
}
};
function handleSearchResultClick(filePath: string) {
clearSearch();
window.location.href = `${props.repoLink}/src/${pathEscapeSegments(props.currentRefNameSubURL)}/${pathEscapeSegments(filePath)}`;
}
const updatePosition = () => {
if (!showPopup.value) return;
const rectInput = refElemInput.value.getBoundingClientRect();
const rectPopup = refElemPopup.value.getBoundingClientRect();
const docElem = document.documentElement;
const style = refElemPopup.value.style;
style.top = `${docElem.scrollTop + rectInput.bottom + 4}px`;
if (rectInput.x + rectPopup.width < docElem.clientWidth) {
// enough space to align left with the input
style.left = `${docElem.scrollLeft + rectInput.x}px`;
} else {
// no enough space, align right from the viewport right edge minus page margin
const leftPos = docElem.scrollLeft + docElem.getBoundingClientRect().width - rectPopup.width;
style.left = `calc(${leftPos}px - var(--page-margin-x))`;
}
};
onMounted(() => {
const searchPopupId = generateElemId('file-search-popup-');
refElemPopup.value.setAttribute('id', searchPopupId);
refElemInput.value.setAttribute('aria-controls', searchPopupId);
document.addEventListener('click', handleClickOutside);
window.addEventListener('resize', updatePosition);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
window.removeEventListener('resize', updatePosition);
});
// Position search results below the input
watch([searchQuery, filteredFiles], async () => {
if (searchQuery.value) {
await nextTick();
updatePosition();
}
});
</script>
<template>
<div>
<div class="ui small input global-shortcut-wrapper">
<input
ref="searchInput" :placeholder="placeholder" autocomplete="off"
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
@input="handleSearchInput" @keydown="handleKeyDown"
>
<kbd data-global-init="onGlobalShortcut" data-shortcut-keys="t">T</kbd>
</div>
<Teleport to="body">
<div v-show="showPopup" ref="searchPopup" class="file-search-popup">
<!-- always create the popup by v-show above to avoid null ref, only create the popup content if the popup should be displayed to save memory -->
<template v-if="showPopup">
<div v-if="filteredFiles.length" role="listbox" class="file-search-results flex-items-block">
<div
v-for="(result, idx) in filteredFiles" :key="result.matchResult.join('')"
:class="['item', { 'selected': idx === selectedIndex }]"
role="option" :aria-selected="idx === selectedIndex" @click="handleSearchResultClick(result.matchResult.join(''))"
@mouseenter="selectedIndex = idx" :title="result.matchResult.join('')"
>
<SvgIcon name="octicon-file" class="file-icon"/>
<span class="full-path">
<span v-for="(part, index) in result.matchResult" :key="index">{{ part }}</span>
</span>
</div>
</div>
<div v-else-if="isLoadingFileList">
<div class="is-loading"/>
</div>
<div v-else class="tw-p-4">
{{ props.noResultsText }}
</div>
</template>
</div>
</Teleport>
</div>
</template>
<style scoped>
.file-search-popup {
position: absolute;
background: var(--color-box-body);
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
width: max-content;
max-height: min(calc(100vw - 20px), 300px);
max-width: min(calc(100vw - 40px), 600px);
overflow-y: auto;
}
.file-search-popup .is-loading {
width: 200px;
height: 200px;
}
.file-search-results .item {
align-items: flex-start;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--color-secondary);
}
.file-search-results .item:last-child {
border-bottom: none;
}
.file-search-results .item:hover,
.file-search-results .item.selected {
background-color: var(--color-hover);
}
.file-search-results .item .file-icon {
flex-shrink: 0;
margin-top: 0.125rem;
}
.file-search-results .item .full-path {
flex: 1;
overflow-wrap: anywhere;
}
.file-search-results .item .full-path :nth-child(even) {
color: var(--color-red);
font-weight: var(--font-weight-semibold);
}
</style>
+151
View File
@@ -0,0 +1,151 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import {
Chart,
Tooltip,
BarElement,
LinearScale,
TimeScale,
type ChartOptions,
type ChartData,
type ChartDataset,
} from 'chart.js';
import {GET} from '../modules/fetch.ts';
import {Bar} from 'vue-chartjs';
import {
startDaysBetween,
firstStartDateAfterDate,
fillEmptyStartDaysWithZeroes,
type DayData,
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';
const {pageData} = window.config;
Chart.defaults.color = chartJsColors.text;
Chart.defaults.borderColor = chartJsColors.border;
Chart.register(
TimeScale,
LinearScale,
BarElement,
Tooltip,
);
defineProps<{
locale: {
loadingTitle: string;
loadingTitleFailed: string;
loadingInfo: string;
};
}>();
const isLoading = shallowRef(false);
const errorText = shallowRef('');
const repoLink = pageData.repoLink!;
const data = ref<DayData[]>([]);
onMounted(() => {
fetchGraphData();
});
async function fetchGraphData() {
isLoading.value = true;
try {
let response: Response;
do {
response = await GET(`${repoLink}/activity/recent-commits/data`);
if (response.status === 202) {
await sleep(1000); // wait for 1 second before retrying
}
} while (response.status === 202);
if (response.ok) {
const dayDataObj: DayDataObject = await response.json();
const start = Object.values(dayDataObj)[0].week;
const end = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(start, end);
data.value = fillEmptyStartDaysWithZeroes(startDays, dayDataObj).slice(-52);
errorText.value = '';
} else {
errorText.value = response.statusText;
}
} catch (err) {
errorText.value = errorMessage(err);
} finally {
isLoading.value = false;
}
}
function toGraphData(data: DayData[]): ChartData<'bar'> {
return {
datasets: [
{
data: data.map((i) => ({x: i.week, y: i.commits})),
label: 'Commits',
backgroundColor: chartJsColors['commits'],
borderWidth: 0,
tension: 0.3,
} as unknown as ChartDataset<'bar'>,
],
};
}
const options: ChartOptions<'bar'> = {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
type: 'time',
grid: {
display: false,
},
time: {
minUnit: 'week',
},
ticks: {
maxRotation: 0,
maxTicksLimit: 52,
},
},
y: {
ticks: {
maxTicksLimit: 6,
},
},
},
} satisfies ChartOptions;
</script>
<template>
<div>
<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">
<div v-if="isLoading || errorText !== ''" class="tw-m-auto">
<div v-if="isLoading">
<SvgIcon name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
{{ locale.loadingInfo }}
</div>
<div v-else class="tw-text-red">
<SvgIcon name="octicon-x-circle-fill"/>
{{ errorText }}
</div>
</div>
<Bar
v-memo="data" v-if="data.length !== 0"
:data="toGraphData(data)" :options="options"
/>
</div>
</div>
</template>
<style scoped>
.main-graph {
height: 250px;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts" setup>
import ViewFileTreeItem from './ViewFileTreeItem.vue';
import {onMounted, useTemplateRef, type ShallowRef} from 'vue';
import {createViewFileTreeStore} from './ViewFileTreeStore.ts';
const elRoot = useTemplateRef('elRoot') as Readonly<ShallowRef<HTMLDivElement>>;;
const props = defineProps({
repoLink: {type: String, required: true},
treePath: {type: String, required: true},
currentRefNameSubURL: {type: String, required: true},
});
const store = createViewFileTreeStore(props);
onMounted(async () => {
store.rootFiles = await store.loadChildren('', props.treePath);
elRoot.value.closest('.is-loading')?.classList?.remove('is-loading');
window.addEventListener('popstate', (e) => {
store.selectedItem = e.state?.treePath || '';
if (e.state?.url) store.loadViewContent(e.state.url);
});
});
</script>
<template>
<div class="view-file-tree-items" ref="elRoot">
<ViewFileTreeItem v-for="item in store.rootFiles" :key="item.entryName" :item="item" :store="store"/>
</div>
</template>
<style scoped>
.view-file-tree-items {
display: flex;
flex-direction: column;
gap: 1px;
margin-right: .5rem;
}
</style>
+120
View File
@@ -0,0 +1,120 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import {isPlainClick} from '../utils/dom.ts';
import {shouldTriggerAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {shallowRef} from 'vue';
import type {createViewFileTreeStore, FileTreeItem} from './ViewFileTreeStore.ts';
const props = defineProps<{
item: FileTreeItem,
store: ReturnType<typeof createViewFileTreeStore>
}>();
const store = props.store;
const isLoading = shallowRef(false);
const children = shallowRef(props.item.children);
const collapsed = shallowRef(!props.item.children);
const doLoadChildren = async () => {
collapsed.value = !collapsed.value;
if (!collapsed.value) {
isLoading.value = true;
try {
children.value = await store.loadChildren(props.item.fullPath);
} finally {
isLoading.value = false;
}
}
};
const onItemClick = (e: MouseEvent) => {
// only handle the click event with partial page reloading if both
// - the user didn't press any special key like "Ctrl+Click" (which may have custom browser behavior)
// - the editor/commit form isn't dirty (a full page reload shows a confirmation dialog if the form contains unsaved changes)
if (!isPlainClick(e) || shouldTriggerAreYouSure()) return;
e.preventDefault();
if (props.item.entryMode === 'tree') doLoadChildren();
store.navigateTreeView(props.item.fullPath);
};
</script>
<template>
<a
class="tree-item silenced"
:class="{
'selected': store.selectedItem === item.fullPath,
'type-submodule': item.entryMode === 'commit',
'type-directory': item.entryMode === 'tree',
'type-symlink': item.entryMode === 'symlink',
'type-file': item.entryMode === 'blob' || item.entryMode === 'exec',
}"
:title="item.entryName"
:href="store.buildTreePathWebUrl(item.fullPath)"
@click.stop="onItemClick"
>
<div v-if="item.entryMode === 'tree'" class="item-toggle">
<SvgIcon v-if="isLoading" name="gitea-running" class="rotate-clockwise"/>
<SvgIcon v-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop.prevent="doLoadChildren"/>
</div>
<div class="item-content">
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="tw-contents" v-html="(!collapsed && item.entryIconOpen) ? item.entryIconOpen : item.entryIcon"/>
<span class="gt-ellipsis">{{ item.entryName }}</span>
</div>
</a>
<div v-if="children?.length" v-show="!collapsed" class="sub-items">
<ViewFileTreeItem v-for="childItem in children" :key="childItem.entryName" :item="childItem" :store="store"/>
</div>
</template>
<style scoped>
.sub-items {
display: flex;
flex-direction: column;
gap: 1px;
margin-left: 14px;
border-left: 1px solid var(--color-secondary);
}
.tree-item.selected {
color: var(--color-text);
background: var(--color-active);
border-radius: 4px;
}
.tree-item.type-directory {
user-select: none;
}
.tree-item {
display: grid;
grid-template-columns: 16px 1fr;
grid-template-areas: "toggle content";
gap: 0.25em;
padding: 6px;
}
.tree-item:hover {
color: var(--color-text);
background: var(--color-hover);
border-radius: 4px;
cursor: pointer;
}
.item-toggle {
grid-area: toggle;
display: flex;
align-items: center;
}
.item-content {
grid-area: content;
display: flex;
align-items: center;
gap: 0.5em;
text-overflow: ellipsis;
min-width: 0;
}
</style>
@@ -0,0 +1,65 @@
import {reactive} from 'vue';
import {GET} from '../modules/fetch.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
export type FileTreeItem = {
entryName: string;
entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown';
entryIcon: string;
entryIconOpen: string;
fullPath: string;
submoduleUrl?: string;
children?: Array<FileTreeItem>;
};
export function createViewFileTreeStore(props: {repoLink: string, treePath: string, currentRefNameSubURL: string}) {
const store = reactive({
rootFiles: [] as Array<FileTreeItem>,
selectedItem: props.treePath,
async loadChildren(treePath: string, subPath: string = '') {
// there is no git ref if no commits were made yet (an empty repo)
if (!props.currentRefNameSubURL) return null;
const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`);
const json = await response.json();
const poolSvgs = [];
for (const [svgId, svgContent] of Object.entries(json.renderedIconPool ?? {})) {
if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent);
}
if (poolSvgs.length) {
const svgContainer = createElementFromHTML(html`<div class="global-svg-icon-pool svg-icon-container"></div>`);
svgContainer.innerHTML = poolSvgs.join('');
document.body.append(svgContainer);
}
return json.fileTreeNodes ?? null;
},
async loadViewContent(url: string) {
const u = new URL(url, window.origin);
u.searchParams.set('only_content', 'true');
const response = await GET(u.href);
const elViewContent = document.querySelector('.repo-view-content')!;
elViewContent.innerHTML = await response.text();
const elViewContentData = elViewContent.querySelector('.repo-view-content-data');
if (!elViewContentData) return; // if error occurs, there is no such element
const t1 = elViewContentData.getAttribute('data-document-title');
const t2 = elViewContentData.getAttribute('data-document-title-common');
document.title = `${t1} - ${t2}`; // follow the format in head.tmpl: <head><title>...</title></head>
},
async navigateTreeView(treePath: string) {
const url = store.buildTreePathWebUrl(treePath);
window.history.pushState({treePath, url}, '', url);
store.selectedItem = treePath;
await store.loadViewContent(url);
},
buildTreePathWebUrl(treePath: string) {
return `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`;
},
});
return store;
}
+822
View File
@@ -0,0 +1,822 @@
<script setup lang="ts">
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import ActionStatusIcon from './ActionStatusIcon.vue';
import {localUserSettings} from '../modules/user-settings.ts';
import {isPlainClick} from '../utils/dom.ts';
import {trN} from '../modules/i18n.ts';
import {debounce} from 'throttle-debounce';
import type {ActionsJob, ActionsStatus} from '../modules/gitea-actions.ts';
import type {ActionRunViewStore} from './ActionRunView.ts';
interface JobNode {
id: number;
name: string;
status: ActionsStatus;
duration: string;
x: number;
y: number;
level: number;
}
interface Edge {
fromId: number;
toId: number;
key: string;
}
interface RoutedEdge extends Edge {
path: string;
fromNode: JobNode;
toNode: JobNode;
}
interface StoredState {
scale: number;
translateX: number;
translateY: number;
timestamp: number;
}
const props = defineProps<{
store: ActionRunViewStore;
jobs: ActionsJob[];
runLink: string;
workflowId: string;
locale: Record<string, string>;
}>()
const settingKeyStates = 'actions-graph-states';
const maxStoredStates = 10;
const scale = ref(1);
const translateX = ref(0);
const translateY = ref(0);
const isDragging = ref(false);
const lastMousePos = ref({x: 0, y: 0});
const graphContainer = ref<HTMLElement | null>(null);
const hoveredJobId = ref<number | null>(null);
const stateKey = () => `${props.store.viewData.currentRun.repoId}-${props.workflowId}`;
const loadSavedState = () => {
const allStates = localUserSettings.getJsonObject<Record<string, StoredState>>(settingKeyStates, {});
const saved = allStates[stateKey()];
if (!saved) return;
scale.value = clampScale(saved.scale ?? scale.value);
translateX.value = saved.translateX ?? translateX.value;
translateY.value = saved.translateY ?? translateY.value;
};
const saveState = () => {
const allStates = localUserSettings.getJsonObject<Record<string, StoredState>>(settingKeyStates, {});
allStates[stateKey()] = {
scale: scale.value,
translateX: translateX.value,
translateY: translateY.value,
timestamp: Date.now(),
};
const sortedStates = Object.entries(allStates)
.sort(([, a], [, b]) => b.timestamp - a.timestamp)
.slice(0, maxStoredStates);
localUserSettings.setJsonObject(settingKeyStates, Object.fromEntries(sortedStates));
};
const minNodeWidth = 168;
const maxNodeWidth = 232;
const nodeWidth = computed(() => {
const maxNameLength = Math.max(...props.jobs.map(j => j.name.length), 0);
return Math.min(Math.max(minNodeWidth, maxNameLength * 8), maxNodeWidth);
});
const horizontalSpacing = computed(() => nodeWidth.value + 84);
const graphWidth = computed(() => {
if (jobsWithLayout.value.length === 0) return 800;
const maxX = Math.max(...jobsWithLayout.value.map(j => j.x + nodeWidth.value));
return maxX + margin * 2;
});
const graphHeight = computed(() => {
if (jobsWithLayout.value.length === 0) return 400;
const maxY = Math.max(...jobsWithLayout.value.map(j => j.y + nodeHeight));
return maxY + margin * 2;
});
const jobsWithLayout = computed<JobNode[]>(() => {
try {
const levels = computeJobLevels(props.jobs);
const currentHorizontalSpacing = horizontalSpacing.value;
const jobsByLevel: ActionsJob[][] = [];
let maxJobsPerLevel = 0;
props.jobs.forEach(job => {
// `?? 0`, not `|| 0`: a root job's level is 0, which `||` would wrongly discard.
const level = levels.get(scopedKey(job)) ?? 0;
if (!jobsByLevel[level]) {
jobsByLevel[level] = [];
}
jobsByLevel[level].push(job);
if (jobsByLevel[level].length > maxJobsPerLevel) {
maxJobsPerLevel = jobsByLevel[level].length;
}
});
const result: JobNode[] = [];
jobsByLevel.forEach((levelJobs, levelIndex) => {
if (!levelJobs || levelJobs.length === 0) {
return;
}
const startY = margin;
levelJobs.forEach((job, jobIndex) => {
result.push({
id: job.id,
name: job.name,
status: job.status,
duration: job.duration,
x: margin + levelIndex * currentHorizontalSpacing,
y: startY + jobIndex * verticalSpacing,
level: levelIndex,
});
});
});
return result;
} catch (error) {
return props.jobs.map((job, index) => ({
id: job.id,
name: job.name,
status: job.status,
duration: job.duration,
x: margin + index * horizontalSpacing.value,
y: margin,
level: 0,
}));
}
});
// scopedKey identifies a job within its reusable-workflow call scope so that the same
// JobID in different reusable calls does not collide.
function scopedKey(job: {parentJobID: number; jobId: string}): string {
return `${job.parentJobID || 0}:${job.jobId}`;
}
function buildDirectNeedsMap(jobs: ActionsJob[]): Map<string, string[]> {
// The map keys/values are scoped keys, not bare jobIds, so we keep edge construction
// accurate when reusable workflows reuse common job names like "build" / "test".
const directNeedsByScopedKey = new Map<string, string[]>();
const dependentsByScopedKey = new Map<string, Set<string>>();
for (const job of jobs) {
const fromKey = scopedKey(job);
const needKeys = (job.needs || []).map((n) => `${job.parentJobID || 0}:${n}`);
directNeedsByScopedKey.set(fromKey, needKeys);
for (const needKey of needKeys) {
if (!dependentsByScopedKey.has(needKey)) {
dependentsByScopedKey.set(needKey, new Set());
}
dependentsByScopedKey.get(needKey)!.add(fromKey);
}
}
const reachabilityCache = new Map<string, boolean>();
function canReach(fromKey: string, toKey: string): boolean {
const cacheKey = `${fromKey}->${toKey}`;
if (reachabilityCache.has(cacheKey)) {
return reachabilityCache.get(cacheKey)!;
}
const visited = new Set<string>();
const stack = [...(dependentsByScopedKey.get(fromKey) || [])];
while (stack.length > 0) {
const current = stack.pop()!;
if (current === toKey) {
reachabilityCache.set(cacheKey, true);
return true;
}
if (visited.has(current)) continue;
visited.add(current);
stack.push(...(dependentsByScopedKey.get(current) || []));
}
reachabilityCache.set(cacheKey, false);
return false;
}
const reducedNeedsByScopedKey = new Map<string, string[]>();
for (const [fromKey, needs] of directNeedsByScopedKey.entries()) {
reducedNeedsByScopedKey.set(fromKey, needs.filter((need) => {
return !needs.some((otherNeed) => otherNeed !== need && canReach(need, otherNeed));
}));
}
return reducedNeedsByScopedKey;
}
const directNeedsByScopedKey = computed(() => buildDirectNeedsMap(props.jobs));
const edges = computed<Edge[]>(() => {
const edgesList: Edge[] = [];
// Store every job per scoped key, not just one: matrix-expanded jobs share same jobId
const jobsByScopedKey = new Map<string, ActionsJob[]>();
for (const job of props.jobs) {
const key = scopedKey(job);
const existing = jobsByScopedKey.get(key);
if (existing) {
existing.push(job);
} else {
jobsByScopedKey.set(key, [job]);
}
}
for (const job of props.jobs) {
for (const needKey of directNeedsByScopedKey.value.get(scopedKey(job)) || []) {
for (const upstreamJob of jobsByScopedKey.get(needKey) || []) {
edgesList.push({
fromId: upstreamJob.id,
toId: job.id,
key: `${upstreamJob.id}-${job.id}`,
});
}
}
}
return edgesList;
});
function buildRoundedConnectorPath(startX: number, startY: number, endX: number, endY: number, turnX: number): string {
const deltaY = endY - startY;
if (Math.abs(deltaY) < 1) {
return `M ${startX} ${startY} H ${endX}`;
}
const direction = deltaY > 0 ? 1 : -1;
const elbowSize = Math.max(8, Math.min(24, Math.abs(deltaY) / 2, Math.abs(endX - startX) / 2));
const controlOffset = elbowSize / 2;
const clampedTurnX = Math.min(Math.max(turnX, startX + elbowSize), endX - elbowSize);
return [
`M ${startX} ${startY}`,
`H ${clampedTurnX - elbowSize}`,
`C ${clampedTurnX - controlOffset} ${startY} ${clampedTurnX} ${startY + direction * controlOffset} ${clampedTurnX} ${startY + direction * elbowSize}`,
`V ${endY - direction * elbowSize}`,
`C ${clampedTurnX} ${endY - direction * controlOffset} ${clampedTurnX + controlOffset} ${endY} ${clampedTurnX + elbowSize} ${endY}`,
`H ${endX}`,
].join(' ');
}
const routedEdges = computed<RoutedEdge[]>(() => {
const nodesById = new Map(jobsWithLayout.value.map((job) => [job.id, job]));
const outgoingEdges = new Map<number, Edge[]>();
const incomingEdges = new Map<number, Edge[]>();
for (const edge of edges.value) {
if (!outgoingEdges.has(edge.fromId)) {
outgoingEdges.set(edge.fromId, []);
}
outgoingEdges.get(edge.fromId)!.push(edge);
if (!incomingEdges.has(edge.toId)) {
incomingEdges.set(edge.toId, []);
}
incomingEdges.get(edge.toId)!.push(edge);
}
for (const sourceEdges of outgoingEdges.values()) {
sourceEdges.sort((a, b) => {
const targetA = nodesById.get(a.toId);
const targetB = nodesById.get(b.toId);
if (!targetA || !targetB) return 0;
return targetA.y - targetB.y || a.toId - b.toId;
});
}
const edgePaths: RoutedEdge[] = [];
for (const edge of edges.value) {
const fromNode = nodesById.get(edge.fromId);
const toNode = nodesById.get(edge.toId);
if (!fromNode || !toNode) continue;
const startX = fromNode.x + nodeWidth.value;
const startY = fromNode.y + nodeHeight / 2;
const endX = toNode.x;
const endY = toNode.y + nodeHeight / 2;
const sourceEdges = outgoingEdges.get(edge.fromId) || [];
const targetEdges = incomingEdges.get(edge.toId) || [];
const horizontalGap = endX - startX;
const turnOffset = Math.min(28, Math.max(16, horizontalGap * 0.14));
const sourceTurnX = startX + turnOffset;
const targetTurnX = endX - turnOffset;
let turnX = startX + horizontalGap / 2;
if (sourceEdges.length > 1) {
turnX = sourceTurnX;
} else if (targetEdges.length > 1) {
turnX = targetTurnX;
}
const path = buildRoundedConnectorPath(startX, startY, endX, endY, turnX);
edgePaths.push({
...edge,
path,
fromNode,
toNode,
});
}
return edgePaths;
});
const graphMetrics = computed(() => {
const successCount = jobsWithLayout.value.filter(job => job.status === 'success').length;
const levels = new Map<number, number>();
jobsWithLayout.value.forEach(job => {
const count = levels.get(job.level) || 0;
levels.set(job.level, count + 1);
})
const parallelism = Math.max(...Array.from(levels.values()), 0);
return {
successRate: `${((successCount / jobsWithLayout.value.length) * 100).toFixed(0)}%`,
parallelism,
};
})
const graphStats = computed(() => [
trN(props.jobs.length, props.locale.graphJobsCount1, props.locale.graphJobsCountN),
trN(edges.value.length, props.locale.graphDependenciesCount1, props.locale.graphDependenciesCountN),
props.locale.graphSuccessRate.replace('%s', graphMetrics.value.successRate),
].join(' • '))
const nodeHeight = 52;
const verticalSpacing = 90;
const margin = 40;
const minScale = 0.3;
const maxScale = 1;
function clampScale(nextScale: number): number {
return Math.min(Math.max(Math.round(nextScale * 100) / 100, minScale), maxScale);
}
const canZoomIn = computed(() => scale.value < maxScale);
function zoomTo(nextScale: number) {
scale.value = clampScale(nextScale);
}
function zoomIn() {
zoomTo(scale.value * 1.2);
}
function zoomOut() {
zoomTo(scale.value / 1.2);
}
function resetView() {
scale.value = 1;
translateX.value = 0;
translateY.value = 0;
}
function handleMouseDown(e: MouseEvent) {
if (!isPlainClick(e)) return;
// don't start drag on interactive/text elements inside the SVG
const target = e.target as Element;
const interactive = target.closest('div, p, a, span, button, input, text, .job-node-group');
if (interactive?.closest('svg')) return;
e.preventDefault();
isDragging.value = true;
lastMousePos.value = {x: e.clientX, y: e.clientY};
graphContainer.value!.style.cursor = 'grabbing';
}
function handleMouseMoveOnDocument(event: MouseEvent) {
if (!isDragging.value) return;
const dx = event.clientX - lastMousePos.value.x;
const dy = event.clientY - lastMousePos.value.y;
translateX.value += dx;
translateY.value += dy;
lastMousePos.value = {x: event.clientX, y: event.clientY};
}
function handleMouseUpOnDocument() {
if (!isDragging.value) return;
isDragging.value = false;
graphContainer.value!.style.cursor = 'grab';
}
function handleWheel(event: WheelEvent) {
// Without a modifier, let the wheel scroll the page
if (!event.ctrlKey && !event.metaKey) {
return;
}
event.preventDefault();
const zoomFactor = Math.exp(-event.deltaY * 0.0015);
zoomTo(scale.value * zoomFactor);
}
onMounted(() => {
loadSavedState();
watch([translateX, translateY, scale], debounce(500, saveState));
watch([scale], debounce(100, saveState));
document.addEventListener('mousemove', handleMouseMoveOnDocument);
document.addEventListener('mouseup', handleMouseUpOnDocument);
});
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMoveOnDocument);
document.removeEventListener('mouseup', handleMouseUpOnDocument);
});
function handleNodeMouseEnter(job: JobNode) {
hoveredJobId.value = job.id;
}
function handleNodeMouseLeave() {
hoveredJobId.value = null;
}
function isEdgeHighlighted(edge: RoutedEdge): boolean {
if (!hoveredJobId.value) {
return false;
}
return edge.fromId === hoveredJobId.value || edge.toId === hoveredJobId.value;
}
const nodesWithIncomingEdge = computed(() => {
const set = new Set<number>();
for (const edge of routedEdges.value) set.add(edge.toId);
return set;
});
const nodesWithOutgoingEdge = computed(() => {
const set = new Set<number>();
for (const edge of routedEdges.value) set.add(edge.fromId);
return set;
});
function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
// Scope-aware: each job is keyed by `${parentJobID}:${jobId}` so the same JobID
// in different reusable workflow calls does not cross-link in the level graph.
const jobMap = new Map<string, ActionsJob>();
jobs.forEach(job => {
jobMap.set(scopedKey(job), job);
});
const levels = new Map<string, number>();
const visited = new Set<string>();
const recursionStack = new Set<string>();
const MAX_DEPTH = 100;
function dfs(scoped: string, depth: number = 0): number {
if (depth > MAX_DEPTH) {
console.error(`Max recursion depth (${MAX_DEPTH}) reached for: ${scoped}`);
return 0;
}
if (recursionStack.has(scoped)) {
console.error(`Cycle detected involving: ${scoped}`);
return 0;
}
if (visited.has(scoped)) {
return levels.get(scoped) || 0;
}
recursionStack.add(scoped);
visited.add(scoped);
const job = jobMap.get(scoped);
if (!job) {
recursionStack.delete(scoped);
return 0;
}
if (!job.needs?.length) {
levels.set(scoped, 0);
recursionStack.delete(scoped);
return 0;
}
let maxLevel = -1;
for (const need of job.needs) {
const needScoped = `${job.parentJobID || 0}:${need}`;
const needJob = jobMap.get(needScoped);
if (!needJob) continue;
const needLevel = dfs(needScoped, depth + 1);
maxLevel = Math.max(maxLevel, needLevel);
}
const level = maxLevel + 1;
levels.set(scoped, level);
recursionStack.delete(scoped);
return level;
}
jobs.forEach(job => {
const sk = scopedKey(job);
if (!visited.has(sk)) {
dfs(sk);
}
});
return levels;
}
function onNodeClick(job: JobNode, event: MouseEvent) {
const link = `${props.runLink}/jobs/${job.id}`;
if (event.ctrlKey || event.metaKey) {
window.open(link, '_blank');
return;
}
window.location.href = link;
}
</script>
<template>
<div class="workflow-graph" v-if="jobs.length > 0">
<div class="graph-header">
<h4 class="graph-title">{{ locale.workflowDependencies }}</h4>
<div class="graph-stats">{{ graphStats }}</div>
<div class="flex-text-block">
<button
type="button"
@click="zoomIn"
class="ui compact tiny icon button"
:disabled="!canZoomIn"
:title="canZoomIn ? locale.graphZoomIn : locale.graphZoomMax"
>
<SvgIcon name="octicon-zoom-in" :size="12"/>
</button>
<button type="button" @click="resetView" class="ui compact tiny icon button" :title="locale.graphResetView">
<SvgIcon name="octicon-sync" :size="12"/>
</button>
<button type="button" @click="zoomOut" class="ui compact tiny icon button" :title="locale.graphZoomOut">
<SvgIcon name="octicon-zoom-out" :size="12"/>
</button>
</div>
</div>
<div
class="graph-container"
ref="graphContainer"
@mousedown="handleMouseDown"
@wheel="handleWheel"
:class="{dragging: isDragging}"
>
<svg
:width="graphWidth"
:height="graphHeight"
class="graph-svg"
:style="{
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
transformOrigin: '0 0',
}"
>
<path
v-for="edge in routedEdges"
:key="edge.key"
:d="edge.path"
fill="none"
stroke="var(--color-secondary-alpha-50)"
stroke-width="1.5"
:class="['node-edge', { 'highlighted-edge': isEdgeHighlighted(edge) }]"
/>
<g
v-for="job in jobsWithLayout"
:key="job.id"
class="job-node-group"
@click="onNodeClick(job, $event)"
@mouseenter="handleNodeMouseEnter(job)"
@mouseleave="handleNodeMouseLeave"
>
<title>{{ job.name }}</title>
<rect
:x="job.x"
:y="job.y"
:width="nodeWidth"
:height="nodeHeight"
rx="8"
fill="var(--color-box-body)"
stroke="var(--color-secondary)"
stroke-width="1"
class="job-rect"
/>
<circle
v-if="nodesWithIncomingEdge.has(job.id)"
:cx="job.x"
:cy="job.y + nodeHeight / 2"
r="4.5"
class="node-port"
/>
<circle
v-if="nodesWithOutgoingEdge.has(job.id)"
:cx="job.x + nodeWidth"
:cy="job.y + nodeHeight / 2"
r="4.5"
class="node-port"
/>
<foreignObject
:x="job.x + 10"
:y="job.y + 16"
width="20"
height="20"
class="job-status-fg-obj"
>
<div class="job-status-icon-wrap">
<ActionStatusIcon :status="job.status" icon-variant="circle-fill"/>
</div>
</foreignObject>
<foreignObject
:x="job.x + 38"
:y="job.y + 2"
:width="nodeWidth - 44"
:height="nodeHeight - 4"
>
<div class="job-text-wrap">
<span class="job-name">{{ job.name }}</span>
<span
v-if="job.duration || job.status === 'success' || job.status === 'failure'"
class="job-duration"
>{{ job.duration }}</span>
</div>
</foreignObject>
</g>
</svg>
</div>
</div>
</template>
<style scoped>
.workflow-graph {
flex: 1;
display: flex;
flex-direction: column;
}
.graph-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 14px;
background: var(--color-box-header);
border-bottom: 1px solid var(--color-secondary);
gap: var(--gap-block);
flex-wrap: wrap;
}
.graph-title {
margin: 0;
color: var(--color-text);
font-size: 16px;
font-weight: var(--font-weight-semibold);
flex: 1;
min-width: 200px;
}
.graph-stats {
display: flex;
align-items: baseline;
column-gap: 8px;
color: var(--color-text-light-1);
font-size: 13px;
white-space: nowrap;
}
.graph-container {
flex: 1;
overflow: hidden;
padding: 10px 14px 18px;
border-radius: 0 0 var(--border-radius) var(--border-radius);
cursor: grab;
position: relative;
background: var(--color-box-body);
}
.graph-container.dragging {
cursor: grabbing;
}
.graph-svg {
display: block;
will-change: transform;
}
.graph-svg path {
transition: all 0.2s ease;
stroke-linecap: round;
stroke-linejoin: round;
}
.highlighted-edge {
stroke-width: 2 !important;
stroke: var(--color-workflow-edge-hover) !important;
}
.job-node-group {
cursor: pointer;
transition: all 0.2s ease;
}
.job-node-group:hover .job-rect {
/* due to SVG rendering limitation, only one of fill and drop-shadow can work */
fill: var(--color-hover);
/* filter: drop-shadow(0 1px 3px var(--color-shadow-opaque)); */
}
.job-text-wrap {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 1px;
padding: 4px 8px 4px 0;
overflow: hidden;
}
.job-name {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: var(--font-weight-semibold);
color: var(--color-text);
user-select: none;
pointer-events: none;
}
.job-duration {
font-size: 10px;
line-height: 1.2;
color: var(--color-text-light-2);
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
pointer-events: none;
}
.job-status-fg-obj,
.job-status-icon-wrap {
pointer-events: none;
}
.job-status-icon-wrap {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.node-port {
fill: var(--color-box-body);
stroke: var(--color-light-border);
stroke-width: 1.25;
opacity: 0.85;
pointer-events: none;
}
.node-edge {
transition: stroke-width 0.2s ease, opacity 0.2s ease;
opacity: 0.75;
}
</style>
+145
View File
@@ -0,0 +1,145 @@
class Source {
url: string;
eventSource: EventSource | null;
listening: Record<string, boolean>;
clients: Array<MessagePort>;
constructor(url: string) {
this.url = url;
this.eventSource = new EventSource(url);
this.listening = {};
this.clients = [];
this.listen('open');
this.listen('close');
this.listen('logout');
this.listen('notification-count');
this.listen('stopwatches');
this.listen('error');
}
register(port: MessagePort) {
if (this.clients.includes(port)) return;
this.clients.push(port);
port.postMessage({
type: 'status',
message: `registered to ${this.url}`,
});
}
deregister(port: MessagePort) {
const portIdx = this.clients.indexOf(port);
if (portIdx < 0) {
return this.clients.length;
}
this.clients.splice(portIdx, 1);
return this.clients.length;
}
close() {
if (!this.eventSource) return;
this.eventSource.close();
this.eventSource = null;
}
listen(eventType: string) {
if (this.listening[eventType]) return;
this.listening[eventType] = true;
this.eventSource?.addEventListener(eventType, (event) => {
this.notifyClients({
type: eventType,
data: event.data,
});
});
}
notifyClients(event: {type: string, data: any}) {
for (const client of this.clients) {
client.postMessage(event);
}
}
status(port: MessagePort) {
port.postMessage({
type: 'status',
message: `url: ${this.url} readyState: ${this.eventSource?.readyState}`,
});
}
}
const sourcesByUrl = new Map<string, Source | null>();
const sourcesByPort = new Map<MessagePort, Source | null>();
(self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => {
for (const port of e.ports) {
port.addEventListener('message', (event: MessageEvent) => {
if (!self.EventSource) {
// some browsers (like PaleMoon, Firefox<53) don't support EventSource in SharedWorkerGlobalScope.
// this event handler needs EventSource when doing "new Source(url)", so just post a message back to the caller,
// in case the caller would like to use a fallback method to do its work.
port.postMessage({type: 'no-event-source'});
return;
}
if (event.data.type === 'start') {
const url = event.data.url;
let source = sourcesByUrl.get(url);
if (source) {
// we have a Source registered to this url
source.register(port);
sourcesByPort.set(port, source);
return;
}
source = sourcesByPort.get(port);
if (source) {
if (source.eventSource && source.url === url) return;
// How this has happened I don't understand...
// deregister from that source
const count = source.deregister(port);
// Clean-up
if (count === 0) {
source.close();
sourcesByUrl.set(source.url, null);
}
}
// Create a new Source
source = new Source(url);
source.register(port);
sourcesByUrl.set(url, source);
sourcesByPort.set(port, source);
} else if (event.data.type === 'listen') {
const source = sourcesByPort.get(port)!;
source.listen(event.data.eventType);
} else if (event.data.type === 'close') {
const source = sourcesByPort.get(port);
if (!source) return;
const count = source.deregister(port);
if (count === 0) {
source.close();
sourcesByUrl.set(source.url, null);
sourcesByPort.set(port, null);
}
} else if (event.data.type === 'status') {
const source = sourcesByPort.get(port);
if (!source) {
port.postMessage({
type: 'status',
message: 'not connected',
});
return;
}
source.status(port);
} else {
// just send it back
port.postMessage({
type: 'error',
message: `received but don't know how to handle: ${event.data}`,
});
}
});
port.start();
}
});
+67
View File
@@ -0,0 +1,67 @@
import type {FrontendRenderFunc, FrontendRenderOptions} from './render/plugin.ts';
type LazyLoadFunc = () => Promise<{frontendRender: FrontendRenderFunc}>;
// It must use a wrapper function to avoid the "import" statement being treated
// as static import and cause the all plugins being loaded together,
// We only need to load the plugins we need.
const frontendPlugins: Record<string, LazyLoadFunc> = {
'viewer-3d': () => import('./render/plugins/frontend-viewer-3d.ts'),
'openapi-swagger': () => import('./render/plugins/frontend-openapi-swagger.ts'),
};
class Options implements FrontendRenderOptions {
container: HTMLElement;
treePath: string;
rawEncoding: string;
rawString: string;
cachedBytes: Uint8Array<ArrayBuffer> | null = null;
cachedString: string | null = null;
constructor(container: HTMLElement, treePath: string, rawEncoding: string, rawString: string) {
this.container = container;
this.treePath = treePath;
this.rawEncoding = rawEncoding;
this.rawString = rawString;
}
decodeBase64(): Uint8Array<ArrayBuffer> {
return Uint8Array.from(atob(this.rawString), (c) => c.charCodeAt(0));
}
contentBytes(): Uint8Array<ArrayBuffer> {
if (this.cachedBytes === null) {
this.cachedBytes = this.rawEncoding === 'base64' ? this.decodeBase64() : new TextEncoder().encode(this.rawString);
}
return this.cachedBytes;
}
contentString(): string {
if (this.cachedString === null) {
this.cachedString = this.rawEncoding === 'base64' ? new TextDecoder('utf-8').decode(this.decodeBase64()) : this.rawString;
}
return this.cachedString;
}
}
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')!;
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;
for (const name of renderNames) {
if (!(name in frontendPlugins)) continue;
const plugin = await frontendPlugins[name]();
found = true;
if (await plugin.frontendRender(opts)) break;
}
if (!found) {
viewerContainer.textContent = 'No frontend render plugin found for this file, but backend declares that there must be one, there must be a bug';
}
}
initFrontendExternalRender();
+25
View File
@@ -0,0 +1,25 @@
import './external-render-helper.ts';
test('isValidCssColor', async () => {
const isValidCssColor = window.testModules.externalRenderHelper!.isValidCssColor;
expect(isValidCssColor(null)).toBe(false);
expect(isValidCssColor('')).toBe(false);
expect(isValidCssColor('#123')).toBe(true);
expect(isValidCssColor('#1234')).toBe(true);
expect(isValidCssColor('#abcabc')).toBe(true);
expect(isValidCssColor('#abcabc12')).toBe(true);
expect(isValidCssColor('rgb(255 255 255)')).toBe(true);
expect(isValidCssColor('rgb(0, 255, 255)')).toBe(true);
// examples from MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/rgb
expect(isValidCssColor('rgb(255 255 255 / 50%)')).toBe(true);
expect(isValidCssColor('rgb(from #123456 hwb(120deg 10% 20%) calc(g + 40) b / 0.5)')).toBe(true);
expect(isValidCssColor('#123 ; other')).toBe(false);
expect(isValidCssColor('#123 : other')).toBe(false);
expect(isValidCssColor('#rgb(0, 255, 255); other')).toBe(false);
expect(isValidCssColor('#rgb(0, 255, 255)} other')).toBe(false);
expect(isValidCssColor('url(other)')).toBe(false);
});
+95
View File
@@ -0,0 +1,95 @@
// External render JS must be a IIFE module to run as early as possible to set up the environment for the content page.
// Avoid unnecessary dependency.
// Do NOT introduce global pollution, because the content page should be fully controlled by the external render.
/* To manually test:
[markup.in-iframe]
ENABLED = true
FILE_EXTENSIONS = .in-iframe
RENDER_CONTENT_MODE = iframe
RENDER_COMMAND = `echo '<div style="width: 100%; height: 2000px; border: 10px solid red; box-sizing: border-box;"><a href="/">a link</a> <a target="_blank" href="//gitea.com">external link</a></div>'`
;RENDER_COMMAND = cat /path/to/file.pdf
;RENDER_CONTENT_SANDBOX = disabled
*/
// Check whether the user-provided color value is a valid CSS color format to avoid CSS injection.
// Don't extract this function to a common module, because this file is an IIFE module for external render
// and should not have any dependency to avoid potential conflicts.
function isValidCssColor(s: string | null): boolean {
if (!s) return false;
// it should only be in format "#hex" or "rgb(...)", because it comes from a computed style's color value
const reHex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const reRgb = /^rgb\([^{}'";:]+\)$/;
return reHex.test(s) || reRgb.test(s);
}
const thisScriptElem = document.querySelector('script#gitea-external-render-helper');
const queryString = thisScriptElem?.getAttribute('data-render-query-string') ?? window.location.search.substring(1);
const queryParams = new URLSearchParams(queryString);
const isDarkTheme = queryParams.get('gitea-is-dark-theme') === 'true';
if (isDarkTheme) {
document.documentElement.setAttribute('data-gitea-theme-dark', String(isDarkTheme));
}
const backgroundColor = queryParams.get('gitea-iframe-bgcolor');
if (isValidCssColor(backgroundColor)) {
// create a style element to set background color, then it can be overridden by the content page's own style if needed
const style = document.createElement('style');
style.textContent = `
:root {
--gitea-iframe-bgcolor: ${backgroundColor};
}
html, body { margin: 0; padding: 0 }
body { background: ${backgroundColor}; }
`;
document.head.append(style);
}
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}, '*');
};
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%
// (which would make getBoundingClientRect return the viewport height instead of content height).
const height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
postIframeMsg('resize', {iframeHeight: height});
// As long as the parent page is responsible for the iframe height, the iframe itself doesn't need scrollbars.
// This style should only be dynamically set here when our code can run.
document.documentElement.style.overflowY = 'hidden';
};
const resizeObserver = new ResizeObserver(() => updateIframeHeight());
resizeObserver.observe(window.document.documentElement);
updateIframeHeight();
window.addEventListener('DOMContentLoaded', updateIframeHeight);
// the easiest way to handle dynamic content changes and easy to debug, can be fine-tuned in the future
setInterval(updateIframeHeight, 1000);
// no way to open an absolute link with CSP frame-src, it needs some tricks like "postMessage" (let parent window to handle) or "copy the link to clipboard" (let users manually paste it to open).
// here we choose "postMessage" way for better user experience.
const openIframeLink = (link: string, target: string | null) => postIframeMsg('open-link', {openLink: link, anchorTarget: target});
document.addEventListener('click', (e) => {
const el = e.target as HTMLAnchorElement;
if (el.nodeName !== 'A') return;
const href = el.getAttribute('href') ?? '';
// safe links: "./any", "../any", "/any", "//host/any", "http://host/any", "https://host/any"
if (href.startsWith('.') || href.startsWith('/') || href.startsWith('http://') || href.startsWith('https://')) {
e.preventDefault();
const forceTarget = (e.metaKey || e.ctrlKey) ? '_blank' : null;
openIframeLink(href, forceTarget ?? el.getAttribute('target'));
}
});
}
if (window.testModules) {
window.testModules.externalRenderHelper = {isValidCssColor};
}
+328
View File
@@ -0,0 +1,328 @@
import {checkAppUrl} from '../common-page.ts';
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {pathEscape} from '../../utils/url.ts';
import {registerGlobalInitFunc} from '../../modules/observer.ts';
const {appSubUrl} = window.config;
function onSecurityProtocolChange(): void {
if (Number(document.querySelector<HTMLInputElement>('#security_protocol')?.value) > 0) {
showElem('.has-tls');
} else {
hideElem('.has-tls');
}
}
export function initAdminCommon(): void {
if (!document.querySelector('.page-content.admin')) return;
// check whether appUrl(ROOT_URL) is correct, if not, show an error message
checkAppUrl();
initAdminUser();
initAdminAuthentication();
initAdminNotice();
registerGlobalInitFunc('initRunnerBulkToolbar', initAdminRunnerBulk);
}
function initAdminRunnerBulk(toolbar: HTMLElement) {
const actionButtons = toolbar.querySelectorAll<HTMLButtonElement>('.runner-bulk-action');
const formRunnerIds = toolbar.querySelector<HTMLInputElement>('form input[name="ids"]')!;
const rowCheckboxes = document.querySelectorAll<HTMLInputElement>('.runner-bulk-select');
const selectAll = document.querySelector<HTMLInputElement>('.runner-bulk-select-all');
if (!selectAll) return;
const refresh = () => {
const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
toggleElem(toolbar, checked.length > 0);
for (const btn of actionButtons) {
btn.querySelector<HTMLElement>('.runner-bulk-count')!.textContent = `(${checked.length})`;
}
selectAll.checked = checked.length > 0 && checked.length === rowCheckboxes.length;
selectAll.indeterminate = checked.length > 0 && checked.length < rowCheckboxes.length;
};
selectAll.addEventListener('change', () => {
for (const cb of rowCheckboxes) cb.checked = selectAll.checked;
refresh();
});
for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
refresh();
const collectSelectedIds = () => {
const ids = [];
for (const cb of rowCheckboxes) {
if (cb.checked) ids.push(cb.getAttribute('data-runner-id')!);
}
return ids.join(',');
};
formRunnerIds.value = collectSelectedIds();
}
function initAdminUser() {
const pageContent = document.querySelector('.page-content.admin.edit.user, .page-content.admin.new.user');
if (!pageContent) return;
document.querySelector<HTMLInputElement>('#login_type')?.addEventListener('change', function () {
if (this.value?.startsWith('0')) {
document.querySelector<HTMLInputElement>('#user_name')?.removeAttribute('disabled');
document.querySelector<HTMLInputElement>('#login_name')?.removeAttribute('required');
hideElem('.non-local');
showElem('.local');
document.querySelector<HTMLInputElement>('#user_name')?.focus();
if (this.getAttribute('data-password') === 'required') {
document.querySelector('#password')?.setAttribute('required', 'required');
}
} else {
if (document.querySelector<HTMLDivElement>('.admin.edit.user')) {
document.querySelector<HTMLInputElement>('#user_name')?.setAttribute('disabled', 'disabled');
}
document.querySelector<HTMLInputElement>('#login_name')?.setAttribute('required', 'required');
showElem('.non-local');
hideElem('.local');
document.querySelector<HTMLInputElement>('#login_name')?.focus();
document.querySelector<HTMLInputElement>('#password')?.removeAttribute('required');
}
});
}
function initAdminAuthentication() {
const pageContent = document.querySelector('.page-content.admin.authentication');
if (!pageContent) return;
const isNewPage = pageContent.classList.contains('new');
const isEditPage = pageContent.classList.contains('edit');
if (!isNewPage && !isEditPage) return;
function onUsePagedSearchChange() {
const searchPageSizeElements = document.querySelectorAll<HTMLDivElement>('.search-page-size');
if (document.querySelector<HTMLInputElement>('#use_paged_search')!.checked) {
showElem('.search-page-size');
for (const el of searchPageSizeElements) {
el.querySelector('input')?.setAttribute('required', 'required');
}
} else {
hideElem('.search-page-size');
for (const el of searchPageSizeElements) {
el.querySelector('input')?.removeAttribute('required');
}
}
}
function onOAuth2Change(applyDefaultValues: boolean) {
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');
}
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`);
if (!elProviderCustomUrlSettings) break; // some providers do not have custom URL settings
const couldChangeCustomURLs = elProviderCustomUrlSettings.getAttribute('data-available') === 'true';
const mustProvideCustomURLs = elProviderCustomUrlSettings.getAttribute('data-required') === 'true';
if (couldChangeCustomURLs) {
showElem('.oauth2_use_custom_url'); // show the checkbox
}
if (mustProvideCustomURLs) {
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')!.checked = true; // make the checkbox checked
}
break;
}
}
const supportSshPublicKey = document.querySelector<HTMLInputElement>(`#${provider}_SupportSSHPublicKey`)?.value === 'true';
toggleElem('.field.oauth2_ssh_public_key_claim_name', supportSshPublicKey);
onOAuth2UseCustomURLChange(applyDefaultValues);
}
function onOAuth2UseCustomURLChange(applyDefaultValues: boolean) {
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
hideElem('.oauth2_use_custom_url_field');
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2_use_custom_url_field input[required]')) {
input.removeAttribute('required');
}
const elProviderCustomUrlSettings = document.querySelector(`#${provider}_customURLSettings`);
if (elProviderCustomUrlSettings && document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')!.checked) {
for (const custom of ['token_url', 'auth_url', 'profile_url', 'email_url', 'tenant']) {
if (applyDefaultValues) {
document.querySelector<HTMLInputElement>(`#oauth2_${custom}`)!.value = document.querySelector<HTMLInputElement>(`#${provider}_${custom}`)!.value;
}
const customInput = document.querySelector(`#${provider}_${custom}`);
if (customInput?.getAttribute('data-available') === 'true') {
for (const input of document.querySelectorAll(`.oauth2_${custom} input`)) {
input.setAttribute('required', 'required');
}
showElem(`.oauth2_${custom}`);
}
}
}
}
function onEnableLdapGroupsChange() {
const checked = document.querySelector<HTMLInputElement>('.js-ldap-group-toggle')?.checked;
toggleElem(document.querySelector('#ldap-group-options')!, checked);
}
const elAuthType = document.querySelector<HTMLInputElement>('#auth_type')!;
// New authentication
if (isNewPage) {
const onAuthTypeChange = function () {
hideElem('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi');
for (const input of document.querySelectorAll<HTMLInputElement>('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required]')) {
input.removeAttribute('required');
}
document.querySelector<HTMLDivElement>('.binddnrequired')?.classList.remove('required');
const authType = elAuthType.value;
switch (authType) {
case '2': // LDAP
showElem('.ldap');
for (const input of document.querySelectorAll<HTMLInputElement>('.binddnrequired input, .ldap div.required:not(.dldap) input')) {
input.setAttribute('required', 'required');
}
document.querySelector('.binddnrequired')?.classList.add('required');
break;
case '3': // SMTP
showElem('.smtp');
showElem('.has-tls');
for (const input of document.querySelectorAll<HTMLInputElement>('.smtp div.required input, .has-tls')) {
input.setAttribute('required', 'required');
}
break;
case '4': // PAM
showElem('.pam');
for (const input of document.querySelectorAll<HTMLInputElement>('.pam input')) {
input.setAttribute('required', 'required');
}
break;
case '5': // LDAP
showElem('.dldap');
for (const input of document.querySelectorAll<HTMLInputElement>('.dldap div.required:not(.ldap) input')) {
input.setAttribute('required', 'required');
}
break;
case '6': // OAuth2
showElem('.oauth2');
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2 div.required:not(.oauth2_use_custom_url,.oauth2_use_custom_url_field,.open_id_connect_auto_discovery_url) input')) {
input.setAttribute('required', 'required');
}
onOAuth2Change(true);
break;
case '7': // SSPI
showElem('.sspi');
for (const input of document.querySelectorAll<HTMLInputElement>('.sspi div.required input')) {
input.setAttribute('required', 'required');
}
break;
}
if (authType === '2' || authType === '5') {
onSecurityProtocolChange();
onEnableLdapGroupsChange();
}
if (authType === '2') {
onUsePagedSearchChange();
}
};
elAuthType.addEventListener('change', onAuthTypeChange);
onAuthTypeChange();
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(true));
document.querySelector('.js-ldap-group-toggle')!.addEventListener('change', onEnableLdapGroupsChange);
}
// Edit authentication
if (isEditPage) {
const authType = elAuthType.value;
if (authType === '2' || authType === '5') {
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
document.querySelector('.js-ldap-group-toggle')!.addEventListener('change', onEnableLdapGroupsChange);
onEnableLdapGroupsChange();
if (authType === '2') {
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
}
} else if (authType === '6') {
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(false));
onOAuth2Change(false);
}
}
const elAuthName = document.querySelector<HTMLInputElement>('#auth_name')!;
const onAuthNameChange = function () {
// appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${pathEscape(elAuthName.value)}/callback`;
};
elAuthName.addEventListener('input', onAuthNameChange);
onAuthNameChange();
}
function initAdminNotice() {
const pageContent = document.querySelector('.page-content.admin.notice');
if (!pageContent) return;
const detailModal = document.querySelector<HTMLDivElement>('#detail-modal')!;
// Attach view detail modals
queryElems(pageContent, '.view-detail', (el) => el.addEventListener('click', (e) => {
e.preventDefault();
const elNoticeDesc = el.closest('tr')!.querySelector('.notice-description')!;
const elModalDesc = detailModal.querySelector('.content pre')!;
elModalDesc.textContent = elNoticeDesc.textContent;
showFomanticModal(detailModal);
}));
// Select actions
const checkboxes = document.querySelectorAll<HTMLInputElement>('.select.table .ui.checkbox input');
queryElems(pageContent, '.select.action', (el) => el.addEventListener('click', () => {
switch (el.getAttribute('data-action')) {
case 'select-all':
for (const checkbox of checkboxes) {
checkbox.checked = true;
}
break;
case 'deselect-all':
for (const checkbox of checkboxes) {
checkbox.checked = false;
}
break;
case 'inverse':
for (const checkbox of checkboxes) {
checkbox.checked = !checkbox.checked;
}
break;
}
}));
document.querySelector<HTMLButtonElement>('#delete-selection')?.addEventListener('click', async function (e) {
e.preventDefault();
this.classList.add('is-loading', 'disabled');
const data = new FormData();
for (const checkbox of checkboxes) {
if (checkbox.checked) {
data.append('ids[]', checkbox.closest('.ui.checkbox')!.getAttribute('data-id')!);
}
}
await POST(this.getAttribute('data-link')!, {data});
window.location.reload();
});
}
+48
View File
@@ -0,0 +1,48 @@
import {ConfigFormValueMapper} from './config.ts';
test('ConfigFormValueMapper', () => {
document.body.innerHTML = `
<form>
<input id="checkbox-unrelated" type="checkbox" value="v-unrelated" checked>
<!-- top-level key -->
<input name="k1" type="checkbox" value="v-key-only" data-config-dyn-key="k1" data-config-value-json="true" data-config-value-type="boolean">
<input type="hidden" data-config-dyn-key="k2" data-config-value-json='"k2-val"'>
<input name="k2">
<textarea name="repository.open-with.editor-apps"> a = b\n</textarea>
<input name="k-flipped-true" type="checkbox" data-config-value-type="flipped">
<input name="k-flipped-false" type="checkbox" checked data-config-value-type="flipped">
<!-- sub key -->
<input type="hidden" data-config-dyn-key="struct" data-config-value-json='{"SubBoolean": true, "SubTimestamp": 123456789, "OtherKey": "other-value"}'>
<input name="struct.SubBoolean" type="checkbox" data-config-value-type="boolean">
<input name="struct.SubTimestamp" type="datetime-local" data-config-value-type="timestamp">
<textarea name="struct.NewKey">new-value</textarea>
</form>
`;
const form = document.querySelector('form')!;
const mapper = new ConfigFormValueMapper(form);
mapper.fillFromSystemConfig();
const formData = mapper.collectToFormData();
const result: Record<string, string> = {};
const keys: string[] = [], values: string[] = [];
for (const [key, value] of formData.entries()) {
if (key === 'key') keys.push(value as string);
if (key === 'value') values.push(value as string);
}
for (let i = 0; i < keys.length; i++) {
result[keys[i]] = values[i];
}
expect(result).toEqual({
'k1': 'true',
'k2': '"k2-val"',
'k-flipped-false': 'false',
'k-flipped-true': 'true',
'repository.open-with.editor-apps': '[{"DisplayName":"a","OpenURL":"b"}]', // TODO: OPEN-WITH-EDITOR-APP-JSON: it must match backend
'struct': '{"SubBoolean":true,"SubTimestamp":123456780,"OtherKey":"other-value","NewKey":"new-value"}',
});
});
+222
View File
@@ -0,0 +1,222 @@
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';
const {appSubUrl} = window.config;
function collectCheckboxBooleanValue(el: HTMLInputElement): boolean {
const valType = el.getAttribute('data-config-value-type') as ConfigValueType;
if (valType === 'boolean') return el.checked;
if (valType === 'flipped') return !el.checked;
requireExplicitValueType(el);
}
function initSystemConfigAutoCheckbox(el: HTMLInputElement) {
el.addEventListener('change', async () => {
// if the checkbox is inside a form, we assume it's handled by the form submit and do not send an individual request
if (el.closest('form')) return;
try {
const data = new URLSearchParams({
key: el.getAttribute('data-config-dyn-key')!,
value: String(collectCheckboxBooleanValue(el)),
});
const resp = await POST(`${appSubUrl}/-/admin/config`, {data});
const json: Record<string, any> = await resp.json();
if (json.errorMessage) throw new Error(json.errorMessage);
} catch (ex) {
showTemporaryTooltip(el, errorMessage(ex));
el.checked = !el.checked;
}
});
}
type GeneralFormFieldElement = HTMLInputElement;
function unsupportedElement(el: Element): never {
// HINT: for future developers: if you need to handle a config that cannot be directly mapped to a form element, you should either:
// * Add a "hidden" input to store the value (not configurable)
// * Design a new "component" to handle the config
throw new Error(`Unsupported config form value mapping for ${el.nodeName} (name=${(el as HTMLInputElement).name},type=${(el as HTMLInputElement).type}), please add more and design carefully`);
}
function requireExplicitValueType(el: Element): never {
throw new Error(`Unsupported config form value type for ${el.nodeName} (name=${(el as HTMLInputElement).name},type=${(el as HTMLInputElement).type}), please add explicit value type with "data-config-value-type" attribute`);
}
// try to extract the subKey for the config value from the element name
// * return '' if the element name exactly matches the config key, which means the value is directly stored in the element
// * return null if the config key not match
function extractElemConfigSubKey(el: GeneralFormFieldElement, dynKey: string): string | null {
if (el.name === dynKey) return '';
if (el.name.startsWith(`${dynKey}.`)) return el.name.slice(dynKey.length + 1); // +1 for the dot
return null;
}
// Due to the different design between HTML form elements and the JSON struct of the config values, we need to explicitly define some types.
// * checkbox can be used for boolean value, it can also be used for multiple values (array)
type ConfigValueType = 'boolean' | 'flipped' | 'string' | 'number' | 'timestamp'; // TODO: support more types like array, not used at the moment.
function toDatetimeLocalValue(unixSeconds: number) {
const d = new Date(unixSeconds * 1000);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
}
export class ConfigFormValueMapper {
form: HTMLFormElement;
presetJsonValues: Record<string, any> = {};
presetValueTypes: Record<string, ConfigValueType> = {};
constructor(form: HTMLFormElement) {
this.form = form;
for (const el of queryElems<HTMLInputElement>(form, '[data-config-value-json]')) {
const dynKey = el.getAttribute('data-config-dyn-key')!;
const jsonStr = el.getAttribute('data-config-value-json');
try {
this.presetJsonValues[dynKey] = JSON.parse(jsonStr || '{}'); // empty string also is valid, default to an empty object
} catch (error) {
this.presetJsonValues[dynKey] = {}; // in case the value in database is corrupted, don't break the whole form
console.error(`Error parsing JSON for config ${dynKey}:`, error);
}
}
for (const el of queryElems<HTMLInputElement>(form, '[data-config-value-type]')) {
const valKey = el.getAttribute('data-config-dyn-key') || el.name;
this.presetValueTypes[valKey] = el.getAttribute('data-config-value-type')! as ConfigValueType;
}
}
// try to assign the config value to the form element, return true if assigned successfully,
// otherwise return false (e.g. the element is not related to the config key)
assignConfigValueToFormElement(el: GeneralFormFieldElement, dynKey: string, cfgVal: any) {
const subKey = extractElemConfigSubKey(el, dynKey);
if (subKey === null) return false; // if not match, skip
const val = subKey ? cfgVal![subKey] : cfgVal;
if (val === null) return true; // if name matches, but no value to assign, also succeed because the form element does exist
const valType = this.presetValueTypes[el.name];
if (el.matches('[type="checkbox"]')) {
if (valType !== 'boolean') requireExplicitValueType(el);
el.checked = Boolean(val ?? el.checked);
} 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') {
el.value = String(val ?? el.value);
} else {
unsupportedElement(el);
}
return true;
}
collectConfigValueFromElement(el: GeneralFormFieldElement) {
let val: any;
const valType = this.presetValueTypes[el.name];
if (el.matches('[type="checkbox"]')) {
// TODO: if it needs to support array values in the future,
// it needs to iterate the "namedElems" to find all the checkboxes with the same name and collect values accordingly,
// and set the namedElems[matchedIdx] to null to avoid duplicate processing.
val = collectCheckboxBooleanValue(el);
} 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') {
val = el.value;
} else {
unsupportedElement(el);
}
return val;
}
collectConfigSubValues(namedElems: Array<GeneralFormFieldElement | null>, dynKey: string, cfgVal: Record<string, any>) {
for (let idx = 0; idx < namedElems.length; idx++) {
const el = namedElems[idx];
if (!el) continue;
const subKey = extractElemConfigSubKey(el, dynKey);
if (!subKey) continue; // if not match, skip
cfgVal[subKey] = this.collectConfigValueFromElement(el);
namedElems[idx] = null;
}
}
fillFromSystemConfig() {
for (const [dynKey, cfgVal] of Object.entries(this.presetJsonValues)) {
const elems = this.form.querySelectorAll<GeneralFormFieldElement>(`[name^="${CSS.escape(dynKey)}"]`);
let assigned = false;
for (const el of elems) {
if (this.assignConfigValueToFormElement(el, dynKey, cfgVal)) {
assigned = true;
}
}
if (!assigned) throw new Error(`Could not find form element for config ${dynKey}, please check the form design and json struct`);
}
}
// TODO: OPEN-WITH-EDITOR-APP-JSON: need to use the same logic as backend
marshalConfigValueOpenWithEditorApps(cfgVal: string): string {
const apps: Array<{DisplayName: string, OpenURL: string}> = [];
const lines = cfgVal.split('\n');
for (const line of lines) {
let [displayName, openUrl] = line.split('=', 2);
displayName = displayName.trim();
openUrl = openUrl?.trim() ?? '';
if (!displayName || !openUrl) continue;
apps.push({DisplayName: displayName, OpenURL: openUrl});
}
return JSON.stringify(apps);
}
marshalConfigValue(dynKey: string, cfgVal: any): string {
if (dynKey === 'repository.open-with.editor-apps') return this.marshalConfigValueOpenWithEditorApps(cfgVal);
return JSON.stringify(cfgVal);
}
collectToFormData(): FormData {
const namedElems: Array<GeneralFormFieldElement | null> = [];
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"
const formData = new FormData();
for (const [dynKey, cfgVal] of Object.entries(this.presetJsonValues)) {
this.collectConfigSubValues(namedElems, dynKey, cfgVal);
formData.append('key', dynKey);
formData.append('value', this.marshalConfigValue(dynKey, cfgVal));
}
// now, the namedElems should only contain the config options without sub values,
// directly store the value in formData with key as the element name, for example:
// "foo.enabled" => "true"
for (const el of namedElems) {
if (!el) continue;
const dynKey = el.name;
const newVal = this.collectConfigValueFromElement(el);
formData.append('key', dynKey);
formData.append('value', this.marshalConfigValue(dynKey, newVal));
}
return formData;
}
}
function initSystemConfigForm(form: HTMLFormElement) {
const formMapper = new ConfigFormValueMapper(form);
formMapper.fillFromSystemConfig();
form.addEventListener('submit', async (e) => {
if (!form.reportValidity()) return;
e.preventDefault();
const formData = formMapper.collectToFormData();
await submitFormFetchAction(form, {formData});
});
}
export function initAdminConfigs(): void {
registerGlobalInitFunc('initAdminConfigSettings', (el) => {
queryElems(el, 'input[type="checkbox"][data-config-dyn-key]', initSystemConfigAutoCheckbox);
queryElems(el, 'form.system-config-form', initSystemConfigForm);
});
}
+31
View File
@@ -0,0 +1,31 @@
import {toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
const {appSubUrl} = window.config;
export async function initAdminSelfCheck() {
const elCheckByFrontend = document.querySelector('#self-check-by-frontend');
if (!elCheckByFrontend) return;
const elContent = document.querySelector<HTMLDivElement>('.page-content.admin .admin-setting-content')!;
// send frontend self-check request
const resp = await POST(`${appSubUrl}/-/admin/self_check`, {
data: new URLSearchParams({
location_origin: window.location.origin,
now: String(Date.now()), // TODO: check time difference between server and client
}),
});
const json: Record<string, any> = await resp.json();
toggleElem(elCheckByFrontend, Boolean(json.problems?.length));
for (const problem of json.problems ?? []) {
const elProblem = document.createElement('div');
elProblem.classList.add('ui', 'warning', 'message');
elProblem.textContent = problem;
elCheckByFrontend.append(elProblem);
}
// only show the "no problem" if there is no visible "self-check-problem"
const hasProblem = Boolean(elContent.querySelectorAll('.self-check-problem:not(.tw-hidden)').length);
toggleElem(elContent.querySelector('.self-check-no-problem')!, !hasProblem);
}
+39
View File
@@ -0,0 +1,39 @@
export function initAdminUserListSearchForm(): void {
const searchForm = window.config.pageData.adminUserListSearchForm;
if (!searchForm) return;
const form = document.querySelector<HTMLFormElement>('#user-list-search-form');
if (!form) return;
for (const button of form.querySelectorAll(`button[name=sort][value="${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}"]`)) {
input.checked = true;
}
}
}
for (const radio of form.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
radio.addEventListener('click', () => {
form.submit();
});
}
const resetButtons = form.querySelectorAll<HTMLAnchorElement>('.j-reset-status-filter');
for (const button of resetButtons) {
button.addEventListener('click', (e) => {
e.preventDefault();
for (const input of form.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
if (input.name.startsWith('status_filter[')) {
input.checked = false;
}
}
form.submit();
});
}
}
+45
View File
@@ -0,0 +1,45 @@
import {isDarkTheme} from '../utils.ts';
export async function initCaptcha() {
const captchaEl = document.querySelector('#captcha');
if (!captchaEl) return;
const siteKey = captchaEl.getAttribute('data-sitekey')!;
const isDark = isDarkTheme();
const params = {
sitekey: siteKey,
theme: isDark ? 'dark' : 'light',
};
switch (captchaEl.getAttribute('data-captcha-type')) {
case 'g-recaptcha': {
if (window.grecaptcha) {
window.grecaptcha.ready(() => {
window.grecaptcha.render(captchaEl, params);
});
}
break;
}
case 'cf-turnstile': {
if (window.turnstile) {
window.turnstile.render(captchaEl, params);
}
break;
}
case 'h-captcha': {
if (window.hcaptcha) {
window.hcaptcha.render(captchaEl, params);
}
break;
}
case 'm-captcha': {
// ref: https://github.com/mCaptcha/glue/blob/master/packages/vanilla/README.md
// sample: https://github.com/mCaptcha/glue/blob/master/packages/vanilla/static/embeded.html
// @mcaptcha/vanilla-glue 0.1.0-rc2 auto-runs on module load, use the existing elements to render.
await import('@mcaptcha/vanilla-glue');
break;
}
default:
}
}
+71
View File
@@ -0,0 +1,71 @@
import {getCurrentLocale} from '../utils.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;
async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) {
const [{Cite, plugins}] = await Promise.all([
import('@citation-js/core'),
import('@citation-js/plugin-software-formats'),
import('@citation-js/plugin-bibtex'),
import('@citation-js/plugin-csl'),
]);
const citationFileContent = pageData.citationFileContent!;
const config = plugins.config.get('@bibtex');
config.constants.fieldTypes.doi = ['field', 'literal'];
config.constants.fieldTypes.version = ['field', 'literal'];
const citationFormatter = new Cite(citationFileContent);
const lang = getCurrentLocale() || 'en-US';
const apaOutput = citationFormatter.format('bibliography', {template: 'apa', lang});
const bibtexOutput = citationFormatter.format('bibtex', {lang});
citationCopyBibtex.setAttribute('data-text', bibtexOutput);
citationCopyApa.setAttribute('data-text', apaOutput);
}
export async function initCitationFileCopyContent() {
const defaultCitationFormat = 'apa'; // apa or bibtex
if (!pageData.citationFileContent) return;
const citationCopyApa = document.querySelector<HTMLButtonElement>('#citation-copy-apa')!;
const citationCopyBibtex = document.querySelector<HTMLButtonElement>('#citation-copy-bibtex')!;
const inputContent = document.querySelector<HTMLInputElement>('#citation-copy-content');
if ((!citationCopyApa && !citationCopyBibtex) || !inputContent) return;
const updateUi = () => {
const isBibtex = localUserSettings.getString('citation-copy-format', defaultCitationFormat) === 'bibtex';
const copyContent = (isBibtex ? citationCopyBibtex : citationCopyApa).getAttribute('data-text')!;
inputContent.value = copyContent;
citationCopyBibtex.classList.toggle('primary', isBibtex);
citationCopyApa.classList.toggle('primary', !isBibtex);
};
document.querySelector('#cite-repo-button')?.addEventListener('click', async () => {
try {
await initInputCitationValue(citationCopyApa, citationCopyBibtex);
} catch (e) {
console.error(`initCitationFileCopyContent error: ${errorMessage(e)}`, e);
return;
}
updateUi();
citationCopyApa.addEventListener('click', () => {
localUserSettings.setString('citation-copy-format', 'apa');
updateUi();
});
citationCopyBibtex.addEventListener('click', () => {
localUserSettings.setString('citation-copy-format', 'bibtex');
updateUi();
});
inputContent.addEventListener('click', () => {
inputContent.select();
});
showFomanticModal(document.querySelector('#cite-repo-modal'));
});
}
+21
View File
@@ -0,0 +1,21 @@
import {createApp} from 'vue';
export async function initRepoCodeFrequency() {
const el = document.querySelector('#repo-code-frequency-chart');
if (!el) return;
const {default: RepoCodeFrequency} = await import('../components/RepoCodeFrequency.vue');
try {
const View = createApp(RepoCodeFrequency, {
locale: {
loadingTitle: el.getAttribute('data-locale-loading-title'),
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
loadingInfo: el.getAttribute('data-locale-loading-info'),
},
});
View.mount(el);
} catch (err) {
console.error('RepoCodeFrequency failed to load', err);
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
}
}
+74
View File
@@ -0,0 +1,74 @@
import {createTippy} from '../modules/tippy.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
export async function initColorPickers() {
let imported = false;
registerGlobalInitFunc('initColorPicker', async (el) => {
if (!imported) {
await Promise.all([
import('vanilla-colorful/hex-color-picker.js'),
import('../../css/features/colorpicker.css'),
]);
imported = true;
}
initPicker(el);
});
}
function updateSquare(el: HTMLElement, newValue: string): void {
el.style.color = /#[0-9a-f]{6}/i.test(newValue) ? newValue : 'transparent';
}
function updatePicker(el: HTMLElement, newValue: string): void {
el.setAttribute('color', newValue);
}
function initPicker(el: HTMLElement): void {
const input = el.querySelector('input')!;
const square = document.createElement('div');
square.classList.add('preview-square');
updateSquare(square, input.value);
el.append(square);
const picker = document.createElement('hex-color-picker');
picker.addEventListener('color-changed', (e) => {
input.value = e.detail.value;
input.focus();
updateSquare(square, e.detail.value);
});
input.addEventListener('input', (e) => {
updateSquare(square, (e.target as HTMLInputElement).value);
updatePicker(picker, (e.target as HTMLInputElement).value);
});
createTippy(input, {
trigger: 'focus click',
theme: 'bare',
hideOnClick: true,
content: picker,
placement: 'bottom-start',
interactive: true,
onShow() {
updatePicker(picker, input.value);
},
});
// init random color & precolors
const setSelectedColor = (color: string) => {
input.value = color;
input.dispatchEvent(new Event('input', {bubbles: true}));
updateSquare(square, color);
};
el.querySelector('.generate-random-color')!.addEventListener('click', () => {
const newValue = `#${Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0')}`;
setSelectedColor(newValue);
});
for (const colorEl of el.querySelectorAll<HTMLElement>('.precolors .color')) {
colorEl.addEventListener('click', (e) => {
const newValue = (e.target as HTMLElement).getAttribute('data-color-hex')!;
setSelectedColor(newValue);
});
}
}
@@ -0,0 +1,34 @@
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {toggleElem, toggleElemClass} from '../utils/dom.ts';
export function initActionsPermissionsForm(): void {
registerGlobalInitFunc('initRepoActionsPermissionsForm', initRepoActionsPermissionsForm);
registerGlobalInitFunc('initOwnerActionsPermissionsForm', initOwnerActionsPermissionsForm);
}
function initRepoActionsPermissionsForm(form: HTMLFormElement) {
initActionsOverrideOwnerConfig(form);
initActionsPermissionTable(form);
}
function initOwnerActionsPermissionsForm(form: HTMLFormElement) {
initActionsPermissionTable(form);
}
function initActionsPermissionTable(form: HTMLFormElement) {
// show or hide permissions table based on enable max permissions checkbox (aka: whether you use custom permissions or not)
const permTable = form.querySelector<HTMLTableElement>('.js-permissions-table')!;
const enableMaxCheckbox = form.querySelector<HTMLInputElement>('input[name=enable_max_permissions]')!;
const onEnableMaxCheckboxChange = () => toggleElem(permTable, enableMaxCheckbox.checked);
onEnableMaxCheckboxChange();
enableMaxCheckbox.addEventListener('change', onEnableMaxCheckboxChange);
}
function initActionsOverrideOwnerConfig(form: HTMLFormElement) {
// enable or disable repo token permissions config section based on override owner config checkbox
const overrideOwnerConfig = form.querySelector<HTMLInputElement>('input[name=override_owner_config]')!;
const repoTokenPermConfigSection = form.querySelector('.js-repo-token-permissions-config')!;
const onOverrideOwnerConfigChange = () => toggleElemClass(repoTokenPermConfigSection, 'container-disabled', !overrideOwnerConfig.checked);
onOverrideOwnerConfigChange();
overrideOwnerConfig.addEventListener('change', onOverrideOwnerConfigChange);
}
+25
View File
@@ -0,0 +1,25 @@
import {assignElementProperty, type ElementWithAssignableProperties} from './common-button.ts';
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');
assignElementProperty(elForm, 'text-content', 'dummy');
expect(elForm.textContent).toBe('dummy');
// mock a form with its property "action" overwritten by an input element
const elFormWithAction = new class implements ElementWithAssignableProperties {
action = document.createElement('input'); // now "form.action" is not string, but an input element
_attrs: Record<string, string> = {};
setAttribute(name: string, value: string) { this._attrs[name] = value }
getAttribute(name: string): string | null { return this._attrs[name] }
}();
assignElementProperty(elFormWithAction, 'action', '/bar');
expect(elFormWithAction.getAttribute('action')).eq('/bar');
const elInput = document.createElement('input');
expect(elInput.readOnly).toBe(false);
assignElementProperty(elInput, 'read-only', 'true');
expect(elInput.readOnly).toBe(true);
});
+197
View File
@@ -0,0 +1,197 @@
import {POST} from '../modules/fetch.ts';
import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {camelize} from 'vue';
import {applyAutoFocus} from './common-page.ts';
export function initGlobalButtonClickOnEnter(): void {
addDelegatedEventListener(document, 'keypress', 'div.ui.button, span.ui.button', (el, e: KeyboardEvent) => {
if (e.code === 'Space' || e.code === 'Enter') {
e.preventDefault();
el.click();
}
});
}
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;
}
}
showFomanticModal(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
},
});
});
}
}
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
e.preventDefault();
const sel = el.getAttribute('data-panel')!;
const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel);
for (const elem of elems) {
if (isElemVisible(elem as HTMLElement)) {
applyAutoFocus(elem);
}
}
}
function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
// a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"`
e.preventDefault();
let sel = el.getAttribute('data-panel');
if (sel) {
hideElem(sel);
return;
}
sel = el.getAttribute('data-panel-closest');
if (sel) {
hideElem((el.parentNode as HTMLElement).closest(sel)!);
return;
}
throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code
}
export type ElementWithAssignableProperties = {
getAttribute: (name: string) => string | null;
setAttribute: (name: string, value: string) => void;
} & Record<string, any>;
export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
const camelizedName = camelize(kebabName);
const old = el[camelizedName];
if (typeof old === 'boolean') {
el[camelizedName] = val === 'true';
} else if (typeof old === 'number') {
el[camelizedName] = parseFloat(val);
} else if (typeof old === 'string') {
el[camelizedName] = val;
} else if (old?.nodeName) {
// "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="..."`
throw new Error(`cannot assign element property "${camelizedName}" by value "${val}"`);
}
}
function onShowModalClick(el: HTMLElement, e: MouseEvent) {
// A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute.
// Each "data-modal-{target}" attribute will be filled to target element's value or text-content.
// * First, try to query '#target'
// * 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".
e.preventDefault();
const modalSelector = el.getAttribute('data-modal')!;
const elModal = document.querySelector(modalSelector);
if (!elModal) throw new Error('no modal for this action');
const modalAttrPrefix = 'data-modal-';
for (const attrib of el.attributes) {
if (!attrib.name.startsWith(modalAttrPrefix)) {
continue;
}
const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
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(`.${attrTargetName}`) ||
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;
}
if (attrTargetProp) {
assignElementProperty(attrTarget, attrTargetProp, attrib.value);
} else if (attrTarget.matches('input, textarea')) {
(attrTarget as HTMLInputElement | HTMLTextAreaElement).value = attrib.value; // FIXME: add more supports like checkbox
} else {
attrTarget.textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p
}
}
showFomanticModal(elModal);
}
export function initGlobalButtons(): void {
// There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form.
// However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission.
// There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content")
addDelegatedEventListener(document, 'click', 'form button.ui.cancel.button', (_ /* el */, e) => e.preventDefault());
// Ideally these "button" events should be handled by registerGlobalEventFunc
// Refactoring would involve too many changes, so at the moment, just use the global event listener.
addDelegatedEventListener(document, 'click', '.show-panel, .hide-panel, .show-modal', (el, e: MouseEvent) => {
if (el.classList.contains('show-panel')) {
onShowPanelClick(el, e);
} else if (el.classList.contains('hide-panel')) {
onHidePanelClick(el, e);
} else if (el.classList.contains('show-modal')) {
onShowModalClick(el, e);
}
});
}
@@ -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();
});
+418
View File
@@ -0,0 +1,418 @@
import {GET, request} from '../modules/fetch.ts';
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {addDelegatedEventListener, createElementFromHTML} from '../utils/dom.ts';
import {errorMessage, errorName} from '../modules/errors.ts';
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.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, 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) {
// 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();
}
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 {
indicatorEl.classList.add('is-loading');
if (indicatorEl.clientHeight < 50) indicatorEl.classList.add('loading-icon-2px');
}
} else {
if ('disabled' in indicatorEl) {
indicatorEl.disabled = false;
} else {
indicatorEl.classList.remove('is-loading', 'loading-icon-2px');
}
}
}
}
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.toString();
}
}
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 | null;
formData?: FormData;
};
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')];
if (submitterName) {
formData.append(submitterName, submitterValue || '');
}
let reqUrl = formActionUrl;
let reqBody: FormData | undefined;
if (formMethodUpper === 'GET') {
const params = new URLSearchParams();
for (const [key, value] of formData) {
params.append(key, value as string);
}
const pos = reqUrl.indexOf('?');
if (pos !== -1) {
reqUrl = reqUrl.slice(0, pos);
}
reqUrl += `?${params.toString()}`;
} else {
reqBody = formData;
}
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
};
}
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('#')) {
// eslint-disable-next-line unicorn/prefer-query-selector
elModal = document.getElementById(dataModalConfirm.substring(1));
if (elModal) {
elModal = createElementFromHTML(elModal.outerHTML);
elModal.removeAttribute('id');
}
}
if (!elModal) {
const modalConfirmContent = dataModalConfirm || el.getAttribute('data-modal-confirm-content') || '';
if (modalConfirmContent) {
const isRisky = el.classList.contains('red') || el.classList.contains('negative');
elModal = createConfirmModal({
header: el.getAttribute('data-modal-confirm-header') || '',
content: modalConfirmContent,
confirmButtonColor: isRisky ? 'red' : 'primary',
});
}
}
if (!elModal) return true;
return await confirmModal(elModal);
}
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();
}
}
}
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() {
// 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);
}
+36
View File
@@ -0,0 +1,36 @@
import {applyAreYouSure, initAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.ts';
import {queryElems} from '../utils/dom.ts';
import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
export function initGlobalFormDirtyLeaveConfirm() {
initAreYouSure(window.jQuery);
// Warn users that try to leave a page after entering data into a form.
// Except on sign-in pages, and for forms marked as 'ignore-dirty'.
if (!document.querySelector('.page-content.user.signin')) {
applyAreYouSure('form:not(.ignore-dirty)');
}
}
export function initGlobalEnterQuickSubmit() {
document.addEventListener('keydown', (e) => {
if (e.isComposing) return;
if (e.key !== 'Enter') return;
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();
}
}
});
}
export function initGlobalComboMarkdownEditor() {
queryElems<HTMLElement>(document, '.combo-markdown-editor:not(.custom-init)', (el) => initComboMarkdownEditor(el));
}
@@ -0,0 +1,16 @@
import {parseIssueListQuickGotoLink} from './common-issue-list.ts';
test('parseIssueListQuickGotoLink', () => {
expect(parseIssueListQuickGotoLink('/link', '')).toEqual('');
expect(parseIssueListQuickGotoLink('/link', 'abc')).toEqual('');
expect(parseIssueListQuickGotoLink('/link', '123')).toEqual('/link/issues/123');
expect(parseIssueListQuickGotoLink('/link', '#123')).toEqual('/link/issues/123');
expect(parseIssueListQuickGotoLink('/link', 'owner/repo#123')).toEqual('/owner/repo/issues/123');
expect(parseIssueListQuickGotoLink('', '')).toEqual('');
expect(parseIssueListQuickGotoLink('', 'abc')).toEqual('');
expect(parseIssueListQuickGotoLink('', '123')).toEqual('');
expect(parseIssueListQuickGotoLink('', '#123')).toEqual('');
expect(parseIssueListQuickGotoLink('', 'owner/repo#')).toEqual('');
expect(parseIssueListQuickGotoLink('', 'owner/repo#123')).toEqual('/owner/repo/issues/123');
});
+58
View File
@@ -0,0 +1,58 @@
import {onInputDebounce, toggleElem} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
const {appSubUrl} = window.config;
const reIssueIndex = /^(\d+)$/; // eg: "123"
const reIssueSharpIndex = /^#(\d+)$/; // eg: "#123"
const reIssueOwnerRepoIndex = /^([-.\w]+)\/([-.\w]+)#(\d+)$/; // eg: "{owner}/{repo}#{index}"
// if the searchText can be parsed to an "issue goto link", return the link, otherwise return empty string
export function parseIssueListQuickGotoLink(repoLink: string, searchText: string) {
searchText = searchText.trim();
let targetUrl = '';
if (repoLink) {
// try to parse it in current repo
if (reIssueIndex.test(searchText)) {
targetUrl = `${repoLink}/issues/${searchText}`;
} else if (reIssueSharpIndex.test(searchText)) {
targetUrl = `${repoLink}/issues/${searchText.substring(1)}`;
}
}
// try to parse it for a global search (eg: "owner/repo#123")
const [_, owner, repo, index] = reIssueOwnerRepoIndex.exec(searchText) || [];
if (owner) {
targetUrl = `${appSubUrl}/${owner}/${repo}/issues/${index}`;
}
return targetUrl;
}
export function initCommonIssueListQuickGoto() {
const elGotoButton = document.querySelector<HTMLElement>('#issue-list-quick-goto');
if (!elGotoButton) return;
const form = elGotoButton.closest('form')!;
const input = form.querySelector<HTMLInputElement>('input[name=q]')!;
const repoLink = elGotoButton.getAttribute('data-repo-link') || '';
elGotoButton.addEventListener('click', () => {
window.location.href = elGotoButton.getAttribute('data-issue-goto-link')!;
});
const onInput = async () => {
const searchText = input.value;
// try to check whether the parsed goto link is valid
let targetUrl = parseIssueListQuickGotoLink(repoLink, searchText);
if (targetUrl) {
const res = await GET(`${targetUrl}/info`); // backend: GetIssueInfo, it only checks whether the issue exists by status code
if (res.status !== 200) targetUrl = '';
}
// if the input value has changed, then ignore the result
if (input.value !== searchText) return;
toggleElem(elGotoButton, Boolean(targetUrl));
elGotoButton.setAttribute('data-issue-goto-link', targetUrl);
};
input.addEventListener('input', onInputDebounce(onInput));
onInput();
}
@@ -0,0 +1,16 @@
import {initCompLabelEdit} from './comp/LabelEdit.ts';
import {toggleElem} from '../utils/dom.ts';
export function initCommonOrganization() {
if (!document.querySelectorAll('.organization').length) {
return;
}
document.querySelector<HTMLInputElement>('.organization.settings.options #org_name')?.addEventListener('input', function () {
const nameChanged = this.value.toLowerCase() !== this.getAttribute('data-org-name')!.toLowerCase();
toggleElem('#org-name-change-prompt', nameChanged);
});
// Labels
initCompLabelEdit('.page-content.organization.settings.labels');
}
+170
View File
@@ -0,0 +1,170 @@
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';
const {appUrl, appSubUrl} = window.config;
function initHeadNavbarContentToggle() {
const navbar = document.querySelector('#navbar');
const btn = document.querySelector('#navbar-expand-toggle');
if (!navbar || !btn) return;
btn.addEventListener('click', () => {
const isExpanded = btn.classList.contains('active');
navbar.classList.toggle('navbar-menu-open', !isExpanded);
btn.classList.toggle('active', !isExpanded);
});
}
function initFooterLanguageMenu() {
document.querySelector('.ui.dropdown .menu.language-menu')?.addEventListener('click', async (e) => {
const item = (e.target as HTMLElement).closest('.item');
if (!item) return;
e.preventDefault();
await GET(item.getAttribute('data-url')!);
window.location.reload();
});
}
function initFooterThemeSelector() {
const elDropdown = document.querySelector('#footer-theme-selector');
if (!elDropdown) return; // some pages don't have footer, for example: 500.tmpl
const $dropdown = fomanticQuery(elDropdown);
$dropdown.dropdown({
direction: 'upward',
apiSettings: {url: `${appSubUrl}/-/web-theme/list`, cache: false},
});
addDelegatedEventListener(elDropdown, 'click', '.menu > .item', async (el) => {
const themeName = el.getAttribute('data-value')!;
await POST(`${appSubUrl}/-/web-theme/apply?theme=${encodeURIComponent(themeName)}`);
window.location.reload();
});
}
export function initCommmPageComponents() {
initHeadNavbarContentToggle();
initFooterLanguageMenu();
initFooterThemeSelector();
}
export function initGlobalDropdown() {
// do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
registerGlobalSelectorFunc('.ui.dropdown:not(.custom)', (el) => {
const $dropdown = fomanticQuery(el);
if ($dropdown.data('module-dropdown')) return; // do not re-init if other code has already initialized it.
$dropdown.dropdown('setting', {hideDividers: 'empty'});
if (el.classList.contains('jump')) {
// The "jump" means this dropdown is mainly used for "menu" purpose,
// clicking an item will jump to somewhere else or trigger an action/function.
// When a dropdown is used for non-refresh actions with tippy,
// it must have this "jump" class to hide the tippy when dropdown is closed.
$dropdown.dropdown('setting', {
action: 'hide',
onShow() {
// hide associated tooltip while dropdown is open
this._tippy?.hide();
this._tippy?.disable();
},
onHide() {
this._tippy?.enable();
// eslint-disable-next-line unicorn/no-this-assignment
const elDropdown = this;
// hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu
setTimeout(() => {
const $dropdown = fomanticQuery(elDropdown);
if ($dropdown.dropdown('is hidden')) {
queryElems(elDropdown, '.menu > .item', (el) => el._tippy?.hide());
}
}, 2000);
},
});
}
// Special popup-directions, prevent Fomantic from guessing the popup direction.
// With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward,
// if the dropdown is at the beginning of the page, then the top part would be clipped by the window view.
// eg: Issue List "Sort" dropdown
// But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position,
// which would make some dropdown popups slightly shift out of the right viewport edge in some cases.
// eg: the "Create New Repo" menu on the navbar.
if (el.classList.contains('upward')) $dropdown.dropdown('setting', 'direction', 'upward');
if (el.classList.contains('downward')) $dropdown.dropdown('setting', 'direction', 'downward');
});
}
export function initGlobalComponent() {
registerGlobalInitFunc('initTabSwitcher', initTabSwitcher);
registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper);
registerGlobalInitFunc('initSearchRepoBox', initCompSearchRepoBox);
}
// for performance considerations, it only uses performant syntax
function attachInputDirAuto(el: Partial<HTMLInputElement | HTMLTextAreaElement>) {
if (el.type !== 'hidden' &&
el.type !== 'checkbox' &&
el.type !== 'radio' &&
el.type !== 'range' &&
el.type !== 'color') {
el.dir = 'auto';
}
}
function autoFocusEnd(el: HTMLInputElement | HTMLTextAreaElement) {
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}
export function applyAutoFocus(container: Element) {
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/autofocus
// "autofocus" behavior is defined by the standard: when a container (e.g.: dialog) becomes visible, focus the element with "autofocus" attribute
// Fomantic UI already supports it for its modal dialog, we need to cover more cases (e.g.: ".show-panel" button)
// Here is just a simple support, we don't expect more than one element that need "autofocus" appearing in the same container
container.querySelector<HTMLElement>('[autofocus]')?.focus();
// Also, apply our autoFocusEnd behavior
// TODO: GLOBAL-INIT-MULTIPLE-FUNCTIONS: use "~=" operator in case we would extend the "data-global-init" to support more functions in the future.
const el = container.querySelector<HTMLInputElement>('[data-global-init~="autoFocusEnd"]');
if (el) autoFocusEnd(el);
}
export function initGlobalInput() {
registerGlobalSelectorFunc('input, textarea', attachInputDirAuto);
// autoFocusEnd is used for autofocus an input/textarea and move the cursor to the end of the text.
// It is useful for "New Issue"/"New PR" pages when the title is pre-filled with prefix text (e.g.: from template or commit message)
// The native "autofocus" isn't used because there is a delay between "focused (DOM rendering)" and "move cursor to end (our JS)", it causes flickers.
registerGlobalInitFunc('autoFocusEnd', autoFocusEnd);
}
/**
* Too many users set their ROOT_URL to wrong value, and it causes a lot of problems:
* * Cross-origin API request without correct cookie
* * Incorrect href in <a>
* * ...
* So we check whether current URL starts with AppUrl(ROOT_URL).
* If they don't match, show a warning to users.
*/
export function checkAppUrl() {
const curUrl = window.location.href;
// some users visit "https://domain/gitea" while appUrl is "https://domain/gitea/", there should be no warning
if (curUrl.startsWith(appUrl) || `${curUrl}/` === appUrl) {
return;
}
showGlobalErrorMessage(`The detected web site URL is "${appUrl}", it's unlikely matching the site config.
Mismatched app.ini ROOT_URL or reverse proxy "Host/X-Forwarded-Proto" config might cause wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`, 'warning');
}
export function checkAppUrlScheme() {
const curUrl = window.location.href;
// some users visit "http://domain" while appUrl is "https://domain", COOKIE_SECURE makes it impossible to sign in
if (curUrl.startsWith('http:') && appUrl.startsWith('https:')) {
showGlobalErrorMessage(`This instance is configured to run under HTTPS (by ROOT_URL config), you are accessing by HTTP. Mismatched scheme might cause problems for sign-in/sign-up.`, 'warning');
}
}
@@ -0,0 +1,454 @@
import '@github/markdown-toolbar-element';
import '@github/text-expander-element';
import {attachTribute} from '../tribute.ts';
import {hideElem, showElem, autosize, isElemVisible, generateElemId} from '../../utils/dom.ts';
import {
EventUploadStateChanged,
initEasyMDEPaste,
initTextareaEvents,
triggerUploadStateChanged,
} from './EditorUpload.ts';
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.ts';
import {renderPreviewPanelContent} from '../repo-editor.ts';
import {toggleTasklistCheckbox} from '../../markup/tasklist.ts';
import {easyMDEToolbarActions} from './EasyMDEToolbarActions.ts';
import {initTextExpander} from './TextExpander.ts';
import {showErrorToast} from '../../modules/toast.ts';
import {POST} from '../../modules/fetch.ts';
import {
EventEditorContentChanged,
initTextareaMarkdown,
replaceTextareaSelection,
triggerEditorContentChanged,
} from './EditorMarkdown.ts';
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
import {createTippy} from '../../modules/tippy.ts';
import {initTabSwitcher} from '../../modules/fomantic/tab.ts';
import type EasyMDE from 'easymde';
import {localUserSettings} from '../../modules/user-settings.ts';
/**
* validate if the given textarea is non-empty.
* @param {HTMLTextAreaElement} textarea - The textarea element to be validated.
* @returns {boolean} returns true if validation succeeded.
*/
export function validateTextareaNonEmpty(textarea: HTMLTextAreaElement) {
// When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation.
// The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert.
if (!textarea.value) {
if (isElemVisible(textarea)) {
textarea.required = true;
const form = textarea.closest('form');
form?.reportValidity();
} else {
// The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places.
showErrorToast('Require non-empty content');
}
return false;
}
return true;
}
type Heights = {
minHeight?: string,
height?: string,
maxHeight?: string,
};
type ComboMarkdownEditorOptions = {
editorHeights?: Heights,
easyMDEOptions?: EasyMDE.Options,
};
type ComboMarkdownEditorTextarea = HTMLTextAreaElement & {_giteaComboMarkdownEditor: any};
type ComboMarkdownEditorContainer = HTMLElement & {_giteaComboMarkdownEditor?: any};
export class ComboMarkdownEditor {
static EventEditorContentChanged = EventEditorContentChanged;
static EventUploadStateChanged = EventUploadStateChanged;
public container: HTMLElement;
options: ComboMarkdownEditorOptions;
tabEditor?: HTMLElement;
tabPreviewer?: HTMLElement;
supportEasyMDE!: boolean;
easyMDE: any;
easyMDEToolbarActions: any;
easyMDEToolbarDefault: any;
textarea!: ComboMarkdownEditorTextarea;
textareaMarkdownToolbar!: HTMLElement;
textareaAutosize: any;
buttonMonospace!: HTMLButtonElement;
dropzone: HTMLElement | null = null;
attachedDropzoneInst: any;
previewMode!: string;
previewUrl!: string;
previewContext!: string;
constructor(container: ComboMarkdownEditorContainer, options:ComboMarkdownEditorOptions = {}) {
if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
container._giteaComboMarkdownEditor = this;
this.options = options;
this.container = container;
}
async init() {
this.prepareEasyMDEToolbarActions();
this.setupContainer();
this.setupTab();
await this.setupDropzone(); // textarea depends on dropzone
this.setupTextarea();
await this.switchToUserPreference();
}
applyEditorHeights(el: HTMLElement, heights: Heights | undefined) {
if (!heights) return;
if (heights.minHeight) el.style.minHeight = heights.minHeight;
if (heights.height) el.style.height = heights.height;
if (heights.maxHeight) el.style.maxHeight = heights.maxHeight;
}
setupContainer() {
this.supportEasyMDE = this.container.getAttribute('data-support-easy-mde') === 'true';
this.previewMode = this.container.getAttribute('data-content-mode')!;
this.previewUrl = this.container.getAttribute('data-preview-url')!;
this.previewContext = this.container.getAttribute('data-preview-context')!;
initTextExpander(this.container.querySelector('text-expander')!);
}
setupTextarea() {
this.textarea = this.container.querySelector('.markdown-text-editor')!;
this.textarea._giteaComboMarkdownEditor = this;
this.textarea.id = generateElemId(`_combo_markdown_editor_`);
this.textarea.addEventListener('input', () => triggerEditorContentChanged(this.container));
this.applyEditorHeights(this.textarea, this.options.editorHeights);
if (this.textarea.getAttribute('data-disable-autosize') !== 'true') {
this.textareaAutosize = autosize(this.textarea, {viewportMarginBottom: 130});
}
this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar')!;
this.textareaMarkdownToolbar.setAttribute('for', this.textarea.id);
for (const el of this.textareaMarkdownToolbar.querySelectorAll('.markdown-toolbar-button')) {
// upstream bug: The role code is never executed in base MarkdownButtonElement https://github.com/github/markdown-toolbar-element/issues/70
el.setAttribute('role', 'button');
// the editor usually is in a form, so the buttons should have "type=button", avoiding conflicting with the form's submit.
if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
}
this.buttonMonospace = this.container.querySelector('.markdown-switch-monospace')!;
this.applyMonospace();
this.buttonMonospace.addEventListener('click', (e) => {
e.preventDefault();
const enabled = !localUserSettings.getBoolean('markdown-editor-monospace');
localUserSettings.setBoolean('markdown-editor-monospace', enabled);
applyMonospaceToAllEditors();
});
if (this.supportEasyMDE) {
const easymdeButton = this.container.querySelector('.markdown-switch-easymde')!;
easymdeButton.addEventListener('click', async (e) => {
e.preventDefault();
this.userPreferredEditor = 'easymde';
await this.switchToEasyMDE();
});
}
this.initMarkdownButtonTableAdd();
initTextareaMarkdown(this.textarea);
initTextareaEvents(this.textarea, this.dropzone);
}
async setupDropzone() {
const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container');
if (!dropzoneParentContainer) return;
this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container')!)?.querySelector('.dropzone') ?? null;
if (!this.dropzone) return;
this.attachedDropzoneInst = await initDropzone(this.dropzone);
// dropzone events
// * "processing" means a file is being uploaded
// * "queuecomplete" means all files have been uploaded
this.attachedDropzoneInst.on('processing', () => triggerUploadStateChanged(this.container));
this.attachedDropzoneInst.on('queuecomplete', () => triggerUploadStateChanged(this.container));
}
dropzoneGetFiles() {
if (!this.dropzone) return null;
return Array.from(this.dropzone.querySelectorAll<HTMLInputElement>('.files [name=files]'), (el) => el.value);
}
dropzoneReloadFiles() {
if (!this.dropzone) return;
this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
}
dropzoneSubmitReload() {
if (!this.dropzone) return;
this.attachedDropzoneInst.emit('submit');
this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
}
isUploading() {
if (!this.dropzone) return false;
return this.attachedDropzoneInst.getQueuedFiles().length || this.attachedDropzoneInst.getUploadingFiles().length;
}
setupTab() {
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();
this.tabEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
this.tabPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
panelEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
panelPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
initTabSwitcher(elTabular);
this.tabEditor.addEventListener('click', () => {
requestAnimationFrame(() => {
this.focus();
});
});
this.tabPreviewer.addEventListener('click', async () => {
const formData = new FormData();
formData.append('mode', this.previewMode);
formData.append('context', this.previewContext);
formData.append('text', this.value());
const response = await POST(this.previewUrl, {data: formData});
const data = await response.text();
renderPreviewPanelContent(panelPreviewer, data);
// enable task list checkboxes in preview and sync state back to the editor
for (const checkbox of panelPreviewer.querySelectorAll<HTMLInputElement>('.task-list-item input[type=checkbox]')) {
checkbox.disabled = false;
checkbox.addEventListener('input', () => {
const position = parseInt(checkbox.getAttribute('data-source-position')!) + 1;
const newContent = toggleTasklistCheckbox(this.value(), position, checkbox.checked);
if (newContent === null) {
checkbox.checked = !checkbox.checked;
return;
}
this.value(newContent);
triggerEditorContentChanged(this.container);
});
}
});
}
generateMarkdownTable(rows: number, cols: number): string {
const tableLines = [];
tableLines.push(
`| ${'Header '.repeat(cols).trim().split(' ').join(' | ')} |`,
`| ${'--- '.repeat(cols).trim().split(' ').join(' | ')} |`,
);
for (let i = 0; i < rows; i++) {
tableLines.push(`| ${'Cell '.repeat(cols).trim().split(' ').join(' | ')} |`);
}
return tableLines.join('\n');
}
initMarkdownButtonTableAdd() {
const addTableButton = this.container.querySelector('.markdown-button-table-add')!;
const addTablePanel = this.container.querySelector('.markdown-add-table-panel')!;
// here the tippy can't attach to the button because the button already owns a tippy for tooltip
const addTablePanelTippy = createTippy(addTablePanel, {
content: addTablePanel,
trigger: 'manual',
placement: 'bottom',
hideOnClick: true,
interactive: true,
getReferenceClientRect: () => addTableButton.getBoundingClientRect(),
});
addTableButton.addEventListener('click', () => addTablePanelTippy.show());
addTablePanel.querySelector('.ui.button.primary')!.addEventListener('click', () => {
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('.add-table-rows')!.value);
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('.add-table-cols')!.value);
rows = Math.max(1, Math.min(100, rows));
cols = Math.max(1, Math.min(100, cols));
replaceTextareaSelection(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
addTablePanelTippy.hide();
});
}
switchTabToEditor() {
this.tabEditor!.click(); // when this function is called, the tab must exist
}
prepareEasyMDEToolbarActions() {
this.easyMDEToolbarDefault = [
'bold', 'italic', 'strikethrough', '|', 'heading-1', 'heading-2', 'heading-3',
'heading-bigger', 'heading-smaller', '|', 'code', 'quote', '|', 'gitea-checkbox-empty',
'gitea-checkbox-checked', '|', 'unordered-list', 'ordered-list', '|', 'link', 'image',
'table', 'horizontal-rule', '|', 'gitea-switch-to-textarea',
];
}
parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions: any) {
this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(easyMde, this);
const processed = [];
for (const action of actions) {
const actionButton = this.easyMDEToolbarActions[action];
if (!actionButton) throw new Error(`Unknown EasyMDE toolbar action ${action}`);
processed.push(actionButton);
}
return processed;
}
async switchToUserPreference() {
if (this.userPreferredEditor === 'easymde' && this.supportEasyMDE) {
await this.switchToEasyMDE();
} else {
this.switchToTextarea();
}
}
switchToTextarea() {
if (!this.easyMDE) return;
showElem(this.textareaMarkdownToolbar);
if (this.easyMDE) {
this.easyMDE.toTextArea();
this.easyMDE = null;
}
}
async switchToEasyMDE() {
if (this.easyMDE) return;
const [{default: EasyMDE}] = await Promise.all([
import('easymde'),
import('../../../css/easymde.css'),
]);
const easyMDEOpt: EasyMDE.Options = {
autoDownloadFontAwesome: false,
element: this.textarea,
forceSync: true,
renderingConfig: {singleLineBreaks: false},
indentWithTabs: false,
tabSize: 4,
spellChecker: false,
inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
nativeSpellcheck: true,
...this.options.easyMDEOptions,
};
easyMDEOpt.toolbar = this.parseEasyMDEToolbar(EasyMDE, easyMDEOpt.toolbar ?? this.easyMDEToolbarDefault);
this.easyMDE = new EasyMDE(easyMDEOpt);
this.easyMDE.codemirror.on('change', () => triggerEditorContentChanged(this.container));
this.easyMDE.codemirror.setOption('extraKeys', {
'Cmd-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
'Ctrl-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
Enter: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
cm.execCommand('newlineAndIndent');
}
},
Up: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
return cm.execCommand('goLineUp');
}
},
Down: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
return cm.execCommand('goLineDown');
}
},
});
this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll')!, this.options.editorHeights);
await attachTribute(this.easyMDE.codemirror.getInputField());
if (this.dropzone) {
initEasyMDEPaste(this.easyMDE, this.dropzone);
}
hideElem(this.textareaMarkdownToolbar);
}
value(v?: any) {
if (v === undefined) {
if (this.easyMDE) {
return this.easyMDE.value();
}
return this.textarea.value;
}
if (this.easyMDE) {
this.easyMDE.value(v);
} else {
this.textarea.value = v;
}
this.textareaAutosize?.resizeToFit();
}
focus() {
if (this.easyMDE) {
this.easyMDE.codemirror.focus();
} else {
this.textarea.focus();
}
}
moveCursorToEnd() {
this.textarea.focus();
this.textarea.setSelectionRange(this.textarea.value.length, this.textarea.value.length);
if (this.easyMDE) {
this.easyMDE.codemirror.focus();
this.easyMDE.codemirror.setCursor(this.easyMDE.codemirror.lineCount(), 0);
}
}
get userPreferredEditor(): string {
return localUserSettings.getString(`markdown-editor-${this.previewMode ?? 'default'}`);
}
set userPreferredEditor(s: string) {
localUserSettings.setString(`markdown-editor-${this.previewMode ?? 'default'}`, s);
}
applyMonospace() {
const enabled = localUserSettings.getBoolean('markdown-editor-monospace');
const text = this.buttonMonospace.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text')!;
this.textarea.classList.toggle('tw-font-mono', enabled);
this.buttonMonospace.setAttribute('data-tooltip-content', text);
this.buttonMonospace.setAttribute('aria-checked', String(enabled));
}
}
function applyMonospaceToAllEditors() {
const editors = document.querySelectorAll<ComboMarkdownEditorContainer>('.combo-markdown-editor');
for (const editorContainer of editors) {
const editor = getComboMarkdownEditor(editorContainer);
if (editor) editor.applyMonospace();
}
}
export function getComboMarkdownEditor(el: any): ComboMarkdownEditor | null {
if (!el) return null;
if (el.length) el = el[0];
return el._giteaComboMarkdownEditor;
}
export async function initComboMarkdownEditor(container: HTMLElement, options:ComboMarkdownEditorOptions = {}) {
if (!container) {
throw new Error('initComboMarkdownEditor: container is null');
}
const editor = new ComboMarkdownEditor(container, options);
await editor.init();
return editor;
}
+45
View File
@@ -0,0 +1,45 @@
import {svg} from '../../svg.ts';
import {html, htmlRaw} from '../../utils/html.ts';
import {createElementFromHTML} from '../../utils/dom.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {hideToastsAll} from '../../modules/toast.ts';
const {i18n} = window.config;
type ConfirmModalOptions = {
header?: string;
content?: string;
confirmButtonColor?: 'primary' | 'red' | 'green' | 'blue';
};
export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement {
const headerHtml = header ? html`<div class="header">${header}</div>` : '';
return createElementFromHTML(html`
<div class="ui g-modal-confirm modal">
${htmlRaw(headerHtml)}
<div class="content">${content}</div>
<div class="actions">
<button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button>
<button class="ui ${confirmButtonColor} ok button">${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button>
</div>
</div>
`.trim());
}
export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> {
if (!(modal instanceof HTMLElement)) modal = createConfirmModal(modal);
// hide existing toasts when we need to show a new modal, otherwise the toasts only interfere the UI
// 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) => {
showFomanticModal(modal, {
onApprove() {
resolve(true);
},
onHidden() {
modal.remove();
resolve(false);
},
});
});
}
+48
View File
@@ -0,0 +1,48 @@
import {showElem} from '../../utils/dom.ts';
type CropperOpts = {
container: HTMLElement,
imageSource: HTMLImageElement,
fileInput: HTMLInputElement,
};
async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
const {default: Cropper} = await import('cropperjs');
let currentFileName = '';
let currentFileLastModified = 0;
const cropper = new Cropper(imageSource, {
aspectRatio: 1,
viewMode: 2,
autoCrop: false,
crop() {
const canvas = cropper.getCroppedCanvas();
canvas.toBlob((blob) => {
if (!blob) return;
const croppedFileName = currentFileName.replace(/\.[^.]{3,4}$/, '.png');
const croppedFile = new File([blob], croppedFileName, {type: 'image/png', lastModified: currentFileLastModified});
const dataTransfer = new DataTransfer();
dataTransfer.items.add(croppedFile);
fileInput.files = dataTransfer.files;
});
},
});
fileInput.addEventListener('input', (e) => {
const files = (e.target as HTMLInputElement).files;
if (files?.length) {
currentFileName = files[0].name;
currentFileLastModified = files[0].lastModified;
const fileURL = URL.createObjectURL(files[0]);
imageSource.src = fileURL;
cropper.replace(fileURL);
showElem(container);
}
});
}
export async function initAvatarUploaderWithCropper(fileInput: HTMLInputElement) {
const panel = fileInput.nextElementSibling as HTMLElement;
if (!panel?.matches('.cropper-panel')) throw new Error('Missing cropper panel for avatar uploader');
const imageSource = panel.querySelector<HTMLImageElement>('.cropper-source')!;
await initCompCropper({container: panel, fileInput, imageSource});
}
@@ -0,0 +1,154 @@
import {svg} from '../../svg.ts';
import type EasyMDE from 'easymde';
import type {ComboMarkdownEditor} from './ComboMarkdownEditor.ts';
export function easyMDEToolbarActions(easyMde: typeof EasyMDE, editor: ComboMarkdownEditor): Record<string, Partial<EasyMDE.ToolbarIcon | string>> {
const actions: Record<string, Partial<EasyMDE.ToolbarIcon> | string> = {
'|': '|',
'heading-1': {
action: easyMde.toggleHeading1,
icon: svg('octicon-heading'),
title: 'Heading 1',
},
'heading-2': {
action: easyMde.toggleHeading2,
icon: svg('octicon-heading'),
title: 'Heading 2',
},
'heading-3': {
action: easyMde.toggleHeading3,
icon: svg('octicon-heading'),
title: 'Heading 3',
},
'heading-smaller': {
action: easyMde.toggleHeadingSmaller,
icon: svg('octicon-heading'),
title: 'Decrease Heading',
},
'heading-bigger': {
action: easyMde.toggleHeadingBigger,
icon: svg('octicon-heading'),
title: 'Increase Heading',
},
'bold': {
action: easyMde.toggleBold,
icon: svg('octicon-bold'),
title: 'Bold',
},
'italic': {
action: easyMde.toggleItalic,
icon: svg('octicon-italic'),
title: 'Italic',
},
'strikethrough': {
action: easyMde.toggleStrikethrough,
icon: svg('octicon-strikethrough'),
title: 'Strikethrough',
},
'quote': {
action: easyMde.toggleBlockquote,
icon: svg('octicon-quote'),
title: 'Quote',
},
'code': {
action: easyMde.toggleCodeBlock,
icon: svg('octicon-code'),
title: 'Code',
},
'link': {
action: easyMde.drawLink,
icon: svg('octicon-link'),
title: 'Link',
},
'unordered-list': {
action: easyMde.toggleUnorderedList,
icon: svg('octicon-list-unordered'),
title: 'Unordered List',
},
'ordered-list': {
action: easyMde.toggleOrderedList,
icon: svg('octicon-list-ordered'),
title: 'Ordered List',
},
'image': {
action: easyMde.drawImage,
icon: svg('octicon-image'),
title: 'Image',
},
'table': {
action: easyMde.drawTable,
icon: svg('octicon-table'),
title: 'Table',
},
'horizontal-rule': {
action: easyMde.drawHorizontalRule,
icon: svg('octicon-horizontal-rule'),
title: 'Horizontal Rule',
},
'preview': {
action: easyMde.togglePreview,
icon: svg('octicon-eye'),
title: 'Preview',
},
'fullscreen': {
action: easyMde.toggleFullScreen,
icon: svg('octicon-screen-full'),
title: 'Fullscreen',
},
'side-by-side': {
action: easyMde.toggleSideBySide,
icon: svg('octicon-columns'),
title: 'Side by Side',
},
// gitea's custom actions
'gitea-checkbox-empty': {
action(e) {
const cm = e.codemirror;
cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`);
cm.focus();
},
icon: svg('gitea-empty-checkbox'),
title: 'Add Checkbox (empty)',
},
'gitea-checkbox-checked': {
action(e) {
const cm = e.codemirror;
cm.replaceSelection(`\n- [x] ${cm.getSelection()}`);
cm.focus();
},
icon: svg('octicon-checkbox'),
title: 'Add Checkbox (checked)',
},
'gitea-switch-to-textarea': {
action: () => {
editor.userPreferredEditor = 'textarea';
editor.switchToTextarea();
},
icon: svg('octicon-arrow-switch'),
title: 'Revert to simple textarea',
},
'gitea-code-inline': {
action(e) {
const cm = e.codemirror;
const selection = cm.getSelection();
cm.replaceSelection(`\`${selection}\``);
if (!selection) {
const cursorPos = cm.getCursor();
cm.setCursor(cursorPos.line, cursorPos.ch - 1);
}
cm.focus();
},
icon: svg('octicon-chevron-right'),
title: 'Add Inline Code',
},
};
for (const [key, value] of Object.entries(actions)) {
if (typeof value !== 'string') {
value.name = key;
}
}
return actions;
}
@@ -0,0 +1,203 @@
import {initTextareaMarkdown, markdownHandleIndention, textareaSplitLines} from './EditorMarkdown.ts';
test('textareaSplitLines', () => {
let ret = textareaSplitLines('a\nbc\nd', 0);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 0});
ret = textareaSplitLines('a\nbc\nd', 1);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 1});
ret = textareaSplitLines('a\nbc\nd', 2);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 0});
ret = textareaSplitLines('a\nbc\nd', 3);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 1});
ret = textareaSplitLines('a\nbc\nd', 4);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 2});
ret = textareaSplitLines('a\nbc\nd', 5);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 0});
ret = textareaSplitLines('a\nbc\nd', 6);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 1});
});
test('markdownHandleIndention', () => {
const testInput = (input: string, expected: string | null) => {
const inputPos = input.indexOf('|');
input = input.replaceAll('|', '');
const ret = markdownHandleIndention({value: input, selStart: inputPos, selEnd: inputPos});
if (expected === null) {
expect(ret).toEqual({handled: false});
} else {
const expectedPos = expected.indexOf('|');
expected = expected.replaceAll('|', '');
expect(ret).toEqual({
handled: true,
valueSelection: {value: expected, selStart: expectedPos, selEnd: expectedPos},
});
}
};
testInput(`
a|b
`, `
a
|b
`);
testInput(`
1. a
2. |
`, `
1. a
|
`);
testInput(`
|1. a
`, null); // let browser handle it
testInput(`
1. a
1. b|c
`, `
1. a
2. b
3. |c
`);
testInput(`
2. a
2. b|
1. x
1. y
`, `
1. a
2. b
3. |
1. x
1. y
`);
testInput(`
2. a
2. b
1. x|
1. y
`, `
2. a
2. b
1. x
2. |
3. y
`);
testInput(`
1. a
2. b|
3. c
`, `
1. a
2. b
3. |
4. c
`);
testInput(`
1. a
1. b
2. b
3. b
4. b
1. c|
`, `
1. a
1. b
2. b
3. b
4. b
2. c
3. |
`);
testInput(`
1. a
2. a
3. a
4. a
5. a
6. a
7. a
8. a
9. b|c
`, `
1. a
2. a
3. a
4. a
5. a
6. a
7. a
8. a
9. b
10. |c
`);
// this is a special case, it's difficult to re-format the parent level at the moment, so leave it to the future
testInput(`
1. a
2. b|
3. c
`, `
1. a
1. b
2. |
3. c
`);
});
test('EditorMarkdown', () => {
const textarea = document.createElement('textarea');
initTextareaMarkdown(textarea);
type ValueWithCursor = string | {
value: string;
pos: number;
};
const testInput = (input: ValueWithCursor, result: ValueWithCursor) => {
const inputValue = typeof input === 'string' ? input : input.value;
const inputPos = typeof input === 'string' ? inputValue.length : input.pos;
textarea.value = inputValue;
textarea.setSelectionRange(inputPos, inputPos);
const e = new KeyboardEvent('keydown', {key: 'Enter', cancelable: true});
textarea.dispatchEvent(e);
if (!e.defaultPrevented) textarea.value += '\n'; // simulate default behavior
const expectedValue = typeof result === 'string' ? result : result.value;
const expectedPos = typeof result === 'string' ? expectedValue.length : result.pos;
expect(textarea.value).toEqual(expectedValue);
expect(textarea.selectionStart).toEqual(expectedPos);
};
testInput('-', '-\n');
testInput('1.', '1.\n');
testInput('- ', '');
testInput('1. ', '');
testInput({value: '1. \n2. ', pos: 3}, {value: '\n2. ', pos: 0});
testInput('- x', '- x\n- ');
testInput('1. foo', '1. foo\n2. ');
testInput({value: '1. a\n2. b\n3. c', pos: 4}, {value: '1. a\n2. \n3. b\n4. c', pos: 8});
testInput('- [ ]', '- [ ]\n- ');
testInput('- [ ] foo', '- [ ] foo\n- [ ] ');
testInput('* [x] foo', '* [x] foo\n* [ ] ');
testInput('1. [x] foo', '1. [x] foo\n2. [ ] ');
});
+238
View File
@@ -0,0 +1,238 @@
export const EventEditorContentChanged = 'ce-editor-content-changed';
export function triggerEditorContentChanged(target: HTMLElement) {
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
}
/** replace selected text or insert text by creating a new edit history entry,
* e.g. CTRL-Z works after this */
export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) {
const before = textarea.value.slice(0, textarea.selectionStart);
const after = textarea.value.slice(textarea.selectionEnd);
textarea.focus();
let success = false;
try {
success = document.execCommand('insertText', false, text); // eslint-disable-line @typescript-eslint/no-deprecated
} catch {}
// fall back to regular replacement
if (!success) {
textarea.value = `${before}${text}${after}`;
triggerEditorContentChanged(textarea);
}
}
type TextareaValueSelection = {
value: string;
selStart: number;
selEnd: number;
};
function handleIndentSelection(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
if (selEnd === selStart) return; // do not process when no selection
e.preventDefault();
const lines = textarea.value.split('\n');
const selectedLines = [];
let pos = 0;
for (let i = 0; i < lines.length; i++) {
if (pos > selEnd) break;
if (pos >= selStart) selectedLines.push(i);
pos += lines[i].length + 1;
}
for (const i of selectedLines) {
if (e.shiftKey) {
lines[i] = lines[i].replace(/^(\t| {1,2})/, '');
} else {
lines[i] = ` ${lines[i]}`;
}
}
// re-calculating the selection range
let newSelStart: number | null = null;
let newSelEnd: number | null = null;
pos = 0;
for (let i = 0; i < lines.length; i++) {
if (i === selectedLines[0]) {
newSelStart = pos;
}
if (i === selectedLines[selectedLines.length - 1]) {
newSelEnd = pos + lines[i].length;
break;
}
pos += lines[i].length + 1;
}
textarea.value = lines.join('\n');
textarea.setSelectionRange(newSelStart, newSelEnd);
triggerEditorContentChanged(textarea);
}
type MarkdownHandleIndentionResult = {
handled: boolean;
valueSelection?: TextareaValueSelection;
};
type TextLinesBuffer = {
lines: string[];
lengthBeforePosLine: number;
posLineIndex: number;
inlinePos: number
};
export function textareaSplitLines(value: string, pos: number): TextLinesBuffer {
const lines = value.split('\n');
let lengthBeforePosLine = 0, inlinePos = 0, posLineIndex = 0;
for (; posLineIndex < lines.length; posLineIndex++) {
const lineLength = lines[posLineIndex].length + 1;
if (lengthBeforePosLine + lineLength > pos) {
inlinePos = pos - lengthBeforePosLine;
break;
}
lengthBeforePosLine += lineLength;
}
return {lines, lengthBeforePosLine, posLineIndex, inlinePos};
}
function markdownReformatListNumbers(linesBuf: TextLinesBuffer, indention: string) {
const reDeeperIndention = new RegExp(`^${indention}\\s+`);
const reSameLevel = new RegExp(`^${indention}([0-9]+)\\.`);
let firstLineIdx: number;
for (firstLineIdx = linesBuf.posLineIndex - 1; firstLineIdx >= 0; firstLineIdx--) {
const line = linesBuf.lines[firstLineIdx];
if (!reDeeperIndention.test(line) && !reSameLevel.test(line)) break;
}
firstLineIdx++;
let num = 1;
for (let i = firstLineIdx; i < linesBuf.lines.length; i++) {
const oldLine = linesBuf.lines[i];
const sameLevel = reSameLevel.test(oldLine);
if (!sameLevel && !reDeeperIndention.test(oldLine)) break;
if (sameLevel) {
const newLine = `${indention}${num}.${oldLine.replace(reSameLevel, '')}`;
linesBuf.lines[i] = newLine;
num++;
if (linesBuf.posLineIndex === i) {
// need to correct the cursor inline position if the line length changes
linesBuf.inlinePos += newLine.length - oldLine.length;
linesBuf.inlinePos = Math.max(0, linesBuf.inlinePos);
linesBuf.inlinePos = Math.min(newLine.length, linesBuf.inlinePos);
}
}
}
recalculateLengthBeforeLine(linesBuf);
}
function recalculateLengthBeforeLine(linesBuf: TextLinesBuffer) {
linesBuf.lengthBeforePosLine = 0;
for (let i = 0; i < linesBuf.posLineIndex; i++) {
linesBuf.lengthBeforePosLine += linesBuf.lines[i].length + 1;
}
}
export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHandleIndentionResult {
const unhandled: MarkdownHandleIndentionResult = {handled: false};
if (tvs.selEnd !== tvs.selStart) return unhandled; // do not process when there is a selection
const linesBuf = textareaSplitLines(tvs.value, tvs.selStart);
const line = linesBuf.lines[linesBuf.posLineIndex] ?? '';
if (!line) return unhandled; // if the line is empty, do nothing, let the browser handle it
// parse the indention
let lineContent = line;
const indention = (/^\s*/.exec(lineContent) || [''])[0];
lineContent = lineContent.slice(indention.length);
if (linesBuf.inlinePos <= indention.length) return unhandled; // if cursor is at the indention, do nothing, let the browser handle it
// parse the prefixes: "1. ", "- ", "* ", there could also be " [ ] " or " [x] " for task lists
// there must be a space after the prefix because none of "1.foo" / "-foo" is a list item
const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(lineContent);
let prefix = '';
if (prefixMatch) {
prefix = prefixMatch[0];
if (prefix.length > linesBuf.inlinePos) prefix = ''; // do not add new line if cursor is at prefix
}
lineContent = lineContent.slice(prefix.length);
if (!indention && !prefix) return unhandled; // if no indention and no prefix, do nothing, let the browser handle it
if (!lineContent) {
// clear current line if we only have i.e. '1. ' and the user presses enter again to finish creating a list
linesBuf.lines[linesBuf.posLineIndex] = '';
linesBuf.inlinePos = 0;
} else {
// start a new line with the same indention
let newPrefix = prefix;
if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`;
newPrefix = newPrefix.replace('[x]', '[ ]');
const inlinePos = linesBuf.inlinePos;
linesBuf.lines[linesBuf.posLineIndex] = line.substring(0, inlinePos);
const newLineLeft = `${indention}${newPrefix}`;
const newLine = `${newLineLeft}${line.substring(inlinePos)}`;
linesBuf.lines.splice(linesBuf.posLineIndex + 1, 0, newLine);
linesBuf.posLineIndex++;
linesBuf.inlinePos = newLineLeft.length;
recalculateLengthBeforeLine(linesBuf);
}
markdownReformatListNumbers(linesBuf, indention);
const newPos = linesBuf.lengthBeforePosLine + linesBuf.inlinePos;
return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
}
function handleNewline(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
if (e.isComposing) return;
const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd});
if (!ret.handled || !ret.valueSelection) return; // FIXME: the "handled" seems redundant, only valueSelection is enough (null for unhandled)
e.preventDefault();
textarea.value = ret.valueSelection.value;
textarea.setSelectionRange(ret.valueSelection.selStart, ret.valueSelection.selEnd);
triggerEditorContentChanged(textarea);
}
// Keys that act as dead keys will not work because the spec dictates that such keys are
// emitted as `Dead` in e.key instead of the actual key.
const pairs = new Map<string, string>([
["'", "'"],
['"', '"'],
['`', '`'],
['(', ')'],
['[', ']'],
['{', '}'],
['<', '>'],
]);
function handlePairCharacter(textarea: HTMLTextAreaElement, e: KeyboardEvent): void {
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
if (selEnd === selStart) return; // do not process when no selection
e.preventDefault();
const inner = textarea.value.substring(selStart, selEnd);
replaceTextareaSelection(textarea, `${e.key}${inner}${pairs.get(e.key)}`);
textarea.setSelectionRange(selStart + 1, selEnd + 1);
}
function isTextExpanderShown(textarea: HTMLElement): boolean {
return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions'));
}
export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
textarea.addEventListener('keydown', (e) => {
if (e.isComposing) return;
if (isTextExpanderShown(textarea)) return;
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
// use Tab/Shift-Tab to indent/unindent the selected lines
handleIndentSelection(textarea, e);
} else if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
// use Enter to insert a new line with the same indention and prefix
handleNewline(textarea, e);
} else if (pairs.has(e.key)) {
handlePairCharacter(textarea, e);
}
});
}
@@ -0,0 +1,14 @@
import {removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
test('removeAttachmentLinksFromMarkdown', () => {
expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b');
expect(removeAttachmentLinksFromMarkdown('a [x](attachments/foo) b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a ![x](attachments/foo) b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a [x](/attachments/foo) b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a ![x](/attachments/foo) b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a <img src="attachments/foo"> b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a <img width="100" src="attachments/foo"> b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo"> b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo" width="100"/> b', 'foo')).toBe('a b');
});
+168
View File
@@ -0,0 +1,168 @@
import {imageInfo} from '../../utils/image.ts';
import {replaceTextareaSelection, triggerEditorContentChanged} from './EditorMarkdown.ts';
import {
DropzoneCustomEventRemovedFile,
DropzoneCustomEventUploadDone,
generateMarkdownLinkForAttachment,
} from '../dropzone.ts';
import {subscribe} from '@github/paste-markdown';
import type CodeMirror from 'codemirror';
import type EasyMDE from 'easymde';
import type Dropzone from '@deltablot/dropzone';
let uploadIdCounter = 0;
export const EventUploadStateChanged = 'ce-upload-state-changed';
export function triggerUploadStateChanged(target: HTMLElement) {
target.dispatchEvent(new CustomEvent(EventUploadStateChanged, {bubbles: true}));
}
function uploadFile(dropzoneEl: HTMLElement, file: File) {
return new Promise((resolve) => {
const curUploadId = uploadIdCounter++;
(file as any)._giteaUploadId = curUploadId;
const dropzoneInst = dropzoneEl.dropzone;
const onUploadDone = ({file}: {file: any}) => {
if (file._giteaUploadId === curUploadId) {
dropzoneInst.off(DropzoneCustomEventUploadDone, onUploadDone);
resolve(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 Dropzone.DropzoneFile);
});
}
class TextareaEditor {
editor: HTMLTextAreaElement;
constructor(editor: HTMLTextAreaElement) {
this.editor = editor;
}
insertPlaceholder(value: string) {
replaceTextareaSelection(this.editor, value);
}
replacePlaceholder(oldVal: string, newVal: string) {
const editor = this.editor;
const startPos = editor.selectionStart;
const endPos = editor.selectionEnd;
if (editor.value.substring(startPos, endPos) === oldVal) {
editor.value = editor.value.substring(0, startPos) + newVal + editor.value.substring(endPos);
editor.selectionEnd = startPos + newVal.length;
} else {
editor.value = editor.value.replace(oldVal, newVal);
editor.selectionEnd -= oldVal.length;
editor.selectionEnd += newVal.length;
}
editor.selectionStart = editor.selectionEnd;
editor.focus();
triggerEditorContentChanged(editor);
}
}
class CodeMirrorEditor {
editor: CodeMirror.EditorFromTextArea;
constructor(editor: CodeMirror.EditorFromTextArea) {
this.editor = editor;
}
insertPlaceholder(value: string) {
const editor = this.editor;
const startPoint = editor.getCursor('start');
const endPoint = editor.getCursor('end');
editor.replaceSelection(value);
endPoint.ch = startPoint.ch + value.length;
editor.setSelection(startPoint, endPoint);
editor.focus();
triggerEditorContentChanged(editor.getTextArea());
}
replacePlaceholder(oldVal: string, newVal: string) {
const editor = this.editor;
const endPoint = editor.getCursor('end');
if (editor.getSelection() === oldVal) {
editor.replaceSelection(newVal);
} else {
editor.setValue(editor.getValue().replace(oldVal, newVal));
}
endPoint.ch -= oldVal.length;
endPoint.ch += newVal.length;
editor.setSelection(endPoint, endPoint);
editor.focus();
triggerEditorContentChanged(editor.getTextArea());
}
}
async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, dropzoneEl: HTMLElement, files: Array<File> | FileList, e: Event) {
e.preventDefault();
for (const file of files) {
const name = file.name.slice(0, file.name.lastIndexOf('.'));
const {width, dppx} = await imageInfo(file);
const placeholder = `[${name}](uploading ...)`;
editor.insertPlaceholder(placeholder);
await uploadFile(dropzoneEl, file); // the "file" will get its "uuid" during the upload
editor.replacePlaceholder(placeholder, generateMarkdownLinkForAttachment(file, {width, dppx}));
}
}
export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) {
text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), '');
text = text.replace(new RegExp(`[<]img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
return text;
}
function getPastedImages(e: ClipboardEvent) {
const images: Array<File> = [];
for (const item of e.clipboardData?.items ?? []) {
if (item.type?.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
images.push(file);
}
}
}
return images;
}
export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
const editor = new CodeMirrorEditor(easyMDE.codemirror as any);
easyMDE.codemirror.on('paste', (_, e) => {
const images = getPastedImages(e);
if (!images.length) return;
handleUploadFiles(editor, dropzoneEl, images, e);
});
easyMDE.codemirror.on('drop', (_, e) => {
if (!e.dataTransfer?.files.length) return;
handleUploadFiles(editor, dropzoneEl, e.dataTransfer.files, e);
});
dropzoneEl.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => {
const oldText = easyMDE.codemirror.getValue();
const newText = removeAttachmentLinksFromMarkdown(oldText, fileUuid);
if (oldText !== newText) easyMDE.codemirror.setValue(newText);
});
}
export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement | null) {
subscribe(textarea); // enable paste features
textarea.addEventListener('paste', (e: ClipboardEvent) => {
const images = getPastedImages(e);
if (images.length && dropzoneEl) {
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e);
}
});
textarea.addEventListener('drop', (e: DragEvent) => {
if (!e.dataTransfer?.files.length) return;
if (!dropzoneEl) return;
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, e.dataTransfer.files, e);
});
dropzoneEl?.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}: {fileUuid: string}) => {
const newText = removeAttachmentLinksFromMarkdown(textarea.value, fileUuid);
if (textarea.value !== newText) textarea.value = newText;
});
}
+93
View File
@@ -0,0 +1,93 @@
import {toggleElem} from '../../utils/dom.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {submitFormFetchAction} from '../common-fetch-action.ts';
function nameHasScope(name: string): boolean {
return /.*[^/]\/[^/].*/.test(name);
}
export function initCompLabelEdit(pageSelector: string) {
const pageContent = document.querySelector<HTMLElement>(pageSelector);
if (!pageContent) return;
// for guest view, the modal is not available, the "labels" are read-only
const elModal = pageContent.querySelector<HTMLElement>('#issue-label-edit-modal');
if (!elModal) return;
const elLabelId = elModal.querySelector<HTMLInputElement>('input[name="id"]')!;
const elNameInput = elModal.querySelector<HTMLInputElement>('.label-name-input')!;
const elExclusiveField = elModal.querySelector('.label-exclusive-input-field')!;
const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input')!;
const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning')!;
const elExclusiveOrderField = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input-field')!;
const elExclusiveOrderInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input')!;
const elIsArchivedField = elModal.querySelector('.label-is-archived-input-field')!;
const elIsArchivedInput = elModal.querySelector<HTMLInputElement>('.label-is-archived-input')!;
const elDescInput = elModal.querySelector<HTMLInputElement>('.label-desc-input')!;
const elColorInput = elModal.querySelector<HTMLInputElement>('.color-picker-combo input')!;
const syncModalUi = () => {
const hasScope = nameHasScope(elNameInput.value);
elExclusiveField.classList.toggle('disabled', !hasScope);
const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive');
toggleElem(elExclusiveWarning, showExclusiveWarning);
if (!hasScope) elExclusiveInput.checked = false;
toggleElem(elExclusiveOrderField, elExclusiveInput.checked);
if (parseInt(elExclusiveOrderInput.value) <= 0) {
elExclusiveOrderInput.style.color = 'var(--color-placeholder-text) !important';
} else {
elExclusiveOrderInput.style.removeProperty('color');
}
};
const showLabelEditModal = (btn:HTMLElement) => {
// the "btn" should contain the label's attributes by its `data-label-xxx` attributes
const form = elModal.querySelector<HTMLFormElement>('form')!;
elLabelId.value = btn.getAttribute('data-label-id') || '';
elNameInput.value = btn.getAttribute('data-label-name') || '';
elExclusiveOrderInput.value = btn.getAttribute('data-label-exclusive-order') || '0';
elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true';
elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true';
elDescInput.value = btn.getAttribute('data-label-description') || '';
elColorInput.value = btn.getAttribute('data-label-color') || '';
elColorInput.dispatchEvent(new Event('input', {bubbles: true})); // trigger the color picker
// if label id exists: "edit label" mode; otherwise: "new label" mode
const isEdit = Boolean(elLabelId.value);
// if a label was not exclusive but has issues, then it should warn user if it will become exclusive
const numIssues = parseInt(btn.getAttribute('data-label-num-issues') || '0');
elModal.toggleAttribute('data-need-warn-exclusive', !elExclusiveInput.checked && numIssues > 0);
elModal.querySelector('.header')!.textContent = isEdit ? elModal.getAttribute('data-text-edit-label') : elModal.getAttribute('data-text-new-label');
const curPageLink = elModal.getAttribute('data-current-page-link');
form.action = isEdit ? `${curPageLink}/edit` : `${curPageLink}/new`;
toggleElem(elIsArchivedField, isEdit);
syncModalUi();
showFomanticModal(elModal, {
onApprove() {
if (!form.checkValidity()) {
form.reportValidity();
return false;
}
submitFormFetchAction(form);
return false;
},
});
};
elModal.addEventListener('input', () => syncModalUi());
// theoretically, if the modal exists, the "new label" button should also exist, just in case it doesn't, use "?."
const elNewLabel = pageContent.querySelector<HTMLElement>('.ui.button.new-label');
elNewLabel?.addEventListener('click', () => showLabelEditModal(elNewLabel));
const elEditLabelButtons = pageContent.querySelectorAll<HTMLElement>('.edit-label-button');
for (const btn of elEditLabelButtons) {
btn.addEventListener('click', (e) => {
e.preventDefault();
showLabelEditModal(btn);
});
}
}
+25
View File
@@ -0,0 +1,25 @@
import {querySingleVisibleElem} from '../../utils/dom.ts';
export function handleGlobalEnterQuickSubmit(target: HTMLElement) {
let form = target.closest('form');
if (form) {
if (!form.checkValidity()) {
form.reportValidity();
} else {
// here use the event to trigger the submit event (instead of calling `submit()` method directly)
// otherwise the `areYouSure` handler won't be executed, then there will be an annoying "confirm to leave" dialog
form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true}));
}
return true;
}
form = target.closest('.ui.form');
if (form) {
// A form should only have at most one "primary" button to do quick-submit.
// Here we don't use a special class to mark the primary button,
// because there could be a lot of forms with a primary button, the quick submit should work out-of-box,
// but not keeps asking developers to add that special class again and again (it could be forgotten easily)
querySingleVisibleElem<HTMLButtonElement>(form, '.ui.primary.button')?.click();
return true;
}
return false;
}
@@ -0,0 +1,30 @@
import {POST} from '../../modules/fetch.ts';
import {registerGlobalEventFunc} from '../../modules/observer.ts';
export function initCompReactionSelector() {
registerGlobalEventFunc('click', 'onCommentReactionButtonClick', async (target: HTMLElement, e: Event) => {
// there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment
e.preventDefault();
if (target.classList.contains('disabled')) return;
const actionUrl = target.closest('[data-action-url]')!.getAttribute('data-action-url');
const reactionContent = target.getAttribute('data-reaction-content')!;
const commentContainer = target.closest('.comment-container')!;
const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction
const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`);
const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true';
const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, {
data: new URLSearchParams({content: reactionContent}),
});
const data = await res.json();
bottomReactions?.remove();
if (data.html) {
commentContainer.insertAdjacentHTML('beforeend', data.html);
}
});
}
+16
View File
@@ -0,0 +1,16 @@
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');
let url = `${appSubUrl}/repo/search?q={query}&uid=${uid}`;
if (exclusive === 'true') url += `&exclusive=true`;
attachSearchBox(el, url, (response: RepoSearchResponse) => response.data.map((item) => ({
title: item.repository.full_name.split('/')[1],
description: item.repository.full_name,
})));
}
+30
View File
@@ -0,0 +1,30 @@
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 box = document.querySelector<HTMLElement>('#search-user-box');
if (!box) return;
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}`;
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;
});
}
+132
View File
@@ -0,0 +1,132 @@
import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
import {emojiString} from '../emoji.ts';
import {svg} from '../../svg.ts';
import {parseIssueHref, parseRepoOwnerPathInfo} from '../../utils.ts';
import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
import {getIssueColorClass, getIssueIcon} from '../issue.ts';
import {debounce} from 'perfect-debounce';
import type TextExpanderElement from '@github/text-expander-element';
import type {TextExpanderChangeEvent, TextExpanderResult} from '@github/text-expander-element';
async function fetchIssueSuggestions(key: string, text: string): Promise<TextExpanderResult> {
const issuePathInfo = parseIssueHref(window.location.href);
if (!issuePathInfo.ownerName) {
const repoOwnerPathInfo = parseRepoOwnerPathInfo(window.location.pathname);
issuePathInfo.ownerName = repoOwnerPathInfo.ownerName;
issuePathInfo.repoName = repoOwnerPathInfo.repoName;
// then no issuePathInfo.indexString here, it is only used to exclude the current issue when "matchIssue"
}
if (!issuePathInfo.ownerName) return {matched: false};
const matches = await matchIssue(issuePathInfo.ownerName, issuePathInfo.repoName, issuePathInfo.indexString, text);
if (!matches.length) return {matched: false};
const ul = createElementFromAttrs('ul', {class: 'suggestions'});
for (const issue of matches) {
const li = createElementFromAttrs(
'li', {role: 'option', class: 'tw-flex tw-gap-2', 'data-value': `${key}${issue.number}`},
createElementFromHTML(svg(getIssueIcon(issue), 16, [getIssueColorClass(issue)])),
createElementFromAttrs('span', null, `#${issue.number}`),
createElementFromAttrs('span', null, issue.title),
);
ul.append(li);
}
return {matched: true, fragment: ul};
}
export function initTextExpander(expander: TextExpanderElement) {
if (!expander) return;
const textarea = expander.querySelector<HTMLTextAreaElement>('textarea')!;
const mentionsUrl = expander.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
// help to fix the text-expander "multiword+promise" bug: do not show the popup when there is no "#" before current line
const shouldShowIssueSuggestions = () => {
const posVal = textarea.value.substring(0, textarea.selectionStart);
const lineStart = posVal.lastIndexOf('\n');
const keyStart = posVal.lastIndexOf('#');
return keyStart > lineStart;
};
const debouncedIssueSuggestions = debounce(async (key: string, text: string): Promise<TextExpanderResult> => {
// https://github.com/github/text-expander-element/issues/71
// Upstream bug: when using "multiword+promise", TextExpander will get wrong "key" position.
// To reproduce, comment out the "shouldShowIssueSuggestions" check, use the "await sleep" below,
// then use content "close #20\nclose #20\nclose #20" (3 lines), keep changing the last line `#20` part from the end (including removing the `#`)
// There will be a JS error: Uncaught (in promise) IndexSizeError: Failed to execute 'setStart' on 'Range': The offset 28 is larger than the node's length (27).
// check the input before the request, to avoid emitting empty query to backend (still related to the upstream bug)
if (!shouldShowIssueSuggestions()) return {matched: false};
// await sleep(Math.random() * 1000); // help to reproduce the text-expander bug
const ret = await fetchIssueSuggestions(key, text);
// check the input again to avoid text-expander using incorrect position (upstream bug)
if (!shouldShowIssueSuggestions()) return {matched: false};
return ret;
}, 300); // to match onInputDebounce delay
expander.addEventListener('text-expander-change', (e: TextExpanderChangeEvent) => {
if (!e.detail) return;
const {key, text, provide} = e.detail;
if (key === ':') {
const matches = matchEmoji(text);
if (!matches.length) return provide({matched: false});
const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const name of matches) {
const emoji = emojiString(name);
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('data-value', emoji);
li.textContent = `${emoji} ${name}`;
ul.append(li);
}
provide({matched: true, fragment: ul});
} else if (key === '@') {
provide((async (): Promise<TextExpanderResult> => {
if (!mentionsUrl) return {matched: false};
const matches = await matchMention(mentionsUrl, text);
if (!matches.length) return {matched: false};
const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const {value, name, fullname, avatar} of matches) {
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('data-value', `${key}${value}`);
const img = document.createElement('img');
img.src = avatar;
li.append(img);
const nameSpan = document.createElement('span');
nameSpan.classList.add('name');
nameSpan.textContent = name;
li.append(nameSpan);
if (fullname && fullname.toLowerCase() !== name) {
const fullnameSpan = document.createElement('span');
fullnameSpan.classList.add('fullname');
fullnameSpan.textContent = fullname;
li.append(fullnameSpan);
}
ul.append(li);
}
return {matched: true, fragment: ul};
})());
} else if (key === '#') {
provide(debouncedIssueSuggestions(key, text));
}
});
expander.addEventListener('text-expander-value', ({detail}: Record<string, any>) => {
if (detail?.item) {
// add a space after @mentions and #issue as it's likely the user wants one
const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
detail.value = `${detail.item.getAttribute('data-value')}${suffix}`;
}
});
}
+42
View File
@@ -0,0 +1,42 @@
import {POST} from '../../modules/fetch.ts';
import {hideElem, showElem, toggleElem} from '../../utils/dom.ts';
export function initCompWebHookEditor() {
if (!document.querySelectorAll('.new.webhook').length) {
return;
}
for (const input of document.querySelectorAll<HTMLInputElement>('.events.checkbox input')) {
input.addEventListener('change', function () {
if (this.checked) {
showElem('.events.fields');
}
});
}
for (const input of document.querySelectorAll<HTMLInputElement>('.non-events.checkbox input')) {
input.addEventListener('change', function () {
if (this.checked) {
hideElem('.events.fields');
}
});
}
// some webhooks (like Gitea) allow to set the request method (GET/POST), and it would toggle the "Content Type" field
const httpMethodInput = document.querySelector<HTMLInputElement>('#http_method');
if (httpMethodInput) {
const updateContentType = function () {
const visible = httpMethodInput.value === 'POST';
toggleElem(document.querySelector('#content_type')!.closest('.field')!, visible);
};
updateContentType();
httpMethodInput.addEventListener('change', updateContentType);
}
// Test delivery
document.querySelector<HTMLButtonElement>('#test-delivery')?.addEventListener('click', async function () {
this.classList.add('is-loading', 'disabled');
await POST(this.getAttribute('data-link')!);
setTimeout(() => window.location.reload(), 5000);
});
}
+31
View File
@@ -0,0 +1,31 @@
import {createApp} from 'vue';
export async function initRepoContributors() {
const el = document.querySelector('#repo-contributors-chart');
if (!el) return;
const {default: RepoContributors} = await import('../components/RepoContributors.vue');
try {
const View = createApp(RepoContributors, {
repoLink: el.getAttribute('data-repo-link'),
repoDefaultBranchName: el.getAttribute('data-repo-default-branch-name'),
locale: {
filterLabel: el.getAttribute('data-locale-filter-label'),
contributionType: {
commits: el.getAttribute('data-locale-contribution-type-commits'),
additions: el.getAttribute('data-locale-contribution-type-additions'),
deletions: el.getAttribute('data-locale-contribution-type-deletions'),
},
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);
} catch (err) {
console.error('RepoContributors failed to load', err);
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
}
}
+25
View File
@@ -0,0 +1,25 @@
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
import {convertImage} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
import {registerGlobalEventFunc} from '../modules/observer.ts';
export function initCopyContent() {
registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => {
if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return;
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('');
}
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();
});
});
}
+9
View File
@@ -0,0 +1,9 @@
import {createApp} from 'vue';
import DashboardRepoList from '../components/DashboardRepoList.vue';
export function initDashboardRepoList() {
const el = document.querySelector('#dashboard-repo-list');
if (el) {
createApp(DashboardRepoList).mount(el);
}
}
+161
View File
@@ -0,0 +1,161 @@
import {svg} from '../svg.ts';
import {html} from '../utils/html.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 Dropzone from '@deltablot/dropzone';
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: Dropzone.DropzoneOptions) {
const [{default: Dropzone}] = await Promise.all([
import('@deltablot/dropzone'),
import('@deltablot/dropzone/dist/dropzone.css'),
]);
return new Dropzone(el, opts);
}
export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFile>, {width, dppx}: {width?: number, dppx?: number} = {}) {
let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
if (isImageFile(file)) {
if (width && width > 0 && dppx && dppx > 1) {
// Scale down images from HiDPI monitors. This uses the <img> tag because it's the only
// method to change image size in Markdown that is supported by all implementations.
// Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}"
fileMarkdown = html`<img width="${Math.round(width / dppx)}" alt="${file.name}" src="attachments/${file.uuid}">`;
} else {
// Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}"
// TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments"
fileMarkdown = `![${file.name}](/attachments/${file.uuid})`;
}
} else if (isVideoFile(file)) {
fileMarkdown = html`<video src="attachments/${file.uuid}" title="${file.name}" controls></video>`;
}
return fileMarkdown;
}
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<HTMLDivElement>(`
<div class="tw-text-center">
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
</div>`);
copyLinkEl.addEventListener('click', async (e) => {
e.preventDefault();
await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file));
});
file.previewTemplate!.append(copyLinkEl);
}
type FileUuidDict = Record<string, {submitted: boolean}>;
/**
* @param {HTMLElement} dropzoneEl
*/
export async function initDropzone(dropzoneEl: HTMLElement) {
const listAttachmentsUrl = dropzoneEl.closest('[data-attachment-url]')?.getAttribute('data-attachment-url');
const removeAttachmentUrl = dropzoneEl.getAttribute('data-remove-url')!;
const attachmentBaseLinkUrl = dropzoneEl.getAttribute('data-link-url')!;
let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
let fileUuidDict: FileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone
const opts: Record<string, any> = {
url: dropzoneEl.getAttribute('data-upload-url'),
acceptedFiles: ['*/*', ''].includes(dropzoneEl.getAttribute('data-accepts')!) ? null : dropzoneEl.getAttribute('data-accepts'),
addRemoveLinks: true,
dictDefaultMessage: dropzoneEl.getAttribute('data-default-message'),
dictInvalidFileType: dropzoneEl.getAttribute('data-invalid-input-type'),
dictFileTooBig: dropzoneEl.getAttribute('data-file-too-big'),
dictRemoveFile: dropzoneEl.getAttribute('data-remove-file'),
timeout: 0,
thumbnailMethod: 'contain',
thumbnailWidth: 480,
thumbnailHeight: 480,
};
if (dropzoneEl.hasAttribute('data-max-file')) opts.maxFiles = Number(dropzoneEl.getAttribute('data-max-file'));
if (dropzoneEl.hasAttribute('data-max-size')) opts.maxFilesize = Number(dropzoneEl.getAttribute('data-max-size'));
// there is a bug in dropzone: if a non-image file is uploaded, then it tries to request the file from server by something like:
// "http://localhost:3000/owner/repo/issues/[object%20Event]"
// the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates '<img src="[object Event]">'
const dzInst = await createDropzone(dropzoneEl, opts);
dzInst.on('success', (file: CustomDropzoneFile, resp: any) => {
file.uuid = resp.uuid;
fileUuidDict[file.uuid] = {submitted: false};
const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid});
dropzoneEl.querySelector('.files')!.append(input);
addCopyLink(file);
dzInst.emit(DropzoneCustomEventUploadDone, {file});
});
dzInst.on('removedfile', async (file: CustomDropzoneFile) => {
if (disableRemovedfileEvent) return;
dzInst.emit(DropzoneCustomEventRemovedFile, {fileUuid: file.uuid});
document.querySelector(`#dropzone-file-${file.uuid}`)?.remove();
// when the uploaded file number reaches the limit, there is no uuid in the dict, and it doesn't need to be removed from server
if (removeAttachmentUrl && fileUuidDict[file.uuid] && !fileUuidDict[file.uuid].submitted) {
await POST(removeAttachmentUrl, {data: new URLSearchParams({file: file.uuid})});
}
});
dzInst.on('submit', () => {
for (const fileUuid of Object.keys(fileUuidDict)) {
fileUuidDict[fileUuid].submitted = true;
}
});
dzInst.on(DropzoneCustomEventReloadFiles, async () => {
try {
if (!listAttachmentsUrl) return;
const resp = await GET(listAttachmentsUrl);
const respData = await resp.json();
// do not trigger the "removedfile" event, otherwise the attachments would be deleted from server
disableRemovedfileEvent = true;
dzInst.removeAllFiles(true);
disableRemovedfileEvent = false;
dropzoneEl.querySelector('.files')!.innerHTML = '';
for (const el of dropzoneEl.querySelectorAll('.dz-preview')) el.remove();
fileUuidDict = {};
for (const attachment of respData) {
const file = {name: attachment.name, uuid: attachment.uuid, size: attachment.size};
dzInst.emit('addedfile', file);
dzInst.emit('complete', file);
if (isImageFile(file.name)) {
const imgSrc = `${attachmentBaseLinkUrl}/${file.uuid}`;
dzInst.emit('thumbnail', file, imgSrc);
}
addCopyLink(file); // it is from server response, so no "type"
fileUuidDict[file.uuid] = {submitted: true};
const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${file.uuid}`, value: file.uuid});
dropzoneEl.querySelector('.files')!.append(input);
}
if (!dropzoneEl.querySelector('.dz-preview')) {
dropzoneEl.classList.remove('dz-started');
}
} 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: ${errorMessage(error)}`);
console.error(error);
}
});
dzInst.on('error', (file, message) => {
showErrorToast(`Dropzone upload error: ${message}`);
dzInst.removeFile(file);
});
if (listAttachmentsUrl) dzInst.emit(DropzoneCustomEventReloadFiles);
return dzInst;
}
+38
View File
@@ -0,0 +1,38 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'};
import {html} from '../utils/html.ts';
const {assetUrlPrefix, customEmojis} = window.config;
const tempMap = {...customEmojis};
for (const {emoji, aliases} of emojis) {
for (const alias of aliases || []) {
tempMap[alias] = emoji;
}
}
export const emojiKeys = Object.keys(tempMap).sort((a, b) => {
if (a === '+1' || a === '-1') return -1;
if (b === '+1' || b === '-1') return 1;
return a.localeCompare(b);
});
const emojiMap: Record<string, string> = {};
for (const key of emojiKeys) {
emojiMap[key] = tempMap[key];
}
// retrieve HTML for given emoji name
export function emojiHTML(name: string) {
let inner;
if (Object.hasOwn(customEmojis, name)) {
inner = html`<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
} else {
inner = emojiString(name);
}
return html`<span class="emoji" title=":${name}:">${inner}</span>`;
}
// retrieve string for given emoji name
export function emojiString(name: string) {
return emojiMap[name] || `:${name}:`;
}
+19
View File
@@ -0,0 +1,19 @@
import {svg} from '../svg.ts';
// Hides the file if newFold is true, and shows it otherwise. The actual hiding is performed using CSS.
//
// The fold arrow is the icon displayed on the upper left of the file box, especially intended for components having the 'fold-file' class.
// The file content box is the box that should be hidden or shown, especially intended for components having the 'file-content' class.
//
export function setFileFolding(fileContentBox: Element, foldArrow: HTMLElement, newFold: boolean) {
foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18);
fileContentBox.setAttribute('data-folded', String(newFold));
if (newFold && fileContentBox.getBoundingClientRect().top < 0) {
fileContentBox.scrollIntoView();
}
}
// Like `setFileFolding`, except that it automatically inverts the current file folding state.
export function invertFileFolding(fileContentBox:HTMLElement, foldArrow: HTMLElement) {
setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true');
}
+64
View File
@@ -0,0 +1,64 @@
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';
const inplacePlugins: InplaceRenderPlugin[] = [];
function initInplacePluginsOnce(): void {
if (inplacePlugins.length) return;
inplacePlugins.push(newInplacePluginPdfViewer());
}
function findInplaceRenderPlugin(filename: string, mimeType: string): InplaceRenderPlugin | null {
return inplacePlugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
}
async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) {
const elViewRawPrompt = container.querySelector('.file-view-raw-prompt');
if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container');
let rendered = false, errorMsg = '';
try {
const plugin = findInplaceRenderPlugin(basename(rawFileLink), mimeType);
if (plugin) {
container.classList.add('is-loading');
container.setAttribute('data-render-name', plugin.name); // not used yet
await plugin.render(container, rawFileLink);
rendered = true;
}
} catch (e) {
errorMsg = errorMessage(e);
} finally {
container.classList.remove('is-loading');
}
if (rendered) {
elViewRawPrompt.remove();
return;
}
// remove all children from the container, and only show the raw file link
container.replaceChildren(elViewRawPrompt);
if (errorMsg) {
const elErrorMessage = createElementFromHTML(html`<div class="ui error message">${errorMsg}</div>`);
elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage);
}
}
export function initRepoFileView(): void {
registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => {
initInplacePluginsOnce();
const rawFileLink = elFileView.getAttribute('data-raw-file-link')!;
const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet
const plugin = findInplaceRenderPlugin(basename(rawFileLink), mimeType);
if (!plugin) return;
const renderContainer = elFileView.querySelector<HTMLElement>('.file-view-render-container');
if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType);
});
}
+56
View File
@@ -0,0 +1,56 @@
import {createApp} from 'vue';
import {translateMonth, translateDay} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
type HeatmapResponse = {
heatmapData: Array<[number, number]>; // [[1617235200, 2]] = [unix timestamp, count]
totalContributions: number;
};
export async function initHeatmap() {
const el = document.querySelector<HTMLElement>('#user-heatmap');
if (!el) return;
try {
const url = el.getAttribute('data-heatmap-url')!;
const resp = await GET(url);
if (!resp.ok) throw new Error(`Failed to load heatmap data: ${resp.status} ${resp.statusText}`);
const {heatmapData, totalContributions} = await resp.json() as HeatmapResponse;
const heatmap: Record<string, number> = {};
for (const [timestamp, contributions] of heatmapData) {
// Convert to user timezone and sum contributions by date
const dateStr = new Date(timestamp * 1000).toDateString();
heatmap[dateStr] = (heatmap[dateStr] || 0) + contributions;
}
const values = Object.keys(heatmap).map((v) => {
return {date: new Date(v), count: heatmap[v]};
});
const totalFormatted = totalContributions.toLocaleString();
const textTotalContributions = el.getAttribute('data-locale-total-contributions')!.replace('%s', totalFormatted);
// last heatmap tooltip localization attempt https://github.com/go-gitea/gitea/pull/24131/commits/a83761cbbae3c2e3b4bced71e680f44432073ac8
const locale = {
heatMapLocale: {
months: new Array(12).fill(undefined).map((_, idx) => translateMonth(idx)),
days: new Array(7).fill(undefined).map((_, idx) => translateDay(idx)),
on: ' - ', // no correct locale support for it, because in many languages the sentence is not "something on someday"
more: el.getAttribute('data-locale-more'),
less: el.getAttribute('data-locale-less'),
},
tooltipUnit: 'contributions',
textTotalContributions,
noDataText: el.getAttribute('data-locale-no-contributions'),
};
const {default: ActivityHeatmap} = await import('../components/ActivityHeatmap.vue');
const View = createApp(ActivityHeatmap, {values, locale});
View.mount(el);
el.classList.remove('is-loading');
} catch (err) {
console.error('Heatmap failed to load', err);
el.textContent = 'Heatmap failed to load';
}
}
+307
View File
@@ -0,0 +1,307 @@
import {GET} from '../modules/fetch.ts';
import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts';
import {parseDom} from '../utils.ts';
type ImageContext = {
imageBefore: HTMLImageElement | undefined,
imageAfter: HTMLImageElement | undefined,
sizeBefore: {width: number, height: number},
sizeAfter: {width: number, height: number},
maxSize: {width: number, height: number},
ratio: [number, number, number, number],
};
type ImageInfo = {
path: string | null,
mime: string | null,
images: NodeListOf<HTMLImageElement>,
boundsInfo: HTMLElement | null,
};
type Bounds = {
width: number,
height: number,
} | null;
type SvgBoundsInfo = {
before: Bounds,
after: Bounds,
};
function getDefaultSvgBoundsIfUndefined(text: string, src: string): Bounds | null {
const defaultSize = 300;
const maxSize = 99999;
const svgDoc = parseDom(text, 'image/svg+xml');
const svg = (svgDoc.documentElement as unknown) as SVGSVGElement;
const width = svg?.width?.baseVal;
const height = svg?.height?.baseVal;
if (width === undefined || height === undefined) {
return null; // in case some svg is invalid or doesn't have the width/height
}
if (width.unitType === SVGLength.SVG_LENGTHTYPE_PERCENTAGE || height.unitType === SVGLength.SVG_LENGTHTYPE_PERCENTAGE) {
const img = new Image();
img.src = src;
if (img.width > 1 && img.width < maxSize && img.height > 1 && img.height < maxSize) {
return {
width: img.width,
height: img.height,
};
}
if (svg.hasAttribute('viewBox')) {
const viewBox = svg.viewBox.baseVal;
return {
width: defaultSize,
height: defaultSize * viewBox.height / viewBox.width,
};
}
return {
width: defaultSize,
height: defaultSize,
};
}
return null;
}
function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageElement, svgBoundsInfo: SvgBoundsInfo): ImageContext {
const sizeAfter = {
width: svgBoundsInfo.after?.width || imageAfter?.width || 0,
height: svgBoundsInfo.after?.height || imageAfter?.height || 0,
};
const sizeBefore = {
width: svgBoundsInfo.before?.width || imageBefore?.width || 0,
height: svgBoundsInfo.before?.height || imageBefore?.height || 0,
};
const maxSize = {
width: Math.max(sizeBefore.width, sizeAfter.width),
height: Math.max(sizeBefore.height, sizeAfter.height),
};
return {
imageAfter,
imageBefore,
sizeAfter,
sizeBefore,
maxSize,
ratio: [
Math.floor(maxSize.width - sizeAfter.width) / 2,
Math.floor(maxSize.height - sizeAfter.height) / 2,
Math.floor(maxSize.width - sizeBefore.width) / 2,
Math.floor(maxSize.height - sizeBefore.height) / 2,
],
};
}
class ImageDiff {
containerEl!: HTMLElement;
diffContainerWidth!: number;
async init(containerEl: HTMLElement) {
this.containerEl = containerEl;
containerEl.setAttribute('data-image-diff-loaded', 'true');
// 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);
const imagePair: [ImageInfo, ImageInfo] = [{
path: containerEl.getAttribute('data-path-after'),
mime: containerEl.getAttribute('data-mime-after'),
images: containerEl.querySelectorAll<HTMLImageElement>('img.image-after'), // matches 3 <img>
boundsInfo: containerEl.querySelector('.bounds-info-after'),
}, {
path: containerEl.getAttribute('data-path-before'),
mime: containerEl.getAttribute('data-mime-before'),
images: containerEl.querySelectorAll<HTMLImageElement>('img.image-before'), // matches 3 <img>
boundsInfo: containerEl.querySelector('.bounds-info-before'),
}];
const svgBoundsInfo: SvgBoundsInfo = {before: null, after: null};
await Promise.all(imagePair.map(async (info, index) => {
const [success] = await Promise.all(Array.from(info.images, (img) => {
return loadElem(img, info.path!);
}));
// only the first images is associated with boundsInfo
if (!success && info.boundsInfo) info.boundsInfo.textContent = '(image error)';
if (info.mime === 'image/svg+xml') {
const resp = await GET(info.path!);
const text = await resp.text();
const bounds = getDefaultSvgBoundsIfUndefined(text, info.path!);
svgBoundsInfo[index === 0 ? 'after' : 'before'] = bounds;
if (bounds) {
hideElem(info.boundsInfo!);
}
}
}));
const imagesAfter = imagePair[0].images;
const imagesBefore = imagePair[1].images;
this.initSideBySide(createContext(imagesAfter[0], imagesBefore[0], svgBoundsInfo));
if (imagesAfter.length > 0 && imagesBefore.length > 0) {
this.initSwipe(createContext(imagesAfter[1], imagesBefore[1], svgBoundsInfo));
this.initOverlay(createContext(imagesAfter[2], imagesBefore[2], svgBoundsInfo));
}
queryElemChildren(containerEl, '.image-diff-tabs', (el) => el.classList.remove('is-loading'));
}
initSideBySide(ctx: ImageContext) {
let factor = 1;
if (ctx.maxSize.width > (this.diffContainerWidth - 24) / 2) {
factor = (this.diffContainerWidth - 24) / 2 / ctx.maxSize.width;
}
const widthChanged = ctx.imageAfter && ctx.imageBefore && ctx.imageAfter.naturalWidth !== ctx.imageBefore.naturalWidth;
const heightChanged = ctx.imageAfter && ctx.imageBefore && ctx.imageAfter.naturalHeight !== ctx.imageBefore.naturalHeight;
if (ctx.imageAfter) {
const boundsInfoAfterWidth = this.containerEl.querySelector('.bounds-info-after .bounds-info-width');
if (boundsInfoAfterWidth) {
boundsInfoAfterWidth.textContent = `${ctx.imageAfter.naturalWidth}px`;
boundsInfoAfterWidth.classList.toggle('green', widthChanged);
}
const boundsInfoAfterHeight = this.containerEl.querySelector('.bounds-info-after .bounds-info-height');
if (boundsInfoAfterHeight) {
boundsInfoAfterHeight.textContent = `${ctx.imageAfter.naturalHeight}px`;
boundsInfoAfterHeight.classList.toggle('green', heightChanged);
}
}
if (ctx.imageBefore) {
const boundsInfoBeforeWidth = this.containerEl.querySelector('.bounds-info-before .bounds-info-width');
if (boundsInfoBeforeWidth) {
boundsInfoBeforeWidth.textContent = `${ctx.imageBefore.naturalWidth}px`;
boundsInfoBeforeWidth.classList.toggle('red', widthChanged);
}
const boundsInfoBeforeHeight = this.containerEl.querySelector('.bounds-info-before .bounds-info-height');
if (boundsInfoBeforeHeight) {
boundsInfoBeforeHeight.textContent = `${ctx.imageBefore.naturalHeight}px`;
boundsInfoBeforeHeight.classList.toggle('red', heightChanged);
}
}
if (ctx.imageAfter) {
const container = ctx.imageAfter.parentNode as HTMLElement;
ctx.imageAfter.style.width = `${ctx.sizeAfter.width * factor}px`;
ctx.imageAfter.style.height = `${ctx.sizeAfter.height * factor}px`;
container.style.margin = '10px auto';
container.style.width = `${ctx.sizeAfter.width * factor + 2}px`;
container.style.height = `${ctx.sizeAfter.height * factor + 2}px`;
}
if (ctx.imageBefore) {
const container = ctx.imageBefore.parentNode as HTMLElement;
ctx.imageBefore.style.width = `${ctx.sizeBefore.width * factor}px`;
ctx.imageBefore.style.height = `${ctx.sizeBefore.height * factor}px`;
container.style.margin = '10px auto';
container.style.width = `${ctx.sizeBefore.width * factor + 2}px`;
container.style.height = `${ctx.sizeBefore.height * factor + 2}px`;
}
}
initSwipe(ctx: ImageContext) {
let factor = 1;
if (ctx.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / ctx.maxSize.width;
}
if (ctx.imageAfter) {
const imgParent = ctx.imageAfter.parentNode as HTMLElement;
const swipeFrame = imgParent.parentNode as HTMLElement;
ctx.imageAfter.style.width = `${ctx.sizeAfter.width * factor}px`;
ctx.imageAfter.style.height = `${ctx.sizeAfter.height * factor}px`;
imgParent.style.margin = `0px ${ctx.ratio[0] * factor}px`;
imgParent.style.width = `${ctx.sizeAfter.width * factor + 2}px`;
imgParent.style.height = `${ctx.sizeAfter.height * factor + 2}px`;
swipeFrame.style.padding = `${ctx.ratio[1] * factor}px 0 0 0`;
swipeFrame.style.width = `${ctx.maxSize.width * factor + 2}px`;
}
if (ctx.imageBefore) {
const imgParent = ctx.imageBefore.parentNode as HTMLElement;
const swipeFrame = imgParent.parentNode as HTMLElement;
ctx.imageBefore.style.width = `${ctx.sizeBefore.width * factor}px`;
ctx.imageBefore.style.height = `${ctx.sizeBefore.height * factor}px`;
imgParent.style.margin = `${ctx.ratio[3] * factor}px ${ctx.ratio[2] * factor}px`;
imgParent.style.width = `${ctx.sizeBefore.width * factor + 2}px`;
imgParent.style.height = `${ctx.sizeBefore.height * factor + 2}px`;
swipeFrame.style.width = `${ctx.maxSize.width * factor + 2}px`;
swipeFrame.style.height = `${ctx.maxSize.height * factor + 2}px`;
}
// extra height for inner "position: absolute" elements
const swipe = this.containerEl.querySelector<HTMLElement>('.diff-swipe');
if (swipe) {
swipe.style.width = `${ctx.maxSize.width * factor + 2}px`;
swipe.style.height = `${ctx.maxSize.height * factor + 30}px`;
}
this.containerEl.querySelector('.swipe-bar')!.addEventListener('mousedown', (e) => {
e.preventDefault();
this.initSwipeEventListeners(e.currentTarget as HTMLElement);
});
}
initSwipeEventListeners(swipeBar: HTMLElement) {
const swipeFrame = swipeBar.parentNode as HTMLElement;
const width = swipeFrame.clientWidth;
const onSwipeMouseMove = (e: MouseEvent) => {
e.preventDefault();
const rect = swipeFrame.getBoundingClientRect();
const value = Math.max(0, Math.min(e.clientX - rect.left, width));
swipeBar.style.left = `${value}px`;
this.containerEl.querySelector<HTMLElement>('.swipe-container')!.style.width = `${swipeFrame.clientWidth - value}px`;
};
const removeEventListeners = () => {
document.removeEventListener('mousemove', onSwipeMouseMove);
document.removeEventListener('mouseup', removeEventListeners);
};
document.addEventListener('mousemove', onSwipeMouseMove);
document.addEventListener('mouseup', removeEventListeners);
}
initOverlay(ctx: ImageContext) {
let factor = 1;
if (ctx.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / ctx.maxSize.width;
}
if (ctx.imageAfter) {
const container = ctx.imageAfter.parentNode as HTMLElement;
ctx.imageAfter.style.width = `${ctx.sizeAfter.width * factor}px`;
ctx.imageAfter.style.height = `${ctx.sizeAfter.height * factor}px`;
container.style.margin = `${ctx.ratio[1] * factor}px ${ctx.ratio[0] * factor}px`;
container.style.width = `${ctx.sizeAfter.width * factor + 2}px`;
container.style.height = `${ctx.sizeAfter.height * factor + 2}px`;
}
if (ctx.imageBefore) {
const container = ctx.imageBefore.parentNode as HTMLElement;
const overlayFrame = container.parentNode as HTMLElement;
ctx.imageBefore.style.width = `${ctx.sizeBefore.width * factor}px`;
ctx.imageBefore.style.height = `${ctx.sizeBefore.height * factor}px`;
container.style.margin = `${ctx.ratio[3] * factor}px ${ctx.ratio[2] * factor}px`;
container.style.width = `${ctx.sizeBefore.width * factor + 2}px`;
container.style.height = `${ctx.sizeBefore.height * factor + 2}px`;
// some inner elements are `position: absolute`, so the container's height must be large enough
overlayFrame.style.width = `${ctx.maxSize.width * factor + 2}px`;
overlayFrame.style.height = `${ctx.maxSize.height * factor + 2}px`;
}
const rangeInput = this.containerEl.querySelector<HTMLInputElement>('input[type="range"]')!;
function updateOpacity() {
if (ctx.imageAfter) {
(ctx.imageAfter.parentNode as HTMLElement).style.opacity = `${Number(rangeInput.value) / 100}`;
}
}
rangeInput?.addEventListener('input', updateOpacity);
updateOpacity();
}
}
export function initImageDiff() {
for (const el of queryElems<HTMLImageElement>(document, '.image-diff:not([data-image-diff-loaded])')) {
(new ImageDiff()).init(el); // it is async, but we don't need to await for it
}
}
+102
View File
@@ -0,0 +1,102 @@
import {hideElem, showElem} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
export function initInstall() {
const page = document.querySelector('.page-content.install');
if (!page) {
return;
}
if (page.classList.contains('post-install')) {
initPostInstall();
} else {
initPreInstall();
}
}
function initPreInstall() {
const defaultDbUser = 'gitea';
const defaultDbName = 'gitea';
const defaultDbHosts: Record<string, string> = {
mysql: '127.0.0.1:3306',
postgres: '127.0.0.1:5432',
mssql: '127.0.0.1:1433',
};
const dbHost = document.querySelector<HTMLInputElement>('#db_host')!;
const dbUser = document.querySelector<HTMLInputElement>('#db_user')!;
const dbName = document.querySelector<HTMLInputElement>('#db_name')!;
// Database type change detection.
document.querySelector<HTMLInputElement>('#db_type')!.addEventListener('change', function () {
const dbType = this.value;
hideElem('div[data-db-setting-for]');
showElem(`div[data-db-setting-for=${dbType}]`);
if (dbType !== 'sqlite3') {
// for most remote database servers
showElem('div[data-db-setting-for=common-host]');
const lastDbHost = dbHost.value;
const isDbHostDefault = !lastDbHost || Object.values(defaultDbHosts).includes(lastDbHost);
if (isDbHostDefault) {
dbHost.value = defaultDbHosts[dbType] ?? '';
}
if (!dbUser.value && !dbName.value) {
dbUser.value = defaultDbUser;
dbName.value = defaultDbName;
}
} // else: for SQLite3, the default path is always prepared by backend code (setting)
});
document.querySelector('#db_type')!.dispatchEvent(new Event('change'));
const appUrl = document.querySelector<HTMLInputElement>('#app_url')!;
if (appUrl.value.includes('://localhost')) {
appUrl.value = window.location.href;
}
const domain = document.querySelector<HTMLInputElement>('#domain')!;
if (domain.value.trim() === 'localhost') {
domain.value = window.location.hostname;
}
// TODO: better handling of exclusive relations.
document.querySelector<HTMLInputElement>('#enable-openid-signin input')!.addEventListener('change', function () {
if (this.checked) {
if (!document.querySelector<HTMLInputElement>('#disable-registration input')!.checked) {
document.querySelector<HTMLInputElement>('#enable-openid-signup input')!.checked = true;
}
} else {
document.querySelector<HTMLInputElement>('#enable-openid-signup input')!.checked = false;
}
});
document.querySelector<HTMLInputElement>('#disable-registration input')!.addEventListener('change', function () {
if (this.checked) {
document.querySelector<HTMLInputElement>('#enable-captcha input')!.checked = false;
document.querySelector<HTMLInputElement>('#enable-openid-signup input')!.checked = false;
} else {
document.querySelector<HTMLInputElement>('#enable-openid-signup input')!.checked = true;
}
});
document.querySelector<HTMLInputElement>('#enable-captcha input')!.addEventListener('change', function () {
if (this.checked) {
document.querySelector<HTMLInputElement>('#disable-registration input')!.checked = false;
}
});
}
function initPostInstall() {
const el = document.querySelector('#goto-after-install');
if (!el) return;
const targetUrl = el.getAttribute('href')!;
let tid: ReturnType<typeof setInterval> | null = setInterval(async () => {
try {
const resp = await GET(targetUrl);
if (tid && resp.status === 200) {
clearInterval(tid);
tid = null;
window.location.href = targetUrl;
}
} catch {}
}, 1000);
}
+41
View File
@@ -0,0 +1,41 @@
import type {Issue} from '../types.ts';
// the getIssueIcon/getIssueColorClass logic should be kept the same as "templates/shared/issueicon.tmpl"
export function getIssueIcon(issue: Issue) {
if (issue.pull_request) {
if (issue.state === 'open') {
if (issue.pull_request.draft) {
return 'octicon-git-pull-request-draft'; // WIP PR
}
return 'octicon-git-pull-request'; // Open PR
} else if (issue.pull_request.merged) {
return 'octicon-git-merge'; // Merged PR
}
return 'octicon-git-pull-request-closed'; // Closed PR
}
if (issue.state === 'open') {
return 'octicon-issue-opened'; // Open Issue
}
return 'octicon-issue-closed'; // Closed Issue
}
export function getIssueColorClass(issue: Issue) {
if (issue.pull_request) {
if (issue.state === 'open') {
if (issue.pull_request.draft) {
return 'tw-text-text-light'; // WIP PR
}
return 'tw-text-green'; // Open PR
} else if (issue.pull_request.merged) {
return 'tw-text-purple'; // Merged PR
}
return 'tw-text-red'; // Closed PR
}
if (issue.state === 'open') {
return 'tw-text-green'; // Open Issue
}
return 'tw-text-red'; // Closed Issue
}
+123
View File
@@ -0,0 +1,123 @@
import {GET} from '../modules/fetch.ts';
import {toggleElem, createElementFromHTML} from '../utils/dom.ts';
import {UserEventsSharedWorker} from '../modules/worker.ts';
const {appSubUrl, notificationSettings} = window.config;
let notificationSequenceNumber = 0;
async function receiveUpdateCount(event: MessageEvent<{type: string, data: string}>) {
try {
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}`;
}
await updateNotificationTable();
} catch (error) {
console.error(error, event);
}
}
export function initNotificationCount() {
if (!document.querySelector('.notification_count')) return;
let usingPeriodicPoller = false;
const startPeriodicPoller = (timeout: number, lastCount?: number) => {
if (timeout <= 0 || !Number.isFinite(timeout)) return;
usingPeriodicPoller = true;
lastCount = lastCount ?? getCurrentCount();
setTimeout(async () => {
await updateNotificationCountWithCallback(startPeriodicPoller, timeout, lastCount);
}, timeout);
};
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
// Try to connect to the event source via the shared worker first
const worker = new UserEventsSharedWorker('notification-worker');
worker.addMessageEventListener((event: MessageEvent) => {
if (event.data.type === 'no-event-source') {
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
} else if (event.data.type === 'notification-count') {
receiveUpdateCount(event); // no await
}
});
worker.startPort();
return;
}
startPeriodicPoller(notificationSettings.MinTimeout);
}
function getCurrentCount() {
return Number(document.querySelector('.notification_count')!.textContent ?? '0');
}
async function updateNotificationCountWithCallback(callback: (timeout: number, newCount: number) => void, timeout: number, lastCount: number) {
const currentCount = getCurrentCount();
if (lastCount !== currentCount) {
callback(notificationSettings.MinTimeout, currentCount);
return;
}
const newCount = await updateNotificationCount();
let needsUpdate = false;
if (lastCount !== newCount) {
needsUpdate = true;
timeout = notificationSettings.MinTimeout;
} else if (timeout < notificationSettings.MaxTimeout) {
timeout += notificationSettings.TimeoutStep;
}
callback(timeout, newCount);
if (needsUpdate) {
await updateNotificationTable();
}
}
async function updateNotificationTable() {
const notificationDiv = document.querySelector('#notification_div');
if (!notificationDiv) return;
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()}`);
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);
}
}
async function updateNotificationCount(): Promise<number> {
try {
const response = await GET(`${appSubUrl}/notifications/new`);
if (!response.ok) {
throw new Error('Failed to fetch notification count');
}
const data = await response.json();
toggleElem('.notification_count', data.new !== 0);
for (const el of document.querySelectorAll('.notification_count')) {
el.textContent = `${data.new}`;
}
return data.new as number;
} catch (error) {
console.error(error);
return 0;
}
}
+9
View File
@@ -0,0 +1,9 @@
export function initOAuth2SettingsDisableCheckbox() {
for (const el of document.querySelectorAll<HTMLInputElement>('.disable-setting')) {
el.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
const dataTarget = target.getAttribute('data-target')!;
document.querySelector(dataTarget)!.classList.toggle('disabled', target.checked);
});
}
}
+17
View File
@@ -0,0 +1,17 @@
import {queryElems, toggleElem} from '../utils/dom.ts';
function initOrgTeamSettings() {
// on the page "page-content organization new team"
const pageContent = document.querySelector('.page-content.organization.new.team');
if (!pageContent) return;
queryElems(pageContent, 'input[name=permission]', (el) => el.addEventListener('change', () => {
// Change team access mode
const val = pageContent.querySelector<HTMLInputElement>('input[name=permission]:checked')?.value;
toggleElem(pageContent.querySelectorAll('.team-units'), val !== 'admin');
}));
}
export function initOrgTeam() {
if (!document.querySelector('.page-content.organization')) return;
initOrgTeamSettings();
}
+86
View File
@@ -0,0 +1,86 @@
import {diffTreeStore, diffTreeStoreSetViewed} from '../modules/diff-file.ts';
import {setFileFolding} from './file-fold.ts';
import {POST} from '../modules/fetch.ts';
const {pageData} = window.config;
// it is undefined on most pages, fortunately, when it is accessed by the related functions, it exists
const prReview = pageData.prReview!;
const viewedStyleClass = 'viewed-file-checked-form';
const viewedCheckboxSelector = '.viewed-file-form'; // Selector under which all "Viewed" checkbox forms can be found
const expandFilesBtnSelector = '#expand-files-btn';
const collapseFilesBtnSelector = '#collapse-files-btn';
// Refreshes the summary of viewed files
// The data used will be window.config.pageData.prReview.numberOf{Viewed}Files
function refreshViewedFilesSummary() {
const viewedFilesProgress = document.querySelector('#viewed-files-summary')!;
viewedFilesProgress.setAttribute('value', String(prReview.numberOfViewedFiles));
const summaryLabel = document.querySelector<HTMLElement>('#viewed-files-summary-label')!;
summaryLabel.textContent = summaryLabel.getAttribute('data-text-changed-template')!
.replace('%[1]d', String(prReview.numberOfViewedFiles))
.replace('%[2]d', String(prReview.numberOfFiles));
}
// Initializes a listener for all children of the given html element
// (for example 'document' in the most basic case)
// to watch for changes of viewed-file checkboxes
export function initViewedCheckboxListenerFor() {
for (const form of document.querySelectorAll(`${viewedCheckboxSelector}:not([data-has-viewed-checkbox-listener="true"])`)) {
// To prevent double addition of listeners
form.setAttribute('data-has-viewed-checkbox-listener', String(true));
// The checkbox consists of a div containing the real checkbox with its label,
// hence the actual checkbox first has to be found
const checkbox = form.querySelector<HTMLInputElement>('input[type=checkbox]')!;
checkbox.addEventListener('input', function() {
// Mark the file as viewed visually - will especially change the background
if (this.checked) {
form.classList.add(viewedStyleClass);
checkbox.setAttribute('checked', '');
prReview.numberOfViewedFiles++;
} else {
form.classList.remove(viewedStyleClass);
checkbox.removeAttribute('checked');
prReview.numberOfViewedFiles--;
}
// Update viewed-files summary and remove "has changed" label if present
refreshViewedFilesSummary();
const hasChangedLabel = form.parentNode!.querySelector('.changed-since-last-review');
hasChangedLabel?.remove();
const fileName = checkbox.getAttribute('name')!;
// check if the file is in our diffTreeStore and if we find it -> change the IsViewed status
diffTreeStoreSetViewed(diffTreeStore(), fileName, this.checked);
// Unfortunately, actual forms cause too many problems, hence another approach is needed
const files: Record<string, boolean> = {};
files[fileName] = this.checked;
const data: Record<string, any> = {files};
const headCommitSHA = form.getAttribute('data-headcommit');
if (headCommitSHA) data.headCommitSHA = headCommitSHA;
POST(form.getAttribute('data-link')!, {data});
// Fold the file accordingly
const parentBox = form.closest('.diff-file-header')!;
setFileFolding(parentBox.closest('.file-content')!, parentBox.querySelector('.fold-file')!, this.checked);
});
}
}
export function initExpandAndCollapseFilesButton() {
// expand btn
document.querySelector(expandFilesBtnSelector)?.addEventListener('click', () => {
for (const box of document.querySelectorAll<HTMLElement>('.file-content[data-folded="true"]')) {
setFileFolding(box, box.querySelector('.fold-file')!, false);
}
});
// collapse btn, need to exclude the div of “show more”
document.querySelector(collapseFilesBtnSelector)?.addEventListener('click', () => {
for (const box of document.querySelectorAll<HTMLElement>('.file-content:not([data-folded="true"])')) {
if (box.getAttribute('id') === 'diff-incomplete') continue;
setFileFolding(box, box.querySelector('.fold-file')!, true);
}
});
}
+21
View File
@@ -0,0 +1,21 @@
import {createApp} from 'vue';
export async function initRepoRecentCommits() {
const el = document.querySelector('#repo-recent-commits-chart');
if (!el) return;
const {default: RepoRecentCommits} = await import('../components/RepoRecentCommits.vue');
try {
const View = createApp(RepoRecentCommits, {
locale: {
loadingTitle: el.getAttribute('data-locale-loading-title'),
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
loadingInfo: el.getAttribute('data-locale-loading-info'),
},
});
View.mount(el);
} catch (err) {
console.error('RepoRecentCommits failed to load', err);
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
}
}
+75
View File
@@ -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);
});
}
+71
View File
@@ -0,0 +1,71 @@
import {createApp} from 'vue';
import RepoActionView from '../components/RepoActionView.vue';
export function initRepositoryActionView() {
const el = document.querySelector('#repo-action-view');
if (!el) return;
// 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
const parentFullHeight = document.querySelector<HTMLElement>('body > div.full.height');
if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0');
const view = createApp(RepoActionView, {
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'),
summary: el.getAttribute('data-locale-summary'),
allJobs: el.getAttribute('data-locale-all-jobs'),
expandCallerJobs: el.getAttribute('data-locale-expand-caller-jobs'),
collapseCallerJobs: el.getAttribute('data-locale-collapse-caller-jobs'),
triggeredVia: el.getAttribute('data-locale-triggered-via'),
totalDuration: el.getAttribute('data-locale-total-duration'),
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-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'),
skipped: el.getAttribute('data-locale-status-skipped'),
blocked: el.getAttribute('data-locale-status-blocked'),
},
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'),
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);
}
+42
View File
@@ -0,0 +1,42 @@
import {toggleElem} from '../utils/dom.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
export function initRepoBranchButton() {
initRepoCreateBranchButton();
initRepoRenameBranchButton();
}
function initRepoCreateBranchButton() {
// 2 pages share this code, one is the branch list page, the other is the commit view page: create branch/tag from current commit (dirty code)
for (const el of document.querySelectorAll('.show-create-branch-modal')) {
el.addEventListener('click', () => {
const modalFormName = el.getAttribute('data-modal-form') || '#create-branch-form';
const modalForm = document.querySelector<HTMLFormElement>(modalFormName);
if (!modalForm) return;
modalForm.action = `${modalForm.getAttribute('data-base-action')}${el.getAttribute('data-branch-from-urlcomponent')}`;
const fromSpanName = el.getAttribute('data-modal-from-span') || '#modal-create-branch-from-span';
document.querySelector(fromSpanName)!.textContent = el.getAttribute('data-branch-from');
showFomanticModal(document.querySelector(el.getAttribute('data-modal')!));
});
}
}
function initRepoRenameBranchButton() {
for (const el of document.querySelectorAll('.show-rename-branch-modal')) {
el.addEventListener('click', () => {
const target = el.getAttribute('data-modal')!;
const modal = document.querySelector(target)!;
const oldBranchName = el.getAttribute('data-old-branch-name')!;
modal.querySelector<HTMLInputElement>('input[name=from]')!.value = oldBranchName;
// display the warning that the branch which is chosen is the default branch
const warn = modal.querySelector('.default-branch-warning')!;
toggleElem(warn, el.getAttribute('data-is-default-branch') === 'true');
const text = modal.querySelector('[data-rename-branch-to]')!;
text.textContent = text.getAttribute('data-rename-branch-to')!.replace('%s', oldBranchName);
});
}
}
+147
View File
@@ -0,0 +1,147 @@
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) {
if (window.history.pushState) {
window.history.pushState(null, '', hash);
} else {
window.location.hash = hash;
}
}
// it selects the code lines defined by range: `L1-L3` (3 lines) or `L2` (singe line)
function selectRange(range: string): Element | null {
for (const el of document.querySelectorAll('.code-view tr.active')) el.classList.remove('active');
const elLineNums = document.querySelectorAll(`.code-view td.lines-num span[data-line-number]`);
const refInNewIssue = document.querySelector('a.ref-in-new-issue');
const copyPermalink = document.querySelector('a.copy-line-permalink');
const viewGitBlame = document.querySelector('a.view_git_blame');
const updateIssueHref = function (anchor: string) {
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
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+$/, '')}`;
if (anchor.length !== 0) {
href = `${href}#${anchor}`;
}
viewGitBlame.setAttribute('href', href);
};
const updateCopyPermalinkUrl = function (anchor: string) {
if (!copyPermalink) return;
let link = copyPermalink.getAttribute('data-url')!;
link = `${window.location.origin}${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
copyPermalink.setAttribute('data-clipboard-text', link);
};
const rangeFields = range ? range.split('-') : [];
const start = rangeFields[0] ?? '';
if (!start) return null;
const stop = rangeFields[1] || start;
// format is i.e. 'L14-L26'
let startLineNum = parseInt(start.substring(1));
let stopLineNum = parseInt(stop.substring(1));
if (startLineNum > stopLineNum) {
const tmp = startLineNum;
startLineNum = stopLineNum;
stopLineNum = tmp;
range = `${stop}-${start}`;
}
const first = elLineNums[startLineNum - 1] ?? null;
for (let i = startLineNum - 1; i <= stopLineNum - 1 && i < elLineNums.length; i++) {
elLineNums[i].closest('tr')!.classList.add('active');
}
changeHash(`#${range}`);
updateIssueHref(range);
updateViewGitBlameFragment(range);
updateCopyPermalinkUrl(range);
return first;
}
function showLineButton() {
const menu = document.querySelector('.code-line-menu');
if (!menu) return;
// remove all other line buttons
for (const el of document.querySelectorAll('.code-line-button')) {
el.remove();
}
// find active row and add button
const tr = document.querySelector('.code-view tr.active');
if (!tr) return;
const td = tr.querySelector('td.lines-num')!;
const btn = document.createElement('button');
btn.classList.add('code-line-button', 'ui', 'basic', 'button');
btn.innerHTML = svg('octicon-kebab-horizontal');
td.prepend(btn);
// put a copy of the menu back into DOM for the next click
btn.closest('.code-view')!.append(menu.cloneNode(true));
createTippy(btn, {
theme: 'menu',
trigger: 'click',
hideOnClick: true,
content: menu,
placement: 'right-start',
interactive: true,
onShow: (tippy) => {
tippy.popper.addEventListener('click', () => {
tippy.hide();
}, {once: true});
},
});
}
export function initRepoCodeView() {
// When viewing a file or blame, there is always a ".file-view" element,
// but the ".code-view" class is only present when viewing the "code" of a file; it is not present when viewing a PDF file.
// Since the ".file-view" will be dynamically reloaded when navigating via the left file tree (eg: view a PDF file, then view a source code file, etc.)
// the "code-view" related event listeners should always be added when the current page contains ".file-view" element.
if (!document.querySelector('.repo-view-container .file-view')) return;
// "file code view" and "blame" pages need this "line number button" feature
let selRangeStart: string | undefined;
addDelegatedEventListener(document, 'click', '.code-view .lines-num span', (el: HTMLElement, e: KeyboardEvent) => {
if (!selRangeStart || !e.shiftKey) {
selRangeStart = el.getAttribute('id')!;
selectRange(selRangeStart);
} else {
const selRangeStop = el.getAttribute('id');
selectRange(`${selRangeStart}-${selRangeStop}`);
}
window.getSelection()!.removeAllRanges();
showLineButton();
});
// apply the selected range from the URL hash
const onHashChange = () => {
if (!window.location.hash) return;
if (!document.querySelector('.code-view .lines-num')) return;
const range = window.location.hash.substring(1);
const first = selectRange(range);
if (first) {
// set scrollRestoration to 'manual' when there is a hash in the URL, so that the scroll position will not be remembered after refreshing
if (window.history.scrollRestoration !== 'manual') window.history.scrollRestoration = 'manual';
first.scrollIntoView({block: 'start'});
showLineButton();
}
};
onHashChange();
window.addEventListener('hashchange', onHashChange);
}
+26
View File
@@ -0,0 +1,26 @@
import {createTippy} from '../modules/tippy.ts';
import {toggleElem} from '../utils/dom.ts';
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
export function initRepoEllipsisButton() {
registerGlobalEventFunc('click', 'onRepoEllipsisButtonClick', async (el: HTMLInputElement, e: Event) => {
e.preventDefault();
const expanded = el.getAttribute('aria-expanded') === 'true';
toggleElem(el.parentElement!.querySelector('.commit-body')!);
el.setAttribute('aria-expanded', String(!expanded));
});
}
export function initCommitStatuses() {
registerGlobalInitFunc('initCommitStatuses', (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: 'box-with-header',
});
});
}
+22
View File
@@ -0,0 +1,22 @@
import {sanitizeRepoName, substituteRepoOpenWithUrl} from './repo-common.ts';
test('substituteRepoOpenWithUrl', () => {
// For example: "x-github-client://openRepo/https://github.com/go-gitea/gitea"
expect(substituteRepoOpenWithUrl('proto://a/{url}', 'https://gitea')).toEqual('proto://a/https://gitea');
expect(substituteRepoOpenWithUrl('proto://a?link={url}', 'https://gitea')).toEqual('proto://a?link=https%3A%2F%2Fgitea');
});
test('sanitizeRepoName', () => {
expect(sanitizeRepoName(' a b ')).toEqual('a-b');
expect(sanitizeRepoName('a-b_c.git ')).toEqual('a-b_c');
expect(sanitizeRepoName('/x.git/')).toEqual('-x.git-');
expect(sanitizeRepoName('.profile')).toEqual('.profile');
expect(sanitizeRepoName('.profile.')).toEqual('.profile');
expect(sanitizeRepoName('.pro..file')).toEqual('.pro.file');
expect(sanitizeRepoName('foo.rss.atom.git.wiki')).toEqual('foo');
expect(sanitizeRepoName('.')).toEqual('');
expect(sanitizeRepoName('..')).toEqual('');
expect(sanitizeRepoName('-')).toEqual('');
});
+179
View File
@@ -0,0 +1,179 @@
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';
import RepoActivityTopAuthors from '../components/RepoActivityTopAuthors.vue';
import {createApp} from 'vue';
import {createTippy} from '../modules/tippy.ts';
import {localUserSettings} from '../modules/user-settings.ts';
async function onDownloadArchive(e: Event) {
e.preventDefault();
// there are many places using the "archive-link", eg: the dropdown on the repo code page, the release list
const el = (e.target as HTMLElement).closest<HTMLAnchorElement>('a.archive-link[href]')!;
const targetLoading = el.closest('.ui.dropdown') ?? el;
targetLoading.classList.add('is-loading', 'loading-icon-2px');
try {
for (let tryCount = 0; ;tryCount++) {
const response = await POST(el.href);
if (!response.ok) throw new Error(`Invalid server response: ${response.status}`);
const data = await response.json();
if (data.complete) break;
await sleep(Math.min((tryCount + 1) * 750, 2000));
}
window.location.href = el.href; // the archive is ready, start real downloading
} catch (e) {
console.error(e);
showErrorToast(`Failed to download the archive: ${errorMessage(e)}`, {duration: 2500});
} finally {
targetLoading.classList.remove('is-loading', 'loading-icon-2px');
}
}
export function initRepoArchiveLinks() {
queryElems(document, 'a.archive-link[href]', (el) => el.addEventListener('click', onDownloadArchive));
}
export function initRepoActivityTopAuthorsChart() {
const el = document.querySelector('#repo-activity-top-authors-chart');
if (el) {
createApp(RepoActivityTopAuthors).mount(el);
}
}
export function substituteRepoOpenWithUrl(tmpl: string, url: string): string {
const pos = tmpl.indexOf('{url}');
if (pos === -1) return tmpl;
const posQuestionMark = tmpl.indexOf('?');
const needEncode = posQuestionMark >= 0 && posQuestionMark < pos;
return tmpl.replace('{url}', needEncode ? encodeURIComponent(url) : url);
}
function initCloneSchemeUrlSelection(parent: Element) {
const elCloneUrlInput = parent.querySelector<HTMLInputElement>('.repo-clone-url')!;
const tabHttps = parent.querySelector('.repo-clone-https');
const tabSsh = parent.querySelector('.repo-clone-ssh');
const tabTea = parent.querySelector('.repo-clone-tea');
const updateClonePanelUi = function() {
let scheme = localUserSettings.getString('repo-clone-protocol');
if (!['https', 'ssh', 'tea'].includes(scheme)) {
scheme = 'https';
}
// Fallbacks if the scheme preference is not available in the tabs, for example: empty repo page, there are only HTTPS and SSH
if (scheme === 'tea' && !tabTea) {
scheme = 'https';
}
if (scheme === 'https' && !tabHttps) {
scheme = 'ssh';
} else if (scheme === 'ssh' && !tabSsh) {
scheme = 'https';
}
const isHttps = scheme === 'https';
const isSsh = scheme === 'ssh';
const isTea = scheme === 'tea';
if (tabHttps) {
const link = tabHttps.getAttribute('data-link')!;
tabHttps.textContent = link.split(':')[0].toUpperCase(); // show "HTTP" or "HTTPS"
tabHttps.classList.toggle('active', isHttps);
}
if (tabSsh) {
tabSsh.classList.toggle('active', isSsh);
}
if (tabTea) {
tabTea.classList.toggle('active', isTea);
}
let tab: Element | null = null;
if (isHttps) {
tab = tabHttps;
} else if (isSsh) {
tab = tabSsh;
} else if (isTea) {
tab = tabTea;
}
if (!tab) return;
const link = tab.getAttribute('data-link')!;
for (const el of document.querySelectorAll('.js-clone-url')) {
if (el.nodeName === 'INPUT') {
(el as HTMLInputElement).value = link;
} else {
el.textContent = link;
}
}
for (const el of parent.querySelectorAll<HTMLAnchorElement>('.js-clone-url-editor')) {
el.href = substituteRepoOpenWithUrl(el.getAttribute('data-href-template')!, link);
}
};
updateClonePanelUi();
// tabSsh or tabHttps might not both exist, eg: guest view, or one is disabled by the server
tabHttps?.addEventListener('click', () => {
localUserSettings.setString('repo-clone-protocol', 'https');
updateClonePanelUi();
});
tabSsh?.addEventListener('click', () => {
localUserSettings.setString('repo-clone-protocol', 'ssh');
updateClonePanelUi();
});
tabTea?.addEventListener('click', () => {
localUserSettings.setString('repo-clone-protocol', 'tea');
updateClonePanelUi();
});
elCloneUrlInput.addEventListener('focus', () => {
elCloneUrlInput.select();
});
}
function initClonePanelButton(btn: HTMLButtonElement) {
const elPanel = btn.nextElementSibling!;
// "init" must be before the "createTippy" otherwise the "tippy-target" will be removed from the document
initCloneSchemeUrlSelection(elPanel);
createTippy(btn, {
content: elPanel,
trigger: 'click',
placement: 'bottom-end',
interactive: true,
hideOnClick: true,
arrow: false,
});
}
export function initRepoCloneButtons() {
queryElems(document, '.js-btn-clone-panel', initClonePanelButton);
queryElems(document, '.clone-buttons-combo', initCloneSchemeUrlSelection);
}
export async function updateIssuesMeta(url: string, action: string, issue_ids: string, id: string) {
try {
const response = await POST(url, {data: new URLSearchParams({action, issue_ids, id})});
if (!response.ok) {
throw new Error('Failed to update issues meta');
}
} catch (error) {
console.error(error);
}
}
export function sanitizeRepoName(name: string): string {
name = name.trim().replace(/[^-.\w]/g, '-');
for (let lastName = ''; lastName !== name;) {
lastName = name;
name = name.replace(/\.+$/g, '');
name = name.replace(/\.{2,}/g, '.');
for (const ext of ['.git', '.wiki', '.rss', '.atom']) {
if (name.endsWith(ext)) {
name = name.substring(0, name.length - ext.length);
}
}
}
if (['.', '..', '-'].includes(name)) name = '';
return name;
}
+53
View File
@@ -0,0 +1,53 @@
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
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-url')!);
const data = await res.json();
hideElem(loadingButton);
addTags(area, data.tags);
addBranches(area, data.branches, data.default_branch);
showElem(area.querySelectorAll('.branch-and-tag-detail'));
} finally {
loadingButton.classList.remove('disabled');
}
}
function addTags(area: Element, tags: Array<Record<string, any>>) {
const tagArea = area.querySelector('.tag-area')!;
toggleElem(tagArea.parentElement!, tags.length > 0);
for (const tag of tags) {
addLink(tagArea, tag.web_link, tag.name);
}
}
function addBranches(area: Element, branches: Array<Record<string, any>>, defaultBranch: string) {
const defaultBranchTooltip = area.getAttribute('data-text-default-branch-tooltip');
const branchArea = area.querySelector('.branch-area')!;
toggleElem(branchArea.parentElement!, branches.length > 0);
for (const branch of branches) {
const tooltip = defaultBranch === branch.name ? defaultBranchTooltip : null;
addLink(branchArea, branch.web_link, branch.name, tooltip);
}
}
function addLink(parent: Element, href: string, text: string, tooltip: string | null = null) {
const link = document.createElement('a');
link.classList.add('muted', 'tw-px-1');
link.href = href;
link.textContent = text;
if (tooltip) {
link.classList.add('tw-border', 'tw-border-secondary', 'tw-rounded');
link.setAttribute('data-tooltip-content', tooltip);
}
parent.append(link);
}
export function initRepoDiffCommitBranchesAndTags() {
for (const area of document.querySelectorAll('.branch-and-tag-area')) {
const btn = area.querySelector('.load-branches-and-tags')!;
btn.addEventListener('click', () => loadBranchesAndTags(area, btn));
}
}
@@ -0,0 +1,10 @@
import {createApp} from 'vue';
import DiffCommitSelector from '../components/DiffCommitSelector.vue';
export function initDiffCommitSelect() {
const el = document.querySelector('#diff-commit-select');
if (!el) return;
const commitSelect = createApp(DiffCommitSelector);
commitSelect.mount(el);
}
+10
View File
@@ -0,0 +1,10 @@
import {createApp} from 'vue';
import DiffFileTree from '../components/DiffFileTree.vue';
export function initDiffFileTree() {
const el = document.querySelector('#diff-file-tree');
if (!el) return;
const fileTreeView = createApp(DiffFileTree);
fileTreeView.mount(el);
}
+294
View File
@@ -0,0 +1,294 @@
import {initRepoIssueContentHistory} from './repo-issue-content.ts';
import {initDiffFileTree} from './repo-diff-filetree.ts';
import {initDiffCommitSelect} from './repo-diff-commitselect.ts';
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 {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} 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
queryElems(el, '.file-view-toggle', (btn) => btn.addEventListener('click', () => {
queryElemSiblings(btn, '.file-view-toggle', (el) => el.classList.remove('active'));
btn.classList.add('active');
const target = document.querySelector(btn.getAttribute('data-toggle-selector')!);
if (!target) throw new Error('Target element not found');
hideElem(queryElemSiblings(target));
showElem(target);
}));
}
function initRepoDiffConversationForm() {
// FIXME: there could be various different form in a conversation-holder (for example: reply form, edit form).
// This listener is for "reply form" only, it should clearly distinguish different forms in the future.
addDelegatedEventListener<HTMLFormElement, SubmitEvent>(document, 'submit', '.conversation-holder form', async (form, e) => {
e.preventDefault();
const textArea = form.querySelector<HTMLTextAreaElement>('textarea')!;
if (!validateTextareaNonEmpty(textArea)) return;
if (form.classList.contains('is-loading')) return;
try {
form.classList.add('is-loading');
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 = e.submitter;
const isSubmittedByButton = submitter instanceof HTMLButtonElement || (submitter instanceof HTMLInputElement && submitter.type === 'submit');
if (isSubmittedByButton && submitter.name) {
formData.append(submitter.name, submitter.value);
}
// on the diff page, the form is inside a "tr" and need to get the line-type ahead
// but on the conversation page, there is no parent "tr"
const trLineType = form.closest('tr')?.getAttribute('data-line-type');
const response = await POST(form.getAttribute('action')!, {data: formData});
const newConversationHolder = createElementFromHTML(await response.text());
const path = newConversationHolder.getAttribute('data-path');
const side = newConversationHolder.getAttribute('data-side');
const idx = newConversationHolder.getAttribute('data-idx');
form.closest('.conversation-holder')!.replaceWith(newConversationHolder);
(form as any) = null; // prevent further usage of the form because it should have been replaced
if (trLineType) {
// if there is a line-type for the "tr", it means the form is on the diff page
// then hide the "add-code-comment" [+] button for current code line by adding "tw-invisible" because the conversation has been added
let selector;
if (trLineType === 'same') {
selector = `[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`;
} else {
selector = `[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`;
}
for (const el of document.querySelectorAll(selector)) {
el.classList.add('tw-invisible');
}
}
// the default behavior is to add a pending review, so if no submitter, it also means "pending_review"
if (!submitter || submitter?.matches('button[name="pending_review"]')) {
const reviewBox = document.querySelector('#review-box')!;
const counter = reviewBox?.querySelector('.review-comments-counter');
if (!counter) return;
const num = parseInt(counter.getAttribute('data-pending-comment-number')!) + 1 || 1;
counter.setAttribute('data-pending-comment-number', String(num));
counter.textContent = String(num);
animateOnce(reviewBox, 'pulse-1p5-200');
}
} catch (error) {
console.error('Error:', error);
showErrorToast(`Submit form failed: ${errorMessage(error)}`);
} finally {
form?.classList.remove('is-loading');
}
});
addDelegatedEventListener(document, 'click', '.resolve-conversation', async (el, e) => {
e.preventDefault();
const comment_id = el.getAttribute('data-comment-id')!;
const origin = el.getAttribute('data-origin')!;
const action = el.getAttribute('data-action')!;
const url = el.getAttribute('data-update-url')!;
try {
const response = await POST(url, {data: new URLSearchParams({origin, action, comment_id})});
const data = await response.text();
const elConversationHolder = el.closest('.conversation-holder');
if (elConversationHolder) {
const elNewConversation = createElementFromHTML(data);
elConversationHolder.replaceWith(elNewConversation);
} else {
window.location.reload();
}
} catch (error) {
console.error('Error:', error);
}
});
}
function initRepoDiffConversationNav() {
// Previous/Next code review conversation
addDelegatedEventListener(document, 'click', '.previous-conversation, .next-conversation', (el, e) => {
e.preventDefault();
const isPrevious = el.matches('.previous-conversation');
const elCurConversation = el.closest('.comment-code-cloud')!;
const elAllConversations = document.querySelectorAll('.comment-code-cloud:not(.tw-hidden)');
const index = Array.from(elAllConversations).indexOf(elCurConversation);
const previousIndex = index > 0 ? index - 1 : elAllConversations.length - 1;
const nextIndex = index < elAllConversations.length - 1 ? index + 1 : 0;
const navIndex = isPrevious ? previousIndex : nextIndex;
const elNavConversation = elAllConversations[navIndex];
const anchor = elNavConversation.querySelector('.comment')!.id;
window.location.href = `#${anchor}`;
});
}
function initDiffHeaderPopup() {
for (const btn of document.querySelectorAll('.diff-header-popup-btn:not([data-header-popup-initialized])')) {
btn.setAttribute('data-header-popup-initialized', '');
const popup = btn.nextElementSibling;
if (!popup?.matches('.tippy-target')) throw new Error('Popup element not found');
createTippy(btn, {
content: popup,
theme: 'menu',
placement: 'bottom-end',
trigger: 'click',
interactive: true,
hideOnClick: true,
});
}
}
// Will be called when the show more (files) button has been pressed
function onShowMoreFiles() {
// TODO: replace these calls with the "observer.ts" methods
initRepoIssueContentHistory();
initViewedCheckboxListenerFor();
initImageDiff();
initDiffHeaderPopup();
}
async function loadMoreFiles(btn: Element): Promise<boolean> {
if (btn.classList.contains('disabled')) {
return false;
}
btn.classList.add('disabled');
const url = btn.getAttribute('data-href')!;
try {
const response = await GET(url);
const resp = await response.text();
const respDoc = parseDom(resp, 'text/html');
const respFileBoxes = respDoc.querySelector('#diff-file-boxes')!;
// the response is a full HTML page, we need to extract the relevant contents:
// * 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);
onShowMoreFiles();
return true;
} catch (error) {
console.error('Error:', error);
showErrorToast('An error occurred while loading more files.');
} finally {
btn.classList.remove('disabled');
}
return false;
}
function initRepoDiffShowMore() {
addDelegatedEventListener(document, 'click', 'a#diff-show-more-files', (el, e) => {
e.preventDefault();
loadMoreFiles(el);
});
addDelegatedEventListener(document, 'click', 'a.diff-load-button', async (el, e) => {
e.preventDefault();
if (el.classList.contains('disabled')) return;
el.classList.add('disabled');
const url = el.getAttribute('data-href')!;
try {
const response = await GET(url);
const resp = await response.text();
const respDoc = parseDom(resp, 'text/html');
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);
// 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.
onShowMoreFiles();
} catch (error) {
console.error('Error:', error);
} finally {
el.classList.remove('disabled');
}
});
}
async function onLocationHashChange() {
// try to scroll to the target element by the current hash
const currentHash = window.location.hash;
if (!currentHash.startsWith('#diff-') && !currentHash.startsWith('#issuecomment-')) return;
// avoid reentrance when we are changing the hash to scroll and trigger ":target" selection
const attrAutoScrollRunning = 'data-auto-scroll-running';
if (document.body.hasAttribute(attrAutoScrollRunning)) return;
const targetElementId = currentHash.substring(1);
while (currentHash === window.location.hash) {
// use getElementById to avoid querySelector throws an error when the hash is invalid
// eslint-disable-next-line unicorn/prefer-query-selector
const targetElement = document.getElementById(targetElementId);
if (targetElement) {
// need to change hash to re-trigger ":target" CSS selector, let's manually scroll to it
targetElement.scrollIntoView();
document.body.setAttribute(attrAutoScrollRunning, 'true');
window.location.hash = '';
window.location.hash = currentHash;
setTimeout(() => document.body.removeAttribute(attrAutoScrollRunning), 0);
return;
}
// If looking for a hidden comment, try to expand the section that contains it
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},"]`);
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');
// 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
}
}
// the button will be refreshed after each "load more", so query it every time
const showMoreButton = document.querySelector('#diff-show-more-files');
if (!showMoreButton) {
return; // nothing more to load
}
// Load more files, await ensures we don't block progress
const ok = await loadMoreFiles(showMoreButton);
if (!ok) return; // failed to load more files
}
}
function initRepoDiffHashChangeListener() {
window.addEventListener('hashchange', onLocationHashChange);
onLocationHashChange();
}
export function initRepoDiffView() {
initRepoDiffConversationForm(); // such form appears on the "conversation" page and "diff" page
if (!document.querySelector('#diff-file-boxes')) return;
initRepoDiffConversationNav(); // "previous" and "next" buttons only appear on "diff" page
initDiffFileTree();
initDiffCommitSelect();
initRepoDiffShowMore();
initDiffHeaderPopup();
initViewedCheckboxListenerFor();
initExpandAndCollapseFilesButton();
initRepoDiffHashChangeListener();
registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
addDelegatedEventListener(document, 'click', '.fold-file', (el) => {
invertFileFolding(el.closest('.file-content')!, el);
});
}
+241
View File
@@ -0,0 +1,241 @@
import {html, htmlRaw} from '../utils/html.ts';
import {createCodeEditor} from '../modules/codeeditor/main.ts';
import {trimTrailingWhitespaceFromView} from '../modules/codeeditor/utils.ts';
import {hideElem, queryElems, showElem, createElementFromHTML, onInputDebounce} from '../utils/dom.ts';
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 {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');
if (!elTabMenu) 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 () => {
// "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', elTextarea.value);
formData.append('file_path', elTreePath.value);
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() {
const dropzoneUpload = document.querySelector<HTMLElement>('.page-content.repository.editor.upload .dropzone');
if (dropzoneUpload) initDropzone(dropzoneUpload);
for (const el of queryElems<HTMLInputElement>(document, '.js-quick-pull-choice-option')) {
el.addEventListener('input', () => {
if (el.value === 'commit-to-new-branch') {
showElem('.quick-pull-branch-name');
document.querySelector<HTMLInputElement>('.quick-pull-branch-name input')!.required = true;
} else {
hideElem('.quick-pull-branch-name');
document.querySelector<HTMLInputElement>('.quick-pull-branch-name input')!.required = false;
}
document.querySelector('#commit-button')!.textContent = el.getAttribute('data-button-text');
});
}
// 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
function joinTreePath() {
const parts = [];
for (const el of document.querySelectorAll('.breadcrumb span.section')) {
const link = el.querySelector('a');
parts.push(link ? link.textContent : el.textContent);
}
if (filenameInput.value) {
parts.push(filenameInput.value);
}
document.querySelector<HTMLInputElement>('#tree_path')!.value = parts.join('/');
}
filenameInput.addEventListener('input', function () {
const parts = filenameInput.value.split('/');
const links = Array.from(document.querySelectorAll('.breadcrumb span.section'));
const dividers = Array.from(document.querySelectorAll('.breadcrumb .breadcrumb-divider'));
let warningDiv = document.querySelector<HTMLDivElement>('.ui.warning.message.flash-message.flash-warning.space-related');
let containSpace = false;
if (parts.length > 1) {
for (let i = 0; i < parts.length; ++i) {
const value = parts[i];
const trimValue = value.trim();
if (trimValue === '..') {
// remove previous tree path
if (links.length > 0) {
const link = links.pop()!;
const divider = dividers.pop()!;
link.remove();
divider.remove();
}
continue;
}
if (i < parts.length - 1) {
if (trimValue.length) {
const linkElement = createElementFromHTML(
html`<span class="section"><a href="#">${value}</a></span>`,
);
const dividerElement = createElementFromHTML(
html`<div class="breadcrumb-divider">/</div>`,
);
links.push(linkElement);
dividers.push(dividerElement);
filenameInput.before(linkElement);
filenameInput.before(dividerElement);
}
} else {
filenameInput.value = value;
}
this.setSelectionRange(0, 0);
containSpace = containSpace || (trimValue !== value && trimValue !== '');
}
}
containSpace = containSpace || Array.from(links).some((link) => {
const value = link.querySelector('a')!.textContent;
return value.trim() !== value;
});
containSpace = containSpace || parts[parts.length - 1].trim() !== parts[parts.length - 1];
if (containSpace) {
if (!warningDiv) {
warningDiv = document.createElement('div');
warningDiv.classList.add('ui', 'warning', 'message', 'flash-message', 'flash-warning', 'space-related');
warningDiv.innerHTML = html`<p>File path contains leading or trailing whitespace.</p>`;
// Change to `block` display because it is set to 'none' in fomantic/build/semantic.css
warningDiv.classList.add('tw-block');
const inputContainer = document.querySelector('.repo-editor-header')!;
inputContainer.insertAdjacentElement('beforebegin', warningDiv);
}
showElem(warningDiv);
} else if (warningDiv) {
hideElem(warningDiv);
}
joinTreePath();
});
filenameInput.addEventListener('keydown', function (e) {
const sections = queryElems(document, '.breadcrumb span.section');
const dividers = queryElems(document, '.breadcrumb .breadcrumb-divider');
// Jump back to last directory once the filename is empty
if (e.code === 'Backspace' && filenameInput.selectionStart === 0 && sections.length > 0) {
e.preventDefault();
const lastSection = sections[sections.length - 1];
const lastDivider = dividers.length ? dividers[dividers.length - 1] : null;
const value = lastSection.querySelector('a')!.textContent;
filenameInput.value = value + filenameInput.value;
this.setSelectionRange(value.length, value.length);
lastDivider?.remove();
lastSection.remove();
joinTreePath();
}
});
const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form')!;
// see the ATTENTION above, on the upload page, there is no editor(textarea)
// so only the filename input above is initialized, the code below (for the code editor) will be skipped
const editArea = document.querySelector<HTMLTextAreaElement>('.page-content.repository.editor textarea#edit_area');
if (!editArea) return;
// Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage
// to enable or disable the commit button
const commitButton = document.querySelector<HTMLButtonElement>('#commit-button')!;
const dirtyFileClass = 'dirty-file';
const syncCommitButtonState = () => {
const dirty = elForm.classList.contains(dirtyFileClass);
commitButton.disabled = !dirty;
};
// Registering a custom listener for the file path and the file content
// FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added
applyAreYouSure(elForm, {
silent: true,
dirtyClass: dirtyFileClass,
fieldSelector: ':input:not(.commit-form-wrapper :input)',
change: syncCommitButtonState,
});
syncCommitButtonState(); // disable the "commit" button when no content changes
initEditPreviewTab(elForm);
(async () => {
const editor = await createCodeEditor(editArea, filenameInput);
filenameInput.addEventListener('input', onInputDebounce(() => editor.updateFilename(filenameInput.value)));
// Update the editor from query params, if available,
// only after the dirtyFileClass initialization
const params = new URLSearchParams(window.location.search);
const value = params.get('value');
if (value) {
editor.view.dispatch({
changes: {from: 0, to: editor.view.state.doc.length, insert: value},
});
}
commitButton.addEventListener('click', async (e) => {
if (editor.trimTrailingWhitespace) {
trimTrailingWhitespaceFromView(editor.view);
}
// A modal which asks if an empty file should be committed
if (!editArea.value) {
e.preventDefault();
if (await confirmModal({
header: elForm.getAttribute('data-text-empty-confirm-header')!,
content: elForm.getAttribute('data-text-empty-confirm-content')!,
})) {
ignoreAreYouSure(elForm);
submitFormFetchAction(elForm);
}
}
});
})();
}
export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
// the content is from the server, so it is safe to use innerHTML
previewPanel.innerHTML = html`<div class="render-content render-preview markup">${htmlRaw(htmlContent)}</div>`;
}
+34
View File
@@ -0,0 +1,34 @@
import {strSubMatch, calcMatchedWeight, filterRepoFilesWeighted} from './repo-findfile.ts';
describe('Repo Find Files', () => {
test('strSubMatch', () => {
expect(strSubMatch('abc', '')).toEqual(['abc']);
expect(strSubMatch('abc', 'a')).toEqual(['', 'a', 'bc']);
expect(strSubMatch('abc', 'b')).toEqual(['a', 'b', 'c']);
expect(strSubMatch('abc', 'c')).toEqual(['ab', 'c']);
expect(strSubMatch('abc', 'ac')).toEqual(['', 'a', 'b', 'c']);
expect(strSubMatch('abc', 'z')).toEqual(['abc']);
expect(strSubMatch('abc', 'az')).toEqual(['abc']);
expect(strSubMatch('ABc', 'ac')).toEqual(['', 'A', 'B', 'c']);
expect(strSubMatch('abC', 'ac')).toEqual(['', 'a', 'b', 'C']);
expect(strSubMatch('aabbcc', 'abc')).toEqual(['', 'a', 'a', 'b', 'b', 'c', 'c']);
expect(strSubMatch('the/directory', 'hedir')).toEqual(['t', 'he', '/', 'dir', 'ectory']);
});
test('calcMatchedWeight', () => {
expect(calcMatchedWeight(['a', 'b', 'c', 'd']) < calcMatchedWeight(['a', 'bc', 'c'])).toBeTruthy();
});
test('filterRepoFilesWeighted', () => {
// the first matched result should always be the "word.txt"
let res = filterRepoFilesWeighted(['word.txt', 'we-got-result.dat'], 'word');
expect(res).toHaveLength(2);
expect(res[0].matchResult).toEqual(['', 'word', '.txt']);
res = filterRepoFilesWeighted(['we-got-result.dat', 'word.txt'], 'word');
expect(res).toHaveLength(2);
expect(res[0].matchResult).toEqual(['', 'word', '.txt']);
});
});
+81
View File
@@ -0,0 +1,81 @@
import {createApp} from 'vue';
import {registerGlobalInitFunc} from '../modules/observer.ts';
const threshold = 50;
// return the case-insensitive sub-match result as an array: [unmatched, matched, unmatched, matched, ...]
// res[even] is unmatched, res[odd] is matched, see unit tests for examples
// argument subLower must be a lower-cased string.
export function strSubMatch(full: string, subLower: string) {
const res = [''];
let i = 0, j = 0;
const fullLower = full.toLowerCase();
while (i < subLower.length && j < fullLower.length) {
if (subLower[i] === fullLower[j]) {
if (res.length % 2 !== 0) res.push('');
res[res.length - 1] += full[j];
j++;
i++;
} else {
if (res.length % 2 === 0) res.push('');
res[res.length - 1] += full[j];
j++;
}
}
if (i !== subLower.length) {
// if the sub string doesn't match the full, only return the full as unmatched.
return [full];
}
if (j < full.length) {
// append remaining chars from full to result as unmatched
if (res.length % 2 === 0) res.push('');
res[res.length - 1] += full.substring(j);
}
return res;
}
export function calcMatchedWeight(matchResult: Array<any>) {
let weight = 0;
for (let i = 0; i < matchResult.length; i++) {
if (i % 2 === 1) { // matches are on odd indices, see strSubMatch
// use a function f(x+x) > f(x) + f(x) to make the longer matched string has higher weight.
weight += matchResult[i].length * matchResult[i].length;
}
}
return weight;
}
export function filterRepoFilesWeighted(files: Array<string>, filter: string) {
let filterResult = [];
if (filter) {
const filterLower = filter.toLowerCase();
// TODO: for large repo, this loop could be slow, maybe there could be one more limit:
// ... && filterResult.length < threshold * 20, wait for more feedbacks
for (const file of files) {
const res = strSubMatch(file, filterLower);
if (res.length > 1) { // length==1 means unmatched, >1 means having matched sub strings
filterResult.push({matchResult: res, matchWeight: calcMatchedWeight(res)});
}
}
filterResult.sort((a, b) => b.matchWeight - a.matchWeight);
filterResult = filterResult.slice(0, threshold);
} else {
for (let i = 0; i < files.length && i < threshold; i++) {
filterResult.push({matchResult: [files[i]], matchWeight: 0});
}
}
return filterResult;
}
export function initRepoFileSearch() {
registerGlobalInitFunc('initRepoFileSearch', async (el) => {
const {default: RepoFileSearch} = await import('../components/RepoFileSearch.vue');
createApp(RepoFileSearch, {
repoLink: el.getAttribute('data-repo-link'),
currentRefNameSubURL: el.getAttribute('data-current-ref-name-sub-url'),
treeListUrl: el.getAttribute('data-tree-list-url'),
noResultsText: el.getAttribute('data-no-results-text'),
placeholder: el.getAttribute('data-placeholder'),
}).mount(el);
});
}
+85
View File
@@ -0,0 +1,85 @@
import {toggleElemClass} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
export function initRepoGraphGit() {
const graphContainer = document.querySelector<HTMLElement>('#git-graph-container');
if (!graphContainer) return;
const elColorMonochrome = document.querySelector<HTMLElement>('#flow-color-monochrome')!;
const elColorColored = document.querySelector<HTMLElement>('#flow-color-colored')!;
const toggleColorMode = (mode: 'monochrome' | 'colored') => {
toggleElemClass(graphContainer, 'monochrome', mode === 'monochrome');
toggleElemClass(graphContainer, 'colored', mode === 'colored');
toggleElemClass(elColorMonochrome, 'active', mode === 'monochrome');
toggleElemClass(elColorColored, 'active', mode === 'colored');
const params = new URLSearchParams(window.location.search);
params.set('mode', mode);
window.history.replaceState(null, '', `?${params.toString()}`);
for (const link of document.querySelectorAll('#git-graph-body .pagination a')) {
const href = link.getAttribute('href');
if (!href) continue;
const url = new URL(href, window.location.href);
const params = url.searchParams;
params.set('mode', mode);
url.search = `?${params.toString()}`;
link.setAttribute('href', url.href);
}
};
elColorMonochrome.addEventListener('click', () => toggleColorMode('monochrome'));
elColorColored.addEventListener('click', () => toggleColorMode('colored'));
const elGraphBody = document.querySelector<HTMLElement>('#git-graph-body')!;
const url = new URL(window.location.href);
const params = url.searchParams;
const loadGitGraph = async () => {
const queryString = params.toString();
const ajaxUrl = new URL(url);
ajaxUrl.searchParams.set('div-only', 'true');
window.history.replaceState(null, '', queryString ? `?${queryString}` : window.location.pathname);
elGraphBody.classList.add('is-loading');
try {
const resp = await GET(ajaxUrl.toString());
elGraphBody.innerHTML = await resp.text();
} finally {
elGraphBody.classList.remove('is-loading');
}
};
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');
}
const $dropdown = fomanticQuery('#flow-select-refs-dropdown');
$dropdown.dropdown({clearable: true});
$dropdown.dropdown('set selected', dropdownSelected);
// must add the callback after setting the selected items, otherwise each "selected" item will trigger the callback
$dropdown.dropdown('setting', {
onRemove(toRemove: string) {
if (toRemove === '...flow-hide-pr-refs') {
params.delete('hide-pr-refs');
} else {
const branches = params.getAll('branch');
params.delete('branch');
for (const branch of branches) {
if (branch !== toRemove) {
params.append('branch', branch);
}
}
}
loadGitGraph();
},
onAdd(toAdd: string) {
if (toAdd === '...flow-hide-pr-refs') {
params.set('hide-pr-refs', 'true');
} else {
params.append('branch', toAdd);
}
loadGitGraph();
},
});
}
+148
View File
@@ -0,0 +1,148 @@
import {stripTags} from '../utils.ts';
import {hideElem, queryElemChildren, showElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast, type Toast} from '../modules/toast.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
export function initRepoTopicBar() {
const mgrBtn = document.querySelector<HTMLButtonElement>('#manage_topic');
if (!mgrBtn) return;
const editDiv = document.querySelector('#topic_edit')!;
const viewDiv = document.querySelector('#repo-topics')!;
const topicDropdown = editDiv.querySelector('.ui.dropdown')!;
let lastErrorToast: Toast | null = null;
mgrBtn.addEventListener('click', () => {
hideElem([viewDiv, mgrBtn]);
showElem(editDiv);
topicDropdown.querySelector<HTMLInputElement>('input.search')!.focus();
});
document.querySelector('#cancel_topic_edit')!.addEventListener('click', () => {
lastErrorToast?.hideToast();
hideElem(editDiv);
showElem([viewDiv, mgrBtn]);
mgrBtn.focus();
});
document.querySelector<HTMLButtonElement>('#save_topic')!.addEventListener('click', async (e) => {
lastErrorToast?.hideToast();
const topics = editDiv.querySelector<HTMLInputElement>('input[name=topics]')!.value;
const data = new FormData();
data.append('topics', topics);
const response = await POST((e.target as HTMLElement).getAttribute('data-link')!, {data});
if (response.ok) {
const responseData = await response.json();
if (responseData.status === 'ok') {
queryElemChildren(viewDiv, '.repo-topic', (el) => el.remove());
if (topics.length) {
const topicArray = topics.split(',');
topicArray.sort();
for (const topic of topicArray) {
// TODO: sort items in topicDropdown, or items in edit div will have different order to the items in view div
// !!!! it SHOULD and MUST match the code in "home_sidebar_top.tmpl" !!!!
const link = document.createElement('a');
link.classList.add('repo-topic', 'ui', 'large', 'label', 'gt-ellipsis');
link.href = `${appSubUrl}/explore/repos?q=${encodeURIComponent(topic)}&topic=1`;
link.textContent = topic;
viewDiv.append(link);
}
}
hideElem(editDiv);
showElem([viewDiv, mgrBtn]);
}
} else if (response.status === 422) {
// how to test: input topic like " invalid topic " (with spaces), and select it from the list, then "Save"
const responseData = await response.json();
lastErrorToast = showErrorToast(responseData.message, {duration: 5000});
if (responseData.invalidTopics && responseData.invalidTopics.length > 0) {
const {invalidTopics} = responseData;
const topicLabels = queryElemChildren(topicDropdown, 'a.ui.label');
for (const [index, value] of topics.split(',').entries()) {
if (invalidTopics.includes(value)) {
topicLabels[index].classList.remove('green');
topicLabels[index].classList.add('red');
}
}
}
}
});
fomanticQuery(topicDropdown).dropdown({
allowAdditions: true,
forceSelection: false,
fullTextSearch: 'exact',
fields: {name: 'description', value: 'data-value'},
saveRemoteData: false,
label: {
transition: 'horizontal flip',
duration: 200,
variation: false,
},
apiSettings: {
url: `${appSubUrl}/explore/topics/search?q={query}`,
throttle: 500,
cache: false,
onResponse(this: any, res: any) {
const formattedResponse = {
success: false,
results: [] as Array<Record<string, any>>,
};
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'));
}
if (res.topics) {
let found = false;
for (const {topic_name} of res.topics) {
// skip currently added tags
if (current_topics.includes(topic_name)) {
continue;
}
if (topic_name.toLowerCase() === query.toLowerCase()) {
found_query = true;
}
formattedResponse.results.push({description: topic_name, 'data-value': topic_name});
found = true;
}
formattedResponse.success = found;
}
if (query.length > 0 && !found_query) {
formattedResponse.success = true;
formattedResponse.results.unshift({description: query, 'data-value': query});
} else if (query.length > 0 && found_query) {
formattedResponse.results.sort((a, b) => {
if (a.description.toLowerCase() === query.toLowerCase()) return -1;
if (b.description.toLowerCase() === query.toLowerCase()) return 1;
if (a.description > b.description) return -1;
if (a.description < b.description) return 1;
return 0;
});
}
return formattedResponse;
},
},
onLabelCreate(value: string) {
value = value.toLowerCase().trim();
this.attr('data-value', value).contents().first().replaceWith(value);
return fomanticQuery(this);
},
onAdd(addedValue: string, _addedText: any, $addedChoice: any) {
addedValue = addedValue.toLowerCase().trim();
$addedChoice[0].setAttribute('data-value', addedValue);
$addedChoice[0].setAttribute('data-text', addedValue);
},
});
}
+156
View File
@@ -0,0 +1,156 @@
import {svg} 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';
let i18nTextEdited: string;
let i18nTextOptions: string;
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 flex-left-right">
<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>
</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 $fomanticDropdownOptions = fomanticQuery(elOptionsDropdown);
$fomanticDropdownOptions.dropdown({
showOnFocus: false,
allowReselection: true,
async onChange(_value: string, _text: string, $item: any) {
const optionItem = $item.data('option-item');
if (optionItem === 'delete') {
if (window.confirm(i18nTextDeleteFromHistoryConfirm)) {
try {
const params = new URLSearchParams();
params.append('comment_id', commentId);
params.append('history_id', historyId);
const response = await POST(`${issueBaseUrl}/content-history/soft-delete?${params.toString()}`);
const resp = await response.json();
if (resp.ok) {
hideFomanticModal(elDetailDialog);
} else {
showErrorToast(resp.message);
}
} catch (error) {
console.error('Error:', error);
showErrorToast('An error occurred while deleting the history.');
}
}
} else { // required by eslint
showErrorToast(`unknown option item: ${optionItem}`);
}
},
onHide() {
$fomanticDropdownOptions.dropdown('clear', true);
},
});
showFomanticModal(elDetailDialog, {
async onShow() {
try {
const params = new URLSearchParams();
params.append('comment_id', commentId);
params.append('history_id', historyId);
const url = `${issueBaseUrl}/content-history/detail?${params.toString()}`;
const response = await GET(url);
const resp = await response.json();
const commentDiffData = elDetailDialog.querySelector('.comment-diff-data')!;
commentDiffData.classList.remove('is-loading');
commentDiffData.innerHTML = resp.diffHtml;
// there is only one option "item[data-option-item=delete]", so the dropdown can be entirely shown/hidden.
if (resp.canSoftDelete) {
showElem(elOptionsDropdown);
}
} catch (error) {
console.error('Error:', error);
}
},
onHidden() {
elDetailDialog.remove();
},
});
}
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 tw-flex-shrink-0" data-comment-id="${commentId}">
&bull; ${i18nTextEdited}${svg('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
</div>
</div>`;
elHeaderLeft.querySelector(`.ui.dropdown.content-history-menu`)?.remove(); // remove the old one if exists
elHeaderLeft.append(createElementFromHTML(menuHtml));
const elDropdown = elHeaderLeft.querySelector('.ui.dropdown.content-history-menu')!;
const $fomanticDropdown = fomanticQuery(elDropdown);
$fomanticDropdown.dropdown({
action: 'hide',
apiSettings: {
cache: false,
url: `${issueBaseUrl}/content-history/list?comment_id=${commentId}`,
},
saveRemoteData: false,
onHide() {
$fomanticDropdown.dropdown('change values', null);
},
onChange(value: string, itemHtml: string, $item: any) {
if (value && !$item.find('[data-history-is-deleted=1]').length) {
showContentHistoryDetail(issueBaseUrl, commentId, value, itemHtml);
}
},
});
}
export async function initRepoIssueContentHistory() {
const issuePageInfo = parseIssuePageInfo();
if (!issuePageInfo.issueNumber) return;
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;
const issueBaseUrl = `${issuePageInfo.repoLink}/issues/${issuePageInfo.issueNumber}`;
try {
const response = await GET(`${issueBaseUrl}/content-history/overview`);
const resp = await response.json();
i18nTextEdited = resp.i18n.textEdited;
i18nTextDeleteFromHistory = resp.i18n.textDeleteFromHistory;
i18nTextDeleteFromHistoryConfirm = resp.i18n.textDeleteFromHistoryConfirm;
i18nTextOptions = resp.i18n.textOptions;
if (resp.editedHistoryCountMap[0] && elIssueDescription) {
showContentHistoryMenu(issueBaseUrl, elIssueDescription, '0');
}
for (const [commentId, _editedCount] of Object.entries(resp.editedHistoryCountMap)) {
if (commentId === '0') continue;
const elIssueComment = document.querySelector(`#issuecomment-${commentId}`);
if (elIssueComment) showContentHistoryMenu(issueBaseUrl, elIssueComment, commentId);
}
} catch (error) {
console.error('Error:', error);
}
}
+158
View File
@@ -0,0 +1,158 @@
import {handleReply} from './repo-issue.ts';
import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
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';
async function tryOnEditContent(e: Event) {
const clickTarget = (e.target as HTMLElement).closest('.edit-content');
if (!clickTarget) return;
e.preventDefault();
const commentContent = clickTarget.closest('.comment-header')!.nextElementSibling!;
const editContentZone = commentContent.querySelector('.edit-content-zone')!;
let renderContent = commentContent.querySelector('.render-content')!;
const rawContent = commentContent.querySelector('.raw-content')!;
let comboMarkdownEditor : ComboMarkdownEditor;
const cancelAndReset = (e: Event) => {
e.preventDefault();
showElem(renderContent);
hideElem(editContentZone);
comboMarkdownEditor.dropzoneReloadFiles();
};
const saveAndRefresh = async (e: Event) => {
e.preventDefault();
// we are already in a form, do not bubble up to the document otherwise there will be other "form submit handlers"
// at the moment, the form submit event conflicts with initRepoDiffConversationForm (global '.conversation-holder form' event handler)
e.stopPropagation();
renderContent.classList.add('is-loading');
showElem(renderContent);
hideElem(editContentZone);
try {
const params = new URLSearchParams({
content: comboMarkdownEditor.value(),
context: String(editContentZone.getAttribute('data-context')),
content_version: String(editContentZone.getAttribute('data-content-version')),
});
for (const file of comboMarkdownEditor.dropzoneGetFiles() ?? []) {
params.append('files[]', file);
}
const response = await POST(editContentZone.getAttribute('data-update-url')!, {data: params});
const data = await response.json();
if (!response.ok) {
showErrorToast(data?.errorMessage ?? window.config.i18n.error_occurred);
return;
}
reinitializeAreYouSure(editContentZone.querySelector('form')); // the form is no longer dirty
editContentZone.setAttribute('data-content-version', data.contentVersion);
// replace the render content with new one, to trigger re-initialization of all features
const newRenderContent = renderContent.cloneNode(false) as HTMLElement;
newRenderContent.innerHTML = data.content;
renderContent.replaceWith(newRenderContent);
renderContent = newRenderContent;
rawContent.textContent = comboMarkdownEditor.value();
if (!commentContent.querySelector('.dropzone-attachments')) {
if (data.attachments !== '') {
commentContent.insertAdjacentHTML('beforeend', data.attachments);
}
} else if (data.attachments === '') {
commentContent.querySelector('.dropzone-attachments')!.remove();
} else {
commentContent.querySelector('.dropzone-attachments')!.outerHTML = data.attachments;
}
comboMarkdownEditor.dropzoneSubmitReload();
} catch (error) {
showErrorToast(`Failed to save the content: ${errorMessage(error)}`);
console.error(error);
} finally {
renderContent.classList.remove('is-loading');
}
};
// Show write/preview tab and copy raw content as needed
showElem(editContentZone);
hideElem(renderContent);
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'))!;
if (!comboMarkdownEditor) {
editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template')!.innerHTML;
const form = editContentZone.querySelector('form')!;
applyAreYouSure(form);
const saveButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.primary.button')!;
const cancelButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.cancel.button')!;
comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')!);
const syncUiState = () => saveButton.disabled = comboMarkdownEditor.isUploading();
comboMarkdownEditor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState);
cancelButton.addEventListener('click', cancelAndReset);
form.addEventListener('submit', saveAndRefresh);
}
// when the content has changed on server side, there is no sync, and this page doesn't have the latest content,
// the editor still shows the old content, server will reject end user's submit by "data-content-version" check
comboMarkdownEditor.value(rawContent.textContent);
comboMarkdownEditor.switchTabToEditor();
comboMarkdownEditor.focus();
triggerUploadStateChanged(comboMarkdownEditor.container);
}
function extractSelectedMarkdown(container: HTMLElement) {
const selection = window.getSelection();
if (!selection?.rangeCount) return '';
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) return '';
// todo: if commonAncestorContainer parent has "[data-markdown-original-content]" attribute, use the parent's markdown content
// otherwise, use the selected HTML content and respect all "[data-markdown-original-content]/[data-markdown-generated-content]" attributes
const contents = selection.getRangeAt(0).cloneContents();
const el = document.createElement('div');
el.append(contents);
return convertHtmlToMarkdown(el);
}
async function tryOnQuoteReply(e: Event) {
const clickTarget = (e.target as HTMLElement).closest('.quote-reply');
if (!clickTarget) return;
e.preventDefault();
const contentToQuoteId = clickTarget.getAttribute('data-target');
const targetRawToQuote = document.querySelector<HTMLElement>(`#${contentToQuoteId}.raw-content`)!;
const targetMarkupToQuote = targetRawToQuote.parentElement!.querySelector<HTMLElement>('.render-content.markup')!;
let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote);
if (!contentToQuote) contentToQuote = targetRawToQuote.textContent;
const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n\n`;
let editor;
if (clickTarget.classList.contains('quote-reply-diff')) {
const replyBtn = clickTarget.closest('.comment-code-cloud')!.querySelector<HTMLElement>('button.comment-form-reply')!;
editor = await handleReply(replyBtn);
} else {
// for normal issue/comment page
editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor'))!;
}
if (editor.value()) {
editor.value(`${editor.value()}\n\n${quotedContent}`);
} else {
editor.value(quotedContent);
}
editor.focus();
editor.moveCursorToEnd();
}
export function initRepoIssueCommentEdit() {
document.addEventListener('click', (e) => {
tryOnEditContent(e); // Edit issue or comment content
tryOnQuoteReply(e); // Quote reply to the comment editor
});
}
+239
View File
@@ -0,0 +1,239 @@
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';
import {parseDom} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import type {SortableEvent} from 'sortablejs';
function initRepoIssueListCheckboxes() {
const issueSelectAll = document.querySelector<HTMLInputElement>('.issue-checkbox-all');
if (!issueSelectAll) return; // logged out state
const issueCheckboxes = document.querySelectorAll<HTMLInputElement>('.issue-checkbox');
const syncIssueSelectionState = () => {
const checkedCheckboxes = Array.from(issueCheckboxes).filter((el) => el.checked);
const anyChecked = Boolean(checkedCheckboxes.length);
const allChecked = anyChecked && checkedCheckboxes.length === issueCheckboxes.length;
if (allChecked) {
issueSelectAll.checked = true;
issueSelectAll.indeterminate = false;
} else if (anyChecked) {
issueSelectAll.checked = false;
issueSelectAll.indeterminate = true;
} else {
issueSelectAll.checked = false;
issueSelectAll.indeterminate = false;
}
// if any issue is selected, show the action panel, otherwise show the filter panel
toggleElem('#issue-filters', !anyChecked);
toggleElem('#issue-actions', anyChecked);
// there are two panels but only one select-all checkbox, so move the checkbox to the visible panel
const panels = document.querySelectorAll<HTMLElement>('#issue-filters, #issue-actions');
const visiblePanel = Array.from(panels).find((el) => isElemVisible(el))!;
const toolbarLeft = visiblePanel.querySelector('.issue-list-toolbar-left')!;
toolbarLeft.prepend(issueSelectAll);
};
for (const el of issueCheckboxes) {
el.addEventListener('change', syncIssueSelectionState);
}
issueSelectAll.addEventListener('change', () => {
for (const el of issueCheckboxes) {
el.checked = issueSelectAll.checked;
}
syncIssueSelectionState();
});
queryElems(document, '.issue-action', (el) => el.addEventListener('click',
async (e: MouseEvent) => {
e.preventDefault();
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 issueIDs = issueIDList.join(',');
if (!issueIDs) return;
// for assignee
if (elementId === '0' && url.endsWith('/assignee')) {
elementId = '';
action = 'clear';
}
// for toggle
if (action === 'toggle' && e.altKey) {
action = 'toggle-alt';
}
// for delete
if (action === 'delete') {
const confirmText = el.getAttribute('data-action-delete-confirm')!;
if (!await confirmModal({content: confirmText, confirmButtonColor: 'red'})) {
return;
}
}
try {
await updateIssuesMeta(url, action, issueIDs, elementId);
window.location.reload();
} catch (err) {
// 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));
}
},
));
}
function initDropdownUserRemoteSearch(el: Element) {
let searchUrl = el.getAttribute('data-search-url');
const actionJumpUrl = el.getAttribute('data-action-jump-url')!;
let selectedUsername = el.getAttribute('data-selected-username') || '';
const $searchDropdown = fomanticQuery(el);
const elMenu = el.querySelector('.menu')!;
const elSearchInput = el.querySelector<HTMLInputElement>('.ui.search input')!;
const elItemFromInput = el.querySelector('.menu > .item-from-input')!;
$searchDropdown.dropdown('setting', {
fullTextSearch: true,
selectOnKeydown: false,
action: (_text: string, value: string) => {
window.location.href = actionJumpUrl.replace('{username}', encodeURIComponent(value));
},
});
const selectUsername = (username: string) => {
queryElems(elMenu, '.item.active, .item.selected', (el) => el.classList.remove('active', 'selected'));
elMenu.querySelector(`.item[data-value="${CSS.escape(username)}"]`)?.classList.add('selected');
};
type ProcessedResult = {value: string, name: string};
const processedResults: ProcessedResult[] = []; // to be used by dropdown to generate menu items
const syncItemFromInput = () => {
const inputVal = elSearchInput.value.trim();
elItemFromInput.setAttribute('data-value', inputVal);
elItemFromInput.textContent = inputVal;
const showItemFromInput = !processedResults.length && inputVal !== '';
toggleElem(elItemFromInput, showItemFromInput);
selectUsername(showItemFromInput ? inputVal : selectedUsername);
};
elSearchInput.value = selectedUsername;
if (!searchUrl) {
elSearchInput.addEventListener('input', syncItemFromInput);
} else {
if (!searchUrl.includes('?')) searchUrl += '?';
$searchDropdown.dropdown('setting', 'apiSettings', {
cache: false,
url: `${searchUrl}&q={query}`,
onResponse(resp: any) {
// the content is provided by backend IssuePosters handler
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 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});
}
resp.results = processedResults;
return resp;
},
});
$searchDropdown.dropdown('setting', 'onShow', () => $searchDropdown.dropdown('filter', ' ')); // trigger a search on first show
}
// we want to generate the dropdown menu items by ourselves, replace its internal setup functions
const dropdownSetup = {...$searchDropdown.dropdown('internal', 'setup')};
const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates');
$searchDropdown.dropdown('internal', 'setup', dropdownSetup);
dropdownSetup.menu = function (values: any) {
// remove old dynamic items
for (const el of elMenu.querySelectorAll(':scope > .dynamic-item')) {
el.remove();
}
const newMenuHtml = dropdownTemplates.menu(values, $searchDropdown.dropdown('setting', 'fields'), true /* html */, $searchDropdown.dropdown('setting', 'className'));
if (newMenuHtml) {
const newMenuItems = parseDom(newMenuHtml, 'text/html').querySelectorAll('body > div');
for (const newMenuItem of newMenuItems) {
newMenuItem.classList.add('dynamic-item');
}
const div = document.createElement('div');
div.classList.add('divider', 'dynamic-item');
elMenu.append(div, ...newMenuItems);
}
$searchDropdown.dropdown('refresh');
// defer our selection to the next tick, because dropdown will set the selection item after this `menu` function
setTimeout(() => syncItemFromInput(), 0);
};
}
function initPinRemoveButton() {
for (const button of document.querySelectorAll('.issue-card-unpin')) {
button.addEventListener('click', async (event) => {
const el = event.currentTarget as HTMLElement;
const id = Number(el.getAttribute('data-issue-id'));
// Send the unpin request
const response = await DELETE(el.getAttribute('data-unpin-url')!);
if (response.ok) {
// Delete the tooltip
el._tippy.destroy();
// Remove the Card
el.closest(`div.issue-card[data-issue-id="${id}"]`)!.remove();
}
});
}
}
async function pinMoveEnd(e: SortableEvent) {
const url = e.item.getAttribute('data-move-url')!;
const id = Number(e.item.getAttribute('data-issue-id'));
await POST(url, {data: {id, position: e.newIndex! + 1}});
}
async function initIssuePinSort() {
const pinDiv = document.querySelector<HTMLElement>('#issue-pins');
if (pinDiv === null) return;
// If the User is not a Repo Admin, we don't need to proceed
if (!pinDiv.hasAttribute('data-is-repo-admin')) return;
initPinRemoveButton();
// If only one issue pinned, we don't need to make this Sortable
if (pinDiv.children.length < 2) return;
createSortable(pinDiv, {
group: 'shared',
onEnd: (e) => {
(async () => {
await pinMoveEnd(e);
})();
},
});
}
export function initRepoIssueList() {
if (document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) {
initRepoIssueListCheckboxes();
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
initIssuePinSort();
} else if (document.querySelector('.page-content.dashboard.issues')) {
// user or org home: issue list, pull request list
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
}
}
+80
View File
@@ -0,0 +1,80 @@
import {createApp} from 'vue';
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';
export function initRepoPullRequestUpdate(el: HTMLElement) {
const elDropdown = el.querySelector(':scope > .ui.dropdown');
if (!elDropdown) return;
const elButton = el.querySelector<HTMLButtonElement>(':scope > button')!;
fomanticQuery(elDropdown).dropdown({
onChange(_text: string, _value: string, $choice: any) {
const choiceEl = $choice[0];
elButton.textContent = choiceEl.textContent;
elButton.setAttribute('data-url', choiceEl.getAttribute('data-update-url'));
},
});
}
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, {mergeFormProps: data});
view.mount(el); // TODO: can unmount when reloaded?
}
export function initRepoPullMergeBox(el: HTMLElement) {
registerGlobalEventFunc('click', 'onCommitStatusChecksToggle', onCommitStatusChecksToggle);
initRepoPullRequestMergeForm(el);
const reloadingIntervalValue = el.getAttribute('data-pull-merge-box-reloading-interval');
if (!reloadingIntervalValue) return;
const reloadingInterval = parseInt(reloadingIntervalValue);
const pullLink = el.getAttribute('data-pull-link');
let timerId: number | null;
let reloadMergeBox: () => Promise<void>;
const stopReloading = () => {
if (!timerId) return;
clearTimeout(timerId);
timerId = null;
};
const startReloading = () => {
if (timerId) return;
setTimeout(reloadMergeBox, reloadingInterval);
};
const onVisibilityChange = () => {
if (document.hidden) {
stopReloading();
} else {
startReloading();
}
};
reloadMergeBox = async () => {
const resp = await GET(`${pullLink}/merge_box`);
stopReloading();
if (!resp.ok) {
startReloading();
return;
}
document.removeEventListener('visibilitychange', onVisibilityChange);
const newElem = createElementFromHTML(await resp.text());
el.replaceWith(newElem);
};
document.addEventListener('visibilitychange', onVisibilityChange);
startReloading();
}

Some files were not shown because too many files have changed in this diff Show More