mirror of
https://github.com/go-gitea/gitea.git
synced 2025-04-23 09:58:47 +03: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
|
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)
|
statuses, _, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, commitID, db.ListOptionsAll)
|
||||||
|
@ -639,7 +639,7 @@ func PrepareCompareDiff(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.PageData["DiffFiles"] = transformDiffTreeForUI(diffTree, nil)
|
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(diffTree, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
headCommit, err := ci.HeadGitRepo.GetCommit(headCommitID)
|
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
|
// 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
|
// as the viewed information is designed to be loaded only on latest PR
|
||||||
// diff and if you're signed in.
|
// diff and if you're signed in.
|
||||||
shouldGetUserSpecificDiff := false
|
var reviewState *pull_model.ReviewState
|
||||||
if !ctx.IsSigned || willShowSpecifiedCommit || willShowSpecifiedCommitRange {
|
if ctx.IsSigned && !willShowSpecifiedCommit && !willShowSpecifiedCommitRange {
|
||||||
// do nothing
|
reviewState, err = gitdiff.SyncUserSpecificDiff(ctx, ctx.Doer.ID, pull, gitRepo, diff, diffOptions)
|
||||||
} else {
|
|
||||||
shouldGetUserSpecificDiff = true
|
|
||||||
err = gitdiff.SyncUserSpecificDiff(ctx, ctx.Doer.ID, pull, gitRepo, diff, diffOptions, files...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.ServerError("SyncUserSpecificDiff", err)
|
ctx.ServerError("SyncUserSpecificDiff", err)
|
||||||
return
|
return
|
||||||
@ -823,18 +820,11 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi
|
|||||||
ctx.ServerError("GetDiffTree", err)
|
ctx.ServerError("GetDiffTree", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var filesViewedState map[string]pull_model.ViewedState
|
||||||
filesViewedState := make(map[string]pull_model.ViewedState)
|
if reviewState != nil {
|
||||||
if shouldGetUserSpecificDiff {
|
filesViewedState = reviewState.UpdatedFiles
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
ctx.PageData["DiffFileTree"] = transformDiffTreeForWeb(diffTree, filesViewedState)
|
||||||
|
|
||||||
ctx.PageData["DiffFiles"] = transformDiffTreeForUI(diffTree, filesViewedState)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Data["Diff"] = diff
|
ctx.Data["Diff"] = diff
|
||||||
|
@ -5,6 +5,7 @@ package repo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
pull_model "code.gitea.io/gitea/models/pull"
|
pull_model "code.gitea.io/gitea/models/pull"
|
||||||
"code.gitea.io/gitea/modules/base"
|
"code.gitea.io/gitea/modules/base"
|
||||||
@ -57,34 +58,85 @@ func isExcludedEntry(entry *git.TreeEntry) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileDiffFile struct {
|
// WebDiffFileItem is used by frontend, check the field names in frontend before changing
|
||||||
Name string
|
type WebDiffFileItem struct {
|
||||||
|
FullName string
|
||||||
|
DisplayName string
|
||||||
NameHash string
|
NameHash string
|
||||||
IsSubmodule bool
|
DiffStatus string
|
||||||
|
EntryMode string
|
||||||
IsViewed bool
|
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
|
// 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 {
|
func transformDiffTreeForWeb(diffTree *gitdiff.DiffTree, filesViewedState map[string]pull_model.ViewedState) (dft WebDiffFileTree) {
|
||||||
files := make([]FileDiffFile, 0, len(diffTree.Files))
|
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 {
|
for _, file := range diffTree.Files {
|
||||||
nameHash := git.HashFilePathForWebUI(file.HeadPath)
|
item := &WebDiffFileItem{FullName: file.HeadPath, DiffStatus: file.Status}
|
||||||
isSubmodule := file.HeadMode == git.EntryModeCommit
|
item.IsViewed = filesViewedState[item.FullName] == pull_model.Viewed
|
||||||
isViewed := filesViewedState[file.HeadPath] == pull_model.Viewed
|
item.NameHash = git.HashFilePathForWebUI(item.FullName)
|
||||||
|
|
||||||
files = append(files, FileDiffFile{
|
switch file.HeadMode {
|
||||||
Name: file.HeadPath,
|
case git.EntryModeTree:
|
||||||
NameHash: nameHash,
|
item.EntryMode = "tree"
|
||||||
IsSubmodule: isSubmodule,
|
case git.EntryModeCommit:
|
||||||
IsViewed: isViewed,
|
item.EntryMode = "commit" // submodule
|
||||||
Status: file.Status,
|
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) {
|
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
|
// 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
|
// 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)
|
review, err := pull_model.GetNewestReviewState(ctx, userID, pull.ID)
|
||||||
if err != nil || review == nil || review.UpdatedFiles == nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
|
}
|
||||||
|
if review == nil || len(review.UpdatedFiles) == 0 {
|
||||||
|
return review, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
latestCommit := opts.AfterCommitID
|
latestCommit := opts.AfterCommitID
|
||||||
@ -1393,11 +1396,11 @@ outer:
|
|||||||
err := pull_model.UpdateReviewState(ctx, review.UserID, review.PullID, review.CommitSHA, filesChangedSinceLastDiff)
|
err := pull_model.UpdateReviewState(ctx, review.UserID, review.PullID, review.CommitSHA, filesChangedSinceLastDiff)
|
||||||
if err != nil {
|
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)
|
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
|
// CommentAsDiff returns c.Patch as *Diff
|
||||||
|
@ -1,21 +1,14 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import DiffFileTreeItem from './DiffFileTreeItem.vue';
|
import DiffFileTreeItem from './DiffFileTreeItem.vue';
|
||||||
import {toggleElem} from '../utils/dom.ts';
|
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 {setFileFolding} from '../features/file-fold.ts';
|
||||||
import {computed, onMounted, onUnmounted} from 'vue';
|
import {onMounted, onUnmounted} from 'vue';
|
||||||
import {pathListToTree, mergeChildIfOnlyOneDir} from '../utils/filetree.ts';
|
|
||||||
|
|
||||||
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
|
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
|
||||||
|
|
||||||
const store = diffTreeStore();
|
const store = diffTreeStore();
|
||||||
|
|
||||||
const fileTree = computed(() => {
|
|
||||||
const result = pathListToTree(store.files);
|
|
||||||
mergeChildIfOnlyOneDir(result); // mutation
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// Default to true if unset
|
// Default to true if unset
|
||||||
store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
|
store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
|
||||||
@ -50,7 +43,7 @@ function toggleVisibility() {
|
|||||||
|
|
||||||
function updateVisibility(visible: boolean) {
|
function updateVisibility(visible: boolean) {
|
||||||
store.fileTreeIsVisible = visible;
|
store.fileTreeIsVisible = visible;
|
||||||
localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
|
localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible.toString());
|
||||||
updateState(store.fileTreeIsVisible);
|
updateState(store.fileTreeIsVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,7 +62,7 @@ function updateState(visible: boolean) {
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
|
<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 -->
|
<!-- 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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -1,18 +1,18 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {SvgIcon, type SvgName} from '../svg.ts';
|
import {SvgIcon, type SvgName} from '../svg.ts';
|
||||||
import {diffTreeStore} from '../modules/stores.ts';
|
|
||||||
import {ref} from 'vue';
|
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<{
|
const props = defineProps<{
|
||||||
item: Item,
|
item: DiffTreeEntry,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const store = diffTreeStore();
|
const store = diffTreeStore();
|
||||||
const collapsed = ref(false);
|
const collapsed = ref(props.item.IsViewed);
|
||||||
|
|
||||||
function getIconForDiffStatus(pType: FileStatus) {
|
function getIconForDiffStatus(pType: DiffStatus) {
|
||||||
const diffTypes: Record<FileStatus, { name: SvgName, classes: Array<string> }> = {
|
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']},
|
'added': {name: 'octicon-diff-added', classes: ['text', 'green']},
|
||||||
'modified': {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
|
'modified': {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
|
||||||
'deleted': {name: 'octicon-diff-removed', classes: ['text', 'red']},
|
'deleted': {name: 'octicon-diff-removed', classes: ['text', 'red']},
|
||||||
@ -20,11 +20,11 @@ function getIconForDiffStatus(pType: FileStatus) {
|
|||||||
'copied': {name: 'octicon-diff-renamed', classes: ['text', 'green']},
|
'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
|
'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) {
|
function entryIcon(entry: DiffTreeEntry) {
|
||||||
if (file.IsSubmodule) {
|
if (entry.EntryMode === 'commit') {
|
||||||
return 'octicon-file-submodule';
|
return 'octicon-file-submodule';
|
||||||
}
|
}
|
||||||
return 'octicon-file';
|
return 'octicon-file';
|
||||||
@ -32,37 +32,36 @@ function fileIcon(file: File) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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"-->
|
<template v-if="item.EntryMode === 'tree'">
|
||||||
<a
|
<div class="item-directory" :class="{ 'viewed': item.IsViewed }" :title="item.DisplayName" @click.stop="collapsed = !collapsed">
|
||||||
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">
|
|
||||||
<!-- directory -->
|
<!-- directory -->
|
||||||
<SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/>
|
<SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/>
|
||||||
<SvgIcon
|
<SvgIcon
|
||||||
class="text primary"
|
class="text primary"
|
||||||
:name="collapsed ? 'octicon-file-directory-fill' : 'octicon-file-directory-open-fill'"
|
: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>
|
||||||
|
|
||||||
<div v-show="!collapsed" class="sub-items">
|
<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>
|
</div>
|
||||||
</template>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
a,
|
a,
|
||||||
a:hover {
|
a:hover {
|
||||||
@ -88,7 +87,8 @@ a:hover {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-file.viewed {
|
.item-file.viewed,
|
||||||
|
.item-directory.viewed {
|
||||||
color: var(--color-text-light-3);
|
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 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.
|
// 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);
|
foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18);
|
||||||
fileContentBox.setAttribute('data-folded', String(newFold));
|
fileContentBox.setAttribute('data-folded', String(newFold));
|
||||||
if (newFold && fileContentBox.getBoundingClientRect().top < 0) {
|
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 {setFileFolding} from './file-fold.ts';
|
||||||
import {POST} from '../modules/fetch.ts';
|
import {POST} from '../modules/fetch.ts';
|
||||||
|
|
||||||
@ -58,11 +58,8 @@ export function initViewedCheckboxListenerFor() {
|
|||||||
|
|
||||||
const fileName = checkbox.getAttribute('name');
|
const fileName = checkbox.getAttribute('name');
|
||||||
|
|
||||||
// check if the file is in our difftreestore and if we find it -> change the IsViewed status
|
// 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);
|
diffTreeStoreSetViewed(diffTreeStore(), fileName, this.checked);
|
||||||
if (fileInPageData) {
|
|
||||||
fileInPageData.IsViewed = this.checked;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unfortunately, actual forms cause too many problems, hence another approach is needed
|
// Unfortunately, actual forms cause too many problems, hence another approach is needed
|
||||||
const files: Record<string, boolean> = {};
|
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