Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions pkg/config/user_config_validation.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package config

import (
"errors"
"fmt"
"log"
"reflect"
"slices"
"strings"

"github.com/jesseduffield/lazygit/pkg/constants"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)

func (config *UserConfig) Validate() error {
Expand Down Expand Up @@ -49,6 +52,22 @@ func (config *UserConfig) Validate() error {
if err := validateCustomCommands(config.CustomCommands); err != nil {
return err
}
if err := validateSpinner(config.Gui.Spinner); err != nil {
return err
}
return nil
}

func validateSpinner(spinner SpinnerConfig) error {
if len(spinner.Frames) == 0 {
return errors.New("gui.spinner.frames must not be empty.")
}
firstWidth := utils.StringWidth(spinner.Frames[0])
if lo.SomeBy(spinner.Frames, func(frame string) bool {
return utils.StringWidth(frame) != firstWidth
}) {
return errors.New("All gui.spinner.frames entries must have the same width.")
}
return nil
}

Expand Down
30 changes: 30 additions & 0 deletions pkg/config/user_config_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,33 @@ func TestUserConfigValidate_enums(t *testing.T) {
})
}
}

func TestUserConfigValidate_spinnerFrames(t *testing.T) {
scenarios := []struct {
name string
frames []string
valid bool
}{
{name: "empty", frames: []string{}, valid: false},
{name: "single frame", frames: []string{"|"}, valid: true},
{name: "all same width", frames: []string{"|", "/", "-", "\\"}, valid: true},
{name: "all same width, multi-char", frames: []string{". ", ".. ", "..."}, valid: true},
{name: "all same width, wide runes", frames: []string{"⠋", "⠙", "⠹"}, valid: true},
{name: "differing widths", frames: []string{"|", "//"}, valid: false},
{name: "first differs from rest", frames: []string{"||", "/", "-"}, valid: false},
}

for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
config := GetDefaultConfig()
config.Gui.Spinner.Frames = s.frames
err := config.Validate()

if s.valid {
assert.NoError(t, err)
} else {
assert.Error(t, err)
}
})
}
}
202 changes: 202 additions & 0 deletions pkg/gocui/flush_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package gocui

import (
"testing"

"github.com/stretchr/testify/assert"
)

func newTestGui(t *testing.T) *Gui {
t.Helper()
g, err := NewGui(NewGuiOpts{
OutputMode: OutputNormal,
Headless: true,
Width: 80,
Height: 24,
})
assert.NoError(t, err)
t.Cleanup(func() { g.Close() })
return g
}

// setupViews creates a few views and does an initial full flush so all views
// start in a clean (non-tainted) state.
func setupViews(t *testing.T, g *Gui) (*View, *View) {
t.Helper()

status, _ := g.SetView("status", 0, 22, 40, 24, 0)
status.Frame = false
main, _ := g.SetView("main", 0, 0, 80, 22, 0)

// Initial content
status.SetContent("Ready")
main.SetContent("hello world")

// Full flush to draw everything and clear tainted flags
assert.NoError(t, g.flush())

return status, main
}

// pushContentOnly pushes a content-only event directly to the channel
// (synchronous, deterministic — unlike Update which spawns a goroutine).
func pushContentOnly(g *Gui, f func(*Gui) error) {
g.userEvents <- userEvent{f: f, task: g.NewTask(), contentOnly: true}
}

// pushRegular pushes a regular event directly to the channel.
func pushRegular(g *Gui, f func(*Gui) error) {
g.userEvents <- userEvent{f: f, task: g.NewTask(), contentOnly: false}
}

func TestFlushContentOnly_SkipsUntaintedViews(t *testing.T) {
g := newTestGui(t)
status, main := setupViews(t, g)

// After initial flush, both views should be untainted
assert.False(t, status.IsTainted(), "status view should not be tainted after flush")
assert.False(t, main.IsTainted(), "main view should not be tainted after flush")

// Modify only the status view
status.SetContent("Fetching /")

assert.True(t, status.IsTainted(), "status view should be tainted after SetContent")
assert.False(t, main.IsTainted(), "main view should not be tainted (was not modified)")

// flushContentOnly should succeed and clear status tainted flag
assert.NoError(t, g.flushContentOnly(g.views))

assert.False(t, status.IsTainted(), "status view should not be tainted after flushContentOnly")
assert.False(t, main.IsTainted(), "main view should not be tainted after flushContentOnly")
}

