diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /web_src/js | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
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)"> </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}"> + • ${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('<script>'); + 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); + }; +} |