Merge pull request 'Port archived labels visual filter' (#2887) from 0ko/forgejo:lable-archive-bw into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/2887
Reviewed-by: Earl Warren <earl-warren@noreply.codeberg.org>
This commit is contained in:
Earl Warren 2024-04-03 20:06:39 +00:00
commit efd0f0f224
14 changed files with 138 additions and 36 deletions

View file

@ -116,12 +116,17 @@ func (l *Label) CalOpenIssues() {
func (l *Label) SetArchived(isArchived bool) { func (l *Label) SetArchived(isArchived bool) {
if !isArchived { if !isArchived {
l.ArchivedUnix = timeutil.TimeStamp(0) l.ArchivedUnix = timeutil.TimeStamp(0)
} else if isArchived && l.ArchivedUnix.IsZero() { } else if isArchived && !l.IsArchived() {
// Only change the date when it is newly archived. // Only change the date when it is newly archived.
l.ArchivedUnix = timeutil.TimeStampNow() l.ArchivedUnix = timeutil.TimeStampNow()
} }
} }
// IsArchived returns true if label is an archived
func (l *Label) IsArchived() bool {
return !l.ArchivedUnix.IsZero()
}
// CalOpenOrgIssues calculates the open issues of a label for a specific repo // CalOpenOrgIssues calculates the open issues of a label for a specific repo
func (l *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) { func (l *Label) CalOpenOrgIssues(ctx context.Context, repoID, labelID int64) {
counts, _ := CountIssuesByRepo(ctx, &IssuesOptions{ counts, _ := CountIssuesByRepo(ctx, &IssuesOptions{
@ -166,11 +171,6 @@ func (l *Label) BelongsToOrg() bool {
return l.OrgID > 0 return l.OrgID > 0
} }
// IsArchived returns true if label is an archived
func (l *Label) IsArchived() bool {
return l.ArchivedUnix > 0
}
// BelongsToRepo returns true if label is a repository label // BelongsToRepo returns true if label is a repository label
func (l *Label) BelongsToRepo() bool { func (l *Label) BelongsToRepo() bool {
return l.RepoID > 0 return l.RepoID > 0

View file

@ -20,6 +20,7 @@ import (
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/markdown" "code.gitea.io/gitea/modules/markup/markdown"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/util"
) )
@ -104,6 +105,18 @@ func RenderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML {
return template.HTML(htmlWithCodeTags) return template.HTML(htmlWithCodeTags)
} }
const (
activeLabelOpacity = uint8(255)
archivedLabelOpacity = uint8(127)
)
func GetLabelOpacityByte(isArchived bool) uint8 {
if isArchived {
return archivedLabelOpacity
}
return activeLabelOpacity
}
// RenderIssueTitle renders issue/pull title with defined post processors // RenderIssueTitle renders issue/pull title with defined post processors
func RenderIssueTitle(ctx context.Context, text string, metas map[string]string) template.HTML { func RenderIssueTitle(ctx context.Context, text string, metas map[string]string) template.HTML {
renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{ renderedText, err := markup.RenderIssueTitle(&markup.RenderContext{
@ -118,22 +131,34 @@ func RenderIssueTitle(ctx context.Context, text string, metas map[string]string)
} }
// RenderLabel renders a label // RenderLabel renders a label
func RenderLabel(ctx context.Context, label *issues_model.Label) template.HTML { // locale is needed due to an import cycle with our context providing the `Tr` function
labelScope := label.ExclusiveScope() func RenderLabel(ctx context.Context, locale translation.Locale, label *issues_model.Label) template.HTML {
var (
textColor := "#111" archivedCSSClass string
textColor = "#111"
labelScope = label.ExclusiveScope()
)
r, g, b := util.HexToRBGColor(label.Color) r, g, b := util.HexToRBGColor(label.Color)
// Determine if label text should be light or dark to be readable on background color // Determine if label text should be light or dark to be readable on background color
// this doesn't account for saturation or transparency
if util.UseLightTextOnBackground(r, g, b) { if util.UseLightTextOnBackground(r, g, b) {
textColor = "#eee" textColor = "#eee"
} }
description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description)) description := emoji.ReplaceAliases(template.HTMLEscapeString(label.Description))
if label.IsArchived() {
archivedCSSClass = "archived-label"
description = locale.TrString("repo.issues.archived_label_description", description)
}
if labelScope == "" { if labelScope == "" {
// Regular label // Regular label
s := fmt.Sprintf("<div class='ui label' style='color: %s !important; background-color: %s !important' data-tooltip-content title='%s'>%s</div>",
textColor, label.Color, description, RenderEmoji(ctx, label.Name)) labelColor := label.Color + hex.EncodeToString([]byte{GetLabelOpacityByte(label.IsArchived())})
s := fmt.Sprintf("<div class='ui label %s' style='color: %s !important; background-color: %s !important;' data-tooltip-content title='%s'>%s</div>",
archivedCSSClass, textColor, labelColor, description, RenderEmoji(ctx, label.Name))
return template.HTML(s) return template.HTML(s)
} }
@ -152,25 +177,28 @@ func RenderLabel(ctx context.Context, label *issues_model.Label) template.HTML {
darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0) darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0)
lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0) lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0)
opacity := GetLabelOpacityByte(label.IsArchived())
scopeBytes := []byte{ scopeBytes := []byte{
uint8(math.Min(math.Round(r*darkenFactor), 255)), uint8(math.Min(math.Round(r*darkenFactor), 255)),
uint8(math.Min(math.Round(g*darkenFactor), 255)), uint8(math.Min(math.Round(g*darkenFactor), 255)),
uint8(math.Min(math.Round(b*darkenFactor), 255)), uint8(math.Min(math.Round(b*darkenFactor), 255)),
opacity,
} }
itemBytes := []byte{ itemBytes := []byte{
uint8(math.Min(math.Round(r*lightenFactor), 255)), uint8(math.Min(math.Round(r*lightenFactor), 255)),
uint8(math.Min(math.Round(g*lightenFactor), 255)), uint8(math.Min(math.Round(g*lightenFactor), 255)),
uint8(math.Min(math.Round(b*lightenFactor), 255)), uint8(math.Min(math.Round(b*lightenFactor), 255)),
opacity,
} }
itemColor := "#" + hex.EncodeToString(itemBytes)
scopeColor := "#" + hex.EncodeToString(scopeBytes) scopeColor := "#" + hex.EncodeToString(scopeBytes)
itemColor := "#" + hex.EncodeToString(itemBytes)
s := fmt.Sprintf("<span class='ui label scope-parent' data-tooltip-content title='%s'>"+ s := fmt.Sprintf("<span class='ui label %s scope-parent' data-tooltip-content title='%s'>"+
"<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+ "<div class='ui label scope-left' style='color: %s !important; background-color: %s !important'>%s</div>"+
"<div class='ui label scope-right' style='color: %s !important; background-color: %s !important'>%s</div>"+ "<div class='ui label scope-right' style='color: %s !important; background-color: %s !important'>%s</div>"+
"</span>", "</span>",
description, archivedCSSClass, description,
textColor, scopeColor, scopeText, textColor, scopeColor, scopeText,
textColor, itemColor, itemText) textColor, itemColor, itemText)
return template.HTML(s) return template.HTML(s)
@ -211,7 +239,7 @@ func RenderMarkdownToHtml(ctx context.Context, input string) template.HTML { //n
return output return output
} }
func RenderLabels(ctx context.Context, labels []*issues_model.Label, repoLink string) template.HTML { func RenderLabels(ctx context.Context, locale translation.Locale, labels []*issues_model.Label, repoLink string) template.HTML {
htmlCode := `<span class="labels-list">` htmlCode := `<span class="labels-list">`
for _, label := range labels { for _, label := range labels {
// Protect against nil value in labels - shouldn't happen but would cause a panic if so // Protect against nil value in labels - shouldn't happen but would cause a panic if so
@ -219,7 +247,7 @@ func RenderLabels(ctx context.Context, labels []*issues_model.Label, repoLink st
continue continue
} }
htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d'>%s</a> ", htmlCode += fmt.Sprintf("<a href='%s/issues?labels=%d'>%s</a> ",
repoLink, label.ID, RenderLabel(ctx, label)) repoLink, label.ID, RenderLabel(ctx, locale, label))
} }
htmlCode += "</span>" htmlCode += "</span>"
return template.HTML(htmlCode) return template.HTML(htmlCode)

View file

@ -1628,6 +1628,7 @@ issues.label_modify = Edit label
issues.label_deletion = Delete label issues.label_deletion = Delete label
issues.label_deletion_desc = Deleting a label removes it from all issues. Continue? issues.label_deletion_desc = Deleting a label removes it from all issues. Continue?
issues.label_deletion_success = The label has been deleted. issues.label_deletion_success = The label has been deleted.
issues.archived_label_description = (Archived) %s
issues.label.filter_sort.alphabetically = Alphabetically issues.label.filter_sort.alphabetically = Alphabetically
issues.label.filter_sort.reverse_alphabetically = Reverse alphabetically issues.label.filter_sort.reverse_alphabetically = Reverse alphabetically
issues.label.filter_sort.by_size = Smallest size issues.label.filter_sort.by_size = Smallest size

View file

@ -13,7 +13,6 @@ import (
"code.gitea.io/gitea/modules/label" "code.gitea.io/gitea/modules/label"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
repo_module "code.gitea.io/gitea/modules/repository" repo_module "code.gitea.io/gitea/modules/repository"
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms" "code.gitea.io/gitea/services/forms"
@ -112,12 +111,11 @@ func NewLabel(ctx *context.Context) {
} }
l := &issues_model.Label{ l := &issues_model.Label{
RepoID: ctx.Repo.Repository.ID, RepoID: ctx.Repo.Repository.ID,
Name: form.Title, Name: form.Title,
Exclusive: form.Exclusive, Exclusive: form.Exclusive,
Description: form.Description, Description: form.Description,
Color: form.Color, Color: form.Color,
ArchivedUnix: timeutil.TimeStamp(0),
} }
if err := issues_model.NewLabel(ctx, l); err != nil { if err := issues_model.NewLabel(ctx, l); err != nil {
ctx.ServerError("NewLabel", err) ctx.ServerError("NewLabel", err)

View file

@ -61,7 +61,7 @@
{{if or .Labels .Assignees}} {{if or .Labels .Assignees}}
<div class="extra content labels-list tw-p-0 tw-pt-1"> <div class="extra content labels-list tw-p-0 tw-pt-1">
{{range .Labels}} {{range .Labels}}
<a target="_blank" href="{{$.Issue.Repo.Link}}/issues?labels={{.ID}}">{{RenderLabel ctx .}}</a> <a target="_blank" href="{{$.Issue.Repo.Link}}/issues?labels={{.ID}}">{{RenderLabel ctx ctx.Locale .}}</a>
{{end}} {{end}}
<div class="right floated"> <div class="right floated">
{{range .Assignees}} {{range .Assignees}}

View file

@ -30,7 +30,7 @@
{{end}} {{end}}
{{$previousExclusiveScope = $exclusiveScope}} {{$previousExclusiveScope = $exclusiveScope}}
<div class="item issue-action tw-flex tw-justify-between" data-action="toggle" data-element-id="{{.ID}}" data-url="{{$.RepoLink}}/issues/labels"> <div class="item issue-action tw-flex tw-justify-between" data-action="toggle" data-element-id="{{.ID}}" data-url="{{$.RepoLink}}/issues/labels">
{{if SliceUtils.Contains $.SelLabelIDs .ID}}{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}{{end}} {{RenderLabel $.Context .}} {{if SliceUtils.Contains $.SelLabelIDs .ID}}{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}{{end}} {{RenderLabel $.Context ctx.Locale .}}
{{template "repo/issue/labels/label_archived" .}} {{template "repo/issue/labels/label_archived" .}}
</div> </div>
{{end}} {{end}}

View file

@ -42,7 +42,7 @@
{{svg "octicon-check"}} {{svg "octicon-check"}}
{{end}} {{end}}
{{end}} {{end}}
{{RenderLabel $.Context .}} {{RenderLabel $.Context ctx.Locale .}}
<p class="tw-ml-auto">{{template "repo/issue/labels/label_archived" .}}</p> <p class="tw-ml-auto">{{template "repo/issue/labels/label_archived" .}}</p>
</a> </a>
{{end}} {{end}}

View file

@ -3,5 +3,5 @@
id="label_{{.label.ID}}" id="label_{{.label.ID}}"
href="{{.root.RepoLink}}/{{if or .root.IsPull .root.Issue.IsPull}}pulls{{else}}issues{{end}}?labels={{.label.ID}}"{{/* FIXME: use .root.Issue.Link or create .root.Link */}} href="{{.root.RepoLink}}/{{if or .root.IsPull .root.Issue.IsPull}}pulls{{else}}issues{{end}}?labels={{.label.ID}}"{{/* FIXME: use .root.Issue.Link or create .root.Link */}}
> >
{{- RenderLabel $.Context .label -}} {{- RenderLabel $.Context ctx.Locale .label -}}
</a> </a>

View file

@ -32,7 +32,7 @@
{{range .Labels}} {{range .Labels}}
<li class="item"> <li class="item">
<div class="label-title"> <div class="label-title">
{{RenderLabel $.Context .}} {{RenderLabel $.Context ctx.Locale .}}
{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji $.Context}}</small>{{end}} {{if .Description}}<br><small class="desc">{{.Description | RenderEmoji $.Context}}</small>{{end}}
</div> </div>
<div class="label-issues"> <div class="label-issues">
@ -72,7 +72,7 @@
{{range .OrgLabels}} {{range .OrgLabels}}
<li class="item org-label"> <li class="item org-label">
<div class="label-title"> <div class="label-title">
{{RenderLabel $.Context .}} {{RenderLabel $.Context ctx.Locale .}}
{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji $.Context}}</small>{{end}} {{if .Description}}<br><small class="desc">{{.Description | RenderEmoji $.Context}}</small>{{end}}
</div> </div>
<div class="label-issues"> <div class="label-issues">

View file

@ -21,7 +21,7 @@
<div class="divider"></div> <div class="divider"></div>
{{end}} {{end}}
{{$previousExclusiveScope = $exclusiveScope}} {{$previousExclusiveScope = $exclusiveScope}}
<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel $.Context .}} <a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel $.Context ctx.Locale .}}
{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji $.Context}}</small>{{end}} {{if .Description}}<br><small class="desc">{{.Description | RenderEmoji $.Context}}</small>{{end}}
<p class="archived-label-hint">{{template "repo/issue/labels/label_archived" .}}</p> <p class="archived-label-hint">{{template "repo/issue/labels/label_archived" .}}</p>
</a> </a>
@ -34,7 +34,7 @@
<div class="divider"></div> <div class="divider"></div>
{{end}} {{end}}
{{$previousExclusiveScope = $exclusiveScope}} {{$previousExclusiveScope = $exclusiveScope}}
<a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel $.Context .}} <a class="{{if .IsChecked}}checked{{end}} item" href="#" data-id="{{.ID}}" {{if .IsArchived}}data-is-archived{{end}} data-id-selector="#label_{{.ID}}" data-scope="{{$exclusiveScope}}"><span class="octicon-check {{if not .IsChecked}}tw-invisible{{end}}">{{if $exclusiveScope}}{{svg "octicon-dot-fill"}}{{else}}{{svg "octicon-check"}}{{end}}</span>&nbsp;&nbsp;{{RenderLabel $.Context ctx.Locale .}}
{{if .Description}}<br><small class="desc">{{.Description | RenderEmoji $.Context}}</small>{{end}} {{if .Description}}<br><small class="desc">{{.Description | RenderEmoji $.Context}}</small>{{end}}
<p class="archived-label-hint">{{template "repo/issue/labels/label_archived" .}}</p> <p class="archived-label-hint">{{template "repo/issue/labels/label_archived" .}}</p>
</a> </a>

