diff --git a/services/gitdiff/gitdiff.go b/services/gitdiff/gitdiff.go index bb1722039e19d..dacb3fff256c3 100644 --- a/services/gitdiff/gitdiff.go +++ b/services/gitdiff/gitdiff.go @@ -448,12 +448,20 @@ func getCommitFileLineCount(commit *git.Commit, filePath string) int { return lineCount } +type FileTreeNode struct { + IsFile bool + Name string + File *DiffFile + Children []*FileTreeNode +} + // Diff represents a difference between two git trees. type Diff struct { Start, End string NumFiles int TotalAddition, TotalDeletion int Files []*DiffFile + FileTree []*FileTreeNode IsIncomplete bool NumViewedFiles int // user-specific } @@ -1212,6 +1220,8 @@ func GetDiff(ctx context.Context, gitRepo *git.Repository, opts *DiffOptions, fi } } + diff.FileTree = buildTree(diff.Files) + if opts.FileOnly { return diff, nil } @@ -1384,3 +1394,78 @@ func GetWhitespaceFlag(whitespaceBehavior string) git.TrustedCmdArgs { log.Warn("unknown whitespace behavior: %q, default to 'show-all'", whitespaceBehavior) return nil } + +func buildTree(files []*DiffFile) []*FileTreeNode { + result := make(map[string]*FileTreeNode) + for _, file := range files { + splits := strings.Split(file.Name, "/") + currentNode := &FileTreeNode{Name: splits[0], IsFile: false} + if _, exists := result[splits[0]]; !exists { + result[splits[0]] = currentNode + } else { + currentNode = result[splits[0]] + } + + parent := currentNode + for _, split := range splits[1:] { + found := false + for _, child := range parent.Children { + if child.Name == split { + parent = child + found = true + break + } + } + if !found { + newNode := &FileTreeNode{Name: split, IsFile: false} + parent.Children = append(parent.Children, newNode) + parent = newNode + } + } + + lastNode := parent + lastNode.IsFile = true + lastNode.File = file + } + + var roots []*FileTreeNode + for _, node := range result { + if len(node.Children) > 0 { + mergedNode := mergeSingleChildDirs(node) + sortChildren(mergedNode) + roots = append(roots, mergedNode) + } else { + roots = append(roots, node) + } + } + sortChildren(&FileTreeNode{Children: roots}) + return roots +} + +func mergeSingleChildDirs(node *FileTreeNode) *FileTreeNode { + if len(node.Children) == 1 && !node.Children[0].IsFile { + merged := &FileTreeNode{ + Name: fmt.Sprintf("%s/%s", node.Name, node.Children[0].Name), + Children: node.Children[0].Children, + IsFile: node.Children[0].IsFile, + File: node.Children[0].File, + } + return mergeSingleChildDirs(merged) + } + for i, child := range node.Children { + node.Children[i] = mergeSingleChildDirs(child) + } + return node +} + +func sortChildren(node *FileTreeNode) { + sort.Slice(node.Children, func(i, j int) bool { + if node.Children[i].IsFile == node.Children[j].IsFile { + return node.Children[i].Name < node.Children[j].Name + } + return !node.Children[i].IsFile + }) + for _, child := range node.Children { + sortChildren(child) + } +} diff --git a/services/gitdiff/gitdiff_test.go b/services/gitdiff/gitdiff_test.go index adcac355a7bb4..d9af23f6a9e02 100644 --- a/services/gitdiff/gitdiff_test.go +++ b/services/gitdiff/gitdiff_test.go @@ -668,3 +668,191 @@ func TestNoCrashes(t *testing.T) { ParsePatch(db.DefaultContext, setting.Git.MaxGitDiffLines, setting.Git.MaxGitDiffLineCharacters, setting.Git.MaxGitDiffFiles, strings.NewReader(testcase.gitdiff), "") } } + +func TestBuildTree(t *testing.T) { + tests := []struct { + name string + input []*DiffFile + expected []*FileTreeNode + }{ + { + name: "Test case 1", + input: []*DiffFile{ + {Name: "test1", NameHash: "b444ac06613fc8d63795be9ad0beaf55011936ac"}, + {Name: "2/2", NameHash: "23b3d60a69a4b1bacb1a3b9ced0a8ac609efbf61"}, + }, + expected: []*FileTreeNode{ + { + Name: "2", + IsFile: false, + Children: []*FileTreeNode{ + { + Name: "2", + IsFile: true, + File: &DiffFile{ + Name: "2/2", + NameHash: "23b3d60a69a4b1bacb1a3b9ced0a8ac609efbf61", + }, + }, + }, + }, + { + Name: "test1", + IsFile: true, + File: &DiffFile{ + Name: "test1", + NameHash: "b444ac06613fc8d63795be9ad0beaf55011936ac", + }, + }, + }, + }, + { + name: "Test case 2", + input: []*DiffFile{ + {Name: "a/b/d", NameHash: "hash2"}, + {Name: "a/e", NameHash: "hash3"}, + {Name: "a/b/c", NameHash: "hash1"}, + {Name: "f", NameHash: "hash4"}, + }, + expected: []*FileTreeNode{ + { + Name: "a", + IsFile: false, + Children: []*FileTreeNode{ + { + Name: "b", + IsFile: false, + Children: []*FileTreeNode{ + { + Name: "c", + IsFile: true, + File: &DiffFile{ + Name: "a/b/c", + NameHash: "hash1", + }, + }, + { + Name: "d", + IsFile: true, + File: &DiffFile{ + Name: "a/b/d", + NameHash: "hash2", + }, + }, + }, + }, + { + Name: "e", + IsFile: true, + File: &DiffFile{ + Name: "a/e", + NameHash: "hash3", + }, + }, + }, + }, + { + Name: "f", + IsFile: true, + File: &DiffFile{ + Name: "f", + NameHash: "hash4", + }, + }, + }, + }, + { + name: "Test case 3", + input: []*DiffFile{ + {Name: "dir2/file4", NameHash: "hash4"}, + {Name: "dir1/file1", NameHash: "hash1"}, + {Name: "dir2/file3", NameHash: "hash3"}, + {Name: "dir1/file2", NameHash: "hash2"}, + {Name: "file5", NameHash: "hash5"}, + }, + expected: []*FileTreeNode{ + { + Name: "dir1", + IsFile: false, + Children: []*FileTreeNode{ + { + Name: "file1", + IsFile: true, + File: &DiffFile{ + Name: "dir1/file1", + NameHash: "hash1", + }, + }, + { + Name: "file2", + IsFile: true, + File: &DiffFile{ + Name: "dir1/file2", + NameHash: "hash2", + }, + }, + }, + }, + { + Name: "dir2", + IsFile: false, + Children: []*FileTreeNode{ + { + Name: "file3", + IsFile: true, + File: &DiffFile{ + Name: "dir2/file3", + NameHash: "hash3", + }, + }, + { + Name: "file4", + IsFile: true, + File: &DiffFile{ + Name: "dir2/file4", + NameHash: "hash4", + }, + }, + }, + }, + { + Name: "file5", + IsFile: true, + File: &DiffFile{ + Name: "file5", + NameHash: "hash5", + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := buildTree(tt.input) + if !compareFileTreeNodes(result, tt.expected) { + t.Errorf("Expected %v, but got %v", tt.expected, result) + } + }) + } +} + +func compareFileTreeNodes(a, b []*FileTreeNode) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Name != b[i].Name || a[i].IsFile != b[i].IsFile { + return false + } + if a[i].IsFile && b[i].IsFile { + if a[i].File.Name != b[i].File.Name || a[i].File.NameHash != b[i].File.NameHash { + return false + } + } + if !compareFileTreeNodes(a[i].Children, b[i].Children) { + return false + } + } + return true +} diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 0f1458bfbfd22..0be1a09ff2b5d 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -61,35 +61,29 @@ {{end}}
{{end}}
{{if $showFileTree}} -
+ {{template "repo/diff/file_tree" dict "Files" .Diff.FileTree "IsIncomplete" .Diff.IsIncomplete "LoadMoreLink" (printf "?skip-to=%s&file-only=true" .Diff.End)}} @@ -228,7 +222,7 @@

