mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-06 05:57:38 -04:00
WrapViewLinesToWidth counted the characters of ANSI escape sequences as if they were visible, so colored text wrapped earlier than the view does, and the number of lines it reported was too high. In a narrow terminal the divergence in the menu that offers to push a stack of branches, colored yellow, ended up on a line of its own even though it fitted. The tooltip of a disabled menu item, whose prefix is red, gets its height from the same function. Skip escape sequences when measuring the width, and never break a line inside one. gocui wraps parsed cells and never sees escape sequences, so this brings the two in line; the test's parity check now compares the wrapped lines with the escape sequences stripped. Only CSI sequences are recognized. Those are the color and style codes that lazygit puts into view content; hyperlinks (OSC 8) only occur in the main view, and gocui wraps that one itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
490 lines
10 KiB
Go
490 lines
10 KiB
Go
package utils
|
|
|
|
import (
|
|
"bufio"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/samber/lo"
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
// TestSplitLines is a function.
|
|
func TestSplitLines(t *testing.T) {
|
|
type scenario struct {
|
|
multilineString string
|
|
expected []string
|
|
}
|
|
|
|
scenarios := []scenario{
|
|
{
|
|
"",
|
|
[]string{},
|
|
},
|
|
{
|
|
"\n",
|
|
[]string{},
|
|
},
|
|
{
|
|
"hello world !\nhello universe !\n",
|
|
[]string{
|
|
"hello world !",
|
|
"hello universe !",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, s := range scenarios {
|
|
assert.EqualValues(t, s.expected, SplitLines(s.multilineString))
|
|
}
|
|
}
|
|
|
|
func TestSplitNul(t *testing.T) {
|
|
type scenario struct {
|
|
multilineString string
|
|
expected []string
|
|
}
|
|
|
|
scenarios := []scenario{
|
|
{
|
|
"",
|
|
[]string{},
|
|
},
|
|
{
|
|
"\x00",
|
|
[]string{
|
|
"",
|
|
},
|
|
},
|
|
{
|
|
"hello world !\x00hello universe !\x00",
|
|
[]string{
|
|
"hello world !",
|
|
"hello universe !",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, s := range scenarios {
|
|
assert.EqualValues(t, s.expected, SplitNul(s.multilineString))
|
|
}
|
|
}
|
|
|
|
// TestNormalizeLinefeeds is a function.
|
|
func TestNormalizeLinefeeds(t *testing.T) {
|
|
type scenario struct {
|
|
byteArray []byte
|
|
expected []byte
|
|
}
|
|
scenarios := []scenario{
|
|
{
|
|
// \r\n
|
|
[]byte{97, 115, 100, 102, 13, 10},
|
|
[]byte{97, 115, 100, 102, 10},
|
|
},
|
|
{
|
|
// bash\r\nblah
|
|
[]byte{97, 115, 100, 102, 13, 10, 97, 115, 100, 102},
|
|
[]byte{97, 115, 100, 102, 10, 97, 115, 100, 102},
|
|
},
|
|
{
|
|
// \r
|
|
[]byte{97, 115, 100, 102, 13},
|
|
[]byte{97, 115, 100, 102},
|
|
},
|
|
{
|
|
// \n
|
|
[]byte{97, 115, 100, 102, 10},
|
|
[]byte{97, 115, 100, 102, 10},
|
|
},
|
|
}
|
|
|
|
for _, s := range scenarios {
|
|
assert.EqualValues(t, string(s.expected), NormalizeLinefeeds(string(s.byteArray)))
|
|
}
|
|
}
|
|
|
|
func TestScanLinesAndTruncateWhenLongerThanBuffer(t *testing.T) {
|
|
type scenario struct {
|
|
input string
|
|
expectedLines []string
|
|
}
|
|
|
|
scenarios := []scenario{
|
|
{
|
|
"",
|
|
[]string{},
|
|
},
|
|
{
|
|
"\n",
|
|
[]string{""},
|
|
},
|
|
{
|
|
"abc",
|
|
[]string{"abc"},
|
|
},
|
|
{
|
|
"abc\ndef",
|
|
[]string{"abc", "def"},
|
|
},
|
|
{
|
|
"abc\n\ndef",
|
|
[]string{"abc", "", "def"},
|
|
},
|
|
{
|
|
"abc\r\ndef\r",
|
|
[]string{"abc", "def"},
|
|
},
|
|
{
|
|
"abcdef",
|
|
[]string{"abcde"},
|
|
},
|
|
{
|
|
"abcdef\n",
|
|
[]string{"abcde"},
|
|
},
|
|
{
|
|
"abcdef\nghijkl\nx",
|
|
[]string{"abcde", "ghijk", "x"},
|
|
},
|
|
{
|
|
"abc\ndefghijklmnopqrstuvw\nx",
|
|
[]string{"abc", "defgh", "x"},
|
|
},
|
|
}
|
|
|
|
for _, s := range scenarios {
|
|
scanner := bufio.NewScanner(strings.NewReader(s.input))
|
|
scanner.Buffer(make([]byte, 5), 5)
|
|
scanner.Split(ScanLinesAndTruncateWhenLongerThanBuffer(5))
|
|
result := []string{}
|
|
for scanner.Scan() {
|
|
result = append(result, scanner.Text())
|
|
}
|
|
assert.NoError(t, scanner.Err())
|
|
assert.EqualValues(t, s.expectedLines, result)
|
|
}
|
|
}
|
|
|
|
func TestWrapViewLinesToWidth(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
wrap bool
|
|
editable bool
|
|
text string
|
|
width int
|
|
tabWidth int
|
|
expectedWrappedLines []string
|
|
expectedWrappedLinesIndices []int
|
|
expectedOriginalLinesIndices []int
|
|
}{
|
|
{
|
|
name: "Wrap off",
|
|
wrap: false,
|
|
text: "1st line\n2nd line\n3rd line",
|
|
width: 5,
|
|
expectedWrappedLines: []string{
|
|
"1st line",
|
|
"2nd line",
|
|
"3rd line",
|
|
},
|
|
expectedWrappedLinesIndices: []int{0, 1, 2},
|
|
expectedOriginalLinesIndices: []int{0, 1, 2},
|
|
},
|
|
{
|
|
name: "Wrap on space",
|
|
wrap: true,
|
|
text: "Hello World",
|
|
width: 5,
|
|
expectedWrappedLines: []string{
|
|
"Hello",
|
|
"World",
|
|
},
|
|
expectedWrappedLinesIndices: []int{0},
|
|
expectedOriginalLinesIndices: []int{0, 0},
|
|
},
|
|
{
|
|
name: "Wrap on hyphen",
|
|
wrap: true,
|
|
text: "Hello-World",
|
|
width: 6,
|
|
expectedWrappedLines: []string{
|
|
"Hello-",
|
|
"World",
|
|
},
|
|
},
|
|
{
|
|
name: "Wrap on hyphen 2",
|
|
wrap: true,
|
|
text: "Blah Hello-World",
|
|
width: 12,
|
|
expectedWrappedLines: []string{
|
|
"Blah Hello-",
|
|
"World",
|
|
},
|
|
},
|
|
{
|
|
name: "Wrap on hyphen 3",
|
|
wrap: true,
|
|
text: "Blah Hello-World",
|
|
width: 11,
|
|
expectedWrappedLines: []string{
|
|
"Blah Hello-",
|
|
"World",
|
|
},
|
|
},
|
|
{
|
|
name: "Wrap on hyphen 4",
|
|
wrap: true,
|
|
text: "Blah Hello-World",
|
|
width: 10,
|
|
expectedWrappedLines: []string{
|
|
"Blah Hello",
|
|
"-World",
|
|
},
|
|
},
|
|
{
|
|
name: "Wrap on space 2",
|
|
wrap: true,
|
|
text: "Blah Hello World",
|
|
width: 10,
|
|
expectedWrappedLines: []string{
|
|
"Blah Hello",
|
|
"World",
|
|
},
|
|
},
|
|
{
|
|
name: "Wrap on space with more words",
|
|
wrap: true,
|
|
text: "Longer word here",
|
|
width: 10,
|
|
expectedWrappedLines: []string{
|
|
"Longer",
|
|
"word here",
|
|
},
|
|
},
|
|
{
|
|
name: "Split word that's too long",
|
|
wrap: true,
|
|
text: "ThisWordIsWayTooLong",
|
|
width: 10,
|
|
expectedWrappedLines: []string{
|
|
"ThisWordIs",
|
|
"WayTooLong",
|
|
},
|
|
},
|
|
{
|
|
name: "Split word that's too long over multiple lines",
|
|
wrap: true,
|
|
text: "ThisWordIsWayTooLong",
|
|
width: 5,
|
|
expectedWrappedLines: []string{
|
|
"ThisW",
|
|
"ordIs",
|
|
"WayTo",
|
|
"oLong",
|
|
},
|
|
},
|
|
{
|
|
name: "Lots of hyphens",
|
|
wrap: true,
|
|
text: "one-two-three-four-five",
|
|
width: 8,
|
|
expectedWrappedLines: []string{
|
|
"one-two-",
|
|
"three-",
|
|
"four-",
|
|
"five",
|
|
},
|
|
},
|
|
{
|
|
name: "Several lines using all the available width",
|
|
wrap: true,
|
|
text: "aaa bb cc ddd-ee ff",
|
|
width: 5,
|
|
expectedWrappedLines: []string{
|
|
"aaa",
|
|
"bb cc",
|
|
"ddd-",
|
|
"ee ff",
|
|
},
|
|
},
|
|
{
|
|
name: "Several lines using all the available width, with multi-cell runes",
|
|
wrap: true,
|
|
text: "🐤🐤🐤 🐝🐝 🙉🙉 🦊🦊🦊-🐬🐬 🦢🦢",
|
|
width: 9,
|
|
expectedWrappedLines: []string{
|
|
"🐤🐤🐤",
|
|
"🐝🐝 🙉🙉",
|
|
"🦊🦊🦊-",
|
|
"🐬🐬 🦢🦢",
|
|
},
|
|
},
|
|
{
|
|
name: "Space in last column",
|
|
wrap: true,
|
|
text: "hello world",
|
|
width: 6,
|
|
expectedWrappedLines: []string{
|
|
"hello",
|
|
"world",
|
|
},
|
|
},
|
|
{
|
|
name: "Hyphen in last column",
|
|
wrap: true,
|
|
text: "hello-world",
|
|
width: 6,
|
|
expectedWrappedLines: []string{
|
|
"hello-",
|
|
"world",
|
|
},
|
|
},
|
|
{
|
|
name: "English text",
|
|
wrap: true,
|
|
text: "+The sea reach of the Thames stretched before us like the bedinnind of an interminable waterway. In the offind the sea and the sky were welded todether without a joint, and in the luminous space the tanned sails of the bardes drifting blah blah",
|
|
width: 81,
|
|
expectedWrappedLines: []string{
|
|
"+The sea reach of the Thames stretched before us like the bedinnind of an",
|
|
"interminable waterway. In the offind the sea and the sky were welded todether",
|
|
"without a joint, and in the luminous space the tanned sails of the bardes",
|
|
"drifting blah blah",
|
|
},
|
|
},
|
|
{
|
|
name: "Tabs, width 4",
|
|
wrap: true,
|
|
text: "\ta\tbb\tccc\tdddd\teeeee",
|
|
width: 50,
|
|
tabWidth: 4,
|
|
expectedWrappedLines: []string{
|
|
" a bb ccc dddd eeeee",
|
|
},
|
|
},
|
|
{
|
|
name: "Tabs, width 8",
|
|
wrap: true,
|
|
text: "\ta\tbb\tccc\tdddddddd\teeeee",
|
|
width: 100,
|
|
tabWidth: 8,
|
|
expectedWrappedLines: []string{
|
|
" a bb ccc dddddddd eeeee",
|
|
},
|
|
},
|
|
{
|
|
name: "Multiple lines",
|
|
wrap: true,
|
|
text: "First paragraph\nThe second paragraph is a bit longer.\nThird paragraph\n",
|
|
width: 10,
|
|
expectedWrappedLines: []string{
|
|
"First",
|
|
"paragraph",
|
|
"The second",
|
|
"paragraph",
|
|
"is a bit",
|
|
"longer.",
|
|
"Third",
|
|
"paragraph",
|
|
},
|
|
expectedWrappedLinesIndices: []int{0, 2, 6},
|
|
expectedOriginalLinesIndices: []int{0, 0, 1, 1, 1, 1, 2, 2},
|
|
},
|
|
{
|
|
name: "Avoid blank line at end if not editable",
|
|
wrap: true,
|
|
editable: false,
|
|
text: "First\nSecond\nThird\n",
|
|
width: 10,
|
|
expectedWrappedLines: []string{
|
|
"First",
|
|
"Second",
|
|
"Third",
|
|
},
|
|
expectedWrappedLinesIndices: []int{0, 1, 2},
|
|
expectedOriginalLinesIndices: []int{0, 1, 2},
|
|
},
|
|
{
|
|
name: "Keep blank line at end if editable",
|
|
wrap: true,
|
|
editable: true,
|
|
text: "First\nSecond\nThird\n",
|
|
width: 10,
|
|
expectedWrappedLines: []string{
|
|
"First",
|
|
"Second",
|
|
"Third",
|
|
"",
|
|
},
|
|
expectedWrappedLinesIndices: []int{0, 1, 2, 3},
|
|
expectedOriginalLinesIndices: []int{0, 1, 2, 3},
|
|
},
|
|
{
|
|
name: "Escape sequences don't count towards the width",
|
|
wrap: true,
|
|
text: "Hello \x1b[33mWorld\x1b[0m",
|
|
width: 11,
|
|
expectedWrappedLines: []string{
|
|
"Hello \x1b[33mWorld\x1b[0m",
|
|
},
|
|
expectedWrappedLinesIndices: []int{0},
|
|
expectedOriginalLinesIndices: []int{0},
|
|
},
|
|
{
|
|
name: "Break at a space before a colored word",
|
|
wrap: true,
|
|
text: "Hello \x1b[33mWorld\x1b[0m",
|
|
width: 5,
|
|
expectedWrappedLines: []string{
|
|
"Hello",
|
|
"\x1b[33mWorld\x1b[0m",
|
|
},
|
|
expectedWrappedLinesIndices: []int{0},
|
|
expectedOriginalLinesIndices: []int{0, 0},
|
|
},
|
|
{
|
|
name: "Width after a break leaves out escape sequences",
|
|
wrap: true,
|
|
text: "a \x1b[33mbcd\x1b[0mef",
|
|
width: 4,
|
|
expectedWrappedLines: []string{
|
|
"a",
|
|
"\x1b[33mbcd\x1b[0me",
|
|
"f",
|
|
},
|
|
expectedWrappedLinesIndices: []int{0},
|
|
expectedOriginalLinesIndices: []int{0, 0, 0},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tabWidth := tt.tabWidth
|
|
if tabWidth == 0 {
|
|
tabWidth = 4
|
|
}
|
|
wrappedLines, wrappedLinesIndices, originalLinesIndices := WrapViewLinesToWidth(tt.wrap, tt.editable, tt.text, tt.width, tabWidth)
|
|
assert.Equal(t, tt.expectedWrappedLines, wrappedLines)
|
|
if tt.expectedWrappedLinesIndices != nil {
|
|
assert.Equal(t, tt.expectedWrappedLinesIndices, wrappedLinesIndices)
|
|
}
|
|
if tt.expectedOriginalLinesIndices != nil {
|
|
assert.Equal(t, tt.expectedOriginalLinesIndices, originalLinesIndices)
|
|
}
|
|
|
|
// As a sanity check, also test that gocui's line wrapping behaves the
|
|
// same way. gocui turns escape sequences into cell styles, so its
|
|
// lines don't contain them.
|
|
view := gocui.NewView("", 0, 0, tt.width+1, 1000, gocui.OutputNormal)
|
|
view.TabWidth = tabWidth
|
|
assert.Equal(t, tt.width, view.InnerWidth())
|
|
view.Wrap = tt.wrap
|
|
view.Editable = tt.editable
|
|
view.SetContent(tt.text)
|
|
plainWrappedLines := lo.Map(wrappedLines, func(line string, _ int) string { return Decolorise(line) })
|
|
assert.Equal(t, plainWrappedLines, view.ViewBufferLines())
|
|
})
|
|
}
|
|
}
|