Load mentionValues asynchronously (#36739)

Eliminate a few database queries on all issue and pull request pages by
moving mention autocomplete data to async JSON endpoints fetched
on-demand when the user types `@`.

See https://github.com/go-gitea/gitea/pull/36739#issuecomment-3963184858
for the full table of affected pages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwind
2026-03-07 21:37:37 +01:00
committed by GitHub
parent f250138f57
commit 130e34994f
32 changed files with 363 additions and 161 deletions

View File

@@ -38,6 +38,7 @@ export function initTextExpander(expander: TextExpanderElement) {
if (!expander) return;
const textarea = expander.querySelector<HTMLTextAreaElement>('textarea')!;
const mentionsUrl = expander.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
// help to fix the text-expander "multiword+promise" bug: do not show the popup when there is no "#" before current line
const shouldShowIssueSuggestions = () => {
@@ -83,36 +84,39 @@ export function initTextExpander(expander: TextExpanderElement) {
provide({matched: true, fragment: ul});
} else if (key === '@') {
const matches = matchMention(text);
if (!matches.length) return provide({matched: false});
provide((async (): Promise<TextExpanderResult> => {
if (!mentionsUrl) return {matched: false};
const matches = await matchMention(mentionsUrl, text);
if (!matches.length) return {matched: false};
const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const {value, name, fullname, avatar} of matches) {
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('data-value', `${key}${value}`);
const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const {value, name, fullname, avatar} of matches) {
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('data-value', `${key}${value}`);
const img = document.createElement('img');
img.src = avatar;
li.append(img);
const img = document.createElement('img');
img.src = avatar;
li.append(img);
const nameSpan = document.createElement('span');
nameSpan.classList.add('name');
nameSpan.textContent = name;
li.append(nameSpan);
const nameSpan = document.createElement('span');
nameSpan.classList.add('name');
nameSpan.textContent = name;
li.append(nameSpan);
if (fullname && fullname.toLowerCase() !== name) {
const fullnameSpan = document.createElement('span');
fullnameSpan.classList.add('fullname');
fullnameSpan.textContent = fullname;
li.append(fullnameSpan);
if (fullname && fullname.toLowerCase() !== name) {
const fullnameSpan = document.createElement('span');
fullnameSpan.classList.add('fullname');
fullnameSpan.textContent = fullname;
li.append(fullnameSpan);
}
ul.append(li);
}
ul.append(li);
}
provide({matched: true, fragment: ul});
return {matched: true, fragment: ul};
})());
} else if (key === '#') {
provide(debouncedIssueSuggestions(key, text));
}

View File

@@ -1,10 +1,12 @@
import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
import {html, htmlRaw} from '../utils/html.ts';
import {fetchMentions} from '../utils/match.ts';
import type {TributeCollection} from 'tributejs';
import type {MentionValue} from '../types.ts';
import type {Mention} from '../types.ts';
export async function attachTribute(element: HTMLElement) {
const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
const mentionsUrl = element.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
const emojiCollection: TributeCollection<string> = { // emojis
trigger: ':',
@@ -29,8 +31,10 @@ export async function attachTribute(element: HTMLElement) {
},
};
const mentionCollection: TributeCollection<MentionValue> = {
values: window.config.mentionValues,
const mentionCollection: TributeCollection<Mention> = {
values: async (_query: string, cb: (matches: Mention[]) => void) => { // eslint-disable-line @typescript-eslint/no-misused-promises
cb(mentionsUrl ? await fetchMentions(mentionsUrl) : []);
},
requireLeadingSpace: true,
menuItemTemplate: (item) => {
const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : '';

View File

@@ -29,7 +29,6 @@ interface Window {
pageData: Record<string, any>,
notificationSettings: Record<string, any>,
enableTimeTracking: boolean,
mentionValues: Array<import('./types.ts').MentionValue>,
mermaidMaxSourceCharacters: number,
i18n: Record<string, string>,
},

View File

@@ -2,7 +2,7 @@ export type IntervalId = ReturnType<typeof setInterval>;
export type Intent = 'error' | 'warning' | 'info';
export type MentionValue = {
export type Mention = {
key: string,
value: string,
name: string,

View File

@@ -1,5 +1,20 @@
import {GET} from '../modules/fetch.ts';
import {matchEmoji, matchMention} from './match.ts';
vi.mock('../modules/fetch.ts', () => ({
GET: vi.fn(),
}));
const testMentions = [
{key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},
{key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'},
{key: 'org3 User 3', value: 'org3', name: 'org3', fullname: 'User 3', avatar: 'https://avatar3.com'},
{key: 'user4 User 4', value: 'user4', name: 'user4', fullname: 'User 4', avatar: 'https://avatar4.com'},
{key: 'user5 User 5', value: 'user5', name: 'user5', fullname: 'User 5', avatar: 'https://avatar5.com'},
{key: 'org6 User 6', value: 'org6', name: 'org6', fullname: 'User 6', avatar: 'https://avatar6.com'},
{key: 'org7 User 7', value: 'org7', name: 'org7', fullname: 'User 7', avatar: 'https://avatar7.com'},
];
test('matchEmoji', () => {
expect(matchEmoji('')).toMatchInlineSnapshot(`
[
@@ -56,7 +71,8 @@ test('matchEmoji', () => {
`);
});
test('matchMention', () => {
expect(matchMention('')).toEqual(window.config.mentionValues.slice(0, 6));
expect(matchMention('user4')).toEqual([window.config.mentionValues[3]]);
test('matchMention', async () => {
vi.mocked(GET).mockResolvedValue({ok: true, json: () => Promise.resolve(testMentions)} as Response);
expect(await matchMention('/any-mentions', '')).toEqual(testMentions.slice(0, 6));
expect(await matchMention('/any-mentions', 'user4')).toEqual([testMentions[3]]);
});

View File

@@ -1,6 +1,8 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'};
import {GET} from '../modules/fetch.ts';
import type {Issue} from '../types.ts';
import {showErrorToast} from '../modules/toast.ts';
import {parseIssuePageInfo} from '../utils.ts';
import type {Issue, Mention} from '../types.ts';
const maxMatches = 6;
@@ -29,13 +31,36 @@ export function matchEmoji(queryText: string): string[] {
return sortAndReduce(results);
}
type MentionSuggestion = {value: string; name: string; fullname: string; avatar: string};
export function matchMention(queryText: string): MentionSuggestion[] {
let cachedMentionsPromise: Promise<Mention[]> | undefined;
let cachedMentionsUrl: string;
export function fetchMentions(mentionsUrl: string): Promise<Mention[]> {
if (cachedMentionsPromise && cachedMentionsUrl === mentionsUrl) {
return cachedMentionsPromise;
}
cachedMentionsUrl = mentionsUrl;
cachedMentionsPromise = (async () => {
try {
const issueIndex = parseIssuePageInfo().issueNumber;
const query = issueIndex ? `?issue_index=${issueIndex}` : '';
const res = await GET(`${mentionsUrl}${query}`);
if (!res.ok) throw new Error(res.statusText);
return await res.json() as Mention[];
} catch (e) {
showErrorToast(`Failed to load mentions: ${e}`);
return [];
}
})();
return cachedMentionsPromise;
}
export async function matchMention(mentionsUrl: string, queryText: string): Promise<Mention[]> {
const values = await fetchMentions(mentionsUrl);
const query = queryText.toLowerCase();
// results is a map of weights, lower is better
const results = new Map<MentionSuggestion, number>();
for (const obj of window.config.mentionValues) {
const results = new Map<Mention, number>();
for (const obj of values) {
const index = obj.key.toLowerCase().indexOf(query);
if (index === -1) continue;
const existing = results.get(obj);

View File

@@ -10,15 +10,6 @@ window.config = {
pageData: {},
notificationSettings: {},
enableTimeTracking: true,
mentionValues: [
{key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},
{key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'},
{key: 'org3 User 3', value: 'org3', name: 'org3', fullname: 'User 3', avatar: 'https://avatar3.com'},
{key: 'user4 User 4', value: 'user4', name: 'user4', fullname: 'User 4', avatar: 'https://avatar4.com'},
{key: 'user5 User 5', value: 'user5', name: 'user5', fullname: 'User 5', avatar: 'https://avatar5.com'},
{key: 'org6 User 6', value: 'org6', name: 'org6', fullname: 'User 6', avatar: 'https://avatar6.com'},
{key: 'org7 User 7', value: 'org7', name: 'org7', fullname: 'User 7', avatar: 'https://avatar7.com'},
],
mermaidMaxSourceCharacters: 5000,
i18n: {},
};