{{template "repo/header" .}} -
+
{{template "base/alert" .}} {{if .Repository.IsArchived}} @@ -16,123 +22,13 @@ {{template "repo/code/recently_pushed_new_branches" .}} - {{$treeNamesLen := len .TreeNames}} - {{$isTreePathRoot := eq $treeNamesLen 0}} - {{$showSidebar := and $isTreePathRoot (not .HideRepoInfo) (not .IsBlame)}} -
-
- {{template "repo/sub_menu" .}} -
-
- {{- /* for repo home (default branch) and /owner/repo/src/branch/the-name */ -}} - {{- $branchDropdownCurrentRefType := "branch" -}} - {{- $branchDropdownCurrentRefShortName := .BranchName -}} - {{- if .IsViewTag -}} - {{- /* for /owner/repo/src/tag/the-name */ -}} - {{- $branchDropdownCurrentRefType = "tag" -}} - {{- $branchDropdownCurrentRefShortName = .TagName -}} - {{- else if .IsViewCommit -}} - {{- /* for /owner/repo/src/commit/000000 */ -}} - {{- $branchDropdownCurrentRefType = "commit" -}} - {{- $branchDropdownCurrentRefShortName = ShortSha .CommitID -}} - {{- end -}} - {{- template "repo/branch_dropdown" dict - "Repository" .Repository - "ShowTabBranches" true - "ShowTabTags" true - "CurrentRefType" $branchDropdownCurrentRefType - "CurrentRefShortName" $branchDropdownCurrentRefShortName - "CurrentTreePath" .TreePath - "RefLinkTemplate" "{RepoLink}/src/{RefType}/{RefShortName}/{TreePath}" - "AllowCreateNewRef" .CanCreateBranch - "ShowViewAllRefsEntry" true - -}} - {{if and .CanCompareOrPull .IsViewBranch (not .Repository.IsArchived)}} - {{$cmpBranch := ""}} - {{if ne .Repository.ID .BaseRepo.ID}} - {{$cmpBranch = printf "%s/%s:" (.Repository.OwnerName|PathEscape) (.Repository.Name|PathEscape)}} - {{end}} - {{$cmpBranch = print $cmpBranch (.BranchName|PathEscapeSegments)}} - {{$compareLink := printf "%s/compare/%s...%s" .BaseRepo.Link (.BaseRepo.DefaultBranch|PathEscapeSegments) $cmpBranch}} - - {{svg "octicon-git-pull-request"}} - - {{end}} - - - {{if $isTreePathRoot}} - {{ctx.Locale.Tr "repo.find_file.go_to_file"}} - {{end}} - - {{if and .CanWriteCode .IsViewBranch (not .Repository.IsMirror) (not .Repository.IsArchived) (not .IsViewFile)}} - - {{end}} - - {{if and $isTreePathRoot .Repository.IsTemplate}} - - {{ctx.Locale.Tr "repo.use_template"}} - - {{end}} - - {{if not $isTreePathRoot}} - {{$treeNameIdxLast := Eval $treeNamesLen "-" 1}} - - {{StringUtils.EllipsisString .Repository.Name 30}} - {{- range $i, $v := .TreeNames -}} - / - {{- if eq $i $treeNameIdxLast -}} - {{$v}} - - {{- else -}} - {{$p := index $.Paths $i}}{{$v}} - {{- end -}} - {{- end -}} - - {{end}} -
+
+ {{if $hasTreeSidebar}} +
{{template "repo/view_file_tree_sidebar" .}}
+ {{end}} -
- - {{if $isTreePathRoot}} - {{template "repo/clone_panel" .}} - {{end}} - {{if and (not $isTreePathRoot) (not .IsViewFile) (not .IsBlame)}}{{/* IsViewDirectory (not home), TODO: split the templates, avoid using "if" tricks */}} - - {{svg "octicon-history" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.file_history"}} - - {{end}} -
-
- {{if .IsViewFile}} - {{template "repo/view_file" .}} - {{else if .IsBlame}} - {{template "repo/blame" .}} - {{else}}{{/* IsViewDirectory */}} - {{if $isTreePathRoot}} - {{template "repo/code/upstream_diverging_info" .}} - {{end}} - {{template "repo/view_list" .}} - {{if and .ReadmeExist (or .IsMarkup .IsPlainText)}} - {{template "repo/view_file" .}} - {{end}} - {{end}} +
+ {{template "repo/home_content" .}}
{{if $showSidebar}} diff --git a/templates/repo/home_branch_dropdown.tmpl b/templates/repo/home_branch_dropdown.tmpl new file mode 100644 index 0000000000000..f720100c7d273 --- /dev/null +++ b/templates/repo/home_branch_dropdown.tmpl @@ -0,0 +1,12 @@ +{{template "repo/branch_dropdown" dict + "Repository" .ctxData.Repository + "ShowTabBranches" true + "ShowTabTags" true + "CurrentRefType" .ctxData.RefFullName.RefType + "CurrentRefShortName" .ctxData.RefFullName.ShortName + "CurrentTreePath" .ctxData.TreePath + "RefLinkTemplate" "{RepoLink}/src/{RefTypeNameSubURL}/{TreePath}" + "AllowCreateNewRef" .ctxData.CanCreateBranch + "ShowViewAllRefsEntry" true + "ContainerClasses" .containerClasses +}} diff --git a/templates/repo/home_content.tmpl b/templates/repo/home_content.tmpl new file mode 100644 index 0000000000000..d8e1890d43473 --- /dev/null +++ b/templates/repo/home_content.tmpl @@ -0,0 +1,101 @@ +{{$treeNamesLen := len .TreeNames}} +{{$isTreePathRoot := eq $treeNamesLen 0}} +{{$showSidebar := and $isTreePathRoot (not .HideRepoInfo) (not .IsBlame)}} +{{$hasTreeSidebar := not $isTreePathRoot}} +{{$showTreeSidebar := .RepoPreferences.ShowFileViewTreeSidebar}} + +{{template "repo/sub_menu" .}} +
+
+ {{if $hasTreeSidebar}} + + {{end}} + {{template "repo/home_branch_dropdown" (dict "ctxData" .)}} + {{if and .CanCompareOrPull .IsViewBranch (not .Repository.IsArchived)}} + {{$cmpBranch := ""}} + {{if ne .Repository.ID .BaseRepo.ID}} + {{$cmpBranch = printf "%s/%s:" (.Repository.OwnerName|PathEscape) (.Repository.Name|PathEscape)}} + {{end}} + {{$cmpBranch = print $cmpBranch (.BranchName|PathEscapeSegments)}} + {{$compareLink := printf "%s/compare/%s...%s" .BaseRepo.Link (.BaseRepo.DefaultBranch|PathEscapeSegments) $cmpBranch}} + + {{svg "octicon-git-pull-request"}} + + {{end}} + + + {{if $isTreePathRoot}} + {{ctx.Locale.Tr "repo.find_file.go_to_file"}} + {{end}} + + {{if and .CanWriteCode .IsViewBranch (not .Repository.IsMirror) (not .Repository.IsArchived) (not .IsViewFile)}} + + {{end}} + + {{if and $isTreePathRoot .Repository.IsTemplate}} + + {{ctx.Locale.Tr "repo.use_template"}} + + {{end}} + + {{if not $isTreePathRoot}} + {{$treeNameIdxLast := Eval $treeNamesLen "-" 1}} + + {{StringUtils.EllipsisString .Repository.Name 30}} + {{- range $i, $v := .TreeNames -}} + / + {{- if eq $i $treeNameIdxLast -}} + {{$v}} + + {{- else -}} + {{$p := index $.Paths $i}}{{$v}} + {{- end -}} + {{- end -}} + + {{end}} +
+ +
+ + {{if $isTreePathRoot}} + {{template "repo/clone_panel" .}} + {{end}} + {{if and (not $isTreePathRoot) (not .IsViewFile) (not .IsBlame)}}{{/* IsViewDirectory (not home), TODO: split the templates, avoid using "if" tricks */}} + + {{svg "octicon-history" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.file_history"}} + + {{end}} +
+
+{{if .IsViewFile}} + {{template "repo/view_file" .}} +{{else if .IsBlame}} + {{template "repo/blame" .}} +{{else}}{{/* IsViewDirectory */}} + {{if $isTreePathRoot}} + {{template "repo/code/upstream_diverging_info" .}} + {{end}} + {{template "repo/view_list" .}} + {{if and .ReadmeExist (or .IsMarkup .IsPlainText)}} + {{template "repo/view_file" .}} + {{end}} +{{end}} diff --git a/templates/repo/view_file_tree_sidebar.tmpl b/templates/repo/view_file_tree_sidebar.tmpl new file mode 100644 index 0000000000000..c2ee6336c393b --- /dev/null +++ b/templates/repo/view_file_tree_sidebar.tmpl @@ -0,0 +1,17 @@ +
+ + +
+
+
+
diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index 65005e2263537..db171e7974b29 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -50,6 +50,65 @@ } } +.repo-grid-tree-sidebar { + display: grid; + grid-template-columns: 300px auto; + grid-template-rows: auto auto 1fr; +} + +.repo-grid-tree-sidebar .repo-home-filelist { + min-width: 0; + grid-column: 2; + grid-row: 1 / 4; + margin-left: 1rem; +} + +#view-file-tree.is-loading { + aspect-ratio: 5.415; /* the size is about 790 x 145 */ +} + +.repo-grid-tree-sidebar .repo-view-file-tree-sidebar { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 100vh; + overflow: hidden; +} + +.repo-grid-tree-sidebar .view-file-tree-sidebar-top { + display: flex; + flex-direction: column; + gap: 0.25em; +} + +.repo-grid-tree-sidebar .view-file-tree-sidebar-top .button { + padding: 6px 10px !important; + height: 30px; + flex-shrink: 0; + margin: 0; +} + +.repo-grid-tree-sidebar .view-file-tree-sidebar-top .sidebar-ref { + display: flex; + gap: 0.25em; +} + +.repo-grid-tree-sidebar .view-file-tree-sidebar-bottom { + flex: 1; + overflow: auto; +} + +.repo-grid-tree-sidebar .repo-button-row { + margin-top: 0 !important; +} + +@media (max-width: 767.98px) { + .repo-grid-tree-sidebar { + grid-template-columns: auto; + grid-template-rows: auto auto auto; + } +} + .language-stats { display: flex; gap: 2px; diff --git a/web_src/js/components/ViewFileTree.vue b/web_src/js/components/ViewFileTree.vue new file mode 100644 index 0000000000000..3337f6e9e553e --- /dev/null +++ b/web_src/js/components/ViewFileTree.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/web_src/js/components/ViewFileTreeItem.vue b/web_src/js/components/ViewFileTreeItem.vue new file mode 100644 index 0000000000000..cb94c1f7f1d06 --- /dev/null +++ b/web_src/js/components/ViewFileTreeItem.vue @@ -0,0 +1,115 @@ + + + + diff --git a/web_src/js/features/common-button.ts b/web_src/js/features/common-button.ts index 3162557b9b21f..c0cd8ee359db9 100644 --- a/web_src/js/features/common-button.ts +++ b/web_src/js/features/common-button.ts @@ -155,12 +155,16 @@ function onShowModalClick(e) { } export function initGlobalButtons(): void { + initTargetButtons(document as ParentNode); +} + +export function initTargetButtons(target: ParentNode): void { // There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form. // However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission. // There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content") - addDelegatedEventListener(document, 'click', 'form button.ui.cancel.button', (_ /* el */, e) => e.preventDefault()); + queryElems(target, 'form button.ui.cancel.button', (el) => el.addEventListener('click', (e) => e.preventDefault())); - queryElems(document, '.show-panel', (el) => el.addEventListener('click', onShowPanelClick)); - queryElems(document, '.hide-panel', (el) => el.addEventListener('click', onHidePanelClick)); - queryElems(document, '.show-modal', (el) => el.addEventListener('click', onShowModalClick)); + queryElems(target, '.show-panel', (el) => el.addEventListener('click', onShowPanelClick)); + queryElems(target, '.hide-panel', (el) => el.addEventListener('click', onHidePanelClick)); + queryElems(target, '.show-modal', (el) => el.addEventListener('click', onShowModalClick)); } diff --git a/web_src/js/features/common-page.ts b/web_src/js/features/common-page.ts index 56c5915b6dbf8..058702785d072 100644 --- a/web_src/js/features/common-page.ts +++ b/web_src/js/features/common-page.ts @@ -28,8 +28,13 @@ export function initFootLanguageMenu() { } export function initGlobalDropdown() { + initTargetDropdown(document.body); +} + +export function initTargetDropdown(target: Element) { // Semantic UI modules. - const $uiDropdowns = fomanticQuery('.ui.dropdown'); + const $target = fomanticQuery(target); + const $uiDropdowns = $target.find('.ui.dropdown'); // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code. $uiDropdowns.filter(':not(.custom)').dropdown({hideDividers: 'empty'}); diff --git a/web_src/js/features/copycontent.ts b/web_src/js/features/copycontent.ts index af867463b2998..554c9b7f47535 100644 --- a/web_src/js/features/copycontent.ts +++ b/web_src/js/features/copycontent.ts @@ -6,7 +6,11 @@ import {GET} from '../modules/fetch.ts'; const {i18n} = window.config; export function initCopyContent() { - const btn = document.querySelector('#copy-content'); + initTargetCopyContent(document); +} + +export function initTargetCopyContent(target: ParentNode) { + const btn = target.querySelector('#copy-content'); if (!btn || btn.classList.contains('disabled')) return; btn.addEventListener('click', async () => { diff --git a/web_src/js/features/repo-commit.ts b/web_src/js/features/repo-commit.ts index 56493443d9068..3d19905934a35 100644 --- a/web_src/js/features/repo-commit.ts +++ b/web_src/js/features/repo-commit.ts @@ -2,7 +2,11 @@ import {createTippy} from '../modules/tippy.ts'; import {toggleElem} from '../utils/dom.ts'; export function initRepoEllipsisButton() { - for (const button of document.querySelectorAll('.js-toggle-commit-body')) { + initTargetRepoEllipsisButton(document); +} + +export function initTargetRepoEllipsisButton(target: ParentNode) { + for (const button of target.querySelectorAll('.js-toggle-commit-body')) { button.addEventListener('click', function (e) { e.preventDefault(); const expanded = this.getAttribute('aria-expanded') === 'true'; diff --git a/web_src/js/features/repo-legacy.ts b/web_src/js/features/repo-legacy.ts index 33f02be865eff..cf51585c57f2c 100644 --- a/web_src/js/features/repo-legacy.ts +++ b/web_src/js/features/repo-legacy.ts @@ -21,12 +21,6 @@ import {initRepoNew} from './repo-new.ts'; import {createApp} from 'vue'; import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue'; -function initRepoBranchTagSelector(selector: string) { - for (const elRoot of document.querySelectorAll(selector)) { - createApp(RepoBranchTagSelector, {elRoot}).mount(elRoot); - } -} - export function initBranchSelectorTabs() { const elSelectBranches = document.querySelectorAll('.ui.dropdown.select-branch'); for (const elSelectBranch of elSelectBranches) { @@ -39,11 +33,17 @@ export function initBranchSelectorTabs() { } } +export function initTargetRepoBranchTagSelector(target: ParentNode, selector: string = '.js-branch-tag-selector') { + for (const elRoot of target.querySelectorAll(selector)) { + createApp(RepoBranchTagSelector, {elRoot}).mount(elRoot); + } +} + export function initRepository() { const pageContent = document.querySelector('.page-content.repository'); if (!pageContent) return; - initRepoBranchTagSelector('.js-branch-tag-selector'); + initTargetRepoBranchTagSelector(document); initRepoCommentFormAndSidebar(); // Labels diff --git a/web_src/js/features/repo-view-file-tree-sidebar.ts b/web_src/js/features/repo-view-file-tree-sidebar.ts new file mode 100644 index 0000000000000..7fc97137134ce --- /dev/null +++ b/web_src/js/features/repo-view-file-tree-sidebar.ts @@ -0,0 +1,100 @@ +import {createApp, ref} from 'vue'; +import {toggleElem} from '../utils/dom.ts'; +import {GET, PUT} from '../modules/fetch.ts'; +import ViewFileTree from '../components/ViewFileTree.vue'; +import {initTargetRepoBranchTagSelector} from './repo-legacy.ts'; +import {initTargetDropdown} from './common-page.ts'; +import {initTargetRepoEllipsisButton} from './repo-commit.ts'; +import {initTargetPdfViewer} from '../render/pdf.ts'; +import {initTargetButtons} from './common-button.ts'; +import {initTargetCopyContent} from './copycontent.ts'; + +async function toggleSidebar(visibility, isSigned) { + const sidebarEl = document.querySelector('.repo-view-file-tree-sidebar'); + const showBtnEl = document.querySelector('.show-tree-sidebar-button'); + const containerClassList = sidebarEl.parentElement.classList; + containerClassList.toggle('repo-grid-tree-sidebar', visibility); + containerClassList.toggle('repo-grid-filelist-only', !visibility); + toggleElem(sidebarEl, visibility); + toggleElem(showBtnEl, !visibility); + + if (!isSigned) return; + + // save to session + await PUT('/repo/preferences', { + data: { + show_file_view_tree_sidebar: visibility, + }, + }); +} + +async function loadChildren(item, recursive?: boolean) { + const fileTree = document.querySelector('#view-file-tree'); + const apiBaseUrl = fileTree.getAttribute('data-api-base-url'); + const refType = fileTree.getAttribute('data-current-ref-type'); + const refName = fileTree.getAttribute('data-current-ref-short-name'); + const response = await GET(`${apiBaseUrl}/tree/${item ? item.path : ''}?ref_type=${refType}&ref_name=${refName}&recursive=${recursive ?? false}`); + const json = await response.json(); + if (json instanceof Array) { + return json.map((i) => ({ + name: i.name, + type: i.type, + path: i.path, + children: i.children, + })); + } + return null; +} + +async function loadContent() { + // load content by path (content based on home_content.tmpl) + const response = await GET(`${window.location.href}?only_content=true`); + const contentEl = document.querySelector('.repo-home-filelist'); + contentEl.innerHTML = await response.text(); + reloadContentScript(contentEl); +} + +function reloadContentScript(contentEl: Element) { + contentEl.querySelector('.show-tree-sidebar-button').addEventListener('click', () => { + toggleSidebar(true, document.querySelector('.repo-view-file-tree-sidebar').hasAttribute('data-is-signed')); + }); + initTargetButtons(contentEl); + initTargetDropdown(contentEl); + initTargetPdfViewer(contentEl); + initTargetRepoBranchTagSelector(contentEl); + initTargetRepoEllipsisButton(contentEl); + initTargetCopyContent(contentEl); +} + +export async function initViewFileTreeSidebar() { + const sidebarElement = document.querySelector('.repo-view-file-tree-sidebar'); + if (!sidebarElement) return; + + const isSigned = sidebarElement.hasAttribute('data-is-signed'); + + document.querySelector('.hide-tree-sidebar-button').addEventListener('click', () => { + toggleSidebar(false, isSigned); + }); + document.querySelector('.repo-home-filelist .show-tree-sidebar-button').addEventListener('click', () => { + toggleSidebar(true, isSigned); + }); + + const fileTree = document.querySelector('#view-file-tree'); + const baseUrl = fileTree.getAttribute('data-api-base-url'); + const treePath = fileTree.getAttribute('data-tree-path'); + const refType = fileTree.getAttribute('data-current-ref-type'); + const refName = fileTree.getAttribute('data-current-ref-short-name'); + const refString = (refType ? (`/${refType}`) : '') + (refName ? (`/${refName}`) : ''); + + const selectedItem = ref(treePath); + + const files = await loadChildren({path: treePath}, true); + + fileTree.classList.remove('is-loading'); + const fileTreeView = createApp(ViewFileTree, {files, selectedItem, loadChildren, loadContent: (item) => { + window.history.pushState(null, null, `${baseUrl}/src${refString}/${item.path}`); + selectedItem.value = item.path; + loadContent(); + }}); + fileTreeView.mount(fileTree); +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 022be033da25f..59bdf87c85171 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -33,6 +33,7 @@ import { } from './features/repo-issue.ts'; import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; import {initRepoTopicBar} from './features/repo-home.ts'; +import {initViewFileTreeSidebar} from './features/repo-view-file-tree-sidebar.ts'; import {initAdminCommon} from './features/admin/common.ts'; import {initRepoCodeView} from './features/repo-code.ts'; import {initSshKeyFormParser} from './features/sshkey-helper.ts'; @@ -192,6 +193,7 @@ onDomReady(() => { initRepoRelease, initRepoReleaseNew, initRepoTopicBar, + initViewFileTreeSidebar, initRepoWikiForm, initRepository, initRepositoryActionView, diff --git a/web_src/js/render/pdf.ts b/web_src/js/render/pdf.ts index f31f161e6e8e2..5bed6f7bab842 100644 --- a/web_src/js/render/pdf.ts +++ b/web_src/js/render/pdf.ts @@ -1,7 +1,11 @@ import {htmlEscape} from 'escape-goat'; export async function initPdfViewer() { - const els = document.querySelectorAll('.pdf-content'); + initTargetPdfViewer(document); +} + +export async function initTargetPdfViewer(target: ParentNode) { + const els = target.querySelectorAll('.pdf-content'); if (!els.length) return; const pdfobject = await import(/* webpackChunkName: "pdfobject" */'pdfobject');