mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-26 12:27:06 +00:00 
			
		
		
		
	Mark parent directory as viewed when all files are viewed (#33958)
Fix #25644 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		| @@ -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) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
| 		item := &WebDiffFileItem{FullName: file.HeadPath, DiffStatus: file.Status} | ||||
| 		item.IsViewed = filesViewedState[item.FullName] == pull_model.Viewed | ||||
| 		item.NameHash = git.HashFilePathForWebUI(item.FullName) | ||||
|  | ||||
| 		files = append(files, FileDiffFile{ | ||||
| 			Name:        file.HeadPath, | ||||
| 			NameHash:    nameHash, | ||||
| 			IsSubmodule: isSubmodule, | ||||
| 			IsViewed:    isViewed, | ||||
| 			Status:      file.Status, | ||||
| 		}) | ||||
| 		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) { | ||||
|   | ||||
							
								
								
									
										60
									
								
								routers/web/repo/treelist_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								routers/web/repo/treelist_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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> | ||||
|  | ||||
|   | ||||
| @@ -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); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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> = {}; | ||||
|   | ||||
							
								
								
									
										47
									
								
								web_src/js/modules/diff-file.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								web_src/js/modules/diff-file.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||
| }); | ||||
							
								
								
									
										78
									
								
								web_src/js/modules/diff-file.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								web_src/js/modules/diff-file.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| } | ||||
| @@ -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; | ||||
| } | ||||
| @@ -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'}}, | ||||
|     ]}, | ||||
|   ]); | ||||
| }); | ||||
| @@ -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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user
	 Kerwin Bryant
					Kerwin Bryant