diff --git a/models/issues/issue.go b/models/issues/issue.go index 60f8d44617..8833d36799 100644 --- a/models/issues/issue.go +++ b/models/issues/issue.go @@ -539,28 +539,6 @@ func FindLatestUpdatedIssues(ctx context.Context, repoID int64, isPull optional. return issues, err } -func FindIssuesSuggestionByKeyword(ctx context.Context, repoID int64, keyword string, isPull optional.Option[bool], excludedID int64, pageSize int) (IssueList, error) { - cond := builder.NewCond() - if excludedID > 0 { - cond = cond.And(builder.Neq{"`id`": excludedID}) - } - - // It seems that GitHub searches both title and content (maybe sorting by the search engine's ranking system?) - // The first PR (https://github.com/go-gitea/gitea/pull/32327) uses "search indexer" to search "name(title) + content" - // But it seems that searching "content" (especially LIKE by DB engine) generates worse (unusable) results. - // So now (https://github.com/go-gitea/gitea/pull/33538) it only searches "name(title)", leave the improvements to the future. - cond = cond.And(db.BuildCaseInsensitiveLike("`name`", keyword)) - - issues := make([]*Issue, 0, pageSize) - err := db.GetEngine(ctx).Where("repo_id = ?", repoID). - And(isPullToCond(isPull)). - And(cond). - OrderBy("updated_unix DESC, `index` DESC"). - Limit(pageSize). - Find(&issues) - return issues, err -} - // GetIssueWithAttrsByIndex returns issue by index in a repository. func GetIssueWithAttrsByIndex(ctx context.Context, repoID, index int64) (*Issue, error) { issue, err := GetIssueByIndex(ctx, repoID, index) diff --git a/routers/web/repo/issue_suggestions.go b/routers/web/repo/issue_suggestions.go index b87a1c0c73..4d1ad33e3b 100644 --- a/routers/web/repo/issue_suggestions.go +++ b/routers/web/repo/issue_suggestions.go @@ -14,8 +14,6 @@ import ( // IssueSuggestions returns a list of issue suggestions func IssueSuggestions(ctx *context.Context) { - keyword := ctx.Req.FormValue("q") - canReadIssues := ctx.Repo.CanRead(unit.TypeIssues) canReadPulls := ctx.Repo.CanRead(unit.TypePullRequests) @@ -26,7 +24,7 @@ func IssueSuggestions(ctx *context.Context) { isPull = optional.Some(false) } - suggestions, err := issue_service.GetSuggestion(ctx, ctx.Repo.Repository, isPull, keyword) + suggestions, err := issue_service.GetSuggestion(ctx, ctx.Repo.Repository, isPull) if err != nil { ctx.ServerError("GetSuggestion", err) return diff --git a/services/issue/suggestion.go b/services/issue/suggestion.go index 7d7df54ae4..b0b37b4b3a 100644 --- a/services/issue/suggestion.go +++ b/services/issue/suggestion.go @@ -5,7 +5,6 @@ package issue import ( "context" - "strconv" issues_model "forgejo.org/models/issues" repo_model "forgejo.org/models/repo" @@ -13,38 +12,14 @@ import ( "forgejo.org/modules/structs" ) -func GetSuggestion(ctx context.Context, repo *repo_model.Repository, isPull optional.Option[bool], keyword string) ([]*structs.Issue, error) { +func GetSuggestion(ctx context.Context, repo *repo_model.Repository, isPull optional.Option[bool]) ([]*structs.Issue, error) { var issues issues_model.IssueList var err error - pageSize := 5 - if keyword == "" { - issues, err = issues_model.FindLatestUpdatedIssues(ctx, repo.ID, isPull, pageSize) - if err != nil { - return nil, err - } - } else { - indexKeyword, _ := strconv.ParseInt(keyword, 10, 64) - var issueByIndex *issues_model.Issue - var excludedID int64 - if indexKeyword > 0 { - issueByIndex, err = issues_model.GetIssueByIndex(ctx, repo.ID, indexKeyword) - if err != nil && !issues_model.IsErrIssueNotExist(err) { - return nil, err - } - if issueByIndex != nil { - excludedID = issueByIndex.ID - pageSize-- - } - } + pageSize := 1000 - issues, err = issues_model.FindIssuesSuggestionByKeyword(ctx, repo.ID, keyword, isPull, excludedID, pageSize) - if err != nil { - return nil, err - } - - if issueByIndex != nil { - issues = append([]*issues_model.Issue{issueByIndex}, issues...) - } + issues, err = issues_model.FindLatestUpdatedIssues(ctx, repo.ID, isPull, pageSize) + if err != nil { + return nil, err } if err := issues.LoadPullRequests(ctx); err != nil { diff --git a/services/issue/suggestion_test.go b/services/issue/suggestion_test.go index 83992d44c8..92468f5c00 100644 --- a/services/issue/suggestion_test.go +++ b/services/issue/suggestion_test.go @@ -22,31 +22,19 @@ func Test_Suggestion(t *testing.T) { repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) testCases := []struct { - keyword string + name string isPull optional.Option[bool] expectedIndexes []int64 }{ { - keyword: "", + name: "All", expectedIndexes: []int64{5, 1, 4, 2, 3}, }, - { - keyword: "1", - expectedIndexes: []int64{1}, - }, - { - keyword: "issue", - expectedIndexes: []int64{4, 1, 2, 3}, - }, - { - keyword: "pull", - expectedIndexes: []int64{5}, - }, } for _, testCase := range testCases { - t.Run(testCase.keyword, func(t *testing.T) { - issues, err := GetSuggestion(db.DefaultContext, repo1, testCase.isPull, testCase.keyword) + t.Run(testCase.name, func(t *testing.T) { + issues, err := GetSuggestion(db.DefaultContext, repo1, testCase.isPull) require.NoError(t, err) issueIndexes := make([]int64, 0, len(issues)) diff --git a/web_src/js/features/comp/TextExpander.js b/web_src/js/features/comp/TextExpander.js index 87a7e8d57e..cc96306716 100644 --- a/web_src/js/features/comp/TextExpander.js +++ b/web_src/js/features/comp/TextExpander.js @@ -1,30 +1,29 @@ import {matchEmoji, matchMention, matchIssue} from '../../utils/match.js'; import {emojiString} from '../emoji.js'; -import {getIssueIcon, getIssueColor} from '../issue.js' -import {parseIssueHref} from '../../utils.js' +import {getIssueIcon, getIssueColor,isIssueSuggestionsLoaded, fetchIssueSuggestions} from '../issue.js' import {svg} from '../../svg.js' import {createElementFromHTML} from '../../utils/dom.js'; -import {debounce} from 'perfect-debounce'; +import { GET } from '../../modules/fetch.js'; -const debouncedSuggestIssues = debounce((key, text) => new Promise( - async (resolve, reject) => { - const {owner, repo, index} = parseIssueHref(window.location.href); - const matches = await matchIssue(owner, repo, index, text); - if (!matches.length) return resolve({matched: false}); +async function issueSuggestions(text) { + const key = '#'; + + const matches = matchIssue(text); + if (!matches.length) return {matched: false}; const ul = document.createElement('ul'); ul.classList.add('suggestions'); for (const issue of matches) { const li = document.createElement('li'); li.setAttribute('role', 'option'); - li.setAttribute('data-value', `${key}${issue.id}`); + li.setAttribute('data-value', `${key}${issue.number}`); li.classList.add('tw-flex', 'tw-gap-2') const icon = svg(getIssueIcon(issue), 16, ['text', getIssueColor(issue)].join(' ')); li.append(createElementFromHTML(icon)); const id = document.createElement('span'); - id.textContent = issue.id.toString(); + id.textContent = issue.number.toString(); li.append(id); const nameSpan = document.createElement('span'); @@ -34,10 +33,14 @@ const debouncedSuggestIssues = debounce((key, text) => new Promise( ul.append(li); } - resolve({matched: true, fragment: ul}); -}), 100) + return {matched: true, fragment: ul}; +} export function initTextExpander(expander) { + if (!expander) return; + + const textarea = expander.querySelector('textarea'); + expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => { if (key === ':') { const matches = matchEmoji(text); @@ -86,7 +89,11 @@ export function initTextExpander(expander) { provide({matched: true, fragment: ul}); } else if (key === '#') { - provide(debouncedSuggestIssues(key, text)); + if (!isIssueSuggestionsLoaded()) { + provide(fetchIssueSuggestions().then(() => issueSuggestions(text))); + } else { + provide(issueSuggestions(text)); + } } }); expander?.addEventListener('text-expander-value', ({detail}) => { diff --git a/web_src/js/features/issue.js b/web_src/js/features/issue.js index 2c12692ed5..44a65eea4c 100644 --- a/web_src/js/features/issue.js +++ b/web_src/js/features/issue.js @@ -1,3 +1,6 @@ +import { GET } from '../modules/fetch.js'; +import {parseIssueHref, parseRepoOwnerPathInfo} from '../utils.js' + export function getIssueIcon(issue) { if (issue.pull_request) { if (issue.state === 'open') { @@ -15,16 +18,37 @@ export function getIssueIcon(issue) { return 'octicon-issue-closed'; // Closed Issue } - export function getIssueColor(issue) { - if (issue.pull_request) { - if (issue.pull_request.draft === true) { - return 'grey'; // WIP PR - } else if (issue.pull_request.merged === true) { - return 'purple'; // Merged PR - } +export function getIssueColor(issue) { + if (issue.pull_request) { + if (issue.pull_request.draft === true) { + return 'grey'; // WIP PR + } else if (issue.pull_request.merged === true) { + return 'purple'; // Merged PR } - if (issue.state === 'open') { - return 'green'; // Open Issue - } - return 'red'; // Closed Issue - } \ No newline at end of file + } + if (issue.state === 'open') { + return 'green'; // Open Issue + } + return 'red'; // Closed Issue +} + +export function isIssueSuggestionsLoaded() { + return !!window.config.issueValues +} + +async function fetchIssueSuggestions() { + const issuePathInfo = parseIssueHref(window.location.href); + if (!issuePathInfo.ownerName) { + const repoOwnerPathInfo = parseRepoOwnerPathInfo(window.location.pathname); + issuePathInfo.ownerName = repoOwnerPathInfo.ownerName; + issuePathInfo.repoName = repoOwnerPathInfo.repoName; + // then no issuePathInfo.indexString here, it is only used to exclude the current issue when "matchIssue" + } + if (!issuePathInfo.ownerName) { + throw new Error('unexpected'); + } + + const res = await GET(`${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/suggestions`); + const issues = await res.json(); + window.config.issueValues = issues; +} \ No newline at end of file diff --git a/web_src/js/utils.js b/web_src/js/utils.js index 8805b702c8..336afca1c7 100644 --- a/web_src/js/utils.js +++ b/web_src/js/utils.js @@ -34,6 +34,13 @@ export function parseIssueHref(href) { return {owner, repo, type, index}; } +export function parseRepoOwnerPathInfo(pathname) { + const appSubUrl = window.config.appSubUrl; + if (appSubUrl && pathname.startsWith(appSubUrl)) pathname = pathname.substring(appSubUrl.length); + const [_, ownerName, repoName] = /([^/]+)\/([^/]+)/.exec(pathname) || []; + return {ownerName, repoName}; +} + // parse a URL, either relative '/path' or absolute 'https://localhost/path' export function parseUrl(str) { return new URL(str, str.startsWith('http') ? undefined : window.location.origin); diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.js index 535aae874a..7d6e89e81c 100644 --- a/web_src/js/utils.test.js +++ b/web_src/js/utils.test.js @@ -76,6 +76,16 @@ test('parseIssueHref', () => { expect(parseIssueHref('')).toEqual({owner: undefined, repo: undefined, type: undefined, index: undefined}); }); +test('parseRepoOwnerPathInfo', () => { + expect(parseRepoOwnerPathInfo('/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo'}); + expect(parseRepoOwnerPathInfo('/owner/repo/releases')).toEqual({ownerName: 'owner', repoName: 'repo'}); + expect(parseRepoOwnerPathInfo('/other')).toEqual({}); + window.config.appSubUrl = '/sub'; + expect(parseRepoOwnerPathInfo('/sub/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo'}); + expect(parseRepoOwnerPathInfo('/sub/owner/repo/compare/feature/branch-1...fix/branch-2')).toEqual({ownerName: 'owner', repoName: 'repo'}); + window.config.appSubUrl = ''; +}); + test('parseUrl', () => { expect(parseUrl('').pathname).toEqual('/'); expect(parseUrl('/path').pathname).toEqual('/path'); diff --git a/web_src/js/utils/match.js b/web_src/js/utils/match.js index 15a06b0c04..369494f06c 100644 --- a/web_src/js/utils/match.js +++ b/web_src/js/utils/match.js @@ -44,12 +44,45 @@ export function matchMention(queryText) { return sortAndReduce(results); } -export async function matchIssue(owner, repo, issueIndexStr, query) { - const res = await GET(`${window.config.appSubUrl}/${owner}/${repo}/issues/suggestions?q=${encodeURIComponent(query)}`); +export function matchIssue(queryText) { + const issues = window.config.issueValues ?? []; + const query = queryText.toLowerCase().trim(); - const issues = await res.json(); - const issueIndex = parseInt(issueIndexStr); + if (!query) { + // Return latest 5 issues/prs sorted by number descending + return [...issues] + .sort((a, b) => b.number - a.number) + .slice(0, 5); + } - // filter out issue with same id - return issues.filter((i) => i.id !== issueIndex); + const isDigital = /^\d+$/.test(query); + const results = []; + + if (isDigital) { + // Find issues/prs with number starting with the query (prefix), sorted by number ascending + const prefixMatches = issues.filter(issue => + String(issue.number).startsWith(query) + ).sort((a, b) => a.number - b.number); + + results.push(...prefixMatches); + } + + if (!isDigital || results.length < 5) { + // Fallback: find by title match, sorted by number descending + const titleMatches = issues + .filter(issue => + issue.title.toLowerCase().includes(query) + ) + .sort((a, b) => b.number - a.number); + + // Add only those not already in the result set + for (const match of titleMatches) { + if (!results.includes(match)) { + results.push(match); + if (results.length >= 5) break; + } + } + } + + return results.slice(0, 5); }