aboutsummaryrefslogtreecommitdiffstats
path: root/web_src/js/components
diff options
context:
space:
mode:
Diffstat (limited to 'web_src/js/components')
-rw-r--r--web_src/js/components/ActionRunStatus.vue12
-rw-r--r--web_src/js/components/ActivityHeatmap.vue4
-rw-r--r--web_src/js/components/ContextPopup.vue12
-rw-r--r--web_src/js/components/DashboardRepoList.vue117
-rw-r--r--web_src/js/components/DiffCommitSelector.vue44
-rw-r--r--web_src/js/components/DiffFileList.vue60
-rw-r--r--web_src/js/components/DiffFileTree.vue90
-rw-r--r--web_src/js/components/DiffFileTreeItem.vue91
-rw-r--r--web_src/js/components/PullRequestMergeForm.vue46
-rw-r--r--web_src/js/components/RepoActionView.vue252
-rw-r--r--web_src/js/components/RepoActivityTopAuthors.vue27
-rw-r--r--web_src/js/components/RepoBranchTagSelector.vue114
-rw-r--r--web_src/js/components/RepoCodeFrequency.vue14
-rw-r--r--web_src/js/components/RepoContributors.vue37
-rw-r--r--web_src/js/components/RepoRecentCommits.vue12
-rw-r--r--web_src/js/components/ScopedAccessTokenSelector.vue81
-rw-r--r--web_src/js/components/ViewFileTree.vue38
-rw-r--r--web_src/js/components/ViewFileTreeItem.vue128
-rw-r--r--web_src/js/components/ViewFileTreeStore.ts44
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)">&nbsp;</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;
+}