Skip to content

Commit 92f997c

Browse files
kerwin612lunnywxiaoguang
authored
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 <[email protected]> Co-authored-by: wxiaoguang <[email protected]>
1 parent 926f0a1 commit 92f997c

22 files changed

+696
-162
lines changed

models/user/setting_keys.go

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const (
1010
SettingsKeyDiffWhitespaceBehavior = "diff.whitespace_behaviour"
1111
// SettingsKeyShowOutdatedComments is the setting key wether or not to show outdated comments in PRs
1212
SettingsKeyShowOutdatedComments = "comment_code.show_outdated"
13+
1314
// UserActivityPubPrivPem is user's private key
1415
UserActivityPubPrivPem = "activitypub.priv_pem"
1516
// UserActivityPubPubPem is user's public key
@@ -18,4 +19,6 @@ const (
1819
SignupIP = "signup.ip"
1920
// SignupUserAgent is the user agent that the user signed up with
2021
SignupUserAgent = "signup.user_agent"
22+
23+
SettingsKeyCodeViewShowFileTree = "code_view.show_file_tree"
2124
)

modules/git/parse_nogogit.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func ParseTreeEntries(data []byte) ([]*TreeEntry, error) {
1919
return parseTreeEntries(data, nil)
2020
}
2121

22-
// parseTreeEntries FIXME this function's design is not right, it should make the caller read all data into memory
22+
// parseTreeEntries FIXME this function's design is not right, it should not make the caller read all data into memory
2323
func parseTreeEntries(data []byte, ptree *Tree) ([]*TreeEntry, error) {
2424
entries := make([]*TreeEntry, 0, bytes.Count(data, []byte{'\n'})+1)
2525
for pos := 0; pos < len(data); {

modules/git/tree_blob_gogit.go

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func (t *Tree) GetTreeEntryByPath(relpath string) (*TreeEntry, error) {
2121
return &TreeEntry{
2222
ID: t.ID,
2323
// Type: ObjectTree,
24+
ptree: t,
2425
gogitTreeEntry: &object.TreeEntry{
2526
Name: "",
2627
Mode: filemode.Dir,

routers/web/repo/blame.go

+22-38
Original file line numberDiff line numberDiff line change
@@ -41,60 +41,45 @@ type blameRow struct {
4141

4242
// RefBlame render blame page
4343
func RefBlame(ctx *context.Context) {
44-
fileName := ctx.Repo.TreePath
45-
if len(fileName) == 0 {
44+
ctx.Data["PageIsViewCode"] = true
45+
ctx.Data["IsBlame"] = true
46+
47+
// Get current entry user currently looking at.
48+
if ctx.Repo.TreePath == "" {
4649
ctx.NotFound(nil)
4750
return
4851
}
49-
50-
branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
51-
treeLink := branchLink
52-
rawLink := ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL()
53-
54-
if len(ctx.Repo.TreePath) > 0 {
55-
treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
56-
}
57-
58-
var treeNames []string
59-
paths := make([]string, 0, 5)
60-
if len(ctx.Repo.TreePath) > 0 {
61-
treeNames = strings.Split(ctx.Repo.TreePath, "/")
62-
for i := range treeNames {
63-
paths = append(paths, strings.Join(treeNames[:i+1], "/"))
64-
}
65-
66-
ctx.Data["HasParentPath"] = true
67-
if len(paths)-2 >= 0 {
68-
ctx.Data["ParentPath"] = "/" + paths[len(paths)-1]
69-
}
70-
}
71-
72-
// Get current entry user currently looking at.
7352
entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
7453
if err != nil {
7554
HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err)
7655
return
7756
}
7857

79-
blob := entry.Blob()
58+
treeNames := strings.Split(ctx.Repo.TreePath, "/")
59+
var paths []string
60+
for i := range treeNames {
61+
paths = append(paths, strings.Join(treeNames[:i+1], "/"))
62+
}
8063

8164
ctx.Data["Paths"] = paths
82-
ctx.Data["TreeLink"] = treeLink
8365
ctx.Data["TreeNames"] = treeNames
84-
ctx.Data["BranchLink"] = branchLink
85-
86-
ctx.Data["RawFileLink"] = rawLink + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
87-
ctx.Data["PageIsViewCode"] = true
88-
89-
ctx.Data["IsBlame"] = true
66+
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
67+
ctx.Data["RawFileLink"] = ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
9068

69+
blob := entry.Blob()
9170
fileSize := blob.Size()
9271
ctx.Data["FileSize"] = fileSize
9372
ctx.Data["FileName"] = blob.Name()
9473

74+
tplName := tplRepoViewContent
75+
if !ctx.FormBool("only_content") {
76+
prepareHomeTreeSideBarSwitch(ctx)
77+
tplName = tplRepoView
78+
}
79+
9580
if fileSize >= setting.UI.MaxDisplayFileSize {
9681
ctx.Data["IsFileTooLarge"] = true
97-
ctx.HTML(http.StatusOK, tplRepoHome)
82+
ctx.HTML(http.StatusOK, tplName)
9883
return
9984
}
10085

@@ -105,8 +90,7 @@ func RefBlame(ctx *context.Context) {
10590
}
10691

10792
bypassBlameIgnore, _ := strconv.ParseBool(ctx.FormString("bypass-blame-ignore"))
108-
109-
result, err := performBlame(ctx, ctx.Repo.Repository, ctx.Repo.Commit, fileName, bypassBlameIgnore)
93+
result, err := performBlame(ctx, ctx.Repo.Repository, ctx.Repo.Commit, ctx.Repo.TreePath, bypassBlameIgnore)
11094
if err != nil {
11195
ctx.NotFound(err)
11296
return
@@ -122,7 +106,7 @@ func RefBlame(ctx *context.Context) {
122106

123107
renderBlame(ctx, result.Parts, commitNames)
124108

125-
ctx.HTML(http.StatusOK, tplRepoHome)
109+
ctx.HTML(http.StatusOK, tplName)
126110
}
127111

128112
type blameResult struct {

routers/web/repo/treelist.go

+10
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"code.gitea.io/gitea/modules/git"
1212
"code.gitea.io/gitea/services/context"
1313
"code.gitea.io/gitea/services/gitdiff"
14+
files_service "code.gitea.io/gitea/services/repository/files"
1415

1516
"github.com/go-enry/go-enry/v2"
1617
)
@@ -84,3 +85,12 @@ func transformDiffTreeForUI(diffTree *gitdiff.DiffTree, filesViewedState map[str
8485

8586
return files
8687
}
88+
89+
func TreeViewNodes(ctx *context.Context) {
90+
results, err := files_service.GetTreeViewNodes(ctx, ctx.Repo.Commit, ctx.Repo.TreePath, ctx.FormString("sub_path"))
91+
if err != nil {
92+
ctx.ServerError("GetTreeViewNodes", err)
93+
return
94+
}
95+
ctx.JSON(http.StatusOK, map[string]any{"fileTreeNodes": results})
96+
}

routers/web/repo/view.go

+8-6
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,14 @@ import (
4747
)
4848

4949
const (
50-
tplRepoEMPTY templates.TplName = "repo/empty"
51-
tplRepoHome templates.TplName = "repo/home"
52-
tplRepoViewList templates.TplName = "repo/view_list"
53-
tplWatchers templates.TplName = "repo/watchers"
54-
tplForks templates.TplName = "repo/forks"
55-
tplMigrating templates.TplName = "repo/migrate/migrating"
50+
tplRepoEMPTY templates.TplName = "repo/empty"
51+
tplRepoHome templates.TplName = "repo/home"
52+
tplRepoView templates.TplName = "repo/view"
53+
tplRepoViewContent templates.TplName = "repo/view_content"
54+
tplRepoViewList templates.TplName = "repo/view_list"
55+
tplWatchers templates.TplName = "repo/watchers"
56+
tplForks templates.TplName = "repo/forks"
57+
tplMigrating templates.TplName = "repo/migrate/migrating"
5658
)
5759

5860
type fileInfo struct {

routers/web/repo/view_home.go

+24-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"html/template"
1010
"net/http"
1111
"path"
12+
"strconv"
1213
"strings"
1314
"time"
1415

@@ -17,6 +18,7 @@ import (
1718
access_model "code.gitea.io/gitea/models/perm/access"
1819
repo_model "code.gitea.io/gitea/models/repo"
1920
unit_model "code.gitea.io/gitea/models/unit"
21+
user_model "code.gitea.io/gitea/models/user"
2022
"code.gitea.io/gitea/modules/git"
2123
"code.gitea.io/gitea/modules/log"
2224
repo_module "code.gitea.io/gitea/modules/repository"
@@ -328,6 +330,19 @@ func handleRepoHomeFeed(ctx *context.Context) bool {
328330
return true
329331
}
330332

333+
func prepareHomeTreeSideBarSwitch(ctx *context.Context) {
334+
showFileTree := true
335+
if ctx.Doer != nil {
336+
v, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyCodeViewShowFileTree, "true")
337+
if err != nil {
338+
log.Error("GetUserSetting: %v", err)
339+
} else {
340+
showFileTree, _ = strconv.ParseBool(v)
341+
}
342+
}
343+
ctx.Data["UserSettingCodeViewShowFileTree"] = showFileTree
344+
}
345+
331346
// Home render repository home page
332347
func Home(ctx *context.Context) {
333348
if handleRepoHomeFeed(ctx) {
@@ -341,6 +356,8 @@ func Home(ctx *context.Context) {
341356
return
342357
}
343358

359+
prepareHomeTreeSideBarSwitch(ctx)
360+
344361
title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name
345362
if len(ctx.Repo.Repository.Description) > 0 {
346363
title += ": " + ctx.Repo.Repository.Description
@@ -410,7 +427,13 @@ func Home(ctx *context.Context) {
410427
}
411428
}
412429

413-
ctx.HTML(http.StatusOK, tplRepoHome)
430+
if ctx.FormBool("only_content") {
431+
ctx.HTML(http.StatusOK, tplRepoViewContent)
432+
} else if len(treeNames) != 0 {
433+
ctx.HTML(http.StatusOK, tplRepoView)
434+
} else {
435+
ctx.HTML(http.StatusOK, tplRepoHome)
436+
}
414437
}
415438

416439
func RedirectRepoTreeToSrc(ctx *context.Context) {

routers/web/user/setting/settings.go

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2025 The Gitea Authors. All rights reserved.
2+
// SPDX-License-Identifier: MIT
3+
4+
package setting
5+
6+
import (
7+
"net/http"
8+
"strconv"
9+
10+
user_model "code.gitea.io/gitea/models/user"
11+
"code.gitea.io/gitea/modules/json"
12+
"code.gitea.io/gitea/services/context"
13+
)
14+
15+
func UpdatePreferences(ctx *context.Context) {
16+
type preferencesForm struct {
17+
CodeViewShowFileTree bool `json:"codeViewShowFileTree"`
18+
}
19+
form := &preferencesForm{}
20+
if err := json.NewDecoder(ctx.Req.Body).Decode(&form); err != nil {
21+
ctx.HTTPError(http.StatusBadRequest, "json decode failed")
22+
return
23+
}
24+
_ = user_model.SetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyCodeViewShowFileTree, strconv.FormatBool(form.CodeViewShowFileTree))
25+
ctx.JSONOK()
26+
}

routers/web/web.go

+6
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,7 @@ func registerRoutes(m *web.Router) {
580580
m.Group("/user/settings", func() {
581581
m.Get("", user_setting.Profile)
582582
m.Post("", web.Bind(forms.UpdateProfileForm{}), user_setting.ProfilePost)
583+
m.Post("/update_preferences", user_setting.UpdatePreferences)
583584
m.Get("/change_password", auth.MustChangePassword)
584585
m.Post("/change_password", web.Bind(forms.MustChangePasswordForm{}), auth.MustChangePasswordPost)
585586
m.Post("/avatar", web.Bind(forms.AvatarForm{}), user_setting.AvatarPost)
@@ -1175,6 +1176,11 @@ func registerRoutes(m *web.Router) {
11751176
m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.TreeList)
11761177
m.Get("/commit/*", context.RepoRefByType(git.RefTypeCommit), repo.TreeList)
11771178
})
1179+
m.Group("/tree-view", func() {
1180+
m.Get("/branch/*", context.RepoRefByType(git.RefTypeBranch), repo.TreeViewNodes)
1181+
m.Get("/tag/*", context.RepoRefByType(git.RefTypeTag), repo.TreeViewNodes)
1182+
m.Get("/commit/*", context.RepoRefByType(git.RefTypeCommit), repo.TreeViewNodes)
1183+
})
11781184
m.Get("/compare", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists, repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff)
11791185
m.Combo("/compare/*", repo.MustBeNotEmpty, repo.SetEditorconfigIfExists).
11801186
Get(repo.SetDiffViewStyle, repo.SetWhitespaceBehavior, repo.CompareDiff).

services/contexttest/context_tests.go

+15-11
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"code.gitea.io/gitea/models/unittest"
2121
user_model "code.gitea.io/gitea/models/user"
2222
"code.gitea.io/gitea/modules/cache"
23+
git_module "code.gitea.io/gitea/modules/git"
2324
"code.gitea.io/gitea/modules/gitrepo"
2425
"code.gitea.io/gitea/modules/reqctx"
2526
"code.gitea.io/gitea/modules/session"
@@ -30,6 +31,7 @@ import (
3031

3132
"github.com/go-chi/chi/v5"
3233
"github.com/stretchr/testify/assert"
34+
"github.com/stretchr/testify/require"
3335
)
3436

3537
func mockRequest(t *testing.T, reqPath string) *http.Request {
@@ -85,7 +87,7 @@ func MockAPIContext(t *testing.T, reqPath string) (*context.APIContext, *httptes
8587
base := context.NewBaseContext(resp, req)
8688
base.Data = middleware.GetContextData(req.Context())
8789
base.Locale = &translation.MockLocale{}
88-
ctx := &context.APIContext{Base: base}
90+
ctx := &context.APIContext{Base: base, Repo: &context.Repository{}}
8991
chiCtx := chi.NewRouteContext()
9092
ctx.SetContextValue(chi.RouteCtxKey, chiCtx)
9193
return ctx, resp
@@ -106,13 +108,13 @@ func MockPrivateContext(t *testing.T, reqPath string) (*context.PrivateContext,
106108
// LoadRepo load a repo into a test context.
107109
func LoadRepo(t *testing.T, ctx gocontext.Context, repoID int64) {
108110
var doer *user_model.User
109-
repo := &context.Repository{}
111+
var repo *context.Repository
110112
switch ctx := ctx.(type) {
111113
case *context.Context:
112-
ctx.Repo = repo
114+
repo = ctx.Repo
113115
doer = ctx.Doer
114116
case *context.APIContext:
115-
ctx.Repo = repo
117+
repo = ctx.Repo
116118
doer = ctx.Doer
117119
default:
118120
assert.FailNow(t, "context is not *context.Context or *context.APIContext")
@@ -140,15 +142,17 @@ func LoadRepoCommit(t *testing.T, ctx gocontext.Context) {
140142
}
141143

142144
gitRepo, err := gitrepo.OpenRepository(ctx, repo.Repository)
143-
assert.NoError(t, err)
145+
require.NoError(t, err)
144146
defer gitRepo.Close()
145-
branch, err := gitRepo.GetHEADBranch()
146-
assert.NoError(t, err)
147-
assert.NotNil(t, branch)
148-
if branch != nil {
149-
repo.Commit, err = gitRepo.GetBranchCommit(branch.Name)
150-
assert.NoError(t, err)
147+
148+
if repo.RefFullName == "" {
149+
repo.RefFullName = git_module.RefNameFromBranch(repo.Repository.DefaultBranch)
150+
}
151+
if repo.RefFullName.IsPull() {
152+
repo.BranchName = repo.RefFullName.ShortName()
151153
}
154+
repo.Commit, err = gitRepo.GetCommit(repo.RefFullName.String())
155+
require.NoError(t, err)
152156
}
153157

154158
// LoadUser load a user into a test context

0 commit comments

Comments
 (0)