mirror of https://github.com/go-gitea/gitea.git
Add "Go to file", "Delete Directory" to repo file list page (#35911)
/claim #35898 Resolves #35898 ### Summary of key changes: 1. Add file name search/Go to file functionality to repo button row. 2. Add backend functionality to delete directory 3. Add context menu for directories with functionality to copy path & delete a directory 4. Move Add/Upload file dropdown to right for parity with Github UI 5. Add tree view to the edit/upload UI --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>pull/36058/head^2
parent
b54af8811e
commit
7d6861ac54
@ -1,24 +0,0 @@
|
|||||||
// Copyright 2022 The Gitea Authors. All rights reserved.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package repo
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/templates"
|
|
||||||
"code.gitea.io/gitea/modules/util"
|
|
||||||
"code.gitea.io/gitea/services/context"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
tplFindFiles templates.TplName = "repo/find/files"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FindFiles render the page to find repository files
|
|
||||||
func FindFiles(ctx *context.Context) {
|
|
||||||
path := ctx.PathParam("*")
|
|
||||||
ctx.Data["TreeLink"] = ctx.Repo.RepoLink + "/src/" + util.PathEscapeSegments(path)
|
|
||||||
ctx.Data["DataLink"] = ctx.Repo.RepoLink + "/tree-list/" + util.PathEscapeSegments(path)
|
|
||||||
ctx.HTML(http.StatusOK, tplFindFiles)
|
|
||||||
}
|
|
||||||
@ -1,13 +1,30 @@
|
|||||||
{{template "base/head" .}}
|
{{template "base/head" .}}
|
||||||
<div role="main" aria-label="{{.Title}}" class="page-content repository file editor delete">
|
<div role="main" aria-label="{{.Title}}" class="page-content repository file editor delete">
|
||||||
{{template "repo/header" .}}
|
{{template "repo/header" .}}
|
||||||
<div class="ui container">
|
<div class="ui container fluid padded">
|
||||||
{{template "base/alert" .}}
|
{{template "base/alert" .}}
|
||||||
|
<div class="repo-view-container">
|
||||||
|
{{template "repo/view_file_tree" .}}
|
||||||
|
<div class="repo-view-content">
|
||||||
<form class="ui form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
|
<form class="ui form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
|
||||||
{{.CsrfTokenHtml}}
|
{{.CsrfTokenHtml}}
|
||||||
{{template "repo/editor/common_top" .}}
|
{{template "repo/editor/common_top" .}}
|
||||||
|
<div class="repo-editor-header">
|
||||||
|
{{/* although the UI isn't good enough, this header is necessary for the "left file tree view" toggle button, this button must exist */}}
|
||||||
|
{{template "repo/view_file_tree_toggle_button" .}}
|
||||||
|
{{/* then, to make the page looks overall good, add the breadcrumb here to make the toggle button can be shown in a text row, but not a single button*/}}
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a class="section" href="{{$.BranchLink}}">{{.Repository.Name}}</a>
|
||||||
|
{{range $i, $v := .TreeNames}}
|
||||||
|
<div class="breadcrumb-divider">/</div>
|
||||||
|
<span class="section"><a href="{{$.BranchLink}}/{{index $.TreePaths $i | PathEscapeSegments}}">{{$v}}</a></span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{template "repo/editor/commit_form" .}}
|
{{template "repo/editor/commit_form" .}}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{template "base/footer" .}}
|
{{template "base/footer" .}}
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
{{template "base/head" .}}
|
|
||||||
<div role="main" aria-label="{{.Title}}" class="page-content repository">
|
|
||||||
{{template "repo/header" .}}
|
|
||||||
<div class="ui container">
|
|
||||||
<div class="tw-flex tw-items-center">
|
|
||||||
<a href="{{$.RepoLink}}">{{.RepoName}}</a>
|
|
||||||
<span class="tw-mx-2">/</span>
|
|
||||||
<div class="ui input tw-flex-1">
|
|
||||||
<input id="repo-file-find-input" type="text" autofocus data-url-data-link="{{.DataLink}}" data-url-tree-link="{{.TreeLink}}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<table id="repo-find-file-table" class="ui single line fixed table">
|
|
||||||
<tbody>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div id="repo-find-file-no-result" class="ui row center tw-mt-8 tw-hidden">
|
|
||||||
<h3>{{ctx.Locale.Tr "repo.find_file.no_matching"}}</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{template "base/footer" .}}
|
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
<button type="button"
|
||||||
|
class="repo-view-file-tree-toggle ui button not-mobile {{if .UserSettingCodeViewShowFileTree}}tw-hidden{{end}}"
|
||||||
|
data-global-click="onRepoViewFileTreeToggle" data-toggle-action="show" data-tooltip-content="{{ctx.Locale.Tr "repo.diff.show_file_tree"}}"
|
||||||
|
>
|
||||||
|
{{svg "octicon-sidebar-collapse"}}
|
||||||
|
</button>
|
||||||
@ -0,0 +1,230 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, watch, nextTick, useTemplateRef, onMounted, onUnmounted } from 'vue';
|
||||||
|
import {generateElemId} from '../utils/dom.ts';
|
||||||
|
import { GET } from '../modules/fetch.ts';
|
||||||
|
import { filterRepoFilesWeighted } from '../features/repo-findfile.ts';
|
||||||
|
import { pathEscapeSegments } from '../utils/url.ts';
|
||||||
|
import { SvgIcon } from '../svg.ts';
|
||||||
|
import {throttle} from 'throttle-debounce';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
repoLink: { type: String, required: true },
|
||||||
|
currentRefNameSubURL: { type: String, required: true },
|
||||||
|
treeListUrl: { type: String, required: true },
|
||||||
|
noResultsText: { type: String, required: true },
|
||||||
|
placeholder: { type: String, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const refElemInput = useTemplateRef<HTMLInputElement>('searchInput');
|
||||||
|
const refElemPopup = useTemplateRef<HTMLElement>('searchPopup');
|
||||||
|
|
||||||
|
const searchQuery = ref('');
|
||||||
|
const allFiles = ref<string[]>([]);
|
||||||
|
const selectedIndex = ref(0);
|
||||||
|
const isLoadingFileList = ref(false);
|
||||||
|
const hasLoadedFileList = ref(false);
|
||||||
|
|
||||||
|
const showPopup = computed(() => searchQuery.value.length > 0);
|
||||||
|
|
||||||
|
const filteredFiles = computed(() => {
|
||||||
|
if (!searchQuery.value) return [];
|
||||||
|
return filterRepoFilesWeighted(allFiles.value, searchQuery.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const applySearchQuery = throttle(300, () => {
|
||||||
|
searchQuery.value = refElemInput.value.value;
|
||||||
|
selectedIndex.value = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearchInput = () => {
|
||||||
|
loadFileListForSearch();
|
||||||
|
applySearchQuery();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
clearSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!searchQuery.value || filteredFiles.value.length === 0) return;
|
||||||
|
|
||||||
|
const handleSelectedItem = (idx: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedIndex.value = idx;
|
||||||
|
const el = refElemPopup.value.querySelector(`.file-search-results > :nth-child(${idx+1} of .item)`);
|
||||||
|
el?.scrollIntoView({ block: 'nearest', behavior: 'instant' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
handleSelectedItem(Math.min(selectedIndex.value + 1, filteredFiles.value.length - 1));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
handleSelectedItem(Math.max(selectedIndex.value - 1, 0))
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const selectedFile = filteredFiles.value[selectedIndex.value];
|
||||||
|
if (selectedFile) {
|
||||||
|
handleSearchResultClick(selectedFile.matchResult.join(''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
searchQuery.value = '';
|
||||||
|
refElemInput.value.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (!searchQuery.value) return;
|
||||||
|
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const clickInside = refElemInput.value.contains(target) || refElemPopup.value.contains(target);
|
||||||
|
if (!clickInside) clearSearch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFileListForSearch = async () => {
|
||||||
|
if (hasLoadedFileList.value || isLoadingFileList.value) return;
|
||||||
|
|
||||||
|
isLoadingFileList.value = true;
|
||||||
|
try {
|
||||||
|
const response = await GET(props.treeListUrl);
|
||||||
|
allFiles.value = await response.json();
|
||||||
|
hasLoadedFileList.value = true;
|
||||||
|
} finally {
|
||||||
|
isLoadingFileList.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleSearchResultClick(filePath: string) {
|
||||||
|
clearSearch();
|
||||||
|
window.location.href = `${props.repoLink}/src/${pathEscapeSegments(props.currentRefNameSubURL)}/${pathEscapeSegments(filePath)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (!showPopup.value) return;
|
||||||
|
|
||||||
|
const rectInput = refElemInput.value.getBoundingClientRect();
|
||||||
|
const rectPopup = refElemPopup.value.getBoundingClientRect();
|
||||||
|
const docElem = document.documentElement;
|
||||||
|
const style = refElemPopup.value.style;
|
||||||
|
style.top = `${docElem.scrollTop + rectInput.bottom + 4}px`;
|
||||||
|
if (rectInput.x + rectPopup.width < docElem.clientWidth) {
|
||||||
|
// enough space to align left with the input
|
||||||
|
style.left = `${docElem.scrollLeft + rectInput.x}px`;
|
||||||
|
} else {
|
||||||
|
// no enough space, align right from the viewport right edge minus page margin
|
||||||
|
const leftPos = docElem.scrollLeft + docElem.getBoundingClientRect().width - rectPopup.width;
|
||||||
|
style.left = `calc(${leftPos}px - var(--page-margin-x))`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const searchPopupId = generateElemId('file-search-popup-');
|
||||||
|
refElemPopup.value.setAttribute('id', searchPopupId);
|
||||||
|
refElemInput.value.setAttribute('aria-controls', searchPopupId);
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
window.addEventListener('resize', updatePosition);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
window.removeEventListener('resize', updatePosition);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Position search results below the input
|
||||||
|
watch([searchQuery, filteredFiles], async () => {
|
||||||
|
if (searchQuery.value) {
|
||||||
|
await nextTick();
|
||||||
|
updatePosition();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="ui small input">
|
||||||
|
<input
|
||||||
|
ref="searchInput" :placeholder="placeholder" autocomplete="off"
|
||||||
|
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
|
||||||
|
@input="handleSearchInput" @keydown="handleKeyDown"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-show="showPopup" ref="searchPopup" class="file-search-popup">
|
||||||
|
<!-- always create the popup by v-show above to avoid null ref, only create the popup content if the popup should be displayed to save memory -->
|
||||||
|
<template v-if="showPopup">
|
||||||
|
<div v-if="filteredFiles.length" role="listbox" class="file-search-results flex-items-block">
|
||||||
|
<div
|
||||||
|
v-for="(result, idx) in filteredFiles" :key="result.matchResult.join('')"
|
||||||
|
:class="['item', { 'selected': idx === selectedIndex }]"
|
||||||
|
role="option" :aria-selected="idx === selectedIndex" @click="handleSearchResultClick(result.matchResult.join(''))"
|
||||||
|
@mouseenter="selectedIndex = idx" :title="result.matchResult.join('')"
|
||||||
|
>
|
||||||
|
<SvgIcon name="octicon-file" class="file-icon"/>
|
||||||
|
<span class="full-path">
|
||||||
|
<span v-for="(part, index) in result.matchResult" :key="index">{{ part }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isLoadingFileList">
|
||||||
|
<div class="is-loading"/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="tw-p-4">
|
||||||
|
{{ props.noResultsText }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.file-search-popup {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--color-box-body);
|
||||||
|
border: 1px solid var(--color-secondary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
width: max-content;
|
||||||
|
max-height: min(calc(100vw - 20px), 300px);
|
||||||
|
max-width: min(calc(100vw - 40px), 600px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-search-popup .is-loading {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-search-results .item {
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-search-results .item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-search-results .item:hover,
|
||||||
|
.file-search-results .item.selected {
|
||||||
|
background-color: var(--color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-search-results .item .file-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-search-results .item .full-path {
|
||||||
|
flex: 1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-search-results .item .full-path :nth-child(even) {
|
||||||
|
color: var(--color-red);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue