diff options
author | Kerwin Bryant <kerwin612@qq.com> | 2025-04-15 22:35:22 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-04-15 22:35:22 +0800 |
commit | 2b99a58f540a15a04b48cba507ace8abf3c52014 (patch) | |
tree | aa14c105285455352ba37ed386f3cfab83b4eccf /web_src/js | |
parent | 18a673bad1d036502baca4491a16679692c42320 (diff) | |
download | gitea-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.vue | 15 | ||||
-rw-r--r-- | web_src/js/components/DiffFileTreeItem.vue | 60 | ||||
-rw-r--r-- | web_src/js/features/file-fold.ts | 2 | ||||
-rw-r--r-- | web_src/js/features/pull-view-file.ts | 9 | ||||
-rw-r--r-- | web_src/js/modules/diff-file.test.ts | 47 | ||||
-rw-r--r-- | web_src/js/modules/diff-file.ts | 78 | ||||
-rw-r--r-- | web_src/js/modules/stores.ts | 16 | ||||
-rw-r--r-- | web_src/js/utils/filetree.test.ts | 86 | ||||
-rw-r--r-- | web_src/js/utils/filetree.ts | 85 |
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; - } - } -} |