Improve issue & code search (#33860)

Each "indexer" should provide the "search modes" they support by
themselves. And we need to remove the "fuzzy" search for code.
main
wxiaoguang 12 hours ago committed by GitHub
parent cd10456664
commit 403775e74e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -23,11 +23,19 @@ type GrepResult struct {
LineCodes []string LineCodes []string
} }
type GrepModeType string
const (
GrepModeExact GrepModeType = "exact"
GrepModeWords GrepModeType = "words"
GrepModeRegexp GrepModeType = "regexp"
)
type GrepOptions struct { type GrepOptions struct {
RefName string RefName string
MaxResultLimit int MaxResultLimit int
ContextLineNumber int ContextLineNumber int
IsFuzzy bool GrepMode GrepModeType
MaxLineLength int // the maximum length of a line to parse, exceeding chars will be truncated MaxLineLength int // the maximum length of a line to parse, exceeding chars will be truncated
PathspecList []string PathspecList []string
} }
@ -52,15 +60,23 @@ func GrepSearch(ctx context.Context, repo *Repository, search string, opts GrepO
2^@repo: go-gitea/gitea 2^@repo: go-gitea/gitea
*/ */
var results []*GrepResult var results []*GrepResult
cmd := NewCommand("grep", "--null", "--break", "--heading", "--fixed-strings", "--line-number", "--ignore-case", "--full-name") cmd := NewCommand("grep", "--null", "--break", "--heading", "--line-number", "--full-name")
cmd.AddOptionValues("--context", fmt.Sprint(opts.ContextLineNumber)) cmd.AddOptionValues("--context", fmt.Sprint(opts.ContextLineNumber))
if opts.IsFuzzy { if opts.GrepMode == GrepModeExact {
cmd.AddArguments("--fixed-strings")
cmd.AddOptionValues("-e", strings.TrimLeft(search, "-"))
} else if opts.GrepMode == GrepModeRegexp {
cmd.AddArguments("--perl-regexp")
cmd.AddOptionValues("-e", strings.TrimLeft(search, "-"))
} else /* words */ {
words := strings.Fields(search) words := strings.Fields(search)
for _, word := range words { cmd.AddArguments("--fixed-strings", "--ignore-case")
for i, word := range words {
cmd.AddOptionValues("-e", strings.TrimLeft(word, "-")) cmd.AddOptionValues("-e", strings.TrimLeft(word, "-"))
if i < len(words)-1 {
cmd.AddOptionValues("--and")
}
} }
} else {
cmd.AddOptionValues("-e", strings.TrimLeft(search, "-"))
} }
cmd.AddDynamicArguments(util.IfZero(opts.RefName, "HEAD")) cmd.AddDynamicArguments(util.IfZero(opts.RefName, "HEAD"))
cmd.AddDashesAndList(opts.PathspecList...) cmd.AddDashesAndList(opts.PathspecList...)

@ -17,6 +17,7 @@ import (
"code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/indexer"
path_filter "code.gitea.io/gitea/modules/indexer/code/bleve/token/path" path_filter "code.gitea.io/gitea/modules/indexer/code/bleve/token/path"
"code.gitea.io/gitea/modules/indexer/code/internal" "code.gitea.io/gitea/modules/indexer/code/internal"
indexer_internal "code.gitea.io/gitea/modules/indexer/internal" indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
@ -136,6 +137,10 @@ type Indexer struct {
indexer_internal.Indexer // do not composite inner_bleve.Indexer directly to avoid exposing too much indexer_internal.Indexer // do not composite inner_bleve.Indexer directly to avoid exposing too much
} }
func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
return indexer.SearchModesExactWords()
}
// NewIndexer creates a new bleve local indexer // NewIndexer creates a new bleve local indexer
func NewIndexer(indexDir string) *Indexer { func NewIndexer(indexDir string) *Indexer {
inner := inner_bleve.NewIndexer(indexDir, repoIndexerLatestVersion, generateBleveIndexMapping) inner := inner_bleve.NewIndexer(indexDir, repoIndexerLatestVersion, generateBleveIndexMapping)
@ -267,19 +272,18 @@ func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int
pathQuery.FieldVal = "Filename" pathQuery.FieldVal = "Filename"
pathQuery.SetBoost(10) pathQuery.SetBoost(10)
keywordAsPhrase, isPhrase := internal.ParseKeywordAsPhrase(opts.Keyword) if opts.SearchMode == indexer.SearchModeExact {
if isPhrase { q := bleve.NewMatchPhraseQuery(opts.Keyword)
q := bleve.NewMatchPhraseQuery(keywordAsPhrase)
q.FieldVal = "Content" q.FieldVal = "Content"
if opts.IsKeywordFuzzy {
q.Fuzziness = inner_bleve.GuessFuzzinessByKeyword(keywordAsPhrase)
}
contentQuery = q contentQuery = q
} else { } else /* words */ {
q := bleve.NewMatchQuery(opts.Keyword) q := bleve.NewMatchQuery(opts.Keyword)
q.FieldVal = "Content" q.FieldVal = "Content"
if opts.IsKeywordFuzzy { if opts.SearchMode == indexer.SearchModeFuzzy {
// this logic doesn't seem right, it is only used to pass the test-case `Keyword: "dESCRIPTION"`, which doesn't seem to be a real-life use-case.
q.Fuzziness = inner_bleve.GuessFuzzinessByKeyword(opts.Keyword) q.Fuzziness = inner_bleve.GuessFuzzinessByKeyword(opts.Keyword)
} else {
q.Operator = query.MatchQueryOperatorAnd
} }
contentQuery = q contentQuery = q
} }

@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/indexer"
"code.gitea.io/gitea/modules/indexer/code/internal" "code.gitea.io/gitea/modules/indexer/code/internal"
indexer_internal "code.gitea.io/gitea/modules/indexer/internal" indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
inner_elasticsearch "code.gitea.io/gitea/modules/indexer/internal/elasticsearch" inner_elasticsearch "code.gitea.io/gitea/modules/indexer/internal/elasticsearch"
@ -24,7 +25,6 @@ import (
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/typesniffer" "code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util"
"github.com/go-enry/go-enry/v2" "github.com/go-enry/go-enry/v2"
"github.com/olivere/elastic/v7" "github.com/olivere/elastic/v7"
@ -46,6 +46,10 @@ type Indexer struct {
indexer_internal.Indexer // do not composite inner_elasticsearch.Indexer directly to avoid exposing too much indexer_internal.Indexer // do not composite inner_elasticsearch.Indexer directly to avoid exposing too much
} }
func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
return indexer.SearchModesExactWords()
}
// NewIndexer creates a new elasticsearch indexer // NewIndexer creates a new elasticsearch indexer
func NewIndexer(url, indexerName string) *Indexer { func NewIndexer(url, indexerName string) *Indexer {
inner := inner_elasticsearch.NewIndexer(url, indexerName, esRepoIndexerLatestVersion, defaultMapping) inner := inner_elasticsearch.NewIndexer(url, indexerName, esRepoIndexerLatestVersion, defaultMapping)
@ -361,15 +365,10 @@ func extractAggs(searchResult *elastic.SearchResult) []*internal.SearchResultLan
// Search searches for codes and language stats by given conditions. // Search searches for codes and language stats by given conditions.
func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) { func (b *Indexer) Search(ctx context.Context, opts *internal.SearchOptions) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
var contentQuery elastic.Query var contentQuery elastic.Query
keywordAsPhrase, isPhrase := internal.ParseKeywordAsPhrase(opts.Keyword) if opts.SearchMode == indexer.SearchModeExact {
if isPhrase { contentQuery = elastic.NewMatchPhraseQuery("content", opts.Keyword)
contentQuery = elastic.NewMatchPhraseQuery("content", keywordAsPhrase) } else /* words */ {
} else { contentQuery = elastic.NewMultiMatchQuery("content", opts.Keyword).Type(esMultiMatchTypeBestFields).Operator("and")
// TODO: this is the old logic, but not really using "fuzziness"
// * IsKeywordFuzzy=true: "best_fields"
// * IsKeywordFuzzy=false: "phrase_prefix"
contentQuery = elastic.NewMultiMatchQuery("content", opts.Keyword).
Type(util.Iif(opts.IsKeywordFuzzy, esMultiMatchTypeBestFields, esMultiMatchTypePhrasePrefix))
} }
kwQuery := elastic.NewBoolQuery().Should( kwQuery := elastic.NewBoolQuery().Should(
contentQuery, contentQuery,

@ -9,6 +9,7 @@ import (
"strings" "strings"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/indexer"
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"
) )
@ -23,11 +24,16 @@ func indexSettingToGitGrepPathspecList() (list []string) {
return list return list
} }
func PerformSearch(ctx context.Context, page int, repoID int64, gitRepo *git.Repository, ref git.RefName, keyword string, isFuzzy bool) (searchResults []*code_indexer.Result, total int, err error) { func PerformSearch(ctx context.Context, page int, repoID int64, gitRepo *git.Repository, ref git.RefName, keyword string, searchMode indexer.SearchModeType) (searchResults []*code_indexer.Result, total int, err error) {
// TODO: it should also respect ParseKeywordAsPhrase and clarify the "fuzzy" behavior grepMode := git.GrepModeWords
if searchMode == indexer.SearchModeExact {
grepMode = git.GrepModeExact
} else if searchMode == indexer.SearchModeRegexp {
grepMode = git.GrepModeRegexp
}
res, err := git.GrepSearch(ctx, gitRepo, keyword, git.GrepOptions{ res, err := git.GrepSearch(ctx, gitRepo, keyword, git.GrepOptions{
ContextLineNumber: 1, ContextLineNumber: 1,
IsFuzzy: isFuzzy, GrepMode: grepMode,
RefName: ref.String(), RefName: ref.String(),
PathspecList: indexSettingToGitGrepPathspecList(), PathspecList: indexSettingToGitGrepPathspecList(),
}) })

@ -14,6 +14,7 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/indexer"
"code.gitea.io/gitea/modules/indexer/code/bleve" "code.gitea.io/gitea/modules/indexer/code/bleve"
"code.gitea.io/gitea/modules/indexer/code/elasticsearch" "code.gitea.io/gitea/modules/indexer/code/elasticsearch"
"code.gitea.io/gitea/modules/indexer/code/internal" "code.gitea.io/gitea/modules/indexer/code/internal"
@ -302,3 +303,11 @@ func populateRepoIndexer(ctx context.Context) {
} }
log.Info("Done (re)populating the repo indexer with existing repositories") log.Info("Done (re)populating the repo indexer with existing repositories")
} }
func SupportedSearchModes() []indexer.SearchMode {
gi := globalIndexer.Load()
if gi == nil {
return nil
}
return (*gi).SupportedSearchModes()
}

@ -11,6 +11,7 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
indexer_module "code.gitea.io/gitea/modules/indexer"
"code.gitea.io/gitea/modules/indexer/code/bleve" "code.gitea.io/gitea/modules/indexer/code/bleve"
"code.gitea.io/gitea/modules/indexer/code/elasticsearch" "code.gitea.io/gitea/modules/indexer/code/elasticsearch"
"code.gitea.io/gitea/modules/indexer/code/internal" "code.gitea.io/gitea/modules/indexer/code/internal"
@ -39,10 +40,11 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
assert.NoError(t, setupRepositoryIndexes(t.Context(), indexer)) assert.NoError(t, setupRepositoryIndexes(t.Context(), indexer))
keywords := []struct { keywords := []struct {
RepoIDs []int64 RepoIDs []int64
Keyword string Keyword string
Langs int Langs int
Results []codeSearchResult SearchMode indexer_module.SearchModeType
Results []codeSearchResult
}{ }{
// Search for an exact match on the contents of a file // Search for an exact match on the contents of a file
// This scenario yields a single result (the file README.md on the repo '1') // This scenario yields a single result (the file README.md on the repo '1')
@ -183,9 +185,10 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
}, },
// Search for matches on the contents of files regardless of case. // Search for matches on the contents of files regardless of case.
{ {
RepoIDs: nil, RepoIDs: nil,
Keyword: "dESCRIPTION", Keyword: "dESCRIPTION",
Langs: 1, Langs: 1,
SearchMode: indexer_module.SearchModeFuzzy,
Results: []codeSearchResult{ Results: []codeSearchResult{
{ {
Filename: "README.md", Filename: "README.md",
@ -193,7 +196,7 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
}, },
}, },
}, },
// Search for an exact match on the filename within the repo '62' (case insenstive). // Search for an exact match on the filename within the repo '62' (case-insensitive).
// This scenario yields a single result (the file avocado.md on the repo '62') // This scenario yields a single result (the file avocado.md on the repo '62')
{ {
RepoIDs: []int64{62}, RepoIDs: []int64{62},
@ -206,7 +209,7 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
}, },
}, },
}, },
// Search for matches on the contents of files when the criteria is a expression. // Search for matches on the contents of files when the criteria are an expression.
{ {
RepoIDs: []int64{62}, RepoIDs: []int64{62},
Keyword: "console.log", Keyword: "console.log",
@ -218,7 +221,7 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
}, },
}, },
}, },
// Search for matches on the contents of files when the criteria is part of a expression. // Search for matches on the contents of files when the criteria are parts of an expression.
{ {
RepoIDs: []int64{62}, RepoIDs: []int64{62},
Keyword: "log", Keyword: "log",
@ -235,16 +238,16 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
for _, kw := range keywords { for _, kw := range keywords {
t.Run(kw.Keyword, func(t *testing.T) { t.Run(kw.Keyword, func(t *testing.T) {
total, res, langs, err := indexer.Search(t.Context(), &internal.SearchOptions{ total, res, langs, err := indexer.Search(t.Context(), &internal.SearchOptions{
RepoIDs: kw.RepoIDs, RepoIDs: kw.RepoIDs,
Keyword: kw.Keyword, Keyword: kw.Keyword,
SearchMode: kw.SearchMode,
Paginator: &db.ListOptions{ Paginator: &db.ListOptions{
Page: 1, Page: 1,
PageSize: 10, PageSize: 10,
}, },
IsKeywordFuzzy: true,
}) })
assert.NoError(t, err) require.NoError(t, err)
assert.Len(t, langs, kw.Langs) require.Len(t, langs, kw.Langs)
hits := make([]codeSearchResult, 0, len(res)) hits := make([]codeSearchResult, 0, len(res))
@ -289,7 +292,7 @@ func TestBleveIndexAndSearch(t *testing.T) {
_, err := idx.Init(t.Context()) _, err := idx.Init(t.Context())
require.NoError(t, err) require.NoError(t, err)
testIndexer("beleve", t, idx) testIndexer("bleve", t, idx)
} }
func TestESIndexAndSearch(t *testing.T) { func TestESIndexAndSearch(t *testing.T) {

@ -9,6 +9,7 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/indexer"
"code.gitea.io/gitea/modules/indexer/internal" "code.gitea.io/gitea/modules/indexer/internal"
) )
@ -18,6 +19,7 @@ type Indexer interface {
Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *RepoChanges) error Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *RepoChanges) error
Delete(ctx context.Context, repoID int64) error Delete(ctx context.Context, repoID int64) error
Search(ctx context.Context, opts *SearchOptions) (int64, []*SearchResult, []*SearchResultLanguages, error) Search(ctx context.Context, opts *SearchOptions) (int64, []*SearchResult, []*SearchResultLanguages, error)
SupportedSearchModes() []indexer.SearchMode
} }
type SearchOptions struct { type SearchOptions struct {
@ -25,7 +27,7 @@ type SearchOptions struct {
Keyword string Keyword string
Language string Language string
IsKeywordFuzzy bool SearchMode indexer.SearchModeType
db.Paginator db.Paginator
} }
@ -41,6 +43,10 @@ type dummyIndexer struct {
internal.Indexer internal.Indexer
} }
func (d *dummyIndexer) SupportedSearchModes() []indexer.SearchMode {
return nil
}
func (d *dummyIndexer) Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *RepoChanges) error { func (d *dummyIndexer) Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *RepoChanges) error {
return fmt.Errorf("indexer is not ready") return fmt.Errorf("indexer is not ready")
} }

@ -10,9 +10,7 @@ import (
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
) )
const ( const filenameMatchNumberOfLines = 7 // Copied from GitHub search
filenameMatchNumberOfLines = 7 // Copied from github search
)
func FilenameIndexerID(repoID int64, filename string) string { func FilenameIndexerID(repoID int64, filename string) string {
return internal.Base36(repoID) + "_" + filename return internal.Base36(repoID) + "_" + filename
@ -48,11 +46,3 @@ func FilenameMatchIndexPos(content string) (int, int) {
} }
return 0, len(content) return 0, len(content)
} }
func ParseKeywordAsPhrase(keyword string) (string, bool) {
if strings.HasPrefix(keyword, `"`) && strings.HasSuffix(keyword, `"`) && len(keyword) > 1 {
// only remove the prefix and suffix quotes, no need to decode the content at the moment
return keyword[1 : len(keyword)-1], true
}
return "", false
}

