diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go
index 3569a356d1523..973d68d45c8ae 100644
--- a/routers/web/repo/commit.go
+++ b/routers/web/repo/commit.go
@@ -369,7 +369,7 @@ func Diff(ctx *context.Context) {
 			return
 		}
 
-		ctx.PageData["DiffFiles"] = transformDiffTreeForUI(diffTree, nil)
+		ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(diffTree, nil)
 	}
 
 	statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptionsAll)
diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go
index 2c36477e6a85f..13fbac981c3e8 100644
--- a/routers/web/repo/compare.go
+++ b/routers/web/repo/compare.go
@@ -639,7 +639,7 @@ func PrepareCompareDiff(
 			return false
 		}
 
-		ctx.PageData["DiffFiles"] = transformDiffTreeForUI(diffTree, nil)
+		ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(diffTree, nil)
 	}
 
 	headCommit, err := ci.HeadGitRepo.GetCommit(headCommitID)
diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go
index c72664f8e9035..0124c163f39b8 100644
--- a/routers/web/repo/pull.go
+++ b/routers/web/repo/pull.go
@@ -759,12 +759,9 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi
 	// have to load only the diff and not get the viewed information
 	// as the viewed information is designed to be loaded only on latest PR
 	// diff and if you're signed in.
-	shouldGetUserSpecificDiff := false
-	if !ctx.IsSigned || willShowSpecifiedCommit || willShowSpecifiedCommitRange {
-		// do nothing
-	} else {
-		shouldGetUserSpecificDiff = true
-		err = gitdiff.SyncUserSpecificDiff(ctx, ctx.Doer.ID, pull, gitRepo, diff, diffOptions, files...)
+	var reviewState *pull_model.ReviewState
+	if ctx.IsSigned && !willShowSpecifiedCommit && !willShowSpecifiedCommitRange {
+		reviewState, err = gitdiff.SyncUserSpecificDiff(ctx, ctx.Doer.ID, pull, gitRepo, diff, diffOptions)
 		if err != nil {
 			ctx.ServerError("SyncUserSpecificDiff", err)
 			return
@@ -823,18 +820,11 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi
 			ctx.ServerError("GetDiffTree", err)
 			return
 		}
-
-		filesViewedState := make(map[string]pull_model.ViewedState)
-		if shouldGetUserSpecificDiff {
-			// This sort of sucks because we already fetch this when getting the diff
-			review, err := pull_model.GetNewestReviewState(ctx, ctx.Doer.ID, issue.ID)
-			if err == nil && review != nil && review.UpdatedFiles != nil {
-				// If there wasn't an error and we have a review with updated files, use that
-				filesViewedState = review.UpdatedFiles
-			}
+		var filesViewedState map[string]pull_model.ViewedState
+		if reviewState != nil {
+			filesViewedState = reviewState.UpdatedFiles
 		}
-
-		ctx.PageData["DiffFiles"] = transformDiffTreeForUI(diffTree, filesViewedState)
+		ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(diffTree, filesViewedState)
 	}
 
 	ctx.Data["Diff"] = diff
diff --git a/routers/web/repo/treelist.go b/routers/web/repo/treelist.go
index 9c5ec8f206731..994b2d0c0af42 100644
--- a/routers/web/repo/treelist.go
+++ b/routers/web/repo/treelist.go
@@ -5,6 +5,7 @@ package repo
 
 import (
 	"net/http"
+	"strings"
 
 	pull_model "code.gitea.io/gitea/models/pull"
 	"code.gitea.io/gitea/modules/base"
@@ -57,34 +58,85 @@ func isExcludedEntry(entry *git.TreeEntry) bool {
 	return false
 }
 
-type FileDiffFile struct {
-	Name        string
+// WebDiffFileItem is used by frontend, check the field names in frontend before changing
+type WebDiffFileItem struct {
+	FullName    string
+	DisplayName string
 	NameHash    string
-	IsSubmodule bool
+	DiffStatus  string
+	EntryMode   string
 	IsViewed    bool
-	Status      string
+	Children    []*WebDiffFileItem
+	// TODO: add icon support in the future
 }
 
-// transformDiffTreeForUI transforms a DiffTree into a slice of FileDiffFile for UI rendering
+// WebDiffFileTree is used by frontend, check the field names in frontend before changing
+type WebDiffFileTree struct {
+	TreeRoot WebDiffFileItem
+}
+
+// transformDiffTreeForWeb transforms a gitdiff.DiffTree into a WebDiffFileTree for Web UI rendering
 // it also takes a map of file names to their viewed state, which is used to mark files as viewed
-func transformDiffTreeForUI(diffTree *gitdiff.DiffTree, filesViewedState map[string]pull_model.ViewedState) []FileDiffFile {
-	files := make([]FileDiffFile, 0, len(diffTree.Files))
+func transformDiffTreeForWeb(diffTree *gitdiff.DiffTree, filesViewedState map[string]pull_model.ViewedState) (dft WebDiffFileTree) {
+	dirNodes := map[string]*WebDiffFileItem{"": &dft.TreeRoot}
+	addItem := func(item *WebDiffFileItem) {
+		var parentPath string
+		pos := strings.LastIndexByte(item.FullName, '/')
+		if pos == -1 {
+			item.DisplayName = item.FullName
+		} else {
+			parentPath = item.FullName[:pos]
+			item.DisplayName = item.FullName[pos+1:]
+		}
+		parentNode, parentExists := dirNodes[parentPath]
+		if !parentExists {
+			parentNode = &dft.TreeRoot
+			fields := strings.Split(parentPath, "/")
+			for idx, field := range fields {
+				nodePath := strings.Join(fields[:idx+1], "/")
+				node, ok := dirNodes[nodePath]
+				if !ok {
+					node = &WebDiffFileItem{EntryMode: "tree", DisplayName: field, FullName: nodePath}
+					dirNodes[nodePath] = node
+					parentNode.Children = append(parentNode.Children, node)
+				}
+				parentNode = node
+			}
+		}
+		parentNode.Children = append(parentNode.Children, item)
+	}
 
 	for _, file := range diffTree.Files {
-		nameHash := git.HashFilePathForWebUI(file.HeadPath)
-		isSubmodule := file.HeadMode == git.EntryModeCommit
-		isViewed := filesViewedState[file.HeadPath] == pull_model.Viewed
-
-		files = append(files, FileDiffFile{
-			Name:        file.HeadPath,
-			NameHash:    nameHash,
-			IsSubmodule: isSubmodule,
-			IsViewed:    isViewed,
-			Status:      file.Status,
-		})
+		item := &WebDiffFileItem{FullName: file.HeadPath, DiffStatus: file.Status}
+		item.IsViewed = filesViewedState[item.FullName] == pull_model.Viewed
+		item.NameHash = git.HashFilePathForWebUI(item.FullName)
+
+		switch file.HeadMode {
+		case git.EntryModeTree:
+			item.EntryMode = "tree"
+		case git.EntryModeCommit:
+			item.EntryMode = "commit" // submodule
+		default:
+			// default to empty, and will be treated as "blob" file because there is no "symlink" support yet
+		}
+		addItem(item)
 	}
 
-	return files
+	var mergeSingleDir func(node *WebDiffFileItem)
+	mergeSingleDir = func(node *WebDiffFileItem) {
+		if len(node.Children) == 1 {
+			if child := node.Children[0]; child.EntryMode == "tree" {
+				node.FullName = child.FullName
+				node.DisplayName = node.DisplayName + "/" + child.DisplayName
+				node.Children = child.Children
+				mergeSingleDir(node)
+			}
+		}
+	}
+	for _, node := range dft.TreeRoot.Children {
+		mergeSingleDir(node)
+	}
+	return dft
 }
 
 func TreeViewNodes(ctx *context.Context) {
diff --git a/routers/web/repo/treelist_test.go b/routers/web/repo/treelist_test.go
new file mode 100644
index 0000000000000..2dff64a028fe7
--- /dev/null
+++ b/routers/web/repo/treelist_test.go
@@ -0,0 +1,60 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package repo
+
+import (
+	"testing"
+
+	pull_model "code.gitea.io/gitea/models/pull"
+	"code.gitea.io/gitea/modules/git"
+	"code.gitea.io/gitea/services/gitdiff"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestTransformDiffTreeForWeb(t *testing.T) {
+	ret := transformDiffTreeForWeb(&gitdiff.DiffTree{Files: []*gitdiff.DiffTreeRecord{
+		{
+			Status:   "changed",
+			HeadPath: "dir-a/dir-a-x/file-deep",
+			HeadMode: git.EntryModeBlob,
+		},
+		{
+			Status:   "added",
+			HeadPath: "file1",
+			HeadMode: git.EntryModeBlob,
+		},
+	}}, map[string]pull_model.ViewedState{
+		"dir-a/dir-a-x/file-deep": pull_model.Viewed,
+	})
+
+	assert.Equal(t, WebDiffFileTree{
+		TreeRoot: WebDiffFileItem{
+			Children: []*WebDiffFileItem{
+				{
+					EntryMode:   "tree",
+					DisplayName: "dir-a/dir-a-x",
+					FullName:    "dir-a/dir-a-x",
+					Children: []*WebDiffFileItem{
+						{
+							EntryMode:   "",
+							DisplayName: "file-deep",
+							FullName:    "dir-a/dir-a-x/file-deep",
+							NameHash:    "4acf7eef1c943a09e9f754e93ff190db8583236b",
+							DiffStatus:  "changed",
+							IsViewed:    true,
+						},
+					},
+				},
+				{
+					EntryMode:   "",
+					DisplayName: "file1",
+					FullName:    "file1",
+					NameHash:    "60b27f004e454aca81b0480209cce5081ec52390",
+					DiffStatus:  "added",
+				},
+			},
+		},
+	}, ret)
+}
diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go
index 9ee86d9dfc0f8..a8599453782ce 100644
--- a/services/gitdiff/gitdiff.go
+++ b/services/gitdiff/gitdiff.go
@@ -1337,10 +1337,13 @@ func GetDiffShortStat(gitRepo *git.Repository, beforeCommitID, afterCommitID str
 
 // SyncUserSpecificDiff inserts user-specific data such as which files the user has already viewed on the given diff
 // Additionally, the database is updated asynchronously if files have changed since the last review
-func SyncUserSpecificDiff(ctx context.Context, userID int64, pull *issues_model.PullRequest, gitRepo *git.Repository, diff *Diff, opts *DiffOptions, files ...string) error {
+func SyncUserSpecificDiff(ctx context.Context, userID int64, pull *issues_model.PullRequest, gitRepo *git.Repository, diff *Diff, opts *DiffOptions) (*pull_model.ReviewState, error) {
 	review, err := pull_model.GetNewestReviewState(ctx, userID, pull.ID)
-	if err != nil || review == nil || review.UpdatedFiles == nil {
-		return err
+	if err != nil {
+		return nil, err
+	}
+	if review == nil || len(review.UpdatedFiles) == 0 {
+		return review, nil
 	}
 
 	latestCommit := opts.AfterCommitID
@@ -1393,11 +1396,11 @@ outer:
 		err := pull_model.UpdateReviewState(ctx, review.UserID, review.PullID, review.CommitSHA, filesChangedSinceLastDiff)
 		if err != nil {
 			log.Warn("Could not update review for user %d, pull %d, commit %s and the changed files %v: %v", review.UserID, review.PullID, review.CommitSHA, filesChangedSinceLastDiff, err)
-			return err
+			return nil, err
 		}
 	}
 
-	return nil
+	return review, err
 }
 
 // CommentAsDiff returns c.Patch as *Diff
diff --git a/web_src/js/components/DiffFileTree.vue b/web_src/js/components/DiffFileTree.vue
index 381a1c3ca420b..5426a672cbe1e 100644
--- a/web_src/js/components/DiffFileTree.vue
+++ b/web_src/js/components/DiffFileTree.vue
@@ -1,21 +1,14 @@
 <script lang="ts" setup>
 import DiffFileTreeItem from './DiffFileTreeItem.vue';
 import {toggleElem} from '../utils/dom.ts';
-import {diffTreeStore} from '../modules/stores.ts';
+import {diffTreeStore} from '../modules/diff-file.ts';
 import {setFileFolding} from '../features/file-fold.ts';
-import {computed, onMounted, onUnmounted} from 'vue';
-import {pathListToTree, mergeChildIfOnlyOneDir} from '../utils/filetree.ts';
+import {onMounted, onUnmounted} from 'vue';
 
 const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
 
 const store = diffTreeStore();
 
-const fileTree = computed(() => {
-  const result = pathListToTree(store.files);
-  mergeChildIfOnlyOneDir(result); // mutation
-  return result;
-});
-
 onMounted(() => {
   // Default to true if unset
   store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
@@ -50,7 +43,7 @@ function toggleVisibility() {
 
 function updateVisibility(visible: boolean) {
   store.fileTreeIsVisible = visible;
-  localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
+  localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible.toString());
   updateState(store.fileTreeIsVisible);
 }
 
@@ -69,7 +62,7 @@ function updateState(visible: boolean) {
 <template>
   <div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
     <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
-    <DiffFileTreeItem v-for="item in fileTree" :key="item.name" :item="item"/>
+    <DiffFileTreeItem v-for="item in store.diffFileTree.TreeRoot.Children" :key="item.FullName" :item="item"/>
   </div>
 </template>
 
diff --git a/web_src/js/components/DiffFileTreeItem.vue b/web_src/js/components/DiffFileTreeItem.vue
index 5ee0e5bcaa181..d6d5506155573 100644
--- a/web_src/js/components/DiffFileTreeItem.vue
+++ b/web_src/js/components/DiffFileTreeItem.vue
@@ -1,18 +1,18 @@
 <script lang="ts" setup>
 import {SvgIcon, type SvgName} from '../svg.ts';
-import {diffTreeStore} from '../modules/stores.ts';
 import {ref} from 'vue';
-import type {Item, File, FileStatus} from '../utils/filetree.ts';
+import {type DiffStatus, type DiffTreeEntry, diffTreeStore} from '../modules/diff-file.ts';
 
-defineProps<{
-  item: Item,
+const props = defineProps<{
+  item: DiffTreeEntry,
 }>();
 
 const store = diffTreeStore();
-const collapsed = ref(false);
+const collapsed = ref(props.item.IsViewed);
 
-function getIconForDiffStatus(pType: FileStatus) {
-  const diffTypes: Record<FileStatus, { name: SvgName, classes: Array<string> }> = {
+function getIconForDiffStatus(pType: DiffStatus) {
+  const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = {
+    '': {name: 'octicon-blocked', classes: ['text', 'red']}, // unknown case
     'added': {name: 'octicon-diff-added', classes: ['text', 'green']},
     'modified': {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
     'deleted': {name: 'octicon-diff-removed', classes: ['text', 'red']},
@@ -20,11 +20,11 @@ function getIconForDiffStatus(pType: FileStatus) {
     'copied': {name: 'octicon-diff-renamed', classes: ['text', 'green']},
     'typechange': {name: 'octicon-diff-modified', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
   };
-  return diffTypes[pType];
+  return diffTypes[pType] ?? diffTypes[''];
 }
 
-function fileIcon(file: File) {
-  if (file.IsSubmodule) {
+function entryIcon(entry: DiffTreeEntry) {
+  if (entry.EntryMode === 'commit') {
     return 'octicon-file-submodule';
   }
   return 'octicon-file';
@@ -32,37 +32,36 @@ function fileIcon(file: File) {
 </script>
 
 <template>
-  <!--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"-->
-  <a
-    v-if="item.isFile" class="item-file"
-    :class="{ 'selected': store.selectedItem === '#diff-' + item.file.NameHash, 'viewed': item.file.IsViewed }"
-    :title="item.name" :href="'#diff-' + item.file.NameHash"
-  >
-    <!-- file -->
-    <SvgIcon :name="fileIcon(item.file)"/>
-    <span class="gt-ellipsis tw-flex-1">{{ item.name }}</span>
-    <SvgIcon
-      :name="getIconForDiffStatus(item.file.Status).name"
-      :class="getIconForDiffStatus(item.file.Status).classes"
-    />
-  </a>
-
-  <template v-else-if="item.isFile === false">
-    <div class="item-directory" :title="item.name" @click.stop="collapsed = !collapsed">
+  <template v-if="item.EntryMode === 'tree'">
+    <div class="item-directory" :class="{ 'viewed': item.IsViewed }" :title="item.DisplayName" @click.stop="collapsed = !collapsed">
       <!-- directory -->
       <SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/>
       <SvgIcon
         class="text primary"
         :name="collapsed ? 'octicon-file-directory-fill' : 'octicon-file-directory-open-fill'"
       />
-      <span class="gt-ellipsis">{{ item.name }}</span>
+      <span class="gt-ellipsis">{{ item.DisplayName }}</span>
     </div>
 
     <div v-show="!collapsed" class="sub-items">
-      <DiffFileTreeItem v-for="childItem in item.children" :key="childItem.name" :item="childItem"/>
+      <DiffFileTreeItem v-for="childItem in item.Children" :key="childItem.DisplayName" :item="childItem"/>
     </div>
   </template>
+  <a
+    v-else
+    class="item-file" :class="{ 'selected': store.selectedItem === '#diff-' + item.NameHash, 'viewed': item.IsViewed }"
+    :title="item.DisplayName" :href="'#diff-' + item.NameHash"
+  >
+    <!-- file -->
+    <SvgIcon :name="entryIcon(item)"/>
+    <span class="gt-ellipsis tw-flex-1">{{ item.DisplayName }}</span>
+    <SvgIcon
+      :name="getIconForDiffStatus(item.DiffStatus).name"
+      :class="getIconForDiffStatus(item.DiffStatus).classes"
+    />
+  </a>
 </template>
+
 <style scoped>
 a,
 a:hover {
@@ -88,7 +87,8 @@ a:hover {
   border-radius: 4px;
 }
 
-.item-file.viewed {
+.item-file.viewed,
+.item-directory.viewed {
   color: var(--color-text-light-3);
 }
 
diff --git a/web_src/js/features/file-fold.ts b/web_src/js/features/file-fold.ts
index 19950d9b9f7ca..74b36c0096c8b 100644
--- a/web_src/js/features/file-fold.ts
+++ b/web_src/js/features/file-fold.ts
@@ -5,7 +5,7 @@ import {svg} from '../svg.ts';
 // The fold arrow is the icon displayed on the upper left of the file box, especially intended for components having the 'fold-file' class.
 // The file content box is the box that should be hidden or shown, especially intended for components having the 'file-content' class.
 //
-export function setFileFolding(fileContentBox: HTMLElement, foldArrow: HTMLElement, newFold: boolean) {
+export function setFileFolding(fileContentBox: Element, foldArrow: HTMLElement, newFold: boolean) {
   foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18);
   fileContentBox.setAttribute('data-folded', String(newFold));
   if (newFold && fileContentBox.getBoundingClientRect().top < 0) {
diff --git a/web_src/js/features/pull-view-file.ts b/web_src/js/features/pull-view-file.ts
index 16ccf000844e9..1124886238c03 100644
--- a/web_src/js/features/pull-view-file.ts
+++ b/web_src/js/features/pull-view-file.ts
@@ -1,4 +1,4 @@
-import {diffTreeStore} from '../modules/stores.ts';
+import {diffTreeStore, diffTreeStoreSetViewed} from '../modules/diff-file.ts';
 import {setFileFolding} from './file-fold.ts';
 import {POST} from '../modules/fetch.ts';
 
@@ -58,11 +58,8 @@ export function initViewedCheckboxListenerFor() {
 
       const fileName = checkbox.getAttribute('name');
 
-      // check if the file is in our difftreestore and if we find it -> change the IsViewed status
-      const fileInPageData = diffTreeStore().files.find((x: Record<string, any>) => x.Name === fileName);
-      if (fileInPageData) {
-        fileInPageData.IsViewed = this.checked;
-      }
+      // check if the file is in our diffTreeStore and if we find it -> change the IsViewed status
+      diffTreeStoreSetViewed(diffTreeStore(), fileName, this.checked);
 
       // Unfortunately, actual forms cause too many problems, hence another approach is needed
       const files: Record<string, boolean> = {};
diff --git a/web_src/js/modules/diff-file.test.ts b/web_src/js/modules/diff-file.test.ts
new file mode 100644
index 0000000000000..1f956a7d8666a
--- /dev/null
+++ b/web_src/js/modules/diff-file.test.ts
@@ -0,0 +1,47 @@
+import {diffTreeStoreSetViewed, reactiveDiffTreeStore} from './diff-file.ts';
+
+test('diff-tree', () => {
+  const store = reactiveDiffTreeStore({
+    'TreeRoot': {
+      'FullName': '',
+      'DisplayName': '',
+      'EntryMode': '',
+      'IsViewed': false,
+      'NameHash': '....',
+      'DiffStatus': '',
+      'Children': [
+        {
+          'FullName': 'dir1',
+          'DisplayName': 'dir1',
+          'EntryMode': 'tree',
+          'IsViewed': false,
+          'NameHash': '....',
+          'DiffStatus': '',
+          'Children': [
+            {
+              'FullName': 'dir1/test.txt',
+              'DisplayName': 'test.txt',
+              'DiffStatus': 'added',
+              'NameHash': '....',
+              'EntryMode': '',
+              'IsViewed': false,
+              'Children': null,
+            },
+          ],
+        },
+        {
+          'FullName': 'other.txt',
+          'DisplayName': 'other.txt',
+          'NameHash': '........',
+          'DiffStatus': 'added',
+          'EntryMode': '',
+          'IsViewed': false,
+          'Children': null,
+        },
+      ],
+    },
+  });
+  diffTreeStoreSetViewed(store, 'dir1/test.txt', true);
+  expect(store.fullNameMap['dir1/test.txt'].IsViewed).toBe(true);
+  expect(store.fullNameMap['dir1'].IsViewed).toBe(true);
+});
diff --git a/web_src/js/modules/diff-file.ts b/web_src/js/modules/diff-file.ts
new file mode 100644
index 0000000000000..5d06f8a333809
--- /dev/null
+++ b/web_src/js/modules/diff-file.ts
@@ -0,0 +1,78 @@
+import {reactive} from 'vue';
+import type {Reactive} from 'vue';
+
+const {pageData} = window.config;
+
+export type DiffStatus = '' | 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange';
+
+export type DiffTreeEntry = {
+  FullName: string,
+  DisplayName: string,
+  NameHash: string,
+  DiffStatus: DiffStatus,
+  EntryMode: string,
+  IsViewed: boolean,
+  Children: DiffTreeEntry[],
+
+  ParentEntry?: DiffTreeEntry,
+}
+
+type DiffFileTreeData = {
+  TreeRoot: DiffTreeEntry,
+};
+
+type DiffFileTree = {
+  diffFileTree: DiffFileTreeData;
+  fullNameMap?: Record<string, DiffTreeEntry>
+  fileTreeIsVisible: boolean;
+  selectedItem: string;
+}
+
+let diffTreeStoreReactive: Reactive<DiffFileTree>;
+export function diffTreeStore() {
+  if (!diffTreeStoreReactive) {
+    diffTreeStoreReactive = reactiveDiffTreeStore(pageData.DiffFileTree);
+  }
+  return diffTreeStoreReactive;
+}
+
+export function diffTreeStoreSetViewed(store: Reactive<DiffFileTree>, fullName: string, viewed: boolean) {
+  const entry = store.fullNameMap[fullName];
+  if (!entry) return;
+  entry.IsViewed = viewed;
+  for (let parent = entry.ParentEntry; parent; parent = parent.ParentEntry) {
+    parent.IsViewed = isEntryViewed(parent);
+  }
+}
+
+function fillFullNameMap(map: Record<string, DiffTreeEntry>, entry: DiffTreeEntry) {
+  map[entry.FullName] = entry;
+  if (!entry.Children) return;
+  entry.IsViewed = isEntryViewed(entry);
+  for (const child of entry.Children) {
+    child.ParentEntry = entry;
+    fillFullNameMap(map, child);
+  }
+}
+
+export function reactiveDiffTreeStore(data: DiffFileTreeData): Reactive<DiffFileTree> {
+  const store = reactive({
+    diffFileTree: data,
+    fileTreeIsVisible: false,
+    selectedItem: '',
+    fullNameMap: {},
+  });
+  fillFullNameMap(store.fullNameMap, data.TreeRoot);
+  return store;
+}
+
+function isEntryViewed(entry: DiffTreeEntry): boolean {
+  if (entry.Children) {
+    let count = 0;
+    for (const child of entry.Children) {
+      if (child.IsViewed) count++;
+    }
+    return count === entry.Children.length;
+  }
+  return entry.IsViewed;
+}
diff --git a/web_src/js/modules/stores.ts b/web_src/js/modules/stores.ts
deleted file mode 100644
index 65da1e044a8a6..0000000000000
--- a/web_src/js/modules/stores.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import {reactive} from 'vue';
-import type {Reactive} from 'vue';
-
-const {pageData} = window.config;
-
-let diffTreeStoreReactive: Reactive<Record<string, any>>;
-export function diffTreeStore() {
-  if (!diffTreeStoreReactive) {
-    diffTreeStoreReactive = reactive({
-      files: pageData.DiffFiles,
-      fileTreeIsVisible: false,
-      selectedItem: '',
-    });
-  }
-  return diffTreeStoreReactive;
-}
diff --git a/web_src/js/utils/filetree.test.ts b/web_src/js/utils/filetree.test.ts
deleted file mode 100644
index f561cb75f0c26..0000000000000
--- a/web_src/js/utils/filetree.test.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import {mergeChildIfOnlyOneDir, pathListToTree, type File} from './filetree.ts';
-
-const emptyList: File[] = [];
-const singleFile = [{Name: 'file1'}] as File[];
-const singleDir = [{Name: 'dir1/file1'}] as File[];
-const nestedDir = [{Name: 'dir1/dir2/file1'}] as File[];
-const multiplePathsDisjoint = [{Name: 'dir1/dir2/file1'}, {Name: 'dir3/file2'}] as File[];
-const multiplePathsShared = [{Name: 'dir1/dir2/dir3/file1'}, {Name: 'dir1/file2'}] as File[];
-
-test('pathListToTree', () => {
-  expect(pathListToTree(emptyList)).toEqual([]);
-  expect(pathListToTree(singleFile)).toEqual([
-    {isFile: true, name: 'file1', path: 'file1', file: {Name: 'file1'}},
-  ]);
-  expect(pathListToTree(singleDir)).toEqual([
-    {isFile: false, name: 'dir1', path: 'dir1', children: [
-      {isFile: true, name: 'file1', path: 'dir1/file1', file: {Name: 'dir1/file1'}},
-    ]},
-  ]);
-  expect(pathListToTree(nestedDir)).toEqual([
-    {isFile: false, name: 'dir1', path: 'dir1', children: [
-      {isFile: false, name: 'dir2', path: 'dir1/dir2', children: [
-        {isFile: true, name: 'file1', path: 'dir1/dir2/file1', file: {Name: 'dir1/dir2/file1'}},
-      ]},
-    ]},
-  ]);
-  expect(pathListToTree(multiplePathsDisjoint)).toEqual([
-    {isFile: false, name: 'dir1', path: 'dir1', children: [
-      {isFile: false, name: 'dir2', path: 'dir1/dir2', children: [
-        {isFile: true, name: 'file1', path: 'dir1/dir2/file1', file: {Name: 'dir1/dir2/file1'}},
-      ]},
-    ]},
-    {isFile: false, name: 'dir3', path: 'dir3', children: [
-      {isFile: true, name: 'file2', path: 'dir3/file2', file: {Name: 'dir3/file2'}},
-    ]},
-  ]);
-  expect(pathListToTree(multiplePathsShared)).toEqual([
-    {isFile: false, name: 'dir1', path: 'dir1', children: [
-      {isFile: false, name: 'dir2', path: 'dir1/dir2', children: [
-        {isFile: false, name: 'dir3', path: 'dir1/dir2/dir3', children: [
-          {isFile: true, name: 'file1', path: 'dir1/dir2/dir3/file1', file: {Name: 'dir1/dir2/dir3/file1'}},
-        ]},
-      ]},
-      {isFile: true, name: 'file2', path: 'dir1/file2', file: {Name: 'dir1/file2'}},
-    ]},
-  ]);
-});
-
-const mergeChildWrapper = (testCase: File[]) => {
-  const tree = pathListToTree(testCase);
-  mergeChildIfOnlyOneDir(tree);
-  return tree;
-};
-
-test('mergeChildIfOnlyOneDir', () => {
-  expect(mergeChildWrapper(emptyList)).toEqual([]);
-  expect(mergeChildWrapper(singleFile)).toEqual([
-    {isFile: true, name: 'file1', path: 'file1', file: {Name: 'file1'}},
-  ]);
-  expect(mergeChildWrapper(singleDir)).toEqual([
-    {isFile: false, name: 'dir1', path: 'dir1', children: [
-      {isFile: true, name: 'file1', path: 'dir1/file1', file: {Name: 'dir1/file1'}},
-    ]},
-  ]);
-  expect(mergeChildWrapper(nestedDir)).toEqual([
-    {isFile: false, name: 'dir1/dir2', path: 'dir1/dir2', children: [
-      {isFile: true, name: 'file1', path: 'dir1/dir2/file1', file: {Name: 'dir1/dir2/file1'}},
-    ]},
-  ]);
-  expect(mergeChildWrapper(multiplePathsDisjoint)).toEqual([
-    {isFile: false, name: 'dir1/dir2', path: 'dir1/dir2', children: [
-      {isFile: true, name: 'file1', path: 'dir1/dir2/file1', file: {Name: 'dir1/dir2/file1'}},
-    ]},
-    {isFile: false, name: 'dir3', path: 'dir3', children: [
-      {isFile: true, name: 'file2', path: 'dir3/file2', file: {Name: 'dir3/file2'}},
-    ]},
-  ]);
-  expect(mergeChildWrapper(multiplePathsShared)).toEqual([
-    {isFile: false, name: 'dir1', path: 'dir1', children: [
-      {isFile: false, name: 'dir2/dir3', path: 'dir1/dir2/dir3', children: [
-        {isFile: true, name: 'file1', path: 'dir1/dir2/dir3/file1', file: {Name: 'dir1/dir2/dir3/file1'}},
-      ]},
-      {isFile: true, name: 'file2', path: 'dir1/file2', file: {Name: 'dir1/file2'}},
-    ]},
-  ]);
-});
diff --git a/web_src/js/utils/filetree.ts b/web_src/js/utils/filetree.ts
deleted file mode 100644
index 35f9f58189faa..0000000000000
--- a/web_src/js/utils/filetree.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import {dirname, basename} from '../utils.ts';
-
-export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange';
-
-export type File = {
-  Name: string;
-  NameHash: string;
-  Status: FileStatus;
-  IsViewed: boolean;
-  IsSubmodule: boolean;
-}
-
-type DirItem = {
-    isFile: false;
-    name: string;
-    path: string;
-
-    children: Item[];
-}
-
-type FileItem = {
-    isFile: true;
-    name: string;
-    path: string;
-    file: File;
-}
-
-export type Item = DirItem | FileItem;
-
-export function pathListToTree(fileEntries: File[]): Item[] {
-  const pathToItem = new Map<string, DirItem>();
-
-    // init root node
-  const root: DirItem = {name: '', path: '', isFile: false, children: []};
-  pathToItem.set('', root);
-
-  for (const fileEntry of fileEntries) {
-    const [parentPath, fileName] = [dirname(fileEntry.Name), basename(fileEntry.Name)];
-
-    let parentItem = pathToItem.get(parentPath);
-    if (!parentItem) {
-      parentItem = constructParents(pathToItem, parentPath);
-    }
-
-    const fileItem: FileItem = {name: fileName, path: fileEntry.Name, isFile: true, file: fileEntry};
-
-    parentItem.children.push(fileItem);
-  }
-
-  return root.children;
-}
-
-function constructParents(pathToItem: Map<string, DirItem>, dirPath: string): DirItem {
-  const [dirParentPath, dirName] = [dirname(dirPath), basename(dirPath)];
-
-  let parentItem = pathToItem.get(dirParentPath);
-  if (!parentItem) {
-    // if the parent node does not exist, create it
-    parentItem = constructParents(pathToItem, dirParentPath);
-  }
-
-  const dirItem: DirItem = {name: dirName, path: dirPath, isFile: false, children: []};
-  parentItem.children.push(dirItem);
-  pathToItem.set(dirPath, dirItem);
-
-  return dirItem;
-}
-
-export function mergeChildIfOnlyOneDir(nodes: Item[]): void {
-  for (const node of nodes) {
-    if (node.isFile) {
-      continue;
-    }
-    const dir = node as DirItem;
-
-    mergeChildIfOnlyOneDir(dir.children);
-
-    if (dir.children.length === 1 && dir.children[0].isFile === false) {
-      const child = dir.children[0];
-      dir.name = `${dir.name}/${child.name}`;
-      dir.path = child.path;
-      dir.children = child.children;
-    }
-  }
-}