Use git diff-tree for DiffFileTree on diff pages (#33514)

Modify Diff View FileTree to show all files

## Changes

* removes Show Status button on diff
* uses `git diff-tree` to generate the file tree for the diff
* doesn't reload the diff tree each time we load more files in the
preview
* selecting and unloaded file will keep loading until that file is
loaded
* removes `DiffFileList.vue` and "Show Stats" in diff options

## Open Questions

* selecting and unloaded file will keep loading until that file is
loaded. Is this behaviour okay? It matches what github does.

### Demo

In this demo I set `git.MAX_GIT_DIFF_FILES=1` in my `app.ini` to
demonstrate a worst case example. In most cases the behaviour isn't
nearly as jarring as we load a bunch of files at a time.


https://github.com/user-attachments/assets/72f29663-d6fc-472d-94fa-7fb5950c2836

---------

Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
Alexander McRae 2025-02-27 16:58:25 -08:00 committed by GitHub
parent 7a8eed13b9
commit aba96f65cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 352 additions and 224 deletions

View file

@ -1,60 +0,0 @@
<script lang="ts" setup>
import {onMounted, onUnmounted} from 'vue';
import {loadMoreFiles} from '../features/repo-diff.ts';
import {diffTreeStore} from '../modules/stores.ts';
const store = diffTreeStore();
onMounted(() => {
document.querySelector('#show-file-list-btn').addEventListener('click', toggleFileList);
});
onUnmounted(() => {
document.querySelector('#show-file-list-btn').removeEventListener('click', toggleFileList);
});
function toggleFileList() {
store.fileListIsVisible = !store.fileListIsVisible;
}
function diffTypeToString(pType: number) {
const diffTypes: Record<string, string> = {
'1': 'add',
'2': 'modify',
'3': 'del',
'4': 'rename',
'5': 'copy',
};
return diffTypes[String(pType)];
}
function diffStatsWidth(adds: number, dels: number) {
return `${adds / (adds + dels) * 100}%`;
}
function loadMoreData() {
loadMoreFiles(store.linkLoadMore);
}
</script>
<template>
<ol class="diff-stats tw-m-0" ref="root" v-if="store.fileListIsVisible">
<li v-for="file in store.files" :key="file.NameHash">
<div class="tw-font-semibold tw-flex tw-items-center pull-right">
<span v-if="file.IsBin" class="tw-ml-0.5 tw-mr-2">{{ store.binaryFileMessage }}</span>
{{ file.IsBin ? '' : file.Addition + file.Deletion }}
<span v-if="!file.IsBin" class="diff-stats-bar tw-mx-2" :data-tooltip-content="store.statisticsMessage.replace('%d', (file.Addition + file.Deletion)).replace('%d', file.Addition).replace('%d', file.Deletion)">
<div class="diff-stats-add-bar" :style="{ 'width': diffStatsWidth(file.Addition, file.Deletion) }"/>
</span>
</div>
<!-- todo finish all file status, now modify, add, delete and rename -->
<span :class="['status', diffTypeToString(file.Type)]" :data-tooltip-content="diffTypeToString(file.Type)">&nbsp;</span>
<a class="file tw-font-mono" :href="'#diff-' + file.NameHash">{{ file.Name }}</a>
</li>
<li v-if="store.isIncomplete" class="tw-pt-1">
<span class="file tw-flex tw-items-center tw-justify-between">{{ store.tooManyFilesMessage }}
<a :class="['ui', 'basic', 'tiny', 'button', store.isLoadingNewData ? 'disabled' : '']" @click.stop="loadMoreData">{{ store.showMoreMessage }}</a>
</span>
</li>
</ol>
</template>

View file

@ -1,75 +1,18 @@
<script lang="ts" setup>
import DiffFileTreeItem, {type Item} from './DiffFileTreeItem.vue';
import {loadMoreFiles} from '../features/repo-diff.ts';
import DiffFileTreeItem from './DiffFileTreeItem.vue';
import {toggleElem} from '../utils/dom.ts';
import {diffTreeStore} from '../modules/stores.ts';
import {setFileFolding} from '../features/file-fold.ts';
import {computed, onMounted, onUnmounted} from 'vue';
import {pathListToTree, mergeChildIfOnlyOneDir} from '../utils/filetree.ts';
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
const store = diffTreeStore();
const fileTree = computed(() => {
const result: Array<Item> = [];
for (const file of store.files) {
// Split file into directories
const splits = file.Name.split('/');
let index = 0;
let parent = null;
let isFile = false;
for (const split of splits) {
index += 1;
// reached the end
if (index === splits.length) {
isFile = true;
}
let newParent: Item = {
name: split,
children: [],
isFile,
};
if (isFile === true) {
newParent.file = file;
}
if (parent) {
// check if the folder already exists
const existingFolder = parent.children.find(
(x) => x.name === split,
);
if (existingFolder) {
newParent = existingFolder;
} else {
parent.children.push(newParent);
}
} else {
const existingFolder = result.find((x) => x.name === split);
if (existingFolder) {
newParent = existingFolder;
} else {
result.push(newParent);
}
}
parent = newParent;
}
}
const mergeChildIfOnlyOneDir = (entries: Array<Record<string, any>>) => {
for (const entry of entries) {
if (entry.children) {
mergeChildIfOnlyOneDir(entry.children);
}
if (entry.children.length === 1 && entry.children[0].isFile === false) {
// Merge it to the parent
entry.name = `${entry.name}/${entry.children[0].name}`;
entry.children = entry.children[0].children;
}
}
};
// Merge folders with just a folder as children in order to
// reduce the depth of our tree.
mergeChildIfOnlyOneDir(result);
const result = pathListToTree(store.files);
mergeChildIfOnlyOneDir(result); // mutation
return result;
});
@ -121,19 +64,12 @@ function updateState(visible: boolean) {
toggleElem(toShow, !visible);
toggleElem(toHide, visible);
}
function loadMoreData() {
loadMoreFiles(store.linkLoadMore);
}
</script>
<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"/>
<div v-if="store.isIncomplete" class="tw-pt-1">
<a :class="['ui', 'basic', 'tiny', 'button', store.isLoadingNewData ? 'disabled' : '']" @click.stop="loadMoreData">{{ store.showMoreMessage }}</a>
</div>
</div>
</template>

View file

@ -2,21 +2,7 @@
import {SvgIcon, type SvgName} from '../svg.ts';
import {diffTreeStore} from '../modules/stores.ts';
import {ref} from 'vue';
type File = {
Name: string;
NameHash: string;
Type: number;
IsViewed: boolean;
IsSubmodule: boolean;
}
export type Item = {
name: string;
isFile: boolean;
file?: File;
children?: Item[];
};
import type {Item, File, FileStatus} from '../utils/filetree.ts';
defineProps<{
item: Item,
@ -25,15 +11,16 @@ defineProps<{
const store = diffTreeStore();
const collapsed = ref(false);
function getIconForDiffType(pType: number) {
const diffTypes: Record<string, {name: SvgName, classes: Array<string>}> = {
'1': {name: 'octicon-diff-added', classes: ['text', 'green']},
'2': {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
'3': {name: 'octicon-diff-removed', classes: ['text', 'red']},
'4': {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
'5': {name: 'octicon-diff-renamed', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
function getIconForDiffStatus(pType: FileStatus) {
const diffTypes: Record<FileStatus, { name: SvgName, classes: Array<string> }> = {
'added': {name: 'octicon-diff-added', classes: ['text', 'green']},
'modified': {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
'deleted': {name: 'octicon-diff-removed', classes: ['text', 'red']},
'renamed': {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
'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[String(pType)];
return diffTypes[pType];
}
function fileIcon(file: File) {
@ -48,27 +35,37 @@ function fileIcon(file: File) {
<!--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}"
: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="getIconForDiffType(item.file.Type).name" :class="getIconForDiffType(item.file.Type).classes"/>
<SvgIcon
:name="getIconForDiffStatus(item.file.Status).name"
:class="getIconForDiffStatus(item.file.Status).classes"
/>
</a>
<div v-else class="item-directory" :title="item.name" @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>
</div>
<div v-if="item.children?.length" v-show="!collapsed" class="sub-items">
<DiffFileTreeItem v-for="childItem in item.children" :key="childItem.name" :item="childItem"/>
</div>
<template v-else-if="item.isFile === false">
<div class="item-directory" :title="item.name" @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>
</div>
<div v-show="!collapsed" class="sub-items">
<DiffFileTreeItem v-for="childItem in item.children" :key="childItem.name" :item="childItem"/>
</div>
</template>
</template>
<style scoped>
a, a:hover {
a,
a:hover {
text-decoration: none;
color: var(--color-text);
}