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

View File

@@ -141,7 +141,7 @@ func NewFuncMap() template.FuncMap {
"ReactionToEmoji": reactionToEmoji,
// -----------------------------------------------------------------
// misc
// misc (TODO: move them to MiscUtils to avoid bloating the main func map)
"ShortSha": base.ShortSha,
"ActionContent2Commits": ActionContent2Commits,
"IsMultilineCommitMessage": isMultilineCommitMessage,

View File

@@ -14,10 +14,12 @@ import (
activities_model "code.gitea.io/gitea/models/activities"
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/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/svg"
"github.com/editorconfig/editorconfig-core-go/v2"
@@ -185,3 +187,49 @@ func tabSizeClass(ec *editorconfig.Editorconfig, filename string) string {
}
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
}

View File

@@ -6,14 +6,12 @@ package common
import (
"errors"
"fmt"
"net/http"
"path"
"strings"
"code.gitea.io/gitea/models/renderhelper"
"code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"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"
if mode == "" || mode == "markdown" {
// raw Markdown doesn't need any special handling
baseLink := urlPathContext
if baseLink == "" {
baseLink = fmt.Sprintf("%s%s", httplib.GuessCurrentHostURL(ctx), urlPathContext)
}
rctx := renderhelper.NewRenderContextSimpleDocument(ctx, baseLink).WithUseAbsoluteLink(true).
// raw Markdown doesn't do any special handling
// TODO: raw markdown doesn't do any link processing, so "urlPathContext" doesn't take effect
rctx := renderhelper.NewRenderContextSimpleDocument(ctx, urlPathContext).WithUseAbsoluteLink(true).
WithMarkupType(markdown.MarkupName)
if err := markdown.RenderRaw(rctx, strings.NewReader(text), ctx.Resp); err != nil {
log.Error("RenderMarkupRaw: %v", err)

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))
}

View File

@@ -14,7 +14,6 @@ import (
"code.gitea.io/gitea/models/db"
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/organization"
access_model "code.gitea.io/gitea/models/perm/access"
project_model "code.gitea.io/gitea/models/project"
"code.gitea.io/gitea/models/renderhelper"
@@ -649,47 +648,3 @@ func attachmentsHTML(ctx *context.Context, attachments []*repo_model.Attachment,
}
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)
}

View File

@@ -662,10 +662,7 @@ func prepareIssueFilterAndList(ctx *context.Context, milestoneID, projectID int6
ctx.ServerError("GetRepoAssignees", err)
return
}
handleMentionableAssigneesAndTeams(ctx, shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers))
if ctx.Written() {
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
ctx.Data["IssueRefEndNames"], ctx.Data["IssueRefURLs"] = issue_service.GetRefEndNamesAndURLs(issues, ctx.Repo.RepoLink)

View File

@@ -155,8 +155,7 @@ func (d *IssuePageMetaData) retrieveAssigneesData(ctx *context.Context) {
}
d.AssigneesData.SelectedAssigneeIDs = strings.Join(ids, ",")
}
// FIXME: this is a tricky part which writes ctx.Data["Mentionable*"]
handleMentionableAssigneesAndTeams(ctx, d.AssigneesData.CandidateAssignees)
ctx.Data["Assignees"] = d.AssigneesData.CandidateAssignees
}
func (d *IssuePageMetaData) retrieveProjectsDataForIssueWriter(ctx *context.Context) {

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))
}

View File

@@ -913,10 +913,7 @@ func viewPullFiles(ctx *context.Context, beforeCommitID, afterCommitID string) {
ctx.ServerError("GetRepoAssignees", err)
return
}
handleMentionableAssigneesAndTeams(ctx, shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers))
if ctx.Written() {
return
}
ctx.Data["Assignees"] = shared_user.MakeSelfOnTop(ctx.Doer, assigneeUsers)
currentReview, err := issues_model.GetCurrentReview(ctx, ctx.Doer, issue)
if err != nil && !issues_model.IsErrReviewNotExist(err) {

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
}

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
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)
reqUnitIssuesReader := context.RequireUnitReader(unit.TypeIssues)
reqUnitPullsReader := context.RequireUnitReader(unit.TypePullRequests)
@@ -1025,6 +1026,9 @@ func registerWebRoutes(m *web.Router) {
}, 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("/heatmap", user.DashboardHeatmap)
@@ -1074,6 +1078,11 @@ func registerWebRoutes(m *web.Router) {
}, optSignIn, context.RepoAssignment, reqUnitCodeReader)
// 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("", func() {
m.Combo("").Get(repo_setting.Settings).

