Merge pull request '[PORT] Support repo code search without setting up an indexer (gitea#29998)' (#2813) from snematoda/port-git-grep into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/2813
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
Earl Warren 2024-03-28 08:15:47 +00:00
commit 327deee2d7
16 changed files with 286 additions and 278 deletions

View file

@ -17,6 +17,12 @@ menu:
# Repository indexer # Repository indexer
## Builtin repository code search without indexer
Users could do repository-level code search without setting up a repository indexer.
The builtin code search is based on the `git grep` command, which is fast and efficient for small repositories.
Better code search support could be achieved by setting up the repository indexer.
## Setting up the repository indexer ## Setting up the repository indexer
Gitea can search through the files of the repositories by enabling this function in your [`app.ini`](administration/config-cheat-sheet.md): Gitea can search through the files of the repositories by enabling this function in your [`app.ini`](administration/config-cheat-sheet.md):

View file

@ -87,6 +87,9 @@ _Symbols used in table:_
| Git Blame | ✓ | ✘ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Git Blame | ✓ | ✘ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
| Visual comparison of image changes | ✓ | ✘ | ✓ | ? | ? | ? | ✘ | ✘ | | Visual comparison of image changes | ✓ | ✘ | ✓ | ? | ? | ? | ✘ | ✘ |
- Gitea has builtin repository-level code search
- Better code search support could be achieved by [using a repository indexer](administration/repo-indexer.md)
## Issue Tracker ## Issue Tracker
| Feature | Gitea | Gogs | GitHub EE | GitLab CE | GitLab EE | BitBucket | RhodeCode CE | RhodeCode EE | | Feature | Gitea | Gogs | GitHub EE | GitLab CE | GitLab EE | BitBucket | RhodeCode CE | RhodeCode EE |

View file

@ -367,7 +367,6 @@ type RunStdError interface {
error error
Unwrap() error Unwrap() error
Stderr() string Stderr() string
IsExitCode(code int) bool
} }
type runStdError struct { type runStdError struct {
@ -392,9 +391,9 @@ func (r *runStdError) Stderr() string {
return r.stderr return r.stderr
} }
func (r *runStdError) IsExitCode(code int) bool { func IsErrorExitCode(err error, code int) bool {
var exitError *exec.ExitError var exitError *exec.ExitError
if errors.As(r.err, &exitError) { if errors.As(err, &exitError) {
return exitError.ExitCode() == code return exitError.ExitCode() == code
} }
return false return false

View file

@ -340,7 +340,7 @@ func CheckGitVersionEqual(equal string) error {
func configSet(key, value string) error { func configSet(key, value string) error {
stdout, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil) stdout, _, err := NewCommand(DefaultContext, "config", "--global", "--get").AddDynamicArguments(key).RunStdString(nil)
if err != nil && !err.IsExitCode(1) { if err != nil && !IsErrorExitCode(err, 1) {
return fmt.Errorf("failed to get git config %s, err: %w", key, err) return fmt.Errorf("failed to get git config %s, err: %w", key, err)
} }
@ -363,7 +363,7 @@ func configSetNonExist(key, value string) error {
// already exist // already exist
return nil return nil
} }
if err.IsExitCode(1) { if IsErrorExitCode(err, 1) {
// not exist, set new config // not exist, set new config
_, _, err = NewCommand(DefaultContext, "config", "--global").AddDynamicArguments(key, value).RunStdString(nil) _, _, err = NewCommand(DefaultContext, "config", "--global").AddDynamicArguments(key, value).RunStdString(nil)
if err != nil { if err != nil {
@ -381,7 +381,7 @@ func configAddNonExist(key, value string) error {
// already exist // already exist
return nil return nil
} }
if err.IsExitCode(1) { if IsErrorExitCode(err, 1) {
// not exist, add new config // not exist, add new config
_, _, err = NewCommand(DefaultContext, "config", "--global", "--add").AddDynamicArguments(key, value).RunStdString(nil) _, _, err = NewCommand(DefaultContext, "config", "--global", "--add").AddDynamicArguments(key, value).RunStdString(nil)
if err != nil { if err != nil {
@ -402,7 +402,7 @@ func configUnsetAll(key, value string) error {
} }
return nil return nil
} }
if err.IsExitCode(1) { if IsErrorExitCode(err, 1) {
// not exist // not exist
return nil return nil
} }

118
modules/git/grep.go Normal file
View file

@ -0,0 +1,118 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"os"
"strconv"
"strings"
"code.gitea.io/gitea/modules/util"
)
type GrepResult struct {
Filename string
LineNumbers []int
LineCodes []string
}
type GrepOptions struct {
RefName string
MaxResultLimit int
ContextLineNumber int
IsFuzzy bool
}
func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepOptions) ([]*GrepResult, error) {
stdoutReader, stdoutWriter, err := os.Pipe()
if err != nil {
return nil, fmt.Errorf("unable to create os pipe to grep: %w", err)
}
defer func() {
_ = stdoutReader.Close()
_ = stdoutWriter.Close()
}()
/*
The output is like this ( "^@" means \x00):
HEAD:.air.toml
6^@bin = "gitea"
HEAD:.changelog.yml
2^@repo: go-gitea/gitea
*/
var results []*GrepResult
cmd := NewCommand(ctx, "grep", "--null", "--break", "--heading", "--fixed-strings", "--line-number", "--ignore-case", "--full-name")
cmd.AddOptionValues("--context", fmt.Sprint(opts.ContextLineNumber))
if opts.IsFuzzy {
words := strings.Fields(search)
for _, word := range words {
cmd.AddOptionValues("-e", strings.TrimLeft(word, "-"))
}
} else {
cmd.AddOptionValues("-e", strings.TrimLeft(search, "-"))
}
cmd.AddDynamicArguments(util.IfZero(opts.RefName, "HEAD"))
opts.MaxResultLimit = util.IfZero(opts.MaxResultLimit, 50)
stderr := bytes.Buffer{}
err = cmd.Run(&RunOpts{
Dir: repo.Path,
Stdout: stdoutWriter,
Stderr: &stderr,
PipelineFunc: func(ctx context.Context, cancel context.CancelFunc) error {
_ = stdoutWriter.Close()
defer stdoutReader.Close()
isInBlock := false
scanner := bufio.NewScanner(stdoutReader)
var res *GrepResult
for scanner.Scan() {
line := scanner.Text()
if !isInBlock {
if _ /* ref */, filename, ok := strings.Cut(line, ":"); ok {
isInBlock = true
res = &GrepResult{Filename: filename}
results = append(results, res)
}
continue
}
if line == "" {
if len(results) >= opts.MaxResultLimit {
cancel()
break
}
isInBlock = false
continue
}
if line == "--" {
continue
}
if lineNum, lineCode, ok := strings.Cut(line, "\x00"); ok {
lineNumInt, _ := strconv.Atoi(lineNum)
res.LineNumbers = append(res.LineNumbers, lineNumInt)
res.LineCodes = append(res.LineCodes, lineCode)
}
}
return scanner.Err()
},
})
// git grep exits by cancel (killed), usually it is caused by the limit of results
if IsErrorExitCode(err, -1) && stderr.Len() == 0 {
return results, nil
}
// git grep exits with 1 if no results are found
if IsErrorExitCode(err, 1) && stderr.Len() == 0 {
return nil, nil
}
if err != nil && !errors.Is(err, context.Canceled) {
return nil, fmt.Errorf("unable to run git grep: %w, stderr: %s", err, stderr.String())
}
return results, nil
}

51
modules/git/grep_test.go Normal file
View file

@ -0,0 +1,51 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package git
import (
"context"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGrepSearch(t *testing.T) {
repo, err := openRepositoryWithDefaultContext(filepath.Join(testReposDir, "language_stats_repo"))
assert.NoError(t, err)
defer repo.Close()
res, err := GrepSearch(context.Background(), repo, "void", GrepOptions{})
assert.NoError(t, err)
assert.Equal(t, []*GrepResult{
{
Filename: "java-hello/main.java",
LineNumbers: []int{3},
LineCodes: []string{" public static void main(String[] args)"},
},
{
Filename: "main.vendor.java",
LineNumbers: []int{3},
LineCodes: []string{" public static void main(String[] args)"},
},
}, res)
res, err = GrepSearch(context.Background(), repo, "void", GrepOptions{MaxResultLimit: 1})
assert.NoError(t, err)
assert.Equal(t, []*GrepResult{
{
Filename: "java-hello/main.java",
LineNumbers: []int{3},
LineCodes: []string{" public static void main(String[] args)"},
},
}, res)
res, err = GrepSearch(context.Background(), repo, "no-such-content", GrepOptions{})
assert.NoError(t, err)
assert.Len(t, res, 0)
res, err = GrepSearch(context.Background(), &Repository{Path: "no-such-git-repo"}, "no-such-content", GrepOptions{})
assert.Error(t, err)
assert.Len(t, res, 0)
}

View file

@ -70,13 +70,27 @@ func writeStrings(buf *bytes.Buffer, strs ...string) error {
return nil return nil
} }
func HighlightSearchResultCode(filename string, lineNums []int, code string) []ResultLine {
// we should highlight the whole code block first, otherwise it doesn't work well with multiple line highlighting
hl, _ := highlight.Code(filename, "", code)
highlightedLines := strings.Split(string(hl), "\n")
// The lineNums outputted by highlight.Code might not match the original lineNums, because "highlight" removes the last `\n`
lines := make([]ResultLine, min(len(highlightedLines), len(lineNums)))
for i := 0; i < len(lines); i++ {
lines[i].Num = lineNums[i]
lines[i].FormattedContent = template.HTML(highlightedLines[i])
}
return lines
}
func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Result, error) { func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Result, error) {
startLineNum := 1 + strings.Count(result.Content[:startIndex], "\n") startLineNum := 1 + strings.Count(result.Content[:startIndex], "\n")
var formattedLinesBuffer bytes.Buffer var formattedLinesBuffer bytes.Buffer
contentLines := strings.SplitAfter(result.Content[startIndex:endIndex], "\n") contentLines := strings.SplitAfter(result.Content[startIndex:endIndex], "\n")
lines := make([]ResultLine, 0, len(contentLines)) lineNums := make([]int, 0, len(contentLines))
index := startIndex index := startIndex
for i, line := range contentLines { for i, line := range contentLines {
var err error var err error
@ -91,29 +105,16 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res
line[closeActiveIndex:], line[closeActiveIndex:],
) )
} else { } else {
err = writeStrings(&formattedLinesBuffer, err = writeStrings(&formattedLinesBuffer, line)
line,
)
} }
if err != nil { if err != nil {
return nil, err return nil, err
} }
lines = append(lines, ResultLine{Num: startLineNum + i}) lineNums = append(lineNums, startLineNum+i)
index += len(line) index += len(line)
} }
// we should highlight the whole code block first, otherwise it doesn't work well with multiple line highlighting
hl, _ := highlight.Code(result.Filename, "", formattedLinesBuffer.String())
highlightedLines := strings.Split(string(hl), "\n")
// The lines outputted by highlight.Code might not match the original lines, because "highlight" removes the last `\n`
lines = lines[:min(len(highlightedLines), len(lines))]
highlightedLines = highlightedLines[:len(lines)]
for i := 0; i < len(lines); i++ {
lines[i].FormattedContent = template.HTML(highlightedLines[i])
}
return &Result{ return &Result{
RepoID: result.RepoID, RepoID: result.RepoID,
Filename: result.Filename, Filename: result.Filename,
@ -121,7 +122,7 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res
UpdatedUnix: result.UpdatedUnix, UpdatedUnix: result.UpdatedUnix,
Language: result.Language, Language: result.Language,
Color: result.Color, Color: result.Color,
Lines: lines, Lines: HighlightSearchResultCode(result.Filename, lineNums, formattedLinesBuffer.String()),
}, nil }, nil
} }

