From 0d521647d6eda8f65e28f18e22b473685df5c120 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 30 Oct 2026 18:52:44 -0300 Subject: [PATCH] =?UTF-8?q?feat(native):=20put=20the=20Pulls=20description?= =?UTF-8?q?'s=20Edit=20in=20a=20=E2=8B=AF=20menu=20and=20draw=20task=20lis?= =?UTF-8?q?ts=20and=20heading=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Overview's Edit button beside the Description heading becomes a ⋯ menu with Copy link, Copy Markdown and Edit, the way GitHub's comments offer them. Markdown task list items draw a read-only checkbox in the bullet's place, and h1/h2 headings get GitHub's rule underneath, with headings at GitHub's 1.25 line height so the rule sits close to its text. --- CHANGELOG.md | 3 ++ native/cmd/lich-native/pulls_edit.go | 47 ++++++++++------ native/cmd/lich-native/pulls_markdown.go | 25 ++++++++- native/cmd/lich-native/pulls_markdown_code.go | 2 +- native/cmd/lich-native/pulls_markdown_list.go | 53 ++++++++++++++++++- .../lich-native/pulls_markdown_list_test.go | 28 ++++++++++ .../cmd/lich-native/pulls_markdown_render.go | 6 ++- native/cmd/lich-native/pulls_markdown_test.go | 15 +++++- native/cmd/lich-native/pulls_panels.go | 6 +-- native/cmd/lich-native/stale_pulls_test.go | 11 +++- native/i18n/locales/en/pulls.json | 2 + native/i18n/locales/es/pulls.json | 2 + native/i18n/locales/ja/pulls.json | 2 + native/i18n/locales/ko/pulls.json | 2 + native/i18n/locales/pt-br/pulls.json | 2 + native/i18n/locales/ru/pulls.json | 2 + native/i18n/locales/zh-cn/pulls.json | 2 + 17 files changed, 186 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2660c4446..f3e1a4ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - The Conversation tab of a pull request on the Pulls screen shows each author's GitHub picture beside their reviews and comments, in a column down the left, the way GitHub does. Comments inside a review thread, on the Conversation tab and on the Files tab, are separated by a line. Until a picture loads, or for an account that has none, the author's initial stands in its place. +- Task lists in pull request descriptions and comments show a checkbox, ticked or not, in place of a literal `[x]` or `[ ]`. +- Top-level headings in pull request descriptions and comments are underlined across the text's width, as on GitHub. +- The Description heading on the Pulls screen's Overview has a ⋯ menu, as GitHub's comments do: Copy link copies the pull request's address, Copy Markdown its description as written, and Edit opens the description for editing, which the Edit button beside the heading did before. ### Changed diff --git a/native/cmd/lich-native/pulls_edit.go b/native/cmd/lich-native/pulls_edit.go index 53f4f0594..93b8f6f78 100644 --- a/native/cmd/lich-native/pulls_edit.go +++ b/native/cmd/lich-native/pulls_edit.go @@ -1,9 +1,12 @@ package main import ( + "io" + "strings" "time" "gioui.org/font" + "gioui.org/io/clipboard" "gioui.org/layout" "gioui.org/widget" @@ -31,7 +34,8 @@ type editState struct { body widget.Editor bodySize ui.TextareaResize bodyBar widget.Scrollbar - bodyOpen widget.Clickable + bodyMore widget.Clickable + bodyMenu ui.MenuState bodySave widget.Clickable bodyCancel widget.Clickable @@ -118,33 +122,46 @@ func (v *pullView) pullTitle(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDe ) } -// descriptionHeading is the Description heading, with the Edit that turns the -// description into a field while it is not one already. -func (v *pullView) descriptionHeading(gtx C, th *ui.Theme, d backend.PRDetail) D { +// descriptionHeading is the Description heading, with the ⋯ that copies the +// description or turns it into a field while it is not one already. +func (v *pullView) descriptionHeading(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail) D { s := &v.edit // Cancel is the field's, drawn under the heading: taken here, the // heading and the field both read it. if s.bodyCancel.Clicked(gtx) && v.busyNow() != "body" { s.bodyURL = "" } - if s.bodyOpen.Clicked(gtx) { - s.bodyURL = d.URL - s.body.SetText(d.Body) - gtx.Execute(keyFocus(&s.body)) - } heading := sectionHeading(th, i18n.T("pulls.pullsOverview.description")) if s.bodyURL == d.URL { return heading(gtx) } - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(heading), - ui.Gap(2), + var copied string + entries := []ui.MenuEntry{ + {Label: i18n.T("pulls.pullsOverview.copyLink"), OnClick: func() { copied = d.URL }}, + {Label: i18n.T("pulls.pullsOverview.copyMarkdown"), Disabled: strings.TrimSpace(d.Body) == "", OnClick: func() { copied = d.Body }}, + {Kind: ui.MenuSeparator}, + {Label: i18n.T("pulls.pullsOverview.editDescription"), OnClick: func() { + s.bodyURL = d.URL + s.body.SetText(d.Body) + }}, + } + b := th.Button(&s.bodyMore, "") + b.Icon, b.Variant, b.Size = icPullMore, ui.VariantGhost, ui.SizeIconXS + dims := layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, heading), layout.Rigid(func(gtx C) D { - b := th.Button(&s.bodyOpen, i18n.T("pulls.pullsOverview.editDescription")) - b.Icon, b.Variant, b.Size = icPullEdit, ui.VariantGhost, ui.SizeXS - return b.Layout(gtx) + return v.withTip(gtx, th, p, "description", i18n.T("pulls.reviewThread.more"), func(gtx C) D { + return th.DropdownMenu(p.root, &s.bodyMenu, entries).Layout(gtx, b.Layout) + }) }), ) + if copied != "" { + gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(strings.NewReader(copied))}) + } + if s.bodyURL == d.URL { + gtx.Execute(keyFocus(&s.body)) + } + return dims } // descriptionField is the description being edited, raw Markdown, saved with diff --git a/native/cmd/lich-native/pulls_markdown.go b/native/cmd/lich-native/pulls_markdown.go index c3cd7c5a2..da486f1d0 100644 --- a/native/cmd/lich-native/pulls_markdown.go +++ b/native/cmd/lich-native/pulls_markdown.go @@ -6,6 +6,7 @@ import ( "math" "gioui.org/font" + "gioui.org/layout" "gioui.org/op" "gioui.org/text" "gioui.org/unit" @@ -19,6 +20,9 @@ const ( markdownH1 = unit.Sp(20) markdownH2 = unit.Sp(19) markdownLineHeightScale = 1.5 + // markdownHeadingLineHeightScale is GitHub's line-height for headings, + // which keeps an h1 or h2's rule close to its text. + markdownHeadingLineHeightScale = 1.25 ) func markdownProseColor(th *ui.Theme) color.NRGBA { @@ -43,6 +47,9 @@ type markdownBlock struct { prose *markdownCell table *markdownTable list *markdownList + // rule underlines the prose across the body's width, GitHub's border + // under an h1 or h2. + rule bool } // markdownCell is rendered Markdown and the link state it is laid out with. @@ -50,6 +57,8 @@ type markdownCell struct { spans []richtext.SpanStyle code []bool state richtext.InteractiveText + // lineHeight is the line height scale, markdownLineHeightScale when 0. + lineHeight float32 } // markdownCache keeps each body's markdownText by a key of the caller's, so @@ -144,6 +153,13 @@ func (b markdownBlock) layout(gtx C, th *ui.Theme, open func(url string)) D { if b.list != nil { return b.list.layout(gtx, th, open) } + if b.rule { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { return b.prose.layout(gtx, th, open) }), + ui.VGap(1), + layout.Rigid(th.Separator), + ) + } return b.prose.layout(gtx, th, open) } @@ -158,7 +174,14 @@ func (c *markdownCell) layout(gtx C, th *ui.Theme, open func(url string)) D { } } style := richtext.Text(&c.state, th.Shaper, c.spans...) - style.LineHeightScale = markdownLineHeightScale + style.LineHeightScale = c.lineHeightScale() c.codeBackgrounds(gtx, th) return style.Layout(gtx) } + +func (c *markdownCell) lineHeightScale() float32 { + if c.lineHeight != 0 { + return c.lineHeight + } + return markdownLineHeightScale +} diff --git a/native/cmd/lich-native/pulls_markdown_code.go b/native/cmd/lich-native/pulls_markdown_code.go index 80b5f98e4..b682e725e 100644 --- a/native/cmd/lich-native/pulls_markdown_code.go +++ b/native/cmd/lich-native/pulls_markdown_code.go @@ -27,7 +27,7 @@ func (c *markdownCell) codeBackgrounds(gtx C, th *ui.Theme) { spans[i] = styledtext.SpanStyle{Font: span.Font, Size: span.Size, Color: color.NRGBA{}, Content: span.Content} } style := styledtext.Text(th.Shaper, spans...) - style.LineHeightScale = markdownLineHeightScale + style.LineHeightScale = c.lineHeightScale() style.Layout(gtx, func(gtx layout.Context, i int, dims layout.Dimensions) { if !c.code[i] || dims.Size.X == 0 || dims.Size.Y == 0 { return diff --git a/native/cmd/lich-native/pulls_markdown_list.go b/native/cmd/lich-native/pulls_markdown_list.go index 6b3053af4..e1ad4598f 100644 --- a/native/cmd/lich-native/pulls_markdown_list.go +++ b/native/cmd/lich-native/pulls_markdown_list.go @@ -6,8 +6,11 @@ import ( "gioui.org/font" "gioui.org/layout" + "gioui.org/op" + "gioui.org/widget" "gioui.org/x/richtext" "github.com/yuin/goldmark/ast" + extast "github.com/yuin/goldmark/extension/ast" "github.com/lichdotdev/lich/native/ui" ) @@ -18,6 +21,9 @@ type markdownList struct { type markdownListItem struct { marker markdownCell + // task is a task list item's box, drawn in the marker's place; nil + // for any other item. + task *widget.Bool blocks []markdownBlock } @@ -33,6 +39,9 @@ func renderList(th *ui.Theme, source []byte, list *ast.List) *markdownList { entry := markdownListItem{marker: markdownCell{spans: []richtext.SpanStyle{{ Font: font.Font{Typeface: th.Sans}, Size: markdownBodySize, Color: markdownProseColor(th), Content: marker, }}}} + if box := taskCheckBox(item); box != nil { + entry.task = &widget.Bool{Value: box.IsChecked} + } for child := item.FirstChild(); child != nil; child = child.NextSibling() { if block, ok := renderBlock(th, source, child); ok { entry.blocks = append(entry.blocks, block) @@ -43,10 +52,21 @@ func renderList(th *ui.Theme, source []byte, list *ast.List) *markdownList { return out } +// taskCheckBox is the [ ] or [x] that opens item's first line, if it opens +// with one. +func taskCheckBox(item ast.Node) *extast.TaskCheckBox { + first := item.FirstChild() + if first == nil { + return nil + } + box, _ := first.FirstChild().(*extast.TaskCheckBox) + return box +} + func (l *markdownList) layout(gtx C, th *ui.Theme, open func(string)) D { markerWidth := 0 for i := range l.items { - markerWidth = max(markerWidth, measureCell(gtx, th, &l.items[i].marker)) + markerWidth = max(markerWidth, l.items[i].markerWidth(gtx, th)) } children := make([]layout.FlexChild, 0, len(l.items)) for i := range l.items { @@ -62,6 +82,9 @@ func (item *markdownListItem) layout(gtx C, th *ui.Theme, open func(string), mar return layout.Flex{Alignment: layout.Start}.Layout(gtx, layout.Rigid(func(gtx C) D { gtx.Constraints.Min.X, gtx.Constraints.Max.X = markerWidth, markerWidth + if item.task != nil { + return item.layoutTask(gtx, th) + } return layout.E.Layout(gtx, func(gtx C) D { return item.marker.layout(gtx, th, nil) }) }), layout.Rigid(layout.Spacer{Width: ui.Space(2)}.Layout), @@ -69,6 +92,34 @@ func (item *markdownListItem) layout(gtx C, th *ui.Theme, open func(string), mar ) } +// markerWidth is the width the item's marker, or its box, takes. +func (item *markdownListItem) markerWidth(gtx C, th *ui.Theme) int { + if item.task == nil { + return measureCell(gtx, th, &item.marker) + } + call := op.Record(gtx.Ops) + gtx.Constraints.Min = image.Point{} + dims := item.layoutTask(gtx, th) + call.Stop() + return dims.Size.X +} + +// layoutTask is the item's box, read-only as GitHub draws it, centred on the +// first line. +func (item *markdownListItem) layoutTask(gtx C, th *ui.Theme) D { + box := th.Checkbox(item.task) + box.Disabled = true + line := int(float32(gtx.Sp(markdownBodySize)) * markdownLineHeightScale) + return layout.E.Layout(gtx, func(gtx C) D { + call := op.Record(gtx.Ops) + dims := box.Layout(gtx) + c := call.Stop() + defer op.Offset(image.Pt(0, (line-dims.Size.Y)/2)).Push(gtx.Ops).Pop() + c.Add(gtx.Ops) + return D{Size: image.Pt(dims.Size.X, line)} + }) +} + func (item *markdownListItem) layoutBody(gtx C, th *ui.Theme, open func(string)) D { children := make([]layout.FlexChild, 0, len(item.blocks)*2) for i, block := range item.blocks { diff --git a/native/cmd/lich-native/pulls_markdown_list_test.go b/native/cmd/lich-native/pulls_markdown_list_test.go index e596fb08c..3e0e5c5b0 100644 --- a/native/cmd/lich-native/pulls_markdown_list_test.go +++ b/native/cmd/lich-native/pulls_markdown_list_test.go @@ -49,3 +49,31 @@ func TestMarkdownListWrapsInsideMarkerColumn(t *testing.T) { t.Errorf("list size %v does not include marker column and wrapped body %v", got, bodySize) } } + +func TestMarkdownTaskItemsDrawABoxInTheMarkersPlace(t *testing.T) { + list := renderBlocks(markdownTheme(), "- [x] done\n- [ ] todo\n- plain")[0].list + if len(list.items) != 3 { + t.Fatalf("got %d items, want 3", len(list.items)) + } + for i, want := range []struct { + task, checked bool + text string + }{{true, true, "done"}, {true, false, "todo"}, {false, false, "plain"}} { + item := list.items[i] + if (item.task != nil) != want.task || (item.task != nil && item.task.Value != want.checked) { + t.Errorf("item %d: task %v, want task %v checked %v", i, item.task, want.task, want.checked) + } + var text string + for _, span := range item.blocks[0].prose.spans { + text += span.Content + } + if text != want.text { + t.Errorf("item %d reads %q, want %q", i, text, want.text) + } + } + gtx := layout.Context{Ops: new(op.Ops), Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, + Constraints: layout.Constraints{Max: image.Pt(400, 800)}} + if w := list.items[0].markerWidth(gtx, markdownTheme()); w < gtx.Dp(ui.Space(4)) { + t.Errorf("marker column %dpx is narrower than the box", w) + } +} diff --git a/native/cmd/lich-native/pulls_markdown_render.go b/native/cmd/lich-native/pulls_markdown_render.go index bad2b2b44..037df890e 100644 --- a/native/cmd/lich-native/pulls_markdown_render.go +++ b/native/cmd/lich-native/pulls_markdown_render.go @@ -26,7 +26,7 @@ import ( // markdownURL is the span metadata a link's destination is kept under. const markdownURL = "url" -var markdownParser = goldmark.New(goldmark.WithExtensions(extension.Table, extension.Linkify)).Parser() +var markdownParser = goldmark.New(goldmark.WithExtensions(extension.Table, extension.Linkify, extension.TaskList)).Parser() // renderBlocks parses src once, so a reference link resolves wherever its // definition sits, and renders each top-level node as its own block. @@ -48,6 +48,10 @@ func renderBlock(th *ui.Theme, source []byte, n ast.Node) (markdownBlock, bool) return markdownBlock{table: renderTable(th, source, n)}, true case *ast.List: return markdownBlock{list: renderList(th, source, n)}, true + case *ast.Heading: + cell := renderNode(th, source, n, markdownProseColor(th)) + cell.lineHeight = markdownHeadingLineHeightScale + return markdownBlock{prose: cell, rule: n.Level <= 2}, len(cell.spans) > 0 default: cell := renderNode(th, source, n, markdownProseColor(th)) return markdownBlock{prose: cell}, len(cell.spans) > 0 diff --git a/native/cmd/lich-native/pulls_markdown_test.go b/native/cmd/lich-native/pulls_markdown_test.go index c38b12809..c7f692a62 100644 --- a/native/cmd/lich-native/pulls_markdown_test.go +++ b/native/cmd/lich-native/pulls_markdown_test.go @@ -2,6 +2,7 @@ package main import ( "image" + "slices" "strings" "testing" "time" @@ -17,7 +18,8 @@ import ( ) func markdownTheme() *ui.Theme { - return &ui.Theme{Palette: ui.Dark, Shaper: text.NewShaper(text.WithCollection(gofont.Collection()))} + th := &ui.Theme{Palette: ui.Dark, Shaper: text.NewShaper(text.WithCollection(gofont.Collection()))} + return th.On(th.Background) } func markdownFrame(height int) (C, *op.Ops) { @@ -149,3 +151,14 @@ func TestMarkdownUpdatesWhenInterfaceAndEditorFontsChange(t *testing.T) { t.Fatal("shaper change retained the old measurements") } } + +func TestMarkdownRulesUnderTopHeadingsOnly(t *testing.T) { + blocks := renderBlocks(markdownTheme(), "# One\n\n## Two\n\n### Three\n\nText") + var got []bool + for _, b := range blocks { + got = append(got, b.rule) + } + if want := []bool{true, true, false, false}; !slices.Equal(got, want) { + t.Errorf("rules = %v, want %v", got, want) + } +} diff --git a/native/cmd/lich-native/pulls_panels.go b/native/cmd/lich-native/pulls_panels.go index 2c9c7686d..7d9730a96 100644 --- a/native/cmd/lich-native/pulls_panels.go +++ b/native/cmd/lich-native/pulls_panels.go @@ -268,7 +268,7 @@ var ( // overview is the description beside the pull request's facts. func (v *pullView) overview(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDetail, act *pullsAction) D { - rows := v.overviewRows(th, d, act) + rows := v.overviewRows(th, p, d, act) if gtx.Constraints.Max.X < gtx.Dp(overviewSplitWidth) { facts := func(gtx C) D { return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(4)}.Layout(gtx, func(gtx C) D { return v.facts(gtx, th, p, d) }) @@ -330,7 +330,7 @@ func (v *pullView) checkoutFact(th *ui.Theme, p *pullsScreen, d backend.PRDetail // overviewRows is the description under its heading, held to a reading // width. -func (v *pullView) overviewRows(th *ui.Theme, d backend.PRDetail, act *pullsAction) []layout.Widget { +func (v *pullView) overviewRows(th *ui.Theme, p *pullsScreen, d backend.PRDetail, act *pullsAction) []layout.Widget { pad := func(top float32, w layout.Widget) layout.Widget { return func(gtx C) D { gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(overviewProseWidth+2*pullsPadX)) @@ -339,7 +339,7 @@ func (v *pullView) overviewRows(th *ui.Theme, d backend.PRDetail, act *pullsActi } body := strings.TrimSpace(d.Body) return []layout.Widget{ - pad(4, func(gtx C) D { return v.descriptionHeading(gtx, th, d) }), + pad(4, func(gtx C) D { return v.descriptionHeading(gtx, th, p, d) }), pad(2, func(gtx C) D { if v.edit.bodyURL == d.URL { return v.descriptionField(gtx, th, d, act) diff --git a/native/cmd/lich-native/stale_pulls_test.go b/native/cmd/lich-native/stale_pulls_test.go index be14290f6..ef9af0758 100644 --- a/native/cmd/lich-native/stale_pulls_test.go +++ b/native/cmd/lich-native/stale_pulls_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "image" "net/http" + "strings" "testing" "gioui.org/io/key" @@ -177,7 +178,15 @@ func TestPullsDeleteDialogWarnsTheThreadGoesAndCancelCloses(t *testing.T) { func TestPullsDescriptionCancelInTheFrameThatTookTheClick(t *testing.T) { u, r := pullsDetailShown(t) - r.clickLabel(t, i18n.T("pulls.pullsOverview.editDescription")) + r.click(t, r.controlOf(t, strings.ToUpper(i18n.T("pulls.pullsOverview.description")))) + r.settle(t) + // A menu row's clickable is its whole panel, so at would land between rows. + for _, n := range r.nodes() { + if n.Desc.Label == i18n.T("pulls.pullsOverview.editDescription") { + r.click(t, centre(image.Rectangle{Min: n.Desc.Bounds.Min, Max: n.Desc.Bounds.Min.Add(image.Pt(8, 8))})) + break + } + } r.clickLabel(t, i18n.T("common.action.cancel")) if u.pulls.view.edit.bodyURL != "" { t.Fatal("the click missed Cancel") diff --git a/native/i18n/locales/en/pulls.json b/native/i18n/locales/en/pulls.json index 246cc3f60..de4b36357 100644 --- a/native/i18n/locales/en/pulls.json +++ b/native/i18n/locales/en/pulls.json @@ -113,6 +113,8 @@ "noDescription": "No description.", "reviewers": "Reviewers", "nobodyYet": "Nobody yet", + "copyLink": "Copy link", + "copyMarkdown": "Copy Markdown", "editDescription": "Edit", "save": "Save", "saving": "Saving…", diff --git a/native/i18n/locales/es/pulls.json b/native/i18n/locales/es/pulls.json index ab20ba242..3edefdd78 100644 --- a/native/i18n/locales/es/pulls.json +++ b/native/i18n/locales/es/pulls.json @@ -113,6 +113,8 @@ "noDescription": "Sin descripción.", "reviewers": "Revisores", "nobodyYet": "Nadie todavía", + "copyLink": "Copiar enlace", + "copyMarkdown": "Copiar Markdown", "editDescription": "Editar", "save": "Guardar", "saving": "Guardando…", diff --git a/native/i18n/locales/ja/pulls.json b/native/i18n/locales/ja/pulls.json index 8e5cd715b..59918951b 100644 --- a/native/i18n/locales/ja/pulls.json +++ b/native/i18n/locales/ja/pulls.json @@ -113,6 +113,8 @@ "noDescription": "説明はありません。", "reviewers": "レビュアー", "nobodyYet": "まだいません", + "copyLink": "リンクをコピー", + "copyMarkdown": "Markdown をコピー", "editDescription": "編集", "save": "保存", "saving": "保存中…", diff --git a/native/i18n/locales/ko/pulls.json b/native/i18n/locales/ko/pulls.json index 4bef26460..cadf74797 100644 --- a/native/i18n/locales/ko/pulls.json +++ b/native/i18n/locales/ko/pulls.json @@ -111,6 +111,8 @@ "noDescription": "설명이 없습니다.", "reviewers": "리뷰어", "nobodyYet": "아직 없음", + "copyLink": "링크 복사", + "copyMarkdown": "Markdown 복사", "editDescription": "편집", "save": "저장", "saving": "저장 중…", diff --git a/native/i18n/locales/pt-br/pulls.json b/native/i18n/locales/pt-br/pulls.json index a2c9f3d36..069a09072 100644 --- a/native/i18n/locales/pt-br/pulls.json +++ b/native/i18n/locales/pt-br/pulls.json @@ -113,6 +113,8 @@ "noDescription": "Sem descrição.", "reviewers": "Revisores", "nobodyYet": "Ninguém ainda", + "copyLink": "Copiar link", + "copyMarkdown": "Copiar Markdown", "editDescription": "Editar", "save": "Salvar", "saving": "Salvando…", diff --git a/native/i18n/locales/ru/pulls.json b/native/i18n/locales/ru/pulls.json index 9915d8d71..0be1e1b57 100644 --- a/native/i18n/locales/ru/pulls.json +++ b/native/i18n/locales/ru/pulls.json @@ -117,6 +117,8 @@ "noDescription": "Описания нет.", "reviewers": "Ревьюеры", "nobodyYet": "Пока никого", + "copyLink": "Копировать ссылку", + "copyMarkdown": "Копировать Markdown", "editDescription": "Изменить", "save": "Сохранить", "saving": "Сохранение…", diff --git a/native/i18n/locales/zh-cn/pulls.json b/native/i18n/locales/zh-cn/pulls.json index d676710e9..a2fa316c8 100644 --- a/native/i18n/locales/zh-cn/pulls.json +++ b/native/i18n/locales/zh-cn/pulls.json @@ -113,6 +113,8 @@ "noDescription": "没有描述。", "reviewers": "评审者", "nobodyYet": "暂无", + "copyLink": "复制链接", + "copyMarkdown": "复制 Markdown", "editDescription": "编辑", "save": "保存", "saving": "正在保存…",