View File

@@ -105,6 +105,7 @@ func NewTemplateContextForWeb(ctx reqctx.RequestContext, req *http.Request, loca
tmplCtx["Locale"] = locale
tmplCtx["AvatarUtils"] = templates.NewAvatarUtils(ctx)
tmplCtx["RenderUtils"] = templates.NewRenderUtils(ctx)
tmplCtx["MiscUtils"] = templates.NewMiscUtils(ctx)
tmplCtx["RootData"] = ctx.GetData()
tmplCtx["Consts"] = map[string]any{
"RepoUnitTypeCode": unit.TypeCode,

View File

@@ -16,21 +16,6 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
pageData: {{.PageData}},
notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}
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}},
{{/* 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: {

View File

@@ -184,7 +184,7 @@
<div>
<h1>ComboMarkdownEditor</h1>
{{template "shared/combomarkdowneditor" dict "MarkdownPreviewContext" "/owner/path"}}
{{template "shared/combomarkdowneditor" dict "MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorGeneral nil)}}
</div>
<h1>Tailwind CSS Demo</h1>

View File

@@ -18,11 +18,10 @@
<div class="field">
<label>{{ctx.Locale.Tr "repo.projects.description"}}</label>
{{/* 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
"MarkdownPreviewInRepo" $.Repository
"MarkdownPreviewContext" (Iif $.Repository "" .HomeLink)
"MarkdownPreviewMode" (Iif $.Repository "comment")
"MarkdownEditorContext" $markdownEditorContext
"TextareaName" "content"
"TextareaContent" .content
"TextareaPlaceholder" (ctx.Locale.Tr "repo.projects.description_placeholder")

View File

@@ -227,8 +227,7 @@
<div class="field">
{{template "shared/combomarkdowneditor" (dict
"CustomInit" true
"MarkdownPreviewInRepo" $.Repository
"MarkdownPreviewMode" "comment"
"MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment $.Repository)
"TextareaName" "content"
"DropzoneParentContainer" ".ui.form"
)}}

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">
<input type="hidden" name="origin" value="{{if $.root.PageIsPullFiles}}diff{{else}}timeline{{end}}">
<input type="hidden" name="latest_commit_id" value="{{$.root.AfterCommitID}}">
@@ -11,8 +11,7 @@
<div class="field">
{{template "shared/combomarkdowneditor" (dict
"CustomInit" true
"MarkdownPreviewInRepo" $.root.Repository
"MarkdownPreviewMode" "comment"
"MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment ctx.RootData.Repository)
"TextareaName" "content"
"TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.comment.placeholder")
"DropzoneParentContainer" "form"

View File

@@ -19,8 +19,7 @@
</div>
<div class="field">
{{template "shared/combomarkdowneditor" (dict
"MarkdownPreviewInRepo" $.Repository
"MarkdownPreviewMode" "comment"
"MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment $.Repository)
"TextareaName" "content"
"TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.review.placeholder")
"DropzoneParentContainer" "form"

View File

@@ -6,8 +6,7 @@
<div class="field">
{{template "shared/combomarkdowneditor" (dict
"CustomInit" true
"MarkdownPreviewInRepo" $.Repository
"MarkdownPreviewMode" "comment"
"MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment $.Repository)
"TextareaName" "content"
"TextareaContent" $textareaContent
"TextareaPlaceholder" (ctx.Locale.Tr "repo.diff.comment.placeholder")

View File

@@ -9,8 +9,7 @@
{{template "shared/combomarkdowneditor" (dict
"CustomInit" true
"ContainerClasses" "tw-hidden"
"MarkdownPreviewInRepo" $.root.Repository
"MarkdownPreviewMode" "comment"
"MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment ctx.RootData.Repository)
"TextareaContent" .item.Attributes.value
"TextareaPlaceholder" .item.Attributes.placeholder
"DropzoneParentContainer" ".combo-editor-dropzone"

View File

@@ -36,8 +36,7 @@
<div class="field">
<label>{{ctx.Locale.Tr "repo.milestones.desc"}}</label>
{{template "shared/combomarkdowneditor" (dict
"MarkdownPreviewInRepo" $.Repository
"MarkdownPreviewMode" "comment"
"MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment $.Repository)
"TextareaName" "content"
"TextareaContent" .content
"TextareaPlaceholder" (ctx.Locale.Tr "repo.milestones.desc")

View File

@@ -147,8 +147,7 @@
<div class="field">
{{template "shared/combomarkdowneditor" (dict
"CustomInit" true
"MarkdownPreviewInRepo" $.Repository
"MarkdownPreviewMode" "comment"
"MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment $.Repository)
"TextareaName" "content"
"DropzoneParentContainer" ".ui.form"
)}}

View File

@@ -69,8 +69,7 @@
</div>
<div class="field">
{{template "shared/combomarkdowneditor" (dict
"MarkdownPreviewInRepo" $.Repository
"MarkdownPreviewMode" "comment"
"MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorComment $.Repository)
"TextareaName" "content"
"TextareaContent" .content
"TextareaPlaceholder" (ctx.Locale.Tr "repo.release.message")

View File

@@ -23,8 +23,7 @@
{{end}}
{{template "shared/combomarkdowneditor" (dict
"CustomInit" true
"MarkdownPreviewInRepo" $.Repository
"MarkdownPreviewMode" "wiki"
"MarkdownEditorContext" (ctx.MiscUtils.MarkdownEditorWiki $.Repository)
"TextareaName" "content"
"TextareaContent" $content
"TextareaPlaceholder" (ctx.Locale.Tr "repo.wiki.page_content")

View File

@@ -2,9 +2,7 @@
* CustomInit: do not initialize the editor automatically
* ContainerId: id attribute for the container element
* ContainerClasses: additional classes for the container element
* MarkdownPreviewInRepo: the repo to preview markdown
* 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"
* MarkdownEditorContext: the context data for the editor, see backend MarkdownEditorContext
* TextareaName: name attribute for the textarea
* TextareaContent: content for the textarea
* TextareaMaxLength: maxlength attribute for the textarea
@@ -14,20 +12,19 @@
* DisableAutosize: whether to disable automatic height resizing
*/}}
{{$ariaLabel := or .TextareaAriaLabel .TextareaPlaceholder}}
{{$repo := .MarkdownPreviewInRepo}}
{{$previewContext := .MarkdownPreviewContext}}
{{$previewMode := .MarkdownPreviewMode}}
{{$previewUrl := print AppSubUrl "/-/markup"}}
{{if $repo}}
{{$previewUrl = print $repo.Link "/markup"}}
{{end}}
{{$editorContext := .MarkdownEditorContext}}
{{$previewMode := or $editorContext.PreviewMode ""}}
{{$previewContext := or $editorContext.PreviewContext ""}}
{{$previewLink := or $editorContext.PreviewLink (print AppSubUrl "/-/markup")}}
{{$mentionsLink := or $editorContext.MentionsLink ""}}
{{$supportEasyMDE := or (eq $previewMode "comment") (eq $previewMode "wiki")}}
<div {{if .ContainerId}}id="{{.ContainerId}}"{{end}} class="combo-markdown-editor {{if .CustomInit}}custom-init{{end}} {{.ContainerClasses}}"
data-dropzone-parent-container="{{.DropzoneParentContainer}}"
data-content-mode="{{$previewMode}}"
data-support-easy-mde="{{$supportEasyMDE}}"
data-preview-url="{{$previewUrl}}"
data-preview-url="{{$previewLink}}"
data-preview-context="{{$previewContext}}"
{{if $mentionsLink}}data-mentions-url="{{$mentionsLink}}"{{end}}
>
{{if ne $previewMode "none"}}
<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>
<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>
{{if eq $previewMode "comment"}}
{{if $mentionsLink}}
<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-ref class="markdown-toolbar-button" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.ref.tooltip"}}">{{svg "octicon-cross-reference"}}</md-ref>