View file

@ -212,3 +212,12 @@ func ToFloat64(number any) (float64, error) {
func ToPointer[T any](val T) *T { func ToPointer[T any](val T) *T {
return &val return &val
} }
// IfZero returns "def" if "v" is a zero value, otherwise "v"
func IfZero[T comparable](v, def T) T {
var zero T
if v == zero {
return def
}
return v
}

View file

@ -171,6 +171,7 @@ org_kind = Search orgs...
team_kind = Search teams... team_kind = Search teams...
code_kind = Search code... code_kind = Search code...
code_search_unavailable = Code search is currently not available. Please contact the site administrator. code_search_unavailable = Code search is currently not available. Please contact the site administrator.
code_search_by_git_grep = Current code search results are provided by "git grep". There might be better results if site administrator enables Repository Indexer.
package_kind = Search packages... package_kind = Search packages...
project_kind = Search projects... project_kind = Search projects...
branch_kind = Search branches... branch_kind = Search branches...

View file

@ -5,13 +5,14 @@ package repo
import ( import (
"net/http" "net/http"
"strings"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/git"
code_indexer "code.gitea.io/gitea/modules/indexer/code" code_indexer "code.gitea.io/gitea/modules/indexer/code"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/repository/files"
) )
const tplSearch base.TplName = "repo/search" const tplSearch base.TplName = "repo/search"
@ -33,17 +34,17 @@ func Search(ctx *context.Context) {
return return
} }
ctx.Data["Repo"] = ctx.Repo.Repository
page := ctx.FormInt("page") page := ctx.FormInt("page")
if page <= 0 { if page <= 0 {
page = 1 page = 1
} }
var total int
var searchResults []*code_indexer.Result
var searchResultLanguages []*code_indexer.SearchResultLanguages
if setting.Indexer.RepoIndexerEnabled { if setting.Indexer.RepoIndexerEnabled {
ctx.Data["CodeIndexerDisabled"] = false var err error
total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{
total, searchResults, searchResultLanguages, err := code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{
RepoIDs: []int64{ctx.Repo.Repository.ID}, RepoIDs: []int64{ctx.Repo.Repository.ID},
Keyword: keyword, Keyword: keyword,
IsKeywordFuzzy: isFuzzy, IsKeywordFuzzy: isFuzzy,
@ -62,7 +63,32 @@ func Search(ctx *context.Context) {
} else { } else {
ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx) ctx.Data["CodeIndexerUnavailable"] = !code_indexer.IsAvailable(ctx)
} }
} else {
res, err := git.GrepSearch(ctx, ctx.Repo.GitRepo, keyword, git.GrepOptions{ContextLineNumber: 3, IsFuzzy: isFuzzy})
if err != nil {
ctx.ServerError("GrepSearch", err)
return
}
total = len(res)
pageStart := min((page-1)*setting.UI.RepoSearchPagingNum, len(res))
pageEnd := min(page*setting.UI.RepoSearchPagingNum, len(res))
res = res[pageStart:pageEnd]
for _, r := range res {
searchResults = append(searchResults, &code_indexer.Result{
RepoID: ctx.Repo.Repository.ID,
Filename: r.Filename,
CommitID: ctx.Repo.CommitID,
// UpdatedUnix: not supported yet
// Language: not supported yet
// Color: not supported yet
Lines: code_indexer.HighlightSearchResultCode(r.Filename, r.LineNumbers, strings.Join(r.LineCodes, "\n")),
})
}
}
ctx.Data["CodeIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
ctx.Data["Repo"] = ctx.Repo.Repository
ctx.Data["SourcePath"] = ctx.Repo.Repository.Link()
ctx.Data["SearchResults"] = searchResults ctx.Data["SearchResults"] = searchResults
ctx.Data["SearchResultLanguages"] = searchResultLanguages ctx.Data["SearchResultLanguages"] = searchResultLanguages
@ -70,20 +96,6 @@ func Search(ctx *context.Context) {
pager.SetDefaultParams(ctx) pager.SetDefaultParams(ctx)
pager.AddParam(ctx, "l", "Language") pager.AddParam(ctx, "l", "Language")
ctx.Data["Page"] = pager ctx.Data["Page"] = pager
} else {
data, err := files.NewRepoGrep(ctx, ctx.Repo.Repository, keyword)
if err != nil {
ctx.ServerError("NewRepoGrep", err)
return
}
ctx.Data["CodeIndexerDisabled"] = true
ctx.Data["SearchResults"] = data
pager := context.NewPagination(len(data), setting.UI.RepoSearchPagingNum, page, 5)
pager.SetDefaultParams(ctx)
ctx.Data["Page"] = pager
}
ctx.HTML(http.StatusOK, tplSearch) ctx.HTML(http.StatusOK, tplSearch)
} }