View file

@ -177,11 +177,11 @@
<span class="text grey muted-links"> <span class="text grey muted-links">
{{template "shared/user/authorlink" .Poster}} {{template "shared/user/authorlink" .Poster}}
{{if and .AddedLabels (not .RemovedLabels)}} {{if and .AddedLabels (not .RemovedLabels)}}
{{ctx.Locale.TrN (len .AddedLabels) "repo.issues.add_label" "repo.issues.add_labels" (RenderLabels $.Context .AddedLabels $.RepoLink) $createdStr}} {{ctx.Locale.TrN (len .AddedLabels) "repo.issues.add_label" "repo.issues.add_labels" (RenderLabels $.Context ctx.Locale .AddedLabels $.RepoLink) $createdStr}}
{{else if and (not .AddedLabels) .RemovedLabels}} {{else if and (not .AddedLabels) .RemovedLabels}}
{{ctx.Locale.TrN (len .RemovedLabels) "repo.issues.remove_label" "repo.issues.remove_labels" (RenderLabels $.Context .RemovedLabels $.RepoLink) $createdStr}} {{ctx.Locale.TrN (len .RemovedLabels) "repo.issues.remove_label" "repo.issues.remove_labels" (RenderLabels $.Context ctx.Locale .RemovedLabels $.RepoLink) $createdStr}}
{{else}} {{else}}
{{ctx.Locale.Tr "repo.issues.add_remove_labels" (RenderLabels $.Context .AddedLabels $.RepoLink) (RenderLabels $.Context .RemovedLabels $.RepoLink) $createdStr}} {{ctx.Locale.Tr "repo.issues.add_remove_labels" (RenderLabels $.Context ctx.Locale .AddedLabels $.RepoLink) (RenderLabels $.Context ctx.Locale .RemovedLabels $.RepoLink) $createdStr}}
{{end}} {{end}}
</span> </span>
</div> </div>