@ -1,30 +0,0 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package internal
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseKeywordAsPhrase(t *testing.T) {
cases := []struct {
keyword string
phrase string
isPhrase bool
}{
{``, "", false},
{`a`, "", false},
{`"`, "", false},
{`"a`, "", false},
{`"a"`, "a", true},
{`""\"""`, `"\""`, true},
}
for _, c := range cases {
phrase, isPhrase := ParseKeywordAsPhrase(c.keyword)
assert.Equal(t, c.phrase, phrase, "keyword=%q", c.keyword)
assert.Equal(t, c.isPhrase, isPhrase, "keyword=%q", c.keyword)
}
}

@ -129,7 +129,6 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res
} }
// PerformSearch perform a search on a repository // PerformSearch perform a search on a repository
// if isFuzzy is true set the Damerau-Levenshtein distance from 0 to 2
func PerformSearch(ctx context.Context, opts *SearchOptions) (int, []*Result, []*SearchResultLanguages, error) { func PerformSearch(ctx context.Context, opts *SearchOptions) (int, []*Result, []*SearchResultLanguages, error) {
if opts == nil || len(opts.Keyword) == 0 { if opts == nil || len(opts.Keyword) == 0 {
return 0, nil, nil, nil return 0, nil, nil, nil

@ -0,0 +1,54 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package indexer
type SearchModeType string
const (
SearchModeExact SearchModeType = "exact"
SearchModeWords SearchModeType = "words"
SearchModeFuzzy SearchModeType = "fuzzy"
SearchModeRegexp SearchModeType = "regexp"
)
type SearchMode struct {
ModeValue SearchModeType
TooltipTrKey string
TitleTrKey string
}
func SearchModesExactWords() []SearchMode {
return []SearchMode{
{
ModeValue: SearchModeExact,
TooltipTrKey: "search.exact_tooltip",
TitleTrKey: "search.exact",
},
{
ModeValue: SearchModeWords,
TooltipTrKey: "search.words_tooltip",
TitleTrKey: "search.words",
},
}
}
func SearchModesExactWordsFuzzy() []SearchMode {
return append(SearchModesExactWords(), []SearchMode{
{
ModeValue: SearchModeFuzzy,
TooltipTrKey: "search.fuzzy_tooltip",
TitleTrKey: "search.fuzzy",
},
}...)
}
func GitGrepSupportedSearchModes() []SearchMode {
return append(SearchModesExactWords(), []SearchMode{
{
ModeValue: SearchModeRegexp,
TooltipTrKey: "search.regexp_tooltip",
TitleTrKey: "search.regexp",
},
}...)
}

@ -28,6 +28,16 @@ func MatchPhraseQuery(matchPhrase, field, analyzer string, fuzziness int) *query
return q return q
} }
// MatchAndQuery generates a match query for the given phrase, field and analyzer
func MatchAndQuery(matchPhrase, field, analyzer string, fuzziness int) *query.MatchQuery {
q := bleve.NewMatchQuery(matchPhrase)
q.FieldVal = field
q.Analyzer = analyzer
q.Fuzziness = fuzziness
q.Operator = query.MatchQueryOperatorAnd
return q
}
// BoolFieldQuery generates a bool field query for the given value and field // BoolFieldQuery generates a bool field query for the given value and field
func BoolFieldQuery(value bool, field string) *query.BoolFieldQuery { func BoolFieldQuery(value bool, field string) *query.BoolFieldQuery {
q := bleve.NewBoolFieldQuery(value) q := bleve.NewBoolFieldQuery(value)

@ -6,6 +6,7 @@ package bleve
import ( import (
"context" "context"
"code.gitea.io/gitea/modules/indexer"
indexer_internal "code.gitea.io/gitea/modules/indexer/internal" indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
inner_bleve "code.gitea.io/gitea/modules/indexer/internal/bleve" inner_bleve "code.gitea.io/gitea/modules/indexer/internal/bleve"
"code.gitea.io/gitea/modules/indexer/issues/internal" "code.gitea.io/gitea/modules/indexer/issues/internal"
@ -120,6 +121,10 @@ type Indexer struct {
indexer_internal.Indexer // do not composite inner_bleve.Indexer directly to avoid exposing too much indexer_internal.Indexer // do not composite inner_bleve.Indexer directly to avoid exposing too much
} }
func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
return indexer.SearchModesExactWordsFuzzy()
}
// NewIndexer creates a new bleve local indexer // NewIndexer creates a new bleve local indexer
func NewIndexer(indexDir string) *Indexer { func NewIndexer(indexDir string) *Indexer {
inner := inner_bleve.NewIndexer(indexDir, issueIndexerLatestVersion, generateIssueIndexMapping) inner := inner_bleve.NewIndexer(indexDir, issueIndexerLatestVersion, generateIssueIndexMapping)
@ -157,16 +162,23 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
var queries []query.Query var queries []query.Query
if options.Keyword != "" { if options.Keyword != "" {
fuzziness := 0 if options.SearchMode == indexer.SearchModeWords || options.SearchMode == indexer.SearchModeFuzzy {
if options.IsFuzzyKeyword { fuzziness := 0
fuzziness = inner_bleve.GuessFuzzinessByKeyword(options.Keyword) if options.SearchMode == indexer.SearchModeFuzzy {
fuzziness = inner_bleve.GuessFuzzinessByKeyword(options.Keyword)
}
queries = append(queries, bleve.NewDisjunctionQuery([]query.Query{
inner_bleve.MatchAndQuery(options.Keyword, "title", issueIndexerAnalyzer, fuzziness),
inner_bleve.MatchAndQuery(options.Keyword, "content", issueIndexerAnalyzer, fuzziness),
inner_bleve.MatchAndQuery(options.Keyword, "comments", issueIndexerAnalyzer, fuzziness),
}...))
} else /* exact */ {
queries = append(queries, bleve.NewDisjunctionQuery([]query.Query{
inner_bleve.MatchPhraseQuery(options.Keyword, "title", issueIndexerAnalyzer, 0),
inner_bleve.MatchPhraseQuery(options.Keyword, "content", issueIndexerAnalyzer, 0),
inner_bleve.MatchPhraseQuery(options.Keyword, "comments", issueIndexerAnalyzer, 0),
}...))
} }
queries = append(queries, bleve.NewDisjunctionQuery([]query.Query{
inner_bleve.MatchPhraseQuery(options.Keyword, "title", issueIndexerAnalyzer, fuzziness),
inner_bleve.MatchPhraseQuery(options.Keyword, "content", issueIndexerAnalyzer, fuzziness),
inner_bleve.MatchPhraseQuery(options.Keyword, "comments", issueIndexerAnalyzer, fuzziness),
}...))
} }
if len(options.RepoIDs) > 0 || options.AllPublic { if len(options.RepoIDs) > 0 || options.AllPublic {

@ -5,9 +5,11 @@ package db
import ( import (
"context" "context"
"strings"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
issue_model "code.gitea.io/gitea/models/issues" issue_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/modules/indexer"
indexer_internal "code.gitea.io/gitea/modules/indexer/internal" indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
inner_db "code.gitea.io/gitea/modules/indexer/internal/db" inner_db "code.gitea.io/gitea/modules/indexer/internal/db"
"code.gitea.io/gitea/modules/indexer/issues/internal" "code.gitea.io/gitea/modules/indexer/issues/internal"
@ -22,6 +24,10 @@ type Indexer struct {
indexer_internal.Indexer indexer_internal.Indexer
} }
func (i *Indexer) SupportedSearchModes() []indexer.SearchMode {
return indexer.SearchModesExactWords()
}
func NewIndexer() *Indexer { func NewIndexer() *Indexer {
return &Indexer{ return &Indexer{
Indexer: &inner_db.Indexer{}, Indexer: &inner_db.Indexer{},
@ -38,6 +44,26 @@ func (i *Indexer) Delete(_ context.Context, _ ...int64) error {
return nil return nil
} }
func buildMatchQuery(mode indexer.SearchModeType, colName, keyword string) builder.Cond {
if mode == indexer.SearchModeExact {
return db.BuildCaseInsensitiveLike("issue.name", keyword)
}
// match words
cond := builder.NewCond()
fields := strings.Fields(keyword)
if len(fields) == 0 {
return builder.Expr("1=1")
}
for _, field := range fields {
if field == "" {
continue
}
cond = cond.And(db.BuildCaseInsensitiveLike(colName, field))
}
return cond
}
// Search searches for issues // Search searches for issues
func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) { func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (*internal.SearchResult, error) {
// FIXME: I tried to avoid importing models here, but it seems to be impossible. // FIXME: I tried to avoid importing models here, but it seems to be impossible.
@ -60,14 +86,14 @@ func (i *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
subQuery := builder.Select("id").From("issue").Where(repoCond) subQuery := builder.Select("id").From("issue").Where(repoCond)
cond = builder.Or( cond = builder.Or(
db.BuildCaseInsensitiveLike("issue.name", options.Keyword), buildMatchQuery(options.SearchMode, "issue.name", options.Keyword),
db.BuildCaseInsensitiveLike("issue.content", options.Keyword), buildMatchQuery(options.SearchMode, "issue.content", options.Keyword),
builder.In("issue.id", builder.Select("issue_id"). builder.In("issue.id", builder.Select("issue_id").
From("comment"). From("comment").
Where(builder.And( Where(builder.And(
builder.Eq{"type": issue_model.CommentTypeComment}, builder.Eq{"type": issue_model.CommentTypeComment},
builder.In("issue_id", subQuery), builder.In("issue_id", subQuery),
db.BuildCaseInsensitiveLike("content", options.Keyword), buildMatchQuery(options.SearchMode, "content", options.Keyword),
)), )),
), ),
) )

@ -10,6 +10,7 @@ import (
"strings" "strings"
"code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/indexer"
indexer_internal "code.gitea.io/gitea/modules/indexer/internal" indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
inner_elasticsearch "code.gitea.io/gitea/modules/indexer/internal/elasticsearch" inner_elasticsearch "code.gitea.io/gitea/modules/indexer/internal/elasticsearch"
"code.gitea.io/gitea/modules/indexer/issues/internal" "code.gitea.io/gitea/modules/indexer/issues/internal"
@ -33,6 +34,11 @@ type Indexer struct {
indexer_internal.Indexer // do not composite inner_elasticsearch.Indexer directly to avoid exposing too much indexer_internal.Indexer // do not composite inner_elasticsearch.Indexer directly to avoid exposing too much
} }
func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
// TODO: es supports fuzzy search, but our code doesn't at the moment, and actually the default fuzziness is already "AUTO"
return indexer.SearchModesExactWords()
}
// NewIndexer creates a new elasticsearch indexer // NewIndexer creates a new elasticsearch indexer
func NewIndexer(url, indexerName string) *Indexer { func NewIndexer(url, indexerName string) *Indexer {
inner := inner_elasticsearch.NewIndexer(url, indexerName, issueIndexerLatestVersion, defaultMapping) inner := inner_elasticsearch.NewIndexer(url, indexerName, issueIndexerLatestVersion, defaultMapping)
@ -146,12 +152,11 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
query := elastic.NewBoolQuery() query := elastic.NewBoolQuery()
if options.Keyword != "" { if options.Keyword != "" {
searchType := esMultiMatchTypePhrasePrefix if options.SearchMode == indexer.SearchModeExact {
if options.IsFuzzyKeyword { query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments").Type(esMultiMatchTypePhrasePrefix))
searchType = esMultiMatchTypeBestFields } else /* words */ {
query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments").Type(esMultiMatchTypeBestFields).Operator("and"))
} }
query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments").Type(searchType))
} }
if len(options.RepoIDs) > 0 { if len(options.RepoIDs) > 0 {

@ -14,6 +14,7 @@ import (
db_model "code.gitea.io/gitea/models/db" db_model "code.gitea.io/gitea/models/db"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/graceful"
"code.gitea.io/gitea/modules/indexer"
"code.gitea.io/gitea/modules/indexer/issues/bleve" "code.gitea.io/gitea/modules/indexer/issues/bleve"
"code.gitea.io/gitea/modules/indexer/issues/db" "code.gitea.io/gitea/modules/indexer/issues/db"
"code.gitea.io/gitea/modules/indexer/issues/elasticsearch" "code.gitea.io/gitea/modules/indexer/issues/elasticsearch"
@ -313,3 +314,11 @@ func CountIssues(ctx context.Context, opts *SearchOptions) (int64, error) {
_, total, err := SearchIssues(ctx, opts) _, total, err := SearchIssues(ctx, opts)
return total, err return total, err
} }
func SupportedSearchModes() []indexer.SearchMode {
gi := globalIndexer.Load()
if gi == nil {
return nil
}
return (*gi).SupportedSearchModes()
}

@ -7,6 +7,7 @@ import (
"context" "context"
"fmt" "fmt"
"code.gitea.io/gitea/modules/indexer"
"code.gitea.io/gitea/modules/indexer/internal" "code.gitea.io/gitea/modules/indexer/internal"
) )
@ -16,6 +17,7 @@ type Indexer interface {
Index(ctx context.Context, issue ...*IndexerData) error Index(ctx context.Context, issue ...*IndexerData) error
Delete(ctx context.Context, ids ...int64) error Delete(ctx context.Context, ids ...int64) error
Search(ctx context.Context, options *SearchOptions) (*SearchResult, error) Search(ctx context.Context, options *SearchOptions) (*SearchResult, error)
SupportedSearchModes() []indexer.SearchMode
} }
// NewDummyIndexer returns a dummy indexer // NewDummyIndexer returns a dummy indexer
@ -29,6 +31,10 @@ type dummyIndexer struct {
internal.Indexer internal.Indexer
} }
func (d *dummyIndexer) SupportedSearchModes() []indexer.SearchMode {
return nil
}
func (d *dummyIndexer) Index(_ context.Context, _ ...*IndexerData) error { func (d *dummyIndexer) Index(_ context.Context, _ ...*IndexerData) error {
return fmt.Errorf("indexer is not ready") return fmt.Errorf("indexer is not ready")
} }

@ -7,6 +7,7 @@ import (
"strconv" "strconv"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/indexer"
"code.gitea.io/gitea/modules/optional" "code.gitea.io/gitea/modules/optional"
"code.gitea.io/gitea/modules/timeutil" "code.gitea.io/gitea/modules/timeutil"
) )
@ -77,7 +78,7 @@ type SearchResult struct {
type SearchOptions struct { type SearchOptions struct {
Keyword string // keyword to search Keyword string // keyword to search
IsFuzzyKeyword bool // if false the levenshtein distance is 0 SearchMode indexer.SearchModeType
RepoIDs []int64 // repository IDs which the issues belong to RepoIDs []int64 // repository IDs which the issues belong to
AllPublic bool // if include all public repositories AllPublic bool // if include all public repositories

@ -10,6 +10,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"code.gitea.io/gitea/modules/indexer"
indexer_internal "code.gitea.io/gitea/modules/indexer/internal" indexer_internal "code.gitea.io/gitea/modules/indexer/internal"
inner_meilisearch "code.gitea.io/gitea/modules/indexer/internal/meilisearch" inner_meilisearch "code.gitea.io/gitea/modules/indexer/internal/meilisearch"
"code.gitea.io/gitea/modules/indexer/issues/internal" "code.gitea.io/gitea/modules/indexer/issues/internal"
@ -35,6 +36,10 @@ type Indexer struct {
indexer_internal.Indexer // do not composite inner_meilisearch.Indexer directly to avoid exposing too much indexer_internal.Indexer // do not composite inner_meilisearch.Indexer directly to avoid exposing too much
} }
func (b *Indexer) SupportedSearchModes() []indexer.SearchMode {
return indexer.SearchModesExactWords()
}
// NewIndexer creates a new meilisearch indexer // NewIndexer creates a new meilisearch indexer
func NewIndexer(url, apiKey, indexerName string) *Indexer { func NewIndexer(url, apiKey, indexerName string) *Indexer {
settings := &meilisearch.Settings{ settings := &meilisearch.Settings{
@ -230,9 +235,8 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
limit = 1 limit = 1
} }
keyword := options.Keyword keyword := options.Keyword // default to match "words"
if !options.IsFuzzyKeyword { if options.SearchMode == indexer.SearchModeExact {
// to make it non fuzzy ("typo tolerance" in meilisearch terms), we have to quote the keyword(s)
// https://www.meilisearch.com/docs/reference/api/search#phrase-search // https://www.meilisearch.com/docs/reference/api/search#phrase-search
keyword = doubleQuoteKeyword(keyword) keyword = doubleQuoteKeyword(keyword)
} }

@ -169,6 +169,10 @@ search = Search...
type_tooltip = Search type type_tooltip = Search type
fuzzy = Fuzzy fuzzy = Fuzzy
fuzzy_tooltip = Include results that also match the search term closely fuzzy_tooltip = Include results that also match the search term closely
words = Words
words_tooltip = Include only results that match the search term words
regexp = Regexp
regexp_tooltip = Include only results that match the regexp search term
exact = Exact exact = Exact
exact_tooltip = Include only results that match the exact search term exact_tooltip = Include only results that match the exact search term
repo_kind = Search repos... repo_kind = Search repos...

@ -4,36 +4,30 @@
package common package common
import ( import (
"code.gitea.io/gitea/modules/indexer"
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"
) )
func PrepareCodeSearch(ctx *context.Context) (ret struct { func PrepareCodeSearch(ctx *context.Context) (ret struct {
Keyword string Keyword string
Language string Language string
IsFuzzy bool SearchMode indexer.SearchModeType
}, },
) { ) {
ret.Language = ctx.FormTrim("l") ret.Language = ctx.FormTrim("l")
ret.Keyword = ctx.FormTrim("q") ret.Keyword = ctx.FormTrim("q")
ret.SearchMode = indexer.SearchModeType(ctx.FormTrim("search_mode"))
fuzzyDefault := setting.Indexer.RepoIndexerEnabled
fuzzyAllow := true
if setting.Indexer.RepoType == "bleve" && setting.Indexer.TypeBleveMaxFuzzniess == 0 {
fuzzyDefault = false
fuzzyAllow = false
}
isFuzzy := ctx.FormOptionalBool("fuzzy").ValueOrDefault(fuzzyDefault)
if isFuzzy && !fuzzyAllow {
ctx.Flash.Info("Fuzzy search is disabled by default due to performance reasons", true)
isFuzzy = false
}
ctx.Data["IsBleveFuzzyDisabled"] = true
ctx.Data["Keyword"] = ret.Keyword ctx.Data["Keyword"] = ret.Keyword
ctx.Data["Language"] = ret.Language ctx.Data["Language"] = ret.Language
ctx.Data["IsFuzzy"] = isFuzzy ctx.Data["SelectedSearchMode"] = string(ret.SearchMode)
if setting.Indexer.RepoIndexerEnabled {
ctx.Data["SearchModes"] = code_indexer.SupportedSearchModes()
} else {
ctx.Data["SearchModes"] = indexer.GitGrepSupportedSearchModes()
}
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
return ret return ret
} }

@ -72,10 +72,10 @@ func Code(ctx *context.Context) {
if (len(repoIDs) > 0) || isAdmin { if (len(repoIDs) > 0) || isAdmin {
total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{
RepoIDs: repoIDs, RepoIDs: repoIDs,
Keyword: prepareSearch.Keyword, Keyword: prepareSearch.Keyword,
IsKeywordFuzzy: prepareSearch.IsFuzzy, SearchMode: prepareSearch.SearchMode,
Language: prepareSearch.Language, Language: prepareSearch.Language,
Paginator: &db.ListOptions{ Paginator: &db.ListOptions{
Page: page, Page: page,
PageSize: setting.UI.RepoSearchPagingNum, PageSize: setting.UI.RepoSearchPagingNum,

@ -38,10 +38,10 @@ func Search(ctx *context.Context) {
if setting.Indexer.RepoIndexerEnabled { if setting.Indexer.RepoIndexerEnabled {
var err error 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: prepareSearch.Keyword, Keyword: prepareSearch.Keyword,
IsKeywordFuzzy: prepareSearch.IsFuzzy, SearchMode: prepareSearch.SearchMode,
Language: prepareSearch.Language, Language: prepareSearch.Language,
Paginator: &db.ListOptions{ Paginator: &db.ListOptions{
Page: page, Page: page,
PageSize: setting.UI.RepoSearchPagingNum, PageSize: setting.UI.RepoSearchPagingNum,
@ -60,7 +60,7 @@ func Search(ctx *context.Context) {
var err error var err error
// ref should be default branch or the first existing branch // ref should be default branch or the first existing branch
searchRef := git.RefNameFromBranch(ctx.Repo.Repository.DefaultBranch) searchRef := git.RefNameFromBranch(ctx.Repo.Repository.DefaultBranch)
searchResults, total, err = gitgrep.PerformSearch(ctx, page, ctx.Repo.Repository.ID, ctx.Repo.GitRepo, searchRef, prepareSearch.Keyword, prepareSearch.IsFuzzy) searchResults, total, err = gitgrep.PerformSearch(ctx, page, ctx.Repo.Repository.ID, ctx.Repo.GitRepo, searchRef, prepareSearch.Keyword, prepareSearch.SearchMode)
if err != nil { if err != nil {
ctx.ServerError("gitgrep.PerformSearch", err) ctx.ServerError("gitgrep.PerformSearch", err)
return return

@ -68,10 +68,10 @@ func CodeSearch(ctx *context.Context) {
if len(repoIDs) > 0 { if len(repoIDs) > 0 {
total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{ total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, &code_indexer.SearchOptions{
RepoIDs: repoIDs, RepoIDs: repoIDs,
Keyword: prepareSearch.Keyword, Keyword: prepareSearch.Keyword,
IsKeywordFuzzy: prepareSearch.IsFuzzy, SearchMode: prepareSearch.SearchMode,
Language: prepareSearch.Language, Language: prepareSearch.Language,
Paginator: &db.ListOptions{ Paginator: &db.ListOptions{
Page: page, Page: page,
PageSize: setting.UI.RepoSearchPagingNum, PageSize: setting.UI.RepoSearchPagingNum,

@ -26,6 +26,7 @@ import (
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/base" "code.gitea.io/gitea/modules/base"
"code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/container"
"code.gitea.io/gitea/modules/indexer"
issue_indexer "code.gitea.io/gitea/modules/indexer/issues" issue_indexer "code.gitea.io/gitea/modules/indexer/issues"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
@ -447,7 +448,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
ctx.Data["FilterAssigneeUsername"] = assigneeUsername ctx.Data["FilterAssigneeUsername"] = assigneeUsername
opts.AssigneeID = user.GetFilterUserIDByName(ctx, assigneeUsername) opts.AssigneeID = user.GetFilterUserIDByName(ctx, assigneeUsername)
isFuzzy := ctx.FormBool("fuzzy") searchMode := ctx.FormString("search_mode")
// Search all repositories which // Search all repositories which
// //
@ -549,7 +550,9 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
var issues issues_model.IssueList var issues issues_model.IssueList
{ {
issueIDs, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts).Copy( issueIDs, _, err := issue_indexer.SearchIssues(ctx, issue_indexer.ToSearchOptions(keyword, opts).Copy(
func(o *issue_indexer.SearchOptions) { o.IsFuzzyKeyword = isFuzzy }, func(o *issue_indexer.SearchOptions) {
o.SearchMode = indexer.SearchModeType(searchMode)
},
)) ))
if err != nil { if err != nil {
ctx.ServerError("issueIDsFromSearch", err) ctx.ServerError("issueIDsFromSearch", err)
@ -578,7 +581,7 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
// ------------------------------- // -------------------------------
issueStats, err := getUserIssueStats(ctx, ctxUser, filterMode, issue_indexer.ToSearchOptions(keyword, opts).Copy( issueStats, err := getUserIssueStats(ctx, ctxUser, filterMode, issue_indexer.ToSearchOptions(keyword, opts).Copy(
func(o *issue_indexer.SearchOptions) { func(o *issue_indexer.SearchOptions) {
o.IsFuzzyKeyword = isFuzzy o.SearchMode = indexer.SearchModeType(searchMode)
}, },
)) ))
if err != nil { if err != nil {
@ -633,7 +636,8 @@ func buildIssueOverview(ctx *context.Context, unitType unit.Type) {
ctx.Data["ViewType"] = viewType ctx.Data["ViewType"] = viewType
ctx.Data["SortType"] = sortType ctx.Data["SortType"] = sortType
ctx.Data["IsShowClosed"] = isShowClosed ctx.Data["IsShowClosed"] = isShowClosed
ctx.Data["IsFuzzy"] = isFuzzy ctx.Data["SearchModes"] = issue_indexer.SupportedSearchModes()
ctx.Data["SelectedSearchMode"] = ctx.FormTrim("search_mode")
if isShowClosed { if isShowClosed {
ctx.Data["State"] = "closed" ctx.Data["State"] = "closed"

@ -1,7 +1,7 @@
<div class="flex-text-block tw-flex-wrap"> <div class="flex-text-block tw-flex-wrap">
{{range $term := .SearchResultLanguages}} {{range $term := .SearchResultLanguages}}
<a class="ui {{if eq $.Language $term.Language}}primary{{end}} basic label tw-m-0" <a class="ui {{if eq $.Language $term.Language}}primary{{end}} basic label tw-m-0"
href="?q={{$.Keyword}}{{if ne $.Language $term.Language}}&l={{$term.Language}}{{end}}&fuzzy={{$.IsFuzzy}}"> href="?q={{$.Keyword}}{{if ne $.Language $term.Language}}&l={{$term.Language}}{{end}}&search_mode={{$.SelectedSearchMode}}">
<i class="color-icon tw-mr-2" style="background-color: {{$term.Color}}"></i> <i class="color-icon tw-mr-2" style="background-color: {{$term.Color}}"></i>
{{$term.Language}} {{$term.Language}}
<div class="detail">{{$term.Count}}</div> <div class="detail">{{$term.Count}}</div>

@ -1,5 +1,11 @@
<form class="ui form ignore-dirty"> <form class="ui form ignore-dirty">
{{template "shared/search/combo_fuzzy" dict "Value" .Keyword "Disabled" .CodeIndexerUnavailable "IsFuzzy" .IsFuzzy "Placeholder" (ctx.Locale.Tr "search.code_kind")}} {{template "shared/search/combo" (dict
"Disabled" .CodeIndexerUnavailable
"Value" .Keyword
"Placeholder" (ctx.Locale.Tr "search.code_kind")
"SearchModes" .SearchModes
"SelectedSearchMode" .SelectedSearchMode
)}}
</form> </form>
<div class="divider"></div> <div class="divider"></div>
<div class="ui list"> <div class="ui list">

@ -1,8 +1,30 @@
{{/* Value - value of the search field (for search results page) */}} {{/* Attributes:
{{/* Disabled (optional) - if search field/button has to be disabled */}} * Value - value of the search field (for search results page)
{{/* Placeholder (optional) - placeholder text to be used */}} * Disabled (optional) - if search field/button has to be disabled
{{/* Tooltip (optional) - a tooltip to be displayed on button hover */}} * Placeholder (optional) - placeholder text to be used
* Tooltip (optional) - a tooltip to be displayed on button hover
* SearchModes - a list of search modes to be displayed in the dropdown
* SelectedSearchMode - the currently selected search mode
*/}}
<div class="ui small fluid action input"> <div class="ui small fluid action input">
{{template "shared/search/input" dict "Value" .Value "Disabled" .Disabled "Placeholder" .Placeholder}} {{template "shared/search/input" dict "Value" .Value "Disabled" .Disabled "Placeholder" .Placeholder}}
{{if .SearchModes}}
<div class="ui small dropdown selection {{if .Disabled}}disabled{{end}}" data-tooltip-content="{{ctx.Locale.Tr "search.type_tooltip"}}">
<div class="text"></div> {{svg "octicon-triangle-down" 14 "dropdown icon"}}
<input name="search_mode" type="hidden" value="
{{- if .SelectedSearchMode -}}
{{- .SelectedSearchMode -}}
{{- else -}}
{{- $defaultSearchMode := index .SearchModes 0 -}}
{{- $defaultSearchMode.ModeValue -}}
{{- end -}}
">
<div class="menu">
{{range $mode := .SearchModes}}
<div class="item" data-value="{{$mode.ModeValue}}" data-tooltip-content="{{ctx.Locale.Tr $mode.TooltipTrKey}}">{{ctx.Locale.Tr $mode.TitleTrKey}}</div>
{{end}}
</div>
</div>
{{end}}
{{template "shared/search/button" dict "Disabled" .Disabled "Tooltip" .Tooltip}} {{template "shared/search/button" dict "Disabled" .Disabled "Tooltip" .Tooltip}}
</div> </div>

@ -1,10 +0,0 @@
{{/* Value - value of the search field (for search results page) */}}
{{/* Disabled (optional) - if search field/button has to be disabled */}}
{{/* Placeholder (optional) - placeholder text to be used */}}
{{/* IsFuzzy - state of the fuzzy search toggle */}}
{{/* Tooltip (optional) - a tooltip to be displayed on button hover */}}
<div class="ui small fluid action input">
{{template "shared/search/input" dict "Value" .Value "Disabled" .Disabled "Placeholder" .Placeholder}}
{{template "shared/search/fuzzy" dict "Disabled" .Disabled "IsFuzzy" .IsFuzzy}}
{{template "shared/search/button" dict "Disabled" .Disabled "Tooltip" .Tooltip}}
</div>

@ -1,10 +0,0 @@
{{/* Disabled (optional) - if dropdown has to be disabled */}}
{{/* IsFuzzy - state of the fuzzy search toggle */}}
<div class="ui small dropdown selection {{if .Disabled}} disabled{{end}}" data-tooltip-content="{{ctx.Locale.Tr "search.type_tooltip"}}">
<input name="fuzzy" type="hidden"{{if .Disabled}} disabled{{end}} value="{{.IsFuzzy}}">{{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="text">{{if .IsFuzzy}}{{ctx.Locale.Tr "search.fuzzy"}}{{else}}{{ctx.Locale.Tr "search.exact"}}{{end}}</div>
<div class="menu">
<div class="item" data-value="true" data-tooltip-content="{{ctx.Locale.Tr "search.fuzzy_tooltip"}}">{{ctx.Locale.Tr "search.fuzzy"}}</div>
<div class="item" data-value="false" data-tooltip-content="{{ctx.Locale.Tr "search.exact_tooltip"}}">{{ctx.Locale.Tr "search.exact"}}</div>
</div>
</div>

@ -4,7 +4,7 @@
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}} {{template "base/alert" .}}
<div class="flex-container"> <div class="flex-container">
{{$queryLink := QueryBuild "?" "type" $.ViewType "sort" $.SortType "state" $.State "q" $.Keyword "labels" .SelectLabels "fuzzy" $.IsFuzzy}} {{$queryLink := QueryBuild "?" "type" $.ViewType "sort" $.SortType "state" $.State "q" $.Keyword "labels" .SelectLabels "search_mode" $.SelectedSearchMode}}
<div class="flex-container-nav"> <div class="flex-container-nav">
<div class="ui secondary vertical filter menu tw-bg-transparent"> <div class="ui secondary vertical filter menu tw-bg-transparent">
<a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="{{QueryBuild $queryLink "type" "your_repositories"}}"> <a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="{{QueryBuild $queryLink "type" "your_repositories"}}">
@ -53,7 +53,13 @@
<input type="hidden" name="type" value="{{$.ViewType}}"> <input type="hidden" name="type" value="{{$.ViewType}}">
<input type="hidden" name="sort" value="{{$.SortType}}"> <input type="hidden" name="sort" value="{{$.SortType}}">
<input type="hidden" name="state" value="{{$.State}}"> <input type="hidden" name="state" value="{{$.State}}">
{{template "shared/search/combo_fuzzy" dict "Value" $.Keyword "IsFuzzy" $.IsFuzzy "Placeholder" (ctx.Locale.Tr (Iif .PageIsPulls "search.pull_kind" "search.issue_kind")) "Tooltip" (ctx.Locale.Tr "explore.go_to")}} {{template "shared/search/combo" (dict
"Value" $.Keyword
"Placeholder" (ctx.Locale.Tr (Iif .PageIsPulls "search.pull_kind" "search.issue_kind"))
"Tooltip" (ctx.Locale.Tr "explore.go_to")
"SearchModes" .SearchModes
"SelectedSearchMode" .SelectedSearchMode
)}}
</form> </form>
<div class="list-header-filters"> <div class="list-header-filters">

Loading…
Cancel
Save