diff options
Diffstat (limited to 'web_src/js/components')
19 files changed, 634 insertions, 589 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 41793d60ed..e938814ec6 100644 --- a/web_src/js/components/DashboardRepoList.vue +++ b/web_src/js/components/DashboardRepoList.vue @@ -1,12 +1,12 @@ <script lang="ts"> -import {createApp, nextTick} from 'vue'; +import {nextTick, defineComponent} from 'vue'; import {SvgIcon} from '../svg.ts'; import {GET} from '../modules/fetch.ts'; import {fomanticQuery} from '../modules/fomantic/base.ts'; const {appSubUrl, assetUrlPrefix, pageData} = window.config; -type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning'; +type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning' | 'skipped'; type CommitStatusMap = { [status in CommitStatus]: { @@ -22,9 +22,10 @@ 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'}, }; -const sfc = { +export default defineComponent({ components: {SvgIcon}, data() { const params = new URLSearchParams(window.location.search); @@ -38,7 +39,7 @@ const sfc = { return { tab, repos: [], - reposTotalCount: 0, + reposTotalCount: null, reposFilter, archivedFilter, privateFilter, @@ -112,9 +113,6 @@ const sfc = { 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 @@ const sfc = { }, 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 @@ const sfc = { 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 @@ const sfc = { } this.repos = []; this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; - this.searchRepos(); + await this.searchRepos(); }, async searchRepos() { @@ -240,12 +240,20 @@ const sfc = { 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 @@ const sfc = { } 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 @@ const sfc = { 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 @@ const sfc = { } }, - repoIcon(repo) { + repoIcon(repo: any) { if (repo.fork) { return 'octicon-repo-forked'; } else if (repo.mirror) { @@ -298,7 +306,7 @@ const sfc = { 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 @@ const sfc = { 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 @@ const sfc = { 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; } @@ -335,16 +343,7 @@ const sfc = { } }, }, -}; - -export function initDashboardRepoList() { - const el = document.querySelector('#dashboard-repo-list'); - if (el) { - createApp(sfc).mount(el); - } -} - -export default sfc; // activate the IDE's Vue plugin +}); </script> <template> <div> @@ -356,13 +355,21 @@ export default sfc; // activate the IDE's Vue plugin <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> @@ -375,7 +382,7 @@ export default sfc; // activate the IDE's Vue plugin 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> @@ -384,7 +391,7 @@ export default sfc; // activate the IDE's Vue plugin <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> @@ -419,17 +426,17 @@ export default sfc; // activate the IDE's Vue plugin </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> @@ -440,26 +447,26 @@ export default sfc; // activate the IDE's Vue plugin 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> @@ -475,11 +482,17 @@ export default sfc; // activate the IDE's Vue plugin <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'"> @@ -489,7 +502,7 @@ export default sfc; // activate the IDE's Vue plugin </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> @@ -554,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 3a394955ca..a375343979 100644 --- a/web_src/js/components/DiffCommitSelector.vue +++ b/web_src/js/components/DiffCommitSelector.vue @@ -1,9 +1,26 @@ <script lang="ts"> +import {defineComponent} from 'vue'; import {SvgIcon} from '../svg.ts'; import {GET} from '../modules/fetch.ts'; import {generateAriaId} from '../modules/fomantic/base.ts'; -export default { +type Commit = { + id: string, + hovered: boolean, + selected: boolean, + summary: string, + committer_or_author_name: string, + time: string, + short_sha: string, +} + +type CommitListResult = { + commits: Array<Commit>, + last_review_commit_sha: string, + locale: Record<string, string>, +} + +export default defineComponent({ components: {SvgIcon}, data: () => { const el = document.querySelector('#diff-commit-select'); @@ -15,9 +32,9 @@ export default { 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(), }; @@ -55,11 +72,11 @@ export default { switch (event.key) { case 'ArrowDown': // select next element event.preventDefault(); - this.focusElem(item.nextElementSibling, item); + this.focusElem(item.nextElementSibling as HTMLElement, item); break; case 'ArrowUp': // select previous element event.preventDefault(); - this.focusElem(item.previousElementSibling, item); + this.focusElem(item.previousElementSibling as HTMLElement, item); break; case 'Escape': // close menu event.preventDefault(); @@ -70,7 +87,7 @@ export default { 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) { @@ -86,7 +103,7 @@ export default { } } }, - 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); @@ -118,16 +135,17 @@ export default { // set correct tabindex to allow easier navigation this.$nextTick(() => { if (this.menuVisible) { - this.focusElem(this.$refs.showAllChanges, this.$refs.expandBtn); + this.focusElem(this.$refs.showAllChanges as HTMLElement, this.$refs.expandBtn as HTMLElement); } else { - this.focusElem(this.$refs.expandBtn, this.$refs.showAllChanges); + this.focusElem(this.$refs.expandBtn as HTMLElement, this.$refs.showAllChanges as HTMLElement); } }); }, + /** Load the commits to show in this dropdown */ async fetchCommits() { const resp = await GET(`${this.issueLink}/commits/list`); - const results = await resp.json(); + const results = await resp.json() as CommitListResult; this.commits.push(...results.commits.map((x) => { x.hovered = false; return x; @@ -165,7 +183,7 @@ export default { * 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 @@ -188,13 +206,13 @@ export default { } }, }, -}; +}); </script> <template> <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 2888c53d2e..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) { - const diffTypes = { - 1: 'add', - 2: 'modify', - 3: 'del', - 4: 'rename', - 5: 'copy', - }; - return diffTypes[pType]; -} - -function diffStatsWidth(adds, dels) { - 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 9eabc65ae9..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) => { - 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'; @@ -110,13 +41,13 @@ function toggleVisibility() { updateVisibility(!store.fileTreeIsVisible); } -function updateVisibility(visible) { +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); } -function updateState(visible) { +function updateState(visible: boolean) { const btn = document.querySelector('.diff-toggle-file-tree-button'); const [toShow, toHide] = btn.querySelectorAll('.icon'); const tree = document.querySelector('#diff-file-tree'); @@ -126,19 +57,12 @@ function updateState(visible) { 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 12cafd8f1b..f15f093ff8 100644 --- a/web_src/js/components/DiffFileTreeItem.vue +++ b/web_src/js/components/DiffFileTreeItem.vue @@ -1,66 +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; -} - -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) { - 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]; + 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="octicon-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); } @@ -83,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 e8bcee70db..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,18 +63,18 @@ 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; } -function switchMergeStyle(name, autoMerge = false) { +function switchMergeStyle(name: string, autoMerge = false) { mergeStyle.value = name; autoMergeWhenSucceed.value = autoMerge; } function clearMergeMessage() { - mergeMessageFieldValue.value = mergeForm.value.defaultMergeMessage; + mergeMessageFieldValue.value = mergeForm.defaultMergeMessage; } </script> @@ -129,7 +129,7 @@ function clearMergeMessage() { {{ mergeForm.textCancel }} </button> - <div class="ui checkbox tw-ml-1" v-if="mergeForm.isPullBranchDeletable && !autoMergeWhenSucceed"> + <div class="ui checkbox tw-ml-1" v-if="mergeForm.isPullBranchDeletable"> <input name="delete_branch_after_merge" type="checkbox" v-model="deleteBranchAfterMerge" id="delete-branch-after-merge"> <label for="delete-branch-after-merge">{{ mergeForm.textDeleteBranch }}</label> </div> @@ -147,7 +147,7 @@ function clearMergeMessage() { </template> </span> </button> - <div class="ui dropdown icon button" @click.stop="showMergeStyleMenu = !showMergeStyleMenu" v-if="mergeStyleAllowedCount>1"> + <div class="ui dropdown icon button" @click.stop="showMergeStyleMenu = !showMergeStyleMenu"> <svg-icon name="octicon-triangle-down" :size="14"/> <div class="menu" :class="{'show':showMergeStyleMenu}"> <template v-for="msd in mergeForm.mergeStyles"> diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index b083fb0b77..2eb2211269 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -1,11 +1,13 @@ <script lang="ts"> import {SvgIcon} from '../svg.ts'; import ActionRunStatus from './ActionRunStatus.vue'; -import {createApp} from 'vue'; +import {defineComponent, type PropType} from 'vue'; 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)) { @@ -38,48 +54,77 @@ function parseLineCommand(line: LogLine): LogLineCommand | null { return null; } -function isLogElementInViewport(el: HTMLElement): boolean { +function isLogElementInViewport(el: Element): boolean { const rect = el.getBoundingClientRect(); return rect.top >= 0 && rect.bottom <= window.innerHeight; // only check height but not width } -const sfc = { +type LocaleStorageOptions = { + autoScroll: boolean; + expandRunning: boolean; +}; + +function getLocaleStorageOptions(): LocaleStorageOptions { + try { + const optsJson = localStorage.getItem('actions-view-options'); + if (optsJson) return JSON.parse(optsJson); + } catch {} + // if no options in localStorage, or failed to parse, return default options + return {autoScroll: true, expandRunning: false}; +} + +export default defineComponent({ name: 'RepoActionView', components: { SvgIcon, ActionRunStatus, }, props: { - runIndex: String, - jobIndex: String, - actionsURL: String, - locale: Object, + runIndex: { + type: String, + default: '', + }, + jobIndex: { + type: String, + default: '', + }, + actionsURL: { + type: String, + default: '', + }, + locale: { + type: Object as PropType<Record<string, any>>, + default: null, + }, }, data() { + 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: { 'log-time-stamp': false, 'log-time-seconds': false, }, + optionAlwaysAutoScroll: autoScroll ?? false, + optionAlwaysExpandRunning: expandRunning ?? false, // provided by backend run: { link: '', title: '', titleHTML: '', - status: '', + status: '' as RunStatus, // do not show the status before initialized, otherwise it would show an incorrect "error" icon canCancel: false, canApprove: false, canRerun: false, + canDeleteArtifact: false, done: false, workflowID: '', workflowLink: '', @@ -92,7 +137,7 @@ const sfc = { // canRerun: false, // duration: '', // }, - ], + ] as Array<Job>, commit: { localeCommit: '', localePushedBy: '', @@ -105,6 +150,7 @@ const sfc = { branch: { name: '', link: '', + isDeleted: false, }, }, }, @@ -117,11 +163,20 @@ const sfc = { // duration: '', // status: '', // } - ], + ] as Array<Step>, }, }; }, + watch: { + optionAlwaysAutoScroll() { + this.saveLocaleStorageOptions(); + }, + optionAlwaysExpandRunning() { + this.saveLocaleStorageOptions(); + }, + }, + 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 @@ -147,19 +202,25 @@ const sfc = { }, methods: { + saveLocaleStorageOptions() { + const opts: LocaleStorageOptions = {autoScroll: this.optionAlwaysAutoScroll, expandRunning: this.optionAlwaysExpandRunning}; + localStorage.setItem('actions-view-options', JSON.stringify(opts)); + }, + // 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` getActiveLogsContainer(stepIndex: number): HTMLElement { const el = this.getJobStepLogsContainer(stepIndex); + // @ts-expect-error - _stepLogsActiveContainer is a custom property return el._stepLogsActiveContainer ?? el; }, // 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, @@ -177,7 +238,7 @@ const sfc = { }, // 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, @@ -228,9 +289,11 @@ const sfc = { }, shouldAutoScroll(stepIndex: number): boolean { + if (!this.optionAlwaysAutoScroll) return false; const el = this.getJobStepLogsContainer(stepIndex); - if (!el.lastChild) return false; - return isLogElementInViewport(el.lastChild); + // if the logs container is empty, then auto-scroll if the step is expanded + if (!el.lastChild) return this.currentJobStepsStates[stepIndex].expanded; + return isLogElementInViewport(el.lastChild as Element); }, appendLogs(stepIndex: number, startTime: number, logLines: LogLine[]) { @@ -280,6 +343,7 @@ const sfc = { const abortController = new AbortController(); this.loadingAbortController = abortController; try { + const isFirstLoad = !this.run.status; const job = await this.fetchJobData(abortController); if (this.loadingAbortController !== abortController) return; @@ -289,9 +353,10 @@ const sfc = { // sync the currentJobStepsStates to store the job step states for (let i = 0; i < this.currentJob.steps.length; i++) { + const expanded = isFirstLoad && this.optionAlwaysExpandRunning && this.currentJob.steps[i].status === 'running'; if (!this.currentJobStepsStates[i]) { // initial states for job steps - this.currentJobStepsStates[i] = {cursor: null, expanded: false}; + this.currentJobStepsStates[i] = {cursor: null, expanded}; } } @@ -343,96 +408,39 @@ const sfc = { 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.querySelectorAll(`.log-time-${type}`)) { + for (const el of (this.$refs.steps as HTMLElement).querySelectorAll(`.log-time-${type}`)) { toggleElem(el, this.timeVisible[`log-time-${type}`]); } }, 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(); } - const logLine = this.$refs.steps.querySelector(selectedLogStep); + const logLine = (this.$refs.steps as HTMLElement).querySelector(selectedLogStep); if (!logLine) return; - logLine.querySelector('.line-num').click(); + logLine.querySelector<HTMLAnchorElement>('.line-num').click(); }, }, -}; - -export default sfc; - -export function initRepositoryActionView() { - const el = document.querySelector('#repo-action-view'); - if (!el) return; - - // TODO: the parent element's full height doesn't work well now, - // but we can not pollute the global style at the moment, only fix the height problem for pages with this component - const parentFullHeight = document.querySelector<HTMLElement>('body > div.full.height'); - if (parentFullHeight) parentFullHeight.style.paddingBottom = '0'; - - const view = createApp(sfc, { - runIndex: el.getAttribute('data-run-index'), - jobIndex: el.getAttribute('data-job-index'), - actionsURL: el.getAttribute('data-actions-url'), - locale: { - approve: el.getAttribute('data-locale-approve'), - cancel: el.getAttribute('data-locale-cancel'), - rerun: el.getAttribute('data-locale-rerun'), - rerun_all: el.getAttribute('data-locale-rerun-all'), - scheduled: el.getAttribute('data-locale-runs-scheduled'), - commit: el.getAttribute('data-locale-runs-commit'), - pushedBy: el.getAttribute('data-locale-runs-pushed-by'), - artifactsTitle: el.getAttribute('data-locale-artifacts-title'), - areYouSure: el.getAttribute('data-locale-are-you-sure'), - confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'), - showTimeStamps: el.getAttribute('data-locale-show-timestamps'), - showLogSeconds: el.getAttribute('data-locale-show-log-seconds'), - showFullScreen: el.getAttribute('data-locale-show-full-screen'), - downloadLogs: el.getAttribute('data-locale-download-logs'), - status: { - unknown: el.getAttribute('data-locale-status-unknown'), - waiting: el.getAttribute('data-locale-status-waiting'), - running: el.getAttribute('data-locale-status-running'), - success: el.getAttribute('data-locale-status-success'), - failure: el.getAttribute('data-locale-status-failure'), - cancelled: el.getAttribute('data-locale-status-cancelled'), - skipped: el.getAttribute('data-locale-status-skipped'), - blocked: el.getAttribute('data-locale-status-blocked'), - }, - }, - }); - view.mount(el); -} +}); </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"> @@ -471,13 +479,13 @@ export function initRepositoryActionView() { <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> @@ -488,14 +496,24 @@ export function initRepositoryActionView() { {{ 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> @@ -528,6 +546,17 @@ export function initRepositoryActionView() { <i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i> {{ locale.showFullScreen }} </a> + + <div class="divider"/> + <a class="item" @click="optionAlwaysAutoScroll = !optionAlwaysAutoScroll"> + <i class="icon"><SvgIcon :name="optionAlwaysAutoScroll ? 'octicon-check' : 'gitea-empty-checkbox'"/></i> + {{ locale.logsAlwaysAutoScroll }} + </a> + <a class="item" @click="optionAlwaysExpandRunning = !optionAlwaysExpandRunning"> + <i class="icon"><SvgIcon :name="optionAlwaysExpandRunning ? 'octicon-check' : 'gitea-empty-checkbox'"/></i> + {{ locale.logsAlwaysExpandRunning }} + </a> + <div class="divider"/> <a :class="['item', !currentJob.steps.length ? 'disabled' : '']" :href="run.link+'/jobs/'+jobIndex+'/logs'" target="_blank"> <i class="icon"><SvgIcon name="octicon-download"/></i> @@ -543,7 +572,7 @@ export function initRepositoryActionView() { <!-- 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"/> @@ -646,6 +675,7 @@ export function initRepositoryActionView() { padding: 6px; display: flex; justify-content: space-between; + align-items: center; } .job-artifacts-list { @@ -653,10 +683,6 @@ export function initRepositoryActionView() { list-style: none; } -.job-artifacts-icon { - padding-right: 3px; -} - .job-brief-list { display: flex; flex-direction: column; @@ -689,11 +715,6 @@ export function initRepositoryActionView() { .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 { @@ -870,16 +891,6 @@ export function initRepositoryActionView() { <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; } @@ -929,7 +940,6 @@ export function initRepositoryActionView() { .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 1f5e9825ba..bbdfda41d0 100644 --- a/web_src/js/components/RepoActivityTopAuthors.vue +++ b/web_src/js/components/RepoActivityTopAuthors.vue @@ -1,20 +1,23 @@ <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', }); -// possible keys: -// * avatar_link: (...) -// * commits: (...) -// * home_link: (...) -// * login: (...) -// * name: (...) -const activityTopAuthors = window.config.pageData.repoActivityTopAuthors || []; +type ActivityAuthorData = { + avatar_link: string; + commits: number; + home_link: string; + login: string; + name: string; +} + +const activityTopAuthors: Array<ActivityAuthorData> = window.config.pageData.repoActivityTopAuthors || []; const graphPoints = computed(() => { return activityTopAuthors.map((item) => { @@ -26,7 +29,7 @@ const graphPoints = computed(() => { }); const graphAuthors = computed(() => { - return activityTopAuthors.map((item, idx) => { + return activityTopAuthors.map((item, idx: number) => { return { position: idx + 1, ...item, @@ -38,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 a5ed8b6dad..8e3a29a0e0 100644 --- a/web_src/js/components/RepoBranchTagSelector.vue +++ b/web_src/js/components/RepoBranchTagSelector.vue @@ -1,5 +1,5 @@ <script lang="ts"> -import {nextTick} from 'vue'; +import {defineComponent, nextTick} from 'vue'; import {SvgIcon} from '../svg.ts'; import {showErrorToast} from '../modules/toast.ts'; import {GET} from '../modules/fetch.ts'; @@ -17,51 +17,11 @@ type SelectedTab = 'branches' | 'tags'; type TabLoadingStates = Record<SelectedTab, '' | 'loading' | 'done'> -const sfc = { +export default defineComponent({ components: {SvgIcon}, props: { elRoot: HTMLElement, }, - computed: { - searchFieldPlaceholder() { - return this.selectedTab === 'branches' ? this.textFilterBranch : this.textFilterTag; - }, - filteredItems(): ListItem[] { - const searchTermLower = this.searchTerm.toLowerCase(); - const items = this.allItems.filter((item: ListItem) => { - const typeMatched = (this.selectedTab === 'branches' && item.refType === 'branch') || (this.selectedTab === 'tags' && item.refType === 'tag'); - if (!typeMatched) return false; - if (!this.searchTerm) return true; // match all - return item.refShortName.toLowerCase().includes(searchTermLower); - }); - - // TODO: fix this anti-pattern: side-effects-in-computed-properties - this.activeItemIndex = !items.length && this.showCreateNewRef ? 0 : -1; - return items; - }, - showNoResults() { - if (this.tabLoadingStates[this.selectedTab] !== 'done') return false; - return !this.filteredItems.length && !this.showCreateNewRef; - }, - showCreateNewRef() { - if (!this.allowCreateNewRef || !this.searchTerm) { - return false; - } - return !this.allItems.filter((item: ListItem) => { - return item.refShortName === this.searchTerm; // FIXME: not quite right here, it mixes "branch" and "tag" names - }).length; - }, - createNewRefFormActionUrl() { - return `${this.currentRepoLink}/branches/_new/${this.currentRefType}/${pathEscapeSegments(this.currentRefShortName)}`; - }, - }, - watch: { - menuVisible(visible: boolean) { - if (!visible) return; - this.focusSearchField(); - this.loadTabItems(); - }, - }, data() { const shouldShowTabBranches = this.elRoot.getAttribute('data-show-tab-branches') === 'true'; return { @@ -89,7 +49,7 @@ const sfc = { currentRepoDefaultBranch: this.elRoot.getAttribute('data-current-repo-default-branch'), currentRepoLink: this.elRoot.getAttribute('data-current-repo-link'), currentTreePath: this.elRoot.getAttribute('data-current-tree-path'), - currentRefType: this.elRoot.getAttribute('data-current-ref-type'), + 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'), @@ -102,6 +62,46 @@ const sfc = { enableFeed: this.elRoot.getAttribute('data-enable-feed') === 'true', }; }, + computed: { + searchFieldPlaceholder() { + return this.selectedTab === 'branches' ? this.textFilterBranch : this.textFilterTag; + }, + filteredItems(): ListItem[] { + const searchTermLower = this.searchTerm.toLowerCase(); + const items = this.allItems.filter((item: ListItem) => { + const typeMatched = (this.selectedTab === 'branches' && item.refType === 'branch') || (this.selectedTab === 'tags' && item.refType === 'tag'); + if (!typeMatched) return false; + if (!this.searchTerm) return true; // match all + return item.refShortName.toLowerCase().includes(searchTermLower); + }); + + // TODO: fix this anti-pattern: side-effects-in-computed-properties + this.activeItemIndex = !items.length && this.showCreateNewRef ? 0 : -1; // eslint-disable-line vue/no-side-effects-in-computed-properties + return items; + }, + showNoResults() { + if (this.tabLoadingStates[this.selectedTab] !== 'done') return false; + return !this.filteredItems.length && !this.showCreateNewRef; + }, + showCreateNewRef() { + if (!this.allowCreateNewRef || !this.searchTerm) { + return false; + } + return !this.allItems.filter((item: ListItem) => { + return item.refShortName === this.searchTerm; // FIXME: not quite right here, it mixes "branch" and "tag" names + }).length; + }, + createNewRefFormActionUrl() { + return `${this.currentRepoLink}/branches/_new/${this.currentRefType}/${pathEscapeSegments(this.currentRefShortName)}`; + }, + }, + watch: { + menuVisible(visible: boolean) { + if (!visible) return; + this.focusSearchField(); + this.loadTabItems(); + }, + }, beforeMount() { document.body.addEventListener('click', (e) => { if (this.$el.contains(e.target)) return; @@ -139,11 +139,11 @@ const sfc = { } }, createNewRef() { - this.$refs.createNewRefForm?.submit(); + (this.$refs.createNewRefForm as HTMLFormElement)?.submit(); }, focusSearchField() { nextTick(() => { - this.$refs.searchField.focus(); + (this.$refs.searchField as HTMLInputElement).focus(); }); }, getSelectedIndexInFiltered() { @@ -154,9 +154,10 @@ const sfc = { }, getActiveItem() { const el = this.$refs[`listItem${this.activeItemIndex}`]; // eslint-disable-line no-jquery/variable-pattern + // @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(); @@ -180,7 +181,7 @@ const sfc = { this.menuVisible = false; } }, - handleTabSwitch(selectedTab) { + handleTabSwitch(selectedTab: SelectedTab) { this.selectedTab = selectedTab; this.focusSearchField(); this.loadTabItems(); @@ -212,22 +213,21 @@ const sfc = { } }, }, -}; - -export default sfc; // activate IDE's Vue plugin +}); </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"> @@ -236,10 +236,10 @@ export default sfc; // activate IDE's Vue plugin </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 e79fc80d8e..754acb997d 100644 --- a/web_src/js/components/RepoContributors.vue +++ b/web_src/js/components/RepoContributors.vue @@ -1,4 +1,5 @@ <script lang="ts"> +import {defineComponent, type PropType} from 'vue'; import {SvgIcon} from '../svg.ts'; import dayjs from 'dayjs'; import { @@ -56,11 +57,11 @@ Chart.register( customEventListener, ); -export default { +export default defineComponent({ components: {ChartLine, SvgIcon}, props: { locale: { - type: Object, + type: Object as PropType<Record<string, any>>, required: true, }, repoLink: { @@ -79,16 +80,16 @@ export default { 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(); fomanticQuery('#repo-contributors').dropdown({ - onChange: (val) => { + onChange: (val: string) => { this.xAxisMin = this.xAxisStart; this.xAxisMax = this.xAxisEnd; this.type = val; @@ -98,7 +99,7 @@ export default { }, 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) @@ -157,7 +158,7 @@ export default { }, filterContributorWeeksByDateRange() { - const filteredData = {}; + const filteredData: Record<string, any> = {}; const data = this.contributorsStats; for (const key of Object.keys(data)) { const user = data[key]; @@ -195,7 +196,7 @@ export default { // 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; @@ -207,7 +208,7 @@ export default { // 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; @@ -231,8 +232,8 @@ export default { }, 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; @@ -320,7 +321,7 @@ export default { }; }, }, -}; +}); </script> <template> <div> @@ -352,12 +353,12 @@ export default { </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> @@ -374,7 +375,7 @@ export default { <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"> @@ -396,7 +397,7 @@ export default { <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..13e2753c94 --- /dev/null +++ b/web_src/js/components/ViewFileTreeStore.ts @@ -0,0 +1,44 @@ +import {reactive} from 'vue'; +import {GET} from '../modules/fetch.ts'; +import {pathEscapeSegments} from '../utils/url.ts'; +import {createElementFromHTML} from '../utils/dom.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('<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; +} |