summaryrefslogtreecommitdiffstats
path: root/web_src/js/components
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
commitdd136858f1ea40ad3c94191d647487fa4f31926c (patch)
tree58fec94a7b2a12510c9664b21793f1ed560c6518 /web_src/js/components
parentInitial commit. (diff)
downloadforgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.tar.xz
forgejo-dd136858f1ea40ad3c94191d647487fa4f31926c.zip
Adding upstream version 9.0.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--web_src/js/components/.eslintrc.yaml21
-rw-r--r--web_src/js/components/ActionRunStatus.vue39
-rw-r--r--web_src/js/components/ActivityHeatmap.vue83
-rw-r--r--web_src/js/components/ContextPopup.test.js163
-rw-r--r--web_src/js/components/ContextPopup.vue130
-rw-r--r--web_src/js/components/DashboardRepoList.vue537
-rw-r--r--web_src/js/components/DiffCommitSelector.vue306
-rw-r--r--web_src/js/components/DiffFileList.vue58
-rw-r--r--web_src/js/components/DiffFileTree.vue144
-rw-r--r--web_src/js/components/DiffFileTreeItem.vue97
-rw-r--r--web_src/js/components/PullRequestMergeForm.vue252
-rw-r--r--web_src/js/components/RepoActionView.test.js105
-rw-r--r--web_src/js/components/RepoActionView.vue905
-rw-r--r--web_src/js/components/RepoActivityTopAuthors.vue164
-rw-r--r--web_src/js/components/RepoBranchTagSelector.vue357
-rw-r--r--web_src/js/components/RepoCodeFrequency.vue172
-rw-r--r--web_src/js/components/RepoContributors.vue431
-rw-r--r--web_src/js/components/RepoRecentCommits.vue149
-rw-r--r--web_src/js/components/ScopedAccessTokenSelector.vue115
19 files changed, 4228 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)">&nbsp;</span>
+ <a class="file tw-font-mono" :href="'#diff-' + file.NameHash">{{ file.Name }}</a>
+ </li>
+ <li v-if="store.isIncomplete" class="tw-pt-1">
+ <span class="file tw-flex tw-items-center tw-justify-between">{{ store.tooManyFilesMessage }}
+ <a :class="['ui', 'basic', 'tiny', 'button', store.isLoadingNewData ? 'disabled' : '']" @click.stop="loadMoreData">{{ store.showMoreMessage }}</a>
+ </span>
+ </li>
+ </ol>
+</template>
diff --git a/web_src/js/components/DiffFileTree.vue b/web_src/js/components/DiffFileTree.vue
new file mode 100644
index 0000000..cddfee1
--- /dev/null
+++ b/web_src/js/components/DiffFileTree.vue
@@ -0,0 +1,144 @@
+<script>
+import DiffFileTreeItem from './DiffFileTreeItem.vue';
+import {loadMoreFiles} from '../features/repo-diff.js';
+import {toggleElem} from '../utils/dom.js';
+import {diffTreeStore} from '../modules/stores.js';
+import {setFileFolding} from '../features/file-fold.js';
+
+const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
+
+export default {
+ components: {DiffFileTreeItem},
+ data: () => {
+ return {store: diffTreeStore()};
+ },
+ computed: {
+ fileTree() {
+ const result = [];
+ for (const file of this.store.files) {
+ // Split file into directories
+ const splits = file.Name.split('/');
+ let index = 0;
+ let parent = null;
+ let isFile = false;
+ for (const split of splits) {
+ index += 1;
+ // reached the end
+ if (index === splits.length) {
+ isFile = true;
+ }
+ let newParent = {
+ name: split,
+ children: [],
+ isFile,
+ };
+
+ if (isFile === true) {
+ newParent.file = file;
+ }
+
+ if (parent) {
+ // check if the folder already exists
+ const existingFolder = parent.children.find(
+ (x) => x.name === split,
+ );
+ if (existingFolder) {
+ newParent = existingFolder;
+ } else {
+ parent.children.push(newParent);
+ }
+ } else {
+ const existingFolder = result.find((x) => x.name === split);
+ if (existingFolder) {
+ newParent = existingFolder;
+ } else {
+ result.push(newParent);
+ }
+ }
+ parent = newParent;
+ }
+ }
+ const mergeChildIfOnlyOneDir = (entries) => {
+ for (const entry of entries) {
+ if (entry.children) {
+ mergeChildIfOnlyOneDir(entry.children);
+ }
+ if (entry.children.length === 1 && entry.children[0].isFile === false) {
+ // Merge it to the parent
+ entry.name = `${entry.name}/${entry.children[0].name}`;
+ entry.children = entry.children[0].children;
+ }
+ }
+ };
+ // Merge folders with just a folder as children in order to
+ // reduce the depth of our tree.
+ mergeChildIfOnlyOneDir(result);
+ return result;
+ },
+ },
+ mounted() {
+ // Default to true if unset
+ this.store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
+ document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', this.toggleVisibility);
+
+ this.hashChangeListener = () => {
+ this.store.selectedItem = window.location.hash;
+ this.expandSelectedFile();
+ };
+ this.hashChangeListener();
+ window.addEventListener('hashchange', this.hashChangeListener);
+ },
+ unmounted() {
+ document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', this.toggleVisibility);
+ window.removeEventListener('hashchange', this.hashChangeListener);
+ },
+ methods: {
+ expandSelectedFile() {
+ // expand file if the selected file is folded
+ if (this.store.selectedItem) {
+ const box = document.querySelector(this.store.selectedItem);
+ const folded = box?.getAttribute('data-folded') === 'true';
+ if (folded) setFileFolding(box, box.querySelector('.fold-file'), false);
+ }
+ },
+ toggleVisibility() {
+ this.updateVisibility(!this.store.fileTreeIsVisible);
+ },
+ updateVisibility(visible) {
+ this.store.fileTreeIsVisible = visible;
+ localStorage.setItem(LOCAL_STORAGE_KEY, this.store.fileTreeIsVisible);
+ this.updateState(this.store.fileTreeIsVisible);
+ },
+ updateState(visible) {
+ const btn = document.querySelector('.diff-toggle-file-tree-button');
+ const [toShow, toHide] = btn.querySelectorAll('.icon');
+ const tree = document.getElementById('diff-file-tree');
+ const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text');
+ btn.setAttribute('data-tooltip-content', newTooltip);
+ toggleElem(tree, visible);
+ toggleElem(toShow, !visible);
+ toggleElem(toHide, visible);
+ },
+ loadMoreData() {
+ loadMoreFiles(this.store.linkLoadMore);
+ },
+ },
+};
+</script>
+<template>
+ <div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
+ <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
+ <DiffFileTreeItem v-for="item in fileTree" :key="item.name" :item="item"/>
+ <div v-if="store.isIncomplete" class="tw-pt-1">
+ <a :class="['ui', 'basic', 'tiny', 'button', store.isLoadingNewData ? 'disabled' : '']" @click.stop="loadMoreData">{{ store.showMoreMessage }}</a>
+ </div>
+ </div>
+</template>
+<style scoped>
+.diff-file-tree-items {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ margin-right: .5rem;
+}
+</style>
diff --git a/web_src/js/components/DiffFileTreeItem.vue b/web_src/js/components/DiffFileTreeItem.vue
new file mode 100644
index 0000000..0f6e543
--- /dev/null
+++ b/web_src/js/components/DiffFileTreeItem.vue
@@ -0,0 +1,97 @@
+<script>
+import {SvgIcon} from '../svg.js';
+import {diffTreeStore} from '../modules/stores.js';
+
+export default {
+ components: {SvgIcon},
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ },
+ data: () => ({
+ store: diffTreeStore(),
+ collapsed: false,
+ }),
+ methods: {
+ getIconForDiffType(pType) {
+ const diffTypes = {
+ 1: {name: 'octicon-diff-added', classes: ['text', 'green']},
+ 2: {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
+ 3: {name: 'octicon-diff-removed', classes: ['text', 'red']},
+ 4: {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
+ 5: {name: 'octicon-diff-renamed', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
+ };
+ return diffTypes[pType];
+ },
+ },
+};
+</script>
+<template>
+ <!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"-->
+ <a
+ v-if="item.isFile" class="item-file"
+ :class="{'selected': store.selectedItem === '#diff-' + item.file.NameHash, 'viewed': item.file.IsViewed}"
+ :title="item.name" :href="'#diff-' + item.file.NameHash"
+ >
+ <!-- file -->
+ <SvgIcon name="octicon-file"/>
+ <span class="gt-ellipsis tw-flex-1">{{ item.name }}</span>
+ <SvgIcon :name="getIconForDiffType(item.file.Type).name" :class="getIconForDiffType(item.file.Type).classes"/>
+ </a>
+ <div v-else class="item-directory" :title="item.name" @click.stop="collapsed = !collapsed">
+ <!-- directory -->
+ <SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/>
+ <SvgIcon class="text primary" name="octicon-file-directory-fill"/>
+ <span class="gt-ellipsis">{{ item.name }}</span>
+ </div>
+
+ <div v-if="item.children?.length" v-show="!collapsed" class="sub-items">
+ <DiffFileTreeItem v-for="childItem in item.children" :key="childItem.name" :item="childItem"/>
+ </div>
+</template>
+<style scoped>
+a, a:hover {
+ text-decoration: none;
+ color: var(--color-text);
+}
+
+.sub-items {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ margin-left: 13px;
+ border-left: 1px solid var(--color-secondary);
+}
+
+.sub-items .item-file {
+ padding-left: 18px;
+}
+
+.item-file.selected {
+ color: var(--color-text);
+ background: var(--color-active);
+ border-radius: 4px;
+}
+
+.item-file.viewed {
+ color: var(--color-text-light-3);
+}
+
+.item-file,
+.item-directory {
+ display: flex;
+ align-items: center;
+ gap: 0.25em;
+ padding: 3px 6px;
+}
+
+.item-file:hover,
+.item-directory:hover {
+ color: var(--color-text);
+ background: var(--color-hover);
+ border-radius: 4px;
+ cursor: pointer;
+}
+</style>
diff --git a/web_src/js/components/PullRequestMergeForm.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>