View file

@ -1,111 +0,0 @@
package files
import (
"context"
"html/template"
"strconv"
"strings"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/highlight"
"code.gitea.io/gitea/modules/timeutil"
"github.com/go-enry/go-enry/v2"
)
type Result struct {
RepoID int64 // ignored
Filename string
CommitID string // branch
UpdatedUnix timeutil.TimeStamp // ignored
Language string
Color string
Lines []ResultLine
}
type ResultLine struct {
Num int64
FormattedContent template.HTML
}
const pHEAD = "HEAD:"
func NewRepoGrep(ctx context.Context, repo *repo_model.Repository, keyword string) ([]*Result, error) {
t, _, err := gitrepo.RepositoryFromContextOrOpen(ctx, repo)
if err != nil {
return nil, err
}
data := []*Result{}
stdout, _, err := git.NewCommand(ctx,
"grep",
"-1", // n before and after lines
"-z",
"--heading",
"--break", // easier parsing
"--fixed-strings", // disallow regex for now
"-n", // line nums
"-i", // ignore case
"--full-name", // full file path, rel to repo
//"--column", // for adding better highlighting support
"-e", // for queries starting with "-"
).
AddDynamicArguments(keyword).
AddArguments("HEAD").
RunStdString(&git.RunOpts{Dir: t.Path})
if err != nil {
return data, nil // non zero exit code when there are no results
}
for _, block := range strings.Split(stdout, "\n\n") {
res := Result{CommitID: repo.DefaultBranch}
linenum := []int64{}
code := []string{}
for _, line := range strings.Split(block, "\n") {
if strings.HasPrefix(line, pHEAD) {
res.Filename = strings.TrimPrefix(line, pHEAD)
continue
}
if ln, after, ok := strings.Cut(line, "\x00"); ok {
i, err := strconv.ParseInt(ln, 10, 64)
if err != nil {
continue
}
linenum = append(linenum, i)
code = append(code, after)
}
}
if res.Filename == "" || len(code) == 0 || len(linenum) == 0 {
continue
}
var hl template.HTML
hl, res.Language = highlight.Code(res.Filename, "", strings.Join(code, "\n"))
res.Color = enry.GetColor(res.Language)
hlCode := strings.Split(string(hl), "\n")
n := min(len(hlCode), len(linenum))
res.Lines = make([]ResultLine, n)
for i := 0; i < n; i++ {
res.Lines[i] = ResultLine{
Num: linenum[i],
FormattedContent: template.HTML(hlCode[i]),
}
}
data = append(data, &res)
}
return data, nil
}

