mirror of
https://github.com/go-gitea/gitea.git
synced 2026-03-17 14:24:07 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
42
routers/web/org/mention.go
Normal file
42
routers/web/org/mention.go
Normal 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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
59
routers/web/repo/mention.go
Normal file
59
routers/web/repo/mention.go
Normal 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))
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
89
routers/web/shared/mention/mention.go
Normal file
89
routers/web/shared/mention/mention.go
Normal 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
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)}}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
)}}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>` : '';
|
||||
|
||||
1
web_src/js/globals.d.ts
vendored
1
web_src/js/globals.d.ts
vendored
@@ -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>,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user