Load mentionValues asynchronously (#36739)

Eliminate a few database queries on all issue and pull request pages by
moving mention autocomplete data to async JSON endpoints fetched
on-demand when the user types `@`.

See https://github.com/go-gitea/gitea/pull/36739#issuecomment-3963184858
for the full table of affected pages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwind
2026-03-07 21:37:37 +01:00
committed by GitHub
parent f250138f57
commit 130e34994f
32 changed files with 363 additions and 161 deletions
+1 -1
View File
@@ -141,7 +141,7 @@ func NewFuncMap() template.FuncMap {
"ReactionToEmoji": reactionToEmoji, "ReactionToEmoji": reactionToEmoji,
// ----------------------------------------------------------------- // -----------------------------------------------------------------
// misc // misc (TODO: move them to MiscUtils to avoid bloating the main func map)
"ShortSha": base.ShortSha, "ShortSha": base.ShortSha,
"ActionContent2Commits": ActionContent2Commits, "ActionContent2Commits": ActionContent2Commits,
"IsMultilineCommitMessage": isMultilineCommitMessage, "IsMultilineCommitMessage": isMultilineCommitMessage,
+48
View File
@@ -14,10 +14,12 @@ import (
activities_model "code.gitea.io/gitea/models/activities" activities_model "code.gitea.io/gitea/models/activities"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/gitrepo" "code.gitea.io/gitea/modules/gitrepo"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/repository" "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg" "code.gitea.io/gitea/modules/svg"
"github.com/editorconfig/editorconfig-core-go/v2" "github.com/editorconfig/editorconfig-core-go/v2"
@@ -185,3 +187,49 @@ func tabSizeClass(ec *editorconfig.Editorconfig, filename string) string {
} }
return "tab-size-4" return "tab-size-4"
} }
type MiscUtils struct {
ctx context.Context
}
func NewMiscUtils(ctx context.Context) *MiscUtils {
return &MiscUtils{ctx: ctx}
}
type MarkdownEditorContext struct {
PreviewMode string // "comment", "wiki", or empty for general
PreviewContext string // the path for resolving the links in the preview (repo preview already has default correct value)
PreviewLink string
MentionsLink string
}
func (m *MiscUtils) MarkdownEditorComment(repo *repo_model.Repository) *MarkdownEditorContext {
if repo == nil {
return nil
}
return &MarkdownEditorContext{
PreviewMode: "comment",
PreviewLink: repo.Link() + "/markup",
MentionsLink: repo.Link() + "/-/mentions-in-repo",
}
}
func (m *MiscUtils) MarkdownEditorWiki(repo *repo_model.Repository) *MarkdownEditorContext {
if repo == nil {
return nil
}
return &MarkdownEditorContext{
PreviewMode: "wiki",
PreviewLink: repo.Link() + "/markup",
MentionsLink: repo.Link() + "/-/mentions-in-repo",
}
}
func (m *MiscUtils) MarkdownEditorGeneral(owner *user_model.User) *MarkdownEditorContext {
ret := &MarkdownEditorContext{PreviewLink: setting.AppSubURL + "/-/markup"}
if owner != nil {
ret.PreviewContext = owner.HomeLink()
ret.MentionsLink = owner.HomeLink() + "/-/mentions-in-owner"
}
return ret
}
+3 -8
View File
@@ -6,14 +6,12 @@ package common
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"path" "path"
"strings" "strings"
"code.gitea.io/gitea/models/renderhelper" "code.gitea.io/gitea/models/renderhelper"
"code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
@@ -32,12 +30,9 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur
// and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc" // and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc"
if mode == "" || mode == "markdown" { if mode == "" || mode == "markdown" {
// raw Markdown doesn't need any special handling // raw Markdown doesn't do any special handling
baseLink := urlPathContext // TODO: raw markdown doesn't do any link processing, so "urlPathContext" doesn't take effect
if baseLink == "" { rctx := renderhelper.NewRenderContextSimpleDocument(ctx, urlPathContext).WithUseAbsoluteLink(true).
baseLink = fmt.Sprintf("%s%s", httplib.GuessCurrentHostURL(ctx), urlPathContext)
}
rctx := renderhelper.NewRenderContextSimpleDocument(ctx, baseLink).WithUseAbsoluteLink(true).
WithMarkupType(markdown.MarkupName) WithMarkupType(markdown.MarkupName)
if err := markdown.RenderRaw(rctx, strings.NewReader(text), ctx.Resp); err != nil { if err := markdown.RenderRaw(rctx, strings.NewReader(text), ctx.Resp); err != nil {
log.Error("RenderMarkupRaw: %v", err) log.Error("RenderMarkupRaw: %v", err)
+42
View File
@@ -0,0 +1,42 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package org
import (
"net/http"
"code.gitea.io/gitea/models/organization"
"code.gitea.io/gitea/modules/util"
shared_mention "code.gitea.io/gitea/routers/web/shared/mention"
"code.gitea.io/gitea/services/context"
)
// GetMentionsInOwner returns JSON data for mention autocomplete on owner-level pages.
func GetMentionsInOwner(ctx *context.Context) {
// for individual users, we don't have a concept of "mentionable" users or teams, so just return an empty list
if !ctx.ContextUser.IsOrganization() {
ctx.JSON(http.StatusOK, []shared_mention.Mention{})
return
}
// for org, return members and teams
c := shared_mention.NewCollector()
org := organization.OrgFromUser(ctx.ContextUser)
// Get org members
members, _, err := org.GetMembers(ctx, ctx.Doer)
if err != nil {
ctx.ServerError("GetMembers", err)
return
}
c.AddUsers(ctx, members)
// Get mentionable teams
if err := c.AddMentionableTeams(ctx, ctx.Doer, ctx.ContextUser); err != nil {
ctx.ServerError("AddMentionableTeams", err)
return
}
ctx.JSON(http.StatusOK, util.SliceNilAsEmpty(c.Result))
}
-45
View File
@@ -14,7 +14,6 @@ import (
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues" issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
access_model "code.gitea.io/gitea/models/perm/access" access_model "code.gitea.io/gitea/models/perm/access"
project_model "code.gitea.io/gitea/models/project" project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/models/renderhelper" "code.gitea.io/gitea/models/renderhelper"
@@ -649,47 +648,3 @@ func attachmentsHTML(ctx *context.Context, attachments []*repo_model.Attachment,
} }
return attachHTML return attachHTML
} }
// handleMentionableAssigneesAndTeams gets all teams that current user can mention, and fills the assignee users to the context data
func handleMentionableAssigneesAndTeams(ctx *context.Context, assignees []*user_model.User) {
// TODO: need to figure out how many places this is really used, and rename it to "MentionableAssignees"
// at the moment it is used on the issue list page, for the markdown editor mention
ctx.Data["Assignees"] = assignees
if ctx.Doer == nil || !ctx.Repo.Owner.IsOrganization() {
return
}
var isAdmin bool
var err error
var teams []*organization.Team
org := organization.OrgFromUser(ctx.Repo.Owner)
// Admin has super access.
if ctx.Doer.IsAdmin {
isAdmin = true
} else {
isAdmin, err = org.IsOwnedBy(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("IsOwnedBy", err)
return
}
}
if isAdmin {
teams, err = org.LoadTeams(ctx)
if err != nil {
ctx.ServerError("LoadTeams", err)
return
}
} else {
teams, err = org.GetUserTeams(ctx, ctx.Doer.ID)
if err != nil {
ctx.ServerError("GetUserTeams", err)
return
}
}
ctx.Data["MentionableTeams"] = teams
ctx.Data["MentionableTeamsOrg"] = ctx.Repo.Owner.Name
ctx.Data["MentionableTeamsOrgAvatar"] = ctx.Repo.Owner.AvatarLink(ctx)
}
+1 -4
View File
@@ -662,10 +662,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
ctx.ServerError("GetRepoAssignees", err) ctx.ServerError("GetRepoAssignees", err)
return return
} }
handleMentionableAssigneesAndTeams(ctx, shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)) ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
if ctx.Written() {
return
}
ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.Repo.RepoLink) ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.Repo.RepoLink)
+1 -2
View File
@@ -155,8 +155,7 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) {
} }
d.AssigneesData.SelectedAssigneeIDs = strings.Join(ids, ",") d.AssigneesData.SelectedAssigneeIDs = strings.Join(ids, ",")
} }
// FIXME: this is a tricky part which writes ctx.Data["Mentionable*"] ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees
handleMentionableAssigneesAndTeams(ctx, d.AssigneesData.CandidateAssignees)
} }
func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) { func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"errors"
"net/http"
issues_model "code.gitea.io/gitea/models/issues"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/util"
shared_mention "code.gitea.io/gitea/routers/web/shared/mention"
"code.gitea.io/gitea/services/context"
)
// GetMentionsInRepo returns JSON data for mention autocomplete (assignees, participants, mentionable teams).
func GetMentionsInRepo(ctx *context.Context) {
c := shared_mention.NewCollector()
// Get participants if issue_index is provided
if issueIndex := ctx.FormInt64("issue_index"); issueIndex > 0 {
issue, err := issues_model.GetIssueByIndex(ctx, ctx.Repo.Repository.ID, issueIndex)
if err != nil && !errors.Is(err, util.ErrNotExist) {
ctx.ServerError("GetIssueByIndex", err)
return
}
if issue != nil {
userIDs, err := issue.GetParticipantIDsByIssue(ctx)
if err != nil {
ctx.ServerError("GetParticipantIDsByIssue", err)
return
}
users, err := user_model.GetUsersByIDs(ctx, userIDs)
if err != nil {
ctx.ServerError("GetUsersByIDs", err)
return
}
c.AddUsers(ctx, users)
}
}
// Get repo assignees
assignees, err := repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository)
if err != nil {
ctx.ServerError("GetRepoAssignees", err)
return
}
c.AddUsers(ctx, assignees)
// Get mentionable teams for org repos
if err := c.AddMentionableTeams(ctx, ctx.Doer, ctx.Repo.Owner); err != nil {
ctx.ServerError("AddMentionableTeams", err)
return
}
ctx.JSON(http.StatusOK, util.SliceNilAsEmpty(c.Result))
}
+1 -4
View File
@@ -913,10 +913,7 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
ctx.ServerError("GetRepoAssignees", err) ctx.ServerError("GetRepoAssignees", err)
return return
} }
handleMentionableAssigneesAndTeams(ctx, shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)) ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
if ctx.Written() {
return
}
currentReview, err := issues_model.GetCurrentReview(ctx, ctx.Doer, issue) currentReview, err := issues_model.GetCurrentReview(ctx, ctx.Doer, issue)
if err != nil && !issues_model.IsErrReviewNotExist(err) { if err != nil && !issues_model.IsErrReviewNotExist(err) {
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package mention
import (
"context"
"code.gitea.io/gitea/models/organization"
user_model "code.gitea.io/gitea/models/user"
)
// Mention is the JSON structure returned by mention autocomplete endpoints.
type Mention struct {
Key string `json:"key"`
Value string `json:"value"`
Name string `json:"name"`
FullName string `json:"fullname"`
Avatar string `json:"avatar"`
}
// Collector builds a deduplicated list of Mention entries.
type Collector struct {
seen map[string]bool
Result []Mention
}
// NewCollector creates a new Collector.
func NewCollector() *Collector {
return &Collector{seen: make(map[string]bool)}
}
// AddUsers adds user mentions, skipping duplicates.
func (c *Collector) AddUsers(ctx context.Context, users []*user_model.User) {
for _, u := range users {
if !c.seen[u.Name] {
c.seen[u.Name] = true
c.Result = append(c.Result, Mention{
Key: u.Name + " " + u.FullName,
Value: u.Name,
Name: u.Name,
FullName: u.FullName,
Avatar: u.AvatarLink(ctx),
})
}
}
}
// AddMentionableTeams loads and adds team mentions for the given owner (if it's an org).
func (c *Collector) AddMentionableTeams(ctx context.Context, doer, owner *user_model.User) error {
if doer == nil || !owner.IsOrganization() {
return nil
}
org := organization.OrgFromUser(owner)
isAdmin := doer.IsAdmin
if !isAdmin {
var err error
isAdmin, err = org.IsOwnedBy(ctx, doer.ID)
if err != nil {
return err
}
}
var teams []*organization.Team
var err error
if isAdmin {
teams, err = org.LoadTeams(ctx)
} else {
teams, err = org.GetUserTeams(ctx, doer.ID)
}
if err != nil {
return err
}
for _, team := range teams {
key := owner.Name + "/" + team.Name
if !c.seen[key] {
c.seen[key] = true
c.Result = append(c.Result, Mention{
Key: key,
Value: key,
Name: key,
Avatar: owner.AvatarLink(ctx),
})
}
}
return nil
}
+9
View File
@@ -835,6 +835,7 @@ func registerWebRoutes(m *web.Router) {
// the legacy names "reqRepoXxx" should be renamed to the correct name "reqUnitXxx", these permissions are for units, not repos // the legacy names "reqRepoXxx" should be renamed to the correct name "reqUnitXxx", these permissions are for units, not repos
reqUnitsWithMarkdown := context.RequireUnitReader(unit.TypeCode, unit.TypeIssues, unit.TypePullRequests, unit.TypeReleases, unit.TypeWiki) reqUnitsWithMarkdown := context.RequireUnitReader(unit.TypeCode, unit.TypeIssues, unit.TypePullRequests, unit.TypeReleases, unit.TypeWiki)
reqUnitsWithMentions := context.RequireUnitReader(unit.TypeIssues, unit.TypePullRequests, unit.TypeReleases, unit.TypeWiki, unit.TypeProjects)
reqUnitCodeReader := context.RequireUnitReader(unit.TypeCode) reqUnitCodeReader := context.RequireUnitReader(unit.TypeCode)
reqUnitIssuesReader := context.RequireUnitReader(unit.TypeIssues) reqUnitIssuesReader := context.RequireUnitReader(unit.TypeIssues)
reqUnitPullsReader := context.RequireUnitReader(unit.TypePullRequests) reqUnitPullsReader := context.RequireUnitReader(unit.TypePullRequests)
@@ -1025,6 +1026,9 @@ func registerWebRoutes(m *web.Router) {
}, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead)) }, context.PackageAssignment(), reqPackageAccess(perm.AccessModeRead))
} }
// at the moment, only editing "owner-level projects" need to "mention", maybe in the future we can relax the permission check
m.Get("/mentions-in-owner", reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), org.GetMentionsInOwner)
m.Get("/repositories", org.Repositories) m.Get("/repositories", org.Repositories)
m.Get("/heatmap", user.DashboardHeatmap) m.Get("/heatmap", user.DashboardHeatmap)
@@ -1074,6 +1078,11 @@ func registerWebRoutes(m *web.Router) {
}, optSignIn, context.RepoAssignment, reqUnitCodeReader) }, optSignIn, context.RepoAssignment, reqUnitCodeReader)
// end "/{username}/{reponame}/-": migrate // end "/{username}/{reponame}/-": migrate
m.Group("/{username}/{reponame}/-", func() {
m.Get("/mentions-in-repo", repo.GetMentionsInRepo)
}, optSignIn, context.RepoAssignment, reqUnitsWithMentions)
// end "/{username}/{reponame}/-": mentions
m.Group("/{username}/{reponame}/settings", func() { m.Group("/{username}/{reponame}/settings", func() {
m.Group("", func() { m.Group("", func() {
m.Combo("").Get(repo_setting.Settings). m.Combo("").Get(repo_setting.Settings).
+1
View File
@@ -105,6 +105,7 @@ func NewTemplateContextForWeb(ctx reqctx.RequestContext, req *http.Request, loca
tmplCtx["Locale"] = locale tmplCtx["Locale"] = locale
tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx) tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx)
tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx) tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx)
tmplCtx["MiscUtils"] = templates.NewMiscUtils(ctx)
tmplCtx["RootData"] = ctx.GetData() tmplCtx["RootData"] = ctx.GetData()
tmplCtx["Consts"] = map[string]any{ tmplCtx["Consts"] = map[string]any{
"RepoUnitTypeCode": unit.TypeCode, "RepoUnitTypeCode": unit.TypeCode,
-15
View File
@@ -16,21 +16,6 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
pageData: {{.PageData}}, pageData: {{.PageData}},
notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}} notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}
enableTimeTracking: {{EnableTimetracking}}, enableTimeTracking: {{EnableTimetracking}},
{{if or .Participants .Assignees .MentionableTeams}}
mentionValues: Array.from(new Map([
{{- range .Participants -}}
['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}', name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink ctx}}'}],
{{- end -}}
{{- range .Assignees -}}
['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}', name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink ctx}}'}],
{{- end -}}
{{- range .MentionableTeams -}}
['{{$.MentionableTeamsOrg}}/{{.Name}}', {key: '{{$.MentionableTeamsOrg}}/{{.Name}}', value: '{{$.MentionableTeamsOrg}}/{{.Name}}', name: '{{$.MentionableTeamsOrg}}/{{.Name}}', avatar: '{{$.MentionableTeamsOrgAvatar}}'}],
{{- end -}}
]).values()),
{{else}}
mentionValues: [],
{{end}}
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}}, mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
{{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}} {{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}}
i18n: { i18n: {
+1 -1
View File
@@ -184,7 +184,7 @@
<div> <div>
<h1>ComboMarkdownEditor</h1> <h1>ComboMarkdownEditor</h1>
{{template "shared/combomarkdowneditor" dict "MarkdownPreviewContext" "/owner/path"}} {{template "shared/combomarkdowneditor" dict "MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorGeneral nil)}}
</div> </div>
<h1>Tailwind CSS Demo</h1> <h1>Tailwind CSS Demo</h1>
+3 -4
View File
@@ -18,11 +18,10 @@
<div class="field"> <div class="field">
<label>{{ctx.Locale.Tr "repo.projects.description"}}</label> <label>{{ctx.Locale.Tr "repo.projects.description"}}</label>
{{/* TODO: repo-level project and org-level project have different behaviors to render */}} {{/* TODO: repo-level project and org-level project have different behaviors to render */}}
{{/* the "Repository" is nil when the project is org-level */}} {{/* "Repository" is nil when the project is owner-level, "Org" can be nil when owner is indivdual user */}}
{{$markdownEditorContext := or (ctx.MiscUtils.MarkdownEditorComment $.Repository) (ctx.MiscUtils.MarkdownEditorGeneral $.ContextUser)}}
{{template "shared/combomarkdowneditor" (dict {{template "shared/combomarkdowneditor" (dict
"MarkdownPreviewInRepo" $.Repository "MarkdownEditorContext" $markdownEditorContext
"MarkdownPreviewContext" (Iif $.Repository "" .HomeLink)
"MarkdownPreviewMode" (Iif $.Repository "comment")
"TextareaName" "content" "TextareaName" "content"
"TextareaContent" .content "TextareaContent" .content
"TextareaPlaceholder" (ctx.Locale.Tr "repo.projects.description_placeholder") "TextareaPlaceholder" (ctx.Locale.Tr "repo.projects.description_placeholder")
+1 -2
View File
@@ -227,8 +227,7 @@
<div class="field"> <div class="field">
{{template "shared/combomarkdowneditor" (dict {{template "shared/combomarkdowneditor" (dict
"CustomInit" true "CustomInit" true
"MarkdownPreviewInRepo" $.Repository "MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment $.Repository)
"MarkdownPreviewMode" "comment"
"TextareaName" "content" "TextareaName" "content"
"DropzoneParentContainer" ".ui.form" "DropzoneParentContainer" ".ui.form"
)}} )}}
+2 -3
View File
@@ -1,4 +1,4 @@
{{if and $.root.SignedUserID (not $.Repository.IsArchived)}} {{if and ctx.RootData.SignedUserID (not ctx.RootData.Repository.IsArchived)}}
<form class="ui form {{if $.hidden}}tw-hidden comment-form{{end}}" action="{{$.root.Issue.Link}}/files/reviews/comments" method="post"> <form class="ui form {{if $.hidden}}tw-hidden comment-form{{end}}" action="{{$.root.Issue.Link}}/files/reviews/comments" method="post">
<input type="hidden" name="origin" value="{{if $.root.PageIsPullFiles}}diff{{else}}timeline{{end}}"> <input type="hidden" name="origin" value="{{if $.root.PageIsPullFiles}}diff{{else}}timeline{{end}}">
<input type="hidden" name="latest_commit_id" value="{{$.root.AfterCommitID}}"> <input type="hidden" name="latest_commit_id" value="{{$.root.AfterCommitID}}">
@@ -11,8 +11,7 @@
<div class="field"> <div class="field">
{{template "shared/combomarkdowneditor" (dict {{template "shared/combomarkdowneditor" (dict
"CustomInit" true "CustomInit" true
"MarkdownPreviewInRepo" $.root.Repository "MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment ctx.RootData.Repository)
"MarkdownPreviewMode" "comment"
"TextareaName" "content" "TextareaName" "content"
"TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.comment.placeholder") "TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.comment.placeholder")
"DropzoneParentContainer" "form" "DropzoneParentContainer" "form"
+1 -2
View File
@@ -19,8 +19,7 @@
</div> </div>
<div class="field"> <div class="field">
{{template "shared/combomarkdowneditor" (dict {{template "shared/combomarkdowneditor" (dict
"MarkdownPreviewInRepo" $.Repository "MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment $.Repository)
"MarkdownPreviewMode" "comment"
"TextareaName" "content" "TextareaName" "content"
"TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.review.placeholder") "TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.review.placeholder")
"DropzoneParentContainer" "form" "DropzoneParentContainer" "form"
+1 -2
View File
@@ -6,8 +6,7 @@
<div class="field"> <div class="field">
{{template "shared/combomarkdowneditor" (dict {{template "shared/combomarkdowneditor" (dict
"CustomInit" true "CustomInit" true
"MarkdownPreviewInRepo" $.Repository "MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment $.Repository)
"MarkdownPreviewMode" "comment"
"TextareaName" "content" "TextareaName" "content"
"TextareaContent" $textareaContent "TextareaContent" $textareaContent
"TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.comment.placeholder") "TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.comment.placeholder")
+1 -2
View File
@@ -9,8 +9,7 @@
{{template "shared/combomarkdowneditor" (dict {{template "shared/combomarkdowneditor" (dict
"CustomInit" true "CustomInit" true
"ContainerClasses" "tw-hidden" "ContainerClasses" "tw-hidden"
"MarkdownPreviewInRepo" $.root.Repository "MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment ctx.RootData.Repository)
"MarkdownPreviewMode" "comment"
"TextareaContent" .item.Attributes.value "TextareaContent" .item.Attributes.value
"TextareaPlaceholder" .item.Attributes.placeholder "TextareaPlaceholder" .item.Attributes.placeholder
"DropzoneParentContainer" ".combo-editor-dropzone" "DropzoneParentContainer" ".combo-editor-dropzone"
+1 -2
View File
@@ -36,8 +36,7 @@
<div class="field"> <div class="field">
<label>{{ctx.Locale.Tr "repo.milestones.desc"}}</label> <label>{{ctx.Locale.Tr "repo.milestones.desc"}}</label>
{{template "shared/combomarkdowneditor" (dict {{template "shared/combomarkdowneditor" (dict
"MarkdownPreviewInRepo" $.Repository "MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment $.Repository)
"MarkdownPreviewMode" "comment"
"TextareaName" "content" "TextareaName" "content"
"TextareaContent" .content "TextareaContent" .content
"TextareaPlaceholder" (ctx.Locale.Tr "repo.milestones.desc") "TextareaPlaceholder" (ctx.Locale.Tr "repo.milestones.desc")
+1 -2
View File
@@ -147,8 +147,7 @@
<div class="field"> <div class="field">
{{template "shared/combomarkdowneditor" (dict {{template "shared/combomarkdowneditor" (dict
"CustomInit" true "CustomInit" true
"MarkdownPreviewInRepo" $.Repository "MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment $.Repository)
"MarkdownPreviewMode" "comment"
"TextareaName" "content" "TextareaName" "content"
"DropzoneParentContainer" ".ui.form" "DropzoneParentContainer" ".ui.form"
)}} )}}
+1 -2
View File
@@ -69,8 +69,7 @@
</div> </div>
<div class="field"> <div class="field">
{{template "shared/combomarkdowneditor" (dict {{template "shared/combomarkdowneditor" (dict
"MarkdownPreviewInRepo" $.Repository "MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment $.Repository)
"MarkdownPreviewMode" "comment"
"TextareaName" "content" "TextareaName" "content"
"TextareaContent" .content "TextareaContent" .content
"TextareaPlaceholder" (ctx.Locale.Tr "repo.release.message") "TextareaPlaceholder" (ctx.Locale.Tr "repo.release.message")
+1 -2
View File
@@ -23,8 +23,7 @@
{{end}} {{end}}
{{template "shared/combomarkdowneditor" (dict {{template "shared/combomarkdowneditor" (dict
"CustomInit" true "CustomInit" true
"MarkdownPreviewInRepo" $.Repository "MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorWiki $.Repository)
"MarkdownPreviewMode" "wiki"
"TextareaName" "content" "TextareaName" "content"
"TextareaContent" $content "TextareaContent" $content
"TextareaPlaceholder" (ctx.Locale.Tr "repo.wiki.page_content") "TextareaPlaceholder" (ctx.Locale.Tr "repo.wiki.page_content")
+9 -12
View File
@@ -2,9 +2,7 @@
* CustomInit: do not initialize the editor automatically * CustomInit: do not initialize the editor automatically
* ContainerId: id attribute for the container element * ContainerId: id attribute for the container element
* ContainerClasses: additional classes for the container element * ContainerClasses: additional classes for the container element
* MarkdownPreviewInRepo: the repo to preview markdown * MarkdownEditorContext: the context data for the editor, see backend MarkdownEditorContext
* MarkdownPreviewContext: preview context (the related url path when rendering) for the preview tab, eg: repo link or user home link
* MarkdownPreviewMode: content mode for the editor, eg: wiki, comment or default, can be disabled by "none"
* TextareaName: name attribute for the textarea * TextareaName: name attribute for the textarea
* TextareaContent: content for the textarea * TextareaContent: content for the textarea
* TextareaMaxLength: maxlength attribute for the textarea * TextareaMaxLength: maxlength attribute for the textarea
@@ -14,20 +12,19 @@
* DisableAutosize: whether to disable automatic height resizing * DisableAutosize: whether to disable automatic height resizing
*/}} */}}
{{$ariaLabel := or .TextareaAriaLabel .TextareaPlaceholder}} {{$ariaLabel := or .TextareaAriaLabel .TextareaPlaceholder}}
{{$repo := .MarkdownPreviewInRepo}} {{$editorContext := .MarkdownEditorContext}}
{{$previewContext := .MarkdownPreviewContext}} {{$previewMode := or $editorContext.PreviewMode ""}}
{{$previewMode := .MarkdownPreviewMode}} {{$previewContext := or $editorContext.PreviewContext ""}}
{{$previewUrl := print AppSubUrl "/-/markup"}} {{$previewLink := or $editorContext.PreviewLink (print AppSubUrl "/-/markup")}}
{{if $repo}} {{$mentionsLink := or $editorContext.MentionsLink ""}}
{{$previewUrl = print $repo.Link "/markup"}}
{{end}}
{{$supportEasyMDE := or (eq $previewMode "comment") (eq $previewMode "wiki")}} {{$supportEasyMDE := or (eq $previewMode "comment") (eq $previewMode "wiki")}}
<div {{if .ContainerId}}id="{{.ContainerId}}"{{end}} class="combo-markdown-editor {{if .CustomInit}}custom-init{{end}} {{.ContainerClasses}}" <div {{if .ContainerId}}id="{{.ContainerId}}"{{end}} class="combo-markdown-editor {{if .CustomInit}}custom-init{{end}} {{.ContainerClasses}}"
data-dropzone-parent-container="{{.DropzoneParentContainer}}" data-dropzone-parent-container="{{.DropzoneParentContainer}}"
data-content-mode="{{$previewMode}}" data-content-mode="{{$previewMode}}"
data-support-easy-mde="{{$supportEasyMDE}}" data-support-easy-mde="{{$supportEasyMDE}}"
data-preview-url="{{$previewUrl}}" data-preview-url="{{$previewLink}}"
data-preview-context="{{$previewContext}}" data-preview-context="{{$previewContext}}"
{{if $mentionsLink}}data-mentions-url="{{$mentionsLink}}"{{end}}
> >
{{if ne $previewMode "none"}} {{if ne $previewMode "none"}}
<div class="ui top tabular menu"> <div class="ui top tabular menu">
@@ -58,7 +55,7 @@
<md-task-list class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.list.task.tooltip"}}">{{svg "octicon-tasklist"}}</md-task-list> <md-task-list class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.list.task.tooltip"}}">{{svg "octicon-tasklist"}}</md-task-list>
<button class="markdown-toolbar-button markdown-button-table-add" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.table.add.tooltip"}}">{{svg "octicon-table"}}</button> <button class="markdown-toolbar-button markdown-button-table-add" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.table.add.tooltip"}}">{{svg "octicon-table"}}</button>
</div> </div>
{{if eq $previewMode "comment"}} {{if $mentionsLink}}
<div class="markdown-toolbar-group"> <div class="markdown-toolbar-group">
<md-mention class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.mention.tooltip"}}">{{svg "octicon-mention"}}</md-mention> <md-mention class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.mention.tooltip"}}">{{svg "octicon-mention"}}</md-mention>
<md-ref class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.ref.tooltip"}}">{{svg "octicon-cross-reference"}}</md-ref> <md-ref class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.ref.tooltip"}}">{{svg "octicon-cross-reference"}}</md-ref>
+28 -24
View File
@@ -38,6 +38,7 @@ export function initTextExpander(expander: TextExpanderElement) {
if (!expander) return; if (!expander) return;
const textarea = expander.querySelector<HTMLTextAreaElement>('textarea')!; const textarea = expander.querySelector<HTMLTextAreaElement>('textarea')!;
const mentionsUrl = expander.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
// help to fix the text-expander "multiword+promise" bug: do not show the popup when there is no "#" before current line // help to fix the text-expander "multiword+promise" bug: do not show the popup when there is no "#" before current line
const shouldShowIssueSuggestions = () => { const shouldShowIssueSuggestions = () => {
@@ -83,36 +84,39 @@ export function initTextExpander(expander: TextExpanderElement) {
provide({matched: true, fragment: ul}); provide({matched: true, fragment: ul});
} else if (key === '@') { } else if (key === '@') {
const matches = matchMention(text); provide((async (): Promise<TextExpanderResult> => {
if (!matches.length) return provide({matched: false}); if (!mentionsUrl) return {matched: false};
const matches = await matchMention(mentionsUrl, text);
if (!matches.length) return {matched: false};
const ul = document.createElement('ul'); const ul = document.createElement('ul');
ul.classList.add('suggestions'); ul.classList.add('suggestions');
for (const {value, name, fullname, avatar} of matches) { for (const {value, name, fullname, avatar} of matches) {
const li = document.createElement('li'); const li = document.createElement('li');
li.setAttribute('role', 'option'); li.setAttribute('role', 'option');
li.setAttribute('data-value', `${key}${value}`); li.setAttribute('data-value', `${key}${value}`);
const img = document.createElement('img'); const img = document.createElement('img');
img.src = avatar; img.src = avatar;
li.append(img); li.append(img);
const nameSpan = document.createElement('span'); const nameSpan = document.createElement('span');
nameSpan.classList.add('name'); nameSpan.classList.add('name');
nameSpan.textContent = name; nameSpan.textContent = name;
li.append(nameSpan); li.append(nameSpan);
if (fullname && fullname.toLowerCase() !== name) { if (fullname && fullname.toLowerCase() !== name) {
const fullnameSpan = document.createElement('span'); const fullnameSpan = document.createElement('span');
fullnameSpan.classList.add('fullname'); fullnameSpan.classList.add('fullname');
fullnameSpan.textContent = fullname; fullnameSpan.textContent = fullname;
li.append(fullnameSpan); li.append(fullnameSpan);
}
ul.append(li);
} }
ul.append(li); return {matched: true, fragment: ul};
} })());
provide({matched: true, fragment: ul});
} else if (key === '#') { } else if (key === '#') {
provide(debouncedIssueSuggestions(key, text)); provide(debouncedIssueSuggestions(key, text));
} }
+7 -3
View File
@@ -1,10 +1,12 @@
import {emojiKeys, emojiHTML, emojiString} from './emoji.ts'; import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
import {html, htmlRaw} from '../utils/html.ts'; import {html, htmlRaw} from '../utils/html.ts';
import {fetchMentions} from '../utils/match.ts';
import type {TributeCollection} from 'tributejs'; import type {TributeCollection} from 'tributejs';
import type {MentionValue} from '../types.ts'; import type {Mention} from '../types.ts';
export async function attachTribute(element: HTMLElement) { export async function attachTribute(element: HTMLElement) {
const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs'); const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
const mentionsUrl = element.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
const emojiCollection: TributeCollection<string> = { // emojis const emojiCollection: TributeCollection<string> = { // emojis
trigger: ':', trigger: ':',
@@ -29,8 +31,10 @@ export async function attachTribute(element: HTMLElement) {
}, },
}; };
const mentionCollection: TributeCollection<MentionValue> = { const mentionCollection: TributeCollection<Mention> = {
values: window.config.mentionValues, values: async (_query: string, cb: (matches: Mention[]) => void) => { // eslint-disable-line @typescript-eslint/no-misused-promises
cb(mentionsUrl ? await fetchMentions(mentionsUrl) : []);
},
requireLeadingSpace: true, requireLeadingSpace: true,
menuItemTemplate: (item) => { menuItemTemplate: (item) => {
const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : ''; const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : '';
-1
View File
@@ -29,7 +29,6 @@ interface Window {
pageData: Record<string, any>, pageData: Record<string, any>,
notificationSettings: Record<string, any>, notificationSettings: Record<string, any>,
enableTimeTracking: boolean, enableTimeTracking: boolean,
mentionValues: Array<import('./types.ts').MentionValue>,
mermaidMaxSourceCharacters: number, mermaidMaxSourceCharacters: number,
i18n: Record<string, string>, i18n: Record<string, string>,
}, },
+1 -1
View File
@@ -2,7 +2,7 @@ export type IntervalId = ReturnType<typeof setInterval>;
export type Intent = 'error' | 'warning' | 'info'; export type Intent = 'error' | 'warning' | 'info';
export type MentionValue = { export type Mention = {
key: string, key: string,
value: string, value: string,
name: string, name: string,
+19 -3
View File
@@ -1,5 +1,20 @@
import {GET} from '../modules/fetch.ts';
import {matchEmoji, matchMention} from './match.ts'; import {matchEmoji, matchMention} from './match.ts';
vi.mock('../modules/fetch.ts', () => ({
GET: vi.fn(),
}));
const testMentions = [
{key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},
{key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'},
{key: 'org3 User 3', value: 'org3', name: 'org3', fullname: 'User 3', avatar: 'https://avatar3.com'},
{key: 'user4 User 4', value: 'user4', name: 'user4', fullname: 'User 4', avatar: 'https://avatar4.com'},
{key: 'user5 User 5', value: 'user5', name: 'user5', fullname: 'User 5', avatar: 'https://avatar5.com'},
{key: 'org6 User 6', value: 'org6', name: 'org6', fullname: 'User 6', avatar: 'https://avatar6.com'},
{key: 'org7 User 7', value: 'org7', name: 'org7', fullname: 'User 7', avatar: 'https://avatar7.com'},
];
test('matchEmoji', () => { test('matchEmoji', () => {
expect(matchEmoji('')).toMatchInlineSnapshot(` expect(matchEmoji('')).toMatchInlineSnapshot(`
[ [
@@ -56,7 +71,8 @@ test('matchEmoji', () => {
`); `);
}); });
test('matchMention', () => { test('matchMention', async () => {
expect(matchMention('')).toEqual(window.config.mentionValues.slice(0, 6)); vi.mocked(GET).mockResolvedValue({ok: true, json: () => Promise.resolve(testMentions)} as Response);
expect(matchMention('user4')).toEqual([window.config.mentionValues[3]]); expect(await matchMention('/any-mentions', '')).toEqual(testMentions.slice(0, 6));
expect(await matchMention('/any-mentions', 'user4')).toEqual([testMentions[3]]);
}); });
+30 -5
View File
@@ -1,6 +1,8 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'}; import emojis from '../../../assets/emoji.json' with {type: 'json'};
import {GET} from '../modules/fetch.ts'; import {GET} from '../modules/fetch.ts';
import type {Issue} from '../types.ts'; import {showErrorToast} from '../modules/toast.ts';
import {parseIssuePageInfo} from '../utils.ts';
import type {Issue, Mention} from '../types.ts';
const maxMatches = 6; const maxMatches = 6;
@@ -29,13 +31,36 @@ export function matchEmoji(queryText: string): string[] {
return sortAndReduce(results); return sortAndReduce(results);
} }
type MentionSuggestion = {value: string; name: string; fullname: string; avatar: string}; let cachedMentionsPromise: Promise<Mention[]> | undefined;
export function matchMention(queryText: string): MentionSuggestion[] { let cachedMentionsUrl: string;
export function fetchMentions(mentionsUrl: string): Promise<Mention[]> {
if (cachedMentionsPromise && cachedMentionsUrl === mentionsUrl) {
return cachedMentionsPromise;
}
cachedMentionsUrl = mentionsUrl;
cachedMentionsPromise = (async () => {
try {
const issueIndex = parseIssuePageInfo().issueNumber;
const query = issueIndex ? `?issue_index=${issueIndex}` : '';
const res = await GET(`${mentionsUrl}${query}`);
if (!res.ok) throw new Error(res.statusText);
return await res.json() as Mention[];
} catch (e) {
showErrorToast(`Failed to load mentions: ${e}`);
return [];
}
})();
return cachedMentionsPromise;
}
export async function matchMention(mentionsUrl: string, queryText: string): Promise<Mention[]> {
const values = await fetchMentions(mentionsUrl);
const query = queryText.toLowerCase(); const query = queryText.toLowerCase();
// results is a map of weights, lower is better // results is a map of weights, lower is better
const results = new Map<MentionSuggestion, number>(); const results = new Map<Mention, number>();
for (const obj of window.config.mentionValues) { for (const obj of values) {
const index = obj.key.toLowerCase().indexOf(query); const index = obj.key.toLowerCase().indexOf(query);
if (index === -1) continue; if (index === -1) continue;
const existing = results.get(obj); const existing = results.get(obj);
-9
View File
@@ -10,15 +10,6 @@ window.config = {
pageData: {}, pageData: {},
notificationSettings: {}, notificationSettings: {},
enableTimeTracking: true, enableTimeTracking: true,
mentionValues: [
{key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},
{key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'},
{key: 'org3 User 3', value: 'org3', name: 'org3', fullname: 'User 3', avatar: 'https://avatar3.com'},
{key: 'user4 User 4', value: 'user4', name: 'user4', fullname: 'User 4', avatar: 'https://avatar4.com'},
{key: 'user5 User 5', value: 'user5', name: 'user5', fullname: 'User 5', avatar: 'https://avatar5.com'},
{key: 'org6 User 6', value: 'org6', name: 'org6', fullname: 'User 6', avatar: 'https://avatar6.com'},
{key: 'org7 User 7', value: 'org7', name: 'org7', fullname: 'User 7', avatar: 'https://avatar7.com'},
],
mermaidMaxSourceCharacters: 5000, mermaidMaxSourceCharacters: 5000,
i18n: {}, i18n: {},
}; };