View file

@ -1,50 +0,0 @@
package files
import (
"testing"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/services/contexttest"
"github.com/stretchr/testify/assert"
)
func TestNewRepoGrep(t *testing.T) {
unittest.PrepareTestEnv(t)
ctx, _ := contexttest.MockContext(t, "user2/repo1")
ctx.SetParams(":id", "1")
contexttest.LoadRepo(t, ctx, 1)
contexttest.LoadRepoCommit(t, ctx)
contexttest.LoadUser(t, ctx, 2)
contexttest.LoadGitRepo(t, ctx)
defer ctx.Repo.GitRepo.Close()
t.Run("with result", func(t *testing.T) {
res, err := NewRepoGrep(ctx, ctx.Repo.Repository, "Description")
assert.NoError(t, err)
expected := []*Result{
{
RepoID: 0,
Filename: "README.md",
CommitID: "master",
UpdatedUnix: 0,
Language: "Markdown",
Color: "#083fa1",
Lines: []ResultLine{
{Num: 2, FormattedContent: ""},
{Num: 3, FormattedContent: "Description for repo1"},
},
},
}
assert.EqualValues(t, res, expected)
})
t.Run("empty result", func(t *testing.T) {
res, err := NewRepoGrep(ctx, ctx.Repo.Repository, "keyword that does not match in the repo")
assert.NoError(t, err)
assert.EqualValues(t, res, []*Result{})
})
}

