Merge branch 'main' into enhancement/show-blocking-in-issue-list

This commit is contained in:
silverwind 2024-03-09 18:55:35 +01:00 committed by GitHub
commit 33fb710bbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 347 additions and 123 deletions

View File

@ -8,7 +8,9 @@
},
"ghcr.io/devcontainers/features/git-lfs:1.1.0": {},
"ghcr.io/devcontainers-contrib/features/poetry:2": {},
"ghcr.io/devcontainers/features/python:1": {}
"ghcr.io/devcontainers/features/python:1": {
"version": "3.12"
}
},
"customizations": {
"vscode": {

View File

@ -832,7 +832,7 @@ Default templates for project boards:
## Issue and pull request attachments (`attachment`)
- `ENABLED`: **true**: Whether issue and pull request attachments are enabled.
- `ALLOWED_TYPES`: **.csv,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip**: Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types.
- `ALLOWED_TYPES`: **.cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip**: Comma-separated list of allowed file extensions (`.zip`), mime types (`text/plain`) or wildcard type (`image/*`, `audio/*`, `video/*`). Empty value or `*/*` allows all types.
- `MAX_SIZE`: **2048**: Maximum size (MB).
- `MAX_FILES`: **5**: Maximum number of attachments that can be uploaded at once.
- `STORAGE_TYPE`: **local**: Storage type for attachments, `local` for local disk or `minio` for s3 compatible object storage service, default is `local` or other name defined with `[storage.xxx]`

View File

@ -782,7 +782,7 @@ Gitea 创建以下非唯一队列:
## 工单和合并请求的附件 (`attachment`)
- `ENABLED`: **true**: 是否允许用户上传附件。
- `ALLOWED_TYPES`: **.csv,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip**: 允许的文件扩展名(`.zip`、mime 类型(`text/plain`)或通配符类型(`image/*`、`audio/*`、`video/*`)的逗号分隔列表。空值或 `*/*` 允许所有类型。
- `ALLOWED_TYPES`: **.cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip**: 允许的文件扩展名(`.zip`、mime 类型(`text/plain`)或通配符类型(`image/*`、`audio/*`、`video/*`)的逗号分隔列表。空值或 `*/*` 允许所有类型。
- `MAX_SIZE`: **2048**: 附件的最大限制MB
- `MAX_FILES`: **5**: 一次最多上传的附件数量。
- `STORAGE_TYPE`: **local**: 附件的存储类型,`local` 表示本地磁盘,`minio` 表示兼容 S3 的对象存储服务,如果未设置将使用默认值 `local` 或其他在 `[storage.xxx]` 中定义的名称。

View File

@ -233,21 +233,21 @@ func (b *Indexer) Delete(_ context.Context, repoID int64) error {
// Search searches for files in the specified repo.
// Returns the matching file-paths
func (b *Indexer) Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isMatch bool) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
func (b *Indexer) Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isFuzzy bool) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
var (
indexerQuery query.Query
keywordQuery query.Query
)
if isMatch {
prefixQuery := bleve.NewPrefixQuery(keyword)
prefixQuery.FieldVal = "Content"
keywordQuery = prefixQuery
} else {
if isFuzzy {
phraseQuery := bleve.NewMatchPhraseQuery(keyword)
phraseQuery.FieldVal = "Content"
phraseQuery.Analyzer = repoIndexerAnalyzer
keywordQuery = phraseQuery
} else {
prefixQuery := bleve.NewPrefixQuery(keyword)
prefixQuery.FieldVal = "Content"
keywordQuery = prefixQuery
}
if len(repoIDs) > 0 {

View File

@ -281,10 +281,10 @@ func extractAggs(searchResult *elastic.SearchResult) []*internal.SearchResultLan
}
// Search searches for codes and language stats by given conditions.
func (b *Indexer) Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isMatch bool) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
searchType := esMultiMatchTypeBestFields
if isMatch {
searchType = esMultiMatchTypePhrasePrefix
func (b *Indexer) Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isFuzzy bool) (int64, []*internal.SearchResult, []*internal.SearchResultLanguages, error) {
searchType := esMultiMatchTypePhrasePrefix
if isFuzzy {
searchType = esMultiMatchTypeBestFields
}
kwQuery := elastic.NewMultiMatchQuery(keyword, "content").Type(searchType)

View File

@ -70,7 +70,7 @@ func testIndexer(name string, t *testing.T, indexer internal.Indexer) {
for _, kw := range keywords {
t.Run(kw.Keyword, func(t *testing.T) {
total, res, langs, err := indexer.Search(context.TODO(), kw.RepoIDs, "", kw.Keyword, 1, 10, false)
total, res, langs, err := indexer.Search(context.TODO(), kw.RepoIDs, "", kw.Keyword, 1, 10, true)
assert.NoError(t, err)
assert.Len(t, kw.IDs, int(total))
assert.Len(t, langs, kw.Langs)

View File

@ -16,7 +16,7 @@ type Indexer interface {
internal.Indexer
Index(ctx context.Context, repo *repo_model.Repository, sha string, changes *RepoChanges) error
Delete(ctx context.Context, repoID int64) error
Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isMatch bool) (int64, []*SearchResult, []*SearchResultLanguages, error)
Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isFuzzy bool) (int64, []*SearchResult, []*SearchResultLanguages, error)
}
// NewDummyIndexer returns a dummy indexer
@ -38,6 +38,6 @@ func (d *dummyIndexer) Delete(ctx context.Context, repoID int64) error {
return fmt.Errorf("indexer is not ready")
}
func (d *dummyIndexer) Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isMatch bool) (int64, []*SearchResult, []*SearchResultLanguages, error) {
func (d *dummyIndexer) Search(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isFuzzy bool) (int64, []*SearchResult, []*SearchResultLanguages, error) {
return 0, nil, nil, fmt.Errorf("indexer is not ready")
}

View File

@ -124,12 +124,13 @@ func searchResult(result *internal.SearchResult, startIndex, endIndex int) (*Res
}
// PerformSearch perform a search on a repository
func PerformSearch(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isMatch bool) (int, []*Result, []*internal.SearchResultLanguages, error) {
// if isFuzzy is true set the Damerau-Levenshtein distance from 0 to 2
func PerformSearch(ctx context.Context, repoIDs []int64, language, keyword string, page, pageSize int, isFuzzy bool) (int, []*Result, []*internal.SearchResultLanguages, error) {
if len(keyword) == 0 {
return 0, nil, nil, nil
}
total, results, resultLanguages, err := (*globalIndexer.Load()).Search(ctx, repoIDs, language, keyword, page, pageSize, isMatch)
total, results, resultLanguages, err := (*globalIndexer.Load()).Search(ctx, repoIDs, language, keyword, page, pageSize, isFuzzy)
if err != nil {
return 0, nil, nil, err
}

View File

@ -25,6 +25,13 @@ func MatchPhraseQuery(matchPhrase, field, analyzer string) *query.MatchPhraseQue
return q
}
// PrefixQuery generates a match prefix query for the given prefix and field
func PrefixQuery(matchPrefix, field string) *query.PrefixQuery {
q := bleve.NewPrefixQuery(matchPrefix)
q.FieldVal = field
return q
}
// BoolFieldQuery generates a bool field query for the given value and field
func BoolFieldQuery(value bool, field string) *query.BoolFieldQuery {
q := bleve.NewBoolFieldQuery(value)

View File

@ -156,12 +156,19 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
var queries []query.Query
if options.Keyword != "" {
keywordQueries := []query.Query{
inner_bleve.MatchPhraseQuery(options.Keyword, "title", issueIndexerAnalyzer),
inner_bleve.MatchPhraseQuery(options.Keyword, "content", issueIndexerAnalyzer),
inner_bleve.MatchPhraseQuery(options.Keyword, "comments", issueIndexerAnalyzer),
if options.IsFuzzyKeyword {
queries = append(queries, bleve.NewDisjunctionQuery([]query.Query{
inner_bleve.MatchPhraseQuery(options.Keyword, "title", issueIndexerAnalyzer),
inner_bleve.MatchPhraseQuery(options.Keyword, "content", issueIndexerAnalyzer),
inner_bleve.MatchPhraseQuery(options.Keyword, "comments", issueIndexerAnalyzer),
}...))
} else {
queries = append(queries, bleve.NewDisjunctionQuery([]query.Query{
inner_bleve.PrefixQuery(options.Keyword, "title"),
inner_bleve.PrefixQuery(options.Keyword, "content"),
inner_bleve.PrefixQuery(options.Keyword, "comments"),
}...))
}
queries = append(queries, bleve.NewDisjunctionQuery(keywordQueries...))
}
if len(options.RepoIDs) > 0 || options.AllPublic {

View File

@ -19,6 +19,10 @@ import (
const (
issueIndexerLatestVersion = 1
// multi-match-types, currently only 2 types are used
// Reference: https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-dsl-multi-match-query.html#multi-match-types
esMultiMatchTypeBestFields = "best_fields"
esMultiMatchTypePhrasePrefix = "phrase_prefix"
)
var _ internal.Indexer = &Indexer{}
@ -141,7 +145,13 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
query := elastic.NewBoolQuery()
if options.Keyword != "" {
query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments"))
searchType := esMultiMatchTypePhrasePrefix
if options.IsFuzzyKeyword {
searchType = esMultiMatchTypeBestFields
}
query.Must(elastic.NewMultiMatchQuery(options.Keyword, "title", "content", "comments").Type(searchType))
}
if len(options.RepoIDs) > 0 {

View File

@ -74,6 +74,8 @@ type SearchResult struct {
type SearchOptions struct {
Keyword string // keyword to search
IsFuzzyKeyword bool // if false the levenshtein distance is 0
RepoIDs []int64 // repository IDs which the issues belong to
AllPublic bool // if include all public repositories

View File

@ -5,6 +5,7 @@ package meilisearch
import (
"context"
"errors"
"strconv"
"strings"
@ -16,12 +17,15 @@ import (
)
const (
issueIndexerLatestVersion = 2
issueIndexerLatestVersion = 3
// TODO: make this configurable if necessary
maxTotalHits = 10000
)
// ErrMalformedResponse is never expected as we initialize the indexer ourself and so define the types.
var ErrMalformedResponse = errors.New("meilisearch returned unexpected malformed content")
var _ internal.Indexer = &Indexer{}
// Indexer implements Indexer interface
@ -47,6 +51,9 @@ func NewIndexer(url, apiKey, indexerName string) *Indexer {
},
DisplayedAttributes: []string{
"id",
"title",
"content",
"comments",
},
FilterableAttributes: []string{
"repo_id",
@ -221,11 +228,9 @@ func (b *Indexer) Search(ctx context.Context, options *internal.SearchOptions) (
return nil, err
}
hits := make([]internal.Match, 0, len(searchRes.Hits))
for _, hit := range searchRes.Hits {
hits = append(hits, internal.Match{
ID: int64(hit.(map[string]any)["id"].(float64)),
})
hits, err := nonFuzzyWorkaround(searchRes, options.Keyword, options.IsFuzzyKeyword)
if err != nil {
return nil, err
}
return &internal.SearchResult{
@ -241,3 +246,77 @@ func parseSortBy(sortBy internal.SortBy) string {
}
return field + ":asc"
}
// nonFuzzyWorkaround is needed as meilisearch does not have an exact search
// and you can only change "typo tolerance" per index. So we have to post-filter the results
// https://www.meilisearch.com/docs/learn/configuration/typo_tolerance#configuring-typo-tolerance
// TODO: remove once https://github.com/orgs/meilisearch/discussions/377 is addressed
func nonFuzzyWorkaround(searchRes *meilisearch.SearchResponse, keyword string, isFuzzy bool) ([]internal.Match, error) {
hits := make([]internal.Match, 0, len(searchRes.Hits))
for _, hit := range searchRes.Hits {
hit, ok := hit.(map[string]any)
if !ok {
return nil, ErrMalformedResponse
}
if !isFuzzy {
keyword = strings.ToLower(keyword)
// declare a anon func to check if the title, content or at least one comment contains the keyword
found, err := func() (bool, error) {
// check if title match first
title, ok := hit["title"].(string)
if !ok {
return false, ErrMalformedResponse
} else if strings.Contains(strings.ToLower(title), keyword) {
return true, nil
}
// check if content has a match
content, ok := hit["content"].(string)
if !ok {
return false, ErrMalformedResponse
} else if strings.Contains(strings.ToLower(content), keyword) {
return true, nil
}
// now check for each comment if one has a match
// so we first try to cast and skip if there are no comments
comments, ok := hit["comments"].([]any)
if !ok {
return false, ErrMalformedResponse
} else if len(comments) == 0 {
return false, nil
}
// now we iterate over all and report as soon as we detect one match
for i := range comments {
comment, ok := comments[i].(string)
if !ok {
return false, ErrMalformedResponse
}
if strings.Contains(strings.ToLower(comment), keyword) {
return true, nil
}
}
// we got no match
return false, nil
}()
if err != nil {
return nil, err
} else if !found {
continue
}
}
issueID, ok := hit["id"].(float64)
if !ok {
return nil, ErrMalformedResponse
}
hits = append(hits, internal.Match{
ID: int64(issueID),
})
}
return hits, nil
}

View File

@ -10,7 +10,11 @@ import (
"testing"
"time"
"code.gitea.io/gitea/modules/indexer/issues/internal"
"code.gitea.io/gitea/modules/indexer/issues/internal/tests"
"github.com/meilisearch/meilisearch-go"
"github.com/stretchr/testify/assert"
)
func TestMeilisearchIndexer(t *testing.T) {
@ -48,3 +52,44 @@ func TestMeilisearchIndexer(t *testing.T) {
tests.TestIndexer(t, indexer)
}
func TestNonFuzzyWorkaround(t *testing.T) {
// get unexpected return
_, err := nonFuzzyWorkaround(&meilisearch.SearchResponse{
Hits: []any{"aa", "bb", "cc", "dd"},
}, "bowling", false)
assert.ErrorIs(t, err, ErrMalformedResponse)
validResponse := &meilisearch.SearchResponse{
Hits: []any{
map[string]any{
"id": float64(11),
"title": "a title",
"content": "issue body with no match",
"comments": []any{"hey whats up?", "I'm currently bowling", "nice"},
},
map[string]any{
"id": float64(22),
"title": "Bowling as title",
"content": "",
"comments": []any{},
},
map[string]any{
"id": float64(33),
"title": "Bowl-ing as fuzzy match",
"content": "",
"comments": []any{},
},
},
}
// nonFuzzy
hits, err := nonFuzzyWorkaround(validResponse, "bowling", false)
assert.NoError(t, err)
assert.EqualValues(t, []internal.Match{{ID: 11}, {ID: 22}}, hits)
// fuzzy
hits, err = nonFuzzyWorkaround(validResponse, "bowling", true)
assert.NoError(t, err)
assert.EqualValues(t, []internal.Match{{ID: 11}, {ID: 22}, {ID: 33}}, hits)
}

View File

@ -12,7 +12,7 @@ var Attachment = struct {
Enabled bool
}{
Storage: &Storage{},
AllowedTypes: ".csv,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip",
AllowedTypes: ".cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip",
MaxSize: 2048,
MaxFiles: 5,
Enabled: true,
@ -25,7 +25,7 @@ func loadAttachmentFrom(rootCfg ConfigProvider) (err error) {
return err
}
Attachment.AllowedTypes = sec.Key("ALLOWED_TYPES").MustString(".csv,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip")
Attachment.AllowedTypes = sec.Key("ALLOWED_TYPES").MustString(".cpuprofile,.csv,.dmp,.docx,.fodg,.fodp,.fods,.fodt,.gif,.gz,.jpeg,.jpg,.json,.jsonc,.log,.md,.mov,.mp4,.odf,.odg,.odp,.ods,.odt,.patch,.pdf,.png,.pptx,.svg,.tgz,.txt,.webm,.xls,.xlsx,.zip")
Attachment.MaxSize = sec.Key("MAX_SIZE").MustInt64(2048)
Attachment.MaxFiles = sec.Key("MAX_FILES").MustInt(5)
Attachment.Enabled = sec.Key("ENABLED").MustBool(true)

View File

@ -35,7 +35,7 @@ func Code(ctx *context.Context) {
keyword := ctx.FormTrim("q")
queryType := ctx.FormTrim("t")
isMatch := queryType == "match"
isFuzzy := queryType != "match"
ctx.Data["Keyword"] = keyword
ctx.Data["Language"] = language
@ -77,7 +77,7 @@ func Code(ctx *context.Context) {
)
if (len(repoIDs) > 0) || isAdmin {
total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, repoIDs, language, keyword, page, setting.UI.RepoSearchPagingNum, isMatch)
total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, repoIDs, language, keyword, page, setting.UI.RepoSearchPagingNum, isFuzzy)
if err != nil {
if code_indexer.IsAvailable(ctx) {
ctx.ServerError("SearchResults", err)

View File

@ -25,7 +25,7 @@ func Search(ctx *context.Context) {
keyword := ctx.FormTrim("q")
queryType := ctx.FormTrim("t")
isMatch := queryType == "match"
isFuzzy := queryType != "match"
ctx.Data["Keyword"] = keyword
ctx.Data["Language"] = language
@ -43,7 +43,7 @@ func Search(ctx *context.Context) {
}
total, searchResults, searchResultLanguages, err := code_indexer.PerformSearch(ctx, []int64{ctx.Repo.Repository.ID},
language, keyword, page, setting.UI.RepoSearchPagingNum, isMatch)
language, keyword, page, setting.UI.RepoSearchPagingNum, isFuzzy)
if err != nil {
if code_indexer.IsAvailable(ctx) {
ctx.ServerError("SearchResults", err)

View File

@ -40,7 +40,7 @@ func CodeSearch(ctx *context.Context) {
keyword := ctx.FormTrim("q")
queryType := ctx.FormTrim("t")
isMatch := queryType == "match"
isFuzzy := queryType != "match"
ctx.Data["Keyword"] = keyword
ctx.Data["Language"] = language
@ -75,7 +75,7 @@ func CodeSearch(ctx *context.Context) {
)
if len(repoIDs) > 0 {
total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, repoIDs, language, keyword, page, setting.UI.RepoSearchPagingNum, isMatch)
total, searchResults, searchResultLanguages, err = code_indexer.PerformSearch(ctx, repoIDs, language, keyword, page, setting.UI.RepoSearchPagingNum, isFuzzy)
if err != nil {
if code_indexer.IsAvailable(ctx) {
ctx.ServerError("SearchResults", err)

View File

@ -59,7 +59,7 @@ func (p *AuthSourceProvider) DisplayName() string {
func (p *AuthSourceProvider) IconHTML(size int) template.HTML {
if p.iconURL != "" {
img := fmt.Sprintf(`<img class="gt-object-contain gt-mr-3" width="%d" height="%d" src="%s" alt="%s">`,
img := fmt.Sprintf(`<img class="tw-object-contain gt-mr-3" width="%d" height="%d" src="%s" alt="%s">`,
size,
size,
html.EscapeString(p.iconURL), html.EscapeString(p.DisplayName()),

View File

@ -59,7 +59,7 @@ func MergeRequiredContextsCommitStatus(commitStatuses []*git_model.CommitStatus,
}
}
if matchedCount == 0 {
if matchedCount == 0 && returnedStatus == structs.CommitStatusSuccess {
status := git_model.CalcCommitStatus(commitStatuses)
if status != nil {
return status.State

View File

@ -31,6 +31,9 @@ export default {
isProduction && '!./web_src/js/standalone/devtest.js',
'!./templates/swagger/v1_json.tmpl',
'!./templates/user/auth/oidc_wellknown.tmpl',
'!**/*_test.go',
'!./modules/{public,options,templates}/bindata.go',
'./{build,models,modules,routers,services}/**/*.go',
'./templates/**/*.tmpl',
'./web_src/js/**/*.{js,vue}',
].filter(Boolean),

View File

@ -1,6 +1,6 @@
{{if .Statuses}}
{{if and (eq (len .Statuses) 1) .Status.TargetURL}}
<a class="gt-vm {{.AdditionalClasses}} gt-no-underline" data-tippy="commit-statuses" href="{{.Status.TargetURL}}">
<a class="gt-vm {{.AdditionalClasses}} tw-no-underline" data-tippy="commit-statuses" href="{{.Status.TargetURL}}">
{{template "repo/commit_status" .Status}}
</a>
{{else}}

View File

@ -80,7 +80,7 @@
</div>
{{else if eq .Type 1}}
<div class="timeline-item event" id="{{.HashTag}}">
<span class="badge gt-bg-green gt-text-white">{{svg "octicon-dot-fill"}}</span>
<span class="badge tw-bg-green tw-text-white">{{svg "octicon-dot-fill"}}</span>
{{if not .OriginalAuthor}}
{{template "shared/user/avatarlink" dict "user" .Poster}}
{{end}}
@ -95,7 +95,7 @@
</div>
{{else if eq .Type 2}}
<div class="timeline-item event" id="{{.HashTag}}">
<span class="badge gt-bg-red gt-text-white">{{svg "octicon-circle-slash"}}</span>
<span class="badge tw-bg-red tw-text-white">{{svg "octicon-circle-slash"}}</span>
{{if not .OriginalAuthor}}
{{template "shared/user/avatarlink" dict "user" .Poster}}
{{end}}
@ -110,7 +110,7 @@
</div>
{{else if eq .Type 28}}
<div class="timeline-item event" id="{{.HashTag}}">
<span class="badge gt-bg-purple gt-text-white">{{svg "octicon-git-merge"}}</span>
<span class="badge tw-bg-purple tw-text-white">{{svg "octicon-git-merge"}}</span>
{{if not .OriginalAuthor}}
{{template "shared/user/avatarlink" dict "user" .Poster}}
{{end}}
@ -379,7 +379,7 @@
{{ctx.AvatarUtils.Avatar .Poster 40}}
</a>
{{end}}
<span class="badge{{if eq .Review.Type 1}} gt-bg-green gt-text-white{{else if eq .Review.Type 3}} gt-bg-red gt-text-white{{end}}">{{svg (printf "octicon-%s" .Review.Type.Icon)}}</span>
<span class="badge{{if eq .Review.Type 1}} tw-bg-green tw-text-white{{else if eq .Review.Type 3}} tw-bg-red tw-text-white{{end}}">{{svg (printf "octicon-%s" .Review.Type.Icon)}}</span>
<span class="text grey muted-links">
{{template "repo/issue/view_content/comments_authorlink" dict "ctxData" $ "comment" .}}
{{if eq .Review.Type 1}}

View File

@ -108,7 +108,7 @@
{{end}}
{{if gt .Activity.PublishedReleaseCount 0}}
<h4 class="divider divider-text gt-normal-case" id="published-releases">
<h4 class="divider divider-text tw-normal-case" id="published-releases">
{{svg "octicon-tag" 16 "gt-mr-3"}}
{{ctx.Locale.Tr "repo.activity.title.releases_published_by"
(ctx.Locale.TrN .Activity.PublishedReleaseCount "repo.activity.title.releases_1" "repo.activity.title.releases_n" .Activity.PublishedReleaseCount)
@ -130,7 +130,7 @@
{{end}}
{{if gt .Activity.MergedPRCount 0}}
<h4 class="divider divider-text gt-normal-case" id="merged-pull-requests">
<h4 class="divider divider-text tw-normal-case" id="merged-pull-requests">
{{svg "octicon-git-pull-request" 16 "gt-mr-3"}}
{{ctx.Locale.Tr "repo.activity.title.prs_merged_by"
(ctx.Locale.TrN .Activity.MergedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.MergedPRCount)
@ -149,7 +149,7 @@
{{end}}
{{if gt .Activity.OpenedPRCount 0}}
<h4 class="divider divider-text gt-normal-case" id="proposed-pull-requests">
<h4 class="divider divider-text tw-normal-case" id="proposed-pull-requests">
{{svg "octicon-git-branch" 16 "gt-mr-3"}}
{{ctx.Locale.Tr "repo.activity.title.prs_opened_by"
(ctx.Locale.TrN .Activity.OpenedPRCount "repo.activity.title.prs_1" "repo.activity.title.prs_n" .Activity.OpenedPRCount)
@ -168,7 +168,7 @@
{{end}}
{{if gt .Activity.ClosedIssueCount 0}}
<h4 class="divider divider-text gt-normal-case" id="closed-issues">
<h4 class="divider divider-text tw-normal-case" id="closed-issues">
{{svg "octicon-issue-closed" 16 "gt-mr-3"}}
{{ctx.Locale.Tr "repo.activity.title.issues_closed_from"
(ctx.Locale.TrN .Activity.ClosedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.ClosedIssueCount)
@ -187,7 +187,7 @@
{{end}}
{{if gt .Activity.OpenedIssueCount 0}}
<h4 class="divider divider-text gt-normal-case" id="new-issues">
<h4 class="divider divider-text tw-normal-case" id="new-issues">
{{svg "octicon-issue-opened" 16 "gt-mr-3"}}
{{ctx.Locale.Tr "repo.activity.title.issues_created_by"
(ctx.Locale.TrN .Activity.OpenedIssueCount "repo.activity.title.issues_1" "repo.activity.title.issues_n" .Activity.OpenedIssueCount)
@ -206,7 +206,7 @@
{{end}}
{{if gt .Activity.UnresolvedIssueCount 0}}
<h4 class="divider divider-text gt-normal-case" id="unresolved-conversations" data-tooltip-content="{{ctx.Locale.Tr "repo.activity.unresolved_conv_desc"}}">
<h4 class="divider divider-text tw-normal-case" id="unresolved-conversations" data-tooltip-content="{{ctx.Locale.Tr "repo.activity.unresolved_conv_desc"}}">
{{svg "octicon-comment-discussion" 16 "gt-mr-3"}}
{{ctx.Locale.TrN .Activity.UnresolvedIssueCount "repo.activity.title.unresolved_conv_1" "repo.activity.title.unresolved_conv_n" .Activity.UnresolvedIssueCount}}
</h4>

View File

@ -68,7 +68,7 @@
{{range .Runners}}
<tr>
<td>
<span class="ui {{if .IsOnline}}green{{else}}basic{{end}} label">{{.StatusLocaleName ctx.Locale}}</span>
<span class="ui {{if .IsOnline}}green{{end}} label">{{.StatusLocaleName ctx.Locale}}</span>
</td>
<td>{{.ID}}</td>
<td><p data-tooltip-content="{{.Description}}">{{.Name}}</p></td>

View File

@ -13,7 +13,7 @@
<div class="flex-item-main">
<div class="flex-item-header">
<div class="flex-item-title">
<a class="gt-no-underline issue-title" href="{{if .Link}}{{.Link}}{{else}}{{$.Link}}/{{.Index}}{{end}}">{{RenderEmoji $.Context .Title | RenderCodeBlock}}</a>
<a class="tw-no-underline issue-title" href="{{if .Link}}{{.Link}}{{else}}{{$.Link}}/{{.Index}}{{end}}">{{RenderEmoji $.Context .Title | RenderCodeBlock}}</a>
{{if .IsPull}}
{{if (index $.CommitStatuses .PullRequest.ID)}}
{{template "repo/commit_statuses" dict "Status" (index $.CommitLastStatus .PullRequest.ID) "Statuses" (index $.CommitStatuses .PullRequest.ID)}}
@ -36,7 +36,7 @@
{{if .Assignees}}
<div class="text grey">
{{range .Assignees}}
<a class="ui assignee gt-no-underline" href="{{.HomeLink}}" data-tooltip-content="{{.GetDisplayName}}">
<a class="ui assignee tw-no-underline" href="{{.HomeLink}}" data-tooltip-content="{{.GetDisplayName}}">
{{ctx.AvatarUtils.Avatar . 20}}
</a>
{{end}}
@ -44,7 +44,7 @@
{{end}}
{{if .NumComments}}
<div class="text grey">
<a class="gt-no-underline muted flex-text-block" href="{{if .Link}}{{.Link}}{{else}}{{$.Link}}/{{.Index}}{{end}}">
<a class="tw-no-underline muted flex-text-block" href="{{if .Link}}{{.Link}}{{else}}{{$.Link}}/{{.Index}}{{end}}">
{{svg "octicon-comment" 16}}{{.NumComments}}
</a>
</div>

View File

@ -4,7 +4,7 @@
<div class="ui container">
<div class="flex-container">
<div class="flex-container-nav">
<div class="ui secondary vertical filter menu gt-bg-transparent">
<div class="ui secondary vertical filter menu tw-bg-transparent">
<a class="{{if eq .ViewType "your_repositories"}}active{{end}} item" href="{{.Link}}?type=your_repositories&sort={{$.SortType}}&state={{.State}}&q={{$.Keyword}}">
{{ctx.Locale.Tr "home.issues.in_your_repos"}}
<strong>{{CountFmt .IssueStats.YourRepositoriesCount}}</strong>

View File

@ -4,7 +4,7 @@
<div class="ui container">
<div class="flex-container">
<div class="flex-container-nav">
<div class="ui secondary vertical filter menu gt-bg-transparent">
<div class="ui secondary vertical filter menu tw-bg-transparent">
<div class="item">
{{ctx.Locale.Tr "home.issues.in_your_repos"}}
<strong>{{.Total}}</strong>

View File

@ -872,10 +872,18 @@ img.ui.avatar,
border-color: var(--color-error-border) !important;
}
/* A fix for text visibility issue in Chrome autofill in dark mode. */
/* It's a problem from Formatic UI, and this rule overrides it. */
.ui.form .field.field input:-webkit-autofill {
-webkit-text-fill-color: var(--color-black) !important;
input:-webkit-autofill,
input:-webkit-autofill:focus,
input:-webkit-autofill:hover,
input:-webkit-autofill:active,
.ui.form .field.field input:-webkit-autofill,
.ui.form .field.field input:-webkit-autofill:focus,
.ui.form .field.field input:-webkit-autofill:hover,
.ui.form .field.field input:-webkit-autofill:active {
-webkit-background-clip: text;
-webkit-text-fill-color: var(--color-text);
box-shadow: 0 0 0 100px var(--color-primary-light-6) inset !important;
border-color: var(--color-primary-light-4) !important;
}
.ui.form .field.muted {

View File

@ -46,12 +46,6 @@ Gitea's private styles use `g-` prefix.
text-overflow: ellipsis;
}
/* below class names match Tailwind CSS */
.gt-object-contain { object-fit: contain !important; }
.gt-no-underline { text-decoration-line: none !important; }
.gt-normal-case { text-transform: none !important; }
.gt-italic { font-style: italic !important; }
.gt-font-light { font-weight: var(--font-weight-light) !important; }
.gt-font-normal { font-weight: var(--font-weight-normal) !important; }
.gt-font-medium { font-weight: var(--font-weight-medium) !important; }
@ -70,23 +64,6 @@ Gitea's private styles use `g-` prefix.
.gt-border-secondary-left { border-left: 1px solid var(--color-secondary) !important; }
.gt-border-secondary-right { border-right: 1px solid var(--color-secondary) !important; }
.gt-bg-red { background: var(--color-red) !important; }
.gt-bg-orange { background: var(--color-orange) !important; }
.gt-bg-yellow { background: var(--color-yellow) !important; }
.gt-bg-olive { background: var(--color-olive) !important; }
.gt-bg-green { background: var(--color-green) !important; }
.gt-bg-teal { background: var(--color-teal) !important; }
.gt-bg-blue { background: var(--color-blue) !important; }
.gt-bg-violet { background: var(--color-violet) !important; }
.gt-bg-purple { background: var(--color-purple) !important; }
.gt-bg-pink { background: var(--color-pink) !important; }
.gt-bg-brown { background: var(--color-brown) !important; }
.gt-bg-grey { background: var(--color-grey) !important; }
.gt-bg-gold { background: var(--color-gold) !important; }
.gt-bg-transparent { background: transparent !important; }
.gt-text-white { color: var(--color-white) !important; }
.interact-fg { color: inherit !important; }
.interact-fg:hover { color: var(--color-primary) !important; }
.interact-fg:active { color: var(--color-primary-active) !important; }

View File

@ -6,10 +6,18 @@
// This file must be imported before any lazy-loading is being attempted.
__webpack_public_path__ = `${window.config?.assetUrlPrefix ?? '/assets'}/`;
const filteredErrors = new Set([
'getModifierState is not a function', // https://github.com/microsoft/monaco-editor/issues/4325
]);
export function showGlobalErrorMessage(msg) {
const pageContent = document.querySelector('.page-content');
if (!pageContent) return;
for (const filteredError of filteredErrors) {
if (msg.includes(filteredError)) return;
}
// compact the message to a data attribute to avoid too many duplicated messages
const msgCompact = msg.replace(/\W/g, '').trim();
let msgDiv = pageContent.querySelector(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);

View File

@ -3,7 +3,7 @@ import '@github/text-expander-element';
import $ from 'jquery';
import {attachTribute} from '../tribute.js';
import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.js';
import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js';
import {initEasyMDEPaste, initTextareaPaste} from './Paste.js';
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
import {renderPreviewPanelContent} from '../repo-editor.js';
import {easyMDEToolbarActions} from './EasyMDEToolbarActions.js';
@ -84,6 +84,17 @@ class ComboMarkdownEditor {
if (el.nodeName === 'BUTTON' && !el.getAttribute('type')) el.setAttribute('type', 'button');
}
this.textarea.addEventListener('keydown', (e) => {
if (e.shiftKey) {
e.target._shiftDown = true;
}
});
this.textarea.addEventListener('keyup', (e) => {
if (!e.shiftKey) {
e.target._shiftDown = false;
}
});
const monospaceButton = this.container.querySelector('.markdown-switch-monospace');
const monospaceEnabled = localStorage?.getItem('markdown-editor-monospace') === 'true';
const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text');
@ -108,7 +119,7 @@ class ComboMarkdownEditor {
});
if (this.dropzone) {
initTextareaImagePaste(this.textarea, this.dropzone);
initTextareaPaste(this.textarea, this.dropzone);
}
}
@ -241,7 +252,7 @@ class ComboMarkdownEditor {
});
this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
initEasyMDEImagePaste(this.easyMDE, this.dropzone);
initEasyMDEPaste(this.easyMDE, this.dropzone);
hideElem(this.textareaMarkdownToolbar);
}

View File

@ -1,6 +1,8 @@
import {htmlEscape} from 'escape-goat';
import {POST} from '../../modules/fetch.js';
import {imageInfo} from '../../utils/image.js';
import {getPastedContent, replaceTextareaSelection} from '../../utils/dom.js';
import {isUrl} from '../../utils/url.js';
async function uploadFile(file, uploadUrl) {
const formData = new FormData();
@ -10,17 +12,6 @@ async function uploadFile(file, uploadUrl) {
return await res.json();
}
function clipboardPastedImages(e) {
if (!e.clipboardData) return [];
const files = [];
for (const item of e.clipboardData.items || []) {
if (!item.type || !item.type.startsWith('image/')) continue;
files.push(item.getAsFile());
}
return files;
}
function triggerEditorContentChanged(target) {
target.dispatchEvent(new CustomEvent('ce-editor-content-changed', {bubbles: true}));
}
@ -91,20 +82,16 @@ class CodeMirrorEditor {
}
}
const uploadClipboardImage = async (editor, dropzone, e) => {
async function handleClipboardImages(editor, dropzone, images, e) {
const uploadUrl = dropzone.getAttribute('data-upload-url');
const filesContainer = dropzone.querySelector('.files');
if (!uploadUrl || !filesContainer) return;
if (!dropzone || !uploadUrl || !filesContainer || !images.length) return;
const pastedImages = clipboardPastedImages(e);
if (!pastedImages || pastedImages.length === 0) {
return;
}
e.preventDefault();
e.stopPropagation();
for (const img of pastedImages) {
for (const img of images) {
const name = img.name.slice(0, img.name.lastIndexOf('.'));
const placeholder = `![${name}](uploading ...)`;
@ -131,18 +118,37 @@ const uploadClipboardImage = async (editor, dropzone, e) => {
input.value = uuid;
filesContainer.append(input);
}
};
}
export function initEasyMDEImagePaste(easyMDE, dropzone) {
if (!dropzone) return;
easyMDE.codemirror.on('paste', async (_, e) => {
return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), dropzone, e);
function handleClipboardText(textarea, text, e) {
// when pasting links over selected text, turn it into [text](link), except when shift key is held
const {value, selectionStart, selectionEnd, _shiftDown} = textarea;
if (_shiftDown) return;
const selectedText = value.substring(selectionStart, selectionEnd);
const trimmedText = text.trim();
if (selectedText && isUrl(trimmedText)) {
e.stopPropagation();
e.preventDefault();
replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`);
}
}
export function initEasyMDEPaste(easyMDE, dropzone) {
easyMDE.codemirror.on('paste', (_, e) => {
const {images} = getPastedContent(e);
if (images.length) {
handleClipboardImages(new CodeMirrorEditor(easyMDE.codemirror), dropzone, images, e);
}
});
}
export function initTextareaImagePaste(textarea, dropzone) {
if (!dropzone) return;
textarea.addEventListener('paste', async (e) => {
return uploadClipboardImage(new TextareaEditor(textarea), dropzone, e);
export function initTextareaPaste(textarea, dropzone) {
textarea.addEventListener('paste', (e) => {
const {images, text} = getPastedContent(e);
if (images.length) {
handleClipboardImages(new TextareaEditor(textarea), dropzone, images, e);
} else if (text) {
handleClipboardText(textarea, text, e);
}
});
}

View File

@ -22,13 +22,16 @@ export function initCompWebHookEditor() {
});
}
const updateContentType = function () {
const visible = document.getElementById('http_method').value === 'POST';
toggleElem(document.getElementById('content_type').parentNode.parentNode, visible);
};
updateContentType();
document.getElementById('http_method').addEventListener('change', updateContentType);
// some webhooks (like Gitea) allow to set the request method (GET/POST), and it would toggle the "Content Type" field
const httpMethodInput = document.getElementById('http_method');
if (httpMethodInput) {
const updateContentType = function () {
const visible = httpMethodInput.value === 'POST';
toggleElem(document.getElementById('content_type').closest('.field'), visible);
};
updateContentType();
httpMethodInput.addEventListener('change', updateContentType);
}
// Test delivery
document.getElementById('test-delivery')?.addEventListener('click', async function () {

View File

@ -39,7 +39,7 @@ export function initMarkupAnchors() {
if (!href.startsWith('#user-content-')) continue;
const originalId = href.replace(/^#user-content-/, '');
a.setAttribute('href', `#${encodeURIComponent(originalId)}`);
if (document.getElementsByName(originalId).length !== 1) {
if (a.closest('.markup').querySelectorAll(`a[name="${originalId}"]`).length !== 1) {
a.addEventListener('click', (e) => {
scrollToAnchor(e.currentTarget.getAttribute('href'), false);
});

View File

@ -243,3 +243,39 @@ export function isElemVisible(element) {
return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
}
// extract text and images from "paste" event
export function getPastedContent(e) {
const images = [];
for (const item of e.clipboardData?.items ?? []) {
if (item.type?.startsWith('image/')) {
images.push(item.getAsFile());
}
}
const text = e.clipboardData?.getData?.('text') ?? '';
return {text, images};
}
// replace selected text in a textarea while preserving editor history, e.g. CTRL-Z works after this
export function replaceTextareaSelection(textarea, text) {
const before = textarea.value.slice(0, textarea.selectionStart ?? undefined);
const after = textarea.value.slice(textarea.selectionEnd ?? undefined);
let success = true;
textarea.contentEditable = 'true';
try {
success = document.execCommand('insertText', false, text);
} catch {
success = false;
}
textarea.contentEditable = 'false';
if (success && !textarea.value.slice(0, textarea.selectionStart ?? undefined).endsWith(text)) {
success = false;
}
if (!success) {
textarea.value = `${before}${text}${after}`;
textarea.dispatchEvent(new CustomEvent('change', {bubbles: true, cancelable: true}));
}
}

View File

@ -1,3 +1,15 @@
export function pathEscapeSegments(s) {
return s.split('/').map(encodeURIComponent).join('/');
}
function stripSlash(url) {
return url.endsWith('/') ? url.slice(0, -1) : url;
}
export function isUrl(url) {
try {
return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim();
} catch {
return false;
}
}

View File

@ -1,6 +1,13 @@
import {pathEscapeSegments} from './url.js';
import {pathEscapeSegments, isUrl} from './url.js';
test('pathEscapeSegments', () => {
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
});
test('isUrl', () => {
expect(isUrl('https://example.com')).toEqual(true);
expect(isUrl('https://example.com/')).toEqual(true);
expect(isUrl('https://example.com/index.html')).toEqual(true);
expect(isUrl('/index.html')).toEqual(false);
});