diff options
Diffstat (limited to 'web_src/js/components')
19 files changed, 485 insertions, 458 deletions
diff --git a/web_src/js/components/ActionRunStatus.vue b/web_src/js/components/ActionRunStatus.vue index 96c6c441be..bc3b99ab89 100644 --- a/web_src/js/components/ActionRunStatus.vue +++ b/web_src/js/components/ActionRunStatus.vue @@ -19,12 +19,12 @@ withDefaults(defineProps<{ <template> <span :data-tooltip-content="localeStatus ?? status" v-if="status"> - <SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class-name="className" v-if="status === 'success'"/> - <SvgIcon name="octicon-skip" class="text grey" :size="size" :class-name="className" v-else-if="status === 'skipped'"/> - <SvgIcon name="octicon-stop" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'cancelled'"/> - <SvgIcon name="octicon-clock" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'waiting'"/> - <SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class-name="className" v-else-if="status === 'blocked'"/> - <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class-name="'job-status-rotate ' + className" v-else-if="status === 'running'"/> + <SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class="className" v-if="status === 'success'"/> + <SvgIcon name="octicon-skip" class="text grey" :size="size" :class="className" v-else-if="status === 'skipped'"/> + <SvgIcon name="octicon-stop" class="text yellow" :size="size" :class="className" v-else-if="status === 'cancelled'"/> + <SvgIcon name="octicon-clock" class="text yellow" :size="size" :class="className" v-else-if="status === 'waiting'"/> + <SvgIcon name="octicon-blocked" class="text yellow" :size="size" :class="className" v-else-if="status === 'blocked'"/> + <SvgIcon name="octicon-meter" class="text yellow" :size="size" :class="'circular-spin ' + className" v-else-if="status === 'running'"/> <SvgIcon name="octicon-x-circle-fill" class="text red" :size="size" v-else/><!-- failure, unknown --> </span> </template> diff --git a/web_src/js/components/ActivityHeatmap.vue b/web_src/js/components/ActivityHeatmap.vue index eaa9b0ffb1..296cb61cff 100644 --- a/web_src/js/components/ActivityHeatmap.vue +++ b/web_src/js/components/ActivityHeatmap.vue @@ -1,7 +1,7 @@ <script lang="ts" setup> // TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap'; -import {onMounted, ref} from 'vue'; +import {onMounted, shallowRef} from 'vue'; import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap'; defineProps<{ @@ -24,7 +24,7 @@ const colorRange = [ 'var(--color-primary-dark-4)', ]; -const endDate = ref(new Date()); +const endDate = shallowRef(new Date()); onMounted(() => { // work around issue with first legend color being rendered twice and legend cut off diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue index 0aae202d42..5ec4499e48 100644 --- a/web_src/js/components/ContextPopup.vue +++ b/web_src/js/components/ContextPopup.vue @@ -2,16 +2,16 @@ import {SvgIcon} from '../svg.ts'; import {GET} from '../modules/fetch.ts'; import {getIssueColor, getIssueIcon} from '../features/issue.ts'; -import {computed, onMounted, ref} from 'vue'; +import {computed, onMounted, shallowRef, useTemplateRef} from 'vue'; import type {IssuePathInfo} from '../types.ts'; const {appSubUrl, i18n} = window.config; -const loading = ref(false); -const issue = ref(null); -const renderedLabels = ref(''); +const loading = shallowRef(false); +const issue = shallowRef(null); +const renderedLabels = shallowRef(''); const i18nErrorOccurred = i18n.error_occurred; -const i18nErrorMessage = ref(null); +const i18nErrorMessage = shallowRef(null); const createdAt = computed(() => new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'})); const body = computed(() => { @@ -22,7 +22,7 @@ const body = computed(() => { return body; }); -const root = ref<HTMLElement | null>(null); +const root = useTemplateRef('root'); onMounted(() => { root.value.addEventListener('ce-load-context-popup', (e: CustomEventInit<IssuePathInfo>) => { diff --git a/web_src/js/components/DashboardRepoList.vue b/web_src/js/components/DashboardRepoList.vue index 40ecbba5e3..e938814ec6 100644 --- a/web_src/js/components/DashboardRepoList.vue +++ b/web_src/js/components/DashboardRepoList.vue @@ -6,7 +6,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts'; const {appSubUrl, assetUrlPrefix, pageData} = window.config; -type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning'; +type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning' | 'skipped'; type CommitStatusMap = { [status in CommitStatus]: { @@ -22,6 +22,7 @@ const commitStatus: CommitStatusMap = { error: {name: 'gitea-exclamation', color: 'red'}, failure: {name: 'octicon-x', color: 'red'}, warning: {name: 'gitea-exclamation', color: 'yellow'}, + skipped: {name: 'octicon-skip', color: 'grey'}, }; export default defineComponent({ @@ -38,7 +39,7 @@ export default defineComponent({ return { tab, repos: [], - reposTotalCount: 0, + reposTotalCount: null, reposFilter, archivedFilter, privateFilter, @@ -112,9 +113,6 @@ export default defineComponent({ const el = document.querySelector('#dashboard-repo-list'); this.changeReposFilter(this.reposFilter); fomanticQuery(el.querySelector('.ui.dropdown')).dropdown(); - nextTick(() => { - this.$refs.search.focus(); - }); this.textArchivedFilterTitles = { 'archived': this.textShowOnlyArchived, @@ -130,12 +128,12 @@ export default defineComponent({ }, methods: { - changeTab(t) { - this.tab = t; + changeTab(tab: string) { + this.tab = tab; this.updateHistory(); }, - changeReposFilter(filter) { + changeReposFilter(filter: string) { this.reposFilter = filter; this.repos = []; this.page = 1; @@ -218,7 +216,9 @@ export default defineComponent({ this.searchRepos(); }, - changePage(page) { + async changePage(page: number) { + if (this.isLoading) return; + this.page = page; if (this.page > this.finalPage) { this.page = this.finalPage; @@ -228,7 +228,7 @@ export default defineComponent({ } this.repos = []; this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; - this.searchRepos(); + await this.searchRepos(); }, async searchRepos() { @@ -240,12 +240,20 @@ export default defineComponent({ 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 = response.headers.get('X-Total-Count') ?? '?'; + this.reposTotalCount = parseInt(response.headers.get('X-Total-Count') ?? '0'); + } + if (firstLoad && this.reposTotalCount) { + nextTick(() => { + // MDN: If there's no focused element, this is the Document.body or Document.documentElement. + if ((document.activeElement === document.body || document.activeElement === document.documentElement)) { + this.$refs.search.focus({preventScroll: true}); + } + }); } - response = await GET(searchedURL); json = await response.json(); } catch { @@ -256,7 +264,7 @@ export default defineComponent({ } if (searchedURL === this.searchURL) { - this.repos = json.data.map((webSearchRepo) => { + 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 @@ -264,7 +272,7 @@ export default defineComponent({ locale_latest_commit_status_state: webSearchRepo.locale_latest_commit_status, }; }); - const count = response.headers.get('X-Total-Count'); + const count = Number(response.headers.get('X-Total-Count')); if (searchedQuery === '' && searchedMode === '' && this.archivedFilter === 'both') { this.reposTotalCount = count; } @@ -275,7 +283,7 @@ export default defineComponent({ } }, - repoIcon(repo) { + repoIcon(repo: any) { if (repo.fork) { return 'octicon-repo-forked'; } else if (repo.mirror) { @@ -298,7 +306,7 @@ export default defineComponent({ return commitStatus[status].color; }, - reposFilterKeyControl(e) { + async reposFilterKeyControl(e: KeyboardEvent) { switch (e.key) { case 'Enter': document.querySelector<HTMLAnchorElement>('.repo-owner-name-list li.active a')?.click(); @@ -307,7 +315,7 @@ export default defineComponent({ if (this.activeIndex > 0) { this.activeIndex--; } else if (this.page > 1) { - this.changePage(this.page - 1); + await this.changePage(this.page - 1); this.activeIndex = this.searchLimit - 1; } break; @@ -316,17 +324,17 @@ export default defineComponent({ this.activeIndex++; } else if (this.page < this.finalPage) { this.activeIndex = 0; - this.changePage(this.page + 1); + await this.changePage(this.page + 1); } break; case 'ArrowRight': if (this.page < this.finalPage) { - this.changePage(this.page + 1); + await this.changePage(this.page + 1); } break; case 'ArrowLeft': if (this.page > 1) { - this.changePage(this.page - 1); + await this.changePage(this.page - 1); } break; } @@ -336,7 +344,6 @@ export default defineComponent({ }, }, }); - </script> <template> <div> @@ -348,13 +355,21 @@ export default defineComponent({ <h4 class="ui top attached header tw-flex tw-items-center"> <div class="tw-flex-1 tw-flex tw-items-center"> {{ textMyRepos }} - <span class="ui grey label tw-ml-2">{{ reposTotalCount }}</span> + <span v-if="reposTotalCount" class="ui grey label tw-ml-2">{{ reposTotalCount }}</span> </div> <a class="tw-flex tw-items-center muted" :href="subUrl + '/repo/create' + (isOrganization ? '?org=' + organizationId : '')" :data-tooltip-content="textNewRepo"> <svg-icon name="octicon-plus"/> </a> </h4> - <div class="ui attached segment repos-search"> + <div v-if="!reposTotalCount" class="ui attached segment"> + <div v-if="!isLoading" class="empty-repo-or-org"> + <svg-icon name="octicon-git-branch" :size="24"/> + <p>{{ textNoRepo }}</p> + </div> + <!-- using the loading indicator here will cause more (unnecessary) page flickers, so at the moment, not use the loading indicator --> + <!-- <div v-else class="is-loading loading-icon-2px tw-min-h-16"/> --> + </div> + <div v-else class="ui attached segment repos-search"> <div class="ui small fluid action left icon input"> <input type="search" spellcheck="false" maxlength="255" @input="changeReposFilter(reposFilter)" v-model="searchQuery" ref="search" @keydown="reposFilterKeyControl" :placeholder="textSearchRepos"> <i class="icon loading-icon-3px" :class="{'is-loading': isLoading}"><svg-icon name="octicon-search" :size="16"/></i> @@ -367,7 +382,7 @@ export default defineComponent({ otherwise if the "input" handles click event for intermediate status, it breaks the internal state--> <input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxArchivedFilterProps"> <label> - <svg-icon name="octicon-archive" :size="16" class-name="tw-mr-1"/> + <svg-icon name="octicon-archive" :size="16" class="tw-mr-1"/> {{ textShowArchived }} </label> </div> @@ -376,7 +391,7 @@ export default defineComponent({ <div class="ui checkbox" ref="checkboxPrivateFilter" :title="checkboxPrivateFilterTitle"> <input type="checkbox" class="tw-pointer-events-none" v-bind.prop="checkboxPrivateFilterProps"> <label> - <svg-icon name="octicon-lock" :size="16" class-name="tw-mr-1"/> + <svg-icon name="octicon-lock" :size="16" class="tw-mr-1"/> {{ textShowPrivate }} </label> </div> @@ -411,17 +426,17 @@ export default defineComponent({ </div> <div v-if="repos.length" class="ui attached table segment tw-rounded-b"> <ul class="repo-owner-name-list"> - <li class="tw-flex tw-items-center tw-py-2" v-for="repo, index in repos" :class="{'active': index === activeIndex}" :key="repo.id"> + <li class="tw-flex tw-items-center tw-py-2" v-for="(repo, index) in repos" :class="{'active': index === activeIndex}" :key="repo.id"> <a class="repo-list-link muted" :href="repo.link"> - <svg-icon :name="repoIcon(repo)" :size="16" class-name="repo-list-icon"/> + <svg-icon :name="repoIcon(repo)" :size="16" class="repo-list-icon"/> <div class="text truncate">{{ repo.full_name }}</div> <div v-if="repo.archived"> <svg-icon name="octicon-archive" :size="16"/> </div> </a> - <a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link" :data-tooltip-content="repo.locale_latest_commit_status_state"> + <a class="tw-flex tw-items-center" v-if="repo.latest_commit_status_state" :href="repo.latest_commit_status_state_link || null" :data-tooltip-content="repo.locale_latest_commit_status_state"> <!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl --> - <svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class-name="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/> + <svg-icon :name="statusIcon(repo.latest_commit_status_state)" :class="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/> </a> </li> </ul> @@ -432,26 +447,26 @@ export default defineComponent({ class="item navigation tw-py-1" :class="{'disabled': page === 1}" @click="changePage(1)" :title="textFirstPage" > - <svg-icon name="gitea-double-chevron-left" :size="16" class-name="tw-mr-1"/> + <svg-icon name="gitea-double-chevron-left" :size="16" class="tw-mr-1"/> </a> <a class="item navigation tw-py-1" :class="{'disabled': page === 1}" @click="changePage(page - 1)" :title="textPreviousPage" > - <svg-icon name="octicon-chevron-left" :size="16" clsas-name="tw-mr-1"/> + <svg-icon name="octicon-chevron-left" :size="16" class="tw-mr-1"/> </a> <a class="active item tw-py-1">{{ page }}</a> <a class="item navigation" :class="{'disabled': page === finalPage}" @click="changePage(page + 1)" :title="textNextPage" > - <svg-icon name="octicon-chevron-right" :size="16" class-name="tw-ml-1"/> + <svg-icon name="octicon-chevron-right" :size="16" class="tw-ml-1"/> </a> <a class="item navigation tw-py-1" :class="{'disabled': page === finalPage}" @click="changePage(finalPage)" :title="textLastPage" > - <svg-icon name="gitea-double-chevron-right" :size="16" class-name="tw-ml-1"/> + <svg-icon name="gitea-double-chevron-right" :size="16" class="tw-ml-1"/> </a> </div> </div> @@ -467,11 +482,17 @@ export default defineComponent({ <svg-icon name="octicon-plus"/> </a> </h4> - <div v-if="organizations.length" class="ui attached table segment tw-rounded-b"> + <div v-if="!organizations.length" class="ui attached segment"> + <div class="empty-repo-or-org"> + <svg-icon name="octicon-organization" :size="24"/> + <p>{{ textNoOrg }}</p> + </div> + </div> + <div v-else class="ui attached table segment tw-rounded-b"> <ul class="repo-owner-name-list"> <li class="tw-flex tw-items-center tw-py-2" v-for="org in organizations" :key="org.name"> <a class="repo-list-link muted" :href="subUrl + '/' + encodeURIComponent(org.name)"> - <svg-icon name="octicon-organization" :size="16" class-name="repo-list-icon"/> + <svg-icon name="octicon-organization" :size="16" class="repo-list-icon"/> <div class="text truncate">{{ org.full_name ? `${org.full_name} (${org.name})` : org.name }}</div> <div><!-- div to prevent underline of label on hover --> <span class="ui tiny basic label" v-if="org.org_visibility !== 'public'"> @@ -481,7 +502,7 @@ export default defineComponent({ </a> <div class="text light grey tw-flex tw-items-center tw-ml-2"> {{ org.num_repos }} - <svg-icon name="octicon-repo" :size="16" class-name="tw-ml-1 tw-mt-0.5"/> + <svg-icon name="octicon-repo" :size="16" class="tw-ml-1 tw-mt-0.5"/> </div> </li> </ul> @@ -546,4 +567,14 @@ ul li:not(:last-child) { .repo-owner-name-list li.active { background: var(--color-hover); } + +.empty-repo-or-org { + margin-top: 1em; + text-align: center; + color: var(--color-placeholder-text); +} + +.empty-repo-or-org p { + margin: 1em auto; +} </style> diff --git a/web_src/js/components/DiffCommitSelector.vue b/web_src/js/components/DiffCommitSelector.vue index 840acd4b51..a375343979 100644 --- a/web_src/js/components/DiffCommitSelector.vue +++ b/web_src/js/components/DiffCommitSelector.vue @@ -4,6 +4,22 @@ import {SvgIcon} from '../svg.ts'; import {GET} from '../modules/fetch.ts'; import {generateAriaId} from '../modules/fomantic/base.ts'; +type Commit = { + id: string, + hovered: boolean, + selected: boolean, + summary: string, + committer_or_author_name: string, + time: string, + short_sha: string, +} + +type CommitListResult = { + commits: Array<Commit>, + last_review_commit_sha: string, + locale: Record<string, string>, +} + export default defineComponent({ components: {SvgIcon}, data: () => { @@ -16,9 +32,9 @@ export default defineComponent({ locale: { filter_changes_by_commit: el.getAttribute('data-filter_changes_by_commit'), } as Record<string, string>, - commits: [], + commits: [] as Array<Commit>, hoverActivated: false, - lastReviewCommitSha: null, + lastReviewCommitSha: '', uniqueIdMenu: generateAriaId(), uniqueIdShowAll: generateAriaId(), }; @@ -71,7 +87,7 @@ export default defineComponent({ 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[commitIdx]); + if (commitIdx) this.highlight(this.commits[Number(commitIdx)]); } }, onKeyUp(event: KeyboardEvent) { @@ -87,7 +103,7 @@ export default defineComponent({ } } }, - highlight(commit) { + 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); @@ -125,10 +141,11 @@ export default defineComponent({ } }); }, + /** Load the commits to show in this dropdown */ async fetchCommits() { const resp = await GET(`${this.issueLink}/commits/list`); - const results = await resp.json(); + const results = await resp.json() as CommitListResult; this.commits.push(...results.commits.map((x) => { x.hovered = false; return x; @@ -166,7 +183,7 @@ export default defineComponent({ * the diff from beginning of PR up to the second clicked commit is * opened */ - commitClickedShift(commit) { + commitClickedShift(commit: Commit) { this.hoverActivated = !this.hoverActivated; commit.selected = true; // Second click -> determine our range and open links accordingly @@ -195,7 +212,7 @@ export default defineComponent({ <div class="ui scrolling dropdown custom diff-commit-selector"> <button ref="expandBtn" - class="ui basic button" + class="ui tiny basic button" @click.stop="toggleMenu()" :data-tooltip-content="locale.filter_changes_by_commit" aria-haspopup="true" diff --git a/web_src/js/components/DiffFileList.vue b/web_src/js/components/DiffFileList.vue deleted file mode 100644 index 792a1aefac..0000000000 --- a/web_src/js/components/DiffFileList.vue +++ /dev/null @@ -1,60 +0,0 @@ -<script lang="ts" setup> -import {onMounted, onUnmounted} from 'vue'; -import {loadMoreFiles} from '../features/repo-diff.ts'; -import {diffTreeStore} from '../modules/stores.ts'; - -const store = diffTreeStore(); - -onMounted(() => { - document.querySelector('#show-file-list-btn').addEventListener('click', toggleFileList); -}); - -onUnmounted(() => { - document.querySelector('#show-file-list-btn').removeEventListener('click', toggleFileList); -}); - -function toggleFileList() { - store.fileListIsVisible = !store.fileListIsVisible; -} - -function diffTypeToString(pType: number) { - const diffTypes = { - 1: 'add', - 2: 'modify', - 3: 'del', - 4: 'rename', - 5: 'copy', - }; - return diffTypes[pType]; -} - -function diffStatsWidth(adds: number, dels: number) { - return `${adds / (adds + dels) * 100}%`; -} - -function loadMoreData() { - loadMoreFiles(store.linkLoadMore); -} -</script> - -<template> - <ol class="diff-stats tw-m-0" ref="root" v-if="store.fileListIsVisible"> - <li v-for="file in store.files" :key="file.NameHash"> - <div class="tw-font-semibold tw-flex tw-items-center pull-right"> - <span v-if="file.IsBin" class="tw-ml-0.5 tw-mr-2">{{ store.binaryFileMessage }}</span> - {{ file.IsBin ? '' : file.Addition + file.Deletion }} - <span v-if="!file.IsBin" class="diff-stats-bar tw-mx-2" :data-tooltip-content="store.statisticsMessage.replace('%d', (file.Addition + file.Deletion)).replace('%d', file.Addition).replace('%d', file.Deletion)"> - <div class="diff-stats-add-bar" :style="{ 'width': diffStatsWidth(file.Addition, file.Deletion) }"/> - </span> - </div> - <!-- todo finish all file status, now modify, add, delete and rename --> - <span :class="['status', diffTypeToString(file.Type)]" :data-tooltip-content="diffTypeToString(file.Type)"> </span> - <a class="file tw-font-mono" :href="'#diff-' + file.NameHash">{{ file.Name }}</a> - </li> - <li v-if="store.isIncomplete" class="tw-pt-1"> - <span class="file tw-flex tw-items-center tw-justify-between">{{ store.tooManyFilesMessage }} - <a :class="['ui', 'basic', 'tiny', 'button', store.isLoadingNewData ? 'disabled' : '']" @click.stop="loadMoreData">{{ store.showMoreMessage }}</a> - </span> - </li> - </ol> -</template> diff --git a/web_src/js/components/DiffFileTree.vue b/web_src/js/components/DiffFileTree.vue index 8676c4d37f..981d10c1c1 100644 --- a/web_src/js/components/DiffFileTree.vue +++ b/web_src/js/components/DiffFileTree.vue @@ -1,83 +1,14 @@ <script lang="ts" setup> import DiffFileTreeItem from './DiffFileTreeItem.vue'; -import {loadMoreFiles} from '../features/repo-diff.ts'; import {toggleElem} from '../utils/dom.ts'; -import {diffTreeStore} from '../modules/stores.ts'; +import {diffTreeStore} from '../modules/diff-file.ts'; import {setFileFolding} from '../features/file-fold.ts'; -import {computed, onMounted, onUnmounted} from 'vue'; +import {onMounted, onUnmounted} from 'vue'; const LOCAL_STORAGE_KEY = 'diff_file_tree_visible'; const store = diffTreeStore(); -const fileTree = computed(() => { - const result = []; - for (const file of store.files) { - // Split file into directories - const splits = file.Name.split('/'); - let index = 0; - let parent = null; - let isFile = false; - for (const split of splits) { - index += 1; - // reached the end - if (index === splits.length) { - isFile = true; - } - let newParent = { - name: split, - children: [], - isFile, - } as { - name: string, - children: any[], - isFile: boolean, - file?: any, - }; - - if (isFile === true) { - newParent.file = file; - } - - if (parent) { - // check if the folder already exists - const existingFolder = parent.children.find( - (x) => x.name === split, - ); - if (existingFolder) { - newParent = existingFolder; - } else { - parent.children.push(newParent); - } - } else { - const existingFolder = result.find((x) => x.name === split); - if (existingFolder) { - newParent = existingFolder; - } else { - result.push(newParent); - } - } - parent = newParent; - } - } - const mergeChildIfOnlyOneDir = (entries: Array<Record<string, any>>) => { - for (const entry of entries) { - if (entry.children) { - mergeChildIfOnlyOneDir(entry.children); - } - if (entry.children.length === 1 && entry.children[0].isFile === false) { - // Merge it to the parent - entry.name = `${entry.name}/${entry.children[0].name}`; - entry.children = entry.children[0].children; - } - } - }; - // Merge folders with just a folder as children in order to - // reduce the depth of our tree. - mergeChildIfOnlyOneDir(result); - return result; -}); - onMounted(() => { // Default to true if unset store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false'; @@ -112,7 +43,7 @@ function toggleVisibility() { function updateVisibility(visible: boolean) { store.fileTreeIsVisible = visible; - localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible); + localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible.toString()); updateState(store.fileTreeIsVisible); } @@ -126,19 +57,12 @@ function updateState(visible: boolean) { toggleElem(toShow, !visible); toggleElem(toHide, visible); } - -function loadMoreData() { - loadMoreFiles(store.linkLoadMore); -} </script> <template> + <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often --> <div v-if="store.fileTreeIsVisible" class="diff-file-tree-items"> - <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often --> - <DiffFileTreeItem v-for="item in fileTree" :key="item.name" :item="item"/> - <div v-if="store.isIncomplete" class="tw-pt-1"> - <a :class="['ui', 'basic', 'tiny', 'button', store.isLoadingNewData ? 'disabled' : '']" @click.stop="loadMoreData">{{ store.showMoreMessage }}</a> - </div> + <DiffFileTreeItem v-for="item in store.diffFileTree.TreeRoot.Children" :key="item.FullName" :item="item"/> </div> </template> diff --git a/web_src/js/components/DiffFileTreeItem.vue b/web_src/js/components/DiffFileTreeItem.vue index 9a21a8ac10..f15f093ff8 100644 --- a/web_src/js/components/DiffFileTreeItem.vue +++ b/web_src/js/components/DiffFileTreeItem.vue @@ -1,74 +1,62 @@ <script lang="ts" setup> -import {SvgIcon} from '../svg.ts'; -import {diffTreeStore} from '../modules/stores.ts'; -import {ref} from 'vue'; +import {SvgIcon, type SvgName} from '../svg.ts'; +import {shallowRef} from 'vue'; +import {type DiffStatus, type DiffTreeEntry, diffTreeStore} from '../modules/diff-file.ts'; -type File = { - Name: string; - NameHash: string; - Type: number; - IsViewed: boolean; - IsSubmodule: boolean; -} - -type Item = { - name: string; - isFile: boolean; - file?: File; - children?: Item[]; -}; - -defineProps<{ - item: Item, +const props = defineProps<{ + item: DiffTreeEntry, }>(); const store = diffTreeStore(); -const collapsed = ref(false); +const collapsed = shallowRef(props.item.IsViewed); -function getIconForDiffType(pType: number) { - const diffTypes = { - 1: {name: 'octicon-diff-added', classes: ['text', 'green']}, - 2: {name: 'octicon-diff-modified', classes: ['text', 'yellow']}, - 3: {name: 'octicon-diff-removed', classes: ['text', 'red']}, - 4: {name: 'octicon-diff-renamed', classes: ['text', 'teal']}, - 5: {name: 'octicon-diff-renamed', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok +function getIconForDiffStatus(pType: DiffStatus) { + const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = { + '': {name: 'octicon-blocked', classes: ['text', 'red']}, // unknown case + 'added': {name: 'octicon-diff-added', classes: ['text', 'green']}, + 'modified': {name: 'octicon-diff-modified', classes: ['text', 'yellow']}, + 'deleted': {name: 'octicon-diff-removed', classes: ['text', 'red']}, + 'renamed': {name: 'octicon-diff-renamed', classes: ['text', 'teal']}, + 'copied': {name: 'octicon-diff-renamed', classes: ['text', 'green']}, + 'typechange': {name: 'octicon-diff-modified', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok }; - return diffTypes[pType]; -} - -function fileIcon(file: File) { - if (file.IsSubmodule) { - return 'octicon-file-submodule'; - } - return 'octicon-file'; + return diffTypes[pType] ?? diffTypes['']; } </script> <template> - <!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"--> + <template v-if="item.EntryMode === 'tree'"> + <div class="item-directory" :class="{ 'viewed': item.IsViewed }" :title="item.DisplayName" @click.stop="collapsed = !collapsed"> + <!-- directory --> + <SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/> + <!-- eslint-disable-next-line vue/no-v-html --> + <span class="tw-contents" v-html="collapsed ? store.folderIcon : store.folderOpenIcon"/> + <span class="gt-ellipsis">{{ item.DisplayName }}</span> + </div> + + <div v-show="!collapsed" class="sub-items"> + <DiffFileTreeItem v-for="childItem in item.Children" :key="childItem.DisplayName" :item="childItem"/> + </div> + </template> <a - v-if="item.isFile" class="item-file" - :class="{'selected': store.selectedItem === '#diff-' + item.file.NameHash, 'viewed': item.file.IsViewed}" - :title="item.name" :href="'#diff-' + item.file.NameHash" + v-else + class="item-file" :class="{ 'selected': store.selectedItem === '#diff-' + item.NameHash, 'viewed': item.IsViewed }" + :title="item.DisplayName" :href="'#diff-' + item.NameHash" > <!-- file --> - <SvgIcon :name="fileIcon(item.file)"/> - <span class="gt-ellipsis tw-flex-1">{{ item.name }}</span> - <SvgIcon :name="getIconForDiffType(item.file.Type).name" :class="getIconForDiffType(item.file.Type).classes"/> + <!-- eslint-disable-next-line vue/no-v-html --> + <span class="tw-contents" v-html="item.FileIcon"/> + <span class="gt-ellipsis tw-flex-1">{{ item.DisplayName }}</span> + <SvgIcon + :name="getIconForDiffStatus(item.DiffStatus).name" + :class="getIconForDiffStatus(item.DiffStatus).classes" + /> </a> - <div v-else class="item-directory" :title="item.name" @click.stop="collapsed = !collapsed"> - <!-- directory --> - <SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/> - <SvgIcon class="text primary" :name="collapsed ? 'octicon-file-directory-fill' : 'octicon-file-directory-open-fill'"/> - <span class="gt-ellipsis">{{ item.name }}</span> - </div> - - <div v-if="item.children?.length" v-show="!collapsed" class="sub-items"> - <DiffFileTreeItem v-for="childItem in item.children" :key="childItem.name" :item="childItem"/> - </div> </template> + <style scoped> -a, a:hover { +a, +a:hover { text-decoration: none; color: var(--color-text); } @@ -91,7 +79,8 @@ a, a:hover { border-radius: 4px; } -.item-file.viewed { +.item-file.viewed, +.item-directory.viewed { color: var(--color-text-light-3); } diff --git a/web_src/js/components/PullRequestMergeForm.vue b/web_src/js/components/PullRequestMergeForm.vue index 1393a7f258..b2c28414c0 100644 --- a/web_src/js/components/PullRequestMergeForm.vue +++ b/web_src/js/components/PullRequestMergeForm.vue @@ -1,19 +1,19 @@ <script lang="ts" setup> -import {computed, onMounted, onUnmounted, ref, watch} from 'vue'; +import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue'; import {SvgIcon} from '../svg.ts'; import {toggleElem} from '../utils/dom.ts'; const {csrfToken, pageData} = window.config; -const mergeForm = ref(pageData.pullRequestMergeForm); +const mergeForm = pageData.pullRequestMergeForm; -const mergeTitleFieldValue = ref(''); -const mergeMessageFieldValue = ref(''); -const deleteBranchAfterMerge = ref(false); -const autoMergeWhenSucceed = ref(false); +const mergeTitleFieldValue = shallowRef(''); +const mergeMessageFieldValue = shallowRef(''); +const deleteBranchAfterMerge = shallowRef(false); +const autoMergeWhenSucceed = shallowRef(false); -const mergeStyle = ref(''); -const mergeStyleDetail = ref({ +const mergeStyle = shallowRef(''); +const mergeStyleDetail = shallowRef({ hideMergeMessageTexts: false, textDoMerge: '', mergeTitleFieldText: '', @@ -21,33 +21,33 @@ const mergeStyleDetail = ref({ hideAutoMerge: false, }); -const mergeStyleAllowedCount = ref(0); +const mergeStyleAllowedCount = shallowRef(0); -const showMergeStyleMenu = ref(false); -const showActionForm = ref(false); +const showMergeStyleMenu = shallowRef(false); +const showActionForm = shallowRef(false); const mergeButtonStyleClass = computed(() => { - if (mergeForm.value.allOverridableChecksOk) return 'primary'; + if (mergeForm.allOverridableChecksOk) return 'primary'; return autoMergeWhenSucceed.value ? 'primary' : 'red'; }); const forceMerge = computed(() => { - return mergeForm.value.canMergeNow && !mergeForm.value.allOverridableChecksOk; + return mergeForm.canMergeNow && !mergeForm.allOverridableChecksOk; }); watch(mergeStyle, (val) => { - mergeStyleDetail.value = mergeForm.value.mergeStyles.find((e) => e.name === val); + mergeStyleDetail.value = mergeForm.mergeStyles.find((e: any) => e.name === val); for (const elem of document.querySelectorAll('[data-pull-merge-style]')) { toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val); } }); onMounted(() => { - mergeStyleAllowedCount.value = mergeForm.value.mergeStyles.reduce((v, msd) => v + (msd.allowed ? 1 : 0), 0); + mergeStyleAllowedCount.value = mergeForm.mergeStyles.reduce((v: any, msd: any) => v + (msd.allowed ? 1 : 0), 0); - let mergeStyle = mergeForm.value.mergeStyles.find((e) => e.allowed && e.name === mergeForm.value.defaultMergeStyle)?.name; - if (!mergeStyle) mergeStyle = mergeForm.value.mergeStyles.find((e) => e.allowed)?.name; - switchMergeStyle(mergeStyle, !mergeForm.value.canMergeNow); + let mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed && e.name === mergeForm.defaultMergeStyle)?.name; + if (!mergeStyle) mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed)?.name; + switchMergeStyle(mergeStyle, !mergeForm.canMergeNow); document.addEventListener('mouseup', hideMergeStyleMenu); }); @@ -63,7 +63,7 @@ function hideMergeStyleMenu() { function toggleActionForm(show: boolean) { showActionForm.value = show; if (!show) return; - deleteBranchAfterMerge.value = mergeForm.value.defaultDeleteBranchAfterMerge; + deleteBranchAfterMerge.value = mergeForm.defaultDeleteBranchAfterMerge; mergeTitleFieldValue.value = mergeStyleDetail.value.mergeTitleFieldText; mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText; } @@ -74,7 +74,7 @@ function switchMergeStyle(name: string, autoMerge = false) { } function clearMergeMessage() { - mergeMessageFieldValue.value = mergeForm.value.defaultMergeMessage; + mergeMessageFieldValue.value = mergeForm.defaultMergeMessage; } </script> diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index 79b43a3746..2eb2211269 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -6,6 +6,8 @@ import {createElementFromAttrs, toggleElem} from '../utils/dom.ts'; import {formatDatetime} from '../utils/time.ts'; import {renderAnsi} from '../render/ansi.ts'; import {POST, DELETE} from '../modules/fetch.ts'; +import type {IntervalId} from '../types.ts'; +import {toggleFullScreen} from '../utils.ts'; // see "models/actions/status.go", if it needs to be used somewhere else, move it to a shared file like "types/actions.ts" type RunStatus = 'unknown' | 'waiting' | 'running' | 'success' | 'failure' | 'cancelled' | 'skipped' | 'blocked'; @@ -24,6 +26,20 @@ type LogLineCommand = { prefix: string, } +type Job = { + id: number; + name: string; + status: RunStatus; + canRerun: boolean; + duration: string; +} + +type Step = { + summary: string, + duration: string, + status: RunStatus, +} + function parseLineCommand(line: LogLine): LogLineCommand | null { for (const prefix of LogLinePrefixesGroup) { if (line.message.startsWith(prefix)) { @@ -77,7 +93,7 @@ export default defineComponent({ default: '', }, locale: { - type: Object as PropType<Record<string, string>>, + type: Object as PropType<Record<string, any>>, default: null, }, }, @@ -86,11 +102,10 @@ export default defineComponent({ const {autoScroll, expandRunning} = getLocaleStorageOptions(); return { // internal state - loadingAbortController: null, - intervalID: null, - currentJobStepsStates: [], - artifacts: [], - onHoverRerunIndex: -1, + loadingAbortController: null as AbortController | null, + intervalID: null as IntervalId | null, + currentJobStepsStates: [] as Array<Record<string, any>>, + artifacts: [] as Array<Record<string, any>>, menuVisible: false, isFullScreen: false, timeVisible: { @@ -105,7 +120,7 @@ export default defineComponent({ link: '', title: '', titleHTML: '', - status: 'unknown' as RunStatus, + status: '' as RunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon canCancel: false, canApprove: false, canRerun: false, @@ -122,7 +137,7 @@ export default defineComponent({ // canRerun: false, // duration: '', // }, - ], + ] as Array<Job>, commit: { localeCommit: '', localePushedBy: '', @@ -148,7 +163,7 @@ export default defineComponent({ // duration: '', // status: '', // } - ], + ] as Array<Step>, }, }; }, @@ -162,7 +177,7 @@ export default defineComponent({ }, }, - async mounted() { // eslint-disable-line @typescript-eslint/no-misused-promises + async mounted() { // load job data and then auto-reload periodically // need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener await this.loadJob(); @@ -194,7 +209,7 @@ export default defineComponent({ // get the job step logs container ('.job-step-logs') getJobStepLogsContainer(stepIndex: number): HTMLElement { - return this.$refs.logs[stepIndex]; + return (this.$refs.logs as any)[stepIndex]; }, // get the active logs container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group` @@ -205,7 +220,7 @@ export default defineComponent({ }, // begin a log group beginLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) { - const el = this.$refs.logs[stepIndex]; + const el = (this.$refs.logs as any)[stepIndex]; const elJobLogGroupSummary = createElementFromAttrs('summary', {class: 'job-log-group-summary'}, this.createLogLine(stepIndex, startTime, { index: line.index, @@ -223,7 +238,7 @@ export default defineComponent({ }, // end a log group endLogGroup(stepIndex: number, startTime: number, line: LogLine, cmd: LogLineCommand) { - const el = this.$refs.logs[stepIndex]; + const el = (this.$refs.logs as any)[stepIndex]; el._stepLogsActiveContainer = null; el.append(this.createLogLine(stepIndex, startTime, { index: line.index, @@ -393,7 +408,7 @@ export default defineComponent({ if (this.menuVisible) this.menuVisible = false; }, - toggleTimeDisplay(type: string) { + toggleTimeDisplay(type: 'seconds' | 'stamp') { this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`]; for (const el of (this.$refs.steps as HTMLElement).querySelectorAll(`.log-time-${type}`)) { toggleElem(el, this.timeVisible[`log-time-${type}`]); @@ -402,29 +417,16 @@ export default defineComponent({ toggleFullScreen() { this.isFullScreen = !this.isFullScreen; - const fullScreenEl = document.querySelector('.action-view-right'); - const outerEl = document.querySelector('.full.height'); - const actionBodyEl = document.querySelector('.action-view-body'); - const headerEl = document.querySelector('#navbar'); - const contentEl = document.querySelector('.page-content'); - const footerEl = document.querySelector('.page-footer'); - toggleElem(headerEl, !this.isFullScreen); - toggleElem(contentEl, !this.isFullScreen); - toggleElem(footerEl, !this.isFullScreen); - // move .action-view-right to new parent - if (this.isFullScreen) { - outerEl.append(fullScreenEl); - } else { - actionBodyEl.append(fullScreenEl); - } + toggleFullScreen('.action-view-right', this.isFullScreen, '.action-view-body'); }, async hashChangeListener() { const selectedLogStep = window.location.hash; if (!selectedLogStep) return; const [_, step, _line] = selectedLogStep.split('-'); - if (!this.currentJobStepsStates[step]) return; - if (!this.currentJobStepsStates[step].expanded && this.currentJobStepsStates[step].cursor === null) { - this.currentJobStepsStates[step].expanded = true; + const stepNum = Number(step); + if (!this.currentJobStepsStates[stepNum]) return; + if (!this.currentJobStepsStates[stepNum].expanded && this.currentJobStepsStates[stepNum].cursor === null) { + this.currentJobStepsStates[stepNum].expanded = true; // need to await for load job if the step log is loaded for the first time // so logline can be selected by querySelector await this.loadJob(); @@ -437,7 +439,8 @@ export default defineComponent({ }); </script> <template> - <div class="ui container action-view-container"> + <!-- make the view container full width to make users easier to read logs --> + <div class="ui fluid container"> <div class="action-view-header"> <div class="action-info-summary"> <div class="action-info-summary-title"> @@ -476,13 +479,13 @@ export default defineComponent({ <div class="action-view-left"> <div class="job-group-section"> <div class="job-brief-list"> - <a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id" @mouseenter="onHoverRerunIndex = job.id" @mouseleave="onHoverRerunIndex = -1"> + <a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id"> <div class="job-brief-item-left"> <ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/> <span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span> </div> <span class="job-brief-item-right"> - <SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun tw-mx-2 link-action" :data-url="`${run.link}/jobs/${index}/rerun`" v-if="job.canRerun && onHoverRerunIndex === job.id"/> + <SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun tw-mx-2 link-action" :data-url="`${run.link}/jobs/${index}/rerun`" v-if="job.canRerun"/> <span class="step-summary-duration">{{ job.duration }}</span> </span> </a> @@ -493,14 +496,24 @@ export default defineComponent({ {{ locale.artifactsTitle }} </div> <ul class="job-artifacts-list"> - <li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.name"> - <a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name"> - <SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }} - </a> - <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)" class="job-artifacts-delete"> - <SvgIcon name="octicon-trash" class="ui text black job-artifacts-icon"/> - </a> - </li> + <template v-for="artifact in artifacts" :key="artifact.name"> + <li class="job-artifacts-item"> + <template v-if="artifact.status !== 'expired'"> + <a class="flex-text-inline" target="_blank" :href="run.link+'/artifacts/'+artifact.name"> + <SvgIcon name="octicon-file" class="text black"/> + <span class="gt-ellipsis">{{ artifact.name }}</span> + </a> + <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)"> + <SvgIcon name="octicon-trash" class="text black"/> + </a> + </template> + <span v-else class="flex-text-inline text light grey"> + <SvgIcon name="octicon-file"/> + <span class="gt-ellipsis">{{ artifact.name }}</span> + <span class="ui label text light grey tw-flex-shrink-0">{{ locale.artifactExpired }}</span> + </span> + </li> + </template> </ul> </div> </div> @@ -559,7 +572,7 @@ export default defineComponent({ <!-- If the job is done and the job step log is loaded for the first time, show the loading icon currentJobStepsStates[i].cursor === null means the log is loaded for the first time --> - <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 job-status-rotate"/> + <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 circular-spin"/> <SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/> <ActionRunStatus :status="jobStep.status" class="tw-mr-2"/> @@ -662,6 +675,7 @@ export default defineComponent({ padding: 6px; display: flex; justify-content: space-between; + align-items: center; } .job-artifacts-list { @@ -669,10 +683,6 @@ export default defineComponent({ list-style: none; } -.job-artifacts-icon { - padding-right: 3px; -} - .job-brief-list { display: flex; flex-direction: column; @@ -705,11 +715,6 @@ export default defineComponent({ .job-brief-item .job-brief-rerun { cursor: pointer; - transition: transform 0.2s; -} - -.job-brief-item .job-brief-rerun:hover { - transform: scale(130%); } .job-brief-item .job-brief-item-left { @@ -886,16 +891,6 @@ export default defineComponent({ <style> /* eslint-disable-line vue-scoped-css/enforce-style-type */ /* some elements are not managed by vue, so we need to use global style */ -.job-status-rotate { - animation: job-status-rotate-keyframes 1s linear infinite; -} - -@keyframes job-status-rotate-keyframes { - 100% { - transform: rotate(-360deg); - } -} - .job-step-section { margin: 10px; } @@ -945,7 +940,6 @@ export default defineComponent({ .job-step-logs .job-log-line .log-msg { flex: 1; - word-break: break-all; white-space: break-spaces; margin-left: 10px; overflow-wrap: anywhere; diff --git a/web_src/js/components/RepoActivityTopAuthors.vue b/web_src/js/components/RepoActivityTopAuthors.vue index 1295e15582..bbdfda41d0 100644 --- a/web_src/js/components/RepoActivityTopAuthors.vue +++ b/web_src/js/components/RepoActivityTopAuthors.vue @@ -1,8 +1,9 @@ <script lang="ts" setup> +// @ts-expect-error - module exports no types import {VueBarGraph} from 'vue-bar-graph'; -import {computed, onMounted, ref} from 'vue'; +import {computed, onMounted, shallowRef, useTemplateRef} from 'vue'; -const colors = ref({ +const colors = shallowRef({ barColor: 'green', textColor: 'black', textAltColor: 'white', @@ -40,8 +41,8 @@ const graphWidth = computed(() => { return activityTopAuthors.length * 40; }); -const styleElement = ref<HTMLElement | null>(null); -const altStyleElement = ref<HTMLElement | null>(null); +const styleElement = useTemplateRef('styleElement'); +const altStyleElement = useTemplateRef('altStyleElement'); onMounted(() => { const refStyle = window.getComputedStyle(styleElement.value); diff --git a/web_src/js/components/RepoBranchTagSelector.vue b/web_src/js/components/RepoBranchTagSelector.vue index 7e35d55b2f..8e3a29a0e0 100644 --- a/web_src/js/components/RepoBranchTagSelector.vue +++ b/web_src/js/components/RepoBranchTagSelector.vue @@ -157,7 +157,7 @@ export default defineComponent({ // @ts-expect-error - el is unknown type return (el && el.length) ? el[0] : null; }, - keydown(e) { + keydown(e: KeyboardEvent) { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); @@ -181,7 +181,7 @@ export default defineComponent({ this.menuVisible = false; } }, - handleTabSwitch(selectedTab) { + handleTabSwitch(selectedTab: SelectedTab) { this.selectedTab = selectedTab; this.focusSearchField(); this.loadTabItems(); @@ -216,17 +216,18 @@ export default defineComponent({ }); </script> <template> - <div class="ui dropdown custom branch-selector-dropdown ellipsis-items-nowrap"> - <div tabindex="0" class="ui button branch-dropdown-button" @click="menuVisible = !menuVisible"> + <div class="ui dropdown custom branch-selector-dropdown ellipsis-text-items"> + <div tabindex="0" class="ui compact button branch-dropdown-button" @click="menuVisible = !menuVisible"> <span class="flex-text-block gt-ellipsis"> <template v-if="dropdownFixedText">{{ dropdownFixedText }}</template> <template v-else> <svg-icon v-if="currentRefType === 'tag'" name="octicon-tag"/> - <svg-icon v-else name="octicon-git-branch"/> - <strong ref="dropdownRefName" class="tw-ml-2 tw-inline-block gt-ellipsis">{{ currentRefShortName }}</strong> + <svg-icon v-else-if="currentRefType === 'branch'" name="octicon-git-branch"/> + <svg-icon v-else name="octicon-git-commit"/> + <strong ref="dropdownRefName" class="tw-inline-block gt-ellipsis">{{ currentRefShortName }}</strong> </template> </span> - <svg-icon name="octicon-triangle-down" :size="14" class-name="dropdown icon"/> + <svg-icon name="octicon-triangle-down" :size="14" class="dropdown icon"/> </div> <div class="menu transition" :class="{visible: menuVisible}" v-show="menuVisible" v-cloak> <div class="ui icon search input"> @@ -235,10 +236,10 @@ export default defineComponent({ </div> <div v-if="showTabBranches" class="branch-tag-tab"> <a class="branch-tag-item muted" :class="{active: selectedTab === 'branches'}" href="#" @click="handleTabSwitch('branches')"> - <svg-icon name="octicon-git-branch" :size="16" class-name="tw-mr-1"/>{{ textBranches }} + <svg-icon name="octicon-git-branch" :size="16" class="tw-mr-1"/>{{ textBranches }} </a> <a v-if="showTabTags" class="branch-tag-item muted" :class="{active: selectedTab === 'tags'}" href="#" @click="handleTabSwitch('tags')"> - <svg-icon name="octicon-tag" :size="16" class-name="tw-mr-1"/>{{ textTags }} + <svg-icon name="octicon-tag" :size="16" class="tw-mr-1"/>{{ textTags }} </a> </div> <div class="branch-tag-divider"/> diff --git a/web_src/js/components/RepoCodeFrequency.vue b/web_src/js/components/RepoCodeFrequency.vue index 7696996cf6..f331a26fe9 100644 --- a/web_src/js/components/RepoCodeFrequency.vue +++ b/web_src/js/components/RepoCodeFrequency.vue @@ -23,7 +23,7 @@ import { import {chartJsColors} from '../utils/color.ts'; import {sleep} from '../utils.ts'; import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; -import {onMounted, ref} from 'vue'; +import {onMounted, shallowRef} from 'vue'; const {pageData} = window.config; @@ -47,10 +47,10 @@ defineProps<{ }; }>(); -const isLoading = ref(false); -const errorText = ref(''); -const repoLink = ref(pageData.repoLink || []); -const data = ref<DayData[]>([]); +const isLoading = shallowRef(false); +const errorText = shallowRef(''); +const repoLink = pageData.repoLink; +const data = shallowRef<DayData[]>([]); onMounted(() => { fetchGraphData(); @@ -61,7 +61,7 @@ async function fetchGraphData() { try { let response: Response; do { - response = await GET(`${repoLink.value}/activity/code-frequency/data`); + response = await GET(`${repoLink}/activity/code-frequency/data`); if (response.status === 202) { await sleep(1000); // wait for 1 second before retrying } @@ -150,7 +150,7 @@ const options: ChartOptions<'line'> = { <div class="tw-flex ui segment main-graph"> <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto"> <div v-if="isLoading"> - <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/> + <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/> {{ locale.loadingInfo }} </div> <div v-else class="text red"> diff --git a/web_src/js/components/RepoContributors.vue b/web_src/js/components/RepoContributors.vue index 5d2c74b6a9..754acb997d 100644 --- a/web_src/js/components/RepoContributors.vue +++ b/web_src/js/components/RepoContributors.vue @@ -80,10 +80,10 @@ export default defineComponent({ sortedContributors: {} as Record<string, any>, type: 'commits', contributorsStats: {} as Record<string, any>, - xAxisStart: null, - xAxisEnd: null, - xAxisMin: null, - xAxisMax: null, + xAxisStart: null as number | null, + xAxisEnd: null as number | null, + xAxisMin: null as number | null, + xAxisMax: null as number | null, }), mounted() { this.fetchGraphData(); @@ -99,7 +99,7 @@ export default defineComponent({ }, methods: { sortContributors() { - const contributors = this.filterContributorWeeksByDateRange(); + const contributors: Record<string, any> = this.filterContributorWeeksByDateRange(); const criteria = `total_${this.type}`; this.sortedContributors = Object.values(contributors) .filter((contributor) => contributor[criteria] !== 0) @@ -158,7 +158,7 @@ export default defineComponent({ }, filterContributorWeeksByDateRange() { - const filteredData = {}; + const filteredData: Record<string, any> = {}; const data = this.contributorsStats; for (const key of Object.keys(data)) { const user = data[key]; @@ -196,7 +196,7 @@ export default defineComponent({ // Normally, chartjs handles this automatically, but it will resize the graph when you // zoom, pan etc. I think resizing the graph makes it harder to compare things visually. const maxValue = Math.max( - ...this.totalStats.weeks.map((o) => o[this.type]), + ...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; @@ -208,7 +208,7 @@ export default defineComponent({ // for contributors' graph. If I let chartjs do this for me, it will choose different // maxY value for each contributors' graph which again makes it harder to compare. const maxValue = Math.max( - ...this.sortedContributors.map((c) => c.max_contribution_type), + ...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; @@ -232,8 +232,8 @@ export default defineComponent({ }, updateOtherCharts({chart}: {chart: Chart}, reset: boolean = false) { - const minVal = chart.options.scales.x.min; - const maxVal = chart.options.scales.x.max; + 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; @@ -353,12 +353,12 @@ export default defineComponent({ </div> <div> <!-- Contribution type --> - <div class="ui dropdown jump" id="repo-contributors"> + <div class="ui floating dropdown jump" id="repo-contributors"> <div class="ui basic compact button"> <span class="not-mobile">{{ locale.filterLabel }}</span> <strong>{{ locale.contributionType[type] }}</strong> <svg-icon name="octicon-triangle-down" :size="14"/> </div> - <div class="menu"> + <div class="left menu"> <div :class="['item', {'selected': type === 'commits'}]" data-value="commits"> {{ locale.contributionType.commits }} </div> @@ -375,7 +375,7 @@ export default defineComponent({ <div class="tw-flex ui segment main-graph"> <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto"> <div v-if="isLoading"> - <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/> + <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/> {{ locale.loadingInfo }} </div> <div v-else class="text red"> @@ -397,7 +397,7 @@ export default defineComponent({ <div class="ui top attached header tw-flex tw-flex-1"> <b class="ui right">#{{ index + 1 }}</b> <a :href="contributor.home_link"> - <img class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link" alt=""> + <img loading="lazy" class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link" alt=""> </a> <div class="tw-ml-2"> <a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a> diff --git a/web_src/js/components/RepoRecentCommits.vue b/web_src/js/components/RepoRecentCommits.vue index 10e1fdd70c..27aa27dfc3 100644 --- a/web_src/js/components/RepoRecentCommits.vue +++ b/web_src/js/components/RepoRecentCommits.vue @@ -21,7 +21,7 @@ import { import {chartJsColors} from '../utils/color.ts'; import {sleep} from '../utils.ts'; import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; -import {onMounted, ref} from 'vue'; +import {onMounted, ref, shallowRef} from 'vue'; const {pageData} = window.config; @@ -43,9 +43,9 @@ defineProps<{ }; }>(); -const isLoading = ref(false); -const errorText = ref(''); -const repoLink = ref(pageData.repoLink || []); +const isLoading = shallowRef(false); +const errorText = shallowRef(''); +const repoLink = pageData.repoLink; const data = ref<DayData[]>([]); onMounted(() => { @@ -57,7 +57,7 @@ async function fetchGraphData() { try { let response: Response; do { - response = await GET(`${repoLink.value}/activity/recent-commits/data`); + response = await GET(`${repoLink}/activity/recent-commits/data`); if (response.status === 202) { await sleep(1000); // wait for 1 second before retrying } @@ -128,7 +128,7 @@ const options: ChartOptions<'bar'> = { <div class="tw-flex ui segment main-graph"> <div v-if="isLoading || errorText !== ''" class="gt-tc tw-m-auto"> <div v-if="isLoading"> - <SvgIcon name="octicon-sync" class="tw-mr-2 job-status-rotate"/> + <SvgIcon name="octicon-sync" class="tw-mr-2 circular-spin"/> {{ locale.loadingInfo }} </div> <div v-else class="text red"> diff --git a/web_src/js/components/ScopedAccessTokenSelector.vue b/web_src/js/components/ScopedAccessTokenSelector.vue deleted file mode 100644 index 63214d0bf5..0000000000 --- a/web_src/js/components/ScopedAccessTokenSelector.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script lang="ts" setup> -import {computed, onMounted, onUnmounted} from 'vue'; -import {hideElem, showElem} from '../utils/dom.ts'; - -const props = defineProps<{ - isAdmin: boolean; - noAccessLabel: string; - readLabel: string; - writeLabel: string; -}>(); - -const categories = computed(() => { - const categories = [ - 'activitypub', - ]; - if (props.isAdmin) { - categories.push('admin'); - } - categories.push( - 'issue', - 'misc', - 'notification', - 'organization', - 'package', - 'repository', - 'user'); - return categories; -}); - -onMounted(() => { - document.querySelector('#scoped-access-submit').addEventListener('click', onClickSubmit); -}); - -onUnmounted(() => { - document.querySelector('#scoped-access-submit').removeEventListener('click', onClickSubmit); -}); - -function onClickSubmit(e) { - e.preventDefault(); - - const warningEl = document.querySelector('#scoped-access-warning'); - // check that at least one scope has been selected - for (const el of document.querySelectorAll<HTMLInputElement>('.access-token-select')) { - if (el.value) { - // Hide the error if it was visible from previous attempt. - hideElem(warningEl); - // Submit the form. - document.querySelector<HTMLFormElement>('#scoped-access-form').submit(); - // Don't show the warning. - return; - } - } - // no scopes selected, show validation error - showElem(warningEl); -} -</script> - -<template> - <div v-for="category in categories" :key="category" class="field tw-pl-1 tw-pb-1 access-token-category"> - <label class="category-label" :for="'access-token-scope-' + category"> - {{ category }} - </label> - <div class="gitea-select"> - <select - class="ui selection access-token-select" - name="scope" - :id="'access-token-scope-' + category" - > - <option value=""> - {{ noAccessLabel }} - </option> - <option :value="'read:' + category"> - {{ readLabel }} - </option> - <option :value="'write:' + category"> - {{ writeLabel }} - </option> - </select> - </div> - </div> -</template> diff --git a/web_src/js/components/ViewFileTree.vue b/web_src/js/components/ViewFileTree.vue new file mode 100644 index 0000000000..1f90f92586 --- /dev/null +++ b/web_src/js/components/ViewFileTree.vue @@ -0,0 +1,38 @@ +<script lang="ts" setup> +import ViewFileTreeItem from './ViewFileTreeItem.vue'; +import {onMounted, useTemplateRef} from 'vue'; +import {createViewFileTreeStore} from './ViewFileTreeStore.ts'; + +const elRoot = useTemplateRef('elRoot'); + +const props = defineProps({ + repoLink: {type: String, required: true}, + treePath: {type: String, required: true}, + currentRefNameSubURL: {type: String, required: true}, +}); + +const store = createViewFileTreeStore(props); +onMounted(async () => { + store.rootFiles = await store.loadChildren('', props.treePath); + elRoot.value.closest('.is-loading')?.classList?.remove('is-loading'); + window.addEventListener('popstate', (e) => { + store.selectedItem = e.state?.treePath || ''; + if (e.state?.url) store.loadViewContent(e.state.url); + }); +}); +</script> + +<template> + <div class="view-file-tree-items" ref="elRoot"> + <ViewFileTreeItem v-for="item in store.rootFiles" :key="item.name" :item="item" :store="store"/> + </div> +</template> + +<style scoped> +.view-file-tree-items { + display: flex; + flex-direction: column; + gap: 1px; + margin-right: .5rem; +} +</style> diff --git a/web_src/js/components/ViewFileTreeItem.vue b/web_src/js/components/ViewFileTreeItem.vue new file mode 100644 index 0000000000..5173c7eb46 --- /dev/null +++ b/web_src/js/components/ViewFileTreeItem.vue @@ -0,0 +1,128 @@ +<script lang="ts" setup> +import {SvgIcon} from '../svg.ts'; +import {isPlainClick} from '../utils/dom.ts'; +import {shallowRef} from 'vue'; +import {type createViewFileTreeStore} from './ViewFileTreeStore.ts'; + +type Item = { + entryName: string; + entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown'; + entryIcon: string; + entryIconOpen: string; + fullPath: string; + submoduleUrl?: string; + children?: Item[]; +}; + +const props = defineProps<{ + item: Item, + store: ReturnType<typeof createViewFileTreeStore> +}>(); + +const store = props.store; +const isLoading = shallowRef(false); +const children = shallowRef(props.item.children); +const collapsed = shallowRef(!props.item.children); + +const doLoadChildren = async () => { + collapsed.value = !collapsed.value; + if (!collapsed.value) { + isLoading.value = true; + try { + children.value = await store.loadChildren(props.item.fullPath); + } finally { + isLoading.value = false; + } + } +}; + +const onItemClick = (e: MouseEvent) => { + // only handle the click event with page partial reloading if the user didn't press any special key + // let browsers handle special keys like "Ctrl+Click" + if (!isPlainClick(e)) return; + e.preventDefault(); + if (props.item.entryMode === 'tree') doLoadChildren(); + store.navigateTreeView(props.item.fullPath); +}; + +</script> + +<template> + <a + class="tree-item silenced" + :class="{ + 'selected': store.selectedItem === item.fullPath, + 'type-submodule': item.entryMode === 'commit', + 'type-directory': item.entryMode === 'tree', + 'type-symlink': item.entryMode === 'symlink', + 'type-file': item.entryMode === 'blob' || item.entryMode === 'exec', + }" + :title="item.entryName" + :href="store.buildTreePathWebUrl(item.fullPath)" + @click.stop="onItemClick" + > + <div v-if="item.entryMode === 'tree'" class="item-toggle"> + <SvgIcon v-if="isLoading" name="octicon-sync" class="circular-spin"/> + <SvgIcon v-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop.prevent="doLoadChildren"/> + </div> + <div class="item-content"> + <!-- eslint-disable-next-line vue/no-v-html --> + <span class="tw-contents" v-html="(!collapsed && item.entryIconOpen) ? item.entryIconOpen : item.entryIcon"/> + <span class="gt-ellipsis">{{ item.entryName }}</span> + </div> + </a> + + <div v-if="children?.length" v-show="!collapsed" class="sub-items"> + <ViewFileTreeItem v-for="childItem in children" :key="childItem.entryName" :item="childItem" :store="store"/> + </div> +</template> + +<style scoped> +.sub-items { + display: flex; + flex-direction: column; + gap: 1px; + margin-left: 14px; + border-left: 1px solid var(--color-secondary); +} + +.tree-item.selected { + color: var(--color-text); + background: var(--color-active); + border-radius: 4px; +} + +.tree-item.type-directory { + user-select: none; +} + +.tree-item { + display: grid; + grid-template-columns: 16px 1fr; + grid-template-areas: "toggle content"; + gap: 0.25em; + padding: 6px; +} + +.tree-item:hover { + color: var(--color-text); + background: var(--color-hover); + border-radius: 4px; + cursor: pointer; +} + +.item-toggle { + grid-area: toggle; + display: flex; + align-items: center; +} + +.item-content { + grid-area: content; + display: flex; + align-items: center; + gap: 0.5em; + text-overflow: ellipsis; + min-width: 0; +} +</style> diff --git a/web_src/js/components/ViewFileTreeStore.ts b/web_src/js/components/ViewFileTreeStore.ts new file mode 100644 index 0000000000..e2155bd58a --- /dev/null +++ b/web_src/js/components/ViewFileTreeStore.ts @@ -0,0 +1,45 @@ +import {reactive} from 'vue'; +import {GET} from '../modules/fetch.ts'; +import {pathEscapeSegments} from '../utils/url.ts'; +import {createElementFromHTML} from '../utils/dom.ts'; +import {html} from '../utils/html.ts'; + +export function createViewFileTreeStore(props: { repoLink: string, treePath: string, currentRefNameSubURL: string}) { + const store = reactive({ + rootFiles: [], + selectedItem: props.treePath, + + async loadChildren(treePath: string, subPath: string = '') { + const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`); + const json = await response.json(); + const poolSvgs = []; + for (const [svgId, svgContent] of Object.entries(json.renderedIconPool ?? {})) { + if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent); + } + if (poolSvgs.length) { + const svgContainer = createElementFromHTML(html`<div class="global-svg-icon-pool tw-hidden"></div>`); + svgContainer.innerHTML = poolSvgs.join(''); + document.body.append(svgContainer); + } + return json.fileTreeNodes ?? null; + }, + + async loadViewContent(url: string) { + url = url.includes('?') ? url.replace('?', '?only_content=true') : `${url}?only_content=true`; + const response = await GET(url); + document.querySelector('.repo-view-content').innerHTML = await response.text(); + }, + + async navigateTreeView(treePath: string) { + const url = store.buildTreePathWebUrl(treePath); + window.history.pushState({treePath, url}, null, url); + store.selectedItem = treePath; + await store.loadViewContent(url); + }, + + buildTreePathWebUrl(treePath: string) { + return `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`; + }, + }); + return store; +} |