View file

@ -5,28 +5,19 @@
{{template "base/alert" .}} {{template "base/alert" .}}
{{template "repo/code/recently_pushed_new_branches" .}} {{template "repo/code/recently_pushed_new_branches" .}}
{{if and (not .HideRepoInfo) (not .IsBlame)}} {{if and (not .HideRepoInfo) (not .IsBlame)}}
<div class="ui repo-description gt-word-break"> <div class="repo-description">
<div id="repo-desc" class="tw-text-16"> <div id="repo-desc" class="gt-word-break tw-text-16">
{{$description := .Repository.DescriptionHTML $.Context}} {{$description := .Repository.DescriptionHTML $.Context}}
{{if $description}}<span class="description">{{$description | RenderCodeBlock}}</span>{{else if .IsRepositoryAdmin}}<span class="no-description text-italic">{{ctx.Locale.Tr "repo.no_desc"}}</span>{{end}} {{if $description}}<span class="description">{{$description | RenderCodeBlock}}</span>{{else if .IsRepositoryAdmin}}<span class="no-description text-italic">{{ctx.Locale.Tr "repo.no_desc"}}</span>{{end}}
<a class="link" href="{{.Repository.Website}}">{{.Repository.Website}}</a> <a class="link" href="{{.Repository.Website}}">{{.Repository.Website}}</a>
</div> </div>
<div class="ui repo-search"> <form class="ignore-dirty" action="{{.RepoLink}}/search" method="get">
<form class="ui form ignore-dirty" action="{{.RepoLink}}/search" method="get"> <div class="ui small action input">
<div class="field"> <input name="q" value="{{.Keyword}}" placeholder="{{ctx.Locale.Tr "search.code_kind"}}">
<div class="ui small action input{{if .CodeIndexerUnavailable}} disabled left icon{{end}}"{{if .CodeIndexerUnavailable}} data-tooltip-content="{{ctx.Locale.Tr "search.code_search_unavailable"}}"{{end}}> {{template "shared/search/button"}}
<input name="q" value="{{.Keyword}}"{{if .CodeIndexerUnavailable}} disabled{{end}} placeholder="{{ctx.Locale.Tr "search.code_kind"}}">
{{if .CodeIndexerUnavailable}}
<i class="icon">{{svg "octicon-alert"}}</i>
{{end}}
<button class="ui small icon button"{{if .CodeIndexerUnavailable}} disabled{{end}} type="submit">
{{svg "octicon-search"}}
</button>
</div>
</div> </div>
</form> </form>
</div> </div>
</div>
<div class="tw-flex tw-items-center tw-flex-wrap tw-gap-1" id="repo-topics"> <div class="tw-flex tw-items-center tw-flex-wrap tw-gap-1" id="repo-topics">
{{range .Topics}}<a class="ui repo-topic large label topic gt-m-0" href="{{AppSubUrl}}/explore/repos?q={{.Name}}&topic=1">{{.Name}}</a>{{end}} {{range .Topics}}<a class="ui repo-topic large label topic gt-m-0" href="{{AppSubUrl}}/explore/repos?q={{.Name}}&topic=1">{{.Name}}</a>{{end}}
{{if and .Permission.IsAdmin (not .Repository.IsArchived)}}<button id="manage_topic" class="btn interact-fg tw-text-12">{{ctx.Locale.Tr "repo.topic.manage_topics"}}</button>{{end}} {{if and .Permission.IsAdmin (not .Repository.IsArchived)}}<button id="manage_topic" class="btn interact-fg tw-text-12">{{ctx.Locale.Tr "repo.topic.manage_topics"}}</button>{{end}}