{{ctx.Locale.Tr "repo.diff.too_many_files"}} - {{ctx.Locale.Tr "repo.diff.show_more"}} + {{ctx.Locale.Tr "repo.diff.show_more"}}

{{end}} diff --git a/templates/repo/diff/file_tree.tmpl b/templates/repo/diff/file_tree.tmpl new file mode 100644 index 0000000000000..c0cee3164813b --- /dev/null +++ b/templates/repo/diff/file_tree.tmpl @@ -0,0 +1,12 @@ +
+
+ {{range .Files}} + {{template "repo/diff/file_tree_item" .}} + {{end}} +
+ {{if .IsIncomplete}} + + {{end}} +
diff --git a/templates/repo/diff/file_tree_item.tmpl b/templates/repo/diff/file_tree_item.tmpl new file mode 100644 index 0000000000000..837082461fd48 --- /dev/null +++ b/templates/repo/diff/file_tree_item.tmpl @@ -0,0 +1,34 @@ +{{if .IsFile}} + + + {{svg "octicon-file"}} + {{.Name}} + {{if eq .File.Type 1}} + {{svg "octicon-diff-added" 16 "text green"}} + {{else if eq .File.Type 2}} + {{svg "octicon-diff-modified" 16 "text yellow"}} + {{else if eq .File.Type 3}} + {{svg "octicon-diff-removed" 16 "text red"}} + {{else if eq .File.Type 4}} + {{svg "octicon-diff-renamed" 16 "text teal"}} + {{else if eq .File.Type 5}} + {{svg "octicon-diff-renamed" 16 "text green"}} + {{end}} + +{{else}} +
+ + {{svg "octicon-chevron-right" 16 "tw-hidden"}} + {{svg "octicon-chevron-down" 16}} + {{svg "octicon-file-directory-fill" 16 "text primary tw-hidden"}} + {{svg "octicon-file-directory-open-fill" 16 "text primary"}} + {{.Name}} +
+{{end}} +{{if and .Children (gt (len .Children) 0)}} +
+ {{range .Children}} + {{template "repo/diff/file_tree_item" .}} + {{end}} +
+{{end}} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 9e1def87a7d76..33ed6ba39fcf4 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -2377,7 +2377,7 @@ tbody.commit-list { gap: 8px; } -#diff-file-tree { +.file-tree { flex: 0 0 20%; max-width: 380px; line-height: inherit; @@ -2389,6 +2389,60 @@ tbody.commit-list { overflow-y: auto; } +.file-tree .file-tree-items { + display: flex; + flex-direction: column; + gap: 1px; + margin-right: .5rem; +} + +.file-tree .file-tree-items a, a:hover { + text-decoration: none; + color: var(--color-text); +} + +.file-tree .file-tree-items .sub-items { + display: flex; + flex-direction: column; + gap: 1px; + margin-left: 13px; + border-left: 1px solid var(--color-secondary); +} + +.file-tree .file-tree-items .sub-items .item-file { + padding-left: 18px; +} + +.file-tree .file-tree-items .item-file.selected { + color: var(--color-text); + background: var(--color-active); + border-radius: 4px; +} + +.file-tree .file-tree-items .item-file.viewed { + color: var(--color-text-light-3); +} + +.file-tree .file-tree-items .item-directory { + user-select: none; +} + +.file-tree .file-tree-items .item-file, +.file-tree .file-tree-items .item-directory { + display: flex; + align-items: center; + gap: 0.25em; + padding: 6px; +} + +.file-tree .file-tree-items .item-file:hover, +.file-tree .file-tree-items .item-directory:hover { + color: var(--color-text); + background: var(--color-hover); + border-radius: 4px; + cursor: pointer; +} + .ui.message.unicode-escape-prompt { margin-bottom: 0; border-radius: 0; diff --git a/web_src/js/features/repo-diff-filetree.ts b/web_src/js/features/repo-diff-filetree.ts index bc275a90f6ab3..4cb4bf2ceabe5 100644 --- a/web_src/js/features/repo-diff-filetree.ts +++ b/web_src/js/features/repo-diff-filetree.ts @@ -1,13 +1,59 @@ import {createApp} from 'vue'; -import DiffFileTree from '../components/DiffFileTree.vue'; +import {toggleElem} from '../utils/dom.ts'; +import {diffTreeStore} from '../modules/stores.ts'; +import {setFileFolding} from './file-fold.ts'; import DiffFileList from '../components/DiffFileList.vue'; +const LOCAL_STORAGE_KEY = 'diff_file_tree_visible'; + +function hashChangeListener() { + for (const el of document.querySelectorAll('.file-tree-items .item-file')) { + el.classList.toggle('selected', el.hash === `${window.location.hash}`); + } + expandSelectedFile(window.location.hash); +} + +function expandSelectedFile(selectedItem) { + // expand file if the selected file is folded + if (selectedItem) { + const box = document.querySelector(selectedItem); + const folded = box?.getAttribute('data-folded') === 'true'; + if (folded) setFileFolding(box, box.querySelector('.fold-file'), false); + } +} + +function updateState(visible) { + const btn = document.querySelector('.diff-toggle-file-tree-button'); + const [toShow, toHide] = btn.querySelectorAll('.icon'); + const tree = document.querySelector('#diff-file-tree'); + const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text'); + btn.setAttribute('data-tooltip-content', newTooltip); + toggleElem(tree, visible); + toggleElem(toShow, !visible); + toggleElem(toHide, visible); +} + export function initDiffFileTree() { const el = document.querySelector('#diff-file-tree'); if (!el) return; - const fileTreeView = createApp(DiffFileTree); - fileTreeView.mount(el); + const store = diffTreeStore(); + store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false'; + document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', () => { + store.fileTreeIsVisible = !store.fileTreeIsVisible; + localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible); + updateState(store.fileTreeIsVisible); + }); + + hashChangeListener(); + window.addEventListener('hashchange', hashChangeListener); + + for (const el of document.querySelectorAll('.file-tree-items .item-directory')) { + el.addEventListener('click', () => { + toggleElem(el.nextElementSibling); + toggleElem(el.querySelectorAll('.svg.octicon-chevron-right, .svg.octicon-chevron-down, .svg.octicon-file-directory-fill, .svg.octicon-file-directory-open-fill')); + }); + } } export function initDiffFileList() { diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts index 58e0d880922dd..3c83306900da7 100644 --- a/web_src/js/features/repo-diff.ts +++ b/web_src/js/features/repo-diff.ts @@ -166,7 +166,7 @@ function onShowMoreFiles() { } export async function loadMoreFiles(url) { - const target = document.querySelector('a#diff-show-more-files'); + const target = document.querySelector('a.diff-show-more-files'); if (target?.classList.contains('disabled') || pageData.diffFileInfo.isLoadingNewData) { return; } @@ -195,7 +195,7 @@ export async function loadMoreFiles(url) { } function initRepoDiffShowMore() { - $(document).on('click', 'a#diff-show-more-files', (e) => { + $(document).on('click', 'a.diff-show-more-files', (e) => { e.preventDefault(); const linkLoadMore = e.target.getAttribute('data-href');