mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-10 05:24:18 +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}}"
|
<div class="{{if .ContainerClasses}}{{.ContainerClasses}}{{end}}"
|
||||||
data-global-init="initRepoBranchTagSelector"
|
data-global-init="initRepoBranchTagSelector"
|
||||||
data-text-release-compare="{{ctx.Locale.Tr "repo.release.compare"}}"
|
|
||||||
data-text-branches="{{ctx.Locale.Tr "repo.branches"}}"
|
data-text-branches="{{ctx.Locale.Tr "repo.branches"}}"
|
||||||
data-text-tags="{{ctx.Locale.Tr "repo.tags"}}"
|
data-text-tags="{{ctx.Locale.Tr "repo.tags"}}"
|
||||||
data-text-filter-branch="{{ctx.Locale.Tr "repo.pulls.filter_branch"}}"
|
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
|
...window.config.pageData.dashboardRepoList, // it only contains searchLimit and uid
|
||||||
|
|
||||||
isMirrorsEnabled: {{.MirrorsEnabled}},
|
isMirrorsEnabled: {{.MirrorsEnabled}},
|
||||||
isStarsEnabled: {{not .IsDisableStars}},
|
|
||||||
|
|
||||||
canCreateMigrations: {{not .DisableMigrations}},
|
|
||||||
|
|
||||||
textNoOrg: {{ctx.Locale.Tr "home.empty_org"}},
|
textNoOrg: {{ctx.Locale.Tr "home.empty_org"}},
|
||||||
textNoRepo: {{ctx.Locale.Tr "home.empty_repo"}},
|
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 {build, defineConfig} from 'vite';
|
||||||
import vuePlugin from '@vitejs/plugin-vue';
|
|
||||||
import {stringPlugin} from 'vite-string-plugin';
|
import {stringPlugin} from 'vite-string-plugin';
|
||||||
|
import {sharedPlugins, vueDefines} from './tools/shared.ts';
|
||||||
import {licensePlugin, wrap} from 'rolldown-license-plugin';
|
import {licensePlugin, wrap} from 'rolldown-license-plugin';
|
||||||
import {readFileSync, writeFileSync, mkdirSync, unlinkSync, globSync} from 'node:fs';
|
import {readFileSync, writeFileSync, mkdirSync, unlinkSync, globSync} from 'node:fs';
|
||||||
import path, {basename, join, parse} from 'node:path';
|
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);
|
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 {
|
function failOnWarningsPlugin(): Rolldown.Plugin {
|
||||||
let warningCount = 0;
|
let warningCount = 0;
|
||||||
return {
|
return {
|
||||||
@@ -305,25 +296,14 @@ export default defineConfig(commonViteOpts({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
define: {
|
define: vueDefines,
|
||||||
__VUE_OPTIONS_API__: true,
|
|
||||||
__VUE_PROD_DEVTOOLS__: false,
|
|
||||||
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
|
|
||||||
},
|
|
||||||
plugins: [
|
plugins: [
|
||||||
iifePlugin('iife.ts'),
|
iifePlugin('iife.ts'),
|
||||||
iifePlugin('external-render-helper.ts'),
|
iifePlugin('external-render-helper.ts'),
|
||||||
viteDevServerPortPlugin(),
|
viteDevServerPortPlugin(),
|
||||||
reducedSourcemapPlugin(),
|
reducedSourcemapPlugin(),
|
||||||
filterCssUrlPlugin(),
|
filterCssUrlPlugin(),
|
||||||
stringPlugin(),
|
...sharedPlugins(),
|
||||||
vuePlugin({
|
|
||||||
template: {
|
|
||||||
compilerOptions: {
|
|
||||||
isCustomElement: (tag) => webComponents.has(tag),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
isProduction ? licensePlugin({
|
isProduction ? licensePlugin({
|
||||||
done(deps, context) {
|
done(deps, context) {
|
||||||
const line = '-'.repeat(80);
|
const line = '-'.repeat(80);
|
||||||
|
|||||||
+3
-6
@@ -1,6 +1,5 @@
|
|||||||
import {defineConfig} from 'vitest/config';
|
import {defineConfig} from 'vitest/config';
|
||||||
import vuePlugin from '@vitejs/plugin-vue';
|
import {sharedPlugins, vueDefines} from './tools/shared.ts';
|
||||||
import {stringPlugin} from 'vite-string-plugin';
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
@@ -21,8 +20,6 @@ export default defineConfig({
|
|||||||
concurrent: true,
|
concurrent: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
define: vueDefines,
|
||||||
stringPlugin(),
|
plugins: sharedPlugins(),
|
||||||
vuePlugin(),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import {nextTick, defineComponent} from 'vue';
|
import {computed, nextTick, onMounted, shallowRef, useTemplateRef, type ShallowRef} from 'vue';
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import {GET} from '../modules/fetch.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';
|
import type {SvgName} from '../svg.ts';
|
||||||
|
|
||||||
const {appSubUrl, assetUrlPrefix, pageData} = window.config;
|
const {appSubUrl, pageData} = window.config;
|
||||||
|
|
||||||
type DashboardRepo = {
|
type DashboardRepo = {
|
||||||
id: number,
|
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
|
// make sure this matches templates/repo/commit_status.tmpl
|
||||||
const commitStatus: CommitStatusMap = {
|
const commitStatus: CommitStatusMap = {
|
||||||
pending: {name: 'octicon-dot-fill', color: 'tw-text-yellow'},
|
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'},
|
skipped: {name: 'octicon-skip', color: 'tw-text-text-light'},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default defineComponent({
|
const searchModes = new Map<RepoFilter, string>([
|
||||||
components: {SvgIcon},
|
['all', ''],
|
||||||
data() {
|
['forks', 'fork'],
|
||||||
const params = new URLSearchParams(window.location.search);
|
['mirrors', 'mirror'],
|
||||||
const tab = params.get('repo-search-tab') || 'repos';
|
['sources', 'source'],
|
||||||
const reposFilter = params.get('repo-search-filter') || 'all';
|
['collaborative', 'collaborative'],
|
||||||
const privateFilter = params.get('repo-search-private') || 'both';
|
]);
|
||||||
const archivedFilter = params.get('repo-search-archived') || 'unarchived';
|
|
||||||
const searchQuery = params.get('repo-search-query') || '';
|
|
||||||
const page = Number(params.get('repo-search-page')) || 1;
|
|
||||||
|
|
||||||
return {
|
const pageDataDefaults = {
|
||||||
tab,
|
subUrl: appSubUrl,
|
||||||
repos: [] as DashboardRepo[],
|
organizations: [] as Array<{name: string, full_name: string, num_repos: number, org_visibility: string}>,
|
||||||
reposTotalCount: null as number | null,
|
isOrganization: true,
|
||||||
reposFilter,
|
canCreateOrganization: false,
|
||||||
archivedFilter,
|
organizationsTotalCount: 0,
|
||||||
privateFilter,
|
organizationId: 0,
|
||||||
page,
|
searchLimit: 0,
|
||||||
finalPage: 1,
|
uid: 0,
|
||||||
searchQuery,
|
teamId: 0,
|
||||||
isLoading: false,
|
isMirrorsEnabled: false,
|
||||||
initialSearchDone: false,
|
textNoOrg: '',
|
||||||
staticPrefix: assetUrlPrefix,
|
textNoRepo: '',
|
||||||
counts: {} as Record<string, number>,
|
textRepository: '',
|
||||||
repoTypes: {
|
textOrganization: '',
|
||||||
all: {
|
textMyRepos: '',
|
||||||
searchMode: '',
|
textNewRepo: '',
|
||||||
},
|
textSearchRepos: '',
|
||||||
forks: {
|
textFilter: '',
|
||||||
searchMode: 'fork',
|
textShowArchived: '',
|
||||||
},
|
textShowPrivate: '',
|
||||||
mirrors: {
|
textShowBothArchivedUnarchived: '',
|
||||||
searchMode: 'mirror',
|
textShowOnlyUnarchived: '',
|
||||||
},
|
textShowOnlyArchived: '',
|
||||||
sources: {
|
textShowBothPrivatePublic: '',
|
||||||
searchMode: 'source',
|
textShowOnlyPublic: '',
|
||||||
},
|
textShowOnlyPrivate: '',
|
||||||
collaborative: {
|
textAll: '',
|
||||||
searchMode: 'collaborative',
|
textSources: '',
|
||||||
},
|
textForks: '',
|
||||||
} as Record<string, {searchMode: string}>,
|
textMirrors: '',
|
||||||
textArchivedFilterTitles: {} as Record<string, string>,
|
textCollaborative: '',
|
||||||
textPrivateFilterTitles: {} as Record<string, string>,
|
textFirstPage: '',
|
||||||
organizations: [] as Array<{name: string, full_name: string, num_repos: number, org_visibility: string}>,
|
textPreviousPage: '',
|
||||||
isOrganization: true,
|
textNextPage: '',
|
||||||
canCreateOrganization: false,
|
textLastPage: '',
|
||||||
organizationsTotalCount: 0,
|
textMyOrgs: '',
|
||||||
organizationId: 0,
|
textNewOrg: '',
|
||||||
searchLimit: 0,
|
textOrgVisibilityLimited: '',
|
||||||
uid: 0,
|
textOrgVisibilityPrivate: '',
|
||||||
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
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
const {
|
||||||
showMoreReposLink() {
|
subUrl, organizations, isOrganization, canCreateOrganization, organizationsTotalCount, organizationId,
|
||||||
return this.repos.length > 0 && this.repos.length < this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
|
searchLimit, uid, teamId, isMirrorsEnabled,
|
||||||
},
|
textNoOrg, textNoRepo, textRepository, textOrganization, textMyRepos, textNewRepo, textSearchRepos,
|
||||||
searchURL() {
|
textFilter, textShowArchived, textShowPrivate,
|
||||||
return `${this.subUrl}/repo/search?sort=updated&order=desc&uid=${this.uid}&team_id=${this.teamId}&q=${this.searchQuery
|
textShowBothArchivedUnarchived, textShowOnlyUnarchived, textShowOnlyArchived,
|
||||||
}&page=${this.page}&limit=${this.searchLimit}&mode=${this.repoTypes[this.reposFilter].searchMode
|
textShowBothPrivatePublic, textShowOnlyPublic, textShowOnlyPrivate,
|
||||||
}${this.archivedFilter === 'archived' ? '&archived=true' : ''}${this.archivedFilter === 'unarchived' ? '&archived=false' : ''
|
textAll, textSources, textForks, textMirrors, textCollaborative,
|
||||||
}${this.privateFilter === 'private' ? '&is_private=true' : ''}${this.privateFilter === 'public' ? '&is_private=false' : ''
|
textFirstPage, textPreviousPage, textNextPage, textLastPage,
|
||||||
}`;
|
textMyOrgs, textNewOrg, textOrgVisibilityLimited, textOrgVisibilityPrivate,
|
||||||
},
|
}: typeof pageDataDefaults = {...pageDataDefaults, ...pageData.dashboardRepoList};
|
||||||
repoTypeCount() {
|
|
||||||
return this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`];
|
|
||||||
},
|
|
||||||
checkboxArchivedFilterTitle() {
|
|
||||||
return this.textArchivedFilterTitles[this.archivedFilter];
|
|
||||||
},
|
|
||||||
checkboxArchivedFilterProps() {
|
|
||||||
return {checked: this.archivedFilter === 'archived', indeterminate: this.archivedFilter === 'both'};
|
|
||||||
},
|
|
||||||
checkboxPrivateFilterTitle() {
|
|
||||||
return this.textPrivateFilterTitles[this.privateFilter];
|
|
||||||
},
|
|
||||||
checkboxPrivateFilterProps() {
|
|
||||||
return {checked: this.privateFilter === 'private', indeterminate: this.privateFilter === 'both'};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
const textArchivedFilterTitles = new Map<ArchivedFilter, string>([
|
||||||
const el = document.querySelector('#dashboard-repo-list')!;
|
['archived', textShowOnlyArchived],
|
||||||
this.changeReposFilter(this.reposFilter);
|
['unarchived', textShowOnlyUnarchived],
|
||||||
fomanticQuery(el.querySelector('.ui.dropdown')!).dropdown();
|
['both', textShowBothArchivedUnarchived],
|
||||||
|
]);
|
||||||
|
|
||||||
this.textArchivedFilterTitles = {
|
const textPrivateFilterTitles = new Map<PrivateFilter, string>([
|
||||||
'archived': this.textShowOnlyArchived,
|
['private', textShowOnlyPrivate],
|
||||||
'unarchived': this.textShowOnlyUnarchived,
|
['public', textShowOnlyPublic],
|
||||||
'both': this.textShowBothArchivedUnarchived,
|
['both', textShowBothPrivatePublic],
|
||||||
};
|
]);
|
||||||
|
|
||||||
this.textPrivateFilterTitles = {
|
const initialParams = new URLSearchParams(window.location.search);
|
||||||
'private': this.textShowOnlyPrivate,
|
const tab = shallowRef((initialParams.get('repo-search-tab') || 'repos') as Tab);
|
||||||
'public': this.textShowOnlyPublic,
|
const reposFilter = shallowRef((initialParams.get('repo-search-filter') || 'all') as RepoFilter);
|
||||||
'both': this.textShowBothPrivatePublic,
|
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: {
|
const repos = shallowRef<DashboardRepo[]>([]);
|
||||||
changeTab(tab: string) {
|
const reposTotalCount = shallowRef<number | null>(null);
|
||||||
this.tab = tab;
|
const finalPage = shallowRef(1);
|
||||||
this.updateHistory();
|
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) {
|
const elSearch = useTemplateRef('elSearch') as Readonly<ShallowRef<HTMLInputElement>>;
|
||||||
this.reposFilter = filter;
|
|
||||||
this.repos = [];
|
|
||||||
this.page = 1;
|
|
||||||
this.searchRepos();
|
|
||||||
},
|
|
||||||
|
|
||||||
updateHistory() {
|
const countsKey = computed(() => `${reposFilter.value}:${archivedFilter.value}:${privateFilter.value}`);
|
||||||
const params = new URLSearchParams(window.location.search);
|
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') {
|
// unknown query string values fall back to no mode
|
||||||
params.delete('repo-search-tab');
|
const searchMode = computed(() => searchModes.get(reposFilter.value) ?? '');
|
||||||
} else {
|
|
||||||
params.set('repo-search-tab', this.tab);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.reposFilter === 'all') {
|
const searchURL = computed(() => {
|
||||||
params.delete('repo-search-filter');
|
// unknown query string values send no filter
|
||||||
} else {
|
const archived = archivedFilter.value === 'archived' ? '&archived=true' : archivedFilter.value === 'unarchived' ? '&archived=false' : '';
|
||||||
params.set('repo-search-filter', this.reposFilter);
|
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}`;
|
||||||
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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
@@ -420,13 +398,13 @@ export default defineComponent({
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="ui attached segment repos-search">
|
<div v-else class="ui attached segment repos-search">
|
||||||
<div class="ui small fluid action left icon input">
|
<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>
|
<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">
|
<div class="ui dropdown icon button" :title="textFilter">
|
||||||
<svg-icon name="octicon-filter" :size="16"/>
|
<svg-icon name="octicon-filter" :size="16"/>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
<a class="item" @click="toggleArchivedFilter()">
|
<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,
|
<!--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-->
|
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">
|
<input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxArchivedFilterProps">
|
||||||
@@ -437,7 +415,7 @@ export default defineComponent({
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a class="item" @click="togglePrivateFilter()">
|
<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">
|
<input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxPrivateFilterProps">
|
||||||
<label>
|
<label>
|
||||||
<svg-icon name="octicon-lock" :size="16" class="tw-mr-1"/>
|
<svg-icon name="octicon-lock" :size="16" class="tw-mr-1"/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import {defineComponent} from 'vue';
|
import {computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useTemplateRef, type ShallowRef} from 'vue';
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import {GET} from '../modules/fetch.ts';
|
import {GET} from '../modules/fetch.ts';
|
||||||
import {generateElemId} from '../utils/dom.ts';
|
import {generateElemId} from '../utils/dom.ts';
|
||||||
@@ -20,205 +20,203 @@ type CommitListResult = {
|
|||||||
locale: Record<string, string>,
|
locale: Record<string, string>,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
const elRoot = useTemplateRef('elRoot') as Readonly<ShallowRef<HTMLDivElement>>;
|
||||||
components: {SvgIcon},
|
const elExpandBtn = useTemplateRef('elExpandBtn') as Readonly<ShallowRef<HTMLButtonElement>>;
|
||||||
data: () => {
|
const elShowAllChanges = useTemplateRef('elShowAllChanges') as Readonly<ShallowRef<HTMLDivElement>>;
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Load the commits to show in this dropdown */
|
const elMount = document.querySelector('#diff-commit-select')!;
|
||||||
async fetchCommits() {
|
const queryParams = elMount.getAttribute('data-queryparams');
|
||||||
const resp = await GET(`${this.issueLink}/commits/list`);
|
const issueLink = elMount.getAttribute('data-issuelink');
|
||||||
const results = await resp.json() as CommitListResult;
|
const mergeBase = elMount.getAttribute('data-merge-base');
|
||||||
this.commits.push(...results.commits.map((x) => {
|
const uniqueIdMenu = generateElemId('diff-commit-selector-menu-');
|
||||||
x.hovered = false;
|
const uniqueIdShowAll = generateElemId('diff-commit-selector-show-all-');
|
||||||
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;
|
|
||||||
|
|
||||||
if (firstSelected === lastSelected) {
|
const menuVisible = shallowRef(false);
|
||||||
// if the start and end are the same, we show this single commit
|
const isLoading = shallowRef(false);
|
||||||
window.location.assign(`${this.issueLink}/commits/${afterCommitID}${this.queryParams}`);
|
const locale = shallowRef<Record<string, string>>({filter_changes_by_commit: elMount.getAttribute('data-filter_changes_by_commit')!});
|
||||||
} else if (beforeCommitID === this.mergeBase && afterCommitID === this.commits.at(-1)!.id) {
|
const commits = ref<Array<Commit>>([]); // deep, the commit objects are mutated in place
|
||||||
// if the first commit is selected and the last commit is selected, we show all commits
|
const hoverActivated = shallowRef(false);
|
||||||
window.location.assign(`${this.issueLink}/files${this.queryParams}`);
|
const lastReviewCommitSha = shallowRef<string | null>(null);
|
||||||
} else {
|
|
||||||
window.location.assign(`${this.issueLink}/files/${beforeCommitID}..${afterCommitID}${this.queryParams}`);
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="ui scrolling dropdown custom diff-commit-selector">
|
<div class="ui scrolling dropdown custom diff-commit-selector" ref="elRoot">
|
||||||
<button
|
<button
|
||||||
ref="expandBtn"
|
ref="elExpandBtn"
|
||||||
class="ui tiny basic button"
|
class="ui tiny basic button"
|
||||||
@click.stop="toggleMenu()"
|
@click.stop="toggleMenu()"
|
||||||
:data-tooltip-content="locale.filter_changes_by_commit"
|
: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 -->
|
<!-- 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="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 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">
|
<div class="gt-ellipsis">
|
||||||
{{ locale.show_all_commits }}
|
{{ locale.show_all_commits }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import {defineComponent, nextTick} from 'vue';
|
import {computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch, type ShallowRef} from 'vue';
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import {showErrorToast} from '../modules/toast.ts';
|
import {showErrorToast} from '../modules/toast.ts';
|
||||||
import {GET} from '../modules/fetch.ts';
|
import {GET} from '../modules/fetch.ts';
|
||||||
import {pathEscapeSegments} from '../utils/url.ts';
|
import {pathEscapeSegments} from '../utils/url.ts';
|
||||||
|
import {queryElemChildren} from '../utils/dom.ts';
|
||||||
import type {GitRefType} from '../types.ts';
|
import type {GitRefType} from '../types.ts';
|
||||||
import {trString} from '../modules/i18n.ts';
|
import {trString} from '../modules/i18n.ts';
|
||||||
|
|
||||||
@@ -18,209 +19,202 @@ type SelectedTab = 'branches' | 'tags';
|
|||||||
|
|
||||||
type TabLoadingStates = Record<SelectedTab, '' | 'loading' | 'done'>
|
type TabLoadingStates = Record<SelectedTab, '' | 'loading' | 'done'>
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
components: {SvgIcon},
|
elRoot: HTMLElement;
|
||||||
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,
|
|
||||||
|
|
||||||
textReleaseCompare: this.elRoot.getAttribute('data-text-release-compare')!,
|
const elDropdown = useTemplateRef('elDropdown') as Readonly<ShallowRef<HTMLDivElement>>;
|
||||||
textBranches: this.elRoot.getAttribute('data-text-branches')!,
|
const elCreateNewRefForm = useTemplateRef('elCreateNewRefForm') as Readonly<ShallowRef<HTMLFormElement>>;
|
||||||
textTags: this.elRoot.getAttribute('data-text-tags')!,
|
const elScrollContainer = useTemplateRef('elScrollContainer') as Readonly<ShallowRef<HTMLDivElement>>;
|
||||||
textFilterBranch: this.elRoot.getAttribute('data-text-filter-branch')!,
|
const elSearchField = useTemplateRef('elSearchField') as Readonly<ShallowRef<HTMLInputElement>>;
|
||||||
textFilterTag: this.elRoot.getAttribute('data-text-filter-tag')!,
|
|
||||||
textDefaultBranchLabel: this.elRoot.getAttribute('data-text-default-branch-label')!,
|
|
||||||
textCreateTag: this.elRoot.getAttribute('data-text-create-tag')!,
|
|
||||||
textCreateBranch: this.elRoot.getAttribute('data-text-create-branch')!,
|
|
||||||
textCreateRefFrom: this.elRoot.getAttribute('data-text-create-ref-from')!,
|
|
||||||
textNoResults: this.elRoot.getAttribute('data-text-no-results')!,
|
|
||||||
textViewAllBranches: this.elRoot.getAttribute('data-text-view-all-branches')!,
|
|
||||||
textViewAllTags: this.elRoot.getAttribute('data-text-view-all-tags')!,
|
|
||||||
|
|
||||||
currentRepoDefaultBranch: this.elRoot.getAttribute('data-current-repo-default-branch')!,
|
const showTabBranches = props.elRoot.getAttribute('data-show-tab-branches') === 'true';
|
||||||
currentRepoLink: this.elRoot.getAttribute('data-current-repo-link')!,
|
|
||||||
currentTreePath: this.elRoot.getAttribute('data-current-tree-path')!,
|
|
||||||
currentRefType: this.elRoot.getAttribute('data-current-ref-type') as GitRefType,
|
|
||||||
currentRefShortName: this.elRoot.getAttribute('data-current-ref-short-name')!,
|
|
||||||
|
|
||||||
refLinkTemplate: this.elRoot.getAttribute('data-ref-link-template')!,
|
const allItems = shallowRef<ListItem[]>([]);
|
||||||
refFormActionTemplate: this.elRoot.getAttribute('data-ref-form-action-template')!,
|
const selectedTab = shallowRef<SelectedTab>(showTabBranches ? 'branches' : 'tags');
|
||||||
dropdownFixedText: this.elRoot.getAttribute('data-dropdown-fixed-text')!,
|
const searchTerm = shallowRef('');
|
||||||
showTabBranches: shouldShowTabBranches,
|
const menuVisible = shallowRef(false);
|
||||||
showTabTags: this.elRoot.getAttribute('data-show-tab-tags') === 'true',
|
const activeItemIndex = shallowRef(0);
|
||||||
allowCreateNewRef: this.elRoot.getAttribute('data-allow-create-new-ref') === 'true',
|
const tabLoadingStates = shallowRef<TabLoadingStates>({branches: '', tags: ''});
|
||||||
showViewAllRefsEntry: this.elRoot.getAttribute('data-show-view-all-refs-entry') === 'true',
|
|
||||||
enableFeed: this.elRoot.getAttribute('data-enable-feed') === 'true',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
searchFieldPlaceholder() {
|
|
||||||
return this.selectedTab === 'branches' ? this.textFilterBranch : this.textFilterTag;
|
|
||||||
},
|
|
||||||
filteredItems(): ListItem[] {
|
|
||||||
const searchTermLower = this.searchTerm.toLowerCase();
|
|
||||||
const items = this.allItems.filter((item: ListItem) => {
|
|
||||||
const typeMatched = (this.selectedTab === 'branches' && item.refType === 'branch') || (this.selectedTab === 'tags' && item.refType === 'tag');
|
|
||||||
if (!typeMatched) return false;
|
|
||||||
if (!this.searchTerm) return true; // match all
|
|
||||||
return item.refShortName.toLowerCase().includes(searchTermLower);
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: fix this anti-pattern: side-effects-in-computed-properties
|
const textBranches = props.elRoot.getAttribute('data-text-branches')!;
|
||||||
this.activeItemIndex = !items.length && this.showCreateNewRef ? 0 : -1; // eslint-disable-line vue/no-side-effects-in-computed-properties
|
const textTags = props.elRoot.getAttribute('data-text-tags')!;
|
||||||
return items;
|
const textFilterBranch = props.elRoot.getAttribute('data-text-filter-branch')!;
|
||||||
},
|
const textFilterTag = props.elRoot.getAttribute('data-text-filter-tag')!;
|
||||||
showNoResults() {
|
const textDefaultBranchLabel = props.elRoot.getAttribute('data-text-default-branch-label')!;
|
||||||
if (this.tabLoadingStates[this.selectedTab] !== 'done') return false;
|
const textCreateTag = props.elRoot.getAttribute('data-text-create-tag')!;
|
||||||
return !this.filteredItems.length && !this.showCreateNewRef;
|
const textCreateBranch = props.elRoot.getAttribute('data-text-create-branch')!;
|
||||||
},
|
const textCreateRefFrom = props.elRoot.getAttribute('data-text-create-ref-from')!;
|
||||||
showCreateNewRef() {
|
const textNoResults = props.elRoot.getAttribute('data-text-no-results')!;
|
||||||
if (!this.allowCreateNewRef || !this.searchTerm) {
|
const textViewAllBranches = props.elRoot.getAttribute('data-text-view-all-branches')!;
|
||||||
return false;
|
const textViewAllTags = props.elRoot.getAttribute('data-text-view-all-tags')!;
|
||||||
}
|
|
||||||
return !this.allItems.filter((item: ListItem) => {
|
|
||||||
return item.refShortName === this.searchTerm; // FIXME: not quite right here, it mixes "branch" and "tag" names
|
|
||||||
}).length;
|
|
||||||
},
|
|
||||||
createNewRefFormActionUrl() {
|
|
||||||
return `${this.currentRepoLink}/branches/_new/${this.currentRefType}/${pathEscapeSegments(this.currentRefShortName!)}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
menuVisible(visible: boolean) {
|
|
||||||
if (!visible) return;
|
|
||||||
this.focusSearchField();
|
|
||||||
this.loadTabItems();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
beforeMount() {
|
|
||||||
document.body.addEventListener('click', (e) => {
|
|
||||||
if (this.$el.contains(e.target)) return;
|
|
||||||
if (this.menuVisible) this.menuVisible = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
const currentRepoDefaultBranch = props.elRoot.getAttribute('data-current-repo-default-branch')!;
|
||||||
if (this.refFormActionTemplate) {
|
const currentRepoLink = props.elRoot.getAttribute('data-current-repo-link')!;
|
||||||
// if the selector is used in a form and needs to change the form action,
|
const currentTreePath = props.elRoot.getAttribute('data-current-tree-path')!;
|
||||||
// make a mock item and select it to update the form action
|
const currentRefType = shallowRef(props.elRoot.getAttribute('data-current-ref-type') as GitRefType);
|
||||||
const item: ListItem = {selected: true, refType: this.currentRefType, refShortName: this.currentRefShortName, rssFeedLink: ''};
|
const currentRefShortName = shallowRef(props.elRoot.getAttribute('data-current-ref-short-name')!);
|
||||||
this.selectItem(item);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
const refLinkTemplate = props.elRoot.getAttribute('data-ref-link-template')!;
|
||||||
trString,
|
const refFormActionTemplate = props.elRoot.getAttribute('data-ref-form-action-template')!;
|
||||||
selectItem(item: ListItem) {
|
const dropdownFixedText = props.elRoot.getAttribute('data-dropdown-fixed-text')!;
|
||||||
this.menuVisible = false;
|
const showTabTags = props.elRoot.getAttribute('data-show-tab-tags') === 'true';
|
||||||
if (this.refFormActionTemplate) {
|
const allowCreateNewRef = props.elRoot.getAttribute('data-allow-create-new-ref') === 'true';
|
||||||
this.currentRefType = item.refType;
|
const showViewAllRefsEntry = props.elRoot.getAttribute('data-show-view-all-refs-entry') === 'true';
|
||||||
this.currentRefShortName = item.refShortName;
|
const enableFeed = props.elRoot.getAttribute('data-enable-feed') === 'true';
|
||||||
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();
|
|
||||||
|
|
||||||
if (this.activeItemIndex === -1) {
|
const searchFieldPlaceholder = computed(() => selectedTab.value === 'branches' ? textFilterBranch : textFilterTag);
|
||||||
this.activeItemIndex = this.getSelectedIndexInFiltered();
|
|
||||||
}
|
|
||||||
const nextIndex = e.key === 'ArrowDown' ? this.activeItemIndex + 1 : this.activeItemIndex - 1;
|
|
||||||
if (nextIndex < 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (nextIndex + (this.showCreateNewRef ? 0 : 1) > this.filteredItems.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.activeItemIndex = nextIndex;
|
|
||||||
this.getActiveItem()!.scrollIntoView({block: 'nearest'});
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.getActiveItem()?.click();
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
e.preventDefault();
|
|
||||||
this.menuVisible = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleTabSwitch(selectedTab: SelectedTab) {
|
|
||||||
this.selectedTab = selectedTab;
|
|
||||||
this.focusSearchField();
|
|
||||||
this.loadTabItems();
|
|
||||||
},
|
|
||||||
async loadTabItems() {
|
|
||||||
const tab = this.selectedTab;
|
|
||||||
if (this.tabLoadingStates[tab] === 'loading' || this.tabLoadingStates[tab] === 'done') return;
|
|
||||||
|
|
||||||
const refType = this.selectedTab === 'branches' ? 'branch' : 'tag';
|
const filteredItems = computed<ListItem[]>(() => {
|
||||||
this.tabLoadingStates[tab] = 'loading';
|
const searchTermLower = searchTerm.value.toLowerCase();
|
||||||
try {
|
const items = allItems.value.filter((item: ListItem) => {
|
||||||
const url = refType === 'branch' ? `${this.currentRepoLink}/branches/list` : `${this.currentRepoLink}/tags/list`;
|
const typeMatched = (selectedTab.value === 'branches' && item.refType === 'branch') || (selectedTab.value === 'tags' && item.refType === 'tag');
|
||||||
const resp = await GET(url);
|
if (!typeMatched) return false;
|
||||||
const {results} = await resp.json();
|
if (!searchTerm.value) return true; // match all
|
||||||
for (const refShortName of results) {
|
return item.refShortName.toLowerCase().includes(searchTermLower);
|
||||||
const item: ListItem = {
|
});
|
||||||
refType,
|
|
||||||
refShortName,
|
// TODO: fix this anti-pattern: side-effects-in-computed-properties
|
||||||
selected: refType === this.currentRefType && refShortName === this.currentRefShortName,
|
activeItemIndex.value = !items.length && showCreateNewRef.value ? 0 : -1; // eslint-disable-line vue/no-side-effects-in-computed-properties
|
||||||
rssFeedLink: `${this.currentRepoLink}/rss/${refType}/${pathEscapeSegments(refShortName)}`,
|
return items;
|
||||||
};
|
|
||||||
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 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>
|
</script>
|
||||||
<template>
|
<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">
|
<div tabindex="0" class="ui compact button branch-dropdown-button" @click="menuVisible = !menuVisible">
|
||||||
<span class="flex-text-block gt-ellipsis">
|
<span class="flex-text-block gt-ellipsis">
|
||||||
<template v-if="dropdownFixedText">{{ dropdownFixedText }}</template>
|
<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-if="currentRefType === 'tag'" name="octicon-tag"/>
|
||||||
<svg-icon v-else-if="currentRefType === 'branch'" name="octicon-git-branch"/>
|
<svg-icon v-else-if="currentRefType === 'branch'" name="octicon-git-branch"/>
|
||||||
<svg-icon v-else name="octicon-git-commit"/>
|
<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>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
<svg-icon name="octicon-triangle-down" :size="14" class="dropdown icon"/>
|
<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="menu transition" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak>
|
||||||
<div class="ui icon search input">
|
<div class="ui icon search input">
|
||||||
<i class="icon"><svg-icon name="octicon-filter" :size="16"/></i>
|
<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>
|
||||||
<div v-if="showTabBranches" class="branch-tag-tab">
|
<div v-if="showTabBranches" class="branch-tag-tab">
|
||||||
<a class="branch-tag-item muted" :class="{active: selectedTab === 'branches'}" href="#" @click="handleTabSwitch('branches')">
|
<a class="branch-tag-item muted" :class="{active: selectedTab === 'branches'}" href="#" @click="handleTabSwitch('branches')">
|
||||||
@@ -247,10 +241,10 @@ export default defineComponent({
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="branch-tag-divider"/>
|
<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"/>
|
<svg-icon name="octicon-rss" symbol-id="svg-symbol-octicon-rss"/>
|
||||||
<div class="loading-indicator is-loading" v-if="tabLoadingStates[selectedTab] === 'loading'"/>
|
<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 }}
|
{{ item.refShortName }}
|
||||||
<div class="ui label" v-if="item.refType === 'branch' && item.refShortName === currentRepoDefaultBranch">
|
<div class="ui label" v-if="item.refType === 'branch' && item.refShortName === currentRepoDefaultBranch">
|
||||||
{{ textDefaultBranchLabel }}
|
{{ textDefaultBranchLabel }}
|
||||||
@@ -260,7 +254,7 @@ export default defineComponent({
|
|||||||
<svg width="14" height="14" class="svg octicon-rss"><use href="#svg-symbol-octicon-rss"/></svg>
|
<svg width="14" height="14" class="svg octicon-rss"><use href="#svg-symbol-octicon-rss"/></svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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'">
|
<div v-if="selectedTab === 'tags'">
|
||||||
<svg-icon name="octicon-tag" class="tw-mr-1"/>
|
<svg-icon name="octicon-tag" class="tw-mr-1"/>
|
||||||
<span v-text="trString(textCreateTag, searchTerm)"/>
|
<span v-text="trString(textCreateTag, searchTerm)"/>
|
||||||
@@ -272,7 +266,7 @@ export default defineComponent({
|
|||||||
<div class="tw-text-xs">
|
<div class="tw-text-xs">
|
||||||
{{ textCreateRefFrom.replace('%s', currentRefShortName) }}
|
{{ textCreateRefFrom.replace('%s', currentRefShortName) }}
|
||||||
</div>
|
</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="new_branch_name" :value="searchTerm">
|
||||||
<input type="hidden" name="create_tag" :value="String(selectedTab === 'tags')">
|
<input type="hidden" name="create_tag" :value="String(selectedTab === 'tags')">
|
||||||
<input type="hidden" name="current_path" :value="currentTreePath">
|
<input type="hidden" name="current_path" :value="currentTreePath">
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts" setup>
|
||||||
import {defineComponent, type PropType} from 'vue';
|
import {computed, onMounted, shallowRef} from 'vue';
|
||||||
import {SvgIcon} from '../svg.ts';
|
import {SvgIcon} from '../svg.ts';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import {GET} from '../modules/fetch.ts';
|
||||||
|
import {Line as ChartLine} from 'vue-chartjs';
|
||||||
import {
|
import {
|
||||||
Chart,
|
Chart,
|
||||||
Title,
|
Title,
|
||||||
@@ -15,21 +17,24 @@ import {
|
|||||||
type ChartData,
|
type ChartData,
|
||||||
type Plugin,
|
type Plugin,
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import {GET} from '../modules/fetch.ts';
|
|
||||||
import zoomPlugin from 'chartjs-plugin-zoom';
|
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 {
|
import {
|
||||||
startDaysBetween,
|
startDaysBetween,
|
||||||
firstStartDateAfterDate,
|
firstStartDateAfterDate,
|
||||||
fillEmptyStartDaysWithZeroes,
|
fillEmptyStartDaysWithZeroes,
|
||||||
} from '../utils/time.ts';
|
} from '../utils/time.ts';
|
||||||
import {chartJsColors} from '../utils/color.ts';
|
|
||||||
import {errorMessage} from '../modules/errors.ts';
|
import {errorMessage} from '../modules/errors.ts';
|
||||||
import {sleep} from '../utils.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 {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||||
import {pathEscapeSegments} from '../utils/url.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 = {
|
const customEventListener: Plugin = {
|
||||||
id: 'customEventListener',
|
id: 'customEventListener',
|
||||||
afterEvent: (chart, args, opts) => {
|
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
|
// so we need to check whether args.replay is true to avoid call loops
|
||||||
if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) {
|
if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) {
|
||||||
chart.resetZoom();
|
chart.resetZoom();
|
||||||
opts.instance.updateOtherCharts(args.event, true);
|
opts.onDoubleClick({chart}, true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -45,8 +50,8 @@ const customEventListener: Plugin = {
|
|||||||
type LineOptions = ChartOptions<'line'> & {
|
type LineOptions = ChartOptions<'line'> & {
|
||||||
plugins?: {
|
plugins?: {
|
||||||
customEventListener?: {
|
customEventListener?: {
|
||||||
chartType: string;
|
chartType: ChartType;
|
||||||
instance: unknown;
|
onDoubleClick: (args: {chart: Chart}, reset: boolean) => void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -66,6 +71,12 @@ Chart.register(
|
|||||||
customEventListener,
|
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 = {
|
type ContributorsData = {
|
||||||
total: {
|
total: {
|
||||||
weeks: Record<string, any>,
|
weeks: Record<string, any>,
|
||||||
@@ -73,270 +84,247 @@ type ContributorsData = {
|
|||||||
[other: string]: Record<string, Record<string, any>>,
|
[other: string]: Record<string, Record<string, any>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineComponent({
|
const props = defineProps<{
|
||||||
components: {ChartLine, SvgIcon},
|
locale: {
|
||||||
props: {
|
filterLabel: string;
|
||||||
locale: {
|
contributionType: Record<ContributionType, string>;
|
||||||
type: Object as PropType<Record<string, any>>,
|
loadingTitle: string;
|
||||||
required: true,
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<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.
|
* 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.
|
* 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