mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-01-10 04:07:18 +02:00
473 lines
26 KiB
Go
473 lines
26 KiB
Go
package helpers
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jesseduffield/lazycore/pkg/boxlayout"
|
|
"github.com/jesseduffield/lazygit/pkg/config"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/samber/lo"
|
|
"golang.org/x/exp/slices"
|
|
)
|
|
|
|
// The best way to add test cases here is to set your args and then get the
|
|
// test to fail and copy+paste the output into the test case's expected string.
|
|
// TODO: add more test cases
|
|
func TestGetWindowDimensions(t *testing.T) {
|
|
getDefaultArgs := func() WindowArrangementArgs {
|
|
return WindowArrangementArgs{
|
|
Width: 75,
|
|
Height: 30,
|
|
UserConfig: config.GetDefaultConfig(),
|
|
CurrentWindow: "files",
|
|
CurrentSideWindow: "files",
|
|
CurrentStaticWindow: "files",
|
|
SplitMainPanel: false,
|
|
ScreenMode: types.SCREEN_NORMAL,
|
|
AppStatus: "",
|
|
InformationStr: "information",
|
|
ShowExtrasWindow: false,
|
|
InDemo: false,
|
|
IsAnyModeActive: false,
|
|
InSearchPrompt: false,
|
|
SearchPrefix: "",
|
|
}
|
|
}
|
|
|
|
type Test struct {
|
|
name string
|
|
mutateArgs func(*WindowArrangementArgs)
|
|
expected string
|
|
}
|
|
|
|
tests := []Test{
|
|
{
|
|
name: "default",
|
|
mutateArgs: func(args *WindowArrangementArgs) {},
|
|
expected: `
|
|
╭status─────────────────╮╭main────────────────────────────────────────────╮
|
|
│ ││ │
|
|
╰───────────────────────╯│ │
|
|
╭files──────────────────╮│ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
╰───────────────────────╯│ │
|
|
╭branches───────────────╮│ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
╰───────────────────────╯│ │
|
|
╭commits────────────────╮│ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
╰───────────────────────╯│ │
|
|
╭stash──────────────────╮│ │
|
|
│ ││ │
|
|
╰───────────────────────╯╰────────────────────────────────────────────────╯
|
|
<options──────────────────────────────────────────────────────>A<B────────>
|
|
A: statusSpacer1
|
|
B: information
|
|
`,
|
|
},
|
|
{
|
|
name: "stash focused",
|
|
mutateArgs: func(args *WindowArrangementArgs) {
|
|
args.CurrentSideWindow = "stash"
|
|
},
|
|
expected: `
|
|
╭status─────────────────╮╭main────────────────────────────────────────────╮
|
|
│ ││ │
|
|
╰───────────────────────╯│ │
|
|
╭files──────────────────╮│ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
╰───────────────────────╯│ │
|
|
╭branches───────────────╮│ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
╰───────────────────────╯│ │
|
|
╭commits────────────────╮│ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
╰───────────────────────╯│ │
|
|
╭stash──────────────────╮│ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
╰───────────────────────╯╰────────────────────────────────────────────────╯
|
|
<options──────────────────────────────────────────────────────>A<B────────>
|
|
A: statusSpacer1
|
|
B: information
|
|
`,
|
|
},
|
|
{
|
|
name: "half screen mode, enlargedSideViewLocation left",
|
|
mutateArgs: func(args *WindowArrangementArgs) {
|
|
args.Height = 20 // smaller height because we don't more here
|
|
args.ScreenMode = types.SCREEN_HALF
|
|
args.UserConfig.Gui.EnlargedSideViewLocation = "left"
|
|
},
|
|
expected: `
|
|
╭status──────────────────────────────╮╭main───────────────────────────────╮
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
│ ││ │
|
|
╰────────────────────────────────────╯╰───────────────────────────────────╯
|
|
<options──────────────────────────────────────────────────────>A<B────────>
|
|
A: statusSpacer1
|
|
B: information
|
|
`,
|
|
},
|
|
{
|
|
name: "half screen mode, enlargedSideViewLocation top",
|
|
mutateArgs: func(args *WindowArrangementArgs) {
|
|
args.Height = 20 // smaller height because we don't more here
|
|
args.ScreenMode = types.SCREEN_HALF
|
|
args.UserConfig.Gui.EnlargedSideViewLocation = "top"
|
|
},
|
|
expected: `
|
|
╭status───────────────────────────────────────────────────────────────────╮
|
|
│ │
|
|
│ │
|
|
│ │
|
|
│ │
|
|
│ │
|
|
╰─────────────────────────────────────────────────────────────────────────╯
|
|
╭main─────────────────────────────────────────────────────────────────────╮
|
|
│ │
|
|
│ │
|
|
│ │
|
|
│ │
|
|
│ │
|
|
│ │
|
|
│ │
|
|
│ │
|
|
│ │
|
|
│ │
|
|
╰─────────────────────────────────────────────────────────────────────────╯
|
|
<options──────────────────────────────────────────────────────>A<B────────>
|
|
A: statusSpacer1
|
|
B: information
|
|
`,
|
|
},
|
|
{
|
|
name: "search mode",
|
|
mutateArgs: func(args *WindowArrangementArgs) {
|
|
args.InSearchPrompt = true
|
|
args.SearchPrefix = "Search: "
|
|
args.Height = 6 // small height cos we only care about the bottom line
|
|
},
|
|
expected: `
|
|
<status─────────────────>╭main────────────────────────────────────────────╮
|
|
<files──────────────────>│ │
|
|
<branches───────────────>│ │
|
|
<commits────────────────>│ │
|
|
<stash──────────────────>╰────────────────────────────────────────────────╯
|
|
<A─────><search───────────────────────────────────────────────────────────>
|
|
A: searchPrefix
|
|
`,
|
|
},
|
|
{
|
|
name: "app status present",
|
|
mutateArgs: func(args *WindowArrangementArgs) {
|
|
args.AppStatus = "Rebasing /"
|
|
args.Height = 6 // small height cos we only care about the bottom line
|
|
},
|
|
// We expect single-character spacers between the windows of the bottom line
|
|
expected: `
|
|
<status─────────────────>╭main────────────────────────────────────────────╮
|
|
<files──────────────────>│ │
|
|
<branches───────────────>│ │
|
|
<commits────────────────>│ │
|
|
<stash──────────────────>╰────────────────────────────────────────────────╯
|
|
<A───────>B<options───────────────────────────────────────────>C<D────────>
|
|
A: appStatus
|
|
B: statusSpacer2
|
|
C: statusSpacer1
|
|
D: information
|
|
`,
|
|
},
|
|
{
|
|
name: "information present without options",
|
|
mutateArgs: func(args *WindowArrangementArgs) {
|
|
args.Height = 6 // small height cos we only care about the bottom line
|
|
args.UserConfig.Gui.ShowBottomLine = false // this hides the options window
|
|
args.IsAnyModeActive = true // this means we show the bottom line despite the user config
|
|
},
|
|
// We expect a spacer on the left of the bottom line so that the information
|
|
// window is right-aligned
|
|
expected: `
|
|
<status─────────────────>╭main────────────────────────────────────────────╮
|
|
<files──────────────────>│ │
|
|
<branches───────────────>│ │
|
|
<commits────────────────>│ │
|
|
<stash──────────────────>╰────────────────────────────────────────────────╯
|
|
<statusSpacer1────────────────────────────────────────────────>A<B────────>
|
|
A: statusSpacer2
|
|
B: information
|
|
`,
|
|
},
|
|
{
|
|
name: "app status present without information or options",
|
|
mutateArgs: func(args *WindowArrangementArgs) {
|
|
args.Height = 6 // small height cos we only care about the bottom line
|
|
args.UserConfig.Gui.ShowBottomLine = false // this hides the options window
|
|
args.IsAnyModeActive = false
|
|
args.AppStatus = "Rebasing /"
|
|
},
|
|
// We expect the app status window to take up all the available space
|
|
expected: `
|
|
<status─────────────────>╭main────────────────────────────────────────────╮
|
|
<files──────────────────>│ │
|
|
<branches───────────────>│ │
|
|
<commits────────────────>│ │
|
|
<stash──────────────────>╰────────────────────────────────────────────────╯
|
|
<appStatus────────────────────────────────────────────────────────────────>
|
|
`,
|
|
},
|
|
{
|
|
name: "app status present with information but without options",
|
|
mutateArgs: func(args *WindowArrangementArgs) {
|
|
args.Height = 6 // small height cos we only care about the bottom line
|
|
args.UserConfig.Gui.ShowBottomLine = false // this hides the options window
|
|
args.IsAnyModeActive = true
|
|
args.AppStatus = "Rebasing /"
|
|
},
|
|
expected: `
|
|
<status─────────────────>╭main────────────────────────────────────────────╮
|
|
<files──────────────────>│ │
|
|
<branches───────────────>│ │
|
|
<commits────────────────>│ │
|
|
<stash──────────────────>╰────────────────────────────────────────────────╯
|
|
<A───────><statusSpacer1──────────────────────────────────────>B<C────────>
|
|
A: appStatus
|
|
B: statusSpacer2
|
|
C: information
|
|
`,
|
|
},
|
|
{
|
|
name: "app status present with very long information but without options",
|
|
mutateArgs: func(args *WindowArrangementArgs) {
|
|
args.Height = 6 // small height cos we only care about the bottom line
|
|
args.Width = 55 // smaller width so that not all bottom line views fit
|
|
args.UserConfig.Gui.ShowBottomLine = false // this hides the options window
|
|
args.IsAnyModeActive = true
|
|
args.AppStatus = "Rebasing /"
|
|
args.InformationStr = "Showing output for: git diff deadbeef fa1afe1 -- (Reset)"
|
|
},
|
|
expected: `
|
|
<status───────────>╭main──────────────────────────────╮
|
|
<files────────────>│ │
|
|
<branches─────────>│ │
|
|
<commits──────────>│ │
|
|
<stash────────────>╰──────────────────────────────────╯
|
|
<A───────>B<information──────────────────────────────────────────>
|
|
A: appStatus
|
|
B: statusSpacer2
|
|
`,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
args := getDefaultArgs()
|
|
test.mutateArgs(&args)
|
|
windows := GetWindowDimensions(args)
|
|
output := renderLayout(windows)
|
|
// removing tabs so that it's easier to paste the expected output
|
|
expected := strings.ReplaceAll(test.expected, "\t", "")
|
|
expected = strings.TrimSpace(expected)
|
|
if output != expected {
|
|
fmt.Println(output)
|
|
t.Errorf("Expected:\n%s\n\nGot:\n%s", expected, output)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func renderLayout(windows map[string]boxlayout.Dimensions) string {
|
|
// Each window will be represented by a letter.
|
|
windowMarkers := map[string]string{}
|
|
shortLabels := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"}
|
|
currentShortLabelIdx := 0
|
|
windowNames := lo.Keys(windows)
|
|
// Sort first by name, then by position. This means our short labels will
|
|
// increment in the order that the windows appear on the screen.
|
|
slices.Sort(windowNames)
|
|
slices.SortStableFunc(windowNames, func(a, b string) bool {
|
|
dimensionsA := windows[a]
|
|
dimensionsB := windows[b]
|
|
if dimensionsA.Y0 < dimensionsB.Y0 {
|
|
return true
|
|
}
|
|
if dimensionsA.Y0 > dimensionsB.Y0 {
|
|
return false
|
|
}
|
|
return dimensionsA.X0 < dimensionsB.X0
|
|
})
|
|
|
|
// Uniquefy windows by dimensions (so perfectly overlapping windows are de-duped). This prevents getting 'fileshes' as a label where the files and branches windows overlap.
|
|
// branches windows overlap.
|
|
windowNames = lo.UniqBy(windowNames, func(windowName string) boxlayout.Dimensions {
|
|
return windows[windowName]
|
|
})
|
|
|
|
// excluding the limit window because it overlaps with everything. In future
|
|
// we should have a concept of layers and then our test can assert against
|
|
// each layer.
|
|
windowNames = lo.Without(windowNames, "limit")
|
|
|
|
// get width/height by getting the max values of the dimensions
|
|
width := 0
|
|
height := 0
|
|
for _, dimensions := range windows {
|
|
if dimensions.X1+1 > width {
|
|
width = dimensions.X1 + 1
|
|
}
|
|
if dimensions.Y1+1 > height {
|
|
height = dimensions.Y1 + 1
|
|
}
|
|
}
|
|
|
|
screen := make([][]string, height)
|
|
for i := range screen {
|
|
screen[i] = make([]string, width)
|
|
}
|
|
|
|
// Draw each window
|
|
for _, windowName := range windowNames {
|
|
dimensions := windows[windowName]
|
|
|
|
zeroWidth := dimensions.X0 == dimensions.X1+1
|
|
if zeroWidth {
|
|
continue
|
|
}
|
|
|
|
singleRow := dimensions.Y0 == dimensions.Y1
|
|
oneOrTwoColumns := dimensions.X0 == dimensions.X1 || dimensions.X0+1 == dimensions.X1
|
|
|
|
assignShortLabel := func(windowName string) string {
|
|
windowMarkers[windowName] = shortLabels[currentShortLabelIdx]
|
|
currentShortLabelIdx++
|
|
return windowMarkers[windowName]
|
|
}
|
|
|
|
if singleRow {
|
|
y := dimensions.Y0
|
|
// If our window only occupies one (or two) columns we'll just use the short
|
|
// label once (or twice) i.e. 'A' or 'AA'.
|
|
if oneOrTwoColumns {
|
|
shortLabel := assignShortLabel(windowName)
|
|
|
|
for x := dimensions.X0; x <= dimensions.X1; x++ {
|
|
screen[y][x] = shortLabel
|
|
}
|
|
} else {
|
|
screen[y][dimensions.X0] = "<"
|
|
screen[y][dimensions.X1] = ">"
|
|
for x := dimensions.X0 + 1; x < dimensions.X1; x++ {
|
|
screen[y][x] = "─"
|
|
}
|
|
|
|
// Now add the label
|
|
label := windowName
|
|
// If we can't fit the label we'll use a one-character short label
|
|
if len(label) > dimensions.X1-dimensions.X0-1 {
|
|
label = assignShortLabel(windowName)
|
|
}
|
|
for i, char := range label {
|
|
screen[y][dimensions.X0+1+i] = string(char)
|
|
}
|
|
}
|
|
} else {
|
|
// Draw box border
|
|
for y := dimensions.Y0; y <= dimensions.Y1; y++ {
|
|
for x := dimensions.X0; x <= dimensions.X1; x++ {
|
|
if x == dimensions.X0 && y == dimensions.Y0 {
|
|
screen[y][x] = "╭"
|
|
} else if x == dimensions.X1 && y == dimensions.Y0 {
|
|
screen[y][x] = "╮"
|
|
} else if x == dimensions.X0 && y == dimensions.Y1 {
|
|
screen[y][x] = "╰"
|
|
} else if x == dimensions.X1 && y == dimensions.Y1 {
|
|
screen[y][x] = "╯"
|
|
} else if y == dimensions.Y0 || y == dimensions.Y1 {
|
|
screen[y][x] = "─"
|
|
} else if x == dimensions.X0 || x == dimensions.X1 {
|
|
screen[y][x] = "│"
|
|
} else {
|
|
screen[y][x] = " "
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add the label
|
|
label := windowName
|
|
// If we can't fit the label we'll use a one-character short label
|
|
if len(label) > dimensions.X1-dimensions.X0-1 {
|
|
label = assignShortLabel(windowName)
|
|
}
|
|
for i, char := range label {
|
|
screen[dimensions.Y0][dimensions.X0+1+i] = string(char)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw the screen
|
|
output := ""
|
|
for _, row := range screen {
|
|
for _, marker := range row {
|
|
output += marker
|
|
}
|
|
output += "\n"
|
|
}
|
|
|
|
// Add a legend
|
|
for _, windowName := range windowNames {
|
|
if !lo.Contains(lo.Keys(windowMarkers), windowName) {
|
|
continue
|
|
}
|
|
marker := windowMarkers[windowName]
|
|
output += fmt.Sprintf("%s: %s\n", marker, windowName)
|
|
}
|
|
|
|
output = strings.TrimSpace(output)
|
|
|
|
return output
|
|
}
|