mirror of
https://github.com/go-gitea/gitea.git
synced 2025-03-23 21:35:29 +08:00
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>
This commit is contained in:
parent
926f0a19be
commit
92f997ce6b
@ -10,6 +10,7 @@ const (
|
|||||||
SettingsKeyDiffWhitespaceBehavior = "diff.whitespace_behaviour"
|
SettingsKeyDiffWhitespaceBehavior = "diff.whitespace_behaviour"
|
||||||
// SettingsKeyShowOutdatedComments is the setting key wether or not to show outdated comments in PRs
|
// SettingsKeyShowOutdatedComments is the setting key wether or not to show outdated comments in PRs
|
||||||
SettingsKeyShowOutdatedComments = "comment_code.show_outdated"
|
SettingsKeyShowOutdatedComments = "comment_code.show_outdated"
|
||||||
|
|
||||||
// UserActivityPubPrivPem is user's private key
|
// UserActivityPubPrivPem is user's private key
|
||||||
UserActivityPubPrivPem = "activitypub.priv_pem"
|
UserActivityPubPrivPem = "activitypub.priv_pem"
|
||||||
// UserActivityPubPubPem is user's public key
|
// UserActivityPubPubPem is user's public key
|
||||||
@ -18,4 +19,6 @@ const (
|
|||||||
SignupIP = "signup.ip"
|
SignupIP = "signup.ip"
|
||||||
// SignupUserAgent is the user agent that the user signed up with
|
// SignupUserAgent is the user agent that the user signed up with
|
||||||
SignupUserAgent = "signup.user_agent"
|
SignupUserAgent = "signup.user_agent"
|
||||||
|
|
||||||
|
SettingsKeyCodeViewShowFileTree = "code_view.show_file_tree"
|
||||||
)
|
)
|
||||||
|
@ -19,7 +19,7 @@ func ParseTreeEntries(data []byte) ([]*TreeEntry, error) {
|
|||||||
return parseTreeEntries(data, nil)
|
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) {
|
func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) {
|
||||||
entries := make([]*TreeEntry, 0, bytes.Count(data, []byte{'\n'})+1)
|
entries := make([]*TreeEntry, 0, bytes.Count(data, []byte{'\n'})+1)
|
||||||
for pos := 0; pos < len(data); {
|
for pos := 0; pos < len(data); {
|
||||||
|
@ -21,6 +21,7 @@ func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) {
|
|||||||
return &TreeEntry{
|
return &TreeEntry{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
// Type: ObjectTree,
|
// Type: ObjectTree,
|
||||||
|
ptree: t,
|
||||||
gogitTreeEntry: &object.TreeEntry{
|
gogitTreeEntry: &object.TreeEntry{
|
||||||
Name: "",
|
Name: "",
|
||||||
Mode: filemode.Dir,
|
Mode: filemode.Dir,
|
||||||
|
@ -41,60 +41,45 @@ type blameRow struct {
|
|||||||
|
|
||||||
// RefBlame render blame page
|
// RefBlame render blame page
|
||||||
func RefBlame(ctx *context.Context) {
|
func RefBlame(ctx *context.Context) {
|
||||||
fileName := ctx.Repo.TreePath
|
ctx.Data["PageIsViewCode"] = true
|
||||||
if len(fileName) == 0 {
|
ctx.Data["IsBlame"] = true
|
||||||
|
|
||||||
|
// Get current entry user currently looking at.
|
||||||
|
if ctx.Repo.TreePath == "" {
|
||||||
ctx.NotFound(nil)
|
ctx.NotFound(nil)
|
||||||
return
|
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)
|
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err)
|
HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err)
|
||||||
return
|
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["Paths"] = paths
|
||||||
ctx.Data["TreeLink"] = treeLink
|
|
||||||
ctx.Data["TreeNames"] = treeNames
|
ctx.Data["TreeNames"] = treeNames
|
||||||
ctx.Data["BranchLink"] = branchLink
|
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)
|
||||||
ctx.Data["RawFileLink"] = rawLink + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
|
|
||||||
ctx.Data["PageIsViewCode"] = true
|
|
||||||
|
|
||||||
ctx.Data["IsBlame"] = true
|
|
||||||
|
|
||||||
|
blob := entry.Blob()
|
||||||
fileSize := blob.Size()
|
fileSize := blob.Size()
|
||||||
ctx.Data["FileSize"] = fileSize
|
ctx.Data["FileSize"] = fileSize
|
||||||
ctx.Data["FileName"] = blob.Name()
|
ctx.Data["FileName"] = blob.Name()
|
||||||
|
|
||||||
|
tplName := tplRepoViewContent
|
||||||
|
if !ctx.FormBool("only_content") {
|
||||||
|
prepareHomeTreeSideBarSwitch(ctx)
|
||||||
|
tplName = tplRepoView
|
||||||
|
}
|
||||||
|
|
||||||
if fileSize >= setting.UI.MaxDisplayFileSize {
|
if fileSize >= setting.UI.MaxDisplayFileSize {
|
||||||
ctx.Data["IsFileTooLarge"] = true
|
ctx.Data["IsFileTooLarge"] = true
|
||||||
ctx.HTML(http.StatusOK, tplRepoHome)
|
ctx.HTML(http.StatusOK, tplName)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,8 +90,7 @@ func RefBlame(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bypassBlameIgnore, _ := strconv.ParseBool(ctx.FormString("bypass-blame-ignore"))
|
bypassBlameIgnore, _ := strconv.ParseBool(ctx.FormString("bypass-blame-ignore"))
|
||||||
|
result, err := performBlame(ctx, ctx.Repo.Repository, ctx.Repo.Commit, ctx.Repo.TreePath, bypassBlameIgnore)
|
||||||
result, err := performBlame(ctx, ctx.Repo.Repository, ctx.Repo.Commit, fileName, bypassBlameIgnore)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.NotFound(err)
|
ctx.NotFound(err)
|
||||||
return
|
return
|
||||||
@ -122,7 +106,7 @@ func RefBlame(ctx *context.Context) {
|
|||||||
|
|
||||||
renderBlame(ctx, result.Parts, commitNames)
|
renderBlame(ctx, result.Parts, commitNames)
|
||||||
|
|
||||||
ctx.HTML(http.StatusOK, tplRepoHome)
|
ctx.HTML(http.StatusOK, tplName)
|
||||||
}
|
}
|
||||||
|
|
||||||
type blameResult struct {
|
type blameResult struct {
|
||||||
|
@ -11,6 +11,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/git"
|
"code.gitea.io/gitea/modules/git"
|
||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
"code.gitea.io/gitea/services/gitdiff"
|
"code.gitea.io/gitea/services/gitdiff"
|
||||||
|
files_service "code.gitea.io/gitea/services/repository/files"
|
||||||
|
|
||||||
"github.com/go-enry/go-enry/v2"
|
"github.com/go-enry/go-enry/v2"
|
||||||
)
|
)
|
||||||
@ -84,3 +85,12 @@ func transformDiffTreeForUI(diffTree *gitdiff.DiffTree, filesViewedState map[str
|
|||||||
|
|
||||||
return files
|
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})
|
||||||
|
}
|
||||||
|
@ -49,6 +49,8 @@ import (
|
|||||||
const (
|
const (
|
||||||
tplRepoEMPTY templates.TplName = "repo/empty"
|
tplRepoEMPTY templates.TplName = "repo/empty"
|
||||||
tplRepoHome templates.TplName = "repo/home"
|
tplRepoHome templates.TplName = "repo/home"
|
||||||
|
tplRepoView templates.TplName = "repo/view"
|
||||||
|
tplRepoViewContent templates.TplName = "repo/view_content"
|
||||||
tplRepoViewList templates.TplName = "repo/view_list"
|
tplRepoViewList templates.TplName = "repo/view_list"
|
||||||
tplWatchers templates.TplName = "repo/watchers"
|
tplWatchers templates.TplName = "repo/watchers"
|
||||||
tplForks templates.TplName = "repo/forks"
|
tplForks templates.TplName = "repo/forks"
|
||||||
|
@ -9,6 +9,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -17,6 +18,7 @@ import (
|
|||||||
access_model "code.gitea.io/gitea/models/perm/access"
|
access_model "code.gitea.io/gitea/models/perm/access"
|
||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
unit_model "code.gitea.io/gitea/models/unit"
|
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/git"
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
repo_module "code.gitea.io/gitea/modules/repository"
|
repo_module "code.gitea.io/gitea/modules/repository"
|
||||||
@ -328,6 +330,19 @@ func handleRepoHomeFeed(ctx *context.Context) bool {
|
|||||||
return true
|
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
|
// Home render repository home page
|
||||||
func Home(ctx *context.Context) {
|
func Home(ctx *context.Context) {
|
||||||
if handleRepoHomeFeed(ctx) {
|
if handleRepoHomeFeed(ctx) {
|
||||||
@ -341,6 +356,8 @@ func Home(ctx *context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prepareHomeTreeSideBarSwitch(ctx)
|
||||||
|
|
||||||
title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name
|
title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name
|
||||||
if len(ctx.Repo.Repository.Description) > 0 {
|
if len(ctx.Repo.Repository.Description) > 0 {
|
||||||
title += ": " + ctx.Repo.Repository.Description
|
title += ": " + ctx.Repo.Repository.Description
|
||||||
@ -410,8 +427,14 @@ func Home(ctx *context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
ctx.HTML(http.StatusOK, tplRepoHome)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func RedirectRepoTreeToSrc(ctx *context.Context) {
|
func RedirectRepoTreeToSrc(ctx *context.Context) {
|
||||||
// Redirect "/owner/repo/tree/*" requests to "/owner/repo/src/*",
|
// Redirect "/owner/repo/tree/*" requests to "/owner/repo/src/*",
|
||||||
|
26
routers/web/user/setting/settings.go
Normal file
26
routers/web/user/setting/settings.go
Normal file
@ -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()
|
||||||
|
}
|
@ -580,6 +580,7 @@ func registerRoutes(m *web.Router) {
|
|||||||
m.Group("/user/settings", func() {
|
m.Group("/user/settings", func() {
|
||||||
m.Get("", user_setting.Profile)
|
m.Get("", user_setting.Profile)
|
||||||
m.Post("", web.Bind(forms.UpdateProfileForm{}), user_setting.ProfilePost)
|
m.Post("", web.Bind(forms.UpdateProfileForm{}), user_setting.ProfilePost)
|
||||||
|
m.Post("/update_preferences", user_setting.UpdatePreferences)
|
||||||
m.Get("/change_password", auth.MustChangePassword)
|
m.Get("/change_password", auth.MustChangePassword)
|
||||||
m.Post("/change_password", web.Bind(forms.MustChangePasswordForm{}), auth.MustChangePasswordPost)
|
m.Post("/change_password", web.Bind(forms.MustChangePasswordForm{}), auth.MustChangePasswordPost)
|
||||||
m.Post("/avatar", web.Bind(forms.AvatarForm{}), user_setting.AvatarPost)
|
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("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.TreeList)
|
||||||
m.Get("/commit/*", context.RepoRefByType(git.RefTypeCommit), 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.Get("/compare", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff)
|
||||||
m.Combo("/compare/*", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists).
|
m.Combo("/compare/*", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists).
|
||||||
Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff).
|
Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff).
|
||||||
|
@ -20,6 +20,7 @@ import (
|
|||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
user_model "code.gitea.io/gitea/models/user"
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
"code.gitea.io/gitea/modules/cache"
|
"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/gitrepo"
|
||||||
"code.gitea.io/gitea/modules/reqctx"
|
"code.gitea.io/gitea/modules/reqctx"
|
||||||
"code.gitea.io/gitea/modules/session"
|
"code.gitea.io/gitea/modules/session"
|
||||||
@ -30,6 +31,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func mockRequest(t *testing.T, reqPath string) *http.Request {
|
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 := context.NewBaseContext(resp, req)
|
||||||
base.Data = middleware.GetContextData(req.Context())
|
base.Data = middleware.GetContextData(req.Context())
|
||||||
base.Locale = &translation.MockLocale{}
|
base.Locale = &translation.MockLocale{}
|
||||||
ctx := &context.APIContext{Base: base}
|
ctx := &context.APIContext{Base: base, Repo: &context.Repository{}}
|
||||||
chiCtx := chi.NewRouteContext()
|
chiCtx := chi.NewRouteContext()
|
||||||
ctx.SetContextValue(chi.RouteCtxKey, chiCtx)
|
ctx.SetContextValue(chi.RouteCtxKey, chiCtx)
|
||||||
return ctx, resp
|
return ctx, resp
|
||||||
@ -106,13 +108,13 @@ func MockPrivateContext(t *testing.T, reqPath string) (*context.PrivateContext,
|
|||||||
// LoadRepo load a repo into a test context.
|
// LoadRepo load a repo into a test context.
|
||||||
func LoadRepo(t *testing.T, ctx gocontext.Context, repoID int64) {
|
func LoadRepo(t *testing.T, ctx gocontext.Context, repoID int64) {
|
||||||
var doer *user_model.User
|
var doer *user_model.User
|
||||||
repo := &context.Repository{}
|
var repo *context.Repository
|
||||||
switch ctx := ctx.(type) {
|
switch ctx := ctx.(type) {
|
||||||
case *context.Context:
|
case *context.Context:
|
||||||
ctx.Repo = repo
|
repo = ctx.Repo
|
||||||
doer = ctx.Doer
|
doer = ctx.Doer
|
||||||
case *context.APIContext:
|
case *context.APIContext:
|
||||||
ctx.Repo = repo
|
repo = ctx.Repo
|
||||||
doer = ctx.Doer
|
doer = ctx.Doer
|
||||||
default:
|
default:
|
||||||
assert.FailNow(t, "context is not *context.Context or *context.APIContext")
|
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)
|
gitRepo, err := gitrepo.OpenRepository(ctx, repo.Repository)
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer gitRepo.Close()
|
defer gitRepo.Close()
|
||||||
branch, err := gitRepo.GetHEADBranch()
|
|
||||||
assert.NoError(t, err)
|
if repo.RefFullName == "" {
|
||||||
assert.NotNil(t, branch)
|
repo.RefFullName = git_module.RefNameFromBranch(repo.Repository.DefaultBranch)
|
||||||
if branch != nil {
|
|
||||||
repo.Commit, err = gitRepo.GetBranchCommit(branch.Name)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
}
|
}
|
||||||
|
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
|
// LoadUser load a user into a test context
|
||||||
|
@ -7,9 +7,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
repo_model "code.gitea.io/gitea/models/repo"
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
"code.gitea.io/gitea/modules/git"
|
"code.gitea.io/gitea/modules/git"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
@ -118,3 +122,98 @@ func GetTreeBySHA(ctx context.Context, repo *repo_model.Repository, gitRepo *git
|
|||||||
}
|
}
|
||||||
return tree, nil
|
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)
|
||||||
|
}
|
||||||
|
@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.gitea.io/gitea/models/unittest"
|
"code.gitea.io/gitea/models/unittest"
|
||||||
|
"code.gitea.io/gitea/modules/git"
|
||||||
api "code.gitea.io/gitea/modules/structs"
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/services/contexttest"
|
"code.gitea.io/gitea/services/contexttest"
|
||||||
|
|
||||||
@ -50,3 +51,51 @@ func TestGetTreeBySHA(t *testing.T) {
|
|||||||
|
|
||||||
assert.EqualValues(t, expectedTree, tree)
|
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)
|
||||||
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
{{template "base/head" .}}
|
{{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}}">
|
<div role="main" aria-label="{{.Title}}" class="page-content repository file list {{if .IsBlame}}blame{{end}}">
|
||||||
{{template "repo/header" .}}
|
{{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" .}}
|
{{template "base/alert" .}}
|
||||||
|
|
||||||
{{if .Repository.IsArchived}}
|
{{if .Repository.IsArchived}}
|
||||||
@ -16,112 +17,9 @@
|
|||||||
|
|
||||||
{{template "repo/code/recently_pushed_new_branches" .}}
|
{{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="{{Iif $showSidebar "repo-grid-filelist-sidebar" "repo-grid-filelist-only"}}">
|
||||||
<div class="repo-home-filelist">
|
<div class="repo-home-filelist">
|
||||||
{{template "repo/sub_menu" .}}
|
{{template "repo/view_content" .}}
|
||||||
<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}}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if $showSidebar}}
|
{{if $showSidebar}}
|
||||||
|
29
templates/repo/view.tmpl
Normal file
29
templates/repo/view.tmpl
Normal file
@ -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" .}}
|
111
templates/repo/view_content.tmpl
Normal file
111
templates/repo/view_content.tmpl
Normal file
@ -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}}
|
15
templates/repo/view_file_tree.tmpl
Normal file
15
templates/repo/view_file_tree.tmpl
Normal file
@ -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>
|
@ -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 {
|
.language-stats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
62
web_src/js/components/ViewFileTree.vue
Normal file
62
web_src/js/components/ViewFileTree.vue
Normal file
@ -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>
|
156
web_src/js/components/ViewFileTreeItem.vue
Normal file
156
web_src/js/components/ViewFileTreeItem.vue
Normal file
@ -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>
|
37
web_src/js/features/repo-view-file-tree.ts
Normal file
37
web_src/js/features/repo-view-file-tree.ts
Normal file
@ -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);
|
||||||
|
}
|
@ -64,6 +64,7 @@ import {initFootLanguageMenu, initGlobalDropdown, initGlobalInput, initGlobalTab
|
|||||||
import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
|
import {initGlobalButtonClickOnEnter, initGlobalButtons, initGlobalDeleteButton} from './features/common-button.ts';
|
||||||
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
|
import {initGlobalComboMarkdownEditor, initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
|
||||||
import {callInitFunctions} from './modules/init.ts';
|
import {callInitFunctions} from './modules/init.ts';
|
||||||
|
import {initRepoViewFileTree} from './features/repo-view-file-tree.ts';
|
||||||
|
|
||||||
initGiteaFomantic();
|
initGiteaFomantic();
|
||||||
initSubmitEventPolyfill();
|
initSubmitEventPolyfill();
|
||||||
@ -139,6 +140,7 @@ onDomReady(() => {
|
|||||||
initRepoRelease,
|
initRepoRelease,
|
||||||
initRepoReleaseNew,
|
initRepoReleaseNew,
|
||||||
initRepoTopicBar,
|
initRepoTopicBar,
|
||||||
|
initRepoViewFileTree,
|
||||||
initRepoWikiForm,
|
initRepoWikiForm,
|
||||||
initRepository,
|
initRepository,
|
||||||
initRepositoryActionView,
|
initRepositoryActionView,
|
||||||
|
@ -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 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 octiconFileDirectoryOpenFill from '../../public/assets/img/svg/octicon-file-directory-open-fill.svg';
|
||||||
import octiconFileSubmodule from '../../public/assets/img/svg/octicon-file-submodule.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 octiconFilter from '../../public/assets/img/svg/octicon-filter.svg';
|
||||||
import octiconGear from '../../public/assets/img/svg/octicon-gear.svg';
|
import octiconGear from '../../public/assets/img/svg/octicon-gear.svg';
|
||||||
import octiconGitBranch from '../../public/assets/img/svg/octicon-git-branch.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-fill': octiconFileDirectoryFill,
|
||||||
'octicon-file-directory-open-fill': octiconFileDirectoryOpenFill,
|
'octicon-file-directory-open-fill': octiconFileDirectoryOpenFill,
|
||||||
'octicon-file-submodule': octiconFileSubmodule,
|
'octicon-file-submodule': octiconFileSubmodule,
|
||||||
|
'octicon-file-symlink-file': octiconFileSymlinkFile,
|
||||||
'octicon-filter': octiconFilter,
|
'octicon-filter': octiconFilter,
|
||||||
'octicon-gear': octiconGear,
|
'octicon-gear': octiconGear,
|
||||||
'octicon-git-branch': octiconGitBranch,
|
'octicon-git-branch': octiconGitBranch,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user