初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+328
View File
@@ -0,0 +1,328 @@
import {checkAppUrl} from '../common-page.ts';
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {pathEscape} from '../../utils/url.ts';
import {registerGlobalInitFunc} from '../../modules/observer.ts';
const {appSubUrl} = window.config;
function onSecurityProtocolChange(): void {
if (Number(document.querySelector<HTMLInputElement>('#security_protocol')?.value) > 0) {
showElem('.has-tls');
} else {
hideElem('.has-tls');
}
}
export function initAdminCommon(): void {
if (!document.querySelector('.page-content.admin')) return;
// check whether appUrl(ROOT_URL) is correct, if not, show an error message
checkAppUrl();
initAdminUser();
initAdminAuthentication();
initAdminNotice();
registerGlobalInitFunc('initRunnerBulkToolbar', initAdminRunnerBulk);
}
function initAdminRunnerBulk(toolbar: HTMLElement) {
const actionButtons = toolbar.querySelectorAll<HTMLButtonElement>('.runner-bulk-action');
const formRunnerIds = toolbar.querySelector<HTMLInputElement>('form input[name="ids"]')!;
const rowCheckboxes = document.querySelectorAll<HTMLInputElement>('.runner-bulk-select');
const selectAll = document.querySelector<HTMLInputElement>('.runner-bulk-select-all');
if (!selectAll) return;
const refresh = () => {
const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
toggleElem(toolbar, checked.length > 0);
for (const btn of actionButtons) {
btn.querySelector<HTMLElement>('.runner-bulk-count')!.textContent = `(${checked.length})`;
}
selectAll.checked = checked.length > 0 && checked.length === rowCheckboxes.length;
selectAll.indeterminate = checked.length > 0 && checked.length < rowCheckboxes.length;
};
selectAll.addEventListener('change', () => {
for (const cb of rowCheckboxes) cb.checked = selectAll.checked;
refresh();
});
for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
refresh();
const collectSelectedIds = () => {
const ids = [];
for (const cb of rowCheckboxes) {
if (cb.checked) ids.push(cb.getAttribute('data-runner-id')!);
}
return ids.join(',');
};
formRunnerIds.value = collectSelectedIds();
}
function initAdminUser() {
const pageContent = document.querySelector('.page-content.admin.edit.user, .page-content.admin.new.user');
if (!pageContent) return;
document.querySelector<HTMLInputElement>('#login_type')?.addEventListener('change', function () {
if (this.value?.startsWith('0')) {
document.querySelector<HTMLInputElement>('#user_name')?.removeAttribute('disabled');
document.querySelector<HTMLInputElement>('#login_name')?.removeAttribute('required');
hideElem('.non-local');
showElem('.local');
document.querySelector<HTMLInputElement>('#user_name')?.focus();
if (this.getAttribute('data-password') === 'required') {
document.querySelector('#password')?.setAttribute('required', 'required');
}
} else {
if (document.querySelector<HTMLDivElement>('.admin.edit.user')) {
document.querySelector<HTMLInputElement>('#user_name')?.setAttribute('disabled', 'disabled');
}
document.querySelector<HTMLInputElement>('#login_name')?.setAttribute('required', 'required');
showElem('.non-local');
hideElem('.local');
document.querySelector<HTMLInputElement>('#login_name')?.focus();
document.querySelector<HTMLInputElement>('#password')?.removeAttribute('required');
}
});
}
function initAdminAuthentication() {
const pageContent = document.querySelector('.page-content.admin.authentication');
if (!pageContent) return;
const isNewPage = pageContent.classList.contains('new');
const isEditPage = pageContent.classList.contains('edit');
if (!isNewPage && !isEditPage) return;
function onUsePagedSearchChange() {
const searchPageSizeElements = document.querySelectorAll<HTMLDivElement>('.search-page-size');
if (document.querySelector<HTMLInputElement>('#use_paged_search')!.checked) {
showElem('.search-page-size');
for (const el of searchPageSizeElements) {
el.querySelector('input')?.setAttribute('required', 'required');
}
} else {
hideElem('.search-page-size');
for (const el of searchPageSizeElements) {
el.querySelector('input')?.removeAttribute('required');
}
}
}
function onOAuth2Change(applyDefaultValues: boolean) {
hideElem('.open_id_connect_auto_discovery_url, .open_id_connect_external_id_claim, .oauth2_use_custom_url');
for (const input of document.querySelectorAll<HTMLInputElement>('.open_id_connect_auto_discovery_url input[required]')) {
input.removeAttribute('required');
}
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
switch (provider) {
case 'openidConnect':
case 'aws-cognito':
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input')!.setAttribute('required', 'required');
showElem('.open_id_connect_auto_discovery_url');
showElem('.open_id_connect_external_id_claim');
break;
default: {
const elProviderCustomUrlSettings = document.querySelector<HTMLInputElement>(`#${provider}_customURLSettings`);
if (!elProviderCustomUrlSettings) break; // some providers do not have custom URL settings
const couldChangeCustomURLs = elProviderCustomUrlSettings.getAttribute('data-available') === 'true';
const mustProvideCustomURLs = elProviderCustomUrlSettings.getAttribute('data-required') === 'true';
if (couldChangeCustomURLs) {
showElem('.oauth2_use_custom_url'); // show the checkbox
}
if (mustProvideCustomURLs) {
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')!.checked = true; // make the checkbox checked
}
break;
}
}
const supportSshPublicKey = document.querySelector<HTMLInputElement>(`#${provider}_SupportSSHPublicKey`)?.value === 'true';
toggleElem('.field.oauth2_ssh_public_key_claim_name', supportSshPublicKey);
onOAuth2UseCustomURLChange(applyDefaultValues);
}
function onOAuth2UseCustomURLChange(applyDefaultValues: boolean) {
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
hideElem('.oauth2_use_custom_url_field');
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2_use_custom_url_field input[required]')) {
input.removeAttribute('required');
}
const elProviderCustomUrlSettings = document.querySelector(`#${provider}_customURLSettings`);
if (elProviderCustomUrlSettings && document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')!.checked) {
for (const custom of ['token_url', 'auth_url', 'profile_url', 'email_url', 'tenant']) {
if (applyDefaultValues) {
document.querySelector<HTMLInputElement>(`#oauth2_${custom}`)!.value = document.querySelector<HTMLInputElement>(`#${provider}_${custom}`)!.value;
}
const customInput = document.querySelector(`#${provider}_${custom}`);
if (customInput?.getAttribute('data-available') === 'true') {
for (const input of document.querySelectorAll(`.oauth2_${custom} input`)) {
input.setAttribute('required', 'required');
}
showElem(`.oauth2_${custom}`);
}
}
}
}
function onEnableLdapGroupsChange() {
const checked = document.querySelector<HTMLInputElement>('.js-ldap-group-toggle')?.checked;
toggleElem(document.querySelector('#ldap-group-options')!, checked);
}
const elAuthType = document.querySelector<HTMLInputElement>('#auth_type')!;
// New authentication
if (isNewPage) {
const onAuthTypeChange = function () {
hideElem('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi');
for (const input of document.querySelectorAll<HTMLInputElement>('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required]')) {
input.removeAttribute('required');
}
document.querySelector<HTMLDivElement>('.binddnrequired')?.classList.remove('required');
const authType = elAuthType.value;
switch (authType) {
case '2': // LDAP
showElem('.ldap');
for (const input of document.querySelectorAll<HTMLInputElement>('.binddnrequired input, .ldap div.required:not(.dldap) input')) {
input.setAttribute('required', 'required');
}
document.querySelector('.binddnrequired')?.classList.add('required');
break;
case '3': // SMTP
showElem('.smtp');
showElem('.has-tls');
for (const input of document.querySelectorAll<HTMLInputElement>('.smtp div.required input, .has-tls')) {
input.setAttribute('required', 'required');
}
break;
case '4': // PAM
showElem('.pam');
for (const input of document.querySelectorAll<HTMLInputElement>('.pam input')) {
input.setAttribute('required', 'required');
}
break;
case '5': // LDAP
showElem('.dldap');
for (const input of document.querySelectorAll<HTMLInputElement>('.dldap div.required:not(.ldap) input')) {
input.setAttribute('required', 'required');
}
break;
case '6': // OAuth2
showElem('.oauth2');
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2 div.required:not(.oauth2_use_custom_url,.oauth2_use_custom_url_field,.open_id_connect_auto_discovery_url) input')) {
input.setAttribute('required', 'required');
}
onOAuth2Change(true);
break;
case '7': // SSPI
showElem('.sspi');
for (const input of document.querySelectorAll<HTMLInputElement>('.sspi div.required input')) {
input.setAttribute('required', 'required');
}
break;
}
if (authType === '2' || authType === '5') {
onSecurityProtocolChange();
onEnableLdapGroupsChange();
}
if (authType === '2') {
onUsePagedSearchChange();
}
};
elAuthType.addEventListener('change', onAuthTypeChange);
onAuthTypeChange();
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(true));
document.querySelector('.js-ldap-group-toggle')!.addEventListener('change', onEnableLdapGroupsChange);
}
// Edit authentication
if (isEditPage) {
const authType = elAuthType.value;
if (authType === '2' || authType === '5') {
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
document.querySelector('.js-ldap-group-toggle')!.addEventListener('change', onEnableLdapGroupsChange);
onEnableLdapGroupsChange();
if (authType === '2') {
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
}
} else if (authType === '6') {
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(false));
onOAuth2Change(false);
}
}
const elAuthName = document.querySelector<HTMLInputElement>('#auth_name')!;
const onAuthNameChange = function () {
// appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
document.querySelector('#oauth2-callback-url')!.textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${pathEscape(elAuthName.value)}/callback`;
};
elAuthName.addEventListener('input', onAuthNameChange);
onAuthNameChange();
}
function initAdminNotice() {
const pageContent = document.querySelector('.page-content.admin.notice');
if (!pageContent) return;
const detailModal = document.querySelector<HTMLDivElement>('#detail-modal')!;
// Attach view detail modals
queryElems(pageContent, '.view-detail', (el) => el.addEventListener('click', (e) => {
e.preventDefault();
const elNoticeDesc = el.closest('tr')!.querySelector('.notice-description')!;
const elModalDesc = detailModal.querySelector('.content pre')!;
elModalDesc.textContent = elNoticeDesc.textContent;
showFomanticModal(detailModal);
}));
// Select actions
const checkboxes = document.querySelectorAll<HTMLInputElement>('.select.table .ui.checkbox input');
queryElems(pageContent, '.select.action', (el) => el.addEventListener('click', () => {
switch (el.getAttribute('data-action')) {
case 'select-all':
for (const checkbox of checkboxes) {
checkbox.checked = true;
}
break;
case 'deselect-all':
for (const checkbox of checkboxes) {
checkbox.checked = false;
}
break;
case 'inverse':
for (const checkbox of checkboxes) {
checkbox.checked = !checkbox.checked;
}
break;
}
}));
document.querySelector<HTMLButtonElement>('#delete-selection')?.addEventListener('click', async function (e) {
e.preventDefault();
this.classList.add('is-loading', 'disabled');
const data = new FormData();
for (const checkbox of checkboxes) {
if (checkbox.checked) {
data.append('ids[]', checkbox.closest('.ui.checkbox')!.getAttribute('data-id')!);
}
}
await POST(this.getAttribute('data-link')!, {data});
window.location.reload();
});
}
+48
View File
@@ -0,0 +1,48 @@
import {ConfigFormValueMapper} from './config.ts';
test('ConfigFormValueMapper', () => {
document.body.innerHTML = `
<form>
<input id="checkbox-unrelated" type="checkbox" value="v-unrelated" checked>
<!-- top-level key -->
<input name="k1" type="checkbox" value="v-key-only" data-config-dyn-key="k1" data-config-value-json="true" data-config-value-type="boolean">
<input type="hidden" data-config-dyn-key="k2" data-config-value-json='"k2-val"'>
<input name="k2">
<textarea name="repository.open-with.editor-apps"> a = b\n</textarea>
<input name="k-flipped-true" type="checkbox" data-config-value-type="flipped">
<input name="k-flipped-false" type="checkbox" checked data-config-value-type="flipped">
<!-- sub key -->
<input type="hidden" data-config-dyn-key="struct" data-config-value-json='{"SubBoolean": true, "SubTimestamp": 123456789, "OtherKey": "other-value"}'>
<input name="struct.SubBoolean" type="checkbox" data-config-value-type="boolean">
<input name="struct.SubTimestamp" type="datetime-local" data-config-value-type="timestamp">
<textarea name="struct.NewKey">new-value</textarea>
</form>
`;
const form = document.querySelector('form')!;
const mapper = new ConfigFormValueMapper(form);
mapper.fillFromSystemConfig();
const formData = mapper.collectToFormData();
const result: Record<string, string> = {};
const keys: string[] = [], values: string[] = [];
for (const [key, value] of formData.entries()) {
if (key === 'key') keys.push(value as string);
if (key === 'value') values.push(value as string);
}
for (let i = 0; i < keys.length; i++) {
result[keys[i]] = values[i];
}
expect(result).toEqual({
'k1': 'true',
'k2': '"k2-val"',
'k-flipped-false': 'false',
'k-flipped-true': 'true',
'repository.open-with.editor-apps': '[{"DisplayName":"a","OpenURL":"b"}]', // TODO: OPEN-WITH-EDITOR-APP-JSON: it must match backend
'struct': '{"SubBoolean":true,"SubTimestamp":123456780,"OtherKey":"other-value","NewKey":"new-value"}',
});
});
+222
View File
@@ -0,0 +1,222 @@
import {showTemporaryTooltip} from '../../modules/tippy.ts';
import {POST} from '../../modules/fetch.ts';
import {registerGlobalInitFunc} from '../../modules/observer.ts';
import {queryElems} from '../../utils/dom.ts';
import {errorMessage} from '../../modules/errors.ts';
import {submitFormFetchAction} from '../common-fetch-action.ts';
const {appSubUrl} = window.config;
function collectCheckboxBooleanValue(el: HTMLInputElement): boolean {
const valType = el.getAttribute('data-config-value-type') as ConfigValueType;
if (valType === 'boolean') return el.checked;
if (valType === 'flipped') return !el.checked;
requireExplicitValueType(el);
}
function initSystemConfigAutoCheckbox(el: HTMLInputElement) {
el.addEventListener('change', async () => {
// if the checkbox is inside a form, we assume it's handled by the form submit and do not send an individual request
if (el.closest('form')) return;
try {
const data = new URLSearchParams({
key: el.getAttribute('data-config-dyn-key')!,
value: String(collectCheckboxBooleanValue(el)),
});
const resp = await POST(`${appSubUrl}/-/admin/config`, {data});
const json: Record<string, any> = await resp.json();
if (json.errorMessage) throw new Error(json.errorMessage);
} catch (ex) {
showTemporaryTooltip(el, errorMessage(ex));
el.checked = !el.checked;
}
});
}
type GeneralFormFieldElement = HTMLInputElement;
function unsupportedElement(el: Element): never {
// HINT: for future developers: if you need to handle a config that cannot be directly mapped to a form element, you should either:
// * Add a "hidden" input to store the value (not configurable)
// * Design a new "component" to handle the config
throw new Error(`Unsupported config form value mapping for ${el.nodeName} (name=${(el as HTMLInputElement).name},type=${(el as HTMLInputElement).type}), please add more and design carefully`);
}
function requireExplicitValueType(el: Element): never {
throw new Error(`Unsupported config form value type for ${el.nodeName} (name=${(el as HTMLInputElement).name},type=${(el as HTMLInputElement).type}), please add explicit value type with "data-config-value-type" attribute`);
}
// try to extract the subKey for the config value from the element name
// * return '' if the element name exactly matches the config key, which means the value is directly stored in the element
// * return null if the config key not match
function extractElemConfigSubKey(el: GeneralFormFieldElement, dynKey: string): string | null {
if (el.name === dynKey) return '';
if (el.name.startsWith(`${dynKey}.`)) return el.name.slice(dynKey.length + 1); // +1 for the dot
return null;
}
// Due to the different design between HTML form elements and the JSON struct of the config values, we need to explicitly define some types.
// * checkbox can be used for boolean value, it can also be used for multiple values (array)
type ConfigValueType = 'boolean' | 'flipped' | 'string' | 'number' | 'timestamp'; // TODO: support more types like array, not used at the moment.
function toDatetimeLocalValue(unixSeconds: number) {
const d = new Date(unixSeconds * 1000);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
}
export class ConfigFormValueMapper {
form: HTMLFormElement;
presetJsonValues: Record<string, any> = {};
presetValueTypes: Record<string, ConfigValueType> = {};
constructor(form: HTMLFormElement) {
this.form = form;
for (const el of queryElems<HTMLInputElement>(form, '[data-config-value-json]')) {
const dynKey = el.getAttribute('data-config-dyn-key')!;
const jsonStr = el.getAttribute('data-config-value-json');
try {
this.presetJsonValues[dynKey] = JSON.parse(jsonStr || '{}'); // empty string also is valid, default to an empty object
} catch (error) {
this.presetJsonValues[dynKey] = {}; // in case the value in database is corrupted, don't break the whole form
console.error(`Error parsing JSON for config ${dynKey}:`, error);
}
}
for (const el of queryElems<HTMLInputElement>(form, '[data-config-value-type]')) {
const valKey = el.getAttribute('data-config-dyn-key') || el.name;
this.presetValueTypes[valKey] = el.getAttribute('data-config-value-type')! as ConfigValueType;
}
}
// try to assign the config value to the form element, return true if assigned successfully,
// otherwise return false (e.g. the element is not related to the config key)
assignConfigValueToFormElement(el: GeneralFormFieldElement, dynKey: string, cfgVal: any) {
const subKey = extractElemConfigSubKey(el, dynKey);
if (subKey === null) return false; // if not match, skip
const val = subKey ? cfgVal![subKey] : cfgVal;
if (val === null) return true; // if name matches, but no value to assign, also succeed because the form element does exist
const valType = this.presetValueTypes[el.name];
if (el.matches('[type="checkbox"]')) {
if (valType !== 'boolean') requireExplicitValueType(el);
el.checked = Boolean(val ?? el.checked);
} else if (el.matches('[type="datetime-local"]')) {
if (valType !== 'timestamp') requireExplicitValueType(el);
if (val) el.value = toDatetimeLocalValue(val);
} else if (el.matches('textarea')) {
el.value = String(val ?? el.value);
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
el.value = String(val ?? el.value);
} else {
unsupportedElement(el);
}
return true;
}
collectConfigValueFromElement(el: GeneralFormFieldElement) {
let val: any;
const valType = this.presetValueTypes[el.name];
if (el.matches('[type="checkbox"]')) {
// TODO: if it needs to support array values in the future,
// it needs to iterate the "namedElems" to find all the checkboxes with the same name and collect values accordingly,
// and set the namedElems[matchedIdx] to null to avoid duplicate processing.
val = collectCheckboxBooleanValue(el);
} else if (el.matches('[type="datetime-local"]')) {
if (valType !== 'timestamp') requireExplicitValueType(el);
val = Math.floor(new Date(el.value).getTime() / 1000) ?? 0; // NaN is fine to JSON.stringify, it becomes null.
} else if (el.matches('textarea')) {
val = el.value;
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
val = el.value;
} else {
unsupportedElement(el);
}
return val;
}
collectConfigSubValues(namedElems: Array<GeneralFormFieldElement | null>, dynKey: string, cfgVal: Record<string, any>) {
for (let idx = 0; idx < namedElems.length; idx++) {
const el = namedElems[idx];
if (!el) continue;
const subKey = extractElemConfigSubKey(el, dynKey);
if (!subKey) continue; // if not match, skip
cfgVal[subKey] = this.collectConfigValueFromElement(el);
namedElems[idx] = null;
}
}
fillFromSystemConfig() {
for (const [dynKey, cfgVal] of Object.entries(this.presetJsonValues)) {
const elems = this.form.querySelectorAll<GeneralFormFieldElement>(`[name^="${CSS.escape(dynKey)}"]`);
let assigned = false;
for (const el of elems) {
if (this.assignConfigValueToFormElement(el, dynKey, cfgVal)) {
assigned = true;
}
}
if (!assigned) throw new Error(`Could not find form element for config ${dynKey}, please check the form design and json struct`);
}
}
// TODO: OPEN-WITH-EDITOR-APP-JSON: need to use the same logic as backend
marshalConfigValueOpenWithEditorApps(cfgVal: string): string {
const apps: Array<{DisplayName: string, OpenURL: string}> = [];
const lines = cfgVal.split('\n');
for (const line of lines) {
let [displayName, openUrl] = line.split('=', 2);
displayName = displayName.trim();
openUrl = openUrl?.trim() ?? '';
if (!displayName || !openUrl) continue;
apps.push({DisplayName: displayName, OpenURL: openUrl});
}
return JSON.stringify(apps);
}
marshalConfigValue(dynKey: string, cfgVal: any): string {
if (dynKey === 'repository.open-with.editor-apps') return this.marshalConfigValueOpenWithEditorApps(cfgVal);
return JSON.stringify(cfgVal);
}
collectToFormData(): FormData {
const namedElems: Array<GeneralFormFieldElement | null> = [];
queryElems(this.form, '[name]', (el) => namedElems.push(el as GeneralFormFieldElement));
// first, process the config options with sub values, for example:
// merge "foo.bar.Enabled", "foo.bar.Message" to "foo.bar"
const formData = new FormData();
for (const [dynKey, cfgVal] of Object.entries(this.presetJsonValues)) {
this.collectConfigSubValues(namedElems, dynKey, cfgVal);
formData.append('key', dynKey);
formData.append('value', this.marshalConfigValue(dynKey, cfgVal));
}
// now, the namedElems should only contain the config options without sub values,
// directly store the value in formData with key as the element name, for example:
// "foo.enabled" => "true"
for (const el of namedElems) {
if (!el) continue;
const dynKey = el.name;
const newVal = this.collectConfigValueFromElement(el);
formData.append('key', dynKey);
formData.append('value', this.marshalConfigValue(dynKey, newVal));
}
return formData;
}
}
function initSystemConfigForm(form: HTMLFormElement) {
const formMapper = new ConfigFormValueMapper(form);
formMapper.fillFromSystemConfig();
form.addEventListener('submit', async (e) => {
if (!form.reportValidity()) return;
e.preventDefault();
const formData = formMapper.collectToFormData();
await submitFormFetchAction(form, {formData});
});
}
export function initAdminConfigs(): void {
registerGlobalInitFunc('initAdminConfigSettings', (el) => {
queryElems(el, 'input[type="checkbox"][data-config-dyn-key]', initSystemConfigAutoCheckbox);
queryElems(el, 'form.system-config-form', initSystemConfigForm);
});
}
+31
View File
@@ -0,0 +1,31 @@
import {toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
const {appSubUrl} = window.config;
export async function initAdminSelfCheck() {
const elCheckByFrontend = document.querySelector('#self-check-by-frontend');
if (!elCheckByFrontend) return;
const elContent = document.querySelector<HTMLDivElement>('.page-content.admin .admin-setting-content')!;
// send frontend self-check request
const resp = await POST(`${appSubUrl}/-/admin/self_check`, {
data: new URLSearchParams({
location_origin: window.location.origin,
now: String(Date.now()), // TODO: check time difference between server and client
}),
});
const json: Record<string, any> = await resp.json();
toggleElem(elCheckByFrontend, Boolean(json.problems?.length));
for (const problem of json.problems ?? []) {
const elProblem = document.createElement('div');
elProblem.classList.add('ui', 'warning', 'message');
elProblem.textContent = problem;
elCheckByFrontend.append(elProblem);
}
// only show the "no problem" if there is no visible "self-check-problem"
const hasProblem = Boolean(elContent.querySelectorAll('.self-check-problem:not(.tw-hidden)').length);
toggleElem(elContent.querySelector('.self-check-no-problem')!, !hasProblem);
}
+39
View File
@@ -0,0 +1,39 @@
export function initAdminUserListSearchForm(): void {
const searchForm = window.config.pageData.adminUserListSearchForm;
if (!searchForm) return;
const form = document.querySelector<HTMLFormElement>('#user-list-search-form');
if (!form) return;
for (const button of form.querySelectorAll(`button[name=sort][value="${searchForm.SortType}"]`)) {
button.classList.add('active');
}
if (searchForm.StatusFilterMap) {
for (const [k, v] of Object.entries(searchForm.StatusFilterMap)) {
if (!v) continue;
for (const input of form.querySelectorAll<HTMLInputElement>(`input[name="status_filter[${k}]"][value="${v}"]`)) {
input.checked = true;
}
}
}
for (const radio of form.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
radio.addEventListener('click', () => {
form.submit();
});
}
const resetButtons = form.querySelectorAll<HTMLAnchorElement>('.j-reset-status-filter');
for (const button of resetButtons) {
button.addEventListener('click', (e) => {
e.preventDefault();
for (const input of form.querySelectorAll<HTMLInputElement>('input[type=radio]')) {
if (input.name.startsWith('status_filter[')) {
input.checked = false;
}
}
form.submit();
});
}
}
+45
View File
@@ -0,0 +1,45 @@
import {isDarkTheme} from '../utils.ts';
export async function initCaptcha() {
const captchaEl = document.querySelector('#captcha');
if (!captchaEl) return;
const siteKey = captchaEl.getAttribute('data-sitekey')!;
const isDark = isDarkTheme();
const params = {
sitekey: siteKey,
theme: isDark ? 'dark' : 'light',
};
switch (captchaEl.getAttribute('data-captcha-type')) {
case 'g-recaptcha': {
if (window.grecaptcha) {
window.grecaptcha.ready(() => {
window.grecaptcha.render(captchaEl, params);
});
}
break;
}
case 'cf-turnstile': {
if (window.turnstile) {
window.turnstile.render(captchaEl, params);
}
break;
}
case 'h-captcha': {
if (window.hcaptcha) {
window.hcaptcha.render(captchaEl, params);
}
break;
}
case 'm-captcha': {
// ref: https://github.com/mCaptcha/glue/blob/master/packages/vanilla/README.md
// sample: https://github.com/mCaptcha/glue/blob/master/packages/vanilla/static/embeded.html
// @mcaptcha/vanilla-glue 0.1.0-rc2 auto-runs on module load, use the existing elements to render.
await import('@mcaptcha/vanilla-glue');
break;
}
default:
}
}
+71
View File
@@ -0,0 +1,71 @@
import {getCurrentLocale} from '../utils.ts';
import {errorMessage} from '../modules/errors.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {localUserSettings} from '../modules/user-settings.ts';
const {pageData} = window.config;
async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) {
const [{Cite, plugins}] = await Promise.all([
import('@citation-js/core'),
import('@citation-js/plugin-software-formats'),
import('@citation-js/plugin-bibtex'),
import('@citation-js/plugin-csl'),
]);
const citationFileContent = pageData.citationFileContent!;
const config = plugins.config.get('@bibtex');
config.constants.fieldTypes.doi = ['field', 'literal'];
config.constants.fieldTypes.version = ['field', 'literal'];
const citationFormatter = new Cite(citationFileContent);
const lang = getCurrentLocale() || 'en-US';
const apaOutput = citationFormatter.format('bibliography', {template: 'apa', lang});
const bibtexOutput = citationFormatter.format('bibtex', {lang});
citationCopyBibtex.setAttribute('data-text', bibtexOutput);
citationCopyApa.setAttribute('data-text', apaOutput);
}
export async function initCitationFileCopyContent() {
const defaultCitationFormat = 'apa'; // apa or bibtex
if (!pageData.citationFileContent) return;
const citationCopyApa = document.querySelector<HTMLButtonElement>('#citation-copy-apa')!;
const citationCopyBibtex = document.querySelector<HTMLButtonElement>('#citation-copy-bibtex')!;
const inputContent = document.querySelector<HTMLInputElement>('#citation-copy-content');
if ((!citationCopyApa && !citationCopyBibtex) || !inputContent) return;
const updateUi = () => {
const isBibtex = localUserSettings.getString('citation-copy-format', defaultCitationFormat) === 'bibtex';
const copyContent = (isBibtex ? citationCopyBibtex : citationCopyApa).getAttribute('data-text')!;
inputContent.value = copyContent;
citationCopyBibtex.classList.toggle('primary', isBibtex);
citationCopyApa.classList.toggle('primary', !isBibtex);
};
document.querySelector('#cite-repo-button')?.addEventListener('click', async () => {
try {
await initInputCitationValue(citationCopyApa, citationCopyBibtex);
} catch (e) {
console.error(`initCitationFileCopyContent error: ${errorMessage(e)}`, e);
return;
}
updateUi();
citationCopyApa.addEventListener('click', () => {
localUserSettings.setString('citation-copy-format', 'apa');
updateUi();
});
citationCopyBibtex.addEventListener('click', () => {
localUserSettings.setString('citation-copy-format', 'bibtex');
updateUi();
});
inputContent.addEventListener('click', () => {
inputContent.select();
});
showFomanticModal(document.querySelector('#cite-repo-modal'));
});
}
+21
View File
@@ -0,0 +1,21 @@
import {createApp} from 'vue';
export async function initRepoCodeFrequency() {
const el = document.querySelector('#repo-code-frequency-chart');
if (!el) return;
const {default: RepoCodeFrequency} = await import('../components/RepoCodeFrequency.vue');
try {
const View = createApp(RepoCodeFrequency, {
locale: {
loadingTitle: el.getAttribute('data-locale-loading-title'),
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
loadingInfo: el.getAttribute('data-locale-loading-info'),
},
});
View.mount(el);
} catch (err) {
console.error('RepoCodeFrequency failed to load', err);
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
}
}
+74
View File
@@ -0,0 +1,74 @@
import {createTippy} from '../modules/tippy.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
export async function initColorPickers() {
let imported = false;
registerGlobalInitFunc('initColorPicker', async (el) => {
if (!imported) {
await Promise.all([
import('vanilla-colorful/hex-color-picker.js'),
import('../../css/features/colorpicker.css'),
]);
imported = true;
}
initPicker(el);
});
}
function updateSquare(el: HTMLElement, newValue: string): void {
el.style.color = /#[0-9a-f]{6}/i.test(newValue) ? newValue : 'transparent';
}
function updatePicker(el: HTMLElement, newValue: string): void {
el.setAttribute('color', newValue);
}
function initPicker(el: HTMLElement): void {
const input = el.querySelector('input')!;
const square = document.createElement('div');
square.classList.add('preview-square');
updateSquare(square, input.value);
el.append(square);
const picker = document.createElement('hex-color-picker');
picker.addEventListener('color-changed', (e) => {
input.value = e.detail.value;
input.focus();
updateSquare(square, e.detail.value);
});
input.addEventListener('input', (e) => {
updateSquare(square, (e.target as HTMLInputElement).value);
updatePicker(picker, (e.target as HTMLInputElement).value);
});
createTippy(input, {
trigger: 'focus click',
theme: 'bare',
hideOnClick: true,
content: picker,
placement: 'bottom-start',
interactive: true,
onShow() {
updatePicker(picker, input.value);
},
});
// init random color & precolors
const setSelectedColor = (color: string) => {
input.value = color;
input.dispatchEvent(new Event('input', {bubbles: true}));
updateSquare(square, color);
};
el.querySelector('.generate-random-color')!.addEventListener('click', () => {
const newValue = `#${Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0')}`;
setSelectedColor(newValue);
});
for (const colorEl of el.querySelectorAll<HTMLElement>('.precolors .color')) {
colorEl.addEventListener('click', (e) => {
const newValue = (e.target as HTMLElement).getAttribute('data-color-hex')!;
setSelectedColor(newValue);
});
}
}
@@ -0,0 +1,34 @@
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {toggleElem, toggleElemClass} from '../utils/dom.ts';
export function initActionsPermissionsForm(): void {
registerGlobalInitFunc('initRepoActionsPermissionsForm', initRepoActionsPermissionsForm);
registerGlobalInitFunc('initOwnerActionsPermissionsForm', initOwnerActionsPermissionsForm);
}
function initRepoActionsPermissionsForm(form: HTMLFormElement) {
initActionsOverrideOwnerConfig(form);
initActionsPermissionTable(form);
}
function initOwnerActionsPermissionsForm(form: HTMLFormElement) {
initActionsPermissionTable(form);
}
function initActionsPermissionTable(form: HTMLFormElement) {
// show or hide permissions table based on enable max permissions checkbox (aka: whether you use custom permissions or not)
const permTable = form.querySelector<HTMLTableElement>('.js-permissions-table')!;
const enableMaxCheckbox = form.querySelector<HTMLInputElement>('input[name=enable_max_permissions]')!;
const onEnableMaxCheckboxChange = () => toggleElem(permTable, enableMaxCheckbox.checked);
onEnableMaxCheckboxChange();
enableMaxCheckbox.addEventListener('change', onEnableMaxCheckboxChange);
}
function initActionsOverrideOwnerConfig(form: HTMLFormElement) {
// enable or disable repo token permissions config section based on override owner config checkbox
const overrideOwnerConfig = form.querySelector<HTMLInputElement>('input[name=override_owner_config]')!;
const repoTokenPermConfigSection = form.querySelector('.js-repo-token-permissions-config')!;
const onOverrideOwnerConfigChange = () => toggleElemClass(repoTokenPermConfigSection, 'container-disabled', !overrideOwnerConfig.checked);
onOverrideOwnerConfigChange();
overrideOwnerConfig.addEventListener('change', onOverrideOwnerConfigChange);
}
+25
View File
@@ -0,0 +1,25 @@
import {assignElementProperty, type ElementWithAssignableProperties} from './common-button.ts';
test('assignElementProperty', () => {
const elForm = document.createElement('form');
assignElementProperty(elForm, 'action', '/test-link');
expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL
expect(elForm.getAttribute('action')).eq('/test-link');
assignElementProperty(elForm, 'text-content', 'dummy');
expect(elForm.textContent).toBe('dummy');
// mock a form with its property "action" overwritten by an input element
const elFormWithAction = new class implements ElementWithAssignableProperties {
action = document.createElement('input'); // now "form.action" is not string, but an input element
_attrs: Record<string, string> = {};
setAttribute(name: string, value: string) { this._attrs[name] = value }
getAttribute(name: string): string | null { return this._attrs[name] }
}();
assignElementProperty(elFormWithAction, 'action', '/bar');
expect(elFormWithAction.getAttribute('action')).eq('/bar');
const elInput = document.createElement('input');
expect(elInput.readOnly).toBe(false);
assignElementProperty(elInput, 'read-only', 'true');
expect(elInput.readOnly).toBe(true);
});
+197
View File
@@ -0,0 +1,197 @@
import {POST} from '../modules/fetch.ts';
import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
import {camelize} from 'vue';
import {applyAutoFocus} from './common-page.ts';
export function initGlobalButtonClickOnEnter(): void {
addDelegatedEventListener(document, 'keypress', 'div.ui.button, span.ui.button', (el, e: KeyboardEvent) => {
if (e.code === 'Space' || e.code === 'Enter') {
e.preventDefault();
el.click();
}
});
}
export function initGlobalDeleteButton(): void {
// ".delete-button" shows a confirmation modal defined by `data-modal-id` attribute.
// Some model/form elements will be filled by `data-id` / `data-name` / `data-data-xxx` attributes.
// If there is a form defined by `data-form`, then the form will be submitted as-is (without any modification).
// If there is no form, then the data will be posted to `data-url`.
// TODO: do not use this method in new code. `show-modal` / `link-action(data-modal-confirm)` does far better than this.
// FIXME: all legacy `delete-button` should be refactored to use `show-modal` or `link-action`
for (const btn of document.querySelectorAll<HTMLElement>('.delete-button')) {
btn.addEventListener('click', (e) => {
e.preventDefault();
// eslint-disable-next-line github/no-dataset -- code depends on the camel-casing
const dataObj = btn.dataset;
const modalId = btn.getAttribute('data-modal-id');
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`)!;
// set the modal "display name" by `data-name`
const modalNameEl = modal.querySelector('.name');
if (modalNameEl) modalNameEl.textContent = btn.getAttribute('data-name');
// fill the modal elements with data-xxx attributes: `data-data-organization-name="..."` => `<span class="dataOrganizationName">...</span>`
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) {
const textEl = modal.querySelector(`.${key}`);
if (textEl) textEl.textContent = value ?? null;
}
}
showFomanticModal(modal, {
closable: false,
onApprove: () => {
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
if (btn.getAttribute('data-type') === 'form') {
const formSelector = btn.getAttribute('data-form')!;
const form = document.querySelector<HTMLFormElement>(formSelector);
if (!form) throw new Error(`no form named ${formSelector} found`);
modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal
form.classList.add('is-loading');
form.submit();
return false; // prevent modal from closing automatically
}
// prepare an AJAX form by data attributes
const postData = new FormData();
for (const [key, value] of Object.entries(dataObj)) {
if (key.startsWith('data')) { // for data-data-xxx (HTML) -> dataXxx (form)
postData.append(key.slice(4), String(value));
}
if (key === 'id') { // for data-id="..."
postData.append('id', String(value));
}
}
(async () => {
const response = await POST(btn.getAttribute('data-url')!, {data: postData});
if (response.ok) {
const data = await response.json();
window.location.href = data.redirect;
}
})();
modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal
return false; // prevent modal from closing automatically
},
});
});
}
}
function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
// a '.show-panel' element can show a panel, by `data-panel="selector"`
// if it has "toggle" class, it toggles the panel
e.preventDefault();
const sel = el.getAttribute('data-panel')!;
const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel);
for (const elem of elems) {
if (isElemVisible(elem as HTMLElement)) {
applyAutoFocus(elem);
}
}
}
function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
// a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"`
e.preventDefault();
let sel = el.getAttribute('data-panel');
if (sel) {
hideElem(sel);
return;
}
sel = el.getAttribute('data-panel-closest');
if (sel) {
hideElem((el.parentNode as HTMLElement).closest(sel)!);
return;
}
throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code
}
export type ElementWithAssignableProperties = {
getAttribute: (name: string) => string | null;
setAttribute: (name: string, value: string) => void;
} & Record<string, any>;
export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
const camelizedName = camelize(kebabName);
const old = el[camelizedName];
if (typeof old === 'boolean') {
el[camelizedName] = val === 'true';
} else if (typeof old === 'number') {
el[camelizedName] = parseFloat(val);
} else if (typeof old === 'string') {
el[camelizedName] = val;
} else if (old?.nodeName) {
// "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute
el.setAttribute(kebabName, val);
} else {
// in the future, we could introduce a better typing system like `data-modal-form.action:string="..."`
throw new Error(`cannot assign element property "${camelizedName}" by value "${val}"`);
}
}
function onShowModalClick(el: HTMLElement, e: MouseEvent) {
// A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute.
// Each "data-modal-{target}" attribute will be filled to target element's value or text-content.
// * First, try to query '#target'
// * Then, try to query '[name=target]'
// * Then, try to query '.target'
// * Then, try to query 'target' as HTML tag
// If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName".
e.preventDefault();
const modalSelector = el.getAttribute('data-modal')!;
const elModal = document.querySelector(modalSelector);
if (!elModal) throw new Error('no modal for this action');
const modalAttrPrefix = 'data-modal-';
for (const attrib of el.attributes) {
if (!attrib.name.startsWith(modalAttrPrefix)) {
continue;
}
const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
const [attrTargetName, attrTargetProp] = attrTargetCombo.split('.');
// try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag", and then try the modal itself
const attrTarget = elModal.querySelector(`#${attrTargetName}`) ||
elModal.querySelector(`[name=${attrTargetName}]`) ||
elModal.querySelector(`.${attrTargetName}`) ||
elModal.querySelector(`${attrTargetName}`) ||
(elModal.matches(`${attrTargetName}`) || elModal.matches(`#${attrTargetName}`) || elModal.matches(`.${attrTargetName}`) ? elModal : null);
if (!attrTarget) {
if (!window.config.runModeIsProd) throw new Error(`attr target "${attrTargetCombo}" not found for modal`);
continue;
}
if (attrTargetProp) {
assignElementProperty(attrTarget, attrTargetProp, attrib.value);
} else if (attrTarget.matches('input, textarea')) {
(attrTarget as HTMLInputElement | HTMLTextAreaElement).value = attrib.value; // FIXME: add more supports like checkbox
} else {
attrTarget.textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p
}
}
showFomanticModal(elModal);
}
export function initGlobalButtons(): void {
// There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form.
// However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission.
// There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content")
addDelegatedEventListener(document, 'click', 'form button.ui.cancel.button', (_ /* el */, e) => e.preventDefault());
// Ideally these "button" events should be handled by registerGlobalEventFunc
// Refactoring would involve too many changes, so at the moment, just use the global event listener.
addDelegatedEventListener(document, 'click', '.show-panel, .hide-panel, .show-modal', (el, e: MouseEvent) => {
if (el.classList.contains('show-panel')) {
onShowPanelClick(el, e);
} else if (el.classList.contains('hide-panel')) {
onHidePanelClick(el, e);
} else if (el.classList.contains('show-modal')) {
onShowModalClick(el, e);
}
});
}
@@ -0,0 +1,59 @@
import {execPseudoSelectorCommands, handleFetchActionSuccessJson} from './common-fetch-action.ts';
test('execPseudoSelectorCommands', () => {
window.document.body.innerHTML = `
<div id="d1">
<ul id="u1">
<li class="x"></li>
</ul>
<ul id="u2">
<li class="x"></li>
</ul>
</div>
<div id="d2">
<ul id="u3">
<li class="x"></li>
</ul>
</div>`;
let ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '');
expect(ret.targets).toEqual([document.querySelector('#u1')]);
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$this');
expect(ret.targets).toEqual([document.querySelector('#u1')]);
expect(ret.cmdInnerHTML).toBeFalsy();
expect(ret.cmdMorph).toBeFalsy();
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body $morph $innerHTML');
expect(ret.targets).toEqual([document.body]);
expect(ret.cmdInnerHTML).toBeTruthy();
expect(ret.cmdMorph).toBeTruthy();
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body .x');
expect(ret.targets.length).toEqual(3);
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('.x')));
ret = execPseudoSelectorCommands(document.querySelector('#u1 .x')!, '$closest(div) .x');
expect(ret.targets.length).toEqual(2);
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('#d1 .x')));
});
test('handleFetchActionSuccessJson', async () => {
const spyAssign = vi.spyOn(window.location, 'assign').mockImplementation(() => {});
const spyReload = vi.spyOn(window.location, 'reload').mockImplementation(() => {});
await handleFetchActionSuccessJson(document.body, {redirect: '/'});
expect(spyAssign).toHaveBeenCalledTimes(1);
expect(spyReload).toHaveBeenCalledTimes(0);
vi.resetAllMocks();
await handleFetchActionSuccessJson(document.body, {redirect: ''});
expect(spyAssign).toHaveBeenCalledTimes(0);
expect(spyReload).toHaveBeenCalledTimes(1);
vi.resetAllMocks();
await handleFetchActionSuccessJson(document.body, {});
expect(spyAssign).toHaveBeenCalledTimes(0);
expect(spyReload).toHaveBeenCalledTimes(1);
vi.resetAllMocks();
});
+418
View File
@@ -0,0 +1,418 @@
import {GET, request} from '../modules/fetch.ts';
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {addDelegatedEventListener, createElementFromHTML} from '../utils/dom.ts';
import {errorMessage, errorName} from '../modules/errors.ts';
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {Idiomorph} from 'idiomorph';
import {parseDom} from '../utils.ts';
import {html} from '../utils/html.ts';
const {appSubUrl, runModeIsProd} = window.config;
type FetchActionOpts = {
method: string;
url: string;
headers?: HeadersInit;
body?: FormData;
formSubmitter?: HTMLElement | null;
// pseudo selectors/commands to update the current page with the response text when the response is text (html)
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
successSync: string;
// the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
// empty means no loading indicator, "$this" means the element itself
loadingIndicator: string;
};
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
// more details are in the backend's fetch-redirect handler
function fetchActionDoRedirect(redirect: string) {
// In production, if the link can be directly navigated by browser, we just do normal redirection, which is faster.
// Otherwise, need to use backend to do redirection:
// * Also do so in development, to make sure the redirection logic is always tested by real users
const needBackendHelp = redirect.includes('#');
if (runModeIsProd && !needBackendHelp) {
window.location.assign(redirect);
return;
}
// use backend to do redirection, which can bypass the browser's limitations of "location"
const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
document.body.append(form);
form.submit();
}
function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) {
const loadingIndicatorElems = opt.loadingIndicator ? execPseudoSelectorCommands(el, opt.loadingIndicator).targets : [];
for (const indicatorEl of loadingIndicatorElems) {
if (isLoading) {
// for button or input element, we can directly disable it, it looks better than adding a loading spinner
if ('disabled' in indicatorEl) {
indicatorEl.disabled = true;
} else {
indicatorEl.classList.add('is-loading');
if (indicatorEl.clientHeight < 50) indicatorEl.classList.add('loading-icon-2px');
}
} else {
if ('disabled' in indicatorEl) {
indicatorEl.disabled = false;
} else {
indicatorEl.classList.remove('is-loading', 'loading-icon-2px');
}
}
}
}
export async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
const redirect = respJson?.redirect;
if (typeof redirect === 'string' && redirect) {
fetchActionDoRedirect(redirect);
} else {
// reserved behavior, in the future, there can be more fields to introduce more behaviors
window.location.reload();
}
}
async function handleFetchActionSuccess(el: HTMLElement, opt: FetchActionOpts, resp: Response) {
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
const respText = await resp.text();
const respJson = isRespJson ? JSON.parse(respText) : null;
if (isRespJson) {
await handleFetchActionSuccessJson(el, respJson);
} else if (opt.successSync) {
await handleFetchActionSuccessSync(el, opt.successSync, respText);
} else {
showErrorToast(`Unsupported fetch action response, expected JSON but got: ${respText.substring(0, 200)}`);
}
}
async function handleFetchActionError(resp: Response) {
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
const respText = await resp.text();
const respJson = isRespJson ? JSON.parse(respText) : null;
if (respJson?.errorMessage) {
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else {
showErrorToast(`Error ${resp.status} ${resp.statusText}. Response: ${respText.substring(0, 200)}`);
}
}
function buildFetchActionUrl(el: HTMLElement, opt: FetchActionOpts) {
let url = opt.url;
if ('name' in el && 'value' in el) {
// ref: https://htmx.org/attributes/hx-get/
// If the element with the hx-get attribute also has a value, this will be included as a parameter
const name = (el as HTMLInputElement).name;
const val = (el as HTMLInputElement).value;
const u = new URL(url, window.location.href);
if (name && !u.searchParams.has(name)) {
u.searchParams.set(name, val);
url = u.toString();
}
}
return url;
}
async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
const attrIsLoading = 'data-fetch-is-loading';
if (el.getAttribute(attrIsLoading)) return;
if (!await confirmFetchAction(opt.formSubmitter ?? el)) return;
el.setAttribute(attrIsLoading, 'true');
toggleLoadingIndicator(el, opt, true);
try {
const url = buildFetchActionUrl(el, opt);
const headers = new Headers(opt.headers);
headers.set('X-Gitea-Fetch-Action', '1');
const resp = await request(url, {method: opt.method, body: opt.body, headers});
if (resp.ok) {
await handleFetchActionSuccess(el, opt, resp);
return;
}
await handleFetchActionError(resp);
} catch (err) {
if (errorName(err) !== 'AbortError') {
console.error(`Fetch action request error:`, err);
showErrorToast(`Error: ${errorMessage(err)}`);
}
} finally {
toggleLoadingIndicator(el, opt, false);
el.removeAttribute(attrIsLoading);
}
}
type SubmitFormFetchActionOpts = {
formSubmitter?: HTMLElement | null;
formData?: FormData;
};
function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}): FetchActionOpts {
const formMethodUpper = formEl.getAttribute('method')?.toUpperCase() || 'GET';
const formActionUrl = formEl.getAttribute('action') || window.location.href;
const formData = opts.formData ?? new FormData(formEl);
const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')];
if (submitterName) {
formData.append(submitterName, submitterValue || '');
}
let reqUrl = formActionUrl;
let reqBody: FormData | undefined;
if (formMethodUpper === 'GET') {
const params = new URLSearchParams();
for (const [key, value] of formData) {
params.append(key, value as string);
}
const pos = reqUrl.indexOf('?');
if (pos !== -1) {
reqUrl = reqUrl.slice(0, pos);
}
reqUrl += `?${params.toString()}`;
} else {
reqBody = formData;
}
return {
method: formMethodUpper,
url: reqUrl,
body: reqBody,
formSubmitter: opts.formSubmitter,
loadingIndicator: '$this', // for form submit, by default, the loading indicator is the whole form
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
};
}
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
hideToastsAll();
await performActionRequest(formEl, prepareFormFetchActionOpts(formEl, opts));
}
async function confirmFetchAction(el: HTMLElement) {
let elModal: HTMLElement | null = null;
const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
if (dataModalConfirm.startsWith('#')) {
// eslint-disable-next-line unicorn/prefer-query-selector
elModal = document.getElementById(dataModalConfirm.substring(1));
if (elModal) {
elModal = createElementFromHTML(elModal.outerHTML);
elModal.removeAttribute('id');
}
}
if (!elModal) {
const modalConfirmContent = dataModalConfirm || el.getAttribute('data-modal-confirm-content') || '';
if (modalConfirmContent) {
const isRisky = el.classList.contains('red') || el.classList.contains('negative');
elModal = createConfirmModal({
header: el.getAttribute('data-modal-confirm-header') || '',
content: modalConfirmContent,
confirmButtonColor: isRisky ? 'red' : 'primary',
});
}
}
if (!elModal) return true;
return await confirmModal(elModal);
}
async function performLinkFetchAction(el: HTMLElement) {
hideToastsAll();
await performActionRequest(el, {
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
url: el.getAttribute('data-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? '$this', // by default, the link-action itself is the loading indicator
successSync: el.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for link-action
});
}
type FetchActionTriggerType = 'click' | 'change' | 'every' | 'load' | 'fetch-reload';
export async function performFetchActionTrigger(el: HTMLElement, triggerType: FetchActionTriggerType) {
const isUserInitiated = triggerType === 'click' || triggerType === 'change';
// for user initiated action, by default, the loading indicator is the element itself, otherwise no loading indicator
const defaultLoadingIndicator = isUserInitiated ? '$this' : '';
if (isUserInitiated) hideToastsAll();
await performActionRequest(el, {
method: el.getAttribute('data-fetch-method') || 'GET', // by default, the method is GET for fetch trigger action
url: el.getAttribute('data-fetch-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? defaultLoadingIndicator,
successSync: el.getAttribute('data-fetch-sync') ?? '$this', // by default, the response will replace the current element
});
}
type PseudoSelectorCommandResult = {
targets: Element[];
cmdInnerHTML: boolean;
cmdMorph: boolean;
};
export function execPseudoSelectorCommands(el: Element, fullCommand: string): PseudoSelectorCommandResult {
const cmds = fullCommand.split(' ').map((s) => s.trim()).filter(Boolean) || [];
let targets = [el], cmdInnerHTML = false, cmdMorph = false;
for (const cmd of cmds) {
if (cmd === '$this') {
targets = [el];
} else if (cmd === '$body') {
targets = [document.body];
} else if (cmd === '$innerHTML') {
cmdInnerHTML = true;
} else if (cmd === '$morph') {
cmdMorph = true;
} else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
const selector = cmd.substring('$closest('.length, cmd.length - 1);
const newTargets: Element[] = [];
for (const target of targets) {
const closest = target.closest(selector);
if (closest) newTargets.push(closest);
}
targets = newTargets;
} else {
const newTargets: Element[] = [];
for (const target of targets) {
newTargets.push(...target.querySelectorAll(cmd));
}
targets = newTargets;
}
}
return {targets, cmdInnerHTML, cmdMorph};
}
async function handleFetchActionSuccessSync(el: Element, successSync: string, respText: string) {
const res = execPseudoSelectorCommands(el, successSync);
if (!res.targets.length) throw new Error(`Fetch-sync command "${successSync}" did not find any target element to update`);
if (res.targets.length > 1) throw new Error(`Fetch-sync command "${successSync}" found multiple target elements, which is not supported`);
const target = res.targets[0];
if (res.cmdMorph) {
Idiomorph.morph(target, respText, {morphStyle: res.cmdInnerHTML ? 'innerHTML' : 'outerHTML'});
} else if (res.cmdInnerHTML) {
target.innerHTML = respText;
} else {
target.outerHTML = respText;
}
await fetchActionReloadOutdatedElements();
}
async function fetchActionReloadOutdatedElements() {
const outdatedElems: HTMLElement[] = [];
for (const outdated of document.querySelectorAll<HTMLElement>('[data-fetch-trigger~="fetch-reload"]')) {
if (!outdated.id) throw new Error(`Elements with "fetch-reload" trigger must have an id to be reloaded after fetch sync: ${outdated.outerHTML.substring(0, 100)}`);
outdatedElems.push(outdated);
}
if (!outdatedElems.length) return;
const resp = await GET(window.location.href);
if (!resp.ok) {
showErrorToast(`Failed to reload page content after fetch action: ${resp.status} ${resp.statusText}`);
return;
}
const newPageHtml = await resp.text();
const newPageDom = parseDom(newPageHtml, 'text/html');
for (const oldEl of outdatedElems) {
// eslint-disable-next-line unicorn/prefer-query-selector
const newEl = newPageDom.getElementById(oldEl.id);
if (newEl) {
oldEl.replaceWith(newEl);
} else {
oldEl.remove();
}
}
}
function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) {
const interval = trigger.substring('every '.length);
const match = /^(\d+)(ms|s)$/.exec(interval);
if (!match) throw new Error(`Invalid interval format: ${interval}`);
const num = parseInt(match[1], 10), unit = match[2];
const intervalMs = unit === 's' ? num * 1000 : num;
const fn = async () => {
try {
await performFetchActionTrigger(el, 'every');
} finally {
// only continue if the element is still in the document
if (document.contains(el)) {
setTimeout(fn, intervalMs);
}
}
};
setTimeout(fn, intervalMs);
}
function initFetchActionTrigger(el: HTMLElement) {
const trigger = el.getAttribute('data-fetch-trigger');
// this trigger is managed internally, only triggered after fetch sync success, not triggered by event or timer
if (trigger === 'fetch-reload') return;
if (trigger === 'load') {
performFetchActionTrigger(el, trigger);
} else if (trigger === 'change') {
el.addEventListener('change', () => performFetchActionTrigger(el, trigger));
} else if (trigger?.startsWith('every ')) {
initFetchActionTriggerEvery(el, trigger);
} else if (!trigger || trigger === 'click') {
el.addEventListener('click', (e) => {
e.preventDefault();
performFetchActionTrigger(el, 'click');
});
} else {
throw new Error(`Unsupported fetch trigger: ${trigger}`);
}
}
export function initGlobalFetchAction() {
// The "fetch-action" framework is a general approach for elements to trigger fetch requests:
// show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success.
//
// If you need more fine-grained control more details, sometimes it's clearer to write the logic in JavaScript, instead of using this generic framework.
//
// Attributes:
//
// * data-fetch-method: the HTTP method to use
// * default to "GET" for "data-fetch-url" actions, "POST" for "link-action" elements
// * this attribute is ignored, the method will be determined by the form's "method" attribute, and default to "GET"
//
// * data-fetch-url: the URL for the request
//
// * data-fetch-trigger: the event to trigger the fetch action, can be:
// * "click", "change" (user-initiated events)
// * "load" (triggered on page load)
// * "every 5s" (also support "ms" unit)
// * "fetch-reload" (only triggered by fetch sync success to reload outdated content)
//
// * data-fetch-indicator: the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
//
// * data-fetch-sync: when the response is text (html), the pseudo selectors/commands defined in "data-fetch-sync"
// will be used to update the content in the current page. It only supports some simple syntaxes that we need.
// "$" prefix means it is our private command (for special logic), the selectors are run one by one from current element.
// * "$this": replace the current element with the response
// * "$innerHTML": replace innerHTML of the current element with the response, instead of replacing the whole element (outerHTML)
// * "$morph": use morph algorithm to update the target element
// * "$body #the-id .the-class": query the selector one by one from body
// * "$closest(tr) td": pseudo command can help to find the target element in a more flexible way
//
// * data-modal-confirm: a "confirm modal dialog" will be shown before taking action.
// * it can be a string for the content of the modal dialog
// * it has "-header" and "-content" variants to set the header and content of the "confirm modal"
// * it can refer an existing modal element by "#the-modal-id"
addDelegatedEventListener<HTMLFormElement, SubmitEvent>(document, 'submit', '.form-fetch-action', async (el, e) => {
// "fetch-action" will use the form's data to send the request
e.preventDefault();
await submitFormFetchAction(el, {formSubmitter: e.submitter});
});
addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => {
// `<a class="link-action" data-url="...">` is a shorthand for
// `<a data-fetch-trigger="click" data-fetch-method="post" data-fetch-url="..." data-fetch-indicator="$this">`
e.preventDefault();
await performLinkFetchAction(el);
});
registerGlobalSelectorFunc('[data-fetch-url]', initFetchActionTrigger);
}
+36
View File
@@ -0,0 +1,36 @@
import {applyAreYouSure, initAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.ts';
import {queryElems} from '../utils/dom.ts';
import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
export function initGlobalFormDirtyLeaveConfirm() {
initAreYouSure(window.jQuery);
// Warn users that try to leave a page after entering data into a form.
// Except on sign-in pages, and for forms marked as 'ignore-dirty'.
if (!document.querySelector('.page-content.user.signin')) {
applyAreYouSure('form:not(.ignore-dirty)');
}
}
export function initGlobalEnterQuickSubmit() {
document.addEventListener('keydown', (e) => {
if (e.isComposing) return;
if (e.key !== 'Enter') return;
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
if (hasCtrlOrMeta && (e.target as HTMLElement).matches('textarea')) {
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
e.preventDefault();
}
} else if ((e.target as HTMLElement).matches('input') && !(e.target as HTMLElement).closest('form')) {
// input in a normal form could handle Enter key by default, so we only handle the input outside a form
// eslint-disable-next-line unicorn/no-lonely-if
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
e.preventDefault();
}
}
});
}
export function initGlobalComboMarkdownEditor() {
queryElems<HTMLElement>(document, '.combo-markdown-editor:not(.custom-init)', (el) => initComboMarkdownEditor(el));
}
@@ -0,0 +1,16 @@
import {parseIssueListQuickGotoLink} from './common-issue-list.ts';
test('parseIssueListQuickGotoLink', () => {
expect(parseIssueListQuickGotoLink('/link', '')).toEqual('');
expect(parseIssueListQuickGotoLink('/link', 'abc')).toEqual('');
expect(parseIssueListQuickGotoLink('/link', '123')).toEqual('/link/issues/123');
expect(parseIssueListQuickGotoLink('/link', '#123')).toEqual('/link/issues/123');
expect(parseIssueListQuickGotoLink('/link', 'owner/repo#123')).toEqual('/owner/repo/issues/123');
expect(parseIssueListQuickGotoLink('', '')).toEqual('');
expect(parseIssueListQuickGotoLink('', 'abc')).toEqual('');
expect(parseIssueListQuickGotoLink('', '123')).toEqual('');
expect(parseIssueListQuickGotoLink('', '#123')).toEqual('');
expect(parseIssueListQuickGotoLink('', 'owner/repo#')).toEqual('');
expect(parseIssueListQuickGotoLink('', 'owner/repo#123')).toEqual('/owner/repo/issues/123');
});
+58
View File
@@ -0,0 +1,58 @@
import {onInputDebounce, toggleElem} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
const {appSubUrl} = window.config;
const reIssueIndex = /^(\d+)$/; // eg: "123"
const reIssueSharpIndex = /^#(\d+)$/; // eg: "#123"
const reIssueOwnerRepoIndex = /^([-.\w]+)\/([-.\w]+)#(\d+)$/; // eg: "{owner}/{repo}#{index}"
// if the searchText can be parsed to an "issue goto link", return the link, otherwise return empty string
export function parseIssueListQuickGotoLink(repoLink: string, searchText: string) {
searchText = searchText.trim();
let targetUrl = '';
if (repoLink) {
// try to parse it in current repo
if (reIssueIndex.test(searchText)) {
targetUrl = `${repoLink}/issues/${searchText}`;
} else if (reIssueSharpIndex.test(searchText)) {
targetUrl = `${repoLink}/issues/${searchText.substring(1)}`;
}
}
// try to parse it for a global search (eg: "owner/repo#123")
const [_, owner, repo, index] = reIssueOwnerRepoIndex.exec(searchText) || [];
if (owner) {
targetUrl = `${appSubUrl}/${owner}/${repo}/issues/${index}`;
}
return targetUrl;
}
export function initCommonIssueListQuickGoto() {
const elGotoButton = document.querySelector<HTMLElement>('#issue-list-quick-goto');
if (!elGotoButton) return;
const form = elGotoButton.closest('form')!;
const input = form.querySelector<HTMLInputElement>('input[name=q]')!;
const repoLink = elGotoButton.getAttribute('data-repo-link') || '';
elGotoButton.addEventListener('click', () => {
window.location.href = elGotoButton.getAttribute('data-issue-goto-link')!;
});
const onInput = async () => {
const searchText = input.value;
// try to check whether the parsed goto link is valid
let targetUrl = parseIssueListQuickGotoLink(repoLink, searchText);
if (targetUrl) {
const res = await GET(`${targetUrl}/info`); // backend: GetIssueInfo, it only checks whether the issue exists by status code
if (res.status !== 200) targetUrl = '';
}
// if the input value has changed, then ignore the result
if (input.value !== searchText) return;
toggleElem(elGotoButton, Boolean(targetUrl));
elGotoButton.setAttribute('data-issue-goto-link', targetUrl);
};
input.addEventListener('input', onInputDebounce(onInput));
onInput();
}
@@ -0,0 +1,16 @@
import {initCompLabelEdit} from './comp/LabelEdit.ts';
import {toggleElem} from '../utils/dom.ts';
export function initCommonOrganization() {
if (!document.querySelectorAll('.organization').length) {
return;
}
document.querySelector<HTMLInputElement>('.organization.settings.options #org_name')?.addEventListener('input', function () {
const nameChanged = this.value.toLowerCase() !== this.getAttribute('data-org-name')!.toLowerCase();
toggleElem('#org-name-change-prompt', nameChanged);
});
// Labels
initCompLabelEdit('.page-content.organization.settings.labels');
}
+170
View File
@@ -0,0 +1,170 @@
import {GET, POST} from '../modules/fetch.ts';
import {showGlobalErrorMessage} from '../modules/errors.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {initTabSwitcher} from '../modules/fomantic/tab.ts';
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
import {initCompSearchRepoBox} from './comp/SearchRepoBox.ts';
const {appUrl, appSubUrl} = window.config;
function initHeadNavbarContentToggle() {
const navbar = document.querySelector('#navbar');
const btn = document.querySelector('#navbar-expand-toggle');
if (!navbar || !btn) return;
btn.addEventListener('click', () => {
const isExpanded = btn.classList.contains('active');
navbar.classList.toggle('navbar-menu-open', !isExpanded);
btn.classList.toggle('active', !isExpanded);
});
}
function initFooterLanguageMenu() {
document.querySelector('.ui.dropdown .menu.language-menu')?.addEventListener('click', async (e) => {
const item = (e.target as HTMLElement).closest('.item');
if (!item) return;
e.preventDefault();
await GET(item.getAttribute('data-url')!);
window.location.reload();
});
}
function initFooterThemeSelector() {
const elDropdown = document.querySelector('#footer-theme-selector');
if (!elDropdown) return; // some pages don't have footer, for example: 500.tmpl
const $dropdown = fomanticQuery(elDropdown);
$dropdown.dropdown({
direction: 'upward',
apiSettings: {url: `${appSubUrl}/-/web-theme/list`, cache: false},
});
addDelegatedEventListener(elDropdown, 'click', '.menu > .item', async (el) => {
const themeName = el.getAttribute('data-value')!;
await POST(`${appSubUrl}/-/web-theme/apply?theme=${encodeURIComponent(themeName)}`);
window.location.reload();
});
}
export function initCommmPageComponents() {
initHeadNavbarContentToggle();
initFooterLanguageMenu();
initFooterThemeSelector();
}
export function initGlobalDropdown() {
// do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
registerGlobalSelectorFunc('.ui.dropdown:not(.custom)', (el) => {
const $dropdown = fomanticQuery(el);
if ($dropdown.data('module-dropdown')) return; // do not re-init if other code has already initialized it.
$dropdown.dropdown('setting', {hideDividers: 'empty'});
if (el.classList.contains('jump')) {
// The "jump" means this dropdown is mainly used for "menu" purpose,
// clicking an item will jump to somewhere else or trigger an action/function.
// When a dropdown is used for non-refresh actions with tippy,
// it must have this "jump" class to hide the tippy when dropdown is closed.
$dropdown.dropdown('setting', {
action: 'hide',
onShow() {
// hide associated tooltip while dropdown is open
this._tippy?.hide();
this._tippy?.disable();
},
onHide() {
this._tippy?.enable();
// eslint-disable-next-line unicorn/no-this-assignment
const elDropdown = this;
// hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu
setTimeout(() => {
const $dropdown = fomanticQuery(elDropdown);
if ($dropdown.dropdown('is hidden')) {
queryElems(elDropdown, '.menu > .item', (el) => el._tippy?.hide());
}
}, 2000);
},
});
}
// Special popup-directions, prevent Fomantic from guessing the popup direction.
// With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward,
// if the dropdown is at the beginning of the page, then the top part would be clipped by the window view.
// eg: Issue List "Sort" dropdown
// But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position,
// which would make some dropdown popups slightly shift out of the right viewport edge in some cases.
// eg: the "Create New Repo" menu on the navbar.
if (el.classList.contains('upward')) $dropdown.dropdown('setting', 'direction', 'upward');
if (el.classList.contains('downward')) $dropdown.dropdown('setting', 'direction', 'downward');
});
}
export function initGlobalComponent() {
registerGlobalInitFunc('initTabSwitcher', initTabSwitcher);
registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper);
registerGlobalInitFunc('initSearchRepoBox', initCompSearchRepoBox);
}
// for performance considerations, it only uses performant syntax
function attachInputDirAuto(el: Partial<HTMLInputElement | HTMLTextAreaElement>) {
if (el.type !== 'hidden' &&
el.type !== 'checkbox' &&
el.type !== 'radio' &&
el.type !== 'range' &&
el.type !== 'color') {
el.dir = 'auto';
}
}
function autoFocusEnd(el: HTMLInputElement | HTMLTextAreaElement) {
el.focus();
el.setSelectionRange(el.value.length, el.value.length);
}
export function applyAutoFocus(container: Element) {
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/autofocus
// "autofocus" behavior is defined by the standard: when a container (e.g.: dialog) becomes visible, focus the element with "autofocus" attribute
// Fomantic UI already supports it for its modal dialog, we need to cover more cases (e.g.: ".show-panel" button)
// Here is just a simple support, we don't expect more than one element that need "autofocus" appearing in the same container
container.querySelector<HTMLElement>('[autofocus]')?.focus();
// Also, apply our autoFocusEnd behavior
// TODO: GLOBAL-INIT-MULTIPLE-FUNCTIONS: use "~=" operator in case we would extend the "data-global-init" to support more functions in the future.
const el = container.querySelector<HTMLInputElement>('[data-global-init~="autoFocusEnd"]');
if (el) autoFocusEnd(el);
}
export function initGlobalInput() {
registerGlobalSelectorFunc('input, textarea', attachInputDirAuto);
// autoFocusEnd is used for autofocus an input/textarea and move the cursor to the end of the text.
// It is useful for "New Issue"/"New PR" pages when the title is pre-filled with prefix text (e.g.: from template or commit message)
// The native "autofocus" isn't used because there is a delay between "focused (DOM rendering)" and "move cursor to end (our JS)", it causes flickers.
registerGlobalInitFunc('autoFocusEnd', autoFocusEnd);
}
/**
* Too many users set their ROOT_URL to wrong value, and it causes a lot of problems:
* * Cross-origin API request without correct cookie
* * Incorrect href in <a>
* * ...
* So we check whether current URL starts with AppUrl(ROOT_URL).
* If they don't match, show a warning to users.
*/
export function checkAppUrl() {
const curUrl = window.location.href;
// some users visit "https://domain/gitea" while appUrl is "https://domain/gitea/", there should be no warning
if (curUrl.startsWith(appUrl) || `${curUrl}/` === appUrl) {
return;
}
showGlobalErrorMessage(`The detected web site URL is "${appUrl}", it's unlikely matching the site config.
Mismatched app.ini ROOT_URL or reverse proxy "Host/X-Forwarded-Proto" config might cause wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`, 'warning');
}
export function checkAppUrlScheme() {
const curUrl = window.location.href;
// some users visit "http://domain" while appUrl is "https://domain", COOKIE_SECURE makes it impossible to sign in
if (curUrl.startsWith('http:') && appUrl.startsWith('https:')) {
showGlobalErrorMessage(`This instance is configured to run under HTTPS (by ROOT_URL config), you are accessing by HTTP. Mismatched scheme might cause problems for sign-in/sign-up.`, 'warning');
}
}
@@ -0,0 +1,454 @@
import '@github/markdown-toolbar-element';
import '@github/text-expander-element';
import {attachTribute} from '../tribute.ts';
import {hideElem, showElem, autosize, isElemVisible, generateElemId} from '../../utils/dom.ts';
import {
EventUploadStateChanged,
initEasyMDEPaste,
initTextareaEvents,
triggerUploadStateChanged,
} from './EditorUpload.ts';
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.ts';
import {renderPreviewPanelContent} from '../repo-editor.ts';
import {toggleTasklistCheckbox} from '../../markup/tasklist.ts';
import {easyMDEToolbarActions} from './EasyMDEToolbarActions.ts';
import {initTextExpander} from './TextExpander.ts';
import {showErrorToast} from '../../modules/toast.ts';
import {POST} from '../../modules/fetch.ts';
import {
EventEditorContentChanged,
initTextareaMarkdown,
replaceTextareaSelection,
triggerEditorContentChanged,
} from './EditorMarkdown.ts';
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
import {createTippy} from '../../modules/tippy.ts';
import {initTabSwitcher} from '../../modules/fomantic/tab.ts';
import type EasyMDE from 'easymde';
import {localUserSettings} from '../../modules/user-settings.ts';
/**
* validate if the given textarea is non-empty.
* @param {HTMLTextAreaElement} textarea - The textarea element to be validated.
* @returns {boolean} returns true if validation succeeded.
*/
export function validateTextareaNonEmpty(textarea: HTMLTextAreaElement) {
// When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation.
// The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert.
if (!textarea.value) {
if (isElemVisible(textarea)) {
textarea.required = true;
const form = textarea.closest('form');
form?.reportValidity();
} else {
// The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places.
showErrorToast('Require non-empty content');
}
return false;
}
return true;
}
type Heights = {
minHeight?: string,
height?: string,
maxHeight?: string,
};
type ComboMarkdownEditorOptions = {
editorHeights?: Heights,
easyMDEOptions?: EasyMDE.Options,
};
type ComboMarkdownEditorTextarea = HTMLTextAreaElement & {_giteaComboMarkdownEditor: any};
type ComboMarkdownEditorContainer = HTMLElement & {_giteaComboMarkdownEditor?: any};
export class ComboMarkdownEditor {
static EventEditorContentChanged = EventEditorContentChanged;
static EventUploadStateChanged = EventUploadStateChanged;
public container: HTMLElement;
options: ComboMarkdownEditorOptions;
tabEditor?: HTMLElement;
tabPreviewer?: HTMLElement;
supportEasyMDE!: boolean;
easyMDE: any;
easyMDEToolbarActions: any;
easyMDEToolbarDefault: any;
textarea!: ComboMarkdownEditorTextarea;
textareaMarkdownToolbar!: HTMLElement;
textareaAutosize: any;
buttonMonospace!: HTMLButtonElement;
dropzone: HTMLElement | null = null;
attachedDropzoneInst: any;
previewMode!: string;
previewUrl!: string;
previewContext!: string;
constructor(container: ComboMarkdownEditorContainer, options:ComboMarkdownEditorOptions = {}) {
if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
container._giteaComboMarkdownEditor = this;
this.options = options;
this.container = container;
}
async init() {
this.prepareEasyMDEToolbarActions();
this.setupContainer();
this.setupTab();
await this.setupDropzone(); // textarea depends on dropzone
this.setupTextarea();
await this.switchToUserPreference();
}
applyEditorHeights(el: HTMLElement, heights: Heights | undefined) {
if (!heights) return;
if (heights.minHeight) el.style.minHeight = heights.minHeight;
if (heights.height) el.style.height = heights.height;
if (heights.maxHeight) el.style.maxHeight = heights.maxHeight;
}
setupContainer() {
this.supportEasyMDE = this.container.getAttribute('data-support-easy-mde') === 'true';
this.previewMode = this.container.getAttribute('data-content-mode')!;
this.previewUrl = this.container.getAttribute('data-preview-url')!;
this.previewContext = this.container.getAttribute('data-preview-context')!;
initTextExpander(this.container.querySelector('text-expander')!);
}
setupTextarea() {
this.textarea = this.container.querySelector('.markdown-text-editor')!;
this.textarea._giteaComboMarkdownEditor = this;
this.textarea.id = generateElemId(`_combo_markdown_editor_`);
this.textarea.addEventListener('input', () => triggerEditorContentChanged(this.container));
this.applyEditorHeights(this.textarea, this.options.editorHeights);
if (this.textarea.getAttribute('data-disable-autosize') !== 'true') {
this.textareaAutosize = autosize(this.textarea, {viewportMarginBottom: 130});
}
this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar')!;
this.textareaMarkdownToolbar.setAttribute('for', this.textarea.id);
for (const el of this.textareaMarkdownToolbar.querySelectorAll('.markdown-toolbar-button')) {
// upstream bug: The role code is never executed in base MarkdownButtonElement https://github.com/github/markdown-toolbar-element/issues/70
el.setAttribute('role', 'button');
// the editor usually is in a form, so the buttons should have "type=button", avoiding conflicting with the form's submit.
if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
}
this.buttonMonospace = this.container.querySelector('.markdown-switch-monospace')!;
this.applyMonospace();
this.buttonMonospace.addEventListener('click', (e) => {
e.preventDefault();
const enabled = !localUserSettings.getBoolean('markdown-editor-monospace');
localUserSettings.setBoolean('markdown-editor-monospace', enabled);
applyMonospaceToAllEditors();
});
if (this.supportEasyMDE) {
const easymdeButton = this.container.querySelector('.markdown-switch-easymde')!;
easymdeButton.addEventListener('click', async (e) => {
e.preventDefault();
this.userPreferredEditor = 'easymde';
await this.switchToEasyMDE();
});
}
this.initMarkdownButtonTableAdd();
initTextareaMarkdown(this.textarea);
initTextareaEvents(this.textarea, this.dropzone);
}
async setupDropzone() {
const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container');
if (!dropzoneParentContainer) return;
this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container')!)?.querySelector('.dropzone') ?? null;
if (!this.dropzone) return;
this.attachedDropzoneInst = await initDropzone(this.dropzone);
// dropzone events
// * "processing" means a file is being uploaded
// * "queuecomplete" means all files have been uploaded
this.attachedDropzoneInst.on('processing', () => triggerUploadStateChanged(this.container));
this.attachedDropzoneInst.on('queuecomplete', () => triggerUploadStateChanged(this.container));
}
dropzoneGetFiles() {
if (!this.dropzone) return null;
return Array.from(this.dropzone.querySelectorAll<HTMLInputElement>('.files [name=files]'), (el) => el.value);
}
dropzoneReloadFiles() {
if (!this.dropzone) return;
this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
}
dropzoneSubmitReload() {
if (!this.dropzone) return;
this.attachedDropzoneInst.emit('submit');
this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
}
isUploading() {
if (!this.dropzone) return false;
return this.attachedDropzoneInst.getQueuedFiles().length || this.attachedDropzoneInst.getUploadingFiles().length;
}
setupTab() {
const elTabular = this.container.querySelector('.ui.tabular');
if (!elTabular) return;
this.tabEditor = this.container.querySelector('[data-tab-for="markdown-writer"]')!;
this.tabPreviewer = this.container.querySelector('[data-tab-for="markdown-previewer"]')!;
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
// Fomantic Tab requires the "data-tab" to be globally unique.
// So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
const tabIdSuffix = generateElemId();
this.tabEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
this.tabPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
panelEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
panelPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
initTabSwitcher(elTabular);
this.tabEditor.addEventListener('click', () => {
requestAnimationFrame(() => {
this.focus();
});
});
this.tabPreviewer.addEventListener('click', async () => {
const formData = new FormData();
formData.append('mode', this.previewMode);
formData.append('context', this.previewContext);
formData.append('text', this.value());
const response = await POST(this.previewUrl, {data: formData});
const data = await response.text();
renderPreviewPanelContent(panelPreviewer, data);
// enable task list checkboxes in preview and sync state back to the editor
for (const checkbox of panelPreviewer.querySelectorAll<HTMLInputElement>('.task-list-item input[type=checkbox]')) {
checkbox.disabled = false;
checkbox.addEventListener('input', () => {
const position = parseInt(checkbox.getAttribute('data-source-position')!) + 1;
const newContent = toggleTasklistCheckbox(this.value(), position, checkbox.checked);
if (newContent === null) {
checkbox.checked = !checkbox.checked;
return;
}
this.value(newContent);
triggerEditorContentChanged(this.container);
});
}
});
}
generateMarkdownTable(rows: number, cols: number): string {
const tableLines = [];
tableLines.push(
`| ${'Header '.repeat(cols).trim().split(' ').join(' | ')} |`,
`| ${'--- '.repeat(cols).trim().split(' ').join(' | ')} |`,
);
for (let i = 0; i < rows; i++) {
tableLines.push(`| ${'Cell '.repeat(cols).trim().split(' ').join(' | ')} |`);
}
return tableLines.join('\n');
}
initMarkdownButtonTableAdd() {
const addTableButton = this.container.querySelector('.markdown-button-table-add')!;
const addTablePanel = this.container.querySelector('.markdown-add-table-panel')!;
// here the tippy can't attach to the button because the button already owns a tippy for tooltip
const addTablePanelTippy = createTippy(addTablePanel, {
content: addTablePanel,
trigger: 'manual',
placement: 'bottom',
hideOnClick: true,
interactive: true,
getReferenceClientRect: () => addTableButton.getBoundingClientRect(),
});
addTableButton.addEventListener('click', () => addTablePanelTippy.show());
addTablePanel.querySelector('.ui.button.primary')!.addEventListener('click', () => {
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('.add-table-rows')!.value);
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('.add-table-cols')!.value);
rows = Math.max(1, Math.min(100, rows));
cols = Math.max(1, Math.min(100, cols));
replaceTextareaSelection(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
addTablePanelTippy.hide();
});
}
switchTabToEditor() {
this.tabEditor!.click(); // when this function is called, the tab must exist
}
prepareEasyMDEToolbarActions() {
this.easyMDEToolbarDefault = [
'bold', 'italic', 'strikethrough', '|', 'heading-1', 'heading-2', 'heading-3',
'heading-bigger', 'heading-smaller', '|', 'code', 'quote', '|', 'gitea-checkbox-empty',
'gitea-checkbox-checked', '|', 'unordered-list', 'ordered-list', '|', 'link', 'image',
'table', 'horizontal-rule', '|', 'gitea-switch-to-textarea',
];
}
parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions: any) {
this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(easyMde, this);
const processed = [];
for (const action of actions) {
const actionButton = this.easyMDEToolbarActions[action];
if (!actionButton) throw new Error(`Unknown EasyMDE toolbar action ${action}`);
processed.push(actionButton);
}
return processed;
}
async switchToUserPreference() {
if (this.userPreferredEditor === 'easymde' && this.supportEasyMDE) {
await this.switchToEasyMDE();
} else {
this.switchToTextarea();
}
}
switchToTextarea() {
if (!this.easyMDE) return;
showElem(this.textareaMarkdownToolbar);
if (this.easyMDE) {
this.easyMDE.toTextArea();
this.easyMDE = null;
}
}
async switchToEasyMDE() {
if (this.easyMDE) return;
const [{default: EasyMDE}] = await Promise.all([
import('easymde'),
import('../../../css/easymde.css'),
]);
const easyMDEOpt: EasyMDE.Options = {
autoDownloadFontAwesome: false,
element: this.textarea,
forceSync: true,
renderingConfig: {singleLineBreaks: false},
indentWithTabs: false,
tabSize: 4,
spellChecker: false,
inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
nativeSpellcheck: true,
...this.options.easyMDEOptions,
};
easyMDEOpt.toolbar = this.parseEasyMDEToolbar(EasyMDE, easyMDEOpt.toolbar ?? this.easyMDEToolbarDefault);
this.easyMDE = new EasyMDE(easyMDEOpt);
this.easyMDE.codemirror.on('change', () => triggerEditorContentChanged(this.container));
this.easyMDE.codemirror.setOption('extraKeys', {
'Cmd-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
'Ctrl-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
Enter: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
cm.execCommand('newlineAndIndent');
}
},
Up: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
return cm.execCommand('goLineUp');
}
},
Down: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
return cm.execCommand('goLineDown');
}
},
});
this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll')!, this.options.editorHeights);
await attachTribute(this.easyMDE.codemirror.getInputField());
if (this.dropzone) {
initEasyMDEPaste(this.easyMDE, this.dropzone);
}
hideElem(this.textareaMarkdownToolbar);
}
value(v?: any) {
if (v === undefined) {
if (this.easyMDE) {
return this.easyMDE.value();
}
return this.textarea.value;
}
if (this.easyMDE) {
this.easyMDE.value(v);
} else {
this.textarea.value = v;
}
this.textareaAutosize?.resizeToFit();
}
focus() {
if (this.easyMDE) {
this.easyMDE.codemirror.focus();
} else {
this.textarea.focus();
}
}
moveCursorToEnd() {
this.textarea.focus();
this.textarea.setSelectionRange(this.textarea.value.length, this.textarea.value.length);
if (this.easyMDE) {
this.easyMDE.codemirror.focus();
this.easyMDE.codemirror.setCursor(this.easyMDE.codemirror.lineCount(), 0);
}
}
get userPreferredEditor(): string {
return localUserSettings.getString(`markdown-editor-${this.previewMode ?? 'default'}`);
}
set userPreferredEditor(s: string) {
localUserSettings.setString(`markdown-editor-${this.previewMode ?? 'default'}`, s);
}
applyMonospace() {
const enabled = localUserSettings.getBoolean('markdown-editor-monospace');
const text = this.buttonMonospace.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text')!;
this.textarea.classList.toggle('tw-font-mono', enabled);
this.buttonMonospace.setAttribute('data-tooltip-content', text);
this.buttonMonospace.setAttribute('aria-checked', String(enabled));
}
}
function applyMonospaceToAllEditors() {
const editors = document.querySelectorAll<ComboMarkdownEditorContainer>('.combo-markdown-editor');
for (const editorContainer of editors) {
const editor = getComboMarkdownEditor(editorContainer);
if (editor) editor.applyMonospace();
}
}
export function getComboMarkdownEditor(el: any): ComboMarkdownEditor | null {
if (!el) return null;
if (el.length) el = el[0];
return el._giteaComboMarkdownEditor;
}
export async function initComboMarkdownEditor(container: HTMLElement, options:ComboMarkdownEditorOptions = {}) {
if (!container) {
throw new Error('initComboMarkdownEditor: container is null');
}
const editor = new ComboMarkdownEditor(container, options);
await editor.init();
return editor;
}
+45
View File
@@ -0,0 +1,45 @@
import {svg} from '../../svg.ts';
import {html, htmlRaw} from '../../utils/html.ts';
import {createElementFromHTML} from '../../utils/dom.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {hideToastsAll} from '../../modules/toast.ts';
const {i18n} = window.config;
type ConfirmModalOptions = {
header?: string;
content?: string;
confirmButtonColor?: 'primary' | 'red' | 'green' | 'blue';
};
export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement {
const headerHtml = header ? html`<div class="header">${header}</div>` : '';
return createElementFromHTML(html`
<div class="ui g-modal-confirm modal">
${htmlRaw(headerHtml)}
<div class="content">${content}</div>
<div class="actions">
<button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button>
<button class="ui ${confirmButtonColor} ok button">${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button>
</div>
</div>
`.trim());
}
export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> {
if (!(modal instanceof HTMLElement)) modal = createConfirmModal(modal);
// hide existing toasts when we need to show a new modal, otherwise the toasts only interfere the UI
// it's fine to do so because the modal is triggered by user's explicit action, so the user should already have read the toast messages
hideToastsAll();
return new Promise((resolve) => {
showFomanticModal(modal, {
onApprove() {
resolve(true);
},
onHidden() {
modal.remove();
resolve(false);
},
});
});
}
+48
View File
@@ -0,0 +1,48 @@
import {showElem} from '../../utils/dom.ts';
type CropperOpts = {
container: HTMLElement,
imageSource: HTMLImageElement,
fileInput: HTMLInputElement,
};
async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
const {default: Cropper} = await import('cropperjs');
let currentFileName = '';
let currentFileLastModified = 0;
const cropper = new Cropper(imageSource, {
aspectRatio: 1,
viewMode: 2,
autoCrop: false,
crop() {
const canvas = cropper.getCroppedCanvas();
canvas.toBlob((blob) => {
if (!blob) return;
const croppedFileName = currentFileName.replace(/\.[^.]{3,4}$/, '.png');
const croppedFile = new File([blob], croppedFileName, {type: 'image/png', lastModified: currentFileLastModified});
const dataTransfer = new DataTransfer();
dataTransfer.items.add(croppedFile);
fileInput.files = dataTransfer.files;
});
},
});
fileInput.addEventListener('input', (e) => {
const files = (e.target as HTMLInputElement).files;
if (files?.length) {
currentFileName = files[0].name;
currentFileLastModified = files[0].lastModified;
const fileURL = URL.createObjectURL(files[0]);
imageSource.src = fileURL;
cropper.replace(fileURL);
showElem(container);
}
});
}
export async function initAvatarUploaderWithCropper(fileInput: HTMLInputElement) {
const panel = fileInput.nextElementSibling as HTMLElement;
if (!panel?.matches('.cropper-panel')) throw new Error('Missing cropper panel for avatar uploader');
const imageSource = panel.querySelector<HTMLImageElement>('.cropper-source')!;
await initCompCropper({container: panel, fileInput, imageSource});
}
@@ -0,0 +1,154 @@
import {svg} from '../../svg.ts';
import type EasyMDE from 'easymde';
import type {ComboMarkdownEditor} from './ComboMarkdownEditor.ts';
export function easyMDEToolbarActions(easyMde: typeof EasyMDE, editor: ComboMarkdownEditor): Record<string, Partial<EasyMDE.ToolbarIcon | string>> {
const actions: Record<string, Partial<EasyMDE.ToolbarIcon> | string> = {
'|': '|',
'heading-1': {
action: easyMde.toggleHeading1,
icon: svg('octicon-heading'),
title: 'Heading 1',
},
'heading-2': {
action: easyMde.toggleHeading2,
icon: svg('octicon-heading'),
title: 'Heading 2',
},
'heading-3': {
action: easyMde.toggleHeading3,
icon: svg('octicon-heading'),
title: 'Heading 3',
},
'heading-smaller': {
action: easyMde.toggleHeadingSmaller,
icon: svg('octicon-heading'),
title: 'Decrease Heading',
},
'heading-bigger': {
action: easyMde.toggleHeadingBigger,
icon: svg('octicon-heading'),
title: 'Increase Heading',
},
'bold': {
action: easyMde.toggleBold,
icon: svg('octicon-bold'),
title: 'Bold',
},
'italic': {
action: easyMde.toggleItalic,
icon: svg('octicon-italic'),
title: 'Italic',
},
'strikethrough': {
action: easyMde.toggleStrikethrough,
icon: svg('octicon-strikethrough'),
title: 'Strikethrough',
},
'quote': {
action: easyMde.toggleBlockquote,
icon: svg('octicon-quote'),
title: 'Quote',
},
'code': {
action: easyMde.toggleCodeBlock,
icon: svg('octicon-code'),
title: 'Code',
},
'link': {
action: easyMde.drawLink,
icon: svg('octicon-link'),
title: 'Link',
},
'unordered-list': {
action: easyMde.toggleUnorderedList,
icon: svg('octicon-list-unordered'),
title: 'Unordered List',
},
'ordered-list': {
action: easyMde.toggleOrderedList,
icon: svg('octicon-list-ordered'),
title: 'Ordered List',
},
'image': {
action: easyMde.drawImage,
icon: svg('octicon-image'),
title: 'Image',
},
'table': {
action: easyMde.drawTable,
icon: svg('octicon-table'),
title: 'Table',
},
'horizontal-rule': {
action: easyMde.drawHorizontalRule,
icon: svg('octicon-horizontal-rule'),
title: 'Horizontal Rule',
},
'preview': {
action: easyMde.togglePreview,
icon: svg('octicon-eye'),
title: 'Preview',
},
'fullscreen': {
action: easyMde.toggleFullScreen,
icon: svg('octicon-screen-full'),
title: 'Fullscreen',
},
'side-by-side': {
action: easyMde.toggleSideBySide,
icon: svg('octicon-columns'),
title: 'Side by Side',
},
// gitea's custom actions
'gitea-checkbox-empty': {
action(e) {
const cm = e.codemirror;
cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`);
cm.focus();
},
icon: svg('gitea-empty-checkbox'),
title: 'Add Checkbox (empty)',
},
'gitea-checkbox-checked': {
action(e) {
const cm = e.codemirror;
cm.replaceSelection(`\n- [x] ${cm.getSelection()}`);
cm.focus();
},
icon: svg('octicon-checkbox'),
title: 'Add Checkbox (checked)',
},
'gitea-switch-to-textarea': {
action: () => {
editor.userPreferredEditor = 'textarea';
editor.switchToTextarea();
},
icon: svg('octicon-arrow-switch'),
title: 'Revert to simple textarea',
},
'gitea-code-inline': {
action(e) {
const cm = e.codemirror;
const selection = cm.getSelection();
cm.replaceSelection(`\`${selection}\``);
if (!selection) {
const cursorPos = cm.getCursor();
cm.setCursor(cursorPos.line, cursorPos.ch - 1);
}
cm.focus();
},
icon: svg('octicon-chevron-right'),
title: 'Add Inline Code',
},
};
for (const [key, value] of Object.entries(actions)) {
if (typeof value !== 'string') {
value.name = key;
}
}
return actions;
}
@@ -0,0 +1,203 @@
import {initTextareaMarkdown, markdownHandleIndention, textareaSplitLines} from './EditorMarkdown.ts';
test('textareaSplitLines', () => {
let ret = textareaSplitLines('a\nbc\nd', 0);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 0});
ret = textareaSplitLines('a\nbc\nd', 1);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 0, posLineIndex: 0, inlinePos: 1});
ret = textareaSplitLines('a\nbc\nd', 2);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 0});
ret = textareaSplitLines('a\nbc\nd', 3);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 1});
ret = textareaSplitLines('a\nbc\nd', 4);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 2, posLineIndex: 1, inlinePos: 2});
ret = textareaSplitLines('a\nbc\nd', 5);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 0});
ret = textareaSplitLines('a\nbc\nd', 6);
expect(ret).toEqual({lines: ['a', 'bc', 'd'], lengthBeforePosLine: 5, posLineIndex: 2, inlinePos: 1});
});
test('markdownHandleIndention', () => {
const testInput = (input: string, expected: string | null) => {
const inputPos = input.indexOf('|');
input = input.replaceAll('|', '');
const ret = markdownHandleIndention({value: input, selStart: inputPos, selEnd: inputPos});
if (expected === null) {
expect(ret).toEqual({handled: false});
} else {
const expectedPos = expected.indexOf('|');
expected = expected.replaceAll('|', '');
expect(ret).toEqual({
handled: true,
valueSelection: {value: expected, selStart: expectedPos, selEnd: expectedPos},
});
}
};
testInput(`
a|b
`, `
a
|b
`);
testInput(`
1. a
2. |
`, `
1. a
|
`);
testInput(`
|1. a
`, null); // let browser handle it
testInput(`
1. a
1. b|c
`, `
1. a
2. b
3. |c
`);
testInput(`
2. a
2. b|
1. x
1. y
`, `
1. a
2. b
3. |
1. x
1. y
`);
testInput(`
2. a
2. b
1. x|
1. y
`, `
2. a
2. b
1. x
2. |
3. y
`);
testInput(`
1. a
2. b|
3. c
`, `
1. a
2. b
3. |
4. c
`);
testInput(`
1. a
1. b
2. b
3. b
4. b
1. c|
`, `
1. a
1. b
2. b
3. b
4. b
2. c
3. |
`);
testInput(`
1. a
2. a
3. a
4. a
5. a
6. a
7. a
8. a
9. b|c
`, `
1. a
2. a
3. a
4. a
5. a
6. a
7. a
8. a
9. b
10. |c
`);
// this is a special case, it's difficult to re-format the parent level at the moment, so leave it to the future
testInput(`
1. a
2. b|
3. c
`, `
1. a
1. b
2. |
3. c
`);
});
test('EditorMarkdown', () => {
const textarea = document.createElement('textarea');
initTextareaMarkdown(textarea);
type ValueWithCursor = string | {
value: string;
pos: number;
};
const testInput = (input: ValueWithCursor, result: ValueWithCursor) => {
const inputValue = typeof input === 'string' ? input : input.value;
const inputPos = typeof input === 'string' ? inputValue.length : input.pos;
textarea.value = inputValue;
textarea.setSelectionRange(inputPos, inputPos);
const e = new KeyboardEvent('keydown', {key: 'Enter', cancelable: true});
textarea.dispatchEvent(e);
if (!e.defaultPrevented) textarea.value += '\n'; // simulate default behavior
const expectedValue = typeof result === 'string' ? result : result.value;
const expectedPos = typeof result === 'string' ? expectedValue.length : result.pos;
expect(textarea.value).toEqual(expectedValue);
expect(textarea.selectionStart).toEqual(expectedPos);
};
testInput('-', '-\n');
testInput('1.', '1.\n');
testInput('- ', '');
testInput('1. ', '');
testInput({value: '1. \n2. ', pos: 3}, {value: '\n2. ', pos: 0});
testInput('- x', '- x\n- ');
testInput('1. foo', '1. foo\n2. ');
testInput({value: '1. a\n2. b\n3. c', pos: 4}, {value: '1. a\n2. \n3. b\n4. c', pos: 8});
testInput('- [ ]', '- [ ]\n- ');
testInput('- [ ] foo', '- [ ] foo\n- [ ] ');
testInput('* [x] foo', '* [x] foo\n* [ ] ');
testInput('1. [x] foo', '1. [x] foo\n2. [ ] ');
});
+238
View File
@@ -0,0 +1,238 @@
export const EventEditorContentChanged = 'ce-editor-content-changed';
export function triggerEditorContentChanged(target: HTMLElement) {
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
}
/** replace selected text or insert text by creating a new edit history entry,
* e.g. CTRL-Z works after this */
export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: string) {
const before = textarea.value.slice(0, textarea.selectionStart);
const after = textarea.value.slice(textarea.selectionEnd);
textarea.focus();
let success = false;
try {
success = document.execCommand('insertText', false, text); // eslint-disable-line @typescript-eslint/no-deprecated
} catch {}
// fall back to regular replacement
if (!success) {
textarea.value = `${before}${text}${after}`;
triggerEditorContentChanged(textarea);
}
}
type TextareaValueSelection = {
value: string;
selStart: number;
selEnd: number;
};
function handleIndentSelection(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
if (selEnd === selStart) return; // do not process when no selection
e.preventDefault();
const lines = textarea.value.split('\n');
const selectedLines = [];
let pos = 0;
for (let i = 0; i < lines.length; i++) {
if (pos > selEnd) break;
if (pos >= selStart) selectedLines.push(i);
pos += lines[i].length + 1;
}
for (const i of selectedLines) {
if (e.shiftKey) {
lines[i] = lines[i].replace(/^(\t| {1,2})/, '');
} else {
lines[i] = ` ${lines[i]}`;
}
}
// re-calculating the selection range
let newSelStart: number | null = null;
let newSelEnd: number | null = null;
pos = 0;
for (let i = 0; i < lines.length; i++) {
if (i === selectedLines[0]) {
newSelStart = pos;
}
if (i === selectedLines[selectedLines.length - 1]) {
newSelEnd = pos + lines[i].length;
break;
}
pos += lines[i].length + 1;
}
textarea.value = lines.join('\n');
textarea.setSelectionRange(newSelStart, newSelEnd);
triggerEditorContentChanged(textarea);
}
type MarkdownHandleIndentionResult = {
handled: boolean;
valueSelection?: TextareaValueSelection;
};
type TextLinesBuffer = {
lines: string[];
lengthBeforePosLine: number;
posLineIndex: number;
inlinePos: number
};
export function textareaSplitLines(value: string, pos: number): TextLinesBuffer {
const lines = value.split('\n');
let lengthBeforePosLine = 0, inlinePos = 0, posLineIndex = 0;
for (; posLineIndex < lines.length; posLineIndex++) {
const lineLength = lines[posLineIndex].length + 1;
if (lengthBeforePosLine + lineLength > pos) {
inlinePos = pos - lengthBeforePosLine;
break;
}
lengthBeforePosLine += lineLength;
}
return {lines, lengthBeforePosLine, posLineIndex, inlinePos};
}
function markdownReformatListNumbers(linesBuf: TextLinesBuffer, indention: string) {
const reDeeperIndention = new RegExp(`^${indention}\\s+`);
const reSameLevel = new RegExp(`^${indention}([0-9]+)\\.`);
let firstLineIdx: number;
for (firstLineIdx = linesBuf.posLineIndex - 1; firstLineIdx >= 0; firstLineIdx--) {
const line = linesBuf.lines[firstLineIdx];
if (!reDeeperIndention.test(line) && !reSameLevel.test(line)) break;
}
firstLineIdx++;
let num = 1;
for (let i = firstLineIdx; i < linesBuf.lines.length; i++) {
const oldLine = linesBuf.lines[i];
const sameLevel = reSameLevel.test(oldLine);
if (!sameLevel && !reDeeperIndention.test(oldLine)) break;
if (sameLevel) {
const newLine = `${indention}${num}.${oldLine.replace(reSameLevel, '')}`;
linesBuf.lines[i] = newLine;
num++;
if (linesBuf.posLineIndex === i) {
// need to correct the cursor inline position if the line length changes
linesBuf.inlinePos += newLine.length - oldLine.length;
linesBuf.inlinePos = Math.max(0, linesBuf.inlinePos);
linesBuf.inlinePos = Math.min(newLine.length, linesBuf.inlinePos);
}
}
}
recalculateLengthBeforeLine(linesBuf);
}
function recalculateLengthBeforeLine(linesBuf: TextLinesBuffer) {
linesBuf.lengthBeforePosLine = 0;
for (let i = 0; i < linesBuf.posLineIndex; i++) {
linesBuf.lengthBeforePosLine += linesBuf.lines[i].length + 1;
}
}
export function markdownHandleIndention(tvs: TextareaValueSelection): MarkdownHandleIndentionResult {
const unhandled: MarkdownHandleIndentionResult = {handled: false};
if (tvs.selEnd !== tvs.selStart) return unhandled; // do not process when there is a selection
const linesBuf = textareaSplitLines(tvs.value, tvs.selStart);
const line = linesBuf.lines[linesBuf.posLineIndex] ?? '';
if (!line) return unhandled; // if the line is empty, do nothing, let the browser handle it
// parse the indention
let lineContent = line;
const indention = (/^\s*/.exec(lineContent) || [''])[0];
lineContent = lineContent.slice(indention.length);
if (linesBuf.inlinePos <= indention.length) return unhandled; // if cursor is at the indention, do nothing, let the browser handle it
// parse the prefixes: "1. ", "- ", "* ", there could also be " [ ] " or " [x] " for task lists
// there must be a space after the prefix because none of "1.foo" / "-foo" is a list item
const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(lineContent);
let prefix = '';
if (prefixMatch) {
prefix = prefixMatch[0];
if (prefix.length > linesBuf.inlinePos) prefix = ''; // do not add new line if cursor is at prefix
}
lineContent = lineContent.slice(prefix.length);
if (!indention && !prefix) return unhandled; // if no indention and no prefix, do nothing, let the browser handle it
if (!lineContent) {
// clear current line if we only have i.e. '1. ' and the user presses enter again to finish creating a list
linesBuf.lines[linesBuf.posLineIndex] = '';
linesBuf.inlinePos = 0;
} else {
// start a new line with the same indention
let newPrefix = prefix;
if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`;
newPrefix = newPrefix.replace('[x]', '[ ]');
const inlinePos = linesBuf.inlinePos;
linesBuf.lines[linesBuf.posLineIndex] = line.substring(0, inlinePos);
const newLineLeft = `${indention}${newPrefix}`;
const newLine = `${newLineLeft}${line.substring(inlinePos)}`;
linesBuf.lines.splice(linesBuf.posLineIndex + 1, 0, newLine);
linesBuf.posLineIndex++;
linesBuf.inlinePos = newLineLeft.length;
recalculateLengthBeforeLine(linesBuf);
}
markdownReformatListNumbers(linesBuf, indention);
const newPos = linesBuf.lengthBeforePosLine + linesBuf.inlinePos;
return {handled: true, valueSelection: {value: linesBuf.lines.join('\n'), selStart: newPos, selEnd: newPos}};
}
function handleNewline(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
if (e.isComposing) return;
const ret = markdownHandleIndention({value: textarea.value, selStart: textarea.selectionStart, selEnd: textarea.selectionEnd});
if (!ret.handled || !ret.valueSelection) return; // FIXME: the "handled" seems redundant, only valueSelection is enough (null for unhandled)
e.preventDefault();
textarea.value = ret.valueSelection.value;
textarea.setSelectionRange(ret.valueSelection.selStart, ret.valueSelection.selEnd);
triggerEditorContentChanged(textarea);
}
// Keys that act as dead keys will not work because the spec dictates that such keys are
// emitted as `Dead` in e.key instead of the actual key.
const pairs = new Map<string, string>([
["'", "'"],
['"', '"'],
['`', '`'],
['(', ')'],
['[', ']'],
['{', '}'],
['<', '>'],
]);
function handlePairCharacter(textarea: HTMLTextAreaElement, e: KeyboardEvent): void {
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
if (selEnd === selStart) return; // do not process when no selection
e.preventDefault();
const inner = textarea.value.substring(selStart, selEnd);
replaceTextareaSelection(textarea, `${e.key}${inner}${pairs.get(e.key)}`);
textarea.setSelectionRange(selStart + 1, selEnd + 1);
}
function isTextExpanderShown(textarea: HTMLElement): boolean {
return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions'));
}
export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
textarea.addEventListener('keydown', (e) => {
if (e.isComposing) return;
if (isTextExpanderShown(textarea)) return;
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
// use Tab/Shift-Tab to indent/unindent the selected lines
handleIndentSelection(textarea, e);
} else if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
// use Enter to insert a new line with the same indention and prefix
handleNewline(textarea, e);
} else if (pairs.has(e.key)) {
handlePairCharacter(textarea, e);
}
});
}
@@ -0,0 +1,14 @@
import {removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
test('removeAttachmentLinksFromMarkdown', () => {
expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b');
expect(removeAttachmentLinksFromMarkdown('a [x](attachments/foo) b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a ![x](attachments/foo) b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a [x](/attachments/foo) b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a ![x](/attachments/foo) b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a <img src="attachments/foo"> b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a <img width="100" src="attachments/foo"> b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo"> b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo" width="100"/> b', 'foo')).toBe('a b');
});
+168
View File
@@ -0,0 +1,168 @@
import {imageInfo} from '../../utils/image.ts';
import {replaceTextareaSelection, triggerEditorContentChanged} from './EditorMarkdown.ts';
import {
DropzoneCustomEventRemovedFile,
DropzoneCustomEventUploadDone,
generateMarkdownLinkForAttachment,
} from '../dropzone.ts';
import {subscribe} from '@github/paste-markdown';
import type CodeMirror from 'codemirror';
import type EasyMDE from 'easymde';
import type Dropzone from '@deltablot/dropzone';
let uploadIdCounter = 0;
export const EventUploadStateChanged = 'ce-upload-state-changed';
export function triggerUploadStateChanged(target: HTMLElement) {
target.dispatchEvent(new CustomEvent(EventUploadStateChanged, {bubbles: true}));
}
function uploadFile(dropzoneEl: HTMLElement, file: File) {
return new Promise((resolve) => {
const curUploadId = uploadIdCounter++;
(file as any)._giteaUploadId = curUploadId;
const dropzoneInst = dropzoneEl.dropzone;
const onUploadDone = ({file}: {file: any}) => {
if (file._giteaUploadId === curUploadId) {
dropzoneInst.off(DropzoneCustomEventUploadDone, onUploadDone);
resolve(file);
}
};
dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone);
// FIXME: this is not entirely correct because `file` does not satisfy DropzoneFile (we have abused the Dropzone for long time)
dropzoneInst.addFile(file as Dropzone.DropzoneFile);
});
}
class TextareaEditor {
editor: HTMLTextAreaElement;
constructor(editor: HTMLTextAreaElement) {
this.editor = editor;
}
insertPlaceholder(value: string) {
replaceTextareaSelection(this.editor, value);
}
replacePlaceholder(oldVal: string, newVal: string) {
const editor = this.editor;
const startPos = editor.selectionStart;
const endPos = editor.selectionEnd;
if (editor.value.substring(startPos, endPos) === oldVal) {
editor.value = editor.value.substring(0, startPos) + newVal + editor.value.substring(endPos);
editor.selectionEnd = startPos + newVal.length;
} else {
editor.value = editor.value.replace(oldVal, newVal);
editor.selectionEnd -= oldVal.length;
editor.selectionEnd += newVal.length;
}
editor.selectionStart = editor.selectionEnd;
editor.focus();
triggerEditorContentChanged(editor);
}
}
class CodeMirrorEditor {
editor: CodeMirror.EditorFromTextArea;
constructor(editor: CodeMirror.EditorFromTextArea) {
this.editor = editor;
}
insertPlaceholder(value: string) {
const editor = this.editor;
const startPoint = editor.getCursor('start');
const endPoint = editor.getCursor('end');
editor.replaceSelection(value);
endPoint.ch = startPoint.ch + value.length;
editor.setSelection(startPoint, endPoint);
editor.focus();
triggerEditorContentChanged(editor.getTextArea());
}
replacePlaceholder(oldVal: string, newVal: string) {
const editor = this.editor;
const endPoint = editor.getCursor('end');
if (editor.getSelection() === oldVal) {
editor.replaceSelection(newVal);
} else {
editor.setValue(editor.getValue().replace(oldVal, newVal));
}
endPoint.ch -= oldVal.length;
endPoint.ch += newVal.length;
editor.setSelection(endPoint, endPoint);
editor.focus();
triggerEditorContentChanged(editor.getTextArea());
}
}
async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, dropzoneEl: HTMLElement, files: Array<File> | FileList, e: Event) {
e.preventDefault();
for (const file of files) {
const name = file.name.slice(0, file.name.lastIndexOf('.'));
const {width, dppx} = await imageInfo(file);
const placeholder = `[${name}](uploading ...)`;
editor.insertPlaceholder(placeholder);
await uploadFile(dropzoneEl, file); // the "file" will get its "uuid" during the upload
editor.replacePlaceholder(placeholder, generateMarkdownLinkForAttachment(file, {width, dppx}));
}
}
export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) {
text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), '');
text = text.replace(new RegExp(`[<]img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
return text;
}
function getPastedImages(e: ClipboardEvent) {
const images: Array<File> = [];
for (const item of e.clipboardData?.items ?? []) {
if (item.type?.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
images.push(file);
}
}
}
return images;
}
export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
const editor = new CodeMirrorEditor(easyMDE.codemirror as any);
easyMDE.codemirror.on('paste', (_, e) => {
const images = getPastedImages(e);
if (!images.length) return;
handleUploadFiles(editor, dropzoneEl, images, e);
});
easyMDE.codemirror.on('drop', (_, e) => {
if (!e.dataTransfer?.files.length) return;
handleUploadFiles(editor, dropzoneEl, e.dataTransfer.files, e);
});
dropzoneEl.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => {
const oldText = easyMDE.codemirror.getValue();
const newText = removeAttachmentLinksFromMarkdown(oldText, fileUuid);
if (oldText !== newText) easyMDE.codemirror.setValue(newText);
});
}
export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement | null) {
subscribe(textarea); // enable paste features
textarea.addEventListener('paste', (e: ClipboardEvent) => {
const images = getPastedImages(e);
if (images.length && dropzoneEl) {
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e);
}
});
textarea.addEventListener('drop', (e: DragEvent) => {
if (!e.dataTransfer?.files.length) return;
if (!dropzoneEl) return;
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, e.dataTransfer.files, e);
});
dropzoneEl?.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}: {fileUuid: string}) => {
const newText = removeAttachmentLinksFromMarkdown(textarea.value, fileUuid);
if (textarea.value !== newText) textarea.value = newText;
});
}
+93
View File
@@ -0,0 +1,93 @@
import {toggleElem} from '../../utils/dom.ts';
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
import {submitFormFetchAction} from '../common-fetch-action.ts';
function nameHasScope(name: string): boolean {
return /.*[^/]\/[^/].*/.test(name);
}
export function initCompLabelEdit(pageSelector: string) {
const pageContent = document.querySelector<HTMLElement>(pageSelector);
if (!pageContent) return;
// for guest view, the modal is not available, the "labels" are read-only
const elModal = pageContent.querySelector<HTMLElement>('#issue-label-edit-modal');
if (!elModal) return;
const elLabelId = elModal.querySelector<HTMLInputElement>('input[name="id"]')!;
const elNameInput = elModal.querySelector<HTMLInputElement>('.label-name-input')!;
const elExclusiveField = elModal.querySelector('.label-exclusive-input-field')!;
const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input')!;
const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning')!;
const elExclusiveOrderField = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input-field')!;
const elExclusiveOrderInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input')!;
const elIsArchivedField = elModal.querySelector('.label-is-archived-input-field')!;
const elIsArchivedInput = elModal.querySelector<HTMLInputElement>('.label-is-archived-input')!;
const elDescInput = elModal.querySelector<HTMLInputElement>('.label-desc-input')!;
const elColorInput = elModal.querySelector<HTMLInputElement>('.color-picker-combo input')!;
const syncModalUi = () => {
const hasScope = nameHasScope(elNameInput.value);
elExclusiveField.classList.toggle('disabled', !hasScope);
const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive');
toggleElem(elExclusiveWarning, showExclusiveWarning);
if (!hasScope) elExclusiveInput.checked = false;
toggleElem(elExclusiveOrderField, elExclusiveInput.checked);
if (parseInt(elExclusiveOrderInput.value) <= 0) {
elExclusiveOrderInput.style.color = 'var(--color-placeholder-text) !important';
} else {
elExclusiveOrderInput.style.removeProperty('color');
}
};
const showLabelEditModal = (btn:HTMLElement) => {
// the "btn" should contain the label's attributes by its `data-label-xxx` attributes
const form = elModal.querySelector<HTMLFormElement>('form')!;
elLabelId.value = btn.getAttribute('data-label-id') || '';
elNameInput.value = btn.getAttribute('data-label-name') || '';
elExclusiveOrderInput.value = btn.getAttribute('data-label-exclusive-order') || '0';
elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true';
elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true';
elDescInput.value = btn.getAttribute('data-label-description') || '';
elColorInput.value = btn.getAttribute('data-label-color') || '';
elColorInput.dispatchEvent(new Event('input', {bubbles: true})); // trigger the color picker
// if label id exists: "edit label" mode; otherwise: "new label" mode
const isEdit = Boolean(elLabelId.value);
// if a label was not exclusive but has issues, then it should warn user if it will become exclusive
const numIssues = parseInt(btn.getAttribute('data-label-num-issues') || '0');
elModal.toggleAttribute('data-need-warn-exclusive', !elExclusiveInput.checked && numIssues > 0);
elModal.querySelector('.header')!.textContent = isEdit ? elModal.getAttribute('data-text-edit-label') : elModal.getAttribute('data-text-new-label');
const curPageLink = elModal.getAttribute('data-current-page-link');
form.action = isEdit ? `${curPageLink}/edit` : `${curPageLink}/new`;
toggleElem(elIsArchivedField, isEdit);
syncModalUi();
showFomanticModal(elModal, {
onApprove() {
if (!form.checkValidity()) {
form.reportValidity();
return false;
}
submitFormFetchAction(form);
return false;
},
});
};
elModal.addEventListener('input', () => syncModalUi());
// theoretically, if the modal exists, the "new label" button should also exist, just in case it doesn't, use "?."
const elNewLabel = pageContent.querySelector<HTMLElement>('.ui.button.new-label');
elNewLabel?.addEventListener('click', () => showLabelEditModal(elNewLabel));
const elEditLabelButtons = pageContent.querySelectorAll<HTMLElement>('.edit-label-button');
for (const btn of elEditLabelButtons) {
btn.addEventListener('click', (e) => {
e.preventDefault();
showLabelEditModal(btn);
});
}
}
+25
View File
@@ -0,0 +1,25 @@
import {querySingleVisibleElem} from '../../utils/dom.ts';
export function handleGlobalEnterQuickSubmit(target: HTMLElement) {
let form = target.closest('form');
if (form) {
if (!form.checkValidity()) {
form.reportValidity();
} else {
// here use the event to trigger the submit event (instead of calling `submit()` method directly)
// otherwise the `areYouSure` handler won't be executed, then there will be an annoying "confirm to leave" dialog
form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true}));
}
return true;
}
form = target.closest('.ui.form');
if (form) {
// A form should only have at most one "primary" button to do quick-submit.
// Here we don't use a special class to mark the primary button,
// because there could be a lot of forms with a primary button, the quick submit should work out-of-box,
// but not keeps asking developers to add that special class again and again (it could be forgotten easily)
querySingleVisibleElem<HTMLButtonElement>(form, '.ui.primary.button')?.click();
return true;
}
return false;
}
@@ -0,0 +1,30 @@
import {POST} from '../../modules/fetch.ts';
import {registerGlobalEventFunc} from '../../modules/observer.ts';
export function initCompReactionSelector() {
registerGlobalEventFunc('click', 'onCommentReactionButtonClick', async (target: HTMLElement, e: Event) => {
// there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment
e.preventDefault();
if (target.classList.contains('disabled')) return;
const actionUrl = target.closest('[data-action-url]')!.getAttribute('data-action-url');
const reactionContent = target.getAttribute('data-reaction-content')!;
const commentContainer = target.closest('.comment-container')!;
const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction
const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`);
const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true';
const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, {
data: new URLSearchParams({content: reactionContent}),
});
const data = await res.json();
bottomReactions?.remove();
if (data.html) {
commentContainer.insertAdjacentHTML('beforeend', data.html);
}
});
}
+16
View File
@@ -0,0 +1,16 @@
import {attachSearchBox} from '../../modules/search.ts';
const {appSubUrl} = window.config;
type RepoSearchResponse = {data: Array<{repository: {full_name: string}}>};
export function initCompSearchRepoBox(el: HTMLElement) {
const uid = el.getAttribute('data-uid');
const exclusive = el.getAttribute('data-exclusive');
let url = `${appSubUrl}/repo/search?q={query}&uid=${uid}`;
if (exclusive === 'true') url += `&exclusive=true`;
attachSearchBox(el, url, (response: RepoSearchResponse) => response.data.map((item) => ({
title: item.repository.full_name.split('/')[1],
description: item.repository.full_name,
})));
}
+30
View File
@@ -0,0 +1,30 @@
import {attachSearchBox, type SearchResult} from '../../modules/search.ts';
const {appSubUrl} = window.config;
const looksLikeEmailAddressCheck = /^\S+@\S+$/;
type UserSearchResponse = {data: Array<{login: string; avatar_url: string; full_name: string}>};
export function initCompSearchUserBox() {
const box = document.querySelector<HTMLElement>('#search-user-box');
if (!box) return;
const allowEmailInput = box.getAttribute('data-allow-email') === 'true';
const allowEmailDescription = box.getAttribute('data-allow-email-description') ?? undefined;
const includeOrgs = box.getAttribute('data-include-orgs') === 'true';
const url = `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`;
attachSearchBox(box, url, (response: UserSearchResponse, query) => {
const items: SearchResult[] = [];
const queryUpper = query.toUpperCase();
for (const item of response.data) {
const result: SearchResult = {title: item.login, image: item.avatar_url, description: item.full_name};
if (queryUpper === item.login.toUpperCase()) items.unshift(result); // exact match floats to top
else items.push(result);
}
if (allowEmailInput && !items.length && looksLikeEmailAddressCheck.test(query)) {
items.push({title: query, description: allowEmailDescription});
}
return items;
});
}
+132
View File
@@ -0,0 +1,132 @@
import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
import {emojiString} from '../emoji.ts';
import {svg} from '../../svg.ts';
import {parseIssueHref, parseRepoOwnerPathInfo} from '../../utils.ts';
import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
import {getIssueColorClass, getIssueIcon} from '../issue.ts';
import {debounce} from 'perfect-debounce';
import type TextExpanderElement from '@github/text-expander-element';
import type {TextExpanderChangeEvent, TextExpanderResult} from '@github/text-expander-element';
async function fetchIssueSuggestions(key: string, text: string): Promise<TextExpanderResult> {
const issuePathInfo = parseIssueHref(window.location.href);
if (!issuePathInfo.ownerName) {
const repoOwnerPathInfo = parseRepoOwnerPathInfo(window.location.pathname);
issuePathInfo.ownerName = repoOwnerPathInfo.ownerName;
issuePathInfo.repoName = repoOwnerPathInfo.repoName;
// then no issuePathInfo.indexString here, it is only used to exclude the current issue when "matchIssue"
}
if (!issuePathInfo.ownerName) return {matched: false};
const matches = await matchIssue(issuePathInfo.ownerName, issuePathInfo.repoName, issuePathInfo.indexString, text);
if (!matches.length) return {matched: false};
const ul = createElementFromAttrs('ul', {class: 'suggestions'});
for (const issue of matches) {
const li = createElementFromAttrs(
'li', {role: 'option', class: 'tw-flex tw-gap-2', 'data-value': `${key}${issue.number}`},
createElementFromHTML(svg(getIssueIcon(issue), 16, [getIssueColorClass(issue)])),
createElementFromAttrs('span', null, `#${issue.number}`),
createElementFromAttrs('span', null, issue.title),
);
ul.append(li);
}
return {matched: true, fragment: ul};
}
export function initTextExpander(expander: TextExpanderElement) {
if (!expander) return;
const textarea = expander.querySelector<HTMLTextAreaElement>('textarea')!;
const mentionsUrl = expander.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
// help to fix the text-expander "multiword+promise" bug: do not show the popup when there is no "#" before current line
const shouldShowIssueSuggestions = () => {
const posVal = textarea.value.substring(0, textarea.selectionStart);
const lineStart = posVal.lastIndexOf('\n');
const keyStart = posVal.lastIndexOf('#');
return keyStart > lineStart;
};
const debouncedIssueSuggestions = debounce(async (key: string, text: string): Promise<TextExpanderResult> => {
// https://github.com/github/text-expander-element/issues/71
// Upstream bug: when using "multiword+promise", TextExpander will get wrong "key" position.
// To reproduce, comment out the "shouldShowIssueSuggestions" check, use the "await sleep" below,
// then use content "close #20\nclose #20\nclose #20" (3 lines), keep changing the last line `#20` part from the end (including removing the `#`)
// There will be a JS error: Uncaught (in promise) IndexSizeError: Failed to execute 'setStart' on 'Range': The offset 28 is larger than the node's length (27).
// check the input before the request, to avoid emitting empty query to backend (still related to the upstream bug)
if (!shouldShowIssueSuggestions()) return {matched: false};
// await sleep(Math.random() * 1000); // help to reproduce the text-expander bug
const ret = await fetchIssueSuggestions(key, text);
// check the input again to avoid text-expander using incorrect position (upstream bug)
if (!shouldShowIssueSuggestions()) return {matched: false};
return ret;
}, 300); // to match onInputDebounce delay
expander.addEventListener('text-expander-change', (e: TextExpanderChangeEvent) => {
if (!e.detail) return;
const {key, text, provide} = e.detail;
if (key === ':') {
const matches = matchEmoji(text);
if (!matches.length) return provide({matched: false});
const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const name of matches) {
const emoji = emojiString(name);
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('data-value', emoji);
li.textContent = `${emoji} ${name}`;
ul.append(li);
}
provide({matched: true, fragment: ul});
} else if (key === '@') {
provide((async (): Promise<TextExpanderResult> => {
if (!mentionsUrl) return {matched: false};
const matches = await matchMention(mentionsUrl, text);
if (!matches.length) return {matched: false};
const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const {value, name, fullname, avatar} of matches) {
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('data-value', `${key}${value}`);
const img = document.createElement('img');
img.src = avatar;
li.append(img);
const nameSpan = document.createElement('span');
nameSpan.classList.add('name');
nameSpan.textContent = name;
li.append(nameSpan);
if (fullname && fullname.toLowerCase() !== name) {
const fullnameSpan = document.createElement('span');
fullnameSpan.classList.add('fullname');
fullnameSpan.textContent = fullname;
li.append(fullnameSpan);
}
ul.append(li);
}
return {matched: true, fragment: ul};
})());
} else if (key === '#') {
provide(debouncedIssueSuggestions(key, text));
}
});
expander.addEventListener('text-expander-value', ({detail}: Record<string, any>) => {
if (detail?.item) {
// add a space after @mentions and #issue as it's likely the user wants one
const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
detail.value = `${detail.item.getAttribute('data-value')}${suffix}`;
}
});
}
+42
View File
@@ -0,0 +1,42 @@
import {POST} from '../../modules/fetch.ts';
import {hideElem, showElem, toggleElem} from '../../utils/dom.ts';
export function initCompWebHookEditor() {
if (!document.querySelectorAll('.new.webhook').length) {
return;
}
for (const input of document.querySelectorAll<HTMLInputElement>('.events.checkbox input')) {
input.addEventListener('change', function () {
if (this.checked) {
showElem('.events.fields');
}
});
}
for (const input of document.querySelectorAll<HTMLInputElement>('.non-events.checkbox input')) {
input.addEventListener('change', function () {
if (this.checked) {
hideElem('.events.fields');
}
});
}
// some webhooks (like Gitea) allow to set the request method (GET/POST), and it would toggle the "Content Type" field
const httpMethodInput = document.querySelector<HTMLInputElement>('#http_method');
if (httpMethodInput) {
const updateContentType = function () {
const visible = httpMethodInput.value === 'POST';
toggleElem(document.querySelector('#content_type')!.closest('.field')!, visible);
};
updateContentType();
httpMethodInput.addEventListener('change', updateContentType);
}
// Test delivery
document.querySelector<HTMLButtonElement>('#test-delivery')?.addEventListener('click', async function () {
this.classList.add('is-loading', 'disabled');
await POST(this.getAttribute('data-link')!);
setTimeout(() => window.location.reload(), 5000);
});
}
+31
View File
@@ -0,0 +1,31 @@
import {createApp} from 'vue';
export async function initRepoContributors() {
const el = document.querySelector('#repo-contributors-chart');
if (!el) return;
const {default: RepoContributors} = await import('../components/RepoContributors.vue');
try {
const View = createApp(RepoContributors, {
repoLink: el.getAttribute('data-repo-link'),
repoDefaultBranchName: el.getAttribute('data-repo-default-branch-name'),
locale: {
filterLabel: el.getAttribute('data-locale-filter-label'),
contributionType: {
commits: el.getAttribute('data-locale-contribution-type-commits'),
additions: el.getAttribute('data-locale-contribution-type-additions'),
deletions: el.getAttribute('data-locale-contribution-type-deletions'),
},
loadingTitle: el.getAttribute('data-locale-loading-title'),
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
loadingInfo: el.getAttribute('data-locale-loading-info'),
chartZoomHint: el.getAttribute('data-locale-chart-zoom-hint'),
},
});
View.mount(el);
} catch (err) {
console.error('RepoContributors failed to load', err);
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
}
}
+25
View File
@@ -0,0 +1,25 @@
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
import {convertImage} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
import {registerGlobalEventFunc} from '../modules/observer.ts';
export function initCopyContent() {
registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => {
if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return;
await copyToClipboardWithFeedback(btn, async () => {
const rawFileLink = btn.getAttribute('data-raw-file-link');
if (!rawFileLink) {
const lineEls = document.querySelectorAll('.file-view .lines-code');
return Array.from(lineEls, (el) => el.textContent).join('');
}
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
const contentType = res.headers.get('content-type')!;
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
// browsers only accept image/png in the clipboard, convert other raster formats
const blob = await res.blob();
return contentType === 'image/png' ? blob : convertImage(blob, 'image/png');
}
return await res.text();
});
});
}
+9
View File
@@ -0,0 +1,9 @@
import {createApp} from 'vue';
import DashboardRepoList from '../components/DashboardRepoList.vue';
export function initDashboardRepoList() {
const el = document.querySelector('#dashboard-repo-list');
if (el) {
createApp(DashboardRepoList).mount(el);
}
}
+161
View File
@@ -0,0 +1,161 @@
import {svg} from '../svg.ts';
import {html} from '../utils/html.ts';
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {isImageFile, isVideoFile} from '../utils.ts';
import type Dropzone from '@deltablot/dropzone';
type CustomDropzoneFile = Dropzone.DropzoneFile & {uuid: string};
// dropzone has its owner event dispatcher (emitter)
export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files';
export const DropzoneCustomEventRemovedFile = 'dropzone-custom-removed-file';
export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done';
async function createDropzone(el: HTMLElement, opts: Dropzone.DropzoneOptions) {
const [{default: Dropzone}] = await Promise.all([
import('@deltablot/dropzone'),
import('@deltablot/dropzone/dist/dropzone.css'),
]);
return new Dropzone(el, opts);
}
export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFile>, {width, dppx}: {width?: number, dppx?: number} = {}) {
let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
if (isImageFile(file)) {
if (width && width > 0 && dppx && dppx > 1) {
// Scale down images from HiDPI monitors. This uses the <img> tag because it's the only
// method to change image size in Markdown that is supported by all implementations.
// Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}"
fileMarkdown = html`<img width="${Math.round(width / dppx)}" alt="${file.name}" src="attachments/${file.uuid}">`;
} else {
// Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}"
// TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments"
fileMarkdown = `![${file.name}](/attachments/${file.uuid})`;
}
} else if (isVideoFile(file)) {
fileMarkdown = html`<video src="attachments/${file.uuid}" title="${file.name}" controls></video>`;
}
return fileMarkdown;
}
function addCopyLink(file: Partial<CustomDropzoneFile>) {
// Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard
// The "<a>" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
const copyLinkEl = createElementFromHTML<HTMLDivElement>(`
<div class="tw-text-center">
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
</div>`);
copyLinkEl.addEventListener('click', async (e) => {
e.preventDefault();
await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file));
});
file.previewTemplate!.append(copyLinkEl);
}
type FileUuidDict = Record<string, {submitted: boolean}>;
/**
* @param {HTMLElement} dropzoneEl
*/
export async function initDropzone(dropzoneEl: HTMLElement) {
const listAttachmentsUrl = dropzoneEl.closest('[data-attachment-url]')?.getAttribute('data-attachment-url');
const removeAttachmentUrl = dropzoneEl.getAttribute('data-remove-url')!;
const attachmentBaseLinkUrl = dropzoneEl.getAttribute('data-link-url')!;
let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
let fileUuidDict: FileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone
const opts: Record<string, any> = {
url: dropzoneEl.getAttribute('data-upload-url'),
acceptedFiles: ['*/*', ''].includes(dropzoneEl.getAttribute('data-accepts')!) ? null : dropzoneEl.getAttribute('data-accepts'),
addRemoveLinks: true,
dictDefaultMessage: dropzoneEl.getAttribute('data-default-message'),
dictInvalidFileType: dropzoneEl.getAttribute('data-invalid-input-type'),
dictFileTooBig: dropzoneEl.getAttribute('data-file-too-big'),
dictRemoveFile: dropzoneEl.getAttribute('data-remove-file'),
timeout: 0,
thumbnailMethod: 'contain',
thumbnailWidth: 480,
thumbnailHeight: 480,
};
if (dropzoneEl.hasAttribute('data-max-file')) opts.maxFiles = Number(dropzoneEl.getAttribute('data-max-file'));
if (dropzoneEl.hasAttribute('data-max-size')) opts.maxFilesize = Number(dropzoneEl.getAttribute('data-max-size'));
// there is a bug in dropzone: if a non-image file is uploaded, then it tries to request the file from server by something like:
// "http://localhost:3000/owner/repo/issues/[object%20Event]"
// the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates '<img src="[object Event]">'
const dzInst = await createDropzone(dropzoneEl, opts);
dzInst.on('success', (file: CustomDropzoneFile, resp: any) => {
file.uuid = resp.uuid;
fileUuidDict[file.uuid] = {submitted: false};
const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid});
dropzoneEl.querySelector('.files')!.append(input);
addCopyLink(file);
dzInst.emit(DropzoneCustomEventUploadDone, {file});
});
dzInst.on('removedfile', async (file: CustomDropzoneFile) => {
if (disableRemovedfileEvent) return;
dzInst.emit(DropzoneCustomEventRemovedFile, {fileUuid: file.uuid});
document.querySelector(`#dropzone-file-${file.uuid}`)?.remove();
// when the uploaded file number reaches the limit, there is no uuid in the dict, and it doesn't need to be removed from server
if (removeAttachmentUrl && fileUuidDict[file.uuid] && !fileUuidDict[file.uuid].submitted) {
await POST(removeAttachmentUrl, {data: new URLSearchParams({file: file.uuid})});
}
});
dzInst.on('submit', () => {
for (const fileUuid of Object.keys(fileUuidDict)) {
fileUuidDict[fileUuid].submitted = true;
}
});
dzInst.on(DropzoneCustomEventReloadFiles, async () => {
try {
if (!listAttachmentsUrl) return;
const resp = await GET(listAttachmentsUrl);
const respData = await resp.json();
// do not trigger the "removedfile" event, otherwise the attachments would be deleted from server
disableRemovedfileEvent = true;
dzInst.removeAllFiles(true);
disableRemovedfileEvent = false;
dropzoneEl.querySelector('.files')!.innerHTML = '';
for (const el of dropzoneEl.querySelectorAll('.dz-preview')) el.remove();
fileUuidDict = {};
for (const attachment of respData) {
const file = {name: attachment.name, uuid: attachment.uuid, size: attachment.size};
dzInst.emit('addedfile', file);
dzInst.emit('complete', file);
if (isImageFile(file.name)) {
const imgSrc = `${attachmentBaseLinkUrl}/${file.uuid}`;
dzInst.emit('thumbnail', file, imgSrc);
}
addCopyLink(file); // it is from server response, so no "type"
fileUuidDict[file.uuid] = {submitted: true};
const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${file.uuid}`, value: file.uuid});
dropzoneEl.querySelector('.files')!.append(input);
}
if (!dropzoneEl.querySelector('.dz-preview')) {
dropzoneEl.classList.remove('dz-started');
}
} catch (error) {
// TODO: if listing the existing attachments failed, it should stop from operating the content or attachments,
// otherwise the attachments might be lost.
showErrorToast(`Failed to load attachments: ${errorMessage(error)}`);
console.error(error);
}
});
dzInst.on('error', (file, message) => {
showErrorToast(`Dropzone upload error: ${message}`);
dzInst.removeFile(file);
});
if (listAttachmentsUrl) dzInst.emit(DropzoneCustomEventReloadFiles);
return dzInst;
}
+38
View File
@@ -0,0 +1,38 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'};
import {html} from '../utils/html.ts';
const {assetUrlPrefix, customEmojis} = window.config;
const tempMap = {...customEmojis};
for (const {emoji, aliases} of emojis) {
for (const alias of aliases || []) {
tempMap[alias] = emoji;
}
}
export const emojiKeys = Object.keys(tempMap).sort((a, b) => {
if (a === '+1' || a === '-1') return -1;
if (b === '+1' || b === '-1') return 1;
return a.localeCompare(b);
});
const emojiMap: Record<string, string> = {};
for (const key of emojiKeys) {
emojiMap[key] = tempMap[key];
}
// retrieve HTML for given emoji name
export function emojiHTML(name: string) {
let inner;
if (Object.hasOwn(customEmojis, name)) {
inner = html`<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
} else {
inner = emojiString(name);
}
return html`<span class="emoji" title=":${name}:">${inner}</span>`;
}
// retrieve string for given emoji name
export function emojiString(name: string) {
return emojiMap[name] || `:${name}:`;
}
+19
View File
@@ -0,0 +1,19 @@
import {svg} from '../svg.ts';
// Hides the file if newFold is true, and shows it otherwise. The actual hiding is performed using CSS.
//
// The fold arrow is the icon displayed on the upper left of the file box, especially intended for components having the 'fold-file' class.
// The file content box is the box that should be hidden or shown, especially intended for components having the 'file-content' class.
//
export function setFileFolding(fileContentBox: Element, foldArrow: HTMLElement, newFold: boolean) {
foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18);
fileContentBox.setAttribute('data-folded', String(newFold));
if (newFold && fileContentBox.getBoundingClientRect().top < 0) {
fileContentBox.scrollIntoView();
}
}
// Like `setFileFolding`, except that it automatically inverts the current file folding state.
export function invertFileFolding(fileContentBox:HTMLElement, foldArrow: HTMLElement) {
setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true');
}
+64
View File
@@ -0,0 +1,64 @@
import type {InplaceRenderPlugin} from '../render/plugin.ts';
import {newInplacePluginPdfViewer} from '../render/plugins/inplace-pdf-viewer.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {html} from '../utils/html.ts';
import {basename} from '../utils.ts';
const inplacePlugins: InplaceRenderPlugin[] = [];
function initInplacePluginsOnce(): void {
if (inplacePlugins.length) return;
inplacePlugins.push(newInplacePluginPdfViewer());
}
function findInplaceRenderPlugin(filename: string, mimeType: string): InplaceRenderPlugin | null {
return inplacePlugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
}
async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) {
const elViewRawPrompt = container.querySelector('.file-view-raw-prompt');
if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container');
let rendered = false, errorMsg = '';
try {
const plugin = findInplaceRenderPlugin(basename(rawFileLink), mimeType);
if (plugin) {
container.classList.add('is-loading');
container.setAttribute('data-render-name', plugin.name); // not used yet
await plugin.render(container, rawFileLink);
rendered = true;
}
} catch (e) {
errorMsg = errorMessage(e);
} finally {
container.classList.remove('is-loading');
}
if (rendered) {
elViewRawPrompt.remove();
return;
}
// remove all children from the container, and only show the raw file link
container.replaceChildren(elViewRawPrompt);
if (errorMsg) {
const elErrorMessage = createElementFromHTML(html`<div class="ui error message">${errorMsg}</div>`);
elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage);
}
}
export function initRepoFileView(): void {
registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => {
initInplacePluginsOnce();
const rawFileLink = elFileView.getAttribute('data-raw-file-link')!;
const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet
const plugin = findInplaceRenderPlugin(basename(rawFileLink), mimeType);
if (!plugin) return;
const renderContainer = elFileView.querySelector<HTMLElement>('.file-view-render-container');
if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType);
});
}
+56
View File
@@ -0,0 +1,56 @@
import {createApp} from 'vue';
import {translateMonth, translateDay} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
type HeatmapResponse = {
heatmapData: Array<[number, number]>; // [[1617235200, 2]] = [unix timestamp, count]
totalContributions: number;
};
export async function initHeatmap() {
const el = document.querySelector<HTMLElement>('#user-heatmap');
if (!el) return;
try {
const url = el.getAttribute('data-heatmap-url')!;
const resp = await GET(url);
if (!resp.ok) throw new Error(`Failed to load heatmap data: ${resp.status} ${resp.statusText}`);
const {heatmapData, totalContributions} = await resp.json() as HeatmapResponse;
const heatmap: Record<string, number> = {};
for (const [timestamp, contributions] of heatmapData) {
// Convert to user timezone and sum contributions by date
const dateStr = new Date(timestamp * 1000).toDateString();
heatmap[dateStr] = (heatmap[dateStr] || 0) + contributions;
}
const values = Object.keys(heatmap).map((v) => {
return {date: new Date(v), count: heatmap[v]};
});
const totalFormatted = totalContributions.toLocaleString();
const textTotalContributions = el.getAttribute('data-locale-total-contributions')!.replace('%s', totalFormatted);
// last heatmap tooltip localization attempt https://github.com/go-gitea/gitea/pull/24131/commits/a83761cbbae3c2e3b4bced71e680f44432073ac8
const locale = {
heatMapLocale: {
months: new Array(12).fill(undefined).map((_, idx) => translateMonth(idx)),
days: new Array(7).fill(undefined).map((_, idx) => translateDay(idx)),
on: ' - ', // no correct locale support for it, because in many languages the sentence is not "something on someday"
more: el.getAttribute('data-locale-more'),
less: el.getAttribute('data-locale-less'),
},
tooltipUnit: 'contributions',
textTotalContributions,
noDataText: el.getAttribute('data-locale-no-contributions'),
};
const {default: ActivityHeatmap} = await import('../components/ActivityHeatmap.vue');
const View = createApp(ActivityHeatmap, {values, locale});
View.mount(el);
el.classList.remove('is-loading');
} catch (err) {
console.error('Heatmap failed to load', err);
el.textContent = 'Heatmap failed to load';
}
}
+307
View File
@@ -0,0 +1,307 @@
import {GET} from '../modules/fetch.ts';
import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts';
import {parseDom} from '../utils.ts';
type ImageContext = {
imageBefore: HTMLImageElement | undefined,
imageAfter: HTMLImageElement | undefined,
sizeBefore: {width: number, height: number},
sizeAfter: {width: number, height: number},
maxSize: {width: number, height: number},
ratio: [number, number, number, number],
};
type ImageInfo = {
path: string | null,
mime: string | null,
images: NodeListOf<HTMLImageElement>,
boundsInfo: HTMLElement | null,
};
type Bounds = {
width: number,
height: number,
} | null;
type SvgBoundsInfo = {
before: Bounds,
after: Bounds,
};
function getDefaultSvgBoundsIfUndefined(text: string, src: string): Bounds | null {
const defaultSize = 300;
const maxSize = 99999;
const svgDoc = parseDom(text, 'image/svg+xml');
const svg = (svgDoc.documentElement as unknown) as SVGSVGElement;
const width = svg?.width?.baseVal;
const height = svg?.height?.baseVal;
if (width === undefined || height === undefined) {
return null; // in case some svg is invalid or doesn't have the width/height
}
if (width.unitType === SVGLength.SVG_LENGTHTYPE_PERCENTAGE || height.unitType === SVGLength.SVG_LENGTHTYPE_PERCENTAGE) {
const img = new Image();
img.src = src;
if (img.width > 1 && img.width < maxSize && img.height > 1 && img.height < maxSize) {
return {
width: img.width,
height: img.height,
};
}
if (svg.hasAttribute('viewBox')) {
const viewBox = svg.viewBox.baseVal;
return {
width: defaultSize,
height: defaultSize * viewBox.height / viewBox.width,
};
}
return {
width: defaultSize,
height: defaultSize,
};
}
return null;
}
function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageElement, svgBoundsInfo: SvgBoundsInfo): ImageContext {
const sizeAfter = {
width: svgBoundsInfo.after?.width || imageAfter?.width || 0,
height: svgBoundsInfo.after?.height || imageAfter?.height || 0,
};
const sizeBefore = {
width: svgBoundsInfo.before?.width || imageBefore?.width || 0,
height: svgBoundsInfo.before?.height || imageBefore?.height || 0,
};
const maxSize = {
width: Math.max(sizeBefore.width, sizeAfter.width),
height: Math.max(sizeBefore.height, sizeAfter.height),
};
return {
imageAfter,
imageBefore,
sizeAfter,
sizeBefore,
maxSize,
ratio: [
Math.floor(maxSize.width - sizeAfter.width) / 2,
Math.floor(maxSize.height - sizeAfter.height) / 2,
Math.floor(maxSize.width - sizeBefore.width) / 2,
Math.floor(maxSize.height - sizeBefore.height) / 2,
],
};
}
class ImageDiff {
containerEl!: HTMLElement;
diffContainerWidth!: number;
async init(containerEl: HTMLElement) {
this.containerEl = containerEl;
containerEl.setAttribute('data-image-diff-loaded', 'true');
// the container may be hidden by "viewed" checkbox, so use the parent's width for reference
this.diffContainerWidth = Math.max(containerEl.closest('.diff-file-box')!.clientWidth - 300, 100);
const imagePair: [ImageInfo, ImageInfo] = [{
path: containerEl.getAttribute('data-path-after'),
mime: containerEl.getAttribute('data-mime-after'),
images: containerEl.querySelectorAll<HTMLImageElement>('img.image-after'), // matches 3 <img>
boundsInfo: containerEl.querySelector('.bounds-info-after'),
}, {
path: containerEl.getAttribute('data-path-before'),
mime: containerEl.getAttribute('data-mime-before'),
images: containerEl.querySelectorAll<HTMLImageElement>('img.image-before'), // matches 3 <img>
boundsInfo: containerEl.querySelector('.bounds-info-before'),
}];
const svgBoundsInfo: SvgBoundsInfo = {before: null, after: null};
await Promise.all(imagePair.map(async (info, index) => {
const [success] = await Promise.all(Array.from(info.images, (img) => {
return loadElem(img, info.path!);
}));
// only the first images is associated with boundsInfo
if (!success && info.boundsInfo) info.boundsInfo.textContent = '(image error)';
if (info.mime === 'image/svg+xml') {
const resp = await GET(info.path!);
const text = await resp.text();
const bounds = getDefaultSvgBoundsIfUndefined(text, info.path!);
svgBoundsInfo[index === 0 ? 'after' : 'before'] = bounds;
if (bounds) {
hideElem(info.boundsInfo!);
}
}
}));
const imagesAfter = imagePair[0].images;
const imagesBefore = imagePair[1].images;
this.initSideBySide(createContext(imagesAfter[0], imagesBefore[0], svgBoundsInfo));
if (imagesAfter.length > 0 && imagesBefore.length > 0) {
this.initSwipe(createContext(imagesAfter[1], imagesBefore[1], svgBoundsInfo));
this.initOverlay(createContext(imagesAfter[2], imagesBefore[2], svgBoundsInfo));
}
queryElemChildren(containerEl, '.image-diff-tabs', (el) => el.classList.remove('is-loading'));
}
initSideBySide(ctx: ImageContext) {
let factor = 1;
if (ctx.maxSize.width > (this.diffContainerWidth - 24) / 2) {
factor = (this.diffContainerWidth - 24) / 2 / ctx.maxSize.width;
}
const widthChanged = ctx.imageAfter && ctx.imageBefore && ctx.imageAfter.naturalWidth !== ctx.imageBefore.naturalWidth;
const heightChanged = ctx.imageAfter && ctx.imageBefore && ctx.imageAfter.naturalHeight !== ctx.imageBefore.naturalHeight;
if (ctx.imageAfter) {
const boundsInfoAfterWidth = this.containerEl.querySelector('.bounds-info-after .bounds-info-width');
if (boundsInfoAfterWidth) {
boundsInfoAfterWidth.textContent = `${ctx.imageAfter.naturalWidth}px`;
boundsInfoAfterWidth.classList.toggle('green', widthChanged);
}
const boundsInfoAfterHeight = this.containerEl.querySelector('.bounds-info-after .bounds-info-height');
if (boundsInfoAfterHeight) {
boundsInfoAfterHeight.textContent = `${ctx.imageAfter.naturalHeight}px`;
boundsInfoAfterHeight.classList.toggle('green', heightChanged);
}
}
if (ctx.imageBefore) {
const boundsInfoBeforeWidth = this.containerEl.querySelector('.bounds-info-before .bounds-info-width');
if (boundsInfoBeforeWidth) {
boundsInfoBeforeWidth.textContent = `${ctx.imageBefore.naturalWidth}px`;
boundsInfoBeforeWidth.classList.toggle('red', widthChanged);
}
const boundsInfoBeforeHeight = this.containerEl.querySelector('.bounds-info-before .bounds-info-height');
if (boundsInfoBeforeHeight) {
boundsInfoBeforeHeight.textContent = `${ctx.imageBefore.naturalHeight}px`;
boundsInfoBeforeHeight.classList.toggle('red', heightChanged);
}
}
if (ctx.imageAfter) {
const container = ctx.imageAfter.parentNode as HTMLElement;
ctx.imageAfter.style.width = `${ctx.sizeAfter.width * factor}px`;
ctx.imageAfter.style.height = `${ctx.sizeAfter.height * factor}px`;
container.style.margin = '10px auto';
container.style.width = `${ctx.sizeAfter.width * factor + 2}px`;
container.style.height = `${ctx.sizeAfter.height * factor + 2}px`;
}
if (ctx.imageBefore) {
const container = ctx.imageBefore.parentNode as HTMLElement;
ctx.imageBefore.style.width = `${ctx.sizeBefore.width * factor}px`;
ctx.imageBefore.style.height = `${ctx.sizeBefore.height * factor}px`;
container.style.margin = '10px auto';
container.style.width = `${ctx.sizeBefore.width * factor + 2}px`;
container.style.height = `${ctx.sizeBefore.height * factor + 2}px`;
}
}
initSwipe(ctx: ImageContext) {
let factor = 1;
if (ctx.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / ctx.maxSize.width;
}
if (ctx.imageAfter) {
const imgParent = ctx.imageAfter.parentNode as HTMLElement;
const swipeFrame = imgParent.parentNode as HTMLElement;
ctx.imageAfter.style.width = `${ctx.sizeAfter.width * factor}px`;
ctx.imageAfter.style.height = `${ctx.sizeAfter.height * factor}px`;
imgParent.style.margin = `0px ${ctx.ratio[0] * factor}px`;
imgParent.style.width = `${ctx.sizeAfter.width * factor + 2}px`;
imgParent.style.height = `${ctx.sizeAfter.height * factor + 2}px`;
swipeFrame.style.padding = `${ctx.ratio[1] * factor}px 0 0 0`;
swipeFrame.style.width = `${ctx.maxSize.width * factor + 2}px`;
}
if (ctx.imageBefore) {
const imgParent = ctx.imageBefore.parentNode as HTMLElement;
const swipeFrame = imgParent.parentNode as HTMLElement;
ctx.imageBefore.style.width = `${ctx.sizeBefore.width * factor}px`;
ctx.imageBefore.style.height = `${ctx.sizeBefore.height * factor}px`;
imgParent.style.margin = `${ctx.ratio[3] * factor}px ${ctx.ratio[2] * factor}px`;
imgParent.style.width = `${ctx.sizeBefore.width * factor + 2}px`;
imgParent.style.height = `${ctx.sizeBefore.height * factor + 2}px`;
swipeFrame.style.width = `${ctx.maxSize.width * factor + 2}px`;
swipeFrame.style.height = `${ctx.maxSize.height * factor + 2}px`;
}
// extra height for inner "position: absolute" elements
const swipe = this.containerEl.querySelector<HTMLElement>('.diff-swipe');
if (swipe) {
swipe.style.width = `${ctx.maxSize.width * factor + 2}px`;
swipe.style.height = `${ctx.maxSize.height * factor + 30}px`;
}
this.containerEl.querySelector('.swipe-bar')!.addEventListener('mousedown', (e) => {
e.preventDefault();
this.initSwipeEventListeners(e.currentTarget as HTMLElement);
});
}
initSwipeEventListeners(swipeBar: HTMLElement) {
const swipeFrame = swipeBar.parentNode as HTMLElement;
const width = swipeFrame.clientWidth;
const onSwipeMouseMove = (e: MouseEvent) => {
e.preventDefault();
const rect = swipeFrame.getBoundingClientRect();
const value = Math.max(0, Math.min(e.clientX - rect.left, width));
swipeBar.style.left = `${value}px`;
this.containerEl.querySelector<HTMLElement>('.swipe-container')!.style.width = `${swipeFrame.clientWidth - value}px`;
};
const removeEventListeners = () => {
document.removeEventListener('mousemove', onSwipeMouseMove);
document.removeEventListener('mouseup', removeEventListeners);
};
document.addEventListener('mousemove', onSwipeMouseMove);
document.addEventListener('mouseup', removeEventListeners);
}
initOverlay(ctx: ImageContext) {
let factor = 1;
if (ctx.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / ctx.maxSize.width;
}
if (ctx.imageAfter) {
const container = ctx.imageAfter.parentNode as HTMLElement;
ctx.imageAfter.style.width = `${ctx.sizeAfter.width * factor}px`;
ctx.imageAfter.style.height = `${ctx.sizeAfter.height * factor}px`;
container.style.margin = `${ctx.ratio[1] * factor}px ${ctx.ratio[0] * factor}px`;
container.style.width = `${ctx.sizeAfter.width * factor + 2}px`;
container.style.height = `${ctx.sizeAfter.height * factor + 2}px`;
}
if (ctx.imageBefore) {
const container = ctx.imageBefore.parentNode as HTMLElement;
const overlayFrame = container.parentNode as HTMLElement;
ctx.imageBefore.style.width = `${ctx.sizeBefore.width * factor}px`;
ctx.imageBefore.style.height = `${ctx.sizeBefore.height * factor}px`;
container.style.margin = `${ctx.ratio[3] * factor}px ${ctx.ratio[2] * factor}px`;
container.style.width = `${ctx.sizeBefore.width * factor + 2}px`;
container.style.height = `${ctx.sizeBefore.height * factor + 2}px`;
// some inner elements are `position: absolute`, so the container's height must be large enough
overlayFrame.style.width = `${ctx.maxSize.width * factor + 2}px`;
overlayFrame.style.height = `${ctx.maxSize.height * factor + 2}px`;
}
const rangeInput = this.containerEl.querySelector<HTMLInputElement>('input[type="range"]')!;
function updateOpacity() {
if (ctx.imageAfter) {
(ctx.imageAfter.parentNode as HTMLElement).style.opacity = `${Number(rangeInput.value) / 100}`;
}
}
rangeInput?.addEventListener('input', updateOpacity);
updateOpacity();
}
}
export function initImageDiff() {
for (const el of queryElems<HTMLImageElement>(document, '.image-diff:not([data-image-diff-loaded])')) {
(new ImageDiff()).init(el); // it is async, but we don't need to await for it
}
}
+102
View File
@@ -0,0 +1,102 @@
import {hideElem, showElem} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
export function initInstall() {
const page = document.querySelector('.page-content.install');
if (!page) {
return;
}
if (page.classList.contains('post-install')) {
initPostInstall();
} else {
initPreInstall();
}
}
function initPreInstall() {
const defaultDbUser = 'gitea';
const defaultDbName = 'gitea';
const defaultDbHosts: Record<string, string> = {
mysql: '127.0.0.1:3306',
postgres: '127.0.0.1:5432',
mssql: '127.0.0.1:1433',
};
const dbHost = document.querySelector<HTMLInputElement>('#db_host')!;
const dbUser = document.querySelector<HTMLInputElement>('#db_user')!;
const dbName = document.querySelector<HTMLInputElement>('#db_name')!;
// Database type change detection.
document.querySelector<HTMLInputElement>('#db_type')!.addEventListener('change', function () {
const dbType = this.value;
hideElem('div[data-db-setting-for]');
showElem(`div[data-db-setting-for=${dbType}]`);
if (dbType !== 'sqlite3') {
// for most remote database servers
showElem('div[data-db-setting-for=common-host]');
const lastDbHost = dbHost.value;
const isDbHostDefault = !lastDbHost || Object.values(defaultDbHosts).includes(lastDbHost);
if (isDbHostDefault) {
dbHost.value = defaultDbHosts[dbType] ?? '';
}
if (!dbUser.value && !dbName.value) {
dbUser.value = defaultDbUser;
dbName.value = defaultDbName;
}
} // else: for SQLite3, the default path is always prepared by backend code (setting)
});
document.querySelector('#db_type')!.dispatchEvent(new Event('change'));
const appUrl = document.querySelector<HTMLInputElement>('#app_url')!;
if (appUrl.value.includes('://localhost')) {
appUrl.value = window.location.href;
}
const domain = document.querySelector<HTMLInputElement>('#domain')!;
if (domain.value.trim() === 'localhost') {
domain.value = window.location.hostname;
}
// TODO: better handling of exclusive relations.
document.querySelector<HTMLInputElement>('#enable-openid-signin input')!.addEventListener('change', function () {
if (this.checked) {
if (!document.querySelector<HTMLInputElement>('#disable-registration input')!.checked) {
document.querySelector<HTMLInputElement>('#enable-openid-signup input')!.checked = true;
}
} else {
document.querySelector<HTMLInputElement>('#enable-openid-signup input')!.checked = false;
}
});
document.querySelector<HTMLInputElement>('#disable-registration input')!.addEventListener('change', function () {
if (this.checked) {
document.querySelector<HTMLInputElement>('#enable-captcha input')!.checked = false;
document.querySelector<HTMLInputElement>('#enable-openid-signup input')!.checked = false;
} else {
document.querySelector<HTMLInputElement>('#enable-openid-signup input')!.checked = true;
}
});
document.querySelector<HTMLInputElement>('#enable-captcha input')!.addEventListener('change', function () {
if (this.checked) {
document.querySelector<HTMLInputElement>('#disable-registration input')!.checked = false;
}
});
}
function initPostInstall() {
const el = document.querySelector('#goto-after-install');
if (!el) return;
const targetUrl = el.getAttribute('href')!;
let tid: ReturnType<typeof setInterval> | null = setInterval(async () => {
try {
const resp = await GET(targetUrl);
if (tid && resp.status === 200) {
clearInterval(tid);
tid = null;
window.location.href = targetUrl;
}
} catch {}
}, 1000);
}
+41
View File
@@ -0,0 +1,41 @@
import type {Issue} from '../types.ts';
// the getIssueIcon/getIssueColorClass logic should be kept the same as "templates/shared/issueicon.tmpl"
export function getIssueIcon(issue: Issue) {
if (issue.pull_request) {
if (issue.state === 'open') {
if (issue.pull_request.draft) {
return 'octicon-git-pull-request-draft'; // WIP PR
}
return 'octicon-git-pull-request'; // Open PR
} else if (issue.pull_request.merged) {
return 'octicon-git-merge'; // Merged PR
}
return 'octicon-git-pull-request-closed'; // Closed PR
}
if (issue.state === 'open') {
return 'octicon-issue-opened'; // Open Issue
}
return 'octicon-issue-closed'; // Closed Issue
}
export function getIssueColorClass(issue: Issue) {
if (issue.pull_request) {
if (issue.state === 'open') {
if (issue.pull_request.draft) {
return 'tw-text-text-light'; // WIP PR
}
return 'tw-text-green'; // Open PR
} else if (issue.pull_request.merged) {
return 'tw-text-purple'; // Merged PR
}
return 'tw-text-red'; // Closed PR
}
if (issue.state === 'open') {
return 'tw-text-green'; // Open Issue
}
return 'tw-text-red'; // Closed Issue
}
+123
View File
@@ -0,0 +1,123 @@
import {GET} from '../modules/fetch.ts';
import {toggleElem, createElementFromHTML} from '../utils/dom.ts';
import {UserEventsSharedWorker} from '../modules/worker.ts';
const {appSubUrl, notificationSettings} = window.config;
let notificationSequenceNumber = 0;
async function receiveUpdateCount(event: MessageEvent<{type: string, data: string}>) {
try {
const data = JSON.parse(event.data.data);
for (const count of document.querySelectorAll('.notification_count')) {
count.classList.toggle('tw-hidden', data.Count === 0);
count.textContent = `${data.Count}`;
}
await updateNotificationTable();
} catch (error) {
console.error(error, event);
}
}
export function initNotificationCount() {
if (!document.querySelector('.notification_count')) return;
let usingPeriodicPoller = false;
const startPeriodicPoller = (timeout: number, lastCount?: number) => {
if (timeout <= 0 || !Number.isFinite(timeout)) return;
usingPeriodicPoller = true;
lastCount = lastCount ?? getCurrentCount();
setTimeout(async () => {
await updateNotificationCountWithCallback(startPeriodicPoller, timeout, lastCount);
}, timeout);
};
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
// Try to connect to the event source via the shared worker first
const worker = new UserEventsSharedWorker('notification-worker');
worker.addMessageEventListener((event: MessageEvent) => {
if (event.data.type === 'no-event-source') {
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
} else if (event.data.type === 'notification-count') {
receiveUpdateCount(event); // no await
}
});
worker.startPort();
return;
}
startPeriodicPoller(notificationSettings.MinTimeout);
}
function getCurrentCount() {
return Number(document.querySelector('.notification_count')!.textContent ?? '0');
}
async function updateNotificationCountWithCallback(callback: (timeout: number, newCount: number) => void, timeout: number, lastCount: number) {
const currentCount = getCurrentCount();
if (lastCount !== currentCount) {
callback(notificationSettings.MinTimeout, currentCount);
return;
}
const newCount = await updateNotificationCount();
let needsUpdate = false;
if (lastCount !== newCount) {
needsUpdate = true;
timeout = notificationSettings.MinTimeout;
} else if (timeout < notificationSettings.MaxTimeout) {
timeout += notificationSettings.TimeoutStep;
}
callback(timeout, newCount);
if (needsUpdate) {
await updateNotificationTable();
}
}
async function updateNotificationTable() {
const notificationDiv = document.querySelector('#notification_div');
if (!notificationDiv) return;
try {
const params = new URLSearchParams(window.location.search);
params.set('div-only', 'true');
params.set('sequence-number', String(++notificationSequenceNumber));
const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
if (!response.ok) {
throw new Error('Failed to fetch notification table');
}
const data = await response.text();
const el = createElementFromHTML(data);
if (parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber) {
notificationDiv.outerHTML = data;
}
} catch (error) {
console.error(error);
}
}
async function updateNotificationCount(): Promise<number> {
try {
const response = await GET(`${appSubUrl}/notifications/new`);
if (!response.ok) {
throw new Error('Failed to fetch notification count');
}
const data = await response.json();
toggleElem('.notification_count', data.new !== 0);
for (const el of document.querySelectorAll('.notification_count')) {
el.textContent = `${data.new}`;
}
return data.new as number;
} catch (error) {
console.error(error);
return 0;
}
}
+9
View File
@@ -0,0 +1,9 @@
export function initOAuth2SettingsDisableCheckbox() {
for (const el of document.querySelectorAll<HTMLInputElement>('.disable-setting')) {
el.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement;
const dataTarget = target.getAttribute('data-target')!;
document.querySelector(dataTarget)!.classList.toggle('disabled', target.checked);
});
}
}
+17
View File
@@ -0,0 +1,17 @@
import {queryElems, toggleElem} from '../utils/dom.ts';
function initOrgTeamSettings() {
// on the page "page-content organization new team"
const pageContent = document.querySelector('.page-content.organization.new.team');
if (!pageContent) return;
queryElems(pageContent, 'input[name=permission]', (el) => el.addEventListener('change', () => {
// Change team access mode
const val = pageContent.querySelector<HTMLInputElement>('input[name=permission]:checked')?.value;
toggleElem(pageContent.querySelectorAll('.team-units'), val !== 'admin');
}));
}
export function initOrgTeam() {
if (!document.querySelector('.page-content.organization')) return;
initOrgTeamSettings();
}
+86
View File
@@ -0,0 +1,86 @@
import {diffTreeStore, diffTreeStoreSetViewed} from '../modules/diff-file.ts';
import {setFileFolding} from './file-fold.ts';
import {POST} from '../modules/fetch.ts';
const {pageData} = window.config;
// it is undefined on most pages, fortunately, when it is accessed by the related functions, it exists
const prReview = pageData.prReview!;
const viewedStyleClass = 'viewed-file-checked-form';
const viewedCheckboxSelector = '.viewed-file-form'; // Selector under which all "Viewed" checkbox forms can be found
const expandFilesBtnSelector = '#expand-files-btn';
const collapseFilesBtnSelector = '#collapse-files-btn';
// Refreshes the summary of viewed files
// The data used will be window.config.pageData.prReview.numberOf{Viewed}Files
function refreshViewedFilesSummary() {
const viewedFilesProgress = document.querySelector('#viewed-files-summary')!;
viewedFilesProgress.setAttribute('value', String(prReview.numberOfViewedFiles));
const summaryLabel = document.querySelector<HTMLElement>('#viewed-files-summary-label')!;
summaryLabel.textContent = summaryLabel.getAttribute('data-text-changed-template')!
.replace('%[1]d', String(prReview.numberOfViewedFiles))
.replace('%[2]d', String(prReview.numberOfFiles));
}
// Initializes a listener for all children of the given html element
// (for example 'document' in the most basic case)
// to watch for changes of viewed-file checkboxes
export function initViewedCheckboxListenerFor() {
for (const form of document.querySelectorAll(`${viewedCheckboxSelector}:not([data-has-viewed-checkbox-listener="true"])`)) {
// To prevent double addition of listeners
form.setAttribute('data-has-viewed-checkbox-listener', String(true));
// The checkbox consists of a div containing the real checkbox with its label,
// hence the actual checkbox first has to be found
const checkbox = form.querySelector<HTMLInputElement>('input[type=checkbox]')!;
checkbox.addEventListener('input', function() {
// Mark the file as viewed visually - will especially change the background
if (this.checked) {
form.classList.add(viewedStyleClass);
checkbox.setAttribute('checked', '');
prReview.numberOfViewedFiles++;
} else {
form.classList.remove(viewedStyleClass);
checkbox.removeAttribute('checked');
prReview.numberOfViewedFiles--;
}
// Update viewed-files summary and remove "has changed" label if present
refreshViewedFilesSummary();
const hasChangedLabel = form.parentNode!.querySelector('.changed-since-last-review');
hasChangedLabel?.remove();
const fileName = checkbox.getAttribute('name')!;
// check if the file is in our diffTreeStore and if we find it -> change the IsViewed status
diffTreeStoreSetViewed(diffTreeStore(), fileName, this.checked);
// Unfortunately, actual forms cause too many problems, hence another approach is needed
const files: Record<string, boolean> = {};
files[fileName] = this.checked;
const data: Record<string, any> = {files};
const headCommitSHA = form.getAttribute('data-headcommit');
if (headCommitSHA) data.headCommitSHA = headCommitSHA;
POST(form.getAttribute('data-link')!, {data});
// Fold the file accordingly
const parentBox = form.closest('.diff-file-header')!;
setFileFolding(parentBox.closest('.file-content')!, parentBox.querySelector('.fold-file')!, this.checked);
});
}
}
export function initExpandAndCollapseFilesButton() {
// expand btn
document.querySelector(expandFilesBtnSelector)?.addEventListener('click', () => {
for (const box of document.querySelectorAll<HTMLElement>('.file-content[data-folded="true"]')) {
setFileFolding(box, box.querySelector('.fold-file')!, false);
}
});
// collapse btn, need to exclude the div of “show more”
document.querySelector(collapseFilesBtnSelector)?.addEventListener('click', () => {
for (const box of document.querySelectorAll<HTMLElement>('.file-content:not([data-folded="true"])')) {
if (box.getAttribute('id') === 'diff-incomplete') continue;
setFileFolding(box, box.querySelector('.fold-file')!, true);
}
});
}
+21
View File
@@ -0,0 +1,21 @@
import {createApp} from 'vue';
export async function initRepoRecentCommits() {
const el = document.querySelector('#repo-recent-commits-chart');
if (!el) return;
const {default: RepoRecentCommits} = await import('../components/RepoRecentCommits.vue');
try {
const View = createApp(RepoRecentCommits, {
locale: {
loadingTitle: el.getAttribute('data-locale-loading-title'),
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
loadingInfo: el.getAttribute('data-locale-loading-info'),
},
});
View.mount(el);
} catch (err) {
console.error('RepoRecentCommits failed to load', err);
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
}
}
+75
View File
@@ -0,0 +1,75 @@
import {parseIssueHref} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
import {createApp} from 'vue';
import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts';
import {addDelegatedEventListener} from '../utils/dom.ts';
import type {Issue} from '../types.ts';
type IssueInfo = {
convertedIssue: Issue,
renderedLabels: string,
};
const issueInfoCache = new Map<string, IssueInfo>();
async function getIssueInfo(url: string): Promise<IssueInfo> {
if (issueInfoCache.has(url)) return issueInfoCache.get(url)!;
const resp = await GET(url);
if (!resp.ok) throw new Error(resp.statusText || 'Unknown network error');
const data = await resp.json();
issueInfoCache.set(url, data);
return data;
}
async function showRefIssuePopup(link: HTMLAnchorElement) {
const [data, {default: ContextPopup}] = await Promise.all([
getIssueInfo(`${link.pathname}/info`),
import('../components/ContextPopup.vue'),
]);
const el = document.createElement('div');
const app = createApp(ContextPopup, {
issue: data.convertedIssue,
renderedLabels: data.renderedLabels,
});
app.mount(el);
// suppress ancestor title like from .commit-summary to prevent double tooltip
link.title = '';
createTippy(link, {
theme: 'default',
content: el,
trigger: 'mouseenter focus',
placement: 'top-start',
interactive: true,
role: 'dialog',
interactiveBorder: 5,
onDestroy: () => app.unmount(),
}).show();
}
export function initRefIssueContextPopup() {
const selector = 'a[href]:not([data-ref-issue-popup]):not(.ref-external-issue)';
addDelegatedEventListener<HTMLAnchorElement, MouseEvent>(document, 'mouseover', selector, (link) => {
if (!parseIssueHref(link.getAttribute('href')!).ownerName) return;
if (!link.classList.contains('ref-issue') && !link.closest('[data-ref-issue-container]')) return;
if (getAttachedTippyInstance(link)) return;
link.setAttribute('data-ref-issue-popup', '');
// delay so a mouse passing over the link doesn't fire a fetch
let timer: ReturnType<typeof setTimeout>;
const cancel = () => {
clearTimeout(timer);
link.removeAttribute('data-ref-issue-popup');
link.removeEventListener('mouseleave', cancel);
};
timer = setTimeout(async () => {
link.removeEventListener('mouseleave', cancel);
try {
await showRefIssuePopup(link);
} catch (err) {
console.error('Failed to load issue info:', err);
link.removeAttribute('data-ref-issue-popup');
}
}, 300);
link.addEventListener('mouseleave', cancel);
});
}
+71
View File
@@ -0,0 +1,71 @@
import {createApp} from 'vue';
import RepoActionView from '../components/RepoActionView.vue';
export function initRepositoryActionView() {
const el = document.querySelector('#repo-action-view');
if (!el) return;
// TODO: the parent element's full height doesn't work well now,
// but we can not pollute the global style at the moment, only fix the height problem for pages with this component
const parentFullHeight = document.querySelector<HTMLElement>('body > div.full.height');
if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0');
const view = createApp(RepoActionView, {
jobId: parseInt(el.getAttribute('data-job-id')!),
actionsViewUrl: el.getAttribute('data-actions-view-url'),
locale: {
approve: el.getAttribute('data-locale-approve'),
cancel: el.getAttribute('data-locale-cancel'),
rerun: el.getAttribute('data-locale-rerun'),
rerun_all: el.getAttribute('data-locale-rerun-all'),
rerun_failed: el.getAttribute('data-locale-rerun-failed'),
latest: el.getAttribute('data-locale-latest'),
latestAttempt: el.getAttribute('data-locale-latest-attempt'),
attempt: el.getAttribute('data-locale-attempt'),
scheduled: el.getAttribute('data-locale-runs-scheduled'),
commit: el.getAttribute('data-locale-runs-commit'),
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
summary: el.getAttribute('data-locale-summary'),
allJobs: el.getAttribute('data-locale-all-jobs'),
expandCallerJobs: el.getAttribute('data-locale-expand-caller-jobs'),
collapseCallerJobs: el.getAttribute('data-locale-collapse-caller-jobs'),
triggeredVia: el.getAttribute('data-locale-triggered-via'),
totalDuration: el.getAttribute('data-locale-total-duration'),
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
showFullScreen: el.getAttribute('data-locale-show-full-screen'),
downloadLogs: el.getAttribute('data-locale-download-logs'),
copyOutput: el.getAttribute('data-locale-copy-output'),
status: {
unknown: el.getAttribute('data-locale-status-unknown'),
waiting: el.getAttribute('data-locale-status-waiting'),
running: el.getAttribute('data-locale-status-running'),
cancelling: el.getAttribute('data-locale-status-cancelling'),
success: el.getAttribute('data-locale-status-success'),
failure: el.getAttribute('data-locale-status-failure'),
cancelled: el.getAttribute('data-locale-status-cancelled'),
skipped: el.getAttribute('data-locale-status-skipped'),
blocked: el.getAttribute('data-locale-status-blocked'),
},
logsAlwaysAutoScroll: el.getAttribute('data-locale-logs-always-auto-scroll'),
logsAlwaysExpandRunning: el.getAttribute('data-locale-logs-always-expand-running'),
workflowFile: el.getAttribute('data-locale-workflow-file'),
runDetails: el.getAttribute('data-locale-run-details'),
workflowDependencies: el.getAttribute('data-locale-workflow-dependencies'),
graphJobsCount1: el.getAttribute('data-locale-graph-jobs-count-1'),
graphJobsCountN: el.getAttribute('data-locale-graph-jobs-count-n'),
graphDependenciesCount1: el.getAttribute('data-locale-graph-dependencies-count-1'),
graphDependenciesCountN: el.getAttribute('data-locale-graph-dependencies-count-n'),
graphSuccessRate: el.getAttribute('data-locale-graph-success-rate'),
graphZoomIn: el.getAttribute('data-locale-graph-zoom-in'),
graphZoomMax: el.getAttribute('data-locale-graph-zoom-max'),
graphZoomOut: el.getAttribute('data-locale-graph-zoom-out'),
graphResetView: el.getAttribute('data-locale-graph-reset-view'),
},
});
view.mount(el);
}
+42
View File
@@ -0,0 +1,42 @@
import {toggleElem} from '../utils/dom.ts';
import {showFomanticModal} from '../modules/fomantic/modal.ts';
export function initRepoBranchButton() {
initRepoCreateBranchButton();
initRepoRenameBranchButton();
}
function initRepoCreateBranchButton() {
// 2 pages share this code, one is the branch list page, the other is the commit view page: create branch/tag from current commit (dirty code)
for (const el of document.querySelectorAll('.show-create-branch-modal')) {
el.addEventListener('click', () => {
const modalFormName = el.getAttribute('data-modal-form') || '#create-branch-form';
const modalForm = document.querySelector<HTMLFormElement>(modalFormName);
if (!modalForm) return;
modalForm.action = `${modalForm.getAttribute('data-base-action')}${el.getAttribute('data-branch-from-urlcomponent')}`;
const fromSpanName = el.getAttribute('data-modal-from-span') || '#modal-create-branch-from-span';
document.querySelector(fromSpanName)!.textContent = el.getAttribute('data-branch-from');
showFomanticModal(document.querySelector(el.getAttribute('data-modal')!));
});
}
}
function initRepoRenameBranchButton() {
for (const el of document.querySelectorAll('.show-rename-branch-modal')) {
el.addEventListener('click', () => {
const target = el.getAttribute('data-modal')!;
const modal = document.querySelector(target)!;
const oldBranchName = el.getAttribute('data-old-branch-name')!;
modal.querySelector<HTMLInputElement>('input[name=from]')!.value = oldBranchName;
// display the warning that the branch which is chosen is the default branch
const warn = modal.querySelector('.default-branch-warning')!;
toggleElem(warn, el.getAttribute('data-is-default-branch') === 'true');
const text = modal.querySelector('[data-rename-branch-to]')!;
text.textContent = text.getAttribute('data-rename-branch-to')!.replace('%s', oldBranchName);
});
}
}
+147
View File
@@ -0,0 +1,147 @@
import {svg} from '../svg.ts';
import {createTippy} from '../modules/tippy.ts';
import {toAbsoluteUrl} from '../utils.ts';
import {addDelegatedEventListener} from '../utils/dom.ts';
function changeHash(hash: string) {
if (window.history.pushState) {
window.history.pushState(null, '', hash);
} else {
window.location.hash = hash;
}
}
// it selects the code lines defined by range: `L1-L3` (3 lines) or `L2` (singe line)
function selectRange(range: string): Element | null {
for (const el of document.querySelectorAll('.code-view tr.active')) el.classList.remove('active');
const elLineNums = document.querySelectorAll(`.code-view td.lines-num span[data-line-number]`);
const refInNewIssue = document.querySelector('a.ref-in-new-issue');
const copyPermalink = document.querySelector('a.copy-line-permalink');
const viewGitBlame = document.querySelector('a.view_git_blame');
const updateIssueHref = function (anchor: string) {
if (!refInNewIssue) return;
const urlIssueNew = refInNewIssue.getAttribute('data-url-issue-new');
const urlParamBodyLink = refInNewIssue.getAttribute('data-url-param-body-link')!;
const issueContent = `${toAbsoluteUrl(urlParamBodyLink)}#${anchor}`; // the default content for issue body
refInNewIssue.setAttribute('href', `${urlIssueNew}?body=${encodeURIComponent(issueContent)}`);
};
const updateViewGitBlameFragment = function (anchor: string) {
if (!viewGitBlame) return;
let href = viewGitBlame.getAttribute('href')!;
href = `${href.replace(/#L\d+$|#L\d+-L\d+$/, '')}`;
if (anchor.length !== 0) {
href = `${href}#${anchor}`;
}
viewGitBlame.setAttribute('href', href);
};
const updateCopyPermalinkUrl = function (anchor: string) {
if (!copyPermalink) return;
let link = copyPermalink.getAttribute('data-url')!;
link = `${window.location.origin}${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
copyPermalink.setAttribute('data-clipboard-text', link);
};
const rangeFields = range ? range.split('-') : [];
const start = rangeFields[0] ?? '';
if (!start) return null;
const stop = rangeFields[1] || start;
// format is i.e. 'L14-L26'
let startLineNum = parseInt(start.substring(1));
let stopLineNum = parseInt(stop.substring(1));
if (startLineNum > stopLineNum) {
const tmp = startLineNum;
startLineNum = stopLineNum;
stopLineNum = tmp;
range = `${stop}-${start}`;
}
const first = elLineNums[startLineNum - 1] ?? null;
for (let i = startLineNum - 1; i <= stopLineNum - 1 && i < elLineNums.length; i++) {
elLineNums[i].closest('tr')!.classList.add('active');
}
changeHash(`#${range}`);
updateIssueHref(range);
updateViewGitBlameFragment(range);
updateCopyPermalinkUrl(range);
return first;
}
function showLineButton() {
const menu = document.querySelector('.code-line-menu');
if (!menu) return;
// remove all other line buttons
for (const el of document.querySelectorAll('.code-line-button')) {
el.remove();
}
// find active row and add button
const tr = document.querySelector('.code-view tr.active');
if (!tr) return;
const td = tr.querySelector('td.lines-num')!;
const btn = document.createElement('button');
btn.classList.add('code-line-button', 'ui', 'basic', 'button');
btn.innerHTML = svg('octicon-kebab-horizontal');
td.prepend(btn);
// put a copy of the menu back into DOM for the next click
btn.closest('.code-view')!.append(menu.cloneNode(true));
createTippy(btn, {
theme: 'menu',
trigger: 'click',
hideOnClick: true,
content: menu,
placement: 'right-start',
interactive: true,
onShow: (tippy) => {
tippy.popper.addEventListener('click', () => {
tippy.hide();
}, {once: true});
},
});
}
export function initRepoCodeView() {
// When viewing a file or blame, there is always a ".file-view" element,
// but the ".code-view" class is only present when viewing the "code" of a file; it is not present when viewing a PDF file.
// Since the ".file-view" will be dynamically reloaded when navigating via the left file tree (eg: view a PDF file, then view a source code file, etc.)
// the "code-view" related event listeners should always be added when the current page contains ".file-view" element.
if (!document.querySelector('.repo-view-container .file-view')) return;
// "file code view" and "blame" pages need this "line number button" feature
let selRangeStart: string | undefined;
addDelegatedEventListener(document, 'click', '.code-view .lines-num span', (el: HTMLElement, e: KeyboardEvent) => {
if (!selRangeStart || !e.shiftKey) {
selRangeStart = el.getAttribute('id')!;
selectRange(selRangeStart);
} else {
const selRangeStop = el.getAttribute('id');
selectRange(`${selRangeStart}-${selRangeStop}`);
}
window.getSelection()!.removeAllRanges();
showLineButton();
});
// apply the selected range from the URL hash
const onHashChange = () => {
if (!window.location.hash) return;
if (!document.querySelector('.code-view .lines-num')) return;
const range = window.location.hash.substring(1);
const first = selectRange(range);
if (first) {
// set scrollRestoration to 'manual' when there is a hash in the URL, so that the scroll position will not be remembered after refreshing
if (window.history.scrollRestoration !== 'manual') window.history.scrollRestoration = 'manual';
first.scrollIntoView({block: 'start'});
showLineButton();
}
};
onHashChange();
window.addEventListener('hashchange', onHashChange);
}
+26
View File
@@ -0,0 +1,26 @@
import {createTippy} from '../modules/tippy.ts';
import {toggleElem} from '../utils/dom.ts';
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
export function initRepoEllipsisButton() {
registerGlobalEventFunc('click', 'onRepoEllipsisButtonClick', async (el: HTMLInputElement, e: Event) => {
e.preventDefault();
const expanded = el.getAttribute('aria-expanded') === 'true';
toggleElem(el.parentElement!.querySelector('.commit-body')!);
el.setAttribute('aria-expanded', String(!expanded));
});
}
export function initCommitStatuses() {
registerGlobalInitFunc('initCommitStatuses', (el: HTMLElement) => {
const nextEl = el.nextElementSibling!;
if (!nextEl.matches('.tippy-target')) throw new Error('Expected next element to be a tippy target');
createTippy(el, {
content: nextEl,
placement: 'bottom-start',
interactive: true,
role: 'dialog',
theme: 'box-with-header',
});
});
}
+22
View File
@@ -0,0 +1,22 @@
import {sanitizeRepoName, substituteRepoOpenWithUrl} from './repo-common.ts';
test('substituteRepoOpenWithUrl', () => {
// For example: "x-github-client://openRepo/https://github.com/go-gitea/gitea"
expect(substituteRepoOpenWithUrl('proto://a/{url}', 'https://gitea')).toEqual('proto://a/https://gitea');
expect(substituteRepoOpenWithUrl('proto://a?link={url}', 'https://gitea')).toEqual('proto://a?link=https%3A%2F%2Fgitea');
});
test('sanitizeRepoName', () => {
expect(sanitizeRepoName(' a b ')).toEqual('a-b');
expect(sanitizeRepoName('a-b_c.git ')).toEqual('a-b_c');
expect(sanitizeRepoName('/x.git/')).toEqual('-x.git-');
expect(sanitizeRepoName('.profile')).toEqual('.profile');
expect(sanitizeRepoName('.profile.')).toEqual('.profile');
expect(sanitizeRepoName('.pro..file')).toEqual('.pro.file');
expect(sanitizeRepoName('foo.rss.atom.git.wiki')).toEqual('foo');
expect(sanitizeRepoName('.')).toEqual('');
expect(sanitizeRepoName('..')).toEqual('');
expect(sanitizeRepoName('-')).toEqual('');
});
+179
View File
@@ -0,0 +1,179 @@
import {queryElems} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {sleep} from '../utils.ts';
import RepoActivityTopAuthors from '../components/RepoActivityTopAuthors.vue';
import {createApp} from 'vue';
import {createTippy} from '../modules/tippy.ts';
import {localUserSettings} from '../modules/user-settings.ts';
async function onDownloadArchive(e: Event) {
e.preventDefault();
// there are many places using the "archive-link", eg: the dropdown on the repo code page, the release list
const el = (e.target as HTMLElement).closest<HTMLAnchorElement>('a.archive-link[href]')!;
const targetLoading = el.closest('.ui.dropdown') ?? el;
targetLoading.classList.add('is-loading', 'loading-icon-2px');
try {
for (let tryCount = 0; ;tryCount++) {
const response = await POST(el.href);
if (!response.ok) throw new Error(`Invalid server response: ${response.status}`);
const data = await response.json();
if (data.complete) break;
await sleep(Math.min((tryCount + 1) * 750, 2000));
}
window.location.href = el.href; // the archive is ready, start real downloading
} catch (e) {
console.error(e);
showErrorToast(`Failed to download the archive: ${errorMessage(e)}`, {duration: 2500});
} finally {
targetLoading.classList.remove('is-loading', 'loading-icon-2px');
}
}
export function initRepoArchiveLinks() {
queryElems(document, 'a.archive-link[href]', (el) => el.addEventListener('click', onDownloadArchive));
}
export function initRepoActivityTopAuthorsChart() {
const el = document.querySelector('#repo-activity-top-authors-chart');
if (el) {
createApp(RepoActivityTopAuthors).mount(el);
}
}
export function substituteRepoOpenWithUrl(tmpl: string, url: string): string {
const pos = tmpl.indexOf('{url}');
if (pos === -1) return tmpl;
const posQuestionMark = tmpl.indexOf('?');
const needEncode = posQuestionMark >= 0 && posQuestionMark < pos;
return tmpl.replace('{url}', needEncode ? encodeURIComponent(url) : url);
}
function initCloneSchemeUrlSelection(parent: Element) {
const elCloneUrlInput = parent.querySelector<HTMLInputElement>('.repo-clone-url')!;
const tabHttps = parent.querySelector('.repo-clone-https');
const tabSsh = parent.querySelector('.repo-clone-ssh');
const tabTea = parent.querySelector('.repo-clone-tea');
const updateClonePanelUi = function() {
let scheme = localUserSettings.getString('repo-clone-protocol');
if (!['https', 'ssh', 'tea'].includes(scheme)) {
scheme = 'https';
}
// Fallbacks if the scheme preference is not available in the tabs, for example: empty repo page, there are only HTTPS and SSH
if (scheme === 'tea' && !tabTea) {
scheme = 'https';
}
if (scheme === 'https' && !tabHttps) {
scheme = 'ssh';
} else if (scheme === 'ssh' && !tabSsh) {
scheme = 'https';
}
const isHttps = scheme === 'https';
const isSsh = scheme === 'ssh';
const isTea = scheme === 'tea';
if (tabHttps) {
const link = tabHttps.getAttribute('data-link')!;
tabHttps.textContent = link.split(':')[0].toUpperCase(); // show "HTTP" or "HTTPS"
tabHttps.classList.toggle('active', isHttps);
}
if (tabSsh) {
tabSsh.classList.toggle('active', isSsh);
}
if (tabTea) {
tabTea.classList.toggle('active', isTea);
}
let tab: Element | null = null;
if (isHttps) {
tab = tabHttps;
} else if (isSsh) {
tab = tabSsh;
} else if (isTea) {
tab = tabTea;
}
if (!tab) return;
const link = tab.getAttribute('data-link')!;
for (const el of document.querySelectorAll('.js-clone-url')) {
if (el.nodeName === 'INPUT') {
(el as HTMLInputElement).value = link;
} else {
el.textContent = link;
}
}
for (const el of parent.querySelectorAll<HTMLAnchorElement>('.js-clone-url-editor')) {
el.href = substituteRepoOpenWithUrl(el.getAttribute('data-href-template')!, link);
}
};
updateClonePanelUi();
// tabSsh or tabHttps might not both exist, eg: guest view, or one is disabled by the server
tabHttps?.addEventListener('click', () => {
localUserSettings.setString('repo-clone-protocol', 'https');
updateClonePanelUi();
});
tabSsh?.addEventListener('click', () => {
localUserSettings.setString('repo-clone-protocol', 'ssh');
updateClonePanelUi();
});
tabTea?.addEventListener('click', () => {
localUserSettings.setString('repo-clone-protocol', 'tea');
updateClonePanelUi();
});
elCloneUrlInput.addEventListener('focus', () => {
elCloneUrlInput.select();
});
}
function initClonePanelButton(btn: HTMLButtonElement) {
const elPanel = btn.nextElementSibling!;
// "init" must be before the "createTippy" otherwise the "tippy-target" will be removed from the document
initCloneSchemeUrlSelection(elPanel);
createTippy(btn, {
content: elPanel,
trigger: 'click',
placement: 'bottom-end',
interactive: true,
hideOnClick: true,
arrow: false,
});
}
export function initRepoCloneButtons() {
queryElems(document, '.js-btn-clone-panel', initClonePanelButton);
queryElems(document, '.clone-buttons-combo', initCloneSchemeUrlSelection);
}
export async function updateIssuesMeta(url: string, action: string, issue_ids: string, id: string) {
try {
const response = await POST(url, {data: new URLSearchParams({action, issue_ids, id})});
if (!response.ok) {
throw new Error('Failed to update issues meta');
}
} catch (error) {
console.error(error);
}
}
export function sanitizeRepoName(name: string): string {
name = name.trim().replace(/[^-.\w]/g, '-');
for (let lastName = ''; lastName !== name;) {
lastName = name;
name = name.replace(/\.+$/g, '');
name = name.replace(/\.{2,}/g, '.');
for (const ext of ['.git', '.wiki', '.rss', '.atom']) {
if (name.endsWith(ext)) {
name = name.substring(0, name.length - ext.length);
}
}
}
if (['.', '..', '-'].includes(name)) name = '';
return name;
}
+53
View File
@@ -0,0 +1,53 @@
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
async function loadBranchesAndTags(area: Element, loadingButton: Element) {
loadingButton.classList.add('disabled');
try {
const res = await GET(loadingButton.getAttribute('data-url')!);
const data = await res.json();
hideElem(loadingButton);
addTags(area, data.tags);
addBranches(area, data.branches, data.default_branch);
showElem(area.querySelectorAll('.branch-and-tag-detail'));
} finally {
loadingButton.classList.remove('disabled');
}
}
function addTags(area: Element, tags: Array<Record<string, any>>) {
const tagArea = area.querySelector('.tag-area')!;
toggleElem(tagArea.parentElement!, tags.length > 0);
for (const tag of tags) {
addLink(tagArea, tag.web_link, tag.name);
}
}
function addBranches(area: Element, branches: Array<Record<string, any>>, defaultBranch: string) {
const defaultBranchTooltip = area.getAttribute('data-text-default-branch-tooltip');
const branchArea = area.querySelector('.branch-area')!;
toggleElem(branchArea.parentElement!, branches.length > 0);
for (const branch of branches) {
const tooltip = defaultBranch === branch.name ? defaultBranchTooltip : null;
addLink(branchArea, branch.web_link, branch.name, tooltip);
}
}
function addLink(parent: Element, href: string, text: string, tooltip: string | null = null) {
const link = document.createElement('a');
link.classList.add('muted', 'tw-px-1');
link.href = href;
link.textContent = text;
if (tooltip) {
link.classList.add('tw-border', 'tw-border-secondary', 'tw-rounded');
link.setAttribute('data-tooltip-content', tooltip);
}
parent.append(link);
}
export function initRepoDiffCommitBranchesAndTags() {
for (const area of document.querySelectorAll('.branch-and-tag-area')) {
const btn = area.querySelector('.load-branches-and-tags')!;
btn.addEventListener('click', () => loadBranchesAndTags(area, btn));
}
}
@@ -0,0 +1,10 @@
import {createApp} from 'vue';
import DiffCommitSelector from '../components/DiffCommitSelector.vue';
export function initDiffCommitSelect() {
const el = document.querySelector('#diff-commit-select');
if (!el) return;
const commitSelect = createApp(DiffCommitSelector);
commitSelect.mount(el);
}
+10
View File
@@ -0,0 +1,10 @@
import {createApp} from 'vue';
import DiffFileTree from '../components/DiffFileTree.vue';
export function initDiffFileTree() {
const el = document.querySelector('#diff-file-tree');
if (!el) return;
const fileTreeView = createApp(DiffFileTree);
fileTreeView.mount(el);
}
+294
View File
@@ -0,0 +1,294 @@
import {initRepoIssueContentHistory} from './repo-issue-content.ts';
import {initDiffFileTree} from './repo-diff-filetree.ts';
import {initDiffCommitSelect} from './repo-diff-commitselect.ts';
import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.ts';
import {initViewedCheckboxListenerFor, initExpandAndCollapseFilesButton} from './pull-view-file.ts';
import {initImageDiff} from './imagediff.ts';
import {showErrorToast} from '../modules/toast.ts';
import {queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {POST, GET} from '../modules/fetch.ts';
import {createTippy} from '../modules/tippy.ts';
import {invertFileFolding} from './file-fold.ts';
import {parseDom} from '../utils.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {performFetchActionTrigger} from './common-fetch-action.ts';
function initRepoDiffFileBox(el: HTMLElement) {
// switch between "rendered" and "source", for image and CSV files
queryElems(el, '.file-view-toggle', (btn) => btn.addEventListener('click', () => {
queryElemSiblings(btn, '.file-view-toggle', (el) => el.classList.remove('active'));
btn.classList.add('active');
const target = document.querySelector(btn.getAttribute('data-toggle-selector')!);
if (!target) throw new Error('Target element not found');
hideElem(queryElemSiblings(target));
showElem(target);
}));
}
function initRepoDiffConversationForm() {
// FIXME: there could be various different form in a conversation-holder (for example: reply form, edit form).
// This listener is for "reply form" only, it should clearly distinguish different forms in the future.
addDelegatedEventListener<HTMLFormElement, SubmitEvent>(document, 'submit', '.conversation-holder form', async (form, e) => {
e.preventDefault();
const textArea = form.querySelector<HTMLTextAreaElement>('textarea')!;
if (!validateTextareaNonEmpty(textArea)) return;
if (form.classList.contains('is-loading')) return;
try {
form.classList.add('is-loading');
const formData = new FormData(form);
// if the form is submitted by a button, append the button's name and value to the form data
const submitter = e.submitter;
const isSubmittedByButton = submitter instanceof HTMLButtonElement || (submitter instanceof HTMLInputElement && submitter.type === 'submit');
if (isSubmittedByButton && submitter.name) {
formData.append(submitter.name, submitter.value);
}
// on the diff page, the form is inside a "tr" and need to get the line-type ahead
// but on the conversation page, there is no parent "tr"
const trLineType = form.closest('tr')?.getAttribute('data-line-type');
const response = await POST(form.getAttribute('action')!, {data: formData});
const newConversationHolder = createElementFromHTML(await response.text());
const path = newConversationHolder.getAttribute('data-path');
const side = newConversationHolder.getAttribute('data-side');
const idx = newConversationHolder.getAttribute('data-idx');
form.closest('.conversation-holder')!.replaceWith(newConversationHolder);
(form as any) = null; // prevent further usage of the form because it should have been replaced
if (trLineType) {
// if there is a line-type for the "tr", it means the form is on the diff page
// then hide the "add-code-comment" [+] button for current code line by adding "tw-invisible" because the conversation has been added
let selector;
if (trLineType === 'same') {
selector = `[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`;
} else {
selector = `[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`;
}
for (const el of document.querySelectorAll(selector)) {
el.classList.add('tw-invisible');
}
}
// the default behavior is to add a pending review, so if no submitter, it also means "pending_review"
if (!submitter || submitter?.matches('button[name="pending_review"]')) {
const reviewBox = document.querySelector('#review-box')!;
const counter = reviewBox?.querySelector('.review-comments-counter');
if (!counter) return;
const num = parseInt(counter.getAttribute('data-pending-comment-number')!) + 1 || 1;
counter.setAttribute('data-pending-comment-number', String(num));
counter.textContent = String(num);
animateOnce(reviewBox, 'pulse-1p5-200');
}
} catch (error) {
console.error('Error:', error);
showErrorToast(`Submit form failed: ${errorMessage(error)}`);
} finally {
form?.classList.remove('is-loading');
}
});
addDelegatedEventListener(document, 'click', '.resolve-conversation', async (el, e) => {
e.preventDefault();
const comment_id = el.getAttribute('data-comment-id')!;
const origin = el.getAttribute('data-origin')!;
const action = el.getAttribute('data-action')!;
const url = el.getAttribute('data-update-url')!;
try {
const response = await POST(url, {data: new URLSearchParams({origin, action, comment_id})});
const data = await response.text();
const elConversationHolder = el.closest('.conversation-holder');
if (elConversationHolder) {
const elNewConversation = createElementFromHTML(data);
elConversationHolder.replaceWith(elNewConversation);
} else {
window.location.reload();
}
} catch (error) {
console.error('Error:', error);
}
});
}
function initRepoDiffConversationNav() {
// Previous/Next code review conversation
addDelegatedEventListener(document, 'click', '.previous-conversation, .next-conversation', (el, e) => {
e.preventDefault();
const isPrevious = el.matches('.previous-conversation');
const elCurConversation = el.closest('.comment-code-cloud')!;
const elAllConversations = document.querySelectorAll('.comment-code-cloud:not(.tw-hidden)');
const index = Array.from(elAllConversations).indexOf(elCurConversation);
const previousIndex = index > 0 ? index - 1 : elAllConversations.length - 1;
const nextIndex = index < elAllConversations.length - 1 ? index + 1 : 0;
const navIndex = isPrevious ? previousIndex : nextIndex;
const elNavConversation = elAllConversations[navIndex];
const anchor = elNavConversation.querySelector('.comment')!.id;
window.location.href = `#${anchor}`;
});
}
function initDiffHeaderPopup() {
for (const btn of document.querySelectorAll('.diff-header-popup-btn:not([data-header-popup-initialized])')) {
btn.setAttribute('data-header-popup-initialized', '');
const popup = btn.nextElementSibling;
if (!popup?.matches('.tippy-target')) throw new Error('Popup element not found');
createTippy(btn, {
content: popup,
theme: 'menu',
placement: 'bottom-end',
trigger: 'click',
interactive: true,
hideOnClick: true,
});
}
}
// Will be called when the show more (files) button has been pressed
function onShowMoreFiles() {
// TODO: replace these calls with the "observer.ts" methods
initRepoIssueContentHistory();
initViewedCheckboxListenerFor();
initImageDiff();
initDiffHeaderPopup();
}
async function loadMoreFiles(btn: Element): Promise<boolean> {
if (btn.classList.contains('disabled')) {
return false;
}
btn.classList.add('disabled');
const url = btn.getAttribute('data-href')!;
try {
const response = await GET(url);
const resp = await response.text();
const respDoc = parseDom(resp, 'text/html');
const respFileBoxes = respDoc.querySelector('#diff-file-boxes')!;
// the response is a full HTML page, we need to extract the relevant contents:
// * append the newly loaded file list items to the existing list
const respFileBoxesChildren = Array.from(respFileBoxes.children); // "children:HTMLCollection" will be empty after replaceWith
document.querySelector('#diff-incomplete')!.replaceWith(...respFileBoxesChildren);
onShowMoreFiles();
return true;
} catch (error) {
console.error('Error:', error);
showErrorToast('An error occurred while loading more files.');
} finally {
btn.classList.remove('disabled');
}
return false;
}
function initRepoDiffShowMore() {
addDelegatedEventListener(document, 'click', 'a#diff-show-more-files', (el, e) => {
e.preventDefault();
loadMoreFiles(el);
});
addDelegatedEventListener(document, 'click', 'a.diff-load-button', async (el, e) => {
e.preventDefault();
if (el.classList.contains('disabled')) return;
el.classList.add('disabled');
const url = el.getAttribute('data-href')!;
try {
const response = await GET(url);
const resp = await response.text();
const respDoc = parseDom(resp, 'text/html');
const respFileBody = respDoc.querySelector('#diff-file-boxes .diff-file-body .file-body')!;
const respFileBodyChildren = Array.from(respFileBody.children); // "children:HTMLCollection" will be empty after replaceWith
el.parentElement!.replaceWith(...respFileBodyChildren);
// FIXME: calling onShowMoreFiles is not quite right here.
// But since onShowMoreFiles mixes "init diff box" and "init diff body" together,
// so it still needs to call it to make the "ImageDiff" and something similar work.
onShowMoreFiles();
} catch (error) {
console.error('Error:', error);
} finally {
el.classList.remove('disabled');
}
});
}
async function onLocationHashChange() {
// try to scroll to the target element by the current hash
const currentHash = window.location.hash;
if (!currentHash.startsWith('#diff-') && !currentHash.startsWith('#issuecomment-')) return;
// avoid reentrance when we are changing the hash to scroll and trigger ":target" selection
const attrAutoScrollRunning = 'data-auto-scroll-running';
if (document.body.hasAttribute(attrAutoScrollRunning)) return;
const targetElementId = currentHash.substring(1);
while (currentHash === window.location.hash) {
// use getElementById to avoid querySelector throws an error when the hash is invalid
// eslint-disable-next-line unicorn/prefer-query-selector
const targetElement = document.getElementById(targetElementId);
if (targetElement) {
// need to change hash to re-trigger ":target" CSS selector, let's manually scroll to it
targetElement.scrollIntoView();
document.body.setAttribute(attrAutoScrollRunning, 'true');
window.location.hash = '';
window.location.hash = currentHash;
setTimeout(() => document.body.removeAttribute(attrAutoScrollRunning), 0);
return;
}
// If looking for a hidden comment, try to expand the section that contains it
const issueCommentPrefix = '#issuecomment-';
if (currentHash.startsWith(issueCommentPrefix)) {
const commentId = currentHash.substring(issueCommentPrefix.length);
const expandButton = document.querySelector<HTMLElement>(`.code-expander-button[data-hidden-comment-ids*=",${commentId},"]`);
if (expandButton) {
// avoid infinite loop, do not re-click the button if already clicked
const attrAutoLoadClicked = 'data-auto-load-clicked';
if (expandButton.hasAttribute(attrAutoLoadClicked)) return;
expandButton.setAttribute(attrAutoLoadClicked, 'true');
// trigger the fetch action to load the hidden comments, after loading, it will try to find the target element again
await performFetchActionTrigger(expandButton, 'load');
continue; // Try again to find the element
}
}
// the button will be refreshed after each "load more", so query it every time
const showMoreButton = document.querySelector('#diff-show-more-files');
if (!showMoreButton) {
return; // nothing more to load
}
// Load more files, await ensures we don't block progress
const ok = await loadMoreFiles(showMoreButton);
if (!ok) return; // failed to load more files
}
}
function initRepoDiffHashChangeListener() {
window.addEventListener('hashchange', onLocationHashChange);
onLocationHashChange();
}
export function initRepoDiffView() {
initRepoDiffConversationForm(); // such form appears on the "conversation" page and "diff" page
if (!document.querySelector('#diff-file-boxes')) return;
initRepoDiffConversationNav(); // "previous" and "next" buttons only appear on "diff" page
initDiffFileTree();
initDiffCommitSelect();
initRepoDiffShowMore();
initDiffHeaderPopup();
initViewedCheckboxListenerFor();
initExpandAndCollapseFilesButton();
initRepoDiffHashChangeListener();
registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
addDelegatedEventListener(document, 'click', '.fold-file', (el) => {
invertFileFolding(el.closest('.file-content')!, el);
});
}
+241
View File
@@ -0,0 +1,241 @@
import {html, htmlRaw} from '../utils/html.ts';
import {createCodeEditor} from '../modules/codeeditor/main.ts';
import {trimTrailingWhitespaceFromView} from '../modules/codeeditor/utils.ts';
import {hideElem, queryElems, showElem, createElementFromHTML, onInputDebounce} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {initDropzone} from './dropzone.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {submitFormFetchAction} from './common-fetch-action.ts';
import {dirname} from '../utils.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {showErrorToast} from '../modules/toast.ts';
function initEditPreviewTab(elForm: HTMLFormElement) {
const elTabMenu = elForm.querySelector('.repo-editor-menu');
if (!elTabMenu) return;
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path');
const elTextarea = elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea');
if (!elTreePath || !elTextarea) return;
const repoLink = elTabMenu.getAttribute('data-repo-link')!;
const refSubUrl = elTabMenu.getAttribute('data-ref-sub-url')!;
const branchName = elTabMenu.getAttribute('data-branch-name')!;
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]')!;
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]')!;
elPreviewTab.addEventListener('click', async () => {
// "preview context" is the request path directory of the file, the rendered links will be resolved based on this path
// TODO: MARKUP-RENDER-CONTEXT: due to various hacky patches, this logic is unnecessarily complicated, see the backend
const previewContext = dirname(`${repoLink}/src/${refSubUrl}/${pathEscapeSegments(elTreePath.value)}`);
const formData = new FormData();
formData.append('mode', 'file');
formData.append('context', previewContext);
formData.append('text', elTextarea.value);
formData.append('file_path', elTreePath.value);
const resp = await POST(`${repoLink}/markup`, {data: formData});
if (!resp.ok) {
showErrorToast(`Failed to render preview: ${resp.status} ${resp.statusText}`);
return;
}
const data = await resp.text();
renderPreviewPanelContent(elPreviewPanel, data);
});
const elDiffTab = elTabMenu.querySelector('a[data-tab="diff"]');
const elDiffPanel = elForm.querySelector('.tab[data-tab="diff"]');
if (elDiffTab && elDiffPanel) {
// the "diff" tab only exists for an existing file, but not for a new file
elDiffTab.addEventListener('click', async () => {
const diffUrl = `${repoLink}/_preview/${pathEscapeSegments(branchName)}/${pathEscapeSegments(elTreePath.value)}`;
// don't use FormData, because FormData sends "\r\n" line endings, backend assumes "\n" line endings
const resp = await POST(diffUrl, {data: new URLSearchParams({content: elTextarea.value})});
if (!resp.ok) {
showErrorToast(`Failed to render diff: ${resp.status} ${resp.statusText}`);
return;
}
elDiffPanel.innerHTML = await resp.text();
});
}
}
export function initRepoEditor() {
const dropzoneUpload = document.querySelector<HTMLElement>('.page-content.repository.editor.upload .dropzone');
if (dropzoneUpload) initDropzone(dropzoneUpload);
for (const el of queryElems<HTMLInputElement>(document, '.js-quick-pull-choice-option')) {
el.addEventListener('input', () => {
if (el.value === 'commit-to-new-branch') {
showElem('.quick-pull-branch-name');
document.querySelector<HTMLInputElement>('.quick-pull-branch-name input')!.required = true;
} else {
hideElem('.quick-pull-branch-name');
document.querySelector<HTMLInputElement>('.quick-pull-branch-name input')!.required = false;
}
document.querySelector('#commit-button')!.textContent = el.getAttribute('data-button-text');
});
}
// ATTENTION: two pages have this filename input
// * new/edit file page: there is a code editor
// * upload page: there is no code editor, but a uploader
// FIXME: the related logic is totally a mess, need to completely rewrite, that's also the root reason for
// why the "migrate to CodeMirror" PR took very long time on the legacy code and introduced "#file-name (filenameInput)" regressions many times
const filenameInput = document.querySelector<HTMLInputElement>('#file-name')!;
if (!filenameInput) return;
filenameInput.value = filenameInput.defaultValue; // prevent browser from restoring form values on refresh
function joinTreePath() {
const parts = [];
for (const el of document.querySelectorAll('.breadcrumb span.section')) {
const link = el.querySelector('a');
parts.push(link ? link.textContent : el.textContent);
}
if (filenameInput.value) {
parts.push(filenameInput.value);
}
document.querySelector<HTMLInputElement>('#tree_path')!.value = parts.join('/');
}
filenameInput.addEventListener('input', function () {
const parts = filenameInput.value.split('/');
const links = Array.from(document.querySelectorAll('.breadcrumb span.section'));
const dividers = Array.from(document.querySelectorAll('.breadcrumb .breadcrumb-divider'));
let warningDiv = document.querySelector<HTMLDivElement>('.ui.warning.message.flash-message.flash-warning.space-related');
let containSpace = false;
if (parts.length > 1) {
for (let i = 0; i < parts.length; ++i) {
const value = parts[i];
const trimValue = value.trim();
if (trimValue === '..') {
// remove previous tree path
if (links.length > 0) {
const link = links.pop()!;
const divider = dividers.pop()!;
link.remove();
divider.remove();
}
continue;
}
if (i < parts.length - 1) {
if (trimValue.length) {
const linkElement = createElementFromHTML(
html`<span class="section"><a href="#">${value}</a></span>`,
);
const dividerElement = createElementFromHTML(
html`<div class="breadcrumb-divider">/</div>`,
);
links.push(linkElement);
dividers.push(dividerElement);
filenameInput.before(linkElement);
filenameInput.before(dividerElement);
}
} else {
filenameInput.value = value;
}
this.setSelectionRange(0, 0);
containSpace = containSpace || (trimValue !== value && trimValue !== '');
}
}
containSpace = containSpace || Array.from(links).some((link) => {
const value = link.querySelector('a')!.textContent;
return value.trim() !== value;
});
containSpace = containSpace || parts[parts.length - 1].trim() !== parts[parts.length - 1];
if (containSpace) {
if (!warningDiv) {
warningDiv = document.createElement('div');
warningDiv.classList.add('ui', 'warning', 'message', 'flash-message', 'flash-warning', 'space-related');
warningDiv.innerHTML = html`<p>File path contains leading or trailing whitespace.</p>`;
// Change to `block` display because it is set to 'none' in fomantic/build/semantic.css
warningDiv.classList.add('tw-block');
const inputContainer = document.querySelector('.repo-editor-header')!;
inputContainer.insertAdjacentElement('beforebegin', warningDiv);
}
showElem(warningDiv);
} else if (warningDiv) {
hideElem(warningDiv);
}
joinTreePath();
});
filenameInput.addEventListener('keydown', function (e) {
const sections = queryElems(document, '.breadcrumb span.section');
const dividers = queryElems(document, '.breadcrumb .breadcrumb-divider');
// Jump back to last directory once the filename is empty
if (e.code === 'Backspace' && filenameInput.selectionStart === 0 && sections.length > 0) {
e.preventDefault();
const lastSection = sections[sections.length - 1];
const lastDivider = dividers.length ? dividers[dividers.length - 1] : null;
const value = lastSection.querySelector('a')!.textContent;
filenameInput.value = value + filenameInput.value;
this.setSelectionRange(value.length, value.length);
lastDivider?.remove();
lastSection.remove();
joinTreePath();
}
});
const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form')!;
// see the ATTENTION above, on the upload page, there is no editor(textarea)
// so only the filename input above is initialized, the code below (for the code editor) will be skipped
const editArea = document.querySelector<HTMLTextAreaElement>('.page-content.repository.editor textarea#edit_area');
if (!editArea) return;
// Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage
// to enable or disable the commit button
const commitButton = document.querySelector<HTMLButtonElement>('#commit-button')!;
const dirtyFileClass = 'dirty-file';
const syncCommitButtonState = () => {
const dirty = elForm.classList.contains(dirtyFileClass);
commitButton.disabled = !dirty;
};
// Registering a custom listener for the file path and the file content
// FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added
applyAreYouSure(elForm, {
silent: true,
dirtyClass: dirtyFileClass,
fieldSelector: ':input:not(.commit-form-wrapper :input)',
change: syncCommitButtonState,
});
syncCommitButtonState(); // disable the "commit" button when no content changes
initEditPreviewTab(elForm);
(async () => {
const editor = await createCodeEditor(editArea, filenameInput);
filenameInput.addEventListener('input', onInputDebounce(() => editor.updateFilename(filenameInput.value)));
// Update the editor from query params, if available,
// only after the dirtyFileClass initialization
const params = new URLSearchParams(window.location.search);
const value = params.get('value');
if (value) {
editor.view.dispatch({
changes: {from: 0, to: editor.view.state.doc.length, insert: value},
});
}
commitButton.addEventListener('click', async (e) => {
if (editor.trimTrailingWhitespace) {
trimTrailingWhitespaceFromView(editor.view);
}
// A modal which asks if an empty file should be committed
if (!editArea.value) {
e.preventDefault();
if (await confirmModal({
header: elForm.getAttribute('data-text-empty-confirm-header')!,
content: elForm.getAttribute('data-text-empty-confirm-content')!,
})) {
ignoreAreYouSure(elForm);
submitFormFetchAction(elForm);
}
}
});
})();
}
export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
// the content is from the server, so it is safe to use innerHTML
previewPanel.innerHTML = html`<div class="render-content render-preview markup">${htmlRaw(htmlContent)}</div>`;
}
+34
View File
@@ -0,0 +1,34 @@
import {strSubMatch, calcMatchedWeight, filterRepoFilesWeighted} from './repo-findfile.ts';
describe('Repo Find Files', () => {
test('strSubMatch', () => {
expect(strSubMatch('abc', '')).toEqual(['abc']);
expect(strSubMatch('abc', 'a')).toEqual(['', 'a', 'bc']);
expect(strSubMatch('abc', 'b')).toEqual(['a', 'b', 'c']);
expect(strSubMatch('abc', 'c')).toEqual(['ab', 'c']);
expect(strSubMatch('abc', 'ac')).toEqual(['', 'a', 'b', 'c']);
expect(strSubMatch('abc', 'z')).toEqual(['abc']);
expect(strSubMatch('abc', 'az')).toEqual(['abc']);
expect(strSubMatch('ABc', 'ac')).toEqual(['', 'A', 'B', 'c']);
expect(strSubMatch('abC', 'ac')).toEqual(['', 'a', 'b', 'C']);
expect(strSubMatch('aabbcc', 'abc')).toEqual(['', 'a', 'a', 'b', 'b', 'c', 'c']);
expect(strSubMatch('the/directory', 'hedir')).toEqual(['t', 'he', '/', 'dir', 'ectory']);
});
test('calcMatchedWeight', () => {
expect(calcMatchedWeight(['a', 'b', 'c', 'd']) < calcMatchedWeight(['a', 'bc', 'c'])).toBeTruthy();
});
test('filterRepoFilesWeighted', () => {
// the first matched result should always be the "word.txt"
let res = filterRepoFilesWeighted(['word.txt', 'we-got-result.dat'], 'word');
expect(res).toHaveLength(2);
expect(res[0].matchResult).toEqual(['', 'word', '.txt']);
res = filterRepoFilesWeighted(['we-got-result.dat', 'word.txt'], 'word');
expect(res).toHaveLength(2);
expect(res[0].matchResult).toEqual(['', 'word', '.txt']);
});
});
+81
View File
@@ -0,0 +1,81 @@
import {createApp} from 'vue';
import {registerGlobalInitFunc} from '../modules/observer.ts';
const threshold = 50;
// return the case-insensitive sub-match result as an array: [unmatched, matched, unmatched, matched, ...]
// res[even] is unmatched, res[odd] is matched, see unit tests for examples
// argument subLower must be a lower-cased string.
export function strSubMatch(full: string, subLower: string) {
const res = [''];
let i = 0, j = 0;
const fullLower = full.toLowerCase();
while (i < subLower.length && j < fullLower.length) {
if (subLower[i] === fullLower[j]) {
if (res.length % 2 !== 0) res.push('');
res[res.length - 1] += full[j];
j++;
i++;
} else {
if (res.length % 2 === 0) res.push('');
res[res.length - 1] += full[j];
j++;
}
}
if (i !== subLower.length) {
// if the sub string doesn't match the full, only return the full as unmatched.
return [full];
}
if (j < full.length) {
// append remaining chars from full to result as unmatched
if (res.length % 2 === 0) res.push('');
res[res.length - 1] += full.substring(j);
}
return res;
}
export function calcMatchedWeight(matchResult: Array<any>) {
let weight = 0;
for (let i = 0; i < matchResult.length; i++) {
if (i % 2 === 1) { // matches are on odd indices, see strSubMatch
// use a function f(x+x) > f(x) + f(x) to make the longer matched string has higher weight.
weight += matchResult[i].length * matchResult[i].length;
}
}
return weight;
}
export function filterRepoFilesWeighted(files: Array<string>, filter: string) {
let filterResult = [];
if (filter) {
const filterLower = filter.toLowerCase();
// TODO: for large repo, this loop could be slow, maybe there could be one more limit:
// ... && filterResult.length < threshold * 20, wait for more feedbacks
for (const file of files) {
const res = strSubMatch(file, filterLower);
if (res.length > 1) { // length==1 means unmatched, >1 means having matched sub strings
filterResult.push({matchResult: res, matchWeight: calcMatchedWeight(res)});
}
}
filterResult.sort((a, b) => b.matchWeight - a.matchWeight);
filterResult = filterResult.slice(0, threshold);
} else {
for (let i = 0; i < files.length && i < threshold; i++) {
filterResult.push({matchResult: [files[i]], matchWeight: 0});
}
}
return filterResult;
}
export function initRepoFileSearch() {
registerGlobalInitFunc('initRepoFileSearch', async (el) => {
const {default: RepoFileSearch} = await import('../components/RepoFileSearch.vue');
createApp(RepoFileSearch, {
repoLink: el.getAttribute('data-repo-link'),
currentRefNameSubURL: el.getAttribute('data-current-ref-name-sub-url'),
treeListUrl: el.getAttribute('data-tree-list-url'),
noResultsText: el.getAttribute('data-no-results-text'),
placeholder: el.getAttribute('data-placeholder'),
}).mount(el);
});
}
+85
View File
@@ -0,0 +1,85 @@
import {toggleElemClass} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
export function initRepoGraphGit() {
const graphContainer = document.querySelector<HTMLElement>('#git-graph-container');
if (!graphContainer) return;
const elColorMonochrome = document.querySelector<HTMLElement>('#flow-color-monochrome')!;
const elColorColored = document.querySelector<HTMLElement>('#flow-color-colored')!;
const toggleColorMode = (mode: 'monochrome' | 'colored') => {
toggleElemClass(graphContainer, 'monochrome', mode === 'monochrome');
toggleElemClass(graphContainer, 'colored', mode === 'colored');
toggleElemClass(elColorMonochrome, 'active', mode === 'monochrome');
toggleElemClass(elColorColored, 'active', mode === 'colored');
const params = new URLSearchParams(window.location.search);
params.set('mode', mode);
window.history.replaceState(null, '', `?${params.toString()}`);
for (const link of document.querySelectorAll('#git-graph-body .pagination a')) {
const href = link.getAttribute('href');
if (!href) continue;
const url = new URL(href, window.location.href);
const params = url.searchParams;
params.set('mode', mode);
url.search = `?${params.toString()}`;
link.setAttribute('href', url.href);
}
};
elColorMonochrome.addEventListener('click', () => toggleColorMode('monochrome'));
elColorColored.addEventListener('click', () => toggleColorMode('colored'));
const elGraphBody = document.querySelector<HTMLElement>('#git-graph-body')!;
const url = new URL(window.location.href);
const params = url.searchParams;
const loadGitGraph = async () => {
const queryString = params.toString();
const ajaxUrl = new URL(url);
ajaxUrl.searchParams.set('div-only', 'true');
window.history.replaceState(null, '', queryString ? `?${queryString}` : window.location.pathname);
elGraphBody.classList.add('is-loading');
try {
const resp = await GET(ajaxUrl.toString());
elGraphBody.innerHTML = await resp.text();
} finally {
elGraphBody.classList.remove('is-loading');
}
};
const dropdownSelected = params.getAll('branch');
if (params.has('hide-pr-refs') && params.get('hide-pr-refs') === 'true') {
dropdownSelected.splice(0, 0, '...flow-hide-pr-refs');
}
const $dropdown = fomanticQuery('#flow-select-refs-dropdown');
$dropdown.dropdown({clearable: true});
$dropdown.dropdown('set selected', dropdownSelected);
// must add the callback after setting the selected items, otherwise each "selected" item will trigger the callback
$dropdown.dropdown('setting', {
onRemove(toRemove: string) {
if (toRemove === '...flow-hide-pr-refs') {
params.delete('hide-pr-refs');
} else {
const branches = params.getAll('branch');
params.delete('branch');
for (const branch of branches) {
if (branch !== toRemove) {
params.append('branch', branch);
}
}
}
loadGitGraph();
},
onAdd(toAdd: string) {
if (toAdd === '...flow-hide-pr-refs') {
params.set('hide-pr-refs', 'true');
} else {
params.append('branch', toAdd);
}
loadGitGraph();
},
});
}
+148
View File
@@ -0,0 +1,148 @@
import {stripTags} from '../utils.ts';
import {hideElem, queryElemChildren, showElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast, type Toast} from '../modules/toast.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
export function initRepoTopicBar() {
const mgrBtn = document.querySelector<HTMLButtonElement>('#manage_topic');
if (!mgrBtn) return;
const editDiv = document.querySelector('#topic_edit')!;
const viewDiv = document.querySelector('#repo-topics')!;
const topicDropdown = editDiv.querySelector('.ui.dropdown')!;
let lastErrorToast: Toast | null = null;
mgrBtn.addEventListener('click', () => {
hideElem([viewDiv, mgrBtn]);
showElem(editDiv);
topicDropdown.querySelector<HTMLInputElement>('input.search')!.focus();
});
document.querySelector('#cancel_topic_edit')!.addEventListener('click', () => {
lastErrorToast?.hideToast();
hideElem(editDiv);
showElem([viewDiv, mgrBtn]);
mgrBtn.focus();
});
document.querySelector<HTMLButtonElement>('#save_topic')!.addEventListener('click', async (e) => {
lastErrorToast?.hideToast();
const topics = editDiv.querySelector<HTMLInputElement>('input[name=topics]')!.value;
const data = new FormData();
data.append('topics', topics);
const response = await POST((e.target as HTMLElement).getAttribute('data-link')!, {data});
if (response.ok) {
const responseData = await response.json();
if (responseData.status === 'ok') {
queryElemChildren(viewDiv, '.repo-topic', (el) => el.remove());
if (topics.length) {
const topicArray = topics.split(',');
topicArray.sort();
for (const topic of topicArray) {
// TODO: sort items in topicDropdown, or items in edit div will have different order to the items in view div
// !!!! it SHOULD and MUST match the code in "home_sidebar_top.tmpl" !!!!
const link = document.createElement('a');
link.classList.add('repo-topic', 'ui', 'large', 'label', 'gt-ellipsis');
link.href = `${appSubUrl}/explore/repos?q=${encodeURIComponent(topic)}&topic=1`;
link.textContent = topic;
viewDiv.append(link);
}
}
hideElem(editDiv);
showElem([viewDiv, mgrBtn]);
}
} else if (response.status === 422) {
// how to test: input topic like " invalid topic " (with spaces), and select it from the list, then "Save"
const responseData = await response.json();
lastErrorToast = showErrorToast(responseData.message, {duration: 5000});
if (responseData.invalidTopics && responseData.invalidTopics.length > 0) {
const {invalidTopics} = responseData;
const topicLabels = queryElemChildren(topicDropdown, 'a.ui.label');
for (const [index, value] of topics.split(',').entries()) {
if (invalidTopics.includes(value)) {
topicLabels[index].classList.remove('green');
topicLabels[index].classList.add('red');
}
}
}
}
});
fomanticQuery(topicDropdown).dropdown({
allowAdditions: true,
forceSelection: false,
fullTextSearch: 'exact',
fields: {name: 'description', value: 'data-value'},
saveRemoteData: false,
label: {
transition: 'horizontal flip',
duration: 200,
variation: false,
},
apiSettings: {
url: `${appSubUrl}/explore/topics/search?q={query}`,
throttle: 500,
cache: false,
onResponse(this: any, res: any) {
const formattedResponse = {
success: false,
results: [] as Array<Record<string, any>>,
};
const query = stripTags(this.urlData.query.trim());
let found_query = false;
const current_topics = [];
for (const el of queryElemChildren(topicDropdown, 'a.ui.label.visible')) {
current_topics.push(el.getAttribute('data-value'));
}
if (res.topics) {
let found = false;
for (const {topic_name} of res.topics) {
// skip currently added tags
if (current_topics.includes(topic_name)) {
continue;
}
if (topic_name.toLowerCase() === query.toLowerCase()) {
found_query = true;
}
formattedResponse.results.push({description: topic_name, 'data-value': topic_name});
found = true;
}
formattedResponse.success = found;
}
if (query.length > 0 && !found_query) {
formattedResponse.success = true;
formattedResponse.results.unshift({description: query, 'data-value': query});
} else if (query.length > 0 && found_query) {
formattedResponse.results.sort((a, b) => {
if (a.description.toLowerCase() === query.toLowerCase()) return -1;
if (b.description.toLowerCase() === query.toLowerCase()) return 1;
if (a.description > b.description) return -1;
if (a.description < b.description) return 1;
return 0;
});
}
return formattedResponse;
},
},
onLabelCreate(value: string) {
value = value.toLowerCase().trim();
this.attr('data-value', value).contents().first().replaceWith(value);
return fomanticQuery(this);
},
onAdd(addedValue: string, _addedText: any, $addedChoice: any) {
addedValue = addedValue.toLowerCase().trim();
$addedChoice[0].setAttribute('data-value', addedValue);
$addedChoice[0].setAttribute('data-text', addedValue);
},
});
}
+156
View File
@@ -0,0 +1,156 @@
import {svg} from '../svg.ts';
import {showErrorToast} from '../modules/toast.ts';
import {GET, POST} from '../modules/fetch.ts';
import {createElementFromHTML, showElem} from '../utils/dom.ts';
import {parseIssuePageInfo} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
let i18nTextEdited: string;
let i18nTextOptions: string;
let i18nTextDeleteFromHistory: string;
let i18nTextDeleteFromHistoryConfirm: string;
function showContentHistoryDetail(issueBaseUrl: string, commentId: string, historyId: string, itemTitleHtml: string) {
const elDetailDialog = createElementFromHTML(`
<div class="ui modal content-history-detail-dialog">
${svg('octicon-x', 16, 'close icon inside')}
<div class="header flex-left-right">
<div>${itemTitleHtml}</div>
<div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
${i18nTextOptions}
${svg('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
<div class="item tw-text-red" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
</div>
</div>
</div>
<div class="comment-diff-data is-loading"></div>
</div>`);
document.body.append(elDetailDialog);
const elOptionsDropdown = elDetailDialog.querySelector('.ui.dropdown.dialog-header-options')!;
const $fomanticDropdownOptions = fomanticQuery(elOptionsDropdown);
$fomanticDropdownOptions.dropdown({
showOnFocus: false,
allowReselection: true,
async onChange(_value: string, _text: string, $item: any) {
const optionItem = $item.data('option-item');
if (optionItem === 'delete') {
if (window.confirm(i18nTextDeleteFromHistoryConfirm)) {
try {
const params = new URLSearchParams();
params.append('comment_id', commentId);
params.append('history_id', historyId);
const response = await POST(`${issueBaseUrl}/content-history/soft-delete?${params.toString()}`);
const resp = await response.json();
if (resp.ok) {
hideFomanticModal(elDetailDialog);
} else {
showErrorToast(resp.message);
}
} catch (error) {
console.error('Error:', error);
showErrorToast('An error occurred while deleting the history.');
}
}
} else { // required by eslint
showErrorToast(`unknown option item: ${optionItem}`);
}
},
onHide() {
$fomanticDropdownOptions.dropdown('clear', true);
},
});
showFomanticModal(elDetailDialog, {
async onShow() {
try {
const params = new URLSearchParams();
params.append('comment_id', commentId);
params.append('history_id', historyId);
const url = `${issueBaseUrl}/content-history/detail?${params.toString()}`;
const response = await GET(url);
const resp = await response.json();
const commentDiffData = elDetailDialog.querySelector('.comment-diff-data')!;
commentDiffData.classList.remove('is-loading');
commentDiffData.innerHTML = resp.diffHtml;
// there is only one option "item[data-option-item=delete]", so the dropdown can be entirely shown/hidden.
if (resp.canSoftDelete) {
showElem(elOptionsDropdown);
}
} catch (error) {
console.error('Error:', error);
}
},
onHidden() {
elDetailDialog.remove();
},
});
}
function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, commentId: string) {
const elHeaderLeft = elCommentItem.querySelector('.comment-header-left')!;
const menuHtml = `
<div class="ui dropdown interact-fg content-history-menu tw-flex-shrink-0" data-comment-id="${commentId}">
&bull; ${i18nTextEdited}${svg('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
</div>
</div>`;
elHeaderLeft.querySelector(`.ui.dropdown.content-history-menu`)?.remove(); // remove the old one if exists
elHeaderLeft.append(createElementFromHTML(menuHtml));
const elDropdown = elHeaderLeft.querySelector('.ui.dropdown.content-history-menu')!;
const $fomanticDropdown = fomanticQuery(elDropdown);
$fomanticDropdown.dropdown({
action: 'hide',
apiSettings: {
cache: false,
url: `${issueBaseUrl}/content-history/list?comment_id=${commentId}`,
},
saveRemoteData: false,
onHide() {
$fomanticDropdown.dropdown('change values', null);
},
onChange(value: string, itemHtml: string, $item: any) {
if (value && !$item.find('[data-history-is-deleted=1]').length) {
showContentHistoryDetail(issueBaseUrl, commentId, value, itemHtml);
}
},
});
}
export async function initRepoIssueContentHistory() {
const issuePageInfo = parseIssuePageInfo();
if (!issuePageInfo.issueNumber) return;
const elIssueDescription = document.querySelector('.repository.issue .timeline-item.comment.issue-content-comment'); // issue(PR) main content
const elComments = document.querySelectorAll('.repository.issue .comment-list .comment'); // includes: issue(PR) comments, review comments, code comments
if (!elIssueDescription && !elComments.length) return;
const issueBaseUrl = `${issuePageInfo.repoLink}/issues/${issuePageInfo.issueNumber}`;
try {
const response = await GET(`${issueBaseUrl}/content-history/overview`);
const resp = await response.json();
i18nTextEdited = resp.i18n.textEdited;
i18nTextDeleteFromHistory = resp.i18n.textDeleteFromHistory;
i18nTextDeleteFromHistoryConfirm = resp.i18n.textDeleteFromHistoryConfirm;
i18nTextOptions = resp.i18n.textOptions;
if (resp.editedHistoryCountMap[0] && elIssueDescription) {
showContentHistoryMenu(issueBaseUrl, elIssueDescription, '0');
}
for (const [commentId, _editedCount] of Object.entries(resp.editedHistoryCountMap)) {
if (commentId === '0') continue;
const elIssueComment = document.querySelector(`#issuecomment-${commentId}`);
if (elIssueComment) showContentHistoryMenu(issueBaseUrl, elIssueComment, commentId);
}
} catch (error) {
console.error('Error:', error);
}
}
+158
View File
@@ -0,0 +1,158 @@
import {handleReply} from './repo-issue.ts';
import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts';
async function tryOnEditContent(e: Event) {
const clickTarget = (e.target as HTMLElement).closest('.edit-content');
if (!clickTarget) return;
e.preventDefault();
const commentContent = clickTarget.closest('.comment-header')!.nextElementSibling!;
const editContentZone = commentContent.querySelector('.edit-content-zone')!;
let renderContent = commentContent.querySelector('.render-content')!;
const rawContent = commentContent.querySelector('.raw-content')!;
let comboMarkdownEditor : ComboMarkdownEditor;
const cancelAndReset = (e: Event) => {
e.preventDefault();
showElem(renderContent);
hideElem(editContentZone);
comboMarkdownEditor.dropzoneReloadFiles();
};
const saveAndRefresh = async (e: Event) => {
e.preventDefault();
// we are already in a form, do not bubble up to the document otherwise there will be other "form submit handlers"
// at the moment, the form submit event conflicts with initRepoDiffConversationForm (global '.conversation-holder form' event handler)
e.stopPropagation();
renderContent.classList.add('is-loading');
showElem(renderContent);
hideElem(editContentZone);
try {
const params = new URLSearchParams({
content: comboMarkdownEditor.value(),
context: String(editContentZone.getAttribute('data-context')),
content_version: String(editContentZone.getAttribute('data-content-version')),
});
for (const file of comboMarkdownEditor.dropzoneGetFiles() ?? []) {
params.append('files[]', file);
}
const response = await POST(editContentZone.getAttribute('data-update-url')!, {data: params});
const data = await response.json();
if (!response.ok) {
showErrorToast(data?.errorMessage ?? window.config.i18n.error_occurred);
return;
}
reinitializeAreYouSure(editContentZone.querySelector('form')); // the form is no longer dirty
editContentZone.setAttribute('data-content-version', data.contentVersion);
// replace the render content with new one, to trigger re-initialization of all features
const newRenderContent = renderContent.cloneNode(false) as HTMLElement;
newRenderContent.innerHTML = data.content;
renderContent.replaceWith(newRenderContent);
renderContent = newRenderContent;
rawContent.textContent = comboMarkdownEditor.value();
if (!commentContent.querySelector('.dropzone-attachments')) {
if (data.attachments !== '') {
commentContent.insertAdjacentHTML('beforeend', data.attachments);
}
} else if (data.attachments === '') {
commentContent.querySelector('.dropzone-attachments')!.remove();
} else {
commentContent.querySelector('.dropzone-attachments')!.outerHTML = data.attachments;
}
comboMarkdownEditor.dropzoneSubmitReload();
} catch (error) {
showErrorToast(`Failed to save the content: ${errorMessage(error)}`);
console.error(error);
} finally {
renderContent.classList.remove('is-loading');
}
};
// Show write/preview tab and copy raw content as needed
showElem(editContentZone);
hideElem(renderContent);
comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'))!;
if (!comboMarkdownEditor) {
editContentZone.innerHTML = document.querySelector('#issue-comment-editor-template')!.innerHTML;
const form = editContentZone.querySelector('form')!;
applyAreYouSure(form);
const saveButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.primary.button')!;
const cancelButton = querySingleVisibleElem<HTMLButtonElement>(editContentZone, '.ui.cancel.button')!;
comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor')!);
const syncUiState = () => saveButton.disabled = comboMarkdownEditor.isUploading();
comboMarkdownEditor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState);
cancelButton.addEventListener('click', cancelAndReset);
form.addEventListener('submit', saveAndRefresh);
}
// when the content has changed on server side, there is no sync, and this page doesn't have the latest content,
// the editor still shows the old content, server will reject end user's submit by "data-content-version" check
comboMarkdownEditor.value(rawContent.textContent);
comboMarkdownEditor.switchTabToEditor();
comboMarkdownEditor.focus();
triggerUploadStateChanged(comboMarkdownEditor.container);
}
function extractSelectedMarkdown(container: HTMLElement) {
const selection = window.getSelection();
if (!selection?.rangeCount) return '';
const range = selection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) return '';
// todo: if commonAncestorContainer parent has "[data-markdown-original-content]" attribute, use the parent's markdown content
// otherwise, use the selected HTML content and respect all "[data-markdown-original-content]/[data-markdown-generated-content]" attributes
const contents = selection.getRangeAt(0).cloneContents();
const el = document.createElement('div');
el.append(contents);
return convertHtmlToMarkdown(el);
}
async function tryOnQuoteReply(e: Event) {
const clickTarget = (e.target as HTMLElement).closest('.quote-reply');
if (!clickTarget) return;
e.preventDefault();
const contentToQuoteId = clickTarget.getAttribute('data-target');
const targetRawToQuote = document.querySelector<HTMLElement>(`#${contentToQuoteId}.raw-content`)!;
const targetMarkupToQuote = targetRawToQuote.parentElement!.querySelector<HTMLElement>('.render-content.markup')!;
let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote);
if (!contentToQuote) contentToQuote = targetRawToQuote.textContent;
const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n\n`;
let editor;
if (clickTarget.classList.contains('quote-reply-diff')) {
const replyBtn = clickTarget.closest('.comment-code-cloud')!.querySelector<HTMLElement>('button.comment-form-reply')!;
editor = await handleReply(replyBtn);
} else {
// for normal issue/comment page
editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor'))!;
}
if (editor.value()) {
editor.value(`${editor.value()}\n\n${quotedContent}`);
} else {
editor.value(quotedContent);
}
editor.focus();
editor.moveCursorToEnd();
}
export function initRepoIssueCommentEdit() {
document.addEventListener('click', (e) => {
tryOnEditContent(e); // Edit issue or comment content
tryOnQuoteReply(e); // Quote reply to the comment editor
});
}
+239
View File
@@ -0,0 +1,239 @@
import {updateIssuesMeta} from './repo-common.ts';
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
import {html, htmlRaw} from '../utils/html.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {errorMessage} from '../modules/errors.ts';
import {showErrorToast} from '../modules/toast.ts';
import {createSortable} from '../modules/sortable.ts';
import {DELETE, POST} from '../modules/fetch.ts';
import {parseDom} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import type {SortableEvent} from 'sortablejs';
function initRepoIssueListCheckboxes() {
const issueSelectAll = document.querySelector<HTMLInputElement>('.issue-checkbox-all');
if (!issueSelectAll) return; // logged out state
const issueCheckboxes = document.querySelectorAll<HTMLInputElement>('.issue-checkbox');
const syncIssueSelectionState = () => {
const checkedCheckboxes = Array.from(issueCheckboxes).filter((el) => el.checked);
const anyChecked = Boolean(checkedCheckboxes.length);
const allChecked = anyChecked && checkedCheckboxes.length === issueCheckboxes.length;
if (allChecked) {
issueSelectAll.checked = true;
issueSelectAll.indeterminate = false;
} else if (anyChecked) {
issueSelectAll.checked = false;
issueSelectAll.indeterminate = true;
} else {
issueSelectAll.checked = false;
issueSelectAll.indeterminate = false;
}
// if any issue is selected, show the action panel, otherwise show the filter panel
toggleElem('#issue-filters', !anyChecked);
toggleElem('#issue-actions', anyChecked);
// there are two panels but only one select-all checkbox, so move the checkbox to the visible panel
const panels = document.querySelectorAll<HTMLElement>('#issue-filters, #issue-actions');
const visiblePanel = Array.from(panels).find((el) => isElemVisible(el))!;
const toolbarLeft = visiblePanel.querySelector('.issue-list-toolbar-left')!;
toolbarLeft.prepend(issueSelectAll);
};
for (const el of issueCheckboxes) {
el.addEventListener('change', syncIssueSelectionState);
}
issueSelectAll.addEventListener('change', () => {
for (const el of issueCheckboxes) {
el.checked = issueSelectAll.checked;
}
syncIssueSelectionState();
});
queryElems(document, '.issue-action', (el) => el.addEventListener('click',
async (e: MouseEvent) => {
e.preventDefault();
const url = el.getAttribute('data-url')!;
let action = el.getAttribute('data-action')!;
let elementId = el.getAttribute('data-element-id')!;
const issueIDList: string[] = [];
for (const el of document.querySelectorAll('.issue-checkbox:checked')) {
issueIDList.push(el.getAttribute('data-issue-id')!);
}
const issueIDs = issueIDList.join(',');
if (!issueIDs) return;
// for assignee
if (elementId === '0' && url.endsWith('/assignee')) {
elementId = '';
action = 'clear';
}
// for toggle
if (action === 'toggle' && e.altKey) {
action = 'toggle-alt';
}
// for delete
if (action === 'delete') {
const confirmText = el.getAttribute('data-action-delete-confirm')!;
if (!await confirmModal({content: confirmText, confirmButtonColor: 'red'})) {
return;
}
}
try {
await updateIssuesMeta(url, action, issueIDs, elementId);
window.location.reload();
} catch (err) {
// FIXME: this logic (including updateIssuesMeta) is not right, should refactor to our JSONError framework
const e = err as {responseJSON?: {error: string}};
showErrorToast(e.responseJSON?.error ?? errorMessage(err));
}
},
));
}
function initDropdownUserRemoteSearch(el: Element) {
let searchUrl = el.getAttribute('data-search-url');
const actionJumpUrl = el.getAttribute('data-action-jump-url')!;
let selectedUsername = el.getAttribute('data-selected-username') || '';
const $searchDropdown = fomanticQuery(el);
const elMenu = el.querySelector('.menu')!;
const elSearchInput = el.querySelector<HTMLInputElement>('.ui.search input')!;
const elItemFromInput = el.querySelector('.menu > .item-from-input')!;
$searchDropdown.dropdown('setting', {
fullTextSearch: true,
selectOnKeydown: false,
action: (_text: string, value: string) => {
window.location.href = actionJumpUrl.replace('{username}', encodeURIComponent(value));
},
});
const selectUsername = (username: string) => {
queryElems(elMenu, '.item.active, .item.selected', (el) => el.classList.remove('active', 'selected'));
elMenu.querySelector(`.item[data-value="${CSS.escape(username)}"]`)?.classList.add('selected');
};
type ProcessedResult = {value: string, name: string};
const processedResults: ProcessedResult[] = []; // to be used by dropdown to generate menu items
const syncItemFromInput = () => {
const inputVal = elSearchInput.value.trim();
elItemFromInput.setAttribute('data-value', inputVal);
elItemFromInput.textContent = inputVal;
const showItemFromInput = !processedResults.length && inputVal !== '';
toggleElem(elItemFromInput, showItemFromInput);
selectUsername(showItemFromInput ? inputVal : selectedUsername);
};
elSearchInput.value = selectedUsername;
if (!searchUrl) {
elSearchInput.addEventListener('input', syncItemFromInput);
} else {
if (!searchUrl.includes('?')) searchUrl += '?';
$searchDropdown.dropdown('setting', 'apiSettings', {
cache: false,
url: `${searchUrl}&q={query}`,
onResponse(resp: any) {
// the content is provided by backend IssuePosters handler
processedResults.length = 0;
for (const item of resp.results) {
const htmlAvatar = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20">`;
const htmlFullName = item.full_name ? html`<span class="username-fullname gt-ellipsis">(${item.full_name})</span>` : '';
const htmlItem = html`<span class="username-display">${htmlRaw(htmlAvatar)}<span>${item.username}</span>${htmlRaw(htmlFullName)}</span>`;
if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
processedResults.push({value: item.username, name: htmlItem});
}
resp.results = processedResults;
return resp;
},
});
$searchDropdown.dropdown('setting', 'onShow', () => $searchDropdown.dropdown('filter', ' ')); // trigger a search on first show
}
// we want to generate the dropdown menu items by ourselves, replace its internal setup functions
const dropdownSetup = {...$searchDropdown.dropdown('internal', 'setup')};
const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates');
$searchDropdown.dropdown('internal', 'setup', dropdownSetup);
dropdownSetup.menu = function (values: any) {
// remove old dynamic items
for (const el of elMenu.querySelectorAll(':scope > .dynamic-item')) {
el.remove();
}
const newMenuHtml = dropdownTemplates.menu(values, $searchDropdown.dropdown('setting', 'fields'), true /* html */, $searchDropdown.dropdown('setting', 'className'));
if (newMenuHtml) {
const newMenuItems = parseDom(newMenuHtml, 'text/html').querySelectorAll('body > div');
for (const newMenuItem of newMenuItems) {
newMenuItem.classList.add('dynamic-item');
}
const div = document.createElement('div');
div.classList.add('divider', 'dynamic-item');
elMenu.append(div, ...newMenuItems);
}
$searchDropdown.dropdown('refresh');
// defer our selection to the next tick, because dropdown will set the selection item after this `menu` function
setTimeout(() => syncItemFromInput(), 0);
};
}
function initPinRemoveButton() {
for (const button of document.querySelectorAll('.issue-card-unpin')) {
button.addEventListener('click', async (event) => {
const el = event.currentTarget as HTMLElement;
const id = Number(el.getAttribute('data-issue-id'));
// Send the unpin request
const response = await DELETE(el.getAttribute('data-unpin-url')!);
if (response.ok) {
// Delete the tooltip
el._tippy.destroy();
// Remove the Card
el.closest(`div.issue-card[data-issue-id="${id}"]`)!.remove();
}
});
}
}
async function pinMoveEnd(e: SortableEvent) {
const url = e.item.getAttribute('data-move-url')!;
const id = Number(e.item.getAttribute('data-issue-id'));
await POST(url, {data: {id, position: e.newIndex! + 1}});
}
async function initIssuePinSort() {
const pinDiv = document.querySelector<HTMLElement>('#issue-pins');
if (pinDiv === null) return;
// If the User is not a Repo Admin, we don't need to proceed
if (!pinDiv.hasAttribute('data-is-repo-admin')) return;
initPinRemoveButton();
// If only one issue pinned, we don't need to make this Sortable
if (pinDiv.children.length < 2) return;
createSortable(pinDiv, {
group: 'shared',
onEnd: (e) => {
(async () => {
await pinMoveEnd(e);
})();
},
});
}
export function initRepoIssueList() {
if (document.querySelector('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list')) {
initRepoIssueListCheckboxes();
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
initIssuePinSort();
} else if (document.querySelector('.page-content.dashboard.issues')) {
// user or org home: issue list, pull request list
queryElems(document, '.ui.dropdown.user-remote-search', (el) => initDropdownUserRemoteSearch(el));
}
}
+80
View File
@@ -0,0 +1,80 @@
import {createApp} from 'vue';
import {GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {registerGlobalEventFunc} from '../modules/observer.ts';
export function initRepoPullRequestUpdate(el: HTMLElement) {
const elDropdown = el.querySelector(':scope > .ui.dropdown');
if (!elDropdown) return;
const elButton = el.querySelector<HTMLButtonElement>(':scope > button')!;
fomanticQuery(elDropdown).dropdown({
onChange(_text: string, _value: string, $choice: any) {
const choiceEl = $choice[0];
elButton.textContent = choiceEl.textContent;
elButton.setAttribute('data-url', choiceEl.getAttribute('data-update-url'));
},
});
}
function onCommitStatusChecksToggle(btn: HTMLElement) {
const panel = btn.closest('.commit-status-toggle')!.parentElement!;
const list = panel.querySelector<HTMLElement>('.commit-status-list')!;
list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
}
async function initRepoPullRequestMergeForm(box: HTMLElement) {
const el = box.querySelector('#pull-request-merge-form');
if (!el) return;
const data = JSON.parse(el.getAttribute('data-merge-form-props')!);
const {default: PullRequestMergeForm} = await import('../components/PullRequestMergeForm.vue');
const view = createApp(PullRequestMergeForm, {mergeFormProps: data});
view.mount(el); // TODO: can unmount when reloaded?
}
export function initRepoPullMergeBox(el: HTMLElement) {
registerGlobalEventFunc('click', 'onCommitStatusChecksToggle', onCommitStatusChecksToggle);
initRepoPullRequestMergeForm(el);
const reloadingIntervalValue = el.getAttribute('data-pull-merge-box-reloading-interval');
if (!reloadingIntervalValue) return;
const reloadingInterval = parseInt(reloadingIntervalValue);
const pullLink = el.getAttribute('data-pull-link');
let timerId: number | null;
let reloadMergeBox: () => Promise<void>;
const stopReloading = () => {
if (!timerId) return;
clearTimeout(timerId);
timerId = null;
};
const startReloading = () => {
if (timerId) return;
setTimeout(reloadMergeBox, reloadingInterval);
};
const onVisibilityChange = () => {
if (document.hidden) {
stopReloading();
} else {
startReloading();
}
};
reloadMergeBox = async () => {
const resp = await GET(`${pullLink}/merge_box`);
stopReloading();
if (!resp.ok) {
startReloading();
return;
}
document.removeEventListener('visibilitychange', onVisibilityChange);
const newElem = createElementFromHTML(await resp.text());
el.replaceWith(newElem);
};
document.addEventListener('visibilitychange', onVisibilityChange);
startReloading();
}
@@ -0,0 +1,61 @@
import {syncIssueMainContentTimelineItems} from './repo-issue-sidebar-combolist.ts';
import {createElementFromHTML} from '../utils/dom.ts';
describe('syncIssueMainContentTimelineItems', () => {
test('InsertNew', () => {
const oldContent = createElementFromHTML(`
<div>
<div class="timeline-item">First</div>
<div class="timeline-item" id="timeline-comments-end"></div>
</div>
`);
const newContent = createElementFromHTML(`
<div>
<div class="timeline-item" id="a">New</div>
</div>
`);
syncIssueMainContentTimelineItems(oldContent, newContent);
expect(oldContent.innerHTML.replace(/>\s+</g, '><').trim()).toBe(
`<div class="timeline-item">First</div>` +
`<div class="timeline-item" id="a">New</div>` +
`<div class="timeline-item" id="timeline-comments-end"></div>`,
);
});
test('Sync', () => {
const oldContent = createElementFromHTML(`
<div>
<div class="timeline-item">First</div>
<div class="timeline-item" id="it-1">Item 1</div>
<div class="timeline-item event" id="it-2">Item 2</div>
<div class="timeline-item" id="it-3">Item 3</div>
<div class="timeline-item event" id="it-4">Item 4</div>
<div class="timeline-item" id="timeline-comments-end"></div>
<div class="timeline-item">Other</div>
</div>
`);
const newContent = createElementFromHTML(`
<div>
<div class="timeline-item" id="it-1">New 1</div>
<div class="timeline-item event" id="it-2">New 2</div>
<div class="timeline-item" id="it-x">New X</div>
</div>
`);
syncIssueMainContentTimelineItems(oldContent, newContent);
// Item 1 won't be replaced because it's not an event
// Item 2 will be replaced with New 2
// Item 3 will be kept because it's not in new content
// Item 4 will be removed because it's not in new content, and it's an event
// New X will be inserted at the end of timeline items (before timeline-comments-end)
expect(oldContent.innerHTML.replace(/>\s+</g, '><').trim()).toBe(
`<div class="timeline-item">First</div>` +
`<div class="timeline-item" id="it-1">Item 1</div>` +
`<div class="timeline-item event" id="it-2">New 2</div>` +
`<div class="timeline-item" id="it-3">Item 3</div>` +
`<div class="timeline-item" id="it-x">New X</div>` +
`<div class="timeline-item" id="timeline-comments-end"></div>` +
`<div class="timeline-item">Other</div>`,
);
});
});
@@ -0,0 +1,214 @@
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) {
// find the end of comments timeline by "id=timeline-comments-end" in current main content, and insert new items before it
const timelineEnd = oldMainContent.querySelector('.timeline-item[id="timeline-comments-end"]');
if (!timelineEnd) return;
const oldTimelineItems = oldMainContent.querySelectorAll(`.timeline-item[id]`);
for (const oldItem of oldTimelineItems) {
const oldItemId = oldItem.getAttribute('id')!;
const newItem = newMainContent.querySelector(`.timeline-item[id="${CSS.escape(oldItemId)}"]`);
if (oldItem.classList.contains('event') && !newItem) {
// if the item is not in new content, we want to remove it from old content only if it's an event item, otherwise we keep it
oldItem.remove();
}
}
const newTimelineItems = newMainContent.querySelectorAll(`.timeline-item[id]`);
for (const newItem of newTimelineItems) {
const newItemId = newItem.getAttribute('id')!;
const oldItem = oldMainContent.querySelector(`.timeline-item[id="${CSS.escape(newItemId)}"]`);
if (oldItem) {
if (oldItem.classList.contains('event')) {
// for event item (e.g.: "add & remove labels"), we want to replace the existing one if exists
// because the label operations can be merged into one event item, so the new item might be different from the old one
oldItem.replaceWith(newItem);
}
continue;
}
timelineEnd.insertAdjacentElement('beforebegin', newItem);
}
}
export class IssueSidebarComboList {
updateUrl: string;
updateAlgo: string;
selectionMode: string;
elDropdown: HTMLElement;
elList: HTMLElement | null;
elComboValue: HTMLInputElement;
initialValues: string[] = [];
container: HTMLElement;
elIssueMainContent: HTMLElement;
elIssueSidebar: HTMLElement;
constructor(container: HTMLElement) {
this.container = container;
this.updateUrl = container.getAttribute('data-update-url')!;
this.updateAlgo = container.getAttribute('data-update-algo')!;
this.selectionMode = container.getAttribute('data-selection-mode')!;
if (!['single', 'multiple'].includes(this.selectionMode)) throw new Error(`Invalid data-update-on: ${this.selectionMode}`);
if (!['diff', 'all'].includes(this.updateAlgo)) throw new Error(`Invalid data-update-algo: ${this.updateAlgo}`);
this.elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown')!;
this.elList = container.querySelector<HTMLElement>(':scope > .ui.list');
this.elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value')!;
this.elIssueMainContent = document.querySelector('.issue-content-left')!;
this.elIssueSidebar = document.querySelector('.issue-content-right')!;
}
collectCheckedValues() {
return Array.from(this.elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value')!);
}
updateUiList(changedValues: Array<string>) {
if (!this.elList) return;
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)}"]`);
if (!el) continue;
const listItem = el.cloneNode(true) as HTMLElement;
queryElems(listItem, '.item-check-mark, .item-secondary-info', (el) => el.remove());
this.elList.append(listItem);
}
const hasItems = Boolean(this.elList.querySelector('.item:not(.empty-list)'));
toggleElem(elEmptyTip, !hasItems);
}
async reloadPagePartially() {
const resp = await GET(window.location.href);
if (!resp.ok) throw new Error(`Failed to reload page: ${resp.statusText}`);
const doc = parseDom(await resp.text(), 'text/html');
// we can safely replace the whole right part (sidebar) because there are only some dropdowns and lists
const newSidebar = doc.querySelector('.issue-content-right')!;
this.elIssueSidebar.replaceWith(newSidebar);
// for the main content (left side), at the moment we only support handling known timeline items
const newMainContent = doc.querySelector('.issue-content-left')!;
syncIssueMainContentTimelineItems(this.elIssueMainContent, newMainContent);
}
async sendRequestToBackend(changedValues: Array<string>): Promise<Response | null> {
let lastResp: Response | null = null;
if (this.updateAlgo === 'diff') {
for (const value of this.initialValues) {
if (!changedValues.includes(value)) {
lastResp = await POST(this.updateUrl, {data: new URLSearchParams({action: 'detach', id: value})});
if (!lastResp.ok) return lastResp;
}
}
for (const value of changedValues) {
if (!this.initialValues.includes(value)) {
lastResp = await POST(this.updateUrl, {data: new URLSearchParams({action: 'attach', id: value})});
if (!lastResp.ok) return lastResp;
}
}
} else {
lastResp = await POST(this.updateUrl, {data: new URLSearchParams({id: changedValues.join(',')})});
}
return lastResp;
}
async updateToBackend(changedValues: Array<string>) {
this.elIssueSidebar.classList.add('is-loading');
try {
const resp = await this.sendRequestToBackend(changedValues);
if (!resp) return; // no request sent, no need to reload
if (!resp.ok) {
showErrorToast(`Failed to update to backend: ${resp.statusText}`);
return;
}
await this.reloadPagePartially();
} catch (e) {
console.error('Failed to update to backend', e);
showErrorToast(`Failed to update to backend: ${errorMessage(e)}`);
} finally {
this.elIssueSidebar.classList.remove('is-loading');
}
}
async doUpdate() {
const changedValues = this.collectCheckedValues();
if (this.initialValues.join(',') === changedValues.join(',')) return;
if (!this.updateUrl) this.updateUiList(changedValues);
if (this.updateUrl) await this.updateToBackend(changedValues);
this.initialValues = changedValues;
}
async onChange() {
if (this.selectionMode === 'single') {
await this.doUpdate();
fomanticQuery(this.elDropdown).dropdown('hide');
}
}
async onItemClick(elItem: HTMLElement, e: Event) {
e.preventDefault();
if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return;
if (elItem.matches('.clear-selection')) {
queryElems(this.elDropdown, '.menu > .item', (el) => el.classList.remove('checked'));
this.elComboValue.value = '';
this.onChange();
return;
}
const scope = elItem.getAttribute('data-scope');
if (scope) {
// scoped items could only be checked one at a time
const elSelected = this.elDropdown.querySelector<HTMLElement>(`.menu > .item.checked[data-scope="${CSS.escape(scope)}"]`);
if (elSelected === elItem) {
elItem.classList.toggle('checked');
} else {
queryElems(this.elDropdown, `.menu > .item[data-scope="${CSS.escape(scope)}"]`, (el) => el.classList.remove('checked'));
elItem.classList.toggle('checked', true);
}
} else {
if (this.selectionMode === 'multiple') {
elItem.classList.toggle('checked');
} else {
queryElems(this.elDropdown, `.menu > .item.checked`, (el) => el.classList.remove('checked'));
elItem.classList.toggle('checked', true);
}
}
this.elComboValue.value = this.collectCheckedValues().join(',');
this.onChange();
}
async onHide() {
if (this.selectionMode === 'multiple') this.doUpdate();
}
init() {
// init the checked items from initial value
if (this.elComboValue.value && this.elComboValue.value !== '0' && !queryElems(this.elDropdown, `.menu > .item.checked`).length) {
const values = this.elComboValue.value.split(',');
for (const value of values) {
const elItem = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
elItem?.classList.add('checked');
}
if (this.elList && this.elList.getAttribute('data-combo-list-inited') !== 'true') {
this.updateUiList(values);
}
}
this.initialValues = this.collectCheckedValues();
addDelegatedEventListener(this.elDropdown, 'click', '.item', (el, e) => this.onItemClick(el, e));
fomanticQuery(this.elDropdown).dropdown('setting', {
action: 'nothing', // do not hide the menu if user presses Enter
fullTextSearch: 'exact',
hideDividers: 'empty',
onHide: () => this.onHide(),
});
}
}
+47
View File
@@ -0,0 +1,47 @@
A sidebar combo (dropdown+list) is like this:
```html
<div class="issue-sidebar-combo" data-selection-mode="..." data-update-url="...">
<input class="combo-value" name="..." type="hidden" value="...">
<div class="ui dropdown">
<div class="menu">
<div class="item clear-selection">clear</div>
<div class="item" data-value="..." data-scope="...">
<span class="item-check-mark">...</span>
...
</div>
</div>
</div>
<div class="ui list">
<span class="item empty-list">no item</span>
<span class="item">...</span>
</div>
</div>
```
When the selected items change, the `combo-value` input will be updated.
If there is `data-update-url`, it also calls backend to attach/detach the changed items.
Also, the changed items will be synchronized to the `ui list` items.
The menu items must have correct `href`, otherwise the links of synchronized (cloned) items would be wrong.
The `ui list` is optional, so a single dropdown can also work, to select items and update them to backend.
Synchronization logic:
* On page load:
* If the dropdown menu contains checked items, there will be no synchronization.
In this case, it's assumed that the dropdown menu is already in sync with the list.
* If the dropdown menu doesn't contain checked items, it will use dropdown's value to mark the selected items as checked.
And the selected (checked) items will be synchronized to the list.
Dropdown's value should be empty if the there is no dropdown item but a pre-defined list item need to be displayed.
* On dropdown selection change:
* The selected items will be synchronized to the list after the dropdown is hidden
The items with the same data-scope only allow one selected at a time.
The dropdown selection could work in 2 modes:
* single: only one item could be selected, it updates immediately when the item is selected.
* multiple: multiple items could be selected, it defers the update until the dropdown is hidden.
When using "scrolling menu", the items must be in the same level,
otherwise keyboard (ArrowUp/ArrowDown/Enter) won't work.
+119
View File
@@ -0,0 +1,119 @@
import {POST} from '../modules/fetch.ts';
import {queryElems, toggleElem} from '../utils/dom.ts';
import {IssueSidebarComboList} from './repo-issue-sidebar-combolist.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {parseIssuePageInfo} from '../utils.ts';
import {html} from '../utils/html.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {showTemporaryTooltip} from '../modules/tippy.ts';
const {appSubUrl} = window.config;
function initRepoIssueBranchSelector(elSidebar: HTMLElement) {
// TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl"
const elSelectBranch = elSidebar.querySelector('.ui.dropdown.select-branch.branch-selector-dropdown');
if (!elSelectBranch) return;
const urlUpdateIssueRef = elSelectBranch.getAttribute('data-url-update-issueref');
const elBranchMenu = elSelectBranch.querySelector('.reference-list-menu')!;
queryElems(elBranchMenu, '.item:not(.no-select)', (el) => el.addEventListener('click', async function (e) {
e.preventDefault();
const selectedValue = this.getAttribute('data-id')!; // eg: "refs/heads/my-branch"
const selectedText = this.getAttribute('data-name'); // eg: "my-branch"
if (urlUpdateIssueRef) {
// for existing issue, send request to update issue ref, and reload page
try {
await POST(urlUpdateIssueRef, {data: new URLSearchParams({ref: selectedValue})});
window.location.reload();
} catch (error) {
console.error(error);
}
} else {
// for new issue, only update UI&form, do not send request/reload
const selectedHiddenSelector = this.getAttribute('data-id-selector')!;
document.querySelector<HTMLInputElement>(selectedHiddenSelector)!.value = selectedValue;
elSelectBranch.querySelector('.text-branch-name')!.textContent = selectedText;
}
}));
}
function initRepoIssueDue(elSidebar: HTMLElement) {
const form = elSidebar.querySelector<HTMLFormElement>('.issue-due-form');
if (!form) return;
const deadline = form.querySelector<HTMLInputElement>('input[name=deadline]')!;
elSidebar.querySelector('.issue-due-edit')?.addEventListener('click', () => {
toggleElem(form);
});
elSidebar.querySelector('.issue-due-remove')?.addEventListener('click', () => {
deadline.value = '';
form.dispatchEvent(new Event('submit', {cancelable: true, bubbles: true}));
});
}
export function initRepoIssueSidebarDependency(elSidebar: HTMLElement) {
const elDropdown = elSidebar.querySelector('#new-dependency-drop-list');
if (!elDropdown) return;
const issuePageInfo = parseIssuePageInfo();
const crossRepoSearch = elDropdown.getAttribute('data-issue-cross-repo-search');
let issueSearchUrl = `${issuePageInfo.repoLink}/issues/search?q={query}&type=${issuePageInfo.issueDependencySearchType}`;
if (crossRepoSearch === 'true') {
issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${issuePageInfo.repoId}&type=${issuePageInfo.issueDependencySearchType}`;
}
fomanticQuery(elDropdown).dropdown({
fullTextSearch: true,
apiSettings: {
cache: false,
rawResponse: true,
url: issueSearchUrl,
onResponse(response: any) {
const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
const currIssueId = elDropdown.getAttribute('data-issue-id');
// Parse the response from the api to work with our dropdown
for (const issue of response) {
// Don't list current issue in the dependency list.
if (String(issue.id) === currIssueId) continue;
filteredResponse.results.push({
value: issue.id,
name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`,
});
}
return filteredResponse;
},
},
});
}
export function initRepoPullRequestAllowMaintainerEdit(elSidebar: HTMLElement) {
const wrapper = elSidebar.querySelector('#allow-edits-from-maintainers')!;
if (!wrapper) return;
const checkbox = wrapper.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
checkbox.addEventListener('input', async () => {
const url = `${wrapper.getAttribute('data-url')}/set_allow_maintainer_edit`;
wrapper.classList.add('is-loading');
try {
const resp = await POST(url, {data: new URLSearchParams({allow_maintainer_edit: String(checkbox.checked)})});
if (!resp.ok) {
throw new Error('Failed to update maintainer edit permission');
}
const data = await resp.json();
checkbox.checked = data.allow_maintainer_edit;
} catch (error) {
checkbox.checked = !checkbox.checked;
console.error(error);
showTemporaryTooltip(wrapper, wrapper.getAttribute('data-prompt-error')!);
} finally {
wrapper.classList.remove('is-loading');
}
});
}
export function initRepoIssueSidebar() {
registerGlobalInitFunc('initRepoIssueSidebar', (elSidebar) => {
initRepoIssueBranchSelector(elSidebar);
initRepoIssueDue(elSidebar);
initRepoIssueSidebarDependency(elSidebar);
initRepoPullRequestAllowMaintainerEdit(elSidebar);
// init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions
queryElems(elSidebar, '.issue-sidebar-combo', (el) => new IssueSidebarComboList(el).init());
});
}
+523
View File
@@ -0,0 +1,523 @@
import {errorMessage} from '../modules/errors.ts';
import {htmlEscape} from '../utils/html.ts';
import {createTippy} from '../modules/tippy.ts';
import {
addDelegatedEventListener,
createElementFromHTML,
hideElem,
queryElems,
showElem,
toggleElem,
} 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';
const {appSubUrl} = window.config;
function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
const url = new URL(window.location.href);
const showArchivedLabels = url.searchParams.get('archived_labels') === 'true';
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
}
const excludeLabel = (e: MouseEvent | KeyboardEvent, item: Element) => {
e.preventDefault();
e.stopPropagation();
const labelId = item.getAttribute('data-label-id')!;
let labelIds: string[] = queryLabels ? queryLabels.split(',') : [];
labelIds = labelIds.filter((id) => Math.abs(parseInt(id)) !== Math.abs(parseInt(labelId)));
labelIds.push(`-${labelId}`);
url.searchParams.set('labels', labelIds.join(','));
window.location.assign(url);
};
// alt(or option) + click to exclude label
queryElems(elDropdown, '.label-filter-query-item', (el) => {
el.addEventListener('click', (e: MouseEvent) => {
if (e.altKey) excludeLabel(e, el);
});
});
// alt(or option) + enter to exclude selected label
elDropdown.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.isComposing) return;
if (e.altKey && e.key === 'Enter') {
const selectedItem = elDropdown.querySelector('.label-filter-query-item.selected');
if (selectedItem) excludeLabel(e, selectedItem);
}
});
// no "labels" query parameter means "all issues"
elDropdown.querySelector('.label-filter-query-default')!.classList.toggle('selected', queryLabels === '');
// "labels=0" query parameter means "issues without label"
elDropdown.querySelector('.label-filter-query-not-set')!.classList.toggle('selected', queryLabels === '0');
// prepare to process "archived" labels
const elShowArchivedLabel = elDropdown.querySelector('.label-filter-archived-toggle');
if (!elShowArchivedLabel) return;
const elShowArchivedInput = elShowArchivedLabel.querySelector<HTMLInputElement>('input')!;
elShowArchivedInput.checked = showArchivedLabels;
const archivedLabels = elDropdown.querySelectorAll('.item[data-is-archived]');
// if no archived labels, hide the toggle and return
if (!archivedLabels.length) {
hideElem(elShowArchivedLabel);
return;
}
// show the archived labels if the toggle is checked or the label is selected
for (const label of archivedLabels) {
toggleElem(label, showArchivedLabels || selectedLabelIds.has(label.getAttribute('data-label-id')!));
}
// update the url when the toggle is changed and reload
elShowArchivedInput.addEventListener('input', () => {
if (elShowArchivedInput.checked) {
url.searchParams.set('archived_labels', 'true');
} else {
url.searchParams.delete('archived_labels');
}
window.location.assign(url);
});
}
export function initRepoIssueFilterItemLabel() {
// the "label-filter" is used in 2 templates: projects/view, issue/filter_list (issue list page including the milestone page)
queryElems(document, '.ui.dropdown.label-filter', initRepoIssueLabelFilter);
}
export function initRepoIssueCommentDelete() {
// Delete comment
document.addEventListener('click', async (e) => {
if (!(e.target as HTMLElement).matches('.delete-comment')) return;
e.preventDefault();
const deleteButton = e.target as HTMLElement;
if (window.confirm(deleteButton.getAttribute('data-locale')!)) {
try {
const response = await POST(deleteButton.getAttribute('data-url')!);
if (!response.ok) throw new Error('Failed to delete comment');
const conversationHolder = deleteButton.closest('.conversation-holder');
const parentTimelineItem = deleteButton.closest('.timeline-item');
const parentTimelineGroup = deleteButton.closest('.timeline-item-group');
// Check if this was a pending comment.
if (conversationHolder?.querySelector('.pending-label')) {
const counter = document.querySelector('#review-box .review-comments-counter')!;
let num = parseInt(counter?.getAttribute('data-pending-comment-number') || '') - 1 || 0;
num = Math.max(num, 0);
counter.setAttribute('data-pending-comment-number', String(num));
counter.textContent = String(num);
}
document.querySelector(`#${deleteButton.getAttribute('data-comment-id')}`)?.remove();
if (conversationHolder && !conversationHolder.querySelector('.comment')) {
const path = conversationHolder.getAttribute('data-path');
const side = conversationHolder.getAttribute('data-side');
const idx = conversationHolder.getAttribute('data-idx');
const lineType = conversationHolder.closest('tr')?.getAttribute('data-line-type');
// the conversation holder could appear either on the "Conversation" page, or the "Files Changed" page
// 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');
} else {
document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`)!.classList.remove('tw-invisible');
}
}
conversationHolder.remove();
}
// Check if there is no review content, move the time avatar upward to avoid overlapping the content below.
if (!parentTimelineGroup?.querySelector('.timeline-item.comment') && !parentTimelineItem?.querySelector('.conversation-holder')) {
const timelineAvatar = parentTimelineGroup?.querySelector('.timeline-avatar');
timelineAvatar?.classList.remove('timeline-avatar-offset');
}
} catch (error) {
console.error(error);
}
}
});
}
export function initRepoIssueCodeCommentCancel() {
// Cancel inline code comment
document.addEventListener('click', (e) => {
if (!(e.target as HTMLElement).matches('.cancel-code-comment')) return;
const form = (e.target as HTMLElement).closest('form')!;
if (form?.classList.contains('comment-form')) {
hideElem(form);
showElem(form.closest('.comment-code-cloud')!.querySelectorAll('button.comment-form-reply'));
} else {
form.closest('.comment-code-cloud')?.remove();
}
});
}
export function initRepoIssueComments() {
if (!document.querySelector('.repository.view.issue .timeline')) return;
document.addEventListener('click', (e: Event) => {
const urlTarget = document.querySelector(':target');
if (!urlTarget) return;
const urlTargetId = urlTarget.id;
if (!urlTargetId) return;
if (!/^(issue|pull)(comment)?-\d+$/.test(urlTargetId)) return;
if (!(e.target as HTMLElement).closest(`#${urlTargetId}`)) {
// if the user clicks outside the comment, remove the hash from the url
// use empty hash and state to avoid scrolling
window.location.hash = ' ';
window.history.pushState(null, '', ' ');
}
});
}
export async function handleReply(el: HTMLElement) {
const form = el.closest('.comment-code-cloud')!.querySelector('.comment-form')!;
const textarea = form.querySelector('textarea');
hideElem(el);
showElem(form);
const editor = getComboMarkdownEditor(textarea) ?? await initComboMarkdownEditor(form.querySelector('.combo-markdown-editor')!);
editor.focus();
return editor;
}
export function initRepoPullRequestReview() {
if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) {
const commentDiv = document.querySelector(window.location.hash);
if (commentDiv) {
// get the name of the parent id
const groupID = commentDiv.closest('div[id^="code-comments-"]')?.getAttribute('id');
if (groupID?.startsWith('code-comments-')) {
const id = groupID.slice(14);
const ancestorDiffBox = commentDiv.closest<HTMLElement>('.diff-file-box');
hideElem(`#show-outdated-${id}`);
showElem(`#code-comments-${id}, #code-preview-${id}, #hide-outdated-${id}`);
// if the comment box is folded, expand it
if (ancestorDiffBox?.getAttribute('data-folded') === 'true') {
setFileFolding(ancestorDiffBox, ancestorDiffBox.querySelector('.fold-file')!, false);
}
}
// set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing
if (window.history.scrollRestoration !== 'manual') window.history.scrollRestoration = 'manual';
// wait for a while because some elements (eg: image, editor, etc.) may change the viewport's height.
setTimeout(() => commentDiv.scrollIntoView({block: 'start'}), 100);
}
}
addDelegatedEventListener(document, 'click', '.show-outdated', (el, e) => {
e.preventDefault();
const id = el.getAttribute('data-comment');
hideElem(el);
showElem(`#code-comments-${id}`);
showElem(`#code-preview-${id}`);
showElem(`#hide-outdated-${id}`);
});
addDelegatedEventListener(document, 'click', '.hide-outdated', (el, e) => {
e.preventDefault();
const id = el.getAttribute('data-comment');
hideElem(el);
hideElem(`#code-comments-${id}`);
hideElem(`#code-preview-${id}`);
showElem(`#show-outdated-${id}`);
});
addDelegatedEventListener(document, 'click', 'button.comment-form-reply', (el, e) => {
e.preventDefault();
handleReply(el);
});
// The following part is only for diff views
if (!document.querySelector('.repository.pull.diff')) return;
const elReviewBtn = document.querySelector('.js-btn-review');
const elReviewPanel = document.querySelector('.review-box-panel.tippy-target');
if (elReviewBtn && elReviewPanel) {
const tippy = createTippy(elReviewBtn, {
content: elReviewPanel,
theme: 'default',
placement: 'bottom',
trigger: 'click',
maxWidth: 'none',
interactive: true,
hideOnClick: true,
});
elReviewPanel.querySelector('.close')!.addEventListener('click', () => tippy.hide());
}
addDelegatedEventListener(document, 'click', '.add-code-comment', async (el, e) => {
e.preventDefault();
const isSplit = el.closest('.code-diff')?.classList.contains('code-diff-split');
const side = el.getAttribute('data-side')!;
const idx = el.getAttribute('data-idx')!;
const path = el.closest('[data-path]')?.getAttribute('data-path');
const tr = el.closest('tr')!;
const lineType = tr.getAttribute('data-line-type')!;
let ntr = tr.nextElementSibling;
if (!ntr?.classList.contains('add-comment')) {
ntr = createElementFromHTML(`
<tr class="add-comment" data-line-type="${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>`);
tr.after(ntr);
}
const td = ntr.querySelector(`.add-comment-${side}`)!;
const commentCloud = td.querySelector('.comment-code-cloud');
if (!commentCloud && !ntr.querySelector('button[name="pending_review"]')) {
const response = await GET(el.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url') ?? '');
td.innerHTML = await response.text();
td.querySelector<HTMLInputElement>("input[name='line']")!.value = idx;
td.querySelector<HTMLInputElement>("input[name='side']")!.value = (side === 'left' ? 'previous' : 'proposed');
td.querySelector<HTMLInputElement>("input[name='path']")!.value = String(path);
const editor = await initComboMarkdownEditor(td.querySelector<HTMLElement>('.combo-markdown-editor')!);
editor.focus();
}
});
}
export function initRepoIssueReferenceIssue() {
const elDropdown = document.querySelector('.issue_reference_repository_search');
if (!elDropdown) return;
const form = elDropdown.closest('form')!;
fomanticQuery(elDropdown).dropdown({
fullTextSearch: true,
apiSettings: {
cache: false,
rawResponse: true,
url: `${appSubUrl}/repo/search?q={query}&limit=20`,
onResponse(response: any) {
const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
for (const repo of response.data) {
filteredResponse.results.push({
name: htmlEscape(repo.repository.full_name),
value: repo.repository.full_name,
});
}
return filteredResponse;
},
},
onChange(_value: string, _text: string, _$choice: any) {
form.setAttribute('action', `${appSubUrl}/${_text}/issues/new`);
},
});
// Reference issue
addDelegatedEventListener(document, 'click', '.reference-issue', (el, e) => {
e.preventDefault();
const target = el.getAttribute('data-target');
const content = document.querySelector(`#${target}`)?.textContent ?? '';
const poster = el.getAttribute('data-poster-username');
const reference = toAbsoluteUrl(el.getAttribute('data-reference')!);
const modalSelector = el.getAttribute('data-modal')!;
const modal = document.querySelector(modalSelector)!;
const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]')!;
textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`;
showFomanticModal(modal);
});
}
export function initRepoIssueWipNewTitle() {
// Toggle WIP for new PR
queryElems(document, '.title_wip_desc > a', (el) => el.addEventListener('click', (e) => {
e.preventDefault();
const wipPrefixes = JSON.parse(el.closest('.title_wip_desc')!.getAttribute('data-wip-prefixes')!);
const titleInput = document.querySelector<HTMLInputElement>('#issue_title')!;
const titleValue = titleInput.value;
for (const prefix of wipPrefixes) {
if (titleValue.startsWith(prefix.toUpperCase())) {
return;
}
}
titleInput.value = `${wipPrefixes[0]} ${titleValue}`;
}));
}
export function initRepoIssueWipToggle() {
// Toggle WIP for existing PR
registerGlobalInitFunc('initPullRequestWipToggle', (toggleWip) => toggleWip.addEventListener('click', async (e) => {
e.preventDefault();
const title = toggleWip.getAttribute('data-title');
const wipPrefix = toggleWip.getAttribute('data-wip-prefix')!;
const updateUrl = toggleWip.getAttribute('data-update-url')!;
const params = new URLSearchParams();
params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
const response = await POST(updateUrl, {data: params});
if (!response.ok) {
showErrorToast(`Failed to toggle 'work in progress' status`);
return;
}
window.location.reload();
}));
}
export function initRepoIssueTitleEdit() {
const issueTitleDisplay = document.querySelector('#issue-title-display')!;
const issueTitleEditor = document.querySelector<HTMLFormElement>('#issue-title-editor');
if (!issueTitleEditor) return;
const issueTitleInput = issueTitleEditor.querySelector('input')!;
const oldTitle = issueTitleInput.getAttribute('data-old-title')!;
issueTitleDisplay.querySelector('#issue-title-edit-show')!.addEventListener('click', () => {
hideElem(issueTitleDisplay);
hideElem('#pull-desc-display');
showElem(issueTitleEditor);
showElem('#pull-desc-editor');
if (!issueTitleInput.value.trim()) {
issueTitleInput.value = oldTitle;
}
issueTitleInput.focus();
});
issueTitleEditor.querySelector('.ui.cancel.button')!.addEventListener('click', () => {
hideElem(issueTitleEditor);
hideElem('#pull-desc-editor');
showElem(issueTitleDisplay);
showElem('#pull-desc-display');
});
const pullDescEditor = document.querySelector('#pull-desc-editor'); // it may not exist for a merged PR
const prTargetUpdateUrl = pullDescEditor?.getAttribute('data-target-update-url');
const editSaveButton = issueTitleEditor.querySelector('.ui.primary.button')!;
issueTitleEditor.addEventListener('submit', async (e) => {
e.preventDefault();
const newTitle = issueTitleInput.value.trim();
try {
if (newTitle && newTitle !== oldTitle) {
const resp = await POST(editSaveButton.getAttribute('data-update-url')!, {data: new URLSearchParams({title: newTitle})});
if (!resp.ok) {
throw new Error(`Failed to update issue title: ${resp.statusText}`);
}
}
if (prTargetUpdateUrl) {
const newTargetBranch = document.querySelector('#pull-target-branch')!.getAttribute('data-branch');
const oldTargetBranch = document.querySelector('#branch_target')!.textContent;
if (newTargetBranch !== oldTargetBranch) {
const resp = await POST(prTargetUpdateUrl, {data: new URLSearchParams({target_branch: String(newTargetBranch)})});
if (!resp.ok) {
throw new Error(`Failed to update PR target branch: ${resp.statusText}`);
}
}
}
ignoreAreYouSure(issueTitleEditor);
window.location.reload();
} catch (error) {
console.error(error);
showErrorToast(errorMessage(error));
}
});
}
export function initRepoIssueBranchSelect() {
document.querySelector<HTMLElement>('#branch-select')?.addEventListener('click', (e: Event) => {
const el = (e.target as HTMLElement).closest('.item[data-branch]');
if (!el) return;
const pullTargetBranch = document.querySelector('#pull-target-branch')!;
const baseName = pullTargetBranch.getAttribute('data-basename');
const branchNameNew = el.getAttribute('data-branch')!;
const branchNameOld = pullTargetBranch.getAttribute('data-branch');
pullTargetBranch.textContent = pullTargetBranch.textContent.replace(`${baseName}:${branchNameOld}`, `${baseName}:${branchNameNew}`);
pullTargetBranch.setAttribute('data-branch', branchNameNew);
});
}
async function initSingleCommentEditor(commentForm: HTMLFormElement) {
// pages:
// * normal new issue/pr page: no status-button, no comment-button (there is only a normal submit button which can submit empty content)
// * issue/pr view page: with comment form, has status-button and comment-button
const editor = await initComboMarkdownEditor(commentForm.querySelector('.combo-markdown-editor')!);
const statusButton = document.querySelector<HTMLButtonElement>('#status-button');
const commentButton = document.querySelector<HTMLButtonElement>('#comment-button');
const syncUiState = () => {
const editorText = editor.value().trim(), isUploading = editor.isUploading();
if (statusButton) {
const statusText = statusButton.getAttribute(editorText ? 'data-status-and-comment' : 'data-status');
statusButton.querySelector<HTMLElement>('.status-button-text')!.textContent = statusText;
statusButton.disabled = isUploading;
}
if (commentButton) {
commentButton.disabled = !editorText || isUploading;
}
};
editor.container.addEventListener(ComboMarkdownEditor.EventUploadStateChanged, syncUiState);
editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, syncUiState);
syncUiState();
}
function initIssueTemplateCommentEditors(commentForm: HTMLFormElement) {
// pages:
// * new issue with issue template
const comboFields = commentForm.querySelectorAll<HTMLElement>('.combo-editor-dropzone');
const initCombo = async (elCombo: HTMLElement) => {
const fieldTextarea = elCombo.querySelector<HTMLTextAreaElement>('.form-field-real')!;
const dropzoneContainer = elCombo.querySelector<HTMLElement>('.form-field-dropzone')!;
const markdownEditor = elCombo.querySelector<HTMLElement>('.combo-markdown-editor')!;
const editor = await initComboMarkdownEditor(markdownEditor);
editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, () => fieldTextarea.value = editor.value());
fieldTextarea.addEventListener('focus', async () => {
// deactivate all markdown editors
showElem(commentForm.querySelectorAll('.combo-editor-dropzone .form-field-real'));
hideElem(commentForm.querySelectorAll('.combo-editor-dropzone .combo-markdown-editor'));
queryElems(commentForm, '.combo-editor-dropzone .form-field-dropzone', (dropzoneContainer) => {
// if "form-field-dropzone" exists, then "dropzone" must also exist
const dropzone = dropzoneContainer.querySelector<HTMLElement>('.dropzone')!.dropzone;
const hasUploadedFiles = dropzone.files.length !== 0;
toggleElem(dropzoneContainer, hasUploadedFiles);
});
// activate this markdown editor
hideElem(fieldTextarea);
showElem(markdownEditor);
showElem(dropzoneContainer);
await editor.switchToUserPreference();
editor.focus();
});
};
for (const el of comboFields) {
initCombo(el);
}
}
export function initRepoCommentFormAndSidebar() {
initRepoIssueSidebar();
const commentForm = document.querySelector<HTMLFormElement>('.comment.form');
if (!commentForm) return;
if (commentForm.querySelector('.field.combo-editor-dropzone')) {
// at the moment, if a form has multiple combo-markdown-editors, it must be an issue template form
initIssueTemplateCommentEditors(commentForm);
} else if (commentForm.querySelector('.combo-markdown-editor')) {
// it's quite unclear about the "comment form" elements, sometimes it's for issue comment, sometimes it's for file editor/uploader message
initSingleCommentEditor(commentForm);
}
}
+77
View File
@@ -0,0 +1,77 @@
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {
initRepoCommentFormAndSidebar,
initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
initRepoIssueComments, initRepoIssueReferenceIssue,
initRepoIssueTitleEdit, initRepoIssueWipNewTitle, initRepoIssueWipToggle,
} from './repo-issue.ts';
import {initUnicodeEscapeButton} from './repo-unicode-escape.ts';
import {initRepoCloneButtons} from './repo-common.ts';
import {initCitationFileCopyContent} from './citation.ts';
import {initCompLabelEdit} from './comp/LabelEdit.ts';
import {initCompReactionSelector} from './comp/ReactionSelector.ts';
import {initRepoSettings} from './repo-settings.ts';
import {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts';
import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
import {initRepoMilestone} from './repo-milestone.ts';
import {initRepoNew} from './repo-new.ts';
import {createApp} from 'vue';
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
import {initRepoPullMergeBox, initRepoPullRequestUpdate} from './repo-issue-pull.ts';
function initRepoBranchTagSelector() {
registerGlobalInitFunc('initRepoBranchTagSelector', async (elRoot: HTMLInputElement) => {
createApp(RepoBranchTagSelector, {elRoot}).mount(elRoot);
});
}
export function initBranchSelectorTabs() {
const elSelectBranches = document.querySelectorAll('.ui.dropdown.select-branch');
for (const elSelectBranch of elSelectBranches) {
queryElems(elSelectBranch, '.reference.column', (el) => el.addEventListener('click', () => {
hideElem(elSelectBranch.querySelectorAll('.scrolling.reference-list-menu'));
showElem(el.getAttribute('data-target')!);
queryElemChildren(el.parentNode!, '.branch-tag-item', (el) => el.classList.remove('active'));
el.classList.add('active');
}));
}
}
export function initRepository() {
registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
registerGlobalInitFunc('initRepoPullRequestUpdate', initRepoPullRequestUpdate);
const pageContent = document.querySelector('.page-content.repository');
if (!pageContent) return;
initRepoBranchTagSelector();
initRepoCommentFormAndSidebar();
// Labels
initCompLabelEdit('.page-content.repository.labels');
initRepoMilestone();
initRepoNew();
initRepoCloneButtons();
initCitationFileCopyContent();
initRepoSettings();
initRepoIssueWipNewTitle();
// Issues
if (pageContent.matches('.page-content.repository.view.issue')) {
initRepoIssueCommentEdit();
initRepoIssueBranchSelect();
initRepoIssueTitleEdit();
initRepoIssueWipToggle();
initRepoIssueComments();
initRepoIssueReferenceIssue();
initRepoIssueCommentDelete();
initRepoIssueCodeCommentCancel();
initCompReactionSelector();
}
initUnicodeEscapeButton();
}
+61
View File
@@ -0,0 +1,61 @@
import {hideElem, showElem} from '../utils/dom.ts';
import {GET, POST} from '../modules/fetch.ts';
export function initRepoMigrationStatusChecker() {
const repoMigrating = document.querySelector('#repo_migrating');
if (!repoMigrating) return;
document.querySelector<HTMLButtonElement>('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry);
const repoLink = repoMigrating.getAttribute('data-migrating-repo-link');
// returns true if the refresh still needs to be called after a while
const refresh = async () => {
const res = await GET(`${repoLink}/-/migrate/status`);
if (res.status !== 200) return true; // continue to refresh if network error occurs
const data = await res.json();
// for all status
if (data.message) {
document.querySelector('#repo_migrating_progress_message')!.textContent = data.message;
}
// TaskStatusFinished
if (data.status === 4) {
window.location.reload();
return false;
}
// TaskStatusFailed
if (data.status === 3) {
hideElem('#repo_migrating_progress');
hideElem('#repo_migrating');
showElem('#repo_migrating_retry');
showElem('#repo_migrating_failed');
showElem('#repo_migrating_failed_image');
document.querySelector('#repo_migrating_failed_error')!.textContent = data.message;
return false;
}
return true; // continue to refresh
};
const syncTaskStatus = async () => {
let doNextRefresh = true;
try {
doNextRefresh = await refresh();
} finally {
if (doNextRefresh) {
setTimeout(syncTaskStatus, 2000);
}
}
};
syncTaskStatus(); // no await
}
async function doMigrationRetry(e: Event) {
await POST((e.target as HTMLElement).getAttribute('data-migrating-task-retry-url')!);
window.location.reload();
}
+80
View File
@@ -0,0 +1,80 @@
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
import {sanitizeRepoName} from './repo-common.ts';
const service = document.querySelector<HTMLInputElement>('#service_type');
const user = document.querySelector<HTMLInputElement>('#auth_username');
const pass = document.querySelector<HTMLInputElement>('#auth_password');
const token = document.querySelector<HTMLInputElement>('#auth_token');
const mirror = document.querySelector<HTMLInputElement>('#mirror');
const lfs = document.querySelector<HTMLInputElement>('#lfs');
const lfsSettings = document.querySelector<HTMLElement>('#lfs_settings')!;
const lfsEndpoint = document.querySelector<HTMLElement>('#lfs_endpoint')!;
const items = document.querySelectorAll<HTMLInputElement>('#migrate_items input[type=checkbox]');
export function initRepoMigration() {
checkAuth();
setLFSSettingsVisibility();
user?.addEventListener('input', () => {checkItems(false)});
pass?.addEventListener('input', () => {checkItems(false)});
token?.addEventListener('input', () => {checkItems(true)});
mirror?.addEventListener('change', () => {checkItems(true)});
document.querySelector('#lfs_settings_show')?.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
showElem(lfsEndpoint);
});
lfs?.addEventListener('change', setLFSSettingsVisibility);
const elCloneAddr = document.querySelector<HTMLInputElement>('#clone_addr');
const elRepoName = document.querySelector<HTMLInputElement>('#repo_name');
if (elCloneAddr && elRepoName) {
let repoNameChanged = false;
elRepoName.addEventListener('input', () => {repoNameChanged = true});
elCloneAddr.addEventListener('input', () => {
if (repoNameChanged) return;
let repoNameFromUrl = elCloneAddr.value.split(/[?#]/)[0];
const parts = /^(.*\/)?((.+?)\/?)$/.exec(repoNameFromUrl);
if (!parts || parts.length < 4) {
elRepoName.value = '';
return;
}
repoNameFromUrl = parts[3].split(/[?#]/)[0];
elRepoName.value = sanitizeRepoName(repoNameFromUrl);
});
}
}
function checkAuth() {
if (!service) return;
const serviceType = Number(service.value);
checkItems(serviceType !== 1);
}
function checkItems(tokenAuth: boolean) {
let enableItems: boolean;
if (tokenAuth) {
enableItems = token?.value !== '';
} else {
enableItems = user?.value !== '' || pass?.value !== '';
}
if (enableItems && Number(service?.value) > 1) {
if (mirror?.checked) {
for (const item of items) {
item.disabled = item.name !== 'wiki';
}
return;
}
for (const item of items) item.disabled = false;
} else {
for (const item of items) item.disabled = true;
}
}
function setLFSSettingsVisibility() {
if (!lfs) return;
const visible = lfs.checked;
toggleElem(lfsSettings, visible);
hideElem(lfsEndpoint);
}
+9
View File
@@ -0,0 +1,9 @@
export function initRepoMilestone() {
const page = document.querySelector('.repository.new.milestone');
if (!page) return;
const deadline = page.querySelector<HTMLInputElement>('form input[name=deadline]')!;
document.querySelector('#milestone-clear-deadline')!.addEventListener('click', () => {
deadline.value = '';
});
}
+99
View File
@@ -0,0 +1,99 @@
import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts';
import {htmlEscape} from '../utils/html.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {sanitizeRepoName} from './repo-common.ts';
const {appSubUrl} = window.config;
function initRepoNewTemplateSearch(form: HTMLFormElement) {
const elSubmitButton = querySingleVisibleElem<HTMLInputElement>(form, '.ui.primary.button')!;
const elCreateRepoErrorMessage = form.querySelector('#create-repo-error-message')!;
const elRepoOwnerDropdown = form.querySelector('#repo_owner_dropdown')!;
const elRepoTemplateDropdown = form.querySelector<HTMLInputElement>('#repo_template_search')!;
const inputRepoTemplate = form.querySelector<HTMLInputElement>('#repo_template')!;
const elTemplateUnits = form.querySelector('#template_units')!;
const elNonTemplate = form.querySelector('#non_template')!;
const checkTemplate = function () {
const hasSelectedTemplate = inputRepoTemplate.value !== '' && inputRepoTemplate.value !== '0';
toggleElem(elTemplateUnits, hasSelectedTemplate);
toggleElem(elNonTemplate, !hasSelectedTemplate);
};
inputRepoTemplate.addEventListener('change', checkTemplate);
checkTemplate();
const $repoOwnerDropdown = fomanticQuery(elRepoOwnerDropdown);
const $repoTemplateDropdown = fomanticQuery(elRepoTemplateDropdown);
const onChangeOwner = function () {
const ownerId = $repoOwnerDropdown.dropdown('get value');
const $ownerItem = $repoOwnerDropdown.dropdown('get item', ownerId);
hideElem(elCreateRepoErrorMessage);
elSubmitButton.disabled = false;
if ($ownerItem?.length) {
const elOwnerItem = $ownerItem[0];
elCreateRepoErrorMessage.textContent = elOwnerItem.getAttribute('data-create-repo-disallowed-prompt') ?? '';
const hasError = Boolean(elCreateRepoErrorMessage.textContent);
toggleElem(elCreateRepoErrorMessage, hasError);
elSubmitButton.disabled = hasError;
}
$repoTemplateDropdown.dropdown('setting', {
apiSettings: {
url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${ownerId}`,
onResponse(response: any) {
const results = [];
results.push({name: '', value: ''}); // empty item means not using template
for (const tmplRepo of response.data) {
results.push({
name: htmlEscape(tmplRepo.repository.full_name),
value: String(tmplRepo.repository.id),
});
}
$repoTemplateDropdown.fomanticExt.onResponseKeepSelectedItem($repoTemplateDropdown, inputRepoTemplate.value);
return {results};
},
cache: false,
},
});
};
$repoOwnerDropdown.dropdown('setting', 'onChange', onChangeOwner);
onChangeOwner();
}
export function initRepoNew() {
const pageContent = document.querySelector('.page-content.repository.new-repo');
if (!pageContent) return;
const form = document.querySelector<HTMLFormElement>('.new-repo-form')!;
const inputGitIgnores = form.querySelector<HTMLInputElement>('input[name="gitignores"]')!;
const inputLicense = form.querySelector<HTMLInputElement>('input[name="license"]')!;
const inputAutoInit = form.querySelector<HTMLInputElement>('input[name="auto_init"]')!;
const updateUiAutoInit = () => {
inputAutoInit.checked = Boolean(inputGitIgnores.value || inputLicense.value);
};
inputGitIgnores.addEventListener('change', updateUiAutoInit);
inputLicense.addEventListener('change', updateUiAutoInit);
updateUiAutoInit();
const inputRepoName = form.querySelector<HTMLInputElement>('input[name="repo_name"]')!;
const inputPrivate = form.querySelector<HTMLInputElement>('input[name="private"]')!;
const updateUiRepoName = () => {
const helps = form.querySelectorAll(`.help[data-help-for-repo-name]`);
hideElem(helps);
let help = form.querySelector(`.help[data-help-for-repo-name="${CSS.escape(inputRepoName.value)}"]`);
if (!help) help = form.querySelector(`.help[data-help-for-repo-name=""]`)!;
showElem(help);
const repoNamePreferPrivate: Record<string, boolean> = {'.profile': false, '.profile-private': true};
const preferPrivate = repoNamePreferPrivate[inputRepoName.value];
// inputPrivate might be disabled because site admin "force private"
if (preferPrivate !== undefined && !inputPrivate.closest('.disabled, [disabled]')) {
inputPrivate.checked = preferPrivate;
}
};
inputRepoName.addEventListener('input', updateUiRepoName);
inputRepoName.addEventListener('change', () => {
inputRepoName.value = sanitizeRepoName(inputRepoName.value);
updateUiRepoName();
});
updateUiRepoName();
initRepoNewTemplateSearch(form);
}
+186
View File
@@ -0,0 +1,186 @@
import {contrastColor} from '../utils/color.ts';
import {createSortable} from '../modules/sortable.ts';
import {POST, request} from '../modules/fetch.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';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {localUserSettings} from '../modules/user-settings.ts';
function updateIssueCount(card: HTMLElement): void {
const parent = card.parentElement!;
const count = parent.querySelectorAll('.issue-card').length;
parent.querySelector('.project-column-issue-count')!.textContent = String(count);
}
async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<void> {
const columnCards = to.querySelectorAll('.issue-card');
updateIssueCount(from);
updateIssueCount(to);
const columnSorting = {
issues: Array.from(columnCards, (card, i) => ({
issueID: parseInt(card.getAttribute('data-issue')!),
sorting: i,
})),
};
try {
await POST(`${to.getAttribute('data-url')}/move`, {
data: columnSorting,
});
} catch (error) {
console.error(error);
if (oldIndex !== undefined) {
from.insertBefore(item, from.children[oldIndex]);
}
}
}
async function initRepoProjectSortable(): Promise<void> {
// the HTML layout is: #project-board.board > .project-column .cards > .issue-card
const mainBoard = document.querySelector<HTMLElement>('#project-board')!;
let boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column');
createSortable(mainBoard, {
group: 'project-column',
draggable: '.project-column',
handle: '.project-column-header',
delayOnTouchOnly: true,
delay: 500,
onSort: async () => { // eslint-disable-line @typescript-eslint/no-misused-promises
boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column');
const columnSorting = {
columns: Array.from(boardColumns, (column, i) => ({
columnID: parseInt(column.getAttribute('data-id')!),
sorting: i,
})),
};
try {
await POST(mainBoard.getAttribute('data-url')!, {
data: columnSorting,
});
} catch (error) {
console.error(error);
}
},
});
for (const boardColumn of boardColumns) {
const boardCardList = boardColumn.querySelector<HTMLElement>('.cards')!;
createSortable(boardCardList, {
group: 'shared',
onAdd: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
onUpdate: moveIssue, // eslint-disable-line @typescript-eslint/no-misused-promises
delayOnTouchOnly: true,
delay: 500,
});
}
}
function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
const elModal = document.querySelector<HTMLElement>('.ui.modal#project-column-modal-edit')!;
const elForm = elModal.querySelector<HTMLFormElement>('form')!;
const elColumnId = elForm.querySelector<HTMLInputElement>('input[name="id"]')!;
const elColumnTitle = elForm.querySelector<HTMLInputElement>('input[name="title"]')!;
const elColumnColor = elForm.querySelector<HTMLInputElement>('input[name="color"]')!;
const attrDataColumnId = 'data-modal-project-column-id';
const attrDataColumnTitle = 'data-modal-project-column-title-input';
const attrDataColumnColor = 'data-modal-project-column-color-input';
// the "new" button is not in project board, so need to query from document
queryElems(document, '.show-project-column-modal-edit', (el) => {
el.addEventListener('click', () => {
elColumnId.value = el.getAttribute(attrDataColumnId)!;
elColumnTitle.value = el.getAttribute(attrDataColumnTitle)!;
elColumnColor.value = el.getAttribute(attrDataColumnColor)!;
elColumnColor.dispatchEvent(new Event('input', {bubbles: true})); // trigger the color picker
});
});
elForm.addEventListener('submit', async (e) => {
e.preventDefault();
const columnId = elColumnId.value;
const actionBaseLink = elForm.getAttribute('data-action-base-link');
const formData = new FormData(elForm);
const formLink = columnId ? `${actionBaseLink}/${columnId}` : `${actionBaseLink}/columns/new`;
const formMethod = columnId ? 'PUT' : 'POST';
try {
elForm.classList.add('is-loading');
await request(formLink, {method: formMethod, data: formData});
if (!columnId) {
window.location.reload(); // newly added column, need to reload the page
return;
}
// 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}"]`)!;
elEditButton.setAttribute(attrDataColumnTitle, elColumnTitle.value);
elEditButton.setAttribute(attrDataColumnColor, elColumnColor.value);
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${columnId}"]`)!;
const elBoardColumnTitle = elBoardColumn.querySelector<HTMLElement>(`.project-column-title-text`)!;
elBoardColumnTitle.textContent = elColumnTitle.value;
if (elColumnColor.value) {
const textColor = contrastColor(elColumnColor.value);
elBoardColumn.style.setProperty('background', elColumnColor.value, 'important');
elBoardColumn.style.setProperty('color', textColor, 'important');
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.color = textColor);
} else {
elBoardColumn.style.removeProperty('background');
elBoardColumn.style.removeProperty('color');
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
}
hideFomanticModal(elModal);
} finally {
elForm.classList.remove('is-loading');
}
});
}
function initRepoProjectToggleFullScreen(elProjectsView: HTMLElement): void {
const enterFullscreenBtn = document.querySelector('.screen-full');
const exitFullscreenBtn = document.querySelector('.screen-normal');
if (!enterFullscreenBtn || !exitFullscreenBtn) return;
const settingKey = 'projects-view-options';
type ProjectsViewOptions = {
fullScreen: boolean;
};
const opts = localUserSettings.getJsonObject<ProjectsViewOptions>(settingKey, {fullScreen: false});
const toggleFullscreenState = (isFullScreen: boolean) => {
toggleFullScreen(elProjectsView, isFullScreen);
toggleElem(enterFullscreenBtn, !isFullScreen);
toggleElem(exitFullscreenBtn, isFullScreen);
opts.fullScreen = isFullScreen;
localUserSettings.setJsonObject(settingKey, opts);
};
enterFullscreenBtn.addEventListener('click', () => toggleFullscreenState(true));
exitFullscreenBtn.addEventListener('click', () => toggleFullscreenState(false));
if (opts.fullScreen) {
// a temporary solution to remember the full screen state, not perfect,
// just make UX better than before, especially for users who need to change the label filter frequently and want to keep full screen mode.
toggleFullscreenState(true);
}
}
export function initRepoProjectsView(): void {
registerGlobalInitFunc('initRepoProjectsView', (elProjectsView) => {
initRepoProjectToggleFullScreen(elProjectsView);
const writableProjectBoard = document.querySelector('#project-board[data-project-board-writable="true"]');
if (!writableProjectBoard) return;
initRepoProjectSortable(); // no await
initRepoProjectColumnEdit(writableProjectBoard);
});
}
+9
View File
@@ -0,0 +1,9 @@
import {guessPreviousReleaseTag} from './repo-release.ts';
test('guessPreviousReleaseTag', async () => {
expect(guessPreviousReleaseTag('v0.9', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('');
expect(guessPreviousReleaseTag('1.3', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.2');
expect(guessPreviousReleaseTag('rel/1.3', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.2');
expect(guessPreviousReleaseTag('v1.3', ['rel/1.0', 'rel/1.2', 'rel/1.4', 'rel/1.6'])).toBe('rel/1.2');
expect(guessPreviousReleaseTag('v2.0', ['v1.0', 'v1.2', 'v1.4', 'v1.6'])).toBe('v1.6');
});
+152
View File
@@ -0,0 +1,152 @@
import {POST} from '../modules/fetch.ts';
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {hideElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {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';
export function initRepoReleaseNew() {
registerGlobalEventFunc('click', 'onReleaseEditAttachmentDelete', (el) => {
const uuid = el.getAttribute('data-uuid')!;
const id = el.getAttribute('data-id')!;
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${uuid}']`)!.value = 'true';
hideElem(`#attachment-${id}`);
});
registerGlobalInitFunc('initReleaseEditForm', (elForm: HTMLFormElement) => {
initTagNameEditor(elForm);
initGenerateReleaseNotes(elForm);
});
}
function getReleaseFormExistingTags(elForm: HTMLFormElement): Array<string> {
return JSON.parse(elForm.getAttribute('data-existing-tags')!);
}
function initTagNameEditor(elForm: HTMLFormElement) {
const tagNameInput = elForm.querySelector<HTMLInputElement>('input[type=text][name=tag_name]');
if (!tagNameInput) return; // only init if tag name input exists (the tag name is editable)
const existingTags = getReleaseFormExistingTags(elForm);
const defaultTagHelperText = elForm.getAttribute('data-tag-helper');
const newTagHelperText = elForm.getAttribute('data-tag-helper-new');
const existingTagHelperText = elForm.getAttribute('data-tag-helper-existing');
const hideTargetInput = function(tagNameInput: HTMLInputElement) {
const value = tagNameInput.value;
const tagHelper = elForm.querySelector('.tag-name-helper')!;
// Old behavior: if the tag already exists, hide the target branch selector and show a helper text to indicate the tag already exists.
// However, it is not right: when creating from an existing tag (not a draft or release yet), it still needs the "target branch"
// So the new logic here: don't hide the target branch selector.
if (existingTags.includes(value)) {
tagHelper.textContent = existingTagHelperText;
} else {
tagHelper.textContent = value ? newTagHelperText : defaultTagHelperText;
}
};
hideTargetInput(tagNameInput); // update on page load because the input may have a value
tagNameInput.addEventListener('input', (e) => {
hideTargetInput(e.target as HTMLInputElement);
});
}
export function guessPreviousReleaseTag(tagName: string, existingTags: Array<string>): string {
let guessedPreviousTag = '', guessedPreviousVer = '';
const cleanup = (s: string) => {
const pos = s.lastIndexOf('/');
if (pos >= 0) s = s.substring(pos + 1);
if (s.substring(0, 1).toLowerCase() === 'v') s = s.substring(1);
return s;
};
const newVer = cleanup(tagName);
for (const s of existingTags) {
const existingVer = cleanup(s);
try {
if (compareVersions(existingVer, newVer) >= 0) continue;
if (!guessedPreviousTag || compareVersions(existingVer, guessedPreviousVer) > 0) {
guessedPreviousTag = s;
guessedPreviousVer = existingVer;
}
} catch {}
}
return guessedPreviousTag;
}
function initGenerateReleaseNotes(elForm: HTMLFormElement) {
const buttonShowModal = elForm.querySelector<HTMLButtonElement>('.button.generate-release-notes')!;
const tagNameInput = elForm.querySelector<HTMLInputElement>('input[name=tag_name]')!;
const targetInput = elForm.querySelector<HTMLInputElement>('input[name=tag_target]')!;
const textMissingTag = buttonShowModal.getAttribute('data-text-missing-tag')!;
const generateUrl = buttonShowModal.getAttribute('data-generate-url')!;
const elModal = document.querySelector('#generate-release-notes-modal')!;
const doSubmit = async (tagName: string) => {
const elPreviousTag = elModal.querySelector<HTMLSelectElement>('[name=previous_tag]')!;
const comboEditor = getComboMarkdownEditor(elForm.querySelector<HTMLElement>('.combo-markdown-editor'))!;
const form = new URLSearchParams();
form.set('tag_name', tagName);
form.set('tag_target', targetInput.value);
form.set('previous_tag', elPreviousTag.value);
elModal.classList.add('loading', 'disabled');
try {
const resp = await POST(generateUrl, {data: form});
const data = await resp.json();
if (!resp.ok) {
showErrorToast(data.errorMessage || resp.statusText);
return;
}
const oldValue = comboEditor.value().trim();
if (oldValue) {
// Don't overwrite existing content. Maybe in the future we can let users decide: overwrite or append or copy-to-clipboard
// GitHub just disables the button if the content is not empty
comboEditor.value(`${oldValue}\n\n${data.content}`);
} else {
comboEditor.value(data.content);
}
} finally {
elModal.classList.remove('loading', 'disabled');
hideFomanticModal(elModal);
comboEditor.focus();
}
};
let inited = false;
const doShowModal = () => {
hideToastsAll();
const tagName = tagNameInput.value.trim();
if (!tagName) {
showErrorToast(textMissingTag, {duration: 3000});
tagNameInput.focus();
return;
}
const existingTags = getReleaseFormExistingTags(elForm);
const $dropdown = fomanticQuery(elModal.querySelector('[name=previous_tag]')!);
if (!inited) {
inited = true;
const values = [];
for (const tagName of existingTags) {
values.push({name: htmlEscape(tagName), value: tagName}); // ATTENTION: dropdown takes the "name" input as raw HTML
}
$dropdown.dropdown('change values', values);
}
$dropdown.dropdown('set selected', guessPreviousReleaseTag(tagName, existingTags));
showFomanticModal(elModal, {
onApprove: () => {
doSubmit(tagName); // don't await, need to return false to keep the modal
return false;
},
});
};
buttonShowModal.addEventListener('click', doShowModal);
}
+23
View File
@@ -0,0 +1,23 @@
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();
});
}
@@ -0,0 +1,58 @@
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
import {POST} from '../modules/fetch.ts';
import {createSortable} from '../modules/sortable.ts';
import type {SortableEvent} from 'sortablejs';
vi.mock('../modules/fetch.ts', () => ({
POST: vi.fn(),
}));
vi.mock('../modules/sortable.ts', () => ({
createSortable: vi.fn(),
}));
const branchesHTML = `
<div id="protected-branches-list" data-update-priority-url="some/repo/branches/priority">
<div class="item" data-id="1">
<div class="drag-handle"></div>
</div>
<div class="item" data-id="2">
<div class="drag-handle"></div>
</div>
<div class="item" data-id="3">
<div class="drag-handle"></div>
</div>
</div>
`;
describe('Repository Branch Settings', () => {
test('should initialize sortable for protected branches list', () => {
document.body.innerHTML = branchesHTML;
const callsBefore = vi.mocked(createSortable).mock.calls.length;
initRepoSettingsBranchesDrag();
const newCalls = vi.mocked(createSortable).mock.calls.slice(callsBefore);
expect(newCalls).toHaveLength(1);
expect(newCalls[0][0]).toBe(document.querySelector('#protected-branches-list'));
expect(newCalls[0][1]).toMatchObject({handle: '.drag-handle', animation: 150});
});
test('should not initialize if protected branches list is not present', () => {
document.querySelector('#protected-branches-list')?.remove();
const callsBefore = vi.mocked(createSortable).mock.calls.length;
initRepoSettingsBranchesDrag();
expect(vi.mocked(createSortable).mock.calls.length).toBe(callsBefore);
});
test('should post new order after sorting', () => {
document.body.innerHTML = branchesHTML;
vi.mocked(POST).mockResolvedValue({ok: true} as Response);
const callsBefore = vi.mocked(createSortable).mock.calls.length;
initRepoSettingsBranchesDrag();
const onEnd = vi.mocked(createSortable).mock.calls[callsBefore][1]!.onEnd!;
onEnd(new Event('SortableEvent') as SortableEvent);
expect(POST).toHaveBeenCalledWith(
'some/repo/branches/priority',
expect.objectContaining({data: {ids: [1, 2, 3]}}),
);
});
});
@@ -0,0 +1,32 @@
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');
if (!protectedBranchesList) return;
createSortable(protectedBranchesList, {
handle: '.drag-handle',
animation: 150,
onEnd: () => {
(async () => {
const itemElems = queryElemChildren(protectedBranchesList, '.item[data-id]');
const itemIds = Array.from(itemElems, (el) => parseInt(el.getAttribute('data-id')!));
try {
await POST(protectedBranchesList.getAttribute('data-update-priority-url')!, {
data: {
ids: itemIds,
},
});
} catch (err) {
showErrorToast(`Failed to update branch protection rule priority: ${errorMessage(err)}`);
}
})();
},
});
}
+142
View File
@@ -0,0 +1,142 @@
import {createCodeEditor} from '../modules/codeeditor/main.ts';
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;
function initRepoSettingsCollaboration() {
// Change collaborator access mode
for (const dropdownEl of queryElems(document, '.page-content.repository .ui.dropdown.access-mode')) {
const textEl = dropdownEl.querySelector(':scope > .text')!;
const $dropdown = fomanticQuery(dropdownEl);
$dropdown.dropdown({
async action(text: string, value: string) {
dropdownEl.classList.add('is-loading', 'loading-icon-2px');
const lastValue = dropdownEl.getAttribute('data-last-value')!;
$dropdown.dropdown('hide');
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);
} finally {
dropdownEl.classList.remove('is-loading');
}
},
onHide() {
// set to the really selected value, defer to next tick to make sure `action` has finished
// its work because the calling order might be onHide -> action
setTimeout(() => {
const $item = $dropdown.dropdown('get item', dropdownEl.getAttribute('data-last-value'));
if ($item) {
$dropdown.dropdown('set selected', dropdownEl.getAttribute('data-last-value'));
} else {
textEl.textContent = '(none)'; // prevent from misleading users when the access mode is undefined
}
}, 0);
},
});
}
}
type TeamSearchResponse = {data: Array<{name: string; permission: string}>};
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() {
if (!document.querySelector('.page-content.repository.settings.edit.githook')) return;
createCodeEditor(document.querySelector<HTMLTextAreaElement>('#content')!);
}
function initRepoSettingsBranches() {
if (!document.querySelector('.repository.settings.branches')) return;
for (const el of document.querySelectorAll<HTMLInputElement>('.toggle-target-enabled')) {
el.addEventListener('change', function () {
const target = document.querySelector(this.getAttribute('data-target')!);
target?.classList.toggle('disabled', !this.checked);
});
}
for (const el of document.querySelectorAll<HTMLInputElement>('.toggle-target-disabled')) {
el.addEventListener('change', function () {
const target = document.querySelector(this.getAttribute('data-target')!);
if (this.checked) target?.classList.add('disabled'); // only disable, do not auto enable
});
}
document.querySelector<HTMLInputElement>('#dismiss_stale_approvals')?.addEventListener('change', function () {
document.querySelector('#ignore_stale_approvals_box')?.classList.toggle('disabled', this.checked);
});
// show the `Matched` mark for the status checks that match the pattern
const markMatchedStatusChecks = () => {
const patterns = (document.querySelector<HTMLTextAreaElement>('#status_check_contexts')!.value || '').split(/[\r\n]+/);
const validPatterns = patterns.map((item) => item.trim()).filter(Boolean as unknown as <T>(x: T | boolean) => x is T);
const marks = document.querySelectorAll('.status-check-matched-mark');
for (const el of marks) {
let matched = false;
const statusCheck = el.getAttribute('data-status-check')!;
for (const pattern of validPatterns) {
if (globMatch(statusCheck, pattern, '/')) {
matched = true;
break;
}
}
toggleElem(el, matched);
}
};
markMatchedStatusChecks();
document.querySelector('#status_check_contexts')!.addEventListener('input', onInputDebounce(markMatchedStatusChecks));
}
function initRepoSettingsOptions() {
const pageContent = document.querySelector('.page-content.repository.settings.options');
if (!pageContent) return;
// toggle related panels for the checkbox/radio inputs, the "selector" may not exist
const toggleTargetContextPanel = (selector: string, enabled: boolean) => {
if (!selector) return;
queryElems(document, selector, (el) => el.classList.toggle('disabled', !enabled));
};
queryElems<HTMLInputElement>(pageContent, '.enable-system', (el) => el.addEventListener('change', () => {
toggleTargetContextPanel(el.getAttribute('data-target')!, el.checked);
toggleTargetContextPanel(el.getAttribute('data-context')!, !el.checked);
}));
queryElems<HTMLInputElement>(pageContent, '.enable-system-radio', (el) => el.addEventListener('change', () => {
toggleTargetContextPanel(el.getAttribute('data-target')!, el.value === 'true');
toggleTargetContextPanel(el.getAttribute('data-context')!, el.value === 'false');
}));
queryElems<HTMLInputElement>(pageContent, '.js-tracker-issue-style', (el) => el.addEventListener('change', () => {
const checkedVal = el.value;
pageContent.querySelector('#tracker-issue-style-regex-box')!.classList.toggle('disabled', checkedVal !== 'regexp');
}));
}
export function initRepoSettings() {
if (!document.querySelector('.page-content.repository.settings')) return;
initRepoSettingsOptions();
initRepoSettingsBranches();
initRepoSettingsCollaboration();
initRepoSettingsSearchTeamBox();
initRepoSettingsGitHook();
initRepoSettingsBranchesDrag();
}
@@ -0,0 +1,28 @@
import {addDelegatedEventListener, hideElem, queryElemSiblings, showElem, toggleElem} from '../utils/dom.ts';
export function initUnicodeEscapeButton() {
// buttons might appear on these pages: file view (code, rendered markdown), diff (commit, pr conversation, pr diff), blame, wiki
addDelegatedEventListener(document, 'click', '.escape-button, .unescape-button, .toggle-escape-button', (btn, e) => {
e.preventDefault();
const unicodeContentSelector = btn.getAttribute('data-unicode-content-selector');
const container = unicodeContentSelector ?
document.querySelector(unicodeContentSelector)! :
btn.closest('.file-content, .non-diff-file-content')!;
const fileView = container.querySelector('.file-code, .file-view') ?? container;
if (btn.matches('.escape-button')) {
fileView.classList.add('unicode-escaped');
hideElem(btn);
showElem(queryElemSiblings(btn, '.unescape-button'));
} else if (btn.matches('.unescape-button')) {
fileView.classList.remove('unicode-escaped');
hideElem(btn);
showElem(queryElemSiblings(btn, '.escape-button'));
} else if (btn.matches('.toggle-escape-button')) {
const isEscaped = fileView.classList.contains('unicode-escaped');
fileView.classList.toggle('unicode-escaped', !isEscaped);
toggleElem(container.querySelectorAll('.unescape-button'), !isEscaped);
toggleElem(container.querySelectorAll('.escape-button'), isEscaped);
}
});
}
@@ -0,0 +1,41 @@
import {createApp} from 'vue';
import {toggleElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import ViewFileTree from '../components/ViewFileTree.vue';
import {registerGlobalEventFunc} from '../modules/observer.ts';
const {appSubUrl} = window.config;
function isUserSignedIn() {
return Boolean(document.querySelector('#navbar .user-menu'));
}
async function toggleSidebar(btn: HTMLElement) {
const elToggleShow = document.querySelector('.repo-view-file-tree-toggle[data-toggle-action="show"]')!;
const elFileTreeContainer = document.querySelector('.repo-view-file-tree-container')!;
const shouldShow = btn.getAttribute('data-toggle-action') === 'show';
toggleElem(elFileTreeContainer, shouldShow);
toggleElem(elToggleShow, !shouldShow);
// FIXME: need to remove "full height" style from parent element
if (!isUserSignedIn()) return;
await POST(`${appSubUrl}/user/settings/update_preferences`, {
data: {codeViewShowFileTree: shouldShow},
});
}
export async function initRepoViewFileTree() {
const sidebar = document.querySelector<HTMLElement>('.repo-view-file-tree-container');
const repoViewContent = document.querySelector('.repo-view-content');
if (!sidebar || !repoViewContent) return;
registerGlobalEventFunc('click', 'onRepoViewFileTreeToggle', toggleSidebar);
const fileTree = sidebar.querySelector('#view-file-tree')!;
createApp(ViewFileTree, {
repoLink: fileTree.getAttribute('data-repo-link'),
treePath: fileTree.getAttribute('data-tree-path'),
currentRefNameSubURL: fileTree.getAttribute('data-current-ref-name-sub-url'),
}).mount(fileTree);
}
+85
View File
@@ -0,0 +1,85 @@
import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {fomanticMobileScreen} from '../modules/fomantic.ts';
import {POST} from '../modules/fetch.ts';
import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {html, htmlRaw} from '../utils/html.ts';
async function initRepoWikiFormEditor() {
const editArea = document.querySelector<HTMLTextAreaElement>('.repository.wiki .combo-markdown-editor textarea');
if (!editArea) return;
const form = document.querySelector('.repository.wiki.new .ui.form')!;
const editorContainer = form.querySelector<HTMLElement>('.combo-markdown-editor')!;
let editor: ComboMarkdownEditor;
let renderRequesting = false;
let lastContent: string = '';
const renderEasyMDEPreview = async function () {
if (renderRequesting) return;
const previewFull = editorContainer.querySelector('.EasyMDEContainer .editor-preview-active');
const previewSide = editorContainer.querySelector('.EasyMDEContainer .editor-preview-active-side');
const previewTarget = previewSide || previewFull;
const newContent = editArea.value;
if (editor && previewTarget && lastContent !== newContent) {
renderRequesting = true;
const formData = new FormData();
formData.append('mode', editor.previewMode);
formData.append('context', editor.previewContext);
formData.append('text', newContent);
try {
const response = await POST(editor.previewUrl, {data: formData});
const data = await response.text();
lastContent = newContent;
previewTarget.innerHTML = html`<div class="render-content markup ui segment">${htmlRaw(data)}</div>`;
} catch (error) {
console.error('Error rendering preview:', error);
} finally {
renderRequesting = false;
setTimeout(renderEasyMDEPreview, 1000);
}
} else {
setTimeout(renderEasyMDEPreview, 1000);
}
};
renderEasyMDEPreview();
editor = await initComboMarkdownEditor(editorContainer, {
// EasyMDE has some problems of height definition, it has inline style height 300px by default, so we also use inline styles to override it.
// And another benefit is that we only need to write the style once for both editors.
// TODO: Move height style to CSS after EasyMDE removal.
editorHeights: {minHeight: '300px', height: 'calc(100vh - 600px)'},
easyMDEOptions: {
previewRender: (_content, previewTarget) => previewTarget.innerHTML, // disable builtin preview render
toolbar: ['bold', 'italic', 'strikethrough', '|',
'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|',
'gitea-code-inline', 'code', 'quote', '|', 'gitea-checkbox-empty', 'gitea-checkbox-checked', '|',
'unordered-list', 'ordered-list', '|',
'link', 'image', 'table', 'horizontal-rule', '|',
'preview', 'fullscreen', 'side-by-side', '|', 'gitea-switch-to-textarea',
] as any, // to use custom toolbar buttons
},
});
form.addEventListener('submit', (e) => {
if (!validateTextareaNonEmpty(editArea)) {
e.preventDefault();
e.stopPropagation();
}
});
}
function collapseWikiTocForMobile(collapse: boolean) {
if (collapse) {
document.querySelector('.wiki-content-toc details')?.removeAttribute('open');
}
}
export function initRepoWikiForm() {
if (!document.querySelector('.page-content.repository.wiki')) return;
fomanticMobileScreen.addEventListener('change', (e) => collapseWikiTocForMobile(e.matches));
collapseWikiTocForMobile(fomanticMobileScreen.matches);
initRepoWikiFormEditor();
}
+10
View File
@@ -0,0 +1,10 @@
export function initSshKeyFormParser() {
// Parse SSH Key
document.querySelector<HTMLTextAreaElement>('#ssh-key-content')?.addEventListener('input', function () {
const arrays = this.value.split(' ');
const title = document.querySelector<HTMLInputElement>('#ssh-key-title')!;
if (!title.value && arrays.length === 3 && arrays[2] !== '') {
title.value = arrays[2];
}
});
}
+113
View File
@@ -0,0 +1,113 @@
import {createTippy} from '../modules/tippy.ts';
import {GET} from '../modules/fetch.ts';
import {hideElem, queryElems, showElem} from '../utils/dom.ts';
import {UserEventsSharedWorker} from '../modules/worker.ts';
const {appSubUrl, notificationSettings, enableTimeTracking} = window.config;
export function initStopwatch() {
if (!enableTimeTracking) {
return;
}
const stopwatchEls = document.querySelectorAll('.active-stopwatch');
const stopwatchPopup = document.querySelector('.active-stopwatch-popup');
if (!stopwatchEls.length || !stopwatchPopup) {
return;
}
// global stop watch (in the head_navbar), it should always work in any case either the EventSource or the PeriodicPoller is used.
const seconds = stopwatchEls[0]?.getAttribute('data-seconds');
if (seconds) {
updateStopwatchTime(parseInt(seconds));
}
for (const stopwatchEl of stopwatchEls) {
stopwatchEl.removeAttribute('href'); // intended for noscript mode only
createTippy(stopwatchEl, {
content: stopwatchPopup.cloneNode(true) as Element,
placement: 'bottom-end',
trigger: 'click',
maxWidth: 'none',
interactive: true,
hideOnClick: true,
theme: 'default',
});
}
let usingPeriodicPoller = false;
const startPeriodicPoller = (timeout: number) => {
if (timeout <= 0 || !Number.isFinite(timeout)) return;
usingPeriodicPoller = true;
setTimeout(() => updateStopwatchWithCallback(startPeriodicPoller, timeout), timeout);
};
// if the browser supports EventSource and SharedWorker, use it instead of the periodic poller
if (notificationSettings.EventSourceUpdateTime > 0 && window.EventSource && window.SharedWorker) {
// Try to connect to the event source via the shared worker first
const worker = new UserEventsSharedWorker('stopwatch-worker');
worker.addMessageEventListener((event) => {
if (event.data.type === 'no-event-source') {
// browser doesn't support EventSource, falling back to periodic poller
if (!usingPeriodicPoller) startPeriodicPoller(notificationSettings.MinTimeout);
} else if (event.data.type === 'stopwatches') {
updateStopwatchData(JSON.parse(event.data.data));
}
});
worker.startPort();
return;
}
startPeriodicPoller(notificationSettings.MinTimeout);
}
async function updateStopwatchWithCallback(callback: (timeout: number) => void, timeout: number) {
const isSet = await updateStopwatch();
if (!isSet) {
timeout = notificationSettings.MinTimeout;
} else if (timeout < notificationSettings.MaxTimeout) {
timeout += notificationSettings.TimeoutStep;
}
callback(timeout);
}
async function updateStopwatch() {
const response = await GET(`${appSubUrl}/user/stopwatches`);
if (!response.ok) {
console.error('Failed to fetch stopwatch data');
return false;
}
const data = await response.json();
return updateStopwatchData(data);
}
function updateStopwatchData(data: any) {
const watch = data[0];
const btnEls = document.querySelectorAll('.active-stopwatch');
if (!watch) {
hideElem(btnEls);
} else {
const {repo_owner_name, repo_name, issue_index, seconds} = watch;
const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`;
document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl);
document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/stop`);
document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`);
const stopwatchIssue = document.querySelector('.stopwatch-issue');
if (stopwatchIssue) stopwatchIssue.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`;
updateStopwatchTime(seconds);
showElem(btnEls);
}
return Boolean(data.length);
}
// TODO: This flickers on page load, we could avoid this by making a custom element to render time periods.
function updateStopwatchTime(seconds: number) {
const hours = seconds / 3600 || 0;
const minutes = seconds / 60 || 0;
const timeText = hours >= 1 ? `${Math.round(hours)}h` : `${Math.round(minutes)}m`;
queryElems(document, '.header-stopwatch-dot', (el) => el.textContent = timeText);
}
+22
View File
@@ -0,0 +1,22 @@
export function initTableSort() {
for (const header of document.querySelectorAll('th[data-sortt-asc]') || []) {
const sorttAsc = header.getAttribute('data-sortt-asc')!;
const sorttDesc = header.getAttribute('data-sortt-desc')!;
const sorttDefault = header.getAttribute('data-sortt-default')!;
header.addEventListener('click', () => {
tableSort(sorttAsc, sorttDesc, sorttDefault);
});
}
}
function tableSort(normSort: string, revSort: string, isDefault: string) {
if (!normSort) return;
if (!revSort) revSort = '';
const url = new URL(window.location.href);
let urlSort = url.searchParams.get('sort');
if (!urlSort && isDefault) urlSort = normSort;
url.searchParams.set('sort', urlSort !== normSort ? normSort : revSort);
window.location.replace(url.href);
}
+60
View File
@@ -0,0 +1,60 @@
import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
import {html, htmlRaw} from '../utils/html.ts';
import {fetchMentions} from '../utils/match.ts';
import type {TributeCollection} from 'tributejs';
import type {Mention} from '../types.ts';
export async function attachTribute(element: HTMLElement) {
const {default: Tribute} = await import('tributejs');
const mentionsUrl = element.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
const emojiCollection: TributeCollection<string> = { // emojis
trigger: ':',
requireLeadingSpace: true,
values: (query: string, cb: (matches: Array<string>) => void) => {
const matches = [];
for (const name of emojiKeys) {
if (name.includes(query)) {
matches.push(name);
if (matches.length > 5) break;
}
}
cb(matches);
},
lookup: (item) => item,
selectTemplate: (item) => {
if (item === undefined) return '';
return emojiString(item.original) ?? '';
},
menuItemTemplate: (item) => {
return html`<div class="tribute-item">${htmlRaw(emojiHTML(item.original))}<span>${item.original}</span></div>`;
},
};
const mentionCollection: TributeCollection<Mention> = {
values: async (_query: string, cb: (matches: Mention[]) => void) => { // eslint-disable-line @typescript-eslint/no-misused-promises
cb(mentionsUrl ? await fetchMentions(mentionsUrl) : []);
},
requireLeadingSpace: true,
menuItemTemplate: (item) => {
const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : '';
return html`
<div class="tribute-item">
<img alt src="${item.original.avatar}" width="21" height="21"/>
<span class="name">${item.original.name}</span>
${htmlRaw(fullNameHtml)}
</div>
`;
},
};
const tribute = new Tribute({
collection: [
emojiCollection,
mentionCollection,
] as TributeCollection<any>[],
noMatchTemplate: () => '',
});
tribute.attach(element);
return tribute;
}
+268
View File
@@ -0,0 +1,268 @@
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;
/** One of the possible values for the `data-webauthn-error-msg` attribute on the webauthn error message element */
type ErrorType = 'general' | 'insecure' | 'browser' | 'unable-to-process' | 'duplicated' | 'unknown';
export async function initUserAuthWebAuthn() {
const elPrompt = document.querySelector('.user.signin.webauthn-prompt');
const elSignInPasskeyBtn = document.querySelector('.signin-passkey');
if (!elPrompt && !elSignInPasskeyBtn) {
return;
}
const errorType = detectWebAuthnSupport();
if (errorType) {
if (elSignInPasskeyBtn) hideElem(elSignInPasskeyBtn);
return;
}
if (elSignInPasskeyBtn) {
elSignInPasskeyBtn.addEventListener('click', loginPasskey);
}
if (elPrompt) {
login2FA();
}
}
async function loginPasskey() {
const res = await GET(`${appSubUrl}/user/webauthn/passkey/assertion`);
if (!res.ok) {
webAuthnError('unknown');
return;
}
const options = await res.json();
options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge);
for (const cred of options.publicKey.allowCredentials ?? []) {
cred.id = decodeURLEncodedBase64(cred.id);
}
try {
const credential = await navigator.credentials.get({
publicKey: options.publicKey,
}) as PublicKeyCredential;
const credResp = credential.response as AuthenticatorAssertionResponse;
// Move data into Arrays in case it is super long
const authData = new Uint8Array(credResp.authenticatorData);
const clientDataJSON = new Uint8Array(credResp.clientDataJSON);
const rawId = new Uint8Array(credential.rawId);
const sig = new Uint8Array(credResp.signature);
const userHandle = new Uint8Array(credResp.userHandle ?? []);
const res = await POST(`${appSubUrl}/user/webauthn/passkey/login`, {
data: {
id: credential.id,
rawId: encodeURLEncodedBase64(rawId),
type: credential.type,
clientExtensionResults: credential.getClientExtensionResults(),
response: {
authenticatorData: encodeURLEncodedBase64(authData),
clientDataJSON: encodeURLEncodedBase64(clientDataJSON),
signature: encodeURLEncodedBase64(sig),
userHandle: encodeURLEncodedBase64(userHandle),
},
},
});
if (res.status === 500) {
webAuthnError('unknown');
return;
} else if (!res.ok) {
webAuthnError('unable-to-process');
return;
}
const reply = await res.json();
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
} catch (err) {
webAuthnError('general', errorMessage(err));
}
}
async function login2FA() {
const res = await GET(`${appSubUrl}/user/webauthn/assertion`);
if (!res.ok) {
webAuthnError('unknown');
return;
}
const options = await res.json();
options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge);
for (const cred of options.publicKey.allowCredentials ?? []) {
cred.id = decodeURLEncodedBase64(cred.id);
}
try {
const credential = await navigator.credentials.get({
publicKey: options.publicKey,
});
await verifyAssertion(credential);
} catch (err) {
if (!options.publicKey.extensions?.appid) {
webAuthnError('general', errorMessage(err));
return;
}
delete options.publicKey.extensions.appid;
try {
const credential = await navigator.credentials.get({
publicKey: options.publicKey,
});
await verifyAssertion(credential);
} catch (err) {
webAuthnError('general', errorMessage(err));
}
}
}
async function verifyAssertion(assertedCredential: any) { // TODO: Credential type does not work
// Move data into Arrays in case it is super long
const authData = new Uint8Array(assertedCredential.response.authenticatorData);
const clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON);
const rawId = new Uint8Array(assertedCredential.rawId);
const sig = new Uint8Array(assertedCredential.response.signature);
const userHandle = new Uint8Array(assertedCredential.response.userHandle);
const res = await POST(`${appSubUrl}/user/webauthn/assertion`, {
data: {
id: assertedCredential.id,
rawId: encodeURLEncodedBase64(rawId),
type: assertedCredential.type,
clientExtensionResults: assertedCredential.getClientExtensionResults(),
response: {
authenticatorData: encodeURLEncodedBase64(authData),
clientDataJSON: encodeURLEncodedBase64(clientDataJSON),
signature: encodeURLEncodedBase64(sig),
userHandle: encodeURLEncodedBase64(userHandle),
},
},
});
if (res.status === 500) {
webAuthnError('unknown');
return;
} else if (!res.ok) {
webAuthnError('unable-to-process');
return;
}
const reply = await res.json();
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
}
async function webauthnRegistered(newCredential: any) { // TODO: Credential type does not work
const attestationObject = new Uint8Array(newCredential.response.attestationObject);
const clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON);
const rawId = new Uint8Array(newCredential.rawId);
const res = await POST(`${appSubUrl}/user/settings/security/webauthn/register`, {
data: {
id: newCredential.id,
rawId: encodeURLEncodedBase64(rawId),
type: newCredential.type,
response: {
attestationObject: encodeURLEncodedBase64(attestationObject),
clientDataJSON: encodeURLEncodedBase64(clientDataJSON),
},
},
});
if (res.status === 409) {
webAuthnError('duplicated');
return;
} else if (res.status !== 201) {
webAuthnError('unknown');
return;
}
window.location.reload();
}
function webAuthnError(errorType: ErrorType, message:string = '') {
const elErrorMsg = document.querySelector(`#webauthn-error-msg`)!;
if (errorType === 'general') {
elErrorMsg.textContent = message || 'unknown error';
} else {
const elTypedError = document.querySelector(`#webauthn-error [data-webauthn-error-msg=${errorType}]`);
if (elTypedError) {
elErrorMsg.textContent = `${elTypedError.textContent}${message ? ` ${message}` : ''}`;
} else {
elErrorMsg.textContent = `unknown error type: ${errorType}${message ? ` ${message}` : ''}`;
}
}
showElem('#webauthn-error');
}
/** Returns the error type or `null` when there was no error. */
function detectWebAuthnSupport(): ErrorType | null {
if (!window.isSecureContext) {
return 'insecure';
}
if (typeof window.PublicKeyCredential !== 'function') {
return 'browser';
}
return null;
}
export function initUserAuthWebAuthnRegister() {
const elRegister = document.querySelector<HTMLInputElement>('#register-webauthn');
if (!elRegister) return;
const errorType = detectWebAuthnSupport();
if (errorType) {
webAuthnError(errorType);
elRegister.disabled = true;
return;
}
elRegister.addEventListener('click', async (e) => {
e.preventDefault();
await webAuthnRegisterRequest();
});
}
async function webAuthnRegisterRequest() {
const elNickname = document.querySelector<HTMLInputElement>('#nickname')!;
const formData = new FormData();
formData.append('name', elNickname.value);
const res = await POST(`${appSubUrl}/user/settings/security/webauthn/request_register`, {
data: formData,
});
if (res.status === 409) {
webAuthnError('duplicated');
return;
} else if (!res.ok) {
webAuthnError('unknown');
return;
}
const options = await res.json();
elNickname.closest('div.field')!.classList.remove('error');
options.publicKey.challenge = decodeURLEncodedBase64(options.publicKey.challenge);
options.publicKey.user.id = decodeURLEncodedBase64(options.publicKey.user.id);
if (options.publicKey.excludeCredentials) {
for (const cred of options.publicKey.excludeCredentials) {
cred.id = decodeURLEncodedBase64(cred.id);
}
}
try {
const credential = await navigator.credentials.create({
publicKey: options.publicKey,
});
await webauthnRegistered(credential);
} catch (err) {
webAuthnError('unknown', errorMessage(err));
}
}
+27
View File
@@ -0,0 +1,27 @@
import {checkAppUrl, checkAppUrlScheme} from './common-page.ts';
export function initUserCheckAppUrl() {
if (!document.querySelector('.page-content.user.signin, .page-content.user.signup, .page-content.user.link-account')) return;
checkAppUrlScheme();
}
export function initUserExternalLogins() {
const container = document.querySelector('#external-login-navigator');
if (!container) return;
// whether the auth method requires app url check (need consistent ROOT_URL with visited URL)
let needCheckAppUrl = false;
for (const link of container.querySelectorAll('.external-login-link')) {
needCheckAppUrl = needCheckAppUrl || link.getAttribute('data-require-appurl-check') === 'true';
link.addEventListener('click', () => {
container.classList.add('is-loading');
setTimeout(() => {
// recover previous content to let user try again, usually redirection will be performed before this action
container.classList.remove('is-loading');
}, 5000);
});
}
if (needCheckAppUrl) {
checkAppUrl();
}
}
+19
View File
@@ -0,0 +1,19 @@
import {hideElem, showElem} from '../utils/dom.ts';
export function initUserSettings() {
if (!document.querySelector('.user.settings.profile')) return;
const usernameInput = document.querySelector<HTMLInputElement>('#username');
if (!usernameInput) return;
usernameInput.addEventListener('input', function () {
const prompt = document.querySelector('#name-change-prompt')!;
const promptRedirect = document.querySelector('#name-change-redirect-prompt')!;
if (this.value.toLowerCase() !== this.getAttribute('data-name')!.toLowerCase()) {
showElem(prompt);
showElem(promptRedirect);
} else {
hideElem(prompt);
hideElem(promptRedirect);
}
});
}