View File

@@ -38,6 +38,7 @@ export function initTextExpander(expander: TextExpanderElement) {
if (!expander) return;
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
const shouldShowIssueSuggestions = () => {
@@ -83,36 +84,39 @@ export function initTextExpander(expander: TextExpanderElement) {
provide({matched: true, fragment: ul});
} else if (key === '@') {
const matches = matchMention(text);
if (!matches.length) return provide({matched: false});
provide((async (): Promise<TextExpanderResult> => {
if (!mentionsUrl) return {matched: false};
const matches = await matchMention(mentionsUrl, text);
if (!matches.length) return {matched: false};
const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const {value, name, fullname, avatar} of matches) {
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('data-value', `${key}${value}`);
const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const {value, name, fullname, avatar} of matches) {
const li = document.createElement('li');
li.setAttribute('role', 'option');
li.setAttribute('data-value', `${key}${value}`);
const img = document.createElement('img');
img.src = avatar;
li.append(img);
const img = document.createElement('img');
img.src = avatar;
li.append(img);
const nameSpan = document.createElement('span');
nameSpan.classList.add('name');
nameSpan.textContent = name;
li.append(nameSpan);
const nameSpan = document.createElement('span');
nameSpan.classList.add('name');
nameSpan.textContent = name;
li.append(nameSpan);
if (fullname && fullname.toLowerCase() !== name) {
const fullnameSpan = document.createElement('span');
fullnameSpan.classList.add('fullname');
fullnameSpan.textContent = fullname;
li.append(fullnameSpan);
if (fullname && fullname.toLowerCase() !== name) {
const fullnameSpan = document.createElement('span');
fullnameSpan.classList.add('fullname');
fullnameSpan.textContent = fullname;
li.append(fullnameSpan);
}
ul.append(li);
}
ul.append(li);
}
provide({matched: true, fragment: ul});
return {matched: true, fragment: ul};
})());
} else if (key === '#') {
provide(debouncedIssueSuggestions(key, text));
}

View File

@@ -1,10 +1,12 @@
import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
import {html, htmlRaw} from '../utils/html.ts';
import {fetchMentions} from '../utils/match.ts';
import type {TributeCollection} from 'tributejs';
import type {MentionValue} from '../types.ts';
import type {Mention} from '../types.ts';
export async function attachTribute(element: HTMLElement) {
const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
const mentionsUrl = element.closest('[data-mentions-url]')?.getAttribute('data-mentions-url');
const emojiCollection: TributeCollection<string> = { // emojis
trigger: ':',
@@ -29,8 +31,10 @@ export async function attachTribute(element: HTMLElement) {
},
};
const mentionCollection: TributeCollection<MentionValue> = {
values: window.config.mentionValues,
const mentionCollection: TributeCollection<Mention> = {
values: async (_query: string, cb: (matches: Mention[]) => void) => { // eslint-disable-line @typescript-eslint/no-misused-promises
cb(mentionsUrl ? await fetchMentions(mentionsUrl) : []);
},
requireLeadingSpace: true,
menuItemTemplate: (item) => {
const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : '';

View File

@@ -29,7 +29,6 @@ interface Window {
pageData: Record<string, any>,
notificationSettings: Record<string, any>,
enableTimeTracking: boolean,
mentionValues: Array<import('./types.ts').MentionValue>,
mermaidMaxSourceCharacters: number,
i18n: Record<string, string>,
},

View File

@@ -2,7 +2,7 @@ export type IntervalId = ReturnType<typeof setInterval>;
export type Intent = 'error' | 'warning' | 'info';
export type MentionValue = {
export type Mention = {
key: string,
value: string,
name: string,

View File

@@ -1,5 +1,20 @@
import {GET} from '../modules/fetch.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', () => {
expect(matchEmoji('')).toMatchInlineSnapshot(`
[
@@ -56,7 +71,8 @@ test('matchEmoji', () => {
`);
});
test('matchMention', () => {
expect(matchMention('')).toEqual(window.config.mentionValues.slice(0, 6));
expect(matchMention('user4')).toEqual([window.config.mentionValues[3]]);
test('matchMention', async () => {
vi.mocked(GET).mockResolvedValue({ok: true, json: () => Promise.resolve(testMentions)} as Response);
expect(await matchMention('/any-mentions', '')).toEqual(testMentions.slice(0, 6));
expect(await matchMention('/any-mentions', 'user4')).toEqual([testMentions[3]]);
});

View File

@@ -1,6 +1,8 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'};
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;
@@ -29,13 +31,36 @@ export function matchEmoji(queryText: string): string[] {
return sortAndReduce(results);
}
type MentionSuggestion = {value: string; name: string; fullname: string; avatar: string};
export function matchMention(queryText: string): MentionSuggestion[] {
let cachedMentionsPromise: Promise<Mention[]> | undefined;
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();
// results is a map of weights, lower is better
const results = new Map<MentionSuggestion, number>();
for (const obj of window.config.mentionValues) {
const results = new Map<Mention, number>();
for (const obj of values) {
const index = obj.key.toLowerCase().indexOf(query);
if (index === -1) continue;
const existing = results.get(obj);

View File

@@ -10,15 +10,6 @@ window.config = {
pageData: {},
notificationSettings: {},
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,
i18n: {},
};