Merge pull request #993 from MichaelMure/cmd-adapt

command: adapt the output of the bug list to the terminal size
This commit is contained in:
Michael Muré 2023-01-17 22:05:29 +01:00 committed by GitHub
commit 0290091317
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 104 additions and 109 deletions

View File

@ -15,6 +15,7 @@ import (
"github.com/MichaelMure/git-bug/commands/execenv" "github.com/MichaelMure/git-bug/commands/execenv"
"github.com/MichaelMure/git-bug/entities/bug" "github.com/MichaelMure/git-bug/entities/bug"
"github.com/MichaelMure/git-bug/entities/common" "github.com/MichaelMure/git-bug/entities/common"
"github.com/MichaelMure/git-bug/entity"
"github.com/MichaelMure/git-bug/query" "github.com/MichaelMure/git-bug/query"
"github.com/MichaelMure/git-bug/util/colors" "github.com/MichaelMure/git-bug/util/colors"
) )
@ -93,10 +94,10 @@ git bug status:open --by creation "foo bar" baz
flags.StringVarP(&options.sortDirection, "direction", "d", "asc", flags.StringVarP(&options.sortDirection, "direction", "d", "asc",
"Select the sorting direction. Valid values are [asc,desc]") "Select the sorting direction. Valid values are [asc,desc]")
cmd.RegisterFlagCompletionFunc("direction", completion.From([]string{"asc", "desc"})) cmd.RegisterFlagCompletionFunc("direction", completion.From([]string{"asc", "desc"}))
flags.StringVarP(&options.outputFormat, "format", "f", "default", flags.StringVarP(&options.outputFormat, "format", "f", "",
"Select the output formatting style. Valid values are [default,plain,compact,id,json,org-mode]") "Select the output formatting style. Valid values are [default,plain,id,json,org-mode]")
cmd.RegisterFlagCompletionFunc("format", cmd.RegisterFlagCompletionFunc("format",
completion.From([]string{"default", "plain", "compact", "id", "json", "org-mode"})) completion.From([]string{"default", "plain", "id", "json", "org-mode"}))
const selectGroup = "select" const selectGroup = "select"
cmd.AddGroup(&cobra.Group{ID: selectGroup, Title: "Implicit selection"}) cmd.AddGroup(&cobra.Group{ID: selectGroup, Title: "Implicit selection"})
@ -146,28 +147,32 @@ func runBug(env *execenv.Env, opts bugOptions, args []string) error {
return err return err
} }
bugExcerpt := make([]*cache.BugExcerpt, len(allIds)) excerpts := make([]*cache.BugExcerpt, len(allIds))
for i, id := range allIds { for i, id := range allIds {
b, err := env.Backend.Bugs().ResolveExcerpt(id) b, err := env.Backend.Bugs().ResolveExcerpt(id)
if err != nil { if err != nil {
return err return err
} }
bugExcerpt[i] = b excerpts[i] = b
} }
switch opts.outputFormat { switch opts.outputFormat {
case "org-mode": case "":
return bugsOrgmodeFormatter(env, bugExcerpt) if env.Out.IsTerminal() {
case "plain": return bugsDefaultFormatter(env, excerpts)
return bugsPlainFormatter(env, bugExcerpt) } else {
case "json": return bugsPlainFormatter(env, excerpts)
return bugsJsonFormatter(env, bugExcerpt) }
case "compact":
return bugsCompactFormatter(env, bugExcerpt)
case "id":
return bugsIDFormatter(env, bugExcerpt)
case "default": case "default":
return bugsDefaultFormatter(env, bugExcerpt) return bugsDefaultFormatter(env, excerpts)
case "id":
return bugsIDFormatter(env, excerpts)
case "plain":
return bugsPlainFormatter(env, excerpts)
case "json":
return bugsJsonFormatter(env, excerpts)
case "org-mode":
return bugsOrgmodeFormatter(env, excerpts)
default: default:
return fmt.Errorf("unknown format %s", opts.outputFormat) return fmt.Errorf("unknown format %s", opts.outputFormat)
} }
@ -186,9 +191,9 @@ func repairQuery(args []string) string {
return strings.Join(args, " ") return strings.Join(args, " ")
} }
func bugsJsonFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error { func bugsJsonFormatter(env *execenv.Env, excerpts []*cache.BugExcerpt) error {
jsonBugs := make([]cmdjson.BugExcerpt, len(bugExcerpts)) jsonBugs := make([]cmdjson.BugExcerpt, len(excerpts))
for i, b := range bugExcerpts { for i, b := range excerpts {
jsonBug, err := cmdjson.NewBugExcerpt(env.Backend, b) jsonBug, err := cmdjson.NewBugExcerpt(env.Backend, b)
if err != nil { if err != nil {
return err return err
@ -198,42 +203,34 @@ func bugsJsonFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error
return env.Out.PrintJSON(jsonBugs) return env.Out.PrintJSON(jsonBugs)
} }
func bugsCompactFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error { func bugsIDFormatter(env *execenv.Env, excerpts []*cache.BugExcerpt) error {
for _, b := range bugExcerpts { for _, b := range excerpts {
author, err := env.Backend.Identities().ResolveExcerpt(b.AuthorId)
if err != nil {
return err
}
var labelsTxt strings.Builder
for _, l := range b.Labels {
lc256 := l.Color().Term256()
labelsTxt.WriteString(lc256.Escape())
labelsTxt.WriteString("◼")
labelsTxt.WriteString(lc256.Unescape())
}
env.Out.Printf("%s %s %s %s %s\n",
colors.Cyan(b.Id().Human()),
colors.Yellow(b.Status),
text.LeftPadMaxLine(strings.TrimSpace(b.Title), 40, 0),
text.LeftPadMaxLine(labelsTxt.String(), 5, 0),
colors.Magenta(text.TruncateMax(author.DisplayName(), 15)),
)
}
return nil
}
func bugsIDFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error {
for _, b := range bugExcerpts {
env.Out.Println(b.Id().String()) env.Out.Println(b.Id().String())
} }
return nil return nil
} }
func bugsDefaultFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error { func bugsDefaultFormatter(env *execenv.Env, excerpts []*cache.BugExcerpt) error {
for _, b := range bugExcerpts { width := env.Out.Width()
widthId := entity.HumanIdLength
widthStatus := len("closed")
widthComment := 6
widthRemaining := width -
widthId - 1 -
widthStatus - 1 -
widthComment - 1
widthTitle := int(float32(widthRemaining-3) * 0.7)
if widthTitle < 0 {
widthTitle = 0
}
widthRemaining = widthRemaining - widthTitle - 3 - 2
widthAuthor := widthRemaining
for _, b := range excerpts {
author, err := env.Backend.Identities().ResolveExcerpt(b.AuthorId) author, err := env.Backend.Identities().ResolveExcerpt(b.AuthorId)
if err != nil { if err != nil {
return err return err
@ -249,8 +246,8 @@ func bugsDefaultFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) err
// truncate + pad if needed // truncate + pad if needed
labelsFmt := text.TruncateMax(labelsTxt.String(), 10) labelsFmt := text.TruncateMax(labelsTxt.String(), 10)
titleFmt := text.LeftPadMaxLine(strings.TrimSpace(b.Title), 50-text.Len(labelsFmt), 0) titleFmt := text.LeftPadMaxLine(strings.TrimSpace(b.Title), widthTitle-text.Len(labelsFmt), 0)
authorFmt := text.LeftPadMaxLine(author.DisplayName(), 15, 0) authorFmt := text.LeftPadMaxLine(author.DisplayName(), widthAuthor, 0)
comments := fmt.Sprintf("%3d 💬", b.LenComments-1) comments := fmt.Sprintf("%3d 💬", b.LenComments-1)
if b.LenComments-1 <= 0 { if b.LenComments-1 <= 0 {
@ -260,7 +257,7 @@ func bugsDefaultFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) err
comments = " ∞ 💬" comments = " ∞ 💬"
} }
env.Out.Printf("%s\t%s\t%s\t%s\t%s\n", env.Out.Printf("%s\t%s\t%s %s %s\n",
colors.Cyan(b.Id().Human()), colors.Cyan(b.Id().Human()),
colors.Yellow(b.Status), colors.Yellow(b.Status),
titleFmt+labelsFmt, titleFmt+labelsFmt,
@ -271,14 +268,14 @@ func bugsDefaultFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) err
return nil return nil
} }
func bugsPlainFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error { func bugsPlainFormatter(env *execenv.Env, excerpts []*cache.BugExcerpt) error {
for _, b := range bugExcerpts { for _, b := range excerpts {
env.Out.Printf("%s [%s] %s\n", b.Id().Human(), b.Status, strings.TrimSpace(b.Title)) env.Out.Printf("%s\t%s\t%s\n", b.Id().Human(), b.Status, strings.TrimSpace(b.Title))
} }
return nil return nil
} }
func bugsOrgmodeFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error { func bugsOrgmodeFormatter(env *execenv.Env, excerpts []*cache.BugExcerpt) error {
// see https://orgmode.org/manual/Tags.html // see https://orgmode.org/manual/Tags.html
orgTagRe := regexp.MustCompile("[^[:alpha:]_@]") orgTagRe := regexp.MustCompile("[^[:alpha:]_@]")
formatTag := func(l bug.Label) string { formatTag := func(l bug.Label) string {
@ -291,7 +288,7 @@ func bugsOrgmodeFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) err
env.Out.Println("#+TODO: OPEN | CLOSED") env.Out.Println("#+TODO: OPEN | CLOSED")
for _, b := range bugExcerpts { for _, b := range excerpts {
status := strings.ToUpper(b.Status.String()) status := strings.ToUpper(b.Status.String())
var title string var title string

View File

@ -2,7 +2,7 @@ package bugcmd
import ( import (
"fmt" "fmt"
"io/ioutil" "os"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -10,7 +10,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/MichaelMure/git-bug/commands/bug/testenv" "github.com/MichaelMure/git-bug/commands/bug/testenv"
"github.com/MichaelMure/git-bug/commands/cmdtest"
"github.com/MichaelMure/git-bug/commands/execenv" "github.com/MichaelMure/git-bug/commands/execenv"
) )
@ -143,7 +142,7 @@ func requireCommentsEqual(t *testing.T, golden string, env *execenv.Env) {
t.Log("Got here") t.Log("Got here")
for i, comment := range comments { for i, comment := range comments {
fileName := fmt.Sprintf(goldenFilePattern, golden, i) fileName := fmt.Sprintf(goldenFilePattern, golden, i)
require.NoError(t, ioutil.WriteFile(fileName, []byte(comment.message), 0644)) require.NoError(t, os.WriteFile(fileName, []byte(comment.message), 0644))
} }
} }
@ -157,7 +156,7 @@ func requireCommentsEqual(t *testing.T, golden string, env *execenv.Env) {
require.Equal(t, date.Add(time.Duration(i)*time.Minute), comment.date) require.Equal(t, date.Add(time.Duration(i)*time.Minute), comment.date)
fileName := fmt.Sprintf(goldenFilePattern, golden, i) fileName := fmt.Sprintf(goldenFilePattern, golden, i)
exp, err := ioutil.ReadFile(fileName) exp, err := os.ReadFile(fileName)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, strings.ReplaceAll(string(exp), "\r", ""), strings.ReplaceAll(comment.message, "\r", "")) require.Equal(t, strings.ReplaceAll(string(exp), "\r", ""), strings.ReplaceAll(comment.message, "\r", ""))
} }

View File

@ -2,7 +2,6 @@ package bugcmd
import ( import (
"encoding/json" "encoding/json"
"fmt"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -61,44 +60,33 @@ $`
format string format string
exp string exp string
}{ }{
{"default", "^[0-9a-f]{7}\topen\tthis is a bug title \tJohn Doe \t\n$"}, {"default", "^[0-9a-f]{7}\topen\tthis is a bug title John Doe \n$"},
{"plain", "^[0-9a-f]{7} \\[open\\] this is a bug title\n$"}, {"plain", "^[0-9a-f]{7}\topen\tthis is a bug title\n$"},
{"compact", "^[0-9a-f]{7} open this is a bug title John Doe\n$"},
{"id", "^[0-9a-f]{64}\n$"}, {"id", "^[0-9a-f]{64}\n$"},
{"org-mode", expOrgMode}, {"org-mode", expOrgMode},
{"json", ".*"},
} }
for _, testcase := range cases { for _, testcase := range cases {
opts := bugOptions{ t.Run(testcase.format, func(t *testing.T) {
sortDirection: "asc",
sortBy: "creation",
outputFormat: testcase.format,
}
name := fmt.Sprintf("with %s format", testcase.format)
t.Run(name, func(t *testing.T) {
env, _ := testenv.NewTestEnvAndBug(t) env, _ := testenv.NewTestEnvAndBug(t)
opts := bugOptions{
sortDirection: "asc",
sortBy: "creation",
outputFormat: testcase.format,
}
require.NoError(t, runBug(env, opts, []string{})) require.NoError(t, runBug(env, opts, []string{}))
require.Regexp(t, testcase.exp, env.Out.String())
switch testcase.format {
case "json":
var bugs []cmdjson.BugExcerpt
require.NoError(t, json.Unmarshal(env.Out.Bytes(), &bugs))
require.Len(t, bugs, 1)
default:
require.Regexp(t, testcase.exp, env.Out.String())
}
}) })
} }
t.Run("with JSON format", func(t *testing.T) {
opts := bugOptions{
sortDirection: "asc",
sortBy: "creation",
outputFormat: "json",
}
env, _ := testenv.NewTestEnvAndBug(t)
require.NoError(t, runBug(env, opts, []string{}))
var bugs []cmdjson.BugExcerpt
require.NoError(t, json.Unmarshal(env.Out.Bytes(), &bugs))
require.Len(t, bugs, 1)
})
} }

View File

@ -7,6 +7,7 @@ import (
"os" "os"
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
"golang.org/x/term"
"github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/cache"
"github.com/MichaelMure/git-bug/repository" "github.com/MichaelMure/git-bug/repository"
@ -57,6 +58,8 @@ type Out interface {
// IsTerminal tells if the output is a user terminal (rather than a buffer, // IsTerminal tells if the output is a user terminal (rather than a buffer,
// a pipe ...), which tells if we can use colors and other interactive features. // a pipe ...), which tells if we can use colors and other interactive features.
IsTerminal() bool IsTerminal() bool
// Width return the width of the attached terminal, or a good enough value.
Width() int
// Raw return the underlying io.Writer, or itself if not. // Raw return the underlying io.Writer, or itself if not.
// This is useful if something need to access the raw file descriptor. // This is useful if something need to access the raw file descriptor.
@ -123,6 +126,16 @@ func (o out) IsTerminal() bool {
return false return false
} }
func (o out) Width() int {
if f, ok := o.Raw().(*os.File); ok {
width, _, err := term.GetSize(int(f.Fd()))
if err == nil {
return width
}
}
return 80
}
func (o out) Raw() io.Writer { func (o out) Raw() io.Writer {
return o.Writer return o.Writer
} }

View File

@ -20,14 +20,14 @@ type TestIn struct {
forceIsTerminal bool forceIsTerminal bool
} }
func (t *TestIn) ForceIsTerminal(value bool) {
t.forceIsTerminal = value
}
func (t *TestIn) IsTerminal() bool { func (t *TestIn) IsTerminal() bool {
return t.forceIsTerminal return t.forceIsTerminal
} }
func (t *TestIn) ForceIsTerminal(value bool) {
t.forceIsTerminal = value
}
var _ Out = &TestOut{} var _ Out = &TestOut{}
type TestOut struct { type TestOut struct {
@ -60,6 +60,10 @@ func (te *TestOut) IsTerminal() bool {
return te.forceIsTerminal return te.forceIsTerminal
} }
func (te *TestOut) Width() int {
return 80
}
func (te *TestOut) Raw() io.Writer { func (te *TestOut) Raw() io.Writer {
return te.Buffer return te.Buffer
} }

View File

@ -13,14 +13,8 @@ git-bug-bridge-new - Configure a new bridge
.SH DESCRIPTION .SH DESCRIPTION
.PP .PP
.RS
.nf
Configure a new bridge by passing flags or/and using interactive terminal prompts. You can avoid all the terminal prompts by passing all the necessary flags to configure your bridge. Configure a new bridge by passing flags or/and using interactive terminal prompts. You can avoid all the terminal prompts by passing all the necessary flags to configure your bridge.
.fi
.RE
.SH OPTIONS .SH OPTIONS
.PP .PP

View File

@ -61,8 +61,8 @@ You can pass an additional query to filter and order the list. This query can be
Select the sorting direction. Valid values are [asc,desc] Select the sorting direction. Valid values are [asc,desc]
.PP .PP
\fB-f\fP, \fB--format\fP="default" \fB-f\fP, \fB--format\fP=""
Select the output formatting style. Valid values are [default,plain,compact,id,json,org-mode] Select the output formatting style. Valid values are [default,plain,id,json,org-mode]
.PP .PP
\fB-h\fP, \fB--help\fP[=false] \fB-h\fP, \fB--help\fP[=false]

View File

@ -4,7 +4,7 @@ Configure a new bridge
### Synopsis ### Synopsis
Configure a new bridge by passing flags or/and using interactive terminal prompts. You can avoid all the terminal prompts by passing all the necessary flags to configure your bridge. Configure a new bridge by passing flags or/and using interactive terminal prompts. You can avoid all the terminal prompts by passing all the necessary flags to configure your bridge.
``` ```
git-bug bridge new [flags] git-bug bridge new [flags]

View File

@ -42,7 +42,7 @@ git bug status:open --by creation "foo bar" baz
-n, --no strings Filter by absence of something. Valid values are [label] -n, --no strings Filter by absence of something. Valid values are [label]
-b, --by string Sort the results by a characteristic. Valid values are [id,creation,edit] (default "creation") -b, --by string Sort the results by a characteristic. Valid values are [id,creation,edit] (default "creation")
-d, --direction string Select the sorting direction. Valid values are [asc,desc] (default "asc") -d, --direction string Select the sorting direction. Valid values are [asc,desc] (default "asc")
-f, --format string Select the output formatting style. Valid values are [default,plain,compact,id,json,org-mode] (default "default") -f, --format string Select the output formatting style. Valid values are [default,plain,id,json,org-mode]
-h, --help help for bug -h, --help help for bug
``` ```

View File

@ -11,7 +11,7 @@ import (
// sha-256 // sha-256
const idLength = 64 const idLength = 64
const humanIdLength = 7 const HumanIdLength = 7
const UnsetId = Id("unset") const UnsetId = Id("unset")
@ -34,7 +34,7 @@ func (i Id) String() string {
// Human return the identifier, shortened for human consumption // Human return the identifier, shortened for human consumption
func (i Id) Human() string { func (i Id) Human() string {
format := fmt.Sprintf("%%.%ds", humanIdLength) format := fmt.Sprintf("%%.%ds", HumanIdLength)
return fmt.Sprintf(format, i) return fmt.Sprintf(format, i)
} }

View File

@ -22,7 +22,7 @@ func (ci CombinedId) String() string {
// Human return the identifier, shortened for human consumption // Human return the identifier, shortened for human consumption
func (ci CombinedId) Human() string { func (ci CombinedId) Human() string {
format := fmt.Sprintf("%%.%ds", humanIdLength) format := fmt.Sprintf("%%.%ds", HumanIdLength)
return fmt.Sprintf(format, ci) return fmt.Sprintf(format, ci)
} }