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
+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>
`,
};
})}),
},
});
}