mirror of
https://gitea.com/gitea/gitea-mirror.git
synced 2026-03-20 03:40:27 +00:00
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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>` : '';
|
||||
|
||||
1
web_src/js/globals.d.ts
vendored
1
web_src/js/globals.d.ts
vendored
@@ -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>,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user