aboutsummaryrefslogtreecommitdiffstats
path: root/web_src/js
diff options
context:
space:
mode:
authorKerwin Bryant <kerwin612@qq.com>2025-04-15 22:35:22 +0800
committerGitHub <noreply@github.com>2025-04-15 22:35:22 +0800
commit2b99a58f540a15a04b48cba507ace8abf3c52014 (patch)
treeaa14c105285455352ba37ed386f3cfab83b4eccf /web_src/js
parent18a673bad1d036502baca4491a16679692c42320 (diff)
downloadgitea-2b99a58f540a15a04b48cba507ace8abf3c52014.tar.gz
gitea-2b99a58f540a15a04b48cba507ace8abf3c52014.zip
Mark parent directory as viewed when all files are viewed (#33958)
Fix #25644 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Diffstat (limited to 'web_src/js')
-rw-r--r--web_src/js/components/DiffFileTree.vue15
-rw-r--r--web_src/js/components/DiffFileTreeItem.vue60
-rw-r--r--web_src/js/features/file-fold.ts2
-rw-r--r--web_src/js/features/pull-view-file.ts9
-rw-r--r--web_src/js/modules/diff-file.test.ts47
-rw-r--r--web_src/js/modules/diff-file.ts78
-rw-r--r--web_src/js/modules/stores.ts16
-rw-r--r--web_src/js/utils/filetree.test.ts86
-rw-r--r--web_src/js/utils/filetree.ts85
9 files changed, 163 insertions, 235 deletions
diff --git a/web_src/js/components/DiffFileTree.vue b/web_src/js/components/DiffFileTree.vue
index 381a1c3ca4..5426a672cb 100644
--- a/web_src/js/components/DiffFileTree.vue
+++ b/web_src/js/components/DiffFileTree.vue
@@ -1,21 +1,14 @@
<script lang="ts" setup>
import DiffFileTreeItem from './DiffFileTreeItem.vue';
import {toggleElem} from '../utils/dom.ts';
-import {diffTreeStore} from '../modules/stores.ts';
+import {diffTreeStore} from '../modules/diff-file.ts';
import {setFileFolding} from '../features/file-fold.ts';
-import {computed, onMounted, onUnmounted} from 'vue';
-import {pathListToTree, mergeChildIfOnlyOneDir} from '../utils/filetree.ts';
+import {onMounted, onUnmounted} from 'vue';
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
const store = diffTreeStore();
-const fileTree = computed(() => {
- const result = pathListToTree(store.files);
- mergeChildIfOnlyOneDir(result); // mutation
- return result;
-});
-
onMounted(() => {
// Default to true if unset
store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
@@ -50,7 +43,7 @@ function toggleVisibility() {
function updateVisibility(visible: boolean) {
store.fileTreeIsVisible = visible;
- localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
+ localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible.toString());
updateState(store.fileTreeIsVisible);
}
@@ -69,7 +62,7 @@ function updateState(visible: boolean) {
<template>
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
<!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
- <DiffFileTreeItem v-for="item in fileTree" :key="item.name" :item="item"/>
+ <DiffFileTreeItem v-for="item in store.diffFileTree.TreeRoot.Children" :key="item.FullName" :item="item"/>
</div>
</template>
diff --git a/web_src/js/components/DiffFileTreeItem.vue b/web_src/js/components/DiffFileTreeItem.vue
index 5ee0e5bcaa..d6d5506155 100644
--- a/web_src/js/components/DiffFileTreeItem.vue
+++ b/web_src/js/components/DiffFileTreeItem.vue
@@ -1,18 +1,18 @@
<script lang="ts" setup>
import {SvgIcon, type SvgName} from '../svg.ts';
-import {diffTreeStore} from '../modules/stores.ts';
import {ref} from 'vue';
-import type {Item, File, FileStatus} from '../utils/filetree.ts';
+import {type DiffStatus, type DiffTreeEntry, diffTreeStore} from '../modules/diff-file.ts';
-defineProps<{
- item: Item,
+const props = defineProps<{
+ item: DiffTreeEntry,
}>();
const store = diffTreeStore();
-const collapsed = ref(false);
+const collapsed = ref(props.item.IsViewed);
-function getIconForDiffStatus(pType: FileStatus) {
- const diffTypes: Record<FileStatus, { name: SvgName, classes: Array<string> }> = {
+function getIconForDiffStatus(pType: DiffStatus) {
+ const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = {
+ '': {name: 'octicon-blocked', classes: ['text', 'red']}, // unknown case
'added': {name: 'octicon-diff-added', classes: ['text', 'green']},
'modified': {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
'deleted': {name: 'octicon-diff-removed', classes: ['text', 'red']},
@@ -20,11 +20,11 @@ function getIconForDiffStatus(pType: FileStatus) {
'copied': {name: 'octicon-diff-renamed', classes: ['text', 'green']},
'typechange': {name: 'octicon-diff-modified', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
};
- return diffTypes[pType];
+ return diffTypes[pType] ?? diffTypes[''];
}
-function fileIcon(file: File) {
- if (file.IsSubmodule) {
+function entryIcon(entry: DiffTreeEntry) {
+ if (entry.EntryMode === 'commit') {
return 'octicon-file-submodule';
}
return 'octicon-file';
@@ -32,37 +32,36 @@ function fileIcon(file: File) {
</script>
<template>
- <!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"-->
- <a
- v-if="item.isFile" class="item-file"
- :class="{ 'selected': store.selectedItem === '#diff-' + item.file.NameHash, 'viewed': item.file.IsViewed }"
- :title="item.name" :href="'#diff-' + item.file.NameHash"
- >
- <!-- file -->
- <SvgIcon :name="fileIcon(item.file)"/>
- <span class="gt-ellipsis tw-flex-1">{{ item.name }}</span>
- <SvgIcon
- :name="getIconForDiffStatus(item.file.Status).name"
- :class="getIconForDiffStatus(item.file.Status).classes"
- />
- </a>
-
- <template v-else-if="item.isFile === false">
- <div class="item-directory" :title="item.name" @click.stop="collapsed = !collapsed">
+ <template v-if="item.EntryMode === 'tree'">
+ <div class="item-directory" :class="{ 'viewed': item.IsViewed }" :title="item.DisplayName" @click.stop="collapsed = !collapsed">
<!-- directory -->
<SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/>
<SvgIcon
class="text primary"
:name="collapsed ? 'octicon-file-directory-fill' : 'octicon-file-directory-open-fill'"
/>
- <span class="gt-ellipsis">{{ item.name }}</span>
+ <span class="gt-ellipsis">{{ item.DisplayName }}</span>
</div>
<div v-show="!collapsed" class="sub-items">
- <DiffFileTreeItem v-for="childItem in item.children" :key="childItem.name" :item="childItem"/>
+ <DiffFileTreeItem v-for="childItem in item.Children" :key="childItem.DisplayName" :item="childItem"/>
</div>
</template>
+ <a
+ v-else
+ class="item-file" :class="{ 'selected': store.selectedItem === '#diff-' + item.NameHash, 'viewed': item.IsViewed }"
+ :title="item.DisplayName" :href="'#diff-' + item.NameHash"
+ >
+ <!-- file -->
+ <SvgIcon :name="entryIcon(item)"/>
+ <span class="gt-ellipsis tw-flex-1">{{ item.DisplayName }}</span>
+ <SvgIcon
+ :name="getIconForDiffStatus(item.DiffStatus).name"
+ :class="getIconForDiffStatus(item.DiffStatus).classes"
+ />
+ </a>
</template>
+
<style scoped>
a,
a:hover {
@@ -88,7 +87,8 @@ a:hover {
border-radius: 4px;
}
-.item-file.viewed {
+.item-file.viewed,
+.item-directory.viewed {
color: var(--color-text-light-3);
}
diff --git a/web_src/js/features/file-fold.ts b/web_src/js/features/file-fold.ts
index 19950d9b9f..74b36c0096 100644
--- a/web_src/js/features/file-fold.ts
+++ b/web_src/js/features/file-fold.ts
@@ -5,7 +5,7 @@ import {svg} from '../svg.ts';
// The fold arrow is the icon displayed on the upper left of the file box, especially intended for components having the 'fold-file' class.
// The file content box is the box that should be hidden or shown, especially intended for components having the 'file-content' class.
//
-export function setFileFolding(fileContentBox: HTMLElement, foldArrow: HTMLElement, newFold: boolean) {
+export function setFileFolding(fileContentBox: Element, foldArrow: HTMLElement, newFold: boolean) {
foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18);
fileContentBox.setAttribute('data-folded', String(newFold));
if (newFold && fileContentBox.getBoundingClientRect().top < 0) {
diff --git a/web_src/js/features/pull-view-file.ts b/web_src/js/features/pull-view-file.ts
index 16ccf00084..1124886238 100644
--- a/web_src/js/features/pull-view-file.ts
+++ b/web_src/js/features/pull-view-file.ts
@@ -1,4 +1,4 @@
-import {diffTreeStore} from '../modules/stores.ts';
+import {diffTreeStore, diffTreeStoreSetViewed} from '../modules/diff-file.ts';
import {setFileFolding} from './file-fold.ts';
import {POST} from '../modules/fetch.ts';
@@ -58,11 +58,8 @@ export function initViewedCheckboxListenerFor() {
const fileName = checkbox.getAttribute('name');
- // check if the file is in our difftreestore and if we find it -> change the IsViewed status
- const fileInPageData = diffTreeStore().files.find((x: Record<string, any>) => x.Name === fileName);
- if (fileInPageData) {
- fileInPageData.IsViewed = this.checked;
- }
+ // check if the file is in our diffTreeStore and if we find it -> change the IsViewed status
+ diffTreeStoreSetViewed(diffTreeStore(), fileName, this.checked);
// Unfortunately, actual forms cause too many problems, hence another approach is needed
const files: Record<string, boolean> = {};
diff --git a/web_src/js/modules/diff-file.test.ts b/web_src/js/modules/diff-file.test.ts
new file mode 100644
index 0000000000..1f956a7d86
--- /dev/null
+++ b/web_src/js/modules/diff-file.test.ts
@@ -0,0 +1,47 @@
+import {diffTreeStoreSetViewed, reactiveDiffTreeStore} from './diff-file.ts';
+
+test('diff-tree', () => {
+ const store = reactiveDiffTreeStore({
+ 'TreeRoot': {
+ 'FullName': '',
+ 'DisplayName': '',
+ 'EntryMode': '',
+ 'IsViewed': false,
+ 'NameHash': '....',
+ 'DiffStatus': '',
+ 'Children': [
+ {
+ 'FullName': 'dir1',
+ 'DisplayName': 'dir1',
+ 'EntryMode': 'tree',
+ 'IsViewed': false,
+ 'NameHash': '....',
+ 'DiffStatus': '',
+ 'Children': [
+ {
+ 'FullName': 'dir1/test.txt',
+ 'DisplayName': 'test.txt',
+ 'DiffStatus': 'added',
+ 'NameHash': '....',
+ 'EntryMode': '',
+ 'IsViewed': false,
+ 'Children': null,
+ },
+ ],
+ },
+ {
+ 'FullName': 'other.txt',
+ 'DisplayName': 'other.txt',
+ 'NameHash': '........',
+ 'DiffStatus': 'added',
+ 'EntryMode': '',
+ 'IsViewed': false,
+ 'Children': null,
+ },
+ ],
+ },
+ });
+ diffTreeStoreSetViewed(store, 'dir1/test.txt', true);
+ expect(store.fullNameMap['dir1/test.txt'].IsViewed).toBe(true);
+ expect(store.fullNameMap['dir1'].IsViewed).toBe(true);
+});
diff --git a/web_src/js/modules/diff-file.ts b/web_src/js/modules/diff-file.ts
new file mode 100644
index 0000000000..5d06f8a333
--- /dev/null
+++ b/web_src/js/modules/diff-file.ts
@@ -0,0 +1,78 @@
+import {reactive} from 'vue';
+import type {Reactive} from 'vue';
+
+const {pageData} = window.config;
+
+export type DiffStatus = '' | 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange';
+
+export type DiffTreeEntry = {
+ FullName: string,
+ DisplayName: string,
+ NameHash: string,
+ DiffStatus: DiffStatus,
+ EntryMode: string,
+ IsViewed: boolean,
+ Children: DiffTreeEntry[],
+
+ ParentEntry?: DiffTreeEntry,
+}
+
+type DiffFileTreeData = {
+ TreeRoot: DiffTreeEntry,
+};
+
+type DiffFileTree = {
+ diffFileTree: DiffFileTreeData;
+ fullNameMap?: Record<string, DiffTreeEntry>
+ fileTreeIsVisible: boolean;
+ selectedItem: string;
+}
+
+let diffTreeStoreReactive: Reactive<DiffFileTree>;
+export function diffTreeStore() {
+ if (!diffTreeStoreReactive) {
+ diffTreeStoreReactive = reactiveDiffTreeStore(pageData.DiffFileTree);
+ }
+ return diffTreeStoreReactive;
+}
+
+export function diffTreeStoreSetViewed(store: Reactive<DiffFileTree>, fullName: string, viewed: boolean) {
+ const entry = store.fullNameMap[fullName];
+ if (!entry) return;
+ entry.IsViewed = viewed;
+ for (let parent = entry.ParentEntry; parent; parent = parent.ParentEntry) {
+ parent.IsViewed = isEntryViewed(parent);
+ }
+}
+
+function fillFullNameMap(map: Record<string, DiffTreeEntry>, entry: DiffTreeEntry) {
+ map[entry.FullName] = entry;
+ if (!entry.Children) return;
+ entry.IsViewed = isEntryViewed(entry);
+ for (const child of entry.Children) {
+ child.ParentEntry = entry;
+ fillFullNameMap(map, child);
+ }
+}
+
+export function reactiveDiffTreeStore(data: DiffFileTreeData): Reactive<DiffFileTree> {
+ const store = reactive({
+ diffFileTree: data,
+ fileTreeIsVisible: false,
+ selectedItem: '',
+ fullNameMap: {},
+ });
+ fillFullNameMap(store.fullNameMap, data.TreeRoot);
+ return store;
+}
+
+function isEntryViewed(entry: DiffTreeEntry): boolean {
+ if (entry.Children) {
+ let count = 0;
+ for (const child of entry.Children) {
+ if (child.IsViewed) count++;
+ }
+ return count === entry.Children.length;
+ }
+ return entry.IsViewed;
+}
diff --git a/web_src/js/modules/stores.ts b/web_src/js/modules/stores.ts
deleted file mode 100644
index 65da1e044a..0000000000
--- a/web_src/js/modules/stores.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import {reactive} from 'vue';
-import type {Reactive} from 'vue';
-
-const {pageData} = window.config;
-
-let diffTreeStoreReactive: Reactive<Record<string, any>>;
-export function diffTreeStore() {
- if (!diffTreeStoreReactive) {
- diffTreeStoreReactive = reactive({
- files: pageData.DiffFiles,
- fileTreeIsVisible: false,
- selectedItem: '',
- });
- }
- return diffTreeStoreReactive;
-}
diff --git a/web_src/js/utils/filetree.test.ts b/web_src/js/utils/filetree.test.ts
deleted file mode 100644
index f561cb75f0..0000000000
--- a/web_src/js/utils/filetree.test.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import {mergeChildIfOnlyOneDir, pathListToTree, type File} from './filetree.ts';
-
-const emptyList: File[] = [];
-const singleFile = [{Name: 'file1'}] as File[];
-const singleDir = [{Name: 'dir1/file1'}] as File[];
-const nestedDir = [{Name: 'dir1/dir2/file1'}] as File[];
-const multiplePathsDisjoint = [{Name: 'dir1/dir2/file1'}, {Name: 'dir3/file2'}] as File[];
-const multiplePathsShared = [{Name: 'dir1/dir2/dir3/file1'}, {Name: 'dir1/file2'}] as File[];
-
-test('pathListToTree', () => {
- expect(pathListToTree(emptyList)).toEqual([]);
- expect(pathListToTree(singleFile)).toEqual([
- {isFile: true, name: 'file1', path: 'file1', file: {Name: 'file1'}},
- ]);
- expect(pathListToTree(singleDir)).toEqual([
- {isFile: false, name: 'dir1', path: 'dir1', children: [
- {isFile: true, name: 'file1', path: 'dir1/file1', file: {Name: 'dir1/file1'}},
- ]},
- ]);
- expect(pathListToTree(nestedDir)).toEqual([
- {isFile: false, name: 'dir1', path: 'dir1', children: [
- {isFile: false, name: 'dir2', path: 'dir1/dir2', children: [
- {isFile: true, name: 'file1', path: 'dir1/dir2/file1', file: {Name: 'dir1/dir2/file1'}},
- ]},
- ]},
- ]);
- expect(pathListToTree(multiplePathsDisjoint)).toEqual([
- {isFile: false, name: 'dir1', path: 'dir1', children: [
- {isFile: false, name: 'dir2', path: 'dir1/dir2', children: [
- {isFile: true, name: 'file1', path: 'dir1/dir2/file1', file: {Name: 'dir1/dir2/file1'}},
- ]},
- ]},
- {isFile: false, name: 'dir3', path: 'dir3', children: [
- {isFile: true, name: 'file2', path: 'dir3/file2', file: {Name: 'dir3/file2'}},
- ]},
- ]);
- expect(pathListToTree(multiplePathsShared)).toEqual([
- {isFile: false, name: 'dir1', path: 'dir1', children: [
- {isFile: false, name: 'dir2', path: 'dir1/dir2', children: [
- {isFile: false, name: 'dir3', path: 'dir1/dir2/dir3', children: [
- {isFile: true, name: 'file1', path: 'dir1/dir2/dir3/file1', file: {Name: 'dir1/dir2/dir3/file1'}},
- ]},
- ]},
- {isFile: true, name: 'file2', path: 'dir1/file2', file: {Name: 'dir1/file2'}},
- ]},
- ]);
-});
-
-const mergeChildWrapper = (testCase: File[]) => {
- const tree = pathListToTree(testCase);
- mergeChildIfOnlyOneDir(tree);
- return tree;
-};
-
-test('mergeChildIfOnlyOneDir', () => {
- expect(mergeChildWrapper(emptyList)).toEqual([]);
- expect(mergeChildWrapper(singleFile)).toEqual([
- {isFile: true, name: 'file1', path: 'file1', file: {Name: 'file1'}},
- ]);
- expect(mergeChildWrapper(singleDir)).toEqual([
- {isFile: false, name: 'dir1', path: 'dir1', children: [
- {isFile: true, name: 'file1', path: 'dir1/file1', file: {Name: 'dir1/file1'}},
- ]},
- ]);
- expect(mergeChildWrapper(nestedDir)).toEqual([
- {isFile: false, name: 'dir1/dir2', path: 'dir1/dir2', children: [
- {isFile: true, name: 'file1', path: 'dir1/dir2/file1', file: {Name: 'dir1/dir2/file1'}},
- ]},
- ]);
- expect(mergeChildWrapper(multiplePathsDisjoint)).toEqual([
- {isFile: false, name: 'dir1/dir2', path: 'dir1/dir2', children: [
- {isFile: true, name: 'file1', path: 'dir1/dir2/file1', file: {Name: 'dir1/dir2/file1'}},
- ]},
- {isFile: false, name: 'dir3', path: 'dir3', children: [
- {isFile: true, name: 'file2', path: 'dir3/file2', file: {Name: 'dir3/file2'}},
- ]},
- ]);
- expect(mergeChildWrapper(multiplePathsShared)).toEqual([
- {isFile: false, name: 'dir1', path: 'dir1', children: [
- {isFile: false, name: 'dir2/dir3', path: 'dir1/dir2/dir3', children: [
- {isFile: true, name: 'file1', path: 'dir1/dir2/dir3/file1', file: {Name: 'dir1/dir2/dir3/file1'}},
- ]},
- {isFile: true, name: 'file2', path: 'dir1/file2', file: {Name: 'dir1/file2'}},
- ]},
- ]);
-});
diff --git a/web_src/js/utils/filetree.ts b/web_src/js/utils/filetree.ts
deleted file mode 100644
index 35f9f58189..0000000000
--- a/web_src/js/utils/filetree.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import {dirname, basename} from '../utils.ts';
-
-export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange';
-
-export type File = {
- Name: string;
- NameHash: string;
- Status: FileStatus;
- IsViewed: boolean;
- IsSubmodule: boolean;
-}
-
-type DirItem = {
- isFile: false;
- name: string;
- path: string;
-
- children: Item[];
-}
-
-type FileItem = {
- isFile: true;
- name: string;
- path: string;
- file: File;
-}
-
-export type Item = DirItem | FileItem;
-
-export function pathListToTree(fileEntries: File[]): Item[] {
- const pathToItem = new Map<string, DirItem>();
-
- // init root node
- const root: DirItem = {name: '', path: '', isFile: false, children: []};
- pathToItem.set('', root);
-
- for (const fileEntry of fileEntries) {
- const [parentPath, fileName] = [dirname(fileEntry.Name), basename(fileEntry.Name)];
-
- let parentItem = pathToItem.get(parentPath);
- if (!parentItem) {
- parentItem = constructParents(pathToItem, parentPath);
- }
-
- const fileItem: FileItem = {name: fileName, path: fileEntry.Name, isFile: true, file: fileEntry};
-
- parentItem.children.push(fileItem);
- }
-
- return root.children;
-}
-
-function constructParents(pathToItem: Map<string, DirItem>, dirPath: string): DirItem {
- const [dirParentPath, dirName] = [dirname(dirPath), basename(dirPath)];
-
- let parentItem = pathToItem.get(dirParentPath);
- if (!parentItem) {
- // if the parent node does not exist, create it
- parentItem = constructParents(pathToItem, dirParentPath);
- }
-
- const dirItem: DirItem = {name: dirName, path: dirPath, isFile: false, children: []};
- parentItem.children.push(dirItem);
- pathToItem.set(dirPath, dirItem);
-
- return dirItem;
-}
-
-export function mergeChildIfOnlyOneDir(nodes: Item[]): void {
- for (const node of nodes) {
- if (node.isFile) {
- continue;
- }
- const dir = node as DirItem;
-
- mergeChildIfOnlyOneDir(dir.children);
-
- if (dir.children.length === 1 && dir.children[0].isFile === false) {
- const child = dir.children[0];
- dir.name = `${dir.name}/${child.name}`;
- dir.path = child.path;
- dir.children = child.children;
- }
- }
-}