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:
bircni
2026-08-17 22:08:15 +02:00
committed by GitHub
parent e223c42ee6
commit 1cf904f101
15 changed files with 115 additions and 83 deletions
+2 -2
View File
@@ -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.
+2
View File
@@ -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",
+12
View File
@@ -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">
+4
View File
@@ -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;
+16 -30
View File
@@ -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
+22 -3
View File
@@ -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;
+3 -1
View File
@@ -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);
}
-2
View File
@@ -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);
},
+13 -37
View File
@@ -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() {
+1 -2
View File
@@ -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');
-2
View File
@@ -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>>};
-1
View File
@@ -49,7 +49,6 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) {
}
return {results};
},
cache: false,
},
});
};
+39
View File
@@ -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>
`,
};
})}),
},
});
}