From e614142f546769c5f8e8079cc8715e5b6efa7928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Rainone?= <476650+arl@users.noreply.github.com> Date: Sun, 23 Jan 2022 17:36:31 +0100 Subject: [PATCH] Issue 58/trim branch name right or left (#59) * Bump gopkg.in/yaml v2 -> v3 * Add branch_trim_direction (default to right) * tmux: remove Formater.remote() Formater.remote() is just remoteBranch() followed by divergence() * tmux: implement and test branch_trim_direction (left|right) * tmux: s/BranchTrimDirection/BranchTrim * README.md: update and add branch_trim * tmux: remove dependency to testify * tmux: use io.Discard in test --- README.md | 62 ++++++------ gitmux.go | 2 +- go.mod | 4 +- go.sum | 5 +- tmux/formater.go | 115 ++++++++++++---------- tmux/formater_test.go | 224 ++++++++++++++++++++++++++++++------------ 6 files changed, 261 insertions(+), 151 deletions(-) diff --git a/README.md b/README.md index c08ae62..3360039 100644 --- a/README.md +++ b/README.md @@ -59,36 +59,37 @@ Note that `tmux v2.1` was released in 2015 so you're probably better off updatin `gitmux` output can be customized via a configuration file in YAML format. -The gitmux configuration file is in YAML format. +This is the default gitmux configuration file, in YAML format: ```yaml tmux: - symbols: - branch: '⎇ ' - hashprefix: ':' - ahead: ↑· - behind: ↓· - staged: '● ' - conflict: '✖ ' - modified: '✚ ' - untracked: '… ' - stashed: '⚑ ' - clean: ✔ - styles: - clear: '#[fg=default]' - state: '#[fg=red,bold]' - branch: '#[fg=white,bold]' - remote: '#[fg=cyan]' - staged: '#[fg=green,bold]' - conflict: '#[fg=red,bold]' - modified: '#[fg=red,bold]' - untracked: '#[fg=magenta,bold]' - stashed: '#[fg=cyan,bold]' - clean: '#[fg=green,bold]' - divergence: "#[fg=yellow]" - layout: [branch, .., remote, " - ", flags] - options: - branch_max_len: 0 + symbols: + branch: '⎇ ' + hashprefix: ':' + ahead: ↑· + behind: ↓· + staged: '● ' + conflict: '✖ ' + modified: '✚ ' + untracked: '… ' + stashed: '⚑ ' + clean: ✔ + styles: + clear: '#[fg=default]' + state: '#[fg=red,bold]' + branch: '#[fg=white,bold]' + remote: '#[fg=cyan]' + staged: '#[fg=green,bold]' + conflict: '#[fg=red,bold]' + modified: '#[fg=red,bold]' + untracked: '#[fg=magenta,bold]' + stashed: '#[fg=cyan,bold]' + clean: '#[fg=green,bold]' + divergence: '#[fg=default]' + layout: [branch, .., remote-branch, divergence, ' - ', flags] + options: + branch_max_len: 0 + branch_trim: right ``` First, save the default configuration to a new file: @@ -209,9 +210,10 @@ layout: [flags, " ", branch] This is the list of additional configuration `options`: -| Option | Description | Default | -| :--------------- | :--------------------------------------------------------- | :------------- | -| `branch_max_len` | Maximum displayed length for local and remote branch names | `0` (no limit) | +| Option | Description | Default | +| :--------------- | :--------------------------------------------------------- | :----------------- | +| `branch_max_len` | Maximum displayed length for local and remote branch names | `0` (no limit) | +| `branch_trim` | Trim left or right end of the branch (`right` or `left`) | `right` (trailing) | ## Troubleshooting diff --git a/gitmux.go b/gitmux.go index 90d7d36..d3b3440 100644 --- a/gitmux.go +++ b/gitmux.go @@ -8,7 +8,7 @@ import ( "time" "github.com/arl/gitstatus" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" "github.com/arl/gitmux/json" "github.com/arl/gitmux/tmux" diff --git a/go.mod b/go.mod index d6668f1..7fa25b2 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,6 @@ go 1.15 require ( github.com/arl/gitstatus v0.4.3 - github.com/stretchr/testify v1.7.0 - gopkg.in/yaml.v2 v2.4.0 + github.com/stretchr/testify v1.7.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) diff --git a/go.sum b/go.sum index 8daaa5f..98e5086 100644 --- a/go.sum +++ b/go.sum @@ -10,7 +10,6 @@ github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5Cc github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tmux/formater.go b/tmux/formater.go index c9bf674..f664ec8 100644 --- a/tmux/formater.go +++ b/tmux/formater.go @@ -5,12 +5,12 @@ import ( "fmt" "io" "strings" + "unicode/utf8" "github.com/arl/gitstatus" + "gopkg.in/yaml.v3" ) -const truncateSymbol string = "..." - // Config is the configuration of the Git status tmux formatter. type Config struct { // Symbols contains the symbols printed before the Git status components. @@ -53,9 +53,32 @@ type styles struct { Divergence string // Divergence is the style string printed before divergence count/symbols. } +const ( + dirLeft direction = "left" + dirRight direction = "right" +) + +type direction string + +func (d *direction) UnmarshalYAML(value *yaml.Node) error { + s := "" + if err := value.Decode(&s); err != nil { + return fmt.Errorf("error decoding 'direction': %v", s) + } + switch direction(s) { + case dirLeft: + *d = dirLeft + case dirRight: + *d = dirRight + default: + return fmt.Errorf("'direction': unexpected value %v", s) + } + return nil +} + type options struct { - // BranchMaxLen is the maximum displayed length for local and remote branch names. - BranchMaxLen int `yaml:"branch_max_len"` + BranchMaxLen int `yaml:"branch_max_len"` + BranchTrim direction `yaml:"branch_trim"` } // DefaultCfg is the default tmux configuration. @@ -88,6 +111,7 @@ var DefaultCfg = Config{ Layout: []string{"branch", "..", "remote-branch", "divergence", " - ", "flags"}, Options: options{ BranchMaxLen: 0, + BranchTrim: dirRight, }, } @@ -98,40 +122,37 @@ type Formater struct { st *gitstatus.Status } -// Truncates branch name if longer than maxlen. If isremote, the leading -// "/" is ignored when counting length. -func truncateBranchName(name string, maxlen int, isremote bool) string { - remoteName := "" - branchName := name - - const ( - idxRemote = 0 - idxBranch = 1 - numItems = 2 - ) - - if isremote { - a := strings.SplitAfterN(name, "/", numItems) - if len(a) == numItems { - remoteName = a[idxRemote] - branchName = a[idxBranch] - } +// truncate returns s, truncated so that it is no more than max characters long. +// Depending on the provided direction, truncation is performed right or left. +// If max is zero, negative or greater than the number of rnues in s, truncate +// just returns s. However, if truncation is applied, then the last 3 chars (or +// 3 first, depending on provided direction) are replaced with "...". +// +// NOTE: If max is lower than 3, in other words if we can't even have ellispis, +// then truncate just truncates the maximum number of characters, without +// bothering with ellipsis. +func truncate(s string, max int, dir direction) string { + slen := utf8.RuneCountInString(s) + if max <= 0 || slen <= max { + return s } - // To count length of characters and extract substring from UTF-8 strings. - branchNameRune := []rune(branchName) - truncateSymbolRune := []rune(truncateSymbol) + runes := []rune(s) + ell := []rune("...") - if maxlen > 0 && maxlen < len(branchNameRune) { - nameLen := maxlen - len(truncateSymbolRune) - if nameLen > 0 { - branchName = string(branchNameRune[:nameLen]) + truncateSymbol - } else { - branchName = string(truncateSymbolRune[:maxlen]) - } + if max < 3 { + ell = nil // Just truncate s since even ellipsis don't fit. } - return remoteName + branchName + switch dir { + case dirRight: + runes = runes[:max-len(ell)] + runes = append(runes, ell...) + case dirLeft: + runes = runes[len(runes)+len(ell)-max:] + runes = append(ell, runes...) + } + return string(runes) } // Format writes st as json into w. @@ -141,8 +162,8 @@ func (f *Formater) Format(w io.Writer, st *gitstatus.Status) error { // overall working tree state if f.st.IsInitial { - fmt.Fprintf(w, "%s%s [no commits yet]", f.Styles.Branch, - truncateBranchName(f.st.LocalBranch, f.Options.BranchMaxLen, false)) + branch := truncate(f.st.LocalBranch, f.Options.BranchMaxLen, f.Options.BranchTrim) + fmt.Fprintf(w, "%s%s [no commits yet]", f.Styles.Branch, branch) f.flags() _, err := f.b.WriteTo(w) @@ -161,7 +182,8 @@ func (f *Formater) format() { case "branch": f.specialState() case "remote": - f.remote() + f.remoteBranch() + f.divergence() case "remote-branch": f.remoteBranch() case "divergence": @@ -200,25 +222,15 @@ func (f *Formater) specialState() { f.currentRef() } -func (f *Formater) remote() { +func (f *Formater) remoteBranch() { if f.st.RemoteBranch == "" { return } f.clear() - fmt.Fprintf(&f.b, "%s%s", f.Styles.Remote, - truncateBranchName(f.st.RemoteBranch, f.Options.BranchMaxLen, true)) - f.divergence() -} - -func (f *Formater) remoteBranch() { - if f.st.RemoteBranch != "" { - f.clear() - - fmt.Fprintf(&f.b, "%s%s", f.Styles.Remote, - truncateBranchName(f.st.RemoteBranch, f.Options.BranchMaxLen, true)) - } + branch := truncate(f.st.RemoteBranch, f.Options.BranchMaxLen, f.Options.BranchTrim) + fmt.Fprintf(&f.b, "%s%s", f.Styles.Remote, branch) } func (f *Formater) divergence() { @@ -249,12 +261,11 @@ func (f *Formater) currentRef() { if f.st.IsDetached { fmt.Fprintf(&f.b, "%s%s%s", f.Styles.Branch, f.Symbols.HashPrefix, f.st.HEAD) - return } - fmt.Fprintf(&f.b, "%s%s", f.Styles.Branch, - truncateBranchName(f.st.LocalBranch, f.Options.BranchMaxLen, false)) + branch := truncate(f.st.LocalBranch, f.Options.BranchMaxLen, f.Options.BranchTrim) + fmt.Fprintf(&f.b, "%s%s", f.Styles.Branch, branch) } func (f *Formater) flags() { diff --git a/tmux/formater_test.go b/tmux/formater_test.go index 1e0146e..dfda8f4 100644 --- a/tmux/formater_test.go +++ b/tmux/formater_test.go @@ -1,11 +1,9 @@ package tmux import ( - "os" + "io" "testing" - "github.com/stretchr/testify/require" - "github.com/arl/gitstatus" ) @@ -76,15 +74,17 @@ func TestFlags(t *testing.T) { want: "StyleClear" + "StyleConflictSymbolConflict42 StyleUntrackedSymbolUntracked17", }, } - for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { f := &Formater{ - Config: Config{Styles: tc.styles, Symbols: tc.symbols, Layout: tc.layout}, - st: tc.st, + Config: Config{Styles: tt.styles, Symbols: tt.symbols, Layout: tt.layout}, + st: tt.st, } f.flags() - require.EqualValues(t, tc.want, f.b.String()) + + if got := f.b.String(); got != tt.want { + t.Errorf("got:\n%s\n\nwant:\n%s\n", got, tt.want) + } }) } } @@ -166,75 +166,145 @@ func TestDivergence(t *testing.T) { want: "StyleClear" + " ↑·128↓·41", }, } - for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { f := &Formater{ - Config: Config{Styles: tc.styles, Symbols: tc.symbols}, - st: tc.st, + Config: Config{Styles: tt.styles, Symbols: tt.symbols}, + st: tt.st, } f.divergence() - require.EqualValues(t, tc.want, f.b.String()) + + if got := f.b.String(); got != tt.want { + t.Errorf("got:\n%s\n\nwant:\n%s\n", got, tt.want) + } }) } } -func TestTruncateBranchName(t *testing.T) { +func TestTruncate(t *testing.T) { tests := []struct { - name string - branchName string - maxLen int - isRemote bool - want string + s string + max int + dir direction + want string }{ + /* trim right */ { - name: "no limit", - branchName: "foo/bar-baz", - maxLen: 0, - isRemote: false, - want: "foo/bar-baz", + s: "br", + max: 1, + dir: dirRight, + want: "b", }, { - name: "no truncate", - branchName: "foo/bar-baz", - maxLen: 11, - isRemote: false, - want: "foo/bar-baz", + s: "br", + max: 3, + dir: dirRight, + want: "br", }, { - name: "truncate", - branchName: "foo/bar-baz", - maxLen: 10, - isRemote: false, - want: "foo/bar...", + s: "super-long-branch", + max: 3, + dir: dirRight, + want: "...", }, { - name: "truncate remote", - branchName: "remote/foo/bar-baz", - maxLen: 10, - isRemote: true, - want: "remote/foo/bar...", + s: "super-long-branch", + max: 15, + dir: dirRight, + want: "super-long-b...", }, { - name: "truncate to 1", - branchName: "foo/bar-baz", - maxLen: 1, - isRemote: false, - want: ".", + s: "super-long-branch", + max: 17, + dir: dirRight, + want: "super-long-branch", }, { - name: "truncate utf-8 name", - branchName: "foo/测试这个名字", - maxLen: 9, - isRemote: false, - want: "foo/测试...", + s: "长長的-树樹枝", + max: 6, + dir: dirRight, + want: "长長的...", + }, + { + s: "super-long-branch", + max: 32, + dir: dirRight, + want: "super-long-branch", + }, + { + s: "super-long-branch", + max: 0, + dir: dirRight, + want: "super-long-branch", + }, + { + s: "super-long-branch", + max: -1, + dir: dirRight, + want: "super-long-branch", + }, + + /* trim left */ + { + s: "br", + max: 1, + dir: dirLeft, + want: "r", + }, + { + s: "br", + max: 3, + dir: dirLeft, + want: "br", + }, + { + s: "super-long-branch", + max: 3, + dir: dirLeft, + want: "...", + }, + { + s: "super-long-branch", + max: 15, + dir: dirLeft, + want: "...-long-branch", + }, + { + s: "super-long-branch", + max: 17, + dir: dirLeft, + want: "super-long-branch", + }, + { + s: "长長的-树樹枝", + max: 6, + dir: dirLeft, + want: "...树樹枝", + }, + { + s: "super-long-branch", + max: 32, + dir: dirLeft, + want: "super-long-branch", + }, + { + s: "super-long-branch", + max: 0, + dir: dirLeft, + want: "super-long-branch", + }, + { + s: "super-long-branch", + max: -1, + dir: dirLeft, + want: "super-long-branch", }, } - for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { - branchName := truncateBranchName(tc.branchName, tc.maxLen, tc.isRemote) - require.EqualValues(t, tc.want, branchName) + for _, tt := range tests { + t.Run("", func(t *testing.T) { + if got := truncate(tt.s, tt.max, tt.dir); got != tt.want { + t.Errorf("truncate(%q, %d, %s) = %q, want %q", tt.s, tt.max, tt.dir, got, tt.want) + } }) } } @@ -360,6 +430,7 @@ func TestFormat(t *testing.T) { layout: []string{"branch", " ", "remote"}, options: options{ BranchMaxLen: 9, + BranchTrim: dirRight, }, st: &gitstatus.Status{ Porcelain: gitstatus.Porcelain{ @@ -370,7 +441,33 @@ func TestFormat(t *testing.T) { want: "StyleClear" + "StyleBranch" + "SymbolBranch" + "StyleClear" + "StyleBranch" + "branch..." + "StyleClear" + " " + - "StyleClear" + "StyleRemote" + "remote/branch...", + "StyleClear" + "StyleRemote" + "remote...", + }, + { + name: "branch and remote, branch_max_len not zero and trim left", + styles: styles{ + Clear: "StyleClear", + Branch: "StyleBranch", + Remote: "StyleRemote", + }, + symbols: symbols{ + Branch: "SymbolBranch", + }, + layout: []string{"branch", " ", "remote"}, + options: options{ + BranchMaxLen: 9, + BranchTrim: dirLeft, + }, + st: &gitstatus.Status{ + Porcelain: gitstatus.Porcelain{ + LocalBranch: "nameBranch", + RemoteBranch: "remote/nameBranch", + }, + }, + want: "StyleClear" + "StyleBranch" + "SymbolBranch" + + "StyleClear" + "StyleBranch" + "...Branch" + + "StyleClear" + " " + + "StyleClear" + "StyleRemote" + "...Branch", }, { name: "issue-32", @@ -391,19 +488,20 @@ func TestFormat(t *testing.T) { "StyleClear" + "StyleBranch" + "branchName", }, } - for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { f := &Formater{ - Config: Config{Styles: tc.styles, Symbols: tc.symbols, Layout: tc.layout, Options: tc.options}, + Config: Config{Styles: tt.styles, Symbols: tt.symbols, Layout: tt.layout, Options: tt.options}, } - if err := f.Format(os.Stdout, tc.st); err != nil { + if err := f.Format(io.Discard, tt.st); err != nil { t.Fatalf("Format error: %s", err) } f.format() - require.EqualValues(t, tc.want, f.b.String()) + if got := f.b.String(); got != tt.want { + t.Errorf("got:\n%s\n\nwant:\n%s\n", got, tt.want) + } }) } }