feat: vendor gitea 1.16.2
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
+11
-13
@@ -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}">
|
||||
• ${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
Reference in New Issue
Block a user