summaryrefslogtreecommitdiffstats
path: root/web_src/js
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-12-12 23:57:56 +0100
commite68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch)
tree97775d6c13b0f416af55314eb6a89ef792474615 /web_src/js
parentInitial commit. (diff)
downloadforgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz
forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--web_src/js/bootstrap.js109
-rw-r--r--web_src/js/bootstrap.test.js12
-rw-r--r--web_src/js/components/.eslintrc.yaml21
-rw-r--r--web_src/js/components/ActionRunStatus.vue39
-rw-r--r--web_src/js/components/ActivityHeatmap.vue83
-rw-r--r--web_src/js/components/ContextPopup.test.js163
-rw-r--r--web_src/js/components/ContextPopup.vue130
-rw-r--r--web_src/js/components/DashboardRepoList.vue537
-rw-r--r--web_src/js/components/DiffCommitSelector.vue306
-rw-r--r--web_src/js/components/DiffFileList.vue58
-rw-r--r--web_src/js/components/DiffFileTree.vue144
-rw-r--r--web_src/js/components/DiffFileTreeItem.vue97
-rw-r--r--web_src/js/components/PullRequestMergeForm.test.js34
-rw-r--r--web_src/js/components/PullRequestMergeForm.vue252
-rw-r--r--web_src/js/components/RepoActionView.test.js105
-rw-r--r--web_src/js/components/RepoActionView.vue905
-rw-r--r--web_src/js/components/RepoActivityTopAuthors.vue164
-rw-r--r--web_src/js/components/RepoBranchTagSelector.vue357
-rw-r--r--web_src/js/components/RepoCodeFrequency.vue172
-rw-r--r--web_src/js/components/RepoContributors.vue431
-rw-r--r--web_src/js/components/RepoRecentCommits.vue149
-rw-r--r--web_src/js/components/ScopedAccessTokenSelector.vue115
-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.js797
-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
-rw-r--r--web_src/js/htmx.js21
-rw-r--r--web_src/js/index.js190
-rw-r--r--web_src/js/jquery.js3
-rw-r--r--web_src/js/markup/anchors.js70
-rw-r--r--web_src/js/markup/asciicast.js17
-rw-r--r--web_src/js/markup/codecopy.js21
-rw-r--r--web_src/js/markup/common.js8
-rw-r--r--web_src/js/markup/content.js18
-rw-r--r--web_src/js/markup/math.js47
-rw-r--r--web_src/js/markup/mermaid.js74
-rw-r--r--web_src/js/markup/tasklist.js90
-rw-r--r--web_src/js/modules/dirauto.js40
-rw-r--r--web_src/js/modules/fetch.js41
-rw-r--r--web_src/js/modules/fetch.test.js10
-rw-r--r--web_src/js/modules/fomantic.js34
-rw-r--r--web_src/js/modules/fomantic/api.js40
-rw-r--r--web_src/js/modules/fomantic/aria.md117
-rw-r--r--web_src/js/modules/fomantic/base.js18
-rw-r--r--web_src/js/modules/fomantic/checkbox.js13
-rw-r--r--web_src/js/modules/fomantic/dropdown.js256
-rw-r--r--web_src/js/modules/fomantic/form.js13
-rw-r--r--web_src/js/modules/fomantic/modal.js28
-rw-r--r--web_src/js/modules/fomantic/transition.js54
-rw-r--r--web_src/js/modules/sortable.js19
-rw-r--r--web_src/js/modules/stores.js10
-rw-r--r--web_src/js/modules/tippy.js195
-rw-r--r--web_src/js/modules/toast.js55
-rw-r--r--web_src/js/modules/toast.test.js16
-rw-r--r--web_src/js/render/ansi.js45
-rw-r--r--web_src/js/render/ansi.test.js20
-rw-r--r--web_src/js/render/pdf.js19
-rw-r--r--web_src/js/standalone/devtest.js11
-rw-r--r--web_src/js/standalone/forgejo-swagger.js23
-rw-r--r--web_src/js/standalone/swagger.js34
-rw-r--r--web_src/js/svg.js228
-rw-r--r--web_src/js/svg.test.js27
-rw-r--r--web_src/js/utils.js144
-rw-r--r--web_src/js/utils.test.js189
-rw-r--r--web_src/js/utils/color.js33
-rw-r--r--web_src/js/utils/color.test.js22
-rw-r--r--web_src/js/utils/dom.js305
-rw-r--r--web_src/js/utils/dom.test.js5
-rw-r--r--web_src/js/utils/image.js47
-rw-r--r--web_src/js/utils/image.test.js29
-rw-r--r--web_src/js/utils/match.js43
-rw-r--r--web_src/js/utils/match.test.js50
-rw-r--r--web_src/js/utils/time.js72
-rw-r--r--web_src/js/utils/time.test.js40
-rw-r--r--web_src/js/utils/url.js15
-rw-r--r--web_src/js/utils/url.test.js13
-rw-r--r--web_src/js/vendor/jquery.are-you-sure.js195
-rw-r--r--web_src/js/vitest.setup.js18
-rw-r--r--web_src/js/webcomponents/README.md11
-rw-r--r--web_src/js/webcomponents/absolute-date.js40
-rw-r--r--web_src/js/webcomponents/absolute-date.test.js15
-rw-r--r--web_src/js/webcomponents/index.js5
-rw-r--r--web_src/js/webcomponents/origin-url.js22
-rw-r--r--web_src/js/webcomponents/origin-url.test.js17
-rw-r--r--web_src/js/webcomponents/overflow-menu.js220
-rw-r--r--web_src/js/webcomponents/polyfills.js17
158 files changed, 15760 insertions, 0 deletions
diff --git a/web_src/js/bootstrap.js b/web_src/js/bootstrap.js
new file mode 100644
index 0000000..c707979
--- /dev/null
+++ b/web_src/js/bootstrap.js
@@ -0,0 +1,109 @@
+// DO NOT IMPORT window.config HERE!
+// to make sure the error handler always works, we should never import `window.config`, because
+// some user's custom template breaks it.
+
+// This sets up the URL prefix used in webpack's chunk loading.
+// This file must be imported before any lazy-loading is being attempted.
+__webpack_public_path__ = `${window.config?.assetUrlPrefix ?? '/assets'}/`;
+
+// Ignore external and some known internal errors that we are unable to currently fix.
+function shouldIgnoreError(err) {
+ const assetBaseUrl = String(new URL(__webpack_public_path__, window.location.origin));
+
+ if (!(err instanceof Error)) return false;
+ // If the error stack trace does not include the base URL of our script assets, it likely came
+ // from a browser extension or inline script. Ignore these errors.
+ if (!err.stack?.includes(assetBaseUrl)) return true;
+ // Ignore some known internal errors that we are unable to currently fix (eg via Monaco).
+ const ignorePatterns = [
+ '/assets/js/monaco.', // https://codeberg.org/forgejo/forgejo/issues/3638 , https://github.com/go-gitea/gitea/issues/30861 , https://github.com/microsoft/monaco-editor/issues/4496
+ ];
+ for (const pattern of ignorePatterns) {
+ if (err.stack?.includes(pattern)) return true;
+ }
+ return false;
+}
+
+const filteredErrors = new Set([
+ 'getModifierState is not a function', // https://github.com/microsoft/monaco-editor/issues/4325
+]);
+
+export function showGlobalErrorMessage(msg) {
+ const pageContent = document.querySelector('.page-content');
+ if (!pageContent) return;
+
+ for (const filteredError of filteredErrors) {
+ if (msg.includes(filteredError)) return;
+ }
+
+ // compact the message to a data attribute to avoid too many duplicated messages
+ const msgCompact = msg.replace(/\W/g, '').trim();
+ let msgDiv = pageContent.querySelector(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
+ if (!msgDiv) {
+ const el = document.createElement('div');
+ el.innerHTML = `<div class="ui container negative message center aligned js-global-error tw-mt-[15px] tw-whitespace-pre-line"></div>`;
+ msgDiv = el.childNodes[0];
+ }
+ // merge duplicated messages into "the message (count)" format
+ const msgCount = Number(msgDiv.getAttribute(`data-global-error-msg-count`)) + 1;
+ msgDiv.setAttribute(`data-global-error-msg-compact`, msgCompact);
+ msgDiv.setAttribute(`data-global-error-msg-count`, msgCount.toString());
+ msgDiv.textContent = msg + (msgCount > 1 ? ` (${msgCount})` : '');
+ pageContent.prepend(msgDiv);
+}
+
+/**
+ * @param {ErrorEvent|PromiseRejectionEvent} event - Event
+ * @param {string} event.message - Only present on ErrorEvent
+ * @param {string} event.error - Only present on ErrorEvent
+ * @param {string} event.type - Only present on ErrorEvent
+ * @param {string} event.filename - Only present on ErrorEvent
+ * @param {number} event.lineno - Only present on ErrorEvent
+ * @param {number} event.colno - Only present on ErrorEvent
+ * @param {string} event.reason - Only present on PromiseRejectionEvent
+ * @param {number} event.promise - Only present on PromiseRejectionEvent
+ */
+function processWindowErrorEvent({error, reason, message, type, filename, lineno, colno}) {
+ const err = error ?? reason;
+ const {runModeIsProd} = window.config ?? {};
+
+ // `error` and `reason` are not guaranteed to be errors. If the value is falsy, it is likely a
+ // non-critical event from the browser. We log them but don't show them to users. Examples:
+ // - https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver#observation_errors
+ // - https://github.com/mozilla-mobile/firefox-ios/issues/10817
+ // - https://github.com/go-gitea/gitea/issues/20240
+ if (!err) {
+ if (message) console.error(new Error(message));
+ if (runModeIsProd) return;
+ }
+
+ // In production do not display errors that should be ignored.
+ if (runModeIsProd && shouldIgnoreError(err)) return;
+
+ let msg = err?.message ?? message;
+ if (lineno) msg += ` (${filename} @ ${lineno}:${colno})`;
+ const dot = msg.endsWith('.') ? '' : '.';
+ const renderedType = type === 'unhandledrejection' ? 'promise rejection' : type;
+ showGlobalErrorMessage(`JavaScript ${renderedType}: ${msg}${dot} Open browser console to see more details.`);
+}
+
+function initGlobalErrorHandler() {
+ if (window._globalHandlerErrors?._inited) {
+ showGlobalErrorMessage(`The global error handler has been initialized, do not initialize it again`);
+ return;
+ }
+ if (!window.config) {
+ showGlobalErrorMessage(`Gitea JavaScript code couldn't run correctly, please check your custom templates`);
+ }
+ // we added an event handler for window error at the very beginning of <script> of page head the
+ // handler calls `_globalHandlerErrors.push` (array method) to record all errors occur before
+ // this init then in this init, we can collect all error events and show them.
+ for (const e of window._globalHandlerErrors || []) {
+ processWindowErrorEvent(e);
+ }
+ // then, change _globalHandlerErrors to an object with push method, to process further error
+ // events directly
+ window._globalHandlerErrors = {_inited: true, push: (e) => processWindowErrorEvent(e)};
+}
+
+initGlobalErrorHandler();
diff --git a/web_src/js/bootstrap.test.js b/web_src/js/bootstrap.test.js
new file mode 100644
index 0000000..a6b901b
--- /dev/null
+++ b/web_src/js/bootstrap.test.js
@@ -0,0 +1,12 @@
+import {showGlobalErrorMessage} from './bootstrap.js';
+
+test('showGlobalErrorMessage', () => {
+ document.body.innerHTML = '<div class="page-content"></div>';
+ showGlobalErrorMessage('test msg 1');
+ showGlobalErrorMessage('test msg 2');
+ showGlobalErrorMessage('test msg 1'); // duplicated
+
+ expect(document.body.innerHTML).toContain('>test msg 1 (2)<');
+ expect(document.body.innerHTML).toContain('>test msg 2<');
+ expect(document.querySelectorAll('.js-global-error').length).toEqual(2);
+});
diff --git a/web_src/js/components/.eslintrc.yaml b/web_src/js/components/.eslintrc.yaml
new file mode 100644
index 0000000..0d23344
--- /dev/null
+++ b/web_src/js/components/.eslintrc.yaml
@@ -0,0 +1,21 @@
+plugins:
+ - eslint-plugin-vue
+ - eslint-plugin-vue-scoped-css
+
+extends:
+ - ../../../.eslintrc.yaml
+ - plugin:vue/vue3-recommended
+ - plugin:vue-scoped-css/vue3-recommended
+
+parserOptions:
+ sourceType: module
+ ecmaVersion: latest
+
+env:
+ browser: true
+
+rules:
+ vue/attributes-order: [0]
+ vue/html-closing-bracket-spacing: [2, {startTag: never, endTag: never, selfClosingTag: never}]
+ vue/max-attributes-per-line: [0]
+ vue-scoped-css/enforce-style-type: [0]
diff --git a/web_src/js/components/ActionRunStatus.vue b/web_src/js/components/ActionRunStatus.vue
new file mode 100644
index 0000000..7ada543
--- /dev/null
+++ b/web_src/js/components/ActionRunStatus.vue
@@ -0,0 +1,39 @@
+<!-- This vue should be kept the same as templates/repo/actions/status.tmpl
+ Please also update the template file above if this vue is modified.
+ action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, unknown
+-->
+<script>
+import {SvgIcon} from '../svg.js';
+
+export default {
+ components: {SvgIcon},
+ props: {
+ status: {
+ type: String,
+ required: true,
+ },
+ size: {
+ type: Number,
+ default: 16,
+ },
+ className: {
+ type: String,
+ default: '',
+ },
+ localeStatus: {
+ type: String,
+ default: '',
+ },
+ },
+};
+</script>
+<template>
+ <span class="tw-flex tw-items-center" :data-tooltip-content="localeStatus" v-if="status">
+ <SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class-name="className" v-if="status === 'success'"/>
+ <SvgIcon name="octicon-skip" class="text grey" :size="size" :class-name="className" v-else-if="status === 'skipped'"/>
+ <SvgIcon name="octicon-clock" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'waiting'"/>
+ <SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'blocked'"/>
+ <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class-name="'job-status-rotate ' + className" v-else-if="status === 'running'"/>
+ <SvgIcon name="octicon-x-circle-fill" class="text red" :size="size" v-else-if="['failure', 'cancelled', 'unknown'].includes(status)"/>
+ </span>
+</template>
diff --git a/web_src/js/components/ActivityHeatmap.vue b/web_src/js/components/ActivityHeatmap.vue
new file mode 100644
index 0000000..be0841e
--- /dev/null
+++ b/web_src/js/components/ActivityHeatmap.vue
@@ -0,0 +1,83 @@
+<script>
+import {CalendarHeatmap} from 'vue3-calendar-heatmap';
+
+export default {
+ components: {CalendarHeatmap},
+ props: {
+ values: {
+ type: Array,
+ default: () => [],
+ },
+ locale: {
+ type: Object,
+ default: () => {},
+ },
+ },
+ data: () => ({
+ colorRange: [
+ 'var(--color-secondary-alpha-60)',
+ 'var(--color-secondary-alpha-60)',
+ 'var(--color-primary-light-4)',
+ 'var(--color-primary-light-2)',
+ 'var(--color-primary)',
+ 'var(--color-primary-dark-2)',
+ 'var(--color-primary-dark-4)',
+ ],
+ endDate: new Date(),
+ }),
+ mounted() {
+ // work around issue with first legend color being rendered twice and legend cut off
+ const legend = document.querySelector('.vch__external-legend-wrapper');
+ legend.setAttribute('viewBox', '12 0 80 10');
+ legend.style.marginRight = '-12px';
+ },
+ methods: {
+ handleDayClick(e) {
+ // Reset filter if same date is clicked
+ const params = new URLSearchParams(document.location.search);
+ const queryDate = params.get('date');
+ // Timezone has to be stripped because toISOString() converts to UTC
+ const clickedDate = new Date(e.date - (e.date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
+
+ if (queryDate && queryDate === clickedDate) {
+ params.delete('date');
+ } else {
+ params.set('date', clickedDate);
+ }
+
+ params.delete('page');
+
+ const newSearch = params.toString();
+ window.location.search = newSearch.length ? `?${newSearch}` : '';
+ },
+ },
+};
+</script>
+<template>
+ <div class="total-contributions">
+ {{ locale.contributions_in_the_last_12_months }}
+ </div>
+ <calendar-heatmap
+ :locale="locale"
+ :no-data-text="locale.contributions_zero"
+ :tooltip-formatter="
+ (v) =>
+ locale.contributions_format
+ .replace(
+ '{contributions}',
+ `<b>${v.count} ${
+ v.count === 1
+ ? locale.contributions_one
+ : locale.contributions_few
+ }</b>`
+ )
+ .replace('{month}', locale.months[v.date.getMonth()])
+ .replace('{day}', v.date.getDate())
+ .replace('{year}', v.date.getFullYear())
+ "
+ :end-date="endDate"
+ :values="values"
+ :range-color="colorRange"
+ @day-click="handleDayClick($event)"
+ />
+</template>
diff --git a/web_src/js/components/ContextPopup.test.js b/web_src/js/components/ContextPopup.test.js
new file mode 100644
index 0000000..2726567
--- /dev/null
+++ b/web_src/js/components/ContextPopup.test.js
@@ -0,0 +1,163 @@
+import {flushPromises, mount} from '@vue/test-utils';
+import ContextPopup from './ContextPopup.vue';
+
+async function assertPopup(popupData, expectedIconColor, expectedIcon) {
+ const date = new Date('2024-07-13T22:00:00Z');
+
+ vi.spyOn(global, 'fetch').mockResolvedValue({
+ json: vi.fn().mockResolvedValue({
+ ok: true,
+ created_at: date.toISOString(),
+ repository: {full_name: 'user2/repo1'},
+ ...popupData,
+ }),
+ ok: true,
+ });
+
+ const popup = mount(ContextPopup);
+ popup.vm.$el.dispatchEvent(new CustomEvent('ce-load-context-popup', {
+ detail: {owner: 'user2', repo: 'repo1', index: popupData.number},
+ }));
+ await flushPromises();
+
+ expect(popup.get('p:nth-of-type(1)').text()).toEqual(`user2/repo1 on ${date.toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'})}`);
+ expect(popup.get('p:nth-of-type(2)').text()).toEqual(`${popupData.title} #${popupData.number}`);
+ expect(popup.get('p:nth-of-type(3)').text()).toEqual(popupData.body);
+
+ expect(popup.get('svg').classes()).toContain(expectedIcon);
+ expect(popup.get('svg').classes()).toContain(expectedIconColor);
+
+ for (const l of popupData.labels) {
+ expect(popup.findAll('.ui.label').map((x) => x.text())).toContain(l.name);
+ }
+}
+
+test('renders an open issue popup', async () => {
+ await assertPopup({
+ title: 'Open Issue',
+ body: 'Open Issue Body',
+ number: 1,
+ labels: [{color: 'd21b1fff', name: 'Bug'}, {color: 'aaff00', name: 'Confirmed'}],
+ state: 'open',
+ pull_request: null,
+ }, 'green', 'octicon-issue-opened');
+});
+
+test('renders a closed issue popup', async () => {
+ await assertPopup({
+ title: 'Closed Issue',
+ body: 'Closed Issue Body',
+ number: 1,
+ labels: [{color: 'd21b1fff', name: 'Bug'}, {color: 'aaff00', name: 'Confirmed'}],
+ state: 'closed',
+ pull_request: null,
+ }, 'red', 'octicon-issue-closed');
+});
+
+test('renders an open PR popup', async () => {
+ await assertPopup({
+ title: 'Open PR',
+ body: 'Open PR Body',
+ number: 1,
+ labels: [{color: 'd21b1fff', name: 'Bug'}, {color: 'aaff00', name: 'Confirmed'}],
+ state: 'open',
+ pull_request: {merged: false, draft: false},
+ }, 'green', 'octicon-git-pull-request');
+});
+
+test('renders an open WIP PR popup', async () => {
+ await assertPopup({
+ title: 'WIP: Open PR',
+ body: 'WIP Open PR Body',
+ number: 1,
+ labels: [{color: 'd21b1fff', name: 'Bug'}, {color: 'aaff00', name: 'Confirmed'}],
+ state: 'open',
+ pull_request: {merged: false, draft: true},
+ }, 'grey', 'octicon-git-pull-request-draft');
+});
+
+test('renders a closed PR popup', async () => {
+ await assertPopup({
+ title: 'Closed PR',
+ body: 'Closed PR Body',
+ number: 1,
+ labels: [{color: 'd21b1fff', name: 'Bug'}, {color: 'aaff00', name: 'Confirmed'}],
+ state: 'closed',
+ pull_request: {merged: false, draft: false},
+ }, 'red', 'octicon-git-pull-request-closed');
+});
+
+test('renders a closed WIP PR popup', async () => {
+ await assertPopup({
+ title: 'WIP: Closed PR',
+ body: 'WIP Closed PR Body',
+ number: 1,
+ labels: [{color: 'd21b1fff', name: 'Bug'}, {color: 'aaff00', name: 'Confirmed'}],
+ state: 'closed',
+ pull_request: {merged: false, draft: true},
+ }, 'red', 'octicon-git-pull-request-closed');
+});
+
+test('renders a merged PR popup', async () => {
+ await assertPopup({
+ title: 'Merged PR',
+ body: 'Merged PR Body',
+ number: 1,
+ labels: [{color: 'd21b1fff', name: 'Bug'}, {color: 'aaff00', name: 'Confirmed'}],
+ state: 'closed',
+ pull_request: {merged: true, draft: false},
+ }, 'purple', 'octicon-git-merge');
+});
+
+test('renders an issue popup with escaped HTML', async () => {
+ const evil = '<script class="evil">alert("evil message");</script>';
+
+ vi.spyOn(global, 'fetch').mockResolvedValue({
+ json: vi.fn().mockResolvedValue({
+ ok: true,
+ created_at: '2024-07-13T22:00:00Z',
+ repository: {full_name: evil},
+ title: evil,
+ body: evil,
+ labels: [{color: '000666', name: evil}],
+ state: 'open',
+ pull_request: null,
+ }),
+ ok: true,
+ });
+
+ const popup = mount(ContextPopup);
+ popup.vm.$el.dispatchEvent(new CustomEvent('ce-load-context-popup', {
+ detail: {owner: evil, repo: evil, index: 1},
+ }));
+ await flushPromises();
+
+ expect(() => popup.get('.evil')).toThrowError();
+ expect(popup.get('p:nth-of-type(1)').text()).toContain(evil);
+ expect(popup.get('p:nth-of-type(2)').text()).toContain(evil);
+ expect(popup.get('p:nth-of-type(3)').text()).toContain(evil);
+});
+
+test('renders an issue popup with emojis', async () => {
+ vi.spyOn(global, 'fetch').mockResolvedValue({
+ json: vi.fn().mockResolvedValue({
+ ok: true,
+ created_at: '2024-07-13T22:00:00Z',
+ repository: {full_name: 'user2/repo1'},
+ title: 'Title',
+ body: 'Body',
+ labels: [{color: '000666', name: 'Tag :+1:'}],
+ state: 'open',
+ pull_request: null,
+ }),
+ ok: true,
+ });
+
+ const popup = mount(ContextPopup);
+ popup.vm.$el.dispatchEvent(new CustomEvent('ce-load-context-popup', {
+ detail: {owner: 'user2', repo: 'repo1', index: 1},
+ }));
+ await flushPromises();
+
+ expect(popup.get('.ui.label').text()).toEqual('Tag 👍');
+});
diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue
new file mode 100644
index 0000000..752a9a1
--- /dev/null
+++ b/web_src/js/components/ContextPopup.vue
@@ -0,0 +1,130 @@
+<script>
+import {SvgIcon} from '../svg.js';
+import {contrastColor} from '../utils/color.js';
+import {GET} from '../modules/fetch.js';
+import {emojiHTML} from '../features/emoji.js';
+import {htmlEscape} from 'escape-goat';
+
+const {appSubUrl, i18n} = window.config;
+
+export default {
+ components: {SvgIcon},
+ data: () => ({
+ loading: false,
+ issue: null,
+ i18nErrorOccurred: i18n.error_occurred,
+ i18nErrorMessage: null,
+ }),
+ computed: {
+ createdAt() {
+ return new Date(this.issue.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
+ },
+
+ body() {
+ const body = this.issue.body.replace(/\n+/g, ' ');
+ if (body.length > 85) {
+ return `${body.substring(0, 85)}…`;
+ }
+ return body;
+ },
+
+ icon() {
+ if (this.issue.pull_request !== null) {
+ if (this.issue.pull_request.merged === true) {
+ return 'octicon-git-merge'; // Merged PR
+ }
+
+ if (this.issue.state === 'closed') {
+ return 'octicon-git-pull-request-closed'; // Closed PR
+ }
+
+ if (this.issue.pull_request.draft === true) {
+ return 'octicon-git-pull-request-draft'; // WIP PR
+ }
+
+ return 'octicon-git-pull-request'; // Open PR
+ }
+
+ if (this.issue.state === 'closed') {
+ return 'octicon-issue-closed'; // Closed issue
+ }
+
+ return 'octicon-issue-opened'; // Open issue
+ },
+
+ color() {
+ if (this.issue.pull_request !== null) {
+ if (this.issue.pull_request.merged === true) {
+ return 'purple'; // Merged PR
+ }
+
+ if (this.issue.pull_request.draft === true && this.issue.state === 'open') {
+ return 'grey'; // WIP PR
+ }
+ }
+
+ if (this.issue.state === 'closed') {
+ return 'red'; // Closed issue
+ }
+
+ return 'green'; // Open issue
+ },
+
+ labels() {
+ return this.issue.labels.map((label) => ({
+ name: htmlEscape(label.name).replaceAll(/:[-+\w]+:/g, (emoji) => {
+ return emojiHTML(emoji.substring(1, emoji.length - 1));
+ }),
+ color: `#${label.color}`,
+ textColor: contrastColor(`#${label.color}`),
+ }));
+ },
+ },
+ mounted() {
+ this.$refs.root.addEventListener('ce-load-context-popup', (e) => {
+ const data = e.detail;
+ if (!this.loading && this.issue === null) {
+ this.load(data);
+ }
+ });
+ },
+ methods: {
+ async load(data) {
+ this.loading = true;
+ this.i18nErrorMessage = null;
+
+ try {
+ const response = await GET(`${appSubUrl}/${data.owner}/${data.repo}/issues/${data.index}/info`);
+ const respJson = await response.json();
+ if (!response.ok) {
+ this.i18nErrorMessage = respJson.message ?? i18n.network_error;
+ return;
+ }
+ this.issue = respJson;
+ } catch {
+ this.i18nErrorMessage = i18n.network_error;
+ } finally {
+ this.loading = false;
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div ref="root">
+ <div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
+ <div v-if="!loading && issue !== null" id="issue-info-popup">
+ <p><small>{{ issue.repository.full_name }} on {{ createdAt }}</small></p>
+ <p><svg-icon :name="icon" :class="['text', color]"/> <strong>{{ issue.title }}</strong> #{{ issue.number }}</p>
+ <p>{{ body }}</p>
+ <div class="labels-list">
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <div v-for="label in labels" :key="label.name" class="ui label" :style="{ color: label.textColor, backgroundColor: label.color }" v-html="label.name"/>
+ </div>
+ </div>
+ <div v-if="!loading && issue === null">
+ <p><small>{{ i18nErrorOccurred }}</small></p>
+ <p>{{ i18nErrorMessage }}</p>
+ </div>
+ </div>
+</template>
diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue
new file mode 100644
index 0000000..e587413
--- /dev/null
+++ b/web_src/js/components/DashboardRepoList.vue
@@ -0,0 +1,537 @@
+<script>
+import {createApp, nextTick} from 'vue';
+import $ from 'jquery';
+import {SvgIcon} from '../svg.js';
+import {GET} from '../modules/fetch.js';
+
+const {appSubUrl, assetUrlPrefix, pageData} = window.config;
+
+// make sure this matches templates/repo/commit_status.tmpl
+const commitStatus = {
+ pending: {name: 'octicon-dot-fill', color: 'yellow'},
+ success: {name: 'octicon-check', color: 'green'},
+ error: {name: 'gitea-exclamation', color: 'red'},
+ failure: {name: 'octicon-x', color: 'red'},
+ warning: {name: 'gitea-exclamation', color: 'yellow'},
+};
+
+const sfc = {
+ components: {SvgIcon},
+ data() {
+ const params = new URLSearchParams(window.location.search);
+ const tab = params.get('repo-search-tab') || 'repos';
+ const reposFilter = params.get('repo-search-filter') || 'all';
+ const privateFilter = params.get('repo-search-private') || 'both';
+ const archivedFilter = params.get('repo-search-archived') || 'unarchived';
+ const searchQuery = params.get('repo-search-query') || '';
+ const page = Number(params.get('repo-search-page')) || 1;
+
+ return {
+ tab,
+ repos: [],
+ reposTotalCount: 0,
+ reposFilter,
+ archivedFilter,
+ privateFilter,
+ page,
+ finalPage: 1,
+ searchQuery,
+ isLoading: false,
+ staticPrefix: assetUrlPrefix,
+ counts: {},
+ repoTypes: {
+ all: {
+ searchMode: '',
+ },
+ forks: {
+ searchMode: 'fork',
+ },
+ mirrors: {
+ searchMode: 'mirror',
+ },
+ sources: {
+ searchMode: 'source',
+ },
+ collaborative: {
+ searchMode: 'collaborative',
+ },
+ },
+ textArchivedFilterTitles: {},
+ textPrivateFilterTitles: {},
+
+ organizations: [],
+ isOrganization: true,
+ canCreateOrganization: false,
+ organizationsTotalCount: 0,
+ organizationId: 0,
+
+ subUrl: appSubUrl,
+ ...pageData.dashboardRepoList,
+ activeIndex: -1, // don't select anything at load, first cursor down will select
+ };
+ },
+
+ computed: {
+ showMoreReposLink() {
+ return this.repos.length > 0 && this.repos.length < this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
+ },
+ searchURL() {
+ return `${this.subUrl}/repo/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=${this.searchQuery
+ }&page=${this.page}&limit=${this.searchLimit}&mode=${this.repoTypes[this.reposFilter].searchMode
+ }${this.archivedFilter === 'archived' ? '&archived=true' : ''}${this.archivedFilter === 'unarchived' ? '&archived=false' : ''
+ }${this.privateFilter === 'private' ? '&is_private=true' : ''}${this.privateFilter === 'public' ? '&is_private=false' : ''
+ }`;
+ },
+ repoTypeCount() {
+ return this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
+ },
+ checkboxArchivedFilterTitle() {
+ return this.textArchivedFilterTitles[this.archivedFilter];
+ },
+ checkboxArchivedFilterProps() {
+ return {checked: this.archivedFilter === 'archived', indeterminate: this.archivedFilter === 'both'};
+ },
+ checkboxPrivateFilterTitle() {
+ return this.textPrivateFilterTitles[this.privateFilter];
+ },
+ checkboxPrivateFilterProps() {
+ return {checked: this.privateFilter === 'private', indeterminate: this.privateFilter === 'both'};
+ },
+ },
+
+ mounted() {
+ const el = document.getElementById('dashboard-repo-list');
+ this.changeReposFilter(this.reposFilter);
+ $(el).find('.dropdown').dropdown();
+ nextTick(() => {
+ this.$refs.search.focus();
+ });
+
+ this.textArchivedFilterTitles = {
+ 'archived': this.textShowOnlyArchived,
+ 'unarchived': this.textShowOnlyUnarchived,
+ 'both': this.textShowBothArchivedUnarchived,
+ };
+
+ this.textPrivateFilterTitles = {
+ 'private': this.textShowOnlyPrivate,
+ 'public': this.textShowOnlyPublic,
+ 'both': this.textShowBothPrivatePublic,
+ };
+ },
+
+ methods: {
+ changeTab(t) {
+ this.tab = t;
+ this.updateHistory();
+ },
+
+ changeReposFilter(filter) {
+ this.reposFilter = filter;
+ this.repos = [];
+ this.page = 1;
+ this.counts[`${filter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
+ this.searchRepos();
+ },
+
+ updateHistory() {
+ const params = new URLSearchParams(window.location.search);
+
+ if (this.tab === 'repos') {
+ params.delete('repo-search-tab');
+ } else {
+ params.set('repo-search-tab', this.tab);
+ }
+
+ if (this.reposFilter === 'all') {
+ params.delete('repo-search-filter');
+ } else {
+ params.set('repo-search-filter', this.reposFilter);
+ }
+
+ if (this.privateFilter === 'both') {
+ params.delete('repo-search-private');
+ } else {
+ params.set('repo-search-private', this.privateFilter);
+ }
+
+ if (this.archivedFilter === 'unarchived') {
+ params.delete('repo-search-archived');
+ } else {
+ params.set('repo-search-archived', this.archivedFilter);
+ }
+
+ if (this.searchQuery === '') {
+ params.delete('repo-search-query');
+ } else {
+ params.set('repo-search-query', this.searchQuery);
+ }
+
+ if (this.page === 1) {
+ params.delete('repo-search-page');
+ } else {
+ params.set('repo-search-page', `${this.page}`);
+ }
+
+ const queryString = params.toString();
+ if (queryString) {
+ window.history.replaceState({}, '', `?${queryString}`);
+ } else {
+ window.history.replaceState({}, '', window.location.pathname);
+ }
+ },
+
+ toggleArchivedFilter() {
+ if (this.archivedFilter === 'unarchived') {
+ this.archivedFilter = 'archived';
+ } else if (this.archivedFilter === 'archived') {
+ this.archivedFilter = 'both';
+ } else { // including both
+ this.archivedFilter = 'unarchived';
+ }
+ this.page = 1;
+ this.repos = [];
+ this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
+ this.searchRepos();
+ },
+
+ togglePrivateFilter() {
+ if (this.privateFilter === 'both') {
+ this.privateFilter = 'public';
+ } else if (this.privateFilter === 'public') {
+ this.privateFilter = 'private';
+ } else { // including private
+ this.privateFilter = 'both';
+ }
+ this.page = 1;
+ this.repos = [];
+ this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
+ this.searchRepos();
+ },
+
+ changePage(page) {
+ this.page = page;
+ if (this.page > this.finalPage) {
+ this.page = this.finalPage;
+ }
+ if (this.page < 1) {
+ this.page = 1;
+ }
+ this.repos = [];
+ this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0;
+ this.searchRepos();
+ },
+
+ async searchRepos() {
+ this.isLoading = true;
+
+ const searchedMode = this.repoTypes[this.reposFilter].searchMode;
+ const searchedURL = this.searchURL;
+ const searchedQuery = this.searchQuery;
+
+ let response, json;
+ try {
+ if (!this.reposTotalCount) {
+ const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`;
+ response = await GET(totalCountSearchURL);
+ this.reposTotalCount = response.headers.get('X-Total-Count') ?? '?';
+ }
+
+ response = await GET(searchedURL);
+ json = await response.json();
+ } catch {
+ if (searchedURL === this.searchURL) {
+ this.isLoading = false;
+ }
+ return;
+ }
+
+ if (searchedURL === this.searchURL) {
+ this.repos = json.data.map((webSearchRepo) => {
+ return {
+ ...webSearchRepo.repository,
+ latest_commit_status: webSearchRepo.latest_commit_status,
+ locale_latest_commit_status: webSearchRepo.locale_latest_commit_status,
+ };
+ });
+ const count = response.headers.get('X-Total-Count');
+ if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') {
+ this.reposTotalCount = count;
+ }
+ this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = count;
+ this.finalPage = Math.ceil(count / this.searchLimit);
+ this.updateHistory();
+ this.isLoading = false;
+ }
+ },
+
+ repoIcon(repo) {
+ if (repo.fork) {
+ return 'octicon-repo-forked';
+ } else if (repo.mirror) {
+ return 'octicon-mirror';
+ } else if (repo.template) {
+ return `octicon-repo-template`;
+ } else if (repo.private) {
+ return 'octicon-lock';
+ } else if (repo.internal) {
+ return 'octicon-repo';
+ }
+ return 'octicon-repo';
+ },
+
+ statusIcon(status) {
+ return commitStatus[status].name;
+ },
+
+ statusColor(status) {
+ return commitStatus[status].color;
+ },
+
+ reposFilterKeyControl(e) {
+ switch (e.key) {
+ case 'Enter':
+ document.querySelector('.repo-owner-name-list li.active a')?.click();
+ break;
+ case 'ArrowUp':
+ if (this.activeIndex > 0) {
+ this.activeIndex--;
+ } else if (this.page > 1) {
+ this.changePage(this.page - 1);
+ this.activeIndex = this.searchLimit - 1;
+ }
+ break;
+ case 'ArrowDown':
+ if (this.activeIndex < this.repos.length - 1) {
+ this.activeIndex++;
+ } else if (this.page < this.finalPage) {
+ this.activeIndex = 0;
+ this.changePage(this.page + 1);
+ }
+ break;
+ case 'ArrowRight':
+ if (this.page < this.finalPage) {
+ this.changePage(this.page + 1);
+ }
+ break;
+ case 'ArrowLeft':
+ if (this.page > 1) {
+ this.changePage(this.page - 1);
+ }
+ break;
+ }
+ if (this.activeIndex === -1 || this.activeIndex > this.repos.length - 1) {
+ this.activeIndex = 0;
+ }
+ },
+ },
+};
+
+export function initDashboardRepoList() {
+ const el = document.getElementById('dashboard-repo-list');
+ if (el) {
+ createApp(sfc).mount(el);
+ }
+}
+
+export default sfc; // activate the IDE's Vue plugin
+</script>
+<template>
+ <div>
+ <div v-if="!isOrganization" class="ui secondary stackable menu tabs-with-labels">
+ <a :class="{item: true, active: tab === 'repos'}" @click="changeTab('repos')">{{ textMyRepos }} <span class="ui grey label tw-ml-2">{{ reposTotalCount }}</span></a>
+ <a :class="{item: true, active: tab === 'organizations'}" @click="changeTab('organizations')">{{ textMyOrgs }} <span class="ui grey label tw-ml-2">{{ organizationsTotalCount }}</span></a>
+ </div>
+ <div v-show="tab === 'repos'" class="ui tab active list dashboard-repos">
+ <h4 v-if="isOrganization" class="ui top attached tw-mt-4 tw-flex tw-items-center">
+ <div class="tw-flex-1 tw-flex tw-items-center">
+ {{ textMyRepos }}
+ <span class="ui grey label tw-ml-2">{{ reposTotalCount }}</span>
+ </div>
+ </h4>
+ <div class="ui top attached segment repos-search">
+ <div class="ui small fluid action left icon input">
+ <input type="search" spellcheck="false" maxlength="255" @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="search" @keydown="reposFilterKeyControl" :placeholder="textSearchRepos">
+ <i class="icon loading-icon-3px" :class="{'is-loading': isLoading}"><svg-icon name="octicon-search" :size="16"/></i>
+ <div class="ui dropdown icon button" :title="textFilter">
+ <svg-icon name="octicon-filter" :size="16"/>
+ <div class="menu">
+ <a class="item" @click="toggleArchivedFilter()">
+ <div class="ui checkbox" ref="checkboxArchivedFilter" :title="checkboxArchivedFilterTitle">
+ <!--the "hidden" is necessary to make the checkbox work without Fomantic UI js,
+ otherwise if the "input" handles click event for intermediate status, it breaks the internal state-->
+ <input type="checkbox" class="hidden" v-bind.prop="checkboxArchivedFilterProps">
+ <label>
+ <svg-icon name="octicon-archive" :size="16" class-name="tw-mr-1"/>
+ {{ textShowArchived }}
+ </label>
+ </div>
+ </a>
+ <a class="item" @click="togglePrivateFilter()">
+ <div class="ui checkbox" ref="checkboxPrivateFilter" :title="checkboxPrivateFilterTitle">
+ <input type="checkbox" class="hidden" v-bind.prop="checkboxPrivateFilterProps">
+ <label>
+ <svg-icon name="octicon-lock" :size="16" class-name="tw-mr-1"/>
+ {{ textShowPrivate }}
+ </label>
+ </div>
+ </a>
+ </div>
+ </div>
+ </div>
+ <overflow-menu class="ui secondary pointing tabular borderless menu repos-filter">
+ <div class="overflow-menu-items tw-justify-center">
+ <a class="item" tabindex="0" :class="{active: reposFilter === 'all'}" @click="changeReposFilter('all')">
+ {{ textAll }}
+ <div v-show="reposFilter === 'all'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
+ </a>
+ <a class="item" tabindex="0" :class="{active: reposFilter === 'sources'}" @click="changeReposFilter('sources')">
+ {{ textSources }}
+ <div v-show="reposFilter === 'sources'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
+ </a>
+ <a class="item" tabindex="0" :class="{active: reposFilter === 'forks'}" @click="changeReposFilter('forks')">
+ {{ textForks }}
+ <div v-show="reposFilter === 'forks'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
+ </a>
+ <a class="item" tabindex="0" :class="{active: reposFilter === 'mirrors'}" @click="changeReposFilter('mirrors')" v-if="isMirrorsEnabled">
+ {{ textMirrors }}
+ <div v-show="reposFilter === 'mirrors'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
+ </a>
+ <a class="item" tabindex="0" :class="{active: reposFilter === 'collaborative'}" @click="changeReposFilter('collaborative')">
+ {{ textCollaborative }}
+ <div v-show="reposFilter === 'collaborative'" class="ui circular mini grey label">{{ repoTypeCount }}</div>
+ </a>
+ </div>
+ </overflow-menu>
+ </div>
+ <div v-if="repos.length" class="ui attached table segment tw-rounded-b">
+ <ul class="repo-owner-name-list">
+ <li class="tw-flex tw-items-center tw-py-2" v-for="repo, index in repos" :class="{'active': index === activeIndex}" :key="repo.id">
+ <a class="repo-list-link muted" :href="repo.link">
+ <svg-icon :name="repoIcon(repo)" :size="16" class-name="repo-list-icon"/>
+ <div class="text truncate">{{ repo.full_name }}</div>
+ <div v-if="repo.archived">
+ <svg-icon name="octicon-archive" :size="16"/>
+ </div>
+ </a>
+ <a class="tw-flex tw-items-center" v-if="repo.latest_commit_status" :href="repo.latest_commit_status.TargetURL" :data-tooltip-content="repo.locale_latest_commit_status">
+ <!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl -->
+ <svg-icon :name="statusIcon(repo.latest_commit_status.State)" :class-name="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status.State)" :size="16"/>
+ </a>
+ </li>
+ </ul>
+ <div v-if="showMoreReposLink" class="tw-text-center">
+ <div class="divider tw-my-0"/>
+ <div class="ui borderless pagination menu narrow tw-my-2">
+ <a
+ class="item navigation tw-py-1" :class="{'disabled': page === 1}"
+ @click="changePage(1)" :title="textFirstPage"
+ >
+ <svg-icon name="gitea-double-chevron-left" :size="16" class-name="tw-mr-1"/>
+ </a>
+ <a
+ class="item navigation tw-py-1" :class="{'disabled': page === 1}"
+ @click="changePage(page - 1)" :title="textPreviousPage"
+ >
+ <svg-icon name="octicon-chevron-left" :size="16" clsas-name="tw-mr-1"/>
+ </a>
+ <a class="active item tw-py-1">{{ page }}</a>
+ <a
+ class="item navigation" :class="{'disabled': page === finalPage}"
+ @click="changePage(page + 1)" :title="textNextPage"
+ >
+ <svg-icon name="octicon-chevron-right" :size="16" class-name="tw-ml-1"/>
+ </a>
+ <a
+ class="item navigation tw-py-1" :class="{'disabled': page === finalPage}"
+ @click="changePage(finalPage)" :title="textLastPage"
+ >
+ <svg-icon name="gitea-double-chevron-right" :size="16" class-name="tw-ml-1"/>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div v-if="!isOrganization" v-show="tab === 'organizations'" class="ui tab active list dashboard-orgs">
+ <div v-if="organizations.length" class="ui attached table segment tw-rounded">
+ <ul class="repo-owner-name-list">
+ <li class="tw-flex tw-items-center tw-py-2" v-for="org in organizations" :key="org.name">
+ <a class="repo-list-link muted" :href="subUrl + '/' + encodeURIComponent(org.name)">
+ <svg-icon name="octicon-organization" :size="16" class-name="repo-list-icon"/>
+ <div class="text truncate">{{ org.name }}</div>
+ <div><!-- div to prevent underline of label on hover -->
+ <span class="ui tiny basic label" v-if="org.org_visibility !== 'public'">
+ {{ org.org_visibility === 'limited' ? textOrgVisibilityLimited: textOrgVisibilityPrivate }}
+ </span>
+ </div>
+ </a>
+ <div class="text light grey tw-flex tw-items-center tw-ml-2">
+ {{ org.num_repos }}
+ <svg-icon name="octicon-repo" :size="16" class-name="tw-ml-1 tw-mt-0.5"/>
+ </div>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+</template>
+<style scoped>
+ul {
+ list-style: none;
+ margin: 0;
+ padding-left: 0;
+}
+
+ul li {
+ padding: 0 10px;
+}
+
+ul li:not(:last-child) {
+ border-bottom: 1px solid var(--color-secondary);
+}
+
+.repos-search {
+ padding-bottom: 0 !important;
+}
+
+.repos-filter {
+ padding-top: 0 !important;
+ margin-top: 0 !important;
+ border-bottom-width: 0 !important;
+ margin-bottom: 2px !important;
+}
+
+.repos-filter .item {
+ padding-left: 6px !important;
+ padding-right: 6px !important;
+}
+
+.repo-list-link {
+ min-width: 0; /* for text truncation */
+ display: flex;
+ align-items: center;
+ flex: 1;
+ gap: 0.5rem;
+}
+
+.repo-list-link .svg {
+ color: var(--color-text-light-2);
+}
+
+.repo-list-icon {
+ min-width: 16px;
+ margin-right: 2px;
+}
+
+/* octicon-mirror has no padding inside the SVG */
+.repo-list-icon.octicon-mirror {
+ width: 14px;
+ min-width: 14px;
+ margin-left: 1px;
+ margin-right: 3px;
+}
+
+.repo-owner-name-list li.active {
+ background: var(--color-hover);
+}
+</style>
diff --git a/web_src/js/components/DiffCommitSelector.vue b/web_src/js/components/DiffCommitSelector.vue
new file mode 100644
index 0000000..3f6e4e4
--- /dev/null
+++ b/web_src/js/components/DiffCommitSelector.vue
@@ -0,0 +1,306 @@
+<script>
+import {SvgIcon} from '../svg.js';
+import {GET} from '../modules/fetch.js';
+
+export default {
+ components: {SvgIcon},
+ data: () => {
+ const el = document.getElementById('diff-commit-select');
+ return {
+ menuVisible: false,
+ isLoading: false,
+ locale: {
+ filter_changes_by_commit: el.getAttribute('data-filter_changes_by_commit'),
+ },
+ commits: [],
+ hoverActivated: false,
+ lastReviewCommitSha: null,
+ };
+ },
+ computed: {
+ commitsSinceLastReview() {
+ if (this.lastReviewCommitSha) {
+ return this.commits.length - this.commits.findIndex((x) => x.id === this.lastReviewCommitSha) - 1;
+ }
+ return 0;
+ },
+ queryParams() {
+ return this.$el.parentNode.getAttribute('data-queryparams');
+ },
+ issueLink() {
+ return this.$el.parentNode.getAttribute('data-issuelink');
+ },
+ },
+ mounted() {
+ document.body.addEventListener('click', this.onBodyClick);
+ this.$el.addEventListener('keydown', this.onKeyDown);
+ this.$el.addEventListener('keyup', this.onKeyUp);
+ },
+ unmounted() {
+ document.body.removeEventListener('click', this.onBodyClick);
+ this.$el.removeEventListener('keydown', this.onKeyDown);
+ this.$el.removeEventListener('keyup', this.onKeyUp);
+ },
+ methods: {
+ onBodyClick(event) {
+ // close this menu on click outside of this element when the dropdown is currently visible opened
+ if (this.$el.contains(event.target)) return;
+ if (this.menuVisible) {
+ this.toggleMenu();
+ }
+ },
+ onKeyDown(event) {
+ if (!this.menuVisible) return;
+ const item = document.activeElement;
+ if (!this.$el.contains(item)) return;
+ switch (event.key) {
+ case 'ArrowDown': // select next element
+ event.preventDefault();
+ this.focusElem(item.nextElementSibling, item);
+ break;
+ case 'ArrowUp': // select previous element
+ event.preventDefault();
+ this.focusElem(item.previousElementSibling, item);
+ break;
+ case 'Escape': // close menu
+ event.preventDefault();
+ item.tabIndex = -1;
+ this.toggleMenu();
+ break;
+ }
+ },
+ onKeyUp(event) {
+ if (!this.menuVisible) return;
+ const item = document.activeElement;
+ if (!this.$el.contains(item)) return;
+ if (event.key === 'Shift' && this.hoverActivated) {
+ // shift is not pressed anymore -> deactivate hovering and reset hovered and selected
+ this.hoverActivated = false;
+ for (const commit of this.commits) {
+ commit.hovered = false;
+ commit.selected = false;
+ }
+ }
+ },
+ highlight(commit) {
+ if (!this.hoverActivated) return;
+ const indexSelected = this.commits.findIndex((x) => x.selected);
+ const indexCurrentElem = this.commits.findIndex((x) => x.id === commit.id);
+ for (const [idx, commit] of this.commits.entries()) {
+ commit.hovered = Math.min(indexSelected, indexCurrentElem) <= idx && idx <= Math.max(indexSelected, indexCurrentElem);
+ }
+ },
+ /** Focus given element */
+ focusElem(elem, prevElem) {
+ if (elem) {
+ elem.tabIndex = 0;
+ if (prevElem) prevElem.tabIndex = -1;
+ elem.focus();
+ }
+ },
+ /** Opens our menu, loads commits before opening */
+ async toggleMenu() {
+ this.menuVisible = !this.menuVisible;
+ // load our commits when the menu is not yet visible (it'll be toggled after loading)
+ // and we got no commits
+ if (!this.commits.length && this.menuVisible && !this.isLoading) {
+ this.isLoading = true;
+ try {
+ await this.fetchCommits();
+ } finally {
+ this.isLoading = false;
+ }
+ }
+ // set correct tabindex to allow easier navigation
+ this.$nextTick(() => {
+ const expandBtn = this.$el.querySelector('#diff-commit-list-expand');
+ const showAllChanges = this.$el.querySelector('#diff-commit-list-show-all');
+ if (this.menuVisible) {
+ this.focusElem(showAllChanges, expandBtn);
+ } else {
+ this.focusElem(expandBtn, showAllChanges);
+ }
+ });
+ },
+ /** Load the commits to show in this dropdown */
+ async fetchCommits() {
+ const resp = await GET(`${this.issueLink}/commits/list`);
+ const results = await resp.json();
+ this.commits.push(...results.commits.map((x) => {
+ x.hovered = false;
+ return x;
+ }));
+ this.commits.reverse();
+ this.lastReviewCommitSha = results.last_review_commit_sha || null;
+ if (this.lastReviewCommitSha && !this.commits.some((x) => x.id === this.lastReviewCommitSha)) {
+ // the lastReviewCommit is not available (probably due to a force push)
+ // reset the last review commit sha
+ this.lastReviewCommitSha = null;
+ }
+ Object.assign(this.locale, results.locale);
+ },
+ showAllChanges() {
+ window.location = `${this.issueLink}/files${this.queryParams}`;
+ },
+ /** Called when user clicks on since last review */
+ changesSinceLastReviewClick() {
+ window.location = `${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1).id}${this.queryParams}`;
+ },
+ /** Clicking on a single commit opens this specific commit */
+ commitClicked(commitId, newWindow = false) {
+ const url = `${this.issueLink}/commits/${commitId}${this.queryParams}`;
+ if (newWindow) {
+ window.open(url);
+ } else {
+ window.location = url;
+ }
+ },
+ /**
+ * When a commit is clicked with shift this enables the range
+ * selection. Second click (with shift) defines the end of the
+ * range. This opens the diff of this range
+ * Exception: first commit is the first commit of this PR. Then
+ * the diff from beginning of PR up to the second clicked commit is
+ * opened
+ */
+ commitClickedShift(commit) {
+ this.hoverActivated = !this.hoverActivated;
+ commit.selected = true;
+ // Second click -> determine our range and open links accordingly
+ if (!this.hoverActivated) {
+ // find all selected commits and generate a link
+ if (this.commits[0].selected) {
+ // first commit is selected - generate a short url with only target sha
+ const lastCommitIdx = this.commits.findLastIndex((x) => x.selected);
+ if (lastCommitIdx === this.commits.length - 1) {
+ // user selected all commits - just show the normal diff page
+ window.location = `${this.issueLink}/files${this.queryParams}`;
+ } else {
+ window.location = `${this.issueLink}/files/${this.commits[lastCommitIdx].id}${this.queryParams}`;
+ }
+ } else {
+ const start = this.commits[this.commits.findIndex((x) => x.selected) - 1].id;
+ const end = this.commits.findLast((x) => x.selected).id;
+ window.location = `${this.issueLink}/files/${start}..${end}${this.queryParams}`;
+ }
+ }
+ },
+ },
+};
+</script>
+<template>
+ <div class="ui scrolling dropdown custom">
+ <button
+ class="ui basic button"
+ id="diff-commit-list-expand"
+ @click.stop="toggleMenu()"
+ :data-tooltip-content="locale.filter_changes_by_commit"
+ aria-haspopup="true"
+ aria-controls="diff-commit-selector-menu"
+ :aria-label="locale.filter_changes_by_commit"
+ aria-activedescendant="diff-commit-list-show-all"
+ >
+ <svg-icon name="octicon-git-commit"/>
+ </button>
+ <div class="menu left transition" id="diff-commit-selector-menu" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak :aria-expanded="menuVisible ? 'true': 'false'">
+ <div class="loading-indicator is-loading" v-if="isLoading"/>
+ <div v-if="!isLoading" class="vertical item" id="diff-commit-list-show-all" role="menuitem" @keydown.enter="showAllChanges()" @click="showAllChanges()">
+ <div class="gt-ellipsis">
+ {{ locale.show_all_commits }}
+ </div>
+ <div class="gt-ellipsis text light-2 tw-mb-0">
+ {{ locale.stats_num_commits }}
+ </div>
+ </div>
+ <!-- only show the show changes since last review if there is a review AND we are commits ahead of the last review -->
+ <div
+ v-if="lastReviewCommitSha != null" role="menuitem"
+ class="vertical item"
+ :class="{disabled: !commitsSinceLastReview}"
+ @keydown.enter="changesSinceLastReviewClick()"
+ @click="changesSinceLastReviewClick()"
+ >
+ <div class="gt-ellipsis">
+ {{ locale.show_changes_since_your_last_review }}
+ </div>
+ <div class="gt-ellipsis text light-2">
+ {{ commitsSinceLastReview }} commits
+ </div>
+ </div>
+ <span v-if="!isLoading" class="info text light-2">{{ locale.select_commit_hold_shift_for_range }}</span>
+ <template v-for="commit in commits" :key="commit.id">
+ <div
+ class="vertical item" role="menuitem"
+ :class="{selection: commit.selected, hovered: commit.hovered}"
+ @keydown.enter.exact="commitClicked(commit.id)"
+ @keydown.enter.shift.exact="commitClickedShift(commit)"
+ @mouseover.shift="highlight(commit)"
+ @click.exact="commitClicked(commit.id)"
+ @click.ctrl.exact="commitClicked(commit.id, true)"
+ @click.meta.exact="commitClicked(commit.id, true)"
+ @click.shift.exact.stop.prevent="commitClickedShift(commit)"
+ >
+ <div class="tw-flex-1 tw-flex tw-flex-col tw-gap-1">
+ <div class="gt-ellipsis commit-list-summary">
+ {{ commit.summary }}
+ </div>
+ <div class="gt-ellipsis text light-2">
+ {{ commit.committer_or_author_name }}
+ <span class="text right">
+ <!-- TODO: make this respect the PreferredTimestampTense setting -->
+ <relative-time prefix="" :datetime="commit.time" data-tooltip-content data-tooltip-interactive="true">{{ commit.time }}</relative-time>
+ </span>
+ </div>
+ </div>
+ <div class="tw-font-mono">
+ {{ commit.short_sha }}
+ </div>
+ </div>
+ </template>
+ </div>
+ </div>
+</template>
+<style scoped>
+ .hovered:not(.selection) {
+ background-color: var(--color-small-accent) !important;
+ }
+ .selection {
+ background-color: var(--color-accent) !important;
+ }
+
+ .info {
+ display: inline-block;
+ padding: 7px 14px !important;
+ line-height: 1.4;
+ width: 100%;
+ }
+
+ #diff-commit-selector-menu {
+ overflow-x: hidden;
+ max-height: 450px;
+ }
+
+ #diff-commit-selector-menu .loading-indicator {
+ height: 200px;
+ width: 350px;
+ }
+
+ #diff-commit-selector-menu .item,
+ #diff-commit-selector-menu .info {
+ display: flex !important;
+ flex-direction: row;
+ line-height: 1.4;
+ padding: 7px 14px !important;
+ border-top: 1px solid var(--color-secondary) !important;
+ gap: 0.25em;
+ }
+
+ #diff-commit-selector-menu .item:focus {
+ color: var(--color-text);
+ background: var(--color-hover);
+ }
+
+ #diff-commit-selector-menu .commit-list-summary {
+ max-width: min(380px, 96vw);
+ }
+</style>
diff --git a/web_src/js/components/DiffFileList.vue b/web_src/js/components/DiffFileList.vue
new file mode 100644
index 0000000..916780d
--- /dev/null
+++ b/web_src/js/components/DiffFileList.vue
@@ -0,0 +1,58 @@
+<script>
+import {loadMoreFiles} from '../features/repo-diff.js';
+import {diffTreeStore} from '../modules/stores.js';
+
+export default {
+ data: () => {
+ return {store: diffTreeStore()};
+ },
+ mounted() {
+ document.getElementById('show-file-list-btn').addEventListener('click', this.toggleFileList);
+ },
+ unmounted() {
+ document.getElementById('show-file-list-btn').removeEventListener('click', this.toggleFileList);
+ },
+ methods: {
+ toggleFileList() {
+ this.store.fileListIsVisible = !this.store.fileListIsVisible;
+ },
+ diffTypeToString(pType) {
+ const diffTypes = {
+ 1: 'add',
+ 2: 'modify',
+ 3: 'del',
+ 4: 'rename',
+ 5: 'copy',
+ };
+ return diffTypes[pType];
+ },
+ diffStatsWidth(adds, dels) {
+ return `${adds / (adds + dels) * 100}%`;
+ },
+ loadMoreData() {
+ loadMoreFiles(this.store.linkLoadMore);
+ },
+ },
+};
+</script>
+<template>
+ <ol class="diff-stats tw-m-0" ref="root" v-if="store.fileListIsVisible">
+ <li v-for="file in store.files" :key="file.NameHash">
+ <div class="tw-font-semibold tw-flex tw-items-center pull-right">
+ <span v-if="file.IsBin" class="tw-ml-0.5 tw-mr-2">{{ store.binaryFileMessage }}</span>
+ {{ file.IsBin ? '' : file.Addition + file.Deletion }}
+ <span v-if="!file.IsBin" class="diff-stats-bar tw-mx-2" :data-tooltip-content="store.statisticsMessage.replace('%d', (file.Addition + file.Deletion)).replace('%d', file.Addition).replace('%d', file.Deletion)">
+ <div class="diff-stats-add-bar" :style="{ 'width': diffStatsWidth(file.Addition, file.Deletion) }"/>
+ </span>
+ </div>
+ <!-- todo finish all file status, now modify, add, delete and rename -->
+ <span :class="['status', diffTypeToString(file.Type)]" :data-tooltip-content="diffTypeToString(file.Type)">&nbsp;</span>
+ <a class="file tw-font-mono" :href="'#diff-' + file.NameHash">{{ file.Name }}</a>
+ </li>
+ <li v-if="store.isIncomplete" class="tw-pt-1">
+ <span class="file tw-flex tw-items-center tw-justify-between">{{ store.tooManyFilesMessage }}
+ <a :class="['ui', 'basic', 'tiny', 'button', store.isLoadingNewData ? 'disabled' : '']" @click.stop="loadMoreData">{{ store.showMoreMessage }}</a>
+ </span>
+ </li>
+ </ol>
+</template>
diff --git a/web_src/js/components/DiffFileTree.vue b/web_src/js/components/DiffFileTree.vue
new file mode 100644
index 0000000..cddfee1
--- /dev/null
+++ b/web_src/js/components/DiffFileTree.vue
@@ -0,0 +1,144 @@
+<script>
+import DiffFileTreeItem from './DiffFileTreeItem.vue';
+import {loadMoreFiles} from '../features/repo-diff.js';
+import {toggleElem} from '../utils/dom.js';
+import {diffTreeStore} from '../modules/stores.js';
+import {setFileFolding} from '../features/file-fold.js';
+
+const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
+
+export default {
+ components: {DiffFileTreeItem},
+ data: () => {
+ return {store: diffTreeStore()};
+ },
+ computed: {
+ fileTree() {
+ const result = [];
+ for (const file of this.store.files) {
+ // Split file into directories
+ const splits = file.Name.split('/');
+ let index = 0;
+ let parent = null;
+ let isFile = false;
+ for (const split of splits) {
+ index += 1;
+ // reached the end
+ if (index === splits.length) {
+ isFile = true;
+ }
+ let newParent = {
+ name: split,
+ children: [],
+ isFile,
+ };
+
+ if (isFile === true) {
+ newParent.file = file;
+ }
+
+ if (parent) {
+ // check if the folder already exists
+ const existingFolder = parent.children.find(
+ (x) => x.name === split,
+ );
+ if (existingFolder) {
+ newParent = existingFolder;
+ } else {
+ parent.children.push(newParent);
+ }
+ } else {
+ const existingFolder = result.find((x) => x.name === split);
+ if (existingFolder) {
+ newParent = existingFolder;
+ } else {
+ result.push(newParent);
+ }
+ }
+ parent = newParent;
+ }
+ }
+ const mergeChildIfOnlyOneDir = (entries) => {
+ for (const entry of entries) {
+ if (entry.children) {
+ mergeChildIfOnlyOneDir(entry.children);
+ }
+ if (entry.children.length === 1 && entry.children[0].isFile === false) {
+ // Merge it to the parent
+ entry.name = `${entry.name}/${entry.children[0].name}`;
+ entry.children = entry.children[0].children;
+ }
+ }
+ };
+ // Merge folders with just a folder as children in order to
+ // reduce the depth of our tree.
+ mergeChildIfOnlyOneDir(result);
+ return result;
+ },
+ },
+ mounted() {
+ // Default to true if unset
+ this.store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
+ document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', this.toggleVisibility);
+
+ this.hashChangeListener = () => {
+ this.store.selectedItem = window.location.hash;
+ this.expandSelectedFile();
+ };
+ this.hashChangeListener();
+ window.addEventListener('hashchange', this.hashChangeListener);
+ },
+ unmounted() {
+ document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', this.toggleVisibility);
+ window.removeEventListener('hashchange', this.hashChangeListener);
+ },
+ methods: {
+ expandSelectedFile() {
+ // expand file if the selected file is folded
+ if (this.store.selectedItem) {
+ const box = document.querySelector(this.store.selectedItem);
+ const folded = box?.getAttribute('data-folded') === 'true';
+ if (folded) setFileFolding(box, box.querySelector('.fold-file'), false);
+ }
+ },
+ toggleVisibility() {
+ this.updateVisibility(!this.store.fileTreeIsVisible);
+ },
+ updateVisibility(visible) {
+ this.store.fileTreeIsVisible = visible;
+ localStorage.setItem(LOCAL_STORAGE_KEY, this.store.fileTreeIsVisible);
+ this.updateState(this.store.fileTreeIsVisible);
+ },
+ updateState(visible) {
+ const btn = document.querySelector('.diff-toggle-file-tree-button');
+ const [toShow, toHide] = btn.querySelectorAll('.icon');
+ const tree = document.getElementById('diff-file-tree');
+ const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text');
+ btn.setAttribute('data-tooltip-content', newTooltip);
+ toggleElem(tree, visible);
+ toggleElem(toShow, !visible);
+ toggleElem(toHide, visible);
+ },
+ loadMoreData() {
+ loadMoreFiles(this.store.linkLoadMore);
+ },
+ },
+};
+</script>
+<template>
+ <div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
+ <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
+ <DiffFileTreeItem v-for="item in fileTree" :key="item.name" :item="item"/>
+ <div v-if="store.isIncomplete" class="tw-pt-1">
+ <a :class="['ui', 'basic', 'tiny', 'button', store.isLoadingNewData ? 'disabled' : '']" @click.stop="loadMoreData">{{ store.showMoreMessage }}</a>
+ </div>
+ </div>
+</template>
+<style scoped>
+.diff-file-tree-items {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ margin-right: .5rem;
+}
+</style>
diff --git a/web_src/js/components/DiffFileTreeItem.vue b/web_src/js/components/DiffFileTreeItem.vue
new file mode 100644
index 0000000..0f6e543
--- /dev/null
+++ b/web_src/js/components/DiffFileTreeItem.vue
@@ -0,0 +1,97 @@
+<script>
+import {SvgIcon} from '../svg.js';
+import {diffTreeStore} from '../modules/stores.js';
+
+export default {
+ components: {SvgIcon},
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ },
+ data: () => ({
+ store: diffTreeStore(),
+ collapsed: false,
+ }),
+ methods: {
+ getIconForDiffType(pType) {
+ const diffTypes = {
+ 1: {name: 'octicon-diff-added', classes: ['text', 'green']},
+ 2: {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
+ 3: {name: 'octicon-diff-removed', classes: ['text', 'red']},
+ 4: {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
+ 5: {name: 'octicon-diff-renamed', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
+ };
+ return diffTypes[pType];
+ },
+ },
+};
+</script>
+<template>
+ <!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"-->
+ <a
+ v-if="item.isFile" class="item-file"
+ :class="{'selected': store.selectedItem === '#diff-' + item.file.NameHash, 'viewed': item.file.IsViewed}"
+ :title="item.name" :href="'#diff-' + item.file.NameHash"
+ >
+ <!-- file -->
+ <SvgIcon name="octicon-file"/>
+ <span class="gt-ellipsis tw-flex-1">{{ item.name }}</span>
+ <SvgIcon :name="getIconForDiffType(item.file.Type).name" :class="getIconForDiffType(item.file.Type).classes"/>
+ </a>
+ <div v-else class="item-directory" :title="item.name" @click.stop="collapsed = !collapsed">
+ <!-- directory -->
+ <SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/>
+ <SvgIcon class="text primary" name="octicon-file-directory-fill"/>
+ <span class="gt-ellipsis">{{ item.name }}</span>
+ </div>
+
+ <div v-if="item.children?.length" v-show="!collapsed" class="sub-items">
+ <DiffFileTreeItem v-for="childItem in item.children" :key="childItem.name" :item="childItem"/>
+ </div>
+</template>
+<style scoped>
+a, a:hover {
+ text-decoration: none;
+ color: var(--color-text);
+}
+
+.sub-items {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ margin-left: 13px;
+ border-left: 1px solid var(--color-secondary);
+}
+
+.sub-items .item-file {
+ padding-left: 18px;
+}
+
+.item-file.selected {
+ color: var(--color-text);
+ background: var(--color-active);
+ border-radius: 4px;
+}
+
+.item-file.viewed {
+ color: var(--color-text-light-3);
+}
+
+.item-file,
+.item-directory {
+ display: flex;
+ align-items: center;
+ gap: 0.25em;
+ padding: 3px 6px;
+}
+
+.item-file:hover,
+.item-directory:hover {
+ color: var(--color-text);
+ background: var(--color-hover);
+ border-radius: 4px;
+ cursor: pointer;
+}
+</style>
diff --git a/web_src/js/components/PullRequestMergeForm.test.js b/web_src/js/components/PullRequestMergeForm.test.js
new file mode 100644
index 0000000..7b856e0
--- /dev/null
+++ b/web_src/js/components/PullRequestMergeForm.test.js
@@ -0,0 +1,34 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+import {flushPromises, mount} from '@vue/test-utils';
+import PullRequestMergeForm from './PullRequestMergeForm.vue';
+
+async function renderMergeForm(branchName) {
+ window.config.pageData.pullRequestMergeForm = {
+ textDeleteBranch: `Delete branch "${branchName}"`,
+ textDoMerge: 'Merge',
+ defaultMergeStyle: 'merge',
+ isPullBranchDeletable: true,
+ canMergeNow: true,
+ mergeStyles: [{
+ 'name': 'merge',
+ 'allowed': true,
+ 'textDoMerge': 'Merge',
+ 'mergeTitleFieldText': 'Merge PR',
+ 'mergeMessageFieldText': 'Description',
+ 'hideAutoMerge': 'Hide this message',
+ }],
+ };
+ const mergeform = mount(PullRequestMergeForm);
+ mergeform.get('.merge-button').trigger('click');
+ await flushPromises();
+ return mergeform;
+}
+
+test('renders escaped branch name', async () => {
+ let mergeform = await renderMergeForm('<b>evil</b>');
+ expect(mergeform.get('label[for="delete-branch-after-merge"]').text()).toBe('Delete branch "<b>evil</b>"');
+
+ mergeform = await renderMergeForm('<script class="evil">alert("evil message");</script>');
+ expect(mergeform.get('label[for="delete-branch-after-merge"]').text()).toBe('Delete branch "<script class="evil">alert("evil message");</script>"');
+});
diff --git a/web_src/js/components/PullRequestMergeForm.vue b/web_src/js/components/PullRequestMergeForm.vue
new file mode 100644
index 0000000..bd0901a
--- /dev/null
+++ b/web_src/js/components/PullRequestMergeForm.vue
@@ -0,0 +1,252 @@
+<script>
+import {SvgIcon} from '../svg.js';
+import {toggleElem} from '../utils/dom.js';
+
+const {csrfToken, pageData} = window.config;
+
+export default {
+ components: {SvgIcon},
+ data: () => ({
+ csrfToken,
+ mergeForm: pageData.pullRequestMergeForm,
+
+ mergeTitleFieldValue: '',
+ mergeMessageFieldValue: '',
+ deleteBranchAfterMerge: false,
+ autoMergeWhenSucceed: false,
+
+ mergeStyle: '',
+ mergeStyleDetail: { // dummy only, these values will come from one of the mergeForm.mergeStyles
+ hideMergeMessageTexts: false,
+ textDoMerge: '',
+ mergeTitleFieldText: '',
+ mergeMessageFieldText: '',
+ hideAutoMerge: false,
+ },
+ mergeStyleAllowedCount: 0,
+
+ showMergeStyleMenu: false,
+ showActionForm: false,
+ }),
+ computed: {
+ mergeButtonStyleClass() {
+ if (this.mergeForm.allOverridableChecksOk) return 'primary';
+ return this.autoMergeWhenSucceed ? 'primary' : 'red';
+ },
+ forceMerge() {
+ return this.mergeForm.canMergeNow && !this.mergeForm.allOverridableChecksOk;
+ },
+ },
+ watch: {
+ mergeStyle(val) {
+ this.mergeStyleDetail = this.mergeForm.mergeStyles.find((e) => e.name === val);
+ for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
+ toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
+ }
+ },
+ },
+ created() {
+ this.mergeStyleAllowedCount = this.mergeForm.mergeStyles.reduce((v, msd) => v + (msd.allowed ? 1 : 0), 0);
+
+ let mergeStyle = this.mergeForm.mergeStyles.find((e) => e.allowed && e.name === this.mergeForm.defaultMergeStyle)?.name;
+ if (!mergeStyle) mergeStyle = this.mergeForm.mergeStyles.find((e) => e.allowed)?.name;
+ this.switchMergeStyle(mergeStyle, !this.mergeForm.canMergeNow);
+ },
+ mounted() {
+ document.addEventListener('mouseup', this.hideMergeStyleMenu);
+ },
+ unmounted() {
+ document.removeEventListener('mouseup', this.hideMergeStyleMenu);
+ },
+ methods: {
+ hideMergeStyleMenu() {
+ this.showMergeStyleMenu = false;
+ },
+ toggleActionForm(show) {
+ this.showActionForm = show;
+ if (!show) return;
+ this.deleteBranchAfterMerge = this.mergeForm.defaultDeleteBranchAfterMerge;
+ this.mergeTitleFieldValue = this.mergeStyleDetail.mergeTitleFieldText;
+ this.mergeMessageFieldValue = this.mergeStyleDetail.mergeMessageFieldText;
+ },
+ switchMergeStyle(name, autoMerge = false) {
+ this.mergeStyle = name;
+ this.autoMergeWhenSucceed = autoMerge;
+ },
+ clearMergeMessage() {
+ this.mergeMessageFieldValue = this.mergeForm.defaultMergeMessage;
+ },
+ },
+};
+</script>
+<template>
+ <!--
+ if this component is shown, either the user is an admin (can do a merge without checks), or they are a writer who has the permission to do a merge
+ if the user is a writer and can't do a merge now (canMergeNow==false), then only show the Auto Merge for them
+ How to test the UI manually:
+ * Method 1: manually set some variables in pull.tmpl, eg: {{$notAllOverridableChecksOk = true}} {{$canMergeNow = false}}
+ * Method 2: make a protected branch, then set state=pending/success :
+ curl -X POST ${root_url}/api/v1/repos/${owner}/${repo}/statuses/${sha} \
+ -H "accept: application/json" -H "authorization: Basic $base64_auth" -H "Content-Type: application/json" \
+ -d '{"context": "test/context", "description": "description", "state": "${state}", "target_url": "http://localhost"}'
+ -->
+ <div>
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <div v-if="mergeForm.hasPendingPullRequestMerge" v-html="mergeForm.hasPendingPullRequestMergeTip" class="ui info message"/>
+
+ <!-- another similar form is in pull.tmpl (manual merge)-->
+ <form class="ui form form-fetch-action" v-if="showActionForm" :action="mergeForm.baseLink+'/merge'" method="post">
+ <input type="hidden" name="_csrf" :value="csrfToken">
+ <input type="hidden" name="head_commit_id" v-model="mergeForm.pullHeadCommitID">
+ <input type="hidden" name="merge_when_checks_succeed" v-model="autoMergeWhenSucceed">
+ <input type="hidden" name="force_merge" v-model="forceMerge">
+
+ <template v-if="!mergeStyleDetail.hideMergeMessageTexts">
+ <div class="field">
+ <input type="text" name="merge_title_field" v-model="mergeTitleFieldValue">
+ </div>
+ <div class="field">
+ <textarea name="merge_message_field" rows="5" :placeholder="mergeForm.mergeMessageFieldPlaceHolder" v-model="mergeMessageFieldValue"/>
+ <template v-if="mergeMessageFieldValue !== mergeForm.defaultMergeMessage">
+ <button @click.prevent="clearMergeMessage" class="btn tw-mt-1 tw-p-1 interact-fg" :data-tooltip-content="mergeForm.textClearMergeMessageHint">
+ {{ mergeForm.textClearMergeMessage }}
+ </button>
+ </template>
+ </div>
+ </template>
+
+ <div class="field" v-if="mergeStyle === 'manually-merged'">
+ <input type="text" name="merge_commit_id" :placeholder="mergeForm.textMergeCommitId">
+ </div>
+
+ <button class="ui button" :class="mergeButtonStyleClass" type="submit" name="do" :value="mergeStyle">
+ {{ mergeStyleDetail.textDoMerge }}
+ <template v-if="autoMergeWhenSucceed">
+ {{ mergeForm.textAutoMergeButtonWhenSucceed }}
+ </template>
+ </button>
+
+ <button class="ui button merge-cancel" @click="toggleActionForm(false)">
+ {{ mergeForm.textCancel }}
+ </button>
+
+ <div class="ui checkbox tw-ml-1" v-if="mergeForm.isPullBranchDeletable && !autoMergeWhenSucceed">
+ <input name="delete_branch_after_merge" type="checkbox" v-model="deleteBranchAfterMerge" id="delete-branch-after-merge">
+ <label for="delete-branch-after-merge">{{ mergeForm.textDeleteBranch }}</label>
+ </div>
+ </form>
+
+ <div v-if="!showActionForm" class="tw-flex">
+ <!-- the merge button -->
+ <div class="ui buttons merge-button" :class="[mergeForm.emptyCommit ? 'grey' : mergeForm.allOverridableChecksOk ? 'primary' : 'red']" @click="toggleActionForm(true)">
+ <button class="ui button">
+ <svg-icon name="octicon-git-merge"/>
+ <span class="button-text">
+ {{ mergeStyleDetail.textDoMerge }}
+ <template v-if="autoMergeWhenSucceed">
+ {{ mergeForm.textAutoMergeButtonWhenSucceed }}
+ </template>
+ </span>
+ </button>
+ <div class="ui dropdown icon button" @click.stop="showMergeStyleMenu = !showMergeStyleMenu" v-if="mergeStyleAllowedCount>1">
+ <svg-icon name="octicon-triangle-down" :size="14"/>
+ <div class="menu" :class="{'show':showMergeStyleMenu}">
+ <template v-for="msd in mergeForm.mergeStyles">
+ <!-- if can merge now, show one action "merge now", and an action "auto merge when succeed" -->
+ <div class="item" v-if="msd.allowed && mergeForm.canMergeNow" :key="msd.name" @click.stop="switchMergeStyle(msd.name)">
+ <div class="action-text">
+ {{ msd.textDoMerge }}
+ </div>
+ <div v-if="!msd.hideAutoMerge" class="auto-merge-small" @click.stop="switchMergeStyle(msd.name, true)">
+ <svg-icon name="octicon-clock" :size="14"/>
+ <div class="auto-merge-tip">
+ {{ mergeForm.textAutoMergeWhenSucceed }}
+ </div>
+ </div>
+ </div>
+
+ <!-- if can NOT merge now, only show one action "auto merge when succeed" -->
+ <div class="item" v-if="msd.allowed && !mergeForm.canMergeNow && !msd.hideAutoMerge" :key="msd.name" @click.stop="switchMergeStyle(msd.name, true)">
+ <div class="action-text">
+ {{ msd.textDoMerge }} {{ mergeForm.textAutoMergeButtonWhenSucceed }}
+ </div>
+ </div>
+ </template>
+ </div>
+ </div>
+ </div>
+
+ <!-- the cancel auto merge button -->
+ <form v-if="mergeForm.hasPendingPullRequestMerge" :action="mergeForm.baseLink+'/cancel_auto_merge'" method="post" class="tw-ml-4">
+ <input type="hidden" name="_csrf" :value="csrfToken">
+ <button class="ui button">
+ {{ mergeForm.textAutoMergeCancelSchedule }}
+ </button>
+ </form>
+ </div>
+ </div>
+</template>
+<style scoped>
+/* to keep UI the same, at the moment we are still using some Fomantic UI styles, but we do not use their scripts, so we need to fine tune some styles */
+.ui.dropdown .menu.show {
+ display: block;
+}
+.ui.checkbox label {
+ cursor: pointer;
+}
+
+/* make the dropdown list left-aligned */
+.ui.merge-button {
+ position: relative;
+}
+.ui.merge-button .ui.dropdown {
+ position: static;
+}
+.ui.merge-button > .ui.dropdown:last-child > .menu:not(.left) {
+ left: 0;
+ right: auto;
+}
+.ui.merge-button .ui.dropdown .menu > .item {
+ display: flex;
+ align-items: stretch;
+ padding: 0 !important; /* polluted by semantic.css: .ui.dropdown .menu > .item { !important } */
+}
+
+/* merge style list item */
+.action-text {
+ padding: 0.8rem;
+ flex: 1
+}
+
+.auto-merge-small {
+ width: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+}
+.auto-merge-small .auto-merge-tip {
+ display: none;
+ left: 38px;
+ top: -1px;
+ bottom: -1px;
+ position: absolute;
+ align-items: center;
+ color: var(--color-info-text);
+ background-color: var(--color-info-bg);
+ border: 1px solid var(--color-info-border);
+ border-left: none;
+ padding-right: 1rem;
+}
+
+.auto-merge-small:hover {
+ color: var(--color-info-text);
+ background-color: var(--color-info-bg);
+ border: 1px solid var(--color-info-border);
+}
+
+.auto-merge-small:hover .auto-merge-tip {
+ display: flex;
+}
+
+</style>
diff --git a/web_src/js/components/RepoActionView.test.js b/web_src/js/components/RepoActionView.test.js
new file mode 100644
index 0000000..8c4e150
--- /dev/null
+++ b/web_src/js/components/RepoActionView.test.js
@@ -0,0 +1,105 @@
+import {mount, flushPromises} from '@vue/test-utils';
+import RepoActionView from './RepoActionView.vue';
+
+test('processes ##[group] and ##[endgroup]', async () => {
+ Object.defineProperty(document.documentElement, 'lang', {value: 'en'});
+ vi.spyOn(global, 'fetch').mockImplementation((url, opts) => {
+ const artifacts_value = {
+ artifacts: [],
+ };
+ const stepsLog_value = [
+ {
+ step: 0,
+ cursor: 0,
+ lines: [
+ {index: 1, message: '##[group]Test group', timestamp: 0},
+ {index: 2, message: 'A test line', timestamp: 0},
+ {index: 3, message: '##[endgroup]', timestamp: 0},
+ {index: 4, message: 'A line outside the group', timestamp: 0},
+ ],
+ },
+ ];
+ const jobs_value = {
+ state: {
+ run: {
+ status: 'success',
+ commit: {
+ pusher: {},
+ },
+ },
+ currentJob: {
+ steps: [
+ {
+ summary: 'Test Job',
+ duration: '1s',
+ status: 'success',
+ },
+ ],
+ },
+ },
+ logs: {
+ stepsLog: opts.body?.includes('"cursor":null') ? stepsLog_value : [],
+ },
+ };
+
+ return Promise.resolve({
+ ok: true,
+ json: vi.fn().mockResolvedValue(
+ url.endsWith('/artifacts') ? artifacts_value : jobs_value,
+ ),
+ });
+ });
+
+ const wrapper = mount(RepoActionView, {
+ props: {
+ jobIndex: '1',
+ locale: {
+ approve: '',
+ cancel: '',
+ rerun: '',
+ artifactsTitle: '',
+ areYouSure: '',
+ confirmDeleteArtifact: '',
+ rerun_all: '',
+ showTimeStamps: '',
+ showLogSeconds: '',
+ showFullScreen: '',
+ downloadLogs: '',
+ status: {
+ unknown: '',
+ waiting: '',
+ running: '',
+ success: '',
+ failure: '',
+ cancelled: '',
+ skipped: '',
+ blocked: '',
+ },
+ },
+ },
+ });
+ await flushPromises();
+ await wrapper.get('.job-step-summary').trigger('click');
+ await flushPromises();
+
+ // Test if header was loaded correctly
+ expect(wrapper.get('.step-summary-msg').text()).toEqual('Test Job');
+
+ // Check if 3 lines where rendered
+ expect(wrapper.findAll('.job-log-line').length).toEqual(3);
+
+ // Check if line 1 contains the group header
+ expect(wrapper.get('.job-log-line:nth-of-type(1) > details.log-msg').text()).toEqual('Test group');
+
+ // Check if right after the header line exists a log list
+ expect(wrapper.find('.job-log-line:nth-of-type(1) + .job-log-list.hidden').exists()).toBe(true);
+
+ // Check if inside the loglist exist exactly one log line
+ expect(wrapper.findAll('.job-log-list > .job-log-line').length).toEqual(1);
+
+ // Check if inside the loglist is an logline with our second logline
+ expect(wrapper.get('.job-log-list > .job-log-line > .log-msg').text()).toEqual('A test line');
+
+ // Check if after the log list exists another log line
+ expect(wrapper.get('.job-log-list + .job-log-line > .log-msg').text()).toEqual('A line outside the group');
+});
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue
new file mode 100644
index 0000000..4e8e18e
--- /dev/null
+++ b/web_src/js/components/RepoActionView.vue
@@ -0,0 +1,905 @@
+<script>
+import {SvgIcon} from '../svg.js';
+import ActionRunStatus from './ActionRunStatus.vue';
+import {createApp} from 'vue';
+import {toggleElem} from '../utils/dom.js';
+import {formatDatetime} from '../utils/time.js';
+import {renderAnsi} from '../render/ansi.js';
+import {GET, POST, DELETE} from '../modules/fetch.js';
+
+const sfc = {
+ name: 'RepoActionView',
+ components: {
+ SvgIcon,
+ ActionRunStatus,
+ },
+ props: {
+ runIndex: String,
+ jobIndex: String,
+ actionsURL: String,
+ workflowName: String,
+ workflowURL: String,
+ locale: Object,
+ },
+
+ data() {
+ return {
+ // internal state
+ loading: false,
+ intervalID: null,
+ currentJobStepsStates: [],
+ artifacts: [],
+ onHoverRerunIndex: -1,
+ menuVisible: false,
+ isFullScreen: false,
+ timeVisible: {
+ 'log-time-stamp': false,
+ 'log-time-seconds': false,
+ },
+
+ // provided by backend
+ run: {
+ link: '',
+ title: '',
+ status: '',
+ canCancel: false,
+ canApprove: false,
+ canRerun: false,
+ done: false,
+ jobs: [
+ // {
+ // id: 0,
+ // name: '',
+ // status: '',
+ // canRerun: false,
+ // duration: '',
+ // },
+ ],
+ commit: {
+ localeCommit: '',
+ localePushedBy: '',
+ localeWorkflow: '',
+ shortSHA: '',
+ link: '',
+ pusher: {
+ displayName: '',
+ link: '',
+ },
+ branch: {
+ name: '',
+ link: '',
+ },
+ },
+ },
+ currentJob: {
+ title: '',
+ detail: '',
+ steps: [
+ // {
+ // summary: '',
+ // duration: '',
+ // status: '',
+ // }
+ ],
+ },
+ };
+ },
+
+ async mounted() {
+ // load job data and then auto-reload periodically
+ // need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
+ await this.loadJob();
+ this.intervalID = setInterval(this.loadJob, 1000);
+ document.body.addEventListener('click', this.closeDropdown);
+ this.hashChangeListener();
+ window.addEventListener('hashchange', this.hashChangeListener);
+ },
+
+ beforeUnmount() {
+ document.body.removeEventListener('click', this.closeDropdown);
+ window.removeEventListener('hashchange', this.hashChangeListener);
+ },
+
+ unmounted() {
+ // clear the interval timer when the component is unmounted
+ // even our page is rendered once, not spa style
+ if (this.intervalID) {
+ clearInterval(this.intervalID);
+ this.intervalID = null;
+ }
+ },
+
+ methods: {
+ // show/hide the step logs for a step
+ toggleStepLogs(idx) {
+ this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded;
+ if (this.currentJobStepsStates[idx].expanded) {
+ this.loadJob(); // try to load the data immediately instead of waiting for next timer interval
+ }
+ },
+ // cancel a run
+ cancelRun() {
+ POST(`${this.run.link}/cancel`);
+ },
+ // approve a run
+ approveRun() {
+ POST(`${this.run.link}/approve`);
+ },
+ // show/hide the step logs for a group
+ toggleGroupLogs(event) {
+ const line = event.target.parentElement;
+ const list = line.nextSibling;
+ if (event.newState === 'open') {
+ list.classList.remove('hidden');
+ } else {
+ list.classList.add('hidden');
+ }
+ },
+
+ createLogLine(line, startTime, stepIndex, group) {
+ const div = document.createElement('div');
+ div.classList.add('job-log-line');
+ div.setAttribute('id', `jobstep-${stepIndex}-${line.index}`);
+ div._jobLogTime = line.timestamp;
+
+ const lineNumber = document.createElement('a');
+ lineNumber.classList.add('line-num', 'muted');
+ lineNumber.textContent = line.index;
+ lineNumber.setAttribute('href', `#jobstep-${stepIndex}-${line.index}`);
+ div.append(lineNumber);
+
+ // for "Show timestamps"
+ const logTimeStamp = document.createElement('span');
+ logTimeStamp.className = 'log-time-stamp';
+ const date = new Date(parseFloat(line.timestamp * 1000));
+ const timeStamp = formatDatetime(date);
+ logTimeStamp.textContent = timeStamp;
+ toggleElem(logTimeStamp, this.timeVisible['log-time-stamp']);
+ // for "Show seconds"
+ const logTimeSeconds = document.createElement('span');
+ logTimeSeconds.className = 'log-time-seconds';
+ const seconds = Math.floor(parseFloat(line.timestamp) - parseFloat(startTime));
+ logTimeSeconds.textContent = `${seconds}s`;
+ toggleElem(logTimeSeconds, this.timeVisible['log-time-seconds']);
+
+ let logMessage = document.createElement('span');
+ logMessage.innerHTML = renderAnsi(line.message);
+ if (group.isHeader) {
+ const details = document.createElement('details');
+ details.addEventListener('toggle', this.toggleGroupLogs);
+ const summary = document.createElement('summary');
+ summary.append(logMessage);
+ details.append(summary);
+ logMessage = details;
+ }
+ logMessage.className = 'log-msg';
+ logMessage.style.paddingLeft = `${group.depth}em`;
+
+ div.append(logTimeStamp);
+ div.append(logMessage);
+ div.append(logTimeSeconds);
+
+ return div;
+ },
+
+ appendLogs(stepIndex, logLines, startTime) {
+ const groupStack = [];
+ const container = this.$refs.logs[stepIndex];
+ for (const line of logLines) {
+ const el = groupStack.length > 0 ? groupStack[groupStack.length - 1] : container;
+ const group = {
+ depth: groupStack.length,
+ isHeader: false,
+ };
+ if (line.message.startsWith('##[group]')) {
+ group.isHeader = true;
+
+ const logLine = this.createLogLine(
+ {
+ ...line,
+ message: line.message.substring(9),
+ },
+ startTime, stepIndex, group,
+ );
+ logLine.setAttribute('data-group', group.index);
+ el.append(logLine);
+
+ const list = document.createElement('div');
+ list.classList.add('job-log-list');
+ list.classList.add('hidden');
+ list.setAttribute('data-group', group.index);
+ groupStack.push(list);
+ el.append(list);
+ } else if (line.message.startsWith('##[endgroup]')) {
+ groupStack.pop();
+ } else {
+ el.append(this.createLogLine(line, startTime, stepIndex, group));
+ }
+ }
+ },
+
+ async fetchArtifacts() {
+ const resp = await GET(`${this.actionsURL}/runs/${this.runIndex}/artifacts`);
+ return await resp.json();
+ },
+
+ async deleteArtifact(name) {
+ if (!window.confirm(this.locale.confirmDeleteArtifact.replace('%s', name))) return;
+ await DELETE(`${this.run.link}/artifacts/${name}`);
+ await this.loadJob();
+ },
+
+ async fetchJob() {
+ const logCursors = this.currentJobStepsStates.map((it, idx) => {
+ // cursor is used to indicate the last position of the logs
+ // it's only used by backend, frontend just reads it and passes it back, it and can be any type.
+ // for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
+ return {step: idx, cursor: it.cursor, expanded: it.expanded};
+ });
+ const resp = await POST(`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`, {
+ data: {logCursors},
+ });
+ return await resp.json();
+ },
+
+ async loadJob() {
+ if (this.loading) return;
+ try {
+ this.loading = true;
+
+ let job, artifacts;
+ try {
+ [job, artifacts] = await Promise.all([
+ this.fetchJob(),
+ this.fetchArtifacts(), // refresh artifacts if upload-artifact step done
+ ]);
+ } catch (err) {
+ if (err instanceof TypeError) return; // avoid network error while unloading page
+ throw err;
+ }
+
+ this.artifacts = artifacts['artifacts'] || [];
+
+ // save the state to Vue data, then the UI will be updated
+ this.run = job.state.run;
+ this.currentJob = job.state.currentJob;
+
+ // sync the currentJobStepsStates to store the job step states
+ for (let i = 0; i < this.currentJob.steps.length; i++) {
+ if (!this.currentJobStepsStates[i]) {
+ // initial states for job steps
+ this.currentJobStepsStates[i] = {cursor: null, expanded: false};
+ }
+ }
+ // append logs to the UI
+ for (const logs of job.logs.stepsLog) {
+ // save the cursor, it will be passed to backend next time
+ this.currentJobStepsStates[logs.step].cursor = logs.cursor;
+ this.appendLogs(logs.step, logs.lines, logs.started);
+ }
+
+ if (this.run.done && this.intervalID) {
+ clearInterval(this.intervalID);
+ this.intervalID = null;
+ }
+ } finally {
+ this.loading = false;
+ }
+ },
+
+ isDone(status) {
+ return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
+ },
+
+ isExpandable(status) {
+ return ['success', 'running', 'failure', 'cancelled'].includes(status);
+ },
+
+ closeDropdown() {
+ if (this.menuVisible) this.menuVisible = false;
+ },
+
+ toggleTimeDisplay(type) {
+ this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`];
+ for (const el of this.$refs.steps.querySelectorAll(`.log-time-${type}`)) {
+ toggleElem(el, this.timeVisible[`log-time-${type}`]);
+ }
+ },
+
+ toggleFullScreen() {
+ this.isFullScreen = !this.isFullScreen;
+ const fullScreenEl = document.querySelector('.action-view-right');
+ const outerEl = document.querySelector('.full.height');
+ const actionBodyEl = document.querySelector('.action-view-body');
+ const headerEl = document.querySelector('#navbar');
+ const contentEl = document.querySelector('.page-content.repository');
+ const footerEl = document.querySelector('.page-footer');
+ toggleElem(headerEl, !this.isFullScreen);
+ toggleElem(contentEl, !this.isFullScreen);
+ toggleElem(footerEl, !this.isFullScreen);
+ // move .action-view-right to new parent
+ if (this.isFullScreen) {
+ outerEl.append(fullScreenEl);
+ } else {
+ actionBodyEl.append(fullScreenEl);
+ }
+ },
+ async hashChangeListener() {
+ const selectedLogStep = window.location.hash;
+ if (!selectedLogStep) return;
+ const [_, step, _line] = selectedLogStep.split('-');
+ if (!this.currentJobStepsStates[step]) return;
+ if (!this.currentJobStepsStates[step].expanded && this.currentJobStepsStates[step].cursor === null) {
+ this.currentJobStepsStates[step].expanded = true;
+ // need to await for load job if the step log is loaded for the first time
+ // so logline can be selected by querySelector
+ await this.loadJob();
+ }
+ const logLine = this.$refs.steps.querySelector(selectedLogStep);
+ if (!logLine) return;
+ logLine.querySelector('.line-num').click();
+ },
+ },
+};
+
+export default sfc;
+
+export function initRepositoryActionView() {
+ const el = document.getElementById('repo-action-view');
+ if (!el) return;
+
+ // TODO: the parent element's full height doesn't work well now,
+ // but we can not pollute the global style at the moment, only fix the height problem for pages with this component
+ const parentFullHeight = document.querySelector('body > div.full.height');
+ if (parentFullHeight) parentFullHeight.style.paddingBottom = '0';
+
+ const view = createApp(sfc, {
+ runIndex: el.getAttribute('data-run-index'),
+ jobIndex: el.getAttribute('data-job-index'),
+ actionsURL: el.getAttribute('data-actions-url'),
+ workflowName: el.getAttribute('data-workflow-name'),
+ workflowURL: el.getAttribute('data-workflow-url'),
+ locale: {
+ approve: el.getAttribute('data-locale-approve'),
+ cancel: el.getAttribute('data-locale-cancel'),
+ rerun: el.getAttribute('data-locale-rerun'),
+ artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
+ areYouSure: el.getAttribute('data-locale-are-you-sure'),
+ confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
+ rerun_all: el.getAttribute('data-locale-rerun-all'),
+ showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
+ showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
+ showFullScreen: el.getAttribute('data-locale-show-full-screen'),
+ downloadLogs: el.getAttribute('data-locale-download-logs'),
+ status: {
+ unknown: el.getAttribute('data-locale-status-unknown'),
+ waiting: el.getAttribute('data-locale-status-waiting'),
+ running: el.getAttribute('data-locale-status-running'),
+ success: el.getAttribute('data-locale-status-success'),
+ failure: el.getAttribute('data-locale-status-failure'),
+ cancelled: el.getAttribute('data-locale-status-cancelled'),
+ skipped: el.getAttribute('data-locale-status-skipped'),
+ blocked: el.getAttribute('data-locale-status-blocked'),
+ },
+ },
+ });
+ view.mount(el);
+}
+</script>
+<template>
+ <div class="ui container action-view-container">
+ <div class="action-view-header">
+ <div class="action-info-summary">
+ <div class="action-info-summary-title">
+ <ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="20"/>
+ <h2 class="action-info-summary-title-text">
+ {{ run.title }}
+ </h2>
+ </div>
+ <button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
+ {{ locale.approve }}
+ </button>
+ <button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
+ {{ locale.cancel }}
+ </button>
+ <button class="ui basic small compact button tw-mr-0 tw-whitespace-nowrap link-action" :data-url="`${run.link}/rerun`" v-else-if="run.canRerun">
+ {{ locale.rerun_all }}
+ </button>
+ </div>
+ <div class="action-summary">
+ {{ run.commit.localeCommit }}
+ <a class="muted" :href="run.commit.link">{{ run.commit.shortSHA }}</a>
+ {{ run.commit.localePushedBy }}
+ <a class="muted" :href="run.commit.pusher.link">{{ run.commit.pusher.displayName }}</a>
+ <span class="ui label tw-max-w-full" v-if="run.commit.shortSHA">
+ <a class="gt-ellipsis" :href="run.commit.branch.link">{{ run.commit.branch.name }}</a>
+ </span>
+ </div>
+ <div class="action-summary">
+ {{ run.commit.localeWorkflow }}
+ <a class="muted" :href="workflowURL">{{ workflowName }}</a>
+ </div>
+ </div>
+ <div class="action-view-body">
+ <div class="action-view-left">
+ <div class="job-group-section">
+ <div class="job-brief-list">
+ <a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id" @mouseenter="onHoverRerunIndex = job.id" @mouseleave="onHoverRerunIndex = -1">
+ <div class="job-brief-item-left">
+ <ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
+ <span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span>
+ </div>
+ <span class="job-brief-item-right">
+ <SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun tw-mx-2 link-action" :data-url="`${run.link}/jobs/${index}/rerun`" v-if="job.canRerun && onHoverRerunIndex === job.id"/>
+ <span class="step-summary-duration">{{ job.duration }}</span>
+ </span>
+ </a>
+ </div>
+ </div>
+ <div class="job-artifacts" v-if="artifacts.length > 0">
+ <div class="job-artifacts-title">
+ {{ locale.artifactsTitle }}
+ </div>
+ <ul class="job-artifacts-list">
+ <li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.name">
+ <a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
+ <SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }}
+ </a>
+ <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)" class="job-artifacts-delete">
+ <SvgIcon name="octicon-trash" class="ui text black job-artifacts-icon"/>
+ </a>
+ </li>
+ </ul>
+ </div>
+ </div>
+
+ <div class="action-view-right">
+ <div class="job-info-header">
+ <div class="job-info-header-left gt-ellipsis">
+ <h3 class="job-info-header-title gt-ellipsis">
+ {{ currentJob.title }}
+ </h3>
+ <p class="job-info-header-detail">
+ {{ currentJob.detail }}
+ </p>
+ </div>
+ <div class="job-info-header-right">
+ <div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible">
+ <button class="btn gt-interact-bg tw-p-2">
+ <SvgIcon name="octicon-gear" :size="18"/>
+ </button>
+ <div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
+ <a class="item" tabindex="0" @click="toggleTimeDisplay('seconds')" @keyup.space="toggleTimeDisplay('seconds')" @keyup.enter="toggleTimeDisplay('seconds')">
+ <i class="icon"><SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
+ {{ locale.showLogSeconds }}
+ </a>
+ <a class="item" tabindex="0" @click="toggleTimeDisplay('stamp')" @keyup.space="toggleTimeDisplay('stamp')" @keyup.enter="toggleTimeDisplay('stamp')">
+ <i class="icon"><SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
+ {{ locale.showTimeStamps }}
+ </a>
+ <a class="item" tabindex="0" @click="toggleFullScreen()" @keyup.space="toggleFullScreen()" @keyup.enter="toggleFullScreen()">
+ <i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
+ {{ locale.showFullScreen }}
+ </a>
+ <div class="divider"/>
+ <a :class="['item', !currentJob.steps.length ? 'disabled' : '']" :href="run.link+'/jobs/'+jobIndex+'/logs'" target="_blank">
+ <i class="icon"><SvgIcon name="octicon-download"/></i>
+ {{ locale.downloadLogs }}
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="job-step-container" ref="steps" v-if="currentJob.steps.length">
+ <div class="job-step-section" v-for="(jobStep, i) in currentJob.steps" :key="i">
+ <div class="job-step-summary" tabindex="0" @click.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" @keyup.enter.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" @keyup.space.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" :class="[currentJobStepsStates[i].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']">
+ <!-- If the job is done and the job step log is loaded for the first time, show the loading icon
+ currentJobStepsStates[i].cursor === null means the log is loaded for the first time
+ -->
+ <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
+ <SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/>
+ <ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
+
+ <span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
+ <span class="step-summary-duration">{{ jobStep.duration }}</span>
+ </div>
+
+ <!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
+ use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
+ <div class="job-step-logs" ref="logs" v-show="currentJobStepsStates[i].expanded"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+<style scoped>
+.action-view-body {
+ padding-top: 12px;
+ padding-bottom: 12px;
+ display: flex;
+ gap: 12px;
+}
+
+/* ================ */
+/* action view header */
+
+.action-view-header {
+ margin-top: 8px;
+}
+
+.action-info-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.action-info-summary-title {
+ display: flex;
+}
+
+.action-info-summary-title-text {
+ font-size: 20px;
+ margin: 0 0 0 8px;
+ flex: 1;
+ overflow-wrap: anywhere;
+}
+
+.action-summary {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ margin-left: 28px;
+}
+
+@media (max-width: 767.98px) {
+ .action-commit-summary {
+ margin-left: 0;
+ margin-top: 8px;
+ }
+}
+
+/* ================ */
+/* action view left */
+
+.action-view-left {
+ width: 30%;
+ max-width: 400px;
+ position: sticky;
+ top: 12px;
+ max-height: 100vh;
+ overflow-y: auto;
+ background: var(--color-body);
+ z-index: 2; /* above .job-info-header */
+}
+
+@media (max-width: 767.98px) {
+ .action-view-left {
+ position: static; /* can not sticky because multiple jobs would overlap into right view */
+ }
+}
+
+.job-artifacts-title {
+ font-size: 18px;
+ margin-top: 16px;
+ padding: 16px 10px 0 20px;
+ border-top: 1px solid var(--color-secondary);
+}
+
+.job-artifacts-item {
+ margin: 5px 0;
+ padding: 6px;
+ display: flex;
+ justify-content: space-between;
+}
+
+.job-artifacts-list {
+ padding-left: 12px;
+ list-style: none;
+}
+
+.job-artifacts-icon {
+ padding-right: 3px;
+}
+
+.job-brief-list {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.job-brief-item {
+ padding: 10px;
+ border-radius: var(--border-radius);
+ text-decoration: none;
+ display: flex;
+ flex-wrap: nowrap;
+ justify-content: space-between;
+ align-items: center;
+ color: var(--color-text);
+}
+
+.job-brief-item:hover {
+ background-color: var(--color-hover);
+}
+
+.job-brief-item.selected {
+ font-weight: var(--font-weight-bold);
+ background-color: var(--color-active);
+}
+
+.job-brief-item:first-of-type {
+ margin-top: 0;
+}
+
+.job-brief-item .job-brief-rerun {
+ cursor: pointer;
+ transition: transform 0.2s;
+}
+
+.job-brief-item .job-brief-rerun:hover {
+ transform: scale(130%);
+}
+
+.job-brief-item .job-brief-item-left {
+ display: flex;
+ width: 100%;
+ min-width: 0;
+}
+
+.job-brief-item .job-brief-item-left span {
+ display: flex;
+ align-items: center;
+}
+
+.job-brief-item .job-brief-item-left .job-brief-name {
+ display: block;
+ width: 70%;
+}
+
+.job-brief-item .job-brief-item-right {
+ display: flex;
+ align-items: center;
+}
+
+/* ================ */
+/* action view right */
+
+.action-view-right {
+ flex: 1;
+ color: var(--color-console-fg-subtle);
+ max-height: 100%;
+ width: 70%;
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--color-console-border);
+ border-radius: var(--border-radius);
+ background: var(--color-console-bg);
+ align-self: flex-start;
+}
+
+/* begin fomantic button overrides */
+
+.action-view-right .ui.button,
+.action-view-right .ui.button:focus {
+ background: transparent;
+ color: var(--color-console-fg-subtle);
+}
+
+.action-view-right .ui.button:hover {
+ background: var(--color-console-hover-bg);
+ color: var(--color-console-fg);
+}
+
+.action-view-right .ui.button:active {
+ background: var(--color-console-active-bg);
+ color: var(--color-console-fg);
+}
+
+/* end fomantic button overrides */
+
+/* begin fomantic dropdown menu overrides */
+
+.action-view-right .ui.dropdown .menu {
+ background: var(--color-console-menu-bg);
+ border-color: var(--color-console-menu-border);
+}
+
+.action-view-right .ui.dropdown .menu > .item {
+ color: var(--color-console-fg);
+}
+
+.action-view-right .ui.dropdown .menu > .item:hover {
+ color: var(--color-console-fg);
+ background: var(--color-console-hover-bg);
+}
+
+.action-view-right .ui.dropdown .menu > .item:active {
+ color: var(--color-console-fg);
+ background: var(--color-console-active-bg);
+}
+
+.action-view-right .ui.dropdown .menu > .divider {
+ border-top-color: var(--color-console-menu-border);
+}
+
+.action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after {
+ background: var(--color-console-menu-bg);
+ box-shadow: -1px -1px 0 0 var(--color-console-menu-border);
+}
+
+/* end fomantic dropdown menu overrides */
+
+.job-info-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 12px;
+ position: sticky;
+ top: 0;
+ height: 60px;
+ z-index: 1; /* above .job-step-container */
+ background: var(--color-console-bg);
+ border-radius: 3px;
+}
+
+.job-info-header:has(+ .job-step-container) {
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
+}
+
+.job-info-header .job-info-header-title {
+ color: var(--color-console-fg);
+ font-size: 16px;
+ margin: 0;
+}
+
+.job-info-header .job-info-header-detail {
+ color: var(--color-console-fg-subtle);
+ font-size: 12px;
+}
+
+.job-info-header-left {
+ flex: 1;
+}
+
+.job-step-container {
+ max-height: 100%;
+ border-radius: 0 0 var(--border-radius) var(--border-radius);
+ border-top: 1px solid var(--color-console-border);
+ z-index: 0;
+}
+
+.job-step-container .job-step-summary {
+ padding: 5px 10px;
+ display: flex;
+ align-items: center;
+ border-radius: var(--border-radius);
+}
+
+.job-step-container .job-step-summary.step-expandable {
+ cursor: pointer;
+}
+
+.job-step-container .job-step-summary.step-expandable:hover {
+ color: var(--color-console-fg);
+ background: var(--color-console-hover-bg);
+}
+
+.job-step-container .job-step-summary .step-summary-msg {
+ flex: 1;
+}
+
+.job-step-container .job-step-summary .step-summary-duration {
+ margin-left: 16px;
+}
+
+.job-step-container .job-step-summary.selected {
+ color: var(--color-console-fg);
+ background-color: var(--color-console-active-bg);
+ position: sticky;
+ top: 60px;
+}
+
+@media (max-width: 767.98px) {
+ .action-view-body {
+ flex-direction: column;
+ }
+ .action-view-left, .action-view-right {
+ width: 100%;
+ }
+ .action-view-left {
+ max-width: none;
+ }
+}
+</style>
+
+<style>
+/* some elements are not managed by vue, so we need to use global style */
+.job-status-rotate {
+ animation: job-status-rotate-keyframes 1s linear infinite;
+}
+
+@keyframes job-status-rotate-keyframes {
+ 100% {
+ transform: rotate(-360deg);
+ }
+}
+
+.job-step-section {
+ margin: 10px;
+}
+
+.job-step-section .job-step-logs {
+ font-family: var(--fonts-monospace);
+ margin: 8px 0;
+ font-size: 12px;
+}
+
+.job-step-section .job-step-logs .job-log-line {
+ display: flex;
+}
+
+.job-log-line:hover,
+.job-log-line:target {
+ background-color: var(--color-console-hover-bg);
+}
+
+.job-log-line:target {
+ scroll-margin-top: 95px;
+}
+
+/* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
+.job-log-line .line-num, .log-time-seconds {
+ width: 48px;
+ color: var(--color-text-light-3);
+ text-align: right;
+ user-select: none;
+}
+
+.job-log-line:target > .line-num {
+ color: var(--color-primary);
+ text-decoration: underline;
+}
+
+.log-time-seconds {
+ padding-right: 2px;
+}
+
+.job-log-line .log-time,
+.log-time-stamp {
+ color: var(--color-text-light-3);
+ margin-left: 10px;
+ white-space: nowrap;
+}
+
+.job-step-section .job-step-logs .job-log-line .log-msg {
+ flex: 1;
+ word-break: break-all;
+ white-space: break-spaces;
+ margin-left: 10px;
+ overflow-wrap: anywhere;
+}
+
+/* selectors here are intentionally exact to only match fullscreen */
+
+.full.height > .action-view-right {
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ border-radius: 0;
+}
+
+.full.height > .action-view-right > .job-info-header {
+ border-radius: 0;
+}
+
+.full.height > .action-view-right > .job-step-container {
+ height: calc(100% - 60px);
+ border-radius: 0;
+}
+
+.job-log-list.hidden {
+ display: none;
+}
+</style>
diff --git a/web_src/js/components/RepoActivityTopAuthors.vue b/web_src/js/components/RepoActivityTopAuthors.vue
new file mode 100644
index 0000000..3752bc5
--- /dev/null
+++ b/web_src/js/components/RepoActivityTopAuthors.vue
@@ -0,0 +1,164 @@
+<script>
+import {Bar} from 'vue-chartjs';
+import {
+ Chart,
+ Tooltip,
+ BarElement,
+ CategoryScale,
+ LinearScale,
+} from 'chart.js';
+import {chartJsColors} from '../utils/color.js';
+import {createApp} from 'vue';
+
+Chart.defaults.color = chartJsColors.text;
+Chart.defaults.borderColor = chartJsColors.border;
+
+Chart.register(
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ Tooltip,
+);
+
+const sfc = {
+ components: {Bar},
+ props: {
+ locale: {
+ type: Object,
+ required: true,
+ },
+ },
+ data: () => ({
+ colors: {
+ barColor: 'green',
+ },
+
+ // possible keys:
+ // * avatar_link: (...)
+ // * commits: (...)
+ // * home_link: (...)
+ // * login: (...)
+ // * name: (...)
+ activityTopAuthors: window.config.pageData.repoActivityTopAuthors || [],
+ i18nCommitActivity: this,
+ }),
+ methods: {
+ graphPoints() {
+ return {
+ datasets: [{
+ label: this.locale.commitActivity,
+ data: this.activityTopAuthors.map((item) => item.commits),
+ backgroundColor: this.colors.barColor,
+ barThickness: 40,
+ borderWidth: 0,
+ tension: 0.3,
+ }],
+ labels: this.activityTopAuthors.map((item) => item.name),
+ };
+ },
+ getOptions() {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: true,
+ scales: {
+ x: {
+ type: 'category',
+ grid: {
+ display: false,
+ },
+ ticks: {
+ // Disable the drawing of the labels on the x-asis and force them all
+ // of them to be 'shown', this avoids them being internally skipped
+ // for some data points. We rely on the internally generated ticks
+ // to know where to draw our own ticks. Set rotation to 90 degree
+ // and disable autoSkip. autoSkip is disabled to ensure no ticks are
+ // skipped and rotation is set to avoid messing with the width of the chart.
+ color: 'transparent',
+ minRotation: 90,
+ maxRotation: 90,
+ autoSkip: false,
+ },
+ },
+ y: {
+ ticks: {
+ stepSize: 1,
+ },
+ },
+ },
+ };
+ },
+ },
+ mounted() {
+ const refStyle = window.getComputedStyle(this.$refs.style);
+ this.colors.barColor = refStyle.backgroundColor;
+
+ for (const item of this.activityTopAuthors) {
+ const img = new Image();
+ img.src = item.avatar_link;
+ item.avatar_img = img;
+ }
+
+ Chart.register({
+ id: 'image_label',
+ afterDraw: (chart) => {
+ const xAxis = chart.boxes[0];
+ const yAxis = chart.boxes[1];
+ for (const [index] of xAxis.ticks.entries()) {
+ const x = xAxis.getPixelForTick(index);
+ const img = this.activityTopAuthors[index].avatar_img;
+
+ chart.ctx.save();
+ chart.ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, x - 10, yAxis.bottom + 10, 20, 20);
+ chart.ctx.restore();
+ }
+ },
+ beforeEvent: (chart, args) => {
+ const event = args.event;
+ if (event.type !== 'mousemove' && event.type !== 'click') return;
+
+ const yAxis = chart.boxes[1];
+ if (event.y < yAxis.bottom + 10 || event.y > yAxis.bottom + 30) {
+ chart.canvas.style.cursor = '';
+ return;
+ }
+
+ const xAxis = chart.boxes[0];
+ const pointIdx = xAxis.ticks.findIndex((_, index) => {
+ const x = xAxis.getPixelForTick(index);
+ return event.x >= x - 10 && event.x <= x + 10;
+ });
+
+ if (pointIdx === -1) {
+ chart.canvas.style.cursor = '';
+ return;
+ }
+
+ chart.canvas.style.cursor = 'pointer';
+ if (event.type === 'click' && this.activityTopAuthors[pointIdx].home_link) {
+ window.location.href = this.activityTopAuthors[pointIdx].home_link;
+ }
+ },
+ });
+ },
+};
+
+export function initRepoActivityTopAuthorsChart() {
+ const el = document.getElementById('repo-activity-top-authors-chart');
+ if (el) {
+ createApp(sfc, {
+ locale: {
+ commitActivity: el.getAttribute('data-locale-commit-activity'),
+ },
+ }).mount(el);
+ }
+}
+
+export default sfc; // activate the IDE's Vue plugin
+</script>
+<template>
+ <div>
+ <div class="activity-bar-graph" ref="style" style="width: 0; height: 0;"/>
+ <Bar height="150px" :data="graphPoints()" :options="getOptions()"/>
+ </div>
+</template>
diff --git a/web_src/js/components/RepoBranchTagSelector.vue b/web_src/js/components/RepoBranchTagSelector.vue
new file mode 100644
index 0000000..bfba203
--- /dev/null
+++ b/web_src/js/components/RepoBranchTagSelector.vue
@@ -0,0 +1,357 @@
+<script>
+import {createApp, nextTick} from 'vue';
+import $ from 'jquery';
+import {SvgIcon} from '../svg.js';
+import {pathEscapeSegments} from '../utils/url.js';
+import {showErrorToast} from '../modules/toast.js';
+import {GET} from '../modules/fetch.js';
+
+const sfc = {
+ components: {SvgIcon},
+
+ // no `data()`, at the moment, the `data()` is provided by the init code, which is not ideal and should be fixed in the future
+
+ computed: {
+ filteredItems() {
+ const items = this.items.filter((item) => {
+ return ((this.mode === 'branches' && item.branch) || (this.mode === 'tags' && item.tag)) &&
+ (!this.searchTerm || item.name.toLowerCase().includes(this.searchTerm.toLowerCase()));
+ });
+
+ // TODO: fix this anti-pattern: side-effects-in-computed-properties
+ this.active = !items.length && this.showCreateNewBranch ? 0 : -1;
+ return items;
+ },
+ showNoResults() {
+ return !this.filteredItems.length && !this.showCreateNewBranch;
+ },
+ showCreateNewBranch() {
+ if (this.disableCreateBranch || !this.searchTerm) {
+ return false;
+ }
+ return !this.items.filter((item) => {
+ return item.name.toLowerCase() === this.searchTerm.toLowerCase();
+ }).length;
+ },
+ formActionUrl() {
+ return `${this.repoLink}/branches/_new/${this.branchNameSubURL}`;
+ },
+ shouldCreateTag() {
+ return this.mode === 'tags';
+ },
+ },
+
+ watch: {
+ menuVisible(visible) {
+ if (visible) {
+ this.focusSearchField();
+ this.fetchBranchesOrTags();
+ }
+ },
+ },
+
+ beforeMount() {
+ if (this.viewType === 'tree') {
+ this.isViewTree = true;
+ this.refNameText = this.commitIdShort;
+ } else if (this.viewType === 'tag') {
+ this.isViewTag = true;
+ this.refNameText = this.tagName;
+ } else {
+ this.isViewBranch = true;
+ this.refNameText = this.branchName;
+ }
+
+ document.body.addEventListener('click', (event) => {
+ if (this.$el.contains(event.target)) return;
+ if (this.menuVisible) {
+ this.menuVisible = false;
+ }
+ });
+ },
+ methods: {
+ selectItem(item) {
+ const prev = this.getSelected();
+ if (prev !== null) {
+ prev.selected = false;
+ }
+ item.selected = true;
+ const url = (item.tag) ? this.tagURLPrefix + item.url + this.tagURLSuffix : this.branchURLPrefix + item.url + this.branchURLSuffix;
+ if (!this.branchForm) {
+ window.location.href = url;
+ } else {
+ this.isViewTree = false;
+ this.isViewTag = false;
+ this.isViewBranch = false;
+ this.$refs.dropdownRefName.textContent = item.name;
+ if (this.setAction) {
+ document.getElementById(this.branchForm)?.setAttribute('action', url);
+ } else {
+ $(`#${this.branchForm} input[name="refURL"]`).val(url);
+ }
+ $(`#${this.branchForm} input[name="ref"]`).val(item.name);
+ if (item.tag) {
+ this.isViewTag = true;
+ $(`#${this.branchForm} input[name="refType"]`).val('tag');
+ } else {
+ this.isViewBranch = true;
+ $(`#${this.branchForm} input[name="refType"]`).val('branch');
+ }
+ if (this.submitForm) {
+ $(`#${this.branchForm}`).trigger('submit');
+ }
+ this.menuVisible = false;
+ }
+ },
+ createNewBranch() {
+ if (!this.showCreateNewBranch) return;
+ $(this.$refs.newBranchForm).trigger('submit');
+ },
+ focusSearchField() {
+ nextTick(() => {
+ this.$refs.searchField.focus();
+ });
+ },
+ getSelected() {
+ for (let i = 0, j = this.items.length; i < j; ++i) {
+ if (this.items[i].selected) return this.items[i];
+ }
+ return null;
+ },
+ getSelectedIndexInFiltered() {
+ for (let i = 0, j = this.filteredItems.length; i < j; ++i) {
+ if (this.filteredItems[i].selected) return i;
+ }
+ return -1;
+ },
+ scrollToActive() {
+ let el = this.$refs[`listItem${this.active}`]; // eslint-disable-line no-jquery/variable-pattern
+ if (!el || !el.length) return;
+ if (Array.isArray(el)) {
+ el = el[0];
+ }
+
+ const cont = this.$refs.scrollContainer;
+ if (el.offsetTop < cont.scrollTop) {
+ cont.scrollTop = el.offsetTop;
+ } else if (el.offsetTop + el.clientHeight > cont.scrollTop + cont.clientHeight) {
+ cont.scrollTop = el.offsetTop + el.clientHeight - cont.clientHeight;
+ }
+ },
+ keydown(event) {
+ if (event.keyCode === 40) { // arrow down
+ event.preventDefault();
+
+ if (this.active === -1) {
+ this.active = this.getSelectedIndexInFiltered();
+ }
+
+ if (this.active + (this.showCreateNewBranch ? 0 : 1) >= this.filteredItems.length) {
+ return;
+ }
+ this.active++;
+ this.scrollToActive();
+ } else if (event.keyCode === 38) { // arrow up
+ event.preventDefault();
+
+ if (this.active === -1) {
+ this.active = this.getSelectedIndexInFiltered();
+ }
+
+ if (this.active <= 0) {
+ return;
+ }
+ this.active--;
+ this.scrollToActive();
+ } else if (event.keyCode === 13) { // enter
+ event.preventDefault();
+
+ if (this.active >= this.filteredItems.length) {
+ this.createNewBranch();
+ } else if (this.active >= 0) {
+ this.selectItem(this.filteredItems[this.active]);
+ }
+ } else if (event.keyCode === 27) { // escape
+ event.preventDefault();
+ this.menuVisible = false;
+ }
+ },
+ handleTabSwitch(mode) {
+ if (this.isLoading) return;
+ this.mode = mode;
+ this.focusSearchField();
+ this.fetchBranchesOrTags();
+ },
+ async fetchBranchesOrTags() {
+ if (!['branches', 'tags'].includes(this.mode) || this.isLoading) return;
+ // only fetch when branch/tag list has not been initialized
+ if (this.hasListInitialized[this.mode] ||
+ (this.mode === 'branches' && !this.showBranchesInDropdown) ||
+ (this.mode === 'tags' && this.noTag)
+ ) {
+ return;
+ }
+ this.isLoading = true;
+ try {
+ const resp = await GET(`${this.repoLink}/${this.mode}/list`);
+ const {results} = await resp.json();
+ for (const result of results) {
+ let selected = false;
+ if (this.mode === 'branches') {
+ selected = result === this.defaultSelectedRefName;
+ } else {
+ selected = result === (this.release ? this.release.tagName : this.defaultSelectedRefName);
+ }
+ this.items.push({name: result, url: pathEscapeSegments(result), branch: this.mode === 'branches', tag: this.mode === 'tags', selected});
+ }
+ this.hasListInitialized[this.mode] = true;
+ } catch (e) {
+ showErrorToast(`Network error when fetching ${this.mode}, error: ${e}`);
+ } finally {
+ this.isLoading = false;
+ }
+ },
+ },
+};
+
+export function initRepoBranchTagSelector(selector) {
+ for (const [elIndex, elRoot] of document.querySelectorAll(selector).entries()) {
+ const data = {
+ csrfToken: window.config.csrfToken,
+ items: [],
+ searchTerm: '',
+ refNameText: '',
+ menuVisible: false,
+ release: null,
+
+ isViewTag: false,
+ isViewBranch: false,
+ isViewTree: false,
+
+ active: 0,
+ isLoading: false,
+ // This means whether branch list/tag list has initialized
+ hasListInitialized: {
+ 'branches': false,
+ 'tags': false,
+ },
+ ...window.config.pageData.branchDropdownDataList[elIndex],
+ };
+
+ const comp = {...sfc, data() { return data }};
+ createApp(comp).mount(elRoot);
+ }
+}
+
+export default sfc; // activate IDE's Vue plugin
+</script>
+<template>
+ <div class="ui dropdown custom">
+ <button class="branch-dropdown-button gt-ellipsis ui basic small compact button tw-flex tw-m-0" @click="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
+ <span class="text tw-flex tw-items-center tw-mr-1 gt-ellipsis">
+ <template v-if="release">{{ textReleaseCompare }}</template>
+ <template v-else>
+ <svg-icon v-if="isViewTag" name="octicon-tag"/>
+ <svg-icon v-else name="octicon-git-branch"/>
+ <strong ref="dropdownRefName" class="tw-ml-2 tw-inline-block gt-ellipsis">{{ refNameText }}</strong>
+ </template>
+ </span>
+ <svg-icon name="octicon-triangle-down" :size="14" class-name="dropdown icon"/>
+ </button>
+ <div class="menu transition" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak>
+ <div class="ui icon search input">
+ <i class="icon"><svg-icon name="octicon-filter" :size="16"/></i>
+ <input name="search" ref="searchField" autocomplete="off" v-model="searchTerm" @keydown="keydown($event)" :placeholder="searchFieldPlaceholder">
+ </div>
+ <div v-if="showBranchesInDropdown" class="branch-tag-tab">
+ <a class="branch-tag-item muted" :class="{active: mode === 'branches'}" href="#" @click="handleTabSwitch('branches')">
+ <svg-icon name="octicon-git-branch" :size="16" class-name="tw-mr-1"/>{{ textBranches }}
+ </a>
+ <a v-if="!noTag" class="branch-tag-item muted" :class="{active: mode === 'tags'}" href="#" @click="handleTabSwitch('tags')">
+ <svg-icon name="octicon-tag" :size="16" class-name="tw-mr-1"/>{{ textTags }}
+ </a>
+ </div>
+ <div class="branch-tag-divider"/>
+ <div class="scrolling menu" ref="scrollContainer">
+ <svg-icon name="octicon-rss" symbol-id="svg-symbol-octicon-rss"/>
+ <div class="loading-indicator is-loading" v-if="isLoading"/>
+ <div v-for="(item, index) in filteredItems" :key="item.name" class="item" :class="{selected: item.selected, active: active === index}" @click="selectItem(item)" :ref="'listItem' + index">
+ {{ item.name }}
+ <div class="ui label" v-if="item.name===repoDefaultBranch && mode === 'branches'">
+ {{ textDefaultBranchLabel }}
+ </div>
+ <a v-show="enableFeed && mode === 'branches'" role="button" class="rss-icon tw-float-right" :href="rssURLPrefix + item.url" target="_blank" @click.stop>
+ <!-- creating a lot of Vue component is pretty slow, so we use a static SVG here -->
+ <svg width="14" height="14" class="svg octicon-rss"><use href="#svg-symbol-octicon-rss"/></svg>
+ </a>
+ </div>
+ <div class="item" v-if="showCreateNewBranch" :class="{active: active === filteredItems.length}" :ref="'listItem' + filteredItems.length">
+ <a href="#" @click="createNewBranch()">
+ <div v-show="shouldCreateTag">
+ <i class="reference tags icon"/>
+ <span v-text="textCreateTag.replace('%s', searchTerm)"/>
+ </div>
+ <div v-show="!shouldCreateTag">
+ <svg-icon name="octicon-git-branch"/>
+ <span v-text="textCreateBranch.replace('%s', searchTerm)"/>
+ </div>
+ <div class="text small">
+ <span v-if="isViewBranch || release">{{ textCreateBranchFrom.replace('%s', branchName) }}</span>
+ <span v-else-if="isViewTag">{{ textCreateBranchFrom.replace('%s', tagName) }}</span>
+ <span v-else>{{ textCreateBranchFrom.replace('%s', commitIdShort) }}</span>
+ </div>
+ </a>
+ <form ref="newBranchForm" :action="formActionUrl" method="post">
+ <input type="hidden" name="_csrf" :value="csrfToken">
+ <input type="hidden" name="new_branch_name" v-model="searchTerm">
+ <input type="hidden" name="create_tag" v-model="shouldCreateTag">
+ <input type="hidden" name="current_path" v-model="treePath" v-if="treePath">
+ </form>
+ </div>
+ </div>
+ <div class="message" v-if="showNoResults && !isLoading">
+ {{ noResults }}
+ </div>
+ </div>
+ </div>
+</template>
+<style scoped>
+.branch-tag-tab {
+ padding: 0 10px;
+}
+
+.branch-tag-item {
+ display: inline-block;
+ padding: 10px;
+ border: 1px solid transparent;
+ border-bottom: none;
+}
+
+.branch-tag-item.active {
+ border-color: var(--color-secondary);
+ background: var(--color-menu);
+ border-top-left-radius: var(--border-radius);
+ border-top-right-radius: var(--border-radius);
+}
+
+.branch-tag-divider {
+ margin-top: -1px !important;
+ border-top: 1px solid var(--color-secondary);
+}
+
+.scrolling.menu {
+ border-top: none !important;
+}
+
+.menu .item .rss-icon {
+ display: none; /* only show RSS icon on hover */
+}
+
+.menu .item:hover .rss-icon {
+ display: inline-block;
+}
+
+.scrolling.menu .loading-indicator {
+ height: 4em;
+}
+</style>
diff --git a/web_src/js/components/RepoCodeFrequency.vue b/web_src/js/components/RepoCodeFrequency.vue
new file mode 100644
index 0000000..1d40d6d
--- /dev/null
+++ b/web_src/js/components/RepoCodeFrequency.vue
@@ -0,0 +1,172 @@
+<script>
+import {SvgIcon} from '../svg.js';
+import {
+ Chart,
+ Legend,
+ LinearScale,
+ TimeScale,
+ PointElement,
+ LineElement,
+ Filler,
+} from 'chart.js';
+import {GET} from '../modules/fetch.js';
+import {Line as ChartLine} from 'vue-chartjs';
+import {
+ startDaysBetween,
+ firstStartDateAfterDate,
+ fillEmptyStartDaysWithZeroes,
+} from '../utils/time.js';
+import {chartJsColors} from '../utils/color.js';
+import {sleep} from '../utils.js';
+import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
+
+const {pageData} = window.config;
+
+Chart.defaults.color = chartJsColors.text;
+Chart.defaults.borderColor = chartJsColors.border;
+
+Chart.register(
+ TimeScale,
+ LinearScale,
+ Legend,
+ PointElement,
+ LineElement,
+ Filler,
+);
+
+export default {
+ components: {ChartLine, SvgIcon},
+ props: {
+ locale: {
+ type: Object,
+ required: true,
+ },
+ },
+ data: () => ({
+ isLoading: false,
+ errorText: '',
+ repoLink: pageData.repoLink || [],
+ data: [],
+ }),
+ mounted() {
+ this.fetchGraphData();
+ },
+ methods: {
+ async fetchGraphData() {
+ this.isLoading = true;
+ try {
+ let response;
+ do {
+ response = await GET(`${this.repoLink}/activity/code-frequency/data`);
+ if (response.status === 202) {
+ await sleep(1000); // wait for 1 second before retrying
+ }
+ } while (response.status === 202);
+ if (response.ok) {
+ this.data = await response.json();
+ const weekValues = Object.values(this.data);
+ const start = weekValues[0].week;
+ const end = firstStartDateAfterDate(new Date());
+ const startDays = startDaysBetween(start, end);
+ this.data = fillEmptyStartDaysWithZeroes(startDays, this.data);
+ this.errorText = '';
+ } else {
+ this.errorText = response.statusText;
+ }
+ } catch (err) {
+ this.errorText = err.message;
+ } finally {
+ this.isLoading = false;
+ }
+ },
+
+ toGraphData(data) {
+ return {
+ datasets: [
+ {
+ data: data.map((i) => ({x: i.week, y: i.additions})),
+ pointRadius: 0,
+ pointHitRadius: 0,
+ fill: true,
+ label: 'Additions',
+ backgroundColor: chartJsColors['additions'],
+ borderWidth: 0,
+ tension: 0.3,
+ },
+ {
+ data: data.map((i) => ({x: i.week, y: -i.deletions})),
+ pointRadius: 0,
+ pointHitRadius: 0,
+ fill: true,
+ label: 'Deletions',
+ backgroundColor: chartJsColors['deletions'],
+ borderWidth: 0,
+ tension: 0.3,
+ },
+ ],
+ };
+ },
+
+ getOptions() {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: true,
+ plugins: {
+ legend: {
+ display: true,
+ },
+ },
+ scales: {
+ x: {
+ type: 'time',
+ grid: {
+ display: false,
+ },
+ time: {
+ minUnit: 'month',
+ },
+ ticks: {
+ maxRotation: 0,
+ maxTicksLimit: 12,
+ },
+ },
+ y: {
+ ticks: {
+ maxTicksLimit: 6,
+ },
+ },
+ },
+ };
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="ui header tw-flex tw-items-center tw-justify-between">
+ {{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: `Code frequency over the history of ${repoLink.slice(1)}` }}
+ </div>
+ <div class="tw-flex ui segment main-graph">
+ <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto">
+ <div v-if="isLoading">
+ <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
+ {{ locale.loadingInfo }}
+ </div>
+ <div v-else class="text red">
+ <SvgIcon name="octicon-x-circle-fill"/>
+ {{ errorText }}
+ </div>
+ </div>
+ <ChartLine
+ v-memo="data" v-if="data.length !== 0"
+ :data="toGraphData(data)" :options="getOptions()"
+ />
+ </div>
+ </div>
+</template>
+<style scoped>
+.main-graph {
+ height: 440px;
+}
+</style>
diff --git a/web_src/js/components/RepoContributors.vue b/web_src/js/components/RepoContributors.vue
new file mode 100644
index 0000000..dec2599
--- /dev/null
+++ b/web_src/js/components/RepoContributors.vue
@@ -0,0 +1,431 @@
+<script>
+import {SvgIcon} from '../svg.js';
+import {
+ Chart,
+ Title,
+ BarElement,
+ LinearScale,
+ TimeScale,
+ PointElement,
+ LineElement,
+ Filler,
+} from 'chart.js';
+import {GET} from '../modules/fetch.js';
+import zoomPlugin from 'chartjs-plugin-zoom';
+import {Line as ChartLine} from 'vue-chartjs';
+import {
+ startDaysBetween,
+ firstStartDateAfterDate,
+ fillEmptyStartDaysWithZeroes,
+} from '../utils/time.js';
+import {chartJsColors} from '../utils/color.js';
+import {sleep} from '../utils.js';
+import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
+import $ from 'jquery';
+
+const customEventListener = {
+ id: 'customEventListener',
+ afterEvent: (chart, args, opts) => {
+ // event will be replayed from chart.update when reset zoom,
+ // so we need to check whether args.replay is true to avoid call loops
+ if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) {
+ chart.resetZoom();
+ opts.instance.updateOtherCharts(args.event, true);
+ }
+ },
+};
+
+Chart.defaults.color = chartJsColors.text;
+Chart.defaults.borderColor = chartJsColors.border;
+
+Chart.register(
+ TimeScale,
+ LinearScale,
+ BarElement,
+ Title,
+ PointElement,
+ LineElement,
+ Filler,
+ zoomPlugin,
+ customEventListener,
+);
+
+export default {
+ components: {ChartLine, SvgIcon},
+ props: {
+ locale: {
+ type: Object,
+ required: true,
+ },
+ repoLink: {
+ type: String,
+ required: true,
+ },
+ },
+ data: () => ({
+ isLoading: false,
+ errorText: '',
+ totalStats: {},
+ sortedContributors: {},
+ type: 'commits',
+ contributorsStats: [],
+ xAxisStart: null,
+ xAxisEnd: null,
+ xAxisMin: null,
+ xAxisMax: null,
+ }),
+ mounted() {
+ this.fetchGraphData();
+
+ $('#repo-contributors').dropdown({
+ onChange: (val) => {
+ this.xAxisMin = this.xAxisStart;
+ this.xAxisMax = this.xAxisEnd;
+ this.type = val;
+ this.sortContributors();
+ },
+ });
+ },
+ methods: {
+ sortContributors() {
+ const contributors = this.filterContributorWeeksByDateRange();
+ const criteria = `total_${this.type}`;
+ this.sortedContributors = Object.values(contributors)
+ .filter((contributor) => contributor[criteria] !== 0)
+ .sort((a, b) => a[criteria] > b[criteria] ? -1 : a[criteria] === b[criteria] ? 0 : 1)
+ .slice(0, 100);
+ },
+
+ async fetchGraphData() {
+ this.isLoading = true;
+ try {
+ let response;
+ do {
+ response = await GET(`${this.repoLink}/activity/contributors/data`);
+ if (response.status === 202) {
+ await sleep(1000); // wait for 1 second before retrying
+ }
+ } while (response.status === 202);
+ if (response.ok) {
+ const data = await response.json();
+ const {total, ...rest} = data;
+ // below line might be deleted if we are sure go produces map always sorted by keys
+ total.weeks = Object.fromEntries(Object.entries(total.weeks).sort());
+
+ const weekValues = Object.values(total.weeks);
+ this.xAxisStart = weekValues[0].week;
+ this.xAxisEnd = firstStartDateAfterDate(new Date());
+ const startDays = startDaysBetween(this.xAxisStart, this.xAxisEnd);
+ total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks);
+ this.xAxisMin = this.xAxisStart;
+ this.xAxisMax = this.xAxisEnd;
+ this.contributorsStats = {};
+ for (const [email, user] of Object.entries(rest)) {
+ user.weeks = fillEmptyStartDaysWithZeroes(startDays, user.weeks);
+ this.contributorsStats[email] = user;
+ }
+ this.sortContributors();
+ this.totalStats = total;
+ this.errorText = '';
+ } else {
+ this.errorText = response.statusText;
+ }
+ } catch (err) {
+ this.errorText = err.message;
+ } finally {
+ this.isLoading = false;
+ }
+ },
+
+ filterContributorWeeksByDateRange() {
+ const filteredData = {};
+ const data = this.contributorsStats;
+ for (const key of Object.keys(data)) {
+ const user = data[key];
+ user.total_commits = 0;
+ user.total_additions = 0;
+ user.total_deletions = 0;
+ user.max_contribution_type = 0;
+ const filteredWeeks = user.weeks.filter((week) => {
+ const oneWeek = 7 * 24 * 60 * 60 * 1000;
+ if (week.week >= this.xAxisMin - oneWeek && week.week <= this.xAxisMax + oneWeek) {
+ user.total_commits += week.commits;
+ user.total_additions += week.additions;
+ user.total_deletions += week.deletions;
+ if (week[this.type] > user.max_contribution_type) {
+ user.max_contribution_type = week[this.type];
+ }
+ return true;
+ }
+ return false;
+ });
+ // this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722
+ // for details.
+ user.max_contribution_type += 1;
+
+ filteredData[key] = {...user, weeks: filteredWeeks};
+ }
+
+ return filteredData;
+ },
+
+ maxMainGraph() {
+ // This method calculates maximum value for Y value of the main graph. If the number
+ // of maximum contributions for selected contribution type is 15.955 it is probably
+ // better to round it up to 20.000.This method is responsible for doing that.
+ // Normally, chartjs handles this automatically, but it will resize the graph when you
+ // zoom, pan etc. I think resizing the graph makes it harder to compare things visually.
+ const maxValue = Math.max(
+ ...this.totalStats.weeks.map((o) => o[this.type]),
+ );
+ const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
+ if (coefficient % 1 === 0) return maxValue;
+ return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
+ },
+
+ maxContributorGraph() {
+ // Similar to maxMainGraph method this method calculates maximum value for Y value
+ // for contributors' graph. If I let chartjs do this for me, it will choose different
+ // maxY value for each contributors' graph which again makes it harder to compare.
+ const maxValue = Math.max(
+ ...this.sortedContributors.map((c) => c.max_contribution_type),
+ );
+ const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
+ if (coefficient % 1 === 0) return maxValue;
+ return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
+ },
+
+ toGraphData(data) {
+ return {
+ datasets: [
+ {
+ data: data.map((i) => ({x: i.week, y: i[this.type]})),
+ pointRadius: 0,
+ pointHitRadius: 0,
+ fill: 'start',
+ backgroundColor: chartJsColors[this.type],
+ borderWidth: 0,
+ tension: 0.3,
+ },
+ ],
+ };
+ },
+
+ updateOtherCharts(event, reset) {
+ const minVal = event.chart.options.scales.x.min;
+ const maxVal = event.chart.options.scales.x.max;
+ if (reset) {
+ this.xAxisMin = this.xAxisStart;
+ this.xAxisMax = this.xAxisEnd;
+ this.sortContributors();
+ } else if (minVal) {
+ this.xAxisMin = minVal;
+ this.xAxisMax = maxVal;
+ this.sortContributors();
+ }
+ },
+
+ getOptions(type) {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: false,
+ events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'],
+ plugins: {
+ title: {
+ display: type === 'main',
+ text: 'drag: zoom, shift+drag: pan, double click: reset zoom',
+ position: 'top',
+ align: 'center',
+ },
+ customEventListener: {
+ chartType: type,
+ instance: this,
+ },
+ zoom: {
+ pan: {
+ enabled: true,
+ modifierKey: 'shift',
+ mode: 'x',
+ threshold: 20,
+ onPanComplete: this.updateOtherCharts,
+ },
+ limits: {
+ x: {
+ // Check https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/options.html#scale-limits
+ // to know what each option means
+ min: 'original',
+ max: 'original',
+
+ // number of milliseconds in 2 weeks. Minimum x range will be 2 weeks when you zoom on the graph
+ minRange: 2 * 7 * 24 * 60 * 60 * 1000,
+ },
+ },
+ zoom: {
+ drag: {
+ enabled: type === 'main',
+ },
+ pinch: {
+ enabled: type === 'main',
+ },
+ mode: 'x',
+ onZoomComplete: this.updateOtherCharts,
+ },
+ },
+ },
+ scales: {
+ x: {
+ min: this.xAxisMin,
+ max: this.xAxisMax,
+ type: 'time',
+ grid: {
+ display: false,
+ },
+ time: {
+ minUnit: 'month',
+ },
+ ticks: {
+ maxRotation: 0,
+ maxTicksLimit: type === 'main' ? 12 : 6,
+ },
+ },
+ y: {
+ min: 0,
+ max: type === 'main' ? this.maxMainGraph() : this.maxContributorGraph(),
+ ticks: {
+ maxTicksLimit: type === 'main' ? 6 : 4,
+ },
+ },
+ },
+ };
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="ui header tw-flex tw-items-center tw-justify-between">
+ <div>
+ <relative-time
+ v-if="xAxisMin > 0"
+ format="datetime"
+ year="numeric"
+ month="short"
+ day="numeric"
+ weekday=""
+ :datetime="new Date(xAxisMin)"
+ >
+ {{ new Date(xAxisMin) }}
+ </relative-time>
+ {{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }}
+ <relative-time
+ v-if="xAxisMax > 0"
+ format="datetime"
+ year="numeric"
+ month="short"
+ day="numeric"
+ weekday=""
+ :datetime="new Date(xAxisMax)"
+ >
+ {{ new Date(xAxisMax) }}
+ </relative-time>
+ </div>
+ <div>
+ <!-- Contribution type -->
+ <div class="ui dropdown jump" id="repo-contributors">
+ <div class="ui basic compact button">
+ <span class="not-mobile">{{ locale.filterLabel }}</span> <strong>{{ locale.contributionType[type] }}</strong>
+ <svg-icon name="octicon-triangle-down" :size="14"/>
+ </div>
+ <div class="menu">
+ <div :class="['item', {'selected': type === 'commits'}]" data-value="commits">
+ {{ locale.contributionType.commits }}
+ </div>
+ <div :class="['item', {'selected': type === 'additions'}]" data-value="additions">
+ {{ locale.contributionType.additions }}
+ </div>
+ <div :class="['item', {'selected': type === 'deletions'}]" data-value="deletions">
+ {{ locale.contributionType.deletions }}
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="tw-flex ui segment main-graph">
+ <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto">
+ <div v-if="isLoading">
+ <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
+ {{ locale.loadingInfo }}
+ </div>
+ <div v-else class="text red">
+ <SvgIcon name="octicon-x-circle-fill"/>
+ {{ errorText }}
+ </div>
+ </div>
+ <ChartLine
+ v-memo="[totalStats.weeks, type]" v-if="Object.keys(totalStats).length !== 0"
+ :data="toGraphData(totalStats.weeks)" :options="getOptions('main')"
+ />
+ </div>
+ <div class="contributor-grid">
+ <div
+ v-for="(contributor, index) in sortedContributors"
+ :key="index"
+ v-memo="[sortedContributors, type]"
+ >
+ <div class="ui top attached header tw-flex tw-flex-1">
+ <b class="ui right">#{{ index + 1 }}</b>
+ <a :href="contributor.home_link">
+ <img class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link">
+ </a>
+ <div class="tw-ml-2">
+ <a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a>
+ <h4 v-else class="contributor-name">
+ {{ contributor.name }}
+ </h4>
+ <p class="tw-text-12 tw-flex tw-gap-1">
+ <strong v-if="contributor.total_commits">{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}</strong>
+ <strong v-if="contributor.total_additions" class="text green">{{ contributor.total_additions.toLocaleString() }}++ </strong>
+ <strong v-if="contributor.total_deletions" class="text red">
+ {{ contributor.total_deletions.toLocaleString() }}--</strong>
+ </p>
+ </div>
+ </div>
+ <div class="ui attached segment">
+ <div>
+ <ChartLine
+ :data="toGraphData(contributor.weeks)"
+ :options="getOptions('contributor')"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+<style scoped>
+.main-graph {
+ height: 260px;
+ padding-top: 2px;
+}
+
+.contributor-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 1rem;
+}
+
+.contributor-grid > * {
+ min-width: 0;
+}
+
+@media (max-width: 991.98px) {
+ .contributor-grid {
+ grid-template-columns: repeat(1, 1fr);
+ }
+}
+
+.contributor-name {
+ margin-bottom: 0;
+}
+</style>
diff --git a/web_src/js/components/RepoRecentCommits.vue b/web_src/js/components/RepoRecentCommits.vue
new file mode 100644
index 0000000..8759978
--- /dev/null
+++ b/web_src/js/components/RepoRecentCommits.vue
@@ -0,0 +1,149 @@
+<script>
+import {SvgIcon} from '../svg.js';
+import {
+ Chart,
+ Tooltip,
+ BarElement,
+ LinearScale,
+ TimeScale,
+} from 'chart.js';
+import {GET} from '../modules/fetch.js';
+import {Bar} from 'vue-chartjs';
+import {
+ startDaysBetween,
+ firstStartDateAfterDate,
+ fillEmptyStartDaysWithZeroes,
+} from '../utils/time.js';
+import {chartJsColors} from '../utils/color.js';
+import {sleep} from '../utils.js';
+import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
+
+const {pageData} = window.config;
+
+Chart.defaults.color = chartJsColors.text;
+Chart.defaults.borderColor = chartJsColors.border;
+
+Chart.register(
+ TimeScale,
+ LinearScale,
+ BarElement,
+ Tooltip,
+);
+
+export default {
+ components: {Bar, SvgIcon},
+ props: {
+ locale: {
+ type: Object,
+ required: true,
+ },
+ },
+ data: () => ({
+ isLoading: false,
+ errorText: '',
+ repoLink: pageData.repoLink || [],
+ data: [],
+ }),
+ mounted() {
+ this.fetchGraphData();
+ },
+ methods: {
+ async fetchGraphData() {
+ this.isLoading = true;
+ try {
+ let response;
+ do {
+ response = await GET(`${this.repoLink}/activity/recent-commits/data`);
+ if (response.status === 202) {
+ await sleep(1000); // wait for 1 second before retrying
+ }
+ } while (response.status === 202);
+ if (response.ok) {
+ const data = await response.json();
+ const start = Object.values(data)[0].week;
+ const end = firstStartDateAfterDate(new Date());
+ const startDays = startDaysBetween(start, end);
+ this.data = fillEmptyStartDaysWithZeroes(startDays, data).slice(-52);
+ this.errorText = '';
+ } else {
+ this.errorText = response.statusText;
+ }
+ } catch (err) {
+ this.errorText = err.message;
+ } finally {
+ this.isLoading = false;
+ }
+ },
+
+ toGraphData(data) {
+ return {
+ datasets: [
+ {
+ data: data.map((i) => ({x: i.week, y: i.commits})),
+ label: 'Commits',
+ backgroundColor: chartJsColors['commits'],
+ borderWidth: 0,
+ tension: 0.3,
+ },
+ ],
+ };
+ },
+
+ getOptions() {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ animation: true,
+ scales: {
+ x: {
+ type: 'time',
+ grid: {
+ display: false,
+ },
+ time: {
+ minUnit: 'week',
+ },
+ ticks: {
+ maxRotation: 0,
+ maxTicksLimit: 52,
+ },
+ },
+ y: {
+ ticks: {
+ maxTicksLimit: 6,
+ },
+ },
+ },
+ };
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="ui header tw-flex tw-items-center tw-justify-between">
+ {{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "Number of commits in the past year" }}
+ </div>
+ <div class="tw-flex ui segment main-graph">
+ <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto">
+ <div v-if="isLoading">
+ <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
+ {{ locale.loadingInfo }}
+ </div>
+ <div v-else class="text red">
+ <SvgIcon name="octicon-x-circle-fill"/>
+ {{ errorText }}
+ </div>
+ </div>
+ <Bar
+ v-memo="data" v-if="data.length !== 0"
+ :data="toGraphData(data)" :options="getOptions()"
+ />
+ </div>
+ </div>
+</template>
+<style scoped>
+.main-graph {
+ height: 250px;
+}
+</style>
diff --git a/web_src/js/components/ScopedAccessTokenSelector.vue b/web_src/js/components/ScopedAccessTokenSelector.vue
new file mode 100644
index 0000000..925e531
--- /dev/null
+++ b/web_src/js/components/ScopedAccessTokenSelector.vue
@@ -0,0 +1,115 @@
+<script>
+import {createApp} from 'vue';
+import {hideElem, showElem} from '../utils/dom.js';
+
+const sfc = {
+ props: {
+ isAdmin: {
+ type: Boolean,
+ required: true,
+ },
+ noAccessLabel: {
+ type: String,
+ required: true,
+ },
+ readLabel: {
+ type: String,
+ required: true,
+ },
+ writeLabel: {
+ type: String,
+ required: true,
+ },
+ },
+
+ computed: {
+ categories() {
+ const categories = [
+ 'activitypub',
+ ];
+ if (this.isAdmin) {
+ categories.push('admin');
+ }
+ categories.push(
+ 'issue',
+ 'misc',
+ 'notification',
+ 'organization',
+ 'package',
+ 'repository',
+ 'user');
+ return categories;
+ },
+ },
+
+ mounted() {
+ document.getElementById('scoped-access-submit').addEventListener('click', this.onClickSubmit);
+ },
+
+ unmounted() {
+ document.getElementById('scoped-access-submit').removeEventListener('click', this.onClickSubmit);
+ },
+
+ methods: {
+ onClickSubmit(e) {
+ e.preventDefault();
+
+ const warningEl = document.getElementById('scoped-access-warning');
+ // check that at least one scope has been selected
+ for (const el of document.getElementsByClassName('access-token-select')) {
+ if (el.value) {
+ // Hide the error if it was visible from previous attempt.
+ hideElem(warningEl);
+ // Submit the form.
+ document.getElementById('scoped-access-form').submit();
+ // Don't show the warning.
+ return;
+ }
+ }
+ // no scopes selected, show validation error
+ showElem(warningEl);
+ },
+ },
+};
+
+export default sfc;
+
+/**
+ * Initialize category toggle sections
+ */
+export function initScopedAccessTokenCategories() {
+ for (const el of document.getElementsByClassName('scoped-access-token')) {
+ createApp(sfc, {
+ isAdmin: el.getAttribute('data-is-admin') === 'true',
+ noAccessLabel: el.getAttribute('data-no-access-label'),
+ readLabel: el.getAttribute('data-read-label'),
+ writeLabel: el.getAttribute('data-write-label'),
+ }).mount(el);
+ }
+}
+
+</script>
+<template>
+ <div v-for="category in categories" :key="category" class="field tw-pl-1 tw-pb-1 access-token-category">
+ <label class="category-label" :for="'access-token-scope-' + category">
+ {{ category }}
+ </label>
+ <div class="gitea-select">
+ <select
+ class="ui selection access-token-select"
+ name="scope"
+ :id="'access-token-scope-' + category"
+ >
+ <option value="">
+ {{ noAccessLabel }}
+ </option>
+ <option :value="'read:' + category">
+ {{ readLabel }}
+ </option>
+ <option :value="'write:' + category">
+ {{ writeLabel }}
+ </option>
+ </select>
+ </div>
+ </div>
+</template>
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..9938d53
--- /dev/null
+++ b/web_src/js/features/repo-issue.js
@@ -0,0 +1,797 @@
+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',
+ });
+ }
+ }
+ } else if (window.history.scrollRestoration === 'manual') {
+ // reset scrollRestoration to 'auto' if there is no hash in url and we set it to 'manual' before
+ window.history.scrollRestoration = 'auto';
+ }
+
+ $(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);
+}
diff --git a/web_src/js/htmx.js b/web_src/js/htmx.js
new file mode 100644
index 0000000..5ca3018
--- /dev/null
+++ b/web_src/js/htmx.js
@@ -0,0 +1,21 @@
+import * as htmx from 'htmx.org';
+import {showErrorToast} from './modules/toast.js';
+
+// https://github.com/bigskysoftware/idiomorph#htmx
+import 'idiomorph/dist/idiomorph-ext.js';
+
+// https://htmx.org/reference/#config
+htmx.config.requestClass = 'is-loading';
+htmx.config.scrollIntoViewOnBoost = false;
+
+// https://htmx.org/events/#htmx:sendError
+document.body.addEventListener('htmx:sendError', (event) => {
+ // TODO: add translations
+ showErrorToast(`Network error when calling ${event.detail.requestConfig.path}`);
+});
+
+// https://htmx.org/events/#htmx:responseError
+document.body.addEventListener('htmx:responseError', (event) => {
+ // TODO: add translations
+ showErrorToast(`Error ${event.detail.xhr.status} when calling ${event.detail.requestConfig.path}`);
+});
diff --git a/web_src/js/index.js b/web_src/js/index.js
new file mode 100644
index 0000000..77014a7
--- /dev/null
+++ b/web_src/js/index.js
@@ -0,0 +1,190 @@
+// bootstrap module must be the first one to be imported, it handles webpack lazy-loading and global errors
+import './bootstrap.js';
+
+import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue';
+import {initScopedAccessTokenCategories} from './components/ScopedAccessTokenSelector.vue';
+import {initDashboardRepoList} from './components/DashboardRepoList.vue';
+
+import {initGlobalCopyToClipboardListener} from './features/clipboard.js';
+import {initContextPopups} from './features/contextpopup.js';
+import {initRepoGraphGit} from './features/repo-graph.js';
+import {initHeatmap} from './features/heatmap.js';
+import {initImageDiff} from './features/imagediff.js';
+import {initRepoMigration} from './features/repo-migration.js';
+import {initRepoProject} from './features/repo-projects.js';
+import {initTableSort} from './features/tablesort.js';
+import {initAutoFocusEnd} from './features/autofocus-end.js';
+import {initAdminUserListSearchForm} from './features/admin/users.js';
+import {initAdminConfigs} from './features/admin/config.js';
+import {initMarkupAnchors} from './markup/anchors.js';
+import {initNotificationCount, initNotificationsTable} from './features/notification.js';
+import {initRepoIssueContentHistory} from './features/repo-issue-content.js';
+import {initStopwatch} from './features/stopwatch.js';
+import {initFindFileInRepo} from './features/repo-findfile.js';
+import {initCommentContent, initMarkupContent} from './markup/content.js';
+import {initPdfViewer} from './render/pdf.js';
+
+import {initUserAuthOauth2} from './features/user-auth.js';
+import {
+ initRepoIssueDue,
+ initRepoIssueReferenceRepositorySearch,
+ initRepoIssueTimeTracking,
+ initRepoIssueWipTitle,
+ initRepoPullRequestAllowMaintainerEdit,
+ initRepoPullRequestReview, initRepoIssueSidebarList, initArchivedLabelHandler,
+} from './features/repo-issue.js';
+import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.js';
+import {
+ initFootLanguageMenu,
+ initGlobalButtonClickOnEnter,
+ initGlobalButtons,
+ initGlobalCommon,
+ initGlobalDropzone,
+ initGlobalEnterQuickSubmit,
+ initGlobalFormDirtyLeaveConfirm,
+ initGlobalLinkActions,
+ initHeadNavbarContentToggle,
+} from './features/common-global.js';
+import {initRepoTopicBar} from './features/repo-home.js';
+import {initAdminEmails} from './features/admin/emails.js';
+import {initAdminCommon} from './features/admin/common.js';
+import {initRepoTemplateSearch} from './features/repo-template.js';
+import {initRepoCodeView} from './features/repo-code.js';
+import {initSshKeyFormParser} from './features/sshkey-helper.js';
+import {initUserSettings} from './features/user-settings.js';
+import {initRepoArchiveLinks} from './features/repo-common.js';
+import {initRepoMigrationStatusChecker} from './features/repo-migrate.js';
+import {
+ initRepoSettingGitHook,
+ initRepoSettingsCollaboration,
+ initRepoSettingSearchTeamBox,
+} from './features/repo-settings.js';
+import {initRepoDiffView} from './features/repo-diff.js';
+import {initOrgTeamSearchRepoBox} from './features/org-team.js';
+import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.js';
+import {initRepoRelease, initRepoReleaseNew} from './features/repo-release.js';
+import {initRepoEditor} from './features/repo-editor.js';
+import {initCompSearchUserBox} from './features/comp/SearchUserBox.js';
+import {initInstall} from './features/install.js';
+import {initCompWebHookEditor} from './features/comp/WebHookEditor.js';
+import {initRepoBranchButton} from './features/repo-branch.js';
+import {initCommonOrganization} from './features/common-organization.js';
+import {initRepoWikiForm} from './features/repo-wiki.js';
+import {initRepoCommentForm, initRepository} from './features/repo-legacy.js';
+import {initCopyContent} from './features/copycontent.js';
+import {initCaptcha} from './features/captcha.js';
+import {initRepositoryActionView} from './components/RepoActionView.vue';
+import {initGlobalTooltips} from './modules/tippy.js';
+import {initGiteaFomantic} from './modules/fomantic.js';
+import {onDomReady} from './utils/dom.js';
+import {initRepoIssueList} from './features/repo-issue-list.js';
+import {initCommonIssueListQuickGoto} from './features/common-issue-list.js';
+import {initRepoContributors} from './features/contributors.js';
+import {initRepoCodeFrequency} from './features/code-frequency.js';
+import {initRepoRecentCommits} from './features/recent-commits.js';
+import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js';
+import {initDirAuto} from './modules/dirauto.js';
+import {initRepositorySearch} from './features/repo-search.js';
+import {initColorPickers} from './features/colorpicker.js';
+
+// Init Gitea's Fomantic settings
+initGiteaFomantic();
+initDirAuto();
+
+onDomReady(() => {
+ initGlobalCommon();
+
+ initGlobalTooltips();
+ initGlobalButtonClickOnEnter();
+ initGlobalButtons();
+ initGlobalCopyToClipboardListener();
+ initGlobalDropzone();
+ initGlobalEnterQuickSubmit();
+ initGlobalFormDirtyLeaveConfirm();
+ initGlobalLinkActions();
+
+ initCommonOrganization();
+ initCommonIssueListQuickGoto();
+
+ initCompSearchUserBox();
+ initCompWebHookEditor();
+
+ initInstall();
+
+ initHeadNavbarContentToggle();
+ initFootLanguageMenu();
+
+ initCommentContent();
+ initContextPopups();
+ initHeatmap();
+ initImageDiff();
+ initMarkupAnchors();
+ initMarkupContent();
+ initSshKeyFormParser();
+ initStopwatch();
+ initTableSort();
+ initAutoFocusEnd();
+ initFindFileInRepo();
+ initCopyContent();
+
+ initAdminCommon();
+ initAdminEmails();
+ initAdminUserListSearchForm();
+ initAdminConfigs();
+
+ initDashboardRepoList();
+
+ initNotificationCount();
+ initNotificationsTable();
+
+ initOrgTeamSearchRepoBox();
+
+ initRepoActivityTopAuthorsChart();
+ initRepoArchiveLinks();
+ initRepoBranchButton();
+ initRepoCodeView();
+ initRepoCommentForm();
+ initRepoEllipsisButton();
+ initRepoDiffCommitBranchesAndTags();
+ initRepoEditor();
+ initRepoGraphGit();
+ initRepoIssueContentHistory();
+ initRepoIssueDue();
+ initRepoIssueList();
+ initRepoIssueSidebarList();
+ initArchivedLabelHandler();
+ initRepoIssueReferenceRepositorySearch();
+ initRepoIssueTimeTracking();
+ initRepoIssueWipTitle();
+ initRepoMigration();
+ initRepoMigrationStatusChecker();
+ initRepoProject();
+ initRepoPullRequestAllowMaintainerEdit();
+ initRepoPullRequestReview();
+ initRepoRelease();
+ initRepoReleaseNew();
+ initRepoSettingGitHook();
+ initRepoSettingSearchTeamBox();
+ initRepoSettingsCollaboration();
+ initRepoTemplateSearch();
+ initRepoTopicBar();
+ initRepoWikiForm();
+ initRepository();
+ initRepositoryActionView();
+ initRepositorySearch();
+ initRepoContributors();
+ initRepoCodeFrequency();
+ initRepoRecentCommits();
+
+ initCommitStatuses();
+ initCaptcha();
+
+ initUserAuthOauth2();
+ initUserAuthWebAuthn();
+ initUserAuthWebAuthnRegister();
+ initUserSettings();
+ initRepoDiffView();
+ initPdfViewer();
+ initScopedAccessTokenCategories();
+ initColorPickers();
+});
diff --git a/web_src/js/jquery.js b/web_src/js/jquery.js
new file mode 100644
index 0000000..6b21998
--- /dev/null
+++ b/web_src/js/jquery.js
@@ -0,0 +1,3 @@
+import $ from 'jquery';
+
+window.$ = window.jQuery = $; // eslint-disable-line no-jquery/variable-pattern
diff --git a/web_src/js/markup/anchors.js b/web_src/js/markup/anchors.js
new file mode 100644
index 0000000..0e2c927
--- /dev/null
+++ b/web_src/js/markup/anchors.js
@@ -0,0 +1,70 @@
+import {svg} from '../svg.js';
+
+const addPrefix = (str) => `user-content-${str}`;
+const removePrefix = (str) => str.replace(/^user-content-/, '');
+const hasPrefix = (str) => str.startsWith('user-content-');
+
+// scroll to anchor while respecting the `user-content` prefix that exists on the target
+function scrollToAnchor(encodedId) {
+ if (!encodedId) return;
+ const id = decodeURIComponent(encodedId);
+ const prefixedId = addPrefix(id);
+ let el = document.getElementById(prefixedId);
+
+ // check for matching user-generated `a[name]`
+ if (!el) {
+ const nameAnchors = document.getElementsByName(prefixedId);
+ if (nameAnchors.length) {
+ el = nameAnchors[0];
+ }
+ }
+
+ // compat for links with old 'user-content-' prefixed hashes
+ if (!el && hasPrefix(id)) {
+ return document.getElementById(id)?.scrollIntoView();
+ }
+
+ el?.scrollIntoView();
+}
+
+export function initMarkupAnchors() {
+ const markupEls = document.querySelectorAll('.markup');
+ if (!markupEls.length) return;
+
+ for (const markupEl of markupEls) {
+ // create link icons for markup headings, the resulting link href will remove `user-content-`
+ for (const heading of markupEl.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
+ const a = document.createElement('a');
+ a.classList.add('anchor');
+ a.setAttribute('href', `#${encodeURIComponent(removePrefix(heading.id))}`);
+ a.innerHTML = svg('octicon-link');
+ heading.prepend(a);
+ }
+
+ // remove `user-content-` prefix from links so they don't show in url bar when clicked
+ for (const a of markupEl.querySelectorAll('a[href^="#"]')) {
+ const href = a.getAttribute('href');
+ if (!href.startsWith('#user-content-')) continue;
+ a.setAttribute('href', `#${removePrefix(href.substring(1))}`);
+ }
+
+ // add `user-content-` prefix to user-generated `a[name]` link targets
+ // TODO: this prefix should be added in backend instead
+ for (const a of markupEl.querySelectorAll('a[name]')) {
+ const name = a.getAttribute('name');
+ if (!name) continue;
+ a.setAttribute('name', addPrefix(a.name));
+ }
+
+ for (const a of markupEl.querySelectorAll('a[href^="#"]')) {
+ a.addEventListener('click', (e) => {
+ scrollToAnchor(e.currentTarget.getAttribute('href')?.substring(1));
+ });
+ }
+ }
+
+ // scroll to anchor unless the browser has already scrolled somewhere during page load
+ if (!document.querySelector(':target')) {
+ scrollToAnchor(window.location.hash?.substring(1));
+ }
+}
diff --git a/web_src/js/markup/asciicast.js b/web_src/js/markup/asciicast.js
new file mode 100644
index 0000000..97b1874
--- /dev/null
+++ b/web_src/js/markup/asciicast.js
@@ -0,0 +1,17 @@
+export async function renderAsciicast() {
+ const els = document.querySelectorAll('.asciinema-player-container');
+ if (!els.length) return;
+
+ const [player] = await Promise.all([
+ import(/* webpackChunkName: "asciinema-player" */'asciinema-player'),
+ import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'),
+ ]);
+
+ for (const el of els) {
+ player.create(el.getAttribute('data-asciinema-player-src'), el, {
+ // poster (a preview frame) to display until the playback is started.
+ // Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more.
+ poster: 'npt:1:0:0',
+ });
+ }
+}
diff --git a/web_src/js/markup/codecopy.js b/web_src/js/markup/codecopy.js
new file mode 100644
index 0000000..078d741
--- /dev/null
+++ b/web_src/js/markup/codecopy.js
@@ -0,0 +1,21 @@
+import {svg} from '../svg.js';
+
+export function makeCodeCopyButton() {
+ const button = document.createElement('button');
+ button.classList.add('code-copy', 'ui', 'button');
+ button.innerHTML = svg('octicon-copy');
+ return button;
+}
+
+export function renderCodeCopy() {
+ const els = document.querySelectorAll('.markup .code-block code');
+ if (!els.length) return;
+
+ for (const el of els) {
+ if (!el.textContent) continue;
+ const btn = makeCodeCopyButton();
+ // remove final trailing newline introduced during HTML rendering
+ btn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, ''));
+ el.after(btn);
+ }
+}
diff --git a/web_src/js/markup/common.js b/web_src/js/markup/common.js
new file mode 100644
index 0000000..aff4a32
--- /dev/null
+++ b/web_src/js/markup/common.js
@@ -0,0 +1,8 @@
+export function displayError(el, err) {
+ el.classList.remove('is-loading');
+ const errorNode = document.createElement('pre');
+ errorNode.setAttribute('class', 'ui message error markup-block-error');
+ errorNode.textContent = err.str || err.message || String(err);
+ el.before(errorNode);
+ el.setAttribute('data-render-done', 'true');
+}
diff --git a/web_src/js/markup/content.js b/web_src/js/markup/content.js
new file mode 100644
index 0000000..1d29dc0
--- /dev/null
+++ b/web_src/js/markup/content.js
@@ -0,0 +1,18 @@
+import {renderMermaid} from './mermaid.js';
+import {renderMath} from './math.js';
+import {renderCodeCopy} from './codecopy.js';
+import {renderAsciicast} from './asciicast.js';
+import {initMarkupTasklist} from './tasklist.js';
+
+// code that runs for all markup content
+export function initMarkupContent() {
+ renderMermaid();
+ renderMath();
+ renderCodeCopy();
+ renderAsciicast();
+}
+
+// code that only runs for comments
+export function initCommentContent() {
+ initMarkupTasklist();
+}
diff --git a/web_src/js/markup/math.js b/web_src/js/markup/math.js
new file mode 100644
index 0000000..872e50a
--- /dev/null
+++ b/web_src/js/markup/math.js
@@ -0,0 +1,47 @@
+import {displayError} from './common.js';
+
+function targetElement(el) {
+ // The target element is either the current element if it has the
+ // `is-loading` class or the pre that contains it
+ return el.classList.contains('is-loading') ? el : el.closest('pre');
+}
+
+export async function renderMath() {
+ const els = document.querySelectorAll('.markup code.language-math');
+ if (!els.length) return;
+
+ const [{default: katex}] = await Promise.all([
+ import(/* webpackChunkName: "katex" */'katex'),
+ import(/* webpackChunkName: "katex" */'katex/dist/katex.css'),
+ ]);
+
+ const MAX_CHARS = 1000;
+ const MAX_SIZE = 25;
+ const MAX_EXPAND = 1000;
+
+ for (const el of els) {
+ const target = targetElement(el);
+ if (target.hasAttribute('data-render-done')) continue;
+ const source = el.textContent;
+
+ if (source.length > MAX_CHARS) {
+ displayError(target, new Error(`Math source of ${source.length} characters exceeds the maximum allowed length of ${MAX_CHARS}.`));
+ continue;
+ }
+
+ const displayMode = el.classList.contains('display');
+ const nodeName = displayMode ? 'p' : 'span';
+
+ try {
+ const tempEl = document.createElement(nodeName);
+ katex.render(source, tempEl, {
+ maxSize: MAX_SIZE,
+ maxExpand: MAX_EXPAND,
+ displayMode,
+ });
+ target.replaceWith(tempEl);
+ } catch (error) {
+ displayError(target, error);
+ }
+ }
+}
diff --git a/web_src/js/markup/mermaid.js b/web_src/js/markup/mermaid.js
new file mode 100644
index 0000000..0549fb3
--- /dev/null
+++ b/web_src/js/markup/mermaid.js
@@ -0,0 +1,74 @@
+import {isDarkTheme} from '../utils.js';
+import {makeCodeCopyButton} from './codecopy.js';
+import {displayError} from './common.js';
+
+const {mermaidMaxSourceCharacters} = window.config;
+
+// margin removal is for https://github.com/mermaid-js/mermaid/issues/4907
+const iframeCss = `:root {color-scheme: normal}
+body {margin: 0; padding: 0; overflow: hidden}
+#mermaid {display: block; margin: 0 auto}
+blockquote, dd, dl, figure, h1, h2, h3, h4, h5, h6, hr, p, pre {margin: 0}`;
+
+export async function renderMermaid() {
+ const els = document.querySelectorAll('.markup code.language-mermaid');
+ if (!els.length) return;
+
+ const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid');
+
+ mermaid.initialize({
+ startOnLoad: false,
+ theme: isDarkTheme() ? 'dark' : 'neutral',
+ securityLevel: 'strict',
+ });
+
+ for (const el of els) {
+ const pre = el.closest('pre');
+ if (pre.hasAttribute('data-render-done')) continue;
+
+ const source = el.textContent;
+ if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) {
+ displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`));
+ continue;
+ }
+
+ try {
+ await mermaid.parse(source);
+ } catch (err) {
+ displayError(pre, err);
+ continue;
+ }
+
+ try {
+ // can't use bindFunctions here because we can't cross the iframe boundary. This
+ // means js-based interactions won't work but they aren't intended to work either
+ const {svg} = await mermaid.render('mermaid', source);
+
+ const iframe = document.createElement('iframe');
+ iframe.classList.add('markup-render', 'tw-invisible');
+ iframe.srcdoc = `<html><head><style>${iframeCss}</style></head><body>${svg}</body></html>`;
+
+ const mermaidBlock = document.createElement('div');
+ mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden');
+ mermaidBlock.append(iframe);
+
+ const btn = makeCodeCopyButton();
+ btn.setAttribute('data-clipboard-text', source);
+ mermaidBlock.append(btn);
+
+ iframe.addEventListener('load', () => {
+ pre.replaceWith(mermaidBlock);
+ mermaidBlock.classList.remove('tw-hidden');
+ iframe.style.height = `${iframe.contentWindow.document.body.clientHeight}px`;
+ setTimeout(() => { // avoid flash of iframe background
+ mermaidBlock.classList.remove('is-loading');
+ iframe.classList.remove('tw-invisible');
+ }, 0);
+ });
+
+ document.body.append(mermaidBlock);
+ } catch (err) {
+ displayError(pre, err);
+ }
+ }
+}
diff --git a/web_src/js/markup/tasklist.js b/web_src/js/markup/tasklist.js
new file mode 100644
index 0000000..375810d
--- /dev/null
+++ b/web_src/js/markup/tasklist.js
@@ -0,0 +1,90 @@
+import {POST} from '../modules/fetch.js';
+import {showErrorToast} from '../modules/toast.js';
+
+const preventListener = (e) => e.preventDefault();
+
+/**
+ * Attaches `input` handlers to markdown rendered tasklist checkboxes in comments.
+ *
+ * When a checkbox value changes, the corresponding [ ] or [x] in the markdown string
+ * is set accordingly and sent to the server. On success it updates the raw-content on
+ * error it resets the checkbox to its original value.
+ */
+export function initMarkupTasklist() {
+ for (const el of document.querySelectorAll(`.markup[data-can-edit=true]`) || []) {
+ const container = el.parentNode;
+ const checkboxes = el.querySelectorAll(`.task-list-item input[type=checkbox]`);
+
+ for (const checkbox of checkboxes) {
+ if (checkbox.hasAttribute('data-editable')) {
+ return;
+ }
+
+ checkbox.setAttribute('data-editable', 'true');
+ checkbox.addEventListener('input', async () => {
+ const checkboxCharacter = checkbox.checked ? 'x' : ' ';
+ const position = parseInt(checkbox.getAttribute('data-source-position')) + 1;
+
+ const rawContent = container.querySelector('.raw-content');
+ const oldContent = rawContent.textContent;
+
+ const encoder = new TextEncoder();
+ const buffer = encoder.encode(oldContent);
+ // Indexes may fall off the ends and return undefined.
+ if (buffer[position - 1] !== '['.codePointAt(0) ||
+ buffer[position] !== ' '.codePointAt(0) && buffer[position] !== 'x'.codePointAt(0) && buffer[position] !== 'X'.codePointAt(0) ||
+ buffer[position + 1] !== ']'.codePointAt(0)) {
+ // Position is probably wrong. Revert and don't allow change.
+ checkbox.checked = !checkbox.checked;
+ throw new Error(`Expected position to be space, x or X and surrounded by brackets, but it's not: position=${position}`);
+ }
+ buffer.set(encoder.encode(checkboxCharacter), position);
+ const newContent = new TextDecoder().decode(buffer);
+
+ if (newContent === oldContent) {
+ return;
+ }
+
+ // Prevent further inputs until the request is done. This does not use the
+ // `disabled` attribute because it causes the border to flash on click.
+ for (const checkbox of checkboxes) {
+ checkbox.addEventListener('click', preventListener);
+ }
+
+ try {
+ const editContentZone = container.querySelector('.edit-content-zone');
+ const updateUrl = editContentZone.getAttribute('data-update-url');
+ const context = editContentZone.getAttribute('data-context');
+ const contentVersion = editContentZone.getAttribute('data-content-version');
+
+ const requestBody = new FormData();
+ requestBody.append('ignore_attachments', 'true');
+ requestBody.append('content', newContent);
+ requestBody.append('context', context);
+ requestBody.append('content_version', contentVersion);
+ const response = await POST(updateUrl, {data: requestBody});
+ const data = await response.json();
+ if (response.status === 400) {
+ showErrorToast(data.errorMessage);
+ return;
+ }
+ editContentZone.setAttribute('data-content-version', data.contentVersion);
+ rawContent.textContent = newContent;
+ } catch (err) {
+ checkbox.checked = !checkbox.checked;
+ console.error(err);
+ }
+
+ // Enable input on checkboxes again
+ for (const checkbox of checkboxes) {
+ checkbox.removeEventListener('click', preventListener);
+ }
+ });
+ }
+
+ // Enable the checkboxes as they are initially disabled by the markdown renderer
+ for (const checkbox of checkboxes) {
+ checkbox.disabled = false;
+ }
+ }
+}
diff --git a/web_src/js/modules/dirauto.js b/web_src/js/modules/dirauto.js
new file mode 100644
index 0000000..cd90f81
--- /dev/null
+++ b/web_src/js/modules/dirauto.js
@@ -0,0 +1,40 @@
+import {isDocumentFragmentOrElementNode} from '../utils/dom.js';
+
+// for performance considerations, it only uses performant syntax
+function attachDirAuto(el) {
+ if (el.type !== 'hidden' &&
+ el.type !== 'checkbox' &&
+ el.type !== 'radio' &&
+ el.type !== 'range' &&
+ el.type !== 'color') {
+ el.dir = 'auto';
+ }
+}
+
+export function initDirAuto() {
+ const observer = new MutationObserver((mutationList) => {
+ const len = mutationList.length;
+ for (let i = 0; i < len; i++) {
+ const mutation = mutationList[i];
+ const len = mutation.addedNodes.length;
+ for (let i = 0; i < len; i++) {
+ const addedNode = mutation.addedNodes[i];
+ if (!isDocumentFragmentOrElementNode(addedNode)) continue;
+ if (addedNode.nodeName === 'INPUT' || addedNode.nodeName === 'TEXTAREA') attachDirAuto(addedNode);
+ const children = addedNode.querySelectorAll('input, textarea');
+ const len = children.length;
+ for (let childIdx = 0; childIdx < len; childIdx++) {
+ attachDirAuto(children[childIdx]);
+ }
+ }
+ }
+ });
+
+ const docNodes = document.querySelectorAll('input, textarea');
+ const len = docNodes.length;
+ for (let i = 0; i < len; i++) {
+ attachDirAuto(docNodes[i]);
+ }
+
+ observer.observe(document, {subtree: true, childList: true});
+}
diff --git a/web_src/js/modules/fetch.js b/web_src/js/modules/fetch.js
new file mode 100644
index 0000000..2191a8d
--- /dev/null
+++ b/web_src/js/modules/fetch.js
@@ -0,0 +1,41 @@
+import {isObject} from '../utils.js';
+
+const {csrfToken} = window.config;
+
+// safe HTTP methods that don't need a csrf token
+const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
+
+// fetch wrapper, use below method name functions and the `data` option to pass in data
+// which will automatically set an appropriate headers. For json content, only object
+// and array types are currently supported.
+export function request(url, {method = 'GET', data, headers = {}, ...other} = {}) {
+ let body, contentType;
+ if (data instanceof FormData || data instanceof URLSearchParams) {
+ body = data;
+ } else if (isObject(data) || Array.isArray(data)) {
+ contentType = 'application/json';
+ body = JSON.stringify(data);
+ }
+
+ const headersMerged = new Headers({
+ ...(!safeMethods.has(method) && {'x-csrf-token': csrfToken}),
+ ...(contentType && {'content-type': contentType}),
+ });
+
+ for (const [name, value] of Object.entries(headers)) {
+ headersMerged.set(name, value);
+ }
+
+ return fetch(url, {
+ method,
+ headers: headersMerged,
+ ...other,
+ ...(body && {body}),
+ });
+}
+
+export const GET = (url, opts) => request(url, {method: 'GET', ...opts});
+export const POST = (url, opts) => request(url, {method: 'POST', ...opts});
+export const PATCH = (url, opts) => request(url, {method: 'PATCH', ...opts});
+export const PUT = (url, opts) => request(url, {method: 'PUT', ...opts});
+export const DELETE = (url, opts) => request(url, {method: 'DELETE', ...opts});
diff --git a/web_src/js/modules/fetch.test.js b/web_src/js/modules/fetch.test.js
new file mode 100644
index 0000000..e4bec3c
--- /dev/null
+++ b/web_src/js/modules/fetch.test.js
@@ -0,0 +1,10 @@
+import {GET, POST, PATCH, PUT, DELETE} from './fetch.js';
+
+// tests here are only to satisfy the linter for unused functions
+test('exports', () => {
+ expect(GET).toBeTruthy();
+ expect(POST).toBeTruthy();
+ expect(PATCH).toBeTruthy();
+ expect(PUT).toBeTruthy();
+ expect(DELETE).toBeTruthy();
+});
diff --git a/web_src/js/modules/fomantic.js b/web_src/js/modules/fomantic.js
new file mode 100644
index 0000000..c04bc6e
--- /dev/null
+++ b/web_src/js/modules/fomantic.js
@@ -0,0 +1,34 @@
+import $ from 'jquery';
+import {initFomanticApiPatch} from './fomantic/api.js';
+import {initAriaCheckboxPatch} from './fomantic/checkbox.js';
+import {initAriaFormFieldPatch} from './fomantic/form.js';
+import {initAriaDropdownPatch} from './fomantic/dropdown.js';
+import {initAriaModalPatch} from './fomantic/modal.js';
+import {initFomanticTransition} from './fomantic/transition.js';
+import {svg} from '../svg.js';
+
+export const fomanticMobileScreen = window.matchMedia('only screen and (max-width: 767.98px)');
+
+export function initGiteaFomantic() {
+ // Silence fomantic's error logging when tabs are used without a target content element
+ $.fn.tab.settings.silent = true;
+
+ // By default, use "exact match" for full text search
+ $.fn.dropdown.settings.fullTextSearch = 'exact';
+ // Do not use "cursor: pointer" for dropdown labels
+ $.fn.dropdown.settings.className.label += ' tw-cursor-default';
+ // Always use Gitea's SVG icons
+ $.fn.dropdown.settings.templates.label = function(_value, text, preserveHTML, className) {
+ const escape = $.fn.dropdown.settings.templates.escape;
+ return escape(text, preserveHTML) + svg('octicon-x', 16, `${className.delete} icon`);
+ };
+
+ initFomanticTransition();
+ initFomanticApiPatch();
+
+ // Use the patches to improve accessibility, these patches are designed to be as independent as possible, make it easy to modify or remove in the future.
+ initAriaCheckboxPatch();
+ initAriaFormFieldPatch();
+ initAriaDropdownPatch();
+ initAriaModalPatch();
+}
diff --git a/web_src/js/modules/fomantic/api.js b/web_src/js/modules/fomantic/api.js
new file mode 100644
index 0000000..ca212c9
--- /dev/null
+++ b/web_src/js/modules/fomantic/api.js
@@ -0,0 +1,40 @@
+import $ from 'jquery';
+
+export function initFomanticApiPatch() {
+ //
+ // Fomantic API module has some very buggy behaviors:
+ //
+ // If encodeParameters=true, it calls `urlEncodedValue` to encode the parameter.
+ // However, `urlEncodedValue` just tries to "guess" whether the parameter is already encoded, by decoding the parameter and encoding it again.
+ //
+ // There are 2 problems:
+ // 1. It may guess wrong, and skip encoding a parameter which looks like encoded.
+ // 2. If the parameter can't be decoded, `decodeURIComponent` will throw an error, and the whole request will fail.
+ //
+ // This patch only fixes the second error behavior at the moment.
+ //
+ const patchKey = '_giteaFomanticApiPatch';
+ const oldApi = $.api;
+ $.api = $.fn.api = function(...args) {
+ const apiCall = oldApi.bind(this);
+ const ret = oldApi.apply(this, args);
+
+ if (typeof args[0] !== 'string') {
+ const internalGet = apiCall('internal', 'get');
+ if (!internalGet.urlEncodedValue[patchKey]) {
+ const oldUrlEncodedValue = internalGet.urlEncodedValue;
+ internalGet.urlEncodedValue = function (value) {
+ try {
+ return oldUrlEncodedValue(value);
+ } catch {
+ // if Fomantic API module's `urlEncodedValue` throws an error, we encode it by ourselves.
+ return encodeURIComponent(value);
+ }
+ };
+ internalGet.urlEncodedValue[patchKey] = true;
+ }
+ }
+ return ret;
+ };
+ $.api.settings = oldApi.settings;
+}
diff --git a/web_src/js/modules/fomantic/aria.md b/web_src/js/modules/fomantic/aria.md
new file mode 100644
index 0000000..5836a34
--- /dev/null
+++ b/web_src/js/modules/fomantic/aria.md
@@ -0,0 +1,117 @@
+# Background
+
+This document is used as aria/accessibility(a11y) reference for future developers.
+
+There are a lot of a11y problems in the Fomantic UI library. Files in
+`web_src/js/modules/fomantic/` are used as a workaround to make the UI more accessible.
+
+The aria-related code is designed to avoid touching the official Fomantic UI library,
+and to be as independent as possible, so it can be easily modified/removed in the future.
+
+To test the aria/accessibility with screen readers, developers can use the following steps:
+
+* On macOS, you can use VoiceOver.
+ * Press `Command + F5` to turn on VoiceOver.
+ * Try to operate the UI with keyboard-only.
+ * Use Tab/Shift+Tab to switch focus between elements.
+ * Arrow keys (Option+Up/Down) to navigate between menu/combobox items (only aria-active, not really focused).
+ * Press Enter to trigger the aria-active element.
+* On Android, you can use TalkBack.
+ * Go to Settings -> Accessibility -> TalkBack, turn it on.
+ * Long-press or press+swipe to switch the aria-active element (not really focused).
+ * Double-tap means old single-tap on the aria-active element.
+ * Double-finger swipe means old single-finger swipe.
+* TODO: on Windows, on Linux, on iOS
+
+# Known Problems
+
+* Tested with Apple VoiceOver: If a dropdown menu/combobox is opened by mouse click, then arrow keys don't work.
+ But if the dropdown is opened by keyboard Tab, then arrow keys work, and from then on, the keys almost work with mouse click too.
+ The clue: when the dropdown is only opened by mouse click, VoiceOver doesn't send 'keydown' events of arrow keys to the DOM,
+ VoiceOver expects to use arrow keys to navigate between some elements, but it couldn't.
+ Users could use Option+ArrowKeys to navigate between menu/combobox items or selection labels if the menu/combobox is opened by mouse click.
+
+# Checkbox
+
+## Accessibility-friendly Checkbox
+
+The ideal checkboxes should be:
+
+```html
+<label><input type="checkbox"> ... </label>
+```
+
+However, the templates still have the Fomantic-style HTML layout:
+
+```html
+<div class="ui checkbox">
+ <input type="checkbox">
+ <label>...</label>
+</div>
+```
+
+We call `initAriaCheckboxPatch` to link the `input` and `label` which makes clicking the
+label etc. work. There is still a problem: These checkboxes are not friendly to screen readers,
+so we add IDs to all the Fomantic UI checkboxes automatically by JS. If the `label` part is empty,
+then the checkbox needs to get the `aria-label` attribute manually.
+
+# Fomantic Dropdown
+
+Fomantic Dropdown is designed to be used for many purposes:
+
+* Menu (the profile menu in navbar, the language menu in footer)
+* Popup (the branch/tag panel, the review box)
+* Simple `<select>` , used in many forms
+* Searchable option-list with static items (used in many forms)
+* Searchable option-list with dynamic items (ajax)
+* Searchable multiple selection option-list with dynamic items: the repo topic setting
+* More complex usages, like the Issue Label selector
+
+Fomantic Dropdown requires that the focus must be on its primary element.
+If the focus changes, it hides or panics.
+
+At the moment, the aria-related code only tries to partially resolve the a11y problems for dropdowns with items.
+
+There are different solutions:
+
+* combobox + listbox + option:
+ * https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
+ * A combobox is an input widget with an associated popup that enables users to select a value for the combobox from
+ a collection of possible values. In some implementations, the popup presents allowed values, while in other implementations,
+ the popup presents suggested values, and users may either select one of the suggestions or type a value.
+* menu + menuitem:
+ * https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
+ * A menu is a widget that offers a list of choices to the user, such as a set of actions or functions.
+
+The current approach is: detect if the dropdown has an input,
+if yes, it works like a combobox, otherwise it works like a menu.
+Multiple selection dropdown is not well-supported yet, it needs more work.
+
+Some important pages for dropdown testing:
+
+* Home(dashboard) page, the "Create Repo" / "Profile" / "Language" menu.
+* Create New Repo page, a lot of dropdowns as combobox.
+* Collaborators page, the "permission" dropdown (the old behavior was not quite good, it just works).
+
+```html
+<!-- read-only dropdown -->
+<div class="ui dropdown"> <!-- focused here, then it's not perfect to use aria-activedescendant to point to the menu item -->
+ <input type="hidden" ...>
+ <div class="text">Default</div>
+ <div class="menu" tabindex="-1"> <!-- "transition hidden|visible" classes will be added by $.dropdown() and when the dropdown is working -->
+ <div class="item active selected">Default</div>
+ <div class="item">...</div>
+ </div>
+</div>
+
+<!-- search input dropdown -->
+<div class="ui dropdown">
+ <input type="hidden" ...>
+ <input class="search" autocomplete="off" tabindex="0"> <!-- focused here -->
+ <div class="text"></div>
+ <div class="menu" tabindex="-1"> <!-- "transition hidden|visible" classes will be added by $.dropdown() and when the dropdown is working -->
+ <div class="item selected">...</div>
+ <div class="item">...</div>
+ </div>
+</div>
+```
diff --git a/web_src/js/modules/fomantic/base.js b/web_src/js/modules/fomantic/base.js
new file mode 100644
index 0000000..7574fdd
--- /dev/null
+++ b/web_src/js/modules/fomantic/base.js
@@ -0,0 +1,18 @@
+let ariaIdCounter = 0;
+
+export function generateAriaId() {
+ return `_aria_auto_id_${ariaIdCounter++}`;
+}
+
+export function linkLabelAndInput(label, input) {
+ const labelFor = label.getAttribute('for');
+ const inputId = input.getAttribute('id');
+
+ if (inputId && !labelFor) { // missing "for"
+ label.setAttribute('for', inputId);
+ } else if (!inputId && !labelFor) { // missing both "id" and "for"
+ const id = generateAriaId();
+ input.setAttribute('id', id);
+ label.setAttribute('for', id);
+ }
+}
diff --git a/web_src/js/modules/fomantic/checkbox.js b/web_src/js/modules/fomantic/checkbox.js
new file mode 100644
index 0000000..ed77406
--- /dev/null
+++ b/web_src/js/modules/fomantic/checkbox.js
@@ -0,0 +1,13 @@
+import {linkLabelAndInput} from './base.js';
+
+export function initAriaCheckboxPatch() {
+ // link the label and the input element so it's clickable and accessible
+ for (const el of document.querySelectorAll('.ui.checkbox')) {
+ if (el.hasAttribute('data-checkbox-patched')) continue;
+ const label = el.querySelector('label');
+ const input = el.querySelector('input');
+ if (!label || !input) continue;
+ linkLabelAndInput(label, input);
+ el.setAttribute('data-checkbox-patched', 'true');
+ }
+}
diff --git a/web_src/js/modules/fomantic/dropdown.js b/web_src/js/modules/fomantic/dropdown.js
new file mode 100644
index 0000000..82e7108
--- /dev/null
+++ b/web_src/js/modules/fomantic/dropdown.js
@@ -0,0 +1,256 @@
+import $ from 'jquery';
+import {generateAriaId} from './base.js';
+
+const ariaPatchKey = '_giteaAriaPatchDropdown';
+const fomanticDropdownFn = $.fn.dropdown;
+
+// use our own `$().dropdown` function to patch Fomantic's dropdown module
+export function initAriaDropdownPatch() {
+ if ($.fn.dropdown === ariaDropdownFn) throw new Error('initAriaDropdownPatch could only be called once');
+ $.fn.dropdown = ariaDropdownFn;
+ ariaDropdownFn.settings = fomanticDropdownFn.settings;
+}
+
+// the patched `$.fn.dropdown` function, it passes the arguments to Fomantic's `$.fn.dropdown` function, and:
+// * it does the one-time attaching on the first call
+// * it delegates the `onLabelCreate` to the patched `onLabelCreate` to add necessary aria attributes
+function ariaDropdownFn(...args) {
+ const ret = fomanticDropdownFn.apply(this, args);
+
+ // if the `$().dropdown()` call is without arguments, or it has non-string (object) argument,
+ // it means that this call will reset the dropdown internal settings, then we need to re-delegate the callbacks.
+ const needDelegate = (!args.length || typeof args[0] !== 'string');
+ for (const el of this) {
+ if (!el[ariaPatchKey]) {
+ attachInit(el);
+ }
+ if (needDelegate) {
+ delegateOne($(el));
+ }
+ }
+ return ret;
+}
+
+// make the item has role=option/menuitem, add an id if there wasn't one yet, make items as non-focusable
+// the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element.
+function updateMenuItem(dropdown, item) {
+ if (!item.id) item.id = generateAriaId();
+ item.setAttribute('role', dropdown[ariaPatchKey].listItemRole);
+ item.setAttribute('tabindex', '-1');
+ for (const el of item.querySelectorAll('a, input, button')) el.setAttribute('tabindex', '-1');
+}
+/**
+ * make the label item and its "delete icon" have correct aria attributes
+ * @param {HTMLElement} label
+ */
+function updateSelectionLabel(label) {
+ // the "label" is like this: "<a|div class="ui label" data-value="1">the-label-name <i|svg class="delete icon"/></a>"
+ if (!label.id) {
+ label.id = generateAriaId();
+ }
+ label.tabIndex = -1;
+
+ const deleteIcon = label.querySelector('.delete.icon');
+ if (deleteIcon) {
+ deleteIcon.setAttribute('aria-hidden', 'false');
+ deleteIcon.setAttribute('aria-label', window.config.i18n.remove_label_str.replace('%s', label.getAttribute('data-value')));
+ deleteIcon.setAttribute('role', 'button');
+ }
+}
+
+// delegate the dropdown's template functions and callback functions to add aria attributes.
+function delegateOne($dropdown) {
+ const dropdownCall = fomanticDropdownFn.bind($dropdown);
+
+ // If there is a "search input" in the "menu", Fomantic will only "focus the input" but not "toggle the menu" when the "dropdown icon" is clicked.
+ // Actually, Fomantic UI doesn't support such layout/usage. It needs to patch the "focusSearch" / "blurSearch" functions to make sure it toggles the menu.
+ const oldFocusSearch = dropdownCall('internal', 'focusSearch');
+ const oldBlurSearch = dropdownCall('internal', 'blurSearch');
+ // * If the "dropdown icon" is clicked, Fomantic calls "focusSearch", so show the menu
+ dropdownCall('internal', 'focusSearch', function () { dropdownCall('show'); oldFocusSearch.call(this) });
+ // * If the "dropdown icon" is clicked again when the menu is visible, Fomantic calls "blurSearch", so hide the menu
+ dropdownCall('internal', 'blurSearch', function () { oldBlurSearch.call(this); dropdownCall('hide') });
+
+ // the "template" functions are used for dynamic creation (eg: AJAX)
+ const dropdownTemplates = {...dropdownCall('setting', 'templates'), t: performance.now()};
+ const dropdownTemplatesMenuOld = dropdownTemplates.menu;
+ dropdownTemplates.menu = function(response, fields, preserveHTML, className) {
+ // when the dropdown menu items are loaded from AJAX requests, the items are created dynamically
+ const menuItems = dropdownTemplatesMenuOld(response, fields, preserveHTML, className);
+ const div = document.createElement('div');
+ div.innerHTML = menuItems;
+ const $wrapper = $(div);
+ const $items = $wrapper.find('> .item');
+ $items.each((_, item) => updateMenuItem($dropdown[0], item));
+ $dropdown[0][ariaPatchKey].deferredRefreshAriaActiveItem();
+ return $wrapper.html();
+ };
+ dropdownCall('setting', 'templates', dropdownTemplates);
+
+ // the `onLabelCreate` is used to add necessary aria attributes for dynamically created selection labels
+ const dropdownOnLabelCreateOld = dropdownCall('setting', 'onLabelCreate');
+ dropdownCall('setting', 'onLabelCreate', function(value, text) {
+ const $label = dropdownOnLabelCreateOld.call(this, value, text);
+ updateSelectionLabel($label[0]);
+ return $label;
+ });
+}
+
+// for static dropdown elements (generated by server-side template), prepare them with necessary aria attributes
+function attachStaticElements(dropdown, focusable, menu) {
+ // prepare static dropdown menu list popup
+ if (!menu.id) {
+ menu.id = generateAriaId();
+ }
+
+ $(menu).find('> .item').each((_, item) => updateMenuItem(dropdown, item));
+
+ // this role could only be changed after its content is ready, otherwise some browsers+readers (like Chrome+AppleVoice) crash
+ menu.setAttribute('role', dropdown[ariaPatchKey].listPopupRole);
+
+ // prepare selection label items
+ for (const label of dropdown.querySelectorAll('.ui.label')) {
+ updateSelectionLabel(label);
+ }
+
+ // make the primary element (focusable) aria-friendly
+ focusable.setAttribute('role', focusable.getAttribute('role') ?? dropdown[ariaPatchKey].focusableRole);
+ focusable.setAttribute('aria-haspopup', dropdown[ariaPatchKey].listPopupRole);
+ focusable.setAttribute('aria-controls', menu.id);
+ focusable.setAttribute('aria-expanded', 'false');
+
+ // use tooltip's content as aria-label if there is no aria-label
+ const tooltipContent = dropdown.getAttribute('data-tooltip-content');
+ if (tooltipContent && !dropdown.getAttribute('aria-label')) {
+ dropdown.setAttribute('aria-label', tooltipContent);
+ }
+}
+
+function attachInit(dropdown) {
+ dropdown[ariaPatchKey] = {};
+ if (dropdown.classList.contains('custom')) return;
+
+ // Dropdown has 2 different focusing behaviors
+ // * with search input: the input is focused, and it works with aria-activedescendant pointing another sibling element.
+ // * without search input (but the readonly text), the dropdown itself is focused. then the aria-activedescendant points to the element inside dropdown
+ // Some desktop screen readers may change the focus, but dropdown requires that the focus must be on its primary element, then they don't work well.
+
+ // Expected user interactions for dropdown with aria support:
+ // * user can use Tab to focus in the dropdown, then the dropdown menu (list) will be shown
+ // * user presses Tab on the focused dropdown to move focus to next sibling focusable element (but not the menu item)
+ // * user can use arrow key Up/Down to navigate between menu items
+ // * when user presses Enter:
+ // - if the menu item is clickable (eg: <a>), then trigger the click event
+ // - otherwise, the dropdown control (low-level code) handles the Enter event, hides the dropdown menu
+
+ // TODO: multiple selection is only partially supported. Check and test them one by one in the future.
+
+ const textSearch = dropdown.querySelector('input.search');
+ const focusable = textSearch || dropdown; // the primary element for focus, see comment above
+ if (!focusable) return;
+
+ // as a combobox, the input should not have autocomplete by default
+ if (textSearch && !textSearch.getAttribute('autocomplete')) {
+ textSearch.setAttribute('autocomplete', 'off');
+ }
+
+ let menu = $(dropdown).find('> .menu')[0];
+ if (!menu) {
+ // some "multiple selection" dropdowns don't have a static menu element in HTML, we need to pre-create it to make it have correct aria attributes
+ menu = document.createElement('div');
+ menu.classList.add('menu');
+ dropdown.append(menu);
+ }
+
+ // There are 2 possible solutions about the role: combobox or menu.
+ // The idea is that if there is an input, then it's a combobox, otherwise it's a menu.
+ // Since #19861 we have prepared the "combobox" solution, but didn't get enough time to put it into practice and test before.
+ const isComboBox = dropdown.querySelectorAll('input').length > 0;
+
+ dropdown[ariaPatchKey].focusableRole = isComboBox ? 'combobox' : 'menu';
+ dropdown[ariaPatchKey].listPopupRole = isComboBox ? 'listbox' : '';
+ dropdown[ariaPatchKey].listItemRole = isComboBox ? 'option' : 'menuitem';
+
+ attachDomEvents(dropdown, focusable, menu);
+ attachStaticElements(dropdown, focusable, menu);
+}
+
+function attachDomEvents(dropdown, focusable, menu) {
+ // when showing, it has class: ".animating.in"
+ // when hiding, it has class: ".visible.animating.out"
+ const isMenuVisible = () => (menu.classList.contains('visible') && !menu.classList.contains('out')) || menu.classList.contains('in');
+
+ // update aria attributes according to current active/selected item
+ const refreshAriaActiveItem = () => {
+ const menuVisible = isMenuVisible();
+ focusable.setAttribute('aria-expanded', menuVisible ? 'true' : 'false');
+
+ // if there is an active item, use it (the user is navigating between items)
+ // otherwise use the "selected" for combobox (for the last selected item)
+ const active = $(menu).find('> .item.active, > .item.selected')[0];
+ if (!active) return;
+ // if the popup is visible and has an active/selected item, use its id as aria-activedescendant
+ if (menuVisible) {
+ focusable.setAttribute('aria-activedescendant', active.id);
+ } else if (dropdown[ariaPatchKey].listPopupRole === 'menu') {
+ // for menu, when the popup is hidden, no need to keep the aria-activedescendant, and clear the active/selected item
+ focusable.removeAttribute('aria-activedescendant');
+ active.classList.remove('active', 'selected');
+ }
+ };
+
+ dropdown.addEventListener('keydown', (e) => {
+ // here it must use keydown event before dropdown's keyup handler, otherwise there is no Enter event in our keyup handler
+ if (e.key === 'Enter') {
+ const dropdownCall = fomanticDropdownFn.bind($(dropdown));
+ let $item = dropdownCall('get item', dropdownCall('get value'));
+ if (!$item) $item = $(menu).find('> .item.selected'); // when dropdown filters items by input, there is no "value", so query the "selected" item
+ // if the selected item is clickable, then trigger the click event.
+ // we can not click any item without check, because Fomantic code might also handle the Enter event. that would result in double click.
+ if ($item?.[0]?.matches('a, .js-aria-clickable')) $item[0].click();
+ }
+ });
+
+ // use setTimeout to run the refreshAria in next tick (to make sure the Fomantic UI code has finished its work)
+ // do not return any value, jQuery has return-value related behaviors.
+ // when the popup is hiding, it's better to have a small "delay", because there is a Fomantic UI animation
+ // without the delay for hiding, the UI will be somewhat laggy and sometimes may get stuck in the animation.
+ const deferredRefreshAriaActiveItem = (delay = 0) => { setTimeout(refreshAriaActiveItem, delay) };
+ dropdown[ariaPatchKey].deferredRefreshAriaActiveItem = deferredRefreshAriaActiveItem;
+ dropdown.addEventListener('keyup', (e) => { if (e.key.startsWith('Arrow')) deferredRefreshAriaActiveItem(); });
+
+ // if the dropdown has been opened by focus, do not trigger the next click event again.
+ // otherwise the dropdown will be closed immediately, especially on Android with TalkBack
+ // * desktop event sequence: mousedown -> focus -> mouseup -> click
+ // * mobile event sequence: focus -> mousedown -> mouseup -> click
+ // Fomantic may stop propagation of blur event, use capture to make sure we can still get the event
+ let ignoreClickPreEvents = 0, ignoreClickPreVisible = 0;
+ dropdown.addEventListener('mousedown', () => {
+ ignoreClickPreVisible += isMenuVisible() ? 1 : 0;
+ ignoreClickPreEvents++;
+ }, true);
+ dropdown.addEventListener('focus', () => {
+ ignoreClickPreVisible += isMenuVisible() ? 1 : 0;
+ ignoreClickPreEvents++;
+ deferredRefreshAriaActiveItem();
+ }, true);
+ dropdown.addEventListener('blur', () => {
+ ignoreClickPreVisible = ignoreClickPreEvents = 0;
+ deferredRefreshAriaActiveItem(100);
+ }, true);
+ dropdown.addEventListener('mouseup', () => {
+ setTimeout(() => {
+ ignoreClickPreVisible = ignoreClickPreEvents = 0;
+ deferredRefreshAriaActiveItem(100);
+ }, 0);
+ }, true);
+ dropdown.addEventListener('click', (e) => {
+ if (isMenuVisible() &&
+ ignoreClickPreVisible !== 2 && // dropdown is switch from invisible to visible
+ ignoreClickPreEvents === 2 // the click event is related to mousedown+focus
+ ) {
+ e.stopPropagation(); // if the dropdown menu has been opened by focus, do not trigger the next click event again
+ }
+ ignoreClickPreEvents = ignoreClickPreVisible = 0;
+ }, true);
+}
diff --git a/web_src/js/modules/fomantic/form.js b/web_src/js/modules/fomantic/form.js
new file mode 100644
index 0000000..3bb0058
--- /dev/null
+++ b/web_src/js/modules/fomantic/form.js
@@ -0,0 +1,13 @@
+import {linkLabelAndInput} from './base.js';
+
+export function initAriaFormFieldPatch() {
+ // link the label and the input element so it's clickable and accessible
+ for (const el of document.querySelectorAll('.ui.form .field')) {
+ if (el.hasAttribute('data-field-patched')) continue;
+ const label = el.querySelector(':scope > label');
+ const input = el.querySelector(':scope > input');
+ if (!label || !input) continue;
+ linkLabelAndInput(label, input);
+ el.setAttribute('data-field-patched', 'true');
+ }
+}
diff --git a/web_src/js/modules/fomantic/modal.js b/web_src/js/modules/fomantic/modal.js
new file mode 100644
index 0000000..8b455cf
--- /dev/null
+++ b/web_src/js/modules/fomantic/modal.js
@@ -0,0 +1,28 @@
+import $ from 'jquery';
+
+const fomanticModalFn = $.fn.modal;
+
+// use our own `$.fn.modal` to patch Fomantic's modal module
+export function initAriaModalPatch() {
+ if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once');
+ $.fn.modal = ariaModalFn;
+ ariaModalFn.settings = fomanticModalFn.settings;
+}
+
+// the patched `$.fn.modal` modal function
+// * it does the one-time attaching on the first call
+function ariaModalFn(...args) {
+ const ret = fomanticModalFn.apply(this, args);
+ if (args[0] === 'show' || args[0]?.autoShow) {
+ for (const el of this) {
+ // If there is a form in the modal, there might be a "cancel" button before "ok" button (all buttons are "type=submit" by default).
+ // In such case, the "Enter" key will trigger the "cancel" button instead of "ok" button, then the dialog will be closed.
+ // It breaks the user experience - the "Enter" key should confirm the dialog and submit the form.
+ // So, all "cancel" buttons without "[type]" must be marked as "type=button".
+ for (const button of el.querySelectorAll('form button.cancel:not([type])')) {
+ button.setAttribute('type', 'button');
+ }
+ }
+ }
+ return ret;
+}
diff --git a/web_src/js/modules/fomantic/transition.js b/web_src/js/modules/fomantic/transition.js
new file mode 100644
index 0000000..78aa053
--- /dev/null
+++ b/web_src/js/modules/fomantic/transition.js
@@ -0,0 +1,54 @@
+import $ from 'jquery';
+
+export function initFomanticTransition() {
+ const transitionNopBehaviors = new Set([
+ 'clear queue', 'stop', 'stop all', 'destroy',
+ 'force repaint', 'repaint', 'reset',
+ 'looping', 'remove looping', 'disable', 'enable',
+ 'set duration', 'save conditions', 'restore conditions',
+ ]);
+ // stand-in for removed transition module
+ $.fn.transition = function (arg0, arg1, arg2) {
+ if (arg0 === 'is supported') return true;
+ if (arg0 === 'is animating') return false;
+ if (arg0 === 'is inward') return false;
+ if (arg0 === 'is outward') return false;
+
+ let argObj;
+ if (typeof arg0 === 'string') {
+ // many behaviors are no-op now. https://fomantic-ui.com/modules/transition.html#/usage
+ if (transitionNopBehaviors.has(arg0)) return this;
+ // now, the arg0 is an animation name, the syntax: (animation, duration, complete)
+ argObj = {animation: arg0, ...(arg1 && {duration: arg1}), ...(arg2 && {onComplete: arg2})};
+ } else if (typeof arg0 === 'object') {
+ argObj = arg0;
+ } else {
+ throw new Error(`invalid argument: ${arg0}`);
+ }
+
+ const isAnimationIn = argObj.animation?.startsWith('show') || argObj.animation?.endsWith(' in');
+ const isAnimationOut = argObj.animation?.startsWith('hide') || argObj.animation?.endsWith(' out');
+ this.each((_, el) => {
+ let toShow = isAnimationIn;
+ if (!isAnimationIn && !isAnimationOut) {
+ // If the animation is not in/out, then it must be a toggle animation.
+ // Fomantic uses computed styles to check "visibility", but to avoid unnecessary arguments, here it only checks the class.
+ toShow = this.hasClass('hidden'); // maybe it could also check "!this.hasClass('visible')", leave it to the future until there is a real problem.
+ }
+ argObj.onStart?.call(el);
+ if (toShow) {
+ el.classList.remove('hidden');
+ el.classList.add('visible', 'transition');
+ if (argObj.displayType) el.style.setProperty('display', argObj.displayType, 'important');
+ argObj.onShow?.call(el);
+ } else {
+ el.classList.add('hidden');
+ el.classList.remove('visible'); // don't remove the transition class because the Fomantic animation style is `.hidden.transition`.
+ el.style.removeProperty('display');
+ argObj.onHidden?.call(el);
+ }
+ argObj.onComplete?.call(el);
+ });
+ return this;
+ };
+}
diff --git a/web_src/js/modules/sortable.js b/web_src/js/modules/sortable.js
new file mode 100644
index 0000000..1c9adb6
--- /dev/null
+++ b/web_src/js/modules/sortable.js
@@ -0,0 +1,19 @@
+export async function createSortable(el, opts = {}) {
+ const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs');
+
+ return new Sortable(el, {
+ animation: 150,
+ ghostClass: 'card-ghost',
+ onChoose: (e) => {
+ const handle = opts.handle ? e.item.querySelector(opts.handle) : e.item;
+ handle.classList.add('tw-cursor-grabbing');
+ opts.onChoose?.(e);
+ },
+ onUnchoose: (e) => {
+ const handle = opts.handle ? e.item.querySelector(opts.handle) : e.item;
+ handle.classList.remove('tw-cursor-grabbing');
+ opts.onUnchoose?.(e);
+ },
+ ...opts,
+ });
+}
diff --git a/web_src/js/modules/stores.js b/web_src/js/modules/stores.js
new file mode 100644
index 0000000..1a0ed7e
--- /dev/null
+++ b/web_src/js/modules/stores.js
@@ -0,0 +1,10 @@
+import {reactive} from 'vue';
+
+let diffTreeStoreReactive;
+export function diffTreeStore() {
+ if (!diffTreeStoreReactive) {
+ diffTreeStoreReactive = reactive(window.config.pageData.diffFileInfo);
+ window.config.pageData.diffFileInfo = diffTreeStoreReactive;
+ }
+ return diffTreeStoreReactive;
+}
diff --git a/web_src/js/modules/tippy.js b/web_src/js/modules/tippy.js
new file mode 100644
index 0000000..83b28e5
--- /dev/null
+++ b/web_src/js/modules/tippy.js
@@ -0,0 +1,195 @@
+import tippy, {followCursor} from 'tippy.js';
+import {isDocumentFragmentOrElementNode} from '../utils/dom.js';
+import {formatDatetime} from '../utils/time.js';
+
+const visibleInstances = new Set();
+const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
+
+export function createTippy(target, opts = {}) {
+ // the callback functions should be destructured from opts,
+ // because we should use our own wrapper functions to handle them, do not let the user override them
+ const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts;
+
+ const instance = tippy(target, {
+ appendTo: document.body,
+ animation: false,
+ allowHTML: false,
+ hideOnClick: false,
+ interactiveBorder: 20,
+ ignoreAttributes: true,
+ maxWidth: 500, // increase over default 350px
+ onHide: (instance) => {
+ visibleInstances.delete(instance);
+ return onHide?.(instance);
+ },
+ onDestroy: (instance) => {
+ visibleInstances.delete(instance);
+ return onDestroy?.(instance);
+ },
+ onShow: (instance) => {
+ // hide other tooltip instances so only one tooltip shows at a time
+ for (const visibleInstance of visibleInstances) {
+ if (visibleInstance.props.role === 'tooltip') {
+ visibleInstance.hide();
+ }
+ }
+ visibleInstances.add(instance);
+ return onShow?.(instance);
+ },
+ arrow: arrow || (theme === 'bare' ? false : arrowSvg),
+ role: role || 'menu', // HTML role attribute
+ theme: theme || role || 'menu', // CSS theme, either "tooltip", "menu", "box-with-header" or "bare"
+ plugins: [followCursor],
+ ...other,
+ });
+
+ if (role === 'menu') {
+ target.setAttribute('aria-haspopup', 'true');
+ }
+
+ return instance;
+}
+
+/**
+ * Attach a tooltip tippy to the given target element.
+ * If the target element already has a tooltip tippy attached, the tooltip will be updated with the new content.
+ * If the target element has no content, then no tooltip will be attached, and it returns null.
+ *
+ * Note: "tooltip" doesn't equal to "tippy". "tooltip" means a auto-popup content, it just uses tippy as the implementation.
+ *
+ * @param target {HTMLElement}
+ * @param content {null|string}
+ * @returns {null|tippy}
+ */
+function attachTooltip(target, content = null) {
+ switchTitleToTooltip(target);
+
+ content = content ?? target.getAttribute('data-tooltip-content');
+ if (!content) return null;
+
+ // when element has a clipboard target, we update the tooltip after copy
+ // in which case it is undesirable to automatically hide it on click as
+ // it would momentarily flash the tooltip out and in.
+ const hasClipboardTarget = target.hasAttribute('data-clipboard-target');
+ const hideOnClick = !hasClipboardTarget;
+
+ const props = {
+ content,
+ delay: 100,
+ role: 'tooltip',
+ theme: 'tooltip',
+ hideOnClick,
+ placement: target.getAttribute('data-tooltip-placement') || 'top-start',
+ followCursor: target.getAttribute('data-tooltip-follow-cursor') || false,
+ ...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}),
+ };
+
+ if (!target._tippy) {
+ createTippy(target, props);
+ } else {
+ target._tippy.setProps(props);
+ }
+ return target._tippy;
+}
+
+function switchTitleToTooltip(target) {
+ let title = target.getAttribute('title');
+ if (title) {
+ // apply custom formatting to relative-time's tooltips
+ if (target.tagName.toLowerCase() === 'relative-time') {
+ const datetime = target.getAttribute('datetime');
+ if (datetime) {
+ title = formatDatetime(new Date(datetime));
+ }
+ }
+ target.setAttribute('data-tooltip-content', title);
+ target.setAttribute('aria-label', title);
+ // keep the attribute, in case there are some other "[title]" selectors
+ // and to prevent infinite loop with <relative-time> which will re-add
+ // title if it is absent
+ target.setAttribute('title', '');
+ }
+}
+
+/**
+ * Creating tooltip tippy instance is expensive, so we only create it when the user hovers over the element
+ * According to https://www.w3.org/TR/DOM-Level-3-Events/#events-mouseevent-event-order , mouseover event is fired before mouseenter event
+ * Some browsers like PaleMoon don't support "addEventListener('mouseenter', capture)"
+ * The tippy by default uses "mouseenter" event to show, so we use "mouseover" event to switch to tippy
+ * @param e {Event}
+ */
+function lazyTooltipOnMouseHover(e) {
+ e.target.removeEventListener('mouseover', lazyTooltipOnMouseHover, true);
+ attachTooltip(this);
+}
+
+// Activate the tooltip for current element.
+// If the element has no aria-label, use the tooltip content as aria-label.
+function attachLazyTooltip(el) {
+ el.addEventListener('mouseover', lazyTooltipOnMouseHover, {capture: true});
+
+ // meanwhile, if the element has no aria-label, use the tooltip content as aria-label
+ if (!el.hasAttribute('aria-label')) {
+ const content = el.getAttribute('data-tooltip-content');
+ if (content) {
+ el.setAttribute('aria-label', content);
+ }
+ }
+}
+
+// Activate the tooltip for all children elements.
+function attachChildrenLazyTooltip(target) {
+ for (const el of target.querySelectorAll('[data-tooltip-content]')) {
+ attachLazyTooltip(el);
+ }
+}
+
+export function initGlobalTooltips() {
+ // use MutationObserver to detect new "data-tooltip-content" elements added to the DOM, or attributes changed
+ const observerConnect = (observer) => observer.observe(document, {
+ subtree: true,
+ childList: true,
+ attributeFilter: ['data-tooltip-content', 'title'],
+ });
+ const observer = new MutationObserver((mutationList, observer) => {
+ const pending = observer.takeRecords();
+ observer.disconnect();
+ for (const mutation of [...mutationList, ...pending]) {
+ if (mutation.type === 'childList') {
+ // mainly for Vue components and AJAX rendered elements
+ for (const el of mutation.addedNodes) {
+ if (!isDocumentFragmentOrElementNode(el)) continue;
+ attachChildrenLazyTooltip(el);
+ if (el.hasAttribute('data-tooltip-content')) {
+ attachLazyTooltip(el);
+ }
+ }
+ } else if (mutation.type === 'attributes') {
+ attachTooltip(mutation.target);
+ }
+ }
+ observerConnect(observer);
+ });
+ observerConnect(observer);
+
+ attachChildrenLazyTooltip(document.documentElement);
+}
+
+export function showTemporaryTooltip(target, content) {
+ // if the target is inside a dropdown, don't show the tooltip because when the dropdown
+ // closes, the tippy would be pushed unsightly to the top-left of the screen like seen
+ // on the issue comment menu.
+ if (target.closest('.ui.dropdown > .menu')) return;
+
+ const tippy = target._tippy ?? attachTooltip(target, content);
+ tippy.setContent(content);
+ if (!tippy.state.isShown) tippy.show();
+ tippy.setProps({
+ onHidden: (tippy) => {
+ // reset the default tooltip content, if no default, then this temporary tooltip could be destroyed
+ if (!attachTooltip(target)) {
+ tippy.destroy();
+ }
+ },
+ });
+}
diff --git a/web_src/js/modules/toast.js b/web_src/js/modules/toast.js
new file mode 100644
index 0000000..d12d203
--- /dev/null
+++ b/web_src/js/modules/toast.js
@@ -0,0 +1,55 @@
+import {htmlEscape} from 'escape-goat';
+import {svg} from '../svg.js';
+import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown
+
+const levels = {
+ info: {
+ icon: 'octicon-check',
+ background: 'var(--color-green)',
+ duration: 2500,
+ },
+ warning: {
+ icon: 'gitea-exclamation',
+ background: 'var(--color-orange)',
+ duration: -1, // requires dismissal to hide
+ },
+ error: {
+ icon: 'gitea-exclamation',
+ background: 'var(--color-red)',
+ duration: -1, // requires dismissal to hide
+ },
+};
+
+// See https://github.com/apvarun/toastify-js#api for options
+function showToast(message, level, {gravity, position, duration, useHtmlBody, ...other} = {}) {
+ const {icon, background, duration: levelDuration} = levels[level ?? 'info'];
+ const toast = Toastify({
+ text: `
+ <div class='toast-icon'>${svg(icon)}</div>
+ <div class='toast-body'>${useHtmlBody ? message : htmlEscape(message)}</div>
+ <button class='toast-close'>${svg('octicon-x')}</button>
+ `,
+ escapeMarkup: false,
+ gravity: gravity ?? 'top',
+ position: position ?? 'center',
+ duration: duration ?? levelDuration,
+ style: {background},
+ ...other,
+ });
+
+ toast.showToast();
+ toast.toastElement.querySelector('.toast-close').addEventListener('click', () => toast.hideToast());
+ return toast;
+}
+
+export function showInfoToast(message, opts) {
+ return showToast(message, 'info', opts);
+}
+
+export function showWarningToast(message, opts) {
+ return showToast(message, 'warning', opts);
+}
+
+export function showErrorToast(message, opts) {
+ return showToast(message, 'error', opts);
+}
diff --git a/web_src/js/modules/toast.test.js b/web_src/js/modules/toast.test.js
new file mode 100644
index 0000000..357f18d
--- /dev/null
+++ b/web_src/js/modules/toast.test.js
@@ -0,0 +1,16 @@
+import {showInfoToast, showErrorToast, showWarningToast} from './toast.js';
+
+test('showInfoToast', async () => {
+ showInfoToast('success 😀', {duration: -1});
+ expect(document.querySelector('.toastify')).toBeTruthy();
+});
+
+test('showWarningToast', async () => {
+ showWarningToast('warning 😐', {duration: -1});
+ expect(document.querySelector('.toastify')).toBeTruthy();
+});
+
+test('showErrorToast', async () => {
+ showErrorToast('error 🙁', {duration: -1});
+ expect(document.querySelector('.toastify')).toBeTruthy();
+});
diff --git a/web_src/js/render/ansi.js b/web_src/js/render/ansi.js
new file mode 100644
index 0000000..bb622dd
--- /dev/null
+++ b/web_src/js/render/ansi.js
@@ -0,0 +1,45 @@
+import {AnsiUp} from 'ansi_up';
+
+const replacements = [
+ [/\x1b\[\d+[A-H]/g, ''], // Move cursor, treat them as no-op
+ [/\x1b\[\d?[JK]/g, '\r'], // Erase display/line, treat them as a Carriage Return
+];
+
+// render ANSI to HTML
+export function renderAnsi(line) {
+ // create a fresh ansi_up instance because otherwise previous renders can influence
+ // the output of future renders, because ansi_up is stateful and remembers things like
+ // unclosed opening tags for colors.
+ const ansi_up = new AnsiUp();
+ ansi_up.use_classes = true;
+
+ if (line.endsWith('\r\n')) {
+ line = line.substring(0, line.length - 2);
+ } else if (line.endsWith('\n')) {
+ line = line.substring(0, line.length - 1);
+ }
+
+ if (line.includes('\x1b')) {
+ for (const [regex, replacement] of replacements) {
+ line = line.replace(regex, replacement);
+ }
+ }
+
+ if (!line.includes('\r')) {
+ return ansi_up.ansi_to_html(line);
+ }
+
+ // handle "\rReading...1%\rReading...5%\rReading...100%",
+ // convert it into a multiple-line string: "Reading...1%\nReading...5%\nReading...100%"
+ const lines = [];
+ for (const part of line.split('\r')) {
+ if (part === '') continue;
+ const partHtml = ansi_up.ansi_to_html(part);
+ if (partHtml !== '') {
+ lines.push(partHtml);
+ }
+ }
+
+ // the log message element is with "white-space: break-spaces;", so use "\n" to break lines
+ return lines.join('\n');
+}
diff --git a/web_src/js/render/ansi.test.js b/web_src/js/render/ansi.test.js
new file mode 100644
index 0000000..5afff71
--- /dev/null
+++ b/web_src/js/render/ansi.test.js
@@ -0,0 +1,20 @@
+import {renderAnsi} from './ansi.js';
+
+test('renderAnsi', () => {
+ expect(renderAnsi('abc')).toEqual('abc');
+ expect(renderAnsi('abc\n')).toEqual('abc');
+ expect(renderAnsi('abc\r\n')).toEqual('abc');
+ expect(renderAnsi('\r')).toEqual('');
+ expect(renderAnsi('\rx\rabc')).toEqual('x\nabc');
+ expect(renderAnsi('\rabc\rx\r')).toEqual('abc\nx');
+ expect(renderAnsi('\x1b[30mblack\x1b[37mwhite')).toEqual('<span class="ansi-black-fg">black</span><span class="ansi-white-fg">white</span>'); // unclosed
+ expect(renderAnsi('<script>')).toEqual('&lt;script&gt;');
+ expect(renderAnsi('\x1b[1A\x1b[2Ktest\x1b[1B\x1b[1A\x1b[2K')).toEqual('test');
+ expect(renderAnsi('\x1b[1A\x1b[2K\rtest\r\x1b[1B\x1b[1A\x1b[2K')).toEqual('test');
+ expect(renderAnsi('\x1b[1A\x1b[2Ktest\x1b[1B\x1b[1A\x1b[2K')).toEqual('test');
+ expect(renderAnsi('\x1b[1A\x1b[2K\rtest\r\x1b[1B\x1b[1A\x1b[2K')).toEqual('test');
+
+ // treat "\033[0K" and "\033[0J" (Erase display/line) as "\r", then it will be covered to "\n" finally.
+ expect(renderAnsi('a\x1b[Kb\x1b[2Jc')).toEqual('a\nb\nc');
+ expect(renderAnsi('\x1b[48;5;88ma\x1b[38;208;48;5;159mb\x1b[m')).toEqual(`<span style="background-color:rgb(135,0,0)">a</span><span style="background-color:rgb(175,255,255)">b</span>`);
+});
diff --git a/web_src/js/render/pdf.js b/web_src/js/render/pdf.js
new file mode 100644
index 0000000..f31f161
--- /dev/null
+++ b/web_src/js/render/pdf.js
@@ -0,0 +1,19 @@
+import {htmlEscape} from 'escape-goat';
+
+export async function initPdfViewer() {
+ const els = document.querySelectorAll('.pdf-content');
+ if (!els.length) return;
+
+ const pdfobject = await import(/* webpackChunkName: "pdfobject" */'pdfobject');
+
+ for (const el of els) {
+ const src = el.getAttribute('data-src');
+ const fallbackText = el.getAttribute('data-fallback-button-text');
+ pdfobject.embed(src, el, {
+ fallbackLink: htmlEscape`
+ <a role="button" class="ui basic button pdf-fallback-button" href="[url]">${fallbackText}</a>
+ `,
+ });
+ el.classList.remove('is-loading');
+ }
+}
diff --git a/web_src/js/standalone/devtest.js b/web_src/js/standalone/devtest.js
new file mode 100644
index 0000000..d0ca511
--- /dev/null
+++ b/web_src/js/standalone/devtest.js
@@ -0,0 +1,11 @@
+import {showInfoToast, showWarningToast, showErrorToast} from '../modules/toast.js';
+
+document.getElementById('info-toast').addEventListener('click', () => {
+ showInfoToast('success 😀');
+});
+document.getElementById('warning-toast').addEventListener('click', () => {
+ showWarningToast('warning 😐');
+});
+document.getElementById('error-toast').addEventListener('click', () => {
+ showErrorToast('error 🙁');
+});
diff --git a/web_src/js/standalone/forgejo-swagger.js b/web_src/js/standalone/forgejo-swagger.js
new file mode 100644
index 0000000..b7550b8
--- /dev/null
+++ b/web_src/js/standalone/forgejo-swagger.js
@@ -0,0 +1,23 @@
+window.addEventListener('load', async () => {
+ const [{default: SwaggerUI}] = await Promise.all([
+ import(/* webpackChunkName: "swagger-ui" */'swagger-ui-dist/swagger-ui-es-bundle.js'),
+ import(/* webpackChunkName: "swagger-ui" */'swagger-ui-dist/swagger-ui.css'),
+ ]);
+ const url = document.getElementById('swagger-ui').getAttribute('data-source');
+
+ const ui = SwaggerUI({
+ url,
+ dom_id: '#swagger-ui',
+ deepLinking: true,
+ docExpansion: 'none',
+ defaultModelRendering: 'model', // don't show examples by default, because they may be incomplete
+ presets: [
+ SwaggerUI.presets.apis,
+ ],
+ plugins: [
+ SwaggerUI.plugins.DownloadUrl,
+ ],
+ });
+
+ window.ui = ui;
+});
diff --git a/web_src/js/standalone/swagger.js b/web_src/js/standalone/swagger.js
new file mode 100644
index 0000000..ec2115e
--- /dev/null
+++ b/web_src/js/standalone/swagger.js
@@ -0,0 +1,34 @@
+window.addEventListener('load', async () => {
+ const [{default: SwaggerUI}] = await Promise.all([
+ import(/* webpackChunkName: "swagger-ui" */'swagger-ui-dist/swagger-ui-es-bundle.js'),
+ import(/* webpackChunkName: "swagger-ui" */'swagger-ui-dist/swagger-ui.css'),
+ ]);
+
+ const url = document.getElementById('swagger-ui').getAttribute('data-source');
+ const res = await fetch(url);
+ const spec = await res.json();
+
+ // Make the page's protocol be at the top of the schemes list
+ const proto = window.location.protocol.slice(0, -1);
+ spec.schemes.sort((a, b) => {
+ if (a === proto) return -1;
+ if (b === proto) return 1;
+ return 0;
+ });
+
+ const ui = SwaggerUI({
+ spec,
+ dom_id: '#swagger-ui',
+ deepLinking: true,
+ docExpansion: 'none',
+ defaultModelRendering: 'model', // don't show examples by default, because they may be incomplete
+ presets: [
+ SwaggerUI.presets.apis,
+ ],
+ plugins: [
+ SwaggerUI.plugins.DownloadUrl,
+ ],
+ });
+
+ window.ui = ui;
+});
diff --git a/web_src/js/svg.js b/web_src/js/svg.js
new file mode 100644
index 0000000..9ef5f28
--- /dev/null
+++ b/web_src/js/svg.js
@@ -0,0 +1,228 @@
+import {h} from 'vue';
+import {parseDom, serializeXml} from './utils.js';
+import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg';
+import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg';
+import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg';
+import giteaExclamation from '../../public/assets/img/svg/gitea-exclamation.svg';
+import octiconArchive from '../../public/assets/img/svg/octicon-archive.svg';
+import octiconArrowSwitch from '../../public/assets/img/svg/octicon-arrow-switch.svg';
+import octiconBlocked from '../../public/assets/img/svg/octicon-blocked.svg';
+import octiconBold from '../../public/assets/img/svg/octicon-bold.svg';
+import octiconCheck from '../../public/assets/img/svg/octicon-check.svg';
+import octiconCheckbox from '../../public/assets/img/svg/octicon-checkbox.svg';
+import octiconCheckCircleFill from '../../public/assets/img/svg/octicon-check-circle-fill.svg';
+import octiconChevronDown from '../../public/assets/img/svg/octicon-chevron-down.svg';
+import octiconChevronLeft from '../../public/assets/img/svg/octicon-chevron-left.svg';
+import octiconChevronRight from '../../public/assets/img/svg/octicon-chevron-right.svg';
+import octiconClock from '../../public/assets/img/svg/octicon-clock.svg';
+import octiconCode from '../../public/assets/img/svg/octicon-code.svg';
+import octiconColumns from '../../public/assets/img/svg/octicon-columns.svg';
+import octiconCopy from '../../public/assets/img/svg/octicon-copy.svg';
+import octiconDiffAdded from '../../public/assets/img/svg/octicon-diff-added.svg';
+import octiconDiffModified from '../../public/assets/img/svg/octicon-diff-modified.svg';
+import octiconDiffRemoved from '../../public/assets/img/svg/octicon-diff-removed.svg';
+import octiconDiffRenamed from '../../public/assets/img/svg/octicon-diff-renamed.svg';
+import octiconDotFill from '../../public/assets/img/svg/octicon-dot-fill.svg';
+import octiconDownload from '../../public/assets/img/svg/octicon-download.svg';
+import octiconEye from '../../public/assets/img/svg/octicon-eye.svg';
+import octiconFile from '../../public/assets/img/svg/octicon-file.svg';
+import octiconFileDirectoryFill from '../../public/assets/img/svg/octicon-file-directory-fill.svg';
+import octiconFilter from '../../public/assets/img/svg/octicon-filter.svg';
+import octiconGear from '../../public/assets/img/svg/octicon-gear.svg';
+import octiconGitBranch from '../../public/assets/img/svg/octicon-git-branch.svg';
+import octiconGitCommit from '../../public/assets/img/svg/octicon-git-commit.svg';
+import octiconGitMerge from '../../public/assets/img/svg/octicon-git-merge.svg';
+import octiconGitPullRequest from '../../public/assets/img/svg/octicon-git-pull-request.svg';
+import octiconGitPullRequestClosed from '../../public/assets/img/svg/octicon-git-pull-request-closed.svg';
+import octiconGitPullRequestDraft from '../../public/assets/img/svg/octicon-git-pull-request-draft.svg';
+import octiconHeading from '../../public/assets/img/svg/octicon-heading.svg';
+import octiconHorizontalRule from '../../public/assets/img/svg/octicon-horizontal-rule.svg';
+import octiconImage from '../../public/assets/img/svg/octicon-image.svg';
+import octiconIssueClosed from '../../public/assets/img/svg/octicon-issue-closed.svg';
+import octiconIssueOpened from '../../public/assets/img/svg/octicon-issue-opened.svg';
+import octiconItalic from '../../public/assets/img/svg/octicon-italic.svg';
+import octiconKebabHorizontal from '../../public/assets/img/svg/octicon-kebab-horizontal.svg';
+import octiconLink from '../../public/assets/img/svg/octicon-link.svg';
+import octiconListOrdered from '../../public/assets/img/svg/octicon-list-ordered.svg';
+import octiconListUnordered from '../../public/assets/img/svg/octicon-list-unordered.svg';
+import octiconLock from '../../public/assets/img/svg/octicon-lock.svg';
+import octiconMeter from '../../public/assets/img/svg/octicon-meter.svg';
+import octiconMilestone from '../../public/assets/img/svg/octicon-milestone.svg';
+import octiconMirror from '../../public/assets/img/svg/octicon-mirror.svg';
+import octiconOrganization from '../../public/assets/img/svg/octicon-organization.svg';
+import octiconPlay from '../../public/assets/img/svg/octicon-play.svg';
+import octiconPlus from '../../public/assets/img/svg/octicon-plus.svg';
+import octiconProject from '../../public/assets/img/svg/octicon-project.svg';
+import octiconQuote from '../../public/assets/img/svg/octicon-quote.svg';
+import octiconRepo from '../../public/assets/img/svg/octicon-repo.svg';
+import octiconRepoForked from '../../public/assets/img/svg/octicon-repo-forked.svg';
+import octiconRepoTemplate from '../../public/assets/img/svg/octicon-repo-template.svg';
+import octiconRss from '../../public/assets/img/svg/octicon-rss.svg';
+import octiconScreenFull from '../../public/assets/img/svg/octicon-screen-full.svg';
+import octiconSearch from '../../public/assets/img/svg/octicon-search.svg';
+import octiconSidebarCollapse from '../../public/assets/img/svg/octicon-sidebar-collapse.svg';
+import octiconSidebarExpand from '../../public/assets/img/svg/octicon-sidebar-expand.svg';
+import octiconSkip from '../../public/assets/img/svg/octicon-skip.svg';
+import octiconStar from '../../public/assets/img/svg/octicon-star.svg';
+import octiconStrikethrough from '../../public/assets/img/svg/octicon-strikethrough.svg';
+import octiconSync from '../../public/assets/img/svg/octicon-sync.svg';
+import octiconTable from '../../public/assets/img/svg/octicon-table.svg';
+import octiconTag from '../../public/assets/img/svg/octicon-tag.svg';
+import octiconTrash from '../../public/assets/img/svg/octicon-trash.svg';
+import octiconTriangleDown from '../../public/assets/img/svg/octicon-triangle-down.svg';
+import octiconX from '../../public/assets/img/svg/octicon-x.svg';
+import octiconXCircleFill from '../../public/assets/img/svg/octicon-x-circle-fill.svg';
+
+const svgs = {
+ 'gitea-double-chevron-left': giteaDoubleChevronLeft,
+ 'gitea-double-chevron-right': giteaDoubleChevronRight,
+ 'gitea-empty-checkbox': giteaEmptyCheckbox,
+ 'gitea-exclamation': giteaExclamation,
+ 'octicon-archive': octiconArchive,
+ 'octicon-arrow-switch': octiconArrowSwitch,
+ 'octicon-blocked': octiconBlocked,
+ 'octicon-bold': octiconBold,
+ 'octicon-check': octiconCheck,
+ 'octicon-check-circle-fill': octiconCheckCircleFill,
+ 'octicon-checkbox': octiconCheckbox,
+ 'octicon-chevron-down': octiconChevronDown,
+ 'octicon-chevron-left': octiconChevronLeft,
+ 'octicon-chevron-right': octiconChevronRight,
+ 'octicon-clock': octiconClock,
+ 'octicon-code': octiconCode,
+ 'octicon-columns': octiconColumns,
+ 'octicon-copy': octiconCopy,
+ 'octicon-diff-added': octiconDiffAdded,
+ 'octicon-diff-modified': octiconDiffModified,
+ 'octicon-diff-removed': octiconDiffRemoved,
+ 'octicon-diff-renamed': octiconDiffRenamed,
+ 'octicon-dot-fill': octiconDotFill,
+ 'octicon-download': octiconDownload,
+ 'octicon-eye': octiconEye,
+ 'octicon-file': octiconFile,
+ 'octicon-file-directory-fill': octiconFileDirectoryFill,
+ 'octicon-filter': octiconFilter,
+ 'octicon-gear': octiconGear,
+ 'octicon-git-branch': octiconGitBranch,
+ 'octicon-git-commit': octiconGitCommit,
+ 'octicon-git-merge': octiconGitMerge,
+ 'octicon-git-pull-request': octiconGitPullRequest,
+ 'octicon-git-pull-request-closed': octiconGitPullRequestClosed,
+ 'octicon-git-pull-request-draft': octiconGitPullRequestDraft,
+ 'octicon-heading': octiconHeading,
+ 'octicon-horizontal-rule': octiconHorizontalRule,
+ 'octicon-image': octiconImage,
+ 'octicon-issue-closed': octiconIssueClosed,
+ 'octicon-issue-opened': octiconIssueOpened,
+ 'octicon-italic': octiconItalic,
+ 'octicon-kebab-horizontal': octiconKebabHorizontal,
+ 'octicon-link': octiconLink,
+ 'octicon-list-ordered': octiconListOrdered,
+ 'octicon-list-unordered': octiconListUnordered,
+ 'octicon-lock': octiconLock,
+ 'octicon-meter': octiconMeter,
+ 'octicon-milestone': octiconMilestone,
+ 'octicon-mirror': octiconMirror,
+ 'octicon-organization': octiconOrganization,
+ 'octicon-play': octiconPlay,
+ 'octicon-plus': octiconPlus,
+ 'octicon-project': octiconProject,
+ 'octicon-quote': octiconQuote,
+ 'octicon-repo': octiconRepo,
+ 'octicon-repo-forked': octiconRepoForked,
+ 'octicon-repo-template': octiconRepoTemplate,
+ 'octicon-rss': octiconRss,
+ 'octicon-screen-full': octiconScreenFull,
+ 'octicon-search': octiconSearch,
+ 'octicon-sidebar-collapse': octiconSidebarCollapse,
+ 'octicon-sidebar-expand': octiconSidebarExpand,
+ 'octicon-skip': octiconSkip,
+ 'octicon-star': octiconStar,
+ 'octicon-strikethrough': octiconStrikethrough,
+ 'octicon-sync': octiconSync,
+ 'octicon-table': octiconTable,
+ 'octicon-tag': octiconTag,
+ 'octicon-trash': octiconTrash,
+ 'octicon-triangle-down': octiconTriangleDown,
+ 'octicon-x': octiconX,
+ 'octicon-x-circle-fill': octiconXCircleFill,
+};
+
+// TODO: use a more general approach to access SVG icons.
+// At the moment, developers must check, pick and fill the names manually,
+// most of the SVG icons in assets couldn't be used directly.
+
+// retrieve an HTML string for given SVG icon name, size and additional classes
+export function svg(name, size = 16, className = '') {
+ if (!(name in svgs)) throw new Error(`Unknown SVG icon: ${name}`);
+ if (size === 16 && !className) return svgs[name];
+
+ const document = parseDom(svgs[name], 'image/svg+xml');
+ const svgNode = document.firstChild;
+ if (size !== 16) {
+ svgNode.setAttribute('width', String(size));
+ svgNode.setAttribute('height', String(size));
+ }
+ if (className) svgNode.classList.add(...className.split(/\s+/).filter(Boolean));
+ return serializeXml(svgNode);
+}
+
+export function svgParseOuterInner(name) {
+ const svgStr = svgs[name];
+ if (!svgStr) throw new Error(`Unknown SVG icon: ${name}`);
+
+ // parse the SVG string to 2 parts
+ // * svgInnerHtml: the inner part of the SVG, will be used as the content of the <svg> VNode
+ // * svgOuter: the outer part of the SVG, including attributes
+ // the builtin SVG contents are clean, so it's safe to use `indexOf` to split the content:
+ // eg: <svg outer-attributes>${svgInnerHtml}</svg>
+ const p1 = svgStr.indexOf('>'), p2 = svgStr.lastIndexOf('<');
+ if (p1 === -1 || p2 === -1) throw new Error(`Invalid SVG icon: ${name}`);
+ const svgInnerHtml = svgStr.slice(p1 + 1, p2);
+ const svgOuterHtml = svgStr.slice(0, p1 + 1) + svgStr.slice(p2);
+ const svgDoc = parseDom(svgOuterHtml, 'image/svg+xml');
+ const svgOuter = svgDoc.firstChild;
+ return {svgOuter, svgInnerHtml};
+}
+
+export const SvgIcon = {
+ name: 'SvgIcon',
+ props: {
+ name: {type: String, required: true},
+ size: {type: Number, default: 16},
+ className: {type: String, default: ''},
+ symbolId: {type: String},
+ },
+ render() {
+ let {svgOuter, svgInnerHtml} = svgParseOuterInner(this.name);
+ // https://vuejs.org/guide/extras/render-function.html#creating-vnodes
+ // the `^` is used for attr, set SVG attributes like 'width', `aria-hidden`, `viewBox`, etc
+ const attrs = {};
+ for (const attr of svgOuter.attributes) {
+ if (attr.name === 'class') continue;
+ attrs[`^${attr.name}`] = attr.value;
+ }
+ attrs[`^width`] = this.size;
+ attrs[`^height`] = this.size;
+
+ // make the <SvgIcon class="foo" class-name="bar"> classes work together
+ const classes = [];
+ for (const cls of svgOuter.classList) {
+ classes.push(cls);
+ }
+ // TODO: drop the `className/class-name` prop in the future, only use "class" prop
+ if (this.className) {
+ classes.push(...this.className.split(/\s+/).filter(Boolean));
+ }
+ if (this.symbolId) {
+ classes.push('tw-hidden', 'svg-symbol-container');
+ svgInnerHtml = `<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${svgInnerHtml}</symbol>`;
+ }
+ // create VNode
+ return h('svg', {
+ ...attrs,
+ class: classes,
+ innerHTML: svgInnerHtml,
+ });
+ },
+};
diff --git a/web_src/js/svg.test.js b/web_src/js/svg.test.js
new file mode 100644
index 0000000..06b320c
--- /dev/null
+++ b/web_src/js/svg.test.js
@@ -0,0 +1,27 @@
+import {svg, SvgIcon, svgParseOuterInner} from './svg.js';
+import {createApp, h} from 'vue';
+
+test('svg', () => {
+ expect(svg('octicon-repo')).toMatch(/^<svg/);
+ expect(svg('octicon-repo', 16)).toContain('width="16"');
+ expect(svg('octicon-repo', 32)).toContain('width="32"');
+});
+
+test('svgParseOuterInner', () => {
+ const {svgOuter, svgInnerHtml} = svgParseOuterInner('octicon-repo');
+ expect(svgOuter.nodeName).toMatch('svg');
+ expect(svgOuter.classList.contains('octicon-repo')).toBeTruthy();
+ expect(svgInnerHtml).toContain('<path');
+});
+
+test('SvgIcon', () => {
+ const root = document.createElement('div');
+ createApp({render: () => h(SvgIcon, {name: 'octicon-link', size: 24, class: 'base', className: 'extra'})}).mount(root);
+ const node = root.firstChild;
+ expect(node.nodeName).toEqual('svg');
+ expect(node.getAttribute('width')).toEqual('24');
+ expect(node.getAttribute('height')).toEqual('24');
+ expect(node.classList.contains('octicon-link')).toBeTruthy();
+ expect(node.classList.contains('base')).toBeTruthy();
+ expect(node.classList.contains('extra')).toBeTruthy();
+});
diff --git a/web_src/js/utils.js b/web_src/js/utils.js
new file mode 100644
index 0000000..ce0fb66
--- /dev/null
+++ b/web_src/js/utils.js
@@ -0,0 +1,144 @@
+import {encode, decode} from 'uint8-to-base64';
+
+// transform /path/to/file.ext to file.ext
+export function basename(path = '') {
+ const lastSlashIndex = path.lastIndexOf('/');
+ return lastSlashIndex < 0 ? path : path.substring(lastSlashIndex + 1);
+}
+
+// transform /path/to/file.ext to .ext
+export function extname(path = '') {
+ const lastPointIndex = path.lastIndexOf('.');
+ return lastPointIndex < 0 ? '' : path.substring(lastPointIndex);
+}
+
+// test whether a variable is an object
+export function isObject(obj) {
+ return Object.prototype.toString.call(obj) === '[object Object]';
+}
+
+// returns whether a dark theme is enabled
+export function isDarkTheme() {
+ const style = window.getComputedStyle(document.documentElement);
+ return style.getPropertyValue('--is-dark-theme').trim().toLowerCase() === 'true';
+}
+
+// strip <tags> from a string
+export function stripTags(text) {
+ return text.replace(/<[^>]*>?/g, '');
+}
+
+export function parseIssueHref(href) {
+ const path = (href || '').replace(/[#?].*$/, '');
+ const [_, owner, repo, type, index] = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path) || [];
+ return {owner, repo, type, index};
+}
+
+// parse a URL, either relative '/path' or absolute 'https://localhost/path'
+export function parseUrl(str) {
+ return new URL(str, str.startsWith('http') ? undefined : window.location.origin);
+}
+
+// return current locale chosen by user
+export function getCurrentLocale() {
+ return document.documentElement.lang;
+}
+
+// given a month (0-11), returns it in the documents language
+export function translateMonth(month) {
+ return new Date(Date.UTC(2022, month, 12)).toLocaleString(getCurrentLocale(), {month: 'short', timeZone: 'UTC'});
+}
+
+// given a weekday (0-6, Sunday to Saturday), returns it in the documents language
+export function translateDay(day) {
+ return new Date(Date.UTC(2022, 7, day)).toLocaleString(getCurrentLocale(), {weekday: 'short', timeZone: 'UTC'});
+}
+
+// convert a Blob to a DataURI
+export function blobToDataURI(blob) {
+ return new Promise((resolve, reject) => {
+ try {
+ const reader = new FileReader();
+ reader.addEventListener('load', (e) => {
+ resolve(e.target.result);
+ });
+ reader.addEventListener('error', () => {
+ reject(new Error('FileReader failed'));
+ });
+ reader.readAsDataURL(blob);
+ } catch (err) {
+ reject(err);
+ }
+ });
+}
+
+// convert image Blob to another mime-type format.
+export function convertImage(blob, mime) {
+ return new Promise(async (resolve, reject) => {
+ try {
+ const img = new Image();
+ const canvas = document.createElement('canvas');
+ img.addEventListener('load', () => {
+ try {
+ canvas.width = img.naturalWidth;
+ canvas.height = img.naturalHeight;
+ const context = canvas.getContext('2d');
+ context.drawImage(img, 0, 0);
+ canvas.toBlob((blob) => {
+ if (!(blob instanceof Blob)) return reject(new Error('imageBlobToPng failed'));
+ resolve(blob);
+ }, mime);
+ } catch (err) {
+ reject(err);
+ }
+ });
+ img.addEventListener('error', () => {
+ reject(new Error('imageBlobToPng failed'));
+ });
+ img.src = await blobToDataURI(blob);
+ } catch (err) {
+ reject(err);
+ }
+ });
+}
+
+export function toAbsoluteUrl(url) {
+ if (url.startsWith('http://') || url.startsWith('https://')) {
+ return url;
+ }
+ if (url.startsWith('//')) {
+ return `${window.location.protocol}${url}`; // it's also a somewhat absolute URL (with the current scheme)
+ }
+ if (url && !url.startsWith('/')) {
+ throw new Error('unsupported url, it should either start with / or http(s)://');
+ }
+ return `${window.location.origin}${url}`;
+}
+
+// Encode an ArrayBuffer into a URLEncoded base64 string.
+export function encodeURLEncodedBase64(arrayBuffer) {
+ return encode(arrayBuffer)
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '');
+}
+
+// Decode a URLEncoded base64 to an ArrayBuffer string.
+export function decodeURLEncodedBase64(base64url) {
+ return decode(base64url
+ .replace(/_/g, '/')
+ .replace(/-/g, '+'));
+}
+
+const domParser = new DOMParser();
+const xmlSerializer = new XMLSerializer();
+
+export function parseDom(text, contentType) {
+ return domParser.parseFromString(text, contentType);
+}
+
+export function serializeXml(node) {
+ return xmlSerializer.serializeToString(node);
+}
+
+export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.js
new file mode 100644
index 0000000..c49bb2a
--- /dev/null
+++ b/web_src/js/utils.test.js
@@ -0,0 +1,189 @@
+import {
+ basename, extname, isObject, stripTags, parseIssueHref,
+ parseUrl, translateMonth, translateDay, blobToDataURI,
+ toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64,
+ isDarkTheme, getCurrentLocale, parseDom, serializeXml, sleep,
+} from './utils.js';
+
+afterEach(() => {
+ // Reset head and body sections of the document
+ document.documentElement.innerHTML = '<head></head><body></body>';
+
+ // Remove 'lang' and 'style' attributes of html tag
+ document.documentElement.removeAttribute('lang');
+ document.documentElement.removeAttribute('style');
+});
+
+test('basename', () => {
+ expect(basename('/path/to/file.js')).toEqual('file.js');
+ expect(basename('/path/to/file')).toEqual('file');
+ expect(basename('file.js')).toEqual('file.js');
+});
+
+test('extname', () => {
+ expect(extname('/path/to/file.js')).toEqual('.js');
+ expect(extname('/path/')).toEqual('');
+ expect(extname('/path')).toEqual('');
+ expect(extname('file.js')).toEqual('.js');
+});
+
+test('isObject', () => {
+ expect(isObject({})).toBeTruthy();
+ expect(isObject([])).toBeFalsy();
+});
+
+test('should return true if dark theme is enabled', () => {
+ // When --is-dark-theme var is defined with value true
+ document.documentElement.style.setProperty('--is-dark-theme', 'true');
+ expect(isDarkTheme()).toBeTruthy();
+
+ // when --is-dark-theme var is defined with value TRUE
+ document.documentElement.style.setProperty('--is-dark-theme', 'TRUE');
+ expect(isDarkTheme()).toBeTruthy();
+});
+
+test('should return false if dark theme is disabled', () => {
+ // when --is-dark-theme var is defined with value false
+ document.documentElement.style.setProperty('--is-dark-theme', 'false');
+ expect(isDarkTheme()).toBeFalsy();
+
+ // when --is-dark-theme var is defined with value FALSE
+ document.documentElement.style.setProperty('--is-dark-theme', 'FALSE');
+ expect(isDarkTheme()).toBeFalsy();
+});
+
+test('should return false if dark theme is not defined', () => {
+ // when --is-dark-theme var is not exist
+ expect(isDarkTheme()).toBeFalsy();
+});
+
+test('stripTags', () => {
+ expect(stripTags('<a>test</a>')).toEqual('test');
+});
+
+test('parseIssueHref', () => {
+ expect(parseIssueHref('/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
+ expect(parseIssueHref('/owner/repo/pulls/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'});
+ expect(parseIssueHref('/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
+ expect(parseIssueHref('/sub/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
+ expect(parseIssueHref('/sub/sub2/owner/repo/pulls/1')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'});
+ expect(parseIssueHref('/sub/sub2/owner/repo/issues/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
+ expect(parseIssueHref('/sub/sub2/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
+ expect(parseIssueHref('https://example.com/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
+ expect(parseIssueHref('https://example.com/owner/repo/pulls/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'});
+ expect(parseIssueHref('https://example.com/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
+ expect(parseIssueHref('https://example.com/sub/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
+ expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/pulls/1')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'});
+ expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/issues/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
+ expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
+ expect(parseIssueHref('')).toEqual({owner: undefined, repo: undefined, type: undefined, index: undefined});
+});
+
+test('parseUrl', () => {
+ expect(parseUrl('').pathname).toEqual('/');
+ expect(parseUrl('/path').pathname).toEqual('/path');
+ expect(parseUrl('/path?search').pathname).toEqual('/path');
+ expect(parseUrl('/path?search').search).toEqual('?search');
+ expect(parseUrl('/path?search#hash').hash).toEqual('#hash');
+ expect(parseUrl('https://localhost/path').pathname).toEqual('/path');
+ expect(parseUrl('https://localhost/path?search').pathname).toEqual('/path');
+ expect(parseUrl('https://localhost/path?search').search).toEqual('?search');
+ expect(parseUrl('https://localhost/path?search#hash').hash).toEqual('#hash');
+});
+
+test('getCurrentLocale', () => {
+ // HTML document without explicit lang
+ expect(getCurrentLocale()).toEqual('');
+
+ // HTML document with explicit lang
+ document.documentElement.setAttribute('lang', 'en-US');
+ expect(getCurrentLocale()).toEqual('en-US');
+});
+
+test('translateMonth', () => {
+ const originalLang = document.documentElement.lang;
+ document.documentElement.lang = 'en-US';
+ expect(translateMonth(0)).toEqual('Jan');
+ expect(translateMonth(4)).toEqual('May');
+ document.documentElement.lang = 'es-ES';
+ expect(translateMonth(5)).toEqual('jun');
+ expect(translateMonth(6)).toEqual('jul');
+ document.documentElement.lang = originalLang;
+});
+
+test('translateDay', () => {
+ const originalLang = document.documentElement.lang;
+ document.documentElement.lang = 'fr-FR';
+ expect(translateDay(1)).toEqual('lun.');
+ expect(translateDay(5)).toEqual('ven.');
+ document.documentElement.lang = 'pl-PL';
+ expect(translateDay(1)).toEqual('pon.');
+ expect(translateDay(5)).toEqual('pt.');
+ document.documentElement.lang = originalLang;
+});
+
+test('blobToDataURI', async () => {
+ const blob = new Blob([JSON.stringify({test: true})], {type: 'application/json'});
+ expect(await blobToDataURI(blob)).toEqual('data:application/json;base64,eyJ0ZXN0Ijp0cnVlfQ==');
+});
+
+test('toAbsoluteUrl', () => {
+ expect(toAbsoluteUrl('//host/dir')).toEqual('http://host/dir');
+ expect(toAbsoluteUrl('https://host/dir')).toEqual('https://host/dir');
+ expect(toAbsoluteUrl('http://host/dir')).toEqual('http://host/dir');
+ expect(toAbsoluteUrl('')).toEqual('http://localhost:3000');
+ expect(toAbsoluteUrl('/user/repo')).toEqual('http://localhost:3000/user/repo');
+ expect(() => toAbsoluteUrl('path')).toThrowError('unsupported');
+});
+
+test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => {
+ // TextEncoder is Node.js API while Uint8Array is jsdom API and their outputs are not
+ // structurally comparable, so we convert to array to compare. The conversion can be
+ // removed once https://github.com/jsdom/jsdom/issues/2524 is resolved.
+ const encoder = new TextEncoder();
+ const uint8array = encoder.encode.bind(encoder);
+
+ expect(encodeURLEncodedBase64(uint8array('AA?'))).toEqual('QUE_'); // standard base64: "QUE/"
+ expect(encodeURLEncodedBase64(uint8array('AA~'))).toEqual('QUF-'); // standard base64: "QUF+"
+
+ expect(Array.from(decodeURLEncodedBase64('QUE/'))).toEqual(Array.from(uint8array('AA?')));
+ expect(Array.from(decodeURLEncodedBase64('QUF+'))).toEqual(Array.from(uint8array('AA~')));
+ expect(Array.from(decodeURLEncodedBase64('QUE_'))).toEqual(Array.from(uint8array('AA?')));
+ expect(Array.from(decodeURLEncodedBase64('QUF-'))).toEqual(Array.from(uint8array('AA~')));
+
+ expect(encodeURLEncodedBase64(uint8array('a'))).toEqual('YQ'); // standard base64: "YQ=="
+ expect(Array.from(decodeURLEncodedBase64('YQ'))).toEqual(Array.from(uint8array('a')));
+ expect(Array.from(decodeURLEncodedBase64('YQ=='))).toEqual(Array.from(uint8array('a')));
+});
+
+test('parseDom', () => {
+ const paragraphStr = 'This is sample paragraph';
+ const paragraphTagStr = `<p>${paragraphStr}</p>`;
+ const content = parseDom(paragraphTagStr, 'text/html');
+ expect(content.body.innerHTML).toEqual(paragraphTagStr);
+
+ // Content should have only one paragraph
+ const paragraphs = content.getElementsByTagName('p');
+ expect(paragraphs.length).toEqual(1);
+ expect(paragraphs[0].textContent).toEqual(paragraphStr);
+});
+
+test('serializeXml', () => {
+ const textStr = 'This is a sample text';
+ const tagName = 'item';
+ const node = document.createElement(tagName);
+ node.textContent = textStr;
+ expect(serializeXml(node)).toEqual(`<${tagName}>${textStr}</${tagName}>`);
+});
+
+test('sleep', async () => {
+ await testSleep(2000);
+});
+
+async function testSleep(ms) {
+ const startTime = Date.now(); // Record the start time
+ await sleep(ms);
+ const endTime = Date.now(); // Record the end time
+ const actualSleepTime = endTime - startTime;
+ expect(actualSleepTime >= ms).toBeTruthy();
+}
diff --git a/web_src/js/utils/color.js b/web_src/js/utils/color.js
new file mode 100644
index 0000000..198f97c
--- /dev/null
+++ b/web_src/js/utils/color.js
@@ -0,0 +1,33 @@
+import tinycolor from 'tinycolor2';
+
+// Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
+// Keep this in sync with modules/util/color.go
+function getRelativeLuminance(color) {
+ const {r, g, b} = tinycolor(color).toRgb();
+ return (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255;
+}
+
+function useLightText(backgroundColor) {
+ return getRelativeLuminance(backgroundColor) < 0.453;
+}
+
+// Given a background color, returns a black or white foreground color that the highest
+// contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better.
+// https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
+export function contrastColor(backgroundColor) {
+ return useLightText(backgroundColor) ? '#fff' : '#000';
+}
+
+function resolveColors(obj) {
+ const styles = window.getComputedStyle(document.documentElement);
+ const getColor = (name) => styles.getPropertyValue(name).trim();
+ return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, getColor(value)]));
+}
+
+export const chartJsColors = resolveColors({
+ text: '--color-text',
+ border: '--color-secondary-alpha-60',
+ commits: '--color-primary-alpha-60',
+ additions: '--color-green',
+ deletions: '--color-red',
+});
diff --git a/web_src/js/utils/color.test.js b/web_src/js/utils/color.test.js
new file mode 100644
index 0000000..fee9afc
--- /dev/null
+++ b/web_src/js/utils/color.test.js
@@ -0,0 +1,22 @@
+import {contrastColor} from './color.js';
+
+test('contrastColor', () => {
+ expect(contrastColor('#d73a4a')).toBe('#fff');
+ expect(contrastColor('#0075ca')).toBe('#fff');
+ expect(contrastColor('#cfd3d7')).toBe('#000');
+ expect(contrastColor('#a2eeef')).toBe('#000');
+ expect(contrastColor('#7057ff')).toBe('#fff');
+ expect(contrastColor('#008672')).toBe('#fff');
+ expect(contrastColor('#e4e669')).toBe('#000');
+ expect(contrastColor('#d876e3')).toBe('#000');
+ expect(contrastColor('#ffffff')).toBe('#000');
+ expect(contrastColor('#2b8684')).toBe('#fff');
+ expect(contrastColor('#2b8786')).toBe('#fff');
+ expect(contrastColor('#2c8786')).toBe('#000');
+ expect(contrastColor('#3bb6b3')).toBe('#000');
+ expect(contrastColor('#7c7268')).toBe('#fff');
+ expect(contrastColor('#7e716c')).toBe('#fff');
+ expect(contrastColor('#81706d')).toBe('#fff');
+ expect(contrastColor('#807070')).toBe('#fff');
+ expect(contrastColor('#84b6eb')).toBe('#000');
+});
diff --git a/web_src/js/utils/dom.js b/web_src/js/utils/dom.js
new file mode 100644
index 0000000..44adc79
--- /dev/null
+++ b/web_src/js/utils/dom.js
@@ -0,0 +1,305 @@
+import {debounce} from 'throttle-debounce';
+
+function elementsCall(el, func, ...args) {
+ if (typeof el === 'string' || el instanceof String) {
+ el = document.querySelectorAll(el);
+ }
+ if (el instanceof Node) {
+ func(el, ...args);
+ } else if (el.length !== undefined) {
+ // this works for: NodeList, HTMLCollection, Array, jQuery
+ for (const e of el) {
+ func(e, ...args);
+ }
+ } else {
+ throw new Error('invalid argument to be shown/hidden');
+ }
+}
+
+/**
+ * @param el string (selector), Node, NodeList, HTMLCollection, Array or jQuery
+ * @param force force=true to show or force=false to hide, undefined to toggle
+ */
+function toggleShown(el, force) {
+ if (force === true) {
+ el.classList.remove('tw-hidden');
+ } else if (force === false) {
+ el.classList.add('tw-hidden');
+ } else if (force === undefined) {
+ el.classList.toggle('tw-hidden');
+ } else {
+ throw new Error('invalid force argument');
+ }
+}
+
+export function showElem(el) {
+ elementsCall(el, toggleShown, true);
+}
+
+export function hideElem(el) {
+ elementsCall(el, toggleShown, false);
+}
+
+export function toggleElem(el, force) {
+ elementsCall(el, toggleShown, force);
+}
+
+export function isElemHidden(el) {
+ const res = [];
+ elementsCall(el, (e) => res.push(e.classList.contains('tw-hidden')));
+ if (res.length > 1) throw new Error(`isElemHidden doesn't work for multiple elements`);
+ return res[0];
+}
+
+function applyElemsCallback(elems, fn) {
+ if (fn) {
+ for (const el of elems) {
+ fn(el);
+ }
+ }
+ return elems;
+}
+
+export function queryElemSiblings(el, selector = '*', fn) {
+ return applyElemsCallback(Array.from(el.parentNode.children).filter((child) => child !== el && child.matches(selector)), fn);
+}
+
+// it works like jQuery.children: only the direct children are selected
+export function queryElemChildren(parent, selector = '*', fn) {
+ return applyElemsCallback(parent.querySelectorAll(`:scope > ${selector}`), fn);
+}
+
+export function queryElems(selector, fn) {
+ return applyElemsCallback(document.querySelectorAll(selector), fn);
+}
+
+export function onDomReady(cb) {
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', cb);
+ } else {
+ cb();
+ }
+}
+
+// checks whether an element is owned by the current document, and whether it is a document fragment or element node
+// if it is, it means it is a "normal" element managed by us, which can be modified safely.
+export function isDocumentFragmentOrElementNode(el) {
+ try {
+ return el.ownerDocument === document && el.nodeType === Node.ELEMENT_NODE || el.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
+ } catch {
+ // in case the el is not in the same origin, then the access to nodeType would fail
+ return false;
+ }
+}
+
+// autosize a textarea to fit content. Based on
+// https://github.com/github/textarea-autosize
+// ---------------------------------------------------------------------
+// Copyright (c) 2018 GitHub, Inc.
+//
+// Permission is hereby granted, free of charge, to any person obtaining
+// a copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to
+// permit persons to whom the Software is furnished to do so, subject to
+// the following conditions:
+//
+// The above copyright notice and this permission notice shall be
+// included in all copies or substantial portions of the Software.
+// ---------------------------------------------------------------------
+export function autosize(textarea, {viewportMarginBottom = 0} = {}) {
+ let isUserResized = false;
+ // lastStyleHeight and initialStyleHeight are CSS values like '100px'
+ let lastMouseX, lastMouseY, lastStyleHeight, initialStyleHeight;
+
+ function onUserResize(event) {
+ if (isUserResized) return;
+ if (lastMouseX !== event.clientX || lastMouseY !== event.clientY) {
+ const newStyleHeight = textarea.style.height;
+ if (lastStyleHeight && lastStyleHeight !== newStyleHeight) {
+ isUserResized = true;
+ }
+ lastStyleHeight = newStyleHeight;
+ }
+
+ lastMouseX = event.clientX;
+ lastMouseY = event.clientY;
+ }
+
+ function overflowOffset() {
+ let offsetTop = 0;
+ let el = textarea;
+
+ while (el !== document.body && el !== null) {
+ offsetTop += el.offsetTop || 0;
+ el = el.offsetParent;
+ }
+
+ const top = offsetTop - document.defaultView.scrollY;
+ const bottom = document.documentElement.clientHeight - (top + textarea.offsetHeight);
+ return {top, bottom};
+ }
+
+ function resizeToFit() {
+ if (isUserResized) return;
+ if (textarea.offsetWidth <= 0 && textarea.offsetHeight <= 0) return;
+
+ try {
+ const {top, bottom} = overflowOffset();
+ const isOutOfViewport = top < 0 || bottom < 0;
+
+ const computedStyle = getComputedStyle(textarea);
+ const topBorderWidth = parseFloat(computedStyle.borderTopWidth);
+ const bottomBorderWidth = parseFloat(computedStyle.borderBottomWidth);
+ const isBorderBox = computedStyle.boxSizing === 'border-box';
+ const borderAddOn = isBorderBox ? topBorderWidth + bottomBorderWidth : 0;
+
+ const adjustedViewportMarginBottom = bottom < viewportMarginBottom ? bottom : viewportMarginBottom;
+ const curHeight = parseFloat(computedStyle.height);
+ const maxHeight = curHeight + bottom - adjustedViewportMarginBottom;
+
+ textarea.style.height = 'auto';
+ let newHeight = textarea.scrollHeight + borderAddOn;
+
+ if (isOutOfViewport) {
+ // it is already out of the viewport:
+ // * if the textarea is expanding: do not resize it
+ if (newHeight > curHeight) {
+ newHeight = curHeight;
+ }
+ // * if the textarea is shrinking, shrink line by line (just use the
+ // scrollHeight). do not apply max-height limit, otherwise the page
+ // flickers and the textarea jumps
+ } else {
+ // * if it is in the viewport, apply the max-height limit
+ newHeight = Math.min(maxHeight, newHeight);
+ }
+
+ textarea.style.height = `${newHeight}px`;
+ lastStyleHeight = textarea.style.height;
+ } finally {
+ // ensure that the textarea is fully scrolled to the end, when the cursor
+ // is at the end during an input event
+ if (textarea.selectionStart === textarea.selectionEnd &&
+ textarea.selectionStart === textarea.value.length) {
+ textarea.scrollTop = textarea.scrollHeight;
+ }
+ }
+ }
+
+ function onFormReset() {
+ isUserResized = false;
+ if (initialStyleHeight !== undefined) {
+ textarea.style.height = initialStyleHeight;
+ } else {
+ textarea.style.removeProperty('height');
+ }
+ }
+
+ textarea.addEventListener('mousemove', onUserResize);
+ textarea.addEventListener('input', resizeToFit);
+ textarea.form?.addEventListener('reset', onFormReset);
+ initialStyleHeight = textarea.style.height ?? undefined;
+ if (textarea.value) resizeToFit();
+
+ return {
+ resizeToFit,
+ destroy() {
+ textarea.removeEventListener('mousemove', onUserResize);
+ textarea.removeEventListener('input', resizeToFit);
+ textarea.form?.removeEventListener('reset', onFormReset);
+ },
+ };
+}
+
+export function onInputDebounce(fn) {
+ return debounce(300, fn);
+}
+
+// Set the `src` attribute on an element and returns a promise that resolves once the element
+// has loaded or errored. Suitable for all elements mention in:
+// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/load_event
+export function loadElem(el, src) {
+ return new Promise((resolve) => {
+ el.addEventListener('load', () => resolve(true), {once: true});
+ el.addEventListener('error', () => resolve(false), {once: true});
+ el.src = src;
+ });
+}
+
+// some browsers like PaleMoon don't have "SubmitEvent" support, so polyfill it by a tricky method: use the last clicked button as submitter
+// it can't use other transparent polyfill patches because PaleMoon also doesn't support "addEventListener(capture)"
+const needSubmitEventPolyfill = typeof SubmitEvent === 'undefined';
+
+export function submitEventSubmitter(e) {
+ return needSubmitEventPolyfill ? (e.target._submitter || null) : e.submitter;
+}
+
+function submitEventPolyfillListener(e) {
+ const form = e.target.closest('form');
+ if (!form) return;
+ form._submitter = e.target.closest('button:not([type]), button[type="submit"], input[type="submit"]');
+}
+
+export function initSubmitEventPolyfill() {
+ if (!needSubmitEventPolyfill) return;
+ console.warn(`This browser doesn't have "SubmitEvent" support, use a tricky method to polyfill`);
+ document.body.addEventListener('click', submitEventPolyfillListener);
+ document.body.addEventListener('focus', submitEventPolyfillListener);
+}
+
+/**
+ * Check if an element is visible, equivalent to jQuery's `:visible` pseudo.
+ * Note: This function doesn't account for all possible visibility scenarios.
+ * @param {HTMLElement} element The element to check.
+ * @returns {boolean} True if the element is visible.
+ */
+export function isElemVisible(element) {
+ if (!element) return false;
+
+ return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
+}
+
+// extract text and images from "paste" event
+export function getPastedContent(e) {
+ const images = [];
+ for (const item of e.clipboardData?.items ?? []) {
+ if (item.type?.startsWith('image/')) {
+ images.push(item.getAsFile());
+ }
+ }
+ const text = e.clipboardData?.getData?.('text') ?? '';
+ return {text, images};
+}
+
+// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this
+export function replaceTextareaSelection(textarea, text) {
+ const before = textarea.value.slice(0, textarea.selectionStart ?? undefined);
+ const after = textarea.value.slice(textarea.selectionEnd ?? undefined);
+ let success = true;
+
+ textarea.contentEditable = 'true';
+ try {
+ success = document.execCommand('insertText', false, text);
+ } catch {
+ success = false;
+ }
+ textarea.contentEditable = 'false';
+
+ if (success && !textarea.value.slice(0, textarea.selectionStart ?? undefined).endsWith(text)) {
+ success = false;
+ }
+
+ if (!success) {
+ textarea.value = `${before}${text}${after}`;
+ textarea.dispatchEvent(new CustomEvent('change', {bubbles: true, cancelable: true}));
+ }
+}
+
+// Warning: Do not enter any unsanitized variables here
+export function createElementFromHTML(htmlString) {
+ const div = document.createElement('div');
+ div.innerHTML = htmlString.trim();
+ return div.firstChild;
+}
diff --git a/web_src/js/utils/dom.test.js b/web_src/js/utils/dom.test.js
new file mode 100644
index 0000000..fd7d97c
--- /dev/null
+++ b/web_src/js/utils/dom.test.js
@@ -0,0 +1,5 @@
+import {createElementFromHTML} from './dom.js';
+
+test('createElementFromHTML', () => {
+ expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>');
+});
diff --git a/web_src/js/utils/image.js b/web_src/js/utils/image.js
new file mode 100644
index 0000000..ed5d98e
--- /dev/null
+++ b/web_src/js/utils/image.js
@@ -0,0 +1,47 @@
+export async function pngChunks(blob) {
+ const uint8arr = new Uint8Array(await blob.arrayBuffer());
+ const chunks = [];
+ if (uint8arr.length < 12) return chunks;
+ const view = new DataView(uint8arr.buffer);
+ if (view.getBigUint64(0) !== 9894494448401390090n) return chunks;
+
+ const decoder = new TextDecoder();
+ let index = 8;
+ while (index < uint8arr.length) {
+ const len = view.getUint32(index);
+ chunks.push({
+ name: decoder.decode(uint8arr.slice(index + 4, index + 8)),
+ data: uint8arr.slice(index + 8, index + 8 + len),
+ });
+ index += len + 12;
+ }
+
+ return chunks;
+}
+
+// decode a image and try to obtain width and dppx. If will never throw but instead
+// return default values.
+export async function imageInfo(blob) {
+ let width = 0; // 0 means no width could be determined
+ let dppx = 1; // 1 dot per pixel for non-HiDPI screens
+
+ if (blob.type === 'image/png') { // only png is supported currently
+ try {
+ for (const {name, data} of await pngChunks(blob)) {
+ const view = new DataView(data.buffer);
+ if (name === 'IHDR' && data?.length) {
+ // extract width from mandatory IHDR chunk
+ width = view.getUint32(0);
+ } else if (name === 'pHYs' && data?.length) {
+ // extract dppx from optional pHYs chunk, assuming pixels are square
+ const unit = view.getUint8(8);
+ if (unit === 1) {
+ dppx = Math.round(view.getUint32(0) / 39.3701) / 72; // meter to inch to dppx
+ }
+ }
+ }
+ } catch {}
+ }
+
+ return {width, dppx};
+}
diff --git a/web_src/js/utils/image.test.js b/web_src/js/utils/image.test.js
new file mode 100644
index 0000000..ba47582
--- /dev/null
+++ b/web_src/js/utils/image.test.js
@@ -0,0 +1,29 @@
+import {pngChunks, imageInfo} from './image.js';
+
+const pngNoPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAADUlEQVQIHQECAP3/AAAAAgABzePRKwAAAABJRU5ErkJggg==';
+const pngPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAEElEQVQI12OQNZcAIgYIBQAL8gGxdzzM0A==';
+const pngEmpty = 'data:image/png;base64,';
+
+async function dataUriToBlob(datauri) {
+ return await (await globalThis.fetch(datauri)).blob();
+}
+
+test('pngChunks', async () => {
+ expect(await pngChunks(await dataUriToBlob(pngNoPhys))).toEqual([
+ {name: 'IHDR', data: new Uint8Array([0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0])},
+ {name: 'IDAT', data: new Uint8Array([8, 29, 1, 2, 0, 253, 255, 0, 0, 0, 2, 0, 1])},
+ {name: 'IEND', data: new Uint8Array([])},
+ ]);
+ expect(await pngChunks(await dataUriToBlob(pngPhys))).toEqual([
+ {name: 'IHDR', data: new Uint8Array([0, 0, 0, 2, 0, 0, 0, 2, 8, 2, 0, 0, 0])},
+ {name: 'pHYs', data: new Uint8Array([0, 0, 22, 37, 0, 0, 22, 37, 1])},
+ {name: 'IDAT', data: new Uint8Array([8, 215, 99, 144, 53, 151, 0, 34, 6, 8, 5, 0, 11, 242, 1, 177])},
+ ]);
+ expect(await pngChunks(await dataUriToBlob(pngEmpty))).toEqual([]);
+});
+
+test('imageInfo', async () => {
+ expect(await imageInfo(await dataUriToBlob(pngNoPhys))).toEqual({width: 1, dppx: 1});
+ expect(await imageInfo(await dataUriToBlob(pngPhys))).toEqual({width: 2, dppx: 2});
+ expect(await imageInfo(await dataUriToBlob(pngEmpty))).toEqual({width: 0, dppx: 1});
+});
diff --git a/web_src/js/utils/match.js b/web_src/js/utils/match.js
new file mode 100644
index 0000000..17fdfed
--- /dev/null
+++ b/web_src/js/utils/match.js
@@ -0,0 +1,43 @@
+import emojis from '../../../assets/emoji.json';
+
+const maxMatches = 6;
+
+function sortAndReduce(map) {
+ const sortedMap = new Map(Array.from(map.entries()).sort((a, b) => a[1] - b[1]));
+ return Array.from(sortedMap.keys()).slice(0, maxMatches);
+}
+
+export function matchEmoji(queryText) {
+ const query = queryText.toLowerCase().replaceAll('_', ' ');
+ if (!query) return emojis.slice(0, maxMatches).map((e) => e.aliases[0]);
+
+ // results is a map of weights, lower is better
+ const results = new Map();
+ for (const {aliases} of emojis) {
+ const mainAlias = aliases[0];
+ for (const [aliasIndex, alias] of aliases.entries()) {
+ const index = alias.replaceAll('_', ' ').indexOf(query);
+ if (index === -1) continue;
+ const existing = results.get(mainAlias);
+ const rankedIndex = index + aliasIndex;
+ results.set(mainAlias, existing ? existing - rankedIndex : rankedIndex);
+ }
+ }
+
+ return sortAndReduce(results);
+}
+
+export function matchMention(queryText) {
+ const query = queryText.toLowerCase();
+
+ // results is a map of weights, lower is better
+ const results = new Map();
+ for (const obj of window.config.mentionValues ?? []) {
+ const index = obj.key.toLowerCase().indexOf(query);
+ if (index === -1) continue;
+ const existing = results.get(obj);
+ results.set(obj, existing ? existing - index : index);
+ }
+
+ return sortAndReduce(results);
+}
diff --git a/web_src/js/utils/match.test.js b/web_src/js/utils/match.test.js
new file mode 100644
index 0000000..1e30b45
--- /dev/null
+++ b/web_src/js/utils/match.test.js
@@ -0,0 +1,50 @@
+import {matchEmoji, matchMention} from './match.js';
+
+test('matchEmoji', () => {
+ expect(matchEmoji('')).toEqual([
+ '+1',
+ '-1',
+ '100',
+ '1234',
+ '1st_place_medal',
+ '2nd_place_medal',
+ ]);
+
+ expect(matchEmoji('hea')).toEqual([
+ 'headphones',
+ 'headstone',
+ 'health_worker',
+ 'hear_no_evil',
+ 'heard_mcdonald_islands',
+ 'heart',
+ ]);
+
+ expect(matchEmoji('hear')).toEqual([
+ 'hear_no_evil',
+ 'heard_mcdonald_islands',
+ 'heart',
+ 'heart_decoration',
+ 'heart_eyes',
+ 'heart_eyes_cat',
+ ]);
+
+ expect(matchEmoji('poo')).toEqual([
+ 'poodle',
+ 'hankey',
+ 'spoon',
+ 'bowl_with_spoon',
+ ]);
+
+ expect(matchEmoji('1st_')).toEqual([
+ '1st_place_medal',
+ ]);
+
+ expect(matchEmoji('jellyfis')).toEqual([
+ 'jellyfish',
+ ]);
+});
+
+test('matchMention', () => {
+ expect(matchMention('')).toEqual(window.config.mentionValues.slice(0, 6));
+ expect(matchMention('user4')).toEqual([window.config.mentionValues[3]]);
+});
diff --git a/web_src/js/utils/time.js b/web_src/js/utils/time.js
new file mode 100644
index 0000000..7c7eabd
--- /dev/null
+++ b/web_src/js/utils/time.js
@@ -0,0 +1,72 @@
+import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc.js';
+import {getCurrentLocale} from '../utils.js';
+
+dayjs.extend(utc);
+
+/**
+ * Returns an array of millisecond-timestamps of start-of-week days (Sundays)
+ *
+ * @param startConfig The start date. Can take any type that `Date` accepts.
+ * @param endConfig The end date. Can take any type that `Date` accepts.
+ */
+export function startDaysBetween(startDate, endDate) {
+ const start = dayjs.utc(startDate);
+ const end = dayjs.utc(endDate);
+
+ let current = start;
+
+ // Ensure the start date is a Sunday
+ while (current.day() !== 0) {
+ current = current.add(1, 'day');
+ }
+
+ const startDays = [];
+ while (current.isBefore(end)) {
+ startDays.push(current.valueOf());
+ current = current.add(1, 'week');
+ }
+
+ return startDays;
+}
+
+export function firstStartDateAfterDate(inputDate) {
+ if (!(inputDate instanceof Date)) {
+ throw new Error('Invalid date');
+ }
+ const dayOfWeek = inputDate.getUTCDay();
+ const daysUntilSunday = 7 - dayOfWeek;
+ const resultDate = new Date(inputDate.getTime());
+ resultDate.setUTCDate(resultDate.getUTCDate() + daysUntilSunday);
+ return resultDate.valueOf();
+}
+
+export function fillEmptyStartDaysWithZeroes(startDays, data) {
+ const result = {};
+
+ for (const startDay of startDays) {
+ result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0};
+ }
+
+ return Object.values(result);
+}
+
+let dateFormat;
+
+// format a Date object to document's locale, but with 24h format from user's current locale because this
+// option is a personal preference of the user, not something that the document's locale should dictate.
+export function formatDatetime(date) {
+ if (!dateFormat) {
+ // TODO: replace `hour12` with `Intl.Locale.prototype.getHourCycles` once there is broad browser support
+ dateFormat = new Intl.DateTimeFormat(getCurrentLocale(), {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ hour: 'numeric',
+ hour12: !Number.isInteger(Number(new Intl.DateTimeFormat([], {hour: 'numeric'}).format())),
+ minute: '2-digit',
+ timeZoneName: 'short',
+ });
+ }
+ return dateFormat.format(date);
+}
diff --git a/web_src/js/utils/time.test.js b/web_src/js/utils/time.test.js
new file mode 100644
index 0000000..dbe5d7d
--- /dev/null
+++ b/web_src/js/utils/time.test.js
@@ -0,0 +1,40 @@
+import {firstStartDateAfterDate, startDaysBetween, fillEmptyStartDaysWithZeroes} from './time.js';
+
+test('startDaysBetween', () => {
+ expect(startDaysBetween(new Date('2024-02-15'), new Date('2024-04-18'))).toEqual([
+ 1708214400000,
+ 1708819200000,
+ 1709424000000,
+ 1710028800000,
+ 1710633600000,
+ 1711238400000,
+ 1711843200000,
+ 1712448000000,
+ 1713052800000,
+ ]);
+});
+
+test('firstStartDateAfterDate', () => {
+ const expectedDate = new Date('2024-02-18').getTime();
+ expect(firstStartDateAfterDate(new Date('2024-02-15'))).toEqual(expectedDate);
+
+ expect(() => firstStartDateAfterDate('2024-02-15')).toThrowError('Invalid date');
+});
+test('fillEmptyStartDaysWithZeroes with data', () => {
+ expect(fillEmptyStartDaysWithZeroes([1708214400000, 1708819200000, 1708819300000], {
+ 1708214400000: {'week': 1708214400000, 'additions': 1, 'deletions': 2, 'commits': 3},
+ 1708819200000: {'week': 1708819200000, 'additions': 4, 'deletions': 5, 'commits': 6},
+ })).toEqual([
+ {'week': 1708214400000, 'additions': 1, 'deletions': 2, 'commits': 3},
+ {'week': 1708819200000, 'additions': 4, 'deletions': 5, 'commits': 6},
+ {
+ 'additions': 0,
+ 'commits': 0,
+ 'deletions': 0,
+ 'week': 1708819300000,
+ }]);
+});
+
+test('fillEmptyStartDaysWithZeroes with empty array', () => {
+ expect(fillEmptyStartDaysWithZeroes([], {})).toEqual([]);
+});
diff --git a/web_src/js/utils/url.js b/web_src/js/utils/url.js
new file mode 100644
index 0000000..470ece3
--- /dev/null
+++ b/web_src/js/utils/url.js
@@ -0,0 +1,15 @@
+export function pathEscapeSegments(s) {
+ return s.split('/').map(encodeURIComponent).join('/');
+}
+
+function stripSlash(url) {
+ return url.endsWith('/') ? url.slice(0, -1) : url;
+}
+
+export function isUrl(url) {
+ try {
+ return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim();
+ } catch {
+ return false;
+ }
+}
diff --git a/web_src/js/utils/url.test.js b/web_src/js/utils/url.test.js
new file mode 100644
index 0000000..08c6373
--- /dev/null
+++ b/web_src/js/utils/url.test.js
@@ -0,0 +1,13 @@
+import {pathEscapeSegments, isUrl} from './url.js';
+
+test('pathEscapeSegments', () => {
+ expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
+ expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
+});
+
+test('isUrl', () => {
+ expect(isUrl('https://example.com')).toEqual(true);
+ expect(isUrl('https://example.com/')).toEqual(true);
+ expect(isUrl('https://example.com/index.html')).toEqual(true);
+ expect(isUrl('/index.html')).toEqual(false);
+});
diff --git a/web_src/js/vendor/jquery.are-you-sure.js b/web_src/js/vendor/jquery.are-you-sure.js
new file mode 100644
index 0000000..e06da39
--- /dev/null
+++ b/web_src/js/vendor/jquery.are-you-sure.js
@@ -0,0 +1,195 @@
+// Fork of the upstream module. The only changes are the addition of `const` on
+// lines 93 and 161 to make it strict mode compatible.
+
+/*!
+ * jQuery Plugin: Are-You-Sure (Dirty Form Detection)
+ * https://github.com/codedance/jquery.AreYouSure/
+ *
+ * Copyright (c) 2012-2014, Chris Dance and PaperCut Software http://www.papercut.com/
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * Author: chris.dance@papercut.com
+ * Version: 1.9.0
+ * Date: 13th August 2014
+ */
+(function($) {
+
+ $.fn.areYouSure = function(options) {
+
+ var settings = $.extend(
+ {
+ 'message' : 'You have unsaved changes!',
+ 'dirtyClass' : 'dirty',
+ 'change' : null,
+ 'silent' : false,
+ 'addRemoveFieldsMarksDirty' : false,
+ 'fieldEvents' : 'change keyup propertychange input',
+ 'fieldSelector': ":input:not(input[type=submit]):not(input[type=button])"
+ }, options);
+
+ var getValue = function($field) {
+ if ($field.hasClass('ays-ignore')
+ || $field.hasClass('aysIgnore')
+ || $field.attr('data-ays-ignore')
+ || $field.attr('name') === undefined) {
+ return null;
+ }
+
+ if ($field.is(':disabled')) {
+ return 'ays-disabled';
+ }
+
+ var val;
+ var type = $field.attr('type');
+ if ($field.is('select')) {
+ type = 'select';
+ }
+
+ switch (type) {
+ case 'checkbox':
+ case 'radio':
+ val = $field.is(':checked');
+ break;
+ case 'select':
+ val = '';
+ $field.find('option').each(function(o) {
+ var $option = $(this);
+ if ($option.is(':selected')) {
+ val += $option.val();
+ }
+ });
+ break;
+ default:
+ val = $field.val();
+ }
+
+ return val;
+ };
+
+ var storeOrigValue = function($field) {
+ $field.data('ays-orig', getValue($field));
+ };
+
+ var checkForm = function(evt) {
+
+ var isFieldDirty = function($field) {
+ var origValue = $field.data('ays-orig');
+ if (undefined === origValue) {
+ return false;
+ }
+ return (getValue($field) != origValue);
+ };
+
+ var $form = ($(this).is('form'))
+ ? $(this)
+ : $(this).parents('form');
+
+ // Test on the target first as it's the most likely to be dirty
+ if (isFieldDirty($(evt.target))) {
+ setDirtyStatus($form, true);
+ return;
+ }
+
+ const $fields = $form.find(settings.fieldSelector);
+
+ if (settings.addRemoveFieldsMarksDirty) {
+ // Check if field count has changed
+ var origCount = $form.data("ays-orig-field-count");
+ if (origCount != $fields.length) {
+ setDirtyStatus($form, true);
+ return;
+ }
+ }
+
+ // Brute force - check each field
+ var isDirty = false;
+ $fields.each(function() {
+ var $field = $(this);
+ if (isFieldDirty($field)) {
+ isDirty = true;
+ return false; // break
+ }
+ });
+
+ setDirtyStatus($form, isDirty);
+ };
+
+ var initForm = function($form) {
+ var fields = $form.find(settings.fieldSelector);
+ $(fields).each(function() { storeOrigValue($(this)); });
+ $(fields).unbind(settings.fieldEvents, checkForm);
+ $(fields).bind(settings.fieldEvents, checkForm);
+ $form.data("ays-orig-field-count", $(fields).length);
+ setDirtyStatus($form, false);
+ };
+
+ var setDirtyStatus = function($form, isDirty) {
+ var changed = isDirty != $form.hasClass(settings.dirtyClass);
+ $form.toggleClass(settings.dirtyClass, isDirty);
+
+ // Fire change event if required
+ if (changed) {
+ if (settings.change) settings.change.call($form, $form);
+
+ if (isDirty) $form.trigger('dirty.areYouSure', [$form]);
+ if (!isDirty) $form.trigger('clean.areYouSure', [$form]);
+ $form.trigger('change.areYouSure', [$form]);
+ }
+ };
+
+ var rescan = function() {
+ var $form = $(this);
+ var fields = $form.find(settings.fieldSelector);
+ $(fields).each(function() {
+ var $field = $(this);
+ if (!$field.data('ays-orig')) {
+ storeOrigValue($field);
+ $field.bind(settings.fieldEvents, checkForm);
+ }
+ });
+ // Check for changes while we're here
+ $form.trigger('checkform.areYouSure');
+ };
+
+ var reinitialize = function() {
+ initForm($(this));
+ }
+
+ if (!settings.silent && !window.aysUnloadSet) {
+ window.aysUnloadSet = true;
+ $(window).bind('beforeunload', function() {
+ const $dirtyForms = $("form").filter('.' + settings.dirtyClass);
+ if ($dirtyForms.length == 0) {
+ return;
+ }
+ // Prevent multiple prompts - seen on Chrome and IE
+ if (navigator.userAgent.toLowerCase().match(/msie|chrome/)) {
+ if (window.aysHasPrompted) {
+ return;
+ }
+ window.aysHasPrompted = true;
+ window.setTimeout(function() {window.aysHasPrompted = false;}, 900);
+ }
+ return settings.message;
+ });
+ }
+
+ return this.each(function(elem) {
+ if (!$(this).is('form')) {
+ return;
+ }
+ var $form = $(this);
+
+ $form.submit(function() {
+ $form.removeClass(settings.dirtyClass);
+ });
+ $form.bind('reset', function() { setDirtyStatus($form, false); });
+ // Add a custom events
+ $form.bind('rescan.areYouSure', rescan);
+ $form.bind('reinitialize.areYouSure', reinitialize);
+ $form.bind('checkform.areYouSure', checkForm);
+ initForm($form);
+ });
+ };
+})(jQuery);
diff --git a/web_src/js/vitest.setup.js b/web_src/js/vitest.setup.js
new file mode 100644
index 0000000..5366958
--- /dev/null
+++ b/web_src/js/vitest.setup.js
@@ -0,0 +1,18 @@
+window.__webpack_public_path__ = '';
+
+window.config = {
+ csrfToken: 'test-csrf-token-123456',
+ pageData: {},
+ i18n: {},
+ customEmojis: {},
+ appSubUrl: '',
+ mentionValues: [
+ {key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},
+ {key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'},
+ {key: 'org3 User 3', value: 'org3', name: 'org3', fullname: 'User 3', avatar: 'https://avatar3.com'},
+ {key: 'user4 User 4', value: 'user4', name: 'user4', fullname: 'User 4', avatar: 'https://avatar4.com'},
+ {key: 'user5 User 5', value: 'user5', name: 'user5', fullname: 'User 5', avatar: 'https://avatar5.com'},
+ {key: 'org6 User 6', value: 'org6', name: 'org6', fullname: 'User 6', avatar: 'https://avatar6.com'},
+ {key: 'org7 User 7', value: 'org7', name: 'org7', fullname: 'User 7', avatar: 'https://avatar7.com'},
+ ],
+};
diff --git a/web_src/js/webcomponents/README.md b/web_src/js/webcomponents/README.md
new file mode 100644
index 0000000..45af58e
--- /dev/null
+++ b/web_src/js/webcomponents/README.md
@@ -0,0 +1,11 @@
+# Web Components
+
+This `webcomponents` directory contains the source code for the web components used in the Gitea Web UI.
+
+https://developer.mozilla.org/en-US/docs/Web/Web_Components
+
+# Guidelines
+
+* These components are loaded in `<head>` (before DOM body) in a separate entry point, they need to be lightweight to not affect the page loading time too much.
+* Do not import `svg.js` into a web component because that file is currently not tree-shakeable, import svg files individually insteat.
+* All our components must be added to `webpack.config.js` so they work correctly in Vue.
diff --git a/web_src/js/webcomponents/absolute-date.js b/web_src/js/webcomponents/absolute-date.js
new file mode 100644
index 0000000..d2be455
--- /dev/null
+++ b/web_src/js/webcomponents/absolute-date.js
@@ -0,0 +1,40 @@
+import {Temporal} from 'temporal-polyfill';
+
+export function toAbsoluteLocaleDate(dateStr, lang, opts) {
+ return Temporal.PlainDate.from(dateStr).toLocaleString(lang ?? [], opts);
+}
+
+window.customElements.define('absolute-date', class extends HTMLElement {
+ static observedAttributes = ['date', 'year', 'month', 'weekday', 'day'];
+
+ update = () => {
+ const year = this.getAttribute('year') ?? '';
+ const month = this.getAttribute('month') ?? '';
+ const weekday = this.getAttribute('weekday') ?? '';
+ const day = this.getAttribute('day') ?? '';
+ const lang = this.closest('[lang]')?.getAttribute('lang') ||
+ this.ownerDocument.documentElement.getAttribute('lang') || '';
+
+ // only use the first 10 characters, e.g. the `yyyy-mm-dd` part
+ const dateStr = this.getAttribute('date').substring(0, 10);
+
+ if (!this.shadowRoot) this.attachShadow({mode: 'open'});
+ this.shadowRoot.textContent = toAbsoluteLocaleDate(dateStr, lang, {
+ ...(year && {year}),
+ ...(month && {month}),
+ ...(weekday && {weekday}),
+ ...(day && {day}),
+ });
+ };
+
+ attributeChangedCallback(_name, oldValue, newValue) {
+ if (!this.initialized || oldValue === newValue) return;
+ this.update();
+ }
+
+ connectedCallback() {
+ this.initialized = false;
+ this.update();
+ this.initialized = true;
+ }
+});
diff --git a/web_src/js/webcomponents/absolute-date.test.js b/web_src/js/webcomponents/absolute-date.test.js
new file mode 100644
index 0000000..ba04451
--- /dev/null
+++ b/web_src/js/webcomponents/absolute-date.test.js
@@ -0,0 +1,15 @@
+import {toAbsoluteLocaleDate} from './absolute-date.js';
+
+test('toAbsoluteLocaleDate', () => {
+ expect(toAbsoluteLocaleDate('2024-03-15', 'en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })).toEqual('March 15, 2024');
+
+ expect(toAbsoluteLocaleDate('2024-03-15', 'de-DE', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ })).toEqual('15. März 2024');
+});
diff --git a/web_src/js/webcomponents/index.js b/web_src/js/webcomponents/index.js
new file mode 100644
index 0000000..7cec9da
--- /dev/null
+++ b/web_src/js/webcomponents/index.js
@@ -0,0 +1,5 @@
+import './polyfills.js';
+import '@github/relative-time-element';
+import './origin-url.js';
+import './overflow-menu.js';
+import './absolute-date.js';
diff --git a/web_src/js/webcomponents/origin-url.js b/web_src/js/webcomponents/origin-url.js
new file mode 100644
index 0000000..09aa77f
--- /dev/null
+++ b/web_src/js/webcomponents/origin-url.js
@@ -0,0 +1,22 @@
+// Convert an absolute or relative URL to an absolute URL with the current origin. It only
+// processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'.
+// NOTE: Keep this function in sync with clone_script.tmpl
+export function toOriginUrl(urlStr) {
+ try {
+ if (urlStr.startsWith('http://') || urlStr.startsWith('https://') || urlStr.startsWith('/')) {
+ const {origin, protocol, hostname, port} = window.location;
+ const url = new URL(urlStr, origin);
+ url.protocol = protocol;
+ url.hostname = hostname;
+ url.port = port || (protocol === 'https:' ? '443' : '80');
+ return url.toString();
+ }
+ } catch {}
+ return urlStr;
+}
+
+window.customElements.define('origin-url', class extends HTMLElement {
+ connectedCallback() {
+ this.textContent = toOriginUrl(this.getAttribute('data-url'));
+ }
+});
diff --git a/web_src/js/webcomponents/origin-url.test.js b/web_src/js/webcomponents/origin-url.test.js
new file mode 100644
index 0000000..3b2ab89
--- /dev/null
+++ b/web_src/js/webcomponents/origin-url.test.js
@@ -0,0 +1,17 @@
+import {toOriginUrl} from './origin-url.js';
+
+test('toOriginUrl', () => {
+ const oldLocation = window.location;
+ for (const origin of ['https://example.com', 'https://example.com:3000']) {
+ window.location = new URL(`${origin}/`);
+ expect(toOriginUrl('/')).toEqual(`${origin}/`);
+ expect(toOriginUrl('/org/repo.git')).toEqual(`${origin}/org/repo.git`);
+ expect(toOriginUrl('https://another.com')).toEqual(`${origin}/`);
+ expect(toOriginUrl('https://another.com/')).toEqual(`${origin}/`);
+ expect(toOriginUrl('https://another.com/org/repo.git')).toEqual(`${origin}/org/repo.git`);
+ expect(toOriginUrl('https://another.com:4000')).toEqual(`${origin}/`);
+ expect(toOriginUrl('https://another.com:4000/')).toEqual(`${origin}/`);
+ expect(toOriginUrl('https://another.com:4000/org/repo.git')).toEqual(`${origin}/org/repo.git`);
+ }
+ window.location = oldLocation;
+});
diff --git a/web_src/js/webcomponents/overflow-menu.js b/web_src/js/webcomponents/overflow-menu.js
new file mode 100644
index 0000000..a69ce16
--- /dev/null
+++ b/web_src/js/webcomponents/overflow-menu.js
@@ -0,0 +1,220 @@
+import {throttle} from 'throttle-debounce';
+import {createTippy} from '../modules/tippy.js';
+import {isDocumentFragmentOrElementNode} from '../utils/dom.js';
+import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
+
+window.customElements.define('overflow-menu', class extends HTMLElement {
+ updateItems = throttle(100, () => {
+ if (!this.tippyContent) {
+ const div = document.createElement('div');
+ div.classList.add('tippy-target');
+ div.tabIndex = '-1'; // for initial focus, programmatic focus only
+ div.addEventListener('keydown', (e) => {
+ if (e.key === 'Tab') {
+ const items = this.tippyContent.querySelectorAll('[role="menuitem"]');
+ if (e.shiftKey) {
+ if (document.activeElement === items[0]) {
+ e.preventDefault();
+ items[items.length - 1].focus();
+ }
+ } else {
+ if (document.activeElement === items[items.length - 1]) {
+ e.preventDefault();
+ items[0].focus();
+ }
+ }
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ e.stopPropagation();
+ this.button._tippy.hide();
+ this.button.focus();
+ } else if (e.key === ' ' || e.code === 'Enter') {
+ if (document.activeElement?.matches('[role="menuitem"]')) {
+ e.preventDefault();
+ e.stopPropagation();
+ document.activeElement.click();
+ }
+ } else if (e.key === 'ArrowDown') {
+ if (document.activeElement?.matches('.tippy-target')) {
+ e.preventDefault();
+ e.stopPropagation();
+ document.activeElement.querySelector('[role="menuitem"]:first-of-type').focus();
+ } else if (document.activeElement?.matches('[role="menuitem"]')) {
+ e.preventDefault();
+ e.stopPropagation();
+ document.activeElement.nextElementSibling?.focus();
+ }
+ } else if (e.key === 'ArrowUp') {
+ if (document.activeElement?.matches('.tippy-target')) {
+ e.preventDefault();
+ e.stopPropagation();
+ document.activeElement.querySelector('[role="menuitem"]:last-of-type').focus();
+ } else if (document.activeElement?.matches('[role="menuitem"]')) {
+ e.preventDefault();
+ e.stopPropagation();
+ document.activeElement.previousElementSibling?.focus();
+ }
+ }
+ });
+ this.append(div);
+ this.tippyContent = div;
+ }
+
+ // move items in tippy back into the menu items for subsequent measurement
+ for (const item of this.tippyItems || []) {
+ this.menuItemsEl.append(item);
+ }
+
+ // measure which items are partially outside the element and move them into the button menu
+ this.tippyItems = [];
+ const menuRight = this.offsetLeft + this.offsetWidth;
+ const menuItems = this.menuItemsEl.querySelectorAll('.item');
+ const settingItem = this.menuItemsEl.querySelector('#settings-btn');
+ for (const item of menuItems) {
+ const itemRight = item.offsetLeft + item.offsetWidth;
+ // Width of the settings button plus a small value to get the next item to the left if there is directly one
+ // If no setting button is in the menu the default threshold is 38 - roughly the width of .overflow-menu-button
+ const overflowBtnThreshold = 38;
+ const threshold = settingItem?.offsetWidth ?? overflowBtnThreshold;
+ // If we have a settings item on the right-hand side, we must also check if the first,
+ // possibly overflowing item would still fit on the left-hand side of the overflow menu
+ // If not, it must be added to the array (twice). The duplicate is removed with the shift.
+ if (settingItem && !this.tippyItems?.length && item !== settingItem && menuRight - itemRight < overflowBtnThreshold) {
+ this.tippyItems.push(settingItem);
+ }
+ if (menuRight - itemRight < threshold) {
+ this.tippyItems.push(item);
+ }
+ }
+
+ // Special handling for settings button on right. Only done if a setting item is present
+ if (settingItem) {
+ // If less than 2 items overflow, remove all items (only settings "overflowed" - because it's on the right side)
+ if (this.tippyItems?.length < 2) {
+ this.tippyItems = [];
+ } else {
+ // Remove the first item of the list, because we have always one item more in the array due to the big threshold above
+ this.tippyItems.shift();
+ }
+ }
+
+ // if there are no overflown items, remove any previously created button
+ if (!this.tippyItems?.length) {
+ const btn = this.querySelector('.overflow-menu-button');
+ btn?._tippy?.destroy();
+ btn?.remove();
+ return;
+ }
+
+ // remove aria role from items that moved from tippy to menu
+ for (const item of menuItems) {
+ if (!this.tippyItems.includes(item)) {
+ item.removeAttribute('role');
+ }
+ }
+
+ // move all items that overflow into tippy
+ for (const item of this.tippyItems) {
+ item.setAttribute('role', 'menuitem');
+ this.tippyContent.append(item);
+ }
+
+ // update existing tippy
+ if (this.button?._tippy) {
+ this.button._tippy.setContent(this.tippyContent);
+ return;
+ }
+
+ // create button initially
+ const btn = document.createElement('button');
+ btn.classList.add('overflow-menu-button', 'btn', 'tw-px-2', 'hover:tw-text-text-dark');
+ btn.setAttribute('aria-label', window.config.i18n.more_items);
+ btn.innerHTML = octiconKebabHorizontal;
+ this.append(btn);
+ this.button = btn;
+
+ createTippy(btn, {
+ trigger: 'click',
+ hideOnClick: true,
+ interactive: true,
+ placement: 'bottom-end',
+ role: 'menu',
+ content: this.tippyContent,
+ onShow: () => { // FIXME: onShown doesn't work (never be called)
+ setTimeout(() => {
+ this.tippyContent.focus();
+ }, 0);
+ },
+ });
+ });
+
+ init() {
+ // for horizontal menus where fomantic boldens active items, prevent this bold text from
+ // enlarging the menu's active item replacing the text node with a div that renders a
+ // invisible pseudo-element that enlarges the box.
+ if (this.matches('.ui.secondary.pointing.menu, .ui.tabular.menu')) {
+ for (const item of this.querySelectorAll('.item')) {
+ for (const child of item.childNodes) {
+ if (child.nodeType === Node.TEXT_NODE) {
+ const text = child.textContent.trim(); // whitespace is insignificant inside flexbox
+ if (!text) continue;
+ const span = document.createElement('span');
+ span.classList.add('resize-for-semibold');
+ span.setAttribute('data-text', text);
+ span.textContent = text;
+ child.replaceWith(span);
+ }
+ }
+ }
+ }
+
+ // ResizeObserver triggers on initial render, so we don't manually call `updateItems` here which
+ // also avoids a full-page FOUC in Firefox that happens when `updateItems` is called too soon.
+ this.resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const newWidth = entry.contentBoxSize[0].inlineSize;
+ if (newWidth !== this.lastWidth) {
+ requestAnimationFrame(() => {
+ this.updateItems();
+ });
+ this.lastWidth = newWidth;
+ }
+ }
+ });
+ this.resizeObserver.observe(this);
+ }
+
+ connectedCallback() {
+ this.setAttribute('role', 'navigation');
+
+ // check whether the mandatory `.overflow-menu-items` element is present initially which happens
+ // with Vue which renders differently than browsers. If it's not there, like in the case of browser
+ // template rendering, wait for its addition.
+ // The eslint rule is not sophisticated enough or aware of this problem, see
+ // https://github.com/43081j/eslint-plugin-wc/pull/130
+ const menuItemsEl = this.querySelector('.overflow-menu-items'); // eslint-disable-line wc/no-child-traversal-in-connectedcallback
+ if (menuItemsEl) {
+ this.menuItemsEl = menuItemsEl;
+ this.init();
+ } else {
+ this.mutationObserver = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ for (const node of mutation.addedNodes) {
+ if (!isDocumentFragmentOrElementNode(node)) continue;
+ if (node.classList.contains('overflow-menu-items')) {
+ this.menuItemsEl = node;
+ this.mutationObserver?.disconnect();
+ this.init();
+ }
+ }
+ }
+ });
+ this.mutationObserver.observe(this, {childList: true});
+ }
+ }
+
+ disconnectedCallback() {
+ this.mutationObserver?.disconnect();
+ this.resizeObserver?.disconnect();
+ }
+});
diff --git a/web_src/js/webcomponents/polyfills.js b/web_src/js/webcomponents/polyfills.js
new file mode 100644
index 0000000..38f50fa
--- /dev/null
+++ b/web_src/js/webcomponents/polyfills.js
@@ -0,0 +1,17 @@
+try {
+ // some browsers like PaleMoon don't have full support for Intl.NumberFormat, so do the minimum polyfill to support "relative-time-element"
+ // https://repo.palemoon.org/MoonchildProductions/UXP/issues/2289
+ new Intl.NumberFormat('en', {style: 'unit', unit: 'minute'}).format(1);
+} catch {
+ const intlNumberFormat = Intl.NumberFormat;
+ Intl.NumberFormat = function(locales, options) {
+ if (options.style === 'unit') {
+ return {
+ format(value) {
+ return ` ${value} ${options.unit}`;
+ },
+ };
+ }
+ return intlNumberFormat(locales, options);
+ };
+}