diff --git a/modules/indexer/code/internal/indexer.go b/modules/indexer/code/internal/indexer.go index c259fcd26eb..440bd678f64 100644 --- a/modules/indexer/code/internal/indexer.go +++ b/modules/indexer/code/internal/indexer.go @@ -26,6 +26,7 @@ type SearchOptions struct { Language string IsKeywordFuzzy bool + IsHtmlSafe bool db.Paginator } diff --git a/modules/indexer/code/search.go b/modules/indexer/code/search.go index 74c957dde65..266eb81de3c 100644 --- a/modules/indexer/code/search.go +++ b/modules/indexer/code/search.go @@ -28,18 +28,19 @@ type Result struct { type ResultLine struct { Num int FormattedContent template.HTML + RawContent string } type SearchResultLanguages = internal.SearchResultLanguages type SearchOptions = internal.SearchOptions -func indices(content string, selectionStartIndex, selectionEndIndex int) (int, int) { +func indices(content string, selectionStartIndex, selectionEndIndex, numLinesBuffer int) (int, int) { startIndex := selectionStartIndex numLinesBefore := 0 for ; startIndex > 0; startIndex-- { if content[startIndex-1] == '\n' { - if numLinesBefore == 1 { + if numLinesBefore == numLinesBuffer { break } numLinesBefore++ @@ -50,7 +51,7 @@ func indices(content string, selectionStartIndex, selectionEndIndex int) (int, i numLinesAfter := 0 for ; endIndex < len(content); endIndex++ { if content[endIndex] == '\n' { - if numLinesAfter == 1 { + if numLinesAfter == numLinesBuffer { break } numLinesAfter++ @@ -86,7 +87,22 @@ func HighlightSearchResultCode(filename, language string, lineNums []int, code s return lines } -func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Result, error) { +func RawSearchResultCode(filename, language string, lineNums []int, code string) []*ResultLine { + // we should highlight the whole code block first, otherwise it doesn't work well with multiple line highlighting + rawLines := strings.Split(code, "\n") + + // The lineNums outputted by highlight.Code might not match the original lineNums, because "highlight" removes the last `\n` + lines := make([]*ResultLine, min(len(rawLines), len(lineNums))) + for i := 0; i < len(lines); i++ { + lines[i] = &ResultLine{ + Num: lineNums[i], + RawContent: rawLines[i], + } + } + return lines +} + +func searchResult(result *internal.SearchResult, startIndex, endIndex int, escapeHtml bool) (*Result, error) { startLineNum := 1 + strings.Count(result.Content[:startIndex], "\n") var formattedLinesBuffer bytes.Buffer @@ -117,6 +133,13 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res index += len(line) } + var lines []*ResultLine + if escapeHtml { + lines = HighlightSearchResultCode(result.Filename, result.Language, lineNums, formattedLinesBuffer.String()) + } else { + lines = RawSearchResultCode(result.Filename, result.Language, lineNums, formattedLinesBuffer.String()) + } + return &Result{ RepoID: result.RepoID, Filename: result.Filename, @@ -124,7 +147,7 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res UpdatedUnix: result.UpdatedUnix, Language: result.Language, Color: result.Color, - Lines: HighlightSearchResultCode(result.Filename, result.Language, lineNums, formattedLinesBuffer.String()), + Lines: lines, }, nil } @@ -142,9 +165,14 @@ func PerformSearch(ctx context.Context, opts *SearchOptions) (int, []*Result, [] displayResults := make([]*Result, len(results)) + nLinesBuffer := 0 + if opts.IsHtmlSafe { + nLinesBuffer = 1 + } + for i, result := range results { - startIndex, endIndex := indices(result.Content, result.StartIndex, result.EndIndex) - displayResults[i], err = searchResult(result, startIndex, endIndex) + startIndex, endIndex := indices(result.Content, result.StartIndex, result.EndIndex, nLinesBuffer) + displayResults[i], err = searchResult(result, startIndex, endIndex, opts.IsHtmlSafe) if err != nil { return 0, nil, nil, err } diff --git a/modules/structs/explore.go b/modules/structs/explore.go new file mode 100644 index 00000000000..db1250cd566 --- /dev/null +++ b/modules/structs/explore.go @@ -0,0 +1,20 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package structs // import "code.gitea.io/gitea/modules/structs" + +// ExploreCodeSearchItem A single search match +// swagger:model +type ExploreCodeSearchItem struct { + RepoName string `json:"repoName"` + FilePath string `json:"path"` + LineNumber int `json:"lineNumber"` + LineText string `json:"lineText"` +} + +// ExploreCodeResult all returned search results +// swagger:model +type ExploreCodeResult struct { + Total int `json:"total"` + Results []ExploreCodeSearchItem `json:"results"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index be67ec1695b..c1ca119c7ed 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -92,6 +92,7 @@ import ( "code.gitea.io/gitea/routers/api/v1/repo" "code.gitea.io/gitea/routers/api/v1/settings" "code.gitea.io/gitea/routers/api/v1/user" + "code.gitea.io/gitea/routers/api/v1/explore" "code.gitea.io/gitea/routers/common" "code.gitea.io/gitea/services/actions" "code.gitea.io/gitea/services/auth" @@ -890,6 +891,14 @@ func Routes() *web.Router { // Misc (public accessible) m.Group("", func() { m.Get("/version", misc.Version) + m.Group("/explore", func() { + m.Get("/code", func(ctx *context.APIContext) { + if unit.TypeCode.UnitGlobalDisabled() { + ctx.NotFound("Repo unit code is disabled", nil) + return + } + }, explore.Code) + }) m.Get("/signing-key.gpg", misc.SigningKey) m.Post("/markup", reqToken(), bind(api.MarkupOption{}), misc.Markup) m.Post("/markdown", reqToken(), bind(api.MarkdownOption{}), misc.Markdown) diff --git a/routers/api/v1/explore/code.go b/routers/api/v1/explore/code.go new file mode 100644 index 00000000000..2f9e2922c3e --- /dev/null +++ b/routers/api/v1/explore/code.go @@ -0,0 +1,117 @@ +// Copyright 2021 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package explore + +import ( + "net/http" + + "code.gitea.io/gitea/models/db" + repo_model "code.gitea.io/gitea/models/repo" + code_indexer "code.gitea.io/gitea/modules/indexer/code" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + "code.gitea.io/gitea/services/convert" +) + +// Code explore code +func Code(ctx *context.APIContext) { + if !setting.Indexer.RepoIndexerEnabled { + ctx.NotFound("Indexer not enabled") + return + } + + language := ctx.FormTrim("l") + keyword := ctx.FormTrim("q") + + isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(true) + + if keyword == "" { + ctx.JSON(http.StatusInternalServerError, api.SearchError{OK: false, Error: "No keyword provided"}) + return + } + + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + + var ( + repoIDs []int64 + err error + isAdmin bool + ) + if ctx.Doer != nil { + isAdmin = ctx.Doer.IsAdmin + } + + // guest user or non-admin user + if ctx.Doer == nil || !isAdmin { + repoIDs, err = repo_model.FindUserCodeAccessibleRepoIDs(ctx, ctx.Doer) + if err != nil { + ctx.ServerError("FindUserCodeAccessibleRepoIDs", err) + return + } + } + + var ( + total int + searchResults []*code_indexer.Result + repoMaps map[int64]*repo_model.Repository + ) + + if (len(repoIDs) > 0) || isAdmin { + total, searchResults, _, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ + RepoIDs: repoIDs, + Keyword: keyword, + IsKeywordFuzzy: isFuzzy, + IsHtmlSafe: false, + Language: language, + Paginator: &db.ListOptions{ + Page: page, + PageSize: setting.API.DefaultPagingNum, + }, + }) + if err != nil { + if code_indexer.IsAvailable(ctx) { + ctx.ServerError("SearchResults", err) + return + } + } + + loadRepoIDs := make([]int64, 0, len(searchResults)) + for _, result := range searchResults { + var find bool + for _, id := range loadRepoIDs { + if id == result.RepoID { + find = true + break + } + } + if !find { + loadRepoIDs = append(loadRepoIDs, result.RepoID) + } + } + + repoMaps, err = repo_model.GetRepositoriesMapByIDs(ctx, loadRepoIDs) + if err != nil { + ctx.ServerError("GetRepositoriesMapByIDs", err) + return + } + + if len(loadRepoIDs) != len(repoMaps) { + // Remove deleted repos from search results + cleanedSearchResults := make([]*code_indexer.Result, 0, len(repoMaps)) + for _, sr := range searchResults { + if _, found := repoMaps[sr.RepoID]; found { + cleanedSearchResults = append(cleanedSearchResults, sr) + } + } + + searchResults = cleanedSearchResults + } + } + + ctx.JSON(http.StatusOK, convert.ToExploreCodeSearchResults(total, searchResults, repoMaps)) +} diff --git a/routers/web/explore/code.go b/routers/web/explore/code.go index ecd7c33e016..a4c14d3c0df 100644 --- a/routers/web/explore/code.go +++ b/routers/web/explore/code.go @@ -81,6 +81,7 @@ func Code(ctx *context.Context) { RepoIDs: repoIDs, Keyword: keyword, IsKeywordFuzzy: isFuzzy, + IsHtmlSafe: true, Language: language, Paginator: &db.ListOptions{ Page: page, diff --git a/routers/web/repo/search.go b/routers/web/repo/search.go index 920a865555b..667bce249b8 100644 --- a/routers/web/repo/search.go +++ b/routers/web/repo/search.go @@ -59,6 +59,7 @@ func Search(ctx *context.Context) { RepoIDs: []int64{ctx.Repo.Repository.ID}, Keyword: keyword, IsKeywordFuzzy: isFuzzy, + IsHtmlSafe: true, Language: language, Paginator: &db.ListOptions{ Page: page, diff --git a/routers/web/user/code.go b/routers/web/user/code.go index 785c37b1243..c3ac724accd 100644 --- a/routers/web/user/code.go +++ b/routers/web/user/code.go @@ -80,6 +80,7 @@ func CodeSearch(ctx *context.Context) { Keyword: keyword, IsKeywordFuzzy: isFuzzy, Language: language, + IsHtmlSafe: true, Paginator: &db.ListOptions{ Page: page, PageSize: setting.UI.RepoSearchPagingNum, diff --git a/services/convert/explore.go b/services/convert/explore.go new file mode 100644 index 00000000000..ae548ee1f8e --- /dev/null +++ b/services/convert/explore.go @@ -0,0 +1,27 @@ +// Copyright 2024 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package convert + +import ( + repo_model "code.gitea.io/gitea/models/repo" + code_indexer "code.gitea.io/gitea/modules/indexer/code" + api "code.gitea.io/gitea/modules/structs" +) + +func ToExploreCodeSearchResults(total int, results []*code_indexer.Result, repoMaps map[int64]*repo_model.Repository) api.ExploreCodeResult { + out := api.ExploreCodeResult{Total: total} + for _, res := range results { + if repo := repoMaps[res.RepoID]; repo != nil { + for _, r := range res.Lines { + out.Results = append(out.Results, api.ExploreCodeSearchItem{ + RepoName: repo.Name, + FilePath: res.Filename, + LineNumber: r.Num, + LineText: r.RawContent, + }) + } + } + } + return out +}