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/components | |
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 'web_src/js/components')
20 files changed, 4262 insertions, 0 deletions
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> |