diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e1a4ca4..f7ea1f882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A session's browser is no longer closed without asking. When no pane shows it and nobody has used it for five minutes, a toast asks whether to close it: Close closes it, and Keep open, or swiping the toast away, keeps it for another five minutes. Unanswered, the toast stays up and the browser stays open, so a page left open over lunch is still there when you come back. Using the browser, or the agent using it, takes the toast down. Before, the browser was closed after five minutes away from its session, and its page, tabs and login were lost. - The sidebar's resize strip sits on the sidebar's own right edge, as the Code and Review panel's does, instead of in the gap between the sidebar and the sessions. +- On the Pulls screen's Overview, the Description heading and its ⋯ stay in place at the top of the column while the description scrolls under them, over a separator, and the ⋯ sits at the column's right edge. +- The Pulls screen's loading placeholders match what loads in their place: the index shows rows the height of real ones with the checks column where it lands, filling the screen, and a pull request shows its title row with its buttons, the real tab strip, and the open tab's layout (the description beside its facts, commits, checks, the conversation's avatars and comments, or the files' toolbar, tree and cards) instead of a few generic bars. The conversation and the diff show the same placeholders while they load, in place of a line of text. + ### Fixed - Code quoted in a review thread on the Pulls screen shows its indentation instead of a box for every tab. diff --git a/native/DESIGN.md b/native/DESIGN.md index 605bd52d7..ea7275550 100644 --- a/native/DESIGN.md +++ b/native/DESIGN.md @@ -162,8 +162,22 @@ the corner at `settingsCloseInset` (14px). Every other screen body follows the s ### Loading -A skeleton mirrors the screen it stands for: the same insets, line heights and columns, a bar where each -piece of text will land, so nothing moves when the content arrives. A generic block of bars is a bug. +A skeleton is the loaded screen with a bar where each piece of text will land, faithful to it in every +measure, so nothing moves when the content arrives: + +- the same insets, columns and separators, laid out by the same constants the loaded screen uses; +- a row the loaded row's exact height, its bars centred in the text's line height (`TextSM` in 20px, `TextXS` + in 16px) and as tall as the font size, never thinner; +- a list as many rows as fill the area, not a handful stacked at the top; +- a fixed column (a row's checks, a facts column) keeps its width and its bar where the value sits. + +A test pins the skeleton row's height to the loaded row's. A generic block of bars is a bug, and so is a +skeleton that only looks close: compare it with a shot of the loaded screen at the same size. + +### Fixed headings + +A heading that stays put while the content under it scrolls (the Overview's Description) sits on a +`th.Separator` across its column, so the edge the content scrolls under reads as one. ### Boxes inside a screen diff --git a/native/cmd/lich-native/pulls_files.go b/native/cmd/lich-native/pulls_files.go index 66d932b7b..a9085a977 100644 --- a/native/cmd/lich-native/pulls_files.go +++ b/native/cmd/lich-native/pulls_files.go @@ -141,7 +141,7 @@ func (v *pullView) filesTab(gtx C, th *ui.Theme, p *pullsScreen, d backend.PRDet read := p.store.diff(p.project.Path, d.Number) switch { case !read.loaded: - return panelNotice(gtx, th, i18n.T("pulls.pullsFiles.loading")) + return v.skeletonFiles(gtx, th) case read.err != "": return panelNotice(gtx, th, i18n.T("pulls.pullsFiles.loadFailed", i18n.Args{"error": read.err})) } diff --git a/native/cmd/lich-native/pulls_list.go b/native/cmd/lich-native/pulls_list.go index 7f9ae3161..df99b0cc9 100644 --- a/native/cmd/lich-native/pulls_list.go +++ b/native/cmd/lich-native/pulls_list.go @@ -222,7 +222,7 @@ func (p *pullsScreen) row(gtx C, th *ui.Theme, pr backend.PRSummary, now time.Ti } func rowContent(gtx C, th *ui.Theme, pr backend.PRSummary, checkout string, now time.Time) D { - return layout.Inset{Left: pullsRowPadX, Right: pullsRowPadX, Top: ui.Space(2.5), Bottom: ui.Space(2.5)}.Layout(gtx, func(gtx C) D { + return layout.Inset{Left: pullsRowPadX, Right: pullsRowPadX, Top: pullsRowPadY, Bottom: pullsRowPadY}.Layout(gtx, func(gtx C) D { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx C) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -396,20 +396,49 @@ func metaGlyph(gtx C, th *ui.Theme, ic *icons.Icon, text string, fg color.NRGBA) ) } -// listSkeleton is ListSkeleton: ragged title bars so it reads as a list. +// listSkeletonMaxRows bounds the skeleton under an unbounded height; no +// island is that tall. +const listSkeletonMaxRows = 60 + +// listSkeleton is the index while it loads: as many rows as the island +// holds, each skeletonRow over a separator, as row draws them. func listSkeleton(gtx C, th *ui.Theme) D { - widths := []float32{0.92, 0.8, 1, 0.75, 0.83} - return layout.Inset{Left: ui.Space(3.5), Right: ui.Space(3.5), Top: ui.Space(2)}.Layout(gtx, func(gtx C) D { - full := gtx.Metric.PxToDp(gtx.Constraints.Max.X) - var rows []layout.FlexChild - for _, w := range widths { - rows = append(rows, - layout.Rigid(th.Skeleton(full*unit.Dp(w), ui.Space(3)).Layout), - ui.VGap(1.5), - layout.Rigid(th.Skeleton(full/3, ui.Space(2.5)).Layout), - ui.VGap(3), - ) - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) + titles := []float32{0.62, 0.48, 0.7, 0.55, 0.4, 0.66, 0.5} + rowHeight := gtx.Dp(2*pullsRowPadY+skeletonSMLine+ui.Space(1)+skeletonXSLine) + gtx.Dp(1) + count := min(gtx.Constraints.Max.Y/rowHeight+1, listSkeletonMaxRows) + var rows []layout.FlexChild + for i := range count { + title := titles[i%len(titles)] + rows = append(rows, layout.Rigid(func(gtx C) D { return skeletonRow(gtx, th, title) }), layout.Rigid(th.Separator)) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) +} + +// skeletonRow is rowContent with a bar where its text lands: the number and +// title, the meta line under them, and the checks chip in its column. +func skeletonRow(gtx C, th *ui.Theme, title float32) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Inset{Left: pullsRowEdge + pullsRowPadX, Right: pullsRowEdge + pullsRowPadX, Top: pullsRowPadY, Bottom: pullsRowPadY}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + full := gtx.Metric.PxToDp(gtx.Constraints.Max.X) + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layout.Flex{}.Layout(gtx, + layout.Rigid(skeletonBar(th, ui.Space(10), skeletonSMLine, ui.Space(3.5))), + ui.Gap(1.5), + layout.Rigid(skeletonBar(th, full*unit.Dp(title), skeletonSMLine, ui.Space(3.5))), + ) + }), + ui.VGap(1), + layout.Rigid(skeletonBar(th, full*unit.Dp(title)*0.6, skeletonXSLine, ui.Space(3))), + ) + }), + ui.Gap(4), + layout.Rigid(func(gtx C) D { + return cell(gtx, rowChecksWidth, skeletonBar(th, ui.Space(9), skeletonXSLine, ui.Space(3))) + }), + layout.Rigid(layout.Spacer{Width: rowVerdictWidth + rowCheckoutWidth}.Layout), + ) }) } diff --git a/native/cmd/lich-native/pulls_list_test.go b/native/cmd/lich-native/pulls_list_test.go index 7e51c9b93..31247513d 100644 --- a/native/cmd/lich-native/pulls_list_test.go +++ b/native/cmd/lich-native/pulls_list_test.go @@ -157,3 +157,16 @@ func TestStepAlongHoldsAtTheEnds(t *testing.T) { t.Errorf("no rows stepped to %d", got) } } + +// The skeleton's row takes the loaded row's room, so nothing moves when the +// index arrives. +func TestListSkeletonRowIsALoadedRowsHeight(t *testing.T) { + th := rowMetaTheme(t) + gtx := layout.Context{Ops: new(op.Ops), Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, Constraints: layout.Constraints{Max: image.Pt(1200, 800)}} + pr := backend.PRSummary{Number: 1003, Title: "feat: a title", Author: "ana", HeadRefName: "feat/x", + UpdatedAt: rowMetaNow.Format(time.RFC3339), Checks: backend.ChecksRollup{Total: 7}} + loaded := rowContent(gtx, th, pr, "fuzzy-pine", rowMetaNow).Size.Y + if got := skeletonRow(gtx, th, 0.5).Size.Y; got != loaded { + t.Errorf("skeleton row is %dpx tall, a loaded row %dpx", got, loaded) + } +} diff --git a/native/cmd/lich-native/pulls_panels.go b/native/cmd/lich-native/pulls_panels.go index 7d9730a96..06567894c 100644 --- a/native/cmd/lich-native/pulls_panels.go +++ b/native/cmd/lich-native/pulls_panels.go @@ -237,6 +237,9 @@ func (v *pullView) panelLayout(gtx C, th *ui.Theme, p *pullsScreen, d backend.PR case tabFiles: return v.filesTab(gtx, th, p, d, act) case tabConversation: + if read := p.store.conversation(p.project.Path, d.Number); read.val == nil && read.err == "" { + return skeletonConversation(gtx, th) + } rows = v.conversationRows(gtx, th, p, d, act) case tabCommits: rows = v.commitRows(gtx, th, d) @@ -264,19 +267,38 @@ var ( overviewProseWidth = unit.Dp(720) overviewFactsWidth = unit.Dp(320) overviewSplitWidth = unit.Dp(760) + // overviewHeadingBottom is the room under the fixed Description heading, + // over its separator. + overviewHeadingBottom = ui.Space(2.5) ) // 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, p, d, act) + rows := v.overviewRows(th, d, act) + heading := func(gtx C) D { + return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(4)}.Layout(gtx, func(gtx C) D { + return v.descriptionHeading(gtx, th, p, d) + }) + } 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) }) } - return v.panelList(gtx, th, append([]layout.Widget{facts}, rows...)) + return v.panelList(gtx, th, append([]layout.Widget{facts, heading}, rows...)) + } + // The heading stays put over the description scrolling under it, the + // separator marking where the scroll starts. + description := func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: ui.Space(1), Bottom: overviewHeadingBottom}.Layout(gtx, heading) + }), + layout.Rigid(th.Separator), + layout.Flexed(1, func(gtx C) D { return v.panelList(gtx, th, rows) }), + ) } return layout.Flex{}.Layout(gtx, - layout.Flexed(1, func(gtx C) D { return v.panelList(gtx, th, rows) }), + layout.Flexed(1, description), layout.Rigid(func(gtx C) D { return verticalRule(gtx, th) }), layout.Rigid(func(gtx C) D { w := gtx.Dp(overviewFactsWidth) @@ -328,19 +350,13 @@ func (v *pullView) checkoutFact(th *ui.Theme, p *pullsScreen, d backend.PRDetail return out } -// overviewRows is the description under its heading, held to a reading -// width. -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)) - return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(top)}.Layout(gtx, w) - } - } +// overviewRows is the description, held to a reading width; its heading +// spans the column. +func (v *pullView) overviewRows(th *ui.Theme, d backend.PRDetail, act *pullsAction) []layout.Widget { body := strings.TrimSpace(d.Body) - return []layout.Widget{ - pad(4, func(gtx C) D { return v.descriptionHeading(gtx, th, p, d) }), - pad(2, func(gtx C) D { + return []layout.Widget{func(gtx C) D { + gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(overviewProseWidth+2*pullsPadX)) + return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(2)}.Layout(gtx, func(gtx C) D { if v.edit.bodyURL == d.URL { return v.descriptionField(gtx, th, d, act) } @@ -348,8 +364,8 @@ func (v *pullView) overviewRows(th *ui.Theme, p *pullsScreen, d backend.PRDetail return th.Text(ui.TextSM, i18n.T("pulls.pullsOverview.noDescription")).Muted().Layout(gtx) } return v.md.layout(gtx, th, "body:"+d.URL, body, act.open) - }), - } + }) + }} } func sectionHeading(th *ui.Theme, text string) layout.Widget { diff --git a/native/cmd/lich-native/pulls_skeleton.go b/native/cmd/lich-native/pulls_skeleton.go index 539630e3a..4176eceed 100644 --- a/native/cmd/lich-native/pulls_skeleton.go +++ b/native/cmd/lich-native/pulls_skeleton.go @@ -1,42 +1,42 @@ package main import ( + "image" + "gioui.org/layout" + "gioui.org/op" "gioui.org/unit" + "gioui.org/widget" + "github.com/lichdotdev/lich/native/i18n" "github.com/lichdotdev/lich/native/ui" ) -// The line heights the skeleton's bars sit in, so each bar takes the room of -// the text it stands for: text-lg, text-sm and text-xs, and the line variant's -// tab list. +// The line heights the list's and rows' bars sit in, so each bar takes the +// room of the text it stands for: text-sm and text-xs. var ( - skeletonTitleLine = ui.Space(7) - skeletonSMLine = ui.Space(5) - skeletonXSLine = ui.Space(4) - skeletonTabsLine = ui.Space(9) + skeletonSMLine = ui.Space(5) + skeletonXSLine = ui.Space(4) ) -// skeletonTabWidths are the tab strip's triggers, Overview to Files. -var skeletonTabWidths = []unit.Dp{ui.Space(18), ui.Space(24), ui.Space(18), ui.Space(14)} - // detailSkeleton mirrors body while the pull request loads, skeletonHeading -// standing in for its title row: the tab strip and the open tab's content, -// each bar where its text will land. +// standing in for its title row: the tab strip, its labels known before the +// pull request is, and the open tab's content, each bar where its text will +// land. func (v *pullView) detailSkeleton(th *ui.Theme) layout.Widget { return func(gtx C) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { - return layout.Inset{Left: pullsToolbarPad, Right: pullsToolbarPad, Top: ui.Space(4)}.Layout(gtx, func(gtx C) D { - return skeletonTabs(gtx, th) + return layout.Inset{Left: pullsToolbarPad, Right: pullsToolbarPad}.Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + ui.VGap(4), + layout.Rigid(func(gtx C) D { return v.skeletonTabs(gtx, th) }), + ) }) }), layout.Rigid(th.Separator), layout.Flexed(1, func(gtx C) D { - if v.tabs.Selected > 0 { - return skeletonRows(gtx, th) - } - return skeletonOverview(gtx, th) + return v.skeletonTab(gtx, th) }), ) } @@ -50,61 +50,158 @@ func skeletonBar(th *ui.Theme, width, line, height unit.Dp) layout.Widget { } } -// skeletonHeading is heading's title over the stats line: state, branches -// and author. +// measured is the size w takes, laid out and thrown away. +func measured(gtx C, w layout.Widget) image.Point { + macro := op.Record(gtx.Ops) + gtx.Constraints.Min = image.Point{} + size := w(gtx).Size + macro.Stop() + return size +} + +// skeletonText is a bar of width standing for a line of text at size: as +// tall as the font, centred in the line the text takes. +func skeletonText(th *ui.Theme, size unit.Sp, width unit.Dp) layout.Widget { + return func(gtx C) D { + line := measured(gtx, th.Text(size, "Ag").Layout).Y + bar := min(gtx.Sp(size), line) + defer op.Offset(image.Pt(0, (line-bar)/2)).Push(gtx.Ops).Pop() + th.Skeleton(width, gtx.Metric.PxToDp(bar)).Layout(gtx) + return D{Size: image.Pt(min(gtx.Dp(width), gtx.Constraints.Max.X), line)} + } +} + +// spanning is w taking all the room a Flexed gives it, as text does, so a +// Rigid after it lands at the row's end and not beside the bar. +func spanning(w layout.Widget) layout.Widget { + return func(gtx C) D { + dims := w(gtx) + dims.Size.X = max(dims.Size.X, gtx.Constraints.Min.X) + return dims + } +} + +// skeletonShape is a block the size w would take: a button whose label is +// known but whose state is not. +func skeletonShape(th *ui.Theme, w layout.Widget) layout.Widget { + return func(gtx C) D { + size := measured(gtx, w) + return th.Skeleton(gtx.Metric.PxToDp(size.X), gtx.Metric.PxToDp(size.Y)).Layout(gtx) + } +} + +// skeletonHeading is titleRow: heading's title over the stats line, beside +// the actions Open in Session, Merge and ⋯ as blocks of their size. func skeletonHeading(gtx C, th *ui.Theme) D { - return layout.Inset{Top: pullsHeadingTop}.Layout(gtx, func(gtx C) D { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(skeletonBar(th, ui.Space(120), skeletonTitleLine, ui.Space(5))), - ui.VGap(3), - layout.Rigid(func(gtx C) D { - return layout.Flex{}.Layout(gtx, - layout.Rigid(skeletonBar(th, ui.Space(14), skeletonXSLine, ui.Space(3))), - ui.Gap(4), - layout.Rigid(skeletonBar(th, ui.Space(48), skeletonXSLine, ui.Space(3))), - ui.Gap(4), - layout.Rigid(skeletonBar(th, ui.Space(24), skeletonXSLine, ui.Space(3))), - ) - }), + var session, merge, more widget.Clickable + actions := func(gtx C) D { + sessionB := th.Button(&session, i18n.T("pulls.pulls.openInSession")) + sessionB.Icon, sessionB.Size = icPullSession, ui.SizeSM + mergeB := th.Button(&merge, i18n.T("pulls.pullRequestView.merge")) + mergeB.Icon, mergeB.Size = icPullMerged, ui.SizeSM + moreB := th.Button(&more, "") + moreB.Icon, moreB.Size = icPullMore, ui.SizeIconSM + return ui.Wrap{GapX: ui.Space(2), Justify: ui.JustifyEnd}.Layout(gtx, + ui.WrapItem(skeletonShape(th, sessionB.Layout)), + ui.WrapItem(skeletonShape(th, mergeB.Layout)), + ui.WrapItem(skeletonShape(th, moreB.Layout)), ) - }) + } + heading := func(gtx C) D { + return layout.Inset{Right: ui.Space(4), Top: pullsHeadingTop}.Layout(gtx, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + // The spacer is the title's pencil, which sets the line when taller. + return layout.Flex{}.Layout(gtx, + layout.Rigid(skeletonText(th, pullsTitleSize, ui.Space(120))), + ui.Gap(1), + layout.Rigid(layout.Spacer{Width: ui.Space(6), Height: ui.Space(6)}.Layout), + ) + }), + ui.VGap(3), + layout.Rigid(func(gtx C) D { + return layout.Flex{}.Layout(gtx, + layout.Rigid(skeletonText(th, ui.TextXS, ui.Space(14))), + ui.Gap(4), + layout.Rigid(skeletonText(th, ui.TextXS, ui.Space(48))), + ui.Gap(4), + layout.Rigid(skeletonText(th, ui.TextXS, ui.Space(24))), + ) + }), + ) + }) + } + return layout.Flex{}.Layout(gtx, layout.Flexed(1, spanning(heading)), layout.Rigid(actions)) } -// skeletonTabs is the line tab strip, a bar where each trigger's label sits. -func skeletonTabs(gtx C, th *ui.Theme) D { - var children []layout.FlexChild - for _, w := range skeletonTabWidths { - children = append(children, layout.Rigid(func(gtx C) D { - return layout.Inset{Left: ui.Space(2), Right: ui.Space(3)}.Layout(gtx, skeletonBar(th, w, skeletonTabsLine, ui.Space(3.5))) - })) +// skeletonTabOrder is every tab a pull request can show, as tabsOf lists them. +var skeletonTabOrder = []pullsTab{tabOverview, tabConversation, tabCommits, tabFiles, tabChecks} + +// skeletonTabs is tabStrip with every tab's label, a bar where a count +// lands, the open tab marked, and a bar where the diff's size lands. +func (v *pullView) skeletonTabs(gtx C, th *ui.Theme) D { + var triggers []ui.TabsTrigger + for _, t := range skeletonTabOrder { + trigger := ui.TabsTrigger{Text: t.label()} + if t != tabOverview { + trigger.Trailing = skeletonText(th, ui.TextSM, ui.Space(2.5)) + } + triggers = append(triggers, trigger) } - return layout.Flex{}.Layout(gtx, children...) + tabs := th.Tabs(&v.tabs, triggers...) + tabs.Variant = ui.TabsVariantLine + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + d := tabs.Layout(gtx) + d.Size.X = gtx.Constraints.Max.X + return d + }), + layout.Rigid(func(gtx C) D { + return layout.Inset{Left: ui.Space(4)}.Layout(gtx, skeletonText(th, ui.TextXS, ui.Space(16))) + }), + ) } -// skeletonOverview is overview's description beside its facts, stacked the -// same way on an island too narrow for both. +// skeletonOverview is overview: the description's fixed heading over its +// prose filling the column, beside the facts, stacked the same way on an +// island too narrow for both. func skeletonOverview(gtx C, th *ui.Theme) D { + var more widget.Clickable + heading := func(gtx C) D { + return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(4)}.Layout(gtx, func(gtx C) D { + moreB := th.Button(&more, "") + moreB.Icon, moreB.Size = icPullMore, ui.SizeIconXS + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, spanning(skeletonText(th, ui.TextXS, ui.Space(24)))), + layout.Rigid(skeletonShape(th, moreB.Layout)), + ) + }) + } prose := func(gtx C) D { + size := gtx.Constraints.Max gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(overviewProseWidth+2*pullsPadX)) - return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(5)}.Layout(gtx, func(gtx C) D { - full := gtx.Metric.PxToDp(gtx.Constraints.Max.X) - children := []layout.FlexChild{layout.Rigid(skeletonBar(th, ui.Space(24), skeletonXSLine, ui.Space(3))), ui.VGap(2)} - for _, w := range []float32{1, 0.94, 0.97, 0.6} { - children = append(children, layout.Rigid(skeletonBar(th, full*unit.Dp(w), skeletonSMLine, ui.Space(3.5)))) - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) - }) + layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(3)}.Layout(gtx, func(gtx C) D { return skeletonProse(gtx, th) }) + return D{Size: size} } if gtx.Constraints.Max.X < gtx.Dp(overviewSplitWidth) { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(5)}.Layout(gtx, func(gtx C) D { return skeletonFacts(gtx, th) }) }), - layout.Rigid(prose), + layout.Rigid(heading), + layout.Flexed(1, prose), ) } return layout.Flex{}.Layout(gtx, - layout.Flexed(1, prose), + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layout.Inset{Top: ui.Space(1), Bottom: overviewHeadingBottom}.Layout(gtx, heading) + }), + layout.Rigid(th.Separator), + layout.Flexed(1, prose), + ) + }), layout.Rigid(func(gtx C) D { return verticalRule(gtx, th) }), layout.Rigid(func(gtx C) D { w := gtx.Dp(overviewFactsWidth) @@ -114,6 +211,31 @@ func skeletonOverview(gtx C, th *ui.Theme) D { ) } +// skeletonProseLines are a paragraph's ragged lines, as fractions of the +// reading width. +var skeletonProseLines = []float32{1, 0.96, 0.98, 0.93, 0.6} + +// skeletonProse is the description's Markdown as paragraphs of body lines, +// a block gap apart, down to the bottom of the column. +func skeletonProse(gtx C, th *ui.Theme) D { + full := gtx.Metric.PxToDp(gtx.Constraints.Max.X) + line := unit.Dp(gtx.Metric.PxToDp(int(float32(gtx.Sp(markdownBodySize)) * markdownLineHeightScale))) + bar := unit.Dp(gtx.Metric.PxToDp(gtx.Sp(markdownBodySize))) + var children []layout.FlexChild + y, gap := 0, gtx.Dp(ui.Space(markdownBlockGap)) + for y < gtx.Constraints.Max.Y { + if len(children) > 0 { + children = append(children, ui.VGap(markdownBlockGap)) + y += gap + } + for _, w := range skeletonProseLines { + children = append(children, layout.Rigid(skeletonBar(th, full*unit.Dp(w), line, bar))) + y += gtx.Dp(line) + } + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) +} + // skeletonFacts is facts' sections, a heading over its value each. func skeletonFacts(gtx C, th *ui.Theme) D { var children []layout.FlexChild @@ -130,29 +252,21 @@ func skeletonFacts(gtx C, th *ui.Theme) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } -// skeletonRows is panelList's rows as Conversation, Commits and Checks draw -// them: a glyph beside a line over a dimmer one. -func skeletonRows(gtx C, th *ui.Theme) D { - widths := []float32{0.3, 0.42, 0.25, 0.36, 0.3} - return layout.Inset{Top: ui.Space(1), Bottom: ui.Space(1)}.Layout(gtx, func(gtx C) D { - var rows []layout.FlexChild - for _, w := range widths { - rows = append(rows, layout.Rigid(func(gtx C) D { - return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(2), Bottom: ui.Space(2)}.Layout(gtx, func(gtx C) D { - full := gtx.Metric.PxToDp(gtx.Constraints.Max.X) - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(th.Skeleton(ui.Space(4), ui.Space(4)).Layout), - ui.Gap(3), - layout.Rigid(func(gtx C) D { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(skeletonBar(th, full*unit.Dp(w), skeletonSMLine, ui.Space(3.5))), - layout.Rigid(skeletonBar(th, full*unit.Dp(w)*0.8, skeletonXSLine, ui.Space(3))), - ) - }), - ) - }) - })) - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, rows...) - }) +// skeletonTab is the open tab's content while the pull request loads. +func (v *pullView) skeletonTab(gtx C, th *ui.Theme) D { + tab := tabOverview + if i := v.tabs.Selected; i >= 0 && i < len(skeletonTabOrder) { + tab = skeletonTabOrder[i] + } + switch tab { + case tabConversation: + return skeletonConversation(gtx, th) + case tabCommits: + return skeletonCommits(gtx, th) + case tabFiles: + return v.skeletonFiles(gtx, th) + case tabChecks: + return skeletonChecks(gtx, th) + } + return skeletonOverview(gtx, th) } diff --git a/native/cmd/lich-native/pulls_skeleton_tabs.go b/native/cmd/lich-native/pulls_skeleton_tabs.go new file mode 100644 index 000000000..543c50481 --- /dev/null +++ b/native/cmd/lich-native/pulls_skeleton_tabs.go @@ -0,0 +1,229 @@ +package main + +import ( + "image" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/unit" + + "github.com/lichdotdev/lich/native/ui" +) + +// skeletonWidths are ragged fractions a list's bars take, so it reads as +// rows of different text. +var skeletonWidths = []float32{0.62, 0.48, 0.7, 0.55, 0.4, 0.66, 0.5} + +func skeletonWidth(i int) float32 { return skeletonWidths[i%len(skeletonWidths)] } + +// skeletonFill lays row after row down to the bottom of gtx, as a loaded +// list fills its panel. +func skeletonFill(gtx C, row func(gtx C, i int) D) D { + defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop() + y := 0 + for i := 0; y < gtx.Constraints.Max.Y && i < listSkeletonMaxRows; i++ { + g := gtx + g.Constraints.Min.Y = 0 + stack := op.Offset(image.Pt(0, y)).Push(gtx.Ops) + y += row(g, i).Size.Y + stack.Pop() + } + return D{Size: image.Pt(gtx.Constraints.Max.X, min(y, gtx.Constraints.Max.Y))} +} + +// skeletonPanel is panelList's inset around rows filling it. +func skeletonPanel(gtx C, row func(gtx C, i int) D) D { + return layout.Inset{Top: ui.Space(1), Bottom: ui.Space(1)}.Layout(gtx, func(gtx C) D { return skeletonFill(gtx, row) }) +} + +// skeletonPanelRow is panelRow's insets around content. +func skeletonPanelRow(gtx C, content layout.Widget) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + pad := pullsRowEdge + pullsRowPadX + return layout.Inset{Left: pad, Right: pad, Top: ui.Space(2), Bottom: ui.Space(2)}.Layout(gtx, content) +} + +// skeletonTwoLines is a text-sm line over a text-xs one, w of the room. +func skeletonTwoLines(th *ui.Theme, w float32) layout.Widget { + return func(gtx C) D { + full := gtx.Metric.PxToDp(gtx.Constraints.Max.X) + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(skeletonText(th, ui.TextSM, full*unit.Dp(w))), + layout.Rigid(skeletonText(th, ui.TextXS, full*unit.Dp(w)*0.6)), + ) + } +} + +// skeletonCommits is commitRows filling the panel. +func skeletonCommits(gtx C, th *ui.Theme) D { + return skeletonPanel(gtx, func(gtx C, i int) D { return skeletonCommitRow(gtx, th, i) }) +} + +// skeletonCommitRow is commitRow: a headline over who and when, the short +// oid at the right. +func skeletonCommitRow(gtx C, th *ui.Theme, i int) D { + return skeletonPanelRow(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, spanning(skeletonTwoLines(th, skeletonWidth(i)))), + layout.Rigid(skeletonText(th, ui.TextXS, ui.Space(12.5))), + ) + }) +} + +// skeletonChecks is checkRows filling the panel. +func skeletonChecks(gtx C, th *ui.Theme) D { + return skeletonPanel(gtx, func(gtx C, i int) D { return skeletonCheckRow(gtx, th, i) }) +} + +// skeletonCheckRow is a check's row: the state glyph, its name over its +// description, the duration at the right. +func skeletonCheckRow(gtx C, th *ui.Theme, i int) D { + return skeletonPanelRow(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(th.Skeleton(ui.Space(4), ui.Space(4)).Layout), + ui.Gap(3), + layout.Flexed(1, spanning(skeletonTwoLines(th, skeletonWidth(i)))), + layout.Rigid(skeletonText(th, ui.TextXS, ui.Space(8))), + ) + }) +} + +// skeletonConversation is talkEntry after talkEntry: the avatar beside the +// byline and a few lines of Markdown. +func skeletonConversation(gtx C, th *ui.Theme) D { + return skeletonPanel(gtx, func(gtx C, i int) D { + return layout.Inset{Left: pullsPadX, Right: pullsPadX, Top: ui.Space(3), Bottom: ui.Space(1)}.Layout(gtx, func(gtx C) D { + return layout.Flex{}.Layout(gtx, + layout.Rigid(th.Skeleton(authorAvatarSize, authorAvatarSize).Layout), + layout.Rigid(layout.Spacer{Width: authorAvatarGap}.Layout), + layout.Flexed(1, func(gtx C) D { + full := gtx.Metric.PxToDp(gtx.Constraints.Max.X) + children := []layout.FlexChild{ + layout.Rigid(skeletonText(th, ui.TextSM, ui.Space(40))), + ui.VGap(1), + } + for j := range 1 + i%3 { + children = append(children, layout.Rigid(skeletonMarkdownLine(th, full*unit.Dp(skeletonProseLines[j])))) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }), + ) + }) + }) +} + +// skeletonMarkdownLine is a line of a Markdown body w wide. +func skeletonMarkdownLine(th *ui.Theme, w unit.Dp) layout.Widget { + return func(gtx C) D { + line := gtx.Metric.PxToDp(int(float32(gtx.Sp(markdownBodySize)) * markdownLineHeightScale)) + return skeletonBar(th, w, line, gtx.Metric.PxToDp(gtx.Sp(markdownBodySize)))(gtx) + } +} + +// skeletonFiles is filesTab: the toolbar over the tree, when shown, beside +// the file cards, each a header over lines of diff. +func (v *pullView) skeletonFiles(gtx C, th *ui.Theme) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layout.Inset{Left: ui.Space(2), Right: ui.Space(2), Top: ui.Space(1.5), Bottom: ui.Space(1.5)}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(th.Skeleton(ui.Space(6), ui.Space(6)).Layout), + ui.Gap(2), + layout.Rigid(skeletonText(th, ui.TextXS, ui.Space(22))), + ) + }) + }), + layout.Rigid(th.Separator), + layout.Flexed(1, func(gtx C) D { + gtx.Constraints.Min = gtx.Constraints.Max + return layout.Flex{}.Layout(gtx, + layout.Rigid(func(gtx C) D { + if v.tree.hidden { + return D{} + } + rem := v.tree.widthRem + if rem == 0 { + rem = treeDefaultRem + } + w := gtx.Dp(unit.Dp(rem * remDp)) + gtx.Constraints = layout.Exact(image.Pt(w, gtx.Constraints.Max.Y)) + return skeletonTree(gtx, th) + }), + layout.Rigid(func(gtx C) D { + if v.tree.hidden { + return D{} + } + return verticalRule(gtx, th) + }), + layout.Flexed(1, func(gtx C) D { return skeletonCards(gtx, th) }), + ) + }), + ) +} + +// skeletonTree is the tree's rows: a folder, then its files indented under it. +func skeletonTree(gtx C, th *ui.Theme) D { + return layout.Inset{Top: ui.Space(1), Bottom: ui.Space(1)}.Layout(gtx, func(gtx C) D { + return skeletonFill(gtx, func(gtx C, i int) D { + depth := min(i%4, 2) + gtx.Constraints.Min.X = gtx.Constraints.Max.X + indent := treeIndentBase + treeIndent*unit.Dp(depth) + return layout.Inset{Left: indent, Right: ui.Space(2), Top: ui.Space(0.5), Bottom: ui.Space(0.5)}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(skeletonText(th, ui.TextXS, ui.Space(16+4*float32(i%3)))), + layout.Flexed(1, func(gtx C) D { return D{Size: image.Pt(gtx.Constraints.Min.X, 0)} }), + layout.Rigid(skeletonText(th, ui.TextXS, ui.Space(6))), + ) + }) + }) + }) +} + +// skeletonDiffLines is how many lines of diff each skeleton card shows. +const skeletonDiffLines = 6 + +// skeletonCards is cardItem after cardItem: a separator between cards, each +// a header on Sidebar over its lines of diff. +func skeletonCards(gtx C, th *ui.Theme) D { + return skeletonFill(gtx, func(gtx C, i int) D { + inset := layout.Inset{Left: ui.Space(2), Right: ui.Space(2), Top: ui.Space(2.5)} + if i == 0 { + inset.Top = ui.Space(2) + } + return inset.Layout(gtx, func(gtx C) D { + children := []layout.FlexChild{} + if i > 0 { + children = append(children, layout.Rigid(func(gtx C) D { return layout.Inset{Bottom: ui.Space(2.5)}.Layout(gtx, th.Separator) })) + } + children = append(children, layout.Rigid(func(gtx C) D { return skeletonCardHeader(gtx, th, i) })) + full := gtx.Metric.PxToDp(gtx.Constraints.Max.X) + for j := range skeletonDiffLines { + w := full * unit.Dp(skeletonProseLines[j%len(skeletonProseLines)]) * 0.7 + children = append(children, layout.Rigid(func(gtx C) D { + return layout.Inset{Left: ui.Space(2)}.Layout(gtx, skeletonText(th, ui.TextXS, w)) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) + }) +} + +// skeletonCardHeader is fileCard.header: the file's name and its +/- in a +// Sidebar strip as tall as the header's icon buttons make it. +func skeletonCardHeader(gtx C, th *ui.Theme, i int) D { + on := th.On(th.Sidebar) + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Background{}.Layout(gtx, + func(gtx C) D { return ui.Fill(gtx, th.Sidebar, 0) }, + func(gtx C) D { + return layout.Inset{Left: ui.Space(2), Right: ui.Space(2), Top: ui.Space(1), Bottom: ui.Space(1)}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: ui.Space(6)}.Layout), + layout.Flexed(1, spanning(skeletonText(on, ui.TextXS, ui.Space(40+8*float32(i%3))))), + layout.Rigid(skeletonText(on, ui.TextXS, ui.Space(10))), + ) + }) + }, + ) +} diff --git a/native/cmd/lich-native/pulls_skeleton_tabs_test.go b/native/cmd/lich-native/pulls_skeleton_tabs_test.go new file mode 100644 index 000000000..354d46e87 --- /dev/null +++ b/native/cmd/lich-native/pulls_skeleton_tabs_test.go @@ -0,0 +1,32 @@ +package main + +import ( + "image" + "testing" + + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" + "gioui.org/widget" + + "github.com/lichdotdev/lich/api" + "github.com/lichdotdev/lich/native/backend" +) + +// A tab's skeleton row takes a loaded row's room, so nothing moves when the +// pull request arrives. +func TestTabSkeletonRowsAreLoadedRowsHeight(t *testing.T) { + th := rowMetaTheme(t) + gtx := layout.Context{Ops: new(op.Ops), Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, Constraints: layout.Constraints{Max: image.Pt(1200, 800)}} + commit := backend.PRCommit{OID: "0123456789abcdef", Headline: "fix: a thing", Name: "ana", Date: rowMetaNow.Format("2006-01-02T15:04:05Z")} + if loaded, got := commitRow(gtx, th, commit, new(widget.Clickable), false, false, rowMetaNow).Size.Y, skeletonCommitRow(gtx, th, 0).Size.Y; got != loaded { + t.Errorf("commit skeleton row is %dpx tall, a loaded one %dpx", got, loaded) + } + d := backend.PRDetail{CheckRuns: []api.CheckItem{{Name: "test", Description: "Go tests", State: "passed"}}} + var v pullView + v.checkClicks = map[string]*widget.Clickable{} + var act pullsAction + if loaded, got := v.checkRows(gtx, th, d, &act)[0](gtx).Size.Y, skeletonCheckRow(gtx, th, 0).Size.Y; got != loaded { + t.Errorf("check skeleton row is %dpx tall, a loaded one %dpx", got, loaded) + } +} diff --git a/native/cmd/lich-native/pulls_view.go b/native/cmd/lich-native/pulls_view.go index 706604f81..c88f973de 100644 --- a/native/cmd/lich-native/pulls_view.go +++ b/native/cmd/lich-native/pulls_view.go @@ -24,6 +24,7 @@ import ( var ( pullsRowEdge = ui.Space(2) pullsRowPadX = ui.Space(2.5) + pullsRowPadY = ui.Space(2.5) pullsPadX = pullsRowEdge + pullsRowPadX pullsTitleSize = unit.Sp(18) pullsToolbarPad = ui.Space(2) diff --git a/native/cmd/lich-native/stale_pulls_test.go b/native/cmd/lich-native/stale_pulls_test.go index ef9af0758..621fc3f45 100644 --- a/native/cmd/lich-native/stale_pulls_test.go +++ b/native/cmd/lich-native/stale_pulls_test.go @@ -5,6 +5,7 @@ import ( "image" "net/http" "strings" + "sync" "testing" "gioui.org/io/key" @@ -21,6 +22,13 @@ const pullsDiff = "diff --git a/a.go b/a.go\n--- a/a.go\n+++ b/a.go\n@@ -1,1 +1, // pullsFrames is the Pulls screen on pull request 7 of /repo, framed the way // the window frames it: its dialogs first, then the screen and its actions. func pullsFrames(t *testing.T) (*window, *frameRig) { + t.Helper() + return pullsFramesHeld(t, nil) +} + +// pullsFramesHeld is pullsFrames whose pull request detail answers only once +// hold is closed; nil answers at once. +func pullsFramesHeld(t *testing.T, hold chan struct{}) (*window, *frameRig) { t.Helper() r := newFrameRig(rigWindow, nil) conv := talkFixture() @@ -29,6 +37,9 @@ func pullsFrames(t *testing.T) (*window, *frameRig) { case "project.ListPullRequests": return []backend.PRSummary{{Number: 7, Title: "Fix the thing", State: "OPEN"}, {Number: 8, Title: "Draft work", State: "OPEN", IsDraft: true}}, http.StatusOK case "project.PullRequestDetail": + if hold != nil { + <-hold + } return backend.PRDetail{Number: 7, URL: "https://github.com/o/r/pull/7", Title: "Fix the thing", Body: "The body.", State: "OPEN", HeadRefName: "fix", BaseRefName: "main"}, http.StatusOK case "project.PullRequestConversation": return conv, http.StatusOK @@ -193,6 +204,40 @@ func TestPullsDescriptionCancelInTheFrameThatTookTheClick(t *testing.T) { } } +func TestPullsDescriptionMenuSitsAtTheColumnsEdge(t *testing.T) { + _, r := pullsDetailShown(t) + more := r.controlOf(t, strings.ToUpper(i18n.T("pulls.pullsOverview.description"))) + if prose := float32(overviewProseWidth + 2*pullsPadX); more.X <= prose { + t.Errorf("⋯ at x=%v sits inside the %vdp prose width, not at the column's edge", more.X, prose) + } +} + +// The skeleton's title row and tab strip take the loaded ones' height, so +// the tabs do not move down when the pull request arrives. +func TestPullsDetailSkeletonKeepsTheTabsWhereTheyLand(t *testing.T) { + hold := make(chan struct{}) + _, r := pullsFramesHeld(t, hold) + // The stub's server waits on its handlers when the test ends. + release := sync.OnceFunc(func() { close(hold) }) + t.Cleanup(release) + r.waitFor(t, "Fix the thing") + r.queueClick(r.at(t, "Fix the thing")) + overview := i18n.T("pulls.pullRequestView.tab.overview") + // The skeleton pulses, so it never settles: frame until its tabs show. + for range maxSettleFrames { + if r.frame(); r.shows(overview) { + break + } + } + skeleton := r.at(t, overview) + release() + r.waitFor(t, i18n.T("pulls.pullsOverview.openedBy", i18n.Args{"author": ""})) + // Only the height: the tabs' widths follow counts the skeleton cannot know. + if loaded := r.at(t, overview); loaded.Y != skeleton.Y { + t.Errorf("the Overview tab sits at y=%v in the skeleton and y=%v loaded", skeleton.Y, loaded.Y) + } +} + func TestPullsViewedTickCountsInTheFrameThatTookTheClick(t *testing.T) { u, r := pullsDetailShown(t) viewed := i18n.T("diff.fileDiff.viewed") diff --git a/native/i18n/locales/en/pulls.json b/native/i18n/locales/en/pulls.json index de4b36357..94843c033 100644 --- a/native/i18n/locales/en/pulls.json +++ b/native/i18n/locales/en/pulls.json @@ -170,8 +170,7 @@ "loadFailed": "Couldn’t load the diff: {error}", "hideTree": "Hide the file tree", "showTree": "Show the file tree", - "viewed": "{viewed} of {total} viewed", - "loading": "Reading the diff…" + "viewed": "{viewed} of {total} viewed" }, "reviewThread": { "replyFailed": "Reply failed: {error}", diff --git a/native/i18n/locales/es/pulls.json b/native/i18n/locales/es/pulls.json index 3edefdd78..89795e1e9 100644 --- a/native/i18n/locales/es/pulls.json +++ b/native/i18n/locales/es/pulls.json @@ -170,8 +170,7 @@ "loadFailed": "No se pudo cargar el diff: {error}", "hideTree": "Ocultar el árbol de archivos", "showTree": "Mostrar el árbol de archivos", - "viewed": "{viewed} de {total} vistos", - "loading": "Leyendo el diff…" + "viewed": "{viewed} de {total} vistos" }, "reviewThread": { "replyFailed": "No se pudo responder: {error}", diff --git a/native/i18n/locales/ja/pulls.json b/native/i18n/locales/ja/pulls.json index 59918951b..a674f8338 100644 --- a/native/i18n/locales/ja/pulls.json +++ b/native/i18n/locales/ja/pulls.json @@ -169,8 +169,7 @@ "loadFailed": "差分を読み込めませんでした: {error}", "hideTree": "ファイルツリーを隠す", "showTree": "ファイルツリーを表示", - "viewed": "{total} 件中 {viewed} 件を確認済み", - "loading": "差分を読み込んでいます…" + "viewed": "{total} 件中 {viewed} 件を確認済み" }, "reviewThread": { "replyFailed": "返信に失敗しました: {error}", diff --git a/native/i18n/locales/ko/pulls.json b/native/i18n/locales/ko/pulls.json index cadf74797..1000eb4d3 100644 --- a/native/i18n/locales/ko/pulls.json +++ b/native/i18n/locales/ko/pulls.json @@ -165,8 +165,7 @@ "loadFailed": "diff를 불러오지 못했습니다: {error}", "hideTree": "파일 트리 숨기기", "showTree": "파일 트리 표시", - "viewed": "{total}개 중 {viewed}개 확인함", - "loading": "diff를 읽는 중…" + "viewed": "{total}개 중 {viewed}개 확인함" }, "reviewThread": { "replyFailed": "답글 실패: {error}", diff --git a/native/i18n/locales/pt-br/pulls.json b/native/i18n/locales/pt-br/pulls.json index 069a09072..8e2b64178 100644 --- a/native/i18n/locales/pt-br/pulls.json +++ b/native/i18n/locales/pt-br/pulls.json @@ -170,8 +170,7 @@ "loadFailed": "Não foi possível carregar o diff: {error}", "hideTree": "Ocultar a árvore de arquivos", "showTree": "Mostrar a árvore de arquivos", - "viewed": "{viewed} de {total} vistos", - "loading": "Lendo o diff…" + "viewed": "{viewed} de {total} vistos" }, "reviewThread": { "replyFailed": "Falha ao responder: {error}", diff --git a/native/i18n/locales/ru/pulls.json b/native/i18n/locales/ru/pulls.json index 0be1e1b57..eb490b4d0 100644 --- a/native/i18n/locales/ru/pulls.json +++ b/native/i18n/locales/ru/pulls.json @@ -180,8 +180,7 @@ "loadFailed": "Не удалось загрузить изменения: {error}", "hideTree": "Скрыть дерево файлов", "showTree": "Показать дерево файлов", - "viewed": "Просмотрено {viewed} из {total}", - "loading": "Чтение изменений…" + "viewed": "Просмотрено {viewed} из {total}" }, "reviewThread": { "replyFailed": "Не удалось ответить: {error}", diff --git a/native/i18n/locales/zh-cn/pulls.json b/native/i18n/locales/zh-cn/pulls.json index a2fa316c8..c2190bfcf 100644 --- a/native/i18n/locales/zh-cn/pulls.json +++ b/native/i18n/locales/zh-cn/pulls.json @@ -169,8 +169,7 @@ "loadFailed": "无法加载差异:{error}", "hideTree": "隐藏文件树", "showTree": "显示文件树", - "viewed": "已查看 {viewed}/{total}", - "loading": "正在读取差异…" + "viewed": "已查看 {viewed}/{total}" }, "reviewThread": { "replyFailed": "回复失败:{error}",