From ac82797cd8630275d61bab689f077187b9326277 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 7 May 2026 21:52:54 +0200 Subject: [PATCH 01/18] feat(repo): add co-author parsing and avatar components Extract and harden co-author trailer parsing, add reusable co-author avatar templates, and wire commit/repo views to render co-author metadata. --- models/user/user.go | 46 +++++++++++++- modules/git/commit.go | 53 ++++++++++++++-- options/locale/locale_en-US.json | 4 ++ routers/web/repo/commit.go | 5 ++ routers/web/repo/view.go | 6 ++ .../repo/commit_coauthor_avatar_stack.tmpl | 49 +++++++++++++++ templates/repo/commit_coauthor_avatars.tmpl | 43 +++++++++++++ templates/repo/commit_page.tmpl | 15 +++++ templates/repo/commits_list.tmpl | 14 +---- templates/repo/latest_commit.tmpl | 10 +-- web_src/css/repo.css | 63 ++++++++++++++++++- 11 files changed, 280 insertions(+), 28 deletions(-) create mode 100644 templates/repo/commit_coauthor_avatar_stack.tmpl create mode 100644 templates/repo/commit_coauthor_avatars.tmpl diff --git a/models/user/user.go b/models/user/user.go index 8a39eca634e43..7a45d7087889a 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -1140,9 +1140,16 @@ func GetUsersBySource(ctx context.Context, s *auth.Source) ([]*User, error) { return users, err } +// CoAuthorUser represents a co-author parsed from a commit trailer, with optional Gitea user. +type CoAuthorUser struct { + GiteaUser *User + TrailerSignature *git.Signature +} + // UserCommit represents a commit with validation of user. type UserCommit struct { //revive:disable-line:exported - User *User + User *User + CoAuthors []*CoAuthorUser *git.Commit } @@ -1158,6 +1165,27 @@ func ValidateCommitWithEmail(ctx context.Context, c *git.Commit) *User { return u } +// CoAuthorsFromCommit resolves co-author signatures from a commit into CoAuthorUser values. +func CoAuthorsFromCommit(ctx context.Context, c *git.Commit) ([]*CoAuthorUser, error) { + sigs := c.CoAuthorSignatures() + if len(sigs) == 0 { + return nil, nil + } + emails := make([]string, len(sigs)) + for i, sig := range sigs { + emails[i] = sig.Email + } + emailUserMap, err := GetUsersByEmails(ctx, emails) + if err != nil { + return nil, err + } + coAuthors := make([]*CoAuthorUser, len(sigs)) + for i, sig := range sigs { + coAuthors[i] = &CoAuthorUser{GiteaUser: emailUserMap.GetByEmail(sig.Email), TrailerSignature: sig} + } + return coAuthors, nil +} + // ValidateCommitsWithEmails checks if authors' e-mails of commits are corresponding to users. func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) ([]*UserCommit, error) { var ( @@ -1168,6 +1196,9 @@ func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) ([ if c.Author != nil { emailSet.Add(c.Author.Email) } + for _, sig := range c.CoAuthorSignatures() { + emailSet.Add(sig.Email) + } } emailUserMap, err := GetUsersByEmails(ctx, emailSet.Values()) @@ -1177,9 +1208,18 @@ func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) ([ for _, c := range oldCommits { user := emailUserMap.GetByEmail(c.Author.Email) // FIXME: why ValidateCommitsWithEmails uses "Author", but ParseCommitsWithSignature uses "Committer"? + coAuthorSigs := c.CoAuthorSignatures() + coAuthors := make([]*CoAuthorUser, 0, len(coAuthorSigs)) + for _, sig := range coAuthorSigs { + coAuthors = append(coAuthors, &CoAuthorUser{ + GiteaUser: emailUserMap.GetByEmail(sig.Email), + TrailerSignature: sig, + }) + } newCommits = append(newCommits, &UserCommit{ - User: user, - Commit: c, + User: user, + CoAuthors: coAuthors, + Commit: c, }) } return newCommits, nil diff --git a/modules/git/commit.go b/modules/git/commit.go index b576451db8703..34e6d64b8606d 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -17,10 +17,11 @@ import ( ) type CommitMessage struct { - MessageRaw string - messageUTF8 *string - messageTitle *string - messageBody *string + MessageRaw string + messageUTF8 *string + messageTitle *string + messageBody *string + messageCoAuthors *[]*Signature } // Commit represents a git commit. @@ -68,6 +69,50 @@ func (c *CommitMessage) MessageBody() string { return *c.messageBody } +// CoAuthorSignatures parses "Co-authored-by:" and "Co-committed-by:" trailers +// from the trailing block of the commit message and returns deduplicated +// Signature values. Only the last paragraph of the body is scanned so that +// quoted or in-body occurrences (e.g. inside a revert/cherry-pick description) +// are not misinterpreted as trailers, matching `git interpret-trailers`. +func (c *CommitMessage) CoAuthorSignatures() []*Signature { + if c.messageCoAuthors != nil { + return *c.messageCoAuthors + } + var sigs []*Signature + seen := make(map[string]struct{}) + body := strings.TrimRight(c.MessageBody(), "\n") + if idx := strings.LastIndex(body, "\n\n"); idx >= 0 { + body = body[idx+2:] + } + for line := range strings.SplitSeq(body, "\n") { + var rest string + var ok bool + if rest, ok = strings.CutPrefix(line, "Co-authored-by:"); !ok { + rest, ok = strings.CutPrefix(line, "Co-committed-by:") + if !ok { + continue + } + } + rest = strings.TrimSpace(rest) + name, emailWithBracket, ok := strings.Cut(rest, " <") + if !ok { + continue + } + email, _, ok := strings.Cut(emailWithBracket, ">") + if !ok { + continue + } + email = strings.ToLower(strings.TrimSpace(email)) + if _, exists := seen[email]; exists { + continue + } + seen[email] = struct{}{} + sigs = append(sigs, &Signature{Name: strings.TrimSpace(name), Email: email}) + } + c.messageCoAuthors = &sigs + return sigs +} + // ParentID returns oid of n-th parent (0-based index). // It returns nil if no such parent exists. func (c *Commit) ParentID(n int) (ObjectID, error) { diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c7ec133e5732c..18acdb059c18e 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2589,6 +2589,10 @@ "repo.diff.review.reject": "Request changes", "repo.diff.review.self_approve": "Pull request authors can't approve their own pull request", "repo.diff.committed_by": "committed by", + "repo.diff.coauthored_by": "co-authored by", + "repo.commits.coauthor_and": "and", + "repo.commits.coauthor_others_1": "%d other", + "repo.commits.coauthor_others_n": "%d others", "repo.diff.protected": "Protected", "repo.diff.image.side_by_side": "Side by Side", "repo.diff.image.swipe": "Swipe", diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go index 6c973696ffef6..b54a1dbf9b943 100644 --- a/routers/web/repo/commit.go +++ b/routers/web/repo/commit.go @@ -382,6 +382,11 @@ func Diff(ctx *context.Context) { verification := asymkey_service.ParseCommitWithSignature(ctx, commit) ctx.Data["Verification"] = verification ctx.Data["Author"] = user_model.ValidateCommitWithEmail(ctx, commit) + if coAuthors, err := user_model.CoAuthorsFromCommit(ctx, commit); err != nil { + log.Error("CoAuthorsFromCommit: %v", err) + } else { + ctx.Data["CoAuthors"] = coAuthors + } ctx.Data["Parents"] = parents ctx.Data["DiffNotAvailable"] = diffShortStat.NumFiles == 0 diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go index 2d95d5233e36c..d0cf7d6f6fbfa 100644 --- a/routers/web/repo/view.go +++ b/routers/web/repo/view.go @@ -135,6 +135,12 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool { ctx.Data["LatestCommitVerification"] = verification ctx.Data["LatestCommitUser"] = user_model.ValidateCommitWithEmail(ctx, latestCommit) + if coAuthors, err := user_model.CoAuthorsFromCommit(ctx, latestCommit); err != nil { + log.Error("CoAuthorsFromCommit: %v", err) + } else { + ctx.Data["LatestCommitCoAuthors"] = coAuthors + } + statuses, err := git_model.GetLatestCommitStatus(ctx, ctx.Repo.Repository.ID, latestCommit.ID.String(), db.ListOptionsAll) if err != nil { log.Error("GetLatestCommitStatus: %v", err) diff --git a/templates/repo/commit_coauthor_avatar_stack.tmpl b/templates/repo/commit_coauthor_avatar_stack.tmpl new file mode 100644 index 0000000000000..772e054aaac38 --- /dev/null +++ b/templates/repo/commit_coauthor_avatar_stack.tmpl @@ -0,0 +1,49 @@ +{{if or .CoAuthors .CoAuthorSignatures}} + {{- $additionalClasses := .AdditionalClasses -}} + + {{- if .AuthorUser -}} + {{- ctx.AvatarUtils.Avatar .AuthorUser 20 -}} + {{- else -}} + {{- ctx.AvatarUtils.AvatarByEmail .AuthorSignature.Email .AuthorSignature.Name 20 -}} + {{- end -}} + {{- if .CoAuthors -}} + {{- $coCount := len .CoAuthors -}} + {{- $maxCo := 9 -}} + {{- $visibleCo := .CoAuthors -}} + {{- $overflow := 0 -}} + {{- if gt $coCount $maxCo -}} + {{- $visibleCo = slice .CoAuthors 0 $maxCo -}} + {{- $overflow = Eval $coCount "-" $maxCo -}} + {{- end -}} + {{- range $visibleCo -}} + {{- if .GiteaUser -}} + {{- ctx.AvatarUtils.Avatar .GiteaUser 20 -}} + {{- else -}} + {{- ctx.AvatarUtils.AvatarByEmail .TrailerSignature.Email .TrailerSignature.Name 20 -}} + {{- end -}} + {{- end -}} + {{- if gt $overflow 0 -}} + +{{$overflow}} + {{- end -}} + {{- else -}} + {{- $coCount := len .CoAuthorSignatures -}} + {{- $maxCo := 9 -}} + {{- $visibleCo := .CoAuthorSignatures -}} + {{- $overflow := 0 -}} + {{- if gt $coCount $maxCo -}} + {{- $visibleCo = slice .CoAuthorSignatures 0 $maxCo -}} + {{- $overflow = Eval $coCount "-" $maxCo -}} + {{- end -}} + {{- range $visibleCo -}} + {{- ctx.AvatarUtils.AvatarByEmail .Email .Name 20 -}} + {{- end -}} + {{- if gt $overflow 0 -}} + +{{$overflow}} + {{- end -}} + {{- end -}} + +{{else if .AuthorUser}} + {{- ctx.AvatarUtils.Avatar .AuthorUser 20 .AdditionalClasses -}} +{{else}} + {{- ctx.AvatarUtils.AvatarByEmail .AuthorSignature.Email .AuthorSignature.Name 20 .AdditionalClasses -}} +{{end}} diff --git a/templates/repo/commit_coauthor_avatars.tmpl b/templates/repo/commit_coauthor_avatars.tmpl new file mode 100644 index 0000000000000..04c04d5186f06 --- /dev/null +++ b/templates/repo/commit_coauthor_avatars.tmpl @@ -0,0 +1,43 @@ +{{/* + Renders the author/co-author avatar stack and name text for a commit. + Args (via dict): + AuthorUser - *user_model.User (may be nil if no Gitea account) + AuthorSignature - *git.Signature (always set: name + email from git) + CoAuthors - []*user_model.CoAuthorUser + fields: GiteaUser (*User, may be nil), TrailerSignature (*git.Signature) + + Avatar stack is capped at 10 children (author + up to 9 co-authors); any + remainder is collapsed into a trailing "+N" chip. The .coauthor-avatar-stack + CSS rules expect at most 10 children to fan out cleanly on hover. +*/}} + +{{- if .CoAuthors -}} + {{- $coCount := len .CoAuthors -}} + {{template "repo/commit_coauthor_avatar_stack" dict "AuthorUser" .AuthorUser "AuthorSignature" .AuthorSignature "CoAuthors" .CoAuthors}} + {{- if .AuthorUser -}} + {{- .AuthorUser.GetShortDisplayNameLinkHTML -}} + {{- else -}} + {{.AuthorSignature.Name}} + {{- end -}} + {{" "}}{{ctx.Locale.Tr "repo.commits.coauthor_and"}}{{" "}} + {{- if eq $coCount 1 -}} + {{- with index .CoAuthors 0 -}} + {{- if .GiteaUser -}} + {{.GiteaUser.GetDisplayName}} + {{- else -}} + {{.TrailerSignature.Name}} + {{- end -}} + {{- end -}} + {{- else -}} + {{- ctx.Locale.TrN $coCount "repo.commits.coauthor_others_1" "repo.commits.coauthor_others_n" $coCount -}} + {{- end -}} +{{- else -}} + {{- if .AuthorUser -}} + {{- template "repo/commit_coauthor_avatar_stack" dict "AuthorUser" .AuthorUser "AuthorSignature" .AuthorSignature "AdditionalClasses" "tw-mr-1" -}} + {{- .AuthorUser.GetShortDisplayNameLinkHTML -}} + {{- else -}} + {{- template "repo/commit_coauthor_avatar_stack" dict "AuthorSignature" .AuthorSignature "AdditionalClasses" "tw-mr-1" -}} + {{.AuthorSignature.Name}} + {{- end -}} +{{- end -}} + diff --git a/templates/repo/commit_page.tmpl b/templates/repo/commit_page.tmpl index 975cd303ec4fc..cda0d297237e9 100644 --- a/templates/repo/commit_page.tmpl +++ b/templates/repo/commit_page.tmpl @@ -155,6 +155,21 @@ {{end}} + {{if .CoAuthors}} +
+ {{ctx.Locale.Tr "repo.diff.coauthored_by"}} + {{range .CoAuthors}} + {{if .GiteaUser}} + {{ctx.AvatarUtils.Avatar .GiteaUser 20}} + {{.GiteaUser.GetDisplayName}} + {{else}} + {{ctx.AvatarUtils.AvatarByEmail .TrailerSignature.Email .TrailerSignature.Name 20}} + {{.TrailerSignature.Name}} + {{end}} + {{end}} +
+ {{end}} + {{if .Verification}} {{template "repo/commit_sign_badge" dict "CommitSignVerification" .Verification}} {{end}} diff --git a/templates/repo/commits_list.tmpl b/templates/repo/commits_list.tmpl index e79d189b8d3fb..e80b6cdc24ec5 100644 --- a/templates/repo/commits_list.tmpl +++ b/templates/repo/commits_list.tmpl @@ -2,9 +2,9 @@ - + - + @@ -14,15 +14,7 @@ {{range $commit := .Commits}} ` } + +// CoAuthorAvatarStack renders an avatar stack for the commit author and co-authors. +// authorUser may be nil when no Gitea account is linked; authorSig must always be set. +func (ut *RenderUtils) CoAuthorAvatarStack(authorUser *user_model.User, authorSig *git.Signature, coAuthors []*user_model.CoAuthorUser, additionalClasses string) template.HTML { + au := NewAvatarUtils(ut.ctx) + if len(coAuthors) == 0 { + if authorUser != nil { + return au.Avatar(authorUser, 20, additionalClasses) + } + return au.AvatarByEmail(authorSig.Email, authorSig.Name, 20, additionalClasses) + } + + const maxCo = 9 + visibleCo := coAuthors + overflow := 0 + if len(coAuthors) > maxCo { + visibleCo = coAuthors[:maxCo] + overflow = len(coAuthors) - maxCo + } + + wrapperClass := "coauthor-avatar-stack-wrapper" + if additionalClasses != "" { + wrapperClass += " " + additionalClasses + } + + var b strings.Builder + b.WriteString(``) + b.WriteString(``) + + if authorUser != nil { + b.WriteString(string(htmlutil.HTMLFormat(`%s`, + template.URL(authorUser.HomeLink()), authorUser.GetDisplayName(), au.Avatar(authorUser, 20)))) + } else { + b.WriteString(string(au.AvatarByEmail(authorSig.Email, authorSig.Name, 20))) + } + + for _, co := range visibleCo { + if co.GiteaUser != nil { + b.WriteString(string(htmlutil.HTMLFormat(`%s`, + template.URL(co.GiteaUser.HomeLink()), co.GiteaUser.GetDisplayName(), au.Avatar(co.GiteaUser, 20)))) + } else { + b.WriteString(string(au.AvatarByEmail(co.TrailerSignature.Email, co.TrailerSignature.Name, 20))) + } + } + + b.WriteString(``) // end coauthor-avatar-stack + + if overflow > 0 { + locale := ut.ctx.Value(translation.ContextKey).(translation.Locale) + overflowLabel := locale.TrN(overflow, "repo.commits.coauthor_others_1", "repo.commits.coauthor_others_n", overflow) + b.WriteString(string(htmlutil.HTMLFormat(`+%d`, + overflowLabel, overflowLabel, overflow))) + } + + b.WriteString(``) // end coauthor-avatar-stack-wrapper + return template.HTML(b.String()) +} + +// CoAuthorAvatars renders the author/co-author avatar stack with descriptive name text. +func (ut *RenderUtils) CoAuthorAvatars(authorUser *user_model.User, authorSig *git.Signature, coAuthors []*user_model.CoAuthorUser) template.HTML { + locale := ut.ctx.Value(translation.ContextKey).(translation.Locale) + var b strings.Builder + b.WriteString(``) + + if len(coAuthors) > 0 { + b.WriteString(string(ut.CoAuthorAvatarStack(authorUser, authorSig, coAuthors, ""))) + if authorUser != nil { + b.WriteString(string(authorUser.GetShortDisplayNameLinkHTML())) + } else { + b.WriteString(html.EscapeString(authorSig.Name)) + } + b.WriteString(" " + string(locale.Tr("repo.commits.coauthor_and")) + " ") + if len(coAuthors) == 1 { + co := coAuthors[0] + if co.GiteaUser != nil { + b.WriteString(string(htmlutil.HTMLFormat(`%s`, + template.URL(co.GiteaUser.HomeLink()), co.GiteaUser.GetDisplayName()))) + } else { + b.WriteString(html.EscapeString(co.TrailerSignature.Name)) + } + } else { + b.WriteString(string(locale.Tr("repo.commits.coauthor_people", len(coAuthors)+1))) + } + } else { + b.WriteString(string(ut.CoAuthorAvatarStack(authorUser, authorSig, nil, "tw-mr-1"))) + if authorUser != nil { + b.WriteString(string(authorUser.GetShortDisplayNameLinkHTML())) + } else { + b.WriteString(html.EscapeString(authorSig.Name)) + } + } + + b.WriteString(``) + return template.HTML(b.String()) +} diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index e6ae31891d6ce..eb2ab68ddf5a2 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -43,7 +43,7 @@
- {{template "repo/commit_coauthor_avatar_stack" dict "AuthorUser" $row.AuthorUser "AuthorSignature" $row.Author "CoAuthors" $row.CoAuthors}} + {{ctx.RenderUtils.CoAuthorAvatarStack $row.AuthorUser $row.Author $row.CoAuthors ""}}
` } +// commitAuthorSearchURL returns the repo's commits-by-author search URL for the current +// ref, or an empty URL when no repo/ref context is available (e.g. dashboard feed). +func (ut *RenderUtils) commitAuthorSearchURL(authorName string) template.URL { + if authorName == "" { + return "" + } + data := ut.ctx.GetData() + repoLink, _ := data["RepoLink"].(string) + refSubURL, _ := data["RefTypeNameSubURL"].(string) + if repoLink == "" || refSubURL == "" { + return "" + } + return template.URL(repoLink + "/commits/" + refSubURL + "/search?q=" + url.QueryEscape("author:"+authorName)) +} + // CoAuthorAvatarStack renders an avatar stack for the commit author and co-authors. // authorUser may be nil when no Gitea account is linked; authorSig must always be set. +// Each stack child carries an inline `--n` custom property so the CSS can apply +// z-index and the :hover translate without hardcoding nth-child rules. func (ut *RenderUtils) CoAuthorAvatarStack(authorUser *user_model.User, authorSig *git.Signature, coAuthors []*user_model.CoAuthorUser, additionalClasses string) template.HTML { au := NewAvatarUtils(ut.ctx) if len(coAuthors) == 0 { @@ -347,78 +363,129 @@ func (ut *RenderUtils) CoAuthorAvatarStack(authorUser *user_model.User, authorSi overflow = len(coAuthors) - maxCo } - wrapperClass := "coauthor-avatar-stack-wrapper" + wrapperClass := "avatar-stack-wrapper" if additionalClasses != "" { wrapperClass += " " + additionalClasses } var b strings.Builder - b.WriteString(``) - b.WriteString(``) + b.WriteString(string(htmlutil.HTMLFormat(``, wrapperClass))) + + appendLinked := func(idx int, u *user_model.User) { + href := ut.commitAuthorSearchURL(u.Name) + if href == "" { + href = template.URL(u.HomeLink()) + } + b.WriteString(string(htmlutil.HTMLFormat(`%s`, + href, idx, au.Avatar(u, 20)))) + } + appendUnlinked := func(idx int, sig *git.Signature) { + if href := ut.commitAuthorSearchURL(sig.Name); href != "" { + b.WriteString(string(htmlutil.HTMLFormat(`%s`, + href, idx, au.AvatarByEmail(sig.Email, sig.Name, 20)))) + return + } + b.WriteString(string(htmlutil.HTMLFormat(`%s`, + idx, au.AvatarByEmail(sig.Email, sig.Name, 20)))) + } if authorUser != nil { - b.WriteString(string(htmlutil.HTMLFormat(`%s`, - template.URL(authorUser.HomeLink()), authorUser.GetDisplayName(), au.Avatar(authorUser, 20)))) + appendLinked(0, authorUser) } else { - b.WriteString(string(au.AvatarByEmail(authorSig.Email, authorSig.Name, 20))) + appendUnlinked(0, authorSig) } - - for _, co := range visibleCo { + for i, co := range visibleCo { if co.GiteaUser != nil { - b.WriteString(string(htmlutil.HTMLFormat(`%s`, - template.URL(co.GiteaUser.HomeLink()), co.GiteaUser.GetDisplayName(), au.Avatar(co.GiteaUser, 20)))) + appendLinked(i+1, co.GiteaUser) } else { - b.WriteString(string(au.AvatarByEmail(co.TrailerSignature.Email, co.TrailerSignature.Name, 20))) + appendUnlinked(i+1, co.TrailerSignature) } } - b.WriteString(``) // end coauthor-avatar-stack - if overflow > 0 { - locale := ut.ctx.Value(translation.ContextKey).(translation.Locale) - overflowLabel := locale.TrN(overflow, "repo.commits.coauthor_others_1", "repo.commits.coauthor_others_n", overflow) - b.WriteString(string(htmlutil.HTMLFormat(`+%d`, - overflowLabel, overflowLabel, overflow))) + b.WriteString(string(htmlutil.HTMLFormat( + `+%d`, + len(visibleCo)+1, overflow, overflow))) } - b.WriteString(``) // end coauthor-avatar-stack-wrapper + b.WriteString(``) return template.HTML(b.String()) } // CoAuthorAvatars renders the author/co-author avatar stack with descriptive name text. +// Label rules follow GitHub: 2 total = ` and `; 3+ total opens +// a popup listing all participants on click. func (ut *RenderUtils) CoAuthorAvatars(authorUser *user_model.User, authorSig *git.Signature, coAuthors []*user_model.CoAuthorUser) template.HTML { locale := ut.ctx.Value(translation.ContextKey).(translation.Locale) + stackClass := "" + if len(coAuthors) == 0 { + stackClass = "tw-mr-1" + } + var b strings.Builder b.WriteString(``) + b.WriteString(string(ut.CoAuthorAvatarStack(authorUser, authorSig, coAuthors, stackClass))) + + switch len(coAuthors) { + case 0: + b.WriteString(string(ut.authorNameLinkHTML(authorUser, authorSig))) + case 1: + b.WriteString(string(ut.authorNameLinkHTML(authorUser, authorSig))) + b.WriteString(string(htmlutil.HTMLFormat(`%s`, locale.Tr("repo.commits.coauthor_and")))) + b.WriteString(string(ut.authorNameLinkHTML(coAuthors[0].GiteaUser, coAuthors[0].TrailerSignature))) + default: + total := len(coAuthors) + 1 + b.WriteString(string(htmlutil.HTMLFormat( + `%s`, + locale.Tr("repo.commits.coauthor_people", total)))) + b.WriteString(`
`) + b.WriteString(string(ut.participantRowHTML(authorUser, authorSig))) + for _, co := range coAuthors { + b.WriteString(string(ut.participantRowHTML(co.GiteaUser, co.TrailerSignature))) + } + b.WriteString(`
`) + } - if len(coAuthors) > 0 { - b.WriteString(string(ut.CoAuthorAvatarStack(authorUser, authorSig, coAuthors, ""))) - if authorUser != nil { - b.WriteString(string(authorUser.GetShortDisplayNameLinkHTML())) - } else { - b.WriteString(html.EscapeString(authorSig.Name)) + b.WriteString(`
`) + return template.HTML(b.String()) +} + +// authorNameLinkHTML renders a muted link for an author. In a repo context the +// link targets the repo's commit search filtered by author; otherwise it falls +// back to the user's profile (or a mailto link when no Gitea account matches). +func (ut *RenderUtils) authorNameLinkHTML(u *user_model.User, sig *git.Signature) template.HTML { + if u != nil { + if href := ut.commitAuthorSearchURL(u.Name); href != "" { + return htmlutil.HTMLFormat(`%s`, href, u.GetDisplayName()) } - b.WriteString(" " + string(locale.Tr("repo.commits.coauthor_and")) + " ") - if len(coAuthors) == 1 { - co := coAuthors[0] - if co.GiteaUser != nil { - b.WriteString(string(htmlutil.HTMLFormat(`%s`, - template.URL(co.GiteaUser.HomeLink()), co.GiteaUser.GetDisplayName()))) - } else { - b.WriteString(html.EscapeString(co.TrailerSignature.Name)) - } - } else { - b.WriteString(string(locale.Tr("repo.commits.coauthor_people", len(coAuthors)+1))) + return u.GetShortDisplayNameLinkHTML() + } + if href := ut.commitAuthorSearchURL(sig.Name); href != "" { + return htmlutil.HTMLFormat(`%s`, href, sig.Name) + } + return htmlutil.HTMLFormat(`%s`, sig.Email, sig.Name) +} + +// participantRowHTML renders one row of the authors popup: avatar + linked name. +func (ut *RenderUtils) participantRowHTML(u *user_model.User, sig *git.Signature) template.HTML { + au := NewAvatarUtils(ut.ctx) + var href template.URL + var avatar template.HTML + var name string + if u != nil { + href = ut.commitAuthorSearchURL(u.Name) + if href == "" { + href = template.URL(u.HomeLink()) } + avatar = au.Avatar(u, 20) + name = u.GetDisplayName() } else { - b.WriteString(string(ut.CoAuthorAvatarStack(authorUser, authorSig, nil, "tw-mr-1"))) - if authorUser != nil { - b.WriteString(string(authorUser.GetShortDisplayNameLinkHTML())) - } else { - b.WriteString(html.EscapeString(authorSig.Name)) + href = ut.commitAuthorSearchURL(sig.Name) + if href == "" { + href = template.URL("mailto:" + sig.Email) } + avatar = au.AvatarByEmail(sig.Email, sig.Name, 20) + name = sig.Name } - - b.WriteString(``) - return template.HTML(b.String()) + return htmlutil.HTMLFormat(`%s%s`, href, avatar, name) } diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go index 5c37f084df63d..2ec7c0f6a74b1 100644 --- a/modules/templates/util_render_test.go +++ b/modules/templates/util_render_test.go @@ -13,15 +13,25 @@ import ( "code.gitea.io/gitea/models/issues" "code.gitea.io/gitea/models/repo" user_model "code.gitea.io/gitea/models/user" + "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/reqctx" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/setting/config" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/translation" "github.com/stretchr/testify/assert" ) +type stubDynGetter struct{} + +func (stubDynGetter) GetValue(ctx context.Context, key string) (string, bool) { + return "", false +} +func (stubDynGetter) GetRevision(ctx context.Context) int { return 0 } +func (stubDynGetter) InvalidateCache() {} + func testInput() string { s := ` space @mention-user /just/a/path.bin @@ -54,6 +64,9 @@ func TestMain(m *testing.M) { return username == "mention-user" }, }) + if config.GetDynGetter() == nil { + config.SetDynGetter(stubDynGetter{}) + } os.Exit(m.Run()) } @@ -223,3 +236,67 @@ func TestUserMention(t *testing.T) { rendered := newTestRenderUtils(t).MarkdownToHtml("@no-such-user @mention-user @mention-user") assert.Equal(t, `

@no-such-user @mention-user @mention-user

`, strings.TrimSpace(string(rendered))) } + +func TestCoAuthorAvatars(t *testing.T) { + ut := newTestRenderUtils(t) + authorSig := &git.Signature{Name: "Alice", Email: "alice@example.com"} + mkCo := func(name, email string) *user_model.CoAuthorUser { + return &user_model.CoAuthorUser{TrailerSignature: &git.Signature{Name: name, Email: email}} + } + + t.Run("zero co-authors renders bare author, no label", func(t *testing.T) { + got := string(ut.CoAuthorAvatars(nil, authorSig, nil)) + assert.Contains(t, got, ``) + assert.Contains(t, got, "Alice") + assert.NotContains(t, got, "coauthor_and") + assert.NotContains(t, got, "coauthor_people") + assert.NotContains(t, got, "avatar-stack") + }) + + t.Run("single co-author uses and label", func(t *testing.T) { + got := string(ut.CoAuthorAvatars(nil, authorSig, []*user_model.CoAuthorUser{mkCo("Bob", "bob@example.com")})) + assert.Contains(t, got, "repo.commits.coauthor_and") + assert.Contains(t, got, "Bob") + assert.NotContains(t, got, "coauthor_people") + assert.Contains(t, got, ``) + }) + + t.Run("two co-authors switches to N people label with tippy popup", func(t *testing.T) { + got := string(ut.CoAuthorAvatars(nil, authorSig, + []*user_model.CoAuthorUser{mkCo("Bob", "bob@example.com"), mkCo("Carol", "carol@example.com")})) + assert.Contains(t, got, "repo.commits.coauthor_people:3") + assert.NotContains(t, got, "repo.commits.coauthor_and") + assert.Contains(t, got, `data-global-init="initAuthorsPopup"`) + assert.Contains(t, got, `
`) + assert.Contains(t, got, `class="authors-popup"`) + }) + + t.Run("overflow chip renders for >9 co-authors", func(t *testing.T) { + cos := make([]*user_model.CoAuthorUser, 11) + for i := range cos { + cos[i] = mkCo("X", "x@example.com") + } + got := string(ut.CoAuthorAvatars(nil, authorSig, cos)) + assert.Contains(t, got, `class="avatar-stack-overflow-chip`) + assert.Contains(t, got, "+2") + assert.Contains(t, got, "repo.commits.coauthor_people:12") + assert.Contains(t, got, `data-global-init="initAuthorsPopup"`) + }) + + t.Run("chip alone renders for 10 co-authors", func(t *testing.T) { + cos := make([]*user_model.CoAuthorUser, 10) + for i := range cos { + cos[i] = mkCo("X", "x@example.com") + } + got := string(ut.CoAuthorAvatarStack(nil, authorSig, cos, "")) + assert.Contains(t, got, `class="avatar-stack-overflow-chip`) + assert.Contains(t, got, "+1") + }) + + t.Run("each stack child carries inline --n", func(t *testing.T) { + got := string(ut.CoAuthorAvatarStack(nil, authorSig, + []*user_model.CoAuthorUser{mkCo("Bob", "bob@example.com")}, "")) + assert.Contains(t, got, `style="--n:0"`) + assert.Contains(t, got, `style="--n:1"`) + }) +} diff --git a/routers/web/devtest/devtest.go b/routers/web/devtest/devtest.go index af548430169de..b412c6fdf2d29 100644 --- a/routers/web/devtest/devtest.go +++ b/routers/web/devtest/devtest.go @@ -159,6 +159,57 @@ func prepareMockDataBadgeActionsSvg(ctx *context.Context) { ctx.Data["SelectedStyle"] = selectedStyle } +func prepareMockDataCoAuthorAvatars(ctx *context.Context) { + mockUsers, _ := db.Find[user_model.User](ctx, user_model.SearchUserOptions{ListOptions: db.ListOptions{PageSize: 3}}) + if len(mockUsers) == 0 { + return + } + u0 := mockUsers[0] + u1, u2 := u0, u0 + if len(mockUsers) >= 2 { + u1 = mockUsers[1] + } + if len(mockUsers) >= 3 { + u2 = mockUsers[2] + } + + authorSig := func(u *user_model.User) *git.Signature { + return &git.Signature{Name: u.Name, Email: u.Email} + } + coLinked := func(u *user_model.User) *user_model.CoAuthorUser { + return &user_model.CoAuthorUser{GiteaUser: u, TrailerSignature: authorSig(u)} + } + coUnlinked := func(name, email string) *user_model.CoAuthorUser { + return &user_model.CoAuthorUser{TrailerSignature: &git.Signature{Name: name, Email: email}} + } + nUnlinked := func(n int) []*user_model.CoAuthorUser { + out := make([]*user_model.CoAuthorUser, n) + for i := range out { + out[i] = coUnlinked(fmt.Sprintf("Contributor %d", i+1), fmt.Sprintf("contrib%d@example.com", i+1)) + } + return out + } + + type scenario struct { + Label string + AuthorUser *user_model.User + AuthorSig *git.Signature + CoAuthors []*user_model.CoAuthorUser + } + ctx.Data["CoAuthorScenarios"] = []scenario{ + {Label: "linked author, no co-authors", AuthorUser: u0, AuthorSig: authorSig(u0)}, + {Label: "unlinked author, no co-authors", AuthorSig: &git.Signature{Name: "External Contributor", Email: "external@example.com"}}, + {Label: "1 linked co-author", AuthorUser: u0, AuthorSig: authorSig(u0), CoAuthors: []*user_model.CoAuthorUser{coLinked(u1)}}, + {Label: "1 unlinked co-author", AuthorUser: u0, AuthorSig: authorSig(u0), CoAuthors: []*user_model.CoAuthorUser{coUnlinked("Bob Smith", "bob@example.com")}}, + {Label: "2 co-authors (3 people)", AuthorUser: u0, AuthorSig: authorSig(u0), CoAuthors: []*user_model.CoAuthorUser{coLinked(u1), coUnlinked("Bob Smith", "bob@example.com")}}, + {Label: "3 co-authors mixed (4 people)", AuthorUser: u0, AuthorSig: authorSig(u0), CoAuthors: []*user_model.CoAuthorUser{coLinked(u1), coLinked(u2), coUnlinked("Bob Smith", "bob@example.com")}}, + {Label: "9 co-authors (max visible, no overflow)", AuthorUser: u0, AuthorSig: authorSig(u0), CoAuthors: nUnlinked(9)}, + {Label: "10 co-authors (overflow +1)", AuthorUser: u0, AuthorSig: authorSig(u0), CoAuthors: nUnlinked(10)}, + {Label: "15 co-authors (overflow +6)", AuthorUser: u0, AuthorSig: authorSig(u0), CoAuthors: nUnlinked(15)}, + {Label: "30 co-authors (overflow +21)", AuthorUser: u0, AuthorSig: authorSig(u0), CoAuthors: nUnlinked(30)}, + } +} + func prepareMockDataRelativeTime(ctx *context.Context) { now := time.Now() ctx.Data["TimeNow"] = now @@ -196,6 +247,8 @@ func prepareMockData(ctx *context.Context) { prepareMockDataToastAndMessage(ctx) case "/devtest/unicode-escape": prepareMockDataUnicodeEscape(ctx) + case "/devtest/coauthor-avatars": + prepareMockDataCoAuthorAvatars(ctx) } } diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index 62697d708c480..d85fe67d7f1b3 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -223,7 +223,7 @@ func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) ma return commitNames } -func renderBlameFillFirstBlameRow(repoLink string, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) { +func fillFirstBlameRow(repoLink string, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) { br.AuthorUser = commit.User br.CoAuthors = commit.CoAuthors br.Author = commit.Author @@ -256,7 +256,7 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa } if partLineIdx == 0 { - renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, part, commitNames[part.Sha], br) + fillFirstBlameRow(ctx.Repo.RepoLink, part, commitNames[part.Sha], br) } } } diff --git a/services/repository/gitgraph/graph_models.go b/services/repository/gitgraph/graph_models.go index fb33d3c0a0451..7168e4403478d 100644 --- a/services/repository/gitgraph/graph_models.go +++ b/services/repository/gitgraph/graph_models.go @@ -131,20 +131,7 @@ func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_ if c.Commit.Author != nil && emailUserMap != nil { c.User = emailUserMap.GetByEmail(c.Commit.Author.Email) } - coAuthorSigs := c.Commit.CoAuthorSignatures() - if len(coAuthorSigs) > 0 { - c.CoAuthors = make([]*user_model.CoAuthorUser, 0, len(coAuthorSigs)) - for _, sig := range coAuthorSigs { - var giteaUser *user_model.User - if emailUserMap != nil { - giteaUser = emailUserMap.GetByEmail(sig.Email) - } - c.CoAuthors = append(c.CoAuthors, &user_model.CoAuthorUser{ - GiteaUser: giteaUser, - TrailerSignature: sig, - }) - } - } + c.CoAuthors = user_model.CoAuthorUsersFromSigs(c.Commit.CoAuthorSignatures(), emailUserMap) c.Verification = asymkey_service.ParseCommitWithSignature(ctx, c.Commit) diff --git a/templates/devtest/coauthor-avatars.tmpl b/templates/devtest/coauthor-avatars.tmpl new file mode 100644 index 0000000000000..9cc81866d7f70 --- /dev/null +++ b/templates/devtest/coauthor-avatars.tmpl @@ -0,0 +1,18 @@ +{{template "devtest/devtest-header"}} +
+
+

