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; - } - } -}