From 92f997ce6b2535c0c71a33ade290378a744c7224 Mon Sep 17 00:00:00 2001 From: Kerwin Bryant <kerwin612@qq.com> Date: Sat, 15 Mar 2025 16:26:49 +0800 Subject: [PATCH] Add file tree to file view page (#32721) Resolve #29328 This pull request introduces a file tree on the left side when reviewing files of a repository. --------- Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com> --- models/user/setting_keys.go | 3 + modules/git/parse_nogogit.go | 2 +- modules/git/tree_blob_gogit.go | 1 + routers/web/repo/blame.go | 60 +++----- routers/web/repo/treelist.go | 10 ++ routers/web/repo/view.go | 14 +- routers/web/repo/view_home.go | 25 +++- routers/web/user/setting/settings.go | 26 ++++ routers/web/web.go | 6 + services/contexttest/context_tests.go | 26 ++-- services/repository/files/tree.go | 99 +++++++++++++ services/repository/files/tree_test.go | 49 +++++++ templates/repo/home.tmpl | 108 +------------- templates/repo/view.tmpl | 29 ++++ templates/repo/view_content.tmpl | 111 +++++++++++++++ templates/repo/view_file_tree.tmpl | 15 ++ web_src/css/repo/home.css | 15 ++ web_src/js/components/ViewFileTree.vue | 62 ++++++++ web_src/js/components/ViewFileTreeItem.vue | 156 +++++++++++++++++++++ web_src/js/features/repo-view-file-tree.ts | 37 +++++ web_src/js/index.ts | 2 + web_src/js/svg.ts | 2 + 22 files changed, 696 insertions(+), 162 deletions(-) create mode 100644 routers/web/user/setting/settings.go create mode 100644 templates/repo/view.tmpl create mode 100644 templates/repo/view_content.tmpl create mode 100644 templates/repo/view_file_tree.tmpl create mode 100644 web_src/js/components/ViewFileTree.vue create mode 100644 web_src/js/components/ViewFileTreeItem.vue create mode 100644 web_src/js/features/repo-view-file-tree.ts diff --git a/models/user/setting_keys.go b/models/user/setting_keys.go index 3149aae18b..2c2ed078be 100644 --- a/models/user/setting_keys.go +++ b/models/user/setting_keys.go @@ -10,6 +10,7 @@ const ( SettingsKeyDiffWhitespaceBehavior = "diff.whitespace_behaviour" // SettingsKeyShowOutdatedComments is the setting key wether or not to show outdated comments in PRs SettingsKeyShowOutdatedComments = "comment_code.show_outdated" + // UserActivityPubPrivPem is user's private key UserActivityPubPrivPem = "activitypub.priv_pem" // UserActivityPubPubPem is user's public key @@ -18,4 +19,6 @@ const ( SignupIP = "signup.ip" // SignupUserAgent is the user agent that the user signed up with SignupUserAgent = "signup.user_agent" + + SettingsKeyCodeViewShowFileTree = "code_view.show_file_tree" ) diff --git a/modules/git/parse_nogogit.go b/modules/git/parse_nogogit.go index 676bb3c76c..78a0162889 100644 --- a/modules/git/parse_nogogit.go +++ b/modules/git/parse_nogogit.go @@ -19,7 +19,7 @@ func ParseTreeEntries(data []byte) ([]*TreeEntry, error) { return parseTreeEntries(data, nil) } -// parseTreeEntries FIXME this function's design is not right, it should make the caller read all data into memory +// parseTreeEntries FIXME this function's design is not right, it should not make the caller read all data into memory func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) { entries := make([]*TreeEntry, 0, bytes.Count(data, []byte{'\n'})+1) for pos := 0; pos < len(data); { diff --git a/modules/git/tree_blob_gogit.go b/modules/git/tree_blob_gogit.go index 92c25cb92c..f29e8f8b9e 100644 --- a/modules/git/tree_blob_gogit.go +++ b/modules/git/tree_blob_gogit.go @@ -21,6 +21,7 @@ func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) { return &TreeEntry{ ID: t.ID, // Type: ObjectTree, + ptree: t, gogitTreeEntry: &object.TreeEntry{ Name: "", Mode: filemode.Dir, diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index 2da5acd299..efd85b9452 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -41,60 +41,45 @@ type blameRow struct { // RefBlame render blame page func RefBlame(ctx *context.Context) { - fileName := ctx.Repo.TreePath - if len(fileName) == 0 { + ctx.Data["PageIsViewCode"] = true + ctx.Data["IsBlame"] = true + + // Get current entry user currently looking at. + if ctx.Repo.TreePath == "" { ctx.NotFound(nil) return } - - branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() - treeLink := branchLink - rawLink := ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() - - if len(ctx.Repo.TreePath) > 0 { - treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath) - } - - var treeNames []string - paths := make([]string, 0, 5) - if len(ctx.Repo.TreePath) > 0 { - treeNames = strings.Split(ctx.Repo.TreePath, "/") - for i := range treeNames { - paths = append(paths, strings.Join(treeNames[:i+1], "/")) - } - - ctx.Data["HasParentPath"] = true - if len(paths)-2 >= 0 { - ctx.Data["ParentPath"] = "/" + paths[len(paths)-1] - } - } - - // Get current entry user currently looking at. entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath) if err != nil { HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err) return } - blob := entry.Blob() + treeNames := strings.Split(ctx.Repo.TreePath, "/") + var paths []string + for i := range treeNames { + paths = append(paths, strings.Join(treeNames[:i+1], "/")) + } ctx.Data["Paths"] = paths - ctx.Data["TreeLink"] = treeLink ctx.Data["TreeNames"] = treeNames - ctx.Data["BranchLink"] = branchLink - - ctx.Data["RawFileLink"] = rawLink + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) - ctx.Data["PageIsViewCode"] = true - - ctx.Data["IsBlame"] = true + ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() + ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + blob := entry.Blob() fileSize := blob.Size() ctx.Data["FileSize"] = fileSize ctx.Data["FileName"] = blob.Name() + tplName := tplRepoViewContent + if !ctx.FormBool("only_content") { + prepareHomeTreeSideBarSwitch(ctx) + tplName = tplRepoView + } + if fileSize >= setting.UI.MaxDisplayFileSize { ctx.Data["IsFileTooLarge"] = true - ctx.HTML(http.StatusOK, tplRepoHome) + ctx.HTML(http.StatusOK, tplName) return } @@ -105,8 +90,7 @@ func RefBlame(ctx *context.Context) { } bypassBlameIgnore, _ := strconv.ParseBool(ctx.FormString("bypass-blame-ignore")) - - result, err := performBlame(ctx, ctx.Repo.Repository, ctx.Repo.Commit, fileName, bypassBlameIgnore) + result, err := performBlame(ctx, ctx.Repo.Repository, ctx.Repo.Commit, ctx.Repo.TreePath, bypassBlameIgnore) if err != nil { ctx.NotFound(err) return @@ -122,7 +106,7 @@ func RefBlame(ctx *context.Context) { renderBlame(ctx, result.Parts, commitNames) - ctx.HTML(http.StatusOK, tplRepoHome) + ctx.HTML(http.StatusOK, tplName) } type blameResult struct { diff --git a/routers/web/repo/treelist.go b/routers/web/repo/treelist.go index 9ce9f8424d..ab74741e61 100644 --- a/routers/web/repo/treelist.go +++ b/routers/web/repo/treelist.go @@ -11,6 +11,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/gitdiff" + files_service "code.gitea.io/gitea/services/repository/files" "github.com/go-enry/go-enry/v2" ) @@ -84,3 +85,12 @@ func transformDiffTreeForUI(diffTree *gitdiff.DiffTree, filesViewedState map[str return files } + +func TreeViewNodes(ctx *context.Context) { + results, err := files_service.GetTreeViewNodes(ctx, ctx.Repo.Commit, ctx.Repo.TreePath, ctx.FormString("sub_path")) + if err != nil { + ctx.ServerError("GetTreeViewNodes", err) + return + } + ctx.JSON(http.StatusOK, map[string]any{"fileTreeNodes": results}) +} diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index cb9c278cac..6ed5801d10 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -47,12 +47,14 @@ import ( ) const ( - tplRepoEMPTY templates.TplName = "repo/empty" - tplRepoHome templates.TplName = "repo/home" - tplRepoViewList templates.TplName = "repo/view_list" - tplWatchers templates.TplName = "repo/watchers" - tplForks templates.TplName = "repo/forks" - tplMigrating templates.TplName = "repo/migrate/migrating" + tplRepoEMPTY templates.TplName = "repo/empty" + tplRepoHome templates.TplName = "repo/home" + tplRepoView templates.TplName = "repo/view" + tplRepoViewContent templates.TplName = "repo/view_content" + tplRepoViewList templates.TplName = "repo/view_list" + tplWatchers templates.TplName = "repo/watchers" + tplForks templates.TplName = "repo/forks" + tplMigrating templates.TplName = "repo/migrate/migrating" ) type fileInfo struct { diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go index 1da89686ad..d538406035 100644 --- a/routers/web/repo/view_home.go +++ b/routers/web/repo/view_home.go @@ -9,6 +9,7 @@ import ( "html/template" "net/http" "path" + "strconv" "strings" "time" @@ -17,6 +18,7 @@ import ( access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" unit_model "code.gitea.io/gitea/models/unit" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/log" repo_module "code.gitea.io/gitea/modules/repository" @@ -328,6 +330,19 @@ func handleRepoHomeFeed(ctx *context.Context) bool { return true } +func prepareHomeTreeSideBarSwitch(ctx *context.Context) { + showFileTree := true + if ctx.Doer != nil { + v, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyCodeViewShowFileTree, "true") + if err != nil { + log.Error("GetUserSetting: %v", err) + } else { + showFileTree, _ = strconv.ParseBool(v) + } + } + ctx.Data["UserSettingCodeViewShowFileTree"] = showFileTree +} + // Home render repository home page func Home(ctx *context.Context) { if handleRepoHomeFeed(ctx) { @@ -341,6 +356,8 @@ func Home(ctx *context.Context) { return } + prepareHomeTreeSideBarSwitch(ctx) + title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name if len(ctx.Repo.Repository.Description) > 0 { title += ": " + ctx.Repo.Repository.Description @@ -410,7 +427,13 @@ func Home(ctx *context.Context) { } } - ctx.HTML(http.StatusOK, tplRepoHome) + if ctx.FormBool("only_content") { + ctx.HTML(http.StatusOK, tplRepoViewContent) + } else if len(treeNames) != 0 { + ctx.HTML(http.StatusOK, tplRepoView) + } else { + ctx.HTML(http.StatusOK, tplRepoHome) + } } func RedirectRepoTreeToSrc(ctx *context.Context) { diff --git a/routers/web/user/setting/settings.go b/routers/web/user/setting/settings.go new file mode 100644 index 0000000000..111931633d --- /dev/null +++ b/routers/web/user/setting/settings.go @@ -0,0 +1,26 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + "strconv" + + user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/services/context" +) + +func UpdatePreferences(ctx *context.Context) { + type preferencesForm struct { + CodeViewShowFileTree bool `json:"codeViewShowFileTree"` + } + form := &preferencesForm{} + if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil { + ctx.HTTPError(http.StatusBadRequest, "json decode failed") + return + } + _ = user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyCodeViewShowFileTree, strconv.FormatBool(form.CodeViewShowFileTree)) + ctx.JSONOK() +} diff --git a/routers/web/web.go b/routers/web/web.go index 1223d56c92..f4bd3ef4bc 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -580,6 +580,7 @@ func registerRoutes(m *web.Router) { m.Group("/user/settings", func() { m.Get("", user_setting.Profile) m.Post("", web.Bind(forms.UpdateProfileForm{}), user_setting.ProfilePost) + m.Post("/update_preferences", user_setting.UpdatePreferences) m.Get("/change_password", auth.MustChangePassword) m.Post("/change_password", web.Bind(forms.MustChangePasswordForm{}), auth.MustChangePasswordPost) m.Post("/avatar", web.Bind(forms.AvatarForm{}), user_setting.AvatarPost) @@ -1175,6 +1176,11 @@ func registerRoutes(m *web.Router) { m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.TreeList) m.Get("/commit/*", context.RepoRefByType(git.RefTypeCommit), repo.TreeList) }) + m.Group("/tree-view", func() { + m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.TreeViewNodes) + m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.TreeViewNodes) + m.Get("/commit/*", context.RepoRefByType(git.RefTypeCommit), repo.TreeViewNodes) + }) 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/services/contexttest/context_tests.go b/services/contexttest/context_tests.go index 98b8bdd63e..c895de3569 100644 --- a/services/contexttest/context_tests.go +++ b/services/contexttest/context_tests.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/cache" + git_module "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/session" @@ -30,6 +31,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func mockRequest(t *testing.T, reqPath string) *http.Request { @@ -85,7 +87,7 @@ func MockAPIContext(t *testing.T, reqPath string) (*context.APIContext, *httptes base := context.NewBaseContext(resp, req) base.Data = middleware.GetContextData(req.Context()) base.Locale = &translation.MockLocale{} - ctx := &context.APIContext{Base: base} + ctx := &context.APIContext{Base: base, Repo: &context.Repository{}} chiCtx := chi.NewRouteContext() ctx.SetContextValue(chi.RouteCtxKey, chiCtx) return ctx, resp @@ -106,13 +108,13 @@ func MockPrivateContext(t *testing.T, reqPath string) (*context.PrivateContext, // LoadRepo load a repo into a test context. func LoadRepo(t *testing.T, ctx gocontext.Context, repoID int64) { var doer *user_model.User - repo := &context.Repository{} + var repo *context.Repository switch ctx := ctx.(type) { case *context.Context: - ctx.Repo = repo + repo = ctx.Repo doer = ctx.Doer case *context.APIContext: - ctx.Repo = repo + repo = ctx.Repo doer = ctx.Doer default: assert.FailNow(t, "context is not *context.Context or *context.APIContext") @@ -140,15 +142,17 @@ func LoadRepoCommit(t *testing.T, ctx gocontext.Context) { } gitRepo, err := gitrepo.OpenRepository(ctx, repo.Repository) - assert.NoError(t, err) + require.NoError(t, err) defer gitRepo.Close() - branch, err := gitRepo.GetHEADBranch() - assert.NoError(t, err) - assert.NotNil(t, branch) - if branch != nil { - repo.Commit, err = gitRepo.GetBranchCommit(branch.Name) - assert.NoError(t, err) + + if repo.RefFullName == "" { + repo.RefFullName = git_module.RefNameFromBranch(repo.Repository.DefaultBranch) } + if repo.RefFullName.IsPull() { + repo.BranchName = repo.RefFullName.ShortName() + } + repo.Commit, err = gitRepo.GetCommit(repo.RefFullName.String()) + require.NoError(t, err) } // LoadUser load a user into a test context diff --git a/services/repository/files/tree.go b/services/repository/files/tree.go index 6775186afd..9142416347 100644 --- a/services/repository/files/tree.go +++ b/services/repository/files/tree.go @@ -7,9 +7,13 @@ import ( "context" "fmt" "net/url" + "path" + "sort" + "strings" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/modules/git" + "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" @@ -118,3 +122,98 @@ func GetTreeBySHA(ctx context.Context, repo *repo_model.Repository, gitRepo *git } return tree, nil } + +func entryModeString(entryMode git.EntryMode) string { + switch entryMode { + case git.EntryModeBlob: + return "blob" + case git.EntryModeExec: + return "exec" + case git.EntryModeSymlink: + return "symlink" + case git.EntryModeCommit: + return "commit" // submodule + case git.EntryModeTree: + return "tree" + } + return "unknown" +} + +type TreeViewNode struct { + EntryName string `json:"entryName"` + EntryMode string `json:"entryMode"` + FullPath string `json:"fullPath"` + SubmoduleURL string `json:"submoduleUrl,omitempty"` + Children []*TreeViewNode `json:"children,omitempty"` +} + +func (node *TreeViewNode) sortLevel() int { + return util.Iif(node.EntryMode == "tree" || node.EntryMode == "commit", 0, 1) +} + +func newTreeViewNodeFromEntry(ctx context.Context, commit *git.Commit, parentDir string, entry *git.TreeEntry) *TreeViewNode { + node := &TreeViewNode{ + EntryName: entry.Name(), + EntryMode: entryModeString(entry.Mode()), + FullPath: path.Join(parentDir, entry.Name()), + } + + if node.EntryMode == "commit" { + if subModule, err := commit.GetSubModule(node.FullPath); err != nil { + log.Error("GetSubModule: %v", err) + } else if subModule != nil { + submoduleFile := git.NewCommitSubmoduleFile(subModule.URL, entry.ID.String()) + webLink := submoduleFile.SubmoduleWebLink(ctx) + node.SubmoduleURL = webLink.CommitWebLink + } + } + + return node +} + +// sortTreeViewNodes list directory first and with alpha sequence +func sortTreeViewNodes(nodes []*TreeViewNode) { + sort.Slice(nodes, func(i, j int) bool { + a, b := nodes[i].sortLevel(), nodes[j].sortLevel() + if a != b { + return a < b + } + return nodes[i].EntryName < nodes[j].EntryName + }) +} + +func listTreeNodes(ctx context.Context, commit *git.Commit, tree *git.Tree, treePath, subPath string) ([]*TreeViewNode, error) { + entries, err := tree.ListEntries() + if err != nil { + return nil, err + } + + subPathDirName, subPathRemaining, _ := strings.Cut(subPath, "/") + nodes := make([]*TreeViewNode, 0, len(entries)) + for _, entry := range entries { + node := newTreeViewNodeFromEntry(ctx, commit, treePath, entry) + nodes = append(nodes, node) + if entry.IsDir() && subPathDirName == entry.Name() { + subTreePath := treePath + "/" + node.EntryName + if subTreePath[0] == '/' { + subTreePath = subTreePath[1:] + } + subNodes, err := listTreeNodes(ctx, commit, entry.Tree(), subTreePath, subPathRemaining) + if err != nil { + log.Error("listTreeNodes: %v", err) + } else { + node.Children = subNodes + } + } + } + sortTreeViewNodes(nodes) + return nodes, nil +} + +func GetTreeViewNodes(ctx context.Context, commit *git.Commit, treePath, subPath string) ([]*TreeViewNode, error) { + entry, err := commit.GetTreeEntryByPath(treePath) + if err != nil { + return nil, err + } + return listTreeNodes(ctx, commit, entry.Tree(), treePath, subPath) +} diff --git a/services/repository/files/tree_test.go b/services/repository/files/tree_test.go index 0c60fddf7b..8ea54969ce 100644 --- a/services/repository/files/tree_test.go +++ b/services/repository/files/tree_test.go @@ -7,6 +7,7 @@ import ( "testing" "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/git" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/services/contexttest" @@ -50,3 +51,51 @@ func TestGetTreeBySHA(t *testing.T) { assert.EqualValues(t, expectedTree, tree) } + +func TestGetTreeViewNodes(t *testing.T) { + unittest.PrepareTestEnv(t) + ctx, _ := contexttest.MockContext(t, "user2/repo1") + ctx.Repo.RefFullName = git.RefNameFromBranch("sub-home-md-img-check") + contexttest.LoadRepo(t, ctx, 1) + contexttest.LoadRepoCommit(t, ctx) + contexttest.LoadUser(t, ctx, 2) + contexttest.LoadGitRepo(t, ctx) + defer ctx.Repo.GitRepo.Close() + + treeNodes, err := GetTreeViewNodes(ctx, ctx.Repo.Commit, "", "") + assert.NoError(t, err) + assert.Equal(t, []*TreeViewNode{ + { + EntryName: "docs", + EntryMode: "tree", + FullPath: "docs", + }, + }, treeNodes) + + treeNodes, err = GetTreeViewNodes(ctx, ctx.Repo.Commit, "", "docs/README.md") + assert.NoError(t, err) + assert.Equal(t, []*TreeViewNode{ + { + EntryName: "docs", + EntryMode: "tree", + FullPath: "docs", + Children: []*TreeViewNode{ + { + EntryName: "README.md", + EntryMode: "blob", + FullPath: "docs/README.md", + }, + }, + }, + }, treeNodes) + + treeNodes, err = GetTreeViewNodes(ctx, ctx.Repo.Commit, "docs", "README.md") + assert.NoError(t, err) + assert.Equal(t, []*TreeViewNode{ + { + EntryName: "README.md", + EntryMode: "blob", + FullPath: "docs/README.md", + }, + }, treeNodes) +} diff --git a/templates/repo/home.tmpl b/templates/repo/home.tmpl index 31a8167b4b..f86b90502d 100644 --- a/templates/repo/home.tmpl +++ b/templates/repo/home.tmpl @@ -1,7 +1,8 @@ {{template "base/head" .}} +{{$showSidebar := and (not .TreeNames) (not .HideRepoInfo) (not .IsBlame)}} <div role="main" aria-label="{{.Title}}" class="page-content repository file list {{if .IsBlame}}blame{{end}}"> {{template "repo/header" .}} - <div class="ui container {{if .IsBlame}}fluid padded{{end}}"> + <div class="ui container {{if or .TreeNames .IsBlame}}fluid padded{{end}}"> {{template "base/alert" .}} {{if .Repository.IsArchived}} @@ -16,112 +17,9 @@ {{template "repo/code/recently_pushed_new_branches" .}} - {{$treeNamesLen := len .TreeNames}} - {{$isTreePathRoot := eq $treeNamesLen 0}} - {{$showSidebar := and $isTreePathRoot (not .HideRepoInfo) (not .IsBlame)}} <div class="{{Iif $showSidebar "repo-grid-filelist-sidebar" "repo-grid-filelist-only"}}"> <div class="repo-home-filelist"> - {{template "repo/sub_menu" .}} - <div class="repo-button-row"> - <div class="repo-button-row-left"> - {{- /* 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}} - <a id="new-pull-request" role="button" class="ui compact basic button" href="{{$compareLink}}" - data-tooltip-content="{{if .PullRequestCtx.Allowed}}{{ctx.Locale.Tr "repo.pulls.compare_changes"}}{{else}}{{ctx.Locale.Tr "action.compare_branch"}}{{end}}"> - {{svg "octicon-git-pull-request"}} - </a> - {{end}} - - <!-- Show go to file if on home page --> - {{if $isTreePathRoot}} - <a href="{{.Repository.Link}}/find/{{.RefTypeNameSubURL}}" class="ui compact basic button">{{ctx.Locale.Tr "repo.find_file.go_to_file"}}</a> - {{end}} - - {{if and .CanWriteCode .RefFullName.IsBranch (not .Repository.IsMirror) (not .Repository.IsArchived) (not .IsViewFile)}} - <button class="ui dropdown basic compact jump button"{{if not .Repository.CanEnableEditor}} disabled{{end}}> - {{ctx.Locale.Tr "repo.editor.add_file"}} - {{svg "octicon-triangle-down" 14 "dropdown icon"}} - <div class="menu"> - <a class="item" href="{{.RepoLink}}/_new/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}"> - {{ctx.Locale.Tr "repo.editor.new_file"}} - </a> - {{if .RepositoryUploadEnabled}} - <a class="item" href="{{.RepoLink}}/_upload/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}"> - {{ctx.Locale.Tr "repo.editor.upload_file"}} - </a> - {{end}} - <a class="item" href="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}"> - {{ctx.Locale.Tr "repo.editor.patch"}} - </a> - </div> - </button> - {{end}} - - {{if and $isTreePathRoot .Repository.IsTemplate}} - <a role="button" class="ui primary compact button" href="{{AppSubUrl}}/repo/create?template_id={{.Repository.ID}}"> - {{ctx.Locale.Tr "repo.use_template"}} - </a> - {{end}} - - {{if not $isTreePathRoot}} - {{$treeNameIdxLast := Eval $treeNamesLen "-" 1}} - <span class="breadcrumb repo-path tw-ml-1"> - <a class="section" href="{{.RepoLink}}/src/{{.RefTypeNameSubURL}}" title="{{.Repository.Name}}">{{StringUtils.EllipsisString .Repository.Name 30}}</a> - {{- range $i, $v := .TreeNames -}} - <span class="breadcrumb-divider">/</span> - {{- if eq $i $treeNameIdxLast -}} - <span class="active section" title="{{$v}}">{{$v}}</span> - <button class="btn interact-fg tw-mx-1" data-clipboard-text="{{$.TreePath}}" data-tooltip-content="{{ctx.Locale.Tr "copy_path"}}">{{svg "octicon-copy" 14}}</button> - {{- else -}} - {{$p := index $.Paths $i}}<span class="section"><a href="{{$.BranchLink}}/{{PathEscapeSegments $p}}" title="{{$v}}">{{$v}}</a></span> - {{- end -}} - {{- end -}} - </span> - {{end}} - </div> - - <div class="repo-button-row-right"> - <!-- Only show clone panel in repository home page --> - {{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 */}} - <a class="ui button" href="{{.RepoLink}}/commits/{{.RefTypeNameSubURL}}/{{.TreePath | PathEscapeSegments}}"> - {{svg "octicon-history" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.file_history"}} - </a> - {{end}} - </div> - </div> - {{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/view_content" .}} </div> {{if $showSidebar}} diff --git a/templates/repo/view.tmpl b/templates/repo/view.tmpl new file mode 100644 index 0000000000..c3d562003d --- /dev/null +++ b/templates/repo/view.tmpl @@ -0,0 +1,29 @@ +{{template "base/head" .}} +<div role="main" aria-label="{{.Title}}" class="page-content repository file list {{if .IsBlame}}blame{{end}}"> + {{template "repo/header" .}} + <div class="ui container {{if or .TreeNames .IsBlame}}fluid padded{{end}}"> + {{template "base/alert" .}} + + {{if .Repository.IsArchived}} + <div class="ui warning message tw-text-center"> + {{if .Repository.ArchivedUnix.IsZero}} + {{ctx.Locale.Tr "repo.archive.title"}} + {{else}} + {{ctx.Locale.Tr "repo.archive.title_date" (DateUtils.AbsoluteLong .Repository.ArchivedUnix)}} + {{end}} + </div> + {{end}} + + {{template "repo/code/recently_pushed_new_branches" .}} + + <div class="repo-view-container"> + <div class="repo-view-file-tree-container not-mobile {{if not .UserSettingCodeViewShowFileTree}}tw-hidden{{end}}" {{if .IsSigned}}data-user-is-signed-in{{end}}> + {{template "repo/view_file_tree" .}} + </div> + <div class="repo-view-content"> + {{template "repo/view_content" .}} + </div> + </div> + </div> +</div> +{{template "base/footer" .}} diff --git a/templates/repo/view_content.tmpl b/templates/repo/view_content.tmpl new file mode 100644 index 0000000000..06e9f8515c --- /dev/null +++ b/templates/repo/view_content.tmpl @@ -0,0 +1,111 @@ +{{$isTreePathRoot := not .TreeNames}} + +{{template "repo/sub_menu" .}} +<div class="repo-button-row"> + <div class="repo-button-row-left"> + {{if not $isTreePathRoot}} + <button class="repo-view-file-tree-toggle-show ui compact basic button icon not-mobile {{if .UserSettingCodeViewShowFileTree}}tw-hidden{{end}}" + data-global-click="onRepoViewFileTreeToggle" data-toggle-action="show" + data-tooltip-content="{{ctx.Locale.Tr "repo.diff.show_file_tree"}}"> + {{svg "octicon-sidebar-collapse"}} + </button> + {{end}} + + {{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}} + <a id="new-pull-request" role="button" class="ui compact basic button" href="{{$compareLink}}" + data-tooltip-content="{{if .PullRequestCtx.Allowed}}{{ctx.Locale.Tr "repo.pulls.compare_changes"}}{{else}}{{ctx.Locale.Tr "action.compare_branch"}}{{end}}"> + {{svg "octicon-git-pull-request"}} + </a> + {{end}} + + <!-- Show go to file if on home page --> + {{if $isTreePathRoot}} + <a href="{{.Repository.Link}}/find/{{.RefTypeNameSubURL}}" class="ui compact basic button">{{ctx.Locale.Tr "repo.find_file.go_to_file"}}</a> + {{end}} + + {{if and .CanWriteCode .RefFullName.IsBranch (not .Repository.IsMirror) (not .Repository.IsArchived) (not .IsViewFile)}} + <button class="ui dropdown basic compact jump button"{{if not .Repository.CanEnableEditor}} disabled{{end}}> + {{ctx.Locale.Tr "repo.editor.add_file"}} + {{svg "octicon-triangle-down" 14 "dropdown icon"}} + <div class="menu"> + <a class="item" href="{{.RepoLink}}/_new/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}"> + {{ctx.Locale.Tr "repo.editor.new_file"}} + </a> + {{if .RepositoryUploadEnabled}} + <a class="item" href="{{.RepoLink}}/_upload/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}"> + {{ctx.Locale.Tr "repo.editor.upload_file"}} + </a> + {{end}} + <a class="item" href="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}"> + {{ctx.Locale.Tr "repo.editor.patch"}} + </a> + </div> + </button> + {{end}} + + {{if and $isTreePathRoot .Repository.IsTemplate}} + <a role="button" class="ui primary compact button" href="{{AppSubUrl}}/repo/create?template_id={{.Repository.ID}}"> + {{ctx.Locale.Tr "repo.use_template"}} + </a> + {{end}} + + {{if not $isTreePathRoot}} + {{$treeNameIdxLast := Eval (len .TreeNames) "-" 1}} + <span class="breadcrumb repo-path tw-ml-1"> + <a class="section" href="{{.RepoLink}}/src/{{.RefTypeNameSubURL}}" title="{{.Repository.Name}}">{{StringUtils.EllipsisString .Repository.Name 30}}</a> + {{- range $i, $v := .TreeNames -}} + <span class="breadcrumb-divider">/</span> + {{- if eq $i $treeNameIdxLast -}} + <span class="active section" title="{{$v}}">{{$v}}</span> + <button class="btn interact-fg tw-mx-1" data-clipboard-text="{{$.TreePath}}" data-tooltip-content="{{ctx.Locale.Tr "copy_path"}}">{{svg "octicon-copy" 14}}</button> + {{- else -}} + {{$p := index $.Paths $i}}<span class="section"><a href="{{$.BranchLink}}/{{PathEscapeSegments $p}}" title="{{$v}}">{{$v}}</a></span> + {{- end -}} + {{- end -}} + </span> + {{end}} + </div> + + <div class="repo-button-row-right"> + <!-- Only show clone panel in repository home page --> + {{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 */}} + <a class="ui button" href="{{.RepoLink}}/commits/{{.RefTypeNameSubURL}}/{{.TreePath | PathEscapeSegments}}"> + {{svg "octicon-history" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.file_history"}} + </a> + {{end}} + </div> +</div> +{{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.tmpl b/templates/repo/view_file_tree.tmpl new file mode 100644 index 0000000000..6e5d504a47 --- /dev/null +++ b/templates/repo/view_file_tree.tmpl @@ -0,0 +1,15 @@ +<div class="flex-text-block tw-mb-2"> + <button class="ui compact tiny icon button" + data-global-click="onRepoViewFileTreeToggle" data-toggle-action="hide" + data-tooltip-content="{{ctx.Locale.Tr "repo.diff.hide_file_tree"}}"> + {{svg "octicon-sidebar-expand"}} + </button> + <b>Files</b> +</div> + +{{/* TODO: Dynamically move components such as refSelector and createPR here */}} +<div id="view-file-tree" class="tw-overflow-auto tw-h-full is-loading" + data-repo-link="{{.RepoLink}}" + data-tree-path="{{$.TreePath}}" + data-current-ref-name-sub-url="{{.RefTypeNameSubURL}}" +></div> diff --git a/web_src/css/repo/home.css b/web_src/css/repo/home.css index 96551979ea..219be77adb 100644 --- a/web_src/css/repo/home.css +++ b/web_src/css/repo/home.css @@ -49,6 +49,21 @@ } } +.repo-view-container { + display: flex; + gap: var(--page-spacing); +} + +.repo-view-container .repo-view-file-tree-container { + flex: 0 1 15%; + min-width: 0; + max-height: 100vh; +} + +.repo-view-content { + flex: 1; +} + .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 0000000000..1820c47e7a --- /dev/null +++ b/web_src/js/components/ViewFileTree.vue @@ -0,0 +1,62 @@ +<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'; + +const elRoot = ref<HTMLElement | null>(null); + +const props = defineProps({ + repoLink: {type: String, required: true}, + treePath: {type: String, required: true}, + 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(); + 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); +} + +onMounted(async () => { + selectedItem.value = props.treePath; + files.value = await 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); + }); +}); +</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"/> + </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..4dffc86a1b --- /dev/null +++ b/web_src/js/components/ViewFileTreeItem.vue @@ -0,0 +1,156 @@ +<script lang="ts" setup> +import {SvgIcon} from '../svg.ts'; +import {ref} from 'vue'; + +type Item = { + entryName: string; + entryMode: string; + fullPath: string; + submoduleUrl?: string; + children?: Item[]; +}; + +const props = defineProps<{ + item: Item, + navigateViewContent:(treePath: string) => void, + loadChildren:(treePath: string, subPath?: string) => Promise<Item[]>, + selectedItem?: string, +}>(); + +const isLoading = ref(false); +const children = ref(props.item.children); +const collapsed = ref(!props.item.children); + +const doLoadChildren = async () => { + collapsed.value = !collapsed.value; + if (!collapsed.value && props.loadChildren) { + isLoading.value = true; + try { + children.value = await props.loadChildren(props.item.fullPath); + } finally { + isLoading.value = false; + } + } +}; + +const doLoadDirContent = () => { + doLoadChildren(); + props.navigateViewContent(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"> + <SvgIcon class="text primary" name="octicon-file-submodule"/> + <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}" + :title="item.entryName" + @click.stop="doLoadFileContent" + > + <!-- symlink --> + <div class="item-content"> + <SvgIcon name="octicon-file-symlink-file"/> + <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"> + <SvgIcon name="octicon-file"/> + <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"> + <SvgIcon v-if="isLoading" name="octicon-sync" class="job-status-rotate"/> + <SvgIcon v-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop="doLoadChildren"/> + </div> + <div class="item-content"> + <SvgIcon class="text primary" :name="collapsed ? 'octicon-file-directory-fill' : 'octicon-file-directory-open-fill'"/> + <span class="gt-ellipsis">{{ item.entryName }}</span> + </div> + </div> + + <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"/> + </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.25em; + text-overflow: ellipsis; + min-width: 0; +} +</style> diff --git a/web_src/js/features/repo-view-file-tree.ts b/web_src/js/features/repo-view-file-tree.ts new file mode 100644 index 0000000000..f52b64cc51 --- /dev/null +++ b/web_src/js/features/repo-view-file-tree.ts @@ -0,0 +1,37 @@ +import {createApp} from 'vue'; +import {toggleElem} from '../utils/dom.ts'; +import {POST} from '../modules/fetch.ts'; +import ViewFileTree from '../components/ViewFileTree.vue'; +import {registerGlobalEventFunc} from '../modules/observer.ts'; + +const {appSubUrl} = window.config; + +async function toggleSidebar(btn: HTMLElement) { + const elToggleShow = document.querySelector('.repo-view-file-tree-toggle-show'); + const elFileTreeContainer = document.querySelector('.repo-view-file-tree-container'); + const shouldShow = btn.getAttribute('data-toggle-action') === 'show'; + toggleElem(elFileTreeContainer, shouldShow); + toggleElem(elToggleShow, !shouldShow); + + // FIXME: need to remove "full height" style from parent element + + if (!elFileTreeContainer.hasAttribute('data-user-is-signed-in')) return; + await POST(`${appSubUrl}/user/settings/update_preferences`, { + data: {codeViewShowFileTree: shouldShow}, + }); +} + +export async function initRepoViewFileTree() { + const sidebar = document.querySelector<HTMLElement>('.repo-view-file-tree-container'); + const repoViewContent = document.querySelector('.repo-view-content'); + if (!sidebar || !repoViewContent) return; + + registerGlobalEventFunc('click', 'onRepoViewFileTreeToggle', toggleSidebar); + + const fileTree = sidebar.querySelector('#view-file-tree'); + createApp(ViewFileTree, { + repoLink: fileTree.getAttribute('data-repo-link'), + treePath: fileTree.getAttribute('data-tree-path'), + currentRefNameSubURL: fileTree.getAttribute('data-current-ref-name-sub-url'), + }).mount(fileTree); +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 2e44ef826a..839a160168 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -64,6 +64,7 @@ import {initFootLanguageMenu, initGlobalDropdown, initGlobalInput, initGlobalTab import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts'; import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts'; import {callInitFunctions} from './modules/init.ts'; +import {initRepoViewFileTree} from './features/repo-view-file-tree.ts'; initGiteaFomantic(); initSubmitEventPolyfill(); @@ -139,6 +140,7 @@ onDomReady(() => { initRepoRelease, initRepoReleaseNew, initRepoTopicBar, + initRepoViewFileTree, initRepoWikiForm, initRepository, initRepositoryActionView, diff --git a/web_src/js/svg.ts b/web_src/js/svg.ts index 8316cbcf85..7b377e1ab4 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,