{{template "repo/header" .}} -
+
{{template "base/alert" .}} {{if .Repository.IsArchived}} @@ -16,112 +17,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/{RefType}/{RefShortName} */ -}} - {{- template "repo/branch_dropdown" dict - "Repository" .Repository - "ShowTabBranches" true - "ShowTabTags" true - "CurrentRefType" .RefFullName.RefType - "CurrentRefShortName" .RefFullName.ShortName - "CurrentTreePath" .TreePath - "RefLinkTemplate" "{RepoLink}/src/{RefType}/{RefShortName}/{TreePath}" - "AllowCreateNewRef" .CanCreateBranch - "ShowViewAllRefsEntry" true - -}} - {{if and .CanCompareOrPull .RefFullName.IsBranch (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 .RefFullName.IsBranch (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 .TreeNames}} +
{{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..cef3b4bdf2993 --- /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/{RefType}/{RefShortName}/{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..291ceb50cdca7 --- /dev/null +++ b/templates/repo/home_content.tmpl @@ -0,0 +1,98 @@ +{{$isTreePathRoot := not .TreeNames}} +{{$showSidebar := and $isTreePathRoot (not .HideRepoInfo) (not .IsBlame)}} + +{{template "repo/sub_menu" .}} +
+
+ {{if not $isTreePathRoot}} + + {{end}} + {{template "repo/home_branch_dropdown" (dict "ctxData" .)}} + {{if and .CanCompareOrPull .RefFullName.IsBranch (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 .RefFullName.IsBranch (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 (len .TreeNames) "-" 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..48b8edfaa5235 --- /dev/null +++ b/templates/repo/view_file_tree_sidebar.tmpl @@ -0,0 +1,18 @@ +
+ + +
+
+
+
diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index 96551979ea21a..2a3764820be2c 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -49,6 +49,68 @@ } } +.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; + position: sticky; + top: 14px; + z-index: 8; +} + +.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..cbfe68ac8e573 --- /dev/null +++ b/web_src/js/components/ViewFileTreeItem.vue @@ -0,0 +1,160 @@ + + + + diff --git a/web_src/js/features/common-button.ts b/web_src/js/features/common-button.ts index 7aebdd8dd5be5..284c590fca62d 100644 --- a/web_src/js/features/common-button.ts +++ b/web_src/js/features/common-button.ts @@ -160,7 +160,11 @@ export function initGlobalButtons(): void { // 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(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)); + initTargetButtons(document); +} + +export function initTargetButtons(target: ParentNode): void { + 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 4bc9281a35e4f..0bc14c3bff5af 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 8994a57f4a8af..f0a2f878d84f6 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..22f7978f3053d --- /dev/null +++ b/web_src/js/features/repo-view-file-tree-sidebar.ts @@ -0,0 +1,127 @@ +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 {initMarkupContent} from '../markup/content.ts'; +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 refTypeNameSubURL = fileTree.getAttribute('data-current-ref-type-name-sub-url'); + const response = await GET(`${apiBaseUrl}/tree/${refTypeNameSubURL}/${item ? item.path : ''}?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, + sub_module_url: i.sub_module_url, + 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')); + }); + initMarkupContent(); + 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); + + window.addEventListener('popstate', () => { + selectedItem.value = extractPath(window.location.href); + loadContent(); + }); +} + +function extractPath(url) { + // Create a URL object + const urlObj = new URL(url); + + // Get the pathname part + const path = urlObj.pathname; + + // Define a regular expression to match "/{param1}/{param2}/src/{branch}/{main}/" + const regex = /^\/[^/]+\/[^/]+\/src\/[^/]+\/[^/]+\//; + + // Use RegExp#exec() method to match the path + const match = regex.exec(path); + if (match) { + return path.substring(match[0].length); + } + + // If the path does not match, return the original path + return path; +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index b89e5960470e1..25c6a1648521e 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -32,6 +32,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'; @@ -191,6 +192,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'); diff --git a/web_src/js/svg.ts b/web_src/js/svg.ts index b193afb255993..0208ff0e98ca0 100644 --- a/web_src/js/svg.ts +++ b/web_src/js/svg.ts @@ -29,6 +29,7 @@ import octiconFile from '../../public/assets/img/svg/octicon-file.svg'; import octiconFileDirectoryFill from '../../public/assets/img/svg/octicon-file-directory-fill.svg'; import octiconFileDirectoryOpenFill from '../../public/assets/img/svg/octicon-file-directory-open-fill.svg'; import octiconFileSubmodule from '../../public/assets/img/svg/octicon-file-submodule.svg'; +import octiconFileSymlinkFile from '../../public/assets/img/svg/octicon-file-symlink-file.svg'; import octiconFilter from '../../public/assets/img/svg/octicon-filter.svg'; import octiconGear from '../../public/assets/img/svg/octicon-gear.svg'; import octiconGitBranch from '../../public/assets/img/svg/octicon-git-branch.svg'; @@ -107,6 +108,7 @@ const svgs = { 'octicon-file-directory-fill': octiconFileDirectoryFill, 'octicon-file-directory-open-fill': octiconFileDirectoryOpenFill, 'octicon-file-submodule': octiconFileSubmodule, + 'octicon-file-symlink-file': octiconFileSymlinkFile, 'octicon-filter': octiconFilter, 'octicon-gear': octiconGear, 'octicon-git-branch': octiconGitBranch,