mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-06 19:08:57 +09:00
refactor: migrate remaining Vue components to <script setup> (#38752)
Migrates the last four Options API components, so all 20 `.vue` files
now use `<script setup>`, and disables the Vue Options API runtime. This
will enable optimizations like Vue Vapor mode in the future.
Bug fixes done while migrating and testing:
- Branch selector: arrowing onto "Create branch …" threw a `TypeError`,
and Enter on it did nothing
- Dashboard: the search term was not escaped, so `&` injected query
parameters and `#` truncated the request
- Dashboard: an unknown `repo-search-filter` threw, and names like
`constructor` leaked `Object.prototype` members into the request
- Dashboard: an unknown archived/private filter rendered `function
Object() { [native code] }` as a checkbox tooltip
- Dashboard: removed a dropdown init that always ran against a
not-yet-rendered element
- Branch selector: the `document.body` click listener was never removed
on unmount
- Contributors: the chart plugin relied on an undeclared chart.js field
- Contributors: `contributorsStats` was mutated behind a `shallowRef`,
so future readers would not update
- Removed template attributes and pageData keys that no component had
read for years
---------
Signed-off-by: silverwind <me@silverwind.io>
This commit is contained in:
@@ -16,7 +16,6 @@ Search "repo/branch_dropdown" in the template directory to find all occurrences.
|
||||
*/}}
|
||||
<div class="{{if .ContainerClasses}}{{.ContainerClasses}}{{end}}"
|
||||
data-global-init="initRepoBranchTagSelector"
|
||||
data-text-release-compare="{{ctx.Locale.Tr "repo.release.compare"}}"
|
||||
data-text-branches="{{ctx.Locale.Tr "repo.branches"}}"
|
||||
data-text-tags="{{ctx.Locale.Tr "repo.tags"}}"
|
||||
data-text-filter-branch="{{ctx.Locale.Tr "repo.pulls.filter_branch"}}"
|
||||
|
||||
@@ -3,9 +3,6 @@ const data = {
|
||||
...window.config.pageData.dashboardRepoList, // it only contains searchLimit and uid
|
||||
|
||||
isMirrorsEnabled: {{.MirrorsEnabled}},
|
||||
isStarsEnabled: {{not .IsDisableStars}},
|
||||
|
||||
canCreateMigrations: {{not .DisableMigrations}},
|
||||
|
||||
textNoOrg: {{ctx.Locale.Tr "home.empty_org"}},
|
||||
textNoRepo: {{ctx.Locale.Tr "home.empty_repo"}},
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import vuePlugin from '@vitejs/plugin-vue';
|
||||
import {stringPlugin} from 'vite-string-plugin';
|
||||
import type {Plugin} from 'vite';
|
||||
|
||||
// custom elements, vue must render these as-is instead of resolving them as components
|
||||
const webComponents = new Set([
|
||||
// our own, in web_src/js/webcomponents
|
||||
'overflow-menu',
|
||||
'relative-time',
|
||||
// from dependencies
|
||||
'markdown-toolbar',
|
||||
'text-expander',
|
||||
]);
|
||||
|
||||
export const vueDefines = {
|
||||
__VUE_OPTIONS_API__: false,
|
||||
__VUE_PROD_DEVTOOLS__: false,
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
|
||||
};
|
||||
|
||||
export function sharedPlugins(): Plugin[] {
|
||||
return [
|
||||
stringPlugin(),
|
||||
vuePlugin({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
isCustomElement: (tag) => webComponents.has(tag),
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
+3
-23
@@ -1,6 +1,6 @@
|
||||
import {build, defineConfig} from 'vite';
|
||||
import vuePlugin from '@vitejs/plugin-vue';
|
||||
import {stringPlugin} from 'vite-string-plugin';
|
||||
import {sharedPlugins, vueDefines} from './tools/shared.ts';
|
||||
import {licensePlugin, wrap} from 'rolldown-license-plugin';
|
||||
import {readFileSync, writeFileSync, mkdirSync, unlinkSync, globSync} from 'node:fs';
|
||||
import path, {basename, join, parse} from 'node:path';
|
||||
@@ -29,15 +29,6 @@ for (const path of globSync('web_src/css/themes/*.css', {cwd: import.meta.dirnam
|
||||
themes[parse(path).name] = join(import.meta.dirname, path);
|
||||
}
|
||||
|
||||
const webComponents = new Set([
|
||||
// our own, in web_src/js/webcomponents
|
||||
'overflow-menu',
|
||||
'relative-time',
|
||||
// from dependencies
|
||||
'markdown-toolbar',
|
||||
'text-expander',
|
||||
]);
|
||||
|
||||
function failOnWarningsPlugin(): Rolldown.Plugin {
|
||||
let warningCount = 0;
|
||||
return {
|
||||
@@ -305,25 +296,14 @@ export default defineConfig(commonViteOpts({
|
||||
],
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__VUE_OPTIONS_API__: true,
|
||||
__VUE_PROD_DEVTOOLS__: false,
|
||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
|
||||
},
|
||||
define: vueDefines,
|
||||
plugins: [
|
||||
iifePlugin('iife.ts'),
|
||||
iifePlugin('external-render-helper.ts'),
|
||||
viteDevServerPortPlugin(),
|
||||
reducedSourcemapPlugin(),
|
||||
filterCssUrlPlugin(),
|
||||
stringPlugin(),
|
||||
vuePlugin({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
isCustomElement: (tag) => webComponents.has(tag),
|
||||
},
|
||||
},
|
||||
}),
|
||||
...sharedPlugins(),
|
||||
isProduction ? licensePlugin({
|
||||
done(deps, context) {
|
||||
const line = '-'.repeat(80);
|
||||
|
||||
+3
-6
@@ -1,6 +1,5 @@
|
||||
import {defineConfig} from 'vitest/config';
|
||||
import vuePlugin from '@vitejs/plugin-vue';
|
||||
import {stringPlugin} from 'vite-string-plugin';
|
||||
import {sharedPlugins, vueDefines} from './tools/shared.ts';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
@@ -21,8 +20,6 @@ export default defineConfig({
|
||||
concurrent: true,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
stringPlugin(),
|
||||
vuePlugin(),
|
||||
],
|
||||
define: vueDefines,
|
||||
plugins: sharedPlugins(),
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import {nextTick, defineComponent} from 'vue';
|
||||
<script lang="ts" setup>
|
||||
import {computed, nextTick, onMounted, shallowRef, useTemplateRef, type ShallowRef} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {urlQueryEscape} from '../utils/url.ts';
|
||||
import type {SvgName} from '../svg.ts';
|
||||
|
||||
const {appSubUrl, assetUrlPrefix, pageData} = window.config;
|
||||
const {appSubUrl, pageData} = window.config;
|
||||
|
||||
type DashboardRepo = {
|
||||
id: number,
|
||||
@@ -31,6 +31,11 @@ type CommitStatusMap = {
|
||||
};
|
||||
};
|
||||
|
||||
type Tab = 'repos' | 'organizations';
|
||||
type RepoFilter = 'all' | 'forks' | 'mirrors' | 'sources' | 'collaborative';
|
||||
type ArchivedFilter = 'archived' | 'unarchived' | 'both';
|
||||
type PrivateFilter = 'private' | 'public' | 'both';
|
||||
|
||||
// make sure this matches templates/repo/commit_status.tmpl
|
||||
const commitStatus: CommitStatusMap = {
|
||||
pending: {name: 'octicon-dot-fill', color: 'tw-text-yellow'},
|
||||
@@ -41,358 +46,331 @@ const commitStatus: CommitStatusMap = {
|
||||
skipped: {name: 'octicon-skip', color: 'tw-text-text-light'},
|
||||
};
|
||||
|
||||
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;
|
||||
const searchModes = new Map<RepoFilter, string>([
|
||||
['all', ''],
|
||||
['forks', 'fork'],
|
||||
['mirrors', 'mirror'],
|
||||
['sources', 'source'],
|
||||
['collaborative', 'collaborative'],
|
||||
]);
|
||||
|
||||
return {
|
||||
tab,
|
||||
repos: [] as DashboardRepo[],
|
||||
reposTotalCount: null as number | null,
|
||||
reposFilter,
|
||||
archivedFilter,
|
||||
privateFilter,
|
||||
page,
|
||||
finalPage: 1,
|
||||
searchQuery,
|
||||
isLoading: false,
|
||||
initialSearchDone: false,
|
||||
staticPrefix: assetUrlPrefix,
|
||||
counts: {} as Record<string, number>,
|
||||
repoTypes: {
|
||||
all: {
|
||||
searchMode: '',
|
||||
},
|
||||
forks: {
|
||||
searchMode: 'fork',
|
||||
},
|
||||
mirrors: {
|
||||
searchMode: 'mirror',
|
||||
},
|
||||
sources: {
|
||||
searchMode: 'source',
|
||||
},
|
||||
collaborative: {
|
||||
searchMode: 'collaborative',
|
||||
},
|
||||
} as Record<string, {searchMode: string}>,
|
||||
textArchivedFilterTitles: {} as Record<string, string>,
|
||||
textPrivateFilterTitles: {} as Record<string, string>,
|
||||
organizations: [] as Array<{name: string, full_name: string, num_repos: number, org_visibility: string}>,
|
||||
isOrganization: true,
|
||||
canCreateOrganization: false,
|
||||
organizationsTotalCount: 0,
|
||||
organizationId: 0,
|
||||
searchLimit: 0,
|
||||
uid: 0,
|
||||
teamId: 0,
|
||||
isMirrorsEnabled: false,
|
||||
isStarsEnabled: false,
|
||||
canCreateMigrations: false,
|
||||
textNoOrg: '',
|
||||
textNoRepo: '',
|
||||
textRepository: '',
|
||||
textOrganization: '',
|
||||
textMyRepos: '',
|
||||
textNewRepo: '',
|
||||
textSearchRepos: '',
|
||||
textFilter: '',
|
||||
textShowArchived: '',
|
||||
textShowPrivate: '',
|
||||
textShowBothArchivedUnarchived: '',
|
||||
textShowOnlyUnarchived: '',
|
||||
textShowOnlyArchived: '',
|
||||
textShowBothPrivatePublic: '',
|
||||
textShowOnlyPublic: '',
|
||||
textShowOnlyPrivate: '',
|
||||
textAll: '',
|
||||
textSources: '',
|
||||
textForks: '',
|
||||
textMirrors: '',
|
||||
textCollaborative: '',
|
||||
textFirstPage: '',
|
||||
textPreviousPage: '',
|
||||
textNextPage: '',
|
||||
textLastPage: '',
|
||||
textMyOrgs: '',
|
||||
textNewOrg: '',
|
||||
textOrgVisibilityLimited: '',
|
||||
textOrgVisibilityPrivate: '',
|
||||
subUrl: appSubUrl,
|
||||
...pageData.dashboardRepoList,
|
||||
activeIndex: -1, // don't select anything at load, first cursor down will select
|
||||
};
|
||||
},
|
||||
const pageDataDefaults = {
|
||||
subUrl: appSubUrl,
|
||||
organizations: [] as Array<{name: string, full_name: string, num_repos: number, org_visibility: string}>,
|
||||
isOrganization: true,
|
||||
canCreateOrganization: false,
|
||||
organizationsTotalCount: 0,
|
||||
organizationId: 0,
|
||||
searchLimit: 0,
|
||||
uid: 0,
|
||||
teamId: 0,
|
||||
isMirrorsEnabled: false,
|
||||
textNoOrg: '',
|
||||
textNoRepo: '',
|
||||
textRepository: '',
|
||||
textOrganization: '',
|
||||
textMyRepos: '',
|
||||
textNewRepo: '',
|
||||
textSearchRepos: '',
|
||||
textFilter: '',
|
||||
textShowArchived: '',
|
||||
textShowPrivate: '',
|
||||
textShowBothArchivedUnarchived: '',
|
||||
textShowOnlyUnarchived: '',
|
||||
textShowOnlyArchived: '',
|
||||
textShowBothPrivatePublic: '',
|
||||
textShowOnlyPublic: '',
|
||||
textShowOnlyPrivate: '',
|
||||
textAll: '',
|
||||
textSources: '',
|
||||
textForks: '',
|
||||
textMirrors: '',
|
||||
textCollaborative: '',
|
||||
textFirstPage: '',
|
||||
textPreviousPage: '',
|
||||
textNextPage: '',
|
||||
textLastPage: '',
|
||||
textMyOrgs: '',
|
||||
textNewOrg: '',
|
||||
textOrgVisibilityLimited: '',
|
||||
textOrgVisibilityPrivate: '',
|
||||
};
|
||||
|
||||
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'};
|
||||
},
|
||||
},
|
||||
const {
|
||||
subUrl, organizations, isOrganization, canCreateOrganization, organizationsTotalCount, organizationId,
|
||||
searchLimit, uid, teamId, isMirrorsEnabled,
|
||||
textNoOrg, textNoRepo, textRepository, textOrganization, textMyRepos, textNewRepo, textSearchRepos,
|
||||
textFilter, textShowArchived, textShowPrivate,
|
||||
textShowBothArchivedUnarchived, textShowOnlyUnarchived, textShowOnlyArchived,
|
||||
textShowBothPrivatePublic, textShowOnlyPublic, textShowOnlyPrivate,
|
||||
textAll, textSources, textForks, textMirrors, textCollaborative,
|
||||
textFirstPage, textPreviousPage, textNextPage, textLastPage,
|
||||
textMyOrgs, textNewOrg, textOrgVisibilityLimited, textOrgVisibilityPrivate,
|
||||
}: typeof pageDataDefaults = {...pageDataDefaults, ...pageData.dashboardRepoList};
|
||||
|
||||
mounted() {
|
||||
const el = document.querySelector('#dashboard-repo-list')!;
|
||||
this.changeReposFilter(this.reposFilter);
|
||||
fomanticQuery(el.querySelector('.ui.dropdown')!).dropdown();
|
||||
const textArchivedFilterTitles = new Map<ArchivedFilter, string>([
|
||||
['archived', textShowOnlyArchived],
|
||||
['unarchived', textShowOnlyUnarchived],
|
||||
['both', textShowBothArchivedUnarchived],
|
||||
]);
|
||||
|
||||
this.textArchivedFilterTitles = {
|
||||
'archived': this.textShowOnlyArchived,
|
||||
'unarchived': this.textShowOnlyUnarchived,
|
||||
'both': this.textShowBothArchivedUnarchived,
|
||||
};
|
||||
const textPrivateFilterTitles = new Map<PrivateFilter, string>([
|
||||
['private', textShowOnlyPrivate],
|
||||
['public', textShowOnlyPublic],
|
||||
['both', textShowBothPrivatePublic],
|
||||
]);
|
||||
|
||||
this.textPrivateFilterTitles = {
|
||||
'private': this.textShowOnlyPrivate,
|
||||
'public': this.textShowOnlyPublic,
|
||||
'both': this.textShowBothPrivatePublic,
|
||||
};
|
||||
},
|
||||
const initialParams = new URLSearchParams(window.location.search);
|
||||
const tab = shallowRef((initialParams.get('repo-search-tab') || 'repos') as Tab);
|
||||
const reposFilter = shallowRef((initialParams.get('repo-search-filter') || 'all') as RepoFilter);
|
||||
const privateFilter = shallowRef((initialParams.get('repo-search-private') || 'both') as PrivateFilter);
|
||||
const archivedFilter = shallowRef((initialParams.get('repo-search-archived') || 'unarchived') as ArchivedFilter);
|
||||
const searchQuery = shallowRef(initialParams.get('repo-search-query') || '');
|
||||
const page = shallowRef(Number(initialParams.get('repo-search-page')) || 1);
|
||||
|
||||
methods: {
|
||||
changeTab(tab: string) {
|
||||
this.tab = tab;
|
||||
this.updateHistory();
|
||||
},
|
||||
const repos = shallowRef<DashboardRepo[]>([]);
|
||||
const reposTotalCount = shallowRef<number | null>(null);
|
||||
const finalPage = shallowRef(1);
|
||||
const counts = shallowRef<Record<string, number>>({});
|
||||
const isLoading = shallowRef(false);
|
||||
const initialSearchDone = shallowRef(false);
|
||||
const activeIndex = shallowRef(-1); // don't select anything at load, first cursor down will select
|
||||
|
||||
changeReposFilter(filter: string) {
|
||||
this.reposFilter = filter;
|
||||
this.repos = [];
|
||||
this.page = 1;
|
||||
this.searchRepos();
|
||||
},
|
||||
const elSearch = useTemplateRef('elSearch') as Readonly<ShallowRef<HTMLInputElement>>;
|
||||
|
||||
updateHistory() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const countsKey = computed(() => `${reposFilter.value}:${archivedFilter.value}:${privateFilter.value}`);
|
||||
const showMoreReposLink = computed(() => repos.value.length > 0 && repos.value.length < repoTypeCount.value);
|
||||
const repoTypeCount = computed(() => counts.value[countsKey.value]);
|
||||
const checkboxArchivedFilterTitle = computed(() => textArchivedFilterTitles.get(archivedFilter.value));
|
||||
const checkboxArchivedFilterProps = computed(() => ({checked: archivedFilter.value === 'archived', indeterminate: archivedFilter.value === 'both'}));
|
||||
const checkboxPrivateFilterTitle = computed(() => textPrivateFilterTitles.get(privateFilter.value));
|
||||
const checkboxPrivateFilterProps = computed(() => ({checked: privateFilter.value === 'private', indeterminate: privateFilter.value === 'both'}));
|
||||
|
||||
if (this.tab === 'repos') {
|
||||
params.delete('repo-search-tab');
|
||||
} else {
|
||||
params.set('repo-search-tab', this.tab);
|
||||
}
|
||||
// unknown query string values fall back to no mode
|
||||
const searchMode = computed(() => searchModes.get(reposFilter.value) ?? '');
|
||||
|
||||
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.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.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 = [];
|
||||
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 as HTMLInputElement).focus({preventScroll: true});
|
||||
}
|
||||
});
|
||||
}
|
||||
response = await GET(searchedURL);
|
||||
json = await response.json();
|
||||
} catch {
|
||||
if (searchedURL === this.searchURL) {
|
||||
this.isLoading = false;
|
||||
this.initialSearchDone = true;
|
||||
}
|
||||
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;
|
||||
this.initialSearchDone = true;
|
||||
}
|
||||
},
|
||||
|
||||
repoIcon(repo: DashboardRepo) {
|
||||
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) {
|
||||
if (e.isComposing) return;
|
||||
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;
|
||||
}
|
||||
},
|
||||
},
|
||||
const searchURL = computed(() => {
|
||||
// unknown query string values send no filter
|
||||
const archived = archivedFilter.value === 'archived' ? '&archived=true' : archivedFilter.value === 'unarchived' ? '&archived=false' : '';
|
||||
const isPrivate = privateFilter.value === 'private' ? '&is_private=true' : privateFilter.value === 'public' ? '&is_private=false' : '';
|
||||
return `${subUrl}/repo/search?sort=updated&order=desc&uid=${uid}&team_id=${teamId}&q=${urlQueryEscape(searchQuery.value)}` +
|
||||
`&page=${page.value}&limit=${searchLimit}&mode=${searchMode.value}${archived}${isPrivate}`;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
changeReposFilter(reposFilter.value); // the filter dropdown is initialised by the global observer
|
||||
});
|
||||
|
||||
function changeTab(newTab: Tab) {
|
||||
tab.value = newTab;
|
||||
updateHistory();
|
||||
}
|
||||
|
||||
function changeReposFilter(filter: RepoFilter) {
|
||||
reposFilter.value = filter;
|
||||
repos.value = [];
|
||||
page.value = 1;
|
||||
searchRepos();
|
||||
}
|
||||
|
||||
function updateHistory() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
if (tab.value === 'repos') {
|
||||
params.delete('repo-search-tab');
|
||||
} else {
|
||||
params.set('repo-search-tab', tab.value);
|
||||
}
|
||||
|
||||
if (reposFilter.value === 'all') {
|
||||
params.delete('repo-search-filter');
|
||||
} else {
|
||||
params.set('repo-search-filter', reposFilter.value);
|
||||
}
|
||||
|
||||
if (privateFilter.value === 'both') {
|
||||
params.delete('repo-search-private');
|
||||
} else {
|
||||
params.set('repo-search-private', privateFilter.value);
|
||||
}
|
||||
|
||||
if (archivedFilter.value === 'unarchived') {
|
||||
params.delete('repo-search-archived');
|
||||
} else {
|
||||
params.set('repo-search-archived', archivedFilter.value);
|
||||
}
|
||||
|
||||
if (searchQuery.value === '') {
|
||||
params.delete('repo-search-query');
|
||||
} else {
|
||||
params.set('repo-search-query', searchQuery.value);
|
||||
}
|
||||
|
||||
if (page.value === 1) {
|
||||
params.delete('repo-search-page');
|
||||
} else {
|
||||
params.set('repo-search-page', `${page.value}`);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
if (queryString) {
|
||||
window.history.replaceState({}, '', `?${queryString}`);
|
||||
} else {
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleArchivedFilter() {
|
||||
if (archivedFilter.value === 'unarchived') {
|
||||
archivedFilter.value = 'archived';
|
||||
} else if (archivedFilter.value === 'archived') {
|
||||
archivedFilter.value = 'both';
|
||||
} else { // including both
|
||||
archivedFilter.value = 'unarchived';
|
||||
}
|
||||
page.value = 1;
|
||||
repos.value = [];
|
||||
searchRepos();
|
||||
}
|
||||
|
||||
function togglePrivateFilter() {
|
||||
if (privateFilter.value === 'both') {
|
||||
privateFilter.value = 'public';
|
||||
} else if (privateFilter.value === 'public') {
|
||||
privateFilter.value = 'private';
|
||||
} else { // including private
|
||||
privateFilter.value = 'both';
|
||||
}
|
||||
page.value = 1;
|
||||
repos.value = [];
|
||||
searchRepos();
|
||||
}
|
||||
|
||||
async function changePage(newPage: number) {
|
||||
if (isLoading.value) return;
|
||||
|
||||
if (newPage > finalPage.value) newPage = finalPage.value;
|
||||
if (newPage < 1) newPage = 1;
|
||||
page.value = newPage;
|
||||
repos.value = [];
|
||||
await searchRepos();
|
||||
}
|
||||
|
||||
async function searchRepos() {
|
||||
isLoading.value = true;
|
||||
|
||||
const searchedMode = searchMode.value;
|
||||
const searchedURL = searchURL.value;
|
||||
const searchedQuery = searchQuery.value;
|
||||
|
||||
let response: Response, json: any;
|
||||
try {
|
||||
const firstLoad = reposTotalCount.value === null;
|
||||
// independent of the search, so both requests go out together
|
||||
const totalCountSearchURL = `${subUrl}/repo/search?count_only=1&uid=${uid}&team_id=${teamId}&q=&page=1&mode=`;
|
||||
const totalCountRequest = reposTotalCount.value ? null : GET(totalCountSearchURL);
|
||||
const searchRequest = GET(searchedURL);
|
||||
searchRequest.catch(() => {}); // awaited below, marked handled in case the count throws first
|
||||
if (totalCountRequest) {
|
||||
reposTotalCount.value = parseInt((await totalCountRequest).headers.get('X-Total-Count') ?? '0');
|
||||
}
|
||||
if (firstLoad && reposTotalCount.value) {
|
||||
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)) {
|
||||
elSearch.value.focus({preventScroll: true});
|
||||
}
|
||||
});
|
||||
}
|
||||
response = await searchRequest;
|
||||
json = await response.json();
|
||||
} catch {
|
||||
if (searchedURL === searchURL.value) {
|
||||
isLoading.value = false;
|
||||
initialSearchDone.value = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchedURL === searchURL.value) {
|
||||
repos.value = 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 = parseInt(response.headers.get('X-Total-Count') ?? '0');
|
||||
if (searchedQuery === '' && searchedMode === '' && archivedFilter.value === 'both') {
|
||||
reposTotalCount.value = count;
|
||||
}
|
||||
counts.value = {...counts.value, [countsKey.value]: count};
|
||||
finalPage.value = Math.ceil(count / searchLimit);
|
||||
updateHistory();
|
||||
isLoading.value = false;
|
||||
initialSearchDone.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function repoIcon(repo: DashboardRepo): SvgName {
|
||||
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';
|
||||
}
|
||||
return 'octicon-repo';
|
||||
}
|
||||
|
||||
function statusIcon(status: CommitStatus) {
|
||||
return commitStatus[status].name;
|
||||
}
|
||||
|
||||
function statusColor(status: CommitStatus) {
|
||||
return commitStatus[status].color;
|
||||
}
|
||||
|
||||
async function reposFilterKeyControl(e: KeyboardEvent) {
|
||||
if (e.isComposing) return;
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
document.querySelector<HTMLAnchorElement>('.repo-owner-name-list li.active a')?.click();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (activeIndex.value > 0) {
|
||||
activeIndex.value--;
|
||||
} else if (page.value > 1) {
|
||||
await changePage(page.value - 1);
|
||||
activeIndex.value = searchLimit - 1;
|
||||
}
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (activeIndex.value < repos.value.length - 1) {
|
||||
activeIndex.value++;
|
||||
} else if (page.value < finalPage.value) {
|
||||
activeIndex.value = 0;
|
||||
await changePage(page.value + 1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (page.value < finalPage.value) {
|
||||
await changePage(page.value + 1);
|
||||
}
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (page.value > 1) {
|
||||
await changePage(page.value - 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (activeIndex.value === -1 || activeIndex.value > repos.value.length - 1) {
|
||||
activeIndex.value = 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
@@ -420,13 +398,13 @@ export default defineComponent({
|
||||
</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">
|
||||
<input type="search" spellcheck="false" maxlength="255" @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="elSearch" @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">
|
||||
<div class="ui checkbox" :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">
|
||||
@@ -437,7 +415,7 @@ export default defineComponent({
|
||||
</div>
|
||||
</a>
|
||||
<a class="item" @click="togglePrivateFilter()">
|
||||
<div class="ui checkbox" ref="checkboxPrivateFilter" :title="checkboxPrivateFilterTitle">
|
||||
<div class="ui checkbox" :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"/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {defineComponent} from 'vue';
|
||||
<script lang="ts" setup>
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, type ShallowRef} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {generateElemId} from '../utils/dom.ts';
|
||||
@@ -20,205 +20,203 @@ type CommitListResult = {
|
||||
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: '' as string | null,
|
||||
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);
|
||||
}
|
||||
});
|
||||
},
|
||||
const elRoot = useTemplateRef('elRoot') as Readonly<ShallowRef<HTMLDivElement>>;
|
||||
const elExpandBtn = useTemplateRef('elExpandBtn') as Readonly<ShallowRef<HTMLButtonElement>>;
|
||||
const elShowAllChanges = useTemplateRef('elShowAllChanges') as Readonly<ShallowRef<HTMLDivElement>>;
|
||||
|
||||
/** 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 | null = null;
|
||||
if (firstSelected === 0) {
|
||||
beforeCommitID = this.mergeBase;
|
||||
} else {
|
||||
beforeCommitID = this.commits[firstSelected - 1].id;
|
||||
}
|
||||
const afterCommitID = this.commits[lastSelected].id;
|
||||
const elMount = document.querySelector('#diff-commit-select')!;
|
||||
const queryParams = elMount.getAttribute('data-queryparams');
|
||||
const issueLink = elMount.getAttribute('data-issuelink');
|
||||
const mergeBase = elMount.getAttribute('data-merge-base');
|
||||
const uniqueIdMenu = generateElemId('diff-commit-selector-menu-');
|
||||
const uniqueIdShowAll = generateElemId('diff-commit-selector-show-all-');
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
const menuVisible = shallowRef(false);
|
||||
const isLoading = shallowRef(false);
|
||||
const locale = shallowRef<Record<string, string>>({filter_changes_by_commit: elMount.getAttribute('data-filter_changes_by_commit')!});
|
||||
const commits = ref<Array<Commit>>([]); // deep, the commit objects are mutated in place
|
||||
const hoverActivated = shallowRef(false);
|
||||
const lastReviewCommitSha = shallowRef<string | null>(null);
|
||||
|
||||
const commitsSinceLastReview = computed(() => {
|
||||
if (lastReviewCommitSha.value) {
|
||||
return commits.value.length - commits.value.findIndex((x) => x.id === lastReviewCommitSha.value) - 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.body.addEventListener('click', onBodyClick);
|
||||
elRoot.value.addEventListener('keydown', onKeyDown);
|
||||
elRoot.value.addEventListener('keyup', onKeyUp);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => { // template refs are null by onUnmounted
|
||||
document.body.removeEventListener('click', onBodyClick);
|
||||
elRoot.value.removeEventListener('keydown', onKeyDown);
|
||||
elRoot.value.removeEventListener('keyup', onKeyUp);
|
||||
});
|
||||
|
||||
function onBodyClick(event: MouseEvent) {
|
||||
// close this menu on click outside of this element when the dropdown is currently visible opened
|
||||
if (elRoot.value.contains(event.target as Node)) return;
|
||||
if (menuVisible.value) {
|
||||
toggleMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (!menuVisible.value) return;
|
||||
const item = document.activeElement as HTMLElement;
|
||||
if (!elRoot.value.contains(item)) return;
|
||||
switch (event.key) {
|
||||
case 'ArrowDown': // select next element
|
||||
event.preventDefault();
|
||||
focusElem(item.nextElementSibling as HTMLElement, item);
|
||||
break;
|
||||
case 'ArrowUp': // select previous element
|
||||
event.preventDefault();
|
||||
focusElem(item.previousElementSibling as HTMLElement, item);
|
||||
break;
|
||||
case 'Escape': // close menu
|
||||
event.preventDefault();
|
||||
item.tabIndex = -1;
|
||||
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) highlight(commits.value[Number(commitIdx)]);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyUp(event: KeyboardEvent) {
|
||||
if (!menuVisible.value) return;
|
||||
const item = document.activeElement;
|
||||
if (!elRoot.value.contains(item)) return;
|
||||
if (event.key === 'Shift' && hoverActivated.value) {
|
||||
// shift is not pressed anymore -> deactivate hovering and reset hovered and selected
|
||||
hoverActivated.value = false;
|
||||
for (const commit of commits.value) {
|
||||
commit.hovered = false;
|
||||
commit.selected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function highlight(commit: Commit) {
|
||||
if (!hoverActivated.value) return;
|
||||
const indexSelected = commits.value.findIndex((x) => x.selected);
|
||||
const indexCurrentElem = commits.value.findIndex((x) => x.id === commit.id);
|
||||
for (const [idx, commit] of commits.value.entries()) {
|
||||
commit.hovered = Math.min(indexSelected, indexCurrentElem) <= idx && idx <= Math.max(indexSelected, indexCurrentElem);
|
||||
}
|
||||
}
|
||||
|
||||
/** Focus given element */
|
||||
function 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 function toggleMenu() {
|
||||
menuVisible.value = !menuVisible.value;
|
||||
// load our commits when the menu is not yet visible (it'll be toggled after loading)
|
||||
// and we got no commits
|
||||
if (!commits.value.length && menuVisible.value && !isLoading.value) {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await fetchCommits();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
// set correct tabindex to allow easier navigation
|
||||
nextTick(() => {
|
||||
if (menuVisible.value) {
|
||||
focusElem(elShowAllChanges.value, elExpandBtn.value);
|
||||
} else {
|
||||
focusElem(elExpandBtn.value, elShowAllChanges.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Load the commits to show in this dropdown */
|
||||
async function fetchCommits() {
|
||||
const resp = await GET(`${issueLink}/commits/list`);
|
||||
const results = await resp.json() as CommitListResult;
|
||||
for (const commit of results.commits) commit.hovered = false;
|
||||
commits.value.push(...results.commits);
|
||||
commits.value.reverse();
|
||||
lastReviewCommitSha.value = results.last_review_commit_sha || null;
|
||||
if (lastReviewCommitSha.value && !commits.value.some((x) => x.id === lastReviewCommitSha.value)) {
|
||||
// the lastReviewCommit is not available (probably due to a force push)
|
||||
// reset the last review commit sha
|
||||
lastReviewCommitSha.value = null;
|
||||
}
|
||||
locale.value = {...locale.value, ...results.locale};
|
||||
}
|
||||
|
||||
function showAllChanges() {
|
||||
window.location.assign(`${issueLink}/files${queryParams}`);
|
||||
}
|
||||
|
||||
/** Called when user clicks on since last review */
|
||||
function changesSinceLastReviewClick() {
|
||||
window.location.assign(`${issueLink}/files/${lastReviewCommitSha.value}..${commits.value.at(-1)!.id}${queryParams}`);
|
||||
}
|
||||
|
||||
/** Clicking on a single commit opens this specific commit */
|
||||
function commitClicked(commitId: string, newWindow = false) {
|
||||
const url = `${issueLink}/commits/${commitId}${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.
|
||||
*/
|
||||
function commitClickedShift(commit: Commit) {
|
||||
hoverActivated.value = !hoverActivated.value;
|
||||
commit.selected = true;
|
||||
// Second click -> determine our range and open links accordingly
|
||||
if (!hoverActivated.value) {
|
||||
// since at least one commit is selected, we can determine the range
|
||||
// find all selected commits and generate a link
|
||||
const firstSelected = commits.value.findIndex((x) => x.selected);
|
||||
const lastSelected = commits.value.findLastIndex((x) => x.selected);
|
||||
const beforeCommitID = firstSelected === 0 ? mergeBase : commits.value[firstSelected - 1].id;
|
||||
const afterCommitID = commits.value[lastSelected].id;
|
||||
|
||||
if (firstSelected === lastSelected) {
|
||||
// if the start and end are the same, we show this single commit
|
||||
window.location.assign(`${issueLink}/commits/${afterCommitID}${queryParams}`);
|
||||
} else if (beforeCommitID === mergeBase && afterCommitID === commits.value.at(-1)!.id) {
|
||||
// if the first commit is selected and the last commit is selected, we show all commits
|
||||
window.location.assign(`${issueLink}/files${queryParams}`);
|
||||
} else {
|
||||
window.location.assign(`${issueLink}/files/${beforeCommitID}..${afterCommitID}${queryParams}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="ui scrolling dropdown custom diff-commit-selector">
|
||||
<div class="ui scrolling dropdown custom diff-commit-selector" ref="elRoot">
|
||||
<button
|
||||
ref="expandBtn"
|
||||
ref="elExpandBtn"
|
||||
class="ui tiny basic button"
|
||||
@click.stop="toggleMenu()"
|
||||
:data-tooltip-content="locale.filter_changes_by_commit"
|
||||
@@ -232,7 +230,7 @@ export default defineComponent({
|
||||
<!-- 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 v-if="!isLoading" class="item" :id="uniqueIdShowAll" ref="elShowAllChanges" role="menuitem" @keydown.enter="showAllChanges()" @click="showAllChanges()">
|
||||
<div class="gt-ellipsis">
|
||||
{{ locale.show_all_commits }}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import {defineComponent, nextTick} from 'vue';
|
||||
<script lang="ts" setup>
|
||||
import {computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch, type ShallowRef} 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 {queryElemChildren} from '../utils/dom.ts';
|
||||
import type {GitRefType} from '../types.ts';
|
||||
import {trString} from '../modules/i18n.ts';
|
||||
|
||||
@@ -18,209 +19,202 @@ type SelectedTab = 'branches' | 'tags';
|
||||
|
||||
type TabLoadingStates = Record<SelectedTab, '' | 'loading' | 'done'>
|
||||
|
||||
export default defineComponent({
|
||||
components: {SvgIcon},
|
||||
props: {
|
||||
elRoot: {
|
||||
type: HTMLElement,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const shouldShowTabBranches = this.elRoot.getAttribute('data-show-tab-branches') === 'true';
|
||||
return {
|
||||
allItems: [] as ListItem[],
|
||||
selectedTab: (shouldShowTabBranches ? 'branches' : 'tags') as SelectedTab,
|
||||
searchTerm: '',
|
||||
menuVisible: false,
|
||||
activeItemIndex: 0,
|
||||
tabLoadingStates: {} as TabLoadingStates,
|
||||
const props = defineProps<{
|
||||
elRoot: HTMLElement;
|
||||
}>();
|
||||
|
||||
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')!,
|
||||
const elDropdown = useTemplateRef('elDropdown') as Readonly<ShallowRef<HTMLDivElement>>;
|
||||
const elCreateNewRefForm = useTemplateRef('elCreateNewRefForm') as Readonly<ShallowRef<HTMLFormElement>>;
|
||||
const elScrollContainer = useTemplateRef('elScrollContainer') as Readonly<ShallowRef<HTMLDivElement>>;
|
||||
const elSearchField = useTemplateRef('elSearchField') as Readonly<ShallowRef<HTMLInputElement>>;
|
||||
|
||||
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')!,
|
||||
const showTabBranches = props.elRoot.getAttribute('data-show-tab-branches') === 'true';
|
||||
|
||||
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);
|
||||
});
|
||||
const allItems = shallowRef<ListItem[]>([]);
|
||||
const selectedTab = shallowRef<SelectedTab>(showTabBranches ? 'branches' : 'tags');
|
||||
const searchTerm = shallowRef('');
|
||||
const menuVisible = shallowRef(false);
|
||||
const activeItemIndex = shallowRef(0);
|
||||
const tabLoadingStates = shallowRef<TabLoadingStates>({branches: '', tags: ''});
|
||||
|
||||
// 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;
|
||||
});
|
||||
},
|
||||
const textBranches = props.elRoot.getAttribute('data-text-branches')!;
|
||||
const textTags = props.elRoot.getAttribute('data-text-tags')!;
|
||||
const textFilterBranch = props.elRoot.getAttribute('data-text-filter-branch')!;
|
||||
const textFilterTag = props.elRoot.getAttribute('data-text-filter-tag')!;
|
||||
const textDefaultBranchLabel = props.elRoot.getAttribute('data-text-default-branch-label')!;
|
||||
const textCreateTag = props.elRoot.getAttribute('data-text-create-tag')!;
|
||||
const textCreateBranch = props.elRoot.getAttribute('data-text-create-branch')!;
|
||||
const textCreateRefFrom = props.elRoot.getAttribute('data-text-create-ref-from')!;
|
||||
const textNoResults = props.elRoot.getAttribute('data-text-no-results')!;
|
||||
const textViewAllBranches = props.elRoot.getAttribute('data-text-view-all-branches')!;
|
||||
const textViewAllTags = props.elRoot.getAttribute('data-text-view-all-tags')!;
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
const currentRepoDefaultBranch = props.elRoot.getAttribute('data-current-repo-default-branch')!;
|
||||
const currentRepoLink = props.elRoot.getAttribute('data-current-repo-link')!;
|
||||
const currentTreePath = props.elRoot.getAttribute('data-current-tree-path')!;
|
||||
const currentRefType = shallowRef(props.elRoot.getAttribute('data-current-ref-type') as GitRefType);
|
||||
const currentRefShortName = shallowRef(props.elRoot.getAttribute('data-current-ref-short-name')!);
|
||||
|
||||
methods: {
|
||||
trString,
|
||||
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}`] as Array<HTMLDivElement>;
|
||||
return el?.length ? el[0] : null;
|
||||
},
|
||||
keydown(e: KeyboardEvent) {
|
||||
if (e.isComposing) return;
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const refLinkTemplate = props.elRoot.getAttribute('data-ref-link-template')!;
|
||||
const refFormActionTemplate = props.elRoot.getAttribute('data-ref-form-action-template')!;
|
||||
const dropdownFixedText = props.elRoot.getAttribute('data-dropdown-fixed-text')!;
|
||||
const showTabTags = props.elRoot.getAttribute('data-show-tab-tags') === 'true';
|
||||
const allowCreateNewRef = props.elRoot.getAttribute('data-allow-create-new-ref') === 'true';
|
||||
const showViewAllRefsEntry = props.elRoot.getAttribute('data-show-view-all-refs-entry') === 'true';
|
||||
const enableFeed = props.elRoot.getAttribute('data-enable-feed') === 'true';
|
||||
|
||||
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 searchFieldPlaceholder = computed(() => selectedTab.value === 'branches' ? textFilterBranch : textFilterTag);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
},
|
||||
const filteredItems = computed<ListItem[]>(() => {
|
||||
const searchTermLower = searchTerm.value.toLowerCase();
|
||||
const items = allItems.value.filter((item: ListItem) => {
|
||||
const typeMatched = (selectedTab.value === 'branches' && item.refType === 'branch') || (selectedTab.value === 'tags' && item.refType === 'tag');
|
||||
if (!typeMatched) return false;
|
||||
if (!searchTerm.value) return true; // match all
|
||||
return item.refShortName.toLowerCase().includes(searchTermLower);
|
||||
});
|
||||
|
||||
// TODO: fix this anti-pattern: side-effects-in-computed-properties
|
||||
activeItemIndex.value = !items.length && showCreateNewRef.value ? 0 : -1; // eslint-disable-line vue/no-side-effects-in-computed-properties
|
||||
return items;
|
||||
});
|
||||
|
||||
const showNoResults = computed(() => {
|
||||
if (tabLoadingStates.value[selectedTab.value] !== 'done') return false;
|
||||
return !filteredItems.value.length && !showCreateNewRef.value;
|
||||
});
|
||||
|
||||
const showCreateNewRef = computed(() => {
|
||||
if (!allowCreateNewRef || !searchTerm.value) {
|
||||
return false;
|
||||
}
|
||||
// FIXME: not quite right here, it mixes "branch" and "tag" names
|
||||
return !allItems.value.some((item: ListItem) => item.refShortName === searchTerm.value);
|
||||
});
|
||||
|
||||
const createNewRefFormActionUrl = computed(() => {
|
||||
return `${currentRepoLink}/branches/_new/${currentRefType.value}/${pathEscapeSegments(currentRefShortName.value)}`;
|
||||
});
|
||||
|
||||
watch(menuVisible, (visible: boolean) => {
|
||||
if (!visible) return;
|
||||
focusSearchField();
|
||||
loadTabItems();
|
||||
});
|
||||
|
||||
function onBodyClick(e: MouseEvent) {
|
||||
if (elDropdown.value.contains(e.target as Node)) return;
|
||||
if (menuVisible.value) menuVisible.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.body.addEventListener('click', onBodyClick);
|
||||
if (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: currentRefType.value, refShortName: currentRefShortName.value, rssFeedLink: ''};
|
||||
selectItem(item);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => { // template refs are null by onUnmounted
|
||||
document.body.removeEventListener('click', onBodyClick);
|
||||
});
|
||||
|
||||
function selectItem(item: ListItem) {
|
||||
menuVisible.value = false;
|
||||
if (refFormActionTemplate) {
|
||||
currentRefType.value = item.refType;
|
||||
currentRefShortName.value = item.refShortName;
|
||||
elDropdown.value.closest('form')!.action = refFormActionTemplate
|
||||
.replace('{RepoLink}', currentRepoLink)
|
||||
.replace('{RefType}', pathEscapeSegments(item.refType))
|
||||
.replace('{RefShortName}', pathEscapeSegments(item.refShortName));
|
||||
} else {
|
||||
window.location.href = refLinkTemplate
|
||||
.replace('{RepoLink}', currentRepoLink)
|
||||
.replace('{RefType}', pathEscapeSegments(item.refType))
|
||||
.replace('{RefShortName}', pathEscapeSegments(item.refShortName))
|
||||
.replace('{TreePath}', pathEscapeSegments(currentTreePath));
|
||||
}
|
||||
}
|
||||
|
||||
function createNewRef() {
|
||||
elCreateNewRefForm.value?.submit();
|
||||
}
|
||||
|
||||
function focusSearchField() {
|
||||
nextTick(() => {
|
||||
elSearchField.value.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectedIndexInFiltered() {
|
||||
return filteredItems.value.findIndex((item) => item.selected);
|
||||
}
|
||||
|
||||
function getActiveItem() {
|
||||
// not filteredItems, its getter resets activeItemIndex when dirty
|
||||
return queryElemChildren<HTMLDivElement>(elScrollContainer.value, '.item')[activeItemIndex.value] ?? null;
|
||||
}
|
||||
|
||||
function keydown(e: KeyboardEvent) {
|
||||
if (e.isComposing) return;
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
|
||||
if (activeItemIndex.value === -1) {
|
||||
activeItemIndex.value = getSelectedIndexInFiltered();
|
||||
}
|
||||
const nextIndex = e.key === 'ArrowDown' ? activeItemIndex.value + 1 : activeItemIndex.value - 1;
|
||||
if (nextIndex < 0) {
|
||||
return;
|
||||
}
|
||||
if (nextIndex + (showCreateNewRef.value ? 0 : 1) > filteredItems.value.length) {
|
||||
return;
|
||||
}
|
||||
activeItemIndex.value = nextIndex;
|
||||
getActiveItem()!.scrollIntoView({block: 'nearest'});
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
getActiveItem()?.click();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
menuVisible.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabSwitch(tab: SelectedTab) {
|
||||
selectedTab.value = tab;
|
||||
focusSearchField();
|
||||
loadTabItems();
|
||||
}
|
||||
|
||||
async function loadTabItems() {
|
||||
const tab = selectedTab.value;
|
||||
if (tabLoadingStates.value[tab] === 'loading' || tabLoadingStates.value[tab] === 'done') return;
|
||||
|
||||
const refType = tab === 'branches' ? 'branch' : 'tag';
|
||||
tabLoadingStates.value = {...tabLoadingStates.value, [tab]: 'loading'};
|
||||
try {
|
||||
const resp = await GET(`${currentRepoLink}/${tab}/list`);
|
||||
const {results} = await resp.json() as {results: string[]};
|
||||
allItems.value = [...allItems.value, ...results.map((refShortName): ListItem => ({
|
||||
refType,
|
||||
refShortName,
|
||||
selected: refType === currentRefType.value && refShortName === currentRefShortName.value,
|
||||
rssFeedLink: `${currentRepoLink}/rss/${refType}/${pathEscapeSegments(refShortName)}`,
|
||||
}))];
|
||||
tabLoadingStates.value = {...tabLoadingStates.value, [tab]: 'done'};
|
||||
} catch (e) {
|
||||
tabLoadingStates.value = {...tabLoadingStates.value, [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 class="ui dropdown custom branch-selector-dropdown ellipsis-text-items" ref="elDropdown">
|
||||
<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>
|
||||
@@ -228,7 +222,7 @@ export default defineComponent({
|
||||
<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>
|
||||
<strong class="tw-inline-block gt-ellipsis">{{ currentRefShortName }}</strong>
|
||||
</template>
|
||||
</span>
|
||||
<svg-icon name="octicon-triangle-down" :size="14" class="dropdown icon"/>
|
||||
@@ -236,7 +230,7 @@ export default defineComponent({
|
||||
<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">
|
||||
<input name="search" ref="elSearchField" 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')">
|
||||
@@ -247,10 +241,10 @@ export default defineComponent({
|
||||
</a>
|
||||
</div>
|
||||
<div class="branch-tag-divider"/>
|
||||
<div class="scrolling menu" ref="scrollContainer">
|
||||
<div class="scrolling menu" ref="elScrollContainer">
|
||||
<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">
|
||||
<div v-for="(item, index) in filteredItems" :key="item.refShortName" class="item" :class="{selected: item.selected, active: activeItemIndex === index}" @click="selectItem(item)">
|
||||
{{ item.refShortName }}
|
||||
<div class="ui label" v-if="item.refType === 'branch' && item.refShortName === currentRepoDefaultBranch">
|
||||
{{ textDefaultBranchLabel }}
|
||||
@@ -260,7 +254,7 @@ export default defineComponent({
|
||||
<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 class="item" v-if="showCreateNewRef" :class="{active: activeItemIndex === filteredItems.length}" @click="createNewRef()">
|
||||
<div v-if="selectedTab === 'tags'">
|
||||
<svg-icon name="octicon-tag" class="tw-mr-1"/>
|
||||
<span v-text="trString(textCreateTag, searchTerm)"/>
|
||||
@@ -272,7 +266,7 @@ export default defineComponent({
|
||||
<div class="tw-text-xs">
|
||||
{{ textCreateRefFrom.replace('%s', currentRefShortName) }}
|
||||
</div>
|
||||
<form ref="createNewRefForm" method="post" :action="createNewRefFormActionUrl">
|
||||
<form ref="elCreateNewRefForm" method="post" :action="createNewRefFormActionUrl">
|
||||
<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">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import {defineComponent, type PropType} from 'vue';
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, shallowRef} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import dayjs from 'dayjs';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {Line as ChartLine} from 'vue-chartjs';
|
||||
import {
|
||||
Chart,
|
||||
Title,
|
||||
@@ -15,21 +17,24 @@ import {
|
||||
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 {chartJsColors} from '../utils/color.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {
|
||||
startDaysBetween,
|
||||
firstStartDateAfterDate,
|
||||
fillEmptyStartDaysWithZeroes,
|
||||
} from '../utils/time.ts';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {errorMessage} from '../modules/errors.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';
|
||||
|
||||
type ContributionType = 'commits' | 'additions' | 'deletions';
|
||||
type ChartType = 'main' | 'contributor';
|
||||
|
||||
const oneWeek = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const customEventListener: Plugin = {
|
||||
id: 'customEventListener',
|
||||
afterEvent: (chart, args, opts) => {
|
||||
@@ -37,7 +42,7 @@ const customEventListener: Plugin = {
|
||||
// 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);
|
||||
opts.onDoubleClick({chart}, true);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -45,8 +50,8 @@ const customEventListener: Plugin = {
|
||||
type LineOptions = ChartOptions<'line'> & {
|
||||
plugins?: {
|
||||
customEventListener?: {
|
||||
chartType: string;
|
||||
instance: unknown;
|
||||
chartType: ChartType;
|
||||
onDoubleClick: (args: {chart: Chart}, reset: boolean) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -66,6 +71,12 @@ Chart.register(
|
||||
customEventListener,
|
||||
);
|
||||
|
||||
// rounds up to the next multiple of the leading power of ten, so the axis does not rescale on zoom and pan
|
||||
function roundUpMax(maxValue: number) {
|
||||
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
|
||||
return Math.ceil(coefficient) * 10 ** exp;
|
||||
}
|
||||
|
||||
type ContributorsData = {
|
||||
total: {
|
||||
weeks: Record<string, any>,
|
||||
@@ -73,270 +84,247 @@ type ContributorsData = {
|
||||
[other: string]: Record<string, Record<string, any>>,
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
components: {ChartLine, SvgIcon},
|
||||
props: {
|
||||
locale: {
|
||||
type: Object as PropType<Record<string, any>>,
|
||||
required: true,
|
||||
const props = defineProps<{
|
||||
locale: {
|
||||
filterLabel: string;
|
||||
contributionType: Record<ContributionType, string>;
|
||||
loadingTitle: string;
|
||||
loadingTitleFailed: string;
|
||||
loadingInfo: string;
|
||||
chartZoomHint: string;
|
||||
};
|
||||
repoLink: string;
|
||||
repoDefaultBranchName: string;
|
||||
}>();
|
||||
|
||||
const isLoading = shallowRef(false);
|
||||
const errorText = shallowRef('');
|
||||
const totalStats = shallowRef<Record<string, any>>({});
|
||||
const sortedContributors = shallowRef<Array<Record<string, any>>>([]);
|
||||
const type = shallowRef<ContributionType>('commits');
|
||||
let contributorsStats: Record<string, any> = {}; // these three are not read during render
|
||||
let xAxisStart: number | null = null;
|
||||
let xAxisEnd: number | null = null;
|
||||
const xAxisMin = shallowRef<number | null>(null);
|
||||
const xAxisMax = shallowRef<number | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
fetchGraphData();
|
||||
|
||||
fomanticQuery('#repo-contributors').dropdown({
|
||||
onChange: (val: ContributionType) => {
|
||||
xAxisMin.value = xAxisStart;
|
||||
xAxisMax.value = xAxisEnd;
|
||||
type.value = val;
|
||||
sortContributors();
|
||||
},
|
||||
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 = errorMessage(err);
|
||||
} 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): LineOptions {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'],
|
||||
plugins: {
|
||||
title: {
|
||||
display: type === 'main',
|
||||
text: this.locale.chartZoomHint,
|
||||
position: 'top',
|
||||
align: 'center',
|
||||
},
|
||||
customEventListener: {
|
||||
chartType: type,
|
||||
instance: this,
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
enabled: true,
|
||||
modifierKey: 'shift',
|
||||
mode: 'x',
|
||||
threshold: 20,
|
||||
onPanComplete: this.updateOtherCharts,
|
||||
},
|
||||
limits: {
|
||||
x: {
|
||||
// Check https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/options.html#scale-limits
|
||||
// to know what each option means
|
||||
min: 'original',
|
||||
max: 'original',
|
||||
|
||||
// number of milliseconds in 2 weeks. Minimum x range will be 2 weeks when you zoom on the graph
|
||||
minRange: 2 * 7 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
zoom: {
|
||||
drag: {
|
||||
enabled: type === 'main',
|
||||
},
|
||||
pinch: {
|
||||
enabled: type === 'main',
|
||||
},
|
||||
mode: 'x',
|
||||
onZoomComplete: this.updateOtherCharts,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
min: this.xAxisMin ?? undefined,
|
||||
max: this.xAxisMax ?? undefined,
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function sortContributors() {
|
||||
const criteria = `total_${type.value}`;
|
||||
sortedContributors.value = filterContributorWeeksByDateRange()
|
||||
.filter((contributor) => contributor[criteria] !== 0)
|
||||
.sort((a, b) => b[criteria] - a[criteria])
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
const searchBase = computed(() => {
|
||||
const min = dayjs(xAxisMin.value).format('YYYY-MM-DD');
|
||||
const max = dayjs(xAxisMax.value).format('YYYY-MM-DD');
|
||||
return {prefix: `after:${min}, before:${max}, author:`, branch: pathEscapeSegments(props.repoDefaultBranchName)};
|
||||
});
|
||||
|
||||
function getContributorSearchQuery(contributorEmail: string) {
|
||||
const params = new URLSearchParams({'q': `${searchBase.value.prefix}${contributorEmail}`});
|
||||
return `${props.repoLink}/commits/branch/${searchBase.value.branch}/search?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function fetchGraphData() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
let response: Response;
|
||||
do {
|
||||
response = await GET(`${props.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);
|
||||
xAxisStart = weekValues[0].week;
|
||||
xAxisEnd = firstStartDateAfterDate(new Date());
|
||||
const startDays = startDaysBetween(xAxisStart, xAxisEnd);
|
||||
total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks);
|
||||
xAxisMin.value = xAxisStart;
|
||||
xAxisMax.value = xAxisEnd;
|
||||
contributorsStats = Object.fromEntries(Object.entries(other).map(([email, user]) => {
|
||||
return [email, {...user, weeks: fillEmptyStartDaysWithZeroes(startDays, user.weeks)}];
|
||||
}));
|
||||
sortContributors();
|
||||
totalStats.value = total;
|
||||
errorText.value = '';
|
||||
} else {
|
||||
errorText.value = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
errorText.value = errorMessage(err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function filterContributorWeeksByDateRange() {
|
||||
const filteredData: Array<Record<string, any>> = [];
|
||||
const minTime = xAxisMin.value! - oneWeek;
|
||||
const maxTime = xAxisMax.value! + oneWeek;
|
||||
const contributionType = type.value;
|
||||
for (const [key, user] of Object.entries(contributorsStats)) {
|
||||
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>) => {
|
||||
if (week.week >= minTime && week.week <= maxTime) {
|
||||
user.total_commits += week.commits;
|
||||
user.total_additions += week.additions;
|
||||
user.total_deletions += week.deletions;
|
||||
if (week[contributionType] > user.max_contribution_type) {
|
||||
user.max_contribution_type = week[contributionType];
|
||||
}
|
||||
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.push({...user, weeks: filteredWeeks, email: key});
|
||||
}
|
||||
|
||||
return filteredData;
|
||||
}
|
||||
|
||||
const maxMainGraph = computed(() => {
|
||||
return roundUpMax(Math.max(...totalStats.value.weeks.map((o: Record<string, any>) => o[type.value])));
|
||||
});
|
||||
|
||||
// one shared maximum, otherwise the contributor graphs cannot be compared
|
||||
const maxContributorGraph = computed(() => {
|
||||
return roundUpMax(Math.max(...sortedContributors.value.map((c: Record<string, any>) => c.max_contribution_type)));
|
||||
});
|
||||
|
||||
function toGraphData(data: Array<Record<string, any>>): ChartData<'line'> {
|
||||
const contributionType = type.value;
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
data: data.map((i) => ({x: i.week, y: i[contributionType]})),
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 0,
|
||||
fill: 'start',
|
||||
backgroundColor: chartJsColors[type.value],
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function 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) {
|
||||
xAxisMin.value = xAxisStart;
|
||||
xAxisMax.value = xAxisEnd;
|
||||
sortContributors();
|
||||
} else if (minVal) {
|
||||
xAxisMin.value = minVal;
|
||||
xAxisMax.value = maxVal;
|
||||
sortContributors();
|
||||
}
|
||||
}
|
||||
|
||||
function getOptions(chartType: ChartType): LineOptions {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'],
|
||||
plugins: {
|
||||
title: {
|
||||
display: chartType === 'main',
|
||||
text: props.locale.chartZoomHint,
|
||||
position: 'top',
|
||||
align: 'center',
|
||||
},
|
||||
customEventListener: {
|
||||
chartType,
|
||||
onDoubleClick: updateOtherCharts,
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
enabled: true,
|
||||
modifierKey: 'shift',
|
||||
mode: 'x',
|
||||
threshold: 20,
|
||||
onPanComplete: 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',
|
||||
|
||||
minRange: 2 * oneWeek, // do not zoom in tighter than two weeks
|
||||
|
||||
},
|
||||
},
|
||||
zoom: {
|
||||
drag: {
|
||||
enabled: chartType === 'main',
|
||||
},
|
||||
pinch: {
|
||||
enabled: chartType === 'main',
|
||||
},
|
||||
mode: 'x',
|
||||
onZoomComplete: updateOtherCharts,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
min: xAxisMin.value ?? undefined,
|
||||
max: xAxisMax.value ?? undefined,
|
||||
type: 'time',
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
time: {
|
||||
minUnit: 'month',
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: chartType === 'main' ? 12 : 6,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
min: 0,
|
||||
max: chartType === 'main' ? maxMainGraph.value : maxContributorGraph.value,
|
||||
ticks: {
|
||||
maxTicksLimit: chartType === 'main' ? 6 : 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
|
||||
@@ -8,4 +8,4 @@ https://developer.mozilla.org/en-US/docs/Web/Web_Components
|
||||
|
||||
* These components are loaded in `<head>` (before DOM body) in a separate entry point, they need to be lightweight to not affect the page loading time too much.
|
||||
* Do not import `svg.js` into a web component because that file is currently not tree-shakeable, import svg files individually insteat.
|
||||
* All our components must be added to `vite.config.ts` so they work correctly in Vue.
|
||||
* Any custom element used inside a `.vue` file must be added to `webComponents` in `vite.config.ts` so Vue does not try to resolve it as a component. That list also covers custom elements from dependencies.
|
||||
|
||||
Reference in New Issue
Block a user