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,