feat: vendor gitea 1.16.2

This commit is contained in:
2026-06-02 08:36:01 +00:00
parent 247cd60f69
commit 54798b4615
2145 changed files with 162965 additions and 126447 deletions
@@ -1,12 +0,0 @@
import {showGlobalErrorMessage} from './bootstrap.ts';
test('showGlobalErrorMessage', () => {
document.body.innerHTML = '<div class="page-content"></div>';
showGlobalErrorMessage('test msg 1');
showGlobalErrorMessage('test msg 2');
showGlobalErrorMessage('test msg 1'); // duplicated
expect(document.body.innerHTML).toContain('>test msg 1 (2)<');
expect(document.body.innerHTML).toContain('>test msg 2<');
expect(document.querySelectorAll('.js-global-error').length).toEqual(2);
});
+7 -77
View File
@@ -1,92 +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 type {Intent} from './types.ts';
import {html} from './utils/html.ts';
import {showGlobalErrorMessage, processWindowErrorEvent} from './modules/errors.ts';
// This sets up the URL prefix used in webpack's chunk loading.
// This file must be imported before any lazy-loading is being attempted.
__webpack_public_path__ = `${window.config?.assetUrlPrefix ?? '/assets'}/`;
function shouldIgnoreError(err: Error) {
const ignorePatterns = [
'/assets/js/monaco.', // https://github.com/go-gitea/gitea/issues/30861 , https://github.com/microsoft/monaco-editor/issues/4496
];
for (const pattern of ignorePatterns) {
if (err.stack?.includes(pattern)) return true;
}
return false;
}
export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
const msgContainer = document.querySelector('.page-content') ?? document.body;
if (!msgContainer) {
alert(`${msgType}: ${msg}`);
return;
}
const msgCompact = msg.replace(/\W/g, '').trim(); // compact the message to a data attribute to avoid too many duplicated messages
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
if (!msgDiv) {
const el = document.createElement('div');
el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
msgDiv = el.childNodes[0] as HTMLDivElement;
}
// merge duplicated messages into "the message (count)" format
const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
msgDiv.querySelector('.ui.message').textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
msgContainer.prepend(msgDiv);
}
function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}: ErrorEvent & PromiseRejectionEvent) {
const err = error ?? reason;
const assetBaseUrl = String(new URL(__webpack_public_path__, window.location.origin));
const {runModeIsProd} = window.config ?? {};
// `error` and `reason` are not guaranteed to be errors. If the value is falsy, it is likely a
// non-critical event from the browser. We log them but don't show them to users. Examples:
// - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
// - https://github.com/mozilla-mobile/firefox-ios/issues/10817
// - https://github.com/go-gitea/gitea/issues/20240
if (!err) {
if (message) console.error(new Error(message));
if (runModeIsProd) return;
}
if (err instanceof Error) {
// If the error stack trace does not include the base URL of our script assets, it likely came
// from a browser extension or inline script. Do not show such errors in production.
if (!err.stack?.includes(assetBaseUrl) && runModeIsProd) return;
// Ignore some known errors that are unable to fix
if (shouldIgnoreError(err)) return;
}
let msg = err?.message ?? message;
if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
const dot = msg.endsWith('.') ? '' : '.';
const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`);
}
function initGlobalErrorHandler() {
if (window._globalHandlerErrors?._inited) {
showGlobalErrorMessage(`The global error handler has been initialized, do not initialize it again`);
return;
}
// 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 || []) {
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
// @ts-expect-error -- this should be refactored to not use a fake array
window._globalHandlerErrors = {_inited: true, push: (e: ErrorEvent & PromiseRejectionEvent) => processWindowErrorEvent(e)};
window._globalHandlerErrors = {_inited: true, push: (e: ErrorEvent & PromiseRejectionEvent) => processWindowErrorEvent(e)} as any;
}
initGlobalErrorHandler();
@@ -0,0 +1,721 @@
<script setup lang="ts">
import {nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import ActionRunStatus from './ActionRunStatus.vue';
import {addDelegatedEventListener, createElementFromAttrs, toggleElem} from '../utils/dom.ts';
import {formatDatetime} from '../utils/time.ts';
import {POST} from '../modules/fetch.ts';
import type {IntervalId} from '../types.ts';
import {toggleFullScreen} from '../utils.ts';
import {localUserSettings} from '../modules/user-settings.ts';
import type {ActionsArtifact, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
import {
type ActionRunViewStore,
createLogLineMessage,
type LogLine,
type LogLineCommand,
parseLogLineCommand
} from './ActionRunView.ts';
function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPortHeight: 0}): boolean {
const rect = el.getBoundingClientRect();
// only check whether bottom is in viewport, because the log element can be a log group which is usually tall
return 0 <= rect.bottom && rect.bottom <= window.innerHeight + extraViewPortHeight;
}
type Step = {
summary: string,
duration: string,
status: ActionsRunStatus,
}
type JobStepState = {
cursor: string|null,
expanded: boolean,
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
}
type StepContainerElement = HTMLElement & {
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
// then the following batches of logs should still use the same group (active logs container).
// maybe it can be refactored to decouple from the HTML element in the future.
_stepLogsActiveContainer?: HTMLElement;
}
type LocaleStorageOptions = {
autoScroll: boolean;
expandRunning: boolean;
actionsLogShowSeconds: boolean;
actionsLogShowTimestamps: boolean;
};
type CurrentJob = {
title: string;
detail: string;
steps: Array<Step>;
};
type JobData = {
artifacts: Array<ActionsArtifact>;
state: {
run: ActionsRun;
currentJob: CurrentJob;
},
logs: {
stepsLog?: Array<{
step: number;
cursor: string | null;
started: number;
lines: LogLine[];
}>;
},
};
defineOptions({
name: 'ActionRunJobView',
});
const props = defineProps<{
store: ActionRunViewStore,
runId: number;
jobId: number;
actionsUrl: string;
locale: Record<string, any>;
}>();
const store = props.store;
const {currentRun: run} = toRefs(store.viewData);
const defaultViewOptions: LocaleStorageOptions = {
autoScroll: true,
expandRunning: false,
actionsLogShowSeconds: false,
actionsLogShowTimestamps: false,
};
const savedViewOptions = localUserSettings.getJsonObject('actions-view-options', defaultViewOptions);
const {autoScroll, expandRunning, actionsLogShowSeconds, actionsLogShowTimestamps} = savedViewOptions;
// internal state
let loadingAbortController: AbortController | null = null;
let intervalID: IntervalId | null = null;
const currentJobStepsStates = ref<Array<JobStepState>>([]);
const menuVisible = ref(false);
const isFullScreen = ref(false);
const timeVisible = ref<Record<string, boolean>>({
'log-time-stamp': actionsLogShowTimestamps,
'log-time-seconds': actionsLogShowSeconds,
});
const optionAlwaysAutoScroll = ref(autoScroll);
const optionAlwaysExpandRunning = ref(expandRunning);
const currentJob = ref<CurrentJob>({
title: '',
detail: '',
steps: [] as Array<Step>,
});
const stepsContainer = ref<HTMLElement | null>(null);
const jobStepLogs = ref<Array<StepContainerElement | undefined>>([]);
watch(optionAlwaysAutoScroll, () => {
saveLocaleStorageOptions();
});
watch(optionAlwaysExpandRunning, () => {
saveLocaleStorageOptions();
});
onMounted(async () => {
// load job data and then auto-reload periodically
// need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
await loadJob();
// auto-scroll to the bottom of the log group when it is opened
// "toggle" event doesn't bubble, so we need to use 'click' event delegation to handle it
addDelegatedEventListener(elStepsContainer(), 'click', 'summary.job-log-group-summary', (el, _) => {
if (!optionAlwaysAutoScroll.value) return;
const elJobLogGroup = el.closest('details.job-log-group') as HTMLDetailsElement;
setTimeout(() => {
if (elJobLogGroup.open && !isLogElementInViewport(elJobLogGroup)) {
elJobLogGroup.scrollIntoView({behavior: 'smooth', block: 'end'});
}
}, 0);
});
intervalID = setInterval(() => void loadJob(), 1000);
document.body.addEventListener('click', closeDropdown);
void hashChangeListener();
window.addEventListener('hashchange', hashChangeListener);
});
onBeforeUnmount(() => {
document.body.removeEventListener('click', closeDropdown);
window.removeEventListener('hashchange', hashChangeListener);
// clear the interval timer when the component is unmounted
// even our page is rendered once, not spa style
if (intervalID) {
clearInterval(intervalID);
intervalID = null;
}
});
function saveLocaleStorageOptions() {
const opts: LocaleStorageOptions = {
autoScroll: optionAlwaysAutoScroll.value,
expandRunning: optionAlwaysExpandRunning.value,
actionsLogShowSeconds: timeVisible.value['log-time-seconds'],
actionsLogShowTimestamps: timeVisible.value['log-time-stamp'],
};
localUserSettings.setJsonObject('actions-view-options', opts);
}
// get the job step logs container ('.job-step-logs')
function getJobStepLogsContainer(stepIndex: number): StepContainerElement {
return jobStepLogs.value[stepIndex] as StepContainerElement;
}
// get the active logs container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
function getActiveLogsContainer(stepIndex: number): StepContainerElement {
const el = getJobStepLogsContainer(stepIndex);
return el._stepLogsActiveContainer ?? el;
}
// begin a log group
function beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) {
const el = getJobStepLogsContainer(stepIndex);
// Using "summary + details" is the best way to create a log group because it has built-in support for "toggle" and "accessibility".
// And it makes users can use "Ctrl+F" to search the logs without opening all log groups.
const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'},
createLogLine(stepIndex, startTime, line, cmd),
);
const elJobLogList = createElementFromAttrs('div', {class: 'job-log-list'});
const elJobLogGroup = createElementFromAttrs('details', {class: 'job-log-group'},
elJobLogGroupSummary,
elJobLogList,
);
el.append(elJobLogGroup);
el._stepLogsActiveContainer = elJobLogList;
}
// end a log group
function endLogGroup(stepIndex: number) {
const el = getJobStepLogsContainer(stepIndex);
el._stepLogsActiveContainer = undefined;
}
// show/hide the step logs for a step
function toggleStepLogs(idx: number) {
currentJobStepsStates.value[idx].expanded = !currentJobStepsStates.value[idx].expanded;
if (currentJobStepsStates.value[idx].expanded) {
void loadJobForce(); // try to load the data immediately instead of waiting for next timer interval
} else if (currentJob.value.steps[idx].status === 'running') {
currentJobStepsStates.value[idx].manuallyCollapsed = true;
}
}
function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand | null) {
const lineNum = createElementFromAttrs('a', {class: 'line-num muted', href: `#jobstep-${stepIndex}-${line.index}`},
String(line.index),
);
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
formatDatetime(new Date(line.timestamp * 1000)), // for "Show timestamps"
);
const logMsg = createLogLineMessage(line, cmd);
const seconds = Math.floor(line.timestamp - startTime);
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
`${seconds}s`, // for "Show seconds"
);
toggleElem(logTimeStamp, timeVisible.value['log-time-stamp']);
toggleElem(logTimeSeconds, timeVisible.value['log-time-seconds']);
const lineClass = cmd?.name ? `job-log-line log-line-${cmd.name}` : 'job-log-line';
return createElementFromAttrs('div', {id: `jobstep-${stepIndex}-${line.index}`, class: lineClass},
lineNum, logTimeStamp, logMsg, logTimeSeconds,
);
}
function shouldAutoScroll(stepIndex: number): boolean {
if (!optionAlwaysAutoScroll.value) return false;
const el = getJobStepLogsContainer(stepIndex);
// if the logs container is empty, then auto-scroll if the step is expanded
if (!el.lastChild) return currentJobStepsStates.value[stepIndex].expanded;
// use extraViewPortHeight to tolerate some extra "virtual view port" height (for example: the last line is partially visible)
return isLogElementInViewport(el.lastChild as Element, {extraViewPortHeight: 5});
}
function appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) {
for (const line of logLines) {
const cmd = parseLogLineCommand(line);
switch (cmd?.name) {
case 'hidden':
continue;
case 'group':
beginLogGroup(stepIndex, startTime, line, cmd);
continue;
case 'endgroup':
endLogGroup(stepIndex);
continue;
}
// the active logs container may change during the loop, for example: entering and leaving a group
const el = getActiveLogsContainer(stepIndex);
el.append(createLogLine(stepIndex, startTime, line, cmd));
}
}
async function fetchJobData(abortController: AbortController): Promise<JobData> {
const logCursors = currentJobStepsStates.value.map((it, idx) => {
// cursor is used to indicate the last position of the logs
// it's only used by backend, frontend just reads it and passes it back, it can be any type.
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
return {step: idx, cursor: it.cursor, expanded: it.expanded};
});
const url = `${props.actionsUrl}/runs/${props.runId}/jobs/${props.jobId}`;
const resp = await POST(url, {
signal: abortController.signal,
data: {logCursors},
});
return await resp.json();
}
async function loadJobForce() {
loadingAbortController?.abort();
loadingAbortController = null;
await loadJob();
}
async function loadJob() {
if (loadingAbortController) return;
const abortController = new AbortController();
loadingAbortController = abortController;
try {
const runJobResp = await fetchJobData(abortController);
if (loadingAbortController !== abortController) return;
// FIXME: this logic is quite hacky and dirty, it should be refactored in a better way in the future
// Use consistent "store" operations to load/update the view data
store.viewData.runArtifacts = runJobResp.artifacts || [];
store.viewData.currentRun = runJobResp.state.run;
currentJob.value = runJobResp.state.currentJob;
const jobLogs = runJobResp.logs.stepsLog ?? [];
// sync the currentJobStepsStates to store the job step states
for (let i = 0; i < currentJob.value.steps.length; i++) {
const autoExpand = optionAlwaysExpandRunning.value && currentJob.value.steps[i].status === 'running';
if (!currentJobStepsStates.value[i]) {
// initial states for job steps
currentJobStepsStates.value[i] = {cursor: null, expanded: autoExpand, manuallyCollapsed: false};
} else {
// if the step is not manually collapsed by user, then auto-expand it if option is enabled
if (autoExpand && !currentJobStepsStates.value[i].manuallyCollapsed) {
currentJobStepsStates.value[i].expanded = true;
}
}
}
await nextTick();
// find the step indexes that need to auto-scroll
const autoScrollStepIndexes = new Map<number, boolean>();
for (const stepLogs of jobLogs) {
if (autoScrollStepIndexes.has(stepLogs.step)) continue;
autoScrollStepIndexes.set(stepLogs.step, shouldAutoScroll(stepLogs.step));
}
// append logs to the UI
for (const stepLogs of jobLogs) {
// save the cursor, it will be passed to backend next time
currentJobStepsStates.value[stepLogs.step].cursor = stepLogs.cursor;
appendLogs(stepLogs.step, stepLogs.started, stepLogs.lines);
}
// auto-scroll to the last log line of the last step
let autoScrollJobStepElement: StepContainerElement | undefined;
for (let stepIndex = 0; stepIndex < currentJob.value.steps.length; stepIndex++) {
if (!autoScrollStepIndexes.get(stepIndex)) continue;
autoScrollJobStepElement = getJobStepLogsContainer(stepIndex);
}
const lastLogElem = autoScrollJobStepElement?.lastElementChild;
if (lastLogElem && !isLogElementInViewport(lastLogElem)) {
lastLogElem.scrollIntoView({behavior: 'smooth', block: 'end'});
}
// clear the interval timer if the job is done
if (run.value.done && intervalID) {
clearInterval(intervalID);
intervalID = null;
}
} catch (e) {
// avoid network error while unloading page, and ignore "abort" error
if (e instanceof TypeError || abortController.signal.aborted) return;
throw e;
} finally {
if (loadingAbortController === abortController) loadingAbortController = null;
}
}
function isDone(status: ActionsRunStatus) {
return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
}
function isExpandable(status: ActionsRunStatus) {
return ['success', 'running', 'failure', 'cancelled'].includes(status);
}
function closeDropdown() {
if (menuVisible.value) menuVisible.value = false;
}
function elStepsContainer(): HTMLElement {
return stepsContainer.value as HTMLElement;
}
function toggleTimeDisplay(type: 'seconds' | 'stamp') {
timeVisible.value[`log-time-${type}`] = !timeVisible.value[`log-time-${type}`];
for (const el of elStepsContainer().querySelectorAll(`.log-time-${type}`)) {
toggleElem(el, timeVisible.value[`log-time-${type}`]);
}
saveLocaleStorageOptions();
}
function toggleFullScreenMode() {
isFullScreen.value = !isFullScreen.value;
toggleFullScreen(document.querySelector('.action-view-right')!, isFullScreen.value, '.action-view-body');
}
async function hashChangeListener() {
const selectedLogStep = window.location.hash;
if (!selectedLogStep) return;
const [_, step, _line] = selectedLogStep.split('-');
const stepNum = Number(step);
if (!currentJobStepsStates.value[stepNum]) return;
if (!currentJobStepsStates.value[stepNum].expanded && currentJobStepsStates.value[stepNum].cursor === null) {
currentJobStepsStates.value[stepNum].expanded = true;
// need to await for load job if the step log is loaded for the first time
// so logline can be selected by querySelector
await loadJob();
}
await nextTick();
const logLine = elStepsContainer().querySelector(selectedLogStep);
if (!logLine) return;
logLine.querySelector<HTMLAnchorElement>('.line-num')!.click();
}
</script>
<template>
<div class="job-info-header">
<div class="job-info-header-left gt-ellipsis">
<h3 class="job-info-header-title gt-ellipsis">
{{ currentJob.title }}
</h3>
<p class="job-info-header-detail">
{{ currentJob.detail }}
</p>
</div>
<div class="job-info-header-right">
<div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
<button class="ui button tw-px-3">
<SvgIcon name="octicon-gear" :size="18"/>
</button>
<div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
<a class="item" @click="toggleTimeDisplay('seconds')">
<i class="icon"><SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
{{ locale.showLogSeconds }}
</a>
<a class="item" @click="toggleTimeDisplay('stamp')">
<i class="icon"><SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
{{ locale.showTimeStamps }}
</a>
<a class="item" @click="toggleFullScreenMode()">
<i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
{{ locale.showFullScreen }}
</a>
<div class="divider"/>
<a class="item" @click="optionAlwaysAutoScroll = !optionAlwaysAutoScroll">
<i class="icon"><SvgIcon :name="optionAlwaysAutoScroll ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
{{ locale.logsAlwaysAutoScroll }}
</a>
<a class="item" @click="optionAlwaysExpandRunning = !optionAlwaysExpandRunning">
<i class="icon"><SvgIcon :name="optionAlwaysExpandRunning ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
{{ locale.logsAlwaysExpandRunning }}
</a>
<div class="divider"/>
<a :class="['item', !currentJob.steps.length ? 'disabled' : '']" :href="run.link + '/jobs/' + jobId + '/logs'" download>
<i class="icon"><SvgIcon name="octicon-download"/></i>
{{ locale.downloadLogs }}
</a>
</div>
</div>
</div>
</div>
<!-- always create the node because we have our own event listeners on it, don't use "v-if" -->
<div class="job-step-container" ref="stepsContainer" v-show="currentJob.steps.length">
<div class="job-step-section" v-for="(jobStep, stepIdx) in currentJob.steps" :key="stepIdx">
<div
class="job-step-summary"
@click.stop="isExpandable(jobStep.status) && toggleStepLogs(stepIdx)"
:class="[currentJobStepsStates[stepIdx].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']"
>
<!-- If the job is done and the job step log is loaded for the first time, show the loading icon
currentJobStepsStates[i].cursor === null means the log is loaded for the first time
-->
<SvgIcon
v-if="isDone(run.status) && currentJobStepsStates[stepIdx].expanded && currentJobStepsStates[stepIdx].cursor === null"
name="gitea-running"
class="tw-mr-2 rotate-clockwise"
/>
<SvgIcon
v-else
:name="currentJobStepsStates[stepIdx].expanded ? 'octicon-chevron-down' : 'octicon-chevron-right'"
:class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"
/>
<ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
<span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
<span class="step-summary-duration">{{ jobStep.duration }}</span>
</div>
<!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
<div class="job-step-logs" :ref="(el) => jobStepLogs[stepIdx] = el as StepContainerElement" v-show="currentJobStepsStates[stepIdx].expanded"/>
</div>
</div>
</template>
<style scoped>
/* begin fomantic dropdown menu overrides */
.action-view-right .ui.dropdown .menu {
background: var(--color-console-menu-bg);
border-color: var(--color-console-menu-border);
}
.action-view-right .ui.dropdown .menu > .item {
color: var(--color-console-fg);
}
.action-view-right .ui.dropdown .menu > .item:hover {
color: var(--color-console-fg);
background: var(--color-console-hover-bg);
}
.action-view-right .ui.dropdown .menu > .item:active {
color: var(--color-console-fg);
background: var(--color-console-active-bg);
}
.action-view-right .ui.dropdown .menu > .divider {
border-top-color: var(--color-console-menu-border);
}
.action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after {
background: var(--color-console-menu-bg);
box-shadow: -1px -1px 0 0 var(--color-console-menu-border);
}
/* end fomantic dropdown menu overrides */
.job-info-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px;
position: sticky;
top: 0;
height: 60px;
z-index: 1; /* above .job-step-container */
background: var(--color-console-bg);
border-radius: 3px;
}
.job-info-header:has(+ .job-step-container) {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
.job-info-header .job-info-header-title {
color: var(--color-console-fg);
font-size: 16px;
margin: 0;
}
.job-info-header .job-info-header-detail {
color: var(--color-console-fg-subtle);
font-size: 12px;
}
.job-info-header-left {
flex: 1;
}
.job-step-container {
max-height: 100%;
border-radius: 0 0 var(--border-radius) var(--border-radius);
border-top: 1px solid var(--color-console-border);
z-index: 0;
}
.job-step-container .job-step-summary {
padding: 5px 10px;
display: flex;
align-items: center;
border-radius: var(--border-radius);
}
.job-step-container .job-step-summary.step-expandable {
cursor: pointer;
}
.job-step-container .job-step-summary.step-expandable:hover {
color: var(--color-console-fg);
background: var(--color-console-hover-bg);
}
.job-step-container .job-step-summary .step-summary-msg {
flex: 1;
}
.job-step-container .job-step-summary .step-summary-duration {
margin-left: 16px;
}
.job-step-container .job-step-summary.selected {
color: var(--color-console-fg);
background-color: var(--color-console-active-bg);
position: sticky;
top: 60px;
/* workaround ansi_up issue related to faintStyle generating a CSS stacking context via `opacity`
inline style which caused such elements to render above the .job-step-summary header. */
z-index: 1;
}
</style>
<style> /* eslint-disable-line vue-scoped-css/enforce-style-type */
/* some elements are not managed by vue, so we need to use global style */
.job-step-section {
margin: 10px;
}
.job-step-section .job-step-logs {
font-family: var(--fonts-monospace);
margin: 8px 0;
font-size: 12px;
}
.job-step-section .job-step-logs .job-log-line {
display: flex;
}
.job-log-line:hover,
.job-log-line:target {
background-color: var(--color-console-hover-bg);
}
.job-log-line:target {
scroll-margin-top: 95px;
}
/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
.job-log-line .line-num, .log-time-seconds {
width: 48px;
color: var(--color-text-light-3);
text-align: right;
user-select: none;
}
.job-log-line:target > .line-num {
color: var(--color-primary);
text-decoration: underline;
}
.log-time-seconds {
padding-right: 2px;
}
.job-log-line .log-time,
.log-time-stamp {
color: var(--color-text-light-3);
margin-left: 10px;
white-space: nowrap;
}
.job-step-logs .job-log-line .log-msg {
flex: 1;
white-space: break-spaces;
margin-left: 10px;
overflow-wrap: anywhere;
}
.job-step-logs .log-msg a {
color: var(--color-console-link) !important;
text-decoration: underline;
}
.job-step-logs .job-log-line .log-cmd-command {
color: var(--color-ansi-blue);
}
.job-step-logs .log-msg-label {
font-weight: var(--font-weight-semibold);
}
.job-step-logs .log-line-error {
background: var(--color-error-bg);
}
.job-step-logs .log-line-warning {
background: var(--color-warning-bg);
}
.job-step-logs .log-line-notice {
background: var(--color-info-bg);
}
.job-step-logs .log-line-debug {
background: var(--color-secondary-alpha-30);
}
.job-step-logs .log-cmd-error > .log-msg-label {
color: var(--color-error-text);
}
.job-step-logs .log-cmd-warning > .log-msg-label {
color: var(--color-warning-text);
}
.job-step-logs .log-cmd-notice > .log-msg-label {
color: var(--color-info-text);
}
.job-step-logs .log-cmd-debug > .log-msg-label {
color: var(--color-violet);
}
/* selectors here are intentionally exact to only match fullscreen */
.full.height > .action-view-right {
width: 100%;
height: 100%;
padding: 0;
border-radius: 0;
}
.full.height > .action-view-right > .job-info-header {
border-radius: 0;
}
.full.height > .action-view-right > .job-step-container {
height: calc(100% - 60px);
border-radius: 0;
}
.job-log-group .job-log-list .job-log-line .log-msg {
margin-left: 2em;
}
.job-log-group-summary {
position: relative;
}
.job-log-group-summary > .job-log-line {
position: absolute;
inset: 0;
z-index: -1; /* to avoid hiding the triangle of the "details" element */
overflow: hidden;
}
</style>
@@ -19,12 +19,12 @@ withDefaults(defineProps<{
<template>
<span :data-tooltip-content="localeStatus ?? status" v-if="status">
<SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class="className" v-if="status === 'success'"/>
<SvgIcon name="octicon-skip" class="text grey" :size="size" :class="className" v-else-if="status === 'skipped'"/>
<SvgIcon name="octicon-stop" class="text grey" :size="size" :class="className" v-else-if="status === 'cancelled'"/>
<SvgIcon name="octicon-circle" class="text grey" :size="size" :class="className" v-else-if="status === 'waiting'"/>
<SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/>
<SvgIcon name="gitea-running" class="text yellow" :size="size" :class="'rotate-clockwise ' + className" v-else-if="status === 'running'"/>
<SvgIcon name="octicon-x-circle-fill" class="text red" :size="size" v-else/><!-- failure, unknown -->
<SvgIcon name="octicon-check-circle-fill" class="tw-text-green" :size="size" :class="className" v-if="status === 'success'"/>
<SvgIcon name="octicon-skip" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'skipped'"/>
<SvgIcon name="octicon-stop" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'cancelled'"/>
<SvgIcon name="octicon-circle" class="tw-text-text-light" :size="size" :class="className" v-else-if="status === 'waiting'"/>
<SvgIcon name="octicon-blocked" class="tw-text-yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/>
<SvgIcon name="gitea-running" class="tw-text-yellow" :size="size" :class="'rotate-clockwise ' + className" v-else-if="status === 'running'"/>
<SvgIcon name="octicon-x-circle-fill" class="tw-text-red" :size="size" v-else/><!-- failure, unknown -->
</span>
</template>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import ActionRunStatus from './ActionRunStatus.vue';
import WorkflowGraph from './WorkflowGraph.vue';
import type {ActionRunViewStore} from "./ActionRunView.ts";
import {computed, onBeforeUnmount, onMounted, toRefs} from "vue";
defineOptions({
name: 'ActionRunSummaryView',
});
const props = defineProps<{
store: ActionRunViewStore;
locale: Record<string, any>;
}>();
const {currentRun: run} = toRefs(props.store.viewData);
const runTriggeredAtIso = computed(() => {
const t = props.store.viewData.currentRun.triggeredAt;
return t ? new Date(t * 1000).toISOString() : '';
});
onMounted(async () => {
await props.store.startPollingCurrentRun();
});
onBeforeUnmount(() => {
props.store.stopPollingCurrentRun();
});
</script>
<template>
<div class="action-run-summary-view">
<div class="action-run-summary-block">
<div class="flex-text-block">
{{ locale.triggeredVia.replace('%s', run.triggerEvent) }} <relative-time :datetime="runTriggeredAtIso" prefix=""/>
</div>
<div class="flex-text-block">
<ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="16"/>
<span>{{ locale.status[run.status] }}</span> <span>{{ locale.totalDuration }} {{ run.duration || '' }}</span>
</div>
</div>
<WorkflowGraph
v-if="run.jobs.length > 0"
:store="store"
:jobs="run.jobs"
:run-link="run.link"
:workflow-id="run.workflowID"
/>
</div>
</template>
<style scoped>
.action-run-summary-view {
flex: 1;
display: flex;
flex-direction: column;
color: var(--color-text-light-1);
}
.action-run-summary-block {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 12px;
border-bottom: 1px solid var(--color-secondary);
border-radius: var(--border-radius) var(--border-radius) 0 0;
background: var(--color-box-header);
}
</style>
@@ -0,0 +1,32 @@
import {createLogLineMessage, parseLogLineCommand} from './ActionRunView.ts';
test('LogLineMessage', () => {
const cases = {
'normal message': '<span class="log-msg">normal message</span>',
'##[group] foo': '<span class="log-msg log-cmd-group"> foo</span>',
'::group::foo': '<span class="log-msg log-cmd-group">foo</span>',
'##[endgroup]': '<span class="log-msg log-cmd-endgroup"></span>',
'::endgroup::': '<span class="log-msg log-cmd-endgroup"></span>',
'##[error] foo': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo</span></span>',
'##[warning] foo': '<span class="log-msg log-cmd-warning"><span class="log-msg-label">Warning:</span><span> foo</span></span>',
'##[notice] foo': '<span class="log-msg log-cmd-notice"><span class="log-msg-label">Notice:</span><span> foo</span></span>',
'##[debug] foo': '<span class="log-msg log-cmd-debug"><span class="log-msg-label">Debug:</span><span> foo</span></span>',
'::error::foo': '<span class="log-msg log-cmd-error"><span class="log-msg-label">Error:</span><span> foo</span></span>',
'::warning file=test.js,line=1::foo': '<span class="log-msg log-cmd-warning"><span class="log-msg-label">Warning:</span><span> foo</span></span>',
'::notice::foo': '<span class="log-msg log-cmd-notice"><span class="log-msg-label">Notice:</span><span> foo</span></span>',
'::debug::foo': '<span class="log-msg log-cmd-debug"><span class="log-msg-label">Debug:</span><span> foo</span></span>',
'[command] foo': '<span class="log-msg log-cmd-command"> foo</span>',
// hidden is special, it is actually skipped before creating
'##[add-matcher]foo': '<span class="log-msg log-cmd-hidden">foo</span>',
'::add-matcher::foo': '<span class="log-msg log-cmd-hidden">foo</span>',
'::remove-matcher foo::': '<span class="log-msg log-cmd-hidden"> foo::</span>', // not correctly parsed, but we don't need it
};
for (const [input, html] of Object.entries(cases)) {
const line = {index: 0, timestamp: 0, message: input};
const cmd = parseLogLineCommand(line);
const el = createLogLineMessage(line, cmd);
expect(el.outerHTML).toBe(html);
}
});
@@ -0,0 +1,181 @@
import {createElementFromAttrs} from '../utils/dom.ts';
import {renderAnsi} from '../render/ansi.ts';
import {reactive} from 'vue';
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsRunStatus} from '../modules/gitea-actions.ts';
import type {IntervalId} from '../types.ts';
import {POST} from '../modules/fetch.ts';
// How GitHub Actions logs work:
// * Workflow command outputs log commands like "::group::the-title", "::add-matcher::...."
// * Workflow runner parses and processes the commands to "##[group]", apply "matchers", hide secrets, etc.
// * The reported logs are the processed logs.
// HOWEVER: Gitea runner does not completely process those commands. Many works are done by the frontend at the moment.
const LogLinePrefixCommandMap: Record<string, LogLineCommandName> = {
'::group::': 'group',
'##[group]': 'group',
'::endgroup::': 'endgroup',
'##[endgroup]': 'endgroup',
'##[error]': 'error',
'##[warning]': 'warning',
'##[notice]': 'notice',
'##[debug]': 'debug',
'[command]': 'command',
// https://github.com/actions/toolkit/blob/master/docs/commands.md
// https://github.com/actions/runner/blob/main/docs/adrs/0276-problem-matchers.md#registration
'::add-matcher::': 'hidden',
'##[add-matcher]': 'hidden',
'::remove-matcher': 'hidden', // it has arguments
};
// Pattern for ::cmd:: and ::cmd args:: format (args are stripped for display)
const LogLineCmdPattern = /^::(error|warning|notice|debug)(?:\s[^:]*)?::/;
export type LogLine = {
index: number;
timestamp: number;
message: string;
};
export type LogLineCommandName = 'group' | 'endgroup' | 'command' | 'error' | 'warning' | 'notice' | 'debug' | 'hidden';
export type LogLineCommand = {
name: LogLineCommandName,
prefix: string,
};
export function parseLogLineCommand(line: LogLine): LogLineCommand | null {
// TODO: in the future it can be refactored to be a general parser that can parse arguments, drop the "prefix match"
for (const prefix of Object.keys(LogLinePrefixCommandMap)) {
if (line.message.startsWith(prefix)) {
return {name: LogLinePrefixCommandMap[prefix], prefix};
}
}
// Handle ::cmd:: and ::cmd args:: format (runner may pass these through raw)
const match = LogLineCmdPattern.exec(line.message);
if (match) {
return {name: match[1] as LogLineCommandName, prefix: match[0]};
}
return null;
}
const LogLineLabelMap: Partial<Record<LogLineCommandName, string>> = {
'error': 'Error',
'warning': 'Warning',
'notice': 'Notice',
'debug': 'Debug',
};
export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
const logMsgAttrs = {class: 'log-msg'};
if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd.name}`; // make it easier to add styles to some commands like "error"
// TODO: for some commands (::group::), the "prefix removal" works well, for some commands with "arguments" (::remove-matcher ...::),
// it needs to do further processing in the future (fortunately, at the moment we don't need to handle these commands)
const msgContent = cmd ? line.message.substring(cmd.prefix.length) : line.message;
const logMsg = createElementFromAttrs('span', logMsgAttrs);
const label = cmd ? LogLineLabelMap[cmd.name] : null;
if (label) {
logMsg.append(createElementFromAttrs('span', {class: 'log-msg-label'}, `${label}:`));
const msgSpan = document.createElement('span');
msgSpan.innerHTML = ` ${renderAnsi(msgContent.trimStart())}`;
logMsg.append(msgSpan);
} else {
logMsg.innerHTML = renderAnsi(msgContent);
}
return logMsg;
}
export function createEmptyActionsRun(): ActionsRun {
return {
repoId: 0,
link: '',
title: '',
titleHTML: '',
status: '' as ActionsRunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon
canCancel: false,
canApprove: false,
canRerun: false,
canRerunFailed: false,
canDeleteArtifact: false,
done: false,
workflowID: '',
workflowLink: '',
isSchedule: false,
duration: '',
triggeredAt: 0,
triggerEvent: '',
jobs: [] as Array<ActionsJob>,
commit: {
localeCommit: '',
localePushedBy: '',
shortSHA: '',
link: '',
pusher: {
displayName: '',
link: '',
},
branch: {
name: '',
link: '',
isDeleted: false,
},
},
};
}
export function createActionRunViewStore(actionsUrl: string, runId: number) {
let loadingAbortController: AbortController | null = null;
let intervalID: IntervalId | null = null;
const viewData = reactive({
currentRun: createEmptyActionsRun(),
runArtifacts: [] as Array<ActionsArtifact>,
});
const loadCurrentRun = async () => {
if (loadingAbortController) return;
const abortController = new AbortController();
loadingAbortController = abortController;
try {
const url = `${actionsUrl}/runs/${runId}`;
const resp = await POST(url, {signal: abortController.signal, data: {}});
const runResp = await resp.json();
if (loadingAbortController !== abortController) return;
viewData.runArtifacts = runResp.artifacts || [];
viewData.currentRun = runResp.state.run;
// clear the interval timer if the job is done
if (viewData.currentRun.done && intervalID) {
clearInterval(intervalID);
intervalID = null;
}
} catch (e) {
// avoid network error while unloading page, and ignore "abort" error
if (e instanceof TypeError || abortController.signal.aborted) return;
throw e;
} finally {
if (loadingAbortController === abortController) loadingAbortController = null;
}
};
return reactive({
viewData,
async startPollingCurrentRun() {
await loadCurrentRun();
intervalID = setInterval(() => loadCurrentRun(), 1000);
},
async forceReloadCurrentRun() {
loadingAbortController?.abort();
loadingAbortController = null;
await loadCurrentRun();
},
stopPollingCurrentRun() {
if (!intervalID) return;
clearInterval(intervalID);
intervalID = null;
},
});
}
export type ActionRunViewStore = ReturnType<typeof createActionRunViewStore>;
@@ -5,7 +5,7 @@ import {onMounted, shallowRef} from 'vue';
import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
defineProps<{
values?: HeatmapValue[];
values: HeatmapValue[];
locale: {
textTotalContributions: string;
heatMapLocale: Partial<HeatmapLocale>;
@@ -28,7 +28,7 @@ const endDate = shallowRef(new Date());
onMounted(() => {
// work around issue with first legend color being rendered twice and legend cut off
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper');
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper')!;
legend.setAttribute('viewBox', '12 0 80 10');
legend.style.marginRight = '-12px';
});
@@ -1,8 +1,9 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {getIssueColor, getIssueIcon} from '../features/issue.ts';
import {getIssueColorClass, getIssueIcon} from '../features/issue.ts';
import {computed, onMounted, shallowRef} from 'vue';
import type {Issue} from '../types.ts';
const props = defineProps<{
repoLink: string,
@@ -10,22 +11,24 @@ const props = defineProps<{
}>();
const loading = shallowRef(false);
const issue = shallowRef(null);
const issue = shallowRef<Issue | null>(null);
const renderedLabels = shallowRef('');
const errorMessage = shallowRef(null);
const errorMessage = shallowRef('');
const createdAt = computed(() => {
if (!issue?.value) return '';
return new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
});
const body = computed(() => {
if (!issue?.value) return '';
const body = issue.value.body.replace(/\n+/g, ' ');
return body.length > 85 ? `${body.substring(0, 85)}` : body;
});
onMounted(async () => {
loading.value = true;
errorMessage.value = null;
errorMessage.value = '';
try {
const resp = await GET(props.loadIssueInfoUrl);
if (!resp.ok) {
@@ -50,7 +53,7 @@ onMounted(async () => {
on {{ createdAt }}
</div>
<div class="flex-text-block">
<svg-icon :name="getIssueIcon(issue)" :class="['text', getIssueColor(issue)]"/>
<svg-icon :name="getIssueIcon(issue)" :class="getIssueColorClass(issue)"/>
<span class="issue-title tw-font-semibold tw-break-anywhere">
{{ issue.title }}
<span class="index">#{{ issue.number }}</span>
@@ -3,26 +3,42 @@ import {nextTick, defineComponent} from 'vue';
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import type {SvgName} from '../svg.ts';
const {appSubUrl, assetUrlPrefix, pageData} = window.config;
type DashboardRepo = {
id: number,
link: string,
full_name: string,
archived: boolean,
fork: boolean,
mirror: boolean,
template: boolean,
private: boolean,
internal: boolean,
latest_commit_status_state?: CommitStatus,
latest_commit_status_state_link?: string,
locale_latest_commit_status_state?: string,
};
type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning' | 'skipped';
type CommitStatusMap = {
[status in CommitStatus]: {
name: string,
name: SvgName,
color: string,
};
};
// make sure this matches templates/repo/commit_status.tmpl
const commitStatus: CommitStatusMap = {
pending: {name: 'octicon-dot-fill', color: 'yellow'},
success: {name: 'octicon-check', color: 'green'},
error: {name: 'gitea-exclamation', color: 'red'},
failure: {name: 'octicon-x', color: 'red'},
warning: {name: 'gitea-exclamation', color: 'yellow'},
skipped: {name: 'octicon-skip', color: 'grey'},
pending: {name: 'octicon-dot-fill', color: 'tw-text-yellow'},
success: {name: 'octicon-check', color: 'tw-text-green'},
error: {name: 'gitea-exclamation', color: 'tw-text-red'},
failure: {name: 'octicon-x', color: 'tw-text-red'},
warning: {name: 'gitea-exclamation', color: 'tw-text-yellow'},
skipped: {name: 'octicon-skip', color: 'tw-text-text-light'},
};
export default defineComponent({
@@ -38,8 +54,8 @@ export default defineComponent({
return {
tab,
repos: [],
reposTotalCount: null,
repos: [] as DashboardRepo[],
reposTotalCount: null as number | null,
reposFilter,
archivedFilter,
privateFilter,
@@ -48,7 +64,7 @@ export default defineComponent({
searchQuery,
isLoading: false,
staticPrefix: assetUrlPrefix,
counts: {},
counts: {} as Record<string, number>,
repoTypes: {
all: {
searchMode: '',
@@ -65,16 +81,49 @@ export default defineComponent({
collaborative: {
searchMode: 'collaborative',
},
},
textArchivedFilterTitles: {},
textPrivateFilterTitles: {},
organizations: [],
} as Record<string, {searchMode: string}>,
textArchivedFilterTitles: {} as Record<string, string>,
textPrivateFilterTitles: {} as Record<string, string>,
organizations: [] as Array<{name: string, full_name: string, num_repos: number, org_visibility: string}>,
isOrganization: true,
canCreateOrganization: false,
organizationsTotalCount: 0,
organizationId: 0,
searchLimit: 0,
uid: 0,
teamId: 0,
isMirrorsEnabled: false,
isStarsEnabled: false,
canCreateMigrations: false,
textNoOrg: '',
textNoRepo: '',
textRepository: '',
textOrganization: '',
textMyRepos: '',
textNewRepo: '',
textSearchRepos: '',
textFilter: '',
textShowArchived: '',
textShowPrivate: '',
textShowBothArchivedUnarchived: '',
textShowOnlyUnarchived: '',
textShowOnlyArchived: '',
textShowBothPrivatePublic: '',
textShowOnlyPublic: '',
textShowOnlyPrivate: '',
textAll: '',
textSources: '',
textForks: '',
textMirrors: '',
textCollaborative: '',
textFirstPage: '',
textPreviousPage: '',
textNextPage: '',
textLastPage: '',
textMyOrgs: '',
textNewOrg: '',
textOrgVisibilityLimited: '',
textOrgVisibilityPrivate: '',
subUrl: appSubUrl,
...pageData.dashboardRepoList,
activeIndex: -1, // don't select anything at load, first cursor down will select
@@ -110,9 +159,9 @@ export default defineComponent({
},
mounted() {
const el = document.querySelector('#dashboard-repo-list');
const el = document.querySelector('#dashboard-repo-list')!;
this.changeReposFilter(this.reposFilter);
fomanticQuery(el.querySelector('.ui.dropdown')).dropdown();
fomanticQuery(el.querySelector('.ui.dropdown')!).dropdown();
this.textArchivedFilterTitles = {
'archived': this.textShowOnlyArchived,
@@ -250,7 +299,7 @@ export default defineComponent({
nextTick(() => {
// MDN: If there's no focused element, this is the Document.body or Document.documentElement.
if ((document.activeElement === document.body || document.activeElement === document.documentElement)) {
this.$refs.search.focus({preventScroll: true});
(this.$refs.search as HTMLInputElement).focus({preventScroll: true});
}
});
}
@@ -283,7 +332,7 @@ export default defineComponent({
}
},
repoIcon(repo: any) {
repoIcon(repo: DashboardRepo) {
if (repo.fork) {
return 'octicon-repo-forked';
} else if (repo.mirror) {
@@ -307,6 +356,7 @@ export default defineComponent({
},
async reposFilterKeyControl(e: KeyboardEvent) {
if (e.isComposing) return;
switch (e.key) {
case 'Enter':
document.querySelector<HTMLAnchorElement>('.repo-owner-name-list li.active a')?.click();
@@ -429,14 +479,14 @@ export default defineComponent({
<li class="tw-flex tw-items-center tw-py-2" v-for="(repo, index) in repos" :class="{'active': index === activeIndex}" :key="repo.id">
<a class="repo-list-link muted" :href="repo.link">
<svg-icon :name="repoIcon(repo)" :size="16" class="repo-list-icon"/>
<div class="text truncate">{{ repo.full_name }}</div>
<div class="tw-inline-block tw-truncate">{{ repo.full_name }}</div>
<div v-if="repo.archived">
<svg-icon name="octicon-archive" :size="16"/>
</div>
</a>
<a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link || null" :data-tooltip-content="repo.locale_latest_commit_status_state">
<a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link || undefined" :data-tooltip-content="repo.locale_latest_commit_status_state">
<!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl -->
<svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/>
<svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class="'tw-ml-2 commit-status icon ' + statusColor(repo.latest_commit_status_state)" :size="16"/>
</a>
</li>
</ul>
@@ -493,14 +543,14 @@ export default defineComponent({
<li class="tw-flex tw-items-center tw-py-2" v-for="org in organizations" :key="org.name">
<a class="repo-list-link muted" :href="subUrl + '/' + encodeURIComponent(org.name)">
<svg-icon name="octicon-organization" :size="16" class="repo-list-icon"/>
<div class="text truncate">{{ org.full_name ? `${org.full_name} (${org.name})` : org.name }}</div>
<div class="tw-inline-block tw-truncate">{{ org.full_name ? `${org.full_name} (${org.name})` : org.name }}</div>
<div><!-- div to prevent underline of label on hover -->
<span class="ui tiny basic label" v-if="org.org_visibility !== 'public'">
{{ org.org_visibility === 'limited' ? textOrgVisibilityLimited: textOrgVisibilityPrivate }}
</span>
</div>
</a>
<div class="text light grey tw-flex tw-items-center tw-ml-2">
<div class="tw-text-grey-light tw-flex tw-items-center tw-ml-2">
{{ org.num_repos }}
<svg-icon name="octicon-repo" :size="16" class="tw-ml-1 tw-mt-0.5"/>
</div>
@@ -23,7 +23,7 @@ type CommitListResult = {
export default defineComponent({
components: {SvgIcon},
data: () => {
const el = document.querySelector('#diff-commit-select');
const el = document.querySelector('#diff-commit-select')!;
return {
menuVisible: false,
isLoading: false,
@@ -35,7 +35,7 @@ export default defineComponent({
mergeBase: el.getAttribute('data-merge-base'),
commits: [] as Array<Commit>,
hoverActivated: false,
lastReviewCommitSha: '',
lastReviewCommitSha: '' as string | null,
uniqueIdMenu: generateElemId('diff-commit-selector-menu-'),
uniqueIdShowAll: generateElemId('diff-commit-selector-show-all-'),
};
@@ -165,7 +165,7 @@ export default defineComponent({
},
/** Called when user clicks on since last review */
changesSinceLastReviewClick() {
window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1).id}${this.queryParams}`);
window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1)!.id}${this.queryParams}`);
},
/** Clicking on a single commit opens this specific commit */
commitClicked(commitId: string, newWindow = false) {
@@ -193,7 +193,7 @@ export default defineComponent({
// find all selected commits and generate a link
const firstSelected = this.commits.findIndex((x) => x.selected);
const lastSelected = this.commits.findLastIndex((x) => x.selected);
let beforeCommitID: string;
let beforeCommitID: string | null = null;
if (firstSelected === 0) {
beforeCommitID = this.mergeBase;
} else {
@@ -204,7 +204,7 @@ export default defineComponent({
if (firstSelected === lastSelected) {
// if the start and end are the same, we show this single commit
window.location.assign(`${this.issueLink}/commits/${afterCommitID}${this.queryParams}`);
} else if (beforeCommitID === this.mergeBase && afterCommitID === this.commits.at(-1).id) {
} else if (beforeCommitID === this.mergeBase && afterCommitID === this.commits.at(-1)!.id) {
// if the first commit is selected and the last commit is selected, we show all commits
window.location.assign(`${this.issueLink}/files${this.queryParams}`);
} else {
@@ -236,7 +236,7 @@ export default defineComponent({
<div class="gt-ellipsis">
{{ locale.show_all_commits }}
</div>
<div class="gt-ellipsis text light-2 tw-mb-0">
<div class="gt-ellipsis tw-text-text-light-2 tw-mb-0">
{{ locale.stats_num_commits }}
</div>
</div>
@@ -251,11 +251,11 @@ export default defineComponent({
<div class="gt-ellipsis">
{{ locale.show_changes_since_your_last_review }}
</div>
<div class="gt-ellipsis text light-2">
<div class="gt-ellipsis tw-text-text-light-2">
{{ commitsSinceLastReview }} commits
</div>
</div>
<span v-if="!isLoading" class="info text light-2">{{ locale.select_commit_hold_shift_for_range }}</span>
<span v-if="!isLoading" class="info tw-text-text-light-2">{{ locale.select_commit_hold_shift_for_range }}</span>
<template v-for="(commit, idx) in commits" :key="commit.id">
<div
class="item" role="menuitem"
@@ -273,7 +273,7 @@ export default defineComponent({
<div class="gt-ellipsis commit-list-summary">
{{ commit.summary }}
</div>
<div class="gt-ellipsis text light-2">
<div class="gt-ellipsis tw-text-text-light-2">
{{ commit.committer_or_author_name }}
<span class="text right">
<!-- TODO: make this respect the PreferredTimestampTense setting -->
@@ -4,6 +4,7 @@ import {toggleElem} from '../utils/dom.ts';
import {diffTreeStore} from '../modules/diff-file.ts';
import {setFileFolding} from '../features/file-fold.ts';
import {onMounted, onUnmounted} from 'vue';
import {localUserSettings} from '../modules/user-settings.ts';
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
@@ -11,15 +12,15 @@ const store = diffTreeStore();
onMounted(() => {
// Default to true if unset
store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', toggleVisibility);
store.fileTreeIsVisible = localUserSettings.getBoolean(LOCAL_STORAGE_KEY, true);
document.querySelector('.diff-toggle-file-tree-button')!.addEventListener('click', toggleVisibility);
hashChangeListener();
window.addEventListener('hashchange', hashChangeListener);
});
onUnmounted(() => {
document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', toggleVisibility);
document.querySelector('.diff-toggle-file-tree-button')!.removeEventListener('click', toggleVisibility);
window.removeEventListener('hashchange', hashChangeListener);
});
@@ -33,7 +34,7 @@ function expandSelectedFile() {
if (store.selectedItem) {
const box = document.querySelector(store.selectedItem);
const folded = box?.getAttribute('data-folded') === 'true';
if (folded) setFileFolding(box, box.querySelector('.fold-file'), false);
if (folded) setFileFolding(box, box.querySelector('.fold-file')!, false);
}
}
@@ -43,15 +44,15 @@ function toggleVisibility() {
function updateVisibility(visible: boolean) {
store.fileTreeIsVisible = visible;
localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible.toString());
localUserSettings.setBoolean(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
updateState(store.fileTreeIsVisible);
}
function updateState(visible: boolean) {
const btn = document.querySelector('.diff-toggle-file-tree-button');
const btn = document.querySelector('.diff-toggle-file-tree-button')!;
const [toShow, toHide] = btn.querySelectorAll('.icon');
const tree = document.querySelector('#diff-file-tree');
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text');
const tree = document.querySelector('#diff-file-tree')!;
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text')!;
btn.setAttribute('data-tooltip-content', newTooltip);
toggleElem(tree, visible);
toggleElem(toShow, !visible);
@@ -12,13 +12,13 @@ const collapsed = shallowRef(props.item.IsViewed);
function getIconForDiffStatus(pType: DiffStatus) {
const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = {
'': {name: 'octicon-blocked', classes: ['text', 'red']}, // unknown case
'added': {name: 'octicon-diff-added', classes: ['text', 'green']},
'modified': {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
'deleted': {name: 'octicon-diff-removed', classes: ['text', 'red']},
'renamed': {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
'copied': {name: 'octicon-diff-renamed', classes: ['text', 'green']},
'typechange': {name: 'octicon-diff-modified', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
'': {name: 'octicon-blocked', classes: ['tw-text-red']}, // unknown case
'added': {name: 'octicon-diff-added', classes: ['tw-text-green']},
'modified': {name: 'octicon-diff-modified', classes: ['tw-text-yellow']},
'deleted': {name: 'octicon-diff-removed', classes: ['tw-text-red']},
'renamed': {name: 'octicon-diff-renamed', classes: ['tw-text-teal']},
'copied': {name: 'octicon-diff-renamed', classes: ['tw-text-green']},
'typechange': {name: 'octicon-diff-modified', classes: ['tw-text-green']}, // there is no octicon for copied, so renamed should be ok
};
return diffTypes[pType] ?? diffTypes[''];
}
@@ -3,9 +3,9 @@ import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import {toggleElem} from '../utils/dom.ts';
const {csrfToken, pageData} = window.config;
const {pageData} = window.config;
const mergeForm = pageData.pullRequestMergeForm;
const mergeForm = pageData.pullRequestMergeForm!;
const mergeTitleFieldValue = shallowRef('');
const mergeMessageFieldValue = shallowRef('');
@@ -95,7 +95,6 @@ function clearMergeMessage() {
<!-- another similar form is in pull.tmpl (manual merge)-->
<form class="ui form form-fetch-action" v-if="showActionForm" :action="mergeForm.baseLink+'/merge'" method="post">
<input type="hidden" name="_csrf" :value="csrfToken">
<input type="hidden" name="head_commit_id" v-model="mergeForm.pullHeadCommitID">
<input type="hidden" name="merge_when_checks_succeed" v-model="autoMergeWhenSucceed">
<input type="hidden" name="force_merge" v-model="forceMerge">
@@ -177,7 +176,6 @@ function clearMergeMessage() {
<!-- the cancel auto merge button -->
<form v-if="mergeForm.hasPendingPullRequestMerge" :action="mergeForm.baseLink+'/cancel_auto_merge'" method="post" class="tw-ml-4">
<input type="hidden" name="_csrf" :value="csrfToken">
<button class="ui button">
{{ mergeForm.textAutoMergeCancelSchedule }}
</button>
@@ -232,7 +230,7 @@ function clearMergeMessage() {
bottom: -1px;
position: absolute;
align-items: center;
color: var(--color-info-text);
color: var(--color-text);
background-color: var(--color-info-bg);
border: 1px solid var(--color-info-border);
border-left: none;
@@ -240,7 +238,7 @@ function clearMergeMessage() {
}
.auto-merge-small:hover {
color: var(--color-info-text);
color: var(--color-text);
background-color: var(--color-info-bg);
border: 1px solid var(--color-info-border);
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,6 @@
<script lang="ts" setup>
// @ts-expect-error - module exports no types
import {VueBarGraph} from 'vue-bar-graph';
import {computed, onMounted, shallowRef, useTemplateRef} from 'vue';
import {computed, onMounted, shallowRef, useTemplateRef, type ShallowRef} from 'vue';
const colors = shallowRef({
barColor: 'green',
@@ -41,8 +40,8 @@ const graphWidth = computed(() => {
return activityTopAuthors.length * 40;
});
const styleElement = useTemplateRef('styleElement');
const altStyleElement = useTemplateRef('altStyleElement');
const styleElement = useTemplateRef('styleElement') as Readonly<ShallowRef<HTMLDivElement>>;
const altStyleElement = useTemplateRef('altStyleElement') as Readonly<ShallowRef<HTMLDivElement>>;
onMounted(() => {
const refStyle = window.getComputedStyle(styleElement.value);
@@ -20,12 +20,14 @@ type TabLoadingStates = Record<SelectedTab, '' | 'loading' | 'done'>
export default defineComponent({
components: {SvgIcon},
props: {
elRoot: HTMLElement,
elRoot: {
type: HTMLElement,
required: true,
},
},
data() {
const shouldShowTabBranches = this.elRoot.getAttribute('data-show-tab-branches') === 'true';
return {
csrfToken: window.config.csrfToken,
allItems: [] as ListItem[],
selectedTab: (shouldShowTabBranches ? 'branches' : 'tags') as SelectedTab,
searchTerm: '',
@@ -33,28 +35,28 @@ export default defineComponent({
activeItemIndex: 0,
tabLoadingStates: {} as TabLoadingStates,
textReleaseCompare: this.elRoot.getAttribute('data-text-release-compare'),
textBranches: this.elRoot.getAttribute('data-text-branches'),
textTags: this.elRoot.getAttribute('data-text-tags'),
textFilterBranch: this.elRoot.getAttribute('data-text-filter-branch'),
textFilterTag: this.elRoot.getAttribute('data-text-filter-tag'),
textDefaultBranchLabel: this.elRoot.getAttribute('data-text-default-branch-label'),
textCreateTag: this.elRoot.getAttribute('data-text-create-tag'),
textCreateBranch: this.elRoot.getAttribute('data-text-create-branch'),
textCreateRefFrom: this.elRoot.getAttribute('data-text-create-ref-from'),
textNoResults: this.elRoot.getAttribute('data-text-no-results'),
textViewAllBranches: this.elRoot.getAttribute('data-text-view-all-branches'),
textViewAllTags: this.elRoot.getAttribute('data-text-view-all-tags'),
textReleaseCompare: this.elRoot.getAttribute('data-text-release-compare')!,
textBranches: this.elRoot.getAttribute('data-text-branches')!,
textTags: this.elRoot.getAttribute('data-text-tags')!,
textFilterBranch: this.elRoot.getAttribute('data-text-filter-branch')!,
textFilterTag: this.elRoot.getAttribute('data-text-filter-tag')!,
textDefaultBranchLabel: this.elRoot.getAttribute('data-text-default-branch-label')!,
textCreateTag: this.elRoot.getAttribute('data-text-create-tag')!,
textCreateBranch: this.elRoot.getAttribute('data-text-create-branch')!,
textCreateRefFrom: this.elRoot.getAttribute('data-text-create-ref-from')!,
textNoResults: this.elRoot.getAttribute('data-text-no-results')!,
textViewAllBranches: this.elRoot.getAttribute('data-text-view-all-branches')!,
textViewAllTags: this.elRoot.getAttribute('data-text-view-all-tags')!,
currentRepoDefaultBranch: this.elRoot.getAttribute('data-current-repo-default-branch'),
currentRepoLink: this.elRoot.getAttribute('data-current-repo-link'),
currentTreePath: this.elRoot.getAttribute('data-current-tree-path'),
currentRepoDefaultBranch: this.elRoot.getAttribute('data-current-repo-default-branch')!,
currentRepoLink: this.elRoot.getAttribute('data-current-repo-link')!,
currentTreePath: this.elRoot.getAttribute('data-current-tree-path')!,
currentRefType: this.elRoot.getAttribute('data-current-ref-type') as GitRefType,
currentRefShortName: this.elRoot.getAttribute('data-current-ref-short-name'),
currentRefShortName: this.elRoot.getAttribute('data-current-ref-short-name')!,
refLinkTemplate: this.elRoot.getAttribute('data-ref-link-template'),
refFormActionTemplate: this.elRoot.getAttribute('data-ref-form-action-template'),
dropdownFixedText: this.elRoot.getAttribute('data-dropdown-fixed-text'),
refLinkTemplate: this.elRoot.getAttribute('data-ref-link-template')!,
refFormActionTemplate: this.elRoot.getAttribute('data-ref-form-action-template')!,
dropdownFixedText: this.elRoot.getAttribute('data-dropdown-fixed-text')!,
showTabBranches: shouldShowTabBranches,
showTabTags: this.elRoot.getAttribute('data-show-tab-tags') === 'true',
allowCreateNewRef: this.elRoot.getAttribute('data-allow-create-new-ref') === 'true',
@@ -92,7 +94,7 @@ export default defineComponent({
}).length;
},
createNewRefFormActionUrl() {
return `${this.currentRepoLink}/branches/_new/${this.currentRefType}/${pathEscapeSegments(this.currentRefShortName)}`;
return `${this.currentRepoLink}/branches/_new/${this.currentRefType}/${pathEscapeSegments(this.currentRefShortName!)}`;
},
},
watch: {
@@ -153,11 +155,11 @@ export default defineComponent({
return -1;
},
getActiveItem() {
const el = this.$refs[`listItem${this.activeItemIndex}`];
// @ts-expect-error - el is unknown type
return (el && el.length) ? el[0] : null;
const el = this.$refs[`listItem${this.activeItemIndex}`] as Array<HTMLDivElement>;
return el?.length ? el[0] : null;
},
keydown(e: KeyboardEvent) {
if (e.isComposing) return;
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
@@ -172,7 +174,7 @@ export default defineComponent({
return;
}
this.activeItemIndex = nextIndex;
this.getActiveItem().scrollIntoView({block: 'nearest'});
this.getActiveItem()!.scrollIntoView({block: 'nearest'});
} else if (e.key === 'Enter') {
e.preventDefault();
this.getActiveItem()?.click();
@@ -265,11 +267,10 @@ export default defineComponent({
<svg-icon name="octicon-git-branch" class="tw-mr-1"/>
<span v-text="textCreateBranch.replace('%s', searchTerm)"/>
</div>
<div class="text small">
<div class="tw-text-xs">
{{ textCreateRefFrom.replace('%s', currentRefShortName) }}
</div>
<form ref="createNewRefForm" method="post" :action="createNewRefFormActionUrl">
<input type="hidden" name="_csrf" :value="csrfToken">
<input type="hidden" name="new_branch_name" :value="searchTerm">
<input type="hidden" name="create_tag" :value="String(selectedTab === 'tags')">
<input type="hidden" name="current_path" :value="currentTreePath">
@@ -49,7 +49,7 @@ defineProps<{
const isLoading = shallowRef(false);
const errorText = shallowRef('');
const repoLink = pageData.repoLink;
const repoLink = pageData.repoLink!;
const data = shallowRef<DayData[]>([]);
onMounted(() => {
@@ -153,7 +153,7 @@ const options: ChartOptions<'line'> = {
<SvgIcon name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
{{ locale.loadingInfo }}
</div>
<div v-else class="text red">
<div v-else class="tw-text-red">
<SvgIcon name="octicon-x-circle-fill"/>
{{ errorText }}
</div>
@@ -41,6 +41,15 @@ const customEventListener: Plugin = {
},
};
type LineOptions = ChartOptions<'line'> & {
plugins?: {
customEventListener?: {
chartType: string;
instance: unknown;
};
};
}
Chart.defaults.color = chartJsColors.text;
Chart.defaults.borderColor = chartJsColors.border;
@@ -174,7 +183,7 @@ export default defineComponent({
user.max_contribution_type = 0;
const filteredWeeks = user.weeks.filter((week: Record<string, number>) => {
const oneWeek = 7 * 24 * 60 * 60 * 1000;
if (week.week >= this.xAxisMin - oneWeek && week.week <= this.xAxisMax + oneWeek) {
if (week.week >= this.xAxisMin! - oneWeek && week.week <= this.xAxisMax! + oneWeek) {
user.total_commits += week.commits;
user.total_additions += week.additions;
user.total_deletions += week.deletions;
@@ -238,8 +247,8 @@ export default defineComponent({
},
updateOtherCharts({chart}: {chart: Chart}, reset: boolean = false) {
const minVal = Number(chart.options.scales.x.min);
const maxVal = Number(chart.options.scales.x.max);
const minVal = Number(chart.options.scales?.x?.min);
const maxVal = Number(chart.options.scales?.x?.max);
if (reset) {
this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd;
@@ -251,7 +260,7 @@ export default defineComponent({
}
},
getOptions(type: string): ChartOptions<'line'> {
getOptions(type: string): LineOptions {
return {
responsive: true,
maintainAspectRatio: false,
@@ -264,7 +273,6 @@ export default defineComponent({
position: 'top',
align: 'center',
},
// @ts-expect-error: bug in chart.js types
customEventListener: {
chartType: type,
instance: this,
@@ -302,8 +310,8 @@ export default defineComponent({
},
scales: {
x: {
min: this.xAxisMin,
max: this.xAxisMax,
min: this.xAxisMin ?? undefined,
max: this.xAxisMax ?? undefined,
type: 'time',
grid: {
display: false,
@@ -334,7 +342,7 @@ export default defineComponent({
<div class="ui header tw-flex tw-items-center tw-justify-between">
<div>
<relative-time
v-if="xAxisMin > 0"
v-if="xAxisMin && xAxisMin > 0"
format="datetime"
year="numeric"
month="short"
@@ -346,7 +354,7 @@ export default defineComponent({
</relative-time>
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }}
<relative-time
v-if="xAxisMax > 0"
v-if="xAxisMax && xAxisMax > 0"
format="datetime"
year="numeric"
month="short"
@@ -384,7 +392,7 @@ export default defineComponent({
<SvgIcon name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
{{ locale.loadingInfo }}
</div>
<div v-else class="text red">
<div v-else class="tw-text-red">
<SvgIcon name="octicon-x-circle-fill"/>
{{ errorText }}
</div>
@@ -416,8 +424,8 @@ export default defineComponent({
{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}
</a>
</strong>
<strong v-if="contributor.total_additions" class="text green">{{ contributor.total_additions.toLocaleString() }}++ </strong>
<strong v-if="contributor.total_deletions" class="text red">
<strong v-if="contributor.total_additions" class="tw-text-green">{{ contributor.total_additions.toLocaleString() }}++ </strong>
<strong v-if="contributor.total_deletions" class="tw-text-red">
{{ contributor.total_deletions.toLocaleString() }}--</strong>
</p>
</div>
@@ -0,0 +1,233 @@
<script lang="ts" setup>
import {ref, computed, watch, nextTick, useTemplateRef, onMounted, onUnmounted, type ShallowRef} from 'vue';
import {generateElemId} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
import {filterRepoFilesWeighted} from '../features/repo-findfile.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {SvgIcon} from '../svg.ts';
import {throttle} from 'throttle-debounce';
const props = defineProps({
repoLink: { type: String, required: true },
currentRefNameSubURL: { type: String, required: true },
treeListUrl: { type: String, required: true },
noResultsText: { type: String, required: true },
placeholder: { type: String, required: true },
});
const refElemInput = useTemplateRef('searchInput') as Readonly<ShallowRef<HTMLInputElement>>;
const refElemPopup = useTemplateRef('searchPopup') as Readonly<ShallowRef<HTMLDivElement>>;
const searchQuery = ref('');
const allFiles = ref<string[]>([]);
const selectedIndex = ref(0);
const isLoadingFileList = ref(false);
const hasLoadedFileList = ref(false);
const showPopup = computed(() => searchQuery.value.length > 0);
const filteredFiles = computed(() => {
if (!searchQuery.value) return [];
return filterRepoFilesWeighted(allFiles.value, searchQuery.value);
});
const applySearchQuery = throttle(300, () => {
searchQuery.value = refElemInput.value.value;
selectedIndex.value = 0;
});
const handleSearchInput = () => {
loadFileListForSearch();
applySearchQuery();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.isComposing) return;
if (e.key === 'Escape') {
clearSearch();
nextTick(() => refElemInput.value.blur());
return;
}
if (!searchQuery.value || filteredFiles.value.length === 0) return;
const handleSelectedItem = (idx: number) => {
e.preventDefault();
selectedIndex.value = idx;
const el = refElemPopup.value.querySelector(`.file-search-results > :nth-child(${idx+1} of .item)`);
el?.scrollIntoView({ block: 'nearest', behavior: 'instant' });
};
if (e.key === 'ArrowDown') {
handleSelectedItem(Math.min(selectedIndex.value + 1, filteredFiles.value.length - 1));
} else if (e.key === 'ArrowUp') {
handleSelectedItem(Math.max(selectedIndex.value - 1, 0))
} else if (e.key === 'Enter') {
e.preventDefault();
const selectedFile = filteredFiles.value[selectedIndex.value];
if (selectedFile) {
handleSearchResultClick(selectedFile.matchResult.join(''));
}
}
};
const clearSearch = () => {
searchQuery.value = '';
refElemInput.value.value = '';
};
const handleClickOutside = (e: MouseEvent) => {
if (!searchQuery.value) return;
const target = e.target as HTMLElement;
const clickInside = refElemInput.value.contains(target) || refElemPopup.value.contains(target);
if (!clickInside) clearSearch();
};
const loadFileListForSearch = async () => {
if (hasLoadedFileList.value || isLoadingFileList.value) return;
isLoadingFileList.value = true;
try {
const response = await GET(props.treeListUrl);
allFiles.value = await response.json();
hasLoadedFileList.value = true;
} finally {
isLoadingFileList.value = false;
}
};
function handleSearchResultClick(filePath: string) {
clearSearch();
window.location.href = `${props.repoLink}/src/${pathEscapeSegments(props.currentRefNameSubURL)}/${pathEscapeSegments(filePath)}`;
}
const updatePosition = () => {
if (!showPopup.value) return;
const rectInput = refElemInput.value.getBoundingClientRect();
const rectPopup = refElemPopup.value.getBoundingClientRect();
const docElem = document.documentElement;
const style = refElemPopup.value.style;
style.top = `${docElem.scrollTop + rectInput.bottom + 4}px`;
if (rectInput.x + rectPopup.width < docElem.clientWidth) {
// enough space to align left with the input
style.left = `${docElem.scrollLeft + rectInput.x}px`;
} else {
// no enough space, align right from the viewport right edge minus page margin
const leftPos = docElem.scrollLeft + docElem.getBoundingClientRect().width - rectPopup.width;
style.left = `calc(${leftPos}px - var(--page-margin-x))`;
}
};
onMounted(() => {
const searchPopupId = generateElemId('file-search-popup-');
refElemPopup.value.setAttribute('id', searchPopupId);
refElemInput.value.setAttribute('aria-controls', searchPopupId);
document.addEventListener('click', handleClickOutside);
window.addEventListener('resize', updatePosition);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
window.removeEventListener('resize', updatePosition);
});
// Position search results below the input
watch([searchQuery, filteredFiles], async () => {
if (searchQuery.value) {
await nextTick();
updatePosition();
}
});
</script>
<template>
<div>
<div class="ui small input global-shortcut-wrapper">
<input
ref="searchInput" :placeholder="placeholder" autocomplete="off"
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
@input="handleSearchInput" @keydown="handleKeyDown"
>
<kbd data-global-init="onGlobalShortcut" data-shortcut-keys="t">T</kbd>
</div>
<Teleport to="body">
<div v-show="showPopup" ref="searchPopup" class="file-search-popup">
<!-- always create the popup by v-show above to avoid null ref, only create the popup content if the popup should be displayed to save memory -->
<template v-if="showPopup">
<div v-if="filteredFiles.length" role="listbox" class="file-search-results flex-items-block">
<div
v-for="(result, idx) in filteredFiles" :key="result.matchResult.join('')"
:class="['item', { 'selected': idx === selectedIndex }]"
role="option" :aria-selected="idx === selectedIndex" @click="handleSearchResultClick(result.matchResult.join(''))"
@mouseenter="selectedIndex = idx" :title="result.matchResult.join('')"
>
<SvgIcon name="octicon-file" class="file-icon"/>
<span class="full-path">
<span v-for="(part, index) in result.matchResult" :key="index">{{ part }}</span>
</span>
</div>
</div>
<div v-else-if="isLoadingFileList">
<div class="is-loading"/>
</div>
<div v-else class="tw-p-4">
{{ props.noResultsText }}
</div>
</template>
</div>
</Teleport>
</div>
</template>
<style scoped>
.file-search-popup {
position: absolute;
background: var(--color-box-body);
border: 1px solid var(--color-secondary);
border-radius: var(--border-radius);
width: max-content;
max-height: min(calc(100vw - 20px), 300px);
max-width: min(calc(100vw - 40px), 600px);
overflow-y: auto;
}
.file-search-popup .is-loading {
width: 200px;
height: 200px;
}
.file-search-results .item {
align-items: flex-start;
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid var(--color-secondary);
}
.file-search-results .item:last-child {
border-bottom: none;
}
.file-search-results .item:hover,
.file-search-results .item.selected {
background-color: var(--color-hover);
}
.file-search-results .item .file-icon {
flex-shrink: 0;
margin-top: 0.125rem;
}
.file-search-results .item .full-path {
flex: 1;
overflow-wrap: anywhere;
}
.file-search-results .item .full-path :nth-child(even) {
color: var(--color-red);
font-weight: var(--font-weight-semibold);
}
</style>
@@ -8,6 +8,7 @@ import {
TimeScale,
type ChartOptions,
type ChartData,
type ChartDataset,
} from 'chart.js';
import {GET} from '../modules/fetch.ts';
import {Bar} from 'vue-chartjs';
@@ -45,7 +46,7 @@ defineProps<{
const isLoading = shallowRef(false);
const errorText = shallowRef('');
const repoLink = pageData.repoLink;
const repoLink = pageData.repoLink!;
const data = ref<DayData[]>([]);
onMounted(() => {
@@ -83,13 +84,12 @@ function toGraphData(data: DayData[]): ChartData<'bar'> {
return {
datasets: [
{
// @ts-expect-error -- bar chart expects one-dimensional data, but apparently x/y still works
data: data.map((i) => ({x: i.week, y: i.commits})),
label: 'Commits',
backgroundColor: chartJsColors['commits'],
borderWidth: 0,
tension: 0.3,
},
} as unknown as ChartDataset<'bar'>,
],
};
}
@@ -131,7 +131,7 @@ const options: ChartOptions<'bar'> = {
<SvgIcon name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
{{ locale.loadingInfo }}
</div>
<div v-else class="text red">
<div v-else class="tw-text-red">
<SvgIcon name="octicon-x-circle-fill"/>
{{ errorText }}
</div>
@@ -1,9 +1,9 @@
<script lang="ts" setup>
import ViewFileTreeItem from './ViewFileTreeItem.vue';
import {onMounted, useTemplateRef} from 'vue';
import {onMounted, useTemplateRef, type ShallowRef} from 'vue';
import {createViewFileTreeStore} from './ViewFileTreeStore.ts';
const elRoot = useTemplateRef('elRoot');
const elRoot = useTemplateRef('elRoot') as Readonly<ShallowRef<HTMLDivElement>>;;
const props = defineProps({
repoLink: {type: String, required: true},
@@ -24,7 +24,7 @@ onMounted(async () => {
<template>
<div class="view-file-tree-items" ref="elRoot">
<ViewFileTreeItem v-for="item in store.rootFiles" :key="item.name" :item="item" :store="store"/>
<ViewFileTreeItem v-for="item in store.rootFiles" :key="item.entryName" :item="item" :store="store"/>
</div>
</template>
@@ -1,21 +1,12 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import {isPlainClick} from '../utils/dom.ts';
import {shouldTriggerAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {shallowRef} from 'vue';
import {type createViewFileTreeStore} from './ViewFileTreeStore.ts';
type Item = {
entryName: string;
entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown';
entryIcon: string;
entryIconOpen: string;
fullPath: string;
submoduleUrl?: string;
children?: Item[];
};
import type {createViewFileTreeStore, FileTreeItem} from './ViewFileTreeStore.ts';
const props = defineProps<{
item: Item,
item: FileTreeItem,
store: ReturnType<typeof createViewFileTreeStore>
}>();
@@ -37,9 +28,10 @@ const doLoadChildren = async () => {
};
const onItemClick = (e: MouseEvent) => {
// only handle the click event with page partial reloading if the user didn't press any special key
// let browsers handle special keys like "Ctrl+Click"
if (!isPlainClick(e)) return;
// only handle the click event with partial page reloading if both
// - the user didn't press any special key like "Ctrl+Click" (which may have custom browser behavior)
// - the editor/commit form isn't dirty (a full page reload shows a confirmation dialog if the form contains unsaved changes)
if (!isPlainClick(e) || shouldTriggerAreYouSure()) return;
e.preventDefault();
if (props.item.entryMode === 'tree') doLoadChildren();
store.navigateTreeView(props.item.fullPath);
@@ -4,12 +4,25 @@ import {pathEscapeSegments} from '../utils/url.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
export type FileTreeItem = {
entryName: string;
entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown';
entryIcon: string;
entryIconOpen: string;
fullPath: string;
submoduleUrl?: string;
children?: Array<FileTreeItem>;
};
export function createViewFileTreeStore(props: {repoLink: string, treePath: string, currentRefNameSubURL: string}) {
const store = reactive({
rootFiles: [],
rootFiles: [] as Array<FileTreeItem>,
selectedItem: props.treePath,
async loadChildren(treePath: string, subPath: string = '') {
// there is no git ref if no commits were made yet (an empty repo)
if (!props.currentRefNameSubURL) return null;
const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`);
const json = await response.json();
const poolSvgs = [];
@@ -28,7 +41,7 @@ export function createViewFileTreeStore(props: {repoLink: string, treePath: stri
const u = new URL(url, window.origin);
u.searchParams.set('only_content', 'true');
const response = await GET(u.href);
const elViewContent = document.querySelector('.repo-view-content');
const elViewContent = document.querySelector('.repo-view-content')!;
elViewContent.innerHTML = await response.text();
const elViewContentData = elViewContent.querySelector('.repo-view-content-data');
if (!elViewContentData) return; // if error occurs, there is no such element
@@ -39,7 +52,7 @@ export function createViewFileTreeStore(props: {repoLink: string, treePath: stri
async navigateTreeView(treePath: string) {
const url = store.buildTreePathWebUrl(treePath);
window.history.pushState({treePath, url}, null, url);
window.history.pushState({treePath, url}, '', url);
store.selectedItem = treePath;
await store.loadViewContent(url);
},
@@ -0,0 +1,802 @@
<script setup lang="ts">
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import {SvgIcon} from '../svg.ts';
import ActionRunStatus from './ActionRunStatus.vue';
import {localUserSettings} from '../modules/user-settings.ts';
import {isPlainClick} from '../utils/dom.ts';
import {debounce} from 'throttle-debounce';
import type {ActionsJob, ActionsRunStatus} from '../modules/gitea-actions.ts';
import type {ActionRunViewStore} from './ActionRunView.ts';
interface JobNode {
id: number;
name: string;
status: ActionsRunStatus;
duration: string;
x: number;
y: number;
level: number;
}
interface Edge {
fromId: number;
toId: number;
key: string;
}
interface RoutedEdge extends Edge {
path: string;
fromNode: JobNode;
toNode: JobNode;
}
interface StoredState {
scale: number;
translateX: number;
translateY: number;
timestamp: number;
}
const props = defineProps<{
store: ActionRunViewStore;
jobs: ActionsJob[];
runLink: string;
workflowId: string;
}>()
const settingKeyStates = 'actions-graph-states';
const maxStoredStates = 10;
const scale = ref(1);
const translateX = ref(0);
const translateY = ref(0);
const isDragging = ref(false);
const lastMousePos = ref({x: 0, y: 0});
const graphContainer = ref<HTMLElement | null>(null);
const hoveredJobId = ref<number | null>(null);
const stateKey = () => `${props.store.viewData.currentRun.repoId}-${props.workflowId}`;
const loadSavedState = () => {
const allStates = localUserSettings.getJsonObject<Record<string, StoredState>>(settingKeyStates, {});
const saved = allStates[stateKey()];
if (!saved) return;
scale.value = clampScale(saved.scale ?? scale.value);
translateX.value = saved.translateX ?? translateX.value;
translateY.value = saved.translateY ?? translateY.value;
};
const saveState = () => {
const allStates = localUserSettings.getJsonObject<Record<string, StoredState>>(settingKeyStates, {});
allStates[stateKey()] = {
scale: scale.value,
translateX: translateX.value,
translateY: translateY.value,
timestamp: Date.now(),
};
const sortedStates = Object.entries(allStates)
.sort(([, a], [, b]) => b.timestamp - a.timestamp)
.slice(0, maxStoredStates);
localUserSettings.setJsonObject(settingKeyStates, Object.fromEntries(sortedStates));
};
const nodeWidth = computed(() => {
const maxNameLength = Math.max(...props.jobs.map(j => j.name.length));
return Math.min(Math.max(140, maxNameLength * 8), 180);
});
const horizontalSpacing = computed(() => nodeWidth.value + 84);
const graphWidth = computed(() => {
if (jobsWithLayout.value.length === 0) return 800;
const maxX = Math.max(...jobsWithLayout.value.map(j => j.x + nodeWidth.value));
return maxX + margin * 2;
});
const graphHeight = computed(() => {
if (jobsWithLayout.value.length === 0) return 400;
const maxY = Math.max(...jobsWithLayout.value.map(j => j.y + nodeHeight));
return maxY + margin * 2;
});
const jobsWithLayout = computed<JobNode[]>(() => {
try {
const levels = computeJobLevels(props.jobs);
const currentHorizontalSpacing = horizontalSpacing.value;
const jobsByLevel: ActionsJob[][] = [];
let maxJobsPerLevel = 0;
props.jobs.forEach(job => {
const level = levels.get(job.name) || levels.get(job.jobId) || 0;
if (!jobsByLevel[level]) {
jobsByLevel[level] = [];
}
jobsByLevel[level].push(job);
if (jobsByLevel[level].length > maxJobsPerLevel) {
maxJobsPerLevel = jobsByLevel[level].length;
}
});
const result: JobNode[] = [];
jobsByLevel.forEach((levelJobs, levelIndex) => {
if (!levelJobs || levelJobs.length === 0) {
return;
}
const startY = margin;
levelJobs.forEach((job, jobIndex) => {
result.push({
id: job.id,
name: job.name,
status: job.status,
duration: job.duration,
x: margin + levelIndex * currentHorizontalSpacing,
y: startY + jobIndex * verticalSpacing,
level: levelIndex,
});
});
});
return result;
} catch (error) {
return props.jobs.map((job, index) => ({
id: job.id,
name: job.name,
status: job.status,
duration: job.duration,
x: margin + index * horizontalSpacing.value,
y: margin,
level: 0,
}));
}
});
function buildDirectNeedsMap(jobs: ActionsJob[]): Map<string, string[]> {
const directNeedsByJobId = new Map<string, string[]>();
const dependentsByJobId = new Map<string, Set<string>>();
for (const job of jobs) {
const needs = job.needs || [];
directNeedsByJobId.set(job.jobId, needs);
for (const need of needs) {
if (!dependentsByJobId.has(need)) {
dependentsByJobId.set(need, new Set());
}
dependentsByJobId.get(need)!.add(job.jobId);
}
}
const reachabilityCache = new Map<string, boolean>();
function canReach(fromJobId: string, toJobId: string): boolean {
const cacheKey = `${fromJobId}->${toJobId}`;
if (reachabilityCache.has(cacheKey)) {
return reachabilityCache.get(cacheKey)!;
}
const visited = new Set<string>();
const stack = [...(dependentsByJobId.get(fromJobId) || [])];
while (stack.length > 0) {
const current = stack.pop()!;
if (current === toJobId) {
reachabilityCache.set(cacheKey, true);
return true;
}
if (visited.has(current)) continue;
visited.add(current);
stack.push(...(dependentsByJobId.get(current) || []));
}
reachabilityCache.set(cacheKey, false);
return false;
}
const reducedNeedsByJobId = new Map<string, string[]>();
for (const [jobId, needs] of directNeedsByJobId.entries()) {
reducedNeedsByJobId.set(jobId, needs.filter((need) => {
return !needs.some((otherNeed) => otherNeed !== need && canReach(need, otherNeed));
}));
}
return reducedNeedsByJobId;
}
const directNeedsByJobId = computed(() => buildDirectNeedsMap(props.jobs));
const edges = computed<Edge[]>(() => {
const edgesList: Edge[] = [];
const jobsByJobId = new Map<string, ActionsJob[]>();
for (const job of props.jobs) {
if (!jobsByJobId.has(job.jobId)) {
jobsByJobId.set(job.jobId, []);
}
jobsByJobId.get(job.jobId)!.push(job);
}
for (const job of props.jobs) {
for (const need of directNeedsByJobId.value.get(job.jobId) || []) {
const upstreamJobs = jobsByJobId.get(need) || [];
for (const upstreamJob of upstreamJobs) {
edgesList.push({
fromId: upstreamJob.id,
toId: job.id,
key: `${upstreamJob.id}-${job.id}`,
});
}
}
}
return edgesList;
});
function buildRoundedConnectorPath(startX: number, startY: number, endX: number, endY: number, turnX: number): string {
const deltaY = endY - startY;
if (Math.abs(deltaY) < 1) {
return `M ${startX} ${startY} H ${endX}`;
}
const direction = deltaY > 0 ? 1 : -1;
const elbowSize = Math.max(8, Math.min(24, Math.abs(deltaY) / 2, Math.abs(endX - startX) / 2));
const controlOffset = elbowSize / 2;
const clampedTurnX = Math.min(Math.max(turnX, startX + elbowSize), endX - elbowSize);
return [
`M ${startX} ${startY}`,
`H ${clampedTurnX - elbowSize}`,
`C ${clampedTurnX - controlOffset} ${startY} ${clampedTurnX} ${startY + direction * controlOffset} ${clampedTurnX} ${startY + direction * elbowSize}`,
`V ${endY - direction * elbowSize}`,
`C ${clampedTurnX} ${endY - direction * controlOffset} ${clampedTurnX + controlOffset} ${endY} ${clampedTurnX + elbowSize} ${endY}`,
`H ${endX}`,
].join(' ');
}
const routedEdges = computed<RoutedEdge[]>(() => {
const nodesById = new Map(jobsWithLayout.value.map((job) => [job.id, job]));
const outgoingEdges = new Map<number, Edge[]>();
const incomingEdges = new Map<number, Edge[]>();
for (const edge of edges.value) {
if (!outgoingEdges.has(edge.fromId)) {
outgoingEdges.set(edge.fromId, []);
}
outgoingEdges.get(edge.fromId)!.push(edge);
if (!incomingEdges.has(edge.toId)) {
incomingEdges.set(edge.toId, []);
}
incomingEdges.get(edge.toId)!.push(edge);
}
for (const sourceEdges of outgoingEdges.values()) {
sourceEdges.sort((a, b) => {
const targetA = nodesById.get(a.toId);
const targetB = nodesById.get(b.toId);
if (!targetA || !targetB) return 0;
return targetA.y - targetB.y || a.toId - b.toId;
});
}
const edgePaths: RoutedEdge[] = [];
for (const edge of edges.value) {
const fromNode = nodesById.get(edge.fromId);
const toNode = nodesById.get(edge.toId);
if (!fromNode || !toNode) continue;
const startX = fromNode.x + nodeWidth.value;
const startY = fromNode.y + nodeHeight / 2;
const endX = toNode.x;
const endY = toNode.y + nodeHeight / 2;
const sourceEdges = outgoingEdges.get(edge.fromId) || [];
const targetEdges = incomingEdges.get(edge.toId) || [];
const horizontalGap = endX - startX;
const turnOffset = Math.min(28, Math.max(16, horizontalGap * 0.14));
const sourceTurnX = startX + turnOffset;
const targetTurnX = endX - turnOffset;
let turnX = startX + horizontalGap / 2;
if (sourceEdges.length > 1) {
turnX = sourceTurnX;
} else if (targetEdges.length > 1) {
turnX = targetTurnX;
}
const path = buildRoundedConnectorPath(startX, startY, endX, endY, turnX);
edgePaths.push({
...edge,
path,
fromNode,
toNode,
});
}
return edgePaths;
});
const graphMetrics = computed(() => {
const successCount = jobsWithLayout.value.filter(job => job.status === 'success').length;
const levels = new Map<number, number>();
jobsWithLayout.value.forEach(job => {
const count = levels.get(job.level) || 0;
levels.set(job.level, count + 1);
})
const parallelism = Math.max(...Array.from(levels.values()), 0);
return {
successRate: `${((successCount / jobsWithLayout.value.length) * 100).toFixed(0)}%`,
parallelism,
};
})
const nodeHeight = 48;
const verticalSpacing = 88;
const margin = 40;
const minScale = 0.3;
const maxScale = 1;
function clampScale(nextScale: number): number {
return Math.min(Math.max(Math.round(nextScale * 100) / 100, minScale), maxScale);
}
const canZoomIn = computed(() => scale.value < maxScale);
function zoomTo(nextScale: number) {
scale.value = clampScale(nextScale);
}
function zoomIn() {
zoomTo(scale.value * 1.2);
}
function zoomOut() {
zoomTo(scale.value / 1.2);
}
function resetView() {
scale.value = 1;
translateX.value = 0;
translateY.value = 0;
}
function handleMouseDown(e: MouseEvent) {
if (!isPlainClick(e)) return;
// don't start drag on interactive/text elements inside the SVG
const target = e.target as Element;
const interactive = target.closest('div, p, a, span, button, input, text, .job-node-group');
if (interactive?.closest('svg')) return;
e.preventDefault();
isDragging.value = true;
lastMousePos.value = {x: e.clientX, y: e.clientY};
graphContainer.value!.style.cursor = 'grabbing';
}
function handleMouseMoveOnDocument(event: MouseEvent) {
if (!isDragging.value) return;
const dx = event.clientX - lastMousePos.value.x;
const dy = event.clientY - lastMousePos.value.y;
translateX.value += dx;
translateY.value += dy;
lastMousePos.value = {x: event.clientX, y: event.clientY};
}
function handleMouseUpOnDocument() {
if (!isDragging.value) return;
isDragging.value = false;
graphContainer.value!.style.cursor = 'grab';
}
function handleWheel(event: WheelEvent) {
// Without a modifier, let the wheel scroll the page
if (!event.ctrlKey && !event.metaKey) {
return;
}
event.preventDefault();
const zoomFactor = Math.exp(-event.deltaY * 0.0015);
zoomTo(scale.value * zoomFactor);
}
onMounted(() => {
loadSavedState();
watch([translateX, translateY, scale], debounce(500, saveState));
watch([scale], debounce(100, saveState));
document.addEventListener('mousemove', handleMouseMoveOnDocument);
document.addEventListener('mouseup', handleMouseUpOnDocument);
});
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMoveOnDocument);
document.removeEventListener('mouseup', handleMouseUpOnDocument);
});
function handleNodeMouseEnter(job: JobNode) {
hoveredJobId.value = job.id;
}
function handleNodeMouseLeave() {
hoveredJobId.value = null;
}
function isEdgeHighlighted(edge: RoutedEdge): boolean {
if (!hoveredJobId.value) {
return false;
}
return edge.fromId === hoveredJobId.value || edge.toId === hoveredJobId.value;
}
const nodesWithIncomingEdge = computed(() => {
const set = new Set<number>();
for (const edge of routedEdges.value) set.add(edge.toId);
return set;
});
const nodesWithOutgoingEdge = computed(() => {
const set = new Set<number>();
for (const edge of routedEdges.value) set.add(edge.fromId);
return set;
});
function computeJobLevels(jobs: ActionsJob[]): Map<string, number> {
const jobMap = new Map<string, ActionsJob>()
jobs.forEach(job => {
jobMap.set(job.name, job);
if (job.jobId) jobMap.set(job.jobId, job);
});
const levels = new Map<string, number>();
const visited = new Set<string>();
const recursionStack = new Set<string>();
const MAX_DEPTH = 100;
function dfs(jobNameOrId: string, depth: number = 0): number {
if (depth > MAX_DEPTH) {
console.error(`Max recursion depth (${MAX_DEPTH}) reached for: ${jobNameOrId}`);
return 0;
}
if (recursionStack.has(jobNameOrId)) {
console.error(`Cycle detected involving: ${jobNameOrId}`);
return 0;
}
if (visited.has(jobNameOrId)) {
return levels.get(jobNameOrId) || 0;
}
recursionStack.add(jobNameOrId);
visited.add(jobNameOrId);
const job = jobMap.get(jobNameOrId);
if (!job) {
recursionStack.delete(jobNameOrId);
return 0;
}
if (!job.needs?.length) {
levels.set(job.jobId, 0);
recursionStack.delete(jobNameOrId);
return 0;
}
let maxLevel = -1;
for (const need of job.needs) {
const needJob = jobMap.get(need);
if (!needJob) continue;
const needLevel = dfs(need, depth + 1);
maxLevel = Math.max(maxLevel, needLevel);
}
const level = maxLevel + 1
levels.set(job.name, level);
if (job.jobId && job.jobId !== job.name) {
levels.set(job.jobId, level);
}
recursionStack.delete(jobNameOrId);
return level;
}
jobs.forEach(job => {
if (!visited.has(job.name) && !visited.has(job.jobId)) {
dfs(job.name);
}
})
return levels;
}
function onNodeClick(job: JobNode, event: MouseEvent) {
const link = `${props.runLink}/jobs/${job.id}`;
if (event.ctrlKey || event.metaKey) {
window.open(link, '_blank');
return;
}
window.location.href = link;
}
</script>
<template>
<div class="workflow-graph" v-if="jobs.length > 0">
<div class="graph-header">
<h4 class="graph-title">Workflow Dependencies</h4>
<div class="graph-stats">
{{ jobs.length }} jobs {{ edges.length }} dependencies
<span v-if="graphMetrics">
<span class="graph-metrics">{{ graphMetrics.successRate }} success</span>
</span>
</div>
<div class="flex-text-block">
<button
type="button"
@click="zoomIn"
class="ui compact tiny icon button"
:disabled="!canZoomIn"
:title="canZoomIn ? 'Zoom in (Ctrl/Cmd + scroll on graph)' : 'Already at 100% zoom'"
>
<SvgIcon name="octicon-zoom-in" :size="12"/>
</button>
<button type="button" @click="resetView" class="ui compact tiny icon button" title="Reset view">
<SvgIcon name="octicon-sync" :size="12"/>
</button>
<button type="button" @click="zoomOut" class="ui compact tiny icon button" title="Zoom out (Ctrl/Cmd + scroll on graph)">
<SvgIcon name="octicon-zoom-out" :size="12"/>
</button>
</div>
</div>
<div
class="graph-container"
ref="graphContainer"
@mousedown="handleMouseDown"
@wheel="handleWheel"
:class="{dragging: isDragging}"
>
<svg
:width="graphWidth"
:height="graphHeight"
class="graph-svg"
:style="{
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
transformOrigin: '0 0',
}"
>
<path
v-for="edge in routedEdges"
:key="edge.key"
:d="edge.path"
fill="none"
stroke="var(--color-secondary-alpha-50)"
stroke-width="1.75"
:class="['node-edge', { 'highlighted-edge': isEdgeHighlighted(edge) }]"
/>
<g
v-for="job in jobsWithLayout"
:key="job.id"
class="job-node-group"
@click="onNodeClick(job, $event)"
@mouseenter="handleNodeMouseEnter(job)"
@mouseleave="handleNodeMouseLeave"
>
<title>{{ job.name }}</title>
<rect
:x="job.x"
:y="job.y"
:width="nodeWidth"
:height="nodeHeight"
rx="10"
fill="var(--color-button)"
stroke="var(--color-light-border)"
stroke-width="1.25"
class="job-rect"
/>
<circle
v-if="nodesWithIncomingEdge.has(job.id)"
:cx="job.x"
:cy="job.y + nodeHeight / 2"
r="6"
class="node-port"
/>
<circle
v-if="nodesWithOutgoingEdge.has(job.id)"
:cx="job.x + nodeWidth"
:cy="job.y + nodeHeight / 2"
r="6"
class="node-port"
/>
<foreignObject
:x="job.x + 10"
:y="job.y + 14"
width="20"
height="20"
class="job-status-fg-obj"
>
<div class="job-status-icon-wrap">
<ActionRunStatus :status="job.status"/>
</div>
</foreignObject>
<foreignObject
:x="job.x + 36"
:y="job.y"
:width="nodeWidth - 40"
:height="nodeHeight"
>
<div class="job-text-wrap">
<span class="job-name">{{ job.name }}</span>
<span
v-if="job.duration || job.status === 'success' || job.status === 'failure'"
class="job-duration"
>{{ job.duration }}</span>
</div>
</foreignObject>
</g>
</svg>
</div>
</div>
</template>
<style scoped>
.workflow-graph {
flex: 1;
display: flex;
flex-direction: column;
}
.graph-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 14px;
background: var(--color-box-header);
border-bottom: 1px solid var(--color-secondary);
gap: var(--gap-block);
flex-wrap: wrap;
}
.graph-title {
margin: 0;
color: var(--color-text);
font-size: 16px;
font-weight: var(--font-weight-semibold);
flex: 1;
min-width: 200px;
}
.graph-stats {
display: flex;
align-items: baseline;
column-gap: 8px;
color: var(--color-text-light-1);
font-size: 13px;
white-space: nowrap;
}
.graph-metrics {
color: var(--color-primary);
font-weight: var(--font-weight-medium);
}
.graph-container {
flex: 1;
overflow: hidden;
padding: 12px 16px 20px;
border-radius: 0 0 var(--border-radius) var(--border-radius);
cursor: grab;
position: relative;
background: var(--color-box-body);
}
.graph-container.dragging {
cursor: grabbing;
}
.graph-svg {
display: block;
will-change: transform;
}
.graph-svg path {
transition: all 0.2s ease;
stroke-linecap: round;
stroke-linejoin: round;
}
.highlighted-edge {
stroke-width: 2.25 !important;
stroke: var(--color-workflow-edge-hover) !important;
}
.job-node-group {
cursor: pointer;
transition: all 0.2s ease;
}
.job-node-group:hover .job-rect {
/* due to SVG rendering limitation, only one of fill and drop-shadow can work */
fill: var(--color-hover);
/* filter: drop-shadow(0 1px 3px var(--color-shadow-opaque)); */
}
.job-text-wrap {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
padding-right: 8px;
}
.job-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-weight: var(--font-weight-semibold);
color: var(--color-text);
user-select: none;
pointer-events: none;
}
.job-duration {
font-size: 11px;
color: var(--color-text-light-2);
white-space: nowrap;
flex-shrink: 0;
user-select: none;
pointer-events: none;
}
.job-status-fg-obj,
.job-status-icon-wrap {
pointer-events: none;
}
.job-status-icon-wrap {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.node-port {
fill: var(--color-box-body);
stroke: var(--color-light-border);
stroke-width: 1.5;
pointer-events: none;
}
.node-edge {
transition: stroke-width 0.2s ease, opacity 0.2s ease;
}
</style>
@@ -1,6 +1,6 @@
class Source {
url: string;
eventSource: EventSource;
eventSource: EventSource | null;
listening: Record<string, boolean>;
clients: Array<MessagePort>;
@@ -47,7 +47,7 @@ class Source {
listen(eventType: string) {
if (this.listening[eventType]) return;
this.listening[eventType] = true;
this.eventSource.addEventListener(eventType, (event) => {
this.eventSource?.addEventListener(eventType, (event) => {
this.notifyClients({
type: eventType,
data: event.data,
@@ -64,18 +64,17 @@ class Source {
status(port: MessagePort) {
port.postMessage({
type: 'status',
message: `url: ${this.url} readyState: ${this.eventSource.readyState}`,
message: `url: ${this.url} readyState: ${this.eventSource?.readyState}`,
});
}
}
const sourcesByUrl: Map<string, Source | null> = new Map();
const sourcesByPort: Map<MessagePort, Source | null> = new Map();
const sourcesByUrl = new Map<string, Source | null>();
const sourcesByPort = new Map<MessagePort, Source | null>();
// @ts-expect-error: typescript bug?
self.addEventListener('connect', (e: MessageEvent) => {
(self as unknown as SharedWorkerGlobalScope).addEventListener('connect', (e: MessageEvent) => {
for (const port of e.ports) {
port.addEventListener('message', (event) => {
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,
@@ -85,14 +84,14 @@ self.addEventListener('connect', (e: MessageEvent) => {
}
if (event.data.type === 'start') {
const url = event.data.url;
if (sourcesByUrl.get(url)) {
let source = sourcesByUrl.get(url);
if (source) {
// we have a Source registered to this url
const source = sourcesByUrl.get(url);
source.register(port);
sourcesByPort.set(port, source);
return;
}
let source = sourcesByPort.get(port);
source = sourcesByPort.get(port);
if (source) {
if (source.eventSource && source.url === url) return;
@@ -111,11 +110,10 @@ self.addEventListener('connect', (e: MessageEvent) => {
sourcesByUrl.set(url, source);
sourcesByPort.set(port, source);
} else if (event.data.type === 'listen') {
const source = sourcesByPort.get(port);
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);
@@ -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();
@@ -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);
});
@@ -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};
}
@@ -2,6 +2,7 @@ import {checkAppUrl} from '../common-page.ts';
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {pathEscape} from '../../utils/url.ts';
const {appSubUrl} = window.config;
@@ -63,7 +64,7 @@ function initAdminAuthentication() {
function onUsePagedSearchChange() {
const searchPageSizeElements = document.querySelectorAll<HTMLDivElement>('.search-page-size');
if (document.querySelector<HTMLInputElement>('#use_paged_search').checked) {
if (document.querySelector<HTMLInputElement>('#use_paged_search')!.checked) {
showElem('.search-page-size');
for (const el of searchPageSizeElements) {
el.querySelector('input')?.setAttribute('required', 'required');
@@ -82,10 +83,10 @@ function initAdminAuthentication() {
input.removeAttribute('required');
}
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider').value;
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
switch (provider) {
case 'openidConnect':
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input').setAttribute('required', 'required');
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input')!.setAttribute('required', 'required');
showElem('.open_id_connect_auto_discovery_url');
break;
default: {
@@ -97,7 +98,7 @@ function initAdminAuthentication() {
showElem('.oauth2_use_custom_url'); // show the checkbox
}
if (mustProvideCustomURLs) {
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url').checked = true; // make the checkbox checked
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')!.checked = true; // make the checkbox checked
}
break;
}
@@ -109,20 +110,20 @@ function initAdminAuthentication() {
}
function onOAuth2UseCustomURLChange(applyDefaultValues: boolean) {
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider').value;
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) {
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;
document.querySelector<HTMLInputElement>(`#oauth2_${custom}`)!.value = document.querySelector<HTMLInputElement>(`#${provider}_${custom}`)!.value;
}
const customInput = document.querySelector(`#${provider}_${custom}`);
if (customInput && customInput.getAttribute('data-available') === 'true') {
if (customInput?.getAttribute('data-available') === 'true') {
for (const input of document.querySelectorAll(`.oauth2_${custom} input`)) {
input.setAttribute('required', 'required');
}
@@ -134,10 +135,10 @@ function initAdminAuthentication() {
function onEnableLdapGroupsChange() {
const checked = document.querySelector<HTMLInputElement>('.js-ldap-group-toggle')?.checked;
toggleElem(document.querySelector('#ldap-group-options'), checked);
toggleElem(document.querySelector('#ldap-group-options')!, checked);
}
const elAuthType = document.querySelector<HTMLInputElement>('#auth_type');
const elAuthType = document.querySelector<HTMLInputElement>('#auth_type')!;
// New authentication
if (isNewPage) {
@@ -208,14 +209,14 @@ function initAdminAuthentication() {
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);
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);
document.querySelector('.js-ldap-group-toggle')!.addEventListener('change', onEnableLdapGroupsChange);
onEnableLdapGroupsChange();
if (authType === '2') {
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
@@ -227,10 +228,10 @@ function initAdminAuthentication() {
}
}
const elAuthName = document.querySelector<HTMLInputElement>('#auth_name');
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/${encodeURIComponent(elAuthName.value)}/callback`;
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${pathEscape(elAuthName.value)}/callback`;
};
elAuthName.addEventListener('input', onAuthNameChange);
onAuthNameChange();
@@ -240,13 +241,13 @@ function initAdminNotice() {
const pageContent = document.querySelector('.page-content.admin.notice');
if (!pageContent) return;
const detailModal = document.querySelector<HTMLDivElement>('#detail-modal');
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');
const elNoticeDesc = el.closest('tr')!.querySelector('.notice-description')!;
const elModalDesc = detailModal.querySelector('.content pre')!;
elModalDesc.textContent = elNoticeDesc.textContent;
fomanticQuery(detailModal).modal('show');
}));
@@ -280,10 +281,10 @@ function initAdminNotice() {
const data = new FormData();
for (const checkbox of checkboxes) {
if (checkbox.checked) {
data.append('ids[]', checkbox.closest('.ui.checkbox').getAttribute('data-id'));
data.append('ids[]', checkbox.closest('.ui.checkbox')!.getAttribute('data-id')!);
}
}
await POST(this.getAttribute('data-link'), {data});
window.location.href = this.getAttribute('data-redirect');
await POST(this.getAttribute('data-link')!, {data});
window.location.href = this.getAttribute('data-redirect')!;
});
}
@@ -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 = [], values = [];
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"}',
});
});
@@ -1,24 +1,221 @@
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 {submitFormFetchAction} from '../common-fetch-action.ts';
const {appSubUrl} = window.config;
export function initAdminConfigs(): void {
const elAdminConfig = document.querySelector<HTMLDivElement>('.page-content.admin.config');
if (!elAdminConfig) return;
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);
}
for (const el of elAdminConfig.querySelectorAll<HTMLInputElement>('input[type="checkbox"][data-config-dyn-key]')) {
el.addEventListener('change', async () => {
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, ex.toString());
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 {
const resp = await POST(`${appSubUrl}/-/admin/config`, {
data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key'), value: String(el.checked)}),
});
const json: Record<string, any> = await resp.json();
if (json.errorMessage) throw new Error(json.errorMessage);
} catch (ex) {
showTemporaryTooltip(el, ex.toString());
el.checked = !el.checked;
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);
});
}
@@ -7,7 +7,7 @@ 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');
const elContent = document.querySelector<HTMLDivElement>('.page-content.admin .admin-setting-content')!;
// send frontend self-check request
const resp = await POST(`${appSubUrl}/-/admin/self_check`, {
@@ -27,5 +27,5 @@ export async function initAdminSelfCheck() {
// 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);
toggleElem(elContent.querySelector('.self-check-no-problem')!, !hasProblem);
}
@@ -4,7 +4,7 @@ export async function initCaptcha() {
const captchaEl = document.querySelector('#captcha');
if (!captchaEl) return;
const siteKey = captchaEl.getAttribute('data-sitekey');
const siteKey = captchaEl.getAttribute('data-sitekey')!;
const isDark = isDarkTheme();
const params = {
@@ -34,23 +34,10 @@ export async function initCaptcha() {
break;
}
case 'm-captcha': {
const mCaptcha = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue');
// FIXME: the mCaptcha code is not right, it's a miracle that the wrong code could run
// * the "vanilla-glue" has some problems with es6 module.
// * the INPUT_NAME is a "const", it should not be changed.
// * the "mCaptcha.default" is actually the "Widget".
// @ts-expect-error TS2540: Cannot assign to 'INPUT_NAME' because it is a read-only property.
mCaptcha.INPUT_NAME = 'm-captcha-response';
const instanceURL = captchaEl.getAttribute('data-instance-url');
new mCaptcha.default({
siteKey: {
instanceUrl: new URL(instanceURL),
key: siteKey,
},
});
// 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:
@@ -1,20 +1,17 @@
import {getCurrentLocale} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.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([
// @ts-expect-error: module exports no types
import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'),
// @ts-expect-error: module exports no types
import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'),
// @ts-expect-error: module exports no types
import(/* webpackChunkName: "citation-js-bibtex" */'@citation-js/plugin-bibtex'),
// @ts-expect-error: module exports no types
import(/* webpackChunkName: "citation-js-csl" */'@citation-js/plugin-csl'),
import('@citation-js/core'),
import('@citation-js/plugin-software-formats'),
import('@citation-js/plugin-bibtex'),
import('@citation-js/plugin-csl'),
]);
const {citationFileContent} = pageData;
const citationFileContent = pageData.citationFileContent!;
const config = plugins.config.get('@bibtex');
config.constants.fieldTypes.doi = ['field', 'literal'];
config.constants.fieldTypes.version = ['field', 'literal'];
@@ -31,15 +28,15 @@ export async function initCitationFileCopyContent() {
if (!pageData.citationFileContent) return;
const citationCopyApa = document.querySelector<HTMLButtonElement>('#citation-copy-apa');
const citationCopyBibtex = document.querySelector<HTMLButtonElement>('#citation-copy-bibtex');
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 = (localStorage.getItem('citation-copy-format') || defaultCitationFormat) === 'bibtex';
const copyContent = (isBibtex ? citationCopyBibtex : citationCopyApa).getAttribute('data-text');
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);
@@ -55,12 +52,12 @@ export async function initCitationFileCopyContent() {
updateUi();
citationCopyApa.addEventListener('click', () => {
localStorage.setItem('citation-copy-format', 'apa');
localUserSettings.setString('citation-copy-format', 'apa');
updateUi();
});
citationCopyBibtex.addEventListener('click', () => {
localStorage.setItem('citation-copy-format', 'bibtex');
localUserSettings.setString('citation-copy-format', 'bibtex');
updateUi();
});
@@ -1,24 +1,31 @@
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {toAbsoluteUrl} from '../utils.ts';
import {clippie} from 'clippie';
import type {DOMEvent} from '../utils/dom.ts';
const {copy_success, copy_error} = window.config.i18n;
// Enable clipboard copy from HTML attributes. These properties are supported:
// - data-clipboard-text: Direct text to copy
// - data-clipboard-target: Holds a selector for a <input> or <textarea> whose content is copied
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of <div> will be copied
// - data-clipboard-text-type: When set to 'url' will convert relative to absolute urls
export function initGlobalCopyToClipboardListener() {
document.addEventListener('click', async (e: DOMEvent<MouseEvent>) => {
const target = e.target.closest('[data-clipboard-text], [data-clipboard-target]');
document.addEventListener('click', async (e) => {
const target = (e.target as HTMLElement).closest('[data-clipboard-text], [data-clipboard-target]');
if (!target) return;
e.preventDefault();
let text = target.getAttribute('data-clipboard-text');
if (!text) {
text = document.querySelector<HTMLInputElement>(target.getAttribute('data-clipboard-target'))?.value;
if (text === null) {
const textSelector = target.getAttribute('data-clipboard-target')!;
const textTarget = document.querySelector(textSelector)!;
if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') {
text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value;
} else if (textTarget.nodeName === 'DIV') {
text = textTarget.textContent;
} else {
throw new Error(`Unsupported element for clipboard target: ${textSelector}`);
}
}
if (text && target.getAttribute('data-clipboard-text-type') === 'url') {
@@ -4,7 +4,7 @@ export async function initRepoCodeFrequency() {
const el = document.querySelector('#repo-code-frequency-chart');
if (!el) return;
const {default: RepoCodeFrequency} = await import(/* webpackChunkName: "code-frequency-graph" */'../components/RepoCodeFrequency.vue');
const {default: RepoCodeFrequency} = await import('../components/RepoCodeFrequency.vue');
try {
const View = createApp(RepoCodeFrequency, {
locale: {
@@ -1,242 +0,0 @@
import tinycolor from 'tinycolor2';
import {basename, extname, isObject, isDarkTheme} from '../utils.ts';
import {onInputDebounce} from '../utils/dom.ts';
import type MonacoNamespace from 'monaco-editor';
type Monaco = typeof MonacoNamespace;
type IStandaloneCodeEditor = MonacoNamespace.editor.IStandaloneCodeEditor;
type IEditorOptions = MonacoNamespace.editor.IEditorOptions;
type IGlobalEditorOptions = MonacoNamespace.editor.IGlobalEditorOptions;
type ITextModelUpdateOptions = MonacoNamespace.editor.ITextModelUpdateOptions;
type MonacoOpts = IEditorOptions & IGlobalEditorOptions & ITextModelUpdateOptions;
type EditorConfig = {
indent_style?: 'tab' | 'space',
indent_size?: string | number, // backend emits this as string
tab_width?: string | number, // backend emits this as string
end_of_line?: 'lf' | 'cr' | 'crlf',
charset?: 'latin1' | 'utf-8' | 'utf-8-bom' | 'utf-16be' | 'utf-16le',
trim_trailing_whitespace?: boolean,
insert_final_newline?: boolean,
root?: boolean,
};
const languagesByFilename: Record<string, string> = {};
const languagesByExt: Record<string, string> = {};
const baseOptions: MonacoOpts = {
fontFamily: 'var(--fonts-monospace)',
fontSize: 14, // https://github.com/microsoft/monaco-editor/issues/2242
guides: {bracketPairs: false, indentation: false},
links: false,
minimap: {enabled: false},
occurrencesHighlight: 'off',
overviewRulerLanes: 0,
renderLineHighlight: 'all',
renderLineHighlightOnlyWhenFocus: true,
rulers: [],
scrollbar: {horizontalScrollbarSize: 6, verticalScrollbarSize: 6},
scrollBeyondLastLine: false,
automaticLayout: true,
};
function getEditorconfig(input: HTMLInputElement): EditorConfig | null {
const json = input.getAttribute('data-editorconfig');
if (!json) return null;
try {
return JSON.parse(json);
} catch {
return null;
}
}
function initLanguages(monaco: Monaco): void {
for (const {filenames, extensions, id} of monaco.languages.getLanguages()) {
for (const filename of filenames || []) {
languagesByFilename[filename] = id;
}
for (const extension of extensions || []) {
languagesByExt[extension] = id;
}
if (id === 'typescript') {
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
// this is needed to suppress error annotations in tsx regarding missing --jsx flag.
jsx: monaco.languages.typescript.JsxEmit.Preserve,
});
}
}
}
function getLanguage(filename: string): string {
return languagesByFilename[filename] || languagesByExt[extname(filename)] || 'plaintext';
}
function updateEditor(monaco: Monaco, editor: IStandaloneCodeEditor, filename: string, lineWrapExts: string[]): void {
editor.updateOptions(getFileBasedOptions(filename, lineWrapExts));
const model = editor.getModel();
if (!model) return;
const language = model.getLanguageId();
const newLanguage = getLanguage(filename);
if (language !== newLanguage) monaco.editor.setModelLanguage(model, newLanguage);
// TODO: Need to update the model uri with the new filename, but there is no easy way currently, see
// https://github.com/microsoft/monaco-editor/discussions/3751
}
// export editor for customization - https://github.com/go-gitea/gitea/issues/10409
function exportEditor(editor: IStandaloneCodeEditor): void {
if (!window.codeEditors) window.codeEditors = [];
if (!window.codeEditors.includes(editor)) window.codeEditors.push(editor);
}
function updateTheme(monaco: Monaco): void {
// https://github.com/microsoft/monaco-editor/issues/2427
// also, monaco can only parse 6-digit hex colors, so we convert the colors to that format
const styles = window.getComputedStyle(document.documentElement);
const getColor = (name: string) => tinycolor(styles.getPropertyValue(name).trim()).toString('hex6');
monaco.editor.defineTheme('gitea', {
base: isDarkTheme() ? 'vs-dark' : 'vs',
inherit: true,
rules: [
{
background: getColor('--color-code-bg'),
token: '',
},
],
colors: {
'editor.background': getColor('--color-code-bg'),
'editor.foreground': getColor('--color-text'),
'editor.inactiveSelectionBackground': getColor('--color-primary-light-4'),
'editor.lineHighlightBackground': getColor('--color-editor-line-highlight'),
'editor.selectionBackground': getColor('--color-primary-light-3'),
'editor.selectionForeground': getColor('--color-primary-light-3'),
'editorLineNumber.background': getColor('--color-code-bg'),
'editorLineNumber.foreground': getColor('--color-secondary-dark-6'),
'editorWidget.background': getColor('--color-body'),
'editorWidget.border': getColor('--color-secondary'),
'input.background': getColor('--color-input-background'),
'input.border': getColor('--color-input-border'),
'input.foreground': getColor('--color-input-text'),
'scrollbar.shadow': getColor('--color-shadow-opaque'),
'progressBar.background': getColor('--color-primary'),
'focusBorder': '#0000', // prevent blue border
},
});
}
type CreateMonacoOpts = MonacoOpts & {language?: string};
export async function createMonaco(textarea: HTMLTextAreaElement, filename: string, opts: CreateMonacoOpts): Promise<{monaco: Monaco, editor: IStandaloneCodeEditor}> {
const monaco = await import(/* webpackChunkName: "monaco" */'monaco-editor');
initLanguages(monaco);
let {language, ...other} = opts;
if (!language) language = getLanguage(filename);
const container = document.createElement('div');
container.className = 'monaco-editor-container';
if (!textarea.parentNode) throw new Error('Parent node absent');
textarea.parentNode.append(container);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
updateTheme(monaco);
});
updateTheme(monaco);
const model = monaco.editor.createModel(textarea.value, language, monaco.Uri.file(filename));
const editor = monaco.editor.create(container, {
model,
theme: 'gitea',
...other,
});
monaco.editor.addKeybindingRules([
{keybinding: monaco.KeyCode.Enter, command: null}, // disable enter from accepting code completion
]);
model.onDidChangeContent(() => {
textarea.value = editor.getValue({
preserveBOM: true,
lineEnding: '',
});
textarea.dispatchEvent(new Event('change')); // seems to be needed for jquery-are-you-sure
});
exportEditor(editor);
const loading = document.querySelector('.editor-loading');
if (loading) loading.remove();
return {monaco, editor};
}
function getFileBasedOptions(filename: string, lineWrapExts: string[]): MonacoOpts {
return {
wordWrap: (lineWrapExts || []).includes(extname(filename)) ? 'on' : 'off',
};
}
function togglePreviewDisplay(previewable: boolean): void {
const previewTab = document.querySelector<HTMLElement>('a[data-tab="preview"]');
if (!previewTab) return;
if (previewable) {
previewTab.style.display = '';
} else {
previewTab.style.display = 'none';
// If the "preview" tab was active, user changes the filename to a non-previewable one,
// then the "preview" tab becomes inactive (hidden), so the "write" tab should become active
if (previewTab.classList.contains('active')) {
const writeTab = document.querySelector<HTMLElement>('a[data-tab="write"]');
writeTab?.click();
}
}
}
export async function createCodeEditor(textarea: HTMLTextAreaElement, filenameInput: HTMLInputElement): Promise<IStandaloneCodeEditor> {
const filename = basename(filenameInput.value);
const previewableExts = new Set((textarea.getAttribute('data-previewable-extensions') || '').split(','));
const lineWrapExts = (textarea.getAttribute('data-line-wrap-extensions') || '').split(',');
const isPreviewable = previewableExts.has(extname(filename));
const editorConfig = getEditorconfig(filenameInput);
togglePreviewDisplay(isPreviewable);
const {monaco, editor} = await createMonaco(textarea, filename, {
...baseOptions,
...getFileBasedOptions(filenameInput.value, lineWrapExts),
...getEditorConfigOptions(editorConfig),
});
filenameInput.addEventListener('input', onInputDebounce(() => {
const filename = filenameInput.value;
const previewable = previewableExts.has(extname(filename));
togglePreviewDisplay(previewable);
updateEditor(monaco, editor, filename, lineWrapExts);
}));
return editor;
}
function getEditorConfigOptions(ec: EditorConfig | null): MonacoOpts {
if (!ec || !isObject(ec)) return {};
const opts: MonacoOpts = {};
opts.detectIndentation = !('indent_style' in ec) || !('indent_size' in ec);
if ('indent_size' in ec) {
opts.indentSize = Number(ec.indent_size);
}
if ('tab_width' in ec) {
opts.tabSize = Number(ec.tab_width) || Number(ec.indent_size);
}
if ('max_line_length' in ec) {
opts.rulers = [Number(ec.max_line_length)];
}
opts.trimAutoWhitespace = ec.trim_trailing_whitespace === true;
opts.insertSpaces = ec.indent_style === 'space';
opts.useTabStops = ec.indent_style === 'tab';
return opts;
}
@@ -1,5 +1,4 @@
import {createTippy} from '../modules/tippy.ts';
import type {DOMEvent} from '../utils/dom.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
export async function initColorPickers() {
@@ -7,8 +6,8 @@ export async function initColorPickers() {
registerGlobalInitFunc('initColorPicker', async (el) => {
if (!imported) {
await Promise.all([
import(/* webpackChunkName: "colorpicker" */'vanilla-colorful/hex-color-picker.js'),
import(/* webpackChunkName: "colorpicker" */'../../css/features/colorpicker.css'),
import('vanilla-colorful/hex-color-picker.js'),
import('../../css/features/colorpicker.css'),
]);
imported = true;
}
@@ -25,7 +24,7 @@ function updatePicker(el: HTMLElement, newValue: string): void {
}
function initPicker(el: HTMLElement): void {
const input = el.querySelector('input');
const input = el.querySelector('input')!;
const square = document.createElement('div');
square.classList.add('preview-square');
@@ -39,9 +38,9 @@ function initPicker(el: HTMLElement): void {
updateSquare(square, e.detail.value);
});
input.addEventListener('input', (e: DOMEvent<Event, HTMLInputElement>) => {
updateSquare(square, e.target.value);
updatePicker(picker, e.target.value);
input.addEventListener('input', (e) => {
updateSquare(square, (e.target as HTMLInputElement).value);
updatePicker(picker, (e.target as HTMLInputElement).value);
});
createTippy(input, {
@@ -62,13 +61,13 @@ function initPicker(el: HTMLElement): void {
input.dispatchEvent(new Event('input', {bubbles: true}));
updateSquare(square, color);
};
el.querySelector('.generate-random-color').addEventListener('click', () => {
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: DOMEvent<MouseEvent, HTMLAnchorElement>) => {
const newValue = e.target.getAttribute('data-color-hex');
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);
}
@@ -2,6 +2,7 @@ import {POST} from '../modules/fetch.ts';
import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.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) => {
@@ -27,7 +28,7 @@ export function initGlobalDeleteButton(): void {
const dataObj = btn.dataset;
const modalId = btn.getAttribute('data-modal-id');
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`);
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`)!;
// set the modal "display name" by `data-name`
const modalNameEl = modal.querySelector('.name');
@@ -37,7 +38,7 @@ export function initGlobalDeleteButton(): void {
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) {
const textEl = modal.querySelector(`.${key}`);
if (textEl) textEl.textContent = value;
if (textEl) textEl.textContent = value ?? null;
}
}
@@ -46,7 +47,7 @@ export function initGlobalDeleteButton(): void {
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 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
@@ -59,14 +60,14 @@ export function initGlobalDeleteButton(): void {
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), value);
postData.append(key.slice(4), String(value));
}
if (key === 'id') { // for data-id="..."
postData.append('id', value);
postData.append('id', String(value));
}
}
(async () => {
const response = await POST(btn.getAttribute('data-url'), {data: postData});
const response = await POST(btn.getAttribute('data-url')!, {data: postData});
if (response.ok) {
const data = await response.json();
window.location.href = data.redirect;
@@ -84,11 +85,11 @@ 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 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)) {
elem.querySelector<HTMLElement>('[autofocus]')?.focus();
applyAutoFocus(elem);
}
}
}
@@ -103,7 +104,7 @@ function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
}
sel = el.getAttribute('data-panel-closest');
if (sel) {
hideElem((el.parentNode as HTMLElement).closest(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
@@ -141,7 +142,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
// * 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 modalSelector = el.getAttribute('data-modal')!;
const elModal = document.querySelector(modalSelector);
if (!elModal) throw new Error('no modal for this action');
@@ -67,10 +67,15 @@ async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: R
async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) {
e.preventDefault();
await submitFormFetchAction(formEl, submitEventSubmitter(e));
await submitFormFetchAction(formEl, {formSubmitter: submitEventSubmitter(e)});
}
export async function submitFormFetchAction(formEl: HTMLFormElement, formSubmitter?: HTMLElement) {
type SubmitFormFetchActionOpts = {
formSubmitter?: HTMLElement;
formData?: FormData;
};
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
if (formEl.classList.contains('is-loading')) return;
formEl.classList.add('is-loading');
@@ -80,8 +85,8 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, formSubmitt
const formMethod = formEl.getAttribute('method') || 'get';
const formActionUrl = formEl.getAttribute('action') || window.location.href;
const formData = new FormData(formEl);
const [submitterName, submitterValue] = [formSubmitter?.getAttribute('name'), formSubmitter?.getAttribute('value')];
const formData = opts.formData ?? new FormData(formEl);
const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')];
if (submitterName) {
formData.append(submitterName, submitterValue || '');
}
@@ -94,7 +99,7 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, formSubmitt
if (formMethod.toLowerCase() === 'get') {
const params = new URLSearchParams();
for (const [key, value] of formData) {
params.append(key, value.toString());
params.append(key, value as string);
}
const pos = reqUrl.indexOf('?');
if (pos !== -1) {
@@ -114,7 +119,7 @@ async function onLinkActionClick(el: HTMLElement, e: Event) {
// If the "link-action" has "data-modal-confirm" attribute, a "confirm modal dialog" will be shown before taking action.
// Attribute "data-modal-confirm" can be a modal element by "#the-modal-id", or a string content for the modal dialog.
e.preventDefault();
const url = el.getAttribute('data-url');
const url = el.getAttribute('data-url')!;
const doRequest = async () => {
if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but "A" doesn't have the "disabled" attribute
await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'});
@@ -1,6 +1,6 @@
import {applyAreYouSure, initAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.ts';
import {queryElems, type DOMEvent} from '../utils/dom.ts';
import {queryElems} from '../utils/dom.ts';
import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
export function initGlobalFormDirtyLeaveConfirm() {
@@ -13,14 +13,15 @@ export function initGlobalFormDirtyLeaveConfirm() {
}
export function initGlobalEnterQuickSubmit() {
document.addEventListener('keydown', (e: DOMEvent<KeyboardEvent>) => {
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.matches('textarea')) {
if (hasCtrlOrMeta && (e.target as HTMLElement).matches('textarea')) {
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
e.preventDefault();
}
} else if (e.target.matches('input') && !e.target.closest('form')) {
} 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)) {
@@ -5,7 +5,7 @@ test('parseIssueListQuickGotoLink', () => {
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('');
expect(parseIssueListQuickGotoLink('/link', 'owner/repo#123')).toEqual('/owner/repo/issues/123');
expect(parseIssueListQuickGotoLink('', '')).toEqual('');
expect(parseIssueListQuickGotoLink('', 'abc')).toEqual('');
@@ -1,4 +1,4 @@
import {isElemVisible, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.ts';
import {onInputDebounce, toggleElem} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
const {appSubUrl} = window.config;
@@ -17,34 +17,25 @@ export function parseIssueListQuickGotoLink(repoLink: string, searchText: string
} else if (reIssueSharpIndex.test(searchText)) {
targetUrl = `${repoLink}/issues/${searchText.substring(1)}`;
}
} else {
// 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}`;
}
}
// 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 goto = document.querySelector<HTMLElement>('#issue-list-quick-goto');
if (!goto) return;
const elGotoButton = document.querySelector<HTMLElement>('#issue-list-quick-goto');
if (!elGotoButton) return;
const form = goto.closest('form');
const input = form.querySelector<HTMLInputElement>('input[name=q]');
const repoLink = goto.getAttribute('data-repo-link');
const form = elGotoButton.closest('form')!;
const input = form.querySelector<HTMLInputElement>('input[name=q]')!;
const repoLink = elGotoButton.getAttribute('data-repo-link') || '';
form.addEventListener('submit', (e) => {
// if there is no goto button, or the form is submitted by non-quick-goto elements, submit the form directly
let doQuickGoto = isElemVisible(goto);
const submitter = submitEventSubmitter(e);
if (submitter !== form && submitter !== input && submitter !== goto) doQuickGoto = false;
if (!doQuickGoto) return;
// if there is a goto button, use its link
e.preventDefault();
window.location.href = goto.getAttribute('data-issue-goto-link');
elGotoButton.addEventListener('click', () => {
window.location.href = elGotoButton.getAttribute('data-issue-goto-link')!;
});
const onInput = async () => {
@@ -58,8 +49,8 @@ export function initCommonIssueListQuickGoto() {
// if the input value has changed, then ignore the result
if (input.value !== searchText) return;
toggleElem(goto, Boolean(targetUrl));
goto.setAttribute('data-issue-goto-link', targetUrl);
toggleElem(elGotoButton, Boolean(targetUrl));
elGotoButton.setAttribute('data-issue-goto-link', targetUrl);
};
input.addEventListener('input', onInputDebounce(onInput));
@@ -7,7 +7,7 @@ export function initCommonOrganization() {
}
document.querySelector<HTMLInputElement>('.organization.settings.options #org_name')?.addEventListener('input', function () {
const nameChanged = this.value.toLowerCase() !== this.getAttribute('data-org-name').toLowerCase();
const nameChanged = this.value.toLowerCase() !== this.getAttribute('data-org-name')!.toLowerCase();
toggleElem('#org-name-change-prompt', nameChanged);
});
@@ -1,14 +1,14 @@
import {GET} from '../modules/fetch.ts';
import {showGlobalErrorMessage} from '../bootstrap.ts';
import {GET, POST} from '../modules/fetch.ts';
import {showGlobalErrorMessage} from '../modules/errors.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {queryElems} from '../utils/dom.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} = window.config;
const {appUrl, appSubUrl} = window.config;
export function initHeadNavbarContentToggle() {
function initHeadNavbarContentToggle() {
const navbar = document.querySelector('#navbar');
const btn = document.querySelector('#navbar-expand-toggle');
if (!navbar || !btn) return;
@@ -20,16 +20,37 @@ export function initHeadNavbarContentToggle() {
});
}
export function initFootLanguageMenu() {
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'));
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) => {
@@ -95,12 +116,30 @@ function attachInputDirAuto(el: Partial<HTMLInputElement | HTMLTextAreaElement>)
}
}
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);
registerGlobalInitFunc('initInputAutoFocusEnd', (el: HTMLInputElement) => {
el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus.
el.setSelectionRange(el.value.length, el.value.length);
});
// 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);
}
/**
@@ -117,8 +156,8 @@ export function checkAppUrl() {
if (curUrl.startsWith(appUrl) || `${curUrl}/` === appUrl) {
return;
}
showGlobalErrorMessage(`Your ROOT_URL in app.ini is "${appUrl}", it's unlikely matching the site you are visiting.
Mismatched ROOT_URL config causes wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`, 'warning');
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() {
@@ -10,6 +10,7 @@ import {
} 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';
@@ -17,13 +18,14 @@ import {POST} from '../../modules/fetch.ts';
import {
EventEditorContentChanged,
initTextareaMarkdown,
textareaInsertText,
replaceTextareaSelection,
triggerEditorContentChanged,
} from './EditorMarkdown.ts';
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
import {createTippy} from '../../modules/tippy.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import type EasyMDE from 'easymde';
import {localUserSettings} from '../../modules/user-settings.ts';
/**
* validate if the given textarea is non-empty.
@@ -81,7 +83,9 @@ export class ComboMarkdownEditor {
textareaMarkdownToolbar: HTMLElement;
textareaAutosize: any;
dropzone: HTMLElement;
buttonMonospace: HTMLButtonElement;
dropzone: HTMLElement | null;
attachedDropzoneInst: any;
previewMode: string;
@@ -105,7 +109,7 @@ export class ComboMarkdownEditor {
await this.switchToUserPreference();
}
applyEditorHeights(el: HTMLElement, heights: Heights) {
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;
@@ -114,14 +118,14 @@ export class ComboMarkdownEditor {
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'));
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 = this.container.querySelector('.markdown-text-editor')!;
this.textarea._giteaComboMarkdownEditor = this;
this.textarea.id = generateElemId(`_combo_markdown_editor_`);
this.textarea.addEventListener('input', () => triggerEditorContentChanged(this.container));
@@ -131,7 +135,7 @@ export class ComboMarkdownEditor {
this.textareaAutosize = autosize(this.textarea, {viewportMarginBottom: 130});
}
this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar');
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
@@ -140,23 +144,17 @@ export class ComboMarkdownEditor {
if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
}
const monospaceButton = this.container.querySelector('.markdown-switch-monospace');
const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true';
const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text');
monospaceButton.setAttribute('data-tooltip-content', monospaceText);
monospaceButton.setAttribute('aria-checked', String(monospaceEnabled));
monospaceButton.addEventListener('click', (e) => {
this.buttonMonospace = this.container.querySelector('.markdown-switch-monospace')!;
this.applyMonospace();
this.buttonMonospace.addEventListener('click', (e) => {
e.preventDefault();
const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true';
localStorage.setItem('markdown-editor-monospace', String(enabled));
this.textarea.classList.toggle('tw-font-mono', enabled);
const text = monospaceButton.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text');
monospaceButton.setAttribute('data-tooltip-content', text);
monospaceButton.setAttribute('aria-checked', String(enabled));
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');
const easymdeButton = this.container.querySelector('.markdown-switch-easymde')!;
easymdeButton.addEventListener('click', async (e) => {
e.preventDefault();
this.userPreferredEditor = 'easymde';
@@ -173,7 +171,7 @@ export class ComboMarkdownEditor {
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');
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);
@@ -212,13 +210,14 @@ export class ComboMarkdownEditor {
// 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 = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer');
this.tabPreviewer = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer');
const tabsArr = Array.from(tabs);
this.tabEditor = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer')!;
this.tabPreviewer = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer')!;
this.tabEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
this.tabPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]');
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]');
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
panelEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
panelPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
@@ -238,6 +237,20 @@ export class ComboMarkdownEditor {
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);
});
}
});
}
@@ -254,8 +267,8 @@ export class ComboMarkdownEditor {
}
initMarkdownButtonTableAdd() {
const addTableButton = this.container.querySelector('.markdown-button-table-add');
const addTablePanel = this.container.querySelector('.markdown-add-table-panel');
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,
@@ -267,12 +280,12 @@ export class ComboMarkdownEditor {
});
addTableButton.addEventListener('click', () => addTablePanelTippy.show());
addTablePanel.querySelector('.ui.button.primary').addEventListener('click', () => {
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=rows]').value);
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]').value);
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));
textareaInsertText(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
replaceTextareaSelection(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
addTablePanelTippy.hide();
});
}
@@ -320,8 +333,10 @@ export class ComboMarkdownEditor {
async switchToEasyMDE() {
if (this.easyMDE) return;
// EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles.
const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde');
const [{default: EasyMDE}] = await Promise.all([
import('easymde'),
import('../../../css/easymde.css'),
]);
const easyMDEOpt: EasyMDE.Options = {
autoDownloadFontAwesome: false,
element: this.textarea,
@@ -360,7 +375,7 @@ export class ComboMarkdownEditor {
}
},
});
this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll')!, this.options.editorHeights);
await attachTribute(this.easyMDE.codemirror.getInputField());
if (this.dropzone) {
initEasyMDEPaste(this.easyMDE, this.dropzone);
@@ -368,7 +383,7 @@ export class ComboMarkdownEditor {
hideElem(this.textareaMarkdownToolbar);
}
value(v: any = undefined) {
value(v?: any) {
if (v === undefined) {
if (this.easyMDE) {
return this.easyMDE.value();
@@ -401,15 +416,32 @@ export class ComboMarkdownEditor {
}
}
get userPreferredEditor() {
return window.localStorage.getItem(`markdown-editor-${this.previewMode ?? 'default'}`);
get userPreferredEditor(): string {
return localUserSettings.getString(`markdown-editor-${this.previewMode ?? 'default'}`);
}
set userPreferredEditor(s) {
window.localStorage.setItem(`markdown-editor-${this.previewMode ?? 'default'}`, s);
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));
}
}
export function getComboMarkdownEditor(el: any) {
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;
@@ -2,6 +2,7 @@ import {svg} from '../../svg.ts';
import {html, htmlRaw} from '../../utils/html.ts';
import {createElementFromHTML} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {hideToastsAll} from '../../modules/toast.ts';
const {i18n} = window.config;
@@ -27,6 +28,9 @@ export function createConfirmModal({header = '', content = '', confirmButtonColo
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) => {
const $modal = fomanticQuery(modal);
$modal.modal({
@@ -1,4 +1,4 @@
import {showElem, type DOMEvent} from '../../utils/dom.ts';
import {showElem} from '../../utils/dom.ts';
type CropperOpts = {
container: HTMLElement,
@@ -7,7 +7,7 @@ type CropperOpts = {
};
async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
const {default: Cropper} = await import('cropperjs');
let currentFileName = '';
let currentFileLastModified = 0;
const cropper = new Cropper(imageSource, {
@@ -17,6 +17,7 @@ async function initCompCropper({container, fileInput, imageSource}: CropperOpts)
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();
@@ -26,9 +27,9 @@ async function initCompCropper({container, fileInput, imageSource}: CropperOpts)
},
});
fileInput.addEventListener('input', (e: DOMEvent<Event, HTMLInputElement>) => {
const files = e.target.files;
if (files?.length > 0) {
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]);
@@ -42,6 +43,6 @@ async function initCompCropper({container, fileInput, imageSource}: CropperOpts)
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');
const imageSource = panel.querySelector<HTMLImageElement>('.cropper-source')!;
await initCompCropper({container: panel, fileInput, imageSource});
}
@@ -24,15 +24,15 @@ test('textareaSplitLines', () => {
});
test('markdownHandleIndention', () => {
const testInput = (input: string, expected?: string) => {
const testInput = (input: string, expected: string | null) => {
const inputPos = input.indexOf('|');
input = input.replace('|', '');
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.replace('|', '');
expected = expected.replaceAll('|', '');
expect(ret).toEqual({
handled: true,
valueSelection: {value: expected, selStart: expectedPos, selEnd: expectedPos},
@@ -171,9 +171,9 @@ test('EditorMarkdown', () => {
pos: number;
};
const testInput = (input: ValueWithCursor, result: ValueWithCursor) => {
const intputValue = typeof input === 'string' ? input : input.value;
const inputPos = typeof input === 'string' ? intputValue.length : input.pos;
textarea.value = intputValue;
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});
@@ -4,14 +4,23 @@ export function triggerEditorContentChanged(target: HTMLElement) {
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
}
export function textareaInsertText(textarea: HTMLTextAreaElement, value: string) {
const startPos = textarea.selectionStart;
const endPos = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos);
textarea.selectionStart = startPos;
textarea.selectionEnd = startPos + value.length;
/** 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();
triggerEditorContentChanged(textarea);
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 = {
@@ -45,7 +54,8 @@ function handleIndentSelection(textarea: HTMLTextAreaElement, e: KeyboardEvent)
}
// re-calculating the selection range
let newSelStart, newSelEnd;
let newSelStart: number | null = null;
let newSelEnd: number | null = null;
pos = 0;
for (let i = 0; i < lines.length; i++) {
if (i === selectedLines[0]) {
@@ -134,7 +144,7 @@ export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHa
// parse the indention
let lineContent = line;
const indention = /^\s*/.exec(lineContent)[0];
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
@@ -175,22 +185,45 @@ export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHa
return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
}
function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
if ((e as KeyboardEvent).isComposing) return;
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) return;
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
@@ -198,6 +231,8 @@ export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
} 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);
}
});
}
@@ -1,5 +1,5 @@
import {imageInfo} from '../../utils/image.ts';
import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts';
import {replaceTextareaSelection, triggerEditorContentChanged} from './EditorMarkdown.ts';
import {
DropzoneCustomEventRemovedFile,
DropzoneCustomEventUploadDone,
@@ -43,7 +43,7 @@ class TextareaEditor {
}
insertPlaceholder(value: string) {
textareaInsertText(this.editor, value);
replaceTextareaSelection(this.editor, value);
}
replacePlaceholder(oldVal: string, newVal: string) {
@@ -121,7 +121,10 @@ function getPastedImages(e: ClipboardEvent) {
const images: Array<File> = [];
for (const item of e.clipboardData?.items ?? []) {
if (item.type?.startsWith('image/')) {
images.push(item.getAsFile());
const file = item.getAsFile();
if (file) {
images.push(file);
}
}
}
return images;
@@ -135,7 +138,7 @@ export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
handleUploadFiles(editor, dropzoneEl, images, e);
});
easyMDE.codemirror.on('drop', (_, e) => {
if (!e.dataTransfer.files.length) return;
if (!e.dataTransfer?.files.length) return;
handleUploadFiles(editor, dropzoneEl, e.dataTransfer.files, e);
});
dropzoneEl.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => {
@@ -145,7 +148,7 @@ export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
});
}
export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement) {
export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement | null) {
subscribe(textarea); // enable paste features
textarea.addEventListener('paste', (e: ClipboardEvent) => {
const images = getPastedImages(e);
@@ -154,7 +157,7 @@ export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HT
}
});
textarea.addEventListener('drop', (e: DragEvent) => {
if (!e.dataTransfer.files.length) return;
if (!e.dataTransfer?.files.length) return;
if (!dropzoneEl) return;
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, e.dataTransfer.files, e);
});
@@ -14,17 +14,17 @@ export function initCompLabelEdit(pageSelector: string) {
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 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);
@@ -37,13 +37,13 @@ export function initCompLabelEdit(pageSelector: string) {
if (parseInt(elExclusiveOrderInput.value) <= 0) {
elExclusiveOrderInput.style.color = 'var(--color-placeholder-text) !important';
} else {
elExclusiveOrderInput.style.color = null;
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');
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';
@@ -59,7 +59,7 @@ export function initCompLabelEdit(pageSelector: string) {
// 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');
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`;
@@ -1,18 +1,17 @@
import {POST} from '../../modules/fetch.ts';
import type {DOMEvent} from '../../utils/dom.ts';
import {registerGlobalEventFunc} from '../../modules/observer.ts';
export function initCompReactionSelector() {
registerGlobalEventFunc('click', 'onCommentReactionButtonClick', async (target: HTMLElement, e: DOMEvent<MouseEvent>) => {
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 actionUrl = target.closest('[data-action-url]')!.getAttribute('data-action-url');
const reactionContent = target.getAttribute('data-reaction-content')!;
const commentContainer = target.closest('.comment-container');
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)}"]`);
@@ -5,10 +5,15 @@ const {appSubUrl} = window.config;
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`;
}
fomanticQuery(el).search({
minCharacters: 2,
apiSettings: {
url: `${appSubUrl}/repo/search?q={query}&uid=${uid}`,
url,
onResponse(response: any) {
const items = [];
for (const item of response.data) {
@@ -10,13 +10,14 @@ export function initCompSearchUserBox() {
const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true';
const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined;
const includeOrgs = searchUserBox.getAttribute('data-include-orgs') === 'true';
fomanticQuery(searchUserBox).search({
minCharacters: 2,
apiSettings: {
url: `${appSubUrl}/user/search_candidates?q={query}`,
url: `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`,
onResponse(response: any) {
const resultItems = [];
const searchQuery = searchUserBox.querySelector('input').value;
const searchQuery = searchUserBox.querySelector('input')!.value;
const searchQueryUppercase = searchQuery.toUpperCase();
for (const item of response.data) {
const resultItem = {
@@ -3,7 +3,7 @@ import {emojiString} from '../emoji.ts';
import {svg} from '../../svg.ts';
import {parseIssueHref, parseRepoOwnerPathInfo} from '../../utils.ts';
import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
import {getIssueColor, getIssueIcon} from '../issue.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';
@@ -25,7 +25,7 @@ async function fetchIssueSuggestions(key: string, text: string): Promise<TextExp
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, ['text', getIssueColor(issue)])),
createElementFromHTML(svg(getIssueIcon(issue), 16, [getIssueColorClass(issue)])),
createElementFromAttrs('span', null, `#${issue.number}`),
createElementFromAttrs('span', null, issue.title),
);
@@ -37,7 +37,8 @@ async function fetchIssueSuggestions(key: string, text: string): Promise<TextExp
export function initTextExpander(expander: TextExpanderElement) {
if (!expander) return;
const textarea = expander.querySelector<HTMLTextAreaElement>('textarea');
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 = () => {
@@ -64,6 +65,7 @@ export function initTextExpander(expander: TextExpanderElement) {
}, 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);
@@ -82,36 +84,39 @@ export function initTextExpander(expander: TextExpanderElement) {
provide({matched: true, fragment: ul});
} else if (key === '@') {
const matches = matchMention(text);
if (!matches.length) return provide({matched: false});
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 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 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);
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);
if (fullname && fullname.toLowerCase() !== name) {
const fullnameSpan = document.createElement('span');
fullnameSpan.classList.add('fullname');
fullnameSpan.textContent = fullname;
li.append(fullnameSpan);
}
ul.append(li);
}
ul.append(li);
}
provide({matched: true, fragment: ul});
return {matched: true, fragment: ul};
})());
} else if (key === '#') {
provide(debouncedIssueSuggestions(key, text));
}
@@ -27,7 +27,7 @@ export function initCompWebHookEditor() {
if (httpMethodInput) {
const updateContentType = function () {
const visible = httpMethodInput.value === 'POST';
toggleElem(document.querySelector('#content_type').closest('.field'), visible);
toggleElem(document.querySelector('#content_type')!.closest('.field')!, visible);
};
updateContentType();
httpMethodInput.addEventListener('change', updateContentType);
@@ -36,9 +36,12 @@ export function initCompWebHookEditor() {
// Test delivery
document.querySelector<HTMLButtonElement>('#test-delivery')?.addEventListener('click', async function () {
this.classList.add('is-loading', 'disabled');
await POST(this.getAttribute('data-link'));
await POST(this.getAttribute('data-link')!);
setTimeout(() => {
window.location.href = this.getAttribute('data-redirect');
const redirectUrl = this.getAttribute('data-redirect');
if (redirectUrl) {
window.location.href = redirectUrl;
}
}, 5000);
});
}
@@ -4,7 +4,7 @@ export async function initRepoContributors() {
const el = document.querySelector('#repo-contributors-chart');
if (!el) return;
const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue');
const {default: RepoContributors} = await import('../components/RepoContributors.vue');
try {
const View = createApp(RepoContributors, {
repoLink: el.getAttribute('data-repo-link'),
@@ -20,7 +20,7 @@ export function initCopyContent() {
btn.classList.add('is-loading', 'loading-icon-2px');
try {
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
const contentType = res.headers.get('content-type');
const contentType = res.headers.get('content-type')!;
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
isRasterImage = true;
@@ -8,7 +8,7 @@ import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
import {isImageFile, isVideoFile} from '../utils.ts';
import type {DropzoneFile, DropzoneOptions} from 'dropzone/index.js';
const {csrfToken, i18n} = window.config;
const {i18n} = window.config;
type CustomDropzoneFile = DropzoneFile & {uuid: string};
@@ -19,8 +19,8 @@ export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done';
async function createDropzone(el: HTMLElement, opts: DropzoneOptions) {
const [{default: Dropzone}] = await Promise.all([
import(/* webpackChunkName: "dropzone" */'dropzone'),
import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'),
import('dropzone'),
import('dropzone/dist/dropzone.css'),
]);
return new Dropzone(el, opts);
}
@@ -28,8 +28,7 @@ async function createDropzone(el: HTMLElement, opts: DropzoneOptions) {
export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFile>, {width, dppx}: {width?: number, dppx?: number} = {}) {
let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
if (isImageFile(file)) {
fileMarkdown = `!${fileMarkdown}`;
if (width > 0 && dppx > 1) {
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}"
@@ -57,7 +56,7 @@ function addCopyLink(file: Partial<CustomDropzoneFile>) {
const success = await clippie(generateMarkdownLinkForAttachment(file));
showTemporaryTooltip(e.target as Element, success ? i18n.copy_success : i18n.copy_error);
});
file.previewTemplate.append(copyLinkEl);
file.previewTemplate!.append(copyLinkEl);
}
type FileUuidDict = Record<string, {submitted: boolean}>;
@@ -67,15 +66,14 @@ type FileUuidDict = Record<string, {submitted: boolean}>;
*/
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');
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'),
headers: {'X-Csrf-Token': csrfToken},
acceptedFiles: ['*/*', ''].includes(dropzoneEl.getAttribute('data-accepts')) ? null : dropzoneEl.getAttribute('data-accepts'),
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'),
@@ -97,7 +95,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
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);
dropzoneEl.querySelector('.files')!.append(input);
addCopyLink(file);
dzInst.emit(DropzoneCustomEventUploadDone, {file});
});
@@ -121,6 +119,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
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
@@ -128,7 +127,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
dzInst.removeAllFiles(true);
disableRemovedfileEvent = false;
dropzoneEl.querySelector('.files').innerHTML = '';
dropzoneEl.querySelector('.files')!.innerHTML = '';
for (const el of dropzoneEl.querySelectorAll('.dz-preview')) el.remove();
fileUuidDict = {};
for (const attachment of respData) {
@@ -142,7 +141,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
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);
dropzoneEl.querySelector('.files')!.append(input);
}
if (!dropzoneEl.querySelector('.dz-preview')) {
dropzoneEl.classList.remove('dz-started');
@@ -1,29 +1,19 @@
import type {FileRenderPlugin} from '../render/plugin.ts';
import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts';
import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts';
import type {InplaceRenderPlugin} from '../render/plugin.ts';
import {newInplacePluginPdfViewer} from '../render/plugins/inplace-pdf-viewer.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {createElementFromHTML, showElem, toggleElemClass} from '../utils/dom.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
import {basename} from '../utils.ts';
const plugins: FileRenderPlugin[] = [];
const inplacePlugins: InplaceRenderPlugin[] = [];
function initPluginsOnce(): void {
if (plugins.length) return;
plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer());
function initInplacePluginsOnce(): void {
if (inplacePlugins.length) return;
inplacePlugins.push(newInplacePluginPdfViewer());
}
function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null {
return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
}
function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void {
const toggleButtons = elFileView.querySelector('.file-view-toggle-buttons');
showElem(toggleButtons);
const displayingRendered = Boolean(renderContainer);
toggleElemClass(toggleButtons.querySelectorAll('.file-view-toggle-source'), 'active', !displayingRendered); // it may not exist
toggleElemClass(toggleButtons.querySelector('.file-view-toggle-rendered'), 'active', displayingRendered);
// TODO: if there is only one button, hide it?
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) {
@@ -32,7 +22,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
let rendered = false, errorMsg = '';
try {
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
const plugin = findInplaceRenderPlugin(basename(rawFileLink), mimeType);
if (plugin) {
container.classList.add('is-loading');
container.setAttribute('data-render-name', plugin.name); // not used yet
@@ -61,16 +51,13 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
export function initRepoFileView(): void {
registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => {
initPluginsOnce();
const rawFileLink = elFileView.getAttribute('data-raw-file-link');
initInplacePluginsOnce();
const rawFileLink = elFileView.getAttribute('data-raw-file-link')!;
const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet
// TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
const plugin = findInplaceRenderPlugin(basename(rawFileLink), mimeType);
if (!plugin) return;
const renderContainer = elFileView.querySelector<HTMLElement>('.file-view-render-container');
showRenderRawFileButton(elFileView, renderContainer);
// maybe in the future multiple plugins can render the same file, so we should not assume only one plugin will render it
if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType);
});
}
@@ -1,14 +1,24 @@
import {createApp} from 'vue';
import ActivityHeatmap from '../components/ActivityHeatmap.vue';
import {translateMonth, translateDay} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
export function initHeatmap() {
const el = document.querySelector('#user-heatmap');
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 {contributions, timestamp} of JSON.parse(el.getAttribute('data-heatmap-data'))) {
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;
@@ -18,6 +28,9 @@ export function initHeatmap() {
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: {
@@ -28,10 +41,11 @@ export function initHeatmap() {
less: el.getAttribute('data-locale-less'),
},
tooltipUnit: 'contributions',
textTotalContributions: el.getAttribute('data-locale-total-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');
@@ -3,7 +3,33 @@ import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts
import {parseDom} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
function getDefaultSvgBoundsIfUndefined(text: string, src: string) {
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;
@@ -27,7 +53,7 @@ function getDefaultSvgBoundsIfUndefined(text: string, src: string) {
const viewBox = svg.viewBox.baseVal;
return {
width: defaultSize,
height: defaultSize * viewBox.width / viewBox.height,
height: defaultSize * viewBox.height / viewBox.width,
};
}
return {
@@ -38,7 +64,7 @@ function getDefaultSvgBoundsIfUndefined(text: string, src: string) {
return null;
}
function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageElement, svgBoundsInfo: any) {
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,
@@ -78,9 +104,9 @@ class ImageDiff {
fomanticQuery(containerEl).find('.ui.menu.tabular .item').tab();
// the container may be hidden by "viewed" checkbox, so use the parent's width for reference
this.diffContainerWidth = Math.max(containerEl.closest('.diff-file-box').clientWidth - 300, 100);
this.diffContainerWidth = Math.max(containerEl.closest('.diff-file-box')!.clientWidth - 300, 100);
const imageInfos = [{
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>
@@ -92,26 +118,26 @@ class ImageDiff {
boundsInfo: containerEl.querySelector('.bounds-info-before'),
}];
const svgBoundsInfo: any = {before: null, after: null};
await Promise.all(imageInfos.map(async (info, index) => {
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);
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 resp = await GET(info.path!);
const text = await resp.text();
const bounds = getDefaultSvgBoundsIfUndefined(text, info.path);
const bounds = getDefaultSvgBoundsIfUndefined(text, info.path!);
svgBoundsInfo[index === 0 ? 'after' : 'before'] = bounds;
if (bounds) {
hideElem(info.boundsInfo);
hideElem(info.boundsInfo!);
}
}
}));
const imagesAfter = imageInfos[0].images;
const imagesBefore = imageInfos[1].images;
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) {
@@ -121,97 +147,97 @@ class ImageDiff {
queryElemChildren(containerEl, '.image-diff-tabs', (el) => el.classList.remove('is-loading'));
}
initSideBySide(sizes: Record<string, any>) {
initSideBySide(ctx: ImageContext) {
let factor = 1;
if (sizes.maxSize.width > (this.diffContainerWidth - 24) / 2) {
factor = (this.diffContainerWidth - 24) / 2 / sizes.maxSize.width;
if (ctx.maxSize.width > (this.diffContainerWidth - 24) / 2) {
factor = (this.diffContainerWidth - 24) / 2 / ctx.maxSize.width;
}
const widthChanged = sizes.imageAfter && sizes.imageBefore && sizes.imageAfter.naturalWidth !== sizes.imageBefore.naturalWidth;
const heightChanged = sizes.imageAfter && sizes.imageBefore && sizes.imageAfter.naturalHeight !== sizes.imageBefore.naturalHeight;
if (sizes.imageAfter) {
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 = `${sizes.imageAfter.naturalWidth}px`;
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 = `${sizes.imageAfter.naturalHeight}px`;
boundsInfoAfterHeight.textContent = `${ctx.imageAfter.naturalHeight}px`;
boundsInfoAfterHeight.classList.toggle('green', heightChanged);
}
}
if (sizes.imageBefore) {
if (ctx.imageBefore) {
const boundsInfoBeforeWidth = this.containerEl.querySelector('.bounds-info-before .bounds-info-width');
if (boundsInfoBeforeWidth) {
boundsInfoBeforeWidth.textContent = `${sizes.imageBefore.naturalWidth}px`;
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 = `${sizes.imageBefore.naturalHeight}px`;
boundsInfoBeforeHeight.textContent = `${ctx.imageBefore.naturalHeight}px`;
boundsInfoBeforeHeight.classList.toggle('red', heightChanged);
}
}
if (sizes.imageAfter) {
const container = sizes.imageAfter.parentNode;
sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`;
sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`;
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 = `${sizes.sizeAfter.width * factor + 2}px`;
container.style.height = `${sizes.sizeAfter.height * factor + 2}px`;
container.style.width = `${ctx.sizeAfter.width * factor + 2}px`;
container.style.height = `${ctx.sizeAfter.height * factor + 2}px`;
}
if (sizes.imageBefore) {
const container = sizes.imageBefore.parentNode;
sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`;
sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}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 = `${sizes.sizeBefore.width * factor + 2}px`;
container.style.height = `${sizes.sizeBefore.height * factor + 2}px`;
container.style.width = `${ctx.sizeBefore.width * factor + 2}px`;
container.style.height = `${ctx.sizeBefore.height * factor + 2}px`;
}
}
initSwipe(sizes: Record<string, any>) {
initSwipe(ctx: ImageContext) {
let factor = 1;
if (sizes.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / sizes.maxSize.width;
if (ctx.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / ctx.maxSize.width;
}
if (sizes.imageAfter) {
const imgParent = sizes.imageAfter.parentNode;
const swipeFrame = imgParent.parentNode;
sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`;
sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`;
imgParent.style.margin = `0px ${sizes.ratio[0] * factor}px`;
imgParent.style.width = `${sizes.sizeAfter.width * factor + 2}px`;
imgParent.style.height = `${sizes.sizeAfter.height * factor + 2}px`;
swipeFrame.style.padding = `${sizes.ratio[1] * factor}px 0 0 0`;
swipeFrame.style.width = `${sizes.maxSize.width * factor + 2}px`;
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 (sizes.imageBefore) {
const imgParent = sizes.imageBefore.parentNode;
const swipeFrame = imgParent.parentNode;
sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`;
sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`;
imgParent.style.margin = `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`;
imgParent.style.width = `${sizes.sizeBefore.width * factor + 2}px`;
imgParent.style.height = `${sizes.sizeBefore.height * factor + 2}px`;
swipeFrame.style.width = `${sizes.maxSize.width * factor + 2}px`;
swipeFrame.style.height = `${sizes.maxSize.height * 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 = `${sizes.maxSize.width * factor + 2}px`;
swipe.style.height = `${sizes.maxSize.height * factor + 30}px`;
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) => {
this.containerEl.querySelector('.swipe-bar')!.addEventListener('mousedown', (e) => {
e.preventDefault();
this.initSwipeEventListeners(e.currentTarget as HTMLElement);
});
@@ -225,7 +251,7 @@ class ImageDiff {
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`;
this.containerEl.querySelector<HTMLElement>('.swipe-container')!.style.width = `${swipeFrame.clientWidth - value}px`;
};
const removeEventListeners = () => {
document.removeEventListener('mousemove', onSwipeMouseMove);
@@ -235,40 +261,40 @@ class ImageDiff {
document.addEventListener('mouseup', removeEventListeners);
}
initOverlay(sizes: Record<string, any>) {
initOverlay(ctx: ImageContext) {
let factor = 1;
if (sizes.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / sizes.maxSize.width;
if (ctx.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / ctx.maxSize.width;
}
if (sizes.imageAfter) {
const container = sizes.imageAfter.parentNode;
sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`;
sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`;
container.style.margin = `${sizes.ratio[1] * factor}px ${sizes.ratio[0] * factor}px`;
container.style.width = `${sizes.sizeAfter.width * factor + 2}px`;
container.style.height = `${sizes.sizeAfter.height * factor + 2}px`;
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 (sizes.imageBefore) {
const container = sizes.imageBefore.parentNode;
const overlayFrame = container.parentNode;
sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`;
sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`;
container.style.margin = `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`;
container.style.width = `${sizes.sizeBefore.width * factor + 2}px`;
container.style.height = `${sizes.sizeBefore.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 = `${sizes.maxSize.width * factor + 2}px`;
overlayFrame.style.height = `${sizes.maxSize.height * factor + 2}px`;
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"]');
const rangeInput = this.containerEl.querySelector<HTMLInputElement>('input[type="range"]')!;
function updateOpacity() {
if (sizes.imageAfter) {
sizes.imageAfter.parentNode.style.opacity = `${Number(rangeInput.value) / 100}`;
if (ctx.imageAfter) {
(ctx.imageAfter.parentNode as HTMLElement).style.opacity = `${Number(rangeInput.value) / 100}`;
}
}
@@ -23,12 +23,12 @@ function initPreInstall() {
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');
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 () {
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}]`);
@@ -47,58 +47,39 @@ function initPreInstall() {
}
} // else: for SQLite3, the default path is always prepared by backend code (setting)
});
document.querySelector('#db_type').dispatchEvent(new Event('change'));
document.querySelector('#db_type')!.dispatchEvent(new Event('change'));
const appUrl = document.querySelector<HTMLInputElement>('#app_url');
const appUrl = document.querySelector<HTMLInputElement>('#app_url')!;
if (appUrl.value.includes('://localhost')) {
appUrl.value = window.location.href;
}
const domain = document.querySelector<HTMLInputElement>('#domain');
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>('#offline-mode input').addEventListener('change', function () {
document.querySelector<HTMLInputElement>('#enable-openid-signin input')!.addEventListener('change', function () {
if (this.checked) {
document.querySelector<HTMLInputElement>('#disable-gravatar input').checked = true;
document.querySelector<HTMLInputElement>('#federated-avatar-lookup input').checked = false;
}
});
document.querySelector<HTMLInputElement>('#disable-gravatar input').addEventListener('change', function () {
if (this.checked) {
document.querySelector<HTMLInputElement>('#federated-avatar-lookup input').checked = false;
} else {
document.querySelector<HTMLInputElement>('#offline-mode input').checked = false;
}
});
document.querySelector<HTMLInputElement>('#federated-avatar-lookup input').addEventListener('change', function () {
if (this.checked) {
document.querySelector<HTMLInputElement>('#disable-gravatar input').checked = false;
document.querySelector<HTMLInputElement>('#offline-mode input').checked = false;
}
});
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;
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>('#enable-openid-signup input')!.checked = false;
}
});
document.querySelector<HTMLInputElement>('#disable-registration input').addEventListener('change', function () {
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;
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-openid-signup input')!.checked = true;
}
});
document.querySelector<HTMLInputElement>('#enable-captcha input').addEventListener('change', function () {
document.querySelector<HTMLInputElement>('#enable-captcha input')!.addEventListener('change', function () {
if (this.checked) {
document.querySelector<HTMLInputElement>('#disable-registration input').checked = false;
document.querySelector<HTMLInputElement>('#disable-registration input')!.checked = false;
}
});
}
@@ -107,8 +88,8 @@ function initPostInstall() {
const el = document.querySelector('#goto-after-install');
if (!el) return;
const targetUrl = el.getAttribute('href');
let tid = setInterval(async () => {
const targetUrl = el.getAttribute('href')!;
let tid: ReturnType<typeof setInterval> | null = setInterval(async () => {
try {
const resp = await GET(targetUrl);
if (tid && resp.status === 200) {
@@ -1,6 +1,6 @@
import type {Issue} from '../types.ts';
// the getIssueIcon/getIssueColor logic should be kept the same as "templates/shared/issueicon.tmpl"
// the getIssueIcon/getIssueColorClass logic should be kept the same as "templates/shared/issueicon.tmpl"
export function getIssueIcon(issue: Issue) {
if (issue.pull_request) {
@@ -21,21 +21,21 @@ export function getIssueIcon(issue: Issue) {
return 'octicon-issue-closed'; // Closed Issue
}
export function getIssueColor(issue: Issue) {
export function getIssueColorClass(issue: Issue) {
if (issue.pull_request) {
if (issue.state === 'open') {
if (issue.pull_request.draft) {
return 'grey'; // WIP PR
return 'tw-text-text-light'; // WIP PR
}
return 'green'; // Open PR
return 'tw-text-green'; // Open PR
} else if (issue.pull_request.merged) {
return 'purple'; // Merged PR
return 'tw-text-purple'; // Merged PR
}
return 'red'; // Closed PR
return 'tw-text-red'; // Closed PR
}
if (issue.state === 'open') {
return 'green'; // Open Issue
return 'tw-text-green'; // Open Issue
}
return 'red'; // Closed Issue
return 'tw-text-red'; // Closed Issue
}
@@ -1,8 +1,8 @@
import {GET} from '../modules/fetch.ts';
import {toggleElem, createElementFromHTML} from '../utils/dom.ts';
import {logoutFromWorker} from '../modules/worker.ts';
import {UserEventsSharedWorker} from '../modules/worker.ts';
const {appSubUrl, notificationSettings, assetVersionEncoded} = window.config;
const {appSubUrl, notificationSettings} = window.config;
let notificationSequenceNumber = 0;
async function receiveUpdateCount(event: MessageEvent<{type: string, data: string}>) {
@@ -33,56 +33,15 @@ export function initNotificationCount() {
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
// Try to connect to the event source via the shared worker first
const worker = new SharedWorker(`${__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker');
worker.addEventListener('error', (event) => {
console.error('worker error', event);
});
worker.port.addEventListener('messageerror', () => {
console.error('unable to deserialize message');
});
worker.port.postMessage({
type: 'start',
url: `${window.location.origin}${appSubUrl}/user/events`,
});
worker.port.addEventListener('message', (event: MessageEvent<{type: string, data: string}>) => {
if (!event.data || !event.data.type) {
console.error('unknown worker message event', event);
return;
}
if (event.data.type === 'notification-count') {
receiveUpdateCount(event); // no await
} else if (event.data.type === 'no-event-source') {
// browser doesn't support EventSource, falling back to periodic poller
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 === 'error') {
console.error('worker port event error', event.data);
} else if (event.data.type === 'logout') {
if (event.data.data !== 'here') {
return;
}
worker.port.postMessage({
type: 'close',
});
worker.port.close();
logoutFromWorker();
} else if (event.data.type === 'close') {
worker.port.postMessage({
type: 'close',
});
worker.port.close();
} else if (event.data.type === 'notification-count') {
receiveUpdateCount(event); // no await
}
});
worker.port.addEventListener('error', (e) => {
console.error('worker port error', e);
});
worker.port.start();
window.addEventListener('beforeunload', () => {
worker.port.postMessage({
type: 'close',
});
worker.port.close();
});
worker.startPort();
return;
}
@@ -90,7 +49,7 @@ export function initNotificationCount() {
}
function getCurrentCount() {
return Number(document.querySelector('.notification_count').textContent ?? '0');
return Number(document.querySelector('.notification_count')!.textContent ?? '0');
}
async function updateNotificationCountWithCallback(callback: (timeout: number, newCount: number) => void, timeout: number, lastCount: number) {
@@ -131,9 +90,9 @@ async function updateNotificationTable() {
const data = await response.text();
const el = createElementFromHTML(data);
if (parseInt(el.getAttribute('data-sequence-number')) === notificationSequenceNumber) {
if (parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber) {
notificationDiv.outerHTML = data;
notificationDiv = document.querySelector('#notification_div');
notificationDiv = document.querySelector('#notification_div')!;
window.htmx.process(notificationDiv); // when using htmx, we must always remember to process the new content changed by us
}
} catch (error) {
@@ -1,9 +1,9 @@
import type {DOMEvent} from '../utils/dom.ts';
export function initOAuth2SettingsDisableCheckbox() {
for (const el of document.querySelectorAll<HTMLInputElement>('.disable-setting')) {
el.addEventListener('change', (e: DOMEvent<Event, HTMLInputElement>) => {
document.querySelector(e.target.getAttribute('data-target')).classList.toggle('disabled', e.target.checked);
el.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
const dataTarget = target.getAttribute('data-target')!;
document.querySelector(dataTarget)!.classList.toggle('disabled', target.checked);
});
}
}
@@ -3,21 +3,22 @@ import {setFileFolding} from './file-fold.ts';
import {POST} from '../modules/fetch.ts';
const {pageData} = window.config;
const prReview = pageData.prReview || {};
// 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 if present
// 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', prReview.numberOfViewedFiles);
const summaryLabel = document.querySelector('#viewed-files-summary-label');
if (summaryLabel) summaryLabel.innerHTML = summaryLabel.getAttribute('data-text-changed-template')
.replace('%[1]d', prReview.numberOfViewedFiles)
.replace('%[2]d', prReview.numberOfFiles);
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
@@ -28,9 +29,9 @@ export function initViewedCheckboxListenerFor() {
// 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 and the CSRF token,
// 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]');
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) {
@@ -45,10 +46,10 @@ export function initViewedCheckboxListenerFor() {
// Update viewed-files summary and remove "has changed" label if present
refreshViewedFilesSummary();
const hasChangedLabel = form.parentNode.querySelector('.changed-since-last-review');
const hasChangedLabel = form.parentNode!.querySelector('.changed-since-last-review');
hasChangedLabel?.remove();
const fileName = checkbox.getAttribute('name');
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);
@@ -59,11 +60,11 @@ export function initViewedCheckboxListenerFor() {
const data: Record<string, any> = {files};
const headCommitSHA = form.getAttribute('data-headcommit');
if (headCommitSHA) data.headCommitSHA = headCommitSHA;
POST(form.getAttribute('data-link'), {data});
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);
const parentBox = form.closest('.diff-file-header')!;
setFileFolding(parentBox.closest('.file-content')!, parentBox.querySelector('.fold-file')!, this.checked);
});
}
}
@@ -72,14 +73,14 @@ 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);
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);
setFileFolding(box, box.querySelector('.fold-file')!, true);
}
});
}
@@ -4,7 +4,7 @@ export async function initRepoRecentCommits() {
const el = document.querySelector('#repo-recent-commits-chart');
if (!el) return;
const {default: RepoRecentCommits} = await import(/* webpackChunkName: "recent-commits-graph" */'../components/RepoRecentCommits.vue');
const {default: RepoRecentCommits} = await import('../components/RepoRecentCommits.vue');
try {
const View = createApp(RepoRecentCommits, {
locale: {
@@ -4,24 +4,32 @@ import RepoActionView from '../components/RepoActionView.vue';
export function initRepositoryActionView() {
const el = document.querySelector('#repo-action-view');
if (!el) return;
const runId = parseInt(el.getAttribute('data-run-id')!);
const jobId = parseInt(el.getAttribute('data-job-id')!);
// TODO: the parent element's full height doesn't work well now,
// but we can not pollute the global style at the moment, only fix the height problem for pages with this component
const parentFullHeight = document.querySelector<HTMLElement>('body > div.full.height');
if (parentFullHeight) parentFullHeight.style.paddingBottom = '0';
if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0');
const view = createApp(RepoActionView, {
runIndex: el.getAttribute('data-run-index'),
jobIndex: el.getAttribute('data-job-index'),
actionsURL: el.getAttribute('data-actions-url'),
runId,
jobId,
actionsUrl: el.getAttribute('data-actions-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'),
scheduled: el.getAttribute('data-locale-runs-scheduled'),
commit: el.getAttribute('data-locale-runs-commit'),
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
workflowGraph: el.getAttribute('data-locale-runs-workflow-graph'),
summary: el.getAttribute('data-locale-summary'),
allJobs: el.getAttribute('data-locale-all-jobs'),
triggeredVia: el.getAttribute('data-locale-triggered-via'),
totalDuration: el.getAttribute('data-locale-total-duration'),
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
areYouSure: el.getAttribute('data-locale-are-you-sure'),
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
@@ -42,6 +50,8 @@ export function initRepositoryActionView() {
},
logsAlwaysAutoScroll: el.getAttribute('data-locale-logs-always-auto-scroll'),
logsAlwaysExpandRunning: el.getAttribute('data-locale-logs-always-expand-running'),
workflowFile: el.getAttribute('data-locale-workflow-file'),
runDetails: el.getAttribute('data-locale-run-details'),
},
});
view.mount(el);
@@ -16,9 +16,9 @@ function initRepoCreateBranchButton() {
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');
document.querySelector(fromSpanName)!.textContent = el.getAttribute('data-branch-from');
fomanticQuery(el.getAttribute('data-modal')).modal('show');
fomanticQuery(el.getAttribute('data-modal')!).modal('show');
});
}
}
@@ -26,17 +26,17 @@ function initRepoCreateBranchButton() {
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;
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');
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);
const text = modal.querySelector('[data-rename-branch-to]')!;
text.textContent = text.getAttribute('data-rename-branch-to')!.replace('%s', oldBranchName);
});
}
}
@@ -5,14 +5,14 @@ import {addDelegatedEventListener} from '../utils/dom.ts';
function changeHash(hash: string) {
if (window.history.pushState) {
window.history.pushState(null, null, hash);
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 {
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]`);
@@ -23,14 +23,14 @@ function selectRange(range: string): Element {
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 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');
let href = viewGitBlame.getAttribute('href')!;
href = `${href.replace(/#L\d+$|#L\d+-L\d+$/, '')}`;
if (anchor.length !== 0) {
href = `${href}#${anchor}`;
@@ -40,7 +40,7 @@ function selectRange(range: string): Element {
const updateCopyPermalinkUrl = function (anchor: string) {
if (!copyPermalink) return;
let link = copyPermalink.getAttribute('data-url');
let link = copyPermalink.getAttribute('data-url')!;
link = `${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
copyPermalink.setAttribute('data-clipboard-text', link);
copyPermalink.setAttribute('data-clipboard-text-type', 'url');
@@ -63,7 +63,7 @@ function selectRange(range: string): Element {
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');
elLineNums[i].closest('tr')!.classList.add('active');
}
changeHash(`#${range}`);
updateIssueHref(range);
@@ -85,14 +85,14 @@ function showLineButton() {
const tr = document.querySelector('.code-view tr.active');
if (!tr) return;
const td = tr.querySelector('td.lines-num');
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));
btn.closest('.code-view')!.append(menu.cloneNode(true));
createTippy(btn, {
theme: 'menu',
@@ -117,16 +117,16 @@ export function initRepoCodeView() {
if (!document.querySelector('.repo-view-container .file-view')) return;
// "file code view" and "blame" pages need this "line number button" feature
let selRangeStart: string;
let selRangeStart: string | undefined;
addDelegatedEventListener(document, 'click', '.code-view .lines-num span', (el: HTMLElement, e: KeyboardEvent) => {
if (!selRangeStart || !e.shiftKey) {
selRangeStart = el.getAttribute('id');
selRangeStart = el.getAttribute('id')!;
selectRange(selRangeStart);
} else {
const selRangeStop = el.getAttribute('id');
selectRange(`${selRangeStart}-${selRangeStop}`);
}
window.getSelection().removeAllRanges();
window.getSelection()!.removeAllRanges();
showLineButton();
});
@@ -6,14 +6,14 @@ 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'));
toggleElem(el.parentElement!.querySelector('.commit-body')!);
el.setAttribute('aria-expanded', String(!expanded));
});
}
export function initCommitStatuses() {
registerGlobalInitFunc('initCommitStatuses', (el: HTMLElement) => {
const nextEl = el.nextElementSibling;
const nextEl = el.nextElementSibling!;
if (!nextEl.matches('.tippy-target')) throw new Error('Expected next element to be a tippy target');
createTippy(el, {
content: nextEl,
@@ -1,16 +1,16 @@
import {queryElems, type DOMEvent} from '../utils/dom.ts';
import {queryElems} from '../utils/dom.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 {toOriginUrl} from '../utils/url.ts';
import {createTippy} from '../modules/tippy.ts';
import {localUserSettings} from '../modules/user-settings.ts';
async function onDownloadArchive(e: DOMEvent<MouseEvent>) {
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.closest<HTMLAnchorElement>('a.archive-link[href]');
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 {
@@ -51,13 +51,13 @@ export function substituteRepoOpenWithUrl(tmpl: string, url: string): string {
}
function initCloneSchemeUrlSelection(parent: Element) {
const elCloneUrlInput = parent.querySelector<HTMLInputElement>('.repo-clone-url');
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 = localStorage.getItem('repo-clone-protocol');
let scheme = localUserSettings.getString('repo-clone-protocol');
if (!['https', 'ssh', 'tea'].includes(scheme)) {
scheme = 'https';
}
@@ -77,7 +77,8 @@ function initCloneSchemeUrlSelection(parent: Element) {
const isTea = scheme === 'tea';
if (tabHttps) {
tabHttps.textContent = window.origin.split(':')[0].toUpperCase(); // show "HTTP" or "HTTPS"
const link = tabHttps.getAttribute('data-link')!;
tabHttps.textContent = link.split(':')[0].toUpperCase(); // show "HTTP" or "HTTPS"
tabHttps.classList.toggle('active', isHttps);
}
if (tabSsh) {
@@ -87,7 +88,7 @@ function initCloneSchemeUrlSelection(parent: Element) {
tabTea.classList.toggle('active', isTea);
}
let tab: Element;
let tab: Element | null = null;
if (isHttps) {
tab = tabHttps;
} else if (isSsh) {
@@ -97,7 +98,7 @@ function initCloneSchemeUrlSelection(parent: Element) {
}
if (!tab) return;
const link = toOriginUrl(tab.getAttribute('data-link'));
const link = tab.getAttribute('data-link')!;
for (const el of document.querySelectorAll('.js-clone-url')) {
if (el.nodeName === 'INPUT') {
@@ -107,22 +108,22 @@ function initCloneSchemeUrlSelection(parent: Element) {
}
}
for (const el of parent.querySelectorAll<HTMLAnchorElement>('.js-clone-url-editor')) {
el.href = substituteRepoOpenWithUrl(el.getAttribute('data-href-template'), link);
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', () => {
localStorage.setItem('repo-clone-protocol', 'https');
localUserSettings.setString('repo-clone-protocol', 'https');
updateClonePanelUi();
});
tabSsh?.addEventListener('click', () => {
localStorage.setItem('repo-clone-protocol', 'ssh');
localUserSettings.setString('repo-clone-protocol', 'ssh');
updateClonePanelUi();
});
tabTea?.addEventListener('click', () => {
localStorage.setItem('repo-clone-protocol', 'tea');
localUserSettings.setString('repo-clone-protocol', 'tea');
updateClonePanelUi();
});
elCloneUrlInput.addEventListener('focus', () => {
@@ -131,7 +132,7 @@ function initCloneSchemeUrlSelection(parent: Element) {
}
function initClonePanelButton(btn: HTMLButtonElement) {
const elPanel = btn.nextElementSibling;
const elPanel = btn.nextElementSibling!;
// "init" must be before the "createTippy" otherwise the "tippy-target" will be removed from the document
initCloneSchemeUrlSelection(elPanel);
createTippy(btn, {
@@ -4,7 +4,7 @@ import {GET} from '../modules/fetch.ts';
async function loadBranchesAndTags(area: Element, loadingButton: Element) {
loadingButton.classList.add('disabled');
try {
const res = await GET(loadingButton.getAttribute('data-fetch-url'));
const res = await GET(loadingButton.getAttribute('data-fetch-url')!);
const data = await res.json();
hideElem(loadingButton);
addTags(area, data.tags);
@@ -16,8 +16,8 @@ async function loadBranchesAndTags(area: Element, loadingButton: Element) {
}
function addTags(area: Element, tags: Array<Record<string, any>>) {
const tagArea = area.querySelector('.tag-area');
toggleElem(tagArea.parentElement, tags.length > 0);
const tagArea = area.querySelector('.tag-area')!;
toggleElem(tagArea.parentElement!, tags.length > 0);
for (const tag of tags) {
addLink(tagArea, tag.web_link, tag.name);
}
@@ -25,15 +25,15 @@ function addTags(area: Element, tags: Array<Record<string, any>>) {
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);
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) {
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;
@@ -47,7 +47,7 @@ function addLink(parent: Element, href: string, text: string, tooltip?: string)
export function initRepoDiffCommitBranchesAndTags() {
for (const area of document.querySelectorAll('.branch-and-tag-area')) {
const btn = area.querySelector('.load-branches-and-tags');
const btn = area.querySelector('.load-branches-and-tags')!;
btn.addEventListener('click', () => loadBranchesAndTags(area, btn));
}
}
@@ -9,7 +9,7 @@ import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce
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 {parseDom, sleep} from '../utils.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
function initRepoDiffFileBox(el: HTMLElement) {
@@ -18,7 +18,7 @@ function initRepoDiffFileBox(el: HTMLElement) {
queryElemSiblings(btn, '.file-view-toggle', (el) => el.classList.remove('active'));
btn.classList.add('active');
const target = document.querySelector(btn.getAttribute('data-toggle-selector'));
const target = document.querySelector(btn.getAttribute('data-toggle-selector')!);
if (!target) throw new Error('Target element not found');
hideElem(queryElemSiblings(target));
@@ -31,7 +31,7 @@ function initRepoDiffConversationForm() {
// 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');
const textArea = form.querySelector<HTMLTextAreaElement>('textarea')!;
if (!validateTextareaNonEmpty(textArea)) return;
if (form.classList.contains('is-loading')) return;
@@ -49,14 +49,14 @@ function initRepoDiffConversationForm() {
// 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 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 = null; // prevent further usage of the form because it should have been replaced
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
@@ -74,10 +74,10 @@ function initRepoDiffConversationForm() {
// 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 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;
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');
@@ -92,10 +92,10 @@ function initRepoDiffConversationForm() {
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');
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})});
@@ -119,14 +119,14 @@ function initRepoDiffConversationNav() {
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 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;
const anchor = elNavConversation.querySelector('.comment')!.id;
window.location.href = `#${anchor}`;
});
}
@@ -162,15 +162,17 @@ async function loadMoreFiles(btn: Element): Promise<boolean> {
}
btn.classList.add('disabled');
const url = btn.getAttribute('data-href');
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');
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
document.querySelector('#diff-incomplete').replaceWith(...Array.from(respFileBoxes.children));
const respFileBoxesChildren = Array.from(respFileBoxes.children); // "children:HTMLCollection" will be empty after replaceWith
document.querySelector('#diff-incomplete')!.replaceWith(...respFileBoxesChildren);
for (const el of respFileBoxesChildren) window.htmx.process(el);
onShowMoreFiles();
return true;
} catch (error) {
@@ -193,15 +195,15 @@ function initRepoDiffShowMore() {
if (el.classList.contains('disabled')) return;
el.classList.add('disabled');
const url = el.getAttribute('data-href');
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); // respFileBody.children will be empty after replaceWith
el.parentElement.replaceWith(...respFileBodyChildren);
const respFileBody = respDoc.querySelector('#diff-file-boxes .diff-file-body .file-body')!;
const respFileBodyChildren = Array.from(respFileBody.children); // "children:HTMLCollection" will be empty after replaceWith
el.parentElement!.replaceWith(...respFileBodyChildren);
for (const el of respFileBodyChildren) window.htmx.process(el);
// FIXME: calling onShowMoreFiles is not quite right here.
// But since onShowMoreFiles mixes "init diff box" and "init diff body" together,
@@ -215,21 +217,46 @@ function initRepoDiffShowMore() {
});
}
async function loadUntilFound() {
const hashTargetSelector = window.location.hash;
if (!hashTargetSelector.startsWith('#diff-') && !hashTargetSelector.startsWith('#issuecomment-')) {
return;
}
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;
while (true) {
// 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(hashTargetSelector.substring(1));
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');
expandButton.click();
await sleep(500); // Wait for HTMX to load the content. FIXME: need to drop htmx in the future
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) {
@@ -243,8 +270,8 @@ async function loadUntilFound() {
}
function initRepoDiffHashChangeListener() {
window.addEventListener('hashchange', loadUntilFound);
loadUntilFound();
window.addEventListener('hashchange', onLocationHashChange);
onLocationHashChange();
}
export function initRepoDiffView() {
@@ -262,6 +289,6 @@ export function initRepoDiffView() {
registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
addDelegatedEventListener(document, 'click', '.fold-file', (el) => {
invertFileFolding(el.closest('.file-content'), el);
invertFileFolding(el.closest('.file-content')!, el);
});
}
@@ -1,6 +1,7 @@
import {html, htmlRaw} from '../utils/html.ts';
import {createCodeEditor} from './codeeditor.ts';
import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.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';
@@ -9,7 +10,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts';
import {submitFormFetchAction} from './common-fetch-action.ts';
function initEditPreviewTab(elForm: HTMLFormElement) {
const elTabMenu = elForm.querySelector('.repo-editor-menu');
const elTabMenu = elForm.querySelector('.repo-editor-menu')!;
fomanticQuery(elTabMenu.querySelectorAll('.item')).tab();
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]');
@@ -17,15 +18,15 @@ function initEditPreviewTab(elForm: HTMLFormElement) {
if (!elPreviewTab || !elPreviewPanel) return;
elPreviewTab.addEventListener('click', async () => {
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path');
const previewUrl = elPreviewTab.getAttribute('data-preview-url');
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path')!;
const previewUrl = elPreviewTab.getAttribute('data-preview-url')!;
const previewContextRef = elPreviewTab.getAttribute('data-preview-context-ref');
let previewContext = `${previewContextRef}/${elTreePath.value}`;
previewContext = previewContext.substring(0, previewContext.lastIndexOf('/'));
const formData = new FormData();
formData.append('mode', 'file');
formData.append('context', previewContext);
formData.append('text', elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea').value);
formData.append('text', elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea')!.value);
formData.append('file_path', elTreePath.value);
const response = await POST(previewUrl, {data: formData});
const data = await response.text();
@@ -41,17 +42,21 @@ export function initRepoEditor() {
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;
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<HTMLInputElement>('.quick-pull-branch-name input')!.required = false;
}
document.querySelector('#commit-button').textContent = el.getAttribute('data-button-text');
document.querySelector('#commit-button')!.textContent = el.getAttribute('data-button-text');
});
}
const filenameInput = document.querySelector<HTMLInputElement>('#file-name');
// 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
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')) {
@@ -61,7 +66,7 @@ export function initRepoEditor() {
if (filenameInput.value) {
parts.push(filenameInput.value);
}
document.querySelector<HTMLInputElement>('#tree_path').value = parts.join('/');
document.querySelector<HTMLInputElement>('#tree_path')!.value = parts.join('/');
}
filenameInput.addEventListener('input', function () {
const parts = filenameInput.value.split('/');
@@ -76,8 +81,8 @@ export function initRepoEditor() {
if (trimValue === '..') {
// remove previous tree path
if (links.length > 0) {
const link = links.pop();
const divider = dividers.pop();
const link = links.pop()!;
const divider = dividers.pop()!;
link.remove();
divider.remove();
}
@@ -104,7 +109,7 @@ export function initRepoEditor() {
}
}
containSpace = containSpace || Array.from(links).some((link) => {
const value = link.querySelector('a').textContent;
const value = link.querySelector('a')!.textContent;
return value.trim() !== value;
});
containSpace = containSpace || parts[parts.length - 1].trim() !== parts[parts.length - 1];
@@ -113,9 +118,9 @@ export function initRepoEditor() {
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>`;
// Add display 'block' because display is set to 'none' in formantic\build\semantic.css
warningDiv.style.display = 'block';
const inputContainer = document.querySelector('.repo-editor-header');
// 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);
@@ -132,7 +137,7 @@ export function initRepoEditor() {
e.preventDefault();
const lastSection = sections[sections.length - 1];
const lastDivider = dividers.length ? dividers[dividers.length - 1] : null;
const value = lastSection.querySelector('a').textContent;
const value = lastSection.querySelector('a')!.textContent;
filenameInput.value = value + filenameInput.value;
this.setSelectionRange(value.length, value.length);
lastDivider?.remove();
@@ -141,15 +146,16 @@ export function initRepoEditor() {
}
});
const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form');
const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form')!;
// on the upload page, there is no editor(textarea)
// 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 commitButton = document.querySelector<HTMLButtonElement>('#commit-button')!;
const dirtyFileClass = 'dirty-file';
const syncCommitButtonState = () => {
@@ -170,22 +176,28 @@ export function initRepoEditor() {
(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.setValue(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'),
header: elForm.getAttribute('data-text-empty-confirm-header')!,
content: elForm.getAttribute('data-text-empty-confirm-content')!,
})) {
ignoreAreYouSure(elForm);
submitFormFetchAction(elForm);
@@ -197,5 +209,5 @@ export function initRepoEditor() {
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 markup">${htmlRaw(htmlContent)}</div>`;
previewPanel.innerHTML = html`<div class="render-content render-preview markup">${htmlRaw(htmlContent)}</div>`;
}
@@ -1,13 +1,7 @@
import {svg} from '../svg.ts';
import {toggleElem} from '../utils/dom.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {GET} from '../modules/fetch.ts';
import {createApp} from 'vue';
import {registerGlobalInitFunc} from '../modules/observer.ts';
const threshold = 50;
let files: Array<string> = [];
let repoFindFileInput: HTMLInputElement;
let repoFindFileTableBody: HTMLElement;
let repoFindFileNoResult: HTMLElement;
// 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
@@ -73,48 +67,15 @@ export function filterRepoFilesWeighted(files: Array<string>, filter: string) {
return filterResult;
}
function filterRepoFiles(filter: string) {
const treeLink = repoFindFileInput.getAttribute('data-url-tree-link');
repoFindFileTableBody.innerHTML = '';
const filterResult = filterRepoFilesWeighted(files, filter);
toggleElem(repoFindFileNoResult, !filterResult.length);
for (const r of filterResult) {
const row = document.createElement('tr');
const cell = document.createElement('td');
const a = document.createElement('a');
a.setAttribute('href', `${treeLink}/${pathEscapeSegments(r.matchResult.join(''))}`);
a.innerHTML = svg('octicon-file', 16, 'tw-mr-2');
row.append(cell);
cell.append(a);
for (const [index, part] of r.matchResult.entries()) {
const span = document.createElement('span');
// safely escape by using textContent
span.textContent = part;
span.title = span.textContent;
// if the target file path is "abc/xyz", to search "bx", then the matchResult is ['a', 'b', 'c/', 'x', 'yz']
// the matchResult[odd] is matched and highlighted to red.
if (index % 2 === 1) span.classList.add('ui', 'text', 'red');
a.append(span);
}
repoFindFileTableBody.append(row);
}
}
async function loadRepoFiles() {
const response = await GET(repoFindFileInput.getAttribute('data-url-data-link'));
files = await response.json();
filterRepoFiles(repoFindFileInput.value);
}
export function initFindFileInRepo() {
repoFindFileInput = document.querySelector('#repo-file-find-input');
if (!repoFindFileInput) return;
repoFindFileTableBody = document.querySelector('#repo-find-file-table tbody');
repoFindFileNoResult = document.querySelector('#repo-find-file-no-result');
repoFindFileInput.addEventListener('input', () => filterRepoFiles(repoFindFileInput.value));
loadRepoFiles();
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);
});
}
@@ -6,8 +6,8 @@ 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 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');
@@ -31,7 +31,7 @@ export function initRepoGraphGit() {
elColorMonochrome.addEventListener('click', () => toggleColorMode('monochrome'));
elColorColored.addEventListener('click', () => toggleColorMode('colored'));
const elGraphBody = document.querySelector<HTMLElement>('#git-graph-body');
const elGraphBody = document.querySelector<HTMLElement>('#git-graph-body')!;
const url = new URL(window.location.href);
const params = url.searchParams;
const loadGitGraph = async () => {
@@ -1,5 +1,5 @@
import {stripTags} from '../utils.ts';
import {hideElem, queryElemChildren, showElem, type DOMEvent} from '../utils/dom.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';
@@ -10,32 +10,32 @@ 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;
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();
topicDropdown.querySelector<HTMLInputElement>('input.search')!.focus();
});
document.querySelector('#cancel_topic_edit').addEventListener('click', () => {
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: DOMEvent<MouseEvent, HTMLButtonElement>) => {
document.querySelector<HTMLButtonElement>('#save_topic')!.addEventListener('click', async (e) => {
lastErrorToast?.hideToast();
const topics = editDiv.querySelector<HTMLInputElement>('input[name=topics]').value;
const topics = editDiv.querySelector<HTMLInputElement>('input[name=topics]')!.value;
const data = new FormData();
data.append('topics', topics);
const response = await POST(e.target.getAttribute('data-link'), {data});
const response = await POST((e.target as HTMLElement).getAttribute('data-link')!, {data});
if (response.ok) {
const responseData = await response.json();
@@ -20,14 +20,14 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
${i18nTextOptions}
${svg('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
<div class="item red text" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
<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 elOptionsDropdown = elDetailDialog.querySelector('.ui.dropdown.dialog-header-options')!;
const $fomanticDialog = fomanticQuery(elDetailDialog);
const $fomanticDropdownOptions = fomanticQuery(elOptionsDropdown);
$fomanticDropdownOptions.dropdown({
@@ -74,7 +74,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
const response = await GET(url);
const resp = await response.json();
const commentDiffData = elDetailDialog.querySelector('.comment-diff-data');
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.
@@ -92,7 +92,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
}
function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, commentId: string) {
const elHeaderLeft = elCommentItem.querySelector('.comment-header-left');
const elHeaderLeft = elCommentItem.querySelector('.comment-header-left')!;
const menuHtml = `
<div class="ui dropdown interact-fg content-history-menu" data-comment-id="${commentId}">
&bull; ${i18nTextEdited}${svg('octicon-triangle-down', 14, 'dropdown icon')}
@@ -103,7 +103,7 @@ function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, co
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 elDropdown = elHeaderLeft.querySelector('.ui.dropdown.content-history-menu')!;
const $fomanticDropdown = fomanticQuery(elDropdown);
$fomanticDropdown.dropdown({
action: 'hide',
@@ -2,20 +2,20 @@ 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, type DOMEvent} from '../utils/dom.ts';
import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.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: DOMEvent<MouseEvent>) {
const clickTarget = e.target.closest('.edit-content');
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');
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;
@@ -37,14 +37,14 @@ async function tryOnEditContent(e: DOMEvent<MouseEvent>) {
try {
const params = new URLSearchParams({
content: comboMarkdownEditor.value(),
context: editContentZone.getAttribute('data-context'),
content_version: editContentZone.getAttribute('data-content-version'),
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 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);
@@ -67,9 +67,9 @@ async function tryOnEditContent(e: DOMEvent<MouseEvent>) {
commentContent.insertAdjacentHTML('beforeend', data.attachments);
}
} else if (data.attachments === '') {
commentContent.querySelector('.dropzone-attachments').remove();
commentContent.querySelector('.dropzone-attachments')!.remove();
} else {
commentContent.querySelector('.dropzone-attachments').outerHTML = data.attachments;
commentContent.querySelector('.dropzone-attachments')!.outerHTML = data.attachments;
}
comboMarkdownEditor.dropzoneSubmitReload();
} catch (error) {
@@ -84,24 +84,22 @@ async function tryOnEditContent(e: DOMEvent<MouseEvent>) {
showElem(editContentZone);
hideElem(renderContent);
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'))!;
if (!comboMarkdownEditor) {
editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template').innerHTML;
const form = editContentZone.querySelector('form');
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 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);
}
// FIXME: ideally here should reload content and attachment list from backend for existing editor, to avoid losing data
if (!comboMarkdownEditor.value()) {
comboMarkdownEditor.value(rawContent.textContent);
}
// 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);
@@ -109,7 +107,7 @@ async function tryOnEditContent(e: DOMEvent<MouseEvent>) {
function extractSelectedMarkdown(container: HTMLElement) {
const selection = window.getSelection();
if (!selection.rangeCount) return '';
if (!selection?.rangeCount) return '';
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) return '';
@@ -127,19 +125,19 @@ async function tryOnQuoteReply(e: Event) {
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');
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');
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'));
editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor'))!;
}
if (editor.value()) {
@@ -1,6 +1,6 @@
import {updateIssuesMeta} from './repo-common.ts';
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
import {html} from '../utils/html.ts';
import {html, htmlRaw} from '../utils/html.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {showErrorToast} from '../modules/toast.ts';
import {createSortable} from '../modules/sortable.ts';
@@ -34,8 +34,8 @@ function initRepoIssueListCheckboxes() {
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');
const visiblePanel = Array.from(panels).find((el) => isElemVisible(el))!;
const toolbarLeft = visiblePanel.querySelector('.issue-list-toolbar-left')!;
toolbarLeft.prepend(issueSelectAll);
};
@@ -54,12 +54,12 @@ function initRepoIssueListCheckboxes() {
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 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'));
issueIDList.push(el.getAttribute('data-issue-id')!);
}
const issueIDs = issueIDList.join(',');
if (!issueIDs) return;
@@ -77,7 +77,7 @@ function initRepoIssueListCheckboxes() {
// for delete
if (action === 'delete') {
const confirmText = el.getAttribute('data-action-delete-confirm');
const confirmText = el.getAttribute('data-action-delete-confirm')!;
if (!await confirmModal({content: confirmText, confirmButtonColor: 'red'})) {
return;
}
@@ -95,12 +95,12 @@ function initRepoIssueListCheckboxes() {
function initDropdownUserRemoteSearch(el: Element) {
let searchUrl = el.getAttribute('data-search-url');
const actionJumpUrl = el.getAttribute('data-action-jump-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');
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,
@@ -138,10 +138,11 @@ function initDropdownUserRemoteSearch(el: Element) {
// the content is provided by backend IssuePosters handler
processedResults.length = 0;
for (const item of resp.results) {
let nameHtml = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20"><span class="gt-ellipsis">${item.username}</span>`;
if (item.full_name) nameHtml += html`<span class="search-fullname tw-ml-2">${item.full_name}</span>`;
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: nameHtml});
processedResults.push({value: item.username, name: htmlItem});
}
resp.results = processedResults;
return resp;
@@ -183,25 +184,25 @@ function initPinRemoveButton() {
const id = Number(el.getAttribute('data-issue-id'));
// Send the unpin request
const response = await DELETE(el.getAttribute('data-unpin-url'));
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();
el.closest(`div.issue-card[data-issue-id="${id}"]`)!.remove();
}
});
}
}
async function pinMoveEnd(e: SortableEvent) {
const url = e.item.getAttribute('data-move-url');
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}});
await POST(url, {data: {id, position: e.newIndex! + 1}});
}
async function initIssuePinSort() {
const pinDiv = document.querySelector('#issue-pins');
const pinDiv = document.querySelector<HTMLElement>('#issue-pins');
if (pinDiv === null) return;
@@ -1,5 +1,4 @@
import {createApp} from 'vue';
import PullRequestMergeForm from '../components/PullRequestMergeForm.vue';
import {GET, POST} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {createElementFromHTML} from '../utils/dom.ts';
@@ -8,21 +7,21 @@ function initRepoPullRequestUpdate(el: HTMLElement) {
const prUpdateButtonContainer = el.querySelector('#update-pr-branch-with-base');
if (!prUpdateButtonContainer) return;
const prUpdateButton = prUpdateButtonContainer.querySelector<HTMLButtonElement>(':scope > button');
const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown');
const prUpdateButton = prUpdateButtonContainer.querySelector<HTMLButtonElement>(':scope > button')!;
const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown')!;
prUpdateButton.addEventListener('click', async function (e) {
e.preventDefault();
const redirect = this.getAttribute('data-redirect');
this.classList.add('is-loading');
let response: Response;
let response: Response | undefined;
try {
response = await POST(this.getAttribute('data-do'));
response = await POST(this.getAttribute('data-do')!);
} catch (error) {
console.error(error);
} finally {
this.classList.remove('is-loading');
}
let data: Record<string, any>;
let data: Record<string, any> | undefined;
try {
data = await response?.json(); // the response is probably not a JSON
} catch (error) {
@@ -54,8 +53,8 @@ function initRepoPullRequestUpdate(el: HTMLElement) {
function initRepoPullRequestCommitStatus(el: HTMLElement) {
for (const btn of el.querySelectorAll('.commit-status-hide-checks')) {
const panel = btn.closest('.commit-status-panel');
const list = panel.querySelector<HTMLElement>('.commit-status-list');
const panel = btn.closest('.commit-status-panel')!;
const list = panel.querySelector<HTMLElement>('.commit-status-list')!;
btn.addEventListener('click', () => {
list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
@@ -63,15 +62,16 @@ function initRepoPullRequestCommitStatus(el: HTMLElement) {
}
}
function initRepoPullRequestMergeForm(box: HTMLElement) {
async function initRepoPullRequestMergeForm(box: HTMLElement) {
const el = box.querySelector('#pull-request-merge-form');
if (!el) return;
const {default: PullRequestMergeForm} = await import('../components/PullRequestMergeForm.vue');
const view = createApp(PullRequestMergeForm);
view.mount(el);
}
function executeScripts(elem: HTMLElement) {
function executeScripts(elem: Element) {
for (const oldScript of elem.querySelectorAll('script')) {
// TODO: that's the only way to load the data for the merge form. In the future
// we need to completely decouple the page data and embedded script
@@ -96,7 +96,7 @@ export function initRepoPullMergeBox(el: HTMLElement) {
const reloadingInterval = parseInt(reloadingIntervalValue);
const pullLink = el.getAttribute('data-pull-link');
let timerId: number;
let timerId: number | null;
let reloadMergeBox: () => Promise<void>;
const stopReloading = () => {
@@ -0,0 +1,61 @@
import {syncIssueMainContentTimelineItems} from './repo-issue-sidebar-combolist.ts';
import {createElementFromHTML} from '../utils/dom.ts';
describe('syncIssueMainContentTimelineItems', () => {
test('InsertNew', () => {
const oldContent = createElementFromHTML(`
<div>
<div class="timeline-item">First</div>
<div class="timeline-item" id="timeline-comments-end"></div>
</div>
`);
const newContent = createElementFromHTML(`
<div>
<div class="timeline-item" id="a">New</div>
</div>
`);
syncIssueMainContentTimelineItems(oldContent, newContent);
expect(oldContent.innerHTML.replace(/>\s+</g, '><').trim()).toBe(
`<div class="timeline-item">First</div>` +
`<div class="timeline-item" id="a">New</div>` +
`<div class="timeline-item" id="timeline-comments-end"></div>`,
);
});
test('Sync', () => {
const oldContent = createElementFromHTML(`
<div>
<div class="timeline-item">First</div>
<div class="timeline-item" id="it-1">Item 1</div>
<div class="timeline-item event" id="it-2">Item 2</div>
<div class="timeline-item" id="it-3">Item 3</div>
<div class="timeline-item event" id="it-4">Item 4</div>
<div class="timeline-item" id="timeline-comments-end"></div>
<div class="timeline-item">Other</div>
</div>
`);
const newContent = createElementFromHTML(`
<div>
<div class="timeline-item" id="it-1">New 1</div>
<div class="timeline-item event" id="it-2">New 2</div>
<div class="timeline-item" id="it-x">New X</div>
</div>
`);
syncIssueMainContentTimelineItems(oldContent, newContent);
// Item 1 won't be replaced because it's not an event
// Item 2 will be replaced with New 2
// Item 3 will be kept because it's not in new content
// Item 4 will be removed because it's not in new content, and it's an event
// New X will be inserted at the end of timeline items (before timeline-comments-end)
expect(oldContent.innerHTML.replace(/>\s+</g, '><').trim()).toBe(
`<div class="timeline-item">First</div>` +
`<div class="timeline-item" id="it-1">Item 1</div>` +
`<div class="timeline-item event" id="it-2">New 2</div>` +
`<div class="timeline-item" id="it-3">Item 3</div>` +
`<div class="timeline-item" id="it-x">New X</div>` +
`<div class="timeline-item" id="timeline-comments-end"></div>` +
`<div class="timeline-item">Other</div>`,
);
});
});
@@ -1,25 +1,40 @@
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {POST} from '../modules/fetch.ts';
import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
import {parseDom} from '../utils.ts';
// if there are draft comments, confirm before reloading, to avoid losing comments
function issueSidebarReloadConfirmDraftComment() {
const commentTextareas = [
document.querySelector<HTMLTextAreaElement>('.edit-content-zone:not(.tw-hidden) textarea'),
document.querySelector<HTMLTextAreaElement>('#comment-form textarea'),
];
for (const textarea of commentTextareas) {
// Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
// But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
if (textarea && textarea.value.trim().length > 10) {
textarea.parentElement.scrollIntoView();
if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
return;
}
break;
export function syncIssueMainContentTimelineItems(oldMainContent: Element, newMainContent: Element) {
// find the end of comments timeline by "id=timeline-comments-end" in current main content, and insert new items before it
const timelineEnd = oldMainContent.querySelector('.timeline-item[id="timeline-comments-end"]');
if (!timelineEnd) return;
const oldTimelineItems = oldMainContent.querySelectorAll(`.timeline-item[id]`);
for (const oldItem of oldTimelineItems) {
const oldItemId = oldItem.getAttribute('id')!;
const newItem = newMainContent.querySelector(`.timeline-item[id="${CSS.escape(oldItemId)}"]`);
if (oldItem.classList.contains('event') && !newItem) {
// if the item is not in new content, we want to remove it from old content only if it's an event item, otherwise we keep it
oldItem.remove();
}
}
window.location.reload();
const newTimelineItems = newMainContent.querySelectorAll(`.timeline-item[id]`);
for (const newItem of newTimelineItems) {
const newItemId = newItem.getAttribute('id')!;
const oldItem = oldMainContent.querySelector(`.timeline-item[id="${CSS.escape(newItemId)}"]`);
if (oldItem) {
if (oldItem.classList.contains('event')) {
// for event item (e.g.: "add & remove labels"), we want to replace the existing one if exists
// because the label operations can be merged into one event item, so the new item might be different from the old one
oldItem.replaceWith(newItem);
window.htmx.process(newItem);
}
continue;
}
timelineEnd.insertAdjacentElement('beforebegin', newItem);
window.htmx.process(newItem);
}
}
export class IssueSidebarComboList {
@@ -27,29 +42,36 @@ export class IssueSidebarComboList {
updateAlgo: string;
selectionMode: string;
elDropdown: HTMLElement;
elList: HTMLElement;
elList: HTMLElement | null;
elComboValue: HTMLInputElement;
initialValues: string[];
container: HTMLElement;
elIssueMainContent: HTMLElement;
elIssueSidebar: HTMLElement;
constructor(container: HTMLElement) {
this.container = container;
this.updateUrl = container.getAttribute('data-update-url');
this.updateAlgo = container.getAttribute('data-update-algo');
this.selectionMode = container.getAttribute('data-selection-mode');
this.updateUrl = container.getAttribute('data-update-url')!;
this.updateAlgo = container.getAttribute('data-update-algo')!;
this.selectionMode = container.getAttribute('data-selection-mode')!;
if (!['single', 'multiple'].includes(this.selectionMode)) throw new Error(`Invalid data-update-on: ${this.selectionMode}`);
if (!['diff', 'all'].includes(this.updateAlgo)) throw new Error(`Invalid data-update-algo: ${this.updateAlgo}`);
this.elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown');
this.elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown')!;
this.elList = container.querySelector<HTMLElement>(':scope > .ui.list');
this.elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value');
this.elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value')!;
this.elIssueMainContent = document.querySelector('.issue-content-left')!;
this.elIssueSidebar = document.querySelector('.issue-content-right')!;
}
collectCheckedValues() {
return Array.from(this.elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value'));
return Array.from(this.elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value')!);
}
updateUiList(changedValues: Array<string>) {
const elEmptyTip = this.elList.querySelector('.item.empty-list');
if (!this.elList) return;
const elEmptyTip = this.elList.querySelector('.item.empty-list')!;
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
for (const value of changedValues) {
const el = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
@@ -62,22 +84,58 @@ export class IssueSidebarComboList {
toggleElem(elEmptyTip, !hasItems);
}
async updateToBackend(changedValues: Array<string>) {
async reloadPagePartially() {
const resp = await GET(window.location.href);
if (!resp.ok) throw new Error(`Failed to reload page: ${resp.statusText}`);
const doc = parseDom(await resp.text(), 'text/html');
// we can safely replace the whole right part (sidebar) because there are only some dropdowns and lists
const newSidebar = doc.querySelector('.issue-content-right')!;
this.elIssueSidebar.replaceWith(newSidebar);
window.htmx.process(newSidebar);
// for the main content (left side), at the moment we only support handling known timeline items
const newMainContent = doc.querySelector('.issue-content-left')!;
syncIssueMainContentTimelineItems(this.elIssueMainContent, newMainContent);
}
async sendRequestToBackend(changedValues: Array<string>): Promise<Response | null> {
let lastResp: Response | null = null;
if (this.updateAlgo === 'diff') {
for (const value of this.initialValues) {
if (!changedValues.includes(value)) {
await POST(this.updateUrl, {data: new URLSearchParams({action: 'detach', id: value})});
lastResp = await POST(this.updateUrl, {data: new URLSearchParams({action: 'detach', id: value})});
if (!lastResp.ok) return lastResp;
}
}
for (const value of changedValues) {
if (!this.initialValues.includes(value)) {
await POST(this.updateUrl, {data: new URLSearchParams({action: 'attach', id: value})});
lastResp = await POST(this.updateUrl, {data: new URLSearchParams({action: 'attach', id: value})});
if (!lastResp.ok) return lastResp;
}
}
} else {
await POST(this.updateUrl, {data: new URLSearchParams({id: changedValues.join(',')})});
lastResp = await POST(this.updateUrl, {data: new URLSearchParams({id: changedValues.join(',')})});
}
return lastResp;
}
async updateToBackend(changedValues: Array<string>) {
this.elIssueSidebar.classList.add('is-loading');
try {
const resp = await this.sendRequestToBackend(changedValues);
if (!resp) return; // no request sent, no need to reload
if (!resp.ok) {
showErrorToast(`Failed to update to backend: ${resp.statusText}`);
return;
}
await this.reloadPagePartially();
} catch (e) {
console.error('Failed to update to backend', e);
showErrorToast(`Failed to update to backend: ${e}`);
} finally {
this.elIssueSidebar.classList.remove('is-loading');
}
issueSidebarReloadConfirmDraftComment();
}
async doUpdate() {
@@ -23,6 +23,19 @@ When the selected items change, the `combo-value` input will be updated.
If there is `data-update-url`, it also calls backend to attach/detach the changed items.
Also, the changed items will be synchronized to the `ui list` items.
The menu items must have correct `href`, otherwise the links of synchronized (cloned) items would be wrong.
The `ui list` is optional, so a single dropdown can also work, to select items and update them to backend.
Synchronization logic:
* On page load:
* If the dropdown menu contains checked items, there will be no synchronization.
In this case, it's assumed that the dropdown menu is already in sync with the list.
* If the dropdown menu doesn't contain checked items, it will use dropdown's value to mark the selected items as checked.
And the selected (checked) items will be synchronized to the list.
Dropdown's value should be empty if the there is no dropdown item but a pre-defined list item need to be displayed.
* On dropdown selection change:
* The selected items will be synchronized to the list after the dropdown is hidden
The items with the same data-scope only allow one selected at a time.
@@ -1,17 +1,23 @@
import {POST} from '../modules/fetch.ts';
import {queryElems, toggleElem} from '../utils/dom.ts';
import {IssueSidebarComboList} from './repo-issue-sidebar-combolist.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {parseIssuePageInfo} from '../utils.ts';
import {html} from '../utils/html.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {showTemporaryTooltip} from '../modules/tippy.ts';
function initBranchSelector() {
const {appSubUrl} = window.config;
function initRepoIssueBranchSelector(elSidebar: HTMLElement) {
// TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl"
const elSelectBranch = document.querySelector('.ui.dropdown.select-branch.branch-selector-dropdown');
const elSelectBranch = elSidebar.querySelector('.ui.dropdown.select-branch.branch-selector-dropdown');
if (!elSelectBranch) return;
const urlUpdateIssueRef = elSelectBranch.getAttribute('data-url-update-issueref');
const elBranchMenu = elSelectBranch.querySelector('.reference-list-menu');
const elBranchMenu = elSelectBranch.querySelector('.reference-list-menu')!;
queryElems(elBranchMenu, '.item:not(.no-select)', (el) => el.addEventListener('click', async function (e) {
e.preventDefault();
const selectedValue = this.getAttribute('data-id'); // eg: "refs/heads/my-branch"
const selectedValue = this.getAttribute('data-id')!; // eg: "refs/heads/my-branch"
const selectedText = this.getAttribute('data-name'); // eg: "my-branch"
if (urlUpdateIssueRef) {
// for existing issue, send request to update issue ref, and reload page
@@ -23,30 +29,91 @@ function initBranchSelector() {
}
} else {
// for new issue, only update UI&form, do not send request/reload
const selectedHiddenSelector = this.getAttribute('data-id-selector');
document.querySelector<HTMLInputElement>(selectedHiddenSelector).value = selectedValue;
elSelectBranch.querySelector('.text-branch-name').textContent = selectedText;
const selectedHiddenSelector = this.getAttribute('data-id-selector')!;
document.querySelector<HTMLInputElement>(selectedHiddenSelector)!.value = selectedValue;
elSelectBranch.querySelector('.text-branch-name')!.textContent = selectedText;
}
}));
}
function initRepoIssueDue() {
const form = document.querySelector<HTMLFormElement>('.issue-due-form');
function initRepoIssueDue(elSidebar: HTMLElement) {
const form = elSidebar.querySelector<HTMLFormElement>('.issue-due-form');
if (!form) return;
const deadline = form.querySelector<HTMLInputElement>('input[name=deadline]');
document.querySelector('.issue-due-edit')?.addEventListener('click', () => {
const deadline = form.querySelector<HTMLInputElement>('input[name=deadline]')!;
elSidebar.querySelector('.issue-due-edit')?.addEventListener('click', () => {
toggleElem(form);
});
document.querySelector('.issue-due-remove')?.addEventListener('click', () => {
elSidebar.querySelector('.issue-due-remove')?.addEventListener('click', () => {
deadline.value = '';
form.dispatchEvent(new Event('submit', {cancelable: true, bubbles: true}));
});
}
export function initRepoIssueSidebar() {
initBranchSelector();
initRepoIssueDue();
export function initRepoIssueSidebarDependency(elSidebar: HTMLElement) {
const elDropdown = elSidebar.querySelector('#new-dependency-drop-list');
if (!elDropdown) return;
// init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions
queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => new IssueSidebarComboList(el).init());
const issuePageInfo = parseIssuePageInfo();
const crossRepoSearch = elDropdown.getAttribute('data-issue-cross-repo-search');
let issueSearchUrl = `${issuePageInfo.repoLink}/issues/search?q={query}&type=${issuePageInfo.issueDependencySearchType}`;
if (crossRepoSearch === 'true') {
issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${issuePageInfo.repoId}&type=${issuePageInfo.issueDependencySearchType}`;
}
fomanticQuery(elDropdown).dropdown({
fullTextSearch: true,
apiSettings: {
cache: false,
rawResponse: true,
url: issueSearchUrl,
onResponse(response: any) {
const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
const currIssueId = elDropdown.getAttribute('data-issue-id');
// Parse the response from the api to work with our dropdown
for (const issue of response) {
// Don't list current issue in the dependency list.
if (String(issue.id) === currIssueId) continue;
filteredResponse.results.push({
value: issue.id,
name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`,
});
}
return filteredResponse;
},
},
});
}
export function initRepoPullRequestAllowMaintainerEdit(elSidebar: HTMLElement) {
const wrapper = elSidebar.querySelector('#allow-edits-from-maintainers')!;
if (!wrapper) return;
const checkbox = wrapper.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
checkbox.addEventListener('input', async () => {
const url = `${wrapper.getAttribute('data-url')}/set_allow_maintainer_edit`;
wrapper.classList.add('is-loading');
try {
const resp = await POST(url, {data: new URLSearchParams({allow_maintainer_edit: String(checkbox.checked)})});
if (!resp.ok) {
throw new Error('Failed to update maintainer edit permission');
}
const data = await resp.json();
checkbox.checked = data.allow_maintainer_edit;
} catch (error) {
checkbox.checked = !checkbox.checked;
console.error(error);
showTemporaryTooltip(wrapper, wrapper.getAttribute('data-prompt-error')!);
} finally {
wrapper.classList.remove('is-loading');
}
});
}
export function initRepoIssueSidebar() {
registerGlobalInitFunc('initRepoIssueSidebar', (elSidebar) => {
initRepoIssueBranchSelector(elSidebar);
initRepoIssueDue(elSidebar);
initRepoIssueSidebarDependency(elSidebar);
initRepoPullRequestAllowMaintainerEdit(elSidebar);
// init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions
queryElems(elSidebar, '.issue-sidebar-combo', (el) => new IssueSidebarComboList(el).init());
});
}
@@ -1,5 +1,5 @@
import {html, htmlEscape} from '../utils/html.ts';
import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
import {htmlEscape} from '../utils/html.ts';
import {createTippy} from '../modules/tippy.ts';
import {
addDelegatedEventListener,
createElementFromHTML,
@@ -7,11 +7,10 @@ import {
queryElems,
showElem,
toggleElem,
type DOMEvent,
} from '../utils/dom.ts';
import {setFileFolding} from './file-fold.ts';
import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {parseIssuePageInfo, toAbsoluteUrl} from '../utils.ts';
import {toAbsoluteUrl} from '../utils.ts';
import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
@@ -21,40 +20,6 @@ import {registerGlobalInitFunc} from '../modules/observer.ts';
const {appSubUrl} = window.config;
export function initRepoIssueSidebarDependency() {
const elDropdown = document.querySelector('#new-dependency-drop-list');
if (!elDropdown) return;
const issuePageInfo = parseIssuePageInfo();
const crossRepoSearch = elDropdown.getAttribute('data-issue-cross-repo-search');
let issueSearchUrl = `${issuePageInfo.repoLink}/issues/search?q={query}&type=${issuePageInfo.issueDependencySearchType}`;
if (crossRepoSearch === 'true') {
issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${issuePageInfo.repoId}&type=${issuePageInfo.issueDependencySearchType}`;
}
fomanticQuery(elDropdown).dropdown({
fullTextSearch: true,
apiSettings: {
cache: false,
rawResponse: true,
url: issueSearchUrl,
onResponse(response: any) {
const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
const currIssueId = elDropdown.getAttribute('data-issue-id');
// Parse the response from the api to work with our dropdown
for (const issue of response) {
// Don't list current issue in the dependency list.
if (String(issue.id) === currIssueId) continue;
filteredResponse.results.push({
value: issue.id,
name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`,
});
}
return filteredResponse;
},
},
});
}
function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
const url = new URL(window.location.href);
const showArchivedLabels = url.searchParams.get('archived_labels') === 'true';
@@ -67,7 +32,7 @@ function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
const excludeLabel = (e: MouseEvent | KeyboardEvent, item: Element) => {
e.preventDefault();
e.stopPropagation();
const labelId = item.getAttribute('data-label-id');
const labelId = item.getAttribute('data-label-id')!;
let labelIds: string[] = queryLabels ? queryLabels.split(',') : [];
labelIds = labelIds.filter((id) => Math.abs(parseInt(id)) !== Math.abs(parseInt(labelId)));
labelIds.push(`-${labelId}`);
@@ -83,20 +48,21 @@ function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
});
// alt(or option) + enter to exclude selected label
elDropdown.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.isComposing) return;
if (e.altKey && e.key === 'Enter') {
const selectedItem = elDropdown.querySelector('.label-filter-query-item.selected');
if (selectedItem) excludeLabel(e, selectedItem);
}
});
// no "labels" query parameter means "all issues"
elDropdown.querySelector('.label-filter-query-default').classList.toggle('selected', queryLabels === '');
elDropdown.querySelector('.label-filter-query-default')!.classList.toggle('selected', queryLabels === '');
// "labels=0" query parameter means "issues without label"
elDropdown.querySelector('.label-filter-query-not-set').classList.toggle('selected', queryLabels === '0');
elDropdown.querySelector('.label-filter-query-not-set')!.classList.toggle('selected', queryLabels === '0');
// prepare to process "archived" labels
const elShowArchivedLabel = elDropdown.querySelector('.label-filter-archived-toggle');
if (!elShowArchivedLabel) return;
const elShowArchivedInput = elShowArchivedLabel.querySelector<HTMLInputElement>('input');
const elShowArchivedInput = elShowArchivedLabel.querySelector<HTMLInputElement>('input')!;
elShowArchivedInput.checked = showArchivedLabels;
const archivedLabels = elDropdown.querySelectorAll('.item[data-is-archived]');
// if no archived labels, hide the toggle and return
@@ -107,7 +73,7 @@ function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
// show the archived labels if the toggle is checked or the label is selected
for (const label of archivedLabels) {
toggleElem(label, showArchivedLabels || selectedLabelIds.has(label.getAttribute('data-label-id')));
toggleElem(label, showArchivedLabels || selectedLabelIds.has(label.getAttribute('data-label-id')!));
}
// update the url when the toggle is changed and reload
elShowArchivedInput.addEventListener('input', () => {
@@ -127,14 +93,14 @@ export function initRepoIssueFilterItemLabel() {
export function initRepoIssueCommentDelete() {
// Delete comment
document.addEventListener('click', async (e: DOMEvent<MouseEvent>) => {
if (!e.target.matches('.delete-comment')) return;
document.addEventListener('click', async (e) => {
if (!(e.target as HTMLElement).matches('.delete-comment')) return;
e.preventDefault();
const deleteButton = e.target;
if (window.confirm(deleteButton.getAttribute('data-locale'))) {
const deleteButton = e.target as HTMLElement;
if (window.confirm(deleteButton.getAttribute('data-locale')!)) {
try {
const response = await POST(deleteButton.getAttribute('data-url'));
const response = await POST(deleteButton.getAttribute('data-url')!);
if (!response.ok) throw new Error('Failed to delete comment');
const conversationHolder = deleteButton.closest('.conversation-holder');
@@ -143,8 +109,8 @@ export function initRepoIssueCommentDelete() {
// Check if this was a pending comment.
if (conversationHolder?.querySelector('.pending-label')) {
const counter = document.querySelector('#review-box .review-comments-counter');
let num = parseInt(counter?.getAttribute('data-pending-comment-number')) - 1 || 0;
const counter = document.querySelector('#review-box .review-comments-counter')!;
let num = parseInt(counter?.getAttribute('data-pending-comment-number') || '') - 1 || 0;
num = Math.max(num, 0);
counter.setAttribute('data-pending-comment-number', String(num));
counter.textContent = String(num);
@@ -162,9 +128,9 @@ export function initRepoIssueCommentDelete() {
// on the Conversation page, there is no parent "tr", so no need to do anything for "add-code-comment"
if (lineType) {
if (lineType === 'same') {
document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`).classList.remove('tw-invisible');
document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`)!.classList.remove('tw-invisible');
} else {
document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`).classList.remove('tw-invisible');
document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`)!.classList.remove('tw-invisible');
}
}
conversationHolder.remove();
@@ -184,49 +150,23 @@ export function initRepoIssueCommentDelete() {
export function initRepoIssueCodeCommentCancel() {
// Cancel inline code comment
document.addEventListener('click', (e: DOMEvent<MouseEvent>) => {
if (!e.target.matches('.cancel-code-comment')) return;
document.addEventListener('click', (e) => {
if (!(e.target as HTMLElement).matches('.cancel-code-comment')) return;
const form = e.target.closest('form');
const form = (e.target as HTMLElement).closest('form')!;
if (form?.classList.contains('comment-form')) {
hideElem(form);
showElem(form.closest('.comment-code-cloud')?.querySelectorAll('button.comment-form-reply'));
showElem(form.closest('.comment-code-cloud')!.querySelectorAll('button.comment-form-reply'));
} else {
form.closest('.comment-code-cloud')?.remove();
}
});
}
export function initRepoPullRequestAllowMaintainerEdit() {
const wrapper = document.querySelector('#allow-edits-from-maintainers');
if (!wrapper) return;
const checkbox = wrapper.querySelector<HTMLInputElement>('input[type="checkbox"]');
checkbox.addEventListener('input', async () => {
const url = `${wrapper.getAttribute('data-url')}/set_allow_maintainer_edit`;
wrapper.classList.add('is-loading');
try {
const resp = await POST(url, {data: new URLSearchParams({
allow_maintainer_edit: String(checkbox.checked),
})});
if (!resp.ok) {
throw new Error('Failed to update maintainer edit permission');
}
const data = await resp.json();
checkbox.checked = data.allow_maintainer_edit;
} catch (error) {
checkbox.checked = !checkbox.checked;
console.error(error);
showTemporaryTooltip(wrapper, wrapper.getAttribute('data-prompt-error'));
} finally {
wrapper.classList.remove('is-loading');
}
});
}
export function initRepoIssueComments() {
if (!document.querySelector('.repository.view.issue .timeline')) return;
document.addEventListener('click', (e: DOMEvent<MouseEvent>) => {
document.addEventListener('click', (e: Event) => {
const urlTarget = document.querySelector(':target');
if (!urlTarget) return;
@@ -235,22 +175,22 @@ export function initRepoIssueComments() {
if (!/^(issue|pull)(comment)?-\d+$/.test(urlTargetId)) return;
if (!e.target.closest(`#${urlTargetId}`)) {
if (!(e.target as HTMLElement).closest(`#${urlTargetId}`)) {
// if the user clicks outside the comment, remove the hash from the url
// use empty hash and state to avoid scrolling
window.location.hash = ' ';
window.history.pushState(null, null, ' ');
window.history.pushState(null, '', ' ');
}
});
}
export async function handleReply(el: HTMLElement) {
const form = el.closest('.comment-code-cloud').querySelector('.comment-form');
const form = el.closest('.comment-code-cloud')!.querySelector('.comment-form')!;
const textarea = form.querySelector('textarea');
hideElem(el);
showElem(form);
const editor = getComboMarkdownEditor(textarea) ?? await initComboMarkdownEditor(form.querySelector('.combo-markdown-editor'));
const editor = getComboMarkdownEditor(textarea) ?? await initComboMarkdownEditor(form.querySelector('.combo-markdown-editor')!);
editor.focus();
return editor;
}
@@ -261,7 +201,7 @@ export function initRepoPullRequestReview() {
if (commentDiv) {
// get the name of the parent id
const groupID = commentDiv.closest('div[id^="code-comments-"]')?.getAttribute('id');
if (groupID && groupID.startsWith('code-comments-')) {
if (groupID?.startsWith('code-comments-')) {
const id = groupID.slice(14);
const ancestorDiffBox = commentDiv.closest<HTMLElement>('.diff-file-box');
@@ -269,7 +209,7 @@ export function initRepoPullRequestReview() {
showElem(`#code-comments-${id}, #code-preview-${id}, #hide-outdated-${id}`);
// if the comment box is folded, expand it
if (ancestorDiffBox?.getAttribute('data-folded') === 'true') {
setFileFolding(ancestorDiffBox, ancestorDiffBox.querySelector('.fold-file'), false);
setFileFolding(ancestorDiffBox, ancestorDiffBox.querySelector('.fold-file')!, false);
}
}
// set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing
@@ -317,23 +257,23 @@ export function initRepoPullRequestReview() {
interactive: true,
hideOnClick: true,
});
elReviewPanel.querySelector('.close').addEventListener('click', () => tippy.hide());
elReviewPanel.querySelector('.close')!.addEventListener('click', () => tippy.hide());
}
addDelegatedEventListener(document, 'click', '.add-code-comment', async (el, e) => {
e.preventDefault();
const isSplit = el.closest('.code-diff')?.classList.contains('code-diff-split');
const side = el.getAttribute('data-side');
const idx = el.getAttribute('data-idx');
const side = el.getAttribute('data-side')!;
const idx = el.getAttribute('data-idx')!;
const path = el.closest('[data-path]')?.getAttribute('data-path');
const tr = el.closest('tr');
const lineType = tr.getAttribute('data-line-type');
const tr = el.closest('tr')!;
const lineType = tr.getAttribute('data-line-type')!;
let ntr = tr.nextElementSibling;
if (!ntr?.classList.contains('add-comment')) {
ntr = createElementFromHTML(`
<tr class="add-comment" data-line-type="${lineType}">
<tr class="add-comment" data-line-type="${htmlEscape(lineType)}">
${isSplit ? `
<td class="add-comment-left" colspan="4"></td>
<td class="add-comment-right" colspan="4"></td>
@@ -343,15 +283,15 @@ export function initRepoPullRequestReview() {
</tr>`);
tr.after(ntr);
}
const td = ntr.querySelector(`.add-comment-${side}`);
const td = ntr.querySelector(`.add-comment-${side}`)!;
const commentCloud = td.querySelector('.comment-code-cloud');
if (!commentCloud && !ntr.querySelector('button[name="pending_review"]')) {
const response = await GET(el.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url'));
const response = await GET(el.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url') ?? '');
td.innerHTML = await response.text();
td.querySelector<HTMLInputElement>("input[name='line']").value = idx;
td.querySelector<HTMLInputElement>("input[name='side']").value = (side === 'left' ? 'previous' : 'proposed');
td.querySelector<HTMLInputElement>("input[name='path']").value = path;
const editor = await initComboMarkdownEditor(td.querySelector<HTMLElement>('.combo-markdown-editor'));
td.querySelector<HTMLInputElement>("input[name='line']")!.value = idx;
td.querySelector<HTMLInputElement>("input[name='side']")!.value = (side === 'left' ? 'previous' : 'proposed');
td.querySelector<HTMLInputElement>("input[name='path']")!.value = String(path);
const editor = await initComboMarkdownEditor(td.querySelector<HTMLElement>('.combo-markdown-editor')!);
editor.focus();
}
});
@@ -360,7 +300,7 @@ export function initRepoPullRequestReview() {
export function initRepoIssueReferenceIssue() {
const elDropdown = document.querySelector('.issue_reference_repository_search');
if (!elDropdown) return;
const form = elDropdown.closest('form');
const form = elDropdown.closest('form')!;
fomanticQuery(elDropdown).dropdown({
fullTextSearch: true,
apiSettings: {
@@ -389,10 +329,10 @@ export function initRepoIssueReferenceIssue() {
const target = el.getAttribute('data-target');
const content = document.querySelector(`#${target}`)?.textContent ?? '';
const poster = el.getAttribute('data-poster-username');
const reference = toAbsoluteUrl(el.getAttribute('data-reference'));
const modalSelector = el.getAttribute('data-modal');
const modal = document.querySelector(modalSelector);
const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]');
const reference = toAbsoluteUrl(el.getAttribute('data-reference')!);
const modalSelector = el.getAttribute('data-modal')!;
const modal = document.querySelector(modalSelector)!;
const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]')!;
textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`;
fomanticQuery(modal).modal('show');
});
@@ -402,8 +342,8 @@ export function initRepoIssueWipNewTitle() {
// Toggle WIP for new PR
queryElems(document, '.title_wip_desc > a', (el) => el.addEventListener('click', (e) => {
e.preventDefault();
const wipPrefixes = JSON.parse(el.closest('.title_wip_desc').getAttribute('data-wip-prefixes'));
const titleInput = document.querySelector<HTMLInputElement>('#issue_title');
const wipPrefixes = JSON.parse(el.closest('.title_wip_desc')!.getAttribute('data-wip-prefixes')!);
const titleInput = document.querySelector<HTMLInputElement>('#issue_title')!;
const titleValue = titleInput.value;
for (const prefix of wipPrefixes) {
if (titleValue.startsWith(prefix.toUpperCase())) {
@@ -419,8 +359,8 @@ export function initRepoIssueWipToggle() {
registerGlobalInitFunc('initPullRequestWipToggle', (toggleWip) => toggleWip.addEventListener('click', async (e) => {
e.preventDefault();
const title = toggleWip.getAttribute('data-title');
const wipPrefix = toggleWip.getAttribute('data-wip-prefix');
const updateUrl = toggleWip.getAttribute('data-update-url');
const wipPrefix = toggleWip.getAttribute('data-wip-prefix')!;
const updateUrl = toggleWip.getAttribute('data-update-url')!;
const params = new URLSearchParams();
params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
@@ -434,13 +374,13 @@ export function initRepoIssueWipToggle() {
}
export function initRepoIssueTitleEdit() {
const issueTitleDisplay = document.querySelector('#issue-title-display');
const issueTitleDisplay = document.querySelector('#issue-title-display')!;
const issueTitleEditor = document.querySelector<HTMLFormElement>('#issue-title-editor');
if (!issueTitleEditor) return;
const issueTitleInput = issueTitleEditor.querySelector('input');
const oldTitle = issueTitleInput.getAttribute('data-old-title');
issueTitleDisplay.querySelector('#issue-title-edit-show').addEventListener('click', () => {
const issueTitleInput = issueTitleEditor.querySelector('input')!;
const oldTitle = issueTitleInput.getAttribute('data-old-title')!;
issueTitleDisplay.querySelector('#issue-title-edit-show')!.addEventListener('click', () => {
hideElem(issueTitleDisplay);
hideElem('#pull-desc-display');
showElem(issueTitleEditor);
@@ -450,7 +390,7 @@ export function initRepoIssueTitleEdit() {
}
issueTitleInput.focus();
});
issueTitleEditor.querySelector('.ui.cancel.button').addEventListener('click', () => {
issueTitleEditor.querySelector('.ui.cancel.button')!.addEventListener('click', () => {
hideElem(issueTitleEditor);
hideElem('#pull-desc-editor');
showElem(issueTitleDisplay);
@@ -460,22 +400,22 @@ export function initRepoIssueTitleEdit() {
const pullDescEditor = document.querySelector('#pull-desc-editor'); // it may not exist for a merged PR
const prTargetUpdateUrl = pullDescEditor?.getAttribute('data-target-update-url');
const editSaveButton = issueTitleEditor.querySelector('.ui.primary.button');
const editSaveButton = issueTitleEditor.querySelector('.ui.primary.button')!;
issueTitleEditor.addEventListener('submit', async (e) => {
e.preventDefault();
const newTitle = issueTitleInput.value.trim();
try {
if (newTitle && newTitle !== oldTitle) {
const resp = await POST(editSaveButton.getAttribute('data-update-url'), {data: new URLSearchParams({title: newTitle})});
const resp = await POST(editSaveButton.getAttribute('data-update-url')!, {data: new URLSearchParams({title: newTitle})});
if (!resp.ok) {
throw new Error(`Failed to update issue title: ${resp.statusText}`);
}
}
if (prTargetUpdateUrl) {
const newTargetBranch = document.querySelector('#pull-target-branch').getAttribute('data-branch');
const oldTargetBranch = document.querySelector('#branch_target').textContent;
const newTargetBranch = document.querySelector('#pull-target-branch')!.getAttribute('data-branch');
const oldTargetBranch = document.querySelector('#branch_target')!.textContent;
if (newTargetBranch !== oldTargetBranch) {
const resp = await POST(prTargetUpdateUrl, {data: new URLSearchParams({target_branch: newTargetBranch})});
const resp = await POST(prTargetUpdateUrl, {data: new URLSearchParams({target_branch: String(newTargetBranch)})});
if (!resp.ok) {
throw new Error(`Failed to update PR target branch: ${resp.statusText}`);
}
@@ -491,12 +431,12 @@ export function initRepoIssueTitleEdit() {
}
export function initRepoIssueBranchSelect() {
document.querySelector<HTMLElement>('#branch-select')?.addEventListener('click', (e: DOMEvent<MouseEvent>) => {
const el = e.target.closest('.item[data-branch]');
document.querySelector<HTMLElement>('#branch-select')?.addEventListener('click', (e: Event) => {
const el = (e.target as HTMLElement).closest('.item[data-branch]');
if (!el) return;
const pullTargetBranch = document.querySelector('#pull-target-branch');
const pullTargetBranch = document.querySelector('#pull-target-branch')!;
const baseName = pullTargetBranch.getAttribute('data-basename');
const branchNameNew = el.getAttribute('data-branch');
const branchNameNew = el.getAttribute('data-branch')!;
const branchNameOld = pullTargetBranch.getAttribute('data-branch');
pullTargetBranch.textContent = pullTargetBranch.textContent.replace(`${baseName}:${branchNameOld}`, `${baseName}:${branchNameNew}`);
pullTargetBranch.setAttribute('data-branch', branchNameNew);
@@ -507,13 +447,14 @@ async function initSingleCommentEditor(commentForm: HTMLFormElement) {
// pages:
// * normal new issue/pr page: no status-button, no comment-button (there is only a normal submit button which can submit empty content)
// * issue/pr view page: with comment form, has status-button and comment-button
const editor = await initComboMarkdownEditor(commentForm.querySelector('.combo-markdown-editor'));
const editor = await initComboMarkdownEditor(commentForm.querySelector('.combo-markdown-editor')!);
const statusButton = document.querySelector<HTMLButtonElement>('#status-button');
const commentButton = document.querySelector<HTMLButtonElement>('#comment-button');
const syncUiState = () => {
const editorText = editor.value().trim(), isUploading = editor.isUploading();
if (statusButton) {
statusButton.textContent = statusButton.getAttribute(editorText ? 'data-status-and-comment' : 'data-status');
const statusText = statusButton.getAttribute(editorText ? 'data-status-and-comment' : 'data-status');
statusButton.querySelector<HTMLElement>('.status-button-text')!.textContent = statusText;
statusButton.disabled = isUploading;
}
if (commentButton) {
@@ -531,9 +472,9 @@ function initIssueTemplateCommentEditors(commentForm: HTMLFormElement) {
const comboFields = commentForm.querySelectorAll<HTMLElement>('.combo-editor-dropzone');
const initCombo = async (elCombo: HTMLElement) => {
const fieldTextarea = elCombo.querySelector<HTMLTextAreaElement>('.form-field-real');
const dropzoneContainer = elCombo.querySelector<HTMLElement>('.form-field-dropzone');
const markdownEditor = elCombo.querySelector<HTMLElement>('.combo-markdown-editor');
const fieldTextarea = elCombo.querySelector<HTMLTextAreaElement>('.form-field-real')!;
const dropzoneContainer = elCombo.querySelector<HTMLElement>('.form-field-dropzone')!;
const markdownEditor = elCombo.querySelector<HTMLElement>('.combo-markdown-editor')!;
const editor = await initComboMarkdownEditor(markdownEditor);
editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, () => fieldTextarea.value = editor.value());
@@ -544,7 +485,7 @@ function initIssueTemplateCommentEditors(commentForm: HTMLFormElement) {
hideElem(commentForm.querySelectorAll('.combo-editor-dropzone .combo-markdown-editor'));
queryElems(commentForm, '.combo-editor-dropzone .form-field-dropzone', (dropzoneContainer) => {
// if "form-field-dropzone" exists, then "dropzone" must also exist
const dropzone = dropzoneContainer.querySelector<HTMLElement>('.dropzone').dropzone;
const dropzone = dropzoneContainer.querySelector<HTMLElement>('.dropzone')!.dropzone;
const hasUploadedFiles = dropzone.files.length !== 0;
toggleElem(dropzoneContainer, hasUploadedFiles);
});
@@ -565,6 +506,8 @@ function initIssueTemplateCommentEditors(commentForm: HTMLFormElement) {
}
export function initRepoCommentFormAndSidebar() {
initRepoIssueSidebar();
const commentForm = document.querySelector<HTMLFormElement>('.comment.form');
if (!commentForm) return;
@@ -575,6 +518,4 @@ export function initRepoCommentFormAndSidebar() {
// it's quite unclear about the "comment form" elements, sometimes it's for issue comment, sometimes it's for file editor/uploader message
initSingleCommentEditor(commentForm);
}
initRepoIssueSidebar();
}
@@ -30,8 +30,8 @@ export function initBranchSelectorTabs() {
for (const elSelectBranch of elSelectBranches) {
queryElems(elSelectBranch, '.reference.column', (el) => el.addEventListener('click', () => {
hideElem(elSelectBranch.querySelectorAll('.scrolling.reference-list-menu'));
showElem(el.getAttribute('data-target'));
queryElemChildren(el.parentNode, '.branch-tag-item', (el) => el.classList.remove('active'));
showElem(el.getAttribute('data-target')!);
queryElemChildren(el.parentNode!, '.branch-tag-item', (el) => el.classList.remove('active'));
el.classList.add('active');
}));
}
@@ -1,4 +1,4 @@
import {hideElem, showElem, type DOMEvent} from '../utils/dom.ts';
import {hideElem, showElem} from '../utils/dom.ts';
import {GET, POST} from '../modules/fetch.ts';
export function initRepoMigrationStatusChecker() {
@@ -18,7 +18,7 @@ export function initRepoMigrationStatusChecker() {
// for all status
if (data.message) {
document.querySelector('#repo_migrating_progress_message').textContent = data.message;
document.querySelector('#repo_migrating_progress_message')!.textContent = data.message;
}
// TaskStatusFinished
@@ -34,7 +34,7 @@ export function initRepoMigrationStatusChecker() {
showElem('#repo_migrating_retry');
showElem('#repo_migrating_failed');
showElem('#repo_migrating_failed_image');
document.querySelector('#repo_migrating_failed_error').textContent = data.message;
document.querySelector('#repo_migrating_failed_error')!.textContent = data.message;
return false;
}
@@ -55,7 +55,7 @@ export function initRepoMigrationStatusChecker() {
syncTaskStatus(); // no await
}
async function doMigrationRetry(e: DOMEvent<MouseEvent>) {
await POST(e.target.getAttribute('data-migrating-task-retry-url'));
async function doMigrationRetry(e: Event) {
await POST((e.target as HTMLElement).getAttribute('data-migrating-task-retry-url')!);
window.location.reload();
}
@@ -7,8 +7,8 @@ const pass = document.querySelector<HTMLInputElement>('#auth_password');
const token = document.querySelector<HTMLInputElement>('#auth_token');
const mirror = document.querySelector<HTMLInputElement>('#mirror');
const lfs = document.querySelector<HTMLInputElement>('#lfs');
const lfsSettings = document.querySelector<HTMLElement>('#lfs_settings');
const lfsEndpoint = document.querySelector<HTMLElement>('#lfs_endpoint');
const lfsSettings = document.querySelector<HTMLElement>('#lfs_settings')!;
const lfsEndpoint = document.querySelector<HTMLElement>('#lfs_endpoint')!;
const items = document.querySelectorAll<HTMLInputElement>('#migrate_items input[type=checkbox]');
export function initRepoMigration() {
@@ -53,7 +53,7 @@ function checkAuth() {
}
function checkItems(tokenAuth: boolean) {
let enableItems = false;
let enableItems: boolean;
if (tokenAuth) {
enableItems = token?.value !== '';
} else {
@@ -2,8 +2,8 @@ export function initRepoMilestone() {
const page = document.querySelector('.repository.new.milestone');
if (!page) return;
const deadline = page.querySelector<HTMLInputElement>('form input[name=deadline]');
document.querySelector('#milestone-clear-deadline').addEventListener('click', () => {
const deadline = page.querySelector<HTMLInputElement>('form input[name=deadline]')!;
document.querySelector('#milestone-clear-deadline')!.addEventListener('click', () => {
deadline.value = '';
});
}
@@ -6,13 +6,13 @@ import {sanitizeRepoName} from './repo-common.ts';
const {appSubUrl} = window.config;
function initRepoNewTemplateSearch(form: HTMLFormElement) {
const elSubmitButton = querySingleVisibleElem<HTMLInputElement>(form, '.ui.primary.button');
const elCreateRepoErrorMessage = form.querySelector('#create-repo-error-message');
const elRepoOwnerDropdown = form.querySelector('#repo_owner_dropdown');
const elRepoTemplateDropdown = form.querySelector<HTMLInputElement>('#repo_template_search');
const inputRepoTemplate = form.querySelector<HTMLInputElement>('#repo_template');
const elTemplateUnits = form.querySelector('#template_units');
const elNonTemplate = form.querySelector('#non_template');
const elSubmitButton = querySingleVisibleElem<HTMLInputElement>(form, '.ui.primary.button')!;
const elCreateRepoErrorMessage = form.querySelector('#create-repo-error-message')!;
const elRepoOwnerDropdown = form.querySelector('#repo_owner_dropdown')!;
const elRepoTemplateDropdown = form.querySelector<HTMLInputElement>('#repo_template_search')!;
const inputRepoTemplate = form.querySelector<HTMLInputElement>('#repo_template')!;
const elTemplateUnits = form.querySelector('#template_units')!;
const elNonTemplate = form.querySelector('#non_template')!;
const checkTemplate = function () {
const hasSelectedTemplate = inputRepoTemplate.value !== '' && inputRepoTemplate.value !== '0';
toggleElem(elTemplateUnits, hasSelectedTemplate);
@@ -62,10 +62,10 @@ export function initRepoNew() {
const pageContent = document.querySelector('.page-content.repository.new-repo');
if (!pageContent) return;
const form = document.querySelector<HTMLFormElement>('.new-repo-form');
const inputGitIgnores = form.querySelector<HTMLInputElement>('input[name="gitignores"]');
const inputLicense = form.querySelector<HTMLInputElement>('input[name="license"]');
const inputAutoInit = form.querySelector<HTMLInputElement>('input[name="auto_init"]');
const form = document.querySelector<HTMLFormElement>('.new-repo-form')!;
const inputGitIgnores = form.querySelector<HTMLInputElement>('input[name="gitignores"]')!;
const inputLicense = form.querySelector<HTMLInputElement>('input[name="license"]')!;
const inputAutoInit = form.querySelector<HTMLInputElement>('input[name="auto_init"]')!;
const updateUiAutoInit = () => {
inputAutoInit.checked = Boolean(inputGitIgnores.value || inputLicense.value);
};
@@ -73,13 +73,13 @@ export function initRepoNew() {
inputLicense.addEventListener('change', updateUiAutoInit);
updateUiAutoInit();
const inputRepoName = form.querySelector<HTMLInputElement>('input[name="repo_name"]');
const inputPrivate = form.querySelector<HTMLInputElement>('input[name="private"]');
const inputRepoName = form.querySelector<HTMLInputElement>('input[name="repo_name"]')!;
const inputPrivate = form.querySelector<HTMLInputElement>('input[name="private"]')!;
const updateUiRepoName = () => {
const helps = form.querySelectorAll(`.help[data-help-for-repo-name]`);
hideElem(helps);
let help = form.querySelector(`.help[data-help-for-repo-name="${CSS.escape(inputRepoName.value)}"]`);
if (!help) help = form.querySelector(`.help[data-help-for-repo-name=""]`);
if (!help) help = form.querySelector(`.help[data-help-for-repo-name=""]`)!;
showElem(help);
const repoNamePreferPrivate: Record<string, boolean> = {'.profile': false, '.profile-private': true};
const preferPrivate = repoNamePreferPrivate[inputRepoName.value];
@@ -5,11 +5,13 @@ import {fomanticQuery} from '../modules/fomantic/base.ts';
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
import type {SortableEvent} from 'sortablejs';
import {toggleFullScreen} from '../utils.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {localUserSettings} from '../modules/user-settings.ts';
function updateIssueCount(card: HTMLElement): void {
const parent = card.parentElement;
const parent = card.parentElement!;
const count = parent.querySelectorAll('.issue-card').length;
parent.querySelector('.project-column-issue-count').textContent = String(count);
parent.querySelector('.project-column-issue-count')!.textContent = String(count);
}
async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<void> {
@@ -19,7 +21,7 @@ async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<voi
const columnSorting = {
issues: Array.from(columnCards, (card, i) => ({
issueID: parseInt(card.getAttribute('data-issue')),
issueID: parseInt(card.getAttribute('data-issue')!),
sorting: i,
})),
};
@@ -30,13 +32,15 @@ async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<voi
});
} catch (error) {
console.error(error);
from.insertBefore(item, from.children[oldIndex]);
if (oldIndex !== undefined) {
from.insertBefore(item, from.children[oldIndex]);
}
}
}
async function initRepoProjectSortable(): Promise<void> {
// the HTML layout is: #project-board.board > .project-column .cards > .issue-card
const mainBoard = document.querySelector('#project-board');
const mainBoard = document.querySelector<HTMLElement>('#project-board')!;
let boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column');
createSortable(mainBoard, {
group: 'project-column',
@@ -49,13 +53,13 @@ async function initRepoProjectSortable(): Promise<void> {
const columnSorting = {
columns: Array.from(boardColumns, (column, i) => ({
columnID: parseInt(column.getAttribute('data-id')),
columnID: parseInt(column.getAttribute('data-id')!),
sorting: i,
})),
};
try {
await POST(mainBoard.getAttribute('data-url'), {
await POST(mainBoard.getAttribute('data-url')!, {
data: columnSorting,
});
} catch (error) {
@@ -65,7 +69,7 @@ async function initRepoProjectSortable(): Promise<void> {
});
for (const boardColumn of boardColumns) {
const boardCardList = boardColumn.querySelector('.cards');
const boardCardList = boardColumn.querySelector<HTMLElement>('.cards')!;
createSortable(boardCardList, {
group: 'shared',
onAdd: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
@@ -77,12 +81,12 @@ async function initRepoProjectSortable(): Promise<void> {
}
function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
const elModal = document.querySelector<HTMLElement>('.ui.modal#project-column-modal-edit');
const elForm = elModal.querySelector<HTMLFormElement>('form');
const elModal = document.querySelector<HTMLElement>('.ui.modal#project-column-modal-edit')!;
const elForm = elModal.querySelector<HTMLFormElement>('form')!;
const elColumnId = elForm.querySelector<HTMLInputElement>('input[name="id"]');
const elColumnTitle = elForm.querySelector<HTMLInputElement>('input[name="title"]');
const elColumnColor = elForm.querySelector<HTMLInputElement>('input[name="color"]');
const elColumnId = elForm.querySelector<HTMLInputElement>('input[name="id"]')!;
const elColumnTitle = elForm.querySelector<HTMLInputElement>('input[name="title"]')!;
const elColumnColor = elForm.querySelector<HTMLInputElement>('input[name="color"]')!;
const attrDataColumnId = 'data-modal-project-column-id';
const attrDataColumnTitle = 'data-modal-project-column-title-input';
@@ -91,9 +95,9 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
// the "new" button is not in project board, so need to query from document
queryElems(document, '.show-project-column-modal-edit', (el) => {
el.addEventListener('click', () => {
elColumnId.value = el.getAttribute(attrDataColumnId);
elColumnTitle.value = el.getAttribute(attrDataColumnTitle);
elColumnColor.value = el.getAttribute(attrDataColumnColor);
elColumnId.value = el.getAttribute(attrDataColumnId)!;
elColumnTitle.value = el.getAttribute(attrDataColumnTitle)!;
elColumnColor.value = el.getAttribute(attrDataColumnColor)!;
elColumnColor.dispatchEvent(new Event('input', {bubbles: true})); // trigger the color picker
});
});
@@ -116,22 +120,22 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
}
// update the newly saved column title and color in the project board (to avoid reload)
const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${attrDataColumnId}="${columnId}"]`);
const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${attrDataColumnId}="${columnId}"]`)!;
elEditButton.setAttribute(attrDataColumnTitle, elColumnTitle.value);
elEditButton.setAttribute(attrDataColumnColor, elColumnColor.value);
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${columnId}"]`);
const elBoardColumnTitle = elBoardColumn.querySelector<HTMLElement>(`.project-column-title-text`);
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${columnId}"]`)!;
const elBoardColumnTitle = elBoardColumn.querySelector<HTMLElement>(`.project-column-title-text`)!;
elBoardColumnTitle.textContent = elColumnTitle.value;
if (elColumnColor.value) {
const textColor = contrastColor(elColumnColor.value);
elBoardColumn.style.setProperty('background', elColumnColor.value, 'important');
elBoardColumn.style.setProperty('color', textColor, 'important');
queryElemChildren<HTMLElement>(elBoardColumn, '.divider', (divider) => divider.style.color = textColor);
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.color = textColor);
} else {
elBoardColumn.style.removeProperty('background');
elBoardColumn.style.removeProperty('color');
queryElemChildren<HTMLElement>(elBoardColumn, '.divider', (divider) => divider.style.removeProperty('color'));
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
}
fomanticQuery(elModal).modal('hide');
@@ -141,27 +145,42 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
});
}
function initRepoProjectToggleFullScreen(): void {
function initRepoProjectToggleFullScreen(elProjectsView: HTMLElement): void {
const enterFullscreenBtn = document.querySelector('.screen-full');
const exitFullscreenBtn = document.querySelector('.screen-normal');
if (!enterFullscreenBtn || !exitFullscreenBtn) return;
const settingKey = 'projects-view-options';
type ProjectsViewOptions = {
fullScreen: boolean;
};
const opts = localUserSettings.getJsonObject<ProjectsViewOptions>(settingKey, {fullScreen: false});
const toggleFullscreenState = (isFullScreen: boolean) => {
toggleFullScreen('.projects-view', isFullScreen);
toggleFullScreen(elProjectsView, isFullScreen);
toggleElem(enterFullscreenBtn, !isFullScreen);
toggleElem(exitFullscreenBtn, isFullScreen);
opts.fullScreen = isFullScreen;
localUserSettings.setJsonObject(settingKey, opts);
};
enterFullscreenBtn.addEventListener('click', () => toggleFullscreenState(true));
exitFullscreenBtn.addEventListener('click', () => toggleFullscreenState(false));
if (opts.fullScreen) {
// a temporary solution to remember the full screen state, not perfect,
// just make UX better than before, especially for users who need to change the label filter frequently and want to keep full screen mode.
toggleFullscreenState(true);
}
}
export function initRepoProject(): void {
initRepoProjectToggleFullScreen();
export function initRepoProjectsView(): void {
registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => {
initRepoProjectToggleFullScreen(elProjectsView);
const writableProjectBoard = document.querySelector('#project-board[data-project-borad-writable="true"]');
if (!writableProjectBoard) return;
const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]');
if (!writableProjectBoard) return;
initRepoProjectSortable(); // no await
initRepoProjectColumnEdit(writableProjectBoard);
initRepoProjectSortable(); // no await
initRepoProjectColumnEdit(writableProjectBoard);
});
}
@@ -0,0 +1,9 @@
import {guessPreviousReleaseTag} from './repo-release.ts';
test('guessPreviousReleaseTag', async () => {
expect(guessPreviousReleaseTag('v0.9', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('');
expect(guessPreviousReleaseTag('1.3', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.2');
expect(guessPreviousReleaseTag('rel/1.3', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.2');
expect(guessPreviousReleaseTag('v1.3', ['rel/1.0', 'rel/1.2', 'rel/1.4', 'rel/1.6'])).toBe('rel/1.2');
expect(guessPreviousReleaseTag('v2.0', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.6');
});
@@ -1,43 +1,47 @@
import {hideElem, showElem, type DOMEvent} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {hideElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
import {htmlEscape} from '../utils/html.ts';
import {compareVersions} from 'compare-versions';
export function initRepoRelease() {
document.addEventListener('click', (e: DOMEvent<MouseEvent>) => {
if (e.target.matches('.remove-rel-attach')) {
const uuid = e.target.getAttribute('data-uuid');
const id = e.target.getAttribute('data-id');
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${uuid}']`).value = 'true';
hideElem(`#attachment-${id}`);
}
export function initRepoReleaseNew() {
registerGlobalEventFunc('click', 'onReleaseEditAttachmentDelete', (el) => {
const uuid = el.getAttribute('data-uuid')!;
const id = el.getAttribute('data-id')!;
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${uuid}']`)!.value = 'true';
hideElem(`#attachment-${id}`);
});
registerGlobalInitFunc('initReleaseEditForm', (elForm: HTMLFormElement) => {
initTagNameEditor(elForm);
initGenerateReleaseNotes(elForm);
});
}
export function initRepoReleaseNew() {
if (!document.querySelector('.repository.new.release')) return;
initTagNameEditor();
function getReleaseFormExistingTags(elForm: HTMLFormElement): Array<string> {
return JSON.parse(elForm.getAttribute('data-existing-tags')!);
}
function initTagNameEditor() {
const el = document.querySelector('#tag-name-editor');
if (!el) return;
function initTagNameEditor(elForm: HTMLFormElement) {
const tagNameInput = elForm.querySelector<HTMLInputElement>('input[type=text][name=tag_name]');
if (!tagNameInput) return; // only init if tag name input exists (the tag name is editable)
const existingTags = JSON.parse(el.getAttribute('data-existing-tags'));
if (!Array.isArray(existingTags)) return;
const existingTags = getReleaseFormExistingTags(elForm);
const defaultTagHelperText = elForm.getAttribute('data-tag-helper');
const newTagHelperText = elForm.getAttribute('data-tag-helper-new');
const existingTagHelperText = elForm.getAttribute('data-tag-helper-existing');
const defaultTagHelperText = el.getAttribute('data-tag-helper');
const newTagHelperText = el.getAttribute('data-tag-helper-new');
const existingTagHelperText = el.getAttribute('data-tag-helper-existing');
const tagNameInput = document.querySelector<HTMLInputElement>('#tag-name');
const hideTargetInput = function(tagNameInput: HTMLInputElement) {
const value = tagNameInput.value;
const tagHelper = document.querySelector('#tag-helper');
const tagHelper = elForm.querySelector('.tag-name-helper')!;
// Old behavior: if the tag already exists, hide the target branch selector and show a helper text to indicate the tag already exists.
// However, it is not right: when creating from an existing tag (not a draft or release yet), it still needs the "target branch"
// So the new logic here: don't hide the target branch selector.
if (existingTags.includes(value)) {
// If the tag already exists, hide the target branch selector.
hideElem('#tag-target-selector');
tagHelper.textContent = existingTagHelperText;
} else {
showElem('#tag-target-selector');
tagHelper.textContent = value ? newTagHelperText : defaultTagHelperText;
}
};
@@ -46,3 +50,102 @@ function initTagNameEditor() {
hideTargetInput(e.target as HTMLInputElement);
});
}
export function guessPreviousReleaseTag(tagName: string, existingTags: Array<string>): string {
let guessedPreviousTag = '', guessedPreviousVer = '';
const cleanup = (s: string) => {
const pos = s.lastIndexOf('/');
if (pos >= 0) s = s.substring(pos + 1);
if (s.substring(0, 1).toLowerCase() === 'v') s = s.substring(1);
return s;
};
const newVer = cleanup(tagName);
for (const s of existingTags) {
const existingVer = cleanup(s);
try {
if (compareVersions(existingVer, newVer) >= 0) continue;
if (!guessedPreviousTag || compareVersions(existingVer, guessedPreviousVer) > 0) {
guessedPreviousTag = s;
guessedPreviousVer = existingVer;
}
} catch {}
}
return guessedPreviousTag;
}
function initGenerateReleaseNotes(elForm: HTMLFormElement) {
const buttonShowModal = elForm.querySelector<HTMLButtonElement>('.button.generate-release-notes')!;
const tagNameInput = elForm.querySelector<HTMLInputElement>('input[name=tag_name]')!;
const targetInput = elForm.querySelector<HTMLInputElement>('input[name=tag_target]')!;
const textMissingTag = buttonShowModal.getAttribute('data-text-missing-tag')!;
const generateUrl = buttonShowModal.getAttribute('data-generate-url')!;
const elModal = document.querySelector('#generate-release-notes-modal')!;
const doSubmit = async (tagName: string) => {
const elPreviousTag = elModal.querySelector<HTMLSelectElement>('[name=previous_tag]')!;
const comboEditor = getComboMarkdownEditor(elForm.querySelector<HTMLElement>('.combo-markdown-editor'))!;
const form = new URLSearchParams();
form.set('tag_name', tagName);
form.set('tag_target', targetInput.value);
form.set('previous_tag', elPreviousTag.value);
elModal.classList.add('loading', 'disabled');
try {
const resp = await POST(generateUrl, {data: form});
const data = await resp.json();
if (!resp.ok) {
showErrorToast(data.errorMessage || resp.statusText);
return;
}
const oldValue = comboEditor.value().trim();
if (oldValue) {
// Don't overwrite existing content. Maybe in the future we can let users decide: overwrite or append or copy-to-clipboard
// GitHub just disables the button if the content is not empty
comboEditor.value(`${oldValue}\n\n${data.content}`);
} else {
comboEditor.value(data.content);
}
} finally {
elModal.classList.remove('loading', 'disabled');
fomanticQuery(elModal).modal('hide');
comboEditor.focus();
}
};
let inited = false;
const doShowModal = () => {
hideToastsAll();
const tagName = tagNameInput.value.trim();
if (!tagName) {
showErrorToast(textMissingTag, {duration: 3000});
tagNameInput.focus();
return;
}
const existingTags = getReleaseFormExistingTags(elForm);
const $dropdown = fomanticQuery(elModal.querySelector('[name=previous_tag]')!);
if (!inited) {
inited = true;
const values = [];
for (const tagName of existingTags) {
values.push({name: htmlEscape(tagName), value: tagName}); // ATTENTION: dropdown takes the "name" input as raw HTML
}
$dropdown.dropdown('change values', values);
}
$dropdown.dropdown('set selected', guessPreviousReleaseTag(tagName, existingTags));
fomanticQuery(elModal).modal({
onApprove: () => {
doSubmit(tagName); // don't await, need to return false to keep the modal
return false;
},
}).modal('show');
};
buttonShowModal.addEventListener('click', doShowModal);
}
@@ -1,17 +1,15 @@
import type {DOMEvent} from '../utils/dom.ts';
export function initRepositorySearch() {
const repositorySearchForm = document.querySelector<HTMLFormElement>('#repo-search-form');
if (!repositorySearchForm) return;
repositorySearchForm.addEventListener('change', (e: DOMEvent<Event, HTMLInputElement>) => {
repositorySearchForm.addEventListener('change', (e: Event) => {
e.preventDefault();
const params = new URLSearchParams();
for (const [key, value] of new FormData(repositorySearchForm).entries()) {
params.set(key, value.toString());
params.set(key, value as string);
}
if (e.target.name === 'clear-filter') {
if ((e.target as HTMLInputElement).name === 'clear-filter') {
params.delete('archived');
params.delete('fork');
params.delete('mirror');

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