View file

@ -11,9 +11,16 @@
<div class="ui error message"> <div class="ui error message">
<p>{{ctx.Locale.Tr "search.code_search_unavailable"}}</p> <p>{{ctx.Locale.Tr "search.code_search_unavailable"}}</p>
</div> </div>
{{else if .SearchResults}} {{else}}
{{if not .CodeIndexerEnabled}}
<div class="ui message">
<p>{{ctx.Locale.Tr "search.code_search_by_git_grep"}}</p>
</div>
{{end}}
{{if .SearchResults}}
{{template "shared/search/code/results" .}} {{template "shared/search/code/results" .}}
{{else if .Keyword}} {{else if .Keyword}}
<div>{{ctx.Locale.Tr "search.no_results"}}</div> <div>{{ctx.Locale.Tr "search.no_results"}}</div>
{{end}} {{end}}
{{end}}
</div> </div>

View file

@ -1,3 +1,4 @@
{{if or .result.Language (not .result.UpdatedUnix.IsZero)}}
<div class="ui bottom attached table segment tw-flex tw-items-center tw-justify-between"> <div class="ui bottom attached table segment tw-flex tw-items-center tw-justify-between">
<div class="tw-flex tw-items-center gt-ml-4"> <div class="tw-flex tw-items-center gt-ml-4">
{{if .result.Language}} {{if .result.Language}}
@ -10,3 +11,4 @@
{{end}} {{end}}
</div> </div>
</div> </div>
{{end}}

View file

