mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	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:
		| @@ -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)"> </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> | ||||
| @@ -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> | ||||
|  | ||||
|   | ||||
| @@ -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); | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| import {createApp} from 'vue'; | ||||
| import DiffFileTree from '../components/DiffFileTree.vue'; | ||||
| import DiffFileList from '../components/DiffFileList.vue'; | ||||
|  | ||||
| export function initDiffFileTree() { | ||||
|   const el = document.querySelector('#diff-file-tree'); | ||||
| @@ -9,11 +8,3 @@ export function initDiffFileTree() { | ||||
|   const fileTreeView = createApp(DiffFileTree); | ||||
|   fileTreeView.mount(el); | ||||
| } | ||||
|  | ||||
| export function initDiffFileList() { | ||||
|   const fileListElement = document.querySelector('#diff-file-list'); | ||||
|   if (!fileListElement) return; | ||||
|  | ||||
|   const fileListView = createApp(DiffFileList); | ||||
|   fileListView.mount(fileListElement); | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import $ from 'jquery'; | ||||
| import {initCompReactionSelector} from './comp/ReactionSelector.ts'; | ||||
| import {initRepoIssueContentHistory} from './repo-issue-content.ts'; | ||||
| import {initDiffFileTree, initDiffFileList} from './repo-diff-filetree.ts'; | ||||
| import {initDiffFileTree} from './repo-diff-filetree.ts'; | ||||
| import {initDiffCommitSelect} from './repo-diff-commitselect.ts'; | ||||
| import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.ts'; | ||||
| import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles, initExpandAndCollapseFilesButton} from './pull-view-file.ts'; | ||||
| @@ -21,7 +21,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts'; | ||||
| import {createTippy} from '../modules/tippy.ts'; | ||||
| import {invertFileFolding} from './file-fold.ts'; | ||||
|  | ||||
| const {pageData, i18n} = window.config; | ||||
| const {i18n} = window.config; | ||||
|  | ||||
| function initRepoDiffFileViewToggle() { | ||||
|   $('.file-view-toggle').on('click', function () { | ||||
| @@ -161,6 +161,7 @@ function initDiffHeaderPopup() { | ||||
|  | ||||
| // Will be called when the show more (files) button has been pressed | ||||
| function onShowMoreFiles() { | ||||
|   // FIXME: here the init calls are incomplete: at least it misses dropdown & initCompReactionSelector | ||||
|   initRepoIssueContentHistory(); | ||||
|   initViewedCheckboxListenerFor(); | ||||
|   countAndUpdateViewedFiles(); | ||||
| @@ -168,41 +169,35 @@ function onShowMoreFiles() { | ||||
|   initDiffHeaderPopup(); | ||||
| } | ||||
|  | ||||
| export async function loadMoreFiles(url: string) { | ||||
|   const target = document.querySelector('a#diff-show-more-files'); | ||||
|   if (target?.classList.contains('disabled') || pageData.diffFileInfo.isLoadingNewData) { | ||||
|     return; | ||||
| async function loadMoreFiles(btn: Element): Promise<boolean> { | ||||
|   if (btn.classList.contains('disabled')) { | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   pageData.diffFileInfo.isLoadingNewData = true; | ||||
|   target?.classList.add('disabled'); | ||||
|  | ||||
|   btn.classList.add('disabled'); | ||||
|   const url = btn.getAttribute('data-href'); | ||||
|   try { | ||||
|     const response = await GET(url); | ||||
|     const resp = await response.text(); | ||||
|     const $resp = $(resp); | ||||
|     // the response is a full HTML page, we need to extract the relevant contents: | ||||
|     // 1. append the newly loaded file list items to the existing list | ||||
|     // * append the newly loaded file list items to the existing list | ||||
|     $('#diff-incomplete').replaceWith($resp.find('#diff-file-boxes').children()); | ||||
|     // 2. re-execute the script to append the newly loaded items to the JS variables to refresh the DiffFileTree | ||||
|     $('body').append($resp.find('script#diff-data-script')); | ||||
|  | ||||
|     onShowMoreFiles(); | ||||
|     return true; | ||||
|   } catch (error) { | ||||
|     console.error('Error:', error); | ||||
|     showErrorToast('An error occurred while loading more files.'); | ||||
|   } finally { | ||||
|     target?.classList.remove('disabled'); | ||||
|     pageData.diffFileInfo.isLoadingNewData = false; | ||||
|     btn.classList.remove('disabled'); | ||||
|   } | ||||
|   return false; | ||||
| } | ||||
|  | ||||
| function initRepoDiffShowMore() { | ||||
|   $(document).on('click', 'a#diff-show-more-files', (e) => { | ||||
|   addDelegatedEventListener(document, 'click', 'a#diff-show-more-files', (el, e) => { | ||||
|     e.preventDefault(); | ||||
|  | ||||
|     const linkLoadMore = e.target.getAttribute('data-href'); | ||||
|     loadMoreFiles(linkLoadMore); | ||||
|     loadMoreFiles(el); | ||||
|   }); | ||||
|  | ||||
|   $(document).on('click', 'a.diff-load-button', async (e) => { | ||||
| @@ -234,17 +229,49 @@ function initRepoDiffShowMore() { | ||||
|   }); | ||||
| } | ||||
|  | ||||
| async function loadUntilFound() { | ||||
|   const hashTargetSelector = window.location.hash; | ||||
|   if (!hashTargetSelector.startsWith('#diff-') && !hashTargetSelector.startsWith('#issuecomment-')) { | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   while (true) { | ||||
|     // use getElementById to avoid querySelector throws an error when the hash is invalid | ||||
|     // eslint-disable-next-line unicorn/prefer-query-selector | ||||
|     const targetElement = document.getElementById(hashTargetSelector.substring(1)); | ||||
|     if (targetElement) { | ||||
|       targetElement.scrollIntoView(); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // the button will be refreshed after each "load more", so query it every time | ||||
|     const showMoreButton = document.querySelector('#diff-show-more-files'); | ||||
|     if (!showMoreButton) { | ||||
|       return; // nothing more to load | ||||
|     } | ||||
|  | ||||
|     // Load more files, await ensures we don't block progress | ||||
|     const ok = await loadMoreFiles(showMoreButton); | ||||
|     if (!ok) return; // failed to load more files | ||||
|   } | ||||
| } | ||||
|  | ||||
| function initRepoDiffHashChangeListener() { | ||||
|   window.addEventListener('hashchange', loadUntilFound); | ||||
|   loadUntilFound(); | ||||
| } | ||||
|  | ||||
| export function initRepoDiffView() { | ||||
|   initRepoDiffConversationForm(); | ||||
|   if (!$('#diff-file-list').length) return; | ||||
|   if (!$('#diff-file-boxes').length) return; | ||||
|   initDiffFileTree(); | ||||
|   initDiffFileList(); | ||||
|   initDiffCommitSelect(); | ||||
|   initRepoDiffShowMore(); | ||||
|   initDiffHeaderPopup(); | ||||
|   initRepoDiffFileViewToggle(); | ||||
|   initViewedCheckboxListenerFor(); | ||||
|   initExpandAndCollapseFilesButton(); | ||||
|   initRepoDiffHashChangeListener(); | ||||
|  | ||||
|   addDelegatedEventListener(document, 'click', '.fold-file', (el) => { | ||||
|     invertFileFolding(el.closest('.file-content'), el); | ||||
|   | ||||
| @@ -1,11 +1,16 @@ | ||||
| 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(window.config.pageData.diffFileInfo); | ||||
|     window.config.pageData.diffFileInfo = diffTreeStoreReactive; | ||||
|     diffTreeStoreReactive = reactive({ | ||||
|       files: pageData.DiffFiles, | ||||
|       fileTreeIsVisible: false, | ||||
|       selectedItem: '', | ||||
|     }); | ||||
|   } | ||||
|   return diffTreeStoreReactive; | ||||
| } | ||||
|   | ||||
| @@ -1,9 +1,15 @@ | ||||
| import { | ||||
|   basename, extname, isObject, stripTags, parseIssueHref, | ||||
|   dirname, basename, extname, isObject, stripTags, parseIssueHref, | ||||
|   parseUrl, translateMonth, translateDay, blobToDataURI, | ||||
|   toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseRepoOwnerPathInfo, | ||||
| } from './utils.ts'; | ||||
|  | ||||
| test('dirname', () => { | ||||
|   expect(dirname('/path/to/file.js')).toEqual('/path/to'); | ||||
|   expect(dirname('/path/to')).toEqual('/path'); | ||||
|   expect(dirname('file.js')).toEqual(''); | ||||
| }); | ||||
|  | ||||
| test('basename', () => { | ||||
|   expect(basename('/path/to/file.js')).toEqual('file.js'); | ||||
|   expect(basename('/path/to/file')).toEqual('file'); | ||||
|   | ||||
| @@ -1,6 +1,12 @@ | ||||
| import {decode, encode} from 'uint8-to-base64'; | ||||
| import type {IssuePageInfo, IssuePathInfo, RepoOwnerPathInfo} from './types.ts'; | ||||
|  | ||||
| // transform /path/to/file.ext to /path/to | ||||
| export function dirname(path: string): string { | ||||
|   const lastSlashIndex = path.lastIndexOf('/'); | ||||
|   return lastSlashIndex < 0 ? '' : path.substring(0, lastSlashIndex); | ||||
| } | ||||
|  | ||||
| // transform /path/to/file.ext to file.ext | ||||
| export function basename(path: string): string { | ||||
|   const lastSlashIndex = path.lastIndexOf('/'); | ||||
|   | ||||
							
								
								
									
										86
									
								
								web_src/js/utils/filetree.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								web_src/js/utils/filetree.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,86 @@ | ||||
| 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'}}, | ||||
|     ]}, | ||||
|   ]); | ||||
| }); | ||||
							
								
								
									
										85
									
								
								web_src/js/utils/filetree.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								web_src/js/utils/filetree.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | ||||
| 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