diff options
Diffstat (limited to 'web_src/js/components')
-rw-r--r-- | web_src/js/components/ActivityHeatmap.vue | 4 | ||||
-rw-r--r-- | web_src/js/components/ContextPopup.vue | 12 | ||||
-rw-r--r-- | web_src/js/components/DashboardRepoList.vue | 40 | ||||
-rw-r--r-- | web_src/js/components/DiffFileTree.vue | 2 | ||||
-rw-r--r-- | web_src/js/components/DiffFileTreeItem.vue | 4 | ||||
-rw-r--r-- | web_src/js/components/PullRequestMergeForm.vue | 40 | ||||
-rw-r--r-- | web_src/js/components/RepoActionView.vue | 5 | ||||
-rw-r--r-- | web_src/js/components/RepoActivityTopAuthors.vue | 8 | ||||
-rw-r--r-- | web_src/js/components/RepoCodeFrequency.vue | 12 | ||||
-rw-r--r-- | web_src/js/components/RepoContributors.vue | 2 | ||||
-rw-r--r-- | web_src/js/components/RepoRecentCommits.vue | 10 | ||||
-rw-r--r-- | web_src/js/components/ViewFileTree.vue | 50 | ||||
-rw-r--r-- | web_src/js/components/ViewFileTreeItem.vue | 102 | ||||
-rw-r--r-- | web_src/js/components/ViewFileTreeStore.ts | 45 |
14 files changed, 161 insertions, 175 deletions
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 fc6a7bd281..e938814ec6 100644 --- a/web_src/js/components/DashboardRepoList.vue +++ b/web_src/js/components/DashboardRepoList.vue @@ -6,7 +6,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts'; const {appSubUrl, assetUrlPrefix, pageData} = window.config; -type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning'; +type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning' | 'skipped'; type CommitStatusMap = { [status in CommitStatus]: { @@ -22,6 +22,7 @@ const commitStatus: CommitStatusMap = { error: {name: 'gitea-exclamation', color: 'red'}, failure: {name: 'octicon-x', color: 'red'}, warning: {name: 'gitea-exclamation', color: 'yellow'}, + skipped: {name: 'octicon-skip', color: 'grey'}, }; export default defineComponent({ @@ -38,7 +39,7 @@ export default defineComponent({ return { tab, repos: [], - reposTotalCount: 0, + reposTotalCount: null, reposFilter, archivedFilter, privateFilter, @@ -112,9 +113,6 @@ export default defineComponent({ const el = document.querySelector('#dashboard-repo-list'); this.changeReposFilter(this.reposFilter); fomanticQuery(el.querySelector('.ui.dropdown')).dropdown(); - nextTick(() => { - this.$refs.search?.focus(); - }); this.textArchivedFilterTitles = { 'archived': this.textShowOnlyArchived, @@ -218,7 +216,9 @@ export default defineComponent({ this.searchRepos(); }, - changePage(page: number) { + async changePage(page: number) { + if (this.isLoading) return; + this.page = page; if (this.page > this.finalPage) { this.page = this.finalPage; @@ -228,7 +228,7 @@ export default defineComponent({ } this.repos = []; this.counts[`${this.reposFilter}:${this.archivedFilter}:${this.privateFilter}`] = 0; - this.searchRepos(); + await this.searchRepos(); }, async searchRepos() { @@ -240,12 +240,20 @@ export default defineComponent({ let response, json; try { + const firstLoad = this.reposTotalCount === null; if (!this.reposTotalCount) { const totalCountSearchURL = `${this.subUrl}/repo/search?count_only=1&uid=${this.uid}&team_id=${this.teamId}&q=&page=1&mode=`; response = await GET(totalCountSearchURL); this.reposTotalCount = 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 { @@ -298,7 +306,7 @@ export default defineComponent({ return commitStatus[status].color; }, - reposFilterKeyControl(e: KeyboardEvent) { + async reposFilterKeyControl(e: KeyboardEvent) { switch (e.key) { case 'Enter': document.querySelector<HTMLAnchorElement>('.repo-owner-name-list li.active a')?.click(); @@ -307,7 +315,7 @@ export default defineComponent({ if (this.activeIndex > 0) { this.activeIndex--; } else if (this.page > 1) { - this.changePage(this.page - 1); + await this.changePage(this.page - 1); this.activeIndex = this.searchLimit - 1; } break; @@ -316,17 +324,17 @@ export default defineComponent({ this.activeIndex++; } else if (this.page < this.finalPage) { this.activeIndex = 0; - this.changePage(this.page + 1); + await this.changePage(this.page + 1); } break; case 'ArrowRight': if (this.page < this.finalPage) { - this.changePage(this.page + 1); + await this.changePage(this.page + 1); } break; case 'ArrowLeft': if (this.page > 1) { - this.changePage(this.page - 1); + await this.changePage(this.page - 1); } break; } @@ -347,7 +355,7 @@ export default defineComponent({ <h4 class="ui top attached header tw-flex tw-items-center"> <div class="tw-flex-1 tw-flex tw-items-center"> {{ textMyRepos }} - <span class="ui grey label tw-ml-2">{{ reposTotalCount }}</span> + <span v-if="reposTotalCount" class="ui grey label tw-ml-2">{{ reposTotalCount }}</span> </div> <a class="tw-flex tw-items-center muted" :href="subUrl + '/repo/create' + (isOrganization ? '?org=' + organizationId : '')" :data-tooltip-content="textNewRepo"> <svg-icon name="octicon-plus"/> @@ -418,7 +426,7 @@ export default defineComponent({ </div> <div v-if="repos.length" class="ui attached table segment tw-rounded-b"> <ul class="repo-owner-name-list"> - <li class="tw-flex tw-items-center tw-py-2" v-for="repo, index in repos" :class="{'active': index === activeIndex}" :key="repo.id"> + <li class="tw-flex tw-items-center tw-py-2" v-for="(repo, index) in repos" :class="{'active': index === activeIndex}" :key="repo.id"> <a class="repo-list-link muted" :href="repo.link"> <svg-icon :name="repoIcon(repo)" :size="16" class="repo-list-icon"/> <div class="text truncate">{{ repo.full_name }}</div> @@ -426,7 +434,7 @@ export default defineComponent({ <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="'tw-ml-2 commit-status icon text ' + statusColor(repo.latest_commit_status_state)" :size="16"/> </a> diff --git a/web_src/js/components/DiffFileTree.vue b/web_src/js/components/DiffFileTree.vue index 5426a672cb..981d10c1c1 100644 --- a/web_src/js/components/DiffFileTree.vue +++ b/web_src/js/components/DiffFileTree.vue @@ -60,8 +60,8 @@ function updateState(visible: boolean) { </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 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 24bf590082..f15f093ff8 100644 --- a/web_src/js/components/DiffFileTreeItem.vue +++ b/web_src/js/components/DiffFileTreeItem.vue @@ -1,6 +1,6 @@ <script lang="ts" setup> import {SvgIcon, type SvgName} from '../svg.ts'; -import {ref} from 'vue'; +import {shallowRef} from 'vue'; import {type DiffStatus, type DiffTreeEntry, diffTreeStore} from '../modules/diff-file.ts'; const props = defineProps<{ @@ -8,7 +8,7 @@ const props = defineProps<{ }>(); const store = diffTreeStore(); -const collapsed = ref(props.item.IsViewed); +const collapsed = shallowRef(props.item.IsViewed); function getIconForDiffStatus(pType: DiffStatus) { const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = { diff --git a/web_src/js/components/PullRequestMergeForm.vue b/web_src/js/components/PullRequestMergeForm.vue index 4f291f5ca1..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: any) => 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: any, msd: any) => 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: any) => e.allowed && e.name === mergeForm.value.defaultMergeStyle)?.name; - if (!mergeStyle) mergeStyle = mergeForm.value.mergeStyles.find((e: any) => e.allowed)?.name; - switchMergeStyle(mergeStyle, !mergeForm.value.canMergeNow); + let mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed && e.name === mergeForm.defaultMergeStyle)?.name; + if (!mergeStyle) mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed)?.name; + switchMergeStyle(mergeStyle, !mergeForm.canMergeNow); document.addEventListener('mouseup', hideMergeStyleMenu); }); @@ -63,7 +63,7 @@ function hideMergeStyleMenu() { function toggleActionForm(show: boolean) { showActionForm.value = show; if (!show) return; - deleteBranchAfterMerge.value = mergeForm.value.defaultDeleteBranchAfterMerge; + deleteBranchAfterMerge.value = mergeForm.defaultDeleteBranchAfterMerge; mergeTitleFieldValue.value = mergeStyleDetail.value.mergeTitleFieldText; mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText; } @@ -74,7 +74,7 @@ function switchMergeStyle(name: string, autoMerge = false) { } function clearMergeMessage() { - mergeMessageFieldValue.value = mergeForm.value.defaultMergeMessage; + mergeMessageFieldValue.value = mergeForm.defaultMergeMessage; } </script> diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue index af300622b4..2eb2211269 100644 --- a/web_src/js/components/RepoActionView.vue +++ b/web_src/js/components/RepoActionView.vue @@ -177,7 +177,7 @@ export default defineComponent({ }, }, - async mounted() { // eslint-disable-line @typescript-eslint/no-misused-promises + async mounted() { // load job data and then auto-reload periodically // need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener await this.loadJob(); @@ -439,7 +439,8 @@ export default defineComponent({ }); </script> <template> - <div class="ui container action-view-container"> + <!-- make the view container full width to make users easier to read logs --> + <div class="ui fluid container"> <div class="action-view-header"> <div class="action-info-summary"> <div class="action-info-summary-title"> diff --git a/web_src/js/components/RepoActivityTopAuthors.vue b/web_src/js/components/RepoActivityTopAuthors.vue index 77b85bd7e2..bbdfda41d0 100644 --- a/web_src/js/components/RepoActivityTopAuthors.vue +++ b/web_src/js/components/RepoActivityTopAuthors.vue @@ -1,9 +1,9 @@ <script lang="ts" setup> // @ts-expect-error - module exports no types import {VueBarGraph} from 'vue-bar-graph'; -import {computed, onMounted, ref} from 'vue'; +import {computed, onMounted, shallowRef, useTemplateRef} from 'vue'; -const colors = ref({ +const colors = shallowRef({ barColor: 'green', textColor: 'black', textAltColor: 'white', @@ -41,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/RepoCodeFrequency.vue b/web_src/js/components/RepoCodeFrequency.vue index f04fc065b6..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 } diff --git a/web_src/js/components/RepoContributors.vue b/web_src/js/components/RepoContributors.vue index 1006ea30bb..754acb997d 100644 --- a/web_src/js/components/RepoContributors.vue +++ b/web_src/js/components/RepoContributors.vue @@ -397,7 +397,7 @@ export default defineComponent({ <div class="ui top attached header tw-flex tw-flex-1"> <b class="ui right">#{{ index + 1 }}</b> <a :href="contributor.home_link"> - <img class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link" alt=""> + <img loading="lazy" class="ui avatar tw-align-middle" height="40" width="40" :src="contributor.avatar_link" alt=""> </a> <div class="tw-ml-2"> <a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a> diff --git a/web_src/js/components/RepoRecentCommits.vue b/web_src/js/components/RepoRecentCommits.vue index 1b3d8fd459..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 } diff --git a/web_src/js/components/ViewFileTree.vue b/web_src/js/components/ViewFileTree.vue index c692142792..1f90f92586 100644 --- a/web_src/js/components/ViewFileTree.vue +++ b/web_src/js/components/ViewFileTree.vue @@ -1,11 +1,9 @@ <script lang="ts" setup> import ViewFileTreeItem from './ViewFileTreeItem.vue'; -import {onMounted, ref} from 'vue'; -import {pathEscapeSegments} from '../utils/url.ts'; -import {GET} from '../modules/fetch.ts'; -import {createElementFromHTML} from '../utils/dom.ts'; +import {onMounted, useTemplateRef} from 'vue'; +import {createViewFileTreeStore} from './ViewFileTreeStore.ts'; -const elRoot = ref<HTMLElement | null>(null); +const elRoot = useTemplateRef('elRoot'); const props = defineProps({ repoLink: {type: String, required: true}, @@ -13,52 +11,20 @@ const props = defineProps({ currentRefNameSubURL: {type: String, required: true}, }); -const files = ref([]); -const selectedItem = ref(''); - -async function 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 function 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 function navigateTreeView(treePath: string) { - const url = `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`; - window.history.pushState({treePath, url}, null, url); - selectedItem.value = treePath; - await loadViewContent(url); -} - +const store = createViewFileTreeStore(props); onMounted(async () => { - selectedItem.value = props.treePath; - files.value = await loadChildren('', props.treePath); + store.rootFiles = await store.loadChildren('', props.treePath); elRoot.value.closest('.is-loading')?.classList?.remove('is-loading'); window.addEventListener('popstate', (e) => { - selectedItem.value = e.state?.treePath || ''; - if (e.state?.url) loadViewContent(e.state.url); + store.selectedItem = e.state?.treePath || ''; + if (e.state?.url) store.loadViewContent(e.state.url); }); }); </script> <template> <div class="view-file-tree-items" ref="elRoot"> - <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often --> - <ViewFileTreeItem v-for="item in files" :key="item.name" :item="item" :selected-item="selectedItem" :navigate-view-content="navigateTreeView" :load-children="loadChildren"/> + <ViewFileTreeItem v-for="item in store.rootFiles" :key="item.name" :item="item" :store="store"/> </div> </template> diff --git a/web_src/js/components/ViewFileTreeItem.vue b/web_src/js/components/ViewFileTreeItem.vue index c39fa1f4ae..5173c7eb46 100644 --- a/web_src/js/components/ViewFileTreeItem.vue +++ b/web_src/js/components/ViewFileTreeItem.vue @@ -1,10 +1,12 @@ <script lang="ts" setup> import {SvgIcon} from '../svg.ts'; -import {ref} from 'vue'; +import {isPlainClick} from '../utils/dom.ts'; +import {shallowRef} from 'vue'; +import {type createViewFileTreeStore} from './ViewFileTreeStore.ts'; type Item = { entryName: string; - entryMode: string; + entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown'; entryIcon: string; entryIconOpen: string; fullPath: string; @@ -14,103 +16,67 @@ type Item = { const props = defineProps<{ item: Item, - navigateViewContent:(treePath: string) => void, - loadChildren:(treePath: string, subPath?: string) => Promise<Item[]>, - selectedItem?: string, + store: ReturnType<typeof createViewFileTreeStore> }>(); -const isLoading = ref(false); -const children = ref(props.item.children); -const collapsed = ref(!props.item.children); +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 && props.loadChildren) { + if (!collapsed.value) { isLoading.value = true; try { - children.value = await props.loadChildren(props.item.fullPath); + children.value = await store.loadChildren(props.item.fullPath); } finally { isLoading.value = false; } } }; -const doLoadDirContent = () => { - doLoadChildren(); - props.navigateViewContent(props.item.fullPath); +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); }; -const doLoadFileContent = () => { - props.navigateViewContent(props.item.fullPath); -}; - -const doGotoSubModule = () => { - location.href = props.item.submoduleUrl; -}; </script> -<!--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> - <div - v-if="item.entryMode === 'commit'" class="tree-item type-submodule" - :title="item.entryName" - @click.stop="doGotoSubModule" - > - <!-- submodule --> - <div class="item-content"> - <!-- eslint-disable-next-line vue/no-v-html --> - <span class="tw-contents" v-html="item.entryIcon"/> - <span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span> - </div> - </div> - <div - v-else-if="item.entryMode === 'symlink'" class="tree-item type-symlink" - :class="{'selected': selectedItem === item.fullPath}" + <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" - @click.stop="doLoadFileContent" + :href="store.buildTreePathWebUrl(item.fullPath)" + @click.stop="onItemClick" > - <!-- symlink --> - <div class="item-content"> - <!-- eslint-disable-next-line vue/no-v-html --> - <span class="tw-contents" v-html="item.entryIcon"/> - <span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span> - </div> - </div> - <div - v-else-if="item.entryMode !== 'tree'" class="tree-item type-file" - :class="{'selected': selectedItem === item.fullPath}" - :title="item.entryName" - @click.stop="doLoadFileContent" - > - <!-- file --> - <div class="item-content"> - <!-- eslint-disable-next-line vue/no-v-html --> - <span class="tw-contents" v-html="item.entryIcon"/> - <span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span> - </div> - </div> - <div - v-else class="tree-item type-directory" - :class="{'selected': selectedItem === item.fullPath}" - :title="item.entryName" - @click.stop="doLoadDirContent" - > - <!-- directory --> - <div class="item-toggle"> + <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="doLoadChildren"/> + <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> - </div> + </a> <div v-if="children?.length" v-show="!collapsed" class="sub-items"> - <ViewFileTreeItem v-for="childItem in children" :key="childItem.entryName" :item="childItem" :selected-item="selectedItem" :navigate-view-content="navigateViewContent" :load-children="loadChildren"/> + <ViewFileTreeItem v-for="childItem in children" :key="childItem.entryName" :item="childItem" :store="store"/> </div> </template> + <style scoped> .sub-items { display: flex; diff --git a/web_src/js/components/ViewFileTreeStore.ts b/web_src/js/components/ViewFileTreeStore.ts new file mode 100644 index 0000000000..e2155bd58a --- /dev/null +++ b/web_src/js/components/ViewFileTreeStore.ts @@ -0,0 +1,45 @@ +import {reactive} from 'vue'; +import {GET} from '../modules/fetch.ts'; +import {pathEscapeSegments} from '../utils/url.ts'; +import {createElementFromHTML} from '../utils/dom.ts'; +import {html} from '../utils/html.ts'; + +export function createViewFileTreeStore(props: { repoLink: string, treePath: string, currentRefNameSubURL: string}) { + const store = reactive({ + rootFiles: [], + selectedItem: props.treePath, + + async loadChildren(treePath: string, subPath: string = '') { + const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`); + const json = await response.json(); + const poolSvgs = []; + for (const [svgId, svgContent] of Object.entries(json.renderedIconPool ?? {})) { + if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent); + } + if (poolSvgs.length) { + const svgContainer = createElementFromHTML(html`<div class="global-svg-icon-pool tw-hidden"></div>`); + svgContainer.innerHTML = poolSvgs.join(''); + document.body.append(svgContainer); + } + return json.fileTreeNodes ?? null; + }, + + async loadViewContent(url: string) { + url = url.includes('?') ? url.replace('?', '?only_content=true') : `${url}?only_content=true`; + const response = await GET(url); + document.querySelector('.repo-view-content').innerHTML = await response.text(); + }, + + async navigateTreeView(treePath: string) { + const url = store.buildTreePathWebUrl(treePath); + window.history.pushState({treePath, url}, null, url); + store.selectedItem = treePath; + await store.loadViewContent(url); + }, + + buildTreePathWebUrl(treePath: string) { + return `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`; + }, + }); + return store; +} |