func TestFlushContentOnly_WritesCorrectContent(t *testing.T) {
g := newTestGui(t)
status, _ := setupViews(t, g)

status.SetContent("Fetching |")
assert.NoError(t, g.flushContentOnly(g.views))

assert.Equal(t, "Fetching |", status.Buffer())
}

func TestProcessEvent_ContentOnlyEvent_SkipsTaintedCheck(t *testing.T) {
g := newTestGui(t)
status, main := setupViews(t, g)

// Send a content-only event that modifies only the status view
pushContentOnly(g, func(gui *Gui) error {
status.SetContent("Fetching /")
return nil
})

assert.NoError(t, g.processEvent())

// status was modified and drawn → tainted cleared
assert.False(t, status.IsTainted(), "status should not be tainted after processEvent with contentOnly")
// main was NOT modified → should still be untainted
assert.False(t, main.IsTainted(), "main should not be tainted after processEvent with contentOnly")
}

func TestProcessEvent_RegularEvent_UsesFullFlush(t *testing.T) {
g := newTestGui(t)
status, _ := setupViews(t, g)

// Regular event (not content-only) should trigger full flush
pushRegular(g, func(gui *Gui) error {
status.SetContent("Fetching \\")
return nil
})

assert.NoError(t, g.processEvent())

assert.False(t, status.IsTainted(), "status should not be tainted after full flush")
}

func TestProcessEvent_MixedBatch_UsesFullFlush(t *testing.T) {
g := newTestGui(t)
status, main := setupViews(t, g)

// Queue a content-only event followed by a regular event.
// processEvent picks up the first; processRemainingEvents picks up
// the second. Since the second is not contentOnly, full flush runs.
pushContentOnly(g, func(gui *Gui) error {
status.SetContent("Fetching -")
return nil
})
pushRegular(g, func(gui *Gui) error {
main.SetContent("updated main")
return nil
})

assert.NoError(t, g.processEvent())

// Both views were modified and should have been drawn by full flush
assert.False(t, status.IsTainted(), "status should not be tainted after full flush")
assert.False(t, main.IsTainted(), "main should not be tainted after full flush")
}

func TestProcessEvent_RegularThenContentOnly_UsesFullFlush(t *testing.T) {
g := newTestGui(t)
status, main := setupViews(t, g)

// Even if a regular event comes first and the remaining are contentOnly,
// the batch must use full flush.
pushRegular(g, func(gui *Gui) error {
main.SetContent("new main content")
return nil
})
pushContentOnly(g, func(gui *Gui) error {
status.SetContent("Fetching |")
return nil
})

assert.NoError(t, g.processEvent())

assert.False(t, status.IsTainted(), "status should not be tainted after full flush")
assert.False(t, main.IsTainted(), "main should not be tainted after full flush")
}

func TestProcessRemainingEvents_AllContentOnly_ReturnsTrue(t *testing.T) {
g := newTestGui(t)
status, _ := setupViews(t, g)

pushContentOnly(g, func(gui *Gui) error {
status.SetContent("a")
return nil
})
pushContentOnly(g, func(gui *Gui) error {
status.SetContent("b")
return nil
})

contentOnly, err := g.processRemainingEvents()
assert.NoError(t, err)
assert.True(t, contentOnly, "should return true when all events are contentOnly")
}

func TestProcessRemainingEvents_MixedEvents_ReturnsFalse(t *testing.T) {
g := newTestGui(t)
status, _ := setupViews(t, g)

pushContentOnly(g, func(gui *Gui) error {
status.SetContent("a")
return nil
})
pushRegular(g, func(gui *Gui) error {
status.SetContent("b")
return nil
})

contentOnly, err := g.processRemainingEvents()
assert.NoError(t, err)
assert.False(t, contentOnly, "should return false when any event is not contentOnly")
}

func TestProcessRemainingEvents_EmptyQueue_ReturnsTrue(t *testing.T) {
g := newTestGui(t)

contentOnly, err := g.processRemainingEvents()
assert.NoError(t, err)
assert.True(t, contentOnly, "should return true when no events are queued")
}
Loading
Loading