@ -11,15 +11,14 @@ import (
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
code_indexer "code.gitea.io/gitea/modules/indexer/code" code_indexer "code.gitea.io/gitea/modules/indexer/code"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func resultFilenames(t testing.TB, doc *goquery.Selection) []string { func resultFilenames(t testing.TB, doc *HTMLDoc) []string {
filenameSelections := doc.Find(".header").Find("span.file") filenameSelections := doc.doc.Find(".repository.search").Find(".repo-search-result").Find(".header").Find("span.file")
result := make([]string, filenameSelections.Length()) result := make([]string, filenameSelections.Length())
filenameSelections.Each(func(i int, selection *goquery.Selection) { filenameSelections.Each(func(i int, selection *goquery.Selection) {
result[i] = selection.Text() result[i] = selection.Text()
@ -27,31 +26,16 @@ func resultFilenames(t testing.TB, doc *goquery.Selection) []string {
return result return result
} }
func checkResultLinks(t *testing.T, substr string, doc *goquery.Selection) { func TestSearchRepo(t *testing.T) {
t.Helper()
linkSelections := doc.Find("a[href]")
linkSelections.Each(func(i int, selection *goquery.Selection) {
assert.Contains(t, selection.AttrOr("href", ""), substr)
})
}
func testSearchRepo(t *testing.T, useExternalIndexer bool) {
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
defer test.MockVariableValue(&setting.Indexer.RepoIndexerEnabled, useExternalIndexer)()
repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1") repo, err := repo_model.GetRepositoryByOwnerAndName(db.DefaultContext, "user2", "repo1")
assert.NoError(t, err) assert.NoError(t, err)
gitReference := "/branch/" + repo.DefaultBranch
if useExternalIndexer {
gitReference = "/commit/"
code_indexer.UpdateRepoIndexer(repo) code_indexer.UpdateRepoIndexer(repo)
}
testSearch(t, "/user2/repo1/search?q=Description&page=1", gitReference, []string{"README.md"}) testSearch(t, "/user2/repo1/search?q=Description&page=1", []string{"README.md"})
if useExternalIndexer {
setting.Indexer.IncludePatterns = setting.IndexerGlobFromString("**.txt") setting.Indexer.IncludePatterns = setting.IndexerGlobFromString("**.txt")
setting.Indexer.ExcludePatterns = setting.IndexerGlobFromString("**/y/**") setting.Indexer.ExcludePatterns = setting.IndexerGlobFromString("**/y/**")
@ -60,33 +44,18 @@ func testSearchRepo(t *testing.T, useExternalIndexer bool) {
code_indexer.UpdateRepoIndexer(repo) code_indexer.UpdateRepoIndexer(repo)
testSearch(t, "/user2/glob/search?q=loren&page=1", gitReference, []string{"a.txt"}) testSearch(t, "/user2/glob/search?q=loren&page=1", []string{"a.txt"})
testSearch(t, "/user2/glob/search?q=loren&page=1&t=match", gitReference, []string{"a.txt"}) testSearch(t, "/user2/glob/search?q=loren&page=1&t=match", []string{"a.txt"})
testSearch(t, "/user2/glob/search?q=file3&page=1", gitReference, []string{"x/b.txt", "a.txt"}) testSearch(t, "/user2/glob/search?q=file3&page=1", []string{"x/b.txt", "a.txt"})
testSearch(t, "/user2/glob/search?q=file3&page=1&t=match", gitReference, []string{"x/b.txt", "a.txt"}) testSearch(t, "/user2/glob/search?q=file3&page=1&t=match", []string{"x/b.txt", "a.txt"})
testSearch(t, "/user2/glob/search?q=file4&page=1&t=match", gitReference, []string{"x/b.txt", "a.txt"}) testSearch(t, "/user2/glob/search?q=file4&page=1&t=match", []string{"x/b.txt", "a.txt"})
testSearch(t, "/user2/glob/search?q=file5&page=1&t=match", gitReference, []string{"x/b.txt", "a.txt"}) testSearch(t, "/user2/glob/search?q=file5&page=1&t=match", []string{"x/b.txt", "a.txt"})
}
} }
func TestIndexerSearchRepo(t *testing.T) { func testSearch(t *testing.T, url string, expected []string) {
testSearchRepo(t, true)
}
func TestNoIndexerSearchRepo(t *testing.T) {
testSearchRepo(t, false)
}
func testSearch(t *testing.T, url, gitRef string, expected []string) {
req := NewRequest(t, "GET", url) req := NewRequest(t, "GET", url)
resp := MakeRequest(t, req, http.StatusOK) resp := MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body).doc. filenames := resultFilenames(t, NewHTMLParser(t, resp.Body))
Find(".repository.search").
Find(".repo-search-result")
filenames := resultFilenames(t, doc)
assert.EqualValues(t, expected, filenames) assert.EqualValues(t, expected, filenames)
checkResultLinks(t, gitRef, doc)
} }