View file

@ -21,7 +21,7 @@
{{end}} {{end}}
<span class="labels-list tw-ml-1"> <span class="labels-list tw-ml-1">
{{range .Labels}} {{range .Labels}}
<a href="?q={{$.Keyword}}&type={{$.ViewType}}&state={{$.State}}&labels={{.ID}}{{if ne $.listType "milestone"}}&milestone={{$.MilestoneID}}{{end}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{RenderLabel $.Context .}}</a> <a href="?q={{$.Keyword}}&type={{$.ViewType}}&state={{$.State}}&labels={{.ID}}{{if ne $.listType "milestone"}}&milestone={{$.MilestoneID}}{{end}}&assignee={{$.AssigneeID}}&poster={{$.PosterID}}{{if $.ShowArchivedLabels}}&archived=true{{end}}">{{RenderLabel $.Context ctx.Locale .}}</a>
{{end}} {{end}}
</span> </span>
</div> </div>

View file

@ -0,0 +1,71 @@
// Copyright 2024 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"net/http"
"net/url"
"strings"
"testing"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
)
func TestArchivedLabelVisualProperties(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
session := loginUser(t, "user2")
// Create labels
session.MakeRequest(t, NewRequestWithValues(t, "POST", "user2/repo1/labels/new", map[string]string{
"_csrf": GetCSRF(t, session, "user2/repo1/labels"),
"title": "active_label",
"description": "",
"color": "#aa00aa",
}), http.StatusSeeOther)
session.MakeRequest(t, NewRequestWithValues(t, "POST", "user2/repo1/labels/new", map[string]string{
"_csrf": GetCSRF(t, session, "user2/repo1/labels"),
"title": "archived_label",
"description": "",
"color": "#00aa00",
}), http.StatusSeeOther)
// Get ID of label to archive it
var id string
doc := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", "user2/repo1/labels"), http.StatusOK).Body)
doc.Find(".issue-label-list .item").Each(func(i int, s *goquery.Selection) {
label := s.Find(".label-title .label")
if label.Text() == "archived_label" {
href, _ := s.Find(".label-issues a.open-issues").Attr("href")
hrefParts := strings.Split(href, "=")
id = hrefParts[len(hrefParts)-1]
}
})
// Make label archived
session.MakeRequest(t, NewRequestWithValues(t, "POST", "user2/repo1/labels/edit", map[string]string{
"_csrf": GetCSRF(t, session, "user2/repo1/labels"),
"id": id,
"title": "archived_label",
"is_archived": "on",
"description": "",
"color": "#00aa00",
}), http.StatusSeeOther)
// Test label properties
doc = NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", "user2/repo1/labels"), http.StatusOK).Body)
doc.Find(".issue-label-list .item").Each(func(i int, s *goquery.Selection) {
label := s.Find(".label-title .label")
style, _ := label.Attr("style")
if label.Text() == "active_label" {
assert.False(t, label.HasClass("archived-label"))
assert.Contains(t, style, "background-color: #aa00aaff")
} else if label.Text() == "archived_label" {
assert.True(t, label.HasClass("archived-label"))
assert.Contains(t, style, "background-color: #00aa007f")
}
})
})
}

View file

@ -2435,6 +2435,10 @@
margin-left: 0; margin-left: 0;
} }
.archived-label {
filter: grayscale(0.25) saturate(0.75);
}
.repo-button-row { .repo-button-row {
margin: 10px 0; margin: 10px 0;
display: flex; display: flex;