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:
silverwind
2026-08-06 07:45:31 +02:00
committed by GitHub
parent d8c3a1afda
commit c6836d0abb
10 changed files with 1012 additions and 1049 deletions
-1
View File
@@ -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
View File
@@ -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"}},
+32
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
});
+326 -348
View File
@@ -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"/>
+194 -196
View File
@@ -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>
+194 -200
View File
@@ -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">
+259 -271
View File
@@ -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>
+1 -1
View File
@@ -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.