diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index ad790875136..2bdaeefb88c 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -46,6 +46,11 @@ func RefBlame(ctx *context.Context) { return } + // ctx.Data["RepoPreferences"] = ctx.Session.Get("repoPreferences") + ctx.Data["RepoPreferences"] = &preferencesForm{ + ShowFileViewTreeSidebar: true, + } + branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.BranchNameSubURL() treeLink := branchLink rawLink := ctx.Repo.RepoLink + "/raw/" + ctx.Repo.BranchNameSubURL() diff --git a/routers/web/repo/file.go b/routers/web/repo/file.go new file mode 100644 index 00000000000..60e7cb24b74 --- /dev/null +++ b/routers/web/repo/file.go @@ -0,0 +1,45 @@ +// Copyright 2014 The Gogs Authors. All rights reserved. +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "net/http" + + "code.gitea.io/gitea/models/unit" + "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/services/context" + files_service "code.gitea.io/gitea/services/repository/files" +) + +// canReadFiles returns true if repository is readable and user has proper access level. +func canReadFiles(r *context.Repository) bool { + return r.Permission.CanRead(unit.TypeCode) +} + +// GetContents Get the metadata and contents (if a file) of an entry in a repository, or a list of entries if a dir +func GetContents(ctx *context.Context) { + if !canReadFiles(ctx.Repo) { + ctx.NotFound("Invalid FilePath", nil) + return + } + + treePath := ctx.PathParam("*") + ref := ctx.FormTrim("ref") + + if fileList, err := files_service.GetContentsOrList(ctx, ctx.Repo.Repository, treePath, ref); err != nil { + if git.IsErrNotExist(err) { + ctx.NotFound("GetContentsOrList", err) + return + } + ctx.ServerError("Repo.GitRepo.GetCommit", err) + } else { + ctx.JSON(http.StatusOK, fileList) + } +} + +// GetContentsList Get the metadata of all the entries of the root dir +func GetContentsList(ctx *context.Context) { + GetContents(ctx) +} diff --git a/routers/web/repo/repo.go b/routers/web/repo/repo.go index f5e59b0357b..fbd3c835518 100644 --- a/routers/web/repo/repo.go +++ b/routers/web/repo/repo.go @@ -22,6 +22,7 @@ import ( "code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/cache" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/optional" repo_module "code.gitea.io/gitea/modules/repository" @@ -758,3 +759,20 @@ func PrepareBranchList(ctx *context.Context) { } ctx.Data["Branches"] = brs } + +type preferencesForm struct { + ShowFileViewTreeSidebar bool `json:"show_file_view_tree_sidebar"` +} + +func UpdatePreferences(ctx *context.Context) { + form := &preferencesForm{} + if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.ServerError("DecodePreferencesForm", err) + return + } + // if err := ctx.Session.Set("repoPreferences", form); err != nil { + // ctx.ServerError("Session.Set", err) + // return + // } + ctx.JSONOK() +} diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index b318c4a621a..707387f8e5c 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -305,6 +305,11 @@ func Home(ctx *context.Context) { return } + // ctx.Data["RepoPreferences"] = ctx.Session.Get("repoPreferences") + ctx.Data["RepoPreferences"] = &preferencesForm{ + ShowFileViewTreeSidebar: true, + } + title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name if len(ctx.Repo.Repository.Description) > 0 { title += ": " + ctx.Repo.Repository.Description diff --git a/routers/web/web.go b/routers/web/web.go index 72ee47bb4c9..bf8c4306bf3 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -987,6 +987,7 @@ func registerRoutes(m *web.Router) { m.Get("/migrate", repo.Migrate) m.Post("/migrate", web.Bind(forms.MigrateRepoForm{}), repo.MigratePost) m.Get("/search", repo.SearchRepo) + m.Put("/preferences", repo.UpdatePreferences) }, reqSignIn) // end "/repo": create, migrate, search @@ -1161,6 +1162,10 @@ func registerRoutes(m *web.Router) { m.Get("/tag/*", context.RepoRefByType(context.RepoRefTag), repo.TreeList) m.Get("/commit/*", context.RepoRefByType(context.RepoRefCommit), repo.TreeList) }) + m.Group("/contents", func() { + m.Get("", repo.GetContentsList) + m.Get("/*", repo.GetContents) + }) m.Get("/compare", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff) m.Combo("/compare/*", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists). Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff). diff --git a/templates/repo/home.tmpl b/templates/repo/home.tmpl index 4e6d375b517..89d2442afaf 100644 --- a/templates/repo/home.tmpl +++ b/templates/repo/home.tmpl @@ -19,11 +19,24 @@ {{$treeNamesLen := len .TreeNames}} {{$isTreePathRoot := eq $treeNamesLen 0}} {{$showSidebar := $isTreePathRoot}} -
+ {{$hasTreeSidebar := not $isTreePathRoot}} + {{$showTreeSidebar := .RepoPreferences.ShowFileViewTreeSidebar}} + {{$hideTreeSidebar := not $showTreeSidebar}} + {{$hasAndShowTreeSidebar := and $hasTreeSidebar $showTreeSidebar}} +
+ {{if $hasTreeSidebar}} +
{{template "repo/view_file_tree_sidebar" .}}
+ {{end}} +
{{template "repo/sub_menu" .}}
+ {{if $hasTreeSidebar}} + + {{end}} {{$branchDropdownCurrentRefType := "branch"}} {{$branchDropdownCurrentRefShortName := .BranchName}} {{if .IsViewTag}} @@ -40,6 +53,7 @@ "RefLinkTemplate" "{RepoLink}/src/{RefType}/{RefShortName}/{TreePath}" "AllowCreateNewRef" .CanCreateBranch "ShowViewAllRefsEntry" true + "ContainerClasses" (Iif $hasAndShowTreeSidebar "tw-hidden" "") }} {{if and .CanCompareOrPull .IsViewBranch (not .Repository.IsArchived)}} {{$cmpBranch := ""}} @@ -48,7 +62,7 @@ {{end}} {{$cmpBranch = print $cmpBranch (.BranchName|PathEscapeSegments)}} {{$compareLink := printf "%s/compare/%s...%s" .BaseRepo.Link (.BaseRepo.DefaultBranch|PathEscapeSegments) $cmpBranch}} - {{svg "octicon-git-pull-request"}} @@ -60,7 +74,7 @@ {{end}} {{if and .CanWriteCode .IsViewBranch (not .Repository.IsMirror) (not .Repository.IsArchived) (not .IsViewFile)}} - + Files +
+ +
+
+
+ {{svg "octicon-sync" 16 "job-status-rotate"}} +
+
diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index ca5b432804f..71ccbaf8f22 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -48,6 +48,49 @@ } } +.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; +} + +.repo-grid-tree-sidebar .repo-view-file-tree-sidebar { + display: flex; + flex-direction: column; + gap: 0.25em; +} + +.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; +} + +@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 00000000000..605314027ce --- /dev/null +++ b/web_src/js/components/ViewFileTree.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/web_src/js/components/ViewFileTreeItem.vue b/web_src/js/components/ViewFileTreeItem.vue new file mode 100644 index 00000000000..0df09613bc0 --- /dev/null +++ b/web_src/js/components/ViewFileTreeItem.vue @@ -0,0 +1,119 @@ + + + + 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 00000000000..26a9d0111b1 --- /dev/null +++ b/web_src/js/features/repo-view-file-tree-sidebar.ts @@ -0,0 +1,87 @@ +import {createApp} from 'vue'; +import {toggleElem} from '../utils/dom.ts'; +import {GET, PUT} from '../modules/fetch.ts'; +import ViewFileTree from '../components/ViewFileTree.vue'; + +async function toggleSidebar(visibility) { + const sidebarEl = document.querySelector('.repo-view-file-tree-sidebar'); + const showBtnEl = document.querySelector('.show-tree-sidebar-button'); + const refSelectorEl = document.querySelector('.repo-home-filelist .js-branch-tag-selector'); + const newPrBtnEl = document.querySelector('.repo-home-filelist #new-pull-request'); + const addFileEl = document.querySelector('.repo-home-filelist .add-file-dropdown'); + 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); + toggleElem(refSelectorEl, !visibility); + toggleElem(newPrBtnEl, !visibility); + if (addFileEl) { + toggleElem(addFileEl, !visibility); + } + + // save to session + await PUT('/repo/preferences', { + data: { + show_file_view_tree_sidebar: visibility, + }, + }); +} + +async function loadChildren(item?) { + const el = document.querySelector('#view-file-tree'); + const apiBaseUrl = el.getAttribute('data-api-base-url'); + const response = await GET(`${apiBaseUrl}/contents/${item ? item.path : ''}`); + const json = await response.json(); + if (json instanceof Array) { + return json.map((i) => ({ + name: i.name, + isFile: i.type === 'file', + htmlUrl: i.html_url, + path: i.path, + })); + } + return null; +} + +async function loadRecursive(treePath) { + let root = null; + let parent = null; + let parentPath = ''; + for (const i of (`/${treePath}`).split('/')) { + const path = `${parentPath}${parentPath ? '/' : ''}${i}`; + const result = await loadChildren({path}); + if (root === null) { + root = result; + parent = root; + } else { + parent = parent.find((item) => item.path === path); + parent.children = result; + parent = result; + } + parentPath = path; + } + return root; +} + +export async function initViewFileTreeSidebar() { + const sidebarElement = document.querySelector('.repo-view-file-tree-sidebar'); + if (!sidebarElement) return; + + document.querySelector('.show-tree-sidebar-button').addEventListener('click', () => { + toggleSidebar(true); + }); + + document.querySelector('.hide-tree-sidebar-button').addEventListener('click', () => { + toggleSidebar(false); + }); + + const fileTree = document.querySelector('#view-file-tree'); + const treePath = fileTree.getAttribute('data-tree-path'); + + const files = await loadRecursive(treePath); + + fileTree.classList.remove('center'); + const fileTreeView = createApp(ViewFileTree, {files, selectedItem: treePath, loadChildren}); + fileTreeView.mount(fileTree); +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 51d8c96fbdf..4602e22ffa5 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 {initRepoTemplateSearch} from './features/repo-template.ts'; import {initRepoCodeView} from './features/repo-code.ts'; @@ -195,6 +196,7 @@ onDomReady(() => { initRepoReleaseNew, initRepoTemplateSearch, initRepoTopicBar, + initViewFileTreeSidebar, initRepoWikiForm, initRepository, initRepositoryActionView,