summaryrefslogtreecommitdiffstats
path: root/web_src/js/features
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
commitdd136858f1ea40ad3c94191d647487fa4f31926c (patch)
tree58fec94a7b2a12510c9664b21793f1ed560c6518 /web_src/js/features
parentInitial commit. (diff)
downloadforgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.tar.xz
forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.zip
Adding upstream version 9.0.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'web_src/js/features')
-rw-r--r--web_src/js/features/admin/common.js258
-rw-r--r--web_src/js/features/admin/config.js24
-rw-r--r--web_src/js/features/admin/emails.js14
-rw-r--r--web_src/js/features/admin/users.js39
-rw-r--r--web_src/js/features/autofocus-end.js6
-rw-r--r--web_src/js/features/captcha.js51
-rw-r--r--web_src/js/features/citation.js50
-rw-r--r--web_src/js/features/clipboard.js32
-rw-r--r--web_src/js/features/code-frequency.js21
-rw-r--r--web_src/js/features/codeeditor.js191
-rw-r--r--web_src/js/features/colorpicker.js66
-rw-r--r--web_src/js/features/common-global.js463
-rw-r--r--web_src/js/features/common-issue-list.js68
-rw-r--r--web_src/js/features/common-issue-list.test.js16
-rw-r--r--web_src/js/features/common-organization.js16
-rw-r--r--web_src/js/features/comp/ComboMarkdownEditor.js413
-rw-r--r--web_src/js/features/comp/ConfirmModal.js30
-rw-r--r--web_src/js/features/comp/EasyMDEToolbarActions.js152
-rw-r--r--web_src/js/features/comp/LabelEdit.js96
-rw-r--r--web_src/js/features/comp/Paste.js144
-rw-r--r--web_src/js/features/comp/QuickSubmit.js17
-rw-r--r--web_src/js/features/comp/ReactionSelector.js38
-rw-r--r--web_src/js/features/comp/SearchUserBox.js51
-rw-r--r--web_src/js/features/comp/TextExpander.js61
-rw-r--r--web_src/js/features/comp/WebHookEditor.js28
-rw-r--r--web_src/js/features/contextpopup.js43
-rw-r--r--web_src/js/features/contributors.js29
-rw-r--r--web_src/js/features/copycontent.js56
-rw-r--r--web_src/js/features/dropzone.js7
-rw-r--r--web_src/js/features/emoji.js38
-rw-r--r--web_src/js/features/eventsource.sharedworker.js141
-rw-r--r--web_src/js/features/file-fold.js19
-rw-r--r--web_src/js/features/heatmap.js40
-rw-r--r--web_src/js/features/imagediff.js271
-rw-r--r--web_src/js/features/install.js119
-rw-r--r--web_src/js/features/notification.js192
-rw-r--r--web_src/js/features/org-team.js26
-rw-r--r--web_src/js/features/pull-view-file.js96
-rw-r--r--web_src/js/features/recent-commits.js21
-rw-r--r--web_src/js/features/repo-branch.js42
-rw-r--r--web_src/js/features/repo-code.js195
-rw-r--r--web_src/js/features/repo-code.test.js17
-rw-r--r--web_src/js/features/repo-commit.js27
-rw-r--r--web_src/js/features/repo-common.js83
-rw-r--r--web_src/js/features/repo-diff-commit.js53
-rw-r--r--web_src/js/features/repo-diff-commitselect.js10
-rw-r--r--web_src/js/features/repo-diff-filetree.js17
-rw-r--r--web_src/js/features/repo-diff.js232
-rw-r--r--web_src/js/features/repo-editor.js203
-rw-r--r--web_src/js/features/repo-findfile.js117
-rw-r--r--web_src/js/features/repo-findfile.test.js34
-rw-r--r--web_src/js/features/repo-graph.js155
-rw-r--r--web_src/js/features/repo-home.js147
-rw-r--r--web_src/js/features/repo-issue-content.js154
-rw-r--r--web_src/js/features/repo-issue-list.js245
-rw-r--r--web_src/js/features/repo-issue-pr-form.js10
-rw-r--r--web_src/js/features/repo-issue-pr-status.js10
-rw-r--r--web_src/js/features/repo-issue.js794
-rw-r--r--web_src/js/features/repo-issue.test.js24
-rw-r--r--web_src/js/features/repo-legacy.js610
-rw-r--r--web_src/js/features/repo-migrate.js64
-rw-r--r--web_src/js/features/repo-migration.js69
-rw-r--r--web_src/js/features/repo-projects.js188
-rw-r--r--web_src/js/features/repo-release.js95
-rw-r--r--web_src/js/features/repo-search.js22
-rw-r--r--web_src/js/features/repo-settings.js120
-rw-r--r--web_src/js/features/repo-template.js51
-rw-r--r--web_src/js/features/repo-unicode-escape.js27
-rw-r--r--web_src/js/features/repo-wiki.js89
-rw-r--r--web_src/js/features/sshkey-helper.js10
-rw-r--r--web_src/js/features/stopwatch.js167
-rw-r--r--web_src/js/features/tablesort.js22
-rw-r--r--web_src/js/features/tribute.js57
-rw-r--r--web_src/js/features/user-auth-webauthn.js194
-rw-r--r--web_src/js/features/user-auth.js22
-rw-r--r--web_src/js/features/user-settings.js63
76 files changed, 7882 insertions, 0 deletions
diff --git a/web_src/js/features/admin/common.js b/web_src/js/features/admin/common.js
new file mode 100644
index 0000000..1a5bd6e
--- /dev/null
+++ b/web_src/js/features/admin/common.js
@@ -0,0 +1,258 @@
+import $ from 'jquery';
+import {checkAppUrl} from '../common-global.js';
+import {hideElem, showElem, toggleElem} from '../../utils/dom.js';
+import {POST} from '../../modules/fetch.js';
+
+const {appSubUrl} = window.config;
+
+function onSecurityProtocolChange() {
+ if (Number(document.getElementById('security_protocol')?.value) > 0) {
+ showElem('.has-tls');
+ } else {
+ hideElem('.has-tls');
+ }
+}
+
+export function initAdminCommon() {
+ if (!document.querySelector('.page-content.admin')) return;
+
+ // check whether appUrl(ROOT_URL) is correct, if not, show an error message
+ checkAppUrl();
+
+ // New user
+ if ($('.admin.new.user').length > 0 || $('.admin.edit.user').length > 0) {
+ document.getElementById('login_type')?.addEventListener('change', function () {
+ if (this.value?.substring(0, 1) === '0') {
+ document.getElementById('user_name')?.removeAttribute('disabled');
+ document.getElementById('login_name')?.removeAttribute('required');
+ hideElem('.non-local');
+ showElem('.local');
+ document.getElementById('user_name')?.focus();
+
+ if (this.getAttribute('data-password') === 'required') {
+ document.getElementById('password')?.setAttribute('required', 'required');
+ }
+ } else {
+ if (document.querySelector('.admin.edit.user')) {
+ document.getElementById('user_name')?.setAttribute('disabled', 'disabled');
+ }
+ document.getElementById('login_name')?.setAttribute('required', 'required');
+ showElem('.non-local');
+ hideElem('.local');
+ document.getElementById('login_name')?.focus();
+
+ document.getElementById('password')?.removeAttribute('required');
+ }
+ });
+ }
+
+ function onUsePagedSearchChange() {
+ const searchPageSizeElements = document.querySelectorAll('.search-page-size');
+ if (document.getElementById('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) {
+ hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url');
+ for (const input of document.querySelectorAll('.open_id_connect_auto_discovery_url input[required]')) {
+ input.removeAttribute('required');
+ }
+
+ const provider = document.getElementById('oauth2_provider')?.value;
+ switch (provider) {
+ case 'openidConnect':
+ for (const input of document.querySelectorAll('.open_id_connect_auto_discovery_url input')) {
+ input.setAttribute('required', 'required');
+ }
+ showElem('.open_id_connect_auto_discovery_url');
+ break;
+ default: {
+ const customURLSettings = document.getElementById(`${provider}_customURLSettings`);
+ if (!customURLSettings) break;
+ const customURLRequired = (customURLSettings.getAttribute('data-required') === 'true');
+ document.getElementById('oauth2_use_custom_url').checked = customURLRequired;
+ if (customURLRequired || customURLSettings.getAttribute('data-available') === 'true') {
+ showElem('.oauth2_use_custom_url');
+ }
+ }
+ }
+ onOAuth2UseCustomURLChange(applyDefaultValues);
+ }
+
+ function onOAuth2UseCustomURLChange(applyDefaultValues) {
+ const provider = document.getElementById('oauth2_provider')?.value;
+ hideElem('.oauth2_use_custom_url_field');
+ for (const input of document.querySelectorAll('.oauth2_use_custom_url_field input[required]')) {
+ input.removeAttribute('required');
+ }
+
+ if (document.getElementById('oauth2_use_custom_url')?.checked) {
+ for (const custom of ['token_url', 'auth_url', 'profile_url', 'email_url', 'tenant']) {
+ const customInput = document.getElementById(`${provider}_${custom}`);
+ if (!customInput) continue;
+ if (applyDefaultValues) {
+ document.getElementById(`oauth2_${custom}`).value = customInput.value;
+ }
+ if (customInput.getAttribute('data-available') === 'true') {
+ for (const input of document.querySelectorAll(`.oauth2_${custom} input`)) {
+ input.setAttribute('required', 'required');
+ }
+ showElem(`.oauth2_${custom}`);
+ }
+ }
+ }
+ }
+
+ function onEnableLdapGroupsChange() {
+ toggleElem(document.getElementById('ldap-group-options'), $('.js-ldap-group-toggle')[0].checked);
+ }
+
+ // New authentication
+ if (document.querySelector('.admin.new.authentication')) {
+ document.getElementById('auth_type')?.addEventListener('change', function () {
+ hideElem('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi');
+
+ for (const input of document.querySelectorAll('.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('.binddnrequired')?.classList.remove('required');
+
+ const authType = this.value;
+ switch (authType) {
+ case '2': // LDAP
+ showElem('.ldap');
+ for (const input of document.querySelectorAll('.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('.smtp div.required input, .has-tls')) {
+ input.setAttribute('required', 'required');
+ }
+ break;
+ case '4': // PAM
+ showElem('.pam');
+ for (const input of document.querySelectorAll('.pam input')) {
+ input.setAttribute('required', 'required');
+ }
+ break;
+ case '5': // LDAP
+ showElem('.dldap');
+ for (const input of document.querySelectorAll('.dldap div.required:not(.ldap) input')) {
+ input.setAttribute('required', 'required');
+ }
+ break;
+ case '6': // OAuth2
+ showElem('.oauth2');
+ for (const input of document.querySelectorAll('.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('.sspi div.required input')) {
+ input.setAttribute('required', 'required');
+ }
+ break;
+ }
+ if (authType === '2' || authType === '5') {
+ onSecurityProtocolChange();
+ onEnableLdapGroupsChange();
+ }
+ if (authType === '2') {
+ onUsePagedSearchChange();
+ }
+ });
+ $('#auth_type').trigger('change');
+ document.getElementById('security_protocol')?.addEventListener('change', onSecurityProtocolChange);
+ document.getElementById('use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
+ document.getElementById('oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
+ document.getElementById('oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(true));
+ $('.js-ldap-group-toggle').on('change', onEnableLdapGroupsChange);
+ }
+ // Edit authentication
+ if (document.querySelector('.admin.edit.authentication')) {
+ const authType = document.getElementById('auth_type')?.value;
+ if (authType === '2' || authType === '5') {
+ document.getElementById('security_protocol')?.addEventListener('change', onSecurityProtocolChange);
+ $('.js-ldap-group-toggle').on('change', onEnableLdapGroupsChange);
+ onEnableLdapGroupsChange();
+ if (authType === '2') {
+ document.getElementById('use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
+ }
+ } else if (authType === '6') {
+ document.getElementById('oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
+ document.getElementById('oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(false));
+ onOAuth2Change(false);
+ }
+ }
+
+ if (document.querySelector('.admin.authentication')) {
+ $('#auth_name').on('input', function () {
+ // appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
+ document.getElementById('oauth2-callback-url').textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${encodeURIComponent(this.value)}/callback`;
+ }).trigger('input');
+ }
+
+ // Notice
+ if (document.querySelector('.admin.notice')) {
+ const detailModal = document.getElementById('detail-modal');
+
+ // Attach view detail modals
+ $('.view-detail').on('click', function () {
+ const description = this.closest('tr').querySelector('.notice-description').textContent;
+ detailModal.querySelector('.content pre').textContent = description;
+ $(detailModal).modal('show');
+ return false;
+ });
+
+ // Select actions
+ const checkboxes = document.querySelectorAll('.select.table .ui.checkbox input');
+
+ $('.select.action').on('click', function () {
+ switch ($(this).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.getElementById('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.href = this.getAttribute('data-redirect');
+ });
+ }
+}
diff --git a/web_src/js/features/admin/config.js b/web_src/js/features/admin/config.js
new file mode 100644
index 0000000..c382342
--- /dev/null
+++ b/web_src/js/features/admin/config.js
@@ -0,0 +1,24 @@
+import {showTemporaryTooltip} from '../../modules/tippy.js';
+import {POST} from '../../modules/fetch.js';
+
+const {appSubUrl} = window.config;
+
+export function initAdminConfigs() {
+ const elAdminConfig = document.querySelector('.page-content.admin.config');
+ if (!elAdminConfig) return;
+
+ for (const el of elAdminConfig.querySelectorAll('input[type="checkbox"][data-config-dyn-key]')) {
+ el.addEventListener('change', async () => {
+ try {
+ const resp = await POST(`${appSubUrl}/admin/config`, {
+ data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key'), value: el.checked}),
+ });
+ const json = await resp.json();
+ if (json.errorMessage) throw new Error(json.errorMessage);
+ } catch (ex) {
+ showTemporaryTooltip(el, ex.toString());
+ el.checked = !el.checked;
+ }
+ });
+ }
+}
diff --git a/web_src/js/features/admin/emails.js b/web_src/js/features/admin/emails.js
new file mode 100644
index 0000000..46fafa7
--- /dev/null
+++ b/web_src/js/features/admin/emails.js
@@ -0,0 +1,14 @@
+import $ from 'jquery';
+
+export function initAdminEmails() {
+ function linkEmailAction(e) {
+ const $this = $(this);
+ $('#form-uid').val($this.data('uid'));
+ $('#form-email').val($this.data('email'));
+ $('#form-primary').val($this.data('primary'));
+ $('#form-activate').val($this.data('activate'));
+ $('#change-email-modal').modal('show');
+ e.preventDefault();
+ }
+ $('.link-email-action').on('click', linkEmailAction);
+}
diff --git a/web_src/js/features/admin/users.js b/web_src/js/features/admin/users.js
new file mode 100644
index 0000000..7cac603
--- /dev/null
+++ b/web_src/js/features/admin/users.js
@@ -0,0 +1,39 @@
+export function initAdminUserListSearchForm() {
+ const searchForm = window.config.pageData.adminUserListSearchForm;
+ if (!searchForm) return;
+
+ const form = document.querySelector('#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(`input[name="status_filter[${k}]"][value="${v}"]`)) {
+ input.checked = true;
+ }
+ }
+ }
+
+ for (const radio of form.querySelectorAll('input[type=radio]')) {
+ radio.addEventListener('click', () => {
+ form.submit();
+ });
+ }
+
+ const resetButtons = form.querySelectorAll('.j-reset-status-filter');
+ for (const button of resetButtons) {
+ button.addEventListener('click', (e) => {
+ e.preventDefault();
+ for (const input of form.querySelectorAll('input[type=radio]')) {
+ if (input.name.startsWith('status_filter[')) {
+ input.checked = false;
+ }
+ }
+ form.submit();
+ });
+ }
+}
diff --git a/web_src/js/features/autofocus-end.js b/web_src/js/features/autofocus-end.js
new file mode 100644
index 0000000..da71ce9
--- /dev/null
+++ b/web_src/js/features/autofocus-end.js
@@ -0,0 +1,6 @@
+export function initAutoFocusEnd() {
+ for (const el of document.querySelectorAll('.js-autofocus-end')) {
+ el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus.
+ el.setSelectionRange(el.value.length, el.value.length);
+ }
+}
diff --git a/web_src/js/features/captcha.js b/web_src/js/features/captcha.js
new file mode 100644
index 0000000..c803a50
--- /dev/null
+++ b/web_src/js/features/captcha.js
@@ -0,0 +1,51 @@
+import {isDarkTheme} from '../utils.js';
+
+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': {
+ const {default: mCaptcha} = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue');
+ mCaptcha.INPUT_NAME = 'm-captcha-response';
+ const instanceURL = captchaEl.getAttribute('data-instance-url');
+
+ mCaptcha.default({
+ siteKey: {
+ instanceUrl: new URL(instanceURL),
+ key: siteKey,
+ },
+ });
+ break;
+ }
+ default:
+ }
+}
diff --git a/web_src/js/features/citation.js b/web_src/js/features/citation.js
new file mode 100644
index 0000000..7e26bff
--- /dev/null
+++ b/web_src/js/features/citation.js
@@ -0,0 +1,50 @@
+import $ from 'jquery';
+import {getCurrentLocale} from '../utils.js';
+
+const {pageData} = window.config;
+
+async function initInputCitationValue(inputContent) {
+ const [{Cite, plugins}] = await Promise.all([
+ import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'),
+ import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'),
+ import(/* webpackChunkName: "citation-js-bibtex" */'@citation-js/plugin-bibtex'),
+ ]);
+ const {citationFileContent} = pageData;
+ 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 bibtexOutput = citationFormatter.format('bibtex', {lang});
+ inputContent.value = bibtexOutput;
+}
+
+export async function initCitationFileCopyContent() {
+ if (!pageData.citationFileContent) return;
+
+ const inputContent = document.getElementById('citation-copy-content');
+
+ if (!inputContent) return;
+
+ document.getElementById('cite-repo-button')?.addEventListener('click', async (e) => {
+ const dropdownBtn = e.target.closest('.ui.dropdown.button');
+ dropdownBtn.classList.add('is-loading');
+
+ try {
+ try {
+ await initInputCitationValue(inputContent);
+ } catch (e) {
+ console.error(`initCitationFileCopyContent error: ${e}`, e);
+ return;
+ }
+
+ inputContent.addEventListener('click', () => {
+ inputContent.select();
+ });
+ } finally {
+ dropdownBtn.classList.remove('is-loading');
+ }
+
+ $('#cite-repo-modal').modal('show');
+ });
+}
diff --git a/web_src/js/features/clipboard.js b/web_src/js/features/clipboard.js
new file mode 100644
index 0000000..daf7e2a
--- /dev/null
+++ b/web_src/js/features/clipboard.js
@@ -0,0 +1,32 @@
+import {showTemporaryTooltip} from '../modules/tippy.js';
+import {toAbsoluteUrl} from '../utils.js';
+import {clippie} from 'clippie';
+
+const {copy_success, copy_error} = window.config.i18n;
+
+// Enable clipboard copy from HTML attributes. These properties are supported:
+// - data-clipboard-text: Direct text to copy
+// - data-clipboard-target: Holds a selector for a <input> or <textarea> whose content is copied
+// - data-clipboard-text-type: When set to 'url' will convert relative to absolute urls
+export function initGlobalCopyToClipboardListener() {
+ document.addEventListener('click', async (e) => {
+ const target = e.target.closest('[data-clipboard-text], [data-clipboard-target]');
+ if (!target) return;
+
+ e.preventDefault();
+
+ let text = target.getAttribute('data-clipboard-text');
+ if (!text) {
+ text = document.querySelector(target.getAttribute('data-clipboard-target'))?.value;
+ }
+
+ if (text && target.getAttribute('data-clipboard-text-type') === 'url') {
+ text = toAbsoluteUrl(text);
+ }
+
+ if (text) {
+ const success = await clippie(text);
+ showTemporaryTooltip(target, success ? copy_success : copy_error);
+ }
+ });
+}
diff --git a/web_src/js/features/code-frequency.js b/web_src/js/features/code-frequency.js
new file mode 100644
index 0000000..47e1539
--- /dev/null
+++ b/web_src/js/features/code-frequency.js
@@ -0,0 +1,21 @@
+import {createApp} from 'vue';
+
+export async function initRepoCodeFrequency() {
+ const el = document.getElementById('repo-code-frequency-chart');
+ if (!el) return;
+
+ const {default: RepoCodeFrequency} = await import(/* webpackChunkName: "code-frequency-graph" */'../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');
+ }
+}
diff --git a/web_src/js/features/codeeditor.js b/web_src/js/features/codeeditor.js
new file mode 100644
index 0000000..07a686f
--- /dev/null
+++ b/web_src/js/features/codeeditor.js
@@ -0,0 +1,191 @@
+import tinycolor from 'tinycolor2';
+import {basename, extname, isObject, isDarkTheme} from '../utils.js';
+import {onInputDebounce} from '../utils/dom.js';
+
+const languagesByFilename = {};
+const languagesByExt = {};
+
+const baseOptions = {
+ fontFamily: 'var(--fonts-monospace)',
+ fontSize: 14, // https://github.com/microsoft/monaco-editor/issues/2242
+ guides: {bracketPairs: false, indentation: false},
+ links: false,
+ minimap: {enabled: false},
+ occurrencesHighlight: 'off',
+ overviewRulerLanes: 0,
+ renderLineHighlight: 'all',
+ renderLineHighlightOnlyWhenFocus: true,
+ rulers: false,
+ scrollbar: {horizontalScrollbarSize: 6, verticalScrollbarSize: 6},
+ scrollBeyondLastLine: false,
+ automaticLayout: true,
+};
+
+function getEditorconfig(input) {
+ try {
+ return JSON.parse(input.getAttribute('data-editorconfig'));
+ } catch {
+ return null;
+ }
+}
+
+function initLanguages(monaco) {
+ for (const {filenames, extensions, id} of monaco.languages.getLanguages()) {
+ for (const filename of filenames || []) {
+ languagesByFilename[filename] = id;
+ }
+ for (const extension of extensions || []) {
+ languagesByExt[extension] = id;
+ }
+ }
+}
+
+function getLanguage(filename) {
+ return languagesByFilename[filename] || languagesByExt[extname(filename)] || 'plaintext';
+}
+
+function updateEditor(monaco, editor, filename, lineWrapExts) {
+ editor.updateOptions(getFileBasedOptions(filename, lineWrapExts));
+ const model = editor.getModel();
+ const language = model.getLanguageId();
+ const newLanguage = getLanguage(filename);
+ if (language !== newLanguage) monaco.editor.setModelLanguage(model, newLanguage);
+}
+
+// export editor for customization - https://github.com/go-gitea/gitea/issues/10409
+function exportEditor(editor) {
+ if (!window.codeEditors) window.codeEditors = [];
+ if (!window.codeEditors.includes(editor)) window.codeEditors.push(editor);
+}
+
+export async function createMonaco(textarea, filename, editorOpts) {
+ const monaco = await import(/* webpackChunkName: "monaco" */'monaco-editor');
+
+ initLanguages(monaco);
+ let {language, ...other} = editorOpts;
+ if (!language) language = getLanguage(filename);
+
+ const container = document.createElement('div');
+ container.className = 'monaco-editor-container';
+ textarea.parentNode.append(container);
+
+ // https://github.com/microsoft/monaco-editor/issues/2427
+ // also, monaco can only parse 6-digit hex colors, so we convert the colors to that format
+ const styles = window.getComputedStyle(document.documentElement);
+ const getColor = (name) => tinycolor(styles.getPropertyValue(name).trim()).toString('hex6');
+
+ monaco.editor.defineTheme('gitea', {
+ base: isDarkTheme() ? 'vs-dark' : 'vs',
+ inherit: true,
+ rules: [
+ {
+ background: getColor('--color-code-bg'),
+ },
+ ],
+ colors: {
+ 'editor.background': getColor('--color-code-bg'),
+ 'editor.foreground': getColor('--color-text'),
+ 'editor.inactiveSelectionBackground': getColor('--color-primary-light-4'),
+ 'editor.lineHighlightBackground': getColor('--color-editor-line-highlight'),
+ 'editor.selectionBackground': getColor('--color-primary-light-3'),
+ 'editor.selectionForeground': getColor('--color-primary-light-3'),
+ 'editorLineNumber.background': getColor('--color-code-bg'),
+ 'editorLineNumber.foreground': getColor('--color-secondary-dark-6'),
+ 'editorWidget.background': getColor('--color-body'),
+ 'editorWidget.border': getColor('--color-secondary'),
+ 'input.background': getColor('--color-input-background'),
+ 'input.border': getColor('--color-input-border'),
+ 'input.foreground': getColor('--color-input-text'),
+ 'scrollbar.shadow': getColor('--color-shadow'),
+ 'progressBar.background': getColor('--color-primary'),
+ },
+ });
+
+ const editor = monaco.editor.create(container, {
+ value: textarea.value,
+ theme: 'gitea',
+ language,
+ ...other,
+ });
+
+ monaco.editor.addKeybindingRules([
+ {keybinding: monaco.KeyCode.Enter, command: null}, // disable enter from accepting code completion
+ ]);
+
+ const model = editor.getModel();
+ model.onDidChangeContent(() => {
+ textarea.value = editor.getValue({preserveBOM: true});
+ textarea.dispatchEvent(new Event('change')); // seems to be needed for jquery-are-you-sure
+ });
+
+ exportEditor(editor);
+
+ const loading = document.querySelector('.editor-loading');
+ if (loading) loading.remove();
+
+ return {monaco, editor};
+}
+
+function getFileBasedOptions(filename, lineWrapExts) {
+ return {
+ wordWrap: (lineWrapExts || []).includes(extname(filename)) ? 'on' : 'off',
+ };
+}
+
+function togglePreviewDisplay(previewable) {
+ const previewTab = document.querySelector('a[data-tab="preview"]');
+ if (!previewTab) return;
+
+ if (previewable) {
+ const newUrl = (previewTab.getAttribute('data-url') || '').replace(/(.*)\/.*/, `$1/markup`);
+ previewTab.setAttribute('data-url', newUrl);
+ previewTab.style.display = '';
+ } else {
+ previewTab.style.display = 'none';
+ // If the "preview" tab was active, user changes the filename to a non-previewable one,
+ // then the "preview" tab becomes inactive (hidden), so the "write" tab should become active
+ if (previewTab.classList.contains('active')) {
+ const writeTab = document.querySelector('a[data-tab="write"]');
+ writeTab.click();
+ }
+ }
+}
+
+export async function createCodeEditor(textarea, filenameInput) {
+ const filename = basename(filenameInput.value);
+ const previewableExts = new Set((textarea.getAttribute('data-previewable-extensions') || '').split(','));
+ const lineWrapExts = (textarea.getAttribute('data-line-wrap-extensions') || '').split(',');
+ const previewable = previewableExts.has(extname(filename));
+ const editorConfig = getEditorconfig(filenameInput);
+
+ togglePreviewDisplay(previewable);
+
+ const {monaco, editor} = await createMonaco(textarea, filename, {
+ ...baseOptions,
+ ...getFileBasedOptions(filenameInput.value, lineWrapExts),
+ ...getEditorConfigOptions(editorConfig),
+ });
+
+ filenameInput.addEventListener('input', onInputDebounce(() => {
+ const filename = filenameInput.value;
+ const previewable = previewableExts.has(extname(filename));
+ togglePreviewDisplay(previewable);
+ updateEditor(monaco, editor, filename, lineWrapExts);
+ }));
+
+ return editor;
+}
+
+function getEditorConfigOptions(ec) {
+ if (!isObject(ec)) return {};
+
+ const opts = {};
+ opts.detectIndentation = !('indent_style' in ec) || !('indent_size' in ec);
+ if ('indent_size' in ec) opts.indentSize = Number(ec.indent_size);
+ if ('tab_width' in ec) opts.tabSize = Number(ec.tab_width) || opts.indentSize;
+ if ('max_line_length' in ec) opts.rulers = [Number(ec.max_line_length)];
+ opts.trimAutoWhitespace = ec.trim_trailing_whitespace === true;
+ opts.insertSpaces = ec.indent_style === 'space';
+ opts.useTabStops = ec.indent_style === 'tab';
+ return opts;
+}
diff --git a/web_src/js/features/colorpicker.js b/web_src/js/features/colorpicker.js
new file mode 100644
index 0000000..6d00d90
--- /dev/null
+++ b/web_src/js/features/colorpicker.js
@@ -0,0 +1,66 @@
+import {createTippy} from '../modules/tippy.js';
+
+export async function initColorPickers() {
+ const els = document.getElementsByClassName('js-color-picker-input');
+ if (!els.length) return;
+
+ await Promise.all([
+ import(/* webpackChunkName: "colorpicker" */'vanilla-colorful/hex-color-picker.js'),
+ import(/* webpackChunkName: "colorpicker" */'../../css/features/colorpicker.css'),
+ ]);
+
+ for (const el of els) {
+ initPicker(el);
+ }
+}
+
+function updateSquare(el, newValue) {
+ el.style.color = /#[0-9a-f]{6}/i.test(newValue) ? newValue : 'transparent';
+}
+
+function updatePicker(el, newValue) {
+ el.setAttribute('color', newValue);
+}
+
+function initPicker(el) {
+ 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.value);
+ updatePicker(picker, e.target.value);
+ });
+
+ createTippy(input, {
+ trigger: 'focus click',
+ theme: 'bare',
+ hideOnClick: true,
+ content: picker,
+ placement: 'bottom-start',
+ interactive: true,
+ onShow() {
+ updatePicker(picker, input.value);
+ },
+ });
+
+ // init precolors
+ for (const colorEl of el.querySelectorAll('.precolors .color')) {
+ colorEl.addEventListener('click', (e) => {
+ const newValue = e.target.getAttribute('data-color-hex');
+ input.value = newValue;
+ input.dispatchEvent(new Event('input', {bubbles: true}));
+ updateSquare(square, newValue);
+ });
+ }
+}
diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js
new file mode 100644
index 0000000..5a304d9
--- /dev/null
+++ b/web_src/js/features/common-global.js
@@ -0,0 +1,463 @@
+import $ from 'jquery';
+import '../vendor/jquery.are-you-sure.js';
+import {clippie} from 'clippie';
+import {createDropzone} from './dropzone.js';
+import {showGlobalErrorMessage} from '../bootstrap.js';
+import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.js';
+import {svg} from '../svg.js';
+import {hideElem, showElem, toggleElem, initSubmitEventPolyfill, submitEventSubmitter} from '../utils/dom.js';
+import {htmlEscape} from 'escape-goat';
+import {showTemporaryTooltip} from '../modules/tippy.js';
+import {confirmModal} from './comp/ConfirmModal.js';
+import {showErrorToast} from '../modules/toast.js';
+import {request, POST, GET} from '../modules/fetch.js';
+import '../htmx.js';
+
+const {appUrl, appSubUrl, csrfToken, i18n} = window.config;
+
+export function initGlobalFormDirtyLeaveConfirm() {
+ // 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 (!$('.user.signin').length) {
+ $('form:not(.ignore-dirty)').areYouSure();
+ }
+}
+
+export function initHeadNavbarContentToggle() {
+ const navbar = document.getElementById('navbar');
+ const btn = document.getElementById('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);
+ });
+}
+
+export function initFootLanguageMenu() {
+ async function linkLanguageAction() {
+ const $this = $(this);
+ await GET($this.data('url'));
+ window.location.reload();
+ }
+
+ $('.language-menu a[lang]').on('click', linkLanguageAction);
+}
+
+export function initGlobalEnterQuickSubmit() {
+ $(document).on('keydown', '.js-quick-submit', (e) => {
+ if (((e.ctrlKey && !e.altKey) || e.metaKey) && (e.key === 'Enter')) {
+ handleGlobalEnterQuickSubmit(e.target);
+ return false;
+ }
+ });
+}
+
+export function initGlobalButtonClickOnEnter() {
+ $(document).on('keypress', 'div.ui.button,span.ui.button', (e) => {
+ if (e.code === ' ' || e.code === 'Enter') {
+ $(e.target).trigger('click');
+ e.preventDefault();
+ }
+ });
+}
+
+// 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) {
+ const form = document.createElement('form');
+ const input = document.createElement('input');
+ form.method = 'post';
+ form.action = `${appSubUrl}/-/fetch-redirect`;
+ input.type = 'hidden';
+ input.name = 'redirect';
+ input.value = redirect;
+ form.append(input);
+ document.body.append(form);
+ form.submit();
+}
+
+async function fetchActionDoRequest(actionElem, url, opt) {
+ try {
+ const resp = await request(url, opt);
+ if (resp.status === 200) {
+ let {redirect} = await resp.json();
+ redirect = redirect || actionElem.getAttribute('data-redirect');
+ actionElem.classList.remove('dirty'); // remove the areYouSure check before reloading
+ if (redirect) {
+ fetchActionDoRedirect(redirect);
+ } else {
+ window.location.reload();
+ }
+ return;
+ } else if (resp.status >= 400 && resp.status < 500) {
+ const data = await resp.json();
+ // 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.
+ if (data.errorMessage) {
+ showErrorToast(data.errorMessage, {useHtmlBody: data.renderFormat === 'html'});
+ } else {
+ showErrorToast(`server error: ${resp.status}`);
+ }
+ } else {
+ showErrorToast(`server error: ${resp.status}`);
+ }
+ } catch (e) {
+ if (e.name !== 'AbortError') {
+ console.error('error when doRequest', e);
+ showErrorToast(`${i18n.network_error} ${e}`);
+ }
+ }
+ actionElem.classList.remove('is-loading', 'loading-icon-2px');
+}
+
+async function formFetchAction(e) {
+ if (!e.target.classList.contains('form-fetch-action')) return;
+
+ e.preventDefault();
+ const formEl = e.target;
+ if (formEl.classList.contains('is-loading')) return;
+
+ formEl.classList.add('is-loading');
+ if (formEl.clientHeight < 50) {
+ formEl.classList.add('loading-icon-2px');
+ }
+
+ const formMethod = formEl.getAttribute('method') || 'get';
+ const formActionUrl = formEl.getAttribute('action');
+ const formData = new FormData(formEl);
+ const formSubmitter = submitEventSubmitter(e);
+ const [submitterName, submitterValue] = [formSubmitter?.getAttribute('name'), formSubmitter?.getAttribute('value')];
+ if (submitterName) {
+ formData.append(submitterName, submitterValue || '');
+ }
+
+ let reqUrl = formActionUrl;
+ const reqOpt = {method: formMethod.toUpperCase()};
+ if (formMethod.toLowerCase() === 'get') {
+ const params = new URLSearchParams();
+ for (const [key, value] of formData) {
+ params.append(key, value.toString());
+ }
+ const pos = reqUrl.indexOf('?');
+ if (pos !== -1) {
+ reqUrl = reqUrl.slice(0, pos);
+ }
+ reqUrl += `?${params.toString()}`;
+ } else {
+ reqOpt.body = formData;
+ }
+
+ await fetchActionDoRequest(formEl, reqUrl, reqOpt);
+}
+
+export function initGlobalCommon() {
+ // Semantic UI modules.
+ const $uiDropdowns = $('.ui.dropdown');
+
+ // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
+ $uiDropdowns.filter(':not(.custom)').dropdown();
+
+ // 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.
+ $uiDropdowns.filter('.jump').dropdown({
+ action: 'hide',
+ onShow() {
+ // hide associated tooltip while dropdown is open
+ this._tippy?.hide();
+ this._tippy?.disable();
+ },
+ onHide() {
+ this._tippy?.enable();
+
+ // hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu
+ setTimeout(() => {
+ const $dropdown = $(this);
+ if ($dropdown.dropdown('is hidden')) {
+ $(this).find('.menu > .item').each((_, item) => {
+ item._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.
+ $uiDropdowns.filter('.upward').dropdown('setting', 'direction', 'upward');
+ $uiDropdowns.filter('.downward').dropdown('setting', 'direction', 'downward');
+
+ $('.tabular.menu .item').tab();
+
+ initSubmitEventPolyfill();
+ document.addEventListener('submit', formFetchAction);
+ document.addEventListener('click', linkAction);
+}
+
+export function initGlobalDropzone() {
+ for (const el of document.querySelectorAll('.dropzone')) {
+ initDropzone(el);
+ }
+}
+
+export function initDropzone(el) {
+ const $dropzone = $(el);
+ const _promise = createDropzone(el, {
+ url: $dropzone.data('upload-url'),
+ headers: {'X-Csrf-Token': csrfToken},
+ maxFiles: $dropzone.data('max-file'),
+ maxFilesize: $dropzone.data('max-size'),
+ acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
+ addRemoveLinks: true,
+ dictDefaultMessage: $dropzone.data('default-message'),
+ dictInvalidFileType: $dropzone.data('invalid-input-type'),
+ dictFileTooBig: $dropzone.data('file-too-big'),
+ dictRemoveFile: $dropzone.data('remove-file'),
+ timeout: 0,
+ thumbnailMethod: 'contain',
+ thumbnailWidth: 480,
+ thumbnailHeight: 480,
+ init() {
+ this.on('success', (file, data) => {
+ file.uuid = data.uuid;
+ const $input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
+ $dropzone.find('.files').append($input);
+ // Create a "Copy Link" element, to conveniently copy the image
+ // or file link as Markdown to the clipboard
+ const copyLinkElement = document.createElement('div');
+ copyLinkElement.className = 'tw-text-center';
+ // The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone
+ copyLinkElement.innerHTML = `<a href="#" style="cursor: pointer;">${svg('octicon-copy', 14, 'copy link')} Copy link</a>`;
+ copyLinkElement.addEventListener('click', async (e) => {
+ e.preventDefault();
+ let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
+ if (file.type.startsWith('image/')) {
+ fileMarkdown = `!${fileMarkdown}`;
+ } else if (file.type.startsWith('video/')) {
+ fileMarkdown = `<video src="/attachments/${file.uuid}" title="${htmlEscape(file.name)}" controls></video>`;
+ }
+ const success = await clippie(fileMarkdown);
+ showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error);
+ });
+ file.previewTemplate.append(copyLinkElement);
+ });
+ this.on('removedfile', (file) => {
+ $(`#${file.uuid}`).remove();
+ if ($dropzone.data('remove-url')) {
+ POST($dropzone.data('remove-url'), {
+ data: new URLSearchParams({file: file.uuid}),
+ });
+ }
+ });
+ this.on('error', function (file, message) {
+ showErrorToast(message);
+ this.removeFile(file);
+ });
+ },
+ });
+}
+
+async function linkAction(e) {
+ // A "link-action" can post AJAX request to its "data-url"
+ // Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading.
+ // If the "link-action" has "data-modal-confirm" attribute, a confirm modal dialog will be shown before taking action.
+ const el = e.target.closest('.link-action');
+ if (!el) return;
+
+ e.preventDefault();
+ const url = el.getAttribute('data-url');
+ const doRequest = async () => {
+ el.disabled = true;
+ await fetchActionDoRequest(el, url, {method: 'POST'});
+ el.disabled = false;
+ };
+
+ const modalConfirmContent = htmlEscape(el.getAttribute('data-modal-confirm') || '');
+ if (!modalConfirmContent) {
+ await doRequest();
+ return;
+ }
+
+ const isRisky = el.classList.contains('red') || el.classList.contains('yellow') || el.classList.contains('orange') || el.classList.contains('negative');
+ if (await confirmModal({content: modalConfirmContent, buttonColor: isRisky ? 'orange' : 'primary'})) {
+ await doRequest();
+ }
+}
+
+export function initGlobalLinkActions() {
+ function showDeletePopup(e) {
+ e.preventDefault();
+ const $this = $(this || e.target);
+ const dataArray = $this.data();
+ let filter = '';
+ if ($this[0].getAttribute('data-modal-id')) {
+ filter += `#${$this[0].getAttribute('data-modal-id')}`;
+ }
+
+ const $dialog = $(`.delete.modal${filter}`);
+ $dialog.find('.name').text($this.data('name'));
+ for (const [key, value] of Object.entries(dataArray)) {
+ if (key && key.startsWith('data')) {
+ $dialog.find(`.${key}`).text(value);
+ }
+ }
+
+ $dialog.modal({
+ closable: false,
+ onApprove: async () => {
+ if ($this.data('type') === 'form') {
+ $($this.data('form')).trigger('submit');
+ return;
+ }
+ if ($this[0].getAttribute('hx-confirm')) {
+ e.detail.issueRequest(true);
+ return;
+ }
+ const postData = new FormData();
+ for (const [key, value] of Object.entries(dataArray)) {
+ if (key && key.startsWith('data')) {
+ postData.append(key.slice(4), value);
+ }
+ if (key === 'id') {
+ postData.append('id', value);
+ }
+ }
+
+ const response = await POST($this.data('url'), {data: postData});
+ if (response.ok) {
+ const data = await response.json();
+ window.location.href = data.redirect;
+ }
+ },
+ }).modal('show');
+ }
+
+ // Helpers.
+ $('.delete-button').on('click', showDeletePopup);
+
+ document.addEventListener('htmx:confirm', (e) => {
+ e.preventDefault();
+ // htmx:confirm is triggered for every HTMX request, even those that don't
+ // have the `hx-confirm` attribute specified. To avoid opening modals for
+ // those elements, check if 'e.detail.question' is empty, which contains the
+ // value of the `hx-confirm` attribute.
+ if (!e.detail.question) {
+ e.detail.issueRequest(true);
+ } else {
+ showDeletePopup(e);
+ }
+ });
+}
+
+function initGlobalShowModal() {
+ // 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 '.target'
+ // * Then, try to query 'target' as HTML tag
+ // If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set.
+ $('.show-modal').on('click', function (e) {
+ e.preventDefault();
+ const modalSelector = this.getAttribute('data-modal');
+ const $modal = $(modalSelector);
+ if (!$modal.length) {
+ throw new Error('no modal for this action');
+ }
+ const modalAttrPrefix = 'data-modal-';
+ for (const attrib of this.attributes) {
+ if (!attrib.name.startsWith(modalAttrPrefix)) {
+ continue;
+ }
+
+ const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
+ const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.');
+ // try to find target by: "#target" -> ".target" -> "target tag"
+ let $attrTarget = $modal.find(`#${attrTargetName}`);
+ if (!$attrTarget.length) $attrTarget = $modal.find(`.${attrTargetName}`);
+ if (!$attrTarget.length) $attrTarget = $modal.find(`${attrTargetName}`);
+ if (!$attrTarget.length) continue; // TODO: show errors in dev mode to remind developers that there is a bug
+
+ if (attrTargetAttr) {
+ $attrTarget[0][attrTargetAttr] = attrib.value;
+ } else if ($attrTarget[0].matches('input, textarea')) {
+ $attrTarget.val(attrib.value); // FIXME: add more supports like checkbox
+ } else {
+ $attrTarget.text(attrib.value); // FIXME: it should be more strict here, only handle div/span/p
+ }
+ }
+
+ $modal.modal('setting', {
+ onApprove: () => {
+ // "form-fetch-action" can handle network errors gracefully,
+ // so keep the modal dialog to make users can re-submit the form if anything wrong happens.
+ if ($modal.find('.form-fetch-action').length) return false;
+ },
+ }).modal('show');
+ });
+}
+
+export function initGlobalButtons() {
+ // 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")
+ $(document).on('click', 'form button.ui.cancel.button', (e) => {
+ e.preventDefault();
+ });
+
+ $('.show-panel').on('click', function (e) {
+ // 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 = this.getAttribute('data-panel');
+ if (this.classList.contains('toggle')) {
+ toggleElem(sel);
+ } else {
+ showElem(sel);
+ }
+ });
+
+ $('.hide-panel').on('click', function (e) {
+ // a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"`
+ e.preventDefault();
+ let sel = this.getAttribute('data-panel');
+ if (sel) {
+ hideElem($(sel));
+ return;
+ }
+ sel = this.getAttribute('data-panel-closest');
+ if (sel) {
+ hideElem($(this).closest(sel));
+ return;
+ }
+ // should never happen, otherwise there is a bug in code
+ showErrorToast('Nothing to hide');
+ });
+
+ initGlobalShowModal();
+}
+
+/**
+ * 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(`Your ROOT_URL in app.ini is "${appUrl}", it's unlikely matching the site you are visiting.
+Mismatched ROOT_URL config causes wrong URL links for web UI/mail content/webhook notification/OAuth2 sign-in.`);
+}
diff --git a/web_src/js/features/common-issue-list.js b/web_src/js/features/common-issue-list.js
new file mode 100644
index 0000000..0c0f6c5
--- /dev/null
+++ b/web_src/js/features/common-issue-list.js
@@ -0,0 +1,68 @@
+import {isElemHidden, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.js';
+import {GET} from '../modules/fetch.js';
+
+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, searchText) {
+ 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.substr(1)}`;
+ }
+ } else {
+ // try to parse it for a global search (eg: "owner/repo#123")
+ const matchIssueOwnerRepoIndex = searchText.match(reIssueOwnerRepoIndex);
+ if (matchIssueOwnerRepoIndex) {
+ const [_, owner, repo, index] = matchIssueOwnerRepoIndex;
+ targetUrl = `${appSubUrl}/${owner}/${repo}/issues/${index}`;
+ }
+ }
+ return targetUrl;
+}
+
+export function initCommonIssueListQuickGoto() {
+ const goto = document.getElementById('issue-list-quick-goto');
+ if (!goto) return;
+
+ const form = goto.closest('form');
+ const input = form.querySelector('input[name=q]');
+ const repoLink = goto.getAttribute('data-repo-link');
+
+ form.addEventListener('submit', (e) => {
+ // if there is no goto button, or the form is submitted by non-quick-goto elements, submit the form directly
+ let doQuickGoto = !isElemHidden(goto);
+ const submitter = submitEventSubmitter(e);
+ if (submitter !== form && submitter !== input && submitter !== goto) doQuickGoto = false;
+ if (!doQuickGoto) return;
+
+ // if there is a goto button, use its link
+ e.preventDefault();
+ window.location.href = goto.getAttribute('data-issue-goto-link');
+ });
+
+ 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`);
+ if (res.status !== 200) targetUrl = '';
+ }
+ // if the input value has changed, then ignore the result
+ if (input.value !== searchText) return;
+
+ toggleElem(goto, Boolean(targetUrl));
+ goto.setAttribute('data-issue-goto-link', targetUrl);
+ };
+
+ input.addEventListener('input', onInputDebounce(onInput));
+ onInput();
+}
diff --git a/web_src/js/features/common-issue-list.test.js b/web_src/js/features/common-issue-list.test.js
new file mode 100644
index 0000000..da7ea64
--- /dev/null
+++ b/web_src/js/features/common-issue-list.test.js
@@ -0,0 +1,16 @@
+import {parseIssueListQuickGotoLink} from './common-issue-list.js';
+
+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('');
+
+ 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');
+});
diff --git a/web_src/js/features/common-organization.js b/web_src/js/features/common-organization.js
new file mode 100644
index 0000000..442714a
--- /dev/null
+++ b/web_src/js/features/common-organization.js
@@ -0,0 +1,16 @@
+import {initCompLabelEdit} from './comp/LabelEdit.js';
+import {toggleElem} from '../utils/dom.js';
+
+export function initCommonOrganization() {
+ if (!document.querySelectorAll('.organization').length) {
+ return;
+ }
+
+ document.querySelector('.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('.organization.settings.labels');
+}
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js
new file mode 100644
index 0000000..70e92de
--- /dev/null
+++ b/web_src/js/features/comp/ComboMarkdownEditor.js
@@ -0,0 +1,413 @@
+import '@github/markdown-toolbar-element';
+import '@github/text-expander-element';
+import $ from 'jquery';
+import {attachTribute} from '../tribute.js';
+import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js';
+import {initEasyMDEPaste, initTextareaPaste} from './Paste.js';
+import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
+import {renderPreviewPanelContent} from '../repo-editor.js';
+import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js';
+import {initTextExpander} from './TextExpander.js';
+import {showErrorToast} from '../../modules/toast.js';
+import {POST} from '../../modules/fetch.js';
+
+let elementIdCounter = 0;
+
+/**
+ * validate if the given textarea is non-empty.
+ * @param {HTMLElement} textarea - The textarea element to be validated.
+ * @returns {boolean} returns true if validation succeeded.
+ */
+export function validateTextareaNonEmpty(textarea) {
+ // 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;
+}
+
+class ComboMarkdownEditor {
+ constructor(container, options = {}) {
+ container._giteaComboMarkdownEditor = this;
+ this.options = options;
+ this.container = container;
+ }
+
+ async init() {
+ this.prepareEasyMDEToolbarActions();
+ this.setupContainer();
+ this.setupTab();
+ this.setupDropzone();
+ this.setupTextarea();
+
+ await this.switchToUserPreference();
+ }
+
+ applyEditorHeights(el, heights) {
+ 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() {
+ initTextExpander(this.container.querySelector('text-expander'));
+ this.container.addEventListener('ce-editor-content-changed', (e) => this.options?.onContentChanged?.(this, e));
+ }
+
+ setupTextarea() {
+ this.textarea = this.container.querySelector('.markdown-text-editor');
+ this.textarea._giteaComboMarkdownEditor = this;
+ this.textarea.id = `_combo_markdown_editor_${String(elementIdCounter++)}`;
+ this.textarea.addEventListener('input', (e) => this.options?.onContentChanged?.(this, e));
+ 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.textareaMarkdownToolbar.querySelector('button[data-md-action="indent"]')?.addEventListener('click', () => {
+ this.indentSelection(false);
+ });
+ this.textareaMarkdownToolbar.querySelector('button[data-md-action="unindent"]')?.addEventListener('click', () => {
+ this.indentSelection(true);
+ });
+
+ this.textarea.addEventListener('keydown', (e) => {
+ if (e.shiftKey) {
+ e.target._shiftDown = true;
+ }
+ if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.altKey) {
+ if (!this.breakLine()) return; // Nothing changed, let the default handler work.
+ this.options?.onContentChanged?.(this, e);
+ e.preventDefault();
+ }
+ });
+ this.textarea.addEventListener('keyup', (e) => {
+ if (!e.shiftKey) {
+ e.target._shiftDown = false;
+ }
+ });
+
+ const monospaceButton = this.container.querySelector('.markdown-switch-monospace');
+ const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true';
+ const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text');
+ monospaceButton.setAttribute('data-tooltip-content', monospaceText);
+ monospaceButton.setAttribute('aria-checked', String(monospaceEnabled));
+
+ monospaceButton?.addEventListener('click', (e) => {
+ e.preventDefault();
+ const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true';
+ localStorage.setItem('markdown-editor-monospace', String(enabled));
+ this.textarea.classList.toggle('tw-font-mono', enabled);
+ const text = monospaceButton.getAttribute(enabled ? 'data-disable-text' : 'data-enable-text');
+ monospaceButton.setAttribute('data-tooltip-content', text);
+ monospaceButton.setAttribute('aria-checked', String(enabled));
+ });
+
+ const easymdeButton = this.container.querySelector('.markdown-switch-easymde');
+ easymdeButton?.addEventListener('click', async (e) => {
+ e.preventDefault();
+ this.userPreferredEditor = 'easymde';
+ await this.switchToEasyMDE();
+ });
+
+ if (this.dropzone) {
+ initTextareaPaste(this.textarea, this.dropzone);
+ }
+ }
+
+ setupDropzone() {
+ const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container');
+ if (dropzoneParentContainer) {
+ this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone');
+ }
+ }
+
+ setupTab() {
+ const $container = $(this.container);
+ const tabs = $container[0].querySelectorAll('.tabular.menu > .item');
+
+ // 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 tabEditor = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer');
+ const tabPreviewer = Array.from(tabs).find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer');
+ tabEditor.setAttribute('data-tab', `markdown-writer-${elementIdCounter}`);
+ tabPreviewer.setAttribute('data-tab', `markdown-previewer-${elementIdCounter}`);
+ const panelEditor = $container[0].querySelector('.ui.tab[data-tab-panel="markdown-writer"]');
+ const panelPreviewer = $container[0].querySelector('.ui.tab[data-tab-panel="markdown-previewer"]');
+ panelEditor.setAttribute('data-tab', `markdown-writer-${elementIdCounter}`);
+ panelPreviewer.setAttribute('data-tab', `markdown-previewer-${elementIdCounter}`);
+ elementIdCounter++;
+
+ tabEditor.addEventListener('click', () => {
+ requestAnimationFrame(() => {
+ this.focus();
+ });
+ });
+
+ $(tabs).tab();
+
+ this.previewUrl = tabPreviewer.getAttribute('data-preview-url');
+ this.previewContext = tabPreviewer.getAttribute('data-preview-context');
+ this.previewMode = this.options.previewMode ?? 'comment';
+ this.previewWiki = this.options.previewWiki ?? false;
+ tabPreviewer.addEventListener('click', async () => {
+ const formData = new FormData();
+ formData.append('mode', this.previewMode);
+ formData.append('context', this.previewContext);
+ formData.append('text', this.value());
+ formData.append('wiki', this.previewWiki);
+ const response = await POST(this.previewUrl, {data: formData});
+ const data = await response.text();
+ renderPreviewPanelContent($(panelPreviewer), data);
+ });
+ }
+
+ 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, actions) {
+ 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') {
+ 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;
+ // EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles.
+ const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde');
+ const easyMDEOpt = {
+ 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', (...args) => {this.options?.onContentChanged?.(this, ...args)});
+ this.easyMDE.codemirror.setOption('extraKeys', {
+ 'Cmd-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
+ 'Ctrl-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
+ Enter: (cm) => {
+ const tributeContainer = document.querySelector('.tribute-container');
+ if (!tributeContainer || tributeContainer.style.display === 'none') {
+ cm.execCommand('newlineAndIndent');
+ }
+ },
+ Up: (cm) => {
+ const tributeContainer = document.querySelector('.tribute-container');
+ if (!tributeContainer || tributeContainer.style.display === 'none') {
+ return cm.execCommand('goLineUp');
+ }
+ },
+ Down: (cm) => {
+ const tributeContainer = document.querySelector('.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(), {mentions: true, emoji: true});
+ initEasyMDEPaste(this.easyMDE, this.dropzone);
+ hideElem(this.textareaMarkdownToolbar);
+ }
+
+ value(v = undefined) {
+ 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);
+ }
+ }
+
+ indentSelection(unindent) {
+ // Indent with 4 spaces, unindent 4 spaces or fewer or a lost tab.
+ const indentPrefix = ' ';
+ const unindentRegex = /^( {1,4}|\t)/;
+
+ // Indent all lines that are included in the selection, partially or whole, while preserving the original selection at the end.
+ const lines = this.textarea.value.split('\n');
+ const changedLines = [];
+ // The current selection or cursor position.
+ const [start, end] = [this.textarea.selectionStart, this.textarea.selectionEnd];
+ // The range containing whole lines that will effectively be replaced.
+ let [editStart, editEnd] = [start, end];
+ // The range that needs to be re-selected to match previous selection.
+ let [newStart, newEnd] = [start, end];
+ // The start and end position of the current line (where end points to the newline or EOF)
+ let [lineStart, lineEnd] = [0, 0];
+
+ for (const line of lines) {
+ lineEnd = lineStart + line.length + 1;
+ if (lineEnd <= start) {
+ lineStart = lineEnd;
+ continue;
+ }
+
+ const updated = unindent ? line.replace(unindentRegex, '') : indentPrefix + line;
+ changedLines.push(updated);
+ const move = updated.length - line.length;
+
+ if (start >= lineStart && start < lineEnd) {
+ editStart = lineStart;
+ newStart = Math.max(start + move, lineStart);
+ }
+
+ newEnd += move;
+ editEnd = lineEnd - 1;
+ lineStart = lineEnd;
+ if (lineStart > end) break;
+ }
+
+ // Update changed lines whole.
+ const text = changedLines.join('\n');
+ this.textarea.focus();
+ this.textarea.setSelectionRange(editStart, editEnd);
+ if (!document.execCommand('insertText', false, text)) {
+ // execCommand is deprecated, but setRangeText (and any other direct value modifications) erases the native undo history.
+ // So only fall back to it if execCommand fails.
+ this.textarea.setRangeText(text);
+ }
+
+ // Set selection to (effectively) be the same as before.
+ this.textarea.setSelectionRange(newStart, Math.max(newStart, newEnd));
+ }
+
+ breakLine() {
+ const [start, end] = [this.textarea.selectionStart, this.textarea.selectionEnd];
+
+ // Do nothing if a range is selected
+ if (start !== end) return false;
+
+ const value = this.textarea.value;
+ // Find the beginning of the current line.
+ const lineStart = Math.max(0, value.lastIndexOf('\n', start - 1) + 1);
+ // Find the end and extract the line.
+ const lineEnd = value.indexOf('\n', start);
+ const line = value.slice(lineStart, lineEnd < 0 ? value.length : lineEnd);
+ // Match any whitespace at the start + any repeatable prefix + exactly one space after.
+ const prefix = line.match(/^\s*((\d+)[.)]\s|[-*+]\s+(\[[ x]\]\s?)?|(>\s+)+)?/);
+
+ // Defer to browser if we can't do anything more useful, or if the cursor is inside the prefix.
+ if (!prefix || !prefix[0].length || lineStart + prefix[0].length > start) return false;
+
+ // Insert newline + prefix.
+ let text = `\n${prefix[0]}`;
+ // Increment a number if present. (perhaps detecting repeating 1. and not doing that then would be a good idea)
+ const num = text.match(/\d+/);
+ if (num) text = text.replace(num[0], Number(num[0]) + 1);
+ text = text.replace('[x]', '[ ]');
+
+ if (!document.execCommand('insertText', false, text)) {
+ this.textarea.setRangeText(text);
+ }
+
+ return true;
+ }
+
+ get userPreferredEditor() {
+ return window.localStorage.getItem(`markdown-editor-${this.options.useScene ?? 'default'}`);
+ }
+ set userPreferredEditor(s) {
+ window.localStorage.setItem(`markdown-editor-${this.options.useScene ?? 'default'}`, s);
+ }
+}
+
+export function getComboMarkdownEditor(el) {
+ if (el instanceof $) el = el[0];
+ return el?._giteaComboMarkdownEditor;
+}
+
+export async function initComboMarkdownEditor(container, options = {}) {
+ if (container instanceof $) {
+ if (container.length !== 1) {
+ throw new Error('initComboMarkdownEditor: container must be a single element');
+ }
+ container = container[0];
+ }
+ if (!container) {
+ throw new Error('initComboMarkdownEditor: container is null');
+ }
+ const editor = new ComboMarkdownEditor(container, options);
+ await editor.init();
+ return editor;
+}
diff --git a/web_src/js/features/comp/ConfirmModal.js b/web_src/js/features/comp/ConfirmModal.js
new file mode 100644
index 0000000..e64996a
--- /dev/null
+++ b/web_src/js/features/comp/ConfirmModal.js
@@ -0,0 +1,30 @@
+import $ from 'jquery';
+import {svg} from '../../svg.js';
+import {htmlEscape} from 'escape-goat';
+
+const {i18n} = window.config;
+
+export async function confirmModal(opts = {content: '', buttonColor: 'primary'}) {
+ return new Promise((resolve) => {
+ const $modal = $(`
+<div class="ui g-modal-confirm modal">
+ <div class="content">${htmlEscape(opts.content)}</div>
+ <div class="actions">
+ <button class="ui cancel button">${svg('octicon-x')} ${i18n.modal_cancel}</button>
+ <button class="ui ${opts.buttonColor || 'primary'} ok button">${svg('octicon-check')} ${i18n.modal_confirm}</button>
+ </div>
+</div>
+`);
+
+ $modal.appendTo(document.body);
+ $modal.modal({
+ onApprove() {
+ resolve(true);
+ },
+ onHidden() {
+ $modal.remove();
+ resolve(false);
+ },
+ }).modal('show');
+ });
+}
diff --git a/web_src/js/features/comp/EasyMDEToolbarActions.js b/web_src/js/features/comp/EasyMDEToolbarActions.js
new file mode 100644
index 0000000..35abb87
--- /dev/null
+++ b/web_src/js/features/comp/EasyMDEToolbarActions.js
@@ -0,0 +1,152 @@
+import {svg} from '../../svg.js';
+
+export function easyMDEToolbarActions(EasyMDE, editor) {
+ const actions = {
+ '|': '|',
+ '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',
+ },
+
+ // Forgejo 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;
+}
diff --git a/web_src/js/features/comp/LabelEdit.js b/web_src/js/features/comp/LabelEdit.js
new file mode 100644
index 0000000..2cc75cc
--- /dev/null
+++ b/web_src/js/features/comp/LabelEdit.js
@@ -0,0 +1,96 @@
+import $ from 'jquery';
+
+function isExclusiveScopeName(name) {
+ return /.*[^/]\/[^/].*/.test(name);
+}
+
+function updateExclusiveLabelEdit(form) {
+ const nameInput = document.querySelector(`${form} .label-name-input`);
+ const exclusiveField = document.querySelector(`${form} .label-exclusive-input-field`);
+ const exclusiveCheckbox = document.querySelector(`${form} .label-exclusive-input`);
+ const exclusiveWarning = document.querySelector(`${form} .label-exclusive-warning`);
+
+ if (isExclusiveScopeName(nameInput.value)) {
+ exclusiveField?.classList.remove('muted');
+ exclusiveField?.removeAttribute('aria-disabled');
+ if (exclusiveCheckbox.checked && exclusiveCheckbox.getAttribute('data-exclusive-warn')) {
+ exclusiveWarning?.classList.remove('tw-hidden');
+ } else {
+ exclusiveWarning?.classList.add('tw-hidden');
+ }
+ } else {
+ exclusiveField?.classList.add('muted');
+ exclusiveField?.setAttribute('aria-disabled', 'true');
+ exclusiveWarning?.classList.add('tw-hidden');
+ }
+}
+
+export function initCompLabelEdit(selector) {
+ if (!$(selector).length) return;
+
+ // Create label
+ $('.new-label.button').on('click', () => {
+ updateExclusiveLabelEdit('.new-label');
+ $('.new-label.modal').modal({
+ onApprove() {
+ const form = document.querySelector('.new-label.form');
+ if (!form.checkValidity()) {
+ form.reportValidity();
+ return false;
+ }
+ $('.new-label.form').trigger('submit');
+ },
+ }).modal('show');
+ return false;
+ });
+
+ // Edit label
+ $('.edit-label-button').on('click', function () {
+ $('#label-modal-id').val($(this).data('id'));
+
+ const $nameInput = $('.edit-label .label-name-input');
+ $nameInput.val($(this).data('title'));
+
+ const $isArchivedCheckbox = $('.edit-label .label-is-archived-input');
+ $isArchivedCheckbox[0].checked = this.hasAttribute('data-is-archived');
+
+ const $exclusiveCheckbox = $('.edit-label .label-exclusive-input');
+ $exclusiveCheckbox[0].checked = this.hasAttribute('data-exclusive');
+ // Warn when label was previously not exclusive and used in issues
+ $exclusiveCheckbox.data('exclusive-warn',
+ $(this).data('num-issues') > 0 &&
+ (!this.hasAttribute('data-exclusive') || !isExclusiveScopeName($nameInput.val())));
+ updateExclusiveLabelEdit('.edit-label');
+
+ $('.edit-label .label-desc-input').val(this.getAttribute('data-description'));
+
+ const colorInput = document.querySelector('.edit-label .js-color-picker-input input');
+ colorInput.value = this.getAttribute('data-color');
+ colorInput.dispatchEvent(new Event('input', {bubbles: true}));
+
+ $('.edit-label.modal').modal({
+ onApprove() {
+ const form = document.querySelector('.edit-label.form');
+ if (!form.checkValidity()) {
+ form.reportValidity();
+ return false;
+ }
+ $('.edit-label.form').trigger('submit');
+ },
+ }).modal('show');
+ return false;
+ });
+
+ $('.new-label .label-name-input').on('input', () => {
+ updateExclusiveLabelEdit('.new-label');
+ });
+ $('.new-label .label-exclusive-input').on('change', () => {
+ updateExclusiveLabelEdit('.new-label');
+ });
+ $('.edit-label .label-name-input').on('input', () => {
+ updateExclusiveLabelEdit('.edit-label');
+ });
+ $('.edit-label .label-exclusive-input').on('change', () => {
+ updateExclusiveLabelEdit('.edit-label');
+ });
+}
diff --git a/web_src/js/features/comp/Paste.js b/web_src/js/features/comp/Paste.js
new file mode 100644
index 0000000..7e4ecbb
--- /dev/null
+++ b/web_src/js/features/comp/Paste.js
@@ -0,0 +1,144 @@
+import {POST} from '../../modules/fetch.js';
+import {getPastedContent, replaceTextareaSelection} from '../../utils/dom.js';
+import {isUrl} from '../../utils/url.js';
+
+async function uploadFile(file, uploadUrl) {
+ const formData = new FormData();
+ formData.append('file', file, file.name);
+
+ const res = await POST(uploadUrl, {data: formData});
+ return await res.json();
+}
+
+function triggerEditorContentChanged(target) {
+ target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true}));
+}
+
+class TextareaEditor {
+ constructor(editor) {
+ this.editor = editor;
+ }
+
+ insertPlaceholder(value) {
+ const editor = this.editor;
+ const startPos = editor.selectionStart;
+ const endPos = editor.selectionEnd;
+ editor.value = editor.value.substring(0, startPos) + value + editor.value.substring(endPos);
+ editor.selectionStart = startPos;
+ editor.selectionEnd = startPos + value.length;
+ editor.focus();
+ triggerEditorContentChanged(editor);
+ }
+
+ replacePlaceholder(oldVal, newVal) {
+ 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 {
+ constructor(editor) {
+ this.editor = editor;
+ }
+
+ insertPlaceholder(value) {
+ 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, newVal) {
+ 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 handleClipboardImages(editor, dropzone, images, e) {
+ const uploadUrl = dropzone.getAttribute('data-upload-url');
+ const filesContainer = dropzone.querySelector('.files');
+
+ if (!dropzone || !uploadUrl || !filesContainer || !images.length) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ for (const img of images) {
+ const name = img.name.slice(0, img.name.lastIndexOf('.'));
+
+ const placeholder = `![${name}](uploading ...)`;
+ editor.insertPlaceholder(placeholder);
+
+ const {uuid} = await uploadFile(img, uploadUrl);
+
+ const url = `/attachments/${uuid}`;
+ const text = `![${name}](${url})`;
+ editor.replacePlaceholder(placeholder, text);
+
+ const input = document.createElement('input');
+ input.setAttribute('name', 'files');
+ input.setAttribute('type', 'hidden');
+ input.setAttribute('id', uuid);
+ input.value = uuid;
+ filesContainer.append(input);
+ }
+}
+
+function handleClipboardText(textarea, text, e) {
+ // when pasting links over selected text, turn it into [text](link), except when shift key is held
+ const {value, selectionStart, selectionEnd, _shiftDown} = textarea;
+ if (_shiftDown) return;
+ const selectedText = value.substring(selectionStart, selectionEnd);
+ const trimmedText = text.trim();
+ if (selectedText && isUrl(trimmedText) && !isUrl(selectedText)) {
+ e.stopPropagation();
+ e.preventDefault();
+ replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`);
+ }
+}
+
+export function initEasyMDEPaste(easyMDE, dropzone) {
+ easyMDE.codemirror.on('paste', (_, e) => {
+ const {images} = getPastedContent(e);
+ if (images.length) {
+ handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e);
+ }
+ });
+}
+
+export function initTextareaPaste(textarea, dropzone) {
+ textarea.addEventListener('paste', (e) => {
+ const {images, text} = getPastedContent(e);
+ if (images.length) {
+ handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e);
+ } else if (text) {
+ handleClipboardText(textarea, text, e);
+ }
+ });
+}
diff --git a/web_src/js/features/comp/QuickSubmit.js b/web_src/js/features/comp/QuickSubmit.js
new file mode 100644
index 0000000..e6d7080
--- /dev/null
+++ b/web_src/js/features/comp/QuickSubmit.js
@@ -0,0 +1,17 @@
+export function handleGlobalEnterQuickSubmit(target) {
+ const form = target.closest('form');
+ if (form) {
+ if (!form.checkValidity()) {
+ form.reportValidity();
+ return;
+ }
+
+ // 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}));
+ } else {
+ // if no form, then the editor is for an AJAX request, dispatch an event to the target, let the target's event handler to do the AJAX request.
+ // the 'ce-' prefix means this is a CustomEvent
+ target.dispatchEvent(new CustomEvent('ce-quick-submit', {bubbles: true}));
+ }
+}
diff --git a/web_src/js/features/comp/ReactionSelector.js b/web_src/js/features/comp/ReactionSelector.js
new file mode 100644
index 0000000..fd4601f
--- /dev/null
+++ b/web_src/js/features/comp/ReactionSelector.js
@@ -0,0 +1,38 @@
+import $ from 'jquery';
+import {POST} from '../../modules/fetch.js';
+
+export function initCompReactionSelector($parent) {
+ $parent.find(`.select-reaction .item.reaction, .comment-reaction-button`).on('click', async function (e) {
+ e.preventDefault();
+
+ if (this.classList.contains('disabled')) return;
+
+ const actionUrl = this.closest('[data-action-url]')?.getAttribute('data-action-url');
+ const reactionContent = this.getAttribute('data-reaction-content');
+ const hasReacted = this.closest('.comment')?.querySelector(`.ui.segment.reactions a[data-reaction-content="${reactionContent}"]`)?.getAttribute('data-has-reacted') === 'true';
+
+ const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, {
+ data: new URLSearchParams({content: reactionContent}),
+ });
+
+ const data = await res.json();
+ if (data && (data.html || data.empty)) {
+ const $content = $(this).closest('.content');
+ let $react = $content.find('.segment.reactions');
+ if ((!data.empty || data.html === '') && $react.length > 0) {
+ $react.remove();
+ }
+ if (!data.empty) {
+ const $attachments = $content.find('.segment.bottom:first');
+ $react = $(data.html);
+ if ($attachments.length > 0) {
+ $react.insertBefore($attachments);
+ } else {
+ $react.appendTo($content);
+ }
+ $react.find('.dropdown').dropdown();
+ initCompReactionSelector($react);
+ }
+ }
+ });
+}
diff --git a/web_src/js/features/comp/SearchUserBox.js b/web_src/js/features/comp/SearchUserBox.js
new file mode 100644
index 0000000..081c474
--- /dev/null
+++ b/web_src/js/features/comp/SearchUserBox.js
@@ -0,0 +1,51 @@
+import $ from 'jquery';
+import {htmlEscape} from 'escape-goat';
+
+const {appSubUrl} = window.config;
+const looksLikeEmailAddressCheck = /^\S+@\S+$/;
+
+export function initCompSearchUserBox() {
+ const searchUserBox = document.getElementById('search-user-box');
+ if (!searchUserBox) return;
+
+ const $searchUserBox = $(searchUserBox);
+ const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true';
+ const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined;
+ $searchUserBox.search({
+ minCharacters: 2,
+ apiSettings: {
+ url: `${appSubUrl}/user/search?active=1&q={query}`,
+ onResponse(response) {
+ const items = [];
+ const searchQuery = $searchUserBox.find('input').val();
+ const searchQueryUppercase = searchQuery.toUpperCase();
+ $.each(response.data, (_i, item) => {
+ const resultItem = {
+ title: item.login,
+ image: item.avatar_url,
+ };
+ if (item.full_name) {
+ resultItem.description = htmlEscape(item.full_name);
+ }
+ if (searchQueryUppercase === item.login.toUpperCase()) {
+ items.unshift(resultItem);
+ } else {
+ items.push(resultItem);
+ }
+ });
+
+ if (allowEmailInput && !items.length && looksLikeEmailAddressCheck.test(searchQuery)) {
+ const resultItem = {
+ title: searchQuery,
+ description: allowEmailDescription,
+ };
+ items.push(resultItem);
+ }
+
+ return {results: items};
+ },
+ },
+ searchFields: ['login', 'full_name'],
+ showNoResults: false,
+ });
+}
diff --git a/web_src/js/features/comp/TextExpander.js b/web_src/js/features/comp/TextExpander.js
new file mode 100644
index 0000000..128a2dd
--- /dev/null
+++ b/web_src/js/features/comp/TextExpander.js
@@ -0,0 +1,61 @@
+import {matchEmoji, matchMention} from '../../utils/match.js';
+import {emojiString} from '../emoji.js';
+
+export function initTextExpander(expander) {
+ expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
+ 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 === '@') {
+ const matches = matchMention(text);
+ if (!matches.length) return provide({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.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);
+ }
+
+ provide({matched: true, fragment: ul});
+ }
+ });
+ expander?.addEventListener('text-expander-value', ({detail}) => {
+ if (detail?.item) {
+ // add a space after @mentions as it's likely the user wants one
+ const suffix = detail.key === '@' ? ' ' : '';
+ detail.value = `${detail.item.getAttribute('data-value')}${suffix}`;
+ }
+ });
+}
diff --git a/web_src/js/features/comp/WebHookEditor.js b/web_src/js/features/comp/WebHookEditor.js
new file mode 100644
index 0000000..522daa1
--- /dev/null
+++ b/web_src/js/features/comp/WebHookEditor.js
@@ -0,0 +1,28 @@
+import {POST} from '../../modules/fetch.js';
+import {toggleElem} from '../../utils/dom.js';
+
+export function initCompWebHookEditor() {
+ if (!document.querySelectorAll('.new.webhook').length) {
+ return;
+ }
+
+ // some webhooks (like Gitea) allow to set the request method (GET/POST), and it would toggle the "Content Type" field
+ const httpMethodInput = document.getElementById('http_method');
+ if (httpMethodInput) {
+ const updateContentType = function () {
+ const visible = httpMethodInput.value === 'POST';
+ toggleElem(document.getElementById('content_type').closest('.field'), visible);
+ };
+ updateContentType();
+ httpMethodInput.addEventListener('change', updateContentType);
+ }
+
+ // Test delivery
+ document.getElementById('test-delivery')?.addEventListener('click', async function () {
+ this.classList.add('is-loading', 'disabled');
+ await POST(this.getAttribute('data-link'));
+ setTimeout(() => {
+ window.location.href = this.getAttribute('data-redirect');
+ }, 5000);
+ });
+}
diff --git a/web_src/js/features/contextpopup.js b/web_src/js/features/contextpopup.js
new file mode 100644
index 0000000..ce90f3e
--- /dev/null
+++ b/web_src/js/features/contextpopup.js
@@ -0,0 +1,43 @@
+import {createApp} from 'vue';
+import ContextPopup from '../components/ContextPopup.vue';
+import {parseIssueHref} from '../utils.js';
+import {createTippy} from '../modules/tippy.js';
+
+export function initContextPopups() {
+ const refIssues = document.querySelectorAll('.ref-issue');
+ attachRefIssueContextPopup(refIssues);
+}
+
+export function attachRefIssueContextPopup(refIssues) {
+ for (const refIssue of refIssues) {
+ if (refIssue.classList.contains('ref-external-issue')) {
+ return;
+ }
+
+ const {owner, repo, index} = parseIssueHref(refIssue.getAttribute('href'));
+ if (!owner) return;
+
+ const el = document.createElement('div');
+ refIssue.parentNode.insertBefore(el, refIssue.nextSibling);
+
+ const view = createApp(ContextPopup);
+
+ try {
+ view.mount(el);
+ } catch (err) {
+ console.error(err);
+ el.textContent = 'ContextPopup failed to load';
+ }
+
+ createTippy(refIssue, {
+ content: el,
+ placement: 'top-start',
+ interactive: true,
+ role: 'dialog',
+ interactiveBorder: 5,
+ onShow: () => {
+ el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: {owner, repo, index}}));
+ },
+ });
+ }
+}
diff --git a/web_src/js/features/contributors.js b/web_src/js/features/contributors.js
new file mode 100644
index 0000000..79b3389
--- /dev/null
+++ b/web_src/js/features/contributors.js
@@ -0,0 +1,29 @@
+import {createApp} from 'vue';
+
+export async function initRepoContributors() {
+ const el = document.getElementById('repo-contributors-chart');
+ if (!el) return;
+
+ const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue');
+ try {
+ const View = createApp(RepoContributors, {
+ repoLink: el.getAttribute('data-repo-link'),
+ 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'),
+ },
+ });
+ View.mount(el);
+ } catch (err) {
+ console.error('RepoContributors failed to load', err);
+ el.textContent = el.getAttribute('data-locale-component-failed-to-load');
+ }
+}
diff --git a/web_src/js/features/copycontent.js b/web_src/js/features/copycontent.js
new file mode 100644
index 0000000..03efe00
--- /dev/null
+++ b/web_src/js/features/copycontent.js
@@ -0,0 +1,56 @@
+import {clippie} from 'clippie';
+import {showTemporaryTooltip} from '../modules/tippy.js';
+import {convertImage} from '../utils.js';
+import {GET} from '../modules/fetch.js';
+
+const {i18n} = window.config;
+
+export function initCopyContent() {
+ const btn = document.getElementById('copy-content');
+ if (!btn || btn.classList.contains('disabled')) return;
+
+ btn.addEventListener('click', async () => {
+ if (btn.classList.contains('is-loading')) return;
+ let content;
+ let isRasterImage = false;
+ const link = btn.getAttribute('data-link');
+
+ // when data-link is present, we perform a fetch. this is either because
+ // the text to copy is not in the DOM or it is an image which should be
+ // fetched to copy in full resolution
+ if (link) {
+ btn.classList.add('is-loading', 'loading-icon-2px');
+ try {
+ const res = await GET(link, {credentials: 'include', redirect: 'follow'});
+ const contentType = res.headers.get('content-type');
+
+ if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
+ isRasterImage = true;
+ content = await res.blob();
+ } else {
+ content = await res.text();
+ }
+ } catch {
+ return showTemporaryTooltip(btn, i18n.copy_error);
+ } finally {
+ btn.classList.remove('is-loading', 'loading-icon-2px');
+ }
+ } else { // text, read from DOM
+ const lineEls = document.querySelectorAll('.file-view .lines-code');
+ content = Array.from(lineEls, (el) => el.textContent).join('');
+ }
+
+ // try copy original first, if that fails and it's an image, convert it to png
+ const success = await clippie(content);
+ if (success) {
+ showTemporaryTooltip(btn, i18n.copy_success);
+ } else {
+ if (isRasterImage) {
+ const success = await clippie(await convertImage(content, 'image/png'));
+ showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error);
+ } else {
+ showTemporaryTooltip(btn, i18n.copy_error);
+ }
+ }
+ });
+}
diff --git a/web_src/js/features/dropzone.js b/web_src/js/features/dropzone.js
new file mode 100644
index 0000000..e7b8a9d
--- /dev/null
+++ b/web_src/js/features/dropzone.js
@@ -0,0 +1,7 @@
+export async function createDropzone(el, opts) {
+ const [{Dropzone}] = await Promise.all([
+ import(/* webpackChunkName: "dropzone" */'dropzone'),
+ import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'),
+ ]);
+ return new Dropzone(el, opts);
+}
diff --git a/web_src/js/features/emoji.js b/web_src/js/features/emoji.js
new file mode 100644
index 0000000..032a3ef
--- /dev/null
+++ b/web_src/js/features/emoji.js
@@ -0,0 +1,38 @@
+import emojis from '../../../assets/emoji.json';
+
+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 = {};
+for (const key of emojiKeys) {
+ emojiMap[key] = tempMap[key];
+}
+
+// retrieve HTML for given emoji name
+export function emojiHTML(name) {
+ let inner;
+ if (Object.hasOwn(customEmojis, name)) {
+ inner = `<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
+ } else {
+ inner = emojiString(name);
+ }
+
+ return `<span class="emoji" title=":${name}:">${inner}</span>`;
+}
+
+// retrieve string for given emoji name
+export function emojiString(name) {
+ return emojiMap[name] || `:${name}:`;
+}
diff --git a/web_src/js/features/eventsource.sharedworker.js b/web_src/js/features/eventsource.sharedworker.js
new file mode 100644
index 0000000..62581cf
--- /dev/null
+++ b/web_src/js/features/eventsource.sharedworker.js
@@ -0,0 +1,141 @@
+const sourcesByUrl = {};
+const sourcesByPort = {};
+
+class Source {
+ constructor(url) {
+ this.url = url;
+ this.eventSource = new EventSource(url);
+ this.listening = {};
+ this.clients = [];
+ this.listen('open');
+ this.listen('close');
+ this.listen('logout');
+ this.listen('notification-count');
+ this.listen('stopwatches');
+ this.listen('error');
+ }
+
+ register(port) {
+ if (this.clients.includes(port)) return;
+
+ this.clients.push(port);
+
+ port.postMessage({
+ type: 'status',
+ message: `registered to ${this.url}`,
+ });
+ }
+
+ deregister(port) {
+ const portIdx = this.clients.indexOf(port);
+ if (portIdx < 0) {
+ return this.clients.length;
+ }
+ this.clients.splice(portIdx, 1);
+ return this.clients.length;
+ }
+
+ close() {
+ if (!this.eventSource) return;
+
+ this.eventSource.close();
+ this.eventSource = null;
+ }
+
+ listen(eventType) {
+ if (this.listening[eventType]) return;
+ this.listening[eventType] = true;
+ this.eventSource.addEventListener(eventType, (event) => {
+ this.notifyClients({
+ type: eventType,
+ data: event.data,
+ });
+ });
+ }
+
+ notifyClients(event) {
+ for (const client of this.clients) {
+ client.postMessage(event);
+ }
+ }
+
+ status(port) {
+ port.postMessage({
+ type: 'status',
+ message: `url: ${this.url} readyState: ${this.eventSource.readyState}`,
+ });
+ }
+}
+
+self.addEventListener('connect', (e) => {
+ for (const port of e.ports) {
+ port.addEventListener('message', (event) => {
+ if (!self.EventSource) {
+ // some browsers (like PaleMoon, Firefox<53) don't support EventSource in SharedWorkerGlobalScope.
+ // this event handler needs EventSource when doing "new Source(url)", so just post a message back to the caller,
+ // in case the caller would like to use a fallback method to do its work.
+ port.postMessage({type: 'no-event-source'});
+ return;
+ }
+ if (event.data.type === 'start') {
+ const url = event.data.url;
+ if (sourcesByUrl[url]) {
+ // we have a Source registered to this url
+ const source = sourcesByUrl[url];
+ source.register(port);
+ sourcesByPort[port] = source;
+ return;
+ }
+ let source = sourcesByPort[port];
+ if (source) {
+ if (source.eventSource && source.url === url) return;
+
+ // How this has happened I don't understand...
+ // deregister from that source
+ const count = source.deregister(port);
+ // Clean-up
+ if (count === 0) {
+ source.close();
+ sourcesByUrl[source.url] = null;
+ }
+ }
+ // Create a new Source
+ source = new Source(url);
+ source.register(port);
+ sourcesByUrl[url] = source;
+ sourcesByPort[port] = source;
+ } else if (event.data.type === 'listen') {
+ const source = sourcesByPort[port];
+ source.listen(event.data.eventType);
+ } else if (event.data.type === 'close') {
+ const source = sourcesByPort[port];
+
+ if (!source) return;
+
+ const count = source.deregister(port);
+ if (count === 0) {
+ source.close();
+ sourcesByUrl[source.url] = null;
+ sourcesByPort[port] = null;
+ }
+ } else if (event.data.type === 'status') {
+ const source = sourcesByPort[port];
+ if (!source) {
+ port.postMessage({
+ type: 'status',
+ message: 'not connected',
+ });
+ return;
+ }
+ source.status(port);
+ } else {
+ // just send it back
+ port.postMessage({
+ type: 'error',
+ message: `received but don't know how to handle: ${event.data}`,
+ });
+ }
+ });
+ port.start();
+ }
+});
diff --git a/web_src/js/features/file-fold.js b/web_src/js/features/file-fold.js
new file mode 100644
index 0000000..3efefaf
--- /dev/null
+++ b/web_src/js/features/file-fold.js
@@ -0,0 +1,19 @@
+import {svg} from '../svg.js';
+
+// 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, foldArrow, newFold) {
+ foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18);
+ fileContentBox.setAttribute('data-folded', 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, foldArrow) {
+ setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true');
+}
diff --git a/web_src/js/features/heatmap.js b/web_src/js/features/heatmap.js
new file mode 100644
index 0000000..688f06c
--- /dev/null
+++ b/web_src/js/features/heatmap.js
@@ -0,0 +1,40 @@
+import {createApp} from 'vue';
+import ActivityHeatmap from '../components/ActivityHeatmap.vue';
+import {translateMonth, translateDay} from '../utils.js';
+
+export function initHeatmap() {
+ const el = document.getElementById('user-heatmap');
+ if (!el) return;
+
+ try {
+ const heatmap = {};
+ for (const {contributions, timestamp} of JSON.parse(el.getAttribute('data-heatmap-data'))) {
+ // 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 locale = {
+ months: new Array(12).fill().map((_, idx) => translateMonth(idx)),
+ days: new Array(7).fill().map((_, idx) => translateDay(idx)),
+ contributions_in_the_last_12_months: el.getAttribute('data-locale-total-contributions'),
+ contributions_zero: el.getAttribute('data-locale-contributions-zero'),
+ contributions_format: el.getAttribute('data-locale-contributions-format'),
+ contributions_one: el.getAttribute('data-locale-contributions-one'),
+ contributions_few: el.getAttribute('data-locale-contributions-few'),
+ more: el.getAttribute('data-locale-more'),
+ less: el.getAttribute('data-locale-less'),
+ };
+
+ 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';
+ }
+}
diff --git a/web_src/js/features/imagediff.js b/web_src/js/features/imagediff.js
new file mode 100644
index 0000000..d1b139f
--- /dev/null
+++ b/web_src/js/features/imagediff.js
@@ -0,0 +1,271 @@
+import $ from 'jquery';
+import {GET} from '../modules/fetch.js';
+import {hideElem, loadElem, queryElemChildren} from '../utils/dom.js';
+import {parseDom} from '../utils.js';
+
+function getDefaultSvgBoundsIfUndefined(text, src) {
+ const DefaultSize = 300;
+ const MaxSize = 99999;
+
+ const svgDoc = parseDom(text, 'image/svg+xml');
+ const svg = svgDoc.documentElement;
+ 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.width / viewBox.height,
+ };
+ }
+ return {
+ width: DefaultSize,
+ height: DefaultSize,
+ };
+ }
+ return null;
+}
+
+function createContext(imageAfter, imageBefore) {
+ const sizeAfter = {
+ width: imageAfter?.width || 0,
+ height: imageAfter?.height || 0,
+ };
+ const sizeBefore = {
+ width: imageBefore?.width || 0,
+ 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,
+ ],
+ };
+}
+
+export function initImageDiff() {
+ $('.image-diff:not([data-image-diff-loaded])').each(async function() {
+ const $container = $(this);
+ this.setAttribute('data-image-diff-loaded', 'true');
+
+ // the container may be hidden by "viewed" checkbox, so use the parent's width for reference
+ const diffContainerWidth = Math.max($container.closest('.diff-file-box').width() - 300, 100);
+
+ const imageInfos = [{
+ path: this.getAttribute('data-path-after'),
+ mime: this.getAttribute('data-mime-after'),
+ $images: $container.find('img.image-after'), // matches 3 <img>
+ $boundsInfo: $container.find('.bounds-info-after'),
+ }, {
+ path: this.getAttribute('data-path-before'),
+ mime: this.getAttribute('data-mime-before'),
+ $images: $container.find('img.image-before'), // matches 3 <img>
+ $boundsInfo: $container.find('.bounds-info-before'),
+ }];
+
+ await Promise.all(imageInfos.map(async (info) => {
+ 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.text('(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);
+ if (bounds) {
+ info.$images.each(function() {
+ this.setAttribute('width', bounds.width);
+ this.setAttribute('height', bounds.height);
+ });
+ hideElem(info.$boundsInfo);
+ }
+ }
+ }));
+
+ const $imagesAfter = imageInfos[0].$images;
+ const $imagesBefore = imageInfos[1].$images;
+
+ initSideBySide(this, createContext($imagesAfter[0], $imagesBefore[0]));
+ if ($imagesAfter.length > 0 && $imagesBefore.length > 0) {
+ initSwipe(createContext($imagesAfter[1], $imagesBefore[1]));
+ initOverlay(createContext($imagesAfter[2], $imagesBefore[2]));
+ }
+
+ queryElemChildren(this, '.image-diff-tabs', (el) => el.classList.remove('is-loading'));
+
+ function initSideBySide(container, sizes) {
+ let factor = 1;
+ if (sizes.maxSize.width > (diffContainerWidth - 24) / 2) {
+ factor = (diffContainerWidth - 24) / 2 / sizes.maxSize.width;
+ }
+
+ const widthChanged = sizes.imageAfter && sizes.imageBefore && sizes.imageAfter.naturalWidth !== sizes.imageBefore.naturalWidth;
+ const heightChanged = sizes.imageAfter && sizes.imageBefore && sizes.imageAfter.naturalHeight !== sizes.imageBefore.naturalHeight;
+ if (sizes.imageAfter) {
+ const boundsInfoAfterWidth = container.querySelector('.bounds-info-after .bounds-info-width');
+ if (boundsInfoAfterWidth) {
+ boundsInfoAfterWidth.textContent = `${sizes.imageAfter.naturalWidth}px`;
+ boundsInfoAfterWidth.classList.toggle('green', widthChanged);
+ }
+ const boundsInfoAfterHeight = container.querySelector('.bounds-info-after .bounds-info-height');
+ if (boundsInfoAfterHeight) {
+ boundsInfoAfterHeight.textContent = `${sizes.imageAfter.naturalHeight}px`;
+ boundsInfoAfterHeight.classList.toggle('green', heightChanged);
+ }
+ }
+
+ if (sizes.imageBefore) {
+ const boundsInfoBeforeWidth = container.querySelector('.bounds-info-before .bounds-info-width');
+ if (boundsInfoBeforeWidth) {
+ boundsInfoBeforeWidth.textContent = `${sizes.imageBefore.naturalWidth}px`;
+ boundsInfoBeforeWidth.classList.toggle('red', widthChanged);
+ }
+ const boundsInfoBeforeHeight = container.querySelector('.bounds-info-before .bounds-info-height');
+ if (boundsInfoBeforeHeight) {
+ boundsInfoBeforeHeight.textContent = `${sizes.imageBefore.naturalHeight}px`;
+ boundsInfoBeforeHeight.classList.add('red', heightChanged);
+ }
+ }
+
+ if (sizes.imageAfter) {
+ const container = sizes.imageAfter.parentNode;
+ sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`;
+ sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`;
+ container.style.margin = '10px auto';
+ container.style.width = `${sizes.sizeAfter.width * factor + 2}px`;
+ container.style.height = `${sizes.sizeAfter.height * factor + 2}px`;
+ }
+
+ if (sizes.imageBefore) {
+ const container = sizes.imageBefore.parentNode;
+ sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`;
+ sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`;
+ container.style.margin = '10px auto';
+ container.style.width = `${sizes.sizeBefore.width * factor + 2}px`;
+ container.style.height = `${sizes.sizeBefore.height * factor + 2}px`;
+ }
+ }
+
+ function initSwipe(sizes) {
+ let factor = 1;
+ if (sizes.maxSize.width > diffContainerWidth - 12) {
+ factor = (diffContainerWidth - 12) / sizes.maxSize.width;
+ }
+
+ if (sizes.imageAfter) {
+ const container = sizes.imageAfter.parentNode;
+ const swipeFrame = container.parentNode;
+ sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`;
+ sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`;
+ container.style.margin = `0px ${sizes.ratio[0] * factor}px`;
+ container.style.width = `${sizes.sizeAfter.width * factor + 2}px`;
+ container.style.height = `${sizes.sizeAfter.height * factor + 2}px`;
+ swipeFrame.style.padding = `${sizes.ratio[1] * factor}px 0 0 0`;
+ swipeFrame.style.width = `${sizes.maxSize.width * factor + 2}px`;
+ }
+
+ if (sizes.imageBefore) {
+ const container = sizes.imageBefore.parentNode;
+ const swipeFrame = container.parentNode;
+ sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`;
+ sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`;
+ container.style.margin = `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`;
+ container.style.width = `${sizes.sizeBefore.width * factor + 2}px`;
+ container.style.height = `${sizes.sizeBefore.height * factor + 2}px`;
+ swipeFrame.style.width = `${sizes.maxSize.width * factor + 2}px`;
+ swipeFrame.style.height = `${sizes.maxSize.height * factor + 2}px`;
+ }
+
+ // extra height for inner "position: absolute" elements
+ const swipe = $container.find('.diff-swipe')[0];
+ if (swipe) {
+ swipe.style.width = `${sizes.maxSize.width * factor + 2}px`;
+ swipe.style.height = `${sizes.maxSize.height * factor + 30}px`;
+ }
+
+ $container.find('.swipe-bar').on('mousedown', function(e) {
+ e.preventDefault();
+
+ const $swipeBar = $(this);
+ const $swipeFrame = $swipeBar.parent();
+ const width = $swipeFrame.width() - $swipeBar.width() - 2;
+
+ $(document).on('mousemove.diff-swipe', (e2) => {
+ e2.preventDefault();
+
+ const value = Math.max(0, Math.min(e2.clientX - $swipeFrame.offset().left, width));
+ $swipeBar[0].style.left = `${value}px`;
+ $container.find('.swipe-container')[0].style.width = `${$swipeFrame.width() - value}px`;
+
+ $(document).on('mouseup.diff-swipe', () => {
+ $(document).off('.diff-swipe');
+ });
+ });
+ });
+ }
+
+ function initOverlay(sizes) {
+ let factor = 1;
+ if (sizes.maxSize.width > diffContainerWidth - 12) {
+ factor = (diffContainerWidth - 12) / sizes.maxSize.width;
+ }
+
+ if (sizes.imageAfter) {
+ const container = sizes.imageAfter.parentNode;
+ sizes.imageAfter.style.width = `${sizes.sizeAfter.width * factor}px`;
+ sizes.imageAfter.style.height = `${sizes.sizeAfter.height * factor}px`;
+ container.style.margin = `${sizes.ratio[1] * factor}px ${sizes.ratio[0] * factor}px`;
+ container.style.width = `${sizes.sizeAfter.width * factor + 2}px`;
+ container.style.height = `${sizes.sizeAfter.height * factor + 2}px`;
+ }
+
+ if (sizes.imageBefore) {
+ const container = sizes.imageBefore.parentNode;
+ const overlayFrame = container.parentNode;
+ sizes.imageBefore.style.width = `${sizes.sizeBefore.width * factor}px`;
+ sizes.imageBefore.style.height = `${sizes.sizeBefore.height * factor}px`;
+ container.style.margin = `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`;
+ container.style.width = `${sizes.sizeBefore.width * factor + 2}px`;
+ container.style.height = `${sizes.sizeBefore.height * factor + 2}px`;
+
+ // some inner elements are `position: absolute`, so the container's height must be large enough
+ overlayFrame.style.width = `${sizes.maxSize.width * factor + 2}px`;
+ overlayFrame.style.height = `${sizes.maxSize.height * factor + 2}px`;
+ }
+
+ const rangeInput = $container[0].querySelector('input[type="range"]');
+ function updateOpacity() {
+ if (sizes.imageAfter) {
+ sizes.imageAfter.parentNode.style.opacity = `${rangeInput.value / 100}`;
+ }
+ }
+ rangeInput?.addEventListener('input', updateOpacity);
+ updateOpacity();
+ }
+ });
+}
diff --git a/web_src/js/features/install.js b/web_src/js/features/install.js
new file mode 100644
index 0000000..897f5fb
--- /dev/null
+++ b/web_src/js/features/install.js
@@ -0,0 +1,119 @@
+import {hideElem, showElem} from '../utils/dom.js';
+import {GET} from '../modules/fetch.js';
+
+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 = 'forgejo';
+ const defaultDbName = 'forgejo';
+
+ const defaultDbHosts = {
+ mysql: '127.0.0.1:3306',
+ postgres: '127.0.0.1:5432',
+ };
+
+ const dbHost = document.getElementById('db_host');
+ const dbUser = document.getElementById('db_user');
+ const dbName = document.getElementById('db_name');
+
+ // Database type change detection.
+ document.getElementById('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.getElementById('db_type').dispatchEvent(new Event('change'));
+
+ const appUrl = document.getElementById('app_url');
+ if (appUrl.value.includes('://localhost')) {
+ appUrl.value = window.location.href;
+ }
+
+ const domain = document.getElementById('domain');
+ if (domain.value.trim() === 'localhost') {
+ domain.value = window.location.hostname;
+ }
+
+ // TODO: better handling of exclusive relations.
+ document.querySelector('#offline-mode input').addEventListener('change', function () {
+ if (this.checked) {
+ document.querySelector('#disable-gravatar input').checked = true;
+ document.querySelector('#federated-avatar-lookup input').checked = false;
+ }
+ });
+ document.querySelector('#disable-gravatar input').addEventListener('change', function () {
+ if (this.checked) {
+ document.querySelector('#federated-avatar-lookup input').checked = false;
+ } else {
+ document.querySelector('#offline-mode input').checked = false;
+ }
+ });
+ document.querySelector('#federated-avatar-lookup input').addEventListener('change', function () {
+ if (this.checked) {
+ document.querySelector('#disable-gravatar input').checked = false;
+ document.querySelector('#offline-mode input').checked = false;
+ }
+ });
+ document.querySelector('#enable-openid-signin input').addEventListener('change', function () {
+ if (this.checked) {
+ if (!document.querySelector('#disable-registration input').checked) {
+ document.querySelector('#enable-openid-signup input').checked = true;
+ }
+ } else {
+ document.querySelector('#enable-openid-signup input').checked = false;
+ }
+ });
+ document.querySelector('#disable-registration input').addEventListener('change', function () {
+ if (this.checked) {
+ document.querySelector('#enable-captcha input').checked = false;
+ document.querySelector('#enable-openid-signup input').checked = false;
+ } else {
+ document.querySelector('#enable-openid-signup input').checked = true;
+ }
+ });
+ document.querySelector('#enable-captcha input').addEventListener('change', function () {
+ if (this.checked) {
+ document.querySelector('#disable-registration input').checked = false;
+ }
+ });
+}
+
+function initPostInstall() {
+ const el = document.getElementById('goto-user-login');
+ if (!el) return;
+
+ const targetUrl = el.getAttribute('href');
+ let tid = setInterval(async () => {
+ try {
+ const resp = await GET(targetUrl);
+ if (tid && resp.status === 200) {
+ clearInterval(tid);
+ tid = null;
+ window.location.href = targetUrl;
+ }
+ } catch {}
+ }, 1000);
+}
diff --git a/web_src/js/features/notification.js b/web_src/js/features/notification.js
new file mode 100644
index 0000000..b57df9c
--- /dev/null
+++ b/web_src/js/features/notification.js
@@ -0,0 +1,192 @@
+import $ from 'jquery';
+import {GET} from '../modules/fetch.js';
+import {toggleElem} from '../utils/dom.js';
+
+const {appSubUrl, notificationSettings, assetVersionEncoded} = window.config;
+let notificationSequenceNumber = 0;
+
+export function initNotificationsTable() {
+ const table = document.getElementById('notification_table');
+ if (!table) return;
+
+ // when page restores from bfcache, delete previously clicked items
+ window.addEventListener('pageshow', (e) => {
+ if (e.persisted) { // page was restored from bfcache
+ const table = document.getElementById('notification_table');
+ const unreadCountEl = document.querySelector('.notifications-unread-count');
+ let unreadCount = parseInt(unreadCountEl.textContent);
+ for (const item of table.querySelectorAll('.notifications-item[data-remove="true"]')) {
+ item.remove();
+ unreadCount -= 1;
+ }
+ unreadCountEl.textContent = unreadCount;
+ }
+ });
+
+ // mark clicked unread links for deletion on bfcache restore
+ for (const link of table.querySelectorAll('.notifications-item[data-status="1"] .notifications-link')) {
+ link.addEventListener('click', (e) => {
+ e.target.closest('.notifications-item').setAttribute('data-remove', 'true');
+ });
+ }
+}
+
+async function receiveUpdateCount(event) {
+ try {
+ const data = JSON.parse(event.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() {
+ const $notificationCount = $('.notification_count');
+
+ if (!$notificationCount.length) {
+ return;
+ }
+
+ let usingPeriodicPoller = false;
+ const startPeriodicPoller = (timeout, lastCount) => {
+ if (timeout <= 0 || !Number.isFinite(timeout)) return;
+ usingPeriodicPoller = true;
+ lastCount = lastCount ?? $notificationCount.text();
+ 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 SharedWorker(`${__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker');
+ worker.addEventListener('error', (event) => {
+ console.error('worker error', event);
+ });
+ worker.port.addEventListener('messageerror', () => {
+ console.error('unable to deserialize message');
+ });
+ worker.port.postMessage({
+ type: 'start',
+ url: `${window.location.origin}${appSubUrl}/user/events`,
+ });
+ worker.port.addEventListener('message', (event) => {
+ if (!event.data || !event.data.type) {
+ console.error('unknown worker message event', event);
+ return;
+ }
+ if (event.data.type === 'notification-count') {
+ const _promise = receiveUpdateCount(event.data);
+ } else 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 === 'error') {
+ console.error('worker port event error', event.data);
+ } else if (event.data.type === 'logout') {
+ if (event.data.data !== 'here') {
+ return;
+ }
+ worker.port.postMessage({
+ type: 'close',
+ });
+ worker.port.close();
+ window.location.href = `${window.location.origin}${appSubUrl}/`;
+ } else if (event.data.type === 'close') {
+ worker.port.postMessage({
+ type: 'close',
+ });
+ worker.port.close();
+ }
+ });
+ worker.port.addEventListener('error', (e) => {
+ console.error('worker port error', e);
+ });
+ worker.port.start();
+ window.addEventListener('beforeunload', () => {
+ worker.port.postMessage({
+ type: 'close',
+ });
+ worker.port.close();
+ });
+
+ return;
+ }
+
+ startPeriodicPoller(notificationSettings.MinTimeout);
+}
+
+async function updateNotificationCountWithCallback(callback, timeout, lastCount) {
+ const currentCount = $('.notification_count').text();
+ 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.getElementById('notification_div');
+ if (notificationDiv) {
+ try {
+ const params = new URLSearchParams(window.location.search);
+ params.set('div-only', true);
+ params.set('sequence-number', ++notificationSequenceNumber);
+ const url = `${appSubUrl}/notifications?${params.toString()}`;
+ const response = await GET(url);
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch notification table');
+ }
+
+ const data = await response.text();
+ if ($(data).data('sequence-number') === notificationSequenceNumber) {
+ notificationDiv.outerHTML = data;
+ initNotificationsTable();
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ }
+}
+
+async function updateNotificationCount() {
+ 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.getElementsByClassName('notification_count')) {
+ el.textContent = `${data.new}`;
+ }
+
+ return `${data.new}`;
+ } catch (error) {
+ console.error(error);
+ return '0';
+ }
+}
diff --git a/web_src/js/features/org-team.js b/web_src/js/features/org-team.js
new file mode 100644
index 0000000..9b059b3
--- /dev/null
+++ b/web_src/js/features/org-team.js
@@ -0,0 +1,26 @@
+import $ from 'jquery';
+
+const {appSubUrl} = window.config;
+
+export function initOrgTeamSearchRepoBox() {
+ const $searchRepoBox = $('#search-repo-box');
+ $searchRepoBox.search({
+ minCharacters: 2,
+ apiSettings: {
+ url: `${appSubUrl}/repo/search?q={query}&uid=${$searchRepoBox.data('uid')}`,
+ onResponse(response) {
+ const items = [];
+ $.each(response.data, (_i, item) => {
+ items.push({
+ title: item.repository.full_name.split('/')[1],
+ description: item.repository.full_name,
+ });
+ });
+
+ return {results: items};
+ },
+ },
+ searchFields: ['full_name'],
+ showNoResults: false,
+ });
+}
diff --git a/web_src/js/features/pull-view-file.js b/web_src/js/features/pull-view-file.js
new file mode 100644
index 0000000..2472e5a
--- /dev/null
+++ b/web_src/js/features/pull-view-file.js
@@ -0,0 +1,96 @@
+import {diffTreeStore} from '../modules/stores.js';
+import {setFileFolding} from './file-fold.js';
+import {POST} from '../modules/fetch.js';
+
+const {pageData} = window.config;
+const prReview = pageData.prReview || {};
+const viewedStyleClass = 'viewed-file-checked-form';
+const viewedCheckboxSelector = '.viewed-file-form'; // Selector under which all "Viewed" checkbox forms can be found
+const expandFilesBtnSelector = '#expand-files-btn';
+const collapseFilesBtnSelector = '#collapse-files-btn';
+
+// Refreshes the summary of viewed files if present
+// The data used will be window.config.pageData.prReview.numberOf{Viewed}Files
+function refreshViewedFilesSummary() {
+ const viewedFilesProgress = document.getElementById('viewed-files-summary');
+ viewedFilesProgress?.setAttribute('value', prReview.numberOfViewedFiles);
+ const summaryLabel = document.getElementById('viewed-files-summary-label');
+ if (summaryLabel) summaryLabel.innerHTML = summaryLabel.getAttribute('data-text-changed-template')
+ .replace('%[1]d', prReview.numberOfViewedFiles)
+ .replace('%[2]d', prReview.numberOfFiles);
+}
+
+// Explicitly recounts how many files the user has currently reviewed by counting the number of checked "viewed" checkboxes
+// Additionally, the viewed files summary will be updated if it exists
+export function countAndUpdateViewedFiles() {
+ // The number of files is constant, but the number of viewed files can change because files can be loaded dynamically
+ prReview.numberOfViewedFiles = document.querySelectorAll(`${viewedCheckboxSelector} > input[type=checkbox][checked]`).length;
+ refreshViewedFilesSummary();
+}
+
+// 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', true);
+
+ // The checkbox consists of a div containing the real checkbox with its label and the CSRF token,
+ // hence the actual checkbox first has to be found
+ const checkbox = form.querySelector('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
+ const fileInPageData = diffTreeStore().files.find((x) => x.Name === fileName);
+ if (fileInPageData) {
+ fileInPageData.IsViewed = this.checked;
+ }
+
+ // Unfortunately, actual forms cause too many problems, hence another approach is needed
+ const files = {};
+ files[fileName] = this.checked;
+ const data = {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('.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('.file-content:not([data-folded="true"])')) {
+ if (box.getAttribute('id') === 'diff-incomplete') continue;
+ setFileFolding(box, box.querySelector('.fold-file'), true);
+ }
+ });
+}
diff --git a/web_src/js/features/recent-commits.js b/web_src/js/features/recent-commits.js
new file mode 100644
index 0000000..030c251
--- /dev/null
+++ b/web_src/js/features/recent-commits.js
@@ -0,0 +1,21 @@
+import {createApp} from 'vue';
+
+export async function initRepoRecentCommits() {
+ const el = document.getElementById('repo-recent-commits-chart');
+ if (!el) return;
+
+ const {default: RepoRecentCommits} = await import(/* webpackChunkName: "recent-commits-graph" */'../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');
+ }
+}
diff --git a/web_src/js/features/repo-branch.js b/web_src/js/features/repo-branch.js
new file mode 100644
index 0000000..b9ffc61
--- /dev/null
+++ b/web_src/js/features/repo-branch.js
@@ -0,0 +1,42 @@
+import $ from 'jquery';
+import {toggleElem} from '../utils/dom.js';
+
+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(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');
+
+ $(el.getAttribute('data-modal')).modal('show');
+ });
+ }
+}
+
+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('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);
+ });
+ }
+}
diff --git a/web_src/js/features/repo-code.js b/web_src/js/features/repo-code.js
new file mode 100644
index 0000000..794cc38
--- /dev/null
+++ b/web_src/js/features/repo-code.js
@@ -0,0 +1,195 @@
+import $ from 'jquery';
+import {svg} from '../svg.js';
+import {invertFileFolding} from './file-fold.js';
+import {createTippy} from '../modules/tippy.js';
+import {clippie} from 'clippie';
+import {toAbsoluteUrl} from '../utils.js';
+
+export const singleAnchorRegex = /^#(L|n)([1-9][0-9]*)$/;
+export const rangeAnchorRegex = /^#(L[1-9][0-9]*)-(L[1-9][0-9]*)$/;
+
+function changeHash(hash) {
+ if (window.history.pushState) {
+ window.history.pushState(null, null, hash);
+ } else {
+ window.location.hash = hash;
+ }
+}
+
+function isBlame() {
+ return Boolean(document.querySelector('div.blame'));
+}
+
+function getLineEls() {
+ return document.querySelectorAll(`.code-view td.lines-code${isBlame() ? '.blame-code' : ''}`);
+}
+
+function selectRange($linesEls, $selectionEndEl, $selectionStartEls) {
+ for (const el of $linesEls) {
+ el.closest('tr').classList.remove('active');
+ }
+
+ // add hashchange to permalink
+ 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) {
+ 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) {
+ 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) {
+ if (!copyPermalink) return;
+ let link = copyPermalink.getAttribute('data-url');
+ link = `${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
+ copyPermalink.setAttribute('data-url', link);
+ };
+
+ if ($selectionStartEls) {
+ let a = parseInt($selectionEndEl[0].getAttribute('rel').slice(1));
+ let b = parseInt($selectionStartEls[0].getAttribute('rel').slice(1));
+ let c;
+ if (a !== b) {
+ if (a > b) {
+ c = a;
+ a = b;
+ b = c;
+ }
+ const classes = [];
+ for (let i = a; i <= b; i++) {
+ classes.push(`[rel=L${i}]`);
+ }
+ $linesEls.filter(classes.join(',')).each(function () {
+ this.closest('tr').classList.add('active');
+ });
+ changeHash(`#L${a}-L${b}`);
+
+ updateIssueHref(`L${a}-L${b}`);
+ updateViewGitBlameFragment(`L${a}-L${b}`);
+ updateCopyPermalinkUrl(`L${a}-L${b}`);
+ return;
+ }
+ }
+ $selectionEndEl[0].closest('tr').classList.add('active');
+ changeHash(`#${$selectionEndEl[0].getAttribute('rel')}`);
+
+ updateIssueHref($selectionEndEl[0].getAttribute('rel'));
+ updateViewGitBlameFragment($selectionEndEl[0].getAttribute('rel'));
+ updateCopyPermalinkUrl($selectionEndEl[0].getAttribute('rel'));
+}
+
+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');
+ 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, {
+ trigger: 'click',
+ hideOnClick: true,
+ content: menu,
+ placement: 'right-start',
+ interactive: true,
+ onShow: (tippy) => {
+ tippy.popper.addEventListener('click', () => {
+ tippy.hide();
+ }, {once: true});
+ },
+ });
+}
+
+export function initRepoCodeView() {
+ if ($('.code-view .lines-num').length > 0) {
+ $(document).on('click', '.lines-num span', function (e) {
+ const linesEls = getLineEls();
+ const selectedEls = Array.from(linesEls).filter((el) => {
+ return el.matches(`[rel=${this.getAttribute('id')}]`);
+ });
+
+ let from;
+ if (e.shiftKey) {
+ from = Array.from(linesEls).filter((el) => {
+ return el.closest('tr').classList.contains('active');
+ });
+ }
+ selectRange($(linesEls), $(selectedEls), from ? $(from) : null);
+
+ if (window.getSelection) {
+ window.getSelection().removeAllRanges();
+ } else {
+ document.selection.empty();
+ }
+
+ showLineButton();
+ });
+
+ $(window).on('hashchange', () => {
+ let m = window.location.hash.match(rangeAnchorRegex);
+ const $linesEls = $(getLineEls());
+ let $first;
+ if (m) {
+ $first = $linesEls.filter(`[rel=${m[1]}]`);
+ if ($first.length) {
+ const $last = $linesEls.filter(`[rel=${m[2]}]`);
+ selectRange($linesEls, $first, $last.length ? $last : $linesEls.last());
+
+ // show code view menu marker (don't show in blame page)
+ if (!isBlame()) {
+ showLineButton();
+ }
+
+ $('html, body').scrollTop($first.offset().top - 200);
+ return;
+ }
+ }
+ m = window.location.hash.match(singleAnchorRegex);
+ if (m) {
+ $first = $linesEls.filter(`[rel=L${m[2]}]`);
+ if ($first.length) {
+ selectRange($linesEls, $first);
+
+ // show code view menu marker (don't show in blame page)
+ if (!isBlame()) {
+ showLineButton();
+ }
+
+ $('html, body').scrollTop($first.offset().top - 200);
+ }
+ }
+ }).trigger('hashchange');
+ }
+ $(document).on('click', '.fold-file', ({currentTarget}) => {
+ invertFileFolding(currentTarget.closest('.file-content'), currentTarget);
+ });
+ $(document).on('click', '.copy-line-permalink', async ({currentTarget}) => {
+ await clippie(toAbsoluteUrl(currentTarget.getAttribute('data-url')));
+ });
+}
diff --git a/web_src/js/features/repo-code.test.js b/web_src/js/features/repo-code.test.js
new file mode 100644
index 0000000..0e0062a
--- /dev/null
+++ b/web_src/js/features/repo-code.test.js
@@ -0,0 +1,17 @@
+import {singleAnchorRegex, rangeAnchorRegex} from './repo-code.js';
+
+test('singleAnchorRegex', () => {
+ expect(singleAnchorRegex.test('#L0')).toEqual(false);
+ expect(singleAnchorRegex.test('#L1')).toEqual(true);
+ expect(singleAnchorRegex.test('#L01')).toEqual(false);
+ expect(singleAnchorRegex.test('#n0')).toEqual(false);
+ expect(singleAnchorRegex.test('#n1')).toEqual(true);
+ expect(singleAnchorRegex.test('#n01')).toEqual(false);
+});
+
+test('rangeAnchorRegex', () => {
+ expect(rangeAnchorRegex.test('#L0-L10')).toEqual(false);
+ expect(rangeAnchorRegex.test('#L1-L10')).toEqual(true);
+ expect(rangeAnchorRegex.test('#L01-L10')).toEqual(false);
+ expect(rangeAnchorRegex.test('#L1-L01')).toEqual(false);
+});
diff --git a/web_src/js/features/repo-commit.js b/web_src/js/features/repo-commit.js
new file mode 100644
index 0000000..f61ea08
--- /dev/null
+++ b/web_src/js/features/repo-commit.js
@@ -0,0 +1,27 @@
+import {createTippy} from '../modules/tippy.js';
+import {toggleElem} from '../utils/dom.js';
+
+export function initRepoEllipsisButton() {
+ for (const button of document.querySelectorAll('.js-toggle-commit-body')) {
+ button.addEventListener('click', function (e) {
+ e.preventDefault();
+ const expanded = this.getAttribute('aria-expanded') === 'true';
+ toggleElem(this.parentElement.querySelector('.commit-body'));
+ this.setAttribute('aria-expanded', String(!expanded));
+ });
+ }
+}
+
+export function initCommitStatuses() {
+ for (const element of document.querySelectorAll('[data-tippy="commit-statuses"]')) {
+ const top = document.querySelector('.repository.file.list') || document.querySelector('.repository.diff');
+
+ createTippy(element, {
+ content: element.nextElementSibling,
+ placement: top ? 'top-start' : 'bottom-start',
+ interactive: true,
+ role: 'dialog',
+ theme: 'box-with-header',
+ });
+ }
+}
diff --git a/web_src/js/features/repo-common.js b/web_src/js/features/repo-common.js
new file mode 100644
index 0000000..88aa93d
--- /dev/null
+++ b/web_src/js/features/repo-common.js
@@ -0,0 +1,83 @@
+import $ from 'jquery';
+import {hideElem, queryElems, showElem} from '../utils/dom.js';
+import {POST} from '../modules/fetch.js';
+import {showErrorToast} from '../modules/toast.js';
+import {sleep} from '../utils.js';
+
+async function onDownloadArchive(e) {
+ e.preventDefault();
+ // there are many places using the "archive-link", eg: the dropdown on the repo code page, the release list
+ const el = e.target.closest('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: ${e}`, {duration: 2500});
+ } finally {
+ targetLoading.classList.remove('is-loading', 'loading-icon-2px');
+ }
+}
+
+export function initRepoArchiveLinks() {
+ queryElems('a.archive-link[href]', (el) => el.addEventListener('click', onDownloadArchive));
+}
+
+export function initRepoCloneLink() {
+ const $repoCloneSsh = $('#repo-clone-ssh');
+ const $repoCloneHttps = $('#repo-clone-https');
+ const $inputLink = $('#repo-clone-url');
+
+ if ((!$repoCloneSsh.length && !$repoCloneHttps.length) || !$inputLink.length) {
+ return;
+ }
+
+ $repoCloneSsh.on('click', () => {
+ localStorage.setItem('repo-clone-protocol', 'ssh');
+ window.updateCloneStates();
+ });
+ $repoCloneHttps.on('click', () => {
+ localStorage.setItem('repo-clone-protocol', 'https');
+ window.updateCloneStates();
+ });
+
+ $inputLink.on('focus', () => {
+ $inputLink.trigger('select');
+ });
+}
+
+export function initRepoCommonBranchOrTagDropdown(selector) {
+ $(selector).each(function () {
+ const $dropdown = $(this);
+ $dropdown.find('.reference.column').on('click', function () {
+ hideElem($dropdown.find('.scrolling.reference-list-menu'));
+ showElem($($(this).data('target')));
+ return false;
+ });
+ });
+}
+
+export function initRepoCommonFilterSearchDropdown(selector) {
+ const $dropdown = $(selector);
+ if (!$dropdown.length) return;
+
+ $dropdown.dropdown({
+ fullTextSearch: 'exact',
+ selectOnKeydown: false,
+ onChange(_text, _value, $choice) {
+ if ($choice[0].getAttribute('data-url')) {
+ window.location.href = $choice[0].getAttribute('data-url');
+ }
+ },
+ message: {noResults: $dropdown[0].getAttribute('data-no-results')},
+ });
+}
diff --git a/web_src/js/features/repo-diff-commit.js b/web_src/js/features/repo-diff-commit.js
new file mode 100644
index 0000000..aa7fc38
--- /dev/null
+++ b/web_src/js/features/repo-diff-commit.js
@@ -0,0 +1,53 @@
+import {hideElem, showElem, toggleElem} from '../utils/dom.js';
+import {GET} from '../modules/fetch.js';
+
+async function loadBranchesAndTags(area, loadingButton) {
+ loadingButton.classList.add('disabled');
+ try {
+ const res = await GET(loadingButton.getAttribute('data-fetch-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, tags) {
+ 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, branches, defaultBranch) {
+ 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, href, text, tooltip) {
+ 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));
+ }
+}
diff --git a/web_src/js/features/repo-diff-commitselect.js b/web_src/js/features/repo-diff-commitselect.js
new file mode 100644
index 0000000..ebac64e
--- /dev/null
+++ b/web_src/js/features/repo-diff-commitselect.js
@@ -0,0 +1,10 @@
+import {createApp} from 'vue';
+import DiffCommitSelector from '../components/DiffCommitSelector.vue';
+
+export function initDiffCommitSelect() {
+ const el = document.getElementById('diff-commit-select');
+ if (!el) return;
+
+ const commitSelect = createApp(DiffCommitSelector);
+ commitSelect.mount(el);
+}
diff --git a/web_src/js/features/repo-diff-filetree.js b/web_src/js/features/repo-diff-filetree.js
new file mode 100644
index 0000000..5dd2c42
--- /dev/null
+++ b/web_src/js/features/repo-diff-filetree.js
@@ -0,0 +1,17 @@
+import {createApp} from 'vue';
+import DiffFileTree from '../components/DiffFileTree.vue';
+import DiffFileList from '../components/DiffFileList.vue';
+
+export function initDiffFileTree() {
+ const el = document.getElementById('diff-file-tree');
+ if (!el) return;
+
+ const fileTreeView = createApp(DiffFileTree);
+ fileTreeView.mount(el);
+
+ const fileListElement = document.getElementById('diff-file-list');
+ if (!fileListElement) return;
+
+ const fileListView = createApp(DiffFileList);
+ fileListView.mount(fileListElement);
+}
diff --git a/web_src/js/features/repo-diff.js b/web_src/js/features/repo-diff.js
new file mode 100644
index 0000000..e5723af
--- /dev/null
+++ b/web_src/js/features/repo-diff.js
@@ -0,0 +1,232 @@
+import $ from 'jquery';
+import {initCompReactionSelector} from './comp/ReactionSelector.js';
+import {initRepoIssueContentHistory} from './repo-issue-content.js';
+import {initDiffFileTree} from './repo-diff-filetree.js';
+import {initDiffCommitSelect} from './repo-diff-commitselect.js';
+import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.js';
+import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles, initExpandAndCollapseFilesButton} from './pull-view-file.js';
+import {initImageDiff} from './imagediff.js';
+import {showErrorToast} from '../modules/toast.js';
+import {submitEventSubmitter, queryElemSiblings, hideElem, showElem} from '../utils/dom.js';
+import {POST, GET} from '../modules/fetch.js';
+
+const {pageData, i18n} = window.config;
+
+function initRepoDiffReviewButton() {
+ const reviewBox = document.getElementById('review-box');
+ if (!reviewBox) return;
+
+ const counter = reviewBox.querySelector('.review-comments-counter');
+ if (!counter) return;
+
+ $(document).on('click', 'button[name="pending_review"]', (e) => {
+ const $form = $(e.target).closest('form');
+ // Watch for the form's submit event.
+ $form.on('submit', () => {
+ const num = parseInt(counter.getAttribute('data-pending-comment-number')) + 1 || 1;
+ counter.setAttribute('data-pending-comment-number', num);
+ counter.textContent = num;
+
+ reviewBox.classList.remove('pulse');
+ requestAnimationFrame(() => {
+ reviewBox.classList.add('pulse');
+ });
+ });
+ });
+}
+
+function initRepoDiffFileViewToggle() {
+ $('.file-view-toggle').on('click', function () {
+ for (const el of queryElemSiblings(this)) {
+ el.classList.remove('active');
+ }
+ this.classList.add('active');
+
+ const target = document.querySelector(this.getAttribute('data-toggle-selector'));
+ if (!target) return;
+
+ hideElem(queryElemSiblings(target));
+ showElem(target);
+ });
+}
+
+function initRepoDiffConversationForm() {
+ $(document).on('submit', '.conversation-holder form', async (e) => {
+ e.preventDefault();
+
+ const $form = $(e.target);
+ const textArea = e.target.querySelector('textarea');
+ if (!validateTextareaNonEmpty(textArea)) {
+ return;
+ }
+
+ if (e.target.classList.contains('is-loading')) return;
+ try {
+ e.target.classList.add('is-loading');
+ const formData = new FormData($form[0]);
+
+ // If the form is submitted by a button, append the button's name and value to the form data.
+ // originalEvent can be undefined, such as an event that's caused by Ctrl+Enter, in that case
+ // sent the event itself.
+ const submitter = submitEventSubmitter(e.originalEvent ?? e);
+ const isSubmittedByButton = (submitter?.nodeName === 'BUTTON') || (submitter?.nodeName === 'INPUT' && submitter.type === 'submit');
+ if (isSubmittedByButton && submitter.name) {
+ formData.append(submitter.name, submitter.value);
+ }
+
+ const response = await POST(e.target.getAttribute('action'), {data: formData});
+ const $newConversationHolder = $(await response.text());
+ const {path, side, idx} = $newConversationHolder.data();
+
+ $form.closest('.conversation-holder').replaceWith($newConversationHolder);
+ let selector;
+ if ($form.closest('tr').data('line-type') === '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');
+ }
+ $newConversationHolder.find('.dropdown').dropdown();
+ initCompReactionSelector($newConversationHolder);
+ } catch { // here the caught error might be a jQuery AJAX error (thrown by await $.post), which is not good to use for error message handling
+ console.error('error when submitting conversation', e);
+ showErrorToast(i18n.network_error);
+ } finally {
+ e.target.classList.remove('is-loading');
+ }
+ });
+
+ $(document).on('click', '.resolve-conversation', async function (e) {
+ e.preventDefault();
+ const comment_id = $(this).data('comment-id');
+ const origin = $(this).data('origin');
+ const action = $(this).data('action');
+ const url = $(this).data('update-url');
+
+ try {
+ const response = await POST(url, {data: new URLSearchParams({origin, action, comment_id})});
+ const data = await response.text();
+
+ if ($(this).closest('.conversation-holder').length) {
+ const $conversation = $(data);
+ $(this).closest('.conversation-holder').replaceWith($conversation);
+ $conversation.find('.dropdown').dropdown();
+ initCompReactionSelector($conversation);
+ } else {
+ window.location.reload();
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ }
+ });
+}
+
+export function initRepoDiffConversationNav() {
+ // Previous/Next code review conversation
+ $(document).on('click', '.previous-conversation', (e) => {
+ const $conversation = $(e.currentTarget).closest('.comment-code-cloud');
+ const $conversations = $('.comment-code-cloud:not(.tw-hidden)');
+ const index = $conversations.index($conversation);
+ const previousIndex = index > 0 ? index - 1 : $conversations.length - 1;
+ const $previousConversation = $conversations.eq(previousIndex);
+ const anchor = $previousConversation.find('.comment').first()[0].getAttribute('id');
+ window.location.href = `#${anchor}`;
+ });
+ $(document).on('click', '.next-conversation', (e) => {
+ const $conversation = $(e.currentTarget).closest('.comment-code-cloud');
+ const $conversations = $('.comment-code-cloud:not(.tw-hidden)');
+ const index = $conversations.index($conversation);
+ const nextIndex = index < $conversations.length - 1 ? index + 1 : 0;
+ const $nextConversation = $conversations.eq(nextIndex);
+ const anchor = $nextConversation.find('.comment').first()[0].getAttribute('id');
+ window.location.href = `#${anchor}`;
+ });
+}
+
+// Will be called when the show more (files) button has been pressed
+function onShowMoreFiles() {
+ initRepoIssueContentHistory();
+ initViewedCheckboxListenerFor();
+ countAndUpdateViewedFiles();
+ initImageDiff();
+}
+
+export async function loadMoreFiles(url) {
+ const target = document.querySelector('a#diff-show-more-files');
+ if (target?.classList.contains('disabled') || pageData.diffFileInfo.isLoadingNewData) {
+ return;
+ }
+
+ pageData.diffFileInfo.isLoadingNewData = true;
+ target?.classList.add('disabled');
+
+ try {
+ const response = await GET(url);
+ const resp = await response.text();
+ const $resp = $(resp);
+ // the response is a full HTML page, we need to extract the relevant contents:
+ // 1. append the newly loaded file list items to the existing list
+ $('#diff-incomplete').replaceWith($resp.find('#diff-file-boxes').children());
+ // 2. re-execute the script to append the newly loaded items to the JS variables to refresh the DiffFileTree
+ $('body').append($resp.find('script#diff-data-script'));
+
+ onShowMoreFiles();
+ } catch (error) {
+ console.error('Error:', error);
+ showErrorToast('An error occurred while loading more files.');
+ } finally {
+ target?.classList.remove('disabled');
+ pageData.diffFileInfo.isLoadingNewData = false;
+ }
+}
+
+function initRepoDiffShowMore() {
+ $(document).on('click', 'a#diff-show-more-files', (e) => {
+ e.preventDefault();
+
+ const linkLoadMore = e.target.getAttribute('data-href');
+ loadMoreFiles(linkLoadMore);
+ });
+
+ $(document).on('click', 'a.diff-load-button', async (e) => {
+ e.preventDefault();
+ const $target = $(e.target);
+
+ if (e.target.classList.contains('disabled')) {
+ return;
+ }
+
+ e.target.classList.add('disabled');
+
+ const url = $target.data('href');
+
+ try {
+ const response = await GET(url);
+ const resp = await response.text();
+
+ if (!resp) {
+ return;
+ }
+ $target.parent().replaceWith($(resp).find('#diff-file-boxes .diff-file-body .file-body').children());
+ onShowMoreFiles();
+ } catch (error) {
+ console.error('Error:', error);
+ } finally {
+ e.target.classList.remove('disabled');
+ }
+ });
+}
+
+export function initRepoDiffView() {
+ initRepoDiffConversationForm();
+ if (!$('#diff-file-list').length) return;
+ initDiffFileTree();
+ initDiffCommitSelect();
+ initRepoDiffShowMore();
+ initRepoDiffReviewButton();
+ initRepoDiffFileViewToggle();
+ initViewedCheckboxListenerFor();
+ initExpandAndCollapseFilesButton();
+}
diff --git a/web_src/js/features/repo-editor.js b/web_src/js/features/repo-editor.js
new file mode 100644
index 0000000..faf8ba1
--- /dev/null
+++ b/web_src/js/features/repo-editor.js
@@ -0,0 +1,203 @@
+import $ from 'jquery';
+import {htmlEscape} from 'escape-goat';
+import {createCodeEditor} from './codeeditor.js';
+import {hideElem, showElem, createElementFromHTML} from '../utils/dom.js';
+import {initMarkupContent} from '../markup/content.js';
+import {attachRefIssueContextPopup} from './contextpopup.js';
+import {POST} from '../modules/fetch.js';
+
+function initEditPreviewTab($form) {
+ const $tabMenu = $form.find('.tabular.menu');
+ $tabMenu.find('.item').tab();
+ const $previewTab = $tabMenu.find(
+ `.item[data-tab="${$tabMenu.data('preview')}"]`,
+ );
+ if ($previewTab.length) {
+ $previewTab.on('click', async function () {
+ const $this = $(this);
+ let context = `${$this.data('context')}/`;
+ const mode = $this.data('markup-mode') || 'comment';
+ const $treePathEl = $form.find('input#tree_path');
+ if ($treePathEl.length > 0) {
+ context += $treePathEl.val();
+ }
+ context = context.substring(0, context.lastIndexOf('/'));
+
+ const formData = new FormData();
+ formData.append('mode', mode);
+ formData.append('context', context);
+ formData.append(
+ 'text',
+ $form.find(`.tab[data-tab="${$tabMenu.data('write')}"] textarea`).val(),
+ );
+ formData.append('file_path', $treePathEl.val());
+ try {
+ const response = await POST($this.data('url'), {data: formData});
+ const data = await response.text();
+ const $previewPanel = $form.find(
+ `.tab[data-tab="${$tabMenu.data('preview')}"]`,
+ );
+ renderPreviewPanelContent($previewPanel, data);
+ } catch (error) {
+ console.error('Error:', error);
+ }
+ });
+ }
+}
+
+function initEditorForm() {
+ const $form = $('.repository .edit.form');
+ if (!$form) return;
+ initEditPreviewTab($form);
+}
+
+function getCursorPosition($e) {
+ const el = $e.get(0);
+ let pos = 0;
+ if ('selectionStart' in el) {
+ pos = el.selectionStart;
+ } else if ('selection' in document) {
+ el.focus();
+ const Sel = document.selection.createRange();
+ const SelLength = document.selection.createRange().text.length;
+ Sel.moveStart('character', -el.value.length);
+ pos = Sel.text.length - SelLength;
+ }
+ return pos;
+}
+
+export function initRepoEditor() {
+ initEditorForm();
+
+ $('.js-quick-pull-choice-option').on('change', function () {
+ if ($(this).val() === 'commit-to-new-branch') {
+ showElem('.quick-pull-branch-name');
+ document.querySelector('.quick-pull-branch-name input').required = true;
+ } else {
+ hideElem('.quick-pull-branch-name');
+ document.querySelector('.quick-pull-branch-name input').required = false;
+ }
+ $('#commit-button').text(this.getAttribute('button_text'));
+ });
+
+ const joinTreePath = ($fileNameEl) => {
+ const parts = [];
+ $('.breadcrumb span.section').each(function () {
+ const $element = $(this);
+ if ($element.find('a').length) {
+ parts.push($element.find('a').text());
+ } else {
+ parts.push($element.text());
+ }
+ });
+ if ($fileNameEl.val()) parts.push($fileNameEl.val());
+ $('#tree_path').val(parts.join('/'));
+ };
+
+ const $editFilename = $('#file-name');
+ $editFilename.on('input', function () {
+ const parts = $(this).val().split('/');
+
+ if (parts.length > 1) {
+ for (let i = 0; i < parts.length; ++i) {
+ const value = parts[i];
+ if (i < parts.length - 1) {
+ if (value.length) {
+ $editFilename[0].before(
+ createElementFromHTML(
+ `<span class="section"><a href="#">${htmlEscape(value)}</a></span>`,
+ ),
+ );
+ $editFilename[0].before(
+ createElementFromHTML(`<div class="breadcrumb-divider">/</div>`),
+ );
+ }
+ } else {
+ $(this).val(value);
+ }
+ this.setSelectionRange(0, 0);
+ }
+ }
+
+ joinTreePath($(this));
+ });
+
+ $editFilename.on('keydown', function (e) {
+ const $section = $('.breadcrumb span.section');
+
+ // Jump back to last directory once the filename is empty
+ if (
+ e.code === 'Backspace' &&
+ getCursorPosition($(this)) === 0 &&
+ $section.length > 0
+ ) {
+ e.preventDefault();
+ const $divider = $('.breadcrumb .breadcrumb-divider');
+ const value = $section.last().find('a').text();
+ $(this).val(value + $(this).val());
+ this.setSelectionRange(value.length, value.length);
+ $section.last().remove();
+ $divider.last().remove();
+ joinTreePath($(this));
+ }
+ });
+
+ const $editArea = $('.repository.editor textarea#edit_area');
+ if (!$editArea.length) return;
+
+ (async () => {
+ const editor = await createCodeEditor($editArea[0], $editFilename[0]);
+
+ // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage
+ // to enable or disable the commit button
+ const commitButton = document.getElementById('commit-button');
+ const $editForm = $('.ui.edit.form');
+ const dirtyFileClass = 'dirty-file';
+
+ // Disabling the button at the start
+ if ($('input[name="page_has_posted"]').val() !== 'true') {
+ commitButton.disabled = true;
+ }
+
+ // Registering a custom listener for the file path and the file content
+ $editForm.areYouSure({
+ silent: true,
+ dirtyClass: dirtyFileClass,
+ fieldSelector: ':input:not(.commit-form-wrapper :input)',
+ change($form) {
+ const dirty = $form[0]?.classList.contains(dirtyFileClass);
+ commitButton.disabled = !dirty;
+ },
+ });
+
+ // Update the editor from query params, if available,
+ // only after the dirtyFileClass initialization
+ const params = new URLSearchParams(window.location.search);
+ const value = params.get('value');
+ if (value) {
+ editor.setValue(value);
+ }
+
+ commitButton?.addEventListener('click', (e) => {
+ // A modal which asks if an empty file should be committed
+ if (!$editArea.val()) {
+ $('#edit-empty-content-modal')
+ .modal({
+ onApprove() {
+ $('.edit.form').trigger('submit');
+ },
+ })
+ .modal('show');
+ e.preventDefault();
+ }
+ });
+ })();
+}
+
+export function renderPreviewPanelContent($panelPreviewer, data) {
+ $panelPreviewer.html(data);
+ initMarkupContent();
+
+ const $refIssues = $panelPreviewer.find('p .ref-issue');
+ attachRefIssueContextPopup($refIssues);
+}
diff --git a/web_src/js/features/repo-findfile.js b/web_src/js/features/repo-findfile.js
new file mode 100644
index 0000000..cff5068
--- /dev/null
+++ b/web_src/js/features/repo-findfile.js
@@ -0,0 +1,117 @@
+import {svg} from '../svg.js';
+import {toggleElem} from '../utils/dom.js';
+import {pathEscapeSegments} from '../utils/url.js';
+import {GET} from '../modules/fetch.js';
+
+const threshold = 50;
+let files = [];
+let repoFindFileInput, repoFindFileTableBody, repoFindFileNoResult;
+
+// 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, subLower) {
+ 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) {
+ 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, filter) {
+ 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 (let i = 0; i < files.length; i++) {
+ const res = strSubMatch(files[i], 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;
+}
+
+function filterRepoFiles(filter) {
+ const treeLink = repoFindFileInput.getAttribute('data-url-tree-link');
+ repoFindFileTableBody.innerHTML = '';
+
+ const filterResult = filterRepoFilesWeighted(files, filter);
+
+ toggleElem(repoFindFileNoResult, !filterResult.length);
+ for (const r of filterResult) {
+ const row = document.createElement('tr');
+ const cell = document.createElement('td');
+ const a = document.createElement('a');
+ a.setAttribute('href', `${treeLink}/${pathEscapeSegments(r.matchResult.join(''))}`);
+ a.innerHTML = svg('octicon-file', 16, 'tw-mr-2');
+ row.append(cell);
+ cell.append(a);
+ for (const [index, part] of r.matchResult.entries()) {
+ const span = document.createElement('span');
+ // safely escape by using textContent
+ span.textContent = part;
+ // if the target file path is "abc/xyz", to search "bx", then the matchResult is ['a', 'b', 'c/', 'x', 'yz']
+ // the matchResult[odd] is matched and highlighted to red.
+ if (index % 2 === 1) span.classList.add('ui', 'text', 'red');
+ a.append(span);
+ }
+ repoFindFileTableBody.append(row);
+ }
+}
+
+async function loadRepoFiles() {
+ const response = await GET(repoFindFileInput.getAttribute('data-url-data-link'));
+ files = await response.json();
+ filterRepoFiles(repoFindFileInput.value);
+}
+
+export function initFindFileInRepo() {
+ repoFindFileInput = document.getElementById('repo-file-find-input');
+ if (!repoFindFileInput) return;
+
+ repoFindFileTableBody = document.querySelector('#repo-find-file-table tbody');
+ repoFindFileNoResult = document.getElementById('repo-find-file-no-result');
+ repoFindFileInput.addEventListener('input', () => filterRepoFiles(repoFindFileInput.value));
+
+ loadRepoFiles();
+}
diff --git a/web_src/js/features/repo-findfile.test.js b/web_src/js/features/repo-findfile.test.js
new file mode 100644
index 0000000..2d96ed4
--- /dev/null
+++ b/web_src/js/features/repo-findfile.test.js
@@ -0,0 +1,34 @@
+import {strSubMatch, calcMatchedWeight, filterRepoFilesWeighted} from './repo-findfile.js';
+
+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']);
+ });
+});
diff --git a/web_src/js/features/repo-graph.js b/web_src/js/features/repo-graph.js
new file mode 100644
index 0000000..689b6f1
--- /dev/null
+++ b/web_src/js/features/repo-graph.js
@@ -0,0 +1,155 @@
+import $ from 'jquery';
+import {hideElem, showElem} from '../utils/dom.js';
+import {GET} from '../modules/fetch.js';
+
+export function initRepoGraphGit() {
+ const graphContainer = document.getElementById('git-graph-container');
+ if (!graphContainer) return;
+
+ document.getElementById('flow-color-monochrome')?.addEventListener('click', () => {
+ document.getElementById('flow-color-monochrome').classList.add('active');
+ document.getElementById('flow-color-colored')?.classList.remove('active');
+ graphContainer.classList.remove('colored');
+ graphContainer.classList.add('monochrome');
+ const params = new URLSearchParams(window.location.search);
+ params.set('mode', 'monochrome');
+ const queryString = params.toString();
+ if (queryString) {
+ window.history.replaceState({}, '', `?${queryString}`);
+ } else {
+ window.history.replaceState({}, '', window.location.pathname);
+ }
+ for (const link of document.querySelectorAll('.pagination a')) {
+ const href = link.getAttribute('href');
+ if (!href) continue;
+ const url = new URL(href, window.location);
+ const params = url.searchParams;
+ params.set('mode', 'monochrome');
+ url.search = `?${params.toString()}`;
+ link.setAttribute('href', url.href);
+ }
+ });
+
+ document.getElementById('flow-color-colored')?.addEventListener('click', () => {
+ document.getElementById('flow-color-colored').classList.add('active');
+ document.getElementById('flow-color-monochrome')?.classList.remove('active');
+ graphContainer.classList.add('colored');
+ graphContainer.classList.remove('monochrome');
+ for (const link of document.querySelectorAll('.pagination a')) {
+ const href = link.getAttribute('href');
+ if (!href) continue;
+ const url = new URL(href, window.location);
+ const params = url.searchParams;
+ params.delete('mode');
+ url.search = `?${params.toString()}`;
+ link.setAttribute('href', url.href);
+ }
+ const params = new URLSearchParams(window.location.search);
+ params.delete('mode');
+ const queryString = params.toString();
+ if (queryString) {
+ window.history.replaceState({}, '', `?${queryString}`);
+ } else {
+ window.history.replaceState({}, '', window.location.pathname);
+ }
+ });
+ const url = new URL(window.location);
+ const params = url.searchParams;
+ const updateGraph = () => {
+ const queryString = params.toString();
+ const ajaxUrl = new URL(url);
+ ajaxUrl.searchParams.set('div-only', 'true');
+ window.history.replaceState({}, '', queryString ? `?${queryString}` : window.location.pathname);
+ document.getElementById('pagination').innerHTML = '';
+ hideElem('#rel-container');
+ hideElem('#rev-container');
+ showElem('#loading-indicator');
+ (async () => {
+ const response = await GET(String(ajaxUrl));
+ const html = await response.text();
+ const div = document.createElement('div');
+ div.innerHTML = html;
+ document.getElementById('pagination').innerHTML = div.querySelector('#pagination').innerHTML;
+ document.getElementById('rel-container').innerHTML = div.querySelector('#rel-container').innerHTML;
+ document.getElementById('rev-container').innerHTML = div.querySelector('#rev-container').innerHTML;
+ hideElem('#loading-indicator');
+ showElem('#rel-container');
+ showElem('#rev-container');
+ })();
+ };
+ 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 flowSelectRefsDropdown = document.getElementById('flow-select-refs-dropdown');
+ $(flowSelectRefsDropdown).dropdown('set selected', dropdownSelected);
+ $(flowSelectRefsDropdown).dropdown({
+ clearable: true,
+ fullTextSeach: 'exact',
+ onRemove(toRemove) {
+ 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);
+ }
+ }
+ }
+ updateGraph();
+ },
+ onAdd(toAdd) {
+ if (toAdd === '...flow-hide-pr-refs') {
+ params.set('hide-pr-refs', true);
+ } else {
+ params.append('branch', toAdd);
+ }
+ updateGraph();
+ },
+ });
+
+ graphContainer.addEventListener('mouseenter', (e) => {
+ if (e.target.matches('#rev-list li')) {
+ const flow = e.target.getAttribute('data-flow');
+ if (flow === '0') return;
+ document.getElementById(`flow-${flow}`)?.classList.add('highlight');
+ e.target.classList.add('hover');
+ for (const item of document.querySelectorAll(`#rev-list li[data-flow='${flow}']`)) {
+ item.classList.add('highlight');
+ }
+ } else if (e.target.matches('#rel-container .flow-group')) {
+ e.target.classList.add('highlight');
+ const flow = e.target.getAttribute('data-flow');
+ for (const item of document.querySelectorAll(`#rev-list li[data-flow='${flow}']`)) {
+ item.classList.add('highlight');
+ }
+ } else if (e.target.matches('#rel-container .flow-commit')) {
+ const rev = e.target.getAttribute('data-rev');
+ document.querySelector(`#rev-list li#commit-${rev}`)?.classList.add('hover');
+ }
+ });
+
+ graphContainer.addEventListener('mouseleave', (e) => {
+ if (e.target.matches('#rev-list li')) {
+ const flow = e.target.getAttribute('data-flow');
+ if (flow === '0') return;
+ document.getElementById(`flow-${flow}`)?.classList.remove('highlight');
+ e.target.classList.remove('hover');
+ for (const item of document.querySelectorAll(`#rev-list li[data-flow='${flow}']`)) {
+ item.classList.remove('highlight');
+ }
+ } else if (e.target.matches('#rel-container .flow-group')) {
+ e.target.classList.remove('highlight');
+ const flow = e.target.getAttribute('data-flow');
+ for (const item of document.querySelectorAll(`#rev-list li[data-flow='${flow}']`)) {
+ item.classList.remove('highlight');
+ }
+ } else if (e.target.matches('#rel-container .flow-commit')) {
+ const rev = e.target.getAttribute('data-rev');
+ document.querySelector(`#rev-list li#commit-${rev}`)?.classList.remove('hover');
+ }
+ });
+}
diff --git a/web_src/js/features/repo-home.js b/web_src/js/features/repo-home.js
new file mode 100644
index 0000000..6a5bce8
--- /dev/null
+++ b/web_src/js/features/repo-home.js
@@ -0,0 +1,147 @@
+import $ from 'jquery';
+import {stripTags} from '../utils.js';
+import {hideElem, queryElemChildren, showElem} from '../utils/dom.js';
+import {POST} from '../modules/fetch.js';
+import {showErrorToast} from '../modules/toast.js';
+
+const {appSubUrl} = window.config;
+
+export function initRepoTopicBar() {
+ const mgrBtn = document.getElementById('manage_topic');
+ if (!mgrBtn) return;
+
+ const editDiv = document.getElementById('topic_edit');
+ const viewDiv = document.getElementById('repo-topics');
+ const topicDropdown = editDiv.querySelector('.ui.dropdown');
+ let lastErrorToast;
+
+ mgrBtn.addEventListener('click', () => {
+ hideElem(viewDiv);
+ showElem(editDiv);
+ topicDropdown.querySelector('input.search').focus();
+ });
+
+ document.querySelector('#cancel_topic_edit').addEventListener('click', () => {
+ lastErrorToast?.hideToast();
+ hideElem(editDiv);
+ showElem(viewDiv);
+ mgrBtn.focus();
+ });
+
+ document.getElementById('save_topic').addEventListener('click', async (e) => {
+ lastErrorToast?.hideToast();
+ const topics = editDiv.querySelector('input[name=topics]').value;
+
+ const data = new FormData();
+ data.append('topics', topics);
+
+ const response = await POST(e.target.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) {
+ // it should match the code in repo/home.tmpl
+ const link = document.createElement('a');
+ link.classList.add('repo-topic', 'ui', 'large', 'label');
+ link.href = `${appSubUrl}/explore/repos?q=${encodeURIComponent(topic)}&topic=1`;
+ link.textContent = topic;
+ mgrBtn.parentNode.insertBefore(link, mgrBtn); // insert all new topics before manage button
+ }
+ }
+ hideElem(editDiv);
+ showElem(viewDiv);
+ }
+ } 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.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');
+ }
+ }
+ }
+ }
+ });
+
+ $(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(res) {
+ const formattedResponse = {
+ success: false,
+ results: [],
+ };
+ 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 (let i = 0; i < res.topics.length; i++) {
+ // skip currently added tags
+ if (current_topics.includes(res.topics[i].topic_name)) {
+ continue;
+ }
+
+ if (res.topics[i].topic_name.toLowerCase() === query.toLowerCase()) {
+ found_query = true;
+ }
+ formattedResponse.results.push({description: res.topics[i].topic_name, 'data-value': res.topics[i].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) {
+ value = value.toLowerCase().trim();
+ this.attr('data-value', value).contents().first().replaceWith(value);
+ return $(this);
+ },
+ onAdd(addedValue, _addedText, $addedChoice) {
+ addedValue = addedValue.toLowerCase().trim();
+ $addedChoice[0].setAttribute('data-value', addedValue);
+ $addedChoice[0].setAttribute('data-text', addedValue);
+ },
+ });
+}
diff --git a/web_src/js/features/repo-issue-content.js b/web_src/js/features/repo-issue-content.js
new file mode 100644
index 0000000..cef2f49
--- /dev/null
+++ b/web_src/js/features/repo-issue-content.js
@@ -0,0 +1,154 @@
+import $ from 'jquery';
+import {svg} from '../svg.js';
+import {showErrorToast} from '../modules/toast.js';
+import {GET, POST} from '../modules/fetch.js';
+import {showElem} from '../utils/dom.js';
+
+const {appSubUrl} = window.config;
+let i18nTextEdited;
+let i18nTextOptions;
+let i18nTextDeleteFromHistory;
+let i18nTextDeleteFromHistoryConfirm;
+
+function showContentHistoryDetail(issueBaseUrl, commentId, historyId, itemTitleHtml) {
+ let $dialog = $('.content-history-detail-dialog');
+ if ($dialog.length) return;
+
+ $dialog = $(`
+<div class="ui modal content-history-detail-dialog">
+ ${svg('octicon-x', 16, 'close icon inside')}
+ <div class="header tw-flex tw-items-center tw-justify-between">
+ <div>${itemTitleHtml}</div>
+ <div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
+ ${i18nTextOptions}
+ ${svg('octicon-triangle-down', 14, 'dropdown icon')}
+ <div class="menu">
+ <div class="item red text" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
+ </div>
+ </div>
+ </div>
+ <div class="comment-diff-data is-loading"></div>
+</div>`);
+ $dialog.appendTo($('body'));
+ $dialog.find('.dialog-header-options').dropdown({
+ showOnFocus: false,
+ allowReselection: true,
+ async onChange(_value, _text, $item) {
+ 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) {
+ $dialog.modal('hide');
+ } 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() {
+ $(this).dropdown('clear', true);
+ },
+ });
+ $dialog.modal({
+ 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 = $dialog.find('.comment-diff-data')[0];
+ 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($dialog.find('.dialog-header-options'));
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ }
+ },
+ onHidden() {
+ $dialog.remove();
+ },
+ }).modal('show');
+}
+
+function showContentHistoryMenu(issueBaseUrl, $item, commentId) {
+ const $headerLeft = $item.find('.comment-header-left');
+ const menuHtml = `
+ <div class="ui dropdown interact-fg content-history-menu" data-comment-id="${commentId}">
+ &bull; ${i18nTextEdited}${svg('octicon-triangle-down', 14, 'dropdown icon')}
+ <div class="menu">
+ </div>
+ </div>`;
+
+ $headerLeft.find(`.content-history-menu`).remove();
+ $headerLeft.append($(menuHtml));
+ $headerLeft.find('.dropdown').dropdown({
+ action: 'hide',
+ apiSettings: {
+ cache: false,
+ url: `${issueBaseUrl}/content-history/list?comment_id=${commentId}`,
+ },
+ saveRemoteData: false,
+ onHide() {
+ $(this).dropdown('change values', null);
+ },
+ onChange(value, itemHtml, $item) {
+ if (value && !$item.find('[data-history-is-deleted=1]').length) {
+ showContentHistoryDetail(issueBaseUrl, commentId, value, itemHtml);
+ }
+ },
+ });
+}
+
+export async function initRepoIssueContentHistory() {
+ const issueIndex = $('#issueIndex').val();
+ if (!issueIndex) return;
+
+ const $itemIssue = $('.repository.issue .timeline-item.comment.first'); // issue(PR) main content
+ const $comments = $('.repository.issue .comment-list .comment'); // includes: issue(PR) comments, review comments, code comments
+ if (!$itemIssue.length && !$comments.length) return;
+
+ const repoLink = $('#repolink').val();
+ const issueBaseUrl = `${appSubUrl}/${repoLink}/issues/${issueIndex}`;
+
+ 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] && $itemIssue.length) {
+ showContentHistoryMenu(issueBaseUrl, $itemIssue, '0');
+ }
+ for (const [commentId, _editedCount] of Object.entries(resp.editedHistoryCountMap)) {
+ if (commentId === '0') continue;
+ const $itemComment = $(`#issuecomment-${commentId}`);
+ showContentHistoryMenu(issueBaseUrl, $itemComment, commentId);
+ }
+ } catch (error) {
+ console.error('Error:', error);
+ }
+}
diff --git a/web_src/js/features/repo-issue-list.js b/web_src/js/features/repo-issue-list.js
new file mode 100644
index 0000000..92f058c
--- /dev/null
+++ b/web_src/js/features/repo-issue-list.js
@@ -0,0 +1,245 @@
+import $ from 'jquery';
+import {updateIssuesMeta} from './repo-issue.js';
+import {toggleElem, hideElem, isElemHidden} from '../utils/dom.js';
+import {htmlEscape} from 'escape-goat';
+import {confirmModal} from './comp/ConfirmModal.js';
+import {showErrorToast} from '../modules/toast.js';
+import {createSortable} from '../modules/sortable.js';
+import {DELETE, POST} from '../modules/fetch.js';
+import {parseDom} from '../utils.js';
+
+function initRepoIssueListCheckboxes() {
+ const issueSelectAll = document.querySelector('.issue-checkbox-all');
+ if (!issueSelectAll) return; // logged out state
+ const issueCheckboxes = document.querySelectorAll('.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('#issue-filters, #issue-actions');
+ const visiblePanel = Array.from(panels).find((el) => !isElemHidden(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();
+ });
+
+ $('.issue-action').on('click', async function (e) {
+ e.preventDefault();
+
+ const url = this.getAttribute('data-url');
+ let action = this.getAttribute('data-action');
+ let elementId = this.getAttribute('data-element-id');
+ let issueIDs = [];
+ for (const el of document.querySelectorAll('.issue-checkbox:checked')) {
+ issueIDs.push(el.getAttribute('data-issue-id'));
+ }
+ issueIDs = issueIDs.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 = e.target.getAttribute('data-action-delete-confirm');
+ if (!await confirmModal({content: confirmText, buttonColor: 'orange'})) {
+ return;
+ }
+ }
+
+ try {
+ await updateIssuesMeta(url, action, issueIDs, elementId);
+ window.location.reload();
+ } catch (err) {
+ showErrorToast(err.responseJSON?.error ?? err.message);
+ }
+ });
+}
+
+function initRepoIssueListAuthorDropdown() {
+ const $searchDropdown = $('.user-remote-search');
+ if (!$searchDropdown.length) return;
+
+ let searchUrl = $searchDropdown[0].getAttribute('data-search-url');
+ const actionJumpUrl = $searchDropdown[0].getAttribute('data-action-jump-url');
+ const selectedUserId = $searchDropdown[0].getAttribute('data-selected-user-id');
+ if (!searchUrl.includes('?')) searchUrl += '?';
+
+ $searchDropdown.dropdown('setting', {
+ fullTextSearch: true,
+ selectOnKeydown: false,
+ apiSettings: {
+ cache: false,
+ url: `${searchUrl}&q={query}`,
+ onResponse(resp) {
+ // the content is provided by backend IssuePosters handler
+ const processedResults = []; // to be used by dropdown to generate menu items
+ for (const item of resp.results) {
+ let html = `<img class="ui avatar tw-align-middle" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt="" width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`;
+ if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</span>`;
+ processedResults.push({value: item.user_id, name: html});
+ }
+ resp.results = processedResults;
+ return resp;
+ },
+ },
+ action: (_text, value) => {
+ window.location.href = actionJumpUrl.replace('{user_id}', encodeURIComponent(value));
+ },
+ 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) {
+ const menu = $searchDropdown.find('> .menu')[0];
+ // remove old dynamic items
+ for (const el of menu.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');
+ menu.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(() => {
+ for (const el of menu.querySelectorAll('.item.active, .item.selected')) {
+ el.classList.remove('active', 'selected');
+ }
+ menu.querySelector(`.item[data-value="${selectedUserId}"]`)?.classList.add('selected');
+ }, 0);
+ };
+}
+
+function initPinRemoveButton() {
+ for (const button of document.getElementsByClassName('issue-card-unpin')) {
+ button.addEventListener('click', async (event) => {
+ const el = event.currentTarget;
+ 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) {
+ 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.getElementById('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: pinMoveEnd,
+ });
+}
+
+function initArchivedLabelFilter() {
+ const archivedLabelEl = document.querySelector('#archived-filter-checkbox');
+ if (!archivedLabelEl) {
+ return;
+ }
+
+ const url = new URL(window.location.href);
+ const archivedLabels = document.querySelectorAll('[data-is-archived]');
+
+ if (!archivedLabels.length) {
+ hideElem('.archived-label-filter');
+ return;
+ }
+ const selectedLabels = (url.searchParams.get('labels') || '')
+ .split(',')
+ .map((id) => id < 0 ? `${~id + 1}` : id); // selectedLabels contains -ve ids, which are excluded so convert any -ve value id to +ve
+
+ const archivedElToggle = () => {
+ for (const label of archivedLabels) {
+ const id = label.getAttribute('data-label-id');
+ toggleElem(label, archivedLabelEl.checked || selectedLabels.includes(id));
+ }
+ };
+
+ archivedElToggle();
+ archivedLabelEl.addEventListener('change', () => {
+ archivedElToggle();
+ if (archivedLabelEl.checked) {
+ url.searchParams.set('archived', 'true');
+ } else {
+ url.searchParams.delete('archived');
+ }
+ window.location.href = url.href;
+ });
+}
+
+export function initRepoIssueList() {
+ if (!document.querySelectorAll('.page-content.repository.issue-list, .page-content.repository.milestone-issue-list').length) return;
+ initRepoIssueListCheckboxes();
+ initRepoIssueListAuthorDropdown();
+ initIssuePinSort();
+ initArchivedLabelFilter();
+}
diff --git a/web_src/js/features/repo-issue-pr-form.js b/web_src/js/features/repo-issue-pr-form.js
new file mode 100644
index 0000000..7b26e64
--- /dev/null
+++ b/web_src/js/features/repo-issue-pr-form.js
@@ -0,0 +1,10 @@
+import {createApp} from 'vue';
+import PullRequestMergeForm from '../components/PullRequestMergeForm.vue';
+
+export function initRepoPullRequestMergeForm() {
+ const el = document.getElementById('pull-request-merge-form');
+ if (!el) return;
+
+ const view = createApp(PullRequestMergeForm);
+ view.mount(el);
+}
diff --git a/web_src/js/features/repo-issue-pr-status.js b/web_src/js/features/repo-issue-pr-status.js
new file mode 100644
index 0000000..7890b9c
--- /dev/null
+++ b/web_src/js/features/repo-issue-pr-status.js
@@ -0,0 +1,10 @@
+export function initRepoPullRequestCommitStatus() {
+ for (const btn of document.querySelectorAll('.commit-status-hide-checks')) {
+ const panel = btn.closest('.commit-status-panel');
+ const list = panel.querySelector('.commit-status-list');
+ btn.addEventListener('click', () => {
+ list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
+ btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
+ });
+ }
+}
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js
new file mode 100644
index 0000000..c4bd70b
--- /dev/null
+++ b/web_src/js/features/repo-issue.js
@@ -0,0 +1,794 @@
+import $ from 'jquery';
+import {htmlEscape} from 'escape-goat';
+import {showTemporaryTooltip, createTippy} from '../modules/tippy.js';
+import {hideElem, showElem, toggleElem} from '../utils/dom.js';
+import {setFileFolding} from './file-fold.js';
+import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
+import {toAbsoluteUrl} from '../utils.js';
+import {initDropzone} from './common-global.js';
+import {POST, GET} from '../modules/fetch.js';
+import {showErrorToast} from '../modules/toast.js';
+import {emojiHTML} from './emoji.js';
+
+const {appSubUrl} = window.config;
+
+// if there are draft comments, confirm before reloading, to avoid losing comments
+export function reloadConfirmDraftComment() {
+ const commentTextareas = [
+ document.querySelector('.edit-content-zone:not(.tw-hidden) textarea'),
+ document.querySelector('#comment-form textarea'),
+ ];
+ for (const textarea of commentTextareas) {
+ // Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
+ // But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
+ if (textarea && textarea.value.trim().length > 10) {
+ textarea.parentElement.scrollIntoView();
+ if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
+ return;
+ }
+ break;
+ }
+ }
+ window.location.reload();
+}
+
+export function initRepoIssueTimeTracking() {
+ $(document).on('click', '.issue-add-time', () => {
+ $('.issue-start-time-modal').modal({
+ duration: 200,
+ onApprove() {
+ $('#add_time_manual_form').trigger('submit');
+ },
+ }).modal('show');
+ $('.issue-start-time-modal input').on('keydown', (e) => {
+ if ((e.keyCode || e.key) === 13) {
+ $('#add_time_manual_form').trigger('submit');
+ }
+ });
+ });
+ $(document).on('click', '.issue-start-time, .issue-stop-time', () => {
+ $('#toggle_stopwatch_form').trigger('submit');
+ });
+ $(document).on('click', '.issue-cancel-time', () => {
+ $('#cancel_stopwatch_form').trigger('submit');
+ });
+ $(document).on('click', 'button.issue-delete-time', function () {
+ const sel = `.issue-delete-time-modal[data-id="${$(this).data('id')}"]`;
+ $(sel).modal({
+ duration: 200,
+ onApprove() {
+ $(`${sel} form`).trigger('submit');
+ },
+ }).modal('show');
+ });
+}
+
+async function updateDeadline(deadlineString) {
+ hideElem('#deadline-err-invalid-date');
+ document.getElementById('deadline-loader')?.classList.add('is-loading');
+
+ let realDeadline = null;
+ if (deadlineString !== '') {
+ const newDate = Date.parse(deadlineString);
+
+ if (Number.isNaN(newDate)) {
+ document.getElementById('deadline-loader')?.classList.remove('is-loading');
+ showElem('#deadline-err-invalid-date');
+ return false;
+ }
+ realDeadline = new Date(newDate);
+ }
+
+ try {
+ const response = await POST(document.getElementById('update-issue-deadline-form').getAttribute('action'), {
+ data: {due_date: realDeadline},
+ });
+
+ if (response.ok) {
+ window.location.reload();
+ } else {
+ throw new Error('Invalid response');
+ }
+ } catch (error) {
+ console.error(error);
+ document.getElementById('deadline-loader').classList.remove('is-loading');
+ showElem('#deadline-err-invalid-date');
+ }
+}
+
+export function initRepoIssueDue() {
+ $(document).on('click', '.issue-due-edit', () => {
+ toggleElem('#deadlineForm');
+ });
+ $(document).on('click', '.issue-due-remove', () => {
+ updateDeadline('');
+ });
+ $(document).on('submit', '.issue-due-form', () => {
+ updateDeadline($('#deadlineDate').val());
+ return false;
+ });
+}
+
+/**
+ * @param {HTMLElement} item
+ */
+function excludeLabel(item) {
+ const href = item.getAttribute('href');
+ const id = item.getAttribute('data-label-id');
+
+ const regStr = `labels=((?:-?[0-9]+%2c)*)(${id})((?:%2c-?[0-9]+)*)&`;
+ const newStr = 'labels=$1-$2$3&';
+
+ window.location = href.replace(new RegExp(regStr), newStr);
+}
+
+export function initRepoIssueSidebarList() {
+ const repolink = $('#repolink').val();
+ const repoId = $('#repoId').val();
+ const crossRepoSearch = $('#crossRepoSearch').val();
+ const tp = $('#type').val();
+ let issueSearchUrl = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}`;
+ if (crossRepoSearch === 'true') {
+ issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}`;
+ }
+ $('#new-dependency-drop-list')
+ .dropdown({
+ apiSettings: {
+ url: issueSearchUrl,
+ onResponse(response) {
+ const filteredResponse = {success: true, results: []};
+ const currIssueId = $('#new-dependency-drop-list').data('issue-id');
+ // Parse the response from the api to work with our dropdown
+ $.each(response, (_i, issue) => {
+ // Don't list current issue in the dependency list.
+ if (issue.id === currIssueId) {
+ return;
+ }
+ filteredResponse.results.push({
+ name: `#${issue.number} ${issueTitleHTML(htmlEscape(issue.title))
+ }<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`,
+ value: issue.id,
+ });
+ });
+ return filteredResponse;
+ },
+ cache: false,
+ },
+
+ fullTextSearch: true,
+ });
+
+ $('.menu a.label-filter-item').each(function () {
+ $(this).on('click', function (e) {
+ if (e.altKey) {
+ e.preventDefault();
+ excludeLabel(this);
+ }
+ });
+ });
+
+ $('.menu .ui.dropdown.label-filter').on('keydown', (e) => {
+ if (e.altKey && e.keyCode === 13) {
+ const selectedItem = document.querySelector('.menu .ui.dropdown.label-filter .menu .item.selected');
+ if (selectedItem) {
+ excludeLabel(selectedItem);
+ }
+ }
+ });
+ $('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems');
+}
+
+export function initRepoIssueCommentDelete() {
+ // Delete comment
+ document.addEventListener('click', async (e) => {
+ if (!e.target.matches('.delete-comment')) return;
+ e.preventDefault();
+
+ const deleteButton = e.target;
+ 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', num);
+ counter.textContent = String(num);
+ }
+
+ document.getElementById(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');
+
+ 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 initRepoIssueDependencyDelete() {
+ // Delete Issue dependency
+ $(document).on('click', '.delete-dependency-button', (e) => {
+ const id = e.currentTarget.getAttribute('data-id');
+ const type = e.currentTarget.getAttribute('data-type');
+
+ $('.remove-dependency').modal({
+ closable: false,
+ duration: 200,
+ onApprove: () => {
+ $('#removeDependencyID').val(id);
+ $('#dependencyType').val(type);
+ $('#removeDependencyForm').trigger('submit');
+ },
+ }).modal('show');
+ });
+}
+
+export function initRepoIssueCodeCommentCancel() {
+ // Cancel inline code comment
+ document.addEventListener('click', (e) => {
+ if (!e.target.matches('.cancel-code-comment')) return;
+
+ const form = e.target.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 initRepoPullRequestUpdate() {
+ // Pull Request update button
+ const pullUpdateButton = document.querySelector('.update-button > button');
+ if (!pullUpdateButton) return;
+
+ pullUpdateButton.addEventListener('click', async function (e) {
+ e.preventDefault();
+ const redirect = this.getAttribute('data-redirect');
+ this.classList.add('is-loading');
+ let response;
+ try {
+ response = await POST(this.getAttribute('data-do'));
+ } catch (error) {
+ console.error(error);
+ } finally {
+ this.classList.remove('is-loading');
+ }
+ let data;
+ try {
+ data = await response?.json(); // the response is probably not a JSON
+ } catch (error) {
+ console.error(error);
+ }
+ if (data?.redirect) {
+ window.location.href = data.redirect;
+ } else if (redirect) {
+ window.location.href = redirect;
+ } else {
+ window.location.reload();
+ }
+ });
+
+ $('.update-button > .dropdown').dropdown({
+ onChange(_text, _value, $choice) {
+ const url = $choice[0].getAttribute('data-do');
+ if (url) {
+ const buttonText = pullUpdateButton.querySelector('.button-text');
+ if (buttonText) {
+ buttonText.textContent = $choice.text();
+ }
+ pullUpdateButton.setAttribute('data-do', url);
+ }
+ },
+ });
+}
+
+export function initRepoPullRequestAllowMaintainerEdit() {
+ const wrapper = document.getElementById('allow-edits-from-maintainers');
+ if (!wrapper) return;
+ const checkbox = wrapper.querySelector('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: 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 initRepoIssueReferenceRepositorySearch() {
+ $('.issue_reference_repository_search')
+ .dropdown({
+ apiSettings: {
+ url: `${appSubUrl}/repo/search?q={query}&limit=20`,
+ onResponse(response) {
+ const filteredResponse = {success: true, results: []};
+ $.each(response.data, (_r, repo) => {
+ filteredResponse.results.push({
+ name: htmlEscape(repo.repository.full_name),
+ value: repo.repository.full_name,
+ });
+ });
+ return filteredResponse;
+ },
+ cache: false,
+ },
+ onChange(_value, _text, $choice) {
+ const $form = $choice.closest('form');
+ if (!$form.length) return;
+
+ $form[0].setAttribute('action', `${appSubUrl}/${_text}/issues/new`);
+ },
+ fullTextSearch: true,
+ });
+}
+
+export function initRepoIssueWipTitle() {
+ $('.title_wip_desc > a').on('click', (e) => {
+ e.preventDefault();
+
+ const $issueTitle = $('#issue_title');
+ $issueTitle.trigger('focus');
+ const value = $issueTitle.val().trim().toUpperCase();
+
+ const wipPrefixes = $('.title_wip_desc').data('wip-prefixes');
+ for (const prefix of wipPrefixes) {
+ if (value.startsWith(prefix.toUpperCase())) {
+ return;
+ }
+ }
+
+ $issueTitle.val(`${wipPrefixes[0]} ${$issueTitle.val()}`);
+ });
+}
+
+export async function updateIssuesMeta(url, action, issue_ids, id) {
+ 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 initRepoIssueComments() {
+ if (!$('.repository.view.issue .timeline').length) return;
+
+ $('.re-request-review').on('click', async function (e) {
+ e.preventDefault();
+ const url = this.getAttribute('data-update-url');
+ const issueId = this.getAttribute('data-issue-id');
+ const id = this.getAttribute('data-id');
+ const isChecked = this.classList.contains('checked');
+
+ await updateIssuesMeta(url, isChecked ? 'detach' : 'attach', issueId, id);
+ window.location.reload();
+ });
+
+ document.addEventListener('click', (e) => {
+ 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.closest(`#${urlTargetId}`)) {
+ const scrollPosition = $(window).scrollTop();
+ window.location.hash = '';
+ $(window).scrollTop(scrollPosition);
+ window.history.pushState(null, null, ' ');
+ }
+ });
+}
+
+export async function handleReply($el) {
+ hideElem($el);
+ const $form = $el.closest('.comment-code-cloud').find('.comment-form');
+ showElem($form);
+
+ const $textarea = $form.find('textarea');
+ let editor = getComboMarkdownEditor($textarea);
+ if (!editor) {
+ // FIXME: the initialization of the dropzone is not consistent.
+ // When the page is loaded, the dropzone is initialized by initGlobalDropzone, but the editor is not initialized.
+ // When the form is submitted and partially reload, none of them is initialized.
+ const dropzone = $form.find('.dropzone')[0];
+ if (!dropzone.dropzone) initDropzone(dropzone);
+ editor = await initComboMarkdownEditor($form.find('.combo-markdown-editor'));
+ }
+ editor.focus();
+ return editor;
+}
+
+export function initRepoPullRequestReview() {
+ if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) {
+ // 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';
+ }
+ 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 && groupID.startsWith('code-comments-')) {
+ const id = groupID.slice(14);
+ const ancestorDiffBox = commentDiv.closest('.diff-file-box');
+ // on pages like conversation, there is no diff header
+ const diffHeader = ancestorDiffBox?.querySelector('.diff-file-header');
+
+ // offset is for scrolling
+ let offset = 30;
+ if (diffHeader) {
+ offset += $('.diff-detail-box').outerHeight() + $(diffHeader).outerHeight();
+ }
+
+ 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);
+ }
+
+ window.scrollTo({
+ top: $(commentDiv).offset().top - offset,
+ behavior: 'instant',
+ });
+ }
+ }
+ }
+
+ $(document).on('click', '.show-outdated', function (e) {
+ e.preventDefault();
+ const id = this.getAttribute('data-comment');
+ hideElem(this);
+ showElem(`#code-comments-${id}`);
+ showElem(`#code-preview-${id}`);
+ showElem(`#hide-outdated-${id}`);
+ });
+
+ $(document).on('click', '.hide-outdated', function (e) {
+ e.preventDefault();
+ const id = this.getAttribute('data-comment');
+ hideElem(this);
+ hideElem(`#code-comments-${id}`);
+ hideElem(`#code-preview-${id}`);
+ showElem(`#show-outdated-${id}`);
+ });
+
+ $(document).on('click', 'button.comment-form-reply', async function (e) {
+ e.preventDefault();
+ await handleReply($(this));
+ });
+
+ const $reviewBox = $('.review-box-panel');
+ if ($reviewBox.length === 1) {
+ const _promise = initComboMarkdownEditor($reviewBox.find('.combo-markdown-editor'));
+ }
+
+ // The following part is only for diff views
+ if (!$('.repository.pull.diff').length) return;
+
+ const $reviewBtn = $('.js-btn-review');
+ const $panel = $reviewBtn.parent().find('.review-box-panel');
+ const $closeBtn = $panel.find('.close');
+
+ if ($reviewBtn.length && $panel.length) {
+ const tippy = createTippy($reviewBtn[0], {
+ content: $panel[0],
+ placement: 'bottom',
+ trigger: 'click',
+ maxWidth: 'none',
+ interactive: true,
+ hideOnClick: true,
+ });
+
+ $closeBtn.on('click', (e) => {
+ e.preventDefault();
+ tippy.hide();
+ });
+ }
+
+ $(document).on('click', '.add-code-comment', async function (e) {
+ if (e.target.classList.contains('btn-add-single')) return; // https://github.com/go-gitea/gitea/issues/4745
+ e.preventDefault();
+
+ const isSplit = this.closest('.code-diff')?.classList.contains('code-diff-split');
+ const side = this.getAttribute('data-side');
+ const idx = this.getAttribute('data-idx');
+ const path = this.closest('[data-path]')?.getAttribute('data-path');
+ const tr = this.closest('tr');
+ const lineType = tr.getAttribute('data-line-type');
+
+ const ntr = tr.nextElementSibling;
+ let $ntr = $(ntr);
+ if (!ntr?.classList.contains('add-comment')) {
+ $ntr = $(`
+ <tr class="add-comment" data-line-type="${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.find(`.add-comment-${side}`);
+ const $commentCloud = $td.find('.comment-code-cloud');
+ if (!$commentCloud.length && !$ntr.find('button[name="pending_review"]').length) {
+ try {
+ const response = await GET(this.closest('[data-new-comment-url]')?.getAttribute('data-new-comment-url'));
+ const html = await response.text();
+ $td.html(html);
+ $td.find("input[name='line']").val(idx);
+ $td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
+ $td.find("input[name='path']").val(path);
+
+ initDropzone($td.find('.dropzone')[0]);
+ const editor = await initComboMarkdownEditor($td.find('.combo-markdown-editor'));
+ editor.focus();
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ });
+}
+
+export function initRepoIssueReferenceIssue() {
+ // Reference issue
+ $(document).on('click', '.reference-issue', function (event) {
+ const $this = $(this);
+ const content = $(`#${$this.data('target')}`).text();
+ const poster = $this.data('poster-username');
+ const reference = toAbsoluteUrl($this.data('reference'));
+ const $modal = $($this.data('modal'));
+ $modal.find('textarea[name="content"]').val(`${content}\n\n_Originally posted by @${poster} in ${reference}_`);
+ $modal.modal('show');
+
+ event.preventDefault();
+ });
+}
+
+export function initRepoIssueWipToggle() {
+ // Toggle WIP
+ $('.toggle-wip a, .toggle-wip button').on('click', async (e) => {
+ e.preventDefault();
+ const toggleWip = e.currentTarget.closest('.toggle-wip');
+ const title = toggleWip.getAttribute('data-title');
+ const wipPrefixes = JSON.parse(toggleWip.getAttribute('data-wip-prefixes'));
+ const updateUrl = toggleWip.getAttribute('data-update-url');
+ const prefix = wipPrefixes.find((prefix) => title.startsWith(prefix));
+
+ try {
+ const params = new URLSearchParams();
+ params.append('title', prefix !== undefined ? title.slice(prefix.length).trim() : `${wipPrefixes[0].trim()} ${title}`);
+
+ const response = await POST(updateUrl, {data: params});
+ if (!response.ok) {
+ throw new Error('Failed to toggle WIP status');
+ }
+ window.location.reload();
+ } catch (error) {
+ console.error(error);
+ }
+ });
+}
+
+export function initRepoIssueTitleEdit() {
+ const issueTitleDisplay = document.querySelector('#issue-title-display');
+ const issueTitleEditor = document.querySelector('#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');
+ const saveAndRefresh = async () => {
+ 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: newTargetBranch})});
+ if (!resp.ok) {
+ throw new Error(`Failed to update PR target branch: ${resp.statusText}`);
+ }
+ }
+ }
+ window.location.reload();
+ } catch (error) {
+ console.error(error);
+ showErrorToast(error.message);
+ }
+ };
+ editSaveButton.addEventListener('click', saveAndRefresh);
+ issueTitleEditor.querySelector('input').addEventListener('ce-quick-submit', saveAndRefresh);
+}
+
+export function initRepoIssueBranchSelect() {
+ document.querySelector('#branch-select')?.addEventListener('click', (e) => {
+ const el = e.target.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);
+ });
+}
+
+export function initRepoIssueAssignMe() {
+ // Assign to me button
+ document.querySelector('.ui.assignees.list .item.no-select .select-assign-me')
+ ?.addEventListener('click', (e) => {
+ e.preventDefault();
+ const selectMe = e.target;
+ const noSelect = selectMe.parentElement;
+ const selectorList = document.querySelector('.ui.select-assignees .menu');
+
+ if (selectMe.getAttribute('data-action') === 'update') {
+ (async () => {
+ await updateIssuesMeta(
+ selectMe.getAttribute('data-update-url'),
+ selectMe.getAttribute('data-action'),
+ selectMe.getAttribute('data-issue-id'),
+ selectMe.getAttribute('data-id'),
+ );
+ reloadConfirmDraftComment();
+ })();
+ } else {
+ for (const item of selectorList.querySelectorAll('.item')) {
+ if (item.getAttribute('data-id') === selectMe.getAttribute('data-id')) {
+ item.classList.add('checked');
+ item.querySelector('.octicon-check').classList.remove('tw-invisible');
+ }
+ }
+ document.querySelector(selectMe.getAttribute('data-id-selector')).classList.remove('tw-hidden');
+ noSelect.classList.add('tw-hidden');
+ document.querySelector(selectorList.getAttribute('data-id')).value = selectMe.getAttribute('data-id');
+ return false;
+ }
+ });
+}
+
+export function initSingleCommentEditor($commentForm) {
+ // pages:
+ // * normal new issue/pr page, no status-button
+ // * issue/pr view page, with comment form, has status-button
+ const opts = {};
+ const statusButton = document.getElementById('status-button');
+ if (statusButton) {
+ opts.onContentChanged = (editor) => {
+ const statusText = statusButton.getAttribute(editor.value().trim() ? 'data-status-and-comment' : 'data-status');
+ statusButton.textContent = statusText;
+ };
+ }
+ initComboMarkdownEditor($commentForm.find('.combo-markdown-editor'), opts);
+}
+
+export function initIssueTemplateCommentEditors($commentForm) {
+ // pages:
+ // * new issue with issue template
+ const $comboFields = $commentForm.find('.combo-editor-dropzone');
+
+ const initCombo = async ($combo) => {
+ const $dropzoneContainer = $combo.find('.form-field-dropzone');
+ const $formField = $combo.find('.form-field-real');
+ const $markdownEditor = $combo.find('.combo-markdown-editor');
+
+ const editor = await initComboMarkdownEditor($markdownEditor, {
+ onContentChanged: (editor) => {
+ $formField.val(editor.value());
+ },
+ });
+
+ $formField.on('focus', async () => {
+ // deactivate all markdown editors
+ showElem($commentForm.find('.combo-editor-dropzone .form-field-real'));
+ hideElem($commentForm.find('.combo-editor-dropzone .combo-markdown-editor'));
+ hideElem($commentForm.find('.combo-editor-dropzone .form-field-dropzone'));
+
+ // activate this markdown editor
+ hideElem($formField);
+ showElem($markdownEditor);
+ showElem($dropzoneContainer);
+
+ await editor.switchToUserPreference();
+ editor.focus();
+ });
+ };
+
+ for (const el of $comboFields) {
+ initCombo($(el));
+ }
+}
+
+// This function used to show and hide archived label on issue/pr
+// page in the sidebar where we select the labels
+// If we have any archived label tagged to issue and pr. We will show that
+// archived label with checked classed otherwise we will hide it
+// with the help of this function.
+// This function runs globally.
+export function initArchivedLabelHandler() {
+ if (!document.querySelector('.archived-label-hint')) return;
+ for (const label of document.querySelectorAll('[data-is-archived]')) {
+ toggleElem(label, label.classList.contains('checked'));
+ }
+}
+
+// Render the issue's title. It converts emojis and code blocks syntax into their respective HTML equivalent.
+export function issueTitleHTML(title) {
+ return title.replaceAll(/:[-+\w]+:/g, (emoji) => emojiHTML(emoji.substring(1, emoji.length - 1)))
+ .replaceAll(/`[^`]+`/g, (code) => `<code class="inline-code-block">${code.substring(1, code.length - 1)}</code>`);
+}
diff --git a/web_src/js/features/repo-issue.test.js b/web_src/js/features/repo-issue.test.js
new file mode 100644
index 0000000..8c9734b
--- /dev/null
+++ b/web_src/js/features/repo-issue.test.js
@@ -0,0 +1,24 @@
+import {vi} from 'vitest';
+
+import {issueTitleHTML} from './repo-issue.js';
+
+// monaco-editor does not have any exports fields, which trips up vitest
+vi.mock('./comp/ComboMarkdownEditor.js', () => ({}));
+// jQuery is missing
+vi.mock('./common-global.js', () => ({}));
+
+test('Convert issue title to html', () => {
+ expect(issueTitleHTML('')).toEqual('');
+ expect(issueTitleHTML('issue title')).toEqual('issue title');
+
+ const expected_thumbs_up = `<span class="emoji" title=":+1:">👍</span>`;
+ expect(issueTitleHTML(':+1:')).toEqual(expected_thumbs_up);
+ expect(issueTitleHTML(':invalid emoji:')).toEqual(':invalid emoji:');
+
+ const expected_code_block = `<code class="inline-code-block">code</code>`;
+ expect(issueTitleHTML('`code`')).toEqual(expected_code_block);
+ expect(issueTitleHTML('`invalid code')).toEqual('`invalid code');
+ expect(issueTitleHTML('invalid code`')).toEqual('invalid code`');
+
+ expect(issueTitleHTML('issue title :+1: `code`')).toEqual(`issue title ${expected_thumbs_up} ${expected_code_block}`);
+});
diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js
new file mode 100644
index 0000000..73aaa45
--- /dev/null
+++ b/web_src/js/features/repo-legacy.js
@@ -0,0 +1,610 @@
+import $ from 'jquery';
+import {
+ initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
+ initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue,
+ initRepoIssueTitleEdit, initRepoIssueWipToggle,
+ initRepoPullRequestUpdate, updateIssuesMeta, handleReply, initIssueTemplateCommentEditors, initSingleCommentEditor,
+ initRepoIssueAssignMe, reloadConfirmDraftComment,
+} from './repo-issue.js';
+import {initUnicodeEscapeButton} from './repo-unicode-escape.js';
+import {svg} from '../svg.js';
+import {htmlEscape} from 'escape-goat';
+import {initRepoBranchTagSelector} from '../components/RepoBranchTagSelector.vue';
+import {
+ initRepoCloneLink, initRepoCommonBranchOrTagDropdown, initRepoCommonFilterSearchDropdown,
+} from './repo-common.js';
+import {initCitationFileCopyContent} from './citation.js';
+import {initCompLabelEdit} from './comp/LabelEdit.js';
+import {initRepoDiffConversationNav} from './repo-diff.js';
+import {createDropzone} from './dropzone.js';
+import {showErrorToast} from '../modules/toast.js';
+import {initCommentContent, initMarkupContent} from '../markup/content.js';
+import {initCompReactionSelector} from './comp/ReactionSelector.js';
+import {initRepoSettingBranches} from './repo-settings.js';
+import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.js';
+import {initRepoPullRequestCommitStatus} from './repo-issue-pr-status.js';
+import {hideElem, showElem} from '../utils/dom.js';
+import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
+import {attachRefIssueContextPopup} from './contextpopup.js';
+import {POST, GET} from '../modules/fetch.js';
+
+const {csrfToken} = window.config;
+
+export function initRepoCommentForm() {
+ const $commentForm = $('.comment.form');
+ if (!$commentForm.length) return;
+
+ if ($commentForm.find('.field.combo-editor-dropzone').length) {
+ // at the moment, if a form has multiple combo-markdown-editors, it must be an issue template form
+ initIssueTemplateCommentEditors($commentForm);
+ } else if ($commentForm.find('.combo-markdown-editor').length) {
+ // 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);
+ }
+
+ function initBranchSelector() {
+ const $selectBranch = $('.ui.select-branch');
+ const $branchMenu = $selectBranch.find('.reference-list-menu');
+ const $isNewIssue = $branchMenu[0]?.classList.contains('new-issue');
+ $branchMenu.find('.item:not(.no-select)').on('click', async function () {
+ const selectedValue = $(this).data('id');
+ const editMode = $('#editing_mode').val();
+ $($(this).data('id-selector')).val(selectedValue);
+ if ($isNewIssue) {
+ $selectBranch.find('.ui .branch-name').text($(this).data('name'));
+ return;
+ }
+
+ if (editMode === 'true') {
+ const form = document.getElementById('update_issueref_form');
+ const params = new URLSearchParams();
+ params.append('ref', selectedValue);
+ try {
+ await POST(form.getAttribute('action'), {data: params});
+ window.location.reload();
+ } catch (error) {
+ console.error(error);
+ }
+ } else if (editMode === '') {
+ $selectBranch.find('.ui .branch-name').text(selectedValue);
+ }
+ });
+ $selectBranch.find('.reference.column').on('click', function () {
+ hideElem($selectBranch.find('.scrolling.reference-list-menu'));
+ $selectBranch.find('.reference .text').removeClass('black');
+ showElem($($(this).data('target')));
+ $(this).find('.text').addClass('black');
+ return false;
+ });
+ }
+
+ initBranchSelector();
+
+ // List submits
+ function initListSubmits(selector, outerSelector) {
+ const $list = $(`.ui.${outerSelector}.list`);
+ const $noSelect = $list.find('.no-select');
+ const $listMenu = $(`.${selector} .menu`);
+ let hasUpdateAction = $listMenu.data('action') === 'update';
+ const items = {};
+
+ $(`.${selector}`).dropdown({
+ 'action': 'nothing', // do not hide the menu if user presses Enter
+ fullTextSearch: 'exact',
+ async onHide() {
+ hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var
+ if (hasUpdateAction) {
+ // TODO: Add batch functionality and make this 1 network request.
+ const itemEntries = Object.entries(items);
+ for (const [elementId, item] of itemEntries) {
+ await updateIssuesMeta(
+ item['update-url'],
+ item.action,
+ item['issue-id'],
+ elementId,
+ );
+ }
+ if (itemEntries.length) {
+ reloadConfirmDraftComment();
+ }
+ }
+ },
+ });
+
+ $listMenu.find('.item:not(.no-select)').on('click', function (e) {
+ e.preventDefault();
+ if (this.classList.contains('ban-change')) {
+ return false;
+ }
+
+ hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var
+
+ const clickedItem = this; // eslint-disable-line unicorn/no-this-assignment
+ const scope = this.getAttribute('data-scope');
+
+ $(this).parent().find('.item').each(function () {
+ if (scope) {
+ // Enable only clicked item for scoped labels
+ if (this.getAttribute('data-scope') !== scope) {
+ return true;
+ }
+ if (this !== clickedItem && !this.classList.contains('checked')) {
+ return true;
+ }
+ } else if (this !== clickedItem) {
+ // Toggle for other labels
+ return true;
+ }
+
+ if (this.classList.contains('checked')) {
+ $(this).removeClass('checked');
+ $(this).find('.octicon-check').addClass('tw-invisible');
+ if (hasUpdateAction) {
+ if (!($(this).data('id') in items)) {
+ items[$(this).data('id')] = {
+ 'update-url': $listMenu.data('update-url'),
+ action: 'detach',
+ 'issue-id': $listMenu.data('issue-id'),
+ };
+ } else {
+ delete items[$(this).data('id')];
+ }
+ }
+ } else {
+ $(this).addClass('checked');
+ $(this).find('.octicon-check').removeClass('tw-invisible');
+ if (hasUpdateAction) {
+ if (!($(this).data('id') in items)) {
+ items[$(this).data('id')] = {
+ 'update-url': $listMenu.data('update-url'),
+ action: 'attach',
+ 'issue-id': $listMenu.data('issue-id'),
+ };
+ } else {
+ delete items[$(this).data('id')];
+ }
+ }
+ }
+ });
+
+ // TODO: Which thing should be done for choosing review requests
+ // to make chosen items be shown on time here?
+ if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') {
+ return false;
+ }
+
+ const listIds = [];
+ $(this).parent().find('.item').each(function () {
+ if (this.classList.contains('checked')) {
+ listIds.push($(this).data('id'));
+ $($(this).data('id-selector')).removeClass('tw-hidden');
+ } else {
+ $($(this).data('id-selector')).addClass('tw-hidden');
+ }
+ });
+ if (!listIds.length) {
+ $noSelect.removeClass('tw-hidden');
+ } else {
+ $noSelect.addClass('tw-hidden');
+ }
+ $($(this).parent().data('id')).val(listIds.join(','));
+ return false;
+ });
+ $listMenu.find('.no-select.item').on('click', function (e) {
+ e.preventDefault();
+ if (hasUpdateAction) {
+ (async () => {
+ await updateIssuesMeta(
+ $listMenu.data('update-url'),
+ 'clear',
+ $listMenu.data('issue-id'),
+ '',
+ );
+ reloadConfirmDraftComment();
+ })();
+ }
+
+ $(this).parent().find('.item').each(function () {
+ $(this).removeClass('checked');
+ $(this).find('.octicon-check').addClass('tw-invisible');
+ });
+
+ if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') {
+ return false;
+ }
+
+ $list.find('.item').each(function () {
+ $(this).addClass('tw-hidden');
+ });
+ $noSelect.removeClass('tw-hidden');
+ $($(this).parent().data('id')).val('');
+ });
+ }
+
+ // Init labels and assignees
+ initListSubmits('select-label', 'labels');
+ initListSubmits('select-assignees', 'assignees');
+ initRepoIssueAssignMe();
+ initListSubmits('select-assignees-modify', 'assignees');
+ initListSubmits('select-reviewers-modify', 'assignees');
+
+ function selectItem(select_id, input_id) {
+ const $menu = $(`${select_id} .menu`);
+ const $list = $(`.ui${select_id}.list`);
+ const hasUpdateAction = $menu.data('action') === 'update';
+
+ $menu.find('.item:not(.no-select)').on('click', function () {
+ $(this).parent().find('.item').each(function () {
+ $(this).removeClass('selected active');
+ });
+
+ $(this).addClass('selected active');
+ if (hasUpdateAction) {
+ (async () => {
+ await updateIssuesMeta(
+ $menu.data('update-url'),
+ '',
+ $menu.data('issue-id'),
+ $(this).data('id'),
+ );
+ reloadConfirmDraftComment();
+ })();
+ }
+
+ let icon = '';
+ if (input_id === '#milestone_id') {
+ icon = svg('octicon-milestone', 18, 'tw-mr-2');
+ } else if (input_id === '#project_id') {
+ icon = svg('octicon-project', 18, 'tw-mr-2');
+ } else if (input_id === '#assignee_ids') {
+ icon = `<img class="ui avatar image tw-mr-2" alt="avatar" src=${$(this).data('avatar')}>`;
+ }
+
+ $list.find('.selected').html(`
+ <a class="item muted sidebar-item-link" href=${$(this).data('href')}>
+ ${icon}
+ ${htmlEscape($(this).text())}
+ </a>
+ `);
+
+ $(`.ui${select_id}.list .no-select`).addClass('tw-hidden');
+ $(input_id).val($(this).data('id'));
+ });
+ $menu.find('.no-select.item').on('click', function () {
+ $(this).parent().find('.item:not(.no-select)').each(function () {
+ $(this).removeClass('selected active');
+ });
+
+ if (hasUpdateAction) {
+ (async () => {
+ await updateIssuesMeta(
+ $menu.data('update-url'),
+ '',
+ $menu.data('issue-id'),
+ $(this).data('id'),
+ );
+ reloadConfirmDraftComment();
+ })();
+ }
+
+ $list.find('.selected').html('');
+ $list.find('.no-select').removeClass('tw-hidden');
+ $(input_id).val('');
+ });
+ }
+
+ // Milestone, Assignee, Project
+ selectItem('.select-project', '#project_id');
+ selectItem('.select-milestone', '#milestone_id');
+ selectItem('.select-assignee', '#assignee_ids');
+}
+
+async function onEditContent(event) {
+ event.preventDefault();
+
+ const segment = this.closest('.header').nextElementSibling;
+ const editContentZone = segment.querySelector('.edit-content-zone');
+ const renderContent = segment.querySelector('.render-content');
+ const rawContent = segment.querySelector('.raw-content');
+
+ let comboMarkdownEditor;
+
+ /**
+ * @param {HTMLElement} dropzone
+ */
+ const setupDropzone = async (dropzone) => {
+ if (!dropzone) return null;
+
+ let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
+ let 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 dz = await createDropzone(dropzone, {
+ url: dropzone.getAttribute('data-upload-url'),
+ headers: {'X-Csrf-Token': csrfToken},
+ maxFiles: dropzone.getAttribute('data-max-file'),
+ maxFilesize: dropzone.getAttribute('data-max-size'),
+ acceptedFiles: ['*/*', ''].includes(dropzone.getAttribute('data-accepts')) ? null : dropzone.getAttribute('data-accepts'),
+ addRemoveLinks: true,
+ dictDefaultMessage: dropzone.getAttribute('data-default-message'),
+ dictInvalidFileType: dropzone.getAttribute('data-invalid-input-type'),
+ dictFileTooBig: dropzone.getAttribute('data-file-too-big'),
+ dictRemoveFile: dropzone.getAttribute('data-remove-file'),
+ timeout: 0,
+ thumbnailMethod: 'contain',
+ thumbnailWidth: 480,
+ thumbnailHeight: 480,
+ init() {
+ this.on('success', (file, data) => {
+ file.uuid = data.uuid;
+ fileUuidDict[file.uuid] = {submitted: false};
+ const input = document.createElement('input');
+ input.id = data.uuid;
+ input.name = 'files';
+ input.type = 'hidden';
+ input.value = data.uuid;
+ dropzone.querySelector('.files').append(input);
+ });
+ this.on('removedfile', async (file) => {
+ document.getElementById(file.uuid)?.remove();
+ if (disableRemovedfileEvent) return;
+ if (dropzone.getAttribute('data-remove-url') && !fileUuidDict[file.uuid].submitted) {
+ try {
+ await POST(dropzone.getAttribute('data-remove-url'), {data: new URLSearchParams({file: file.uuid})});
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ });
+ this.on('submit', () => {
+ for (const fileUuid of Object.keys(fileUuidDict)) {
+ fileUuidDict[fileUuid].submitted = true;
+ }
+ });
+ this.on('reload', async () => {
+ try {
+ const response = await GET(editContentZone.getAttribute('data-attachment-url'));
+ const data = await response.json();
+ // do not trigger the "removedfile" event, otherwise the attachments would be deleted from server
+ disableRemovedfileEvent = true;
+ dz.removeAllFiles(true);
+ dropzone.querySelector('.files').innerHTML = '';
+ for (const el of dropzone.querySelectorAll('.dz-preview')) el.remove();
+ fileUuidDict = {};
+ disableRemovedfileEvent = false;
+
+ for (const attachment of data) {
+ const imgSrc = `${dropzone.getAttribute('data-link-url')}/${attachment.uuid}`;
+ dz.emit('addedfile', attachment);
+ dz.emit('thumbnail', attachment, imgSrc);
+ dz.emit('complete', attachment);
+ fileUuidDict[attachment.uuid] = {submitted: true};
+ dropzone.querySelector(`img[src='${imgSrc}']`).style.maxWidth = '100%';
+ const input = document.createElement('input');
+ input.id = attachment.uuid;
+ input.name = 'files';
+ input.type = 'hidden';
+ input.value = attachment.uuid;
+ dropzone.querySelector('.files').append(input);
+ }
+ if (!dropzone.querySelector('.dz-preview')) {
+ dropzone.classList.remove('dz-started');
+ }
+ } catch (error) {
+ console.error(error);
+ }
+ });
+ },
+ });
+ dz.emit('reload');
+ return dz;
+ };
+
+ const cancelAndReset = (e) => {
+ e.preventDefault();
+ showElem(renderContent);
+ hideElem(editContentZone);
+ comboMarkdownEditor.attachedDropzoneInst?.emit('reload');
+ };
+
+ const saveAndRefresh = async (e) => {
+ e.preventDefault();
+ showElem(renderContent);
+ hideElem(editContentZone);
+ const dropzoneInst = comboMarkdownEditor.attachedDropzoneInst;
+ try {
+ const params = new URLSearchParams({
+ content: comboMarkdownEditor.value(),
+ context: editContentZone.getAttribute('data-context'),
+ content_version: editContentZone.getAttribute('data-content-version'),
+ });
+ for (const fileInput of dropzoneInst?.element.querySelectorAll('.files [name=files]')) params.append('files[]', fileInput.value);
+
+ const response = await POST(editContentZone.getAttribute('data-update-url'), {data: params});
+ const data = await response.json();
+ if (response.status === 400) {
+ showErrorToast(data.errorMessage);
+ return;
+ }
+ editContentZone.setAttribute('data-content-version', data.contentVersion);
+ if (!data.content) {
+ renderContent.innerHTML = document.getElementById('no-content').innerHTML;
+ rawContent.textContent = '';
+ } else {
+ renderContent.innerHTML = data.content;
+ rawContent.textContent = comboMarkdownEditor.value();
+ const refIssues = renderContent.querySelectorAll('p .ref-issue');
+ attachRefIssueContextPopup(refIssues);
+ }
+ const content = segment;
+ if (!content.querySelector('.dropzone-attachments')) {
+ if (data.attachments !== '') {
+ content.insertAdjacentHTML('beforeend', data.attachments);
+ }
+ } else if (data.attachments === '') {
+ content.querySelector('.dropzone-attachments').remove();
+ } else {
+ content.querySelector('.dropzone-attachments').outerHTML = data.attachments;
+ }
+ dropzoneInst?.emit('submit');
+ dropzoneInst?.emit('reload');
+ initMarkupContent();
+ initCommentContent();
+ } catch (error) {
+ console.error(error);
+ }
+ };
+
+ comboMarkdownEditor = getComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
+ if (!comboMarkdownEditor) {
+ editContentZone.innerHTML = document.getElementById('issue-comment-editor-template').innerHTML;
+ comboMarkdownEditor = await initComboMarkdownEditor(editContentZone.querySelector('.combo-markdown-editor'));
+ comboMarkdownEditor.attachedDropzoneInst = await setupDropzone(editContentZone.querySelector('.dropzone'));
+ editContentZone.addEventListener('ce-quick-submit', saveAndRefresh);
+ editContentZone.querySelector('.cancel.button').addEventListener('click', cancelAndReset);
+ editContentZone.querySelector('.save.button').addEventListener('click', saveAndRefresh);
+ } else {
+ const tabEditor = editContentZone.querySelector('.combo-markdown-editor').querySelector('.tabular.menu > a[data-tab-for=markdown-writer]');
+ tabEditor?.click();
+ }
+
+ // Show write/preview tab and copy raw content as needed
+ showElem(editContentZone);
+ hideElem(renderContent);
+ if (!comboMarkdownEditor.value()) {
+ comboMarkdownEditor.value(rawContent.textContent);
+ }
+ comboMarkdownEditor.focus();
+}
+
+export function initRepository() {
+ if (!$('.page-content.repository').length) return;
+
+ initRepoBranchTagSelector('.js-branch-tag-selector');
+
+ // Options
+ if ($('.repository.settings.options').length > 0) {
+ // Enable or select internal/external wiki system and issue tracker.
+ $('.enable-system').on('change', function () {
+ if (this.checked) {
+ $($(this).data('target')).removeClass('disabled');
+ if (!$(this).data('context')) $($(this).data('context')).addClass('disabled');
+ } else {
+ $($(this).data('target')).addClass('disabled');
+ if (!$(this).data('context')) $($(this).data('context')).removeClass('disabled');
+ }
+ });
+ $('.enable-system-radio').on('change', function () {
+ if (this.value === 'false') {
+ $($(this).data('target')).addClass('disabled');
+ if ($(this).data('context') !== undefined) $($(this).data('context')).removeClass('disabled');
+ } else if (this.value === 'true') {
+ $($(this).data('target')).removeClass('disabled');
+ if ($(this).data('context') !== undefined) $($(this).data('context')).addClass('disabled');
+ }
+ });
+ const $trackerIssueStyleRadios = $('.js-tracker-issue-style');
+ $trackerIssueStyleRadios.on('change input', () => {
+ const checkedVal = $trackerIssueStyleRadios.filter(':checked').val();
+ $('#tracker-issue-style-regex-box').toggleClass('disabled', checkedVal !== 'regexp');
+ });
+ }
+
+ // Labels
+ initCompLabelEdit('.repository.labels');
+
+ // Milestones
+ if ($('.repository.new.milestone').length > 0) {
+ $('#clear-date').on('click', () => {
+ $('#deadline').val('');
+ return false;
+ });
+ }
+
+ // Repo Creation
+ if ($('.repository.new.repo').length > 0) {
+ $('input[name="gitignores"], input[name="license"]').on('change', () => {
+ const gitignores = $('input[name="gitignores"]').val();
+ const license = $('input[name="license"]').val();
+ if (gitignores || license) {
+ document.querySelector('input[name="auto_init"]').checked = true;
+ }
+ });
+ }
+
+ // Compare or pull request
+ const $repoDiff = $('.repository.diff');
+ if ($repoDiff.length) {
+ initRepoCommonBranchOrTagDropdown('.choose.branch .dropdown');
+ initRepoCommonFilterSearchDropdown('.choose.branch .dropdown');
+ }
+
+ initRepoCloneLink();
+ initCitationFileCopyContent();
+ initRepoSettingBranches();
+
+ // Issues
+ if ($('.repository.view.issue').length > 0) {
+ initRepoIssueCommentEdit();
+
+ initRepoIssueBranchSelect();
+ initRepoIssueTitleEdit();
+ initRepoIssueWipToggle();
+ initRepoIssueComments();
+
+ initRepoDiffConversationNav();
+ initRepoIssueReferenceIssue();
+
+ initRepoIssueCommentDelete();
+ initRepoIssueDependencyDelete();
+ initRepoIssueCodeCommentCancel();
+ initRepoPullRequestUpdate();
+ initCompReactionSelector($(document));
+
+ initRepoPullRequestMergeForm();
+ initRepoPullRequestCommitStatus();
+ }
+
+ // Pull request
+ const $repoComparePull = $('.repository.compare.pull');
+ if ($repoComparePull.length > 0) {
+ // show pull request form
+ $repoComparePull.find('button.show-form').on('click', function (e) {
+ e.preventDefault();
+ hideElem($(this).parent());
+
+ const $form = $repoComparePull.find('.pullrequest-form');
+ showElem($form);
+ });
+ }
+
+ initUnicodeEscapeButton();
+}
+
+function initRepoIssueCommentEdit() {
+ // Edit issue or comment content
+ $(document).on('click', '.edit-content', onEditContent);
+
+ // Quote reply
+ $(document).on('click', '.quote-reply', async function (event) {
+ event.preventDefault();
+ const target = $(this).data('target');
+ const quote = $(`#${target}`).text().replace(/\n/g, '\n> ');
+ const content = `> ${quote}\n\n`;
+ let editor;
+ if (this.classList.contains('quote-reply-diff')) {
+ const $replyBtn = $(this).closest('.comment-code-cloud').find('button.comment-form-reply');
+ editor = await handleReply($replyBtn);
+ } else {
+ // for normal issue/comment page
+ editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor'));
+ }
+ if (editor) {
+ if (editor.value()) {
+ editor.value(`${editor.value()}\n\n${content}`);
+ } else {
+ editor.value(content);
+ }
+ editor.focus();
+ editor.moveCursorToEnd();
+ }
+ });
+}
diff --git a/web_src/js/features/repo-migrate.js b/web_src/js/features/repo-migrate.js
new file mode 100644
index 0000000..fc42ce8
--- /dev/null
+++ b/web_src/js/features/repo-migrate.js
@@ -0,0 +1,64 @@
+import {hideElem, showElem} from '../utils/dom.js';
+import {GET, POST} from '../modules/fetch.js';
+
+const {appSubUrl} = window.config;
+
+export function initRepoMigrationStatusChecker() {
+ const repoMigrating = document.getElementById('repo_migrating');
+ if (!repoMigrating) return;
+
+ document.getElementById('repo_migrating_retry')?.addEventListener('click', doMigrationRetry);
+
+ const task = repoMigrating.getAttribute('data-migrating-task-id');
+
+ // returns true if the refresh still needs to be called after a while
+ const refresh = async () => {
+ const res = await GET(`${appSubUrl}/user/task/${task}`);
+ if (res.url.endsWith('/login')) return false; // stop refreshing if redirected to login
+ 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.getElementById('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.getElementById('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) {
+ await POST(e.target.getAttribute('data-migrating-task-retry-url'));
+ window.location.reload();
+}
diff --git a/web_src/js/features/repo-migration.js b/web_src/js/features/repo-migration.js
new file mode 100644
index 0000000..59e282e
--- /dev/null
+++ b/web_src/js/features/repo-migration.js
@@ -0,0 +1,69 @@
+import {hideElem, showElem, toggleElem} from '../utils/dom.js';
+
+const service = document.getElementById('service_type');
+const user = document.getElementById('auth_username');
+const pass = document.getElementById('auth_password');
+const token = document.getElementById('auth_token');
+const mirror = document.getElementById('mirror');
+const lfs = document.getElementById('lfs');
+const lfsSettings = document.getElementById('lfs_settings');
+const lfsEndpoint = document.getElementById('lfs_endpoint');
+const items = document.querySelectorAll('#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.getElementById('lfs_settings_show')?.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ showElem(lfsEndpoint);
+ });
+ lfs?.addEventListener('change', setLFSSettingsVisibility);
+
+ const cloneAddr = document.getElementById('clone_addr');
+ cloneAddr?.addEventListener('change', () => {
+ const repoName = document.getElementById('repo_name');
+ if (cloneAddr.value && !repoName?.value) { // Only modify if repo_name input is blank
+ repoName.value = cloneAddr.value.match(/^(.*\/)?((.+?)(\.git)?)$/)[3];
+ }
+ });
+}
+
+function checkAuth() {
+ if (!service) return;
+ const serviceType = Number(service.value);
+
+ checkItems(serviceType !== 1);
+}
+
+function checkItems(tokenAuth) {
+ let enableItems;
+ 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);
+}
diff --git a/web_src/js/features/repo-projects.js b/web_src/js/features/repo-projects.js
new file mode 100644
index 0000000..a1cc4b3
--- /dev/null
+++ b/web_src/js/features/repo-projects.js
@@ -0,0 +1,188 @@
+import $ from 'jquery';
+import {contrastColor} from '../utils/color.js';
+import {createSortable} from '../modules/sortable.js';
+import {POST, DELETE, PUT} from '../modules/fetch.js';
+
+function updateIssueCount(cards) {
+ const parent = cards.parentElement;
+ const cnt = parent.getElementsByClassName('issue-card').length;
+ parent.getElementsByClassName('project-column-issue-count')[0].textContent = cnt;
+}
+
+async function createNewColumn(url, columnTitle, projectColorInput) {
+ try {
+ await POST(url, {
+ data: {
+ title: columnTitle.val(),
+ color: projectColorInput.val(),
+ },
+ });
+ } catch (error) {
+ console.error(error);
+ } finally {
+ columnTitle.closest('form').removeClass('dirty');
+ window.location.reload();
+ }
+}
+
+async function moveIssue({item, from, to, oldIndex}) {
+ const columnCards = to.getElementsByClassName('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);
+ from.insertBefore(item, from.children[oldIndex]);
+ }
+}
+
+async function initRepoProjectSortable() {
+ const els = document.querySelectorAll('#project-board > .board.sortable');
+ if (!els.length) return;
+
+ // the HTML layout is: #project-board > .board > .project-column .cards > .issue-card
+ const mainBoard = els[0];
+ let boardColumns = mainBoard.getElementsByClassName('project-column');
+ createSortable(mainBoard, {
+ group: 'project-column',
+ draggable: '.project-column',
+ handle: '.project-column-header',
+ delayOnTouchOnly: true,
+ delay: 500,
+ onSort: async () => {
+ boardColumns = mainBoard.getElementsByClassName('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.getElementsByClassName('cards')[0];
+ createSortable(boardCardList, {
+ group: 'shared',
+ onAdd: moveIssue,
+ onUpdate: moveIssue,
+ delayOnTouchOnly: true,
+ delay: 500,
+ });
+ }
+}
+
+export function initRepoProject() {
+ if (!document.querySelector('.repository.projects')) {
+ return;
+ }
+
+ const _promise = initRepoProjectSortable();
+
+ for (const modal of document.getElementsByClassName('edit-project-column-modal')) {
+ const projectHeader = modal.closest('.project-column-header');
+ const projectTitleLabel = projectHeader?.querySelector('.project-column-title-label');
+ const projectTitleInput = modal.querySelector('.project-column-title-input');
+ const projectColorInput = modal.querySelector('#new_project_column_color');
+ const boardColumn = modal.closest('.project-column');
+ modal.querySelector('.edit-project-column-button')?.addEventListener('click', async function (e) {
+ e.preventDefault();
+ try {
+ await PUT(this.getAttribute('data-url'), {
+ data: {
+ title: projectTitleInput?.value,
+ color: projectColorInput?.value,
+ },
+ });
+ } catch (error) {
+ console.error(error);
+ } finally {
+ projectTitleLabel.textContent = projectTitleInput?.value;
+ projectTitleInput.closest('form')?.classList.remove('dirty');
+ const dividers = boardColumn.querySelectorAll(':scope > .divider');
+ if (projectColorInput.value) {
+ const color = contrastColor(projectColorInput.value);
+ boardColumn.style.setProperty('background', projectColorInput.value, 'important');
+ boardColumn.style.setProperty('color', color, 'important');
+ for (const divider of dividers) {
+ divider.style.setProperty('color', color);
+ }
+ } else {
+ boardColumn.style.removeProperty('background');
+ boardColumn.style.removeProperty('color');
+ for (const divider of dividers) {
+ divider.style.removeProperty('color');
+ }
+ }
+ $('.ui.modal').modal('hide');
+ }
+ });
+ }
+
+ $('.default-project-column-modal').each(function () {
+ const $boardColumn = $(this).closest('.project-column');
+ const $showButton = $($boardColumn).find('.default-project-column-show');
+ const $commitButton = $(this).find('.actions > .ok.button');
+
+ $($commitButton).on('click', async (e) => {
+ e.preventDefault();
+
+ try {
+ await POST($($showButton).data('url'));
+ } catch (error) {
+ console.error(error);
+ } finally {
+ window.location.reload();
+ }
+ });
+ });
+
+ $('.show-delete-project-column-modal').each(function () {
+ const $deleteColumnModal = $(`${this.getAttribute('data-modal')}`);
+ const $deleteColumnButton = $deleteColumnModal.find('.actions > .ok.button');
+ const deleteUrl = this.getAttribute('data-url');
+
+ $deleteColumnButton.on('click', async (e) => {
+ e.preventDefault();
+
+ try {
+ await DELETE(deleteUrl);
+ } catch (error) {
+ console.error(error);
+ } finally {
+ window.location.reload();
+ }
+ });
+ });
+
+ $('#new_project_column_submit').on('click', (e) => {
+ e.preventDefault();
+ const $columnTitle = $('#new_project_column');
+ const $projectColorInput = $('#new_project_column_color_picker');
+ if (!$columnTitle.val()) {
+ return;
+ }
+ const url = e.target.getAttribute('data-url');
+ createNewColumn(url, $columnTitle, $projectColorInput);
+ });
+}
diff --git a/web_src/js/features/repo-release.js b/web_src/js/features/repo-release.js
new file mode 100644
index 0000000..0db9b8a
--- /dev/null
+++ b/web_src/js/features/repo-release.js
@@ -0,0 +1,95 @@
+import {hideElem, showElem} from '../utils/dom.js';
+import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
+
+export function initRepoRelease() {
+ for (const el of document.querySelectorAll('.remove-rel-attach')) {
+ el.addEventListener('click', (e) => {
+ const uuid = e.target.getAttribute('data-uuid');
+ const id = e.target.getAttribute('data-id');
+ document.querySelector(`input[name='attachment-del-${uuid}']`).value =
+ 'true';
+ hideElem(`#attachment-${id}`);
+ });
+ }
+}
+
+export function initRepoReleaseNew() {
+ if (!document.querySelector('.repository.new.release')) return;
+
+ initTagNameEditor();
+ initRepoReleaseEditor();
+ initAddExternalLinkButton();
+}
+
+function initTagNameEditor() {
+ const el = document.getElementById('tag-name-editor');
+ if (!el) return;
+
+ const existingTags = JSON.parse(el.getAttribute('data-existing-tags'));
+ if (!Array.isArray(existingTags)) return;
+
+ const defaultTagHelperText = el.getAttribute('data-tag-helper');
+ const newTagHelperText = el.getAttribute('data-tag-helper-new');
+ const existingTagHelperText = el.getAttribute('data-tag-helper-existing');
+
+ document.getElementById('tag-name').addEventListener('keyup', (e) => {
+ const value = e.target.value;
+ const tagHelper = document.getElementById('tag-helper');
+ if (existingTags.includes(value)) {
+ // If the tag already exists, hide the target branch selector.
+ hideElem('#tag-target-selector');
+ tagHelper.textContent = existingTagHelperText;
+ } else {
+ showElem('#tag-target-selector');
+ tagHelper.textContent = value ? newTagHelperText : defaultTagHelperText;
+ }
+ });
+}
+
+function initRepoReleaseEditor() {
+ const editor = document.querySelector(
+ '.repository.new.release .combo-markdown-editor',
+ );
+ if (!editor) {
+ return;
+ }
+ initComboMarkdownEditor(editor);
+}
+
+let newAttachmentCount = 0;
+
+function initAddExternalLinkButton() {
+ const addExternalLinkButton = document.getElementById('add-external-link');
+ if (!addExternalLinkButton) return;
+
+ addExternalLinkButton.addEventListener('click', () => {
+ newAttachmentCount += 1;
+ const attachmentTemplate = document.getElementById('attachment-template');
+
+ const newAttachment = attachmentTemplate.cloneNode(true);
+ newAttachment.id = `attachment-N${newAttachmentCount}`;
+ newAttachment.classList.remove('tw-hidden');
+
+ const attachmentName = newAttachment.querySelector(
+ 'input[name="attachment-template-new-name"]',
+ );
+ attachmentName.name = `attachment-new-name-${newAttachmentCount}`;
+ attachmentName.required = true;
+
+ const attachmentExtUrl = newAttachment.querySelector(
+ 'input[name="attachment-template-new-exturl"]',
+ );
+ attachmentExtUrl.name = `attachment-new-exturl-${newAttachmentCount}`;
+ attachmentExtUrl.required = true;
+
+ const attachmentDel = newAttachment.querySelector('.remove-rel-attach');
+ attachmentDel.addEventListener('click', () => {
+ newAttachment.remove();
+ });
+
+ attachmentTemplate.parentNode.insertBefore(
+ newAttachment,
+ attachmentTemplate,
+ );
+ });
+}
diff --git a/web_src/js/features/repo-search.js b/web_src/js/features/repo-search.js
new file mode 100644
index 0000000..185f611
--- /dev/null
+++ b/web_src/js/features/repo-search.js
@@ -0,0 +1,22 @@
+export function initRepositorySearch() {
+ const repositorySearchForm = document.querySelector('#repo-search-form');
+ if (!repositorySearchForm) return;
+
+ repositorySearchForm.addEventListener('change', (e) => {
+ e.preventDefault();
+
+ const formData = new FormData(repositorySearchForm);
+ const params = new URLSearchParams(formData);
+
+ if (e.target.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();
+ });
+}
diff --git a/web_src/js/features/repo-settings.js b/web_src/js/features/repo-settings.js
new file mode 100644
index 0000000..52c5de2
--- /dev/null
+++ b/web_src/js/features/repo-settings.js
@@ -0,0 +1,120 @@
+import $ from 'jquery';
+import {minimatch} from 'minimatch';
+import {createMonaco} from './codeeditor.js';
+import {onInputDebounce, toggleElem} from '../utils/dom.js';
+import {POST} from '../modules/fetch.js';
+
+const {appSubUrl, csrfToken} = window.config;
+
+export function initRepoSettingsCollaboration() {
+ // Change collaborator access mode
+ $('.page-content.repository .ui.dropdown.access-mode').each((_, el) => {
+ const $dropdown = $(el);
+ const $text = $dropdown.find('> .text');
+ $dropdown.dropdown({
+ async action(_text, value) {
+ const lastValue = el.getAttribute('data-last-value');
+ try {
+ el.setAttribute('data-last-value', value);
+ $dropdown.dropdown('hide');
+ const data = new FormData();
+ data.append('uid', el.getAttribute('data-uid'));
+ data.append('mode', value);
+ await POST(el.getAttribute('data-url'), {data});
+ } catch {
+ $text.text('(error)'); // prevent from misleading users when error occurs
+ el.setAttribute('data-last-value', lastValue);
+ }
+ },
+ onChange(_value, text, _$choice) {
+ $text.text(text); // update the text when using keyboard navigating
+ },
+ 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', el.getAttribute('data-last-value'));
+ if ($item) {
+ $dropdown.dropdown('set selected', el.getAttribute('data-last-value'));
+ } else {
+ $text.text('(none)'); // prevent from misleading users when the access mode is undefined
+ }
+ }, 0);
+ },
+ });
+ });
+}
+
+export function initRepoSettingSearchTeamBox() {
+ const searchTeamBox = document.getElementById('search-team-box');
+ if (!searchTeamBox) return;
+
+ $(searchTeamBox).search({
+ minCharacters: 2,
+ apiSettings: {
+ url: `${appSubUrl}/org/${searchTeamBox.getAttribute('data-org-name')}/teams/-/search?q={query}`,
+ headers: {'X-Csrf-Token': csrfToken},
+ onResponse(response) {
+ const items = [];
+ $.each(response.data, (_i, item) => {
+ items.push({
+ title: item.name,
+ description: `${item.permission} access`, // TODO: translate this string
+ });
+ });
+
+ return {results: items};
+ },
+ },
+ searchFields: ['name', 'description'],
+ showNoResults: false,
+ });
+}
+
+export function initRepoSettingGitHook() {
+ if (!$('.edit.githook').length) return;
+ const filename = document.querySelector('.hook-filename').textContent;
+ const _promise = createMonaco($('#content')[0], filename, {language: 'shell'});
+}
+
+export function initRepoSettingBranches() {
+ if (!document.querySelector('.repository.settings.branches')) return;
+
+ for (const el of document.getElementsByClassName('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.getElementsByClassName('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.getElementById('dismiss_stale_approvals')?.addEventListener('change', function () {
+ document.getElementById('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.getElementById('status_check_contexts').value || '').split(/[\r\n]+/);
+ const validPatterns = patterns.map((item) => item.trim()).filter(Boolean);
+ const marks = document.getElementsByClassName('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 (minimatch(statusCheck, pattern)) {
+ matched = true;
+ break;
+ }
+ }
+ toggleElem(el, matched);
+ }
+ };
+ markMatchedStatusChecks();
+ document.getElementById('status_check_contexts').addEventListener('input', onInputDebounce(markMatchedStatusChecks));
+}
diff --git a/web_src/js/features/repo-template.js b/web_src/js/features/repo-template.js
new file mode 100644
index 0000000..5f63e8b
--- /dev/null
+++ b/web_src/js/features/repo-template.js
@@ -0,0 +1,51 @@
+import $ from 'jquery';
+import {htmlEscape} from 'escape-goat';
+import {hideElem, showElem} from '../utils/dom.js';
+
+const {appSubUrl} = window.config;
+
+export function initRepoTemplateSearch() {
+ const $repoTemplate = $('#repo_template');
+ const checkTemplate = function () {
+ const $templateUnits = $('#template_units');
+ const $nonTemplate = $('#non_template');
+ if ($repoTemplate.val() !== '' && $repoTemplate.val() !== '0') {
+ showElem($templateUnits);
+ hideElem($nonTemplate);
+ } else {
+ hideElem($templateUnits);
+ showElem($nonTemplate);
+ }
+ };
+ $repoTemplate.on('change', checkTemplate);
+ checkTemplate();
+
+ const changeOwner = function () {
+ $('#repo_template_search')
+ .dropdown({
+ apiSettings: {
+ url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${$('#uid').val()}`,
+ onResponse(response) {
+ const filteredResponse = {success: true, results: []};
+ filteredResponse.results.push({
+ name: '',
+ value: '',
+ });
+ // Parse the response from the api to work with our dropdown
+ $.each(response.data, (_r, repo) => {
+ filteredResponse.results.push({
+ name: htmlEscape(repo.repository.full_name),
+ value: repo.repository.id,
+ });
+ });
+ return filteredResponse;
+ },
+ cache: false,
+ },
+
+ fullTextSearch: true,
+ });
+ };
+ $('#uid').on('change', changeOwner);
+ changeOwner();
+}
diff --git a/web_src/js/features/repo-unicode-escape.js b/web_src/js/features/repo-unicode-escape.js
new file mode 100644
index 0000000..9f0c745
--- /dev/null
+++ b/web_src/js/features/repo-unicode-escape.js
@@ -0,0 +1,27 @@
+import {hideElem, queryElemSiblings, showElem, toggleElem} from '../utils/dom.js';
+
+export function initUnicodeEscapeButton() {
+ document.addEventListener('click', (e) => {
+ const btn = e.target.closest('.escape-button, .unescape-button, .toggle-escape-button');
+ if (!btn) return;
+
+ e.preventDefault();
+
+ const fileContent = btn.closest('.file-content, .non-diff-file-content, .file-preview-box');
+ const fileView = fileContent?.querySelectorAll('.file-code, .file-view, .file-preview');
+ if (btn.matches('.escape-button')) {
+ for (const el of fileView) el.classList.add('unicode-escaped');
+ hideElem(btn);
+ showElem(queryElemSiblings(btn, '.unescape-button'));
+ } else if (btn.matches('.unescape-button')) {
+ for (const el of fileView) el.classList.remove('unicode-escaped');
+ hideElem(btn);
+ showElem(queryElemSiblings(btn, '.escape-button'));
+ } else if (btn.matches('.toggle-escape-button')) {
+ const isEscaped = fileView[0]?.classList.contains('unicode-escaped');
+ for (const el of fileView) el.classList.toggle('unicode-escaped', !isEscaped);
+ toggleElem(fileContent.querySelectorAll('.unescape-button'), !isEscaped);
+ toggleElem(fileContent.querySelectorAll('.escape-button'), isEscaped);
+ }
+ });
+}
diff --git a/web_src/js/features/repo-wiki.js b/web_src/js/features/repo-wiki.js
new file mode 100644
index 0000000..03a2c68
--- /dev/null
+++ b/web_src/js/features/repo-wiki.js
@@ -0,0 +1,89 @@
+import {initMarkupContent} from '../markup/content.js';
+import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
+import {fomanticMobileScreen} from '../modules/fomantic.js';
+import {POST} from '../modules/fetch.js';
+
+async function initRepoWikiFormEditor() {
+ const editArea = document.querySelector('.repository.wiki .combo-markdown-editor textarea');
+ if (!editArea) return;
+
+ const form = document.querySelector('.repository.wiki.new .ui.form');
+ const editorContainer = form.querySelector('.combo-markdown-editor');
+ let editor;
+
+ let renderRequesting = false;
+ let lastContent;
+ 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);
+ formData.append('wiki', editor.previewWiki);
+ try {
+ const response = await POST(editor.previewUrl, {data: formData});
+ const data = await response.text();
+ lastContent = newContent;
+ previewTarget.innerHTML = `<div class="markup ui segment">${data}</div>`;
+ initMarkupContent();
+ } catch (error) {
+ console.error('Error rendering preview:', error);
+ } finally {
+ renderRequesting = false;
+ setTimeout(renderEasyMDEPreview, 1000);
+ }
+ } else {
+ setTimeout(renderEasyMDEPreview, 1000);
+ }
+ };
+ renderEasyMDEPreview();
+
+ editor = await initComboMarkdownEditor(editorContainer, {
+ useScene: 'wiki',
+ // 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)'},
+ previewMode: 'gfm',
+ previewWiki: true,
+ 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',
+ ],
+ },
+ });
+
+ form.addEventListener('submit', (e) => {
+ if (!validateTextareaNonEmpty(editArea)) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ });
+}
+
+function collapseWikiTocForMobile(collapse) {
+ 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();
+}
diff --git a/web_src/js/features/sshkey-helper.js b/web_src/js/features/sshkey-helper.js
new file mode 100644
index 0000000..3960eef
--- /dev/null
+++ b/web_src/js/features/sshkey-helper.js
@@ -0,0 +1,10 @@
+export function initSshKeyFormParser() {
+ // Parse SSH Key
+ document.getElementById('ssh-key-content')?.addEventListener('input', function () {
+ const arrays = this.value.split(' ');
+ const title = document.getElementById('ssh-key-title');
+ if (!title.value && arrays.length === 3 && arrays[2] !== '') {
+ title.value = arrays[2];
+ }
+ });
+}
diff --git a/web_src/js/features/stopwatch.js b/web_src/js/features/stopwatch.js
new file mode 100644
index 0000000..d070f52
--- /dev/null
+++ b/web_src/js/features/stopwatch.js
@@ -0,0 +1,167 @@
+import prettyMilliseconds from 'pretty-ms';
+import {createTippy} from '../modules/tippy.js';
+import {GET} from '../modules/fetch.js';
+import {hideElem, showElem} from '../utils/dom.js';
+
+const {appSubUrl, notificationSettings, enableTimeTracking, assetVersionEncoded} = window.config;
+
+export function initStopwatch() {
+ if (!enableTimeTracking) {
+ return;
+ }
+
+ const stopwatchEl = document.querySelector('.active-stopwatch-trigger');
+ const stopwatchPopup = document.querySelector('.active-stopwatch-popup');
+
+ if (!stopwatchEl || !stopwatchPopup) {
+ return;
+ }
+
+ stopwatchEl.removeAttribute('href'); // intended for noscript mode only
+
+ createTippy(stopwatchEl, {
+ content: stopwatchPopup,
+ placement: 'bottom-end',
+ trigger: 'click',
+ maxWidth: 'none',
+ interactive: true,
+ hideOnClick: true,
+ });
+
+ // global stop watch (in the head_navbar), it should always work in any case either the EventSource or the PeriodicPoller is used.
+ const currSeconds = document.querySelector('.stopwatch-time')?.getAttribute('data-seconds');
+ if (currSeconds) {
+ updateStopwatchTime(currSeconds);
+ }
+
+ let usingPeriodicPoller = false;
+ const startPeriodicPoller = (timeout) => {
+ 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 SharedWorker(`${__webpack_public_path__}js/eventsource.sharedworker.js?v=${assetVersionEncoded}`, 'notification-worker');
+ worker.addEventListener('error', (event) => {
+ console.error('worker error', event);
+ });
+ worker.port.addEventListener('messageerror', () => {
+ console.error('unable to deserialize message');
+ });
+ worker.port.postMessage({
+ type: 'start',
+ url: `${window.location.origin}${appSubUrl}/user/events`,
+ });
+ worker.port.addEventListener('message', (event) => {
+ if (!event.data || !event.data.type) {
+ console.error('unknown worker message event', event);
+ return;
+ }
+ if (event.data.type === 'stopwatches') {
+ updateStopwatchData(JSON.parse(event.data.data));
+ } else 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 === 'error') {
+ console.error('worker port event error', event.data);
+ } else if (event.data.type === 'logout') {
+ if (event.data.data !== 'here') {
+ return;
+ }
+ worker.port.postMessage({
+ type: 'close',
+ });
+ worker.port.close();
+ window.location.href = `${window.location.origin}${appSubUrl}/`;
+ } else if (event.data.type === 'close') {
+ worker.port.postMessage({
+ type: 'close',
+ });
+ worker.port.close();
+ }
+ });
+ worker.port.addEventListener('error', (e) => {
+ console.error('worker port error', e);
+ });
+ worker.port.start();
+ window.addEventListener('beforeunload', () => {
+ worker.port.postMessage({
+ type: 'close',
+ });
+ worker.port.close();
+ });
+
+ return;
+ }
+
+ startPeriodicPoller(notificationSettings.MinTimeout);
+}
+
+async function updateStopwatchWithCallback(callback, timeout) {
+ 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) {
+ const watch = data[0];
+ const btnEl = document.querySelector('.active-stopwatch-trigger');
+ if (!watch) {
+ clearStopwatchTimer();
+ hideElem(btnEl);
+ } 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/toggle`);
+ 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(btnEl);
+ }
+ return Boolean(data.length);
+}
+
+let updateTimeIntervalId = null; // holds setInterval id when active
+function clearStopwatchTimer() {
+ if (updateTimeIntervalId !== null) {
+ clearInterval(updateTimeIntervalId);
+ updateTimeIntervalId = null;
+ }
+}
+function updateStopwatchTime(seconds) {
+ const secs = parseInt(seconds);
+ if (!Number.isFinite(secs)) return;
+
+ clearStopwatchTimer();
+ const stopwatch = document.querySelector('.stopwatch-time');
+ // TODO: replace with <relative-time> similar to how system status up time is shown
+ const start = Date.now();
+ const updateUi = () => {
+ const delta = Date.now() - start;
+ const dur = prettyMilliseconds(secs * 1000 + delta, {compact: true});
+ if (stopwatch) stopwatch.textContent = dur;
+ };
+ updateUi();
+ updateTimeIntervalId = setInterval(updateUi, 1000);
+}
diff --git a/web_src/js/features/tablesort.js b/web_src/js/features/tablesort.js
new file mode 100644
index 0000000..436fe0a
--- /dev/null
+++ b/web_src/js/features/tablesort.js
@@ -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, revSort, isDefault) {
+ if (!normSort) return false;
+ if (!revSort) revSort = '';
+
+ const url = new URL(window.location);
+ let urlSort = url.searchParams.get('sort');
+ if (!urlSort && isDefault) urlSort = normSort;
+
+ url.searchParams.set('sort', urlSort !== normSort ? normSort : revSort);
+ window.location.replace(url.href);
+}
diff --git a/web_src/js/features/tribute.js b/web_src/js/features/tribute.js
new file mode 100644
index 0000000..02cd484
--- /dev/null
+++ b/web_src/js/features/tribute.js
@@ -0,0 +1,57 @@
+import {emojiKeys, emojiHTML, emojiString} from './emoji.js';
+import {htmlEscape} from 'escape-goat';
+
+function makeCollections({mentions, emoji}) {
+ const collections = [];
+
+ if (emoji) {
+ collections.push({
+ trigger: ':',
+ requireLeadingSpace: true,
+ values: (query, cb) => {
+ 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 null;
+ return emojiString(item.original);
+ },
+ menuItemTemplate: (item) => {
+ return `<div class="tribute-item">${emojiHTML(item.original)}<span>${htmlEscape(item.original)}</span></div>`;
+ },
+ });
+ }
+
+ if (mentions) {
+ collections.push({
+ values: window.config.mentionValues ?? [],
+ requireLeadingSpace: true,
+ menuItemTemplate: (item) => {
+ return `
+ <div class="tribute-item">
+ <img src="${htmlEscape(item.original.avatar)}" class="tw-mr-2"/>
+ <span class="name">${htmlEscape(item.original.name)}</span>
+ ${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''}
+ </div>
+ `;
+ },
+ });
+ }
+
+ return collections;
+}
+
+export async function attachTribute(element, {mentions, emoji} = {}) {
+ const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
+ const collections = makeCollections({mentions, emoji});
+ const tribute = new Tribute({collection: collections, noMatchTemplate: ''});
+ tribute.attach(element);
+ return tribute;
+}
diff --git a/web_src/js/features/user-auth-webauthn.js b/web_src/js/features/user-auth-webauthn.js
new file mode 100644
index 0000000..6dfbb4d
--- /dev/null
+++ b/web_src/js/features/user-auth-webauthn.js
@@ -0,0 +1,194 @@
+import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.js';
+import {showElem} from '../utils/dom.js';
+import {GET, POST} from '../modules/fetch.js';
+
+const {appSubUrl} = window.config;
+
+export async function initUserAuthWebAuthn() {
+ const elPrompt = document.querySelector('.user.signin.webauthn-prompt');
+ if (!elPrompt) {
+ return;
+ }
+
+ if (!detectWebAuthnSupport()) {
+ return;
+ }
+
+ const res = await GET(`${appSubUrl}/user/webauthn/assertion`);
+ if (res.status !== 200) {
+ 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', err.message);
+ return;
+ }
+ delete options.publicKey.extensions.appid;
+ try {
+ const credential = await navigator.credentials.get({
+ publicKey: options.publicKey,
+ });
+ await verifyAssertion(credential);
+ } catch (err) {
+ webAuthnError('general', err.message);
+ }
+ }
+}
+
+async function verifyAssertion(assertedCredential) {
+ // 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.status !== 200) {
+ webAuthnError('unable-to-process');
+ return;
+ }
+ const reply = await res.json();
+
+ window.location.href = reply?.redirect ?? `${appSubUrl}/`;
+}
+
+async function webauthnRegistered(newCredential) {
+ 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, message) {
+ const elErrorMsg = document.getElementById(`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');
+}
+
+function detectWebAuthnSupport() {
+ if (!window.isSecureContext) {
+ webAuthnError('insecure');
+ return false;
+ }
+
+ if (typeof window.PublicKeyCredential !== 'function') {
+ webAuthnError('browser');
+ return false;
+ }
+
+ return true;
+}
+
+export function initUserAuthWebAuthnRegister() {
+ const elRegister = document.getElementById('register-webauthn');
+ if (!elRegister) {
+ return;
+ }
+ if (!detectWebAuthnSupport()) {
+ elRegister.disabled = true;
+ return;
+ }
+ elRegister.addEventListener('click', async (e) => {
+ e.preventDefault();
+ await webAuthnRegisterRequest();
+ });
+}
+
+async function webAuthnRegisterRequest() {
+ const elNickname = document.getElementById('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.status !== 200) {
+ 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', err);
+ }
+}
diff --git a/web_src/js/features/user-auth.js b/web_src/js/features/user-auth.js
new file mode 100644
index 0000000..a871ac4
--- /dev/null
+++ b/web_src/js/features/user-auth.js
@@ -0,0 +1,22 @@
+import {checkAppUrl} from './common-global.js';
+
+export function initUserAuthOauth2() {
+ const outer = document.getElementById('oauth2-login-navigator');
+ if (!outer) return;
+ const inner = document.getElementById('oauth2-login-navigator-inner');
+
+ checkAppUrl();
+
+ for (const link of outer.querySelectorAll('.oauth-login-link')) {
+ link.addEventListener('click', () => {
+ inner.classList.add('tw-invisible');
+ outer.classList.add('is-loading');
+ setTimeout(() => {
+ // recover previous content to let user try again
+ // usually redirection will be performed before this action
+ outer.classList.remove('is-loading');
+ inner.classList.remove('tw-invisible');
+ }, 5000);
+ });
+ }
+}
diff --git a/web_src/js/features/user-settings.js b/web_src/js/features/user-settings.js
new file mode 100644
index 0000000..717ef94
--- /dev/null
+++ b/web_src/js/features/user-settings.js
@@ -0,0 +1,63 @@
+import {hideElem, showElem} from '../utils/dom.js';
+
+function onPronounsDropdownUpdate() {
+ const pronounsCustom = document.getElementById('pronouns-custom');
+ const pronounsDropdown = document.getElementById('pronouns-dropdown');
+ const pronounsInput = pronounsDropdown.querySelector('input');
+ // must be kept in sync with `routers/web/user/setting/profile.go`
+ const isCustom = !(
+ pronounsInput.value === '' ||
+ pronounsInput.value === 'he/him' ||
+ pronounsInput.value === 'she/her' ||
+ pronounsInput.value === 'they/them' ||
+ pronounsInput.value === 'it/its' ||
+ pronounsInput.value === 'any pronouns'
+ );
+ if (isCustom) {
+ if (pronounsInput.value === '!') {
+ pronounsCustom.value = '';
+ } else {
+ pronounsCustom.value = pronounsInput.value;
+ }
+ pronounsCustom.style.display = '';
+ } else {
+ pronounsCustom.style.display = 'none';
+ }
+}
+function onPronounsCustomUpdate() {
+ const pronounsCustom = document.getElementById('pronouns-custom');
+ const pronounsInput = document.querySelector('#pronouns-dropdown input');
+ pronounsInput.value = pronounsCustom.value;
+}
+
+export function initUserSettings() {
+ if (!document.querySelectorAll('.user.settings.profile').length) return;
+
+ const usernameInput = document.getElementById('username');
+ if (!usernameInput) return;
+ usernameInput.addEventListener('input', function () {
+ const prompt = document.getElementById('name-change-prompt');
+ const promptRedirect = document.getElementById('name-change-redirect-prompt');
+ if (this.value.toLowerCase() !== this.getAttribute('data-name').toLowerCase()) {
+ showElem(prompt);
+ showElem(promptRedirect);
+ } else {
+ hideElem(prompt);
+ hideElem(promptRedirect);
+ }
+ });
+
+ const pronounsDropdown = document.getElementById('pronouns-dropdown');
+ const pronounsCustom = document.getElementById('pronouns-custom');
+ const pronounsInput = pronounsDropdown.querySelector('input');
+
+ // If JS is disabled, the page will show the custom input, as the dropdown requires JS to work.
+ // JS progressively enhances the input by adding a dropdown, but it works regardless.
+ pronounsCustom.removeAttribute('name');
+ pronounsInput.setAttribute('name', 'pronouns');
+ pronounsDropdown.style.display = '';
+
+ onPronounsDropdownUpdate();
+ pronounsInput.addEventListener('change', onPronounsDropdownUpdate);
+ pronounsCustom.addEventListener('input', onPronounsCustomUpdate);
+}