feat: update gitea vendor
runner nix smoke / nix label and flake smoke (push) Failing after 1m28s

This commit is contained in:
2026-09-26 21:18:24 +00:00
parent c439c1b948
commit d9b2a4e787
3538 changed files with 116131 additions and 44340 deletions
@@ -1,8 +1,9 @@
import {checkAppUrl} from '../common-page.ts';
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {pathEscape} from '../../utils/url.ts';
import {registerGlobalInitFunc} from '../../modules/observer.ts';
const {appSubUrl} = window.config;
@@ -23,6 +24,33 @@ export function initAdminCommon(): void {
initAdminUser();
initAdminAuthentication();
initAdminNotice();
registerGlobalInitFunc('initRunnerBulkToolbar', initAdminRunnerBulk);
}
function initAdminRunnerBulk(toolbar: HTMLElement) {
const actionButtons = toolbar.querySelectorAll<HTMLButtonElement>('.runner-bulk-action');
const formRunnerIds = toolbar.querySelector<HTMLInputElement>('form input[name="ids"]')!;
const rowCheckboxes = document.querySelectorAll<HTMLInputElement>('.runner-bulk-select');
const selectAll = document.querySelector<HTMLInputElement>('.runner-bulk-select-all');
if (!selectAll) return;
const refresh = () => {
const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
formRunnerIds.value = checked.map((c) => c.getAttribute('data-runner-id')!).join(',');
toggleElem(toolbar, checked.length > 0);
for (const btn of actionButtons) {
btn.querySelector<HTMLElement>('.runner-bulk-count')!.textContent = `(${checked.length})`;
}
selectAll.checked = checked.length > 0 && checked.length === rowCheckboxes.length;
selectAll.indeterminate = checked.length > 0 && checked.length < rowCheckboxes.length;
};
selectAll.addEventListener('change', () => {
for (const cb of rowCheckboxes) cb.checked = selectAll.checked;
refresh();
});
for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
refresh();
}
function initAdminUser() {
@@ -78,7 +106,7 @@ function initAdminAuthentication() {
}
function onOAuth2Change(applyDefaultValues: boolean) {
hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url');
hideElem('.open_id_connect_auto_discovery_url, .open_id_connect_external_id_claim, .oauth2_use_custom_url');
for (const input of document.querySelectorAll<HTMLInputElement>('.open_id_connect_auto_discovery_url input[required]')) {
input.removeAttribute('required');
}
@@ -86,8 +114,10 @@ function initAdminAuthentication() {
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
switch (provider) {
case 'openidConnect':
case 'aws-cognito':
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input')!.setAttribute('required', 'required');
showElem('.open_id_connect_auto_discovery_url');
showElem('.open_id_connect_external_id_claim');
break;
default: {
const elProviderCustomUrlSettings = document.querySelector<HTMLInputElement>(`#${provider}_customURLSettings`);
@@ -249,7 +279,7 @@ function initAdminNotice() {
const elNoticeDesc = el.closest('tr')!.querySelector('.notice-description')!;
const elModalDesc = detailModal.querySelector('.content pre')!;
elModalDesc.textContent = elNoticeDesc.textContent;
fomanticQuery(detailModal).modal('show');
showFomanticModal(detailModal);
}));
// Select actions
@@ -285,6 +315,6 @@ function initAdminNotice() {
}
}
await POST(this.getAttribute('data-link')!, {data});
window.location.href = this.getAttribute('data-redirect')!;
window.location.reload();
});
}
@@ -29,10 +29,10 @@ test('ConfigFormValueMapper', () => {
mapper.fillFromSystemConfig();
const formData = mapper.collectToFormData();
const result: Record<string, string> = {};
const keys = [], values = [];
for (const [key, value] of formData.entries()) {
const keys: string[] = [], values: string[] = [];
for (const [key, value] of formData) {
if (key === 'key') keys.push(value as string);
if (key === 'value') values.push(value as string);
else if (key === 'value') values.push(value as string);
}
for (let i = 0; i < keys.length; i++) {
result[keys[i]] = values[i];
@@ -2,7 +2,9 @@ import {showTemporaryTooltip} from '../../modules/tippy.ts';
import {POST} from '../../modules/fetch.ts';
import {registerGlobalInitFunc} from '../../modules/observer.ts';
import {queryElems} from '../../utils/dom.ts';
import {errorMessage} from '../../modules/errors.ts';
import {submitFormFetchAction} from '../common-fetch-action.ts';
import {cutString} from '../../utils/string.ts';
const {appSubUrl} = window.config;
@@ -26,7 +28,7 @@ function initSystemConfigAutoCheckbox(el: HTMLInputElement) {
const json: Record<string, any> = await resp.json();
if (json.errorMessage) throw new Error(json.errorMessage);
} catch (ex) {
showTemporaryTooltip(el, ex.toString());
showTemporaryTooltip(el, errorMessage(ex));
el.checked = !el.checked;
}
});
@@ -101,9 +103,7 @@ export class ConfigFormValueMapper {
} else if (el.matches('[type="datetime-local"]')) {
if (valType !== 'timestamp') requireExplicitValueType(el);
if (val) el.value = toDatetimeLocalValue(val);
} else if (el.matches('textarea')) {
el.value = String(val ?? el.value);
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
} else if (el.matches('textarea') || (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text')) {
el.value = String(val ?? el.value);
} else {
unsupportedElement(el);
@@ -122,9 +122,7 @@ export class ConfigFormValueMapper {
} else if (el.matches('[type="datetime-local"]')) {
if (valType !== 'timestamp') requireExplicitValueType(el);
val = Math.floor(new Date(el.value).getTime() / 1000) ?? 0; // NaN is fine to JSON.stringify, it becomes null.
} else if (el.matches('textarea')) {
val = el.value;
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
} else if (el.matches('textarea') || (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text')) {
val = el.value;
} else {
unsupportedElement(el);
@@ -161,7 +159,7 @@ export class ConfigFormValueMapper {
const apps: Array<{DisplayName: string, OpenURL: string}> = [];
const lines = cfgVal.split('\n');
for (const line of lines) {
let [displayName, openUrl] = line.split('=', 2);
let [displayName, openUrl] = cutString(line, '=');
displayName = displayName.trim();
openUrl = openUrl?.trim() ?? '';
if (!displayName || !openUrl) continue;
@@ -177,7 +175,7 @@ export class ConfigFormValueMapper {
collectToFormData(): FormData {
const namedElems: Array<GeneralFormFieldElement | null> = [];
queryElems(this.form, '[name]', (el) => namedElems.push(el as GeneralFormFieldElement));
queryElems(this.form, '[name]', (el) => { namedElems.push(el as GeneralFormFieldElement) });
// first, process the config options with sub values, for example:
// merge "foo.bar.Enabled", "foo.bar.Message" to "foo.bar"
@@ -5,14 +5,14 @@ export function initAdminUserListSearchForm(): void {
const form = document.querySelector<HTMLFormElement>('#user-list-search-form');
if (!form) return;
for (const button of form.querySelectorAll(`button[name=sort][value="${searchForm.SortType}"]`)) {
for (const button of form.querySelectorAll(`button[name=sort][value="${CSS.escape(searchForm.SortType)}"]`)) {
button.classList.add('active');
}
if (searchForm.StatusFilterMap) {
for (const [k, v] of Object.entries(searchForm.StatusFilterMap)) {
if (!v) continue;
for (const input of form.querySelectorAll<HTMLInputElement>(`input[name="status_filter[${k}]"][value="${v}"]`)) {
for (const input of form.querySelectorAll<HTMLInputElement>(`input[name="status_filter[${CSS.escape(k)}]"][value="${CSS.escape(v)}"]`)) {
input.checked = true;
}
}
@@ -1,5 +1,6 @@
import {getCurrentLocale} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {errorMessage} from '../modules/errors.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {localUserSettings} from '../modules/user-settings.ts';
const {pageData} = window.config;
@@ -46,7 +47,7 @@ export async function initCitationFileCopyContent() {
try {
await initInputCitationValue(citationCopyApa, citationCopyBibtex);
} catch (e) {
console.error(`initCitationFileCopyContent error: ${e}`, e);
console.error(`initCitationFileCopyContent error: ${errorMessage(e)}`, e);
return;
}
updateUi();
@@ -65,6 +66,6 @@ export async function initCitationFileCopyContent() {
inputContent.select();
});
fomanticQuery('#cite-repo-modal').modal('show');
showFomanticModal(document.querySelector('#cite-repo-modal'));
});
}
@@ -1,40 +0,0 @@
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {toAbsoluteUrl} from '../utils.ts';
import {clippie} from 'clippie';
const {copy_success, copy_error} = window.config.i18n;
// Enable clipboard copy from HTML attributes. These properties are supported:
// - data-clipboard-text: Direct text to copy
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of <div> will be copied
// - data-clipboard-text-type: When set to 'url' will convert relative to absolute urls
export function initGlobalCopyToClipboardListener() {
document.addEventListener('click', async (e) => {
const target = (e.target as HTMLElement).closest('[data-clipboard-text], [data-clipboard-target]');
if (!target) return;
e.preventDefault();
let text = target.getAttribute('data-clipboard-text');
if (text === null) {
const textSelector = target.getAttribute('data-clipboard-target')!;
const textTarget = document.querySelector(textSelector)!;
if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') {
text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value;
} else if (textTarget.nodeName === 'DIV') {
text = textTarget.textContent;
} else {
throw new Error(`Unsupported element for clipboard target: ${textSelector}`);
}
}
if (text && target.getAttribute('data-clipboard-text-type') === 'url') {
text = toAbsoluteUrl(text);
}
if (text) {
const success = await clippie(text);
showTemporaryTooltip(target, success ? copy_success : copy_error);
}
});
}
@@ -2,9 +2,10 @@ import {assignElementProperty, type ElementWithAssignableProperties} from './com
test('assignElementProperty', () => {
const elForm = document.createElement('form');
assignElementProperty(elForm, 'action', '/test-link');
expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL
expect(elForm.getAttribute('action')).eq('/test-link');
expect(() => assignElementProperty(elForm, 'action', '/test-link')).toThrow();
assignElementProperty(elForm, 'url', '/test-url');
expect(elForm.action).contains('/test-url'); // the DOM always returns absolute URL
expect(elForm.getAttribute('action')).eq('/test-url');
assignElementProperty(elForm, 'text-content', 'dummy');
expect(elForm.textContent).toBe('dummy');
@@ -14,8 +15,9 @@ test('assignElementProperty', () => {
_attrs: Record<string, string> = {};
setAttribute(name: string, value: string) { this._attrs[name] = value }
getAttribute(name: string): string | null { return this._attrs[name] }
nodeName = 'FORM';
}();
assignElementProperty(elFormWithAction, 'action', '/bar');
assignElementProperty(elFormWithAction, 'url', '/bar');
expect(elFormWithAction.getAttribute('action')).eq('/bar');
const elInput = document.createElement('input');
@@ -1,6 +1,5 @@
import {POST} from '../modules/fetch.ts';
import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {camelize} from 'vue';
import {applyAutoFocus} from './common-page.ts';
@@ -13,74 +12,6 @@ export function initGlobalButtonClickOnEnter(): void {
});
}
export function initGlobalDeleteButton(): void {
// ".delete-button" shows a confirmation modal defined by `data-modal-id` attribute.
// Some model/form elements will be filled by `data-id` / `data-name` / `data-data-xxx` attributes.
// If there is a form defined by `data-form`, then the form will be submitted as-is (without any modification).
// If there is no form, then the data will be posted to `data-url`.
// TODO: do not use this method in new code. `show-modal` / `link-action(data-modal-confirm)` does far better than this.
// FIXME: all legacy `delete-button` should be refactored to use `show-modal` or `link-action`
for (const btn of document.querySelectorAll<HTMLElement>('.delete-button')) {
btn.addEventListener('click', (e) => {
e.preventDefault();
// eslint-disable-next-line github/no-dataset -- code depends on the camel-casing
const dataObj = btn.dataset;
const modalId = btn.getAttribute('data-modal-id');
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`)!;
// set the modal "display name" by `data-name`
const modalNameEl = modal.querySelector('.name');
if (modalNameEl) modalNameEl.textContent = btn.getAttribute('data-name');
// fill the modal elements with data-xxx attributes: `data-data-organization-name="..."` => `<span class="dataOrganizationName">...</span>`
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) {
const textEl = modal.querySelector(`.${key}`);
if (textEl) textEl.textContent = value ?? null;
}
}
fomanticQuery(modal).modal({
closable: false,
onApprove: () => {
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
if (btn.getAttribute('data-type') === 'form') {
const formSelector = btn.getAttribute('data-form')!;
const form = document.querySelector<HTMLFormElement>(formSelector);
if (!form) throw new Error(`no form named ${formSelector} found`);
modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal
form.classList.add('is-loading');
form.submit();
return false; // prevent modal from closing automatically
}
// prepare an AJAX form by data attributes
const postData = new FormData();
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) { // for data-data-xxx (HTML) -> dataXxx (form)
postData.append(key.slice(4), String(value));
}
if (key === 'id') { // for data-id="..."
postData.append('id', String(value));
}
}
(async () => {
const response = await POST(btn.getAttribute('data-url')!, {data: postData});
if (response.ok) {
const data = await response.json();
window.location.href = data.redirect;
}
})();
modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal
return false; // prevent modal from closing automatically
},
}).modal('show');
});
}
}
function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
// a '.show-panel' element can show a panel, by `data-panel="selector"`
// if it has "toggle" class, it toggles the panel
@@ -111,11 +42,20 @@ function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
}
export type ElementWithAssignableProperties = {
nodeName: string;
getAttribute: (name: string) => string | null;
setAttribute: (name: string, value: string) => void;
} & Record<string, any>;
export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
if (el.nodeName === 'FORM') {
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: a special case for Golang HTML template escaping.
// Golang HTML template only handles some "known" attribute names as URL (e.g.: when the name is "action" or contains "url")
// To prevent template developers from making mistakes like `data-modal-form.action="?k={{ValueWithSpecialChars}}" (no escaping),
// here we use `data-modal-form.url="?k={{ValueWithSpecialChars}}", then the value gets correctly escaped by Golang HTML template.
if (kebabName === 'action') throw new Error(`don't assign element property "action" by value, use "data-modal-form.url" instead`);
if (kebabName === 'url') kebabName = 'action';
}
const camelizedName = camelize(kebabName);
const old = el[camelizedName];
if (typeof old === 'boolean') {
@@ -128,7 +68,7 @@ export function assignElementProperty(el: ElementWithAssignableProperties, kebab
// "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute
el.setAttribute(kebabName, val);
} else {
// in the future, we could introduce a better typing system like `data-modal-form.action:string="..."`
// in the future, maybe we could introduce a better typing system if it is really needed
throw new Error(`cannot assign element property "${camelizedName}" by value "${val}"`);
}
}
@@ -140,7 +80,11 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
// * Then, try to query '[name=target]'
// * Then, try to query '.target'
// * Then, try to query 'target' as HTML tag
// If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName".
// If there's a ".{prop-name}" part like "data-modal-input.value", the "input" element's "value" property will be set,
// the "prop-name" will be camel-cased to "propName" (e.g.: "data-modal-input.read-only" for "readOnly" property).
//
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: Form element's "action" property must be set by "data-modal-form.url"
// to make the template variables get correctly escaped in the URL.
e.preventDefault();
const modalSelector = el.getAttribute('data-modal')!;
const elModal = document.querySelector(modalSelector);
@@ -156,10 +100,10 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
const [attrTargetName, attrTargetProp] = attrTargetCombo.split('.');
// try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag", and then try the modal itself
const attrTarget = elModal.querySelector(`#${attrTargetName}`) ||
elModal.querySelector(`[name=${attrTargetName}]`) ||
elModal.querySelector(`[name=${CSS.escape(attrTargetName)}]`) ||
elModal.querySelector(`.${attrTargetName}`) ||
elModal.querySelector(`${attrTargetName}`) ||
(elModal.matches(`${attrTargetName}`) || elModal.matches(`#${attrTargetName}`) || elModal.matches(`.${attrTargetName}`) ? elModal : null);
elModal.querySelector(attrTargetName) ||
(elModal.matches(attrTargetName) || elModal.matches(`#${attrTargetName}`) || elModal.matches(`.${attrTargetName}`) ? elModal : null);
if (!attrTarget) {
if (!window.config.runModeIsProd) throw new Error(`attr target "${attrTargetCombo}" not found for modal`);
continue;
@@ -174,7 +118,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
}
}
fomanticQuery(elModal).modal('show');
showFomanticModal(elModal);
}
export function initGlobalButtons(): void {
@@ -0,0 +1,59 @@
import {execPseudoSelectorCommands, handleFetchActionSuccessJson} from './common-fetch-action.ts';
test('execPseudoSelectorCommands', () => {
window.document.body.innerHTML = `
<div id="d1">
<ul id="u1">
<li class="x"></li>
</ul>
<ul id="u2">
<li class="x"></li>
</ul>
</div>
<div id="d2">
<ul id="u3">
<li class="x"></li>
</ul>
</div>`;
let ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '');
expect(ret.targets).toEqual([document.querySelector('#u1')]);
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$this');
expect(ret.targets).toEqual([document.querySelector('#u1')]);
expect(ret.cmdInnerHTML).toBeFalsy();
expect(ret.cmdMorph).toBeFalsy();
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body $morph $innerHTML');
expect(ret.targets).toEqual([document.body]);
expect(ret.cmdInnerHTML).toBeTruthy();
expect(ret.cmdMorph).toBeTruthy();
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body .x');
expect(ret.targets.length).toEqual(3);
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('.x')));
ret = execPseudoSelectorCommands(document.querySelector('#u1 .x')!, '$closest(div) .x');
expect(ret.targets.length).toEqual(2);
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('#d1 .x')));
});
test('handleFetchActionSuccessJson', async () => {
const spyAssign = vi.spyOn(window.location, 'assign').mockImplementation(() => {});
const spyReload = vi.spyOn(window.location, 'reload').mockImplementation(() => {});
await handleFetchActionSuccessJson(document.body, {redirect: '/'});
expect(spyAssign).toHaveBeenCalledTimes(1);
expect(spyReload).toHaveBeenCalledTimes(0);
vi.resetAllMocks();
await handleFetchActionSuccessJson(document.body, {redirect: ''});
expect(spyAssign).toHaveBeenCalledTimes(0);
expect(spyReload).toHaveBeenCalledTimes(1);
vi.resetAllMocks();
await handleFetchActionSuccessJson(document.body, {});
expect(spyAssign).toHaveBeenCalledTimes(0);
expect(spyReload).toHaveBeenCalledTimes(1);
vi.resetAllMocks();
});
@@ -1,89 +1,161 @@
import {request} from '../modules/fetch.ts';
import {GET, request} from '../modules/fetch.ts';
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts';
import {addDelegatedEventListener, createElementFromHTML} from '../utils/dom.ts';
import {errorMessage, errorName} from '../modules/errors.ts';
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
import type {RequestOpts} from '../types.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {Idiomorph} from 'idiomorph';
import {parseDom} from '../utils.ts';
import {html} from '../utils/html.ts';
const {appSubUrl} = window.config;
const {appSubUrl, runModeIsProd} = window.config;
type FetchActionOpts = {
method: string;
url: string;
headers?: HeadersInit;
body?: FormData;
formSubmitter?: HTMLElement | null;
// pseudo selectors/commands to update the current page with the response text when the response is text (html)
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
successSync: string;
// the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
// empty means no loading indicator, "$this" means the element itself
loadingIndicator: string;
};
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
// more details are in the backend's fetch-redirect handler
function fetchActionDoRedirect(redirect: string) {
const form = document.createElement('form');
const input = document.createElement('input');
form.method = 'post';
form.action = `${appSubUrl}/-/fetch-redirect`;
input.type = 'hidden';
input.name = 'redirect';
input.value = redirect;
form.append(input);
// In production, if the link can be directly navigated by browser, we just do normal redirection, which is faster.
// Otherwise, need to use backend to do redirection:
// * Also do so in development, to make sure the redirection logic is always tested by real users
const needBackendHelp = redirect.includes('#');
if (runModeIsProd && !needBackendHelp) {
window.location.assign(redirect);
return;
}
// use backend to do redirection, which can bypass the browser's limitations of "location"
const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
document.body.append(form);
form.submit();
}
async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) {
const showErrorForResponse = (code: number, message: string) => {
showErrorToast(`Error ${code || 'request'}: ${message}`);
};
let respStatus = 0;
let respText = '';
try {
hideToastsAll();
const resp = await request(url, opt);
respStatus = resp.status;
respText = await resp.text();
const respJson = JSON.parse(respText);
if (respStatus === 200) {
let {redirect} = respJson;
redirect = redirect || actionElem.getAttribute('data-redirect');
ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading
if (redirect) {
fetchActionDoRedirect(redirect);
function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) {
const loadingIndicatorElems = opt.loadingIndicator ? execPseudoSelectorCommands(el, opt.loadingIndicator).targets : [];
for (const indicatorEl of loadingIndicatorElems) {
if (isLoading) {
// for button or input element, we can directly disable it, it looks better than adding a loading spinner
if ('disabled' in indicatorEl) {
indicatorEl.disabled = true;
} else {
window.location.reload();
indicatorEl.classList.add('is-loading');
if (indicatorEl.clientHeight < 50) indicatorEl.classList.add('loading-icon-2px');
}
return;
}
if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) {
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else {
showErrorForResponse(respStatus, respText);
}
} catch (e) {
if (e.name === 'SyntaxError') {
showErrorForResponse(respStatus, (respText || '').substring(0, 100));
} else if (e.name !== 'AbortError') {
console.error('fetchActionDoRequest error', e);
showErrorForResponse(respStatus, `${e}`);
if ('disabled' in indicatorEl) {
indicatorEl.disabled = false;
} else {
indicatorEl.classList.remove('is-loading', 'loading-icon-2px');
}
}
}
actionElem.classList.remove('is-loading', 'loading-icon-2px');
}
async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) {
e.preventDefault();
await submitFormFetchAction(formEl, {formSubmitter: submitEventSubmitter(e)});
export async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
const redirect = respJson?.redirect;
if (typeof redirect === 'string' && redirect) {
fetchActionDoRedirect(redirect);
} else {
// reserved behavior, in the future, there can be more fields to introduce more behaviors
window.location.reload();
}
}
async function handleFetchActionSuccess(el: HTMLElement, opt: FetchActionOpts, resp: Response) {
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
const respText = await resp.text();
const respJson = isRespJson ? JSON.parse(respText) : null;
if (isRespJson) {
await handleFetchActionSuccessJson(el, respJson);
} else if (opt.successSync) {
await handleFetchActionSuccessSync(el, opt.successSync, respText);
} else {
showErrorToast(`Unsupported fetch action response, expected JSON but got: ${respText.substring(0, 200)}`);
}
}
async function handleFetchActionError(resp: Response) {
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
const respText = await resp.text();
const respJson = isRespJson ? JSON.parse(respText) : null;
if (respJson?.errorMessage) {
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else {
showErrorToast(`Error ${resp.status} ${resp.statusText}. Response: ${respText.substring(0, 200)}`);
}
}
function buildFetchActionUrl(el: HTMLElement, opt: FetchActionOpts) {
let url = opt.url;
if ('name' in el && 'value' in el) {
// ref: https://htmx.org/attributes/hx-get/
// If the element with the hx-get attribute also has a value, this will be included as a parameter
const name = (el as HTMLInputElement).name;
const val = (el as HTMLInputElement).value;
const u = new URL(url, window.location.href);
if (name && !u.searchParams.has(name)) {
u.searchParams.set(name, val);
url = u.href;
}
}
return url;
}
async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
const attrIsLoading = 'data-fetch-is-loading';
if (el.getAttribute(attrIsLoading)) return;
if (!await confirmFetchAction(opt.formSubmitter ?? el)) return;
el.setAttribute(attrIsLoading, 'true');
toggleLoadingIndicator(el, opt, true);
try {
const url = buildFetchActionUrl(el, opt);
const headers = new Headers(opt.headers);
headers.set('X-Gitea-Fetch-Action', '1');
const resp = await request(url, {method: opt.method, body: opt.body, headers});
if (resp.ok) {
await handleFetchActionSuccess(el, opt, resp);
return;
}
await handleFetchActionError(resp);
} catch (err) {
if (errorName(err) !== 'AbortError') {
console.error(`Fetch action request error:`, err);
showErrorToast(`Error: ${errorMessage(err)}`);
}
} finally {
toggleLoadingIndicator(el, opt, false);
el.removeAttribute(attrIsLoading);
}
}
type SubmitFormFetchActionOpts = {
formSubmitter?: HTMLElement;
formSubmitter?: HTMLElement | null;
formData?: FormData;
};
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
if (formEl.classList.contains('is-loading')) return;
formEl.classList.add('is-loading');
if (formEl.clientHeight < 50) {
formEl.classList.add('loading-icon-2px');
}
const formMethod = formEl.getAttribute('method') || 'get';
function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}): FetchActionOpts {
const formMethodUpper = formEl.getAttribute('method')?.toUpperCase() || 'GET';
const formActionUrl = formEl.getAttribute('action') || window.location.href;
const formData = opts.formData ?? new FormData(formEl);
const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')];
@@ -92,11 +164,8 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
}
let reqUrl = formActionUrl;
const reqOpt = {
method: formMethod.toUpperCase(),
body: null as FormData | null,
};
if (formMethod.toLowerCase() === 'get') {
let reqBody: FormData | undefined;
if (formMethodUpper === 'GET') {
const params = new URLSearchParams();
for (const [key, value] of formData) {
params.append(key, value as string);
@@ -107,25 +176,24 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
}
reqUrl += `?${params.toString()}`;
} else {
reqOpt.body = formData;
reqBody = formData;
}
await fetchActionDoRequest(formEl, reqUrl, reqOpt);
return {
method: formMethodUpper,
url: reqUrl,
body: reqBody,
formSubmitter: opts.formSubmitter,
loadingIndicator: '$this', // for form submit, by default, the loading indicator is the whole form
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
};
}
async function onLinkActionClick(el: HTMLElement, e: Event) {
// A "link-action" can post AJAX request to its "data-url"
// Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading.
// If the "link-action" has "data-modal-confirm" attribute, a "confirm modal dialog" will be shown before taking action.
// Attribute "data-modal-confirm" can be a modal element by "#the-modal-id", or a string content for the modal dialog.
e.preventDefault();
const url = el.getAttribute('data-url')!;
const doRequest = async () => {
if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but "A" doesn't have the "disabled" attribute
await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'});
if ('disabled' in el) el.disabled = false;
};
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
hideToastsAll();
await performActionRequest(formEl, prepareFormFetchActionOpts(formEl, opts));
}
async function confirmFetchAction(el: HTMLElement) {
let elModal: HTMLElement | null = null;
const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
if (dataModalConfirm.startsWith('#')) {
@@ -147,18 +215,204 @@ async function onLinkActionClick(el: HTMLElement, e: Event) {
});
}
}
if (!elModal) return true;
return await confirmModal(elModal);
}
if (!elModal) {
await doRequest();
async function performLinkFetchAction(el: HTMLElement) {
hideToastsAll();
await performActionRequest(el, {
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
url: el.getAttribute('data-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? '$this', // by default, the link-action itself is the loading indicator
successSync: el.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for link-action
});
}
type FetchActionTriggerType = 'click' | 'change' | 'every' | 'load' | 'fetch-reload';
export async function performFetchActionTrigger(el: HTMLElement, triggerType: FetchActionTriggerType) {
const isUserInitiated = triggerType === 'click' || triggerType === 'change';
// for user initiated action, by default, the loading indicator is the element itself, otherwise no loading indicator
const defaultLoadingIndicator = isUserInitiated ? '$this' : '';
if (isUserInitiated) hideToastsAll();
await performActionRequest(el, {
method: el.getAttribute('data-fetch-method') || 'GET', // by default, the method is GET for fetch trigger action
url: el.getAttribute('data-fetch-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? defaultLoadingIndicator,
successSync: el.getAttribute('data-fetch-sync') ?? '$this', // by default, the response will replace the current element
});
}
type PseudoSelectorCommandResult = {
targets: Element[];
cmdInnerHTML: boolean;
cmdMorph: boolean;
};
export function execPseudoSelectorCommands(el: Element, fullCommand: string): PseudoSelectorCommandResult {
const cmds = fullCommand.split(' ').map((s) => s.trim()).filter(Boolean) || [];
let targets = [el], cmdInnerHTML = false, cmdMorph = false;
for (const cmd of cmds) {
if (cmd === '$this') {
targets = [el];
} else if (cmd === '$body') {
targets = [document.body];
} else if (cmd === '$innerHTML') {
cmdInnerHTML = true;
} else if (cmd === '$morph') {
cmdMorph = true;
} else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
const selector = cmd.substring('$closest('.length, cmd.length - 1);
const newTargets: Element[] = [];
for (const target of targets) {
const closest = target.closest(selector);
if (closest) newTargets.push(closest);
}
targets = newTargets;
} else {
const newTargets: Element[] = [];
for (const target of targets) {
newTargets.push(...target.querySelectorAll(cmd));
}
targets = newTargets;
}
}
return {targets, cmdInnerHTML, cmdMorph};
}
async function handleFetchActionSuccessSync(el: Element, successSync: string, respText: string) {
const res = execPseudoSelectorCommands(el, successSync);
if (!res.targets.length) throw new Error(`Fetch-sync command "${successSync}" did not find any target element to update`);
if (res.targets.length > 1) throw new Error(`Fetch-sync command "${successSync}" found multiple target elements, which is not supported`);
const target = res.targets[0];
if (res.cmdMorph) {
Idiomorph.morph(target, respText, {morphStyle: res.cmdInnerHTML ? 'innerHTML' : 'outerHTML'});
} else if (res.cmdInnerHTML) {
target.innerHTML = respText;
} else {
target.outerHTML = respText;
}
await fetchActionReloadOutdatedElements();
}
async function fetchActionReloadOutdatedElements() {
const outdatedElems: HTMLElement[] = [];
for (const outdated of document.querySelectorAll<HTMLElement>('[data-fetch-trigger~="fetch-reload"]')) {
if (!outdated.id) throw new Error(`Elements with "fetch-reload" trigger must have an id to be reloaded after fetch sync: ${outdated.outerHTML.substring(0, 100)}`);
outdatedElems.push(outdated);
}
if (!outdatedElems.length) return;
const resp = await GET(window.location.href);
if (!resp.ok) {
showErrorToast(`Failed to reload page content after fetch action: ${resp.status} ${resp.statusText}`);
return;
}
const newPageHtml = await resp.text();
const newPageDom = parseDom(newPageHtml, 'text/html');
for (const oldEl of outdatedElems) {
// eslint-disable-next-line unicorn/prefer-query-selector
const newEl = newPageDom.getElementById(oldEl.id);
if (newEl) {
oldEl.replaceWith(newEl);
} else {
oldEl.remove();
}
}
}
if (await confirmModal(elModal)) {
await doRequest();
function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) {
const interval = trigger.substring('every '.length);
const match = /^(\d+)(ms|s)$/.exec(interval);
if (!match) throw new Error(`Invalid interval format: ${interval}`);
const num = parseInt(match[1], 10), unit = match[2];
const intervalMs = unit === 's' ? num * 1000 : num;
const fn = async () => {
try {
await performFetchActionTrigger(el, 'every');
} finally {
// only continue if the element is still in the document
if (document.contains(el)) {
setTimeout(fn, intervalMs);
}
}
};
setTimeout(fn, intervalMs);
}
function initFetchActionTrigger(el: HTMLElement) {
const trigger = el.getAttribute('data-fetch-trigger');
// this trigger is managed internally, only triggered after fetch sync success, not triggered by event or timer
if (trigger === 'fetch-reload') return;
if (trigger === 'load') {
performFetchActionTrigger(el, trigger);
} else if (trigger === 'change') {
el.addEventListener('change', () => performFetchActionTrigger(el, trigger));
} else if (trigger?.startsWith('every ')) {
initFetchActionTriggerEvery(el, trigger);
} else if (!trigger || trigger === 'click') {
el.addEventListener('click', (e) => {
e.preventDefault();
performFetchActionTrigger(el, 'click');
});
} else {
throw new Error(`Unsupported fetch trigger: ${trigger}`);
}
}
export function initGlobalFetchAction() {
addDelegatedEventListener(document, 'submit', '.form-fetch-action', onFormFetchActionSubmit);
addDelegatedEventListener(document, 'click', '.link-action', onLinkActionClick);
// The "fetch-action" framework is a general approach for elements to trigger fetch requests:
// show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success.
//
// If you need more fine-grained control more details, sometimes it's clearer to write the logic in JavaScript, instead of using this generic framework.
//
// Attributes:
//
// * data-fetch-method: the HTTP method to use
// * default to "GET" for "data-fetch-url" actions, "POST" for "link-action" elements
// * this attribute is ignored, the method will be determined by the form's "method" attribute, and default to "GET"
//
// * data-fetch-url: the URL for the request
//
// * data-fetch-trigger: the event to trigger the fetch action, can be:
// * "click", "change" (user-initiated events)
// * "load" (triggered on page load)
// * "every 5s" (also support "ms" unit)
// * "fetch-reload" (only triggered by fetch sync success to reload outdated content)
//
// * data-fetch-indicator: the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
//
// * data-fetch-sync: when the response is text (html), the pseudo selectors/commands defined in "data-fetch-sync"
// will be used to update the content in the current page. It only supports some simple syntaxes that we need.
// "$" prefix means it is our private command (for special logic), the selectors are run one by one from current element.
// * "$this": replace the current element with the response
// * "$innerHTML": replace innerHTML of the current element with the response, instead of replacing the whole element (outerHTML)
// * "$morph": use morph algorithm to update the target element
// * "$body #the-id .the-class": query the selector one by one from body
// * "$closest(tr) td": pseudo command can help to find the target element in a more flexible way
//
// * data-modal-confirm: a "confirm modal dialog" will be shown before taking action.
// * it can be a string for the content of the modal dialog
// * it has "-header" and "-content" variants to set the header and content of the "confirm modal"
// * it can refer an existing modal element by "#the-modal-id"
addDelegatedEventListener<HTMLFormElement, SubmitEvent>(document, 'submit', '.form-fetch-action', async (el, e) => {
// "fetch-action" will use the form's data to send the request
e.preventDefault();
await submitFormFetchAction(el, {formSubmitter: e.submitter});
});
addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => {
// `<a class="link-action" data-url="...">` is a shorthand for
// `<a data-fetch-trigger="click" data-fetch-method="post" data-fetch-url="..." data-fetch-indicator="$this">`
e.preventDefault();
await performLinkFetchAction(el);
});
registerGlobalSelectorFunc('[data-fetch-url]', initFetchActionTrigger);
}
@@ -16,17 +16,13 @@ export function initGlobalEnterQuickSubmit() {
document.addEventListener('keydown', (e) => {
if (e.isComposing) return;
if (e.key !== 'Enter') return;
const el = e.target as HTMLElement;
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
if (hasCtrlOrMeta && (e.target as HTMLElement).matches('textarea')) {
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
e.preventDefault();
}
} else if ((e.target as HTMLElement).matches('input') && !(e.target as HTMLElement).closest('form')) {
// input in a normal form could handle Enter key by default, so we only handle the input outside a form
// eslint-disable-next-line unicorn/no-lonely-if
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
e.preventDefault();
}
const isCtrlEnterInTextarea = hasCtrlOrMeta && el.matches('textarea');
// an input in a normal form could handle Enter key by default, so we only handle the input outside a form
const isEnterInBareInput = el.matches('input') && !el.closest('form');
if ((isCtrlEnterInTextarea || isEnterInBareInput) && handleGlobalEnterQuickSubmit(el)) {
e.preventDefault();
}
});
}
@@ -35,7 +35,7 @@ export function initCommonIssueListQuickGoto() {
const repoLink = elGotoButton.getAttribute('data-repo-link') || '';
elGotoButton.addEventListener('click', () => {
window.location.href = elGotoButton.getAttribute('data-issue-goto-link')!;
window.location.assign(elGotoButton.getAttribute('data-issue-goto-link')!);
});
const onInput = async () => {
@@ -1,10 +1,12 @@
import {GET, POST} from '../modules/fetch.ts';
import {showGlobalErrorMessage} from '../modules/errors.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {initTabSwitcher} from '../modules/fomantic/tab.ts';
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
import {initCompSearchRepoBox} from './comp/SearchRepoBox.ts';
import {initScopedWorkflowRequired} from './comp/ScopedWorkflows.ts';
const {appUrl, appSubUrl} = window.config;
@@ -100,9 +102,10 @@ export function initGlobalDropdown() {
}
export function initGlobalComponent() {
fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab();
registerGlobalInitFunc('initTabSwitcher', initTabSwitcher);
registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper);
registerGlobalInitFunc('initSearchRepoBox', initCompSearchRepoBox);
registerGlobalInitFunc('initScopedWorkflowRequired', initScopedWorkflowRequired);
}
// for performance considerations, it only uses performant syntax
@@ -23,7 +23,7 @@ import {
} from './EditorMarkdown.ts';
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
import {createTippy} from '../../modules/tippy.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {initTabSwitcher} from '../../modules/fomantic/tab.ts';
import type EasyMDE from 'easymde';
import {localUserSettings} from '../../modules/user-settings.ts';
@@ -71,26 +71,26 @@ export class ComboMarkdownEditor {
options: ComboMarkdownEditorOptions;
tabEditor: HTMLElement;
tabPreviewer: HTMLElement;
tabEditor?: HTMLElement;
tabPreviewer?: HTMLElement;
supportEasyMDE: boolean;
supportEasyMDE!: boolean;
easyMDE: any;
easyMDEToolbarActions: any;
easyMDEToolbarDefault: any;
textarea: ComboMarkdownEditorTextarea;
textareaMarkdownToolbar: HTMLElement;
textarea!: ComboMarkdownEditorTextarea;
textareaMarkdownToolbar!: HTMLElement;
textareaAutosize: any;
buttonMonospace: HTMLButtonElement;
buttonMonospace!: HTMLButtonElement;
dropzone: HTMLElement | null;
dropzone: HTMLElement | null = null;
attachedDropzoneInst: any;
previewMode: string;
previewUrl: string;
previewContext: string;
previewMode!: string;
previewUrl!: string;
previewContext!: string;
constructor(container: ComboMarkdownEditorContainer, options:ComboMarkdownEditorOptions = {}) {
if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
@@ -204,22 +204,21 @@ export class ComboMarkdownEditor {
}
setupTab() {
const tabs = this.container.querySelectorAll<HTMLElement>('.tabular.menu > .item');
if (!tabs.length) return;
const elTabular = this.container.querySelector('.ui.tabular');
if (!elTabular) return;
this.tabEditor = this.container.querySelector('[data-tab-for="markdown-writer"]')!;
this.tabPreviewer = this.container.querySelector('[data-tab-for="markdown-previewer"]')!;
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
// Fomantic Tab requires the "data-tab" to be globally unique.
// So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
const tabIdSuffix = generateElemId();
const tabsArr = Array.from(tabs);
this.tabEditor = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer')!;
this.tabPreviewer = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer')!;
this.tabEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
this.tabPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
panelEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
panelPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
initTabSwitcher(elTabular);
this.tabEditor.addEventListener('click', () => {
requestAnimationFrame(() => {
@@ -227,8 +226,6 @@ export class ComboMarkdownEditor {
});
});
fomanticQuery(tabs).tab();
this.tabPreviewer.addEventListener('click', async () => {
const formData = new FormData();
formData.append('mode', this.previewMode);
@@ -291,7 +288,7 @@ export class ComboMarkdownEditor {
}
switchTabToEditor() {
this.tabEditor.click();
this.tabEditor!.click(); // when this function is called, the tab must exist
}
prepareEasyMDEToolbarActions() {
@@ -1,7 +1,7 @@
import {svg} from '../../svg.ts';
import {html, htmlRaw} from '../../utils/html.ts';
import {createElementFromHTML} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {hideToastsAll} from '../../modules/toast.ts';
const {i18n} = window.config;
@@ -32,15 +32,14 @@ export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<
// it's fine to do so because the modal is triggered by user's explicit action, so the user should already have read the toast messages
hideToastsAll();
return new Promise((resolve) => {
const $modal = fomanticQuery(modal);
$modal.modal({
showFomanticModal(modal, {
onApprove() {
resolve(true);
},
onHidden() {
$modal.remove();
modal.remove();
resolve(false);
},
}).modal('show');
});
});
}
@@ -8,7 +8,7 @@ import {
import {subscribe} from '@github/paste-markdown';
import type CodeMirror from 'codemirror';
import type EasyMDE from 'easymde';
import type {DropzoneFile} from 'dropzone';
import type Dropzone from '@deltablot/dropzone';
let uploadIdCounter = 0;
@@ -31,7 +31,7 @@ function uploadFile(dropzoneEl: HTMLElement, file: File) {
};
dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone);
// FIXME: this is not entirely correct because `file` does not satisfy DropzoneFile (we have abused the Dropzone for long time)
dropzoneInst.addFile(file as DropzoneFile);
dropzoneInst.addFile(file as Dropzone.DropzoneFile);
});
}
@@ -1,5 +1,5 @@
import {toggleElem} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {submitFormFetchAction} from '../common-fetch-action.ts';
function nameHasScope(name: string): boolean {
@@ -65,7 +65,7 @@ export function initCompLabelEdit(pageSelector: string) {
form.action = isEdit ? `${curPageLink}/edit` : `${curPageLink}/new`;
toggleElem(elIsArchivedField, isEdit);
syncModalUi();
fomanticQuery(elModal).modal({
showFomanticModal(elModal, {
onApprove() {
if (!form.checkValidity()) {
form.reportValidity();
@@ -74,7 +74,7 @@ export function initCompLabelEdit(pageSelector: string) {
submitFormFetchAction(form);
return false;
},
}).modal('show');
});
};
elModal.addEventListener('input', () => syncModalUi());
@@ -0,0 +1,104 @@
import {initScopedWorkflowRequired} from './ScopedWorkflows.ts';
function setupForm(required = false) {
window.document.body.innerHTML = `
<form>
<table><tbody>
<tr>
<td>ci.yaml<input type="hidden" name="workflow_ids" value="ci.yaml"></td>
<td><div class="ui checkbox"><input type="checkbox" class="js-scoped-required-toggle" ${required ? 'checked' : ''}><label></label></div></td>
<td>
<textarea class="js-scoped-required-patterns${required ? '' : ' tw-hidden'}" data-default-pattern="org/src: CI / *">${required ? 'org/src: CI / *' : ''}</textarea>
<span class="js-scoped-required-hint${required ? ' tw-hidden' : ''}">hint</span>
</td>
</tr>
</tbody></table>
</form>`;
const form = document.querySelector('form')!;
const checkbox = form.querySelector<HTMLInputElement>('.js-scoped-required-toggle')!;
const textarea = form.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
const hint = form.querySelector<HTMLElement>('.js-scoped-required-hint')!;
return {form, checkbox, textarea, hint};
}
test('required toggle shows/prefills the patterns textarea (and hides the hint) and reverses otherwise, keeping the value', () => {
const {form, checkbox, textarea, hint} = setupForm();
initScopedWorkflowRequired(form);
expect(textarea.classList.contains('tw-hidden')).toBe(true); // initial: not required -> textarea hidden
expect(hint.classList.contains('tw-hidden')).toBe(false); // ... and the hint shown in its place
// check -> textarea shown and prefilled; hint hidden
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
expect(textarea.classList.contains('tw-hidden')).toBe(false);
expect(hint.classList.contains('tw-hidden')).toBe(true);
expect(textarea.value).toBe('org/src: CI / *');
// admin edits the pattern
textarea.value = 'org/src: CI / build (pull_request)';
// uncheck -> textarea hidden (value kept, still submits as history), hint shown again
checkbox.checked = false;
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
expect(textarea.classList.contains('tw-hidden')).toBe(true);
expect(hint.classList.contains('tw-hidden')).toBe(false);
expect(textarea.value).toBe('org/src: CI / build (pull_request)');
// re-check -> shown again with the same value (not re-prefilled to the default)
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
expect(textarea.classList.contains('tw-hidden')).toBe(false);
expect(textarea.value).toBe('org/src: CI / build (pull_request)');
});
test('an already-required row stays shown with its stored patterns (not re-prefilled)', () => {
const {form, textarea} = setupForm(true);
textarea.value = 'org/src: custom / build (push)'; // a stored, admin-edited pattern
initScopedWorkflowRequired(form);
expect(textarea.classList.contains('tw-hidden')).toBe(false);
expect(textarea.value).toBe('org/src: custom / build (push)');
});
function setupFormWithContexts(patterns: string) {
window.document.body.innerHTML = `
<form>
<table><tbody>
<tr>
<td>ci.yaml<input type="hidden" name="workflow_ids" value="ci.yaml"></td>
<td><div class="ui checkbox"><input type="checkbox" class="js-scoped-required-toggle" checked><label></label></div></td>
<td>
<textarea class="js-scoped-required-patterns" data-default-pattern="org/src: CI / *">${patterns}</textarea>
<span class="js-scoped-required-hint tw-hidden">hint</span>
<table class="js-scoped-required-contexts"><tbody>
<tr><td><span class="js-scoped-context" data-context="org/src: CI / lint (push)"></span><span class="js-scoped-context-matched tw-hidden">Matched</span></td></tr>
<tr><td><span class="js-scoped-context" data-context="org/src: CI / build (push)"></span><span class="js-scoped-context-matched tw-hidden">Matched</span></td></tr>
</tbody></table>
</td>
</tr>
</tbody></table>
</form>`;
const form = document.querySelector('form')!;
const [lintMark, buildMark] = Array.from(form.querySelectorAll<HTMLElement>('.js-scoped-context-matched'));
return {form, lintMark, buildMark};
}
test('an exact pattern marks only the context it matches', () => {
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: CI / lint (push)');
initScopedWorkflowRequired(form);
expect(lintMark.classList.contains('tw-hidden')).toBe(false); // matched
expect(buildMark.classList.contains('tw-hidden')).toBe(true); // not matched
});
test('a wildcard pattern marks every matching context', () => {
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: CI / *');
initScopedWorkflowRequired(form);
expect(lintMark.classList.contains('tw-hidden')).toBe(false);
expect(buildMark.classList.contains('tw-hidden')).toBe(false);
});
test('a wildcard crossing "/" matches every matching context', () => {
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: *');
initScopedWorkflowRequired(form);
expect(lintMark.classList.contains('tw-hidden')).toBe(false);
expect(buildMark.classList.contains('tw-hidden')).toBe(false);
});
@@ -0,0 +1,38 @@
import {addDelegatedEventListener, onInputDebounce, toggleElem} from '../../utils/dom.ts';
import {globMatch} from '../../utils/glob.ts';
// markRowMatchedContexts marks each expected status-check context whose row's textarea patterns match it.
function markRowMatchedContexts(row: HTMLElement) {
const textarea = row.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
const patterns = textarea.value.split(/[\r\n]+/).map((p) => p.trim()).filter(Boolean);
for (const ctxEl of row.querySelectorAll<HTMLElement>('.js-scoped-context')) {
const context = ctxEl.getAttribute('data-context')!;
const matched = patterns.some((p) => globMatch(context, p));
toggleElem(ctxEl.parentElement!.querySelector('.js-scoped-context-matched')!, matched);
}
}
// syncScopedRequiredRow shows a scoped workflow's status-check patterns textarea (and its expected-checks preview) only while the workflow is required.
function syncScopedRequiredRow(checkbox: HTMLInputElement) {
const row = checkbox.closest('tr')!;
const textarea = row.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
toggleElem(textarea, checkbox.checked);
toggleElem(row.querySelector('.js-scoped-required-hint')!, !checkbox.checked); // the "mark as required" hint shown in the textarea's place
const contexts = row.querySelector('.js-scoped-required-contexts'); // only rendered when the workflow has expected checks
if (contexts) toggleElem(contexts, checkbox.checked);
if (checkbox.checked && !textarea.value.trim()) {
textarea.value = textarea.getAttribute('data-default-pattern')!;
}
if (checkbox.checked) markRowMatchedContexts(row);
}
export function initScopedWorkflowRequired(form: HTMLElement) {
for (const checkbox of form.querySelectorAll<HTMLInputElement>('.js-scoped-required-toggle')) {
syncScopedRequiredRow(checkbox);
}
for (const textarea of form.querySelectorAll<HTMLTextAreaElement>('.js-scoped-required-patterns')) {
const row = textarea.closest('tr')!;
textarea.addEventListener('input', onInputDebounce(() => markRowMatchedContexts(row)));
}
addDelegatedEventListener(form, 'change', '.js-scoped-required-toggle', (checkbox: HTMLInputElement) => syncScopedRequiredRow(checkbox));
}
@@ -1,31 +1,18 @@
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {htmlEscape} from '../../utils/html.ts';
import {attachSearchBox} from '../../modules/search.ts';
const {appSubUrl} = window.config;
type RepoSearchResponse = {data: Array<{repository: {full_name: string}}>};
export function initCompSearchRepoBox(el: HTMLElement) {
const uid = el.getAttribute('data-uid');
const exclusive = el.getAttribute('data-exclusive');
// when set, the selected value is the full "owner/name" rather than the bare repo name, so a cross-owner search can be resolved unambiguously
const fullName = el.getAttribute('data-full-name') === 'true';
let url = `${appSubUrl}/repo/search?q={query}&uid=${uid}`;
if (exclusive === 'true') {
url += `&exclusive=true`;
}
fomanticQuery(el).search({
minCharacters: 2,
apiSettings: {
url,
onResponse(response: any) {
const items = [];
for (const item of response.data) {
items.push({
title: htmlEscape(item.repository.full_name.split('/')[1]),
description: htmlEscape(item.repository.full_name),
});
}
return {results: items};
},
},
searchFields: ['full_name'],
showNoResults: false,
});
if (exclusive === 'true') url += `&exclusive=true`;
attachSearchBox(el, url, (response: RepoSearchResponse) => response.data.map((item) => ({
title: fullName ? item.repository.full_name : item.repository.full_name.split('/')[1],
description: item.repository.full_name,
})));
}
@@ -1,49 +1,30 @@
import {htmlEscape} from '../../utils/html.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import {attachSearchBox, type SearchResult} from '../../modules/search.ts';
const {appSubUrl} = window.config;
const looksLikeEmailAddressCheck = /^\S+@\S+$/;
type UserSearchResponse = {data: Array<{login: string; avatar_url: string; full_name: string}>};
export function initCompSearchUserBox() {
const searchUserBox = document.querySelector('#search-user-box');
if (!searchUserBox) return;
const box = document.querySelector<HTMLElement>('#search-user-box');
if (!box) return;
const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true';
const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined;
const includeOrgs = searchUserBox.getAttribute('data-include-orgs') === 'true';
fomanticQuery(searchUserBox).search({
minCharacters: 2,
apiSettings: {
url: `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`,
onResponse(response: any) {
const resultItems = [];
const searchQuery = searchUserBox.querySelector('input')!.value;
const searchQueryUppercase = searchQuery.toUpperCase();
for (const item of response.data) {
const resultItem = {
title: item.login,
image: item.avatar_url,
description: htmlEscape(item.full_name),
};
if (searchQueryUppercase === item.login.toUpperCase()) {
resultItems.unshift(resultItem); // add the exact match to the top
} else {
resultItems.push(resultItem);
}
}
const allowEmailInput = box.getAttribute('data-allow-email') === 'true';
const allowEmailDescription = box.getAttribute('data-allow-email-description') ?? undefined;
const includeOrgs = box.getAttribute('data-include-orgs') === 'true';
const url = `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`;
if (allowEmailInput && !resultItems.length && looksLikeEmailAddressCheck.test(searchQuery)) {
const resultItem = {
title: searchQuery,
description: allowEmailDescription,
};
resultItems.push(resultItem);
}
return {results: resultItems};
},
},
searchFields: ['login', 'full_name'],
showNoResults: false,
attachSearchBox(box, url, (response: UserSearchResponse, query) => {
const items: SearchResult[] = [];
const queryUpper = query.toUpperCase();
for (const item of response.data) {
const result: SearchResult = {title: item.login, image: item.avatar_url, description: item.full_name};
if (queryUpper === item.login.toUpperCase()) items.unshift(result); // exact match floats to top
else items.push(result);
}
if (allowEmailInput && !items.length && looksLikeEmailAddressCheck.test(query)) {
items.push({title: query, description: allowEmailDescription});
}
return items;
});
}
@@ -37,11 +37,6 @@ export function initCompWebHookEditor() {
document.querySelector<HTMLButtonElement>('#test-delivery')?.addEventListener('click', async function () {
this.classList.add('is-loading', 'disabled');
await POST(this.getAttribute('data-link')!);
setTimeout(() => {
const redirectUrl = this.getAttribute('data-redirect');
if (redirectUrl) {
window.location.href = redirectUrl;
}
}, 5000);
setTimeout(() => window.location.reload(), 5000);
});
}
@@ -20,6 +20,7 @@ export async function initRepoContributors() {
loadingTitle: el.getAttribute('data-locale-loading-title'),
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
loadingInfo: el.getAttribute('data-locale-loading-info'),
chartZoomHint: el.getAttribute('data-locale-chart-zoom-hint'),
},
});
View.mount(el);
@@ -1,54 +1,25 @@
import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
import {convertImage} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
import {registerGlobalEventFunc} from '../modules/observer.ts';
const {i18n} = window.config;
export function initCopyContent() {
registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => {
if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return;
const rawFileLink = btn.getAttribute('data-raw-file-link');
let content, isRasterImage = false;
// when "data-raw-link" is present, we perform a fetch. this is either because
// the text to copy is not in the DOM, or it is an image that should be
// fetched to copy in full resolution
if (rawFileLink) {
btn.classList.add('is-loading', 'loading-icon-2px');
try {
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
const contentType = res.headers.get('content-type')!;
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
isRasterImage = true;
content = await res.blob();
} else {
content = await res.text();
}
} catch {
return showTemporaryTooltip(btn, i18n.copy_error);
} finally {
btn.classList.remove('is-loading', 'loading-icon-2px');
await copyToClipboardWithFeedback(btn, async () => {
const rawFileLink = btn.getAttribute('data-raw-file-link');
if (!rawFileLink) {
const lineEls = document.querySelectorAll('.file-view .lines-code');
return Array.from(lineEls, (el) => el.textContent).join('');
}
} else { // text, read from DOM
const lineEls = document.querySelectorAll('.file-view .lines-code');
content = Array.from(lineEls, (el) => el.textContent).join('');
}
// try copy original first, if that fails, and it's an image, convert it to png
const success = await clippie(content);
if (success) {
showTemporaryTooltip(btn, i18n.copy_success);
} else {
if (isRasterImage) {
const success = await clippie(await convertImage(content as Blob, 'image/png'));
showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error);
} else {
showTemporaryTooltip(btn, i18n.copy_error);
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
const contentType = res.headers.get('content-type')!;
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
// browsers only accept image/png in the clipboard, convert other raster formats
const blob = await res.blob();
return contentType === 'image/png' ? blob : convertImage(blob, 'image/png');
}
}
return await res.text();
});
});
}
@@ -1,26 +1,24 @@
import {svg} from '../svg.ts';
import {svgRaw} from '../svg.ts';
import {html} from '../utils/html.ts';
import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {isImageFile, isVideoFile} from '../utils.ts';
import type {DropzoneFile, DropzoneOptions} from 'dropzone/index.js';
import type Dropzone from '@deltablot/dropzone';
const {i18n} = window.config;
type CustomDropzoneFile = DropzoneFile & {uuid: string};
type CustomDropzoneFile = Dropzone.DropzoneFile & {uuid: string};
// dropzone has its owner event dispatcher (emitter)
export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files';
export const DropzoneCustomEventRemovedFile = 'dropzone-custom-removed-file';
export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done';
async function createDropzone(el: HTMLElement, opts: DropzoneOptions) {
async function createDropzone(el: HTMLElement, opts: Dropzone.DropzoneOptions) {
const [{default: Dropzone}] = await Promise.all([
import('dropzone'),
import('dropzone/dist/dropzone.css'),
import('@deltablot/dropzone'),
import('@deltablot/dropzone/dist/dropzone.css'),
]);
return new Dropzone(el, opts);
}
@@ -47,14 +45,14 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi
function addCopyLink(file: Partial<CustomDropzoneFile>) {
// Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard
// The "<a>" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
const copyLinkEl = createElementFromHTML(`
<div class="tw-text-center">
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
</div>`);
const copyLinkEl = createElementFromHTML<HTMLDivElement>(html`
<div class="tw-text-center">
<a href="#" class="tw-cursor-pointer">${svgRaw('octicon-copy', 14)} Copy link</a>
</div>
`);
copyLinkEl.addEventListener('click', async (e) => {
e.preventDefault();
const success = await clippie(generateMarkdownLinkForAttachment(file));
showTemporaryTooltip(e.target as Element, success ? i18n.copy_success : i18n.copy_error);
await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file));
});
file.previewTemplate!.append(copyLinkEl);
}
@@ -112,8 +110,8 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
});
dzInst.on('submit', () => {
for (const fileUuid of Object.keys(fileUuidDict)) {
fileUuidDict[fileUuid].submitted = true;
for (const value of Object.values(fileUuidDict)) {
value.submitted = true;
}
});
@@ -149,7 +147,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
} catch (error) {
// TODO: if listing the existing attachments failed, it should stop from operating the content or attachments,
// otherwise the attachments might be lost.
showErrorToast(`Failed to load attachments: ${error}`);
showErrorToast(`Failed to load attachments: ${errorMessage(error)}`);
console.error(error);
}
});
@@ -2,6 +2,7 @@ import type {InplaceRenderPlugin} from '../render/plugin.ts';
import {newInplacePluginPdfViewer} from '../render/plugins/inplace-pdf-viewer.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {html} from '../utils/html.ts';
import {basename} from '../utils.ts';
@@ -30,7 +31,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
rendered = true;
}
} catch (e) {
errorMsg = `${e}`;
errorMsg = errorMessage(e);
} finally {
container.classList.remove('is-loading');
}
@@ -24,8 +24,8 @@ export async function initHeatmap() {
heatmap[dateStr] = (heatmap[dateStr] || 0) + contributions;
}
const values = Object.keys(heatmap).map((v) => {
return {date: new Date(v), count: heatmap[v]};
const values = Object.entries(heatmap).map(([dateStr, count]) => {
return {date: new Date(dateStr), count};
});
const totalFormatted = totalContributions.toLocaleString();
@@ -1,7 +1,6 @@
import {GET} from '../modules/fetch.ts';
import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts';
import {parseDom} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
type ImageContext = {
imageBefore: HTMLImageElement | undefined,
@@ -94,15 +93,13 @@ function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageEleme
}
class ImageDiff {
containerEl: HTMLElement;
diffContainerWidth: number;
containerEl!: HTMLElement;
diffContainerWidth!: number;
async init(containerEl: HTMLElement) {
this.containerEl = containerEl;
containerEl.setAttribute('data-image-diff-loaded', 'true');
fomanticQuery(containerEl).find('.ui.menu.tabular .item').tab();
// the container may be hidden by "viewed" checkbox, so use the parent's width for reference
this.diffContainerWidth = Math.max(containerEl.closest('.diff-file-box')!.clientWidth - 300, 100);
@@ -294,7 +291,7 @@ class ImageDiff {
function updateOpacity() {
if (ctx.imageAfter) {
(ctx.imageAfter.parentNode as HTMLElement).style.opacity = `${Number(rangeInput.value) / 100}`;
(ctx.imageAfter.parentNode as HTMLElement).style.opacity = String(Number(rangeInput.value) / 100);
}
}
@@ -95,7 +95,7 @@ function initPostInstall() {
if (tid && resp.status === 200) {
clearInterval(tid);
tid = null;
window.location.href = targetUrl;
window.location.assign(targetUrl);
}
} catch {}
}, 1000);
@@ -10,7 +10,7 @@ async function receiveUpdateCount(event: MessageEvent<{type: string, data: strin
const data = JSON.parse(event.data.data);
for (const count of document.querySelectorAll('.notification_count')) {
count.classList.toggle('tw-hidden', data.Count === 0);
count.textContent = `${data.Count}`;
count.textContent = String(data.Count);
}
await updateNotificationTable();
} catch (error) {
@@ -76,28 +76,26 @@ async function updateNotificationCountWithCallback(callback: (timeout: number, n
}
async function updateNotificationTable() {
let notificationDiv = document.querySelector('#notification_div');
if (notificationDiv) {
try {
const params = new URLSearchParams(window.location.search);
params.set('div-only', 'true');
params.set('sequence-number', String(++notificationSequenceNumber));
const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
const notificationDiv = document.querySelector('#notification_div');
if (!notificationDiv) return;
if (!response.ok) {
throw new Error('Failed to fetch notification table');
}
try {
const params = new URLSearchParams(window.location.search);
params.set('div-only', 'true');
params.set('sequence-number', String(++notificationSequenceNumber));
const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
const data = await response.text();
const el = createElementFromHTML(data);
if (parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber) {
notificationDiv.outerHTML = data;
notificationDiv = document.querySelector('#notification_div')!;
window.htmx.process(notificationDiv); // when using htmx, we must always remember to process the new content changed by us
}
} catch (error) {
console.error(error);
if (!response.ok) {
throw new Error('Failed to fetch notification table');
}
const data = await response.text();
const el = createElementFromHTML(data);
if (parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber) {
notificationDiv.outerHTML = data;
}
} catch (error) {
console.error(error);
}
}
@@ -114,7 +112,7 @@ async function updateNotificationCount(): Promise<number> {
toggleElem('.notification_count', data.new !== 0);
for (const el of document.querySelectorAll('.notification_count')) {
el.textContent = `${data.new}`;
el.textContent = String(data.new);
}
return data.new as number;
@@ -0,0 +1,75 @@
import {parseIssueHref} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
import {createApp} from 'vue';
import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts';
import {addDelegatedEventListener} from '../utils/dom.ts';
import type {Issue} from '../types.ts';
type IssueInfo = {
convertedIssue: Issue,
renderedLabels: string,
};
const issueInfoCache = new Map<string, IssueInfo>();
async function getIssueInfo(url: string): Promise<IssueInfo> {
if (issueInfoCache.has(url)) return issueInfoCache.get(url)!;
const resp = await GET(url);
if (!resp.ok) throw new Error(resp.statusText || 'Unknown network error');
const data = await resp.json();
issueInfoCache.set(url, data);
return data;
}
async function showRefIssuePopup(link: HTMLAnchorElement) {
const [data, {default: ContextPopup}] = await Promise.all([
getIssueInfo(`${link.pathname}/info`),
import('../components/ContextPopup.vue'),
]);
const el = document.createElement('div');
const app = createApp(ContextPopup, {
issue: data.convertedIssue,
renderedLabels: data.renderedLabels,
});
app.mount(el);
// suppress ancestor title like from .commit-summary to prevent double tooltip
link.title = '';
createTippy(link, {
theme: 'default',
content: el,
trigger: 'mouseenter focus',
placement: 'top-start',
interactive: true,
role: 'dialog',
interactiveBorder: 5,
onDestroy: () => app.unmount(),
}).show();
}
export function initRefIssueContextPopup() {
const selector = 'a[href]:not([data-ref-issue-popup]):not(.ref-external-issue)';
addDelegatedEventListener<HTMLAnchorElement, MouseEvent>(document, 'mouseover', selector, (link) => {
if (!parseIssueHref(link.getAttribute('href')!).ownerName) return;
if (!link.classList.contains('ref-issue') && !link.closest('[data-ref-issue-container]')) return;
if (getAttachedTippyInstance(link)) return;
link.setAttribute('data-ref-issue-popup', '');
// delay so a mouse passing over the link doesn't fire a fetch
let timer: ReturnType<typeof setTimeout>;
const cancel = () => {
clearTimeout(timer);
link.removeAttribute('data-ref-issue-popup');
link.removeEventListener('mouseleave', cancel);
};
timer = setTimeout(async () => {
link.removeEventListener('mouseleave', cancel);
try {
await showRefIssuePopup(link);
} catch (err) {
console.error('Failed to load issue info:', err);
link.removeAttribute('data-ref-issue-popup');
}
}, 300);
link.addEventListener('mouseleave', cancel);
});
}
@@ -0,0 +1,30 @@
import {updateWorkflowBadgeFields} from './repo-actions.ts';
test('updateWorkflowBadgeFields updates badge snippets for selected branch', () => {
document.body.innerHTML = `
<div
data-badge-url="https://gitea.example.com/user1/repo1/actions/workflows/build/test%20workflow.yml/badge.svg?branch=main"
data-workflow-url="https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml"
data-workflow-display-name="CI [prod]\\build &quot;fast&quot; &lt;ok&gt;"
>
<img data-workflow-badge-image src="">
<input id="workflow-badge-url" readonly>
<textarea id="workflow-badge-markdown" readonly></textarea>
<textarea id="workflow-badge-html" readonly></textarea>
</div>
`;
const form = document.querySelector<HTMLElement>('[data-badge-url]')!;
updateWorkflowBadgeFields(form, 'release/1.0 & hotfix');
const badgeURL = 'https://gitea.example.com/user1/repo1/actions/workflows/build/test%20workflow.yml/badge.svg?branch=release%2F1.0+%26+hotfix';
expect(form.querySelector<HTMLImageElement>('[data-workflow-badge-image]')!.src).toBe(badgeURL);
expect(form.querySelector<HTMLInputElement>('#workflow-badge-url')!.value).toBe(badgeURL);
expect(form.querySelector<HTMLTextAreaElement>('#workflow-badge-markdown')!.value).toBe(
`[![CI \\[prod\\]\\\\build "fast" <ok>](${badgeURL})](https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml)`,
);
expect(form.querySelector<HTMLTextAreaElement>('#workflow-badge-html')!.value).toBe(
`<a href="https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml"><img src="${badgeURL}" alt="CI [prod]\\build &quot;fast&quot; &lt;ok&gt;"></a>`,
);
});
@@ -1,11 +1,37 @@
import {createApp} from 'vue';
import RepoActionView from '../components/RepoActionView.vue';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {html} from '../utils/html.ts';
export function initRepositoryActionView() {
export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void {
const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!);
badgeURLParsed.searchParams.set('branch', branch);
const badgeURL = badgeURLParsed.href;
const workflowURL = form.getAttribute('data-workflow-url')!;
const displayName = form.getAttribute('data-workflow-display-name')!;
const markdownAltText = displayName.replaceAll(/[\\[\]]/g, (c) => `\\${c}`);
form.querySelector<HTMLImageElement>('[data-workflow-badge-image]')!.src = badgeURL;
form.querySelector<HTMLInputElement>('#workflow-badge-url')!.value = badgeURL;
form.querySelector<HTMLTextAreaElement>('#workflow-badge-markdown')!.value = `[![${markdownAltText}](${badgeURL})](${workflowURL})`;
form.querySelector<HTMLTextAreaElement>('#workflow-badge-html')!.value = html`<a href="${workflowURL}"><img src="${badgeURL}" alt="${displayName}"></a>`;
}
function initWorkflowBadgeForm(form: HTMLElement): void {
const branchInput = form.querySelector<HTMLInputElement>('[data-workflow-badge-branch]')!;
branchInput.addEventListener('change', () => updateWorkflowBadgeFields(form, branchInput.value));
updateWorkflowBadgeFields(form, branchInput.value);
}
export function initRepositoryActions() {
registerGlobalInitFunc('initWorkflowBadgeForm', initWorkflowBadgeForm);
initRepositoryActionsView();
}
function initRepositoryActionsView() {
const el = document.querySelector('#repo-action-view');
if (!el) return;
const runId = parseInt(el.getAttribute('data-run-id')!);
const jobId = parseInt(el.getAttribute('data-job-id')!);
// TODO: the parent element's full height doesn't work well now,
// but we can not pollute the global style at the moment, only fix the height problem for pages with this component
@@ -13,35 +39,46 @@ export function initRepositoryActionView() {
if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0');
const view = createApp(RepoActionView, {
runId,
jobId,
actionsUrl: el.getAttribute('data-actions-url'),
jobId: parseInt(el.getAttribute('data-job-id')!),
actionsViewUrl: el.getAttribute('data-actions-view-url'),
locale: {
approve: el.getAttribute('data-locale-approve'),
cancel: el.getAttribute('data-locale-cancel'),
rerun: el.getAttribute('data-locale-rerun'),
rerun_all: el.getAttribute('data-locale-rerun-all'),
rerun_failed: el.getAttribute('data-locale-rerun-failed'),
latest: el.getAttribute('data-locale-latest'),
latestAttempt: el.getAttribute('data-locale-latest-attempt'),
attempt: el.getAttribute('data-locale-attempt'),
scheduled: el.getAttribute('data-locale-runs-scheduled'),
commit: el.getAttribute('data-locale-runs-commit'),
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
workflowGraph: el.getAttribute('data-locale-runs-workflow-graph'),
summary: el.getAttribute('data-locale-summary'),
allJobs: el.getAttribute('data-locale-all-jobs'),
jobSummaries: el.getAttribute('data-locale-job-summaries'),
expandCallerJobs: el.getAttribute('data-locale-expand-caller-jobs'),
collapseCallerJobs: el.getAttribute('data-locale-collapse-caller-jobs'),
triggeredVia: el.getAttribute('data-locale-triggered-via'),
rerunTriggered: el.getAttribute('data-locale-rerun-triggered'),
backToPullRequest: el.getAttribute('data-locale-back-to-pull-request'),
backToWorkflow: el.getAttribute('data-locale-back-to-workflow'),
statusLabel: el.getAttribute('data-locale-status-label'),
totalDuration: el.getAttribute('data-locale-total-duration'),
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
areYouSure: el.getAttribute('data-locale-are-you-sure'),
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
artifactExpiredAt: el.getAttribute('data-locale-artifact-expired-at'),
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
showFullScreen: el.getAttribute('data-locale-show-full-screen'),
downloadLogs: el.getAttribute('data-locale-download-logs'),
copyOutput: el.getAttribute('data-locale-copy-output'),
status: {
unknown: el.getAttribute('data-locale-status-unknown'),
waiting: el.getAttribute('data-locale-status-waiting'),
running: el.getAttribute('data-locale-status-running'),
cancelling: el.getAttribute('data-locale-status-cancelling'),
success: el.getAttribute('data-locale-status-success'),
failure: el.getAttribute('data-locale-status-failure'),
cancelled: el.getAttribute('data-locale-status-cancelled'),
@@ -51,7 +88,18 @@ export function initRepositoryActionView() {
logsAlwaysAutoScroll: el.getAttribute('data-locale-logs-always-auto-scroll'),
logsAlwaysExpandRunning: el.getAttribute('data-locale-logs-always-expand-running'),
workflowFile: el.getAttribute('data-locale-workflow-file'),
workflowFileNoPermission: el.getAttribute('data-locale-workflow-file-no-permission'),
runDetails: el.getAttribute('data-locale-run-details'),
workflowDependencies: el.getAttribute('data-locale-workflow-dependencies'),
graphJobsCount1: el.getAttribute('data-locale-graph-jobs-count-1'),
graphJobsCountN: el.getAttribute('data-locale-graph-jobs-count-n'),
graphDependenciesCount1: el.getAttribute('data-locale-graph-dependencies-count-1'),
graphDependenciesCountN: el.getAttribute('data-locale-graph-dependencies-count-n'),
graphSuccessRate: el.getAttribute('data-locale-graph-success-rate'),
graphZoomIn: el.getAttribute('data-locale-graph-zoom-in'),
graphZoomMax: el.getAttribute('data-locale-graph-zoom-max'),
graphZoomOut: el.getAttribute('data-locale-graph-zoom-out'),
graphResetView: el.getAttribute('data-locale-graph-reset-view'),
},
});
view.mount(el);
@@ -1,5 +1,5 @@
import {toggleElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
export function initRepoBranchButton() {
initRepoCreateBranchButton();
@@ -18,7 +18,7 @@ function initRepoCreateBranchButton() {
const fromSpanName = el.getAttribute('data-modal-from-span') || '#modal-create-branch-from-span';
document.querySelector(fromSpanName)!.textContent = el.getAttribute('data-branch-from');
fomanticQuery(el.getAttribute('data-modal')!).modal('show');
showFomanticModal(document.querySelector(el.getAttribute('data-modal')!));
});
}
}
@@ -1,6 +1,5 @@
import {svg} from '../svg.ts';
import {createTippy} from '../modules/tippy.ts';
import {toAbsoluteUrl} from '../utils.ts';
import {addDelegatedEventListener} from '../utils/dom.ts';
function changeHash(hash: string) {
@@ -24,16 +23,16 @@ function selectRange(range: string): Element | null {
if (!refInNewIssue) return;
const urlIssueNew = refInNewIssue.getAttribute('data-url-issue-new');
const urlParamBodyLink = refInNewIssue.getAttribute('data-url-param-body-link')!;
const issueContent = `${toAbsoluteUrl(urlParamBodyLink)}#${anchor}`; // the default content for issue body
const issueContent = `${urlParamBodyLink}#${anchor}`; // the default content for issue body
refInNewIssue.setAttribute('href', `${urlIssueNew}?body=${encodeURIComponent(issueContent)}`);
};
const updateViewGitBlameFragment = function (anchor: string) {
if (!viewGitBlame) return;
let href = viewGitBlame.getAttribute('href')!;
href = `${href.replace(/#L\d+$|#L\d+-L\d+$/, '')}`;
href = href.replace(/#L\d+$|#L\d+-L\d+$/, '');
if (anchor.length !== 0) {
href = `${href}#${anchor}`;
href += `#${anchor}`;
}
viewGitBlame.setAttribute('href', href);
};
@@ -41,9 +40,8 @@ function selectRange(range: string): Element | null {
const updateCopyPermalinkUrl = function (anchor: string) {
if (!copyPermalink) return;
let link = copyPermalink.getAttribute('data-url')!;
link = `${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
link = `${window.location.origin}${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
copyPermalink.setAttribute('data-clipboard-text', link);
copyPermalink.setAttribute('data-clipboard-text-type', 'url');
};
const rangeFields = range ? range.split('-') : [];
@@ -24,3 +24,29 @@ export function initCommitStatuses() {
});
});
}
export function initAvatarStackPopup() {
registerGlobalInitFunc('initAvatarStackPopup', (el: HTMLElement) => {
const nextEl = el.nextElementSibling!;
if (!nextEl.matches('.tippy-target')) throw new Error('Expected next element to be a tippy target');
createTippy(el, {
content: nextEl,
placement: 'bottom-start',
interactive: true,
role: 'dialog',
theme: 'menu',
trigger: 'click',
hideOnClick: true,
});
});
}
export function initCommitFileHistoryFollowRename() {
registerGlobalInitFunc('initCommitHistoryFollowRename', (el: HTMLInputElement) => {
el.addEventListener('change', () => {
const url = new URL(window.location.toString());
url.searchParams.set('follow-rename', String(el.checked));
window.location.assign(url.href);
});
});
}
@@ -1,4 +1,5 @@
import {queryElems} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {sleep} from '../utils.ts';
@@ -22,10 +23,10 @@ async function onDownloadArchive(e: Event) {
if (data.complete) break;
await sleep(Math.min((tryCount + 1) * 750, 2000));
}
window.location.href = el.href; // the archive is ready, start real downloading
window.location.assign(el.href); // the archive is ready, start real downloading
} catch (e) {
console.error(e);
showErrorToast(`Failed to download the archive: ${e}`, {duration: 2500});
showErrorToast(`Failed to download the archive: ${errorMessage(e)}`, {duration: 2500});
} finally {
targetLoading.classList.remove('is-loading', 'loading-icon-2px');
}
@@ -4,7 +4,7 @@ import {GET} from '../modules/fetch.ts';
async function loadBranchesAndTags(area: Element, loadingButton: Element) {
loadingButton.classList.add('disabled');
try {
const res = await GET(loadingButton.getAttribute('data-fetch-url')!);
const res = await GET(loadingButton.getAttribute('data-url')!);
const data = await res.json();
hideElem(loadingButton);
addTags(area, data.tags);
@@ -5,12 +5,14 @@ import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.ts';
import {initViewedCheckboxListenerFor, initExpandAndCollapseFilesButton} from './pull-view-file.ts';
import {initImageDiff} from './imagediff.ts';
import {showErrorToast} from '../modules/toast.ts';
import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts';
import {queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {POST, GET} from '../modules/fetch.ts';
import {createTippy} from '../modules/tippy.ts';
import {invertFileFolding} from './file-fold.ts';
import {parseDom, sleep} from '../utils.ts';
import {parseDom} from '../utils.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {performFetchActionTrigger} from './common-fetch-action.ts';
function initRepoDiffFileBox(el: HTMLElement) {
// switch between "rendered" and "source", for image and CSV files
@@ -40,8 +42,8 @@ function initRepoDiffConversationForm() {
const formData = new FormData(form);
// if the form is submitted by a button, append the button's name and value to the form data
const submitter = submitEventSubmitter(e);
const isSubmittedByButton = (submitter?.nodeName === 'BUTTON') || (submitter?.nodeName === 'INPUT' && submitter.type === 'submit');
const submitter = e.submitter;
const isSubmittedByButton = submitter instanceof HTMLButtonElement || (submitter instanceof HTMLInputElement && submitter.type === 'submit');
if (isSubmittedByButton && submitter.name) {
formData.append(submitter.name, submitter.value);
}
@@ -84,7 +86,7 @@ function initRepoDiffConversationForm() {
}
} catch (error) {
console.error('Error:', error);
showErrorToast(`Submit form failed: ${error}`);
showErrorToast(`Submit form failed: ${errorMessage(error)}`);
} finally {
form?.classList.remove('is-loading');
}
@@ -127,7 +129,7 @@ function initRepoDiffConversationNav() {
const navIndex = isPrevious ? previousIndex : nextIndex;
const elNavConversation = elAllConversations[navIndex];
const anchor = elNavConversation.querySelector('.comment')!.id;
window.location.href = `#${anchor}`;
window.location.assign(`#${anchor}`);
});
}
@@ -172,7 +174,6 @@ async function loadMoreFiles(btn: Element): Promise<boolean> {
// * append the newly loaded file list items to the existing list
const respFileBoxesChildren = Array.from(respFileBoxes.children); // "children:HTMLCollection" will be empty after replaceWith
document.querySelector('#diff-incomplete')!.replaceWith(...respFileBoxesChildren);
for (const el of respFileBoxesChildren) window.htmx.process(el);
onShowMoreFiles();
return true;
} catch (error) {
@@ -204,7 +205,6 @@ function initRepoDiffShowMore() {
const respFileBody = respDoc.querySelector('#diff-file-boxes .diff-file-body .file-body')!;
const respFileBodyChildren = Array.from(respFileBody.children); // "children:HTMLCollection" will be empty after replaceWith
el.parentElement!.replaceWith(...respFileBodyChildren);
for (const el of respFileBodyChildren) window.htmx.process(el);
// FIXME: calling onShowMoreFiles is not quite right here.
// But since onShowMoreFiles mixes "init diff box" and "init diff body" together,
// so it still needs to call it to make the "ImageDiff" and something similar work.
@@ -245,14 +245,14 @@ async function onLocationHashChange() {
const issueCommentPrefix = '#issuecomment-';
if (currentHash.startsWith(issueCommentPrefix)) {
const commentId = currentHash.substring(issueCommentPrefix.length);
const expandButton = document.querySelector<HTMLElement>(`.code-expander-button[data-hidden-comment-ids*=",${commentId},"]`);
const expandButton = document.querySelector<HTMLElement>(`.code-expander-button[data-hidden-comment-ids*=",${CSS.escape(commentId)},"]`);
if (expandButton) {
// avoid infinite loop, do not re-click the button if already clicked
const attrAutoLoadClicked = 'data-auto-load-clicked';
if (expandButton.hasAttribute(attrAutoLoadClicked)) return;
expandButton.setAttribute(attrAutoLoadClicked, 'true');
expandButton.click();
await sleep(500); // Wait for HTMX to load the content. FIXME: need to drop htmx in the future
// trigger the fetch action to load the hidden comments, after loading, it will try to find the target element again
await performFetchActionTrigger(expandButton, 'load');
continue; // Try again to find the element
}
}
@@ -6,32 +6,58 @@ import {POST} from '../modules/fetch.ts';
import {initDropzone} from './dropzone.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {submitFormFetchAction} from './common-fetch-action.ts';
import {dirname} from '../utils.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {showErrorToast} from '../modules/toast.ts';
function initEditPreviewTab(elForm: HTMLFormElement) {
const elTabMenu = elForm.querySelector('.repo-editor-menu')!;
fomanticQuery(elTabMenu.querySelectorAll('.item')).tab();
const elTabMenu = elForm.querySelector('.repo-editor-menu');
if (!elTabMenu) return;
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]');
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]');
if (!elPreviewTab || !elPreviewPanel) return;
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path');
const elTextarea = elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea');
if (!elTreePath || !elTextarea) return;
const repoLink = elTabMenu.getAttribute('data-repo-link')!;
const refSubUrl = elTabMenu.getAttribute('data-ref-sub-url')!;
const branchName = elTabMenu.getAttribute('data-branch-name')!;
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]')!;
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]')!;
elPreviewTab.addEventListener('click', async () => {
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path')!;
const previewUrl = elPreviewTab.getAttribute('data-preview-url')!;
const previewContextRef = elPreviewTab.getAttribute('data-preview-context-ref');
let previewContext = `${previewContextRef}/${elTreePath.value}`;
previewContext = previewContext.substring(0, previewContext.lastIndexOf('/'));
// "preview context" is the request path directory of the file, the rendered links will be resolved based on this path
// TODO: MARKUP-RENDER-CONTEXT: due to various hacky patches, this logic is unnecessarily complicated, see the backend
const previewContext = dirname(`${repoLink}/src/${refSubUrl}/${pathEscapeSegments(elTreePath.value)}`);
const formData = new FormData();
formData.append('mode', 'file');
formData.append('context', previewContext);
formData.append('text', elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea')!.value);
formData.append('text', elTextarea.value);
formData.append('file_path', elTreePath.value);
const response = await POST(previewUrl, {data: formData});
const data = await response.text();
const resp = await POST(`${repoLink}/markup`, {data: formData});
if (!resp.ok) {
showErrorToast(`Failed to render preview: ${resp.status} ${resp.statusText}`);
return;
}
const data = await resp.text();
renderPreviewPanelContent(elPreviewPanel, data);
});
const elDiffTab = elTabMenu.querySelector('a[data-tab="diff"]');
const elDiffPanel = elForm.querySelector('.tab[data-tab="diff"]');
if (elDiffTab && elDiffPanel) {
// the "diff" tab only exists for an existing file, but not for a new file
elDiffTab.addEventListener('click', async () => {
const diffUrl = `${repoLink}/_preview/${pathEscapeSegments(branchName)}/${pathEscapeSegments(elTreePath.value)}`;
// don't use FormData, because FormData sends "\r\n" line endings, backend assumes "\n" line endings
const resp = await POST(diffUrl, {data: new URLSearchParams({content: elTextarea.value})});
if (!resp.ok) {
showErrorToast(`Failed to render diff: ${resp.status} ${resp.statusText}`);
return;
}
elDiffPanel.innerHTML = await resp.text();
});
}
}
export function initRepoEditor() {
@@ -54,6 +80,8 @@ export function initRepoEditor() {
// ATTENTION: two pages have this filename input
// * new/edit file page: there is a code editor
// * upload page: there is no code editor, but a uploader
// FIXME: the related logic is totally a mess, need to completely rewrite, that's also the root reason for
// why the "migrate to CodeMirror" PR took very long time on the legacy code and introduced "#file-name (filenameInput)" regressions many times
const filenameInput = document.querySelector<HTMLInputElement>('#file-name')!;
if (!filenameInput) return;
filenameInput.value = filenameInput.defaultValue; // prevent browser from restoring form values on refresh
@@ -42,7 +42,7 @@ export function initRepoGraphGit() {
elGraphBody.classList.add('is-loading');
try {
const resp = await GET(ajaxUrl.toString());
const resp = await GET(ajaxUrl.href);
elGraphBody.innerHTML = await resp.text();
} finally {
elGraphBody.classList.remove('is-loading');
@@ -51,7 +51,7 @@ export function initRepoGraphGit() {
const dropdownSelected = params.getAll('branch');
if (params.has('hide-pr-refs') && params.get('hide-pr-refs') === 'true') {
dropdownSelected.splice(0, 0, '...flow-hide-pr-refs');
dropdownSelected.unshift('...flow-hide-pr-refs');
}
const $dropdown = fomanticQuery('#flow-select-refs-dropdown');
@@ -96,10 +96,7 @@ export function initRepoTopicBar() {
};
const query = stripTags(this.urlData.query.trim());
let found_query = false;
const current_topics = [];
for (const el of queryElemChildren(topicDropdown, 'a.ui.label.visible')) {
current_topics.push(el.getAttribute('data-value'));
}
const current_topics = Array.from(queryElemChildren(topicDropdown, 'a.ui.label.visible'), (el) => el.getAttribute('data-value'));
if (res.topics) {
let found = false;
@@ -1,9 +1,11 @@
import {svg} from '../svg.ts';
import {svgRaw} from '../svg.ts';
import {showErrorToast} from '../modules/toast.ts';
import {GET, POST} from '../modules/fetch.ts';
import {createElementFromHTML, showElem} from '../utils/dom.ts';
import {parseIssuePageInfo} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
import {html, htmlRaw} from '../utils/html.ts';
let i18nTextEdited: string;
let i18nTextOptions: string;
@@ -11,24 +13,24 @@ let i18nTextDeleteFromHistory: string;
let i18nTextDeleteFromHistoryConfirm: string;
function showContentHistoryDetail(issueBaseUrl: string, commentId: string, historyId: string, itemTitleHtml: string) {
const elDetailDialog = createElementFromHTML(`
<div class="ui modal content-history-detail-dialog">
${svg('octicon-x', 16, 'close icon inside')}
<div class="header tw-flex tw-items-center tw-justify-between">
<div>${itemTitleHtml}</div>
<div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
${i18nTextOptions}
${svg('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
<div class="item tw-text-red" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
const elDetailDialog = createElementFromHTML(html`
<div class="ui modal content-history-detail-dialog">
${svgRaw('octicon-x', 16, 'close icon inside')}
<div class="header flex-left-right">
<div>${htmlRaw(itemTitleHtml)}</div>
<div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
${i18nTextOptions}
${svgRaw('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
<div class="item tw-text-red" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
</div>
</div>
</div>
<div class="comment-diff-data is-loading"></div>
</div>
</div>
<div class="comment-diff-data is-loading"></div>
</div>`);
`);
document.body.append(elDetailDialog);
const elOptionsDropdown = elDetailDialog.querySelector('.ui.dropdown.dialog-header-options')!;
const $fomanticDialog = fomanticQuery(elDetailDialog);
const $fomanticDropdownOptions = fomanticQuery(elOptionsDropdown);
$fomanticDropdownOptions.dropdown({
showOnFocus: false,
@@ -46,7 +48,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
const resp = await response.json();
if (resp.ok) {
$fomanticDialog.modal('hide');
hideFomanticModal(elDetailDialog);
} else {
showErrorToast(resp.message);
}
@@ -63,7 +65,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
$fomanticDropdownOptions.dropdown('clear', true);
},
});
$fomanticDialog.modal({
showFomanticModal(elDetailDialog, {
async onShow() {
try {
const params = new URLSearchParams();
@@ -86,19 +88,20 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
}
},
onHidden() {
$fomanticDialog.remove();
elDetailDialog.remove();
},
}).modal('show');
});
}
function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, commentId: string) {
const elHeaderLeft = elCommentItem.querySelector('.comment-header-left')!;
const menuHtml = `
<div class="ui dropdown interact-fg content-history-menu" data-comment-id="${commentId}">
&bull; ${i18nTextEdited}${svg('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
const menuHtml = html`
<div class="ui dropdown interact-fg content-history-menu tw-flex-shrink-0" data-comment-id="${commentId}">
&bull; ${i18nTextEdited}${svgRaw('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
</div>
</div>
</div>`;
`;
elHeaderLeft.querySelector(`.ui.dropdown.content-history-menu`)?.remove(); // remove the old one if exists
elHeaderLeft.append(createElementFromHTML(menuHtml));
@@ -127,7 +130,7 @@ export async function initRepoIssueContentHistory() {
const issuePageInfo = parseIssuePageInfo();
if (!issuePageInfo.issueNumber) return;
const elIssueDescription = document.querySelector('.repository.issue .timeline-item.comment.first'); // issue(PR) main content
const elIssueDescription = document.querySelector('.repository.issue .timeline-item.comment.issue-content-comment'); // issue(PR) main content
const elComments = document.querySelectorAll('.repository.issue .comment-list .comment'); // includes: issue(PR) comments, review comments, code comments
if (!elIssueDescription && !elComments.length) return;
@@ -145,7 +148,7 @@ export async function initRepoIssueContentHistory() {
if (resp.editedHistoryCountMap[0] && elIssueDescription) {
showContentHistoryMenu(issueBaseUrl, elIssueDescription, '0');
}
for (const [commentId, _editedCount] of Object.entries(resp.editedHistoryCountMap)) {
for (const commentId of Object.keys(resp.editedHistoryCountMap)) {
if (commentId === '0') continue;
const elIssueComment = document.querySelector(`#issuecomment-${commentId}`);
if (elIssueComment) showContentHistoryMenu(issueBaseUrl, elIssueComment, commentId);
@@ -3,6 +3,7 @@ import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} fr
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts';
@@ -73,7 +74,7 @@ async function tryOnEditContent(e: Event) {
}
comboMarkdownEditor.dropzoneSubmitReload();
} catch (error) {
showErrorToast(`Failed to save the content: ${error}`);
showErrorToast(`Failed to save the content: ${errorMessage(error)}`);
console.error(error);
} finally {
renderContent.classList.remove('is-loading');
@@ -2,6 +2,7 @@ import {updateIssuesMeta} from './repo-common.ts';
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
import {html, htmlRaw} from '../utils/html.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {errorMessage} from '../modules/errors.ts';
import {showErrorToast} from '../modules/toast.ts';
import {createSortable} from '../modules/sortable.ts';
import {DELETE, POST} from '../modules/fetch.ts';
@@ -56,21 +57,12 @@ function initRepoIssueListCheckboxes() {
const url = el.getAttribute('data-url')!;
let action = el.getAttribute('data-action')!;
let elementId = el.getAttribute('data-element-id')!;
const issueIDList: string[] = [];
for (const el of document.querySelectorAll('.issue-checkbox:checked')) {
issueIDList.push(el.getAttribute('data-issue-id')!);
}
const elementId = el.getAttribute('data-element-id')!;
const issueIDList: string[] = Array.from(document.querySelectorAll('.issue-checkbox:checked'), (el) => (el.getAttribute('data-issue-id')!));
const issueIDs = issueIDList.join(',');
if (!issueIDs) return;
// for assignee
if (elementId === '0' && url.endsWith('/assignee')) {
elementId = '';
action = 'clear';
}
// for toggle
// for label toggle
if (action === 'toggle' && e.altKey) {
action = 'toggle-alt';
}
@@ -87,7 +79,9 @@ function initRepoIssueListCheckboxes() {
await updateIssuesMeta(url, action, issueIDs, elementId);
window.location.reload();
} catch (err) {
showErrorToast(err.responseJSON?.error ?? err.message);
// FIXME: this logic (including updateIssuesMeta) is not right, should refactor to our JSONError framework
const e = err as {responseJSON?: {error: string}};
showErrorToast(e.responseJSON?.error ?? errorMessage(err));
}
},
));
@@ -106,7 +100,7 @@ function initDropdownUserRemoteSearch(el: Element) {
fullTextSearch: true,
selectOnKeydown: false,
action: (_text: string, value: string) => {
window.location.href = actionJumpUrl.replace('{username}', encodeURIComponent(value));
window.location.assign(actionJumpUrl.replace('{username}', encodeURIComponent(value)));
},
});
@@ -139,7 +133,7 @@ function initDropdownUserRemoteSearch(el: Element) {
processedResults.length = 0;
for (const item of resp.results) {
const htmlAvatar = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20">`;
const htmlFullName = item.full_name ? html`<span class="username-fullname gt-ellipsis">(${item.full_name})</span>` : '';
const htmlFullName = item.full_name ? html`<span class="username-fullname">(${item.full_name})</span>` : '';
const htmlItem = html`<span class="username-display">${htmlRaw(htmlAvatar)}<span>${item.username}</span>${htmlRaw(htmlFullName)}</span>`;
if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
processedResults.push({value: item.username, name: htmlItem});
@@ -189,7 +183,7 @@ function initPinRemoveButton() {
// Delete the tooltip
el._tippy.destroy();
// Remove the Card
el.closest(`div.issue-card[data-issue-id="${id}"]`)!.remove();
el.closest(`div.issue-card[data-issue-id="${CSS.escape(String(id))}"]`)!.remove();
}
});
}
@@ -1,94 +1,42 @@
import {createApp} from 'vue';
import {GET, POST} from '../modules/fetch.ts';
import {GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {registerGlobalEventFunc} from '../modules/observer.ts';
function initRepoPullRequestUpdate(el: HTMLElement) {
const prUpdateButtonContainer = el.querySelector('#update-pr-branch-with-base');
if (!prUpdateButtonContainer) return;
export function initRepoPullRequestUpdate(el: HTMLElement) {
const elDropdown = el.querySelector(':scope > .ui.dropdown');
if (!elDropdown) return;
const elButton = el.querySelector<HTMLButtonElement>(':scope > button')!;
const prUpdateButton = prUpdateButtonContainer.querySelector<HTMLButtonElement>(':scope > button')!;
const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown')!;
prUpdateButton.addEventListener('click', async function (e) {
e.preventDefault();
const redirect = this.getAttribute('data-redirect');
this.classList.add('is-loading');
let response: Response | undefined;
try {
response = await POST(this.getAttribute('data-do')!);
} catch (error) {
console.error(error);
} finally {
this.classList.remove('is-loading');
}
let data: Record<string, any> | undefined;
try {
data = await response?.json(); // the response is probably not a JSON
} catch (error) {
console.error(error);
}
if (data?.redirect) {
window.location.href = data.redirect;
} else if (redirect) {
window.location.href = redirect;
} else {
window.location.reload();
}
});
fomanticQuery(prUpdateDropdown).dropdown({
fomanticQuery(elDropdown).dropdown({
onChange(_text: string, _value: string, $choice: any) {
const choiceEl = $choice[0];
const url = choiceEl.getAttribute('data-do');
if (url) {
const buttonText = prUpdateButton.querySelector('.button-text');
if (buttonText) {
buttonText.textContent = choiceEl.textContent;
}
prUpdateButton.setAttribute('data-do', url);
}
elButton.textContent = choiceEl.textContent;
elButton.setAttribute('data-url', choiceEl.getAttribute('data-update-url'));
},
});
}
function initRepoPullRequestCommitStatus(el: HTMLElement) {
for (const btn of el.querySelectorAll('.commit-status-hide-checks')) {
const panel = btn.closest('.commit-status-panel')!;
const list = panel.querySelector<HTMLElement>('.commit-status-list')!;
btn.addEventListener('click', () => {
list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
});
}
function onCommitStatusChecksToggle(btn: HTMLElement) {
const panel = btn.closest('.commit-status-toggle')!.parentElement!;
const list = panel.querySelector<HTMLElement>('.commit-status-list')!;
list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
}
async function initRepoPullRequestMergeForm(box: HTMLElement) {
const el = box.querySelector('#pull-request-merge-form');
if (!el) return;
const data = JSON.parse(el.getAttribute('data-merge-form-props')!);
const {default: PullRequestMergeForm} = await import('../components/PullRequestMergeForm.vue');
const view = createApp(PullRequestMergeForm);
view.mount(el);
}
function executeScripts(elem: Element) {
for (const oldScript of elem.querySelectorAll('script')) {
// TODO: that's the only way to load the data for the merge form. In the future
// we need to completely decouple the page data and embedded script
// eslint-disable-next-line github/no-dynamic-script-tag
const newScript = document.createElement('script');
for (const attr of oldScript.attributes) {
if (attr.name === 'type' && attr.value === 'module') continue;
newScript.setAttribute(attr.name, attr.value);
}
newScript.text = oldScript.text;
document.body.append(newScript);
}
const view = createApp(PullRequestMergeForm, {mergeFormProps: data});
view.mount(el); // TODO: can unmount when reloaded?
}
export function initRepoPullMergeBox(el: HTMLElement) {
initRepoPullRequestCommitStatus(el);
initRepoPullRequestUpdate(el);
registerGlobalEventFunc('click', 'onCommitStatusChecksToggle', onCommitStatusChecksToggle);
initRepoPullRequestMergeForm(el);
const reloadingIntervalValue = el.getAttribute('data-pull-merge-box-reloading-interval');
@@ -98,6 +46,14 @@ export function initRepoPullMergeBox(el: HTMLElement) {
const pullLink = el.getAttribute('data-pull-link');
let timerId: number | null;
// The merge box has complex buttons & form, if the user has interacted with any element, don't refresh.
// Otherwise, the user won't be able to merge or schedule a merge (auto-merge) when the PR status is not ready.
let interacted = false;
const interactionEvents = ['focusin', 'mousedown', 'click', 'keydown', 'input'];
for (const event of interactionEvents) {
el.addEventListener(event, () => { interacted = true }, {capture: true});
}
let reloadMergeBox: () => Promise<void>;
const stopReloading = () => {
if (!timerId) return;
@@ -105,7 +61,7 @@ export function initRepoPullMergeBox(el: HTMLElement) {
timerId = null;
};
const startReloading = () => {
if (timerId) return;
if (timerId || interacted) return;
setTimeout(reloadMergeBox, reloadingInterval);
};
const onVisibilityChange = () => {
@@ -116,6 +72,7 @@ export function initRepoPullMergeBox(el: HTMLElement) {
}
};
reloadMergeBox = async () => {
if (interacted) return;
const resp = await GET(`${pullLink}/merge_box`);
stopReloading();
if (!resp.ok) {
@@ -123,8 +80,13 @@ export function initRepoPullMergeBox(el: HTMLElement) {
return;
}
document.removeEventListener('visibilitychange', onVisibilityChange);
const newElem = createElementFromHTML(await resp.text());
executeScripts(newElem);
const respText = (await resp.text()).trim();
if (!respText) {
el.remove(); // merge box might not exist if the PR has changed (e.g.: merged and the head branch has been deleted)
return;
}
const newElem = createElementFromHTML(respText);
el.replaceWith(newElem);
};
@@ -2,6 +2,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts';
import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {parseDom} from '../utils.ts';
export function syncIssueMainContentTimelineItems(oldMainContent: Element, newMainContent: Element) {
@@ -28,12 +29,10 @@ export function syncIssueMainContentTimelineItems(oldMainContent: Element, newMa
// for event item (e.g.: "add & remove labels"), we want to replace the existing one if exists
// because the label operations can be merged into one event item, so the new item might be different from the old one
oldItem.replaceWith(newItem);
window.htmx.process(newItem);
}
continue;
}
timelineEnd.insertAdjacentElement('beforebegin', newItem);
window.htmx.process(newItem);
}
}
@@ -44,7 +43,7 @@ export class IssueSidebarComboList {
elDropdown: HTMLElement;
elList: HTMLElement | null;
elComboValue: HTMLInputElement;
initialValues: string[];
initialValues: string[] = [];
container: HTMLElement;
elIssueMainContent: HTMLElement;
@@ -71,7 +70,7 @@ export class IssueSidebarComboList {
updateUiList(changedValues: Array<string>) {
if (!this.elList) return;
const elEmptyTip = this.elList.querySelector('.item.empty-list')!;
const elEmptyTip = this.elList.querySelector(':scope > .item.empty-list')!;
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
for (const value of changedValues) {
const el = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
@@ -92,7 +91,6 @@ export class IssueSidebarComboList {
// we can safely replace the whole right part (sidebar) because there are only some dropdowns and lists
const newSidebar = doc.querySelector('.issue-content-right')!;
this.elIssueSidebar.replaceWith(newSidebar);
window.htmx.process(newSidebar);
// for the main content (left side), at the moment we only support handling known timeline items
const newMainContent = doc.querySelector('.issue-content-left')!;
@@ -132,7 +130,7 @@ export class IssueSidebarComboList {
await this.reloadPagePartially();
} catch (e) {
console.error('Failed to update to backend', e);
showErrorToast(`Failed to update to backend: ${e}`);
showErrorToast(`Failed to update to backend: ${errorMessage(e)}`);
} finally {
this.elIssueSidebar.classList.remove('is-loading');
}
@@ -141,7 +139,7 @@ export class IssueSidebarComboList {
async doUpdate() {
const changedValues = this.collectCheckedValues();
if (this.initialValues.join(',') === changedValues.join(',')) return;
this.updateUiList(changedValues);
if (!this.updateUrl) this.updateUiList(changedValues);
if (this.updateUrl) await this.updateToBackend(changedValues);
this.initialValues = changedValues;
}
@@ -198,7 +196,9 @@ export class IssueSidebarComboList {
const elItem = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
elItem?.classList.add('checked');
}
this.updateUiList(values);
if (this.elList && this.elList.getAttribute('data-combo-list-inited') !== 'true') {
this.updateUiList(values);
}
}
this.initialValues = this.collectCheckedValues();
@@ -1,4 +1,5 @@
import {htmlEscape} from '../utils/html.ts';
import {errorMessage} from '../modules/errors.ts';
import {html, htmlEscape, htmlRaw} from '../utils/html.ts';
import {createTippy} from '../modules/tippy.ts';
import {
addDelegatedEventListener,
@@ -10,11 +11,11 @@ import {
} from '../utils/dom.ts';
import {setFileFolding} from './file-fold.ts';
import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {toAbsoluteUrl} from '../utils.ts';
import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
@@ -26,7 +27,7 @@ function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
const queryLabels = url.searchParams.get('labels') || '';
const selectedLabelIds = new Set<string>();
for (const id of queryLabels ? queryLabels.split(',') : []) {
selectedLabelIds.add(`${Math.abs(parseInt(id))}`); // "labels" contains negative ids, which are excluded
selectedLabelIds.add(String(Math.abs(parseInt(id)))); // "labels" contains negative ids, which are excluded
}
const excludeLabel = (e: MouseEvent | KeyboardEvent, item: Element) => {
@@ -128,9 +129,9 @@ export function initRepoIssueCommentDelete() {
// on the Conversation page, there is no parent "tr", so no need to do anything for "add-code-comment"
if (lineType) {
if (lineType === 'same') {
document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`)!.classList.remove('tw-invisible');
document.querySelector(`[data-path="${CSS.escape(String(path))}"] .add-code-comment[data-idx="${CSS.escape(String(idx))}"]`)!.classList.remove('tw-invisible');
} else {
document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`)!.classList.remove('tw-invisible');
document.querySelector(`[data-path="${CSS.escape(String(path))}"] .add-code-comment[data-side="${CSS.escape(String(side))}"][data-idx="${CSS.escape(String(idx))}"]`)!.classList.remove('tw-invisible');
}
}
conversationHolder.remove();
@@ -196,8 +197,9 @@ export async function handleReply(el: HTMLElement) {
}
export function initRepoPullRequestReview() {
if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) {
const commentDiv = document.querySelector(window.location.hash);
const currentHash = window.location.hash;
if (currentHash.startsWith('#issuecomment-') || currentHash.startsWith('#pullrequestreview-')) {
const commentDiv = document.querySelector(currentHash);
if (commentDiv) {
// get the name of the parent id
const groupID = commentDiv.closest('div[id^="code-comments-"]')?.getAttribute('id');
@@ -272,15 +274,13 @@ export function initRepoPullRequestReview() {
let ntr = tr.nextElementSibling;
if (!ntr?.classList.contains('add-comment')) {
ntr = createElementFromHTML(`
<tr class="add-comment" data-line-type="${htmlEscape(lineType)}">
${isSplit ? `
<td class="add-comment-left" colspan="4"></td>
<td class="add-comment-right" colspan="4"></td>
` : `
<td class="add-comment-left add-comment-right" colspan="5"></td>
`}
</tr>`);
const tdSplit = html`<td class="add-comment-left" colspan="4"></td><td class="add-comment-right" colspan="4"></td>`;
const tdUnified = html`<td class="add-comment-left add-comment-right" colspan="5"></td>`;
ntr = createElementFromHTML(html`
<tr class="add-comment" data-line-type="${lineType}">
${isSplit ? htmlRaw(tdSplit) : htmlRaw(tdUnified)}
</tr>
`);
tr.after(ntr);
}
const td = ntr.querySelector(`.add-comment-${side}`)!;
@@ -329,12 +329,12 @@ export function initRepoIssueReferenceIssue() {
const target = el.getAttribute('data-target');
const content = document.querySelector(`#${target}`)?.textContent ?? '';
const poster = el.getAttribute('data-poster-username');
const reference = toAbsoluteUrl(el.getAttribute('data-reference')!);
const reference = el.getAttribute('data-reference')!;
const modalSelector = el.getAttribute('data-modal')!;
const modal = document.querySelector(modalSelector)!;
const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]')!;
textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`;
fomanticQuery(modal).modal('show');
showFomanticModal(modal);
});
}
@@ -425,7 +425,7 @@ export function initRepoIssueTitleEdit() {
window.location.reload();
} catch (error) {
console.error(error);
showErrorToast(error.message);
showErrorToast(errorMessage(error));
}
});
}
@@ -17,7 +17,7 @@ import {initRepoMilestone} from './repo-milestone.ts';
import {initRepoNew} from './repo-new.ts';
import {createApp} from 'vue';
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
import {initRepoPullMergeBox} from './repo-issue-pull.ts';
import {initRepoPullMergeBox, initRepoPullRequestUpdate} from './repo-issue-pull.ts';
function initRepoBranchTagSelector() {
registerGlobalInitFunc('initRepoBranchTagSelector', async (elRoot: HTMLInputElement) => {
@@ -38,6 +38,9 @@ export function initBranchSelectorTabs() {
}
export function initRepository() {
registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
registerGlobalInitFunc('initRepoPullRequestUpdate', initRepoPullRequestUpdate);
const pageContent = document.querySelector('.page-content.repository');
if (!pageContent) return;
@@ -68,8 +71,6 @@ export function initRepository() {
initRepoIssueCommentDelete();
initRepoIssueCodeCommentCancel();
initCompReactionSelector();
registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
}
initUnicodeEscapeButton();
@@ -47,7 +47,6 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) {
value: String(tmplRepo.repository.id),
});
}
$repoTemplateDropdown.fomanticExt.onResponseKeepSelectedItem($repoTemplateDropdown, inputRepoTemplate.value);
return {results};
},
cache: false,
@@ -1,7 +1,7 @@
import {contrastColor} from '../utils/color.ts';
import {createSortable} from '../modules/sortable.ts';
import {POST, request} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {hideFomanticModal} from '../modules/fomantic/modal.ts';
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
import type {SortableEvent} from 'sortablejs';
import {toggleFullScreen} from '../utils.ts';
@@ -120,11 +120,11 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
}
// update the newly saved column title and color in the project board (to avoid reload)
const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${attrDataColumnId}="${columnId}"]`)!;
const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${CSS.escape(attrDataColumnId)}="${CSS.escape(columnId)}"]`)!;
elEditButton.setAttribute(attrDataColumnTitle, elColumnTitle.value);
elEditButton.setAttribute(attrDataColumnColor, elColumnColor.value);
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${columnId}"]`)!;
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${CSS.escape(columnId)}"]`)!;
const elBoardColumnTitle = elBoardColumn.querySelector<HTMLElement>(`.project-column-title-text`)!;
elBoardColumnTitle.textContent = elColumnTitle.value;
if (elColumnColor.value) {
@@ -138,7 +138,7 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
}
fomanticQuery(elModal).modal('hide');
hideFomanticModal(elModal);
} finally {
elForm.classList.remove('is-loading');
}
@@ -3,6 +3,7 @@ import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {hideElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
import {htmlEscape} from '../utils/html.ts';
import {compareVersions} from 'compare-versions';
@@ -11,7 +12,7 @@ export function initRepoReleaseNew() {
registerGlobalEventFunc('click', 'onReleaseEditAttachmentDelete', (el) => {
const uuid = el.getAttribute('data-uuid')!;
const id = el.getAttribute('data-id')!;
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${uuid}']`)!.value = 'true';
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${CSS.escape(uuid)}']`)!.value = 'true';
hideElem(`#attachment-${id}`);
});
registerGlobalInitFunc('initReleaseEditForm', (elForm: HTMLFormElement) => {
@@ -112,7 +113,7 @@ function initGenerateReleaseNotes(elForm: HTMLFormElement) {
}
} finally {
elModal.classList.remove('loading', 'disabled');
fomanticQuery(elModal).modal('hide');
hideFomanticModal(elModal);
comboEditor.focus();
}
};
@@ -139,12 +140,12 @@ function initGenerateReleaseNotes(elForm: HTMLFormElement) {
}
$dropdown.dropdown('set selected', guessPreviousReleaseTag(tagName, existingTags));
fomanticQuery(elModal).modal({
showFomanticModal(elModal, {
onApprove: () => {
doSubmit(tagName); // don't await, need to return false to keep the modal
return false;
},
}).modal('show');
});
};
buttonShowModal.addEventListener('click', doShowModal);
@@ -1,23 +1,12 @@
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
export function initRepositorySearch() {
const repositorySearchForm = document.querySelector<HTMLFormElement>('#repo-search-form');
if (!repositorySearchForm) return;
repositorySearchForm.addEventListener('change', (e: Event) => {
e.preventDefault();
const params = new URLSearchParams();
for (const [key, value] of new FormData(repositorySearchForm).entries()) {
params.set(key, value as string);
}
if ((e.target as HTMLInputElement).name === 'clear-filter') {
params.delete('archived');
params.delete('fork');
params.delete('mirror');
params.delete('template');
params.delete('private');
}
params.delete('clear-filter');
window.location.search = params.toString();
registerGlobalInitFunc('initRepositorySearch', (form: HTMLFormElement) => {
addDelegatedEventListener(form, 'change', 'input[type="radio"]', () => form.submit());
form.querySelector('.repo-search-filter-reset')!.addEventListener('click', () => {
queryElems(form, 'input[type="radio"]', (el: HTMLInputElement) => el.checked = false);
form.submit();
});
});
}
@@ -13,13 +13,13 @@ vi.mock('../modules/sortable.ts', () => ({
const branchesHTML = `
<div id="protected-branches-list" data-update-priority-url="some/repo/branches/priority">
<div class="flex-item tw-items-center item" data-id="1">
<div class="item" data-id="1">
<div class="drag-handle"></div>
</div>
<div class="flex-item tw-items-center item" data-id="2">
<div class="item" data-id="2">
<div class="drag-handle"></div>
</div>
<div class="flex-item tw-items-center item" data-id="3">
<div class="item" data-id="3">
<div class="drag-handle"></div>
</div>
</div>
@@ -2,6 +2,7 @@ import {createSortable} from '../modules/sortable.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {queryElemChildren} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
export function initRepoSettingsBranchesDrag() {
const protectedBranchesList = document.querySelector<HTMLElement>('#protected-branches-list');
@@ -23,8 +24,7 @@ export function initRepoSettingsBranchesDrag() {
},
});
} catch (err) {
const errorMessage = String(err);
showErrorToast(`Failed to update branch protection rule priority:, error: ${errorMessage}`);
showErrorToast(`Failed to update branch protection rule priority: ${errorMessage(err)}`);
}
})();
},
@@ -3,6 +3,7 @@ import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {attachSearchBox} from '../modules/search.ts';
import {globMatch} from '../utils/glob.ts';
const {appSubUrl} = window.config;
@@ -17,16 +18,21 @@ function initRepoSettingsCollaboration() {
dropdownEl.classList.add('is-loading', 'loading-icon-2px');
const lastValue = dropdownEl.getAttribute('data-last-value')!;
$dropdown.dropdown('hide');
let respOk = false;
try {
const uid = dropdownEl.getAttribute('data-uid')!;
await POST(dropdownEl.getAttribute('data-url')!, {data: new URLSearchParams({uid, 'mode': value})});
textEl.textContent = text;
dropdownEl.setAttribute('data-last-value', value);
} catch {
textEl.textContent = '(error)'; // prevent from misleading users when error occurs
dropdownEl.setAttribute('data-last-value', lastValue);
const resp = await POST(dropdownEl.getAttribute('data-url')!, {data: new URLSearchParams({uid, 'mode': value})});
respOk = resp.ok;
if (respOk) {
textEl.textContent = text;
dropdownEl.setAttribute('data-last-value', value);
}
} finally {
dropdownEl.classList.remove('is-loading');
if (!respOk) {
textEl.textContent = '(error)'; // prevent from misleading users when error occurs
dropdownEl.setAttribute('data-last-value', lastValue);
}
}
},
onHide() {
@@ -45,29 +51,17 @@ function initRepoSettingsCollaboration() {
}
}
function initRepoSettingsSearchTeamBox() {
const searchTeamBox = document.querySelector('#search-team-box');
if (!searchTeamBox) return;
type TeamSearchResponse = {data: Array<{name: string; permission: string}>};
fomanticQuery(searchTeamBox).search({
minCharacters: 2,
searchFields: ['name', 'description'],
showNoResults: false,
rawResponse: true,
apiSettings: {
url: `${appSubUrl}/org/${searchTeamBox.getAttribute('data-org-name')}/teams/-/search?q={query}`,
onResponse(response: any) {
const items: Array<Record<string, any>> = [];
for (const item of response.data) {
items.push({
title: item.name,
description: `${item.permission} access`, // TODO: translate this string
});
}
return {results: items};
},
},
});
function initRepoSettingsSearchTeamBox() {
const box = document.querySelector<HTMLElement>('#search-team-box');
if (!box) return;
const url = `${appSubUrl}/org/${box.getAttribute('data-org-name')}/teams/-/search?q={query}`;
attachSearchBox(box, url, (response: TeamSearchResponse) => response.data.map((item) => ({
title: item.name,
description: `${item.permission} access`, // TODO: translate this string
})));
}
function initRepoSettingsGitHook() {
@@ -50,9 +50,9 @@ export async function attachTribute(element: HTMLElement) {
const tribute = new Tribute({
collection: [
emojiCollection as TributeCollection<any>,
mentionCollection as TributeCollection<any>,
],
emojiCollection,
mentionCollection,
] as TributeCollection<any>[],
noMatchTemplate: () => '',
});
tribute.attach(element);
@@ -1,5 +1,6 @@
import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts';
import {hideElem, showElem} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {GET, POST} from '../modules/fetch.ts';
const {appSubUrl} = window.config;
@@ -78,9 +79,9 @@ async function loginPasskey() {
}
const reply = await res.json();
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
window.location.assign(reply?.redirect ?? `${appSubUrl}/`);
} catch (err) {
webAuthnError('general', err.message);
webAuthnError('general', errorMessage(err));
}
}
@@ -104,7 +105,7 @@ async function login2FA() {
await verifyAssertion(credential);
} catch (err) {
if (!options.publicKey.extensions?.appid) {
webAuthnError('general', err.message);
webAuthnError('general', errorMessage(err));
return;
}
delete options.publicKey.extensions.appid;
@@ -114,7 +115,7 @@ async function login2FA() {
});
await verifyAssertion(credential);
} catch (err) {
webAuthnError('general', err.message);
webAuthnError('general', errorMessage(err));
}
}
}
@@ -150,7 +151,7 @@ async function verifyAssertion(assertedCredential: any) { // TODO: Credential ty
}
const reply = await res.json();
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
window.location.assign(reply?.redirect ?? `${appSubUrl}/`);
}
async function webauthnRegistered(newCredential: any) { // TODO: Credential type does not work
@@ -187,7 +188,7 @@ function webAuthnError(errorType: ErrorType, message:string = '') {
if (errorType === 'general') {
elErrorMsg.textContent = message || 'unknown error';
} else {
const elTypedError = document.querySelector(`#webauthn-error [data-webauthn-error-msg=${errorType}]`);
const elTypedError = document.querySelector(`#webauthn-error [data-webauthn-error-msg=${CSS.escape(errorType)}]`);
if (elTypedError) {
elErrorMsg.textContent = `${elTypedError.textContent}${message ? ` ${message}` : ''}`;
} else {
@@ -262,6 +263,11 @@ async function webAuthnRegisterRequest() {
});
await webauthnRegistered(credential);
} catch (err) {
webAuthnError('unknown', err);
// an already registered authenticator raises this
if (err instanceof DOMException && err.name === 'InvalidStateError') {
webAuthnError('duplicated');
return;
}
webAuthnError('unknown', errorMessage(err));
}
}