Co-Author Avatars

+
{{ctx.Locale.Tr "repo.commits.author"}}{{ctx.Locale.Tr "repo.commits.author"}} {{StringUtils.ToUpper $.Repository.ObjectFormatName}}{{ctx.Locale.Tr "repo.commits.message"}}{{ctx.Locale.Tr "repo.commits.message"}} {{ctx.Locale.Tr "repo.commits.date"}}
- - {{- if .User -}} - {{- ctx.AvatarUtils.Avatar .User 20 "tw-mr-2" -}} - {{- .User.GetShortDisplayNameLinkHTML -}} - {{- else -}} - {{- ctx.AvatarUtils.AvatarByEmail .Author.Email .Author.Name 20 "tw-mr-2" -}} - {{- .Author.Name -}} - {{- end -}} - + {{template "repo/commit_coauthor_avatars" dict "AuthorUser" .User "AuthorSignature" .Author "CoAuthors" .CoAuthors}} {{$commitBaseLink := ""}} diff --git a/templates/repo/latest_commit.tmpl b/templates/repo/latest_commit.tmpl index c0518189b85a2..3a3b162ba51a9 100644 --- a/templates/repo/latest_commit.tmpl +++ b/templates/repo/latest_commit.tmpl @@ -2,15 +2,7 @@ {{if not .LatestCommit}} … {{else}} - - {{- if .LatestCommitUser -}} - {{- ctx.AvatarUtils.Avatar .LatestCommitUser 20 "tw-mr-2" -}} - {{.LatestCommitUser.GetShortDisplayNameLinkHTML}} - {{- else if .LatestCommit.Author -}} - {{- ctx.AvatarUtils.AvatarByEmail .LatestCommit.Author.Email .LatestCommit.Author.Name 20 "tw-mr-2" -}} - {{.LatestCommit.Author.Name}} - {{- end -}} - + {{template "repo/commit_coauthor_avatars" dict "AuthorUser" .LatestCommitUser "AuthorSignature" .LatestCommit.Author "CoAuthors" .LatestCommitCoAuthors}} {{template "repo/commit_sign_badge" dict "Commit" .LatestCommit "CommitBaseLink" (print .RepoLink "/commit") "CommitSignVerification" .LatestCommitVerification}} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 3eb016650f16f..135eeec2810e6 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -1403,11 +1403,72 @@ tbody.commit-list { } .author-wrapper { - max-width: 180px; + max-width: 240px; align-self: center; white-space: nowrap; } +.coauthor-avatar-stack { + display: inline-flex; + align-items: center; + vertical-align: middle; + margin-right: 0.25rem; +} + +/* each direct child of the stack is either (linked user) or (no account) */ +.coauthor-avatar-stack > * { + margin-left: -12px; + transition: transform 0.15s ease; + position: relative; + z-index: 0; + display: inline-flex; + border-radius: 50%; +} + +.coauthor-avatar-stack > *:first-child { + margin-left: 0; +} + +.coauthor-avatar-stack .avatar { + border: 2px solid var(--color-body); + border-radius: 50%; +} + +.coauthor-overflow-chip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + font-size: 10px; + font-weight: var(--font-weight-semibold); + border: 2px solid var(--color-body); + border-radius: 50%; + background: var(--color-secondary); + color: var(--color-text); +} + +.coauthor-avatar-stack > *:nth-child(1) { z-index: 20; } +.coauthor-avatar-stack > *:nth-child(2) { z-index: 19; } +.coauthor-avatar-stack > *:nth-child(3) { z-index: 18; } +.coauthor-avatar-stack > *:nth-child(4) { z-index: 17; } +.coauthor-avatar-stack > *:nth-child(5) { z-index: 16; } +.coauthor-avatar-stack > *:nth-child(6) { z-index: 15; } +.coauthor-avatar-stack > *:nth-child(7) { z-index: 14; } +.coauthor-avatar-stack > *:nth-child(8) { z-index: 13; } +.coauthor-avatar-stack > *:nth-child(9) { z-index: 12; } +.coauthor-avatar-stack > *:nth-child(10) { z-index: 11; } + +.coauthor-avatar-stack:hover > *:nth-child(2) { transform: translateX(12px); } +.coauthor-avatar-stack:hover > *:nth-child(3) { transform: translateX(24px); } +.coauthor-avatar-stack:hover > *:nth-child(4) { transform: translateX(36px); } +.coauthor-avatar-stack:hover > *:nth-child(5) { transform: translateX(48px); } +.coauthor-avatar-stack:hover > *:nth-child(6) { transform: translateX(60px); } +.coauthor-avatar-stack:hover > *:nth-child(7) { transform: translateX(72px); } +.coauthor-avatar-stack:hover > *:nth-child(8) { transform: translateX(84px); } +.coauthor-avatar-stack:hover > *:nth-child(9) { transform: translateX(96px); } +.coauthor-avatar-stack:hover > *:nth-child(10) { transform: translateX(108px); } + .latest-commit .message-wrapper { max-width: calc(100% - 2.5rem); } From 10461b2f4e4539f3088a3c90a091457b2f117abf Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 7 May 2026 21:53:01 +0200 Subject: [PATCH 02/18] feat(repo): display co-author stacks beside commit SHAs Show co-author avatar stacks on commit SHA surfaces including commits list rows, graph, blame, and dashboard feeds, with repository-level data propagation and tests. Co-Authored-By: Claude Opus 4.7 --- modules/repository/commits.go | 2 + modules/repository/commits_test.go | 8 +++- routers/web/repo/blame.go | 18 ++++---- services/repository/gitgraph/graph_models.go | 46 ++++++++++++++++---- templates/repo/blame.tmpl | 2 +- templates/repo/commit_sign_badge.tmpl | 4 +- templates/repo/commits_list_small.tmpl | 6 +-- templates/repo/graph/commits.tmpl | 9 +--- templates/user/dashboard/feeds.tmpl | 2 +- 9 files changed, 60 insertions(+), 37 deletions(-) diff --git a/modules/repository/commits.go b/modules/repository/commits.go index 32550a9f03eec..546e88109041c 100644 --- a/modules/repository/commits.go +++ b/modules/repository/commits.go @@ -29,6 +29,7 @@ type PushCommit struct { AuthorName string CommitterEmail string CommitterName string + CoAuthors []*git.Signature Timestamp time.Time } @@ -157,6 +158,7 @@ func CommitToPushCommit(commit *git.Commit) *PushCommit { AuthorName: commit.Author.Name, CommitterEmail: commit.Committer.Email, CommitterName: commit.Committer.Name, + CoAuthors: commit.CoAuthorSignatures(), Timestamp: commit.Author.When, } } diff --git a/modules/repository/commits_test.go b/modules/repository/commits_test.go index 46db7b028b3a1..a4c0eea8b52d2 100644 --- a/modules/repository/commits_test.go +++ b/modules/repository/commits_test.go @@ -145,14 +145,18 @@ func TestCommitToPushCommit(t *testing.T) { ID: sha1, Author: sig, Committer: sig, - CommitMessage: git.CommitMessage{MessageRaw: "Commit Message"}, + CommitMessage: git.CommitMessage{MessageRaw: "Commit Message\n\nCo-authored-by: Jane Doe "}, }) assert.Equal(t, hexString, pushCommit.Sha1) - assert.Equal(t, "Commit Message", pushCommit.Message) + assert.Equal(t, "Commit Message\n\nCo-authored-by: Jane Doe ", pushCommit.Message) assert.Equal(t, "example@example.com", pushCommit.AuthorEmail) assert.Equal(t, "John Doe", pushCommit.AuthorName) assert.Equal(t, "example@example.com", pushCommit.CommitterEmail) assert.Equal(t, "John Doe", pushCommit.CommitterName) + if assert.Len(t, pushCommit.CoAuthors, 1) { + assert.Equal(t, "jane@example.com", pushCommit.CoAuthors[0].Email) + assert.Equal(t, "Jane Doe", pushCommit.CoAuthors[0].Name) + } assert.Equal(t, now, pushCommit.Timestamp) } diff --git a/routers/web/repo/blame.go b/routers/web/repo/blame.go index 803397f33f1d4..62697d708c480 100644 --- a/routers/web/repo/blame.go +++ b/routers/web/repo/blame.go @@ -29,12 +29,14 @@ import ( type blameRow struct { RowNumber int - Avatar template.HTML PreviousSha string PreviousShaURL string CommitURL string CommitMessage string CommitSince template.HTML + AuthorUser *user_model.User + CoAuthors []*user_model.CoAuthorUser + Author *git.Signature Code template.HTML EscapeStatus *charset.EscapeStatus @@ -221,13 +223,10 @@ func processBlameParts(ctx *context.Context, blameParts []*gitrepo.BlamePart) ma return commitNames } -func renderBlameFillFirstBlameRow(repoLink string, avatarUtils *templates.AvatarUtils, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) { - if commit.User != nil { - br.Avatar = avatarUtils.Avatar(commit.User, 18) - } else { - br.Avatar = avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18) - } - +func renderBlameFillFirstBlameRow(repoLink string, part *gitrepo.BlamePart, commit *user_model.UserCommit, br *blameRow) { + br.AuthorUser = commit.User + br.CoAuthors = commit.CoAuthors + br.Author = commit.Author br.PreviousSha = part.PreviousSha br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath)) br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha)) @@ -243,7 +242,6 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa buf := &bytes.Buffer{} rows := make([]*blameRow, 0) - avatarUtils := templates.NewAvatarUtils(ctx) rowNumber := 0 // will be 1-based for _, part := range blameParts { for partLineIdx, line := range part.Lines { @@ -258,7 +256,7 @@ func renderBlame(ctx *context.Context, blameParts []*gitrepo.BlamePart, commitNa } if partLineIdx == 0 { - renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, avatarUtils, part, commitNames[part.Sha], br) + renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, part, commitNames[part.Sha], br) } } } diff --git a/services/repository/gitgraph/graph_models.go b/services/repository/gitgraph/graph_models.go index fc2eb85b87fd1..96ab2639c50de 100644 --- a/services/repository/gitgraph/graph_models.go +++ b/services/repository/gitgraph/graph_models.go @@ -93,9 +93,7 @@ func (graph *Graph) AddCommit(row, column int, flowID int64, data []byte) error // before finally retrieving the latest status func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_model.Repository, gitRepo *git.Repository) error { var err error - var ok bool - - emails := map[string]*user_model.User{} + emailSet := map[string]struct{}{} keyMap := map[string]bool{} for _, c := range graph.Commits { @@ -106,13 +104,44 @@ func (graph *Graph) LoadAndProcessCommits(ctx context.Context, repository *repo_ if err != nil { return fmt.Errorf("GetCommit: %s Error: %w", c.Rev, err) } - if c.Commit.Author != nil { - email := c.Commit.Author.Email - if c.User, ok = emails[email]; !ok { - c.User, _ = user_model.GetUserByEmail(ctx, email) - emails[email] = c.User + emailSet[c.Commit.Author.Email] = struct{}{} + } + for _, sig := range c.Commit.CoAuthorSignatures() { + emailSet[sig.Email] = struct{}{} + } + } + + allEmails := make([]string, 0, len(emailSet)) + for email := range emailSet { + allEmails = append(allEmails, email) + } + var emailUserMap *user_model.EmailUserMap + if len(allEmails) > 0 { + emailUserMap, err = user_model.GetUsersByEmails(ctx, allEmails) + if err != nil { + log.Error("GetUsersByEmails: %v", err) + } + } + + for _, c := range graph.Commits { + if c.Commit == nil { + continue + } + if c.Commit.Author != nil && emailUserMap != nil { + c.User = emailUserMap.GetByEmail(c.Commit.Author.Email) + } + coAuthorSigs := c.Commit.CoAuthorSignatures() + c.CoAuthors = make([]*user_model.CoAuthorUser, 0, len(coAuthorSigs)) + for _, sig := range coAuthorSigs { + var giteaUser *user_model.User + if emailUserMap != nil { + giteaUser = emailUserMap.GetByEmail(sig.Email) } + c.CoAuthors = append(c.CoAuthors, &user_model.CoAuthorUser{ + GiteaUser: giteaUser, + TrailerSignature: sig, + }) } c.Verification = asymkey_service.ParseCommitWithSignature(ctx, c.Commit) @@ -248,6 +277,7 @@ func newRefsFromRefNames(refNames []byte) []git.Reference { type Commit struct { Commit *git.Commit User *user_model.User + CoAuthors []*user_model.CoAuthorUser Verification *asymkey_model.CommitVerification Status *git_model.CommitStatus Flow int64 diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index 8bdefa5d43e2c..e6ae31891d6ce 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -43,7 +43,7 @@
- {{$row.Avatar}} + {{template "repo/commit_coauthor_avatar_stack" dict "AuthorUser" $row.AuthorUser "AuthorSignature" $row.Author "CoAuthors" $row.CoAuthors}}
` + ut.RenderUnicodeEscapeToggleButton(escapeStatus) + `
- {{template "repo/commit_coauthor_avatars" dict "AuthorUser" .User "AuthorSignature" .Author "CoAuthors" .CoAuthors}} + {{ctx.RenderUtils.CoAuthorAvatars .User .Author .CoAuthors}} {{$commitBaseLink := ""}} diff --git a/templates/repo/commits_list_small.tmpl b/templates/repo/commits_list_small.tmpl index c1cadf74ba397..76a7cc9eff227 100644 --- a/templates/repo/commits_list_small.tmpl +++ b/templates/repo/commits_list_small.tmpl @@ -5,7 +5,7 @@ {{$index = Eval $index "+" 1}}
{{/*singular-commit*/}} {{svg "octicon-git-commit"}} - {{template "repo/commit_coauthor_avatar_stack" dict "AuthorUser" .User "AuthorSignature" .Author "CoAuthors" .CoAuthors}} + {{ctx.RenderUtils.CoAuthorAvatarStack .User .Author .CoAuthors ""}} {{$commitBaseLink := printf "%s/commit" $.comment.Issue.PullRequest.BaseRepo.Link}} {{$commitLink:= printf "%s/%s" $commitBaseLink (PathEscape .ID.String)}} diff --git a/templates/repo/graph/commits.tmpl b/templates/repo/graph/commits.tmpl index 7b834d2be7115..fb93f0889c516 100644 --- a/templates/repo/graph/commits.tmpl +++ b/templates/repo/graph/commits.tmpl @@ -41,7 +41,7 @@ - {{template "repo/commit_coauthor_avatars" dict "AuthorUser" $commit.User "AuthorSignature" $commit.Commit.Author "CoAuthors" $commit.CoAuthors}} + {{ctx.RenderUtils.CoAuthorAvatars $commit.User $commit.Commit.Author $commit.CoAuthors}} {{DateUtils.FullTime $commit.Date}} diff --git a/templates/repo/latest_commit.tmpl b/templates/repo/latest_commit.tmpl index 3a3b162ba51a9..230d8f88ef15e 100644 --- a/templates/repo/latest_commit.tmpl +++ b/templates/repo/latest_commit.tmpl @@ -2,7 +2,7 @@ {{if not .LatestCommit}} … {{else}} - {{template "repo/commit_coauthor_avatars" dict "AuthorUser" .LatestCommitUser "AuthorSignature" .LatestCommit.Author "CoAuthors" .LatestCommitCoAuthors}} + {{ctx.RenderUtils.CoAuthorAvatars .LatestCommitUser .LatestCommit.Author .LatestCommitCoAuthors}} {{template "repo/commit_sign_badge" dict "Commit" .LatestCommit "CommitBaseLink" (print .RepoLink "/commit") "CommitSignVerification" .LatestCommitVerification}} diff --git a/templates/user/dashboard/feeds.tmpl b/templates/user/dashboard/feeds.tmpl index 0981fea8f108e..67bede6cac469 100644 --- a/templates/user/dashboard/feeds.tmpl +++ b/templates/user/dashboard/feeds.tmpl @@ -91,7 +91,7 @@ {{range $pushCommit := $push.Commits}} {{$commitLink := printf "%s/commit/%s" $repoLink .Sha1}}
- {{template "repo/commit_coauthor_avatar_stack" dict "AuthorSignature" .AuthorSignature "CoAuthors" .CoAuthorUsers}} + {{ctx.RenderUtils.CoAuthorAvatarStack nil .AuthorSignature .CoAuthorUsers ""}} {{ShortSha .Sha1}} {{ctx.RenderUtils.RenderCommitMessage $pushCommit.Message $repo}} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 0a30cf6ce5067..dcd9f40f0b938 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -1411,7 +1411,6 @@ tbody.commit-list { .coauthor-avatar-stack-wrapper { display: inline-flex; align-items: center; - vertical-align: middle; margin-right: 0.25rem; } @@ -1425,7 +1424,6 @@ tbody.commit-list { margin-left: -12px; transition: transform 0.15s ease; position: relative; - z-index: 0; display: inline-flex; border-radius: 50%; } @@ -1445,7 +1443,6 @@ tbody.commit-list { justify-content: center; width: 20px; height: 20px; - font-size: 10px; font-weight: var(--font-weight-semibold); border: 2px solid var(--color-body); border-radius: 50%; From 84d5c585c9e49de8311c53e6264d16ff6ce96781 Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 13 May 2026 03:36:16 +0200 Subject: [PATCH 05/18] refactor(repo): polish co-author avatar stack, links, and popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplify trailer parser: use stdlib `net/mail.ParseAddress` plus `strings.Cut`/`strings.EqualFold`, single-pass extraction - Make raw parser `parseCoAuthorSignatures` unexported so only the filtered `Commit.CoAuthorSignatures` is part of the public surface - Drop the parallel `PushCommitCoAuthor` type; `PushCommit.CoAuthors` now stores `[]*git.Signature` directly - Extract `CoAuthorUsersFromSigs` helper, deduplicating three copies of the email→user resolution loop - Rename CSS classes `coauthor-*` → `avatar-stack-*`; spacing tweaked to 6px stride between primary author and first co-author, 4px stride between subsequent; opaque background + 2px ring to keep avatars distinct under overlap; hover-spread via `--n` CSS variable - For 3+ participants, the "N people" label opens an interactive tippy popup listing all participants (initAuthorsPopup) - All author/co-author names link to the repo's commit author search (`/commits//search?q=author:`) when a repo+ref context is available; fall back to profile or mailto otherwise - Add `/devtest/coauthor-avatars` showcase - Rename `renderBlameFillFirstBlameRow` → `fillFirstBlameRow` (no longer renders) Co-Authored-By: Claude (Opus 4.7) --- models/user/user.go | 38 ++--- modules/git/commit.go | 104 ++++++------- modules/git/commit_test.go | 2 +- modules/repository/commits.go | 35 +---- modules/templates/util_render.go | 155 +++++++++++++------ modules/templates/util_render_test.go | 77 +++++++++ routers/web/devtest/devtest.go | 53 +++++++ routers/web/repo/blame.go | 4 +- services/repository/gitgraph/graph_models.go | 15 +- templates/devtest/coauthor-avatars.tmpl | 18 +++ web_src/css/repo.css | 68 ++++---- web_src/js/features/repo-commit.ts | 14 ++ web_src/js/index.ts | 3 +- 13 files changed, 392 insertions(+), 194 deletions(-) create mode 100644 templates/devtest/coauthor-avatars.tmpl diff --git a/models/user/user.go b/models/user/user.go index ffeb92acc8be7..41587a92e5ed2 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -1172,6 +1172,22 @@ func ValidateCommitWithEmail(ctx context.Context, c *git.Commit) *User { return u } +// CoAuthorUsersFromSigs wraps each signature with the matching Gitea user if any. +func CoAuthorUsersFromSigs(sigs []*git.Signature, emailUserMap *EmailUserMap) []*CoAuthorUser { + if len(sigs) == 0 { + return nil + } + out := make([]*CoAuthorUser, len(sigs)) + for i, sig := range sigs { + var giteaUser *User + if emailUserMap != nil { + giteaUser = emailUserMap.GetByEmail(sig.Email) + } + out[i] = &CoAuthorUser{GiteaUser: giteaUser, TrailerSignature: sig} + } + return out +} + // CoAuthorsFromCommit resolves co-author signatures from a commit into CoAuthorUser values. func CoAuthorsFromCommit(ctx context.Context, c *git.Commit) ([]*CoAuthorUser, error) { sigs := c.CoAuthorSignatures() @@ -1186,11 +1202,7 @@ func CoAuthorsFromCommit(ctx context.Context, c *git.Commit) ([]*CoAuthorUser, e if err != nil { return nil, err } - coAuthors := make([]*CoAuthorUser, len(sigs)) - for i, sig := range sigs { - coAuthors[i] = &CoAuthorUser{GiteaUser: emailUserMap.GetByEmail(sig.Email), TrailerSignature: sig} - } - return coAuthors, nil + return CoAuthorUsersFromSigs(sigs, emailUserMap), nil } // ValidateCommitsWithEmails checks if authors' e-mails of commits are corresponding to users. @@ -1214,21 +1226,9 @@ func ValidateCommitsWithEmails(ctx context.Context, oldCommits []*git.Commit) ([ } for _, c := range oldCommits { - user := emailUserMap.GetByEmail(c.Author.Email) // FIXME: why ValidateCommitsWithEmails uses "Author", but ParseCommitsWithSignature uses "Committer"? - coAuthorSigs := c.CoAuthorSignatures() - var coAuthors []*CoAuthorUser - if len(coAuthorSigs) > 0 { - coAuthors = make([]*CoAuthorUser, 0, len(coAuthorSigs)) - for _, sig := range coAuthorSigs { - coAuthors = append(coAuthors, &CoAuthorUser{ - GiteaUser: emailUserMap.GetByEmail(sig.Email), - TrailerSignature: sig, - }) - } - } newCommits = append(newCommits, &UserCommit{ - User: user, - CoAuthors: coAuthors, + User: emailUserMap.GetByEmail(c.Author.Email), // FIXME: why ValidateCommitsWithEmails uses "Author", but ParseCommitsWithSignature uses "Committer"? + CoAuthors: CoAuthorUsersFromSigs(c.CoAuthorSignatures(), emailUserMap), Commit: c, }) } diff --git a/modules/git/commit.go b/modules/git/commit.go index 4a23533d2f932..801cf5c05dd51 100644 --- a/modules/git/commit.go +++ b/modules/git/commit.go @@ -8,6 +8,7 @@ import ( "context" "errors" "io" + "net/mail" "os/exec" "strings" @@ -69,22 +70,12 @@ func (c *CommitMessage) MessageBody() string { return *c.messageBody } -// cutTrailerPrefix matches ":" case-insensitively at the start of line and -// returns the trailing value plus whether a match was found. `git interpret-trailers` -// matches trailer tokens case-insensitively, so e.g. "Co-Authored-By:" is valid. -func cutTrailerPrefix(line, token string) (string, bool) { - if len(line) < len(token)+1 || line[len(token)] != ':' { - return "", false - } - if !strings.EqualFold(line[:len(token)], token) { - return "", false - } - return line[len(token)+1:], true -} - -func isTrailerLine(line string) bool { +// isTrailerLineShape reports whether the line looks like a `Token: value` trailer +// per `git interpret-trailers`: the token is non-empty, contains only [A-Za-z0-9-], +// and the value (after `:`) is non-empty. +func isTrailerLineShape(line string) bool { token, rest, ok := strings.Cut(line, ":") - if !ok || strings.TrimSpace(rest) == "" { + if !ok || token == "" || strings.TrimSpace(rest) == "" { return false } for _, r := range token { @@ -93,59 +84,60 @@ func isTrailerLine(line string) bool { } return false } - return token != "" + return true } -// CoAuthorSignatures parses "Co-authored-by:" and "Co-committed-by:" trailers -// from the trailing block of the commit message and returns deduplicated -// Signature values. Only the last paragraph of the body is scanned so that -// quoted or in-body occurrences (e.g. inside a revert/cherry-pick description) -// are not misinterpreted as trailers. The trailing paragraph must contain only -// trailer-shaped lines. -// Token matching is case-insensitive to match git's behaviour. -func (c *CommitMessage) CoAuthorSignatures() []*Signature { +// parseCoAuthorTrailer extracts a co-author Signature if the line is a +// `Co-authored-by:`/`Co-committed-by:` trailer. Token matching is case-insensitive +// to match git's behaviour. Email is lowercased; the address is parsed with +// net/mail so a missing `<…>` or malformed address is rejected. +func parseCoAuthorTrailer(line string) (*Signature, bool) { + token, rest, ok := strings.Cut(line, ":") + if !ok { + return nil, false + } + if !strings.EqualFold(token, "Co-authored-by") && !strings.EqualFold(token, "Co-committed-by") { + return nil, false + } + addr, err := mail.ParseAddress(strings.TrimSpace(rest)) + if err != nil { + return nil, false + } + return &Signature{Name: addr.Name, Email: strings.ToLower(addr.Address)}, true +} + +// parseCoAuthorSignatures parses `Co-authored-by:` and `Co-committed-by:` trailers +// from the trailing block of the commit message and returns deduplicated Signature +// values. Only the last paragraph of the body is scanned so quoted or in-body +// occurrences (e.g. inside a revert/cherry-pick description) are not +// misinterpreted; the trailing paragraph must contain only trailer-shaped lines. +func (c *CommitMessage) parseCoAuthorSignatures() []*Signature { if c.messageCoAuthors != nil { return *c.messageCoAuthors } - var sigs []*Signature - seen := make(map[string]struct{}) - body := strings.TrimRight(c.MessageBody(), "\r\n") - if idx := strings.LastIndex(body, "\r\n\r\n"); idx >= 0 { - body = body[idx+4:] - } else if idx := strings.LastIndex(body, "\n\n"); idx >= 0 { + body := strings.ReplaceAll(strings.TrimRight(c.MessageBody(), "\r\n"), "\r\n", "\n") + if idx := strings.LastIndex(body, "\n\n"); idx >= 0 { body = body[idx+2:] } - lines := strings.Split(body, "\n") - for i, line := range lines { - lines[i] = strings.TrimRight(line, "\r") - if !isTrailerLine(lines[i]) { - c.messageCoAuthors = &sigs - return sigs - } - } - for _, line := range lines { - rest, ok := cutTrailerPrefix(line, "Co-authored-by") - if !ok { - rest, ok = cutTrailerPrefix(line, "Co-committed-by") - if !ok { - continue - } + var sigs []*Signature + var seen map[string]struct{} + for line := range strings.SplitSeq(body, "\n") { + if !isTrailerLineShape(line) { + sigs = nil + break } - rest = strings.TrimSpace(rest) - name, emailWithBracket, ok := strings.Cut(rest, " <") + sig, ok := parseCoAuthorTrailer(line) if !ok { continue } - email, _, ok := strings.Cut(emailWithBracket, ">") - if !ok { + if _, dup := seen[sig.Email]; dup { continue } - email = strings.ToLower(strings.TrimSpace(email)) - if _, exists := seen[email]; exists { - continue + if seen == nil { + seen = make(map[string]struct{}) } - seen[email] = struct{}{} - sigs = append(sigs, &Signature{Name: strings.TrimSpace(name), Email: email}) + seen[sig.Email] = struct{}{} + sigs = append(sigs, sig) } c.messageCoAuthors = &sigs return sigs @@ -155,11 +147,11 @@ func (c *CommitMessage) CoAuthorSignatures() []*Signature { // own author and committer emails filtered out, so a contributor who copies // themselves into a Co-authored-by line is not duplicated in the avatar stack. func (c *Commit) CoAuthorSignatures() []*Signature { - raw := c.CommitMessage.CoAuthorSignatures() + raw := c.parseCoAuthorSignatures() if len(raw) == 0 { return raw } - exclude := make(map[string]struct{}, 2) + exclude := make(map[string]struct{}) if c.Author != nil { exclude[strings.ToLower(strings.TrimSpace(c.Author.Email))] = struct{}{} } diff --git a/modules/git/commit_test.go b/modules/git/commit_test.go index 08116b0199efc..90227c816aa00 100644 --- a/modules/git/commit_test.go +++ b/modules/git/commit_test.go @@ -270,7 +270,7 @@ func TestCoAuthorSignatures(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { cm := CommitMessage{MessageRaw: tc.body} - got := cm.CoAuthorSignatures() + got := cm.parseCoAuthorSignatures() assert.Len(t, got, len(tc.want)) for i, w := range tc.want { if i >= len(got) { diff --git a/modules/repository/commits.go b/modules/repository/commits.go index 83ae10d8f5f64..93550b0e22f3f 100644 --- a/modules/repository/commits.go +++ b/modules/repository/commits.go @@ -29,16 +29,10 @@ type PushCommit struct { AuthorName string CommitterEmail string CommitterName string - CoAuthors []*PushCommitCoAuthor `json:",omitempty"` + CoAuthors []*git.Signature `json:",omitempty"` Timestamp time.Time } -// PushCommitCoAuthor represents a co-author in a push commit payload. -type PushCommitCoAuthor struct { - Name string - Email string -} - // PushCommits represents list of commits in a push operation. type PushCommits struct { Commits []*PushCommit @@ -155,17 +149,6 @@ func (pc *PushCommits) AvatarLink(ctx context.Context, email string) string { return v } -func pushCommitCoAuthorsFromSignatures(sigs []*git.Signature) []*PushCommitCoAuthor { - if len(sigs) == 0 { - return nil - } - coAuthors := make([]*PushCommitCoAuthor, len(sigs)) - for i, sig := range sigs { - coAuthors[i] = &PushCommitCoAuthor{Name: sig.Name, Email: sig.Email} - } - return coAuthors -} - // CommitToPushCommit transforms a git.Commit to PushCommit type. func CommitToPushCommit(commit *git.Commit) *PushCommit { return &PushCommit{ @@ -175,30 +158,24 @@ func CommitToPushCommit(commit *git.Commit) *PushCommit { AuthorName: commit.Author.Name, CommitterEmail: commit.Committer.Email, CommitterName: commit.Committer.Name, - CoAuthors: pushCommitCoAuthorsFromSignatures(commit.CoAuthorSignatures()), + CoAuthors: commit.CoAuthorSignatures(), Timestamp: commit.Author.When, } } // AuthorSignature returns the push commit author as a git signature. func (pc *PushCommit) AuthorSignature() *git.Signature { - return &git.Signature{ - Email: pc.AuthorEmail, - Name: pc.AuthorName, - } + return &git.Signature{Email: pc.AuthorEmail, Name: pc.AuthorName} } -// CoAuthorUsers returns co-authors in the template view shape. +// CoAuthorUsers returns co-authors in the template view shape, without resolved Gitea users. func (pc *PushCommit) CoAuthorUsers() []*user_model.CoAuthorUser { if len(pc.CoAuthors) == 0 { return nil } coAuthors := make([]*user_model.CoAuthorUser, len(pc.CoAuthors)) - for i, coAuthor := range pc.CoAuthors { - coAuthors[i] = &user_model.CoAuthorUser{TrailerSignature: &git.Signature{ - Name: coAuthor.Name, - Email: coAuthor.Email, - }} + for i, sig := range pc.CoAuthors { + coAuthors[i] = &user_model.CoAuthorUser{TrailerSignature: sig} } return coAuthors } diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go index 7efa1abbd4d58..8a202da4925ce 100644 --- a/modules/templates/util_render.go +++ b/modules/templates/util_render.go @@ -6,7 +6,6 @@ package templates import ( "encoding/hex" "fmt" - "html" "html/template" "math" "net/url" @@ -328,8 +327,25 @@ func (ut *RenderUtils) RenderUnicodeEscapeToggleTd(combined, escapeStatus *chars return `
` + ut.RenderUnicodeEscapeToggleButton(escapeStatus) + `
+ + + {{range $s := .CoAuthorScenarios}} + + + + + {{end}} + +
ScenarioRendered
{{$s.Label}}{{ctx.RenderUtils.CoAuthorAvatars $s.AuthorUser $s.AuthorSig $s.CoAuthors}}
+ + +{{template "devtest/devtest-footer"}} diff --git a/web_src/css/repo.css b/web_src/css/repo.css index dcd9f40f0b938..4b614a91bf721 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -1406,38 +1406,51 @@ tbody.commit-list { max-width: 240px; align-self: center; white-space: nowrap; + display: inline-flex; + align-items: center; } -.coauthor-avatar-stack-wrapper { +.avatar-stack-wrapper { display: inline-flex; align-items: center; margin-right: 0.25rem; } -.coauthor-avatar-stack { +.avatar-stack { display: inline-flex; align-items: center; } -/* each direct child of the stack is either (linked user) or (no account) */ -.coauthor-avatar-stack > * { - margin-left: -12px; +/* each direct child carries inline --n (0 = leftmost). Higher --n sits lower in the stack + and slides further right on hover so the whole stack fans out from the left anchor. */ +.avatar-stack > * { + margin-left: -16px; transition: transform 0.15s ease; position: relative; display: inline-flex; border-radius: 50%; + z-index: calc(100 - var(--n, 0)); } -.coauthor-avatar-stack > *:first-child { +.avatar-stack > *:first-child { margin-left: 0; } -.coauthor-avatar-stack .avatar { +.avatar-stack > *:nth-child(2) { + margin-left: -14px; +} + +.avatar-stack:hover > * { + transform: translateX(max(0px, calc(14px + (var(--n, 0) - 1) * 16px))); +} + +.avatar-stack .avatar { border: 2px solid var(--color-body); + background: var(--color-body); border-radius: 50%; } -.coauthor-overflow-chip { +.avatar-stack-overflow-chip { display: inline-flex; align-items: center; justify-content: center; @@ -1450,26 +1463,25 @@ tbody.commit-list { color: var(--color-text); } -.coauthor-avatar-stack > *:nth-child(1) { z-index: 20; } -.coauthor-avatar-stack > *:nth-child(2) { z-index: 19; } -.coauthor-avatar-stack > *:nth-child(3) { z-index: 18; } -.coauthor-avatar-stack > *:nth-child(4) { z-index: 17; } -.coauthor-avatar-stack > *:nth-child(5) { z-index: 16; } -.coauthor-avatar-stack > *:nth-child(6) { z-index: 15; } -.coauthor-avatar-stack > *:nth-child(7) { z-index: 14; } -.coauthor-avatar-stack > *:nth-child(8) { z-index: 13; } -.coauthor-avatar-stack > *:nth-child(9) { z-index: 12; } -.coauthor-avatar-stack > *:nth-child(10) { z-index: 11; } - -.coauthor-avatar-stack:hover > *:nth-child(2) { transform: translateX(12px); } -.coauthor-avatar-stack:hover > *:nth-child(3) { transform: translateX(24px); } -.coauthor-avatar-stack:hover > *:nth-child(4) { transform: translateX(36px); } -.coauthor-avatar-stack:hover > *:nth-child(5) { transform: translateX(48px); } -.coauthor-avatar-stack:hover > *:nth-child(6) { transform: translateX(60px); } -.coauthor-avatar-stack:hover > *:nth-child(7) { transform: translateX(72px); } -.coauthor-avatar-stack:hover > *:nth-child(8) { transform: translateX(84px); } -.coauthor-avatar-stack:hover > *:nth-child(9) { transform: translateX(96px); } -.coauthor-avatar-stack:hover > *:nth-child(10) { transform: translateX(108px); } +.authors-popup-trigger { + cursor: pointer; +} + +.authors-popup { + min-width: 200px; + display: flex; + flex-direction: column; + padding: 4px 0; +} + +.authors-popup > a { + padding: 6px 12px; + gap: 8px; +} + +.authors-popup > a:hover { + background: var(--color-hover); +} .latest-commit .message-wrapper { max-width: calc(100% - 2.5rem); diff --git a/web_src/js/features/repo-commit.ts b/web_src/js/features/repo-commit.ts index 16f38993749f3..843586d8fb084 100644 --- a/web_src/js/features/repo-commit.ts +++ b/web_src/js/features/repo-commit.ts @@ -24,3 +24,17 @@ export function initCommitStatuses() { }); }); } + +export function initAuthorsPopup() { + registerGlobalInitFunc('initAuthorsPopup', (el: HTMLElement) => { + const nextEl = el.nextElementSibling!; + if (!nextEl.matches('.tippy-target')) throw new Error('Expected next element to be a tippy target'); + createTippy(el, { + content: nextEl, + placement: 'bottom-start', + interactive: true, + role: 'dialog', + theme: 'menu', + }); + }); +} diff --git a/web_src/js/index.ts b/web_src/js/index.ts index cb2b56a5bd5e3..739fc209cf5b0 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -20,7 +20,7 @@ import {initMarkupContent} from './markup/content.ts'; import {initRepoFileView} from './features/file-view.ts'; import {initUserExternalLogins, initUserCheckAppUrl} from './features/user-auth.ts'; import {initRepoPullRequestReview, initRepoIssueFilterItemLabel} from './features/repo-issue.ts'; -import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; +import {initRepoEllipsisButton, initCommitStatuses, initAuthorsPopup} from './features/repo-commit.ts'; import {initRepoTopicBar} from './features/repo-home.ts'; import {initAdminCommon} from './features/admin/common.ts'; import {initRepoCodeView} from './features/repo-code.ts'; @@ -145,6 +145,7 @@ const initPerformanceTracer = callInitFunctions([ initRepoRecentCommits, initCommitStatuses, + initAuthorsPopup, initCaptcha, initUserCheckAppUrl, From 680a50e375d47e6480906060469c152a066cf76f Mon Sep 17 00:00:00 2001 From: silverwind Date: Wed, 13 May 2026 03:39:43 +0200 Subject: [PATCH 06/18] fix(repo): rename AvatarStack and handle nil author in blame rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `CoAuthorAvatarStack` → `AvatarStack`; update all template callers (`blame.tmpl`, `commits_list_small.tmpl`, `feeds.tmpl`) plus the internal call from `CoAuthorAvatars` and the unit tests. - Return empty HTML when both `authorUser` and `authorSig` are nil. Blame rows that are not the first line of a blame chunk have no author info populated; the previous pre-rendered-per-row pattern resulted in an empty `template.HTML` field for these, but the direct-call refactor was dereferencing `authorSig.Email` and crashing the blame view. Co-Authored-By: Claude (Opus 4.7) --- modules/templates/util_render.go | 11 +++++++---- modules/templates/util_render_test.go | 4 ++-- templates/repo/blame.tmpl | 2 +- templates/repo/commits_list_small.tmpl | 2 +- templates/user/dashboard/feeds.tmpl | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go index 8a202da4925ce..6bf9b6650c0c3 100644 --- a/modules/templates/util_render.go +++ b/modules/templates/util_render.go @@ -342,11 +342,14 @@ func (ut *RenderUtils) commitAuthorSearchURL(authorName string) template.URL { return template.URL(repoLink + "/commits/" + refSubURL + "/search?q=" + url.QueryEscape("author:"+authorName)) } -// CoAuthorAvatarStack renders an avatar stack for the commit author and co-authors. -// authorUser may be nil when no Gitea account is linked; authorSig must always be set. +// AvatarStack renders an avatar stack for the commit author and co-authors. +// Returns empty when neither a Gitea user nor a git signature is available. // Each stack child carries an inline `--n` custom property so the CSS can apply // z-index and the :hover translate without hardcoding nth-child rules. -func (ut *RenderUtils) CoAuthorAvatarStack(authorUser *user_model.User, authorSig *git.Signature, coAuthors []*user_model.CoAuthorUser, additionalClasses string) template.HTML { +func (ut *RenderUtils) AvatarStack(authorUser *user_model.User, authorSig *git.Signature, coAuthors []*user_model.CoAuthorUser, additionalClasses string) template.HTML { + if authorUser == nil && authorSig == nil { + return "" + } au := NewAvatarUtils(ut.ctx) if len(coAuthors) == 0 { if authorUser != nil { @@ -424,7 +427,7 @@ func (ut *RenderUtils) CoAuthorAvatars(authorUser *user_model.User, authorSig *g var b strings.Builder b.WriteString(``) - b.WriteString(string(ut.CoAuthorAvatarStack(authorUser, authorSig, coAuthors, stackClass))) + b.WriteString(string(ut.AvatarStack(authorUser, authorSig, coAuthors, stackClass))) switch len(coAuthors) { case 0: diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go index 2ec7c0f6a74b1..b4b6863d7dceb 100644 --- a/modules/templates/util_render_test.go +++ b/modules/templates/util_render_test.go @@ -288,13 +288,13 @@ func TestCoAuthorAvatars(t *testing.T) { for i := range cos { cos[i] = mkCo("X", "x@example.com") } - got := string(ut.CoAuthorAvatarStack(nil, authorSig, cos, "")) + got := string(ut.AvatarStack(nil, authorSig, cos, "")) assert.Contains(t, got, `class="avatar-stack-overflow-chip`) assert.Contains(t, got, "+1") }) t.Run("each stack child carries inline --n", func(t *testing.T) { - got := string(ut.CoAuthorAvatarStack(nil, authorSig, + got := string(ut.AvatarStack(nil, authorSig, []*user_model.CoAuthorUser{mkCo("Bob", "bob@example.com")}, "")) assert.Contains(t, got, `style="--n:0"`) assert.Contains(t, got, `style="--n:1"`) diff --git a/templates/repo/blame.tmpl b/templates/repo/blame.tmpl index eb2ab68ddf5a2..67585a27f5130 100644 --- a/templates/repo/blame.tmpl +++ b/templates/repo/blame.tmpl @@ -43,7 +43,7 @@
- {{ctx.RenderUtils.CoAuthorAvatarStack $row.AuthorUser $row.Author $row.CoAuthors ""}} + {{ctx.RenderUtils.AvatarStack $row.AuthorUser $row.Author $row.CoAuthors ""}}