mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-19 19:18:34 +00:00
feat(repo): add quick repository switcher to repo header (#38188)
Add a GitHub-style quick repo switcher: a caret next to the owner/repo breadcrumb opens a dropdown that lists and searches the current owner's repositories and navigates to the selected one. The current repository is marked with a check, and private/fork repos show an icon. Also, fix various bugs in fomtantic dropdown remote query ## Screenshots <img width="505" height="198" alt="image" src="https://github.com/user-attachments/assets/9f673d1b-fe60-41f0-b9e2-b00dc43720b5" /> Fixes #38187 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -169,8 +169,8 @@ type SearchRepoOptions struct {
|
||||
// False -> include just public
|
||||
IsPrivate optional.Option[bool]
|
||||
// None -> include collaborative AND non-collaborative
|
||||
// True -> include just collaborative
|
||||
// False -> include just non-collaborative
|
||||
// True -> include just collaborative (the "OwnerID" is not really an owner, it just means a collaborator who doesn't own the repo)
|
||||
// False -> include just non-collaborative (the repo must be in the owner's name space)
|
||||
Collaborate optional.Option[bool]
|
||||
// What type of unit the user can be collaborative in,
|
||||
// it is ignored if Collaborate is False.
|
||||
|
||||
@@ -1072,6 +1072,8 @@
|
||||
"repo.desc.internal": "Internal",
|
||||
"repo.desc.archived": "Archived",
|
||||
"repo.desc.sha256": "SHA256",
|
||||
"repo.switch_repository": "Switch repository",
|
||||
"repo.switch_repository.search": "Search repositories...",
|
||||
"repo.template.items": "Template Items",
|
||||
"repo.template.git_content": "Git Content (Default Branch)",
|
||||
"repo.template.git_hooks": "Git Hooks",
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
{{template "repo/icon" .}}
|
||||
<div class="flex-text-block tw-flex-wrap tw-text-18">
|
||||
<a class="muted tw-font-normal" href="{{.Owner.HomeLink}}">{{.Owner.Name}}</a>/<a class="muted" href="{{$.RepoLink}}">{{.Name}}</a>
|
||||
{{if $.IsSigned}}{{/* actually the switch also works for anonymous users, GitHub only shows it for sign-in users */}}
|
||||
<div class="ui custom dropdown" data-global-init="initRepoSwitcher" data-current-full-name="{{.FullName}}" data-tooltip-content="{{ctx.Locale.Tr "repo.switch_repository"}}"
|
||||
data-query-url="{{AppSubUrl}}/repo/search?q={query}&uid={{.Owner.ID}}&exclusive=true" {{/* only search the repo owned by "uid" */}}
|
||||
>
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<div class="menu flex-items-menu items-width-limit">
|
||||
<div class="ui icon search input">
|
||||
<input type="text" class="search" autocomplete="off" aria-label="{{ctx.Locale.Tr "repo.switch_repository"}}" placeholder="{{ctx.Locale.Tr "repo.switch_repository.search"}}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="flex-text-block tw-flex-wrap">
|
||||
{{if .IsArchived}}
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<div>
|
||||
<form method="post" action="{{.Issue.Link}}/dependency/add" id="addDependencyForm" class="form-fetch-action">
|
||||
<div class="ui fluid action input">
|
||||
<div class="ui search selection dropdown" id="new-dependency-drop-list" data-issue-id="{{.Issue.ID}}" data-issue-cross-repo-search="{{.AllowCrossRepositoryDependencies}}">
|
||||
<div class="ui search selection dropdown custom" id="new-dependency-drop-list" data-issue-id="{{.Issue.ID}}" data-issue-cross-repo-search="{{.AllowCrossRepositoryDependencies}}">
|
||||
<input name="newDependency" type="hidden">
|
||||
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
|
||||
<input type="text" class="search">
|
||||
|
||||
@@ -939,6 +939,10 @@ the "!important" is necessary to override Fomantic UI menu item styles, meanwhil
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ui.dropdown .menu.items-width-limit .item {
|
||||
max-width: min(500px, calc(100vw - 2em));
|
||||
}
|
||||
|
||||
.ui.dropdown.ellipsis-text-items {
|
||||
/* reset y padding and use the line-height below instead, to avoid the "overflow: hidden" clips the larger image in the "text" element */
|
||||
padding-top: 0;
|
||||
|
||||
@@ -75,6 +75,8 @@ $.api = $.fn.api = function(parameters) {
|
||||
data,
|
||||
requestStartTime,
|
||||
|
||||
cachedResponses = {},
|
||||
|
||||
// standard module
|
||||
element = this,
|
||||
context = $context[0],
|
||||
@@ -141,34 +143,16 @@ $.api = $.fn.api = function(parameters) {
|
||||
|
||||
read: {
|
||||
cachedResponse: function(url) {
|
||||
var
|
||||
response
|
||||
;
|
||||
if(window.Storage === undefined) {
|
||||
module.error(error.noStorage);
|
||||
return;
|
||||
}
|
||||
response = sessionStorage.getItem(url);
|
||||
module.debug('Using cached response', url, response);
|
||||
response = module.decode.json(response);
|
||||
return response;
|
||||
const cacheItem = cachedResponses[url];
|
||||
if (!cacheItem) return null;
|
||||
return module.decode.json(cacheItem.respText);
|
||||
}
|
||||
},
|
||||
write: {
|
||||
cachedResponse: function(url, response) {
|
||||
if(response && response === '') {
|
||||
module.debug('Response empty, not caching', response);
|
||||
return;
|
||||
}
|
||||
if(window.Storage === undefined) {
|
||||
module.error(error.noStorage);
|
||||
return;
|
||||
}
|
||||
if( $.isPlainObject(response) ) {
|
||||
response = JSON.stringify(response);
|
||||
}
|
||||
sessionStorage.setItem(url, response);
|
||||
module.verbose('Storing cached response for url', url, response);
|
||||
if(!response) return;
|
||||
if($.isPlainObject(response) || $.isArray(response)) response = JSON.stringify(response);
|
||||
cachedResponses[url] = {cacheTime: Date.now(), respText: response};
|
||||
}
|
||||
},
|
||||
|
||||
@@ -243,10 +227,12 @@ $.api = $.fn.api = function(parameters) {
|
||||
|
||||
module.debug('Querying URL', ajaxSettings.url);
|
||||
module.verbose('Using AJAX settings', ajaxSettings);
|
||||
if(settings.cache === 'local' && module.read.cachedResponse(url)) {
|
||||
|
||||
const cachedObj = module.read.cachedResponse(url);
|
||||
if(settings.cache === 'local' && cachedObj) {
|
||||
module.debug('Response returned from local cache');
|
||||
module.request = module.create.request();
|
||||
module.request.resolveWith(context, [ module.read.cachedResponse(url) ]);
|
||||
module.request.resolveWith(context, [ cachedObj ]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -537,13 +523,13 @@ $.api = $.fn.api = function(parameters) {
|
||||
}
|
||||
},
|
||||
request: {
|
||||
done: function(response, xhr) {
|
||||
done: function(response) {
|
||||
module.debug('Successful API Response', response);
|
||||
if(settings.cache === 'local' && url) {
|
||||
module.write.cachedResponse(url, response);
|
||||
module.debug('Saving server response locally', module.cache);
|
||||
}
|
||||
settings.onSuccess.call(context, response, $module, xhr);
|
||||
settings.onSuccess.call(context, response, $module);
|
||||
},
|
||||
complete: function(firstParameter, secondParameter) {
|
||||
var
|
||||
@@ -1081,7 +1067,7 @@ $.api.settings = {
|
||||
throttle : 0,
|
||||
|
||||
// whether to throttle first request or only repeated
|
||||
throttleFirstRequest : true,
|
||||
throttleFirstRequest : false,
|
||||
|
||||
// standard ajax settings
|
||||
method : 'get',
|
||||
@@ -1110,7 +1096,7 @@ $.api.settings = {
|
||||
// response was successful, if JSON passed validation
|
||||
onSuccess : function(response, $module) {},
|
||||
|
||||
// request finished without aborting
|
||||
// request completed, either success or error/failure/abort: jQuery.ajax().always()
|
||||
onComplete : function(response, $module) {},
|
||||
|
||||
// failed JSON success test
|
||||
|
||||
@@ -431,7 +431,12 @@ $.fn.dropdown = function(parameters) {
|
||||
module.refresh();
|
||||
},
|
||||
menu: function(values) {
|
||||
$menu.html( templates.menu(values, fields,settings.preserveHTML,settings.className));
|
||||
const $menuItems = $(templates.menu(values, fields,settings.preserveHTML,settings.className));
|
||||
$menuItems.attr('data-item-dynamic', '');
|
||||
$menu.find('[data-item-dynamic]').remove();
|
||||
$menu.append(...$menuItems);
|
||||
settings.onMenuUpdated();
|
||||
|
||||
$item = $menu.find(selector.item);
|
||||
$divider = settings.hideDividers ? $item.parent().children(selector.divider) : $();
|
||||
},
|
||||
@@ -515,8 +520,10 @@ $.fn.dropdown = function(parameters) {
|
||||
? callback
|
||||
: function(){}
|
||||
;
|
||||
if(!module.can.show() && module.is.remote()) {
|
||||
const dataKeyRemoteQueried = 'remote-queried';
|
||||
if(module.is.remote() && !$module.data(dataKeyRemoteQueried)) {
|
||||
module.debug('No API results retrieved, searching before show');
|
||||
$module.data(dataKeyRemoteQueried, true)
|
||||
module.queryRemote(module.get.query(), module.show);
|
||||
}
|
||||
if( module.can.show() && !module.is.active() ) {
|
||||
@@ -793,6 +800,9 @@ $.fn.dropdown = function(parameters) {
|
||||
},
|
||||
|
||||
queryRemote: function(query, callback) {
|
||||
const dataKeyRemoteQuerying = 'remote-querying';
|
||||
if ($module.data(dataKeyRemoteQuerying) === query) return;
|
||||
$module.data(dataKeyRemoteQuerying, query);
|
||||
var
|
||||
apiSettings = {
|
||||
errorDuration : false,
|
||||
@@ -801,6 +811,11 @@ $.fn.dropdown = function(parameters) {
|
||||
urlData : {
|
||||
query: query
|
||||
},
|
||||
onComplete: function() {
|
||||
if ($module.data(dataKeyRemoteQuerying) === query) {
|
||||
$module.removeData(dataKeyRemoteQuerying);
|
||||
}
|
||||
},
|
||||
onError: function() {
|
||||
module.add.message(message.serverError);
|
||||
callback();
|
||||
@@ -3930,7 +3945,8 @@ $.fn.dropdown.settings = {
|
||||
minCharacters : 0, // Minimum characters required to trigger API call
|
||||
|
||||
filterRemoteData : false, // Whether API results should be filtered after being returned for query term
|
||||
saveRemoteData : true, // Whether remote name/value pairs should be stored in sessionStorage to allow remote data to be restored on page refresh
|
||||
saveRemoteData : false, // Whether remote name/value pairs should be stored in sessionStorage to allow remote data to be restored on page refresh
|
||||
// saveRemoteData is a wrong design and buggy, don't use it.
|
||||
|
||||
throttle : 200, // How long to wait after last user input to search remotely
|
||||
|
||||
@@ -3996,6 +4012,7 @@ $.fn.dropdown.settings = {
|
||||
onLabelCreate : function(value, text) { return $(this); },
|
||||
onLabelRemove : function(value) { return true; },
|
||||
onNoResults : function(searchTerm) { return true; },
|
||||
onMenuUpdated : function(){},
|
||||
onShow : function(){},
|
||||
onHide : function(){},
|
||||
|
||||
@@ -4222,6 +4239,8 @@ $.fn.dropdown.settings.templates = {
|
||||
if(option[fields.divider]){
|
||||
html += '<div class="'+className.divider+'"></div>';
|
||||
}
|
||||
} else if( itemType === 'html' ) {
|
||||
html += option.html;
|
||||
}
|
||||
});
|
||||
return html;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
|
||||
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
|
||||
import {initCompSearchRepoBox} from './comp/SearchRepoBox.ts';
|
||||
import {initRepoSwitcher} from './repo-switcher.ts';
|
||||
import {initScopedWorkflowRequired} from './comp/ScopedWorkflows.ts';
|
||||
|
||||
const {appUrl, appSubUrl} = window.config;
|
||||
@@ -38,7 +39,7 @@ function initFooterThemeSelector() {
|
||||
const $dropdown = fomanticQuery(elDropdown);
|
||||
$dropdown.dropdown({
|
||||
direction: 'upward',
|
||||
apiSettings: {url: `${appSubUrl}/-/web-theme/list`, cache: false},
|
||||
apiSettings: {url: `${appSubUrl}/-/web-theme/list`},
|
||||
});
|
||||
addDelegatedEventListener(elDropdown, 'click', '.menu > .item', async (el) => {
|
||||
const themeName = el.getAttribute('data-value')!;
|
||||
@@ -105,6 +106,7 @@ export function initGlobalComponent() {
|
||||
registerGlobalInitFunc('initTabSwitcher', initTabSwitcher);
|
||||
registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper);
|
||||
registerGlobalInitFunc('initSearchRepoBox', initCompSearchRepoBox);
|
||||
registerGlobalInitFunc('initRepoSwitcher', initRepoSwitcher);
|
||||
registerGlobalInitFunc('initScopedWorkflowRequired', initScopedWorkflowRequired);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,6 @@ export function initRepoTopicBar() {
|
||||
forceSelection: false,
|
||||
fullTextSearch: 'exact',
|
||||
fields: {name: 'description', value: 'data-value'},
|
||||
saveRemoteData: false,
|
||||
label: {
|
||||
transition: 'horizontal flip',
|
||||
duration: 200,
|
||||
@@ -88,7 +87,6 @@ export function initRepoTopicBar() {
|
||||
apiSettings: {
|
||||
url: `${appSubUrl}/explore/topics/search?q={query}`,
|
||||
throttle: 500,
|
||||
cache: false,
|
||||
onResponse(this: any, res: any) {
|
||||
const formattedResponse = {
|
||||
success: false,
|
||||
|
||||
@@ -111,10 +111,8 @@ function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, co
|
||||
$fomanticDropdown.dropdown({
|
||||
action: 'hide',
|
||||
apiSettings: {
|
||||
cache: false,
|
||||
url: `${issueBaseUrl}/content-history/list?comment_id=${commentId}`,
|
||||
},
|
||||
saveRemoteData: false,
|
||||
onHide() {
|
||||
$fomanticDropdown.dropdown('change values', null);
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ import {html, htmlRaw} from '../utils/html.ts';
|
||||
import {confirmModal} from './comp/ConfirmModal.ts';
|
||||
import {createSortable} from '../modules/sortable.ts';
|
||||
import {DELETE, POST} from '../modules/fetch.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {performFetchAction} from './common-fetch-action.ts';
|
||||
import type {SortableEvent} from 'sortablejs';
|
||||
@@ -101,8 +100,7 @@ function initDropdownUserRemoteSearch(el: Element) {
|
||||
elMenu.querySelector(`.item[data-value="${CSS.escape(username)}"]`)?.classList.add('selected');
|
||||
};
|
||||
|
||||
type ProcessedResult = {value: string, name: string};
|
||||
const processedResults: ProcessedResult[] = []; // to be used by dropdown to generate menu items
|
||||
const processedResults: Record<string, string>[] = []; // to be used by dropdown to generate menu items
|
||||
const syncItemFromInput = () => {
|
||||
const inputVal = elSearchInput.value.trim();
|
||||
elItemFromInput.setAttribute('data-value', inputVal);
|
||||
@@ -115,10 +113,13 @@ function initDropdownUserRemoteSearch(el: Element) {
|
||||
elSearchInput.value = selectedUsername;
|
||||
if (!searchUrl) {
|
||||
elSearchInput.addEventListener('input', syncItemFromInput);
|
||||
} else {
|
||||
if (!searchUrl.includes('?')) searchUrl += '?';
|
||||
$searchDropdown.dropdown('setting', 'apiSettings', {
|
||||
cache: false,
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchUrl.includes('?')) searchUrl += '?';
|
||||
$searchDropdown.dropdown('setting', {
|
||||
onMenuUpdated: () => syncItemFromInput(),
|
||||
apiSettings: {
|
||||
url: `${searchUrl}&q={query}`,
|
||||
onResponse(resp: any) {
|
||||
// the content is provided by backend IssuePosters handler
|
||||
@@ -126,41 +127,16 @@ function initDropdownUserRemoteSearch(el: Element) {
|
||||
for (const item of resp.results) {
|
||||
const htmlAvatar = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20">`;
|
||||
const htmlFullName = item.full_name ? html`<span class="username-fullname">(${item.full_name})</span>` : '';
|
||||
const htmlItem = html`<span class="username-display">${htmlRaw(htmlAvatar)}<span>${item.username}</span>${htmlRaw(htmlFullName)}</span>`;
|
||||
const htmlItemInner = html`<span class="username-display">${htmlRaw(htmlAvatar)}<span>${item.username}</span>${htmlRaw(htmlFullName)}</span>`;
|
||||
if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
|
||||
processedResults.push({value: item.username, name: htmlItem});
|
||||
const htmlItem = html`<div class="item" data-value="${item.username}">${htmlRaw(htmlItemInner)}</div>`;
|
||||
processedResults.push({type: 'html', html: htmlItem});
|
||||
}
|
||||
resp.results = processedResults;
|
||||
return resp;
|
||||
},
|
||||
});
|
||||
$searchDropdown.dropdown('setting', 'onShow', () => $searchDropdown.dropdown('filter', ' ')); // trigger a search on first show
|
||||
}
|
||||
|
||||
// we want to generate the dropdown menu items by ourselves, replace its internal setup functions
|
||||
const dropdownSetup = {...$searchDropdown.dropdown('internal', 'setup')};
|
||||
const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates');
|
||||
$searchDropdown.dropdown('internal', 'setup', dropdownSetup);
|
||||
dropdownSetup.menu = function (values: any) {
|
||||
// remove old dynamic items
|
||||
for (const el of elMenu.querySelectorAll(':scope > .dynamic-item')) {
|
||||
el.remove();
|
||||
}
|
||||
|
||||
const newMenuHtml = dropdownTemplates.menu(values, $searchDropdown.dropdown('setting', 'fields'), true /* html */, $searchDropdown.dropdown('setting', 'className'));
|
||||
if (newMenuHtml) {
|
||||
const newMenuItems = parseDom(newMenuHtml, 'text/html').querySelectorAll('body > div');
|
||||
for (const newMenuItem of newMenuItems) {
|
||||
newMenuItem.classList.add('dynamic-item');
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.classList.add('divider', 'dynamic-item');
|
||||
elMenu.append(div, ...newMenuItems);
|
||||
}
|
||||
$searchDropdown.dropdown('refresh');
|
||||
// defer our selection to the next tick, because dropdown will set the selection item after this `menu` function
|
||||
setTimeout(() => syncItemFromInput(), 0);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function initPinRemoveButton() {
|
||||
|
||||
@@ -62,9 +62,8 @@ export function initRepoIssueSidebarDependency(elSidebar: HTMLElement) {
|
||||
fomanticQuery(elDropdown).dropdown({
|
||||
fullTextSearch: true,
|
||||
apiSettings: {
|
||||
cache: false,
|
||||
rawResponse: true,
|
||||
url: issueSearchUrl,
|
||||
rawResponse: true, // backend responds an array, prevent fomantic api from converting it to an object
|
||||
onResponse(response: any) {
|
||||
const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
|
||||
const currIssueId = elDropdown.getAttribute('data-issue-id');
|
||||
|
||||
@@ -303,8 +303,6 @@ export function initRepoIssueReferenceIssue() {
|
||||
fomanticQuery(elDropdown).dropdown({
|
||||
fullTextSearch: true,
|
||||
apiSettings: {
|
||||
cache: false,
|
||||
rawResponse: true,
|
||||
url: `${appSubUrl}/repo/search?q={query}&limit=20`,
|
||||
onResponse(response: any) {
|
||||
const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
|
||||
|
||||
@@ -49,7 +49,6 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) {
|
||||
}
|
||||
return {results};
|
||||
},
|
||||
cache: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
|
||||
type RepoItem = {full_name: string; html_url: string; private: boolean; fork: boolean};
|
||||
type RepoSearchResponse = {data: Array<{repository: RepoItem}>};
|
||||
|
||||
function repoIcon(repo: RepoItem): string {
|
||||
if (repo.private) return svg('octicon-lock', 16);
|
||||
if (repo.fork) return svg('octicon-repo-forked', 16);
|
||||
return svg('octicon-repo', 16);
|
||||
}
|
||||
|
||||
// quick repo switcher: the caret next to the repo name opens a remote-search dropdown
|
||||
// listing the owner's repositories, and navigates to the selected one
|
||||
export function initRepoSwitcher(el: HTMLElement) {
|
||||
const currentFullName = el.getAttribute('data-current-full-name');
|
||||
const $dropdown = fomanticQuery(el);
|
||||
$dropdown.dropdown({
|
||||
showOnFocus: false, // don't auto popup the dropdown menu, avoid interrupting users keyboard navigation on the page
|
||||
apiSettings: {
|
||||
url: el.getAttribute('data-query-url')!,
|
||||
onResponse: (response: RepoSearchResponse) => ({results: response.data.map(({repository: repo}) => {
|
||||
const svgCheck = repo.full_name === currentFullName ? svg('octicon-check', 16) : '';
|
||||
const svgRepoIcon = repoIcon(repo);
|
||||
return {
|
||||
type: 'html',
|
||||
html: html`
|
||||
<a class="item" href="${repo.html_url}">
|
||||
<span class="tw-w-[16px]">${htmlRaw(svgCheck)}</span>
|
||||
<span>${htmlRaw(svgRepoIcon)}</span>
|
||||
<span class="gt-ellipsis">${repo.full_name.split('/')[1]}</span>
|
||||
</a>
|
||||
`,
|
||||
};
|
||||
})}),
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user