gitea source for verification 2026-05-22
This commit is contained in:
30
web_src/js/components/ActionRunStatus.vue
Normal file
30
web_src/js/components/ActionRunStatus.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<!-- 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 lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
status: 'success' | 'skipped' | 'waiting' | 'blocked' | 'running' | 'failure' | 'cancelled' | 'unknown',
|
||||
size?: number,
|
||||
className?: string,
|
||||
localeStatus?: string,
|
||||
}>(), {
|
||||
size: 16,
|
||||
className: '',
|
||||
localeStatus: undefined,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :data-tooltip-content="localeStatus ?? status" v-if="status">
|
||||
<SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class="className" v-if="status === 'success'"/>
|
||||
<SvgIcon name="octicon-skip" class="text grey" :size="size" :class="className" v-else-if="status === 'skipped'"/>
|
||||
<SvgIcon name="octicon-stop" class="text grey" :size="size" :class="className" v-else-if="status === 'cancelled'"/>
|
||||
<SvgIcon name="octicon-circle" class="text grey" :size="size" :class="className" v-else-if="status === 'waiting'"/>
|
||||
<SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/>
|
||||
<SvgIcon name="gitea-running" class="text yellow" :size="size" :class="'rotate-clockwise ' + className" v-else-if="status === 'running'"/>
|
||||
<SvgIcon name="octicon-x-circle-fill" class="text red" :size="size" v-else/><!-- failure, unknown -->
|
||||
</span>
|
||||
</template>
|
||||
69
web_src/js/components/ActivityHeatmap.vue
Normal file
69
web_src/js/components/ActivityHeatmap.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts" setup>
|
||||
// TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged
|
||||
import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap';
|
||||
import {onMounted, shallowRef} from 'vue';
|
||||
import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
|
||||
|
||||
defineProps<{
|
||||
values?: HeatmapValue[];
|
||||
locale: {
|
||||
textTotalContributions: string;
|
||||
heatMapLocale: Partial<HeatmapLocale>;
|
||||
noDataText: string;
|
||||
tooltipUnit: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const 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)',
|
||||
];
|
||||
|
||||
const endDate = shallowRef(new Date());
|
||||
|
||||
onMounted(() => {
|
||||
// work around issue with first legend color being rendered twice and legend cut off
|
||||
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper');
|
||||
legend.setAttribute('viewBox', '12 0 80 10');
|
||||
legend.style.marginRight = '-12px';
|
||||
});
|
||||
|
||||
function handleDayClick(e: Event & {date: Date}) {
|
||||
// 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.getTime() - (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.textTotalContributions }}
|
||||
</div>
|
||||
<calendar-heatmap
|
||||
:locale="locale.heatMapLocale"
|
||||
:no-data-text="locale.noDataText"
|
||||
:tooltip-unit="locale.tooltipUnit"
|
||||
:end-date="endDate"
|
||||
:values="values"
|
||||
:range-color="colorRange"
|
||||
@day-click="handleDayClick($event)"
|
||||
:tippy-props="{theme: 'tooltip'}"
|
||||
/>
|
||||
</template>
|
||||
67
web_src/js/components/ContextPopup.vue
Normal file
67
web_src/js/components/ContextPopup.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {getIssueColor, getIssueIcon} from '../features/issue.ts';
|
||||
import {computed, onMounted, shallowRef} from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
repoLink: string,
|
||||
loadIssueInfoUrl: string,
|
||||
}>();
|
||||
|
||||
const loading = shallowRef(false);
|
||||
const issue = shallowRef(null);
|
||||
const renderedLabels = shallowRef('');
|
||||
const errorMessage = shallowRef(null);
|
||||
|
||||
const createdAt = computed(() => {
|
||||
return new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
|
||||
});
|
||||
|
||||
const body = computed(() => {
|
||||
const body = issue.value.body.replace(/\n+/g, ' ');
|
||||
return body.length > 85 ? `${body.substring(0, 85)}…` : body;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
errorMessage.value = null;
|
||||
try {
|
||||
const resp = await GET(props.loadIssueInfoUrl);
|
||||
if (!resp.ok) {
|
||||
errorMessage.value = resp.status ? resp.statusText : 'Unknown network error';
|
||||
return;
|
||||
}
|
||||
const respJson = await resp.json();
|
||||
issue.value = respJson.convertedIssue;
|
||||
renderedLabels.value = respJson.renderedLabels;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tw-p-4">
|
||||
<div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
|
||||
<div v-else-if="issue" class="tw-flex tw-flex-col tw-gap-2">
|
||||
<div class="tw-text-12">
|
||||
<a :href="repoLink" class="muted">{{ issue.repository.full_name }}</a>
|
||||
on {{ createdAt }}
|
||||
</div>
|
||||
<div class="flex-text-block">
|
||||
<svg-icon :name="getIssueIcon(issue)" :class="['text', getIssueColor(issue)]"/>
|
||||
<span class="issue-title tw-font-semibold tw-break-anywhere">
|
||||
{{ issue.title }}
|
||||
<span class="index">#{{ issue.number }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="body">{{ body }}</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-if="issue.labels.length" v-html="renderedLabels"/>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
580
web_src/js/components/DashboardRepoList.vue
Normal file
580
web_src/js/components/DashboardRepoList.vue
Normal file
@@ -0,0 +1,580 @@
|
||||
<script lang="ts">
|
||||
import {nextTick, defineComponent} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
|
||||
const {appSubUrl, assetUrlPrefix, pageData} = window.config;
|
||||
|
||||
type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning' | 'skipped';
|
||||
|
||||
type CommitStatusMap = {
|
||||
[status in CommitStatus]: {
|
||||
name: string,
|
||||
color: string,
|
||||
};
|
||||
};
|
||||
|
||||
// make sure this matches templates/repo/commit_status.tmpl
|
||||
const commitStatus: CommitStatusMap = {
|
||||
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'},
|
||||
skipped: {name: 'octicon-skip', color: 'grey'},
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
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: null,
|
||||
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.querySelector('#dashboard-repo-list');
|
||||
this.changeReposFilter(this.reposFilter);
|
||||
fomanticQuery(el.querySelector('.ui.dropdown')).dropdown();
|
||||
|
||||
this.textArchivedFilterTitles = {
|
||||
'archived': this.textShowOnlyArchived,
|
||||
'unarchived': this.textShowOnlyUnarchived,
|
||||
'both': this.textShowBothArchivedUnarchived,
|
||||
};
|
||||
|
||||
this.textPrivateFilterTitles = {
|
||||
'private': this.textShowOnlyPrivate,
|
||||
'public': this.textShowOnlyPublic,
|
||||
'both': this.textShowBothPrivatePublic,
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
changeTab(tab: string) {
|
||||
this.tab = tab;
|
||||
this.updateHistory();
|
||||
},
|
||||
|
||||
changeReposFilter(filter: string) {
|
||||
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();
|
||||
},
|
||||
|
||||
async changePage(page: number) {
|
||||
if (this.isLoading) return;
|
||||
|
||||
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;
|
||||
await 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 {
|
||||
const firstLoad = this.reposTotalCount === null;
|
||||
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 = parseInt(response.headers.get('X-Total-Count') ?? '0');
|
||||
}
|
||||
if (firstLoad && this.reposTotalCount) {
|
||||
nextTick(() => {
|
||||
// MDN: If there's no focused element, this is the Document.body or Document.documentElement.
|
||||
if ((document.activeElement === document.body || document.activeElement === document.documentElement)) {
|
||||
this.$refs.search.focus({preventScroll: true});
|
||||
}
|
||||
});
|
||||
}
|
||||
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: any) => {
|
||||
return {
|
||||
...webSearchRepo.repository,
|
||||
latest_commit_status_state: webSearchRepo.latest_commit_status?.State, // if latest_commit_status is null, it means there is no commit status
|
||||
latest_commit_status_state_link: webSearchRepo.latest_commit_status?.TargetURL,
|
||||
locale_latest_commit_status_state: webSearchRepo.locale_latest_commit_status,
|
||||
};
|
||||
});
|
||||
const count = Number(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: any) {
|
||||
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: CommitStatus) {
|
||||
return commitStatus[status].name;
|
||||
},
|
||||
|
||||
statusColor(status: CommitStatus) {
|
||||
return commitStatus[status].color;
|
||||
},
|
||||
|
||||
async reposFilterKeyControl(e: KeyboardEvent) {
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
document.querySelector<HTMLAnchorElement>('.repo-owner-name-list li.active a')?.click();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (this.activeIndex > 0) {
|
||||
this.activeIndex--;
|
||||
} else if (this.page > 1) {
|
||||
await 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;
|
||||
await this.changePage(this.page + 1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (this.page < this.finalPage) {
|
||||
await this.changePage(this.page + 1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (this.page > 1) {
|
||||
await this.changePage(this.page - 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (this.activeIndex === -1 || this.activeIndex > this.repos.length - 1) {
|
||||
this.activeIndex = 0;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="!isOrganization" class="ui two item menu">
|
||||
<a :class="{item: true, active: tab === 'repos'}" @click="changeTab('repos')">{{ textRepository }}</a>
|
||||
<a :class="{item: true, active: tab === 'organizations'}" @click="changeTab('organizations')">{{ textOrganization }}</a>
|
||||
</div>
|
||||
<div v-show="tab === 'repos'" class="ui tab active list dashboard-repos">
|
||||
<h4 class="ui top attached header tw-flex tw-items-center">
|
||||
<div class="tw-flex-1 tw-flex tw-items-center">
|
||||
{{ textMyRepos }}
|
||||
<span v-if="reposTotalCount" class="ui grey label tw-ml-2">{{ reposTotalCount }}</span>
|
||||
</div>
|
||||
<a class="tw-flex tw-items-center muted" :href="subUrl + '/repo/create' + (isOrganization ? '?org=' + organizationId : '')" :data-tooltip-content="textNewRepo">
|
||||
<svg-icon name="octicon-plus"/>
|
||||
</a>
|
||||
</h4>
|
||||
<div v-if="!reposTotalCount" class="ui attached segment">
|
||||
<div v-if="!isLoading" class="empty-repo-or-org">
|
||||
<svg-icon name="octicon-git-branch" :size="24"/>
|
||||
<p>{{ textNoRepo }}</p>
|
||||
</div>
|
||||
<!-- using the loading indicator here will cause more (unnecessary) page flickers, so at the moment, not use the loading indicator -->
|
||||
<!-- <div v-else class="is-loading loading-icon-2px tw-min-h-16"/> -->
|
||||
</div>
|
||||
<div v-else class="ui 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 "tw-pointer-events-none" is necessary to prevent the checkbox from handling user's input,
|
||||
otherwise if the "input" handles click event for intermediate status, it breaks the internal state-->
|
||||
<input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxArchivedFilterProps">
|
||||
<label>
|
||||
<svg-icon name="octicon-archive" :size="16" class="tw-mr-1"/>
|
||||
{{ textShowArchived }}
|
||||
</label>
|
||||
</div>
|
||||
</a>
|
||||
<a class="item" @click="togglePrivateFilter()">
|
||||
<div class="ui checkbox" ref="checkboxPrivateFilter" :title="checkboxPrivateFilterTitle">
|
||||
<input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxPrivateFilterProps">
|
||||
<label>
|
||||
<svg-icon name="octicon-lock" :size="16" class="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="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_state" :href="repo.latest_commit_status_state_link || null" :data-tooltip-content="repo.locale_latest_commit_status_state">
|
||||
<!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl -->
|
||||
<svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class="'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="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" class="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="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="tw-ml-1"/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isOrganization" v-show="tab === 'organizations'" class="ui tab active list dashboard-orgs">
|
||||
<h4 class="ui top attached header tw-flex tw-items-center">
|
||||
<div class="tw-flex-1 tw-flex tw-items-center">
|
||||
{{ textMyOrgs }}
|
||||
<span class="ui grey label tw-ml-2">{{ organizationsTotalCount }}</span>
|
||||
</div>
|
||||
<a class="tw-flex tw-items-center muted" v-if="canCreateOrganization" :href="subUrl + '/org/create'" :data-tooltip-content="textNewOrg">
|
||||
<svg-icon name="octicon-plus"/>
|
||||
</a>
|
||||
</h4>
|
||||
<div v-if="!organizations.length" class="ui attached segment">
|
||||
<div class="empty-repo-or-org">
|
||||
<svg-icon name="octicon-organization" :size="24"/>
|
||||
<p>{{ textNoOrg }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else 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="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="repo-list-icon"/>
|
||||
<div class="text truncate">{{ org.full_name ? `${org.full_name} (${org.name})` : 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="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 {
|
||||
margin-top: 0 !important;
|
||||
border-bottom-width: 0 !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);
|
||||
}
|
||||
|
||||
.empty-repo-or-org {
|
||||
margin-top: 1em;
|
||||
text-align: center;
|
||||
color: var(--color-placeholder-text);
|
||||
}
|
||||
|
||||
.empty-repo-or-org p {
|
||||
margin: 1em auto;
|
||||
}
|
||||
</style>
|
||||
333
web_src/js/components/DiffCommitSelector.vue
Normal file
333
web_src/js/components/DiffCommitSelector.vue
Normal file
@@ -0,0 +1,333 @@
|
||||
<script lang="ts">
|
||||
import {defineComponent} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {generateElemId} from '../utils/dom.ts';
|
||||
|
||||
type Commit = {
|
||||
id: string,
|
||||
hovered: boolean,
|
||||
selected: boolean,
|
||||
summary: string,
|
||||
committer_or_author_name: string,
|
||||
time: string,
|
||||
short_sha: string,
|
||||
}
|
||||
|
||||
type CommitListResult = {
|
||||
commits: Array<Commit>,
|
||||
last_review_commit_sha: string,
|
||||
locale: Record<string, string>,
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {SvgIcon},
|
||||
data: () => {
|
||||
const el = document.querySelector('#diff-commit-select');
|
||||
return {
|
||||
menuVisible: false,
|
||||
isLoading: false,
|
||||
queryParams: el.getAttribute('data-queryparams'),
|
||||
issueLink: el.getAttribute('data-issuelink'),
|
||||
locale: {
|
||||
filter_changes_by_commit: el.getAttribute('data-filter_changes_by_commit'),
|
||||
} as Record<string, string>,
|
||||
mergeBase: el.getAttribute('data-merge-base'),
|
||||
commits: [] as Array<Commit>,
|
||||
hoverActivated: false,
|
||||
lastReviewCommitSha: '',
|
||||
uniqueIdMenu: generateElemId('diff-commit-selector-menu-'),
|
||||
uniqueIdShowAll: generateElemId('diff-commit-selector-show-all-'),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
commitsSinceLastReview() {
|
||||
if (this.lastReviewCommitSha) {
|
||||
return this.commits.length - this.commits.findIndex((x) => x.id === this.lastReviewCommitSha) - 1;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
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: MouseEvent) {
|
||||
// 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: KeyboardEvent) {
|
||||
if (!this.menuVisible) return;
|
||||
const item = document.activeElement as HTMLElement;
|
||||
if (!this.$el.contains(item)) return;
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': // select next element
|
||||
event.preventDefault();
|
||||
this.focusElem(item.nextElementSibling as HTMLElement, item);
|
||||
break;
|
||||
case 'ArrowUp': // select previous element
|
||||
event.preventDefault();
|
||||
this.focusElem(item.previousElementSibling as HTMLElement, item);
|
||||
break;
|
||||
case 'Escape': // close menu
|
||||
event.preventDefault();
|
||||
item.tabIndex = -1;
|
||||
this.toggleMenu();
|
||||
break;
|
||||
}
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||
const item = document.activeElement; // try to highlight the selected commits
|
||||
const commitIdx = item?.matches('.item') ? item.getAttribute('data-commit-idx') : null;
|
||||
if (commitIdx) this.highlight(this.commits[Number(commitIdx)]);
|
||||
}
|
||||
},
|
||||
onKeyUp(event: KeyboardEvent) {
|
||||
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: 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: HTMLElement, prevElem: HTMLElement) {
|
||||
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(() => {
|
||||
if (this.menuVisible) {
|
||||
this.focusElem(this.$refs.showAllChanges as HTMLElement, this.$refs.expandBtn as HTMLElement);
|
||||
} else {
|
||||
this.focusElem(this.$refs.expandBtn as HTMLElement, this.$refs.showAllChanges as HTMLElement);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** Load the commits to show in this dropdown */
|
||||
async fetchCommits() {
|
||||
const resp = await GET(`${this.issueLink}/commits/list`);
|
||||
const results = await resp.json() as CommitListResult;
|
||||
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.assign(`${this.issueLink}/files${this.queryParams}`);
|
||||
},
|
||||
/** Called when user clicks on since last review */
|
||||
changesSinceLastReviewClick() {
|
||||
window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1).id}${this.queryParams}`);
|
||||
},
|
||||
/** Clicking on a single commit opens this specific commit */
|
||||
commitClicked(commitId: string, newWindow = false) {
|
||||
const url = `${this.issueLink}/commits/${commitId}${this.queryParams}`;
|
||||
if (newWindow) {
|
||||
window.open(url);
|
||||
} else {
|
||||
window.location.assign(url);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When a commit is clicked while holding Shift, it enables range selection.
|
||||
* - The range selection is a half-open, half-closed range, meaning it excludes the start commit but includes the end commit.
|
||||
* - The start of the commit range is always the previous commit of the first clicked commit.
|
||||
* - If the first commit in the list is clicked, the mergeBase will be used as the start of the range instead.
|
||||
* - The second Shift-click defines the end of the range.
|
||||
* - Once both are selected, the diff view for the selected commit range will open.
|
||||
*/
|
||||
commitClickedShift(commit: Commit) {
|
||||
this.hoverActivated = !this.hoverActivated;
|
||||
commit.selected = true;
|
||||
// Second click -> determine our range and open links accordingly
|
||||
if (!this.hoverActivated) {
|
||||
// since at least one commit is selected, we can determine the range
|
||||
// find all selected commits and generate a link
|
||||
const firstSelected = this.commits.findIndex((x) => x.selected);
|
||||
const lastSelected = this.commits.findLastIndex((x) => x.selected);
|
||||
let beforeCommitID: string;
|
||||
if (firstSelected === 0) {
|
||||
beforeCommitID = this.mergeBase;
|
||||
} else {
|
||||
beforeCommitID = this.commits[firstSelected - 1].id;
|
||||
}
|
||||
const afterCommitID = this.commits[lastSelected].id;
|
||||
|
||||
if (firstSelected === lastSelected) {
|
||||
// if the start and end are the same, we show this single commit
|
||||
window.location.assign(`${this.issueLink}/commits/${afterCommitID}${this.queryParams}`);
|
||||
} else if (beforeCommitID === this.mergeBase && afterCommitID === this.commits.at(-1).id) {
|
||||
// if the first commit is selected and the last commit is selected, we show all commits
|
||||
window.location.assign(`${this.issueLink}/files${this.queryParams}`);
|
||||
} else {
|
||||
window.location.assign(`${this.issueLink}/files/${beforeCommitID}..${afterCommitID}${this.queryParams}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="ui scrolling dropdown custom diff-commit-selector">
|
||||
<button
|
||||
ref="expandBtn"
|
||||
class="ui tiny basic button"
|
||||
@click.stop="toggleMenu()"
|
||||
:data-tooltip-content="locale.filter_changes_by_commit"
|
||||
aria-haspopup="true"
|
||||
:aria-label="locale.filter_changes_by_commit"
|
||||
:aria-controls="uniqueIdMenu"
|
||||
:aria-activedescendant="uniqueIdShowAll"
|
||||
>
|
||||
<svg-icon name="octicon-git-commit"/>
|
||||
</button>
|
||||
<!-- this dropdown is not managed by Fomantic UI, so it needs some classes like "transition" explicitly -->
|
||||
<div class="left menu transition" :id="uniqueIdMenu" :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="item" :id="uniqueIdShowAll" ref="showAllChanges" 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"
|
||||
class="item" role="menuitem"
|
||||
: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, idx) in commits" :key="commit.id">
|
||||
<div
|
||||
class="item" role="menuitem"
|
||||
:class="{selected: commit.selected, hovered: commit.hovered}"
|
||||
:data-commit-idx="idx"
|
||||
@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>
|
||||
.ui.dropdown.diff-commit-selector .menu {
|
||||
margin-top: 0.25em;
|
||||
overflow-x: hidden;
|
||||
max-height: 450px;
|
||||
}
|
||||
|
||||
.ui.dropdown.diff-commit-selector .menu .loading-indicator {
|
||||
height: 200px;
|
||||
width: 350px;
|
||||
}
|
||||
|
||||
.ui.dropdown.diff-commit-selector .menu > .item,
|
||||
.ui.dropdown.diff-commit-selector .menu > .info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
line-height: 1.4;
|
||||
gap: 0.25em;
|
||||
padding: 7px 14px !important;
|
||||
}
|
||||
|
||||
.ui.dropdown.diff-commit-selector .menu > .item:not(:first-child),
|
||||
.ui.dropdown.diff-commit-selector .menu > .info:not(:first-child) {
|
||||
border-top: 1px solid var(--color-secondary) !important;
|
||||
}
|
||||
|
||||
.ui.dropdown.diff-commit-selector .menu > .item:focus {
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
.ui.dropdown.diff-commit-selector .menu > .item.hovered {
|
||||
background-color: var(--color-small-accent);
|
||||
}
|
||||
|
||||
.ui.dropdown.diff-commit-selector .menu > .item.selected {
|
||||
background-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.ui.dropdown.diff-commit-selector .menu .commit-list-summary {
|
||||
max-width: min(380px, 96vw);
|
||||
}
|
||||
</style>
|
||||
76
web_src/js/components/DiffFileTree.vue
Normal file
76
web_src/js/components/DiffFileTree.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script lang="ts" setup>
|
||||
import DiffFileTreeItem from './DiffFileTreeItem.vue';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
import {diffTreeStore} from '../modules/diff-file.ts';
|
||||
import {setFileFolding} from '../features/file-fold.ts';
|
||||
import {onMounted, onUnmounted} from 'vue';
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
|
||||
|
||||
const store = diffTreeStore();
|
||||
|
||||
onMounted(() => {
|
||||
// Default to true if unset
|
||||
store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
|
||||
document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', toggleVisibility);
|
||||
|
||||
hashChangeListener();
|
||||
window.addEventListener('hashchange', hashChangeListener);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', toggleVisibility);
|
||||
window.removeEventListener('hashchange', hashChangeListener);
|
||||
});
|
||||
|
||||
function hashChangeListener() {
|
||||
store.selectedItem = window.location.hash;
|
||||
expandSelectedFile();
|
||||
}
|
||||
|
||||
function expandSelectedFile() {
|
||||
// expand file if the selected file is folded
|
||||
if (store.selectedItem) {
|
||||
const box = document.querySelector(store.selectedItem);
|
||||
const folded = box?.getAttribute('data-folded') === 'true';
|
||||
if (folded) setFileFolding(box, box.querySelector('.fold-file'), false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVisibility() {
|
||||
updateVisibility(!store.fileTreeIsVisible);
|
||||
}
|
||||
|
||||
function updateVisibility(visible: boolean) {
|
||||
store.fileTreeIsVisible = visible;
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible.toString());
|
||||
updateState(store.fileTreeIsVisible);
|
||||
}
|
||||
|
||||
function updateState(visible: boolean) {
|
||||
const btn = document.querySelector('.diff-toggle-file-tree-button');
|
||||
const [toShow, toHide] = btn.querySelectorAll('.icon');
|
||||
const tree = document.querySelector('#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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
|
||||
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
|
||||
<DiffFileTreeItem v-for="item in store.diffFileTree.TreeRoot.Children" :key="item.FullName" :item="item"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.diff-file-tree-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
</style>
|
||||
106
web_src/js/components/DiffFileTreeItem.vue
Normal file
106
web_src/js/components/DiffFileTreeItem.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon, type SvgName} from '../svg.ts';
|
||||
import {shallowRef} from 'vue';
|
||||
import {type DiffStatus, type DiffTreeEntry, diffTreeStore} from '../modules/diff-file.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
item: DiffTreeEntry,
|
||||
}>();
|
||||
|
||||
const store = diffTreeStore();
|
||||
const collapsed = shallowRef(props.item.IsViewed);
|
||||
|
||||
function getIconForDiffStatus(pType: DiffStatus) {
|
||||
const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = {
|
||||
'': {name: 'octicon-blocked', classes: ['text', 'red']}, // unknown case
|
||||
'added': {name: 'octicon-diff-added', classes: ['text', 'green']},
|
||||
'modified': {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
|
||||
'deleted': {name: 'octicon-diff-removed', classes: ['text', 'red']},
|
||||
'renamed': {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
|
||||
'copied': {name: 'octicon-diff-renamed', classes: ['text', 'green']},
|
||||
'typechange': {name: 'octicon-diff-modified', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
|
||||
};
|
||||
return diffTypes[pType] ?? diffTypes[''];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="item.EntryMode === 'tree'">
|
||||
<div class="item-directory" :class="{ 'viewed': item.IsViewed }" :title="item.DisplayName" @click.stop="collapsed = !collapsed">
|
||||
<!-- directory -->
|
||||
<SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span class="tw-contents" v-html="collapsed ? store.folderIcon : store.folderOpenIcon"/>
|
||||
<span class="gt-ellipsis">{{ item.DisplayName }}</span>
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed" class="sub-items">
|
||||
<DiffFileTreeItem v-for="childItem in item.Children" :key="childItem.DisplayName" :item="childItem"/>
|
||||
</div>
|
||||
</template>
|
||||
<a
|
||||
v-else
|
||||
class="item-file" :class="{ 'selected': store.selectedItem === '#diff-' + item.NameHash, 'viewed': item.IsViewed }"
|
||||
:title="item.DisplayName" :href="'#diff-' + item.NameHash"
|
||||
>
|
||||
<!-- file -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span class="tw-contents" v-html="item.FileIcon"/>
|
||||
<span class="gt-ellipsis tw-flex-1">{{ item.DisplayName }}</span>
|
||||
<SvgIcon
|
||||
:name="getIconForDiffStatus(item.DiffStatus).name"
|
||||
:class="getIconForDiffStatus(item.DiffStatus).classes"
|
||||
/>
|
||||
</a>
|
||||
</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,
|
||||
.item-directory.viewed {
|
||||
color: var(--color-text-light-3);
|
||||
}
|
||||
|
||||
.item-directory {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.item-file,
|
||||
.item-directory {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.item-file:hover,
|
||||
.item-directory:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-hover);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
252
web_src/js/components/PullRequestMergeForm.vue
Normal file
252
web_src/js/components/PullRequestMergeForm.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
|
||||
const {csrfToken, pageData} = window.config;
|
||||
|
||||
const mergeForm = pageData.pullRequestMergeForm;
|
||||
|
||||
const mergeTitleFieldValue = shallowRef('');
|
||||
const mergeMessageFieldValue = shallowRef('');
|
||||
const deleteBranchAfterMerge = shallowRef(false);
|
||||
const autoMergeWhenSucceed = shallowRef(false);
|
||||
|
||||
const mergeStyle = shallowRef('');
|
||||
const mergeStyleDetail = shallowRef({
|
||||
hideMergeMessageTexts: false,
|
||||
textDoMerge: '',
|
||||
mergeTitleFieldText: '',
|
||||
mergeMessageFieldText: '',
|
||||
hideAutoMerge: false,
|
||||
});
|
||||
|
||||
const mergeStyleAllowedCount = shallowRef(0);
|
||||
|
||||
const showMergeStyleMenu = shallowRef(false);
|
||||
const showActionForm = shallowRef(false);
|
||||
|
||||
const mergeButtonStyleClass = computed(() => {
|
||||
if (mergeForm.allOverridableChecksOk) return 'primary';
|
||||
return autoMergeWhenSucceed.value ? 'primary' : 'red';
|
||||
});
|
||||
|
||||
const forceMerge = computed(() => {
|
||||
return mergeForm.canMergeNow && !mergeForm.allOverridableChecksOk;
|
||||
});
|
||||
|
||||
watch(mergeStyle, (val) => {
|
||||
mergeStyleDetail.value = mergeForm.mergeStyles.find((e: any) => e.name === val);
|
||||
for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
|
||||
toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
mergeStyleAllowedCount.value = mergeForm.mergeStyles.reduce((v: any, msd: any) => v + (msd.allowed ? 1 : 0), 0);
|
||||
|
||||
let mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed && e.name === mergeForm.defaultMergeStyle)?.name;
|
||||
if (!mergeStyle) mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed)?.name;
|
||||
switchMergeStyle(mergeStyle, !mergeForm.canMergeNow);
|
||||
|
||||
document.addEventListener('mouseup', hideMergeStyleMenu);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mouseup', hideMergeStyleMenu);
|
||||
});
|
||||
|
||||
function hideMergeStyleMenu() {
|
||||
showMergeStyleMenu.value = false;
|
||||
}
|
||||
|
||||
function toggleActionForm(show: boolean) {
|
||||
showActionForm.value = show;
|
||||
if (!show) return;
|
||||
deleteBranchAfterMerge.value = mergeForm.defaultDeleteBranchAfterMerge;
|
||||
mergeTitleFieldValue.value = mergeStyleDetail.value.mergeTitleFieldText;
|
||||
mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText;
|
||||
}
|
||||
|
||||
function switchMergeStyle(name: string, autoMerge = false) {
|
||||
mergeStyle.value = name;
|
||||
autoMergeWhenSucceed.value = autoMerge;
|
||||
}
|
||||
|
||||
function clearMergeMessage() {
|
||||
mergeMessageFieldValue.value = 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">
|
||||
<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 ? '' : 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">
|
||||
<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>
|
||||
1017
web_src/js/components/RepoActionView.vue
Normal file
1017
web_src/js/components/RepoActionView.vue
Normal file
File diff suppressed because it is too large
Load Diff
108
web_src/js/components/RepoActivityTopAuthors.vue
Normal file
108
web_src/js/components/RepoActivityTopAuthors.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts" setup>
|
||||
// @ts-expect-error - module exports no types
|
||||
import {VueBarGraph} from 'vue-bar-graph';
|
||||
import {computed, onMounted, shallowRef, useTemplateRef} from 'vue';
|
||||
|
||||
const colors = shallowRef({
|
||||
barColor: 'green',
|
||||
textColor: 'black',
|
||||
textAltColor: 'white',
|
||||
});
|
||||
|
||||
type ActivityAuthorData = {
|
||||
avatar_link: string;
|
||||
commits: number;
|
||||
home_link: string;
|
||||
login: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const activityTopAuthors: Array<ActivityAuthorData> = window.config.pageData.repoActivityTopAuthors || [];
|
||||
|
||||
const graphPoints = computed(() => {
|
||||
return activityTopAuthors.map((item) => {
|
||||
return {
|
||||
value: item.commits,
|
||||
label: item.name,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const graphAuthors = computed(() => {
|
||||
return activityTopAuthors.map((item, idx: number) => {
|
||||
return {
|
||||
position: idx + 1,
|
||||
...item,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const graphWidth = computed(() => {
|
||||
return activityTopAuthors.length * 40;
|
||||
});
|
||||
|
||||
const styleElement = useTemplateRef('styleElement');
|
||||
const altStyleElement = useTemplateRef('altStyleElement');
|
||||
|
||||
onMounted(() => {
|
||||
const refStyle = window.getComputedStyle(styleElement.value);
|
||||
const refAltStyle = window.getComputedStyle(altStyleElement.value);
|
||||
|
||||
colors.value = {
|
||||
barColor: refStyle.backgroundColor,
|
||||
textColor: refStyle.color,
|
||||
textAltColor: refAltStyle.color,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="activity-bar-graph tw-w-0 tw-h-0" ref="styleElement"/>
|
||||
<div class="activity-bar-graph-alt tw-w-0 tw-h-0" ref="altStyleElement"/>
|
||||
<vue-bar-graph
|
||||
:points="graphPoints"
|
||||
:show-x-axis="true"
|
||||
:show-y-axis="false"
|
||||
:show-values="true"
|
||||
:width="graphWidth"
|
||||
:bar-color="colors.barColor"
|
||||
:text-color="colors.textColor"
|
||||
:text-alt-color="colors.textAltColor"
|
||||
:height="100"
|
||||
:label-height="20"
|
||||
>
|
||||
<template #label="opt">
|
||||
<g v-for="(author, idx) in graphAuthors" :key="author.position">
|
||||
<a
|
||||
v-if="opt.bar.index === idx && author.home_link"
|
||||
:href="author.home_link"
|
||||
>
|
||||
<image
|
||||
:x="`${opt.bar.midPoint - 10}px`"
|
||||
:y="`${opt.bar.yLabel}px`"
|
||||
height="20"
|
||||
width="20"
|
||||
:href="author.avatar_link"
|
||||
/>
|
||||
</a>
|
||||
<image
|
||||
v-else-if="opt.bar.index === idx"
|
||||
:x="`${opt.bar.midPoint - 10}px`"
|
||||
:y="`${opt.bar.yLabel}px`"
|
||||
height="20"
|
||||
width="20"
|
||||
:href="author.avatar_link"
|
||||
/>
|
||||
</g>
|
||||
</template>
|
||||
<template #title="opt">
|
||||
<tspan v-for="(author, idx) in graphAuthors" :key="author.position">
|
||||
<tspan v-if="opt.bar.index === idx">
|
||||
{{ author.name }}
|
||||
</tspan>
|
||||
</tspan>
|
||||
</template>
|
||||
</vue-bar-graph>
|
||||
</div>
|
||||
</template>
|
||||
289
web_src/js/components/RepoBranchTagSelector.vue
Normal file
289
web_src/js/components/RepoBranchTagSelector.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<script lang="ts">
|
||||
import {defineComponent, nextTick} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {pathEscapeSegments} from '../utils/url.ts';
|
||||
import type {GitRefType} from '../types.ts';
|
||||
|
||||
type ListItem = {
|
||||
selected: boolean;
|
||||
refShortName: string;
|
||||
refType: GitRefType;
|
||||
rssFeedLink: string;
|
||||
};
|
||||
|
||||
type SelectedTab = 'branches' | 'tags';
|
||||
|
||||
type TabLoadingStates = Record<SelectedTab, '' | 'loading' | 'done'>
|
||||
|
||||
export default defineComponent({
|
||||
components: {SvgIcon},
|
||||
props: {
|
||||
elRoot: HTMLElement,
|
||||
},
|
||||
data() {
|
||||
const shouldShowTabBranches = this.elRoot.getAttribute('data-show-tab-branches') === 'true';
|
||||
return {
|
||||
csrfToken: window.config.csrfToken,
|
||||
allItems: [] as ListItem[],
|
||||
selectedTab: (shouldShowTabBranches ? 'branches' : 'tags') as SelectedTab,
|
||||
searchTerm: '',
|
||||
menuVisible: false,
|
||||
activeItemIndex: 0,
|
||||
tabLoadingStates: {} as TabLoadingStates,
|
||||
|
||||
textReleaseCompare: this.elRoot.getAttribute('data-text-release-compare'),
|
||||
textBranches: this.elRoot.getAttribute('data-text-branches'),
|
||||
textTags: this.elRoot.getAttribute('data-text-tags'),
|
||||
textFilterBranch: this.elRoot.getAttribute('data-text-filter-branch'),
|
||||
textFilterTag: this.elRoot.getAttribute('data-text-filter-tag'),
|
||||
textDefaultBranchLabel: this.elRoot.getAttribute('data-text-default-branch-label'),
|
||||
textCreateTag: this.elRoot.getAttribute('data-text-create-tag'),
|
||||
textCreateBranch: this.elRoot.getAttribute('data-text-create-branch'),
|
||||
textCreateRefFrom: this.elRoot.getAttribute('data-text-create-ref-from'),
|
||||
textNoResults: this.elRoot.getAttribute('data-text-no-results'),
|
||||
textViewAllBranches: this.elRoot.getAttribute('data-text-view-all-branches'),
|
||||
textViewAllTags: this.elRoot.getAttribute('data-text-view-all-tags'),
|
||||
|
||||
currentRepoDefaultBranch: this.elRoot.getAttribute('data-current-repo-default-branch'),
|
||||
currentRepoLink: this.elRoot.getAttribute('data-current-repo-link'),
|
||||
currentTreePath: this.elRoot.getAttribute('data-current-tree-path'),
|
||||
currentRefType: this.elRoot.getAttribute('data-current-ref-type') as GitRefType,
|
||||
currentRefShortName: this.elRoot.getAttribute('data-current-ref-short-name'),
|
||||
|
||||
refLinkTemplate: this.elRoot.getAttribute('data-ref-link-template'),
|
||||
refFormActionTemplate: this.elRoot.getAttribute('data-ref-form-action-template'),
|
||||
dropdownFixedText: this.elRoot.getAttribute('data-dropdown-fixed-text'),
|
||||
showTabBranches: shouldShowTabBranches,
|
||||
showTabTags: this.elRoot.getAttribute('data-show-tab-tags') === 'true',
|
||||
allowCreateNewRef: this.elRoot.getAttribute('data-allow-create-new-ref') === 'true',
|
||||
showViewAllRefsEntry: this.elRoot.getAttribute('data-show-view-all-refs-entry') === 'true',
|
||||
enableFeed: this.elRoot.getAttribute('data-enable-feed') === 'true',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
searchFieldPlaceholder() {
|
||||
return this.selectedTab === 'branches' ? this.textFilterBranch : this.textFilterTag;
|
||||
},
|
||||
filteredItems(): ListItem[] {
|
||||
const searchTermLower = this.searchTerm.toLowerCase();
|
||||
const items = this.allItems.filter((item: ListItem) => {
|
||||
const typeMatched = (this.selectedTab === 'branches' && item.refType === 'branch') || (this.selectedTab === 'tags' && item.refType === 'tag');
|
||||
if (!typeMatched) return false;
|
||||
if (!this.searchTerm) return true; // match all
|
||||
return item.refShortName.toLowerCase().includes(searchTermLower);
|
||||
});
|
||||
|
||||
// TODO: fix this anti-pattern: side-effects-in-computed-properties
|
||||
this.activeItemIndex = !items.length && this.showCreateNewRef ? 0 : -1; // eslint-disable-line vue/no-side-effects-in-computed-properties
|
||||
return items;
|
||||
},
|
||||
showNoResults() {
|
||||
if (this.tabLoadingStates[this.selectedTab] !== 'done') return false;
|
||||
return !this.filteredItems.length && !this.showCreateNewRef;
|
||||
},
|
||||
showCreateNewRef() {
|
||||
if (!this.allowCreateNewRef || !this.searchTerm) {
|
||||
return false;
|
||||
}
|
||||
return !this.allItems.filter((item: ListItem) => {
|
||||
return item.refShortName === this.searchTerm; // FIXME: not quite right here, it mixes "branch" and "tag" names
|
||||
}).length;
|
||||
},
|
||||
createNewRefFormActionUrl() {
|
||||
return `${this.currentRepoLink}/branches/_new/${this.currentRefType}/${pathEscapeSegments(this.currentRefShortName)}`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
menuVisible(visible: boolean) {
|
||||
if (!visible) return;
|
||||
this.focusSearchField();
|
||||
this.loadTabItems();
|
||||
},
|
||||
},
|
||||
beforeMount() {
|
||||
document.body.addEventListener('click', (e) => {
|
||||
if (this.$el.contains(e.target)) return;
|
||||
if (this.menuVisible) this.menuVisible = false;
|
||||
});
|
||||
},
|
||||
|
||||
mounted() {
|
||||
if (this.refFormActionTemplate) {
|
||||
// if the selector is used in a form and needs to change the form action,
|
||||
// make a mock item and select it to update the form action
|
||||
const item: ListItem = {selected: true, refType: this.currentRefType, refShortName: this.currentRefShortName, rssFeedLink: ''};
|
||||
this.selectItem(item);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
selectItem(item: ListItem) {
|
||||
this.menuVisible = false;
|
||||
if (this.refFormActionTemplate) {
|
||||
this.currentRefType = item.refType;
|
||||
this.currentRefShortName = item.refShortName;
|
||||
let actionLink = this.refFormActionTemplate;
|
||||
actionLink = actionLink.replace('{RepoLink}', this.currentRepoLink);
|
||||
actionLink = actionLink.replace('{RefType}', pathEscapeSegments(item.refType));
|
||||
actionLink = actionLink.replace('{RefShortName}', pathEscapeSegments(item.refShortName));
|
||||
this.$el.closest('form').action = actionLink;
|
||||
} else {
|
||||
let link = this.refLinkTemplate;
|
||||
link = link.replace('{RepoLink}', this.currentRepoLink);
|
||||
link = link.replace('{RefType}', pathEscapeSegments(item.refType));
|
||||
link = link.replace('{RefShortName}', pathEscapeSegments(item.refShortName));
|
||||
link = link.replace('{TreePath}', pathEscapeSegments(this.currentTreePath));
|
||||
window.location.href = link;
|
||||
}
|
||||
},
|
||||
createNewRef() {
|
||||
(this.$refs.createNewRefForm as HTMLFormElement)?.submit();
|
||||
},
|
||||
focusSearchField() {
|
||||
nextTick(() => {
|
||||
(this.$refs.searchField as HTMLInputElement).focus();
|
||||
});
|
||||
},
|
||||
getSelectedIndexInFiltered() {
|
||||
for (let i = 0; i < this.filteredItems.length; ++i) {
|
||||
if (this.filteredItems[i].selected) return i;
|
||||
}
|
||||
return -1;
|
||||
},
|
||||
getActiveItem() {
|
||||
const el = this.$refs[`listItem${this.activeItemIndex}`];
|
||||
// @ts-expect-error - el is unknown type
|
||||
return (el && el.length) ? el[0] : null;
|
||||
},
|
||||
keydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.activeItemIndex === -1) {
|
||||
this.activeItemIndex = this.getSelectedIndexInFiltered();
|
||||
}
|
||||
const nextIndex = e.key === 'ArrowDown' ? this.activeItemIndex + 1 : this.activeItemIndex - 1;
|
||||
if (nextIndex < 0) {
|
||||
return;
|
||||
}
|
||||
if (nextIndex + (this.showCreateNewRef ? 0 : 1) > this.filteredItems.length) {
|
||||
return;
|
||||
}
|
||||
this.activeItemIndex = nextIndex;
|
||||
this.getActiveItem().scrollIntoView({block: 'nearest'});
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
this.getActiveItem()?.click();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.menuVisible = false;
|
||||
}
|
||||
},
|
||||
handleTabSwitch(selectedTab: SelectedTab) {
|
||||
this.selectedTab = selectedTab;
|
||||
this.focusSearchField();
|
||||
this.loadTabItems();
|
||||
},
|
||||
async loadTabItems() {
|
||||
const tab = this.selectedTab;
|
||||
if (this.tabLoadingStates[tab] === 'loading' || this.tabLoadingStates[tab] === 'done') return;
|
||||
|
||||
const refType = this.selectedTab === 'branches' ? 'branch' : 'tag';
|
||||
this.tabLoadingStates[tab] = 'loading';
|
||||
try {
|
||||
const url = refType === 'branch' ? `${this.currentRepoLink}/branches/list` : `${this.currentRepoLink}/tags/list`;
|
||||
const resp = await GET(url);
|
||||
const {results} = await resp.json();
|
||||
for (const refShortName of results) {
|
||||
const item: ListItem = {
|
||||
refType,
|
||||
refShortName,
|
||||
selected: refType === this.currentRefType && refShortName === this.currentRefShortName,
|
||||
rssFeedLink: `${this.currentRepoLink}/rss/${refType}/${pathEscapeSegments(refShortName)}`,
|
||||
};
|
||||
this.allItems.push(item);
|
||||
}
|
||||
this.tabLoadingStates[tab] = 'done';
|
||||
} catch (e) {
|
||||
this.tabLoadingStates[tab] = '';
|
||||
showErrorToast(`Network error when fetching items for ${tab}, error: ${e}`);
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="ui dropdown custom branch-selector-dropdown ellipsis-text-items">
|
||||
<div tabindex="0" class="ui compact button branch-dropdown-button" @click="menuVisible = !menuVisible">
|
||||
<span class="flex-text-block gt-ellipsis">
|
||||
<template v-if="dropdownFixedText">{{ dropdownFixedText }}</template>
|
||||
<template v-else>
|
||||
<svg-icon v-if="currentRefType === 'tag'" name="octicon-tag"/>
|
||||
<svg-icon v-else-if="currentRefType === 'branch'" name="octicon-git-branch"/>
|
||||
<svg-icon v-else name="octicon-git-commit"/>
|
||||
<strong ref="dropdownRefName" class="tw-inline-block gt-ellipsis">{{ currentRefShortName }}</strong>
|
||||
</template>
|
||||
</span>
|
||||
<svg-icon name="octicon-triangle-down" :size="14" class="dropdown icon"/>
|
||||
</div>
|
||||
<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="showTabBranches" class="branch-tag-tab">
|
||||
<a class="branch-tag-item muted" :class="{active: selectedTab === 'branches'}" href="#" @click="handleTabSwitch('branches')">
|
||||
<svg-icon name="octicon-git-branch" :size="16" class="tw-mr-1"/>{{ textBranches }}
|
||||
</a>
|
||||
<a v-if="showTabTags" class="branch-tag-item muted" :class="{active: selectedTab === 'tags'}" href="#" @click="handleTabSwitch('tags')">
|
||||
<svg-icon name="octicon-tag" :size="16" class="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="tabLoadingStates[selectedTab] === 'loading'"/>
|
||||
<div v-for="(item, index) in filteredItems" :key="item.refShortName" class="item" :class="{selected: item.selected, active: activeItemIndex === index}" @click="selectItem(item)" :ref="'listItem' + index">
|
||||
{{ item.refShortName }}
|
||||
<div class="ui label" v-if="item.refType === 'branch' && item.refShortName === currentRepoDefaultBranch">
|
||||
{{ textDefaultBranchLabel }}
|
||||
</div>
|
||||
<a v-if="enableFeed && selectedTab === 'branches'" role="button" class="rss-icon" target="_blank" @click.stop :href="item.rssFeedLink">
|
||||
<!-- 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="showCreateNewRef" :class="{active: activeItemIndex === filteredItems.length}" :ref="'listItem' + filteredItems.length" @click="createNewRef()">
|
||||
<div v-if="selectedTab === 'tags'">
|
||||
<svg-icon name="octicon-tag" class="tw-mr-1"/>
|
||||
<span v-text="textCreateTag.replace('%s', searchTerm)"/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<svg-icon name="octicon-git-branch" class="tw-mr-1"/>
|
||||
<span v-text="textCreateBranch.replace('%s', searchTerm)"/>
|
||||
</div>
|
||||
<div class="text small">
|
||||
{{ textCreateRefFrom.replace('%s', currentRefShortName) }}
|
||||
</div>
|
||||
<form ref="createNewRefForm" method="post" :action="createNewRefFormActionUrl">
|
||||
<input type="hidden" name="_csrf" :value="csrfToken">
|
||||
<input type="hidden" name="new_branch_name" :value="searchTerm">
|
||||
<input type="hidden" name="create_tag" :value="String(selectedTab === 'tags')">
|
||||
<input type="hidden" name="current_path" :value="currentTreePath">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message" v-if="showNoResults">
|
||||
{{ textNoResults }}
|
||||
</div>
|
||||
<template v-if="showViewAllRefsEntry">
|
||||
<div class="divider tw-m-0"/>
|
||||
<a v-if="selectedTab === 'branches'" class="item" :href="currentRepoLink + '/branches'">{{ textViewAllBranches }}</a>
|
||||
<a v-if="selectedTab === 'tags'" class="item" :href="currentRepoLink + '/tags'">{{ textViewAllTags }}</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
173
web_src/js/components/RepoCodeFrequency.vue
Normal file
173
web_src/js/components/RepoCodeFrequency.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {
|
||||
Chart,
|
||||
Legend,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
type ChartOptions,
|
||||
type ChartData,
|
||||
} from 'chart.js';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {Line as ChartLine} from 'vue-chartjs';
|
||||
import {
|
||||
startDaysBetween,
|
||||
firstStartDateAfterDate,
|
||||
fillEmptyStartDaysWithZeroes,
|
||||
type DayData,
|
||||
type DayDataObject,
|
||||
} from '../utils/time.ts';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {onMounted, shallowRef} from 'vue';
|
||||
|
||||
const {pageData} = window.config;
|
||||
|
||||
Chart.defaults.color = chartJsColors.text;
|
||||
Chart.defaults.borderColor = chartJsColors.border;
|
||||
|
||||
Chart.register(
|
||||
TimeScale,
|
||||
LinearScale,
|
||||
Legend,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
);
|
||||
|
||||
defineProps<{
|
||||
locale: {
|
||||
loadingTitle: string;
|
||||
loadingTitleFailed: string;
|
||||
loadingInfo: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const isLoading = shallowRef(false);
|
||||
const errorText = shallowRef('');
|
||||
const repoLink = pageData.repoLink;
|
||||
const data = shallowRef<DayData[]>([]);
|
||||
|
||||
onMounted(() => {
|
||||
fetchGraphData();
|
||||
});
|
||||
|
||||
async function fetchGraphData() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
let response: Response;
|
||||
do {
|
||||
response = await GET(`${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) {
|
||||
const dayDataObject: DayDataObject = await response.json();
|
||||
const weekValues = Object.values(dayDataObject);
|
||||
const start = weekValues[0].week;
|
||||
const end = firstStartDateAfterDate(new Date());
|
||||
const startDays = startDaysBetween(start, end);
|
||||
data.value = fillEmptyStartDaysWithZeroes(startDays, dayDataObject);
|
||||
errorText.value = '';
|
||||
} else {
|
||||
errorText.value = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
errorText.value = err.message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toGraphData(data: Array<Record<string, any>>): ChartData<'line'> {
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const options: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
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="tw-m-auto">
|
||||
<div v-if="isLoading">
|
||||
<SvgIcon name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
|
||||
{{ 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="options"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main-graph {
|
||||
height: 440px;
|
||||
}
|
||||
</style>
|
||||
462
web_src/js/components/RepoContributors.vue
Normal file
462
web_src/js/components/RepoContributors.vue
Normal file
@@ -0,0 +1,462 @@
|
||||
<script lang="ts">
|
||||
import {defineComponent, type PropType} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Chart,
|
||||
Title,
|
||||
BarElement,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Filler,
|
||||
type ChartOptions,
|
||||
type ChartData,
|
||||
type Plugin,
|
||||
} from 'chart.js';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import zoomPlugin from 'chartjs-plugin-zoom';
|
||||
import {Line as ChartLine} from 'vue-chartjs';
|
||||
import {
|
||||
startDaysBetween,
|
||||
firstStartDateAfterDate,
|
||||
fillEmptyStartDaysWithZeroes,
|
||||
} from '../utils/time.ts';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {pathEscapeSegments} from '../utils/url.ts';
|
||||
|
||||
const customEventListener: Plugin = {
|
||||
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,
|
||||
);
|
||||
|
||||
type ContributorsData = {
|
||||
total: {
|
||||
weeks: Record<string, any>,
|
||||
},
|
||||
[other: string]: Record<string, Record<string, any>>,
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {ChartLine, SvgIcon},
|
||||
props: {
|
||||
locale: {
|
||||
type: Object as PropType<Record<string, any>>,
|
||||
required: true,
|
||||
},
|
||||
repoLink: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
repoDefaultBranchName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
isLoading: false,
|
||||
errorText: '',
|
||||
totalStats: {} as Record<string, any>,
|
||||
sortedContributors: {} as Record<string, any>,
|
||||
type: 'commits',
|
||||
contributorsStats: {} as Record<string, any>,
|
||||
xAxisStart: null as number | null,
|
||||
xAxisEnd: null as number | null,
|
||||
xAxisMin: null as number | null,
|
||||
xAxisMax: null as number | null,
|
||||
}),
|
||||
mounted() {
|
||||
this.fetchGraphData();
|
||||
|
||||
fomanticQuery('#repo-contributors').dropdown({
|
||||
onChange: (val: string) => {
|
||||
this.xAxisMin = this.xAxisStart;
|
||||
this.xAxisMax = this.xAxisEnd;
|
||||
this.type = val;
|
||||
this.sortContributors();
|
||||
},
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
sortContributors() {
|
||||
const contributors: Record<string, any> = 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);
|
||||
},
|
||||
|
||||
getContributorSearchQuery(contributorEmail: string) {
|
||||
const min = dayjs(this.xAxisMin).format('YYYY-MM-DD');
|
||||
const max = dayjs(this.xAxisMax).format('YYYY-MM-DD');
|
||||
const params = new URLSearchParams({
|
||||
'q': `after:${min}, before:${max}, author:${contributorEmail}`,
|
||||
});
|
||||
return `${this.repoLink}/commits/branch/${pathEscapeSegments(this.repoDefaultBranchName)}/search?${params.toString()}`;
|
||||
},
|
||||
|
||||
async fetchGraphData() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
let response: 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() as ContributorsData;
|
||||
const {total, ...other} = 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(other)) {
|
||||
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: Record<string, any> = {};
|
||||
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: Record<string, number>) => {
|
||||
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, email: key};
|
||||
}
|
||||
|
||||
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: Record<string, any>) => 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: Record<string, any>) => 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: Array<Record<string, any>>): ChartData<'line'> {
|
||||
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({chart}: {chart: Chart}, reset: boolean = false) {
|
||||
const minVal = Number(chart.options.scales.x.min);
|
||||
const maxVal = Number(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: string): ChartOptions<'line'> {
|
||||
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',
|
||||
},
|
||||
// @ts-expect-error: bug in chart.js types
|
||||
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 floating 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="left 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="tw-m-auto">
|
||||
<div v-if="isLoading">
|
||||
<SvgIcon name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
|
||||
{{ 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 loading="lazy" class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link" alt="">
|
||||
</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">
|
||||
<a class="silenced" :href="getContributorSearchQuery(contributor.email)">
|
||||
{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}
|
||||
</a>
|
||||
</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>
|
||||
150
web_src/js/components/RepoRecentCommits.vue
Normal file
150
web_src/js/components/RepoRecentCommits.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {
|
||||
Chart,
|
||||
Tooltip,
|
||||
BarElement,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
type ChartOptions,
|
||||
type ChartData,
|
||||
} from 'chart.js';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {Bar} from 'vue-chartjs';
|
||||
import {
|
||||
startDaysBetween,
|
||||
firstStartDateAfterDate,
|
||||
fillEmptyStartDaysWithZeroes,
|
||||
type DayData,
|
||||
type DayDataObject,
|
||||
} from '../utils/time.ts';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {onMounted, ref, shallowRef} from 'vue';
|
||||
|
||||
const {pageData} = window.config;
|
||||
|
||||
Chart.defaults.color = chartJsColors.text;
|
||||
Chart.defaults.borderColor = chartJsColors.border;
|
||||
|
||||
Chart.register(
|
||||
TimeScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Tooltip,
|
||||
);
|
||||
|
||||
defineProps<{
|
||||
locale: {
|
||||
loadingTitle: string;
|
||||
loadingTitleFailed: string;
|
||||
loadingInfo: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const isLoading = shallowRef(false);
|
||||
const errorText = shallowRef('');
|
||||
const repoLink = pageData.repoLink;
|
||||
const data = ref<DayData[]>([]);
|
||||
|
||||
onMounted(() => {
|
||||
fetchGraphData();
|
||||
});
|
||||
|
||||
async function fetchGraphData() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
let response: Response;
|
||||
do {
|
||||
response = await GET(`${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 dayDataObj: DayDataObject = await response.json();
|
||||
const start = Object.values(dayDataObj)[0].week;
|
||||
const end = firstStartDateAfterDate(new Date());
|
||||
const startDays = startDaysBetween(start, end);
|
||||
data.value = fillEmptyStartDaysWithZeroes(startDays, dayDataObj).slice(-52);
|
||||
errorText.value = '';
|
||||
} else {
|
||||
errorText.value = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
errorText.value = err.message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toGraphData(data: DayData[]): ChartData<'bar'> {
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
// @ts-expect-error -- bar chart expects one-dimensional data, but apparently x/y still works
|
||||
data: data.map((i) => ({x: i.week, y: i.commits})),
|
||||
label: 'Commits',
|
||||
backgroundColor: chartJsColors['commits'],
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const options: ChartOptions<'bar'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
time: {
|
||||
minUnit: 'week',
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: 52,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
maxTicksLimit: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ChartOptions;
|
||||
</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="tw-m-auto">
|
||||
<div v-if="isLoading">
|
||||
<SvgIcon name="gitea-running" class="tw-mr-2 rotate-clockwise"/>
|
||||
{{ 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="options"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.main-graph {
|
||||
height: 250px;
|
||||
}
|
||||
</style>
|
||||
38
web_src/js/components/ViewFileTree.vue
Normal file
38
web_src/js/components/ViewFileTree.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts" setup>
|
||||
import ViewFileTreeItem from './ViewFileTreeItem.vue';
|
||||
import {onMounted, useTemplateRef} from 'vue';
|
||||
import {createViewFileTreeStore} from './ViewFileTreeStore.ts';
|
||||
|
||||
const elRoot = useTemplateRef('elRoot');
|
||||
|
||||
const props = defineProps({
|
||||
repoLink: {type: String, required: true},
|
||||
treePath: {type: String, required: true},
|
||||
currentRefNameSubURL: {type: String, required: true},
|
||||
});
|
||||
|
||||
const store = createViewFileTreeStore(props);
|
||||
onMounted(async () => {
|
||||
store.rootFiles = await store.loadChildren('', props.treePath);
|
||||
elRoot.value.closest('.is-loading')?.classList?.remove('is-loading');
|
||||
window.addEventListener('popstate', (e) => {
|
||||
store.selectedItem = e.state?.treePath || '';
|
||||
if (e.state?.url) store.loadViewContent(e.state.url);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view-file-tree-items" ref="elRoot">
|
||||
<ViewFileTreeItem v-for="item in store.rootFiles" :key="item.name" :item="item" :store="store"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.view-file-tree-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
</style>
|
||||
128
web_src/js/components/ViewFileTreeItem.vue
Normal file
128
web_src/js/components/ViewFileTreeItem.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {isPlainClick} from '../utils/dom.ts';
|
||||
import {shallowRef} from 'vue';
|
||||
import {type createViewFileTreeStore} from './ViewFileTreeStore.ts';
|
||||
|
||||
type Item = {
|
||||
entryName: string;
|
||||
entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown';
|
||||
entryIcon: string;
|
||||
entryIconOpen: string;
|
||||
fullPath: string;
|
||||
submoduleUrl?: string;
|
||||
children?: Item[];
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
item: Item,
|
||||
store: ReturnType<typeof createViewFileTreeStore>
|
||||
}>();
|
||||
|
||||
const store = props.store;
|
||||
const isLoading = shallowRef(false);
|
||||
const children = shallowRef(props.item.children);
|
||||
const collapsed = shallowRef(!props.item.children);
|
||||
|
||||
const doLoadChildren = async () => {
|
||||
collapsed.value = !collapsed.value;
|
||||
if (!collapsed.value) {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
children.value = await store.loadChildren(props.item.fullPath);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onItemClick = (e: MouseEvent) => {
|
||||
// only handle the click event with page partial reloading if the user didn't press any special key
|
||||
// let browsers handle special keys like "Ctrl+Click"
|
||||
if (!isPlainClick(e)) return;
|
||||
e.preventDefault();
|
||||
if (props.item.entryMode === 'tree') doLoadChildren();
|
||||
store.navigateTreeView(props.item.fullPath);
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
class="tree-item silenced"
|
||||
:class="{
|
||||
'selected': store.selectedItem === item.fullPath,
|
||||
'type-submodule': item.entryMode === 'commit',
|
||||
'type-directory': item.entryMode === 'tree',
|
||||
'type-symlink': item.entryMode === 'symlink',
|
||||
'type-file': item.entryMode === 'blob' || item.entryMode === 'exec',
|
||||
}"
|
||||
:title="item.entryName"
|
||||
:href="store.buildTreePathWebUrl(item.fullPath)"
|
||||
@click.stop="onItemClick"
|
||||
>
|
||||
<div v-if="item.entryMode === 'tree'" class="item-toggle">
|
||||
<SvgIcon v-if="isLoading" name="gitea-running" class="rotate-clockwise"/>
|
||||
<SvgIcon v-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop.prevent="doLoadChildren"/>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span class="tw-contents" v-html="(!collapsed && item.entryIconOpen) ? item.entryIconOpen : item.entryIcon"/>
|
||||
<span class="gt-ellipsis">{{ item.entryName }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div v-if="children?.length" v-show="!collapsed" class="sub-items">
|
||||
<ViewFileTreeItem v-for="childItem in children" :key="childItem.entryName" :item="childItem" :store="store"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sub-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
margin-left: 14px;
|
||||
border-left: 1px solid var(--color-secondary);
|
||||
}
|
||||
|
||||
.tree-item.selected {
|
||||
color: var(--color-text);
|
||||
background: var(--color-active);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tree-item.type-directory {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
display: grid;
|
||||
grid-template-columns: 16px 1fr;
|
||||
grid-template-areas: "toggle content";
|
||||
gap: 0.25em;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-hover);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-toggle {
|
||||
grid-area: toggle;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
grid-area: content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
52
web_src/js/components/ViewFileTreeStore.ts
Normal file
52
web_src/js/components/ViewFileTreeStore.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import {reactive} from 'vue';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {pathEscapeSegments} from '../utils/url.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
|
||||
export function createViewFileTreeStore(props: {repoLink: string, treePath: string, currentRefNameSubURL: string}) {
|
||||
const store = reactive({
|
||||
rootFiles: [],
|
||||
selectedItem: props.treePath,
|
||||
|
||||
async loadChildren(treePath: string, subPath: string = '') {
|
||||
const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`);
|
||||
const json = await response.json();
|
||||
const poolSvgs = [];
|
||||
for (const [svgId, svgContent] of Object.entries(json.renderedIconPool ?? {})) {
|
||||
if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent);
|
||||
}
|
||||
if (poolSvgs.length) {
|
||||
const svgContainer = createElementFromHTML(html`<div class="global-svg-icon-pool tw-hidden"></div>`);
|
||||
svgContainer.innerHTML = poolSvgs.join('');
|
||||
document.body.append(svgContainer);
|
||||
}
|
||||
return json.fileTreeNodes ?? null;
|
||||
},
|
||||
|
||||
async loadViewContent(url: string) {
|
||||
const u = new URL(url, window.origin);
|
||||
u.searchParams.set('only_content', 'true');
|
||||
const response = await GET(u.href);
|
||||
const elViewContent = document.querySelector('.repo-view-content');
|
||||
elViewContent.innerHTML = await response.text();
|
||||
const elViewContentData = elViewContent.querySelector('.repo-view-content-data');
|
||||
if (!elViewContentData) return; // if error occurs, there is no such element
|
||||
const t1 = elViewContentData.getAttribute('data-document-title');
|
||||
const t2 = elViewContentData.getAttribute('data-document-title-common');
|
||||
document.title = `${t1} - ${t2}`; // follow the format in head.tmpl: <head><title>...</title></head>
|
||||
},
|
||||
|
||||
async navigateTreeView(treePath: string) {
|
||||
const url = store.buildTreePathWebUrl(treePath);
|
||||
window.history.pushState({treePath, url}, null, url);
|
||||
store.selectedItem = treePath;
|
||||
await store.loadViewContent(url);
|
||||
},
|
||||
|
||||
buildTreePathWebUrl(treePath: string) {
|
||||
return `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`;
|
||||
},
|
||||
});
|
||||
return store;
|
||||
}
|
||||
Reference in New Issue
Block a user