mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-01-04 03:48:07 +02:00
Section headers in keybindings menu (#2911)
This commit is contained in:
commit
1fb0e1e151
@ -24,7 +24,7 @@ func NewBranchesContext(c *ContextCommon) *BranchesContext {
|
||||
},
|
||||
)
|
||||
|
||||
getDisplayStrings := func(startIdx int, length int) [][]string {
|
||||
getDisplayStrings := func(_ int, _ int) [][]string {
|
||||
return presentation.GetBranchListDisplayStrings(
|
||||
viewModel.GetItems(),
|
||||
c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL,
|
||||
@ -45,9 +45,11 @@ func NewBranchesContext(c *ContextCommon) *BranchesContext {
|
||||
Kind: types.SIDE_CONTEXT,
|
||||
Focusable: true,
|
||||
})),
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
c: c,
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -28,7 +28,7 @@ func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
|
||||
c.UserConfig.Gui.ShowFileTree,
|
||||
)
|
||||
|
||||
getDisplayStrings := func(startIdx int, length int) [][]string {
|
||||
getDisplayStrings := func(_ int, _ int) [][]string {
|
||||
if viewModel.Len() == 0 {
|
||||
return [][]string{{style.FgRed.Sprint("(none)")}}
|
||||
}
|
||||
@ -54,9 +54,11 @@ func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
|
||||
Transient: true,
|
||||
}),
|
||||
),
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
c: c,
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -4,17 +4,13 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
type ListContextTrait struct {
|
||||
types.Context
|
||||
ListRenderer
|
||||
|
||||
c *ContextCommon
|
||||
list types.IList
|
||||
getDisplayStrings func(startIdx int, length int) [][]string
|
||||
// Alignment for each column. If nil, the default is left alignment
|
||||
getColumnAlignments func() []utils.Alignment
|
||||
c *ContextCommon
|
||||
// Some contexts, like the commit context, will highlight the path from the selected commit
|
||||
// to its parents, because it's ambiguous otherwise. For these, we need to refresh the viewport
|
||||
// so that we show the highlighted path.
|
||||
@ -26,10 +22,6 @@ type ListContextTrait struct {
|
||||
|
||||
func (self *ListContextTrait) IsListContext() {}
|
||||
|
||||
func (self *ListContextTrait) GetList() types.IList {
|
||||
return self.list
|
||||
}
|
||||
|
||||
func (self *ListContextTrait) FocusLine() {
|
||||
// Doing this at the end of the layout function because we need the view to be
|
||||
// resized before we focus the line, otherwise if we're in accordion mode
|
||||
@ -37,7 +29,8 @@ func (self *ListContextTrait) FocusLine() {
|
||||
self.c.AfterLayout(func() error {
|
||||
oldOrigin, _ := self.GetViewTrait().ViewPortYBounds()
|
||||
|
||||
self.GetViewTrait().FocusPoint(self.list.GetSelectedLineIdx())
|
||||
self.GetViewTrait().FocusPoint(
|
||||
self.ModelIndexToViewIndex(self.list.GetSelectedLineIdx()))
|
||||
|
||||
// If FocusPoint() caused the view to scroll (because the selected line
|
||||
// was out of view before), we need to rerender the view port again.
|
||||
@ -59,8 +52,7 @@ func (self *ListContextTrait) FocusLine() {
|
||||
|
||||
func (self *ListContextTrait) refreshViewport() {
|
||||
startIdx, length := self.GetViewTrait().ViewPortYBounds()
|
||||
displayStrings := self.getDisplayStrings(startIdx, length)
|
||||
content := utils.RenderDisplayStrings(displayStrings, nil)
|
||||
content := self.renderLines(startIdx, startIdx+length)
|
||||
self.GetViewTrait().SetViewPortContent(content)
|
||||
}
|
||||
|
||||
@ -93,14 +85,7 @@ func (self *ListContextTrait) HandleFocusLost(opts types.OnFocusLostOpts) error
|
||||
// OnFocus assumes that the content of the context has already been rendered to the view. OnRender is the function which actually renders the content to the view
|
||||
func (self *ListContextTrait) HandleRender() error {
|
||||
self.list.RefreshSelectedIdx()
|
||||
var columnAlignments []utils.Alignment
|
||||
if self.getColumnAlignments != nil {
|
||||
columnAlignments = self.getColumnAlignments()
|
||||
}
|
||||
content := utils.RenderDisplayStrings(
|
||||
self.getDisplayStrings(0, self.list.Len()),
|
||||
columnAlignments,
|
||||
)
|
||||
content := self.renderLines(-1, -1)
|
||||
self.GetViewTrait().SetContent(content)
|
||||
self.c.Render()
|
||||
self.setFooter()
|
||||
|
124
pkg/gui/context/list_renderer.go
Normal file
124
pkg/gui/context/list_renderer.go
Normal file
@ -0,0 +1,124 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/samber/lo"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type NonModelItem struct {
|
||||
// Where in the model this should be inserted
|
||||
Index int
|
||||
// Content to render
|
||||
Content string
|
||||
// The column from which to render the item
|
||||
Column int
|
||||
}
|
||||
|
||||
type ListRenderer struct {
|
||||
list types.IList
|
||||
// Function to get the display strings for each model item in the given
|
||||
// range. startIdx and endIdx are model indices. For each model item, return
|
||||
// an array of strings, one for each column; the list renderer will take
|
||||
// care of aligning the columns appropriately.
|
||||
getDisplayStrings func(startIdx int, endIdx int) [][]string
|
||||
// Alignment for each column. If nil, the default is left alignment
|
||||
getColumnAlignments func() []utils.Alignment
|
||||
// Function to insert non-model items (e.g. section headers). If nil, no
|
||||
// such items are inserted
|
||||
getNonModelItems func() []*NonModelItem
|
||||
|
||||
// The remaining fields are private and shouldn't be initialized by clients
|
||||
numNonModelItems int
|
||||
viewIndicesByModelIndex []int
|
||||
modelIndicesByViewIndex []int
|
||||
}
|
||||
|
||||
func (self *ListRenderer) GetList() types.IList {
|
||||
return self.list
|
||||
}
|
||||
|
||||
func (self *ListRenderer) ModelIndexToViewIndex(modelIndex int) int {
|
||||
modelIndex = lo.Clamp(modelIndex, 0, self.list.Len())
|
||||
if self.viewIndicesByModelIndex != nil {
|
||||
return self.viewIndicesByModelIndex[modelIndex]
|
||||
}
|
||||
|
||||
return modelIndex
|
||||
}
|
||||
|
||||
func (self *ListRenderer) ViewIndexToModelIndex(viewIndex int) int {
|
||||
viewIndex = utils.Clamp(viewIndex, 0, self.list.Len()+self.numNonModelItems)
|
||||
if self.modelIndicesByViewIndex != nil {
|
||||
return self.modelIndicesByViewIndex[viewIndex]
|
||||
}
|
||||
|
||||
return viewIndex
|
||||
}
|
||||
|
||||
// startIdx and endIdx are view indices, not model indices. If you want to
|
||||
// render the whole list, pass -1 for both.
|
||||
func (self *ListRenderer) renderLines(startIdx int, endIdx int) string {
|
||||
var columnAlignments []utils.Alignment
|
||||
if self.getColumnAlignments != nil {
|
||||
columnAlignments = self.getColumnAlignments()
|
||||
}
|
||||
nonModelItems := []*NonModelItem{}
|
||||
self.numNonModelItems = 0
|
||||
if self.getNonModelItems != nil {
|
||||
nonModelItems = self.getNonModelItems()
|
||||
self.prepareConversionArrays(nonModelItems)
|
||||
}
|
||||
startModelIdx := 0
|
||||
if startIdx == -1 {
|
||||
startIdx = 0
|
||||
} else {
|
||||
startModelIdx = self.ViewIndexToModelIndex(startIdx)
|
||||
}
|
||||
endModelIdx := self.list.Len()
|
||||
if endIdx == -1 {
|
||||
endIdx = endModelIdx + len(nonModelItems)
|
||||
} else {
|
||||
endModelIdx = self.ViewIndexToModelIndex(endIdx)
|
||||
}
|
||||
lines, columnPositions := utils.RenderDisplayStrings(
|
||||
self.getDisplayStrings(startModelIdx, endModelIdx),
|
||||
columnAlignments)
|
||||
lines = self.insertNonModelItems(nonModelItems, endIdx, startIdx, lines, columnPositions)
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (self *ListRenderer) prepareConversionArrays(nonModelItems []*NonModelItem) {
|
||||
self.numNonModelItems = len(nonModelItems)
|
||||
self.viewIndicesByModelIndex = lo.Range(self.list.Len() + 1)
|
||||
self.modelIndicesByViewIndex = lo.Range(self.list.Len() + 1)
|
||||
offset := 0
|
||||
for _, item := range nonModelItems {
|
||||
for i := item.Index; i <= self.list.Len(); i++ {
|
||||
self.viewIndicesByModelIndex[i]++
|
||||
}
|
||||
self.modelIndicesByViewIndex = slices.Insert(
|
||||
self.modelIndicesByViewIndex, item.Index+offset, self.modelIndicesByViewIndex[item.Index+offset])
|
||||
offset++
|
||||
}
|
||||
}
|
||||
|
||||
func (self *ListRenderer) insertNonModelItems(
|
||||
nonModelItems []*NonModelItem, endIdx int, startIdx int, lines []string, columnPositions []int,
|
||||
) []string {
|
||||
offset := 0
|
||||
for _, item := range nonModelItems {
|
||||
if item.Index+offset >= endIdx {
|
||||
break
|
||||
}
|
||||
if item.Index+offset >= startIdx {
|
||||
padding := strings.Repeat(" ", columnPositions[item.Column])
|
||||
lines = slices.Insert(lines, item.Index+offset-startIdx, padding+item.Content)
|
||||
}
|
||||
offset++
|
||||
}
|
||||
return lines
|
||||
}
|
256
pkg/gui/context/list_renderer_test.go
Normal file
256
pkg/gui/context/list_renderer_test.go
Normal file
@ -0,0 +1,256 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestListRenderer_renderLines(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
modelStrings []string
|
||||
nonModelIndices []int
|
||||
startIdx int
|
||||
endIdx int
|
||||
expectedOutput string
|
||||
}{
|
||||
{
|
||||
name: "Render whole list",
|
||||
modelStrings: []string{"a", "b", "c"},
|
||||
startIdx: 0,
|
||||
endIdx: 3,
|
||||
expectedOutput: `
|
||||
a
|
||||
b
|
||||
c`,
|
||||
},
|
||||
{
|
||||
name: "Partial list, beginning",
|
||||
modelStrings: []string{"a", "b", "c"},
|
||||
startIdx: 0,
|
||||
endIdx: 2,
|
||||
expectedOutput: `
|
||||
a
|
||||
b`,
|
||||
},
|
||||
{
|
||||
name: "Partial list, end",
|
||||
modelStrings: []string{"a", "b", "c"},
|
||||
startIdx: 1,
|
||||
endIdx: 3,
|
||||
expectedOutput: `
|
||||
b
|
||||
c`,
|
||||
},
|
||||
{
|
||||
name: "Pass an endIdx greater than the model length",
|
||||
modelStrings: []string{"a", "b", "c"},
|
||||
startIdx: 2,
|
||||
endIdx: 5,
|
||||
expectedOutput: `
|
||||
c`,
|
||||
},
|
||||
{
|
||||
name: "Whole list with section headers",
|
||||
modelStrings: []string{"a", "b", "c"},
|
||||
nonModelIndices: []int{1, 3},
|
||||
startIdx: 0,
|
||||
endIdx: 5,
|
||||
expectedOutput: `
|
||||
a
|
||||
--- 1 (0) ---
|
||||
b
|
||||
c
|
||||
--- 3 (1) ---`,
|
||||
},
|
||||
{
|
||||
name: "Multiple consecutive headers",
|
||||
modelStrings: []string{"a", "b", "c"},
|
||||
nonModelIndices: []int{0, 0, 2, 2, 2},
|
||||
startIdx: 0,
|
||||
endIdx: 8,
|
||||
expectedOutput: `
|
||||
--- 0 (0) ---
|
||||
--- 0 (1) ---
|
||||
a
|
||||
b
|
||||
--- 2 (2) ---
|
||||
--- 2 (3) ---
|
||||
--- 2 (4) ---
|
||||
c`,
|
||||
},
|
||||
{
|
||||
name: "Partial list with headers, beginning",
|
||||
modelStrings: []string{"a", "b", "c"},
|
||||
nonModelIndices: []int{1, 3},
|
||||
startIdx: 0,
|
||||
endIdx: 3,
|
||||
expectedOutput: `
|
||||
a
|
||||
--- 1 (0) ---
|
||||
b`,
|
||||
},
|
||||
{
|
||||
name: "Partial list with headers, end (beyond end index)",
|
||||
modelStrings: []string{"a", "b", "c"},
|
||||
nonModelIndices: []int{1, 3},
|
||||
startIdx: 2,
|
||||
endIdx: 7,
|
||||
expectedOutput: `
|
||||
b
|
||||
c
|
||||
--- 3 (1) ---`,
|
||||
},
|
||||
}
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
viewModel := NewListViewModel[string](func() []string { return s.modelStrings })
|
||||
var getNonModelItems func() []*NonModelItem
|
||||
if s.nonModelIndices != nil {
|
||||
getNonModelItems = func() []*NonModelItem {
|
||||
return lo.Map(s.nonModelIndices, func(modelIndex int, nonModelIndex int) *NonModelItem {
|
||||
return &NonModelItem{
|
||||
Index: modelIndex,
|
||||
Content: fmt.Sprintf("--- %d (%d) ---", modelIndex, nonModelIndex),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
self := &ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: func(startIdx int, endIdx int) [][]string {
|
||||
return lo.Map(s.modelStrings[startIdx:endIdx],
|
||||
func(s string, _ int) []string { return []string{s} })
|
||||
},
|
||||
getNonModelItems: getNonModelItems,
|
||||
}
|
||||
|
||||
expectedOutput := strings.Join(lo.Map(
|
||||
strings.Split(strings.TrimPrefix(s.expectedOutput, "\n"), "\n"),
|
||||
func(line string, _ int) string { return strings.TrimSpace(line) }), "\n")
|
||||
|
||||
assert.Equal(t, expectedOutput, self.renderLines(s.startIdx, s.endIdx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
numModelItems int
|
||||
nonModelIndices []int
|
||||
|
||||
modelIndices []int
|
||||
expectedViewIndices []int
|
||||
|
||||
viewIndices []int
|
||||
expectedModelIndices []int
|
||||
}{
|
||||
{
|
||||
name: "no headers (no getNonModelItems provided)",
|
||||
numModelItems: 3,
|
||||
nonModelIndices: nil, // no get
|
||||
|
||||
modelIndices: []int{-1, 0, 1, 2, 3, 4},
|
||||
expectedViewIndices: []int{0, 0, 1, 2, 3, 3},
|
||||
|
||||
viewIndices: []int{-1, 0, 1, 2, 3, 4},
|
||||
expectedModelIndices: []int{0, 0, 1, 2, 3, 3},
|
||||
},
|
||||
{
|
||||
name: "no headers (getNonModelItems returns zero items)",
|
||||
numModelItems: 3,
|
||||
nonModelIndices: []int{},
|
||||
|
||||
modelIndices: []int{-1, 0, 1, 2, 3, 4},
|
||||
expectedViewIndices: []int{0, 0, 1, 2, 3, 3},
|
||||
|
||||
viewIndices: []int{-1, 0, 1, 2, 3, 4},
|
||||
expectedModelIndices: []int{0, 0, 1, 2, 3, 3},
|
||||
},
|
||||
{
|
||||
name: "basic",
|
||||
numModelItems: 3,
|
||||
nonModelIndices: []int{1, 2},
|
||||
|
||||
/*
|
||||
0: model 0
|
||||
1: --- header 0 ---
|
||||
2: model 1
|
||||
3: --- header 1 ---
|
||||
4: model 2
|
||||
*/
|
||||
|
||||
modelIndices: []int{-1, 0, 1, 2, 3, 4},
|
||||
expectedViewIndices: []int{0, 0, 2, 4, 5, 5},
|
||||
|
||||
viewIndices: []int{-1, 0, 1, 2, 3, 4, 5, 6},
|
||||
expectedModelIndices: []int{0, 0, 1, 1, 2, 2, 3, 3},
|
||||
},
|
||||
{
|
||||
name: "consecutive section headers",
|
||||
numModelItems: 3,
|
||||
nonModelIndices: []int{0, 0, 2, 2, 2, 3, 3},
|
||||
|
||||
/*
|
||||
0: --- header 0 ---
|
||||
1: --- header 1 ---
|
||||
2: model 0
|
||||
3: model 1
|
||||
4: --- header 2 ---
|
||||
5: --- header 3 ---
|
||||
6: --- header 4 ---
|
||||
7: model 2
|
||||
8: --- header 5 ---
|
||||
9: --- header 6 ---
|
||||
*/
|
||||
modelIndices: []int{-1, 0, 1, 2, 3, 4},
|
||||
expectedViewIndices: []int{2, 2, 3, 7, 10, 10},
|
||||
|
||||
viewIndices: []int{-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11},
|
||||
expectedModelIndices: []int{0, 0, 0, 0, 1, 2, 2, 2, 2, 3, 3, 3, 3},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
// Expect lists of equal length for each test:
|
||||
assert.Equal(t, len(s.modelIndices), len(s.expectedViewIndices))
|
||||
assert.Equal(t, len(s.viewIndices), len(s.expectedModelIndices))
|
||||
|
||||
modelInts := lo.Range(s.numModelItems)
|
||||
viewModel := NewListViewModel[int](func() []int { return modelInts })
|
||||
var getNonModelItems func() []*NonModelItem
|
||||
if s.nonModelIndices != nil {
|
||||
getNonModelItems = func() []*NonModelItem {
|
||||
return lo.Map(s.nonModelIndices, func(modelIndex int, _ int) *NonModelItem {
|
||||
return &NonModelItem{Index: modelIndex, Content: ""}
|
||||
})
|
||||
}
|
||||
}
|
||||
self := &ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: func(startIdx int, endIdx int) [][]string {
|
||||
return lo.Map(modelInts[startIdx:endIdx],
|
||||
func(i int, _ int) []string { return []string{fmt.Sprint(i)} })
|
||||
},
|
||||
getNonModelItems: getNonModelItems,
|
||||
}
|
||||
|
||||
// Need to render first so that it knows the non-model items
|
||||
self.renderLines(-1, -1)
|
||||
|
||||
for i := 0; i < len(s.modelIndices); i++ {
|
||||
assert.Equal(t, s.expectedViewIndices[i], self.ModelIndexToViewIndex(s.modelIndices[i]))
|
||||
}
|
||||
|
||||
for i := 0; i < len(s.viewIndices); i++ {
|
||||
assert.Equal(t, s.expectedModelIndices[i], self.ViewIndexToModelIndex(s.viewIndices[i]))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -27,7 +27,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
c,
|
||||
)
|
||||
|
||||
getDisplayStrings := func(startIdx int, length int) [][]string {
|
||||
getDisplayStrings := func(startIdx int, endIdx int) [][]string {
|
||||
selectedCommitSha := ""
|
||||
|
||||
if c.CurrentContext().GetKey() == LOCAL_COMMITS_CONTEXT_KEY {
|
||||
@ -56,7 +56,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
c.UserConfig.Git.ParseEmoji,
|
||||
selectedCommitSha,
|
||||
startIdx,
|
||||
length,
|
||||
endIdx,
|
||||
shouldShowGraph(c),
|
||||
c.Model().BisectInfo,
|
||||
showYouAreHereLabel,
|
||||
@ -74,8 +74,10 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
Kind: types.SIDE_CONTEXT,
|
||||
Focusable: true,
|
||||
})),
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
},
|
||||
c: c,
|
||||
refreshViewportOnChange: true,
|
||||
},
|
||||
|
@ -34,10 +34,13 @@ func NewMenuContext(
|
||||
Focusable: true,
|
||||
HasUncontrolledBounds: true,
|
||||
})),
|
||||
getDisplayStrings: viewModel.GetDisplayStrings,
|
||||
list: viewModel,
|
||||
c: c,
|
||||
getColumnAlignments: func() []utils.Alignment { return viewModel.columnAlignment },
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: viewModel.GetDisplayStrings,
|
||||
getColumnAlignments: func() []utils.Alignment { return viewModel.columnAlignment },
|
||||
getNonModelItems: viewModel.GetNonModelItems,
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -79,7 +82,7 @@ func (self *MenuViewModel) SetMenuItems(items []*types.MenuItem, columnAlignment
|
||||
}
|
||||
|
||||
// TODO: move into presentation package
|
||||
func (self *MenuViewModel) GetDisplayStrings(_startIdx int, _length int) [][]string {
|
||||
func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string {
|
||||
menuItems := self.FilteredListViewModel.GetItems()
|
||||
showKeys := lo.SomeBy(menuItems, func(item *types.MenuItem) bool {
|
||||
return item.Key != nil
|
||||
@ -111,6 +114,40 @@ func (self *MenuViewModel) GetDisplayStrings(_startIdx int, _length int) [][]str
|
||||
})
|
||||
}
|
||||
|
||||
func (self *MenuViewModel) GetNonModelItems() []*NonModelItem {
|
||||
// Don't display section headers when we are filtering. The reason is that
|
||||
// filtering changes the order of the items (they are sorted by best match),
|
||||
// so all the sections would be messed up.
|
||||
if self.FilteredListViewModel.IsFiltering() {
|
||||
return []*NonModelItem{}
|
||||
}
|
||||
|
||||
result := []*NonModelItem{}
|
||||
menuItems := self.FilteredListViewModel.GetItems()
|
||||
var prevSection *types.MenuSection = nil
|
||||
for i, menuItem := range menuItems {
|
||||
menuItem := menuItem
|
||||
if menuItem.Section != nil && menuItem.Section != prevSection {
|
||||
if prevSection != nil {
|
||||
result = append(result, &NonModelItem{
|
||||
Index: i,
|
||||
Column: 1,
|
||||
Content: "",
|
||||
})
|
||||
}
|
||||
|
||||
result = append(result, &NonModelItem{
|
||||
Index: i,
|
||||
Column: 1,
|
||||
Content: style.FgGreen.SetBold().Sprintf("--- %s ---", menuItem.Section.Title),
|
||||
})
|
||||
prevSection = menuItem.Section
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
basicBindings := self.ListContextTrait.GetKeybindings(opts)
|
||||
menuItemsWithKeys := lo.Filter(self.menuItems, func(item *types.MenuItem, _ int) bool {
|
||||
|
@ -26,7 +26,7 @@ func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
|
||||
},
|
||||
)
|
||||
|
||||
getDisplayStrings := func(startIdx int, length int) [][]string {
|
||||
getDisplayStrings := func(_ int, _ int) [][]string {
|
||||
return presentation.GetReflogCommitListDisplayStrings(
|
||||
viewModel.GetItems(),
|
||||
c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL,
|
||||
@ -49,9 +49,11 @@ func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
|
||||
Kind: types.SIDE_CONTEXT,
|
||||
Focusable: true,
|
||||
})),
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
c: c,
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -27,7 +27,7 @@ func NewRemoteBranchesContext(
|
||||
},
|
||||
)
|
||||
|
||||
getDisplayStrings := func(startIdx int, length int) [][]string {
|
||||
getDisplayStrings := func(_ int, _ int) [][]string {
|
||||
return presentation.GetRemoteBranchListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref)
|
||||
}
|
||||
|
||||
@ -43,9 +43,11 @@ func NewRemoteBranchesContext(
|
||||
Focusable: true,
|
||||
Transient: true,
|
||||
})),
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
c: c,
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ func NewRemotesContext(c *ContextCommon) *RemotesContext {
|
||||
},
|
||||
)
|
||||
|
||||
getDisplayStrings := func(startIdx int, length int) [][]string {
|
||||
getDisplayStrings := func(_ int, _ int) [][]string {
|
||||
return presentation.GetRemoteListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref)
|
||||
}
|
||||
|
||||
@ -38,9 +38,11 @@ func NewRemotesContext(c *ContextCommon) *RemotesContext {
|
||||
Kind: types.SIDE_CONTEXT,
|
||||
Focusable: true,
|
||||
})),
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
c: c,
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ func NewStashContext(
|
||||
},
|
||||
)
|
||||
|
||||
getDisplayStrings := func(startIdx int, length int) [][]string {
|
||||
getDisplayStrings := func(_ int, _ int) [][]string {
|
||||
return presentation.GetStashEntryListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref)
|
||||
}
|
||||
|
||||
@ -40,9 +40,11 @@ func NewStashContext(
|
||||
Kind: types.SIDE_CONTEXT,
|
||||
Focusable: true,
|
||||
})),
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
c: c,
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ func NewSubCommitsContext(
|
||||
limitCommits: true,
|
||||
}
|
||||
|
||||
getDisplayStrings := func(startIdx int, length int) [][]string {
|
||||
getDisplayStrings := func(startIdx int, endIdx int) [][]string {
|
||||
// This can happen if a sub-commits view is asked to be rerendered while
|
||||
// it is invisble; for example when switching screen modes, which
|
||||
// rerenders all views.
|
||||
@ -72,7 +72,7 @@ func NewSubCommitsContext(
|
||||
c.UserConfig.Git.ParseEmoji,
|
||||
selectedCommitSha,
|
||||
startIdx,
|
||||
length,
|
||||
endIdx,
|
||||
shouldShowGraph(c),
|
||||
git_commands.NewNullBisectInfo(),
|
||||
false,
|
||||
@ -93,8 +93,10 @@ func NewSubCommitsContext(
|
||||
Focusable: true,
|
||||
Transient: true,
|
||||
})),
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
},
|
||||
c: c,
|
||||
refreshViewportOnChange: true,
|
||||
},
|
||||
|
@ -21,7 +21,7 @@ func NewSubmodulesContext(c *ContextCommon) *SubmodulesContext {
|
||||
},
|
||||
)
|
||||
|
||||
getDisplayStrings := func(startIdx int, length int) [][]string {
|
||||
getDisplayStrings := func(_ int, _ int) [][]string {
|
||||
return presentation.GetSubmoduleListDisplayStrings(viewModel.GetItems())
|
||||
}
|
||||
|
||||
@ -35,9 +35,11 @@ func NewSubmodulesContext(c *ContextCommon) *SubmodulesContext {
|
||||
Kind: types.SIDE_CONTEXT,
|
||||
Focusable: true,
|
||||
})),
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
c: c,
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ func NewSuggestionsContext(
|
||||
return state.Suggestions
|
||||
}
|
||||
|
||||
getDisplayStrings := func(startIdx int, length int) [][]string {
|
||||
getDisplayStrings := func(_ int, _ int) [][]string {
|
||||
return presentation.GetSuggestionListDisplayStrings(state.Suggestions)
|
||||
}
|
||||
|
||||
@ -54,9 +54,11 @@ func NewSuggestionsContext(
|
||||
Focusable: true,
|
||||
HasUncontrolledBounds: true,
|
||||
})),
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
c: c,
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ func NewTagsContext(
|
||||
},
|
||||
)
|
||||
|
||||
getDisplayStrings := func(startIdx int, length int) [][]string {
|
||||
getDisplayStrings := func(_ int, _ int) [][]string {
|
||||
return presentation.GetTagListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref)
|
||||
}
|
||||
|
||||
@ -40,9 +40,11 @@ func NewTagsContext(
|
||||
Kind: types.SIDE_CONTEXT,
|
||||
Focusable: true,
|
||||
})),
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
c: c,
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
|
||||
c.UserConfig.Gui.ShowFileTree,
|
||||
)
|
||||
|
||||
getDisplayStrings := func(startIdx int, length int) [][]string {
|
||||
getDisplayStrings := func(_ int, _ int) [][]string {
|
||||
lines := presentation.RenderFileTree(viewModel, c.Modes().Diffing.Ref, c.Model().Submodules)
|
||||
return lo.Map(lines, func(line string, _ int) []string {
|
||||
return []string{line}
|
||||
@ -41,9 +41,11 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
|
||||
Kind: types.SIDE_CONTEXT,
|
||||
Focusable: true,
|
||||
})),
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
c: c,
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
|
||||
|
@ -21,7 +21,7 @@ func NewWorktreesContext(c *ContextCommon) *WorktreesContext {
|
||||
},
|
||||
)
|
||||
|
||||
getDisplayStrings := func(startIdx int, length int) [][]string {
|
||||
getDisplayStrings := func(_ int, _ int) [][]string {
|
||||
return presentation.GetWorktreeDisplayStrings(
|
||||
c.Tr,
|
||||
viewModel.GetFilteredList(),
|
||||
@ -38,9 +38,11 @@ func NewWorktreesContext(c *ContextCommon) *WorktreesContext {
|
||||
Kind: types.SIDE_CONTEXT,
|
||||
Focusable: true,
|
||||
})),
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
c: c,
|
||||
ListRenderer: ListRenderer{
|
||||
list: viewModel,
|
||||
getDisplayStrings: getDisplayStrings,
|
||||
},
|
||||
c: c,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -83,9 +83,11 @@ func (self *ListController) handleLineChange(change int) error {
|
||||
// we're not constantly re-rendering the main view.
|
||||
if before != after {
|
||||
if change == -1 {
|
||||
checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig, before, after)
|
||||
checkScrollUp(self.context.GetViewTrait(), self.c.UserConfig,
|
||||
self.context.ModelIndexToViewIndex(before), self.context.ModelIndexToViewIndex(after))
|
||||
} else if change == 1 {
|
||||
checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig, before, after)
|
||||
checkScrollDown(self.context.GetViewTrait(), self.c.UserConfig,
|
||||
self.context.ModelIndexToViewIndex(before), self.context.ModelIndexToViewIndex(after))
|
||||
}
|
||||
|
||||
return self.context.HandleFocus(types.OnFocusOpts{})
|
||||
@ -112,7 +114,7 @@ func (self *ListController) HandleGotoBottom() error {
|
||||
|
||||
func (self *ListController) HandleClick(opts gocui.ViewMouseBindingOpts) error {
|
||||
prevSelectedLineIdx := self.context.GetList().GetSelectedLineIdx()
|
||||
newSelectedLineIdx := opts.Y
|
||||
newSelectedLineIdx := self.context.ViewIndexToModelIndex(opts.Y)
|
||||
alreadyFocused := self.isFocused()
|
||||
|
||||
if err := self.pushContextIfNotFocused(); err != nil {
|
||||
|
@ -18,23 +18,33 @@ func (self *OptionsMenuAction) Call() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
bindings := self.getBindings(ctx)
|
||||
local, global, navigation := self.getBindings(ctx)
|
||||
|
||||
menuItems := lo.Map(bindings, func(binding *types.Binding, _ int) *types.MenuItem {
|
||||
return &types.MenuItem{
|
||||
OpensMenu: binding.OpensMenu,
|
||||
Label: binding.Description,
|
||||
OnPress: func() error {
|
||||
if binding.Handler == nil {
|
||||
return nil
|
||||
menuItems := []*types.MenuItem{}
|
||||
|
||||
appendBindings := func(bindings []*types.Binding, section *types.MenuSection) {
|
||||
menuItems = append(menuItems,
|
||||
lo.Map(bindings, func(binding *types.Binding, _ int) *types.MenuItem {
|
||||
return &types.MenuItem{
|
||||
OpensMenu: binding.OpensMenu,
|
||||
Label: binding.Description,
|
||||
OnPress: func() error {
|
||||
if binding.Handler == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return binding.Handler()
|
||||
},
|
||||
Key: binding.Key,
|
||||
Tooltip: binding.Tooltip,
|
||||
Section: section,
|
||||
}
|
||||
})...)
|
||||
}
|
||||
|
||||
return binding.Handler()
|
||||
},
|
||||
Key: binding.Key,
|
||||
Tooltip: binding.Tooltip,
|
||||
}
|
||||
})
|
||||
appendBindings(local, &types.MenuSection{Title: self.c.Tr.KeybindingsMenuSectionLocal, Column: 1})
|
||||
appendBindings(global, &types.MenuSection{Title: self.c.Tr.KeybindingsMenuSectionGlobal, Column: 1})
|
||||
appendBindings(navigation, &types.MenuSection{Title: self.c.Tr.KeybindingsMenuSectionNavigation, Column: 1})
|
||||
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: self.c.Tr.Keybindings,
|
||||
@ -44,7 +54,8 @@ func (self *OptionsMenuAction) Call() error {
|
||||
})
|
||||
}
|
||||
|
||||
func (self *OptionsMenuAction) getBindings(context types.Context) []*types.Binding {
|
||||
// Returns three slices of bindings: local, global, and navigation
|
||||
func (self *OptionsMenuAction) getBindings(context types.Context) ([]*types.Binding, []*types.Binding, []*types.Binding) {
|
||||
var bindingsGlobal, bindingsPanel, bindingsNavigation []*types.Binding
|
||||
|
||||
bindings, _ := self.c.GetInitialKeybindingsWithCustomCommands()
|
||||
@ -61,14 +72,7 @@ func (self *OptionsMenuAction) getBindings(context types.Context) []*types.Bindi
|
||||
}
|
||||
}
|
||||
|
||||
resultBindings := []*types.Binding{}
|
||||
resultBindings = append(resultBindings, uniqueBindings(bindingsPanel)...)
|
||||
// adding a separator between the panel-specific bindings and the other bindings
|
||||
resultBindings = append(resultBindings, &types.Binding{})
|
||||
resultBindings = append(resultBindings, uniqueBindings(bindingsGlobal)...)
|
||||
resultBindings = append(resultBindings, uniqueBindings(bindingsNavigation)...)
|
||||
|
||||
return resultBindings
|
||||
return uniqueBindings(bindingsPanel), uniqueBindings(bindingsGlobal), uniqueBindings(bindingsNavigation)
|
||||
}
|
||||
|
||||
// We shouldn't really need to do this. We should define alternative keys for the same
|
||||
|
@ -1,7 +1,8 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/theme"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
@ -27,7 +28,7 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error {
|
||||
}
|
||||
|
||||
if item.OpensMenu {
|
||||
item.LabelColumns[0] = presentation.OpensMenuStyle(item.LabelColumns[0])
|
||||
item.LabelColumns[0] = fmt.Sprintf("%s...", item.LabelColumns[0])
|
||||
}
|
||||
|
||||
maxColumnSize = utils.Max(maxColumnSize, len(item.LabelColumns))
|
||||
|
@ -52,7 +52,7 @@ func GetCommitListDisplayStrings(
|
||||
parseEmoji bool,
|
||||
selectedCommitSha string,
|
||||
startIdx int,
|
||||
length int,
|
||||
endIdx int,
|
||||
showGraph bool,
|
||||
bisectInfo *git_commands.BisectInfo,
|
||||
showYouAreHereLabel bool,
|
||||
@ -68,11 +68,10 @@ func GetCommitListDisplayStrings(
|
||||
return nil
|
||||
}
|
||||
|
||||
end := utils.Min(startIdx+length, len(commits))
|
||||
// this is where my non-TODO commits begin
|
||||
rebaseOffset := utils.Min(indexOfFirstNonTODOCommit(commits), end)
|
||||
rebaseOffset := utils.Min(indexOfFirstNonTODOCommit(commits), endIdx)
|
||||
|
||||
filteredCommits := commits[startIdx:end]
|
||||
filteredCommits := commits[startIdx:endIdx]
|
||||
|
||||
bisectBounds := getbisectBounds(commits, bisectInfo)
|
||||
|
||||
@ -85,8 +84,8 @@ func GetCommitListDisplayStrings(
|
||||
|
||||
pipeSets := loadPipesets(commits[rebaseOffset:])
|
||||
pipeSetOffset := utils.Max(startIdx-rebaseOffset, 0)
|
||||
graphPipeSets := pipeSets[pipeSetOffset:utils.Max(end-rebaseOffset, 0)]
|
||||
graphCommits := commits[graphOffset:end]
|
||||
graphPipeSets := pipeSets[pipeSetOffset:utils.Max(endIdx-rebaseOffset, 0)]
|
||||
graphCommits := commits[graphOffset:endIdx]
|
||||
graphLines := graph.RenderAux(
|
||||
graphPipeSets,
|
||||
graphCommits,
|
||||
|
@ -41,7 +41,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
parseEmoji bool
|
||||
selectedCommitSha string
|
||||
startIdx int
|
||||
length int
|
||||
endIdx int
|
||||
showGraph bool
|
||||
bisectInfo *git_commands.BisectInfo
|
||||
showYouAreHereLabel bool
|
||||
@ -52,7 +52,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
testName: "no commits",
|
||||
commits: []*models.Commit{},
|
||||
startIdx: 0,
|
||||
length: 1,
|
||||
endIdx: 1,
|
||||
showGraph: false,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -66,7 +66,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
{Name: "commit2", Sha: "sha2"},
|
||||
},
|
||||
startIdx: 0,
|
||||
length: 2,
|
||||
endIdx: 2,
|
||||
showGraph: false,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -83,7 +83,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
{Name: "commit2", Sha: "sha2"},
|
||||
},
|
||||
startIdx: 0,
|
||||
length: 2,
|
||||
endIdx: 2,
|
||||
showGraph: false,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -110,7 +110,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
currentBranchName: "current-branch",
|
||||
hasUpdateRefConfig: true,
|
||||
startIdx: 0,
|
||||
length: 4,
|
||||
endIdx: 4,
|
||||
showGraph: false,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -135,7 +135,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
currentBranchName: "current-branch",
|
||||
hasUpdateRefConfig: true,
|
||||
startIdx: 0,
|
||||
length: 2,
|
||||
endIdx: 2,
|
||||
showGraph: false,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -158,7 +158,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
currentBranchName: "current-branch",
|
||||
hasUpdateRefConfig: false,
|
||||
startIdx: 0,
|
||||
length: 2,
|
||||
endIdx: 2,
|
||||
showGraph: false,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -179,7 +179,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
{Name: "some-branch", CommitHash: "sha2"},
|
||||
},
|
||||
startIdx: 0,
|
||||
length: 3,
|
||||
endIdx: 3,
|
||||
showGraph: false,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -200,7 +200,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 0,
|
||||
length: 5,
|
||||
endIdx: 5,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -223,7 +223,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 0,
|
||||
length: 5,
|
||||
endIdx: 5,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -247,7 +247,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 1,
|
||||
length: 10,
|
||||
endIdx: 5,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -270,7 +270,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 3,
|
||||
length: 2,
|
||||
endIdx: 5,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -291,7 +291,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 0,
|
||||
length: 2,
|
||||
endIdx: 2,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -312,7 +312,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 4,
|
||||
length: 2,
|
||||
endIdx: 5,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -332,7 +332,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
{Name: "commit5", Sha: "sha5", Parents: []string{"sha7"}},
|
||||
},
|
||||
startIdx: 0,
|
||||
length: 2,
|
||||
endIdx: 2,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -351,7 +351,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
{Name: "commit3", Sha: "sha3", Parents: []string{"sha4"}},
|
||||
},
|
||||
startIdx: 0,
|
||||
length: 5,
|
||||
endIdx: 3,
|
||||
showGraph: true,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -373,7 +373,7 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
timeFormat: "2006-01-02",
|
||||
shortTimeFormat: "3:04PM",
|
||||
startIdx: 0,
|
||||
length: 2,
|
||||
endIdx: 2,
|
||||
showGraph: false,
|
||||
bisectInfo: git_commands.NewNullBisectInfo(),
|
||||
cherryPickedCommitShaSet: set.New[string](),
|
||||
@ -416,13 +416,14 @@ func TestGetCommitListDisplayStrings(t *testing.T) {
|
||||
s.parseEmoji,
|
||||
s.selectedCommitSha,
|
||||
s.startIdx,
|
||||
s.length,
|
||||
s.endIdx,
|
||||
s.showGraph,
|
||||
s.bisectInfo,
|
||||
s.showYouAreHereLabel,
|
||||
)
|
||||
|
||||
renderedResult := utils.RenderDisplayStrings(result, nil)
|
||||
renderedLines, _ := utils.RenderDisplayStrings(result, nil)
|
||||
renderedResult := strings.Join(renderedLines, "\n")
|
||||
t.Logf("\n%s", renderedResult)
|
||||
|
||||
assert.EqualValues(t, s.expected, renderedResult)
|
||||
|
@ -1,7 +0,0 @@
|
||||
package presentation
|
||||
|
||||
import "github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
|
||||
func OpensMenuStyle(str string) string {
|
||||
return style.FgMagenta.Sprintf("%s...", str)
|
||||
}
|
@ -177,6 +177,11 @@ type PromptOpts struct {
|
||||
Mask bool
|
||||
}
|
||||
|
||||
type MenuSection struct {
|
||||
Title string
|
||||
Column int // The column that this section title should be aligned with
|
||||
}
|
||||
|
||||
type MenuItem struct {
|
||||
Label string
|
||||
|
||||
@ -194,6 +199,14 @@ type MenuItem struct {
|
||||
|
||||
// The tooltip will be displayed upon highlighting the menu item
|
||||
Tooltip string
|
||||
|
||||
// Can be used to group menu items into sections with headers. MenuItems
|
||||
// with the same Section should be contiguous, and will automatically get a
|
||||
// section header. If nil, the item is not part of a section.
|
||||
// Note that pointer comparison is used to determine whether two menu items
|
||||
// belong to the same section, so make sure all your items in a given
|
||||
// section point to the same MenuSection instance.
|
||||
Section *MenuSection
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
|
@ -124,6 +124,8 @@ type IListContext interface {
|
||||
GetSelectedItemId() string
|
||||
|
||||
GetList() IList
|
||||
ViewIndexToModelIndex(int) int
|
||||
ModelIndexToViewIndex(int) int
|
||||
|
||||
FocusLine()
|
||||
IsListContext() // used for type switch
|
||||
|
@ -388,6 +388,9 @@ type TranslationSet struct {
|
||||
Panel string
|
||||
Keybindings string
|
||||
KeybindingsLegend string
|
||||
KeybindingsMenuSectionLocal string
|
||||
KeybindingsMenuSectionGlobal string
|
||||
KeybindingsMenuSectionNavigation string
|
||||
RenameBranch string
|
||||
SetUnsetUpstream string
|
||||
NewGitFlowBranchPrompt string
|
||||
@ -986,6 +989,9 @@ func EnglishTranslationSet() TranslationSet {
|
||||
ConflictsResolved: "All merge conflicts resolved. Continue?",
|
||||
Continue: "Continue",
|
||||
Keybindings: "Keybindings",
|
||||
KeybindingsMenuSectionLocal: "Local",
|
||||
KeybindingsMenuSectionGlobal: "Global",
|
||||
KeybindingsMenuSectionNavigation: "Navigation",
|
||||
RebasingTitle: "Rebase '{{.checkedOutBranch}}' onto '{{.ref}}'",
|
||||
RebasingFromBaseCommitTitle: "Rebase '{{.checkedOutBranch}}' from marked base onto '{{.ref}}'",
|
||||
SimpleRebase: "Simple rebase",
|
||||
|
@ -456,13 +456,13 @@ func (self *ViewDriver) NavigateToLine(matcher *TextMatcher) *ViewDriver {
|
||||
self.IsFocused()
|
||||
|
||||
view := self.getView()
|
||||
lines := view.BufferLines()
|
||||
|
||||
var matchIndex int
|
||||
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
matchIndex = -1
|
||||
var matches []string
|
||||
lines := view.BufferLines()
|
||||
// first we look for a duplicate on the current screen. We won't bother looking beyond that though.
|
||||
for i, line := range lines {
|
||||
ok, _ := matcher.test(line)
|
||||
@ -486,19 +486,38 @@ func (self *ViewDriver) NavigateToLine(matcher *TextMatcher) *ViewDriver {
|
||||
return self
|
||||
}
|
||||
if selectedLineIdx == matchIndex {
|
||||
self.SelectedLine(matcher)
|
||||
} else if selectedLineIdx < matchIndex {
|
||||
for i := selectedLineIdx; i < matchIndex; i++ {
|
||||
self.SelectNextItem()
|
||||
}
|
||||
self.SelectedLine(matcher)
|
||||
} else {
|
||||
for i := selectedLineIdx; i > matchIndex; i-- {
|
||||
self.SelectPreviousItem()
|
||||
}
|
||||
self.SelectedLine(matcher)
|
||||
return self.SelectedLine(matcher)
|
||||
}
|
||||
|
||||
// At this point we can't just take the difference of selected and matched
|
||||
// index and press up or down arrow this many times. The reason is that
|
||||
// there might be section headers between those lines, and these will be
|
||||
// skipped when pressing up or down arrow. So we must keep pressing the
|
||||
// arrow key in a loop, and check after each one whether we now reached the
|
||||
// target line.
|
||||
var maxNumKeyPresses int
|
||||
var keyPress func()
|
||||
if selectedLineIdx < matchIndex {
|
||||
maxNumKeyPresses = matchIndex - selectedLineIdx
|
||||
keyPress = func() { self.SelectNextItem() }
|
||||
} else {
|
||||
maxNumKeyPresses = selectedLineIdx - matchIndex
|
||||
keyPress = func() { self.SelectPreviousItem() }
|
||||
}
|
||||
|
||||
for i := 0; i < maxNumKeyPresses; i++ {
|
||||
keyPress()
|
||||
idx, err := self.getSelectedLineIdx()
|
||||
if err != nil {
|
||||
self.t.fail(err.Error())
|
||||
return self
|
||||
}
|
||||
if ok, _ := matcher.test(lines[idx]); ok {
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
self.t.fail(fmt.Sprintf("Could not navigate to item matching: %s. Lines:\n%s", matcher.name(), strings.Join(lines, "\n")))
|
||||
return self
|
||||
}
|
||||
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/mattn/go-runewidth"
|
||||
"github.com/samber/lo"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
type Alignment int
|
||||
@ -36,10 +37,14 @@ func WithPadding(str string, padding int, alignment Alignment) string {
|
||||
|
||||
// defaults to left-aligning each column. If you want to set the alignment of
|
||||
// each column, pass in a slice of Alignment values.
|
||||
func RenderDisplayStrings(displayStringsArr [][]string, columnAlignments []Alignment) string {
|
||||
displayStringsArr = excludeBlankColumns(displayStringsArr)
|
||||
// returns a list of strings that should be joined with "\n", and an array of
|
||||
// the column positions
|
||||
func RenderDisplayStrings(displayStringsArr [][]string, columnAlignments []Alignment) ([]string, []int) {
|
||||
displayStringsArr, columnAlignments, removedColumns := excludeBlankColumns(displayStringsArr, columnAlignments)
|
||||
padWidths := getPadWidths(displayStringsArr)
|
||||
columnConfigs := make([]ColumnConfig, len(padWidths))
|
||||
columnPositions := make([]int, len(padWidths)+1)
|
||||
columnPositions[0] = 0
|
||||
for i, padWidth := range padWidths {
|
||||
// gracefully handle when columnAlignments is shorter than padWidths
|
||||
alignment := AlignLeft
|
||||
@ -51,16 +56,23 @@ func RenderDisplayStrings(displayStringsArr [][]string, columnAlignments []Align
|
||||
Width: padWidth,
|
||||
Alignment: alignment,
|
||||
}
|
||||
columnPositions[i+1] = columnPositions[i] + padWidth + 1
|
||||
}
|
||||
output := getPaddedDisplayStrings(displayStringsArr, columnConfigs)
|
||||
|
||||
return output
|
||||
// Add the removed columns back into columnPositions (a removed column gets
|
||||
// the same position as the following column); clients should be able to rely
|
||||
// on them all to be there
|
||||
for _, removedColumn := range removedColumns {
|
||||
if removedColumn < len(columnPositions) {
|
||||
columnPositions = slices.Insert(columnPositions, removedColumn, columnPositions[removedColumn])
|
||||
}
|
||||
}
|
||||
return getPaddedDisplayStrings(displayStringsArr, columnConfigs), columnPositions
|
||||
}
|
||||
|
||||
// NOTE: this mutates the input slice for the sake of performance
|
||||
func excludeBlankColumns(displayStringsArr [][]string) [][]string {
|
||||
func excludeBlankColumns(displayStringsArr [][]string, columnAlignments []Alignment) ([][]string, []Alignment, []int) {
|
||||
if len(displayStringsArr) == 0 {
|
||||
return displayStringsArr
|
||||
return displayStringsArr, columnAlignments, []int{}
|
||||
}
|
||||
|
||||
// if all rows share a blank column, we want to remove that column
|
||||
@ -76,26 +88,33 @@ outer:
|
||||
}
|
||||
|
||||
if len(toRemove) == 0 {
|
||||
return displayStringsArr
|
||||
return displayStringsArr, columnAlignments, []int{}
|
||||
}
|
||||
|
||||
// remove the columns
|
||||
for i, strings := range displayStringsArr {
|
||||
for j := len(toRemove) - 1; j >= 0; j-- {
|
||||
strings = append(strings[:toRemove[j]], strings[toRemove[j]+1:]...)
|
||||
strings = slices.Delete(strings, toRemove[j], toRemove[j]+1)
|
||||
}
|
||||
displayStringsArr[i] = strings
|
||||
}
|
||||
|
||||
return displayStringsArr
|
||||
for j := len(toRemove) - 1; j >= 0; j-- {
|
||||
if columnAlignments != nil && toRemove[j] < len(columnAlignments) {
|
||||
columnAlignments = slices.Delete(columnAlignments, toRemove[j], toRemove[j]+1)
|
||||
}
|
||||
}
|
||||
|
||||
return displayStringsArr, columnAlignments, toRemove
|
||||
}
|
||||
|
||||
func getPaddedDisplayStrings(stringArrays [][]string, columnConfigs []ColumnConfig) string {
|
||||
builder := strings.Builder{}
|
||||
for i, stringArray := range stringArrays {
|
||||
func getPaddedDisplayStrings(stringArrays [][]string, columnConfigs []ColumnConfig) []string {
|
||||
result := make([]string, 0, len(stringArrays))
|
||||
for _, stringArray := range stringArrays {
|
||||
if len(stringArray) == 0 {
|
||||
continue
|
||||
}
|
||||
builder := strings.Builder{}
|
||||
for j, columnConfig := range columnConfigs {
|
||||
if len(stringArray)-1 < j {
|
||||
continue
|
||||
@ -107,12 +126,9 @@ func getPaddedDisplayStrings(stringArrays [][]string, columnConfigs []ColumnConf
|
||||
continue
|
||||
}
|
||||
builder.WriteString(stringArray[len(columnConfigs)])
|
||||
|
||||
if i < len(stringArrays)-1 {
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
result = append(result, builder.String())
|
||||
}
|
||||
return builder.String()
|
||||
return result
|
||||
}
|
||||
|
||||
func getPadWidths(stringArrays [][]string) []int {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@ -157,66 +158,90 @@ func TestTruncateWithEllipsis(t *testing.T) {
|
||||
|
||||
func TestRenderDisplayStrings(t *testing.T) {
|
||||
type scenario struct {
|
||||
input [][]string
|
||||
columnAlignments []Alignment
|
||||
expected string
|
||||
input [][]string
|
||||
columnAlignments []Alignment
|
||||
expectedOutput string
|
||||
expectedColumnPositions []int
|
||||
}
|
||||
|
||||
tests := []scenario{
|
||||
{
|
||||
input: [][]string{{""}, {""}},
|
||||
columnAlignments: nil,
|
||||
expected: "",
|
||||
input: [][]string{{""}, {""}},
|
||||
columnAlignments: nil,
|
||||
expectedOutput: "",
|
||||
expectedColumnPositions: []int{0, 0},
|
||||
},
|
||||
{
|
||||
input: [][]string{{"a"}, {""}},
|
||||
columnAlignments: nil,
|
||||
expected: "a\n",
|
||||
input: [][]string{{"a"}, {""}},
|
||||
columnAlignments: nil,
|
||||
expectedOutput: "a\n",
|
||||
expectedColumnPositions: []int{0},
|
||||
},
|
||||
{
|
||||
input: [][]string{{"a"}, {"b"}},
|
||||
columnAlignments: nil,
|
||||
expected: "a\nb",
|
||||
input: [][]string{{"a"}, {"b"}},
|
||||
columnAlignments: nil,
|
||||
expectedOutput: "a\nb",
|
||||
expectedColumnPositions: []int{0},
|
||||
},
|
||||
{
|
||||
input: [][]string{{"a", "b"}, {"c", "d"}},
|
||||
columnAlignments: nil,
|
||||
expected: "a b\nc d",
|
||||
input: [][]string{{"a", "b"}, {"c", "d"}},
|
||||
columnAlignments: nil,
|
||||
expectedOutput: "a b\nc d",
|
||||
expectedColumnPositions: []int{0, 2},
|
||||
},
|
||||
{
|
||||
input: [][]string{{"a", "", "c"}, {"d", "", "f"}},
|
||||
columnAlignments: nil,
|
||||
expected: "a c\nd f",
|
||||
input: [][]string{{"a", "", "c"}, {"d", "", "f"}},
|
||||
columnAlignments: nil,
|
||||
expectedOutput: "a c\nd f",
|
||||
expectedColumnPositions: []int{0, 2, 2},
|
||||
},
|
||||
{
|
||||
input: [][]string{{"a", "", "c", ""}, {"d", "", "f", ""}},
|
||||
columnAlignments: nil,
|
||||
expected: "a c\nd f",
|
||||
input: [][]string{{"a", "", "c", ""}, {"d", "", "f", ""}},
|
||||
columnAlignments: nil,
|
||||
expectedOutput: "a c\nd f",
|
||||
expectedColumnPositions: []int{0, 2, 2},
|
||||
},
|
||||
{
|
||||
input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}},
|
||||
columnAlignments: nil,
|
||||
expected: "abc d\ne f",
|
||||
input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}},
|
||||
columnAlignments: nil,
|
||||
expectedOutput: "abc d\ne f",
|
||||
expectedColumnPositions: []int{0, 4, 4},
|
||||
},
|
||||
{
|
||||
input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}},
|
||||
columnAlignments: []Alignment{AlignLeft, AlignLeft}, // same as nil (default)
|
||||
expected: "abc d\ne f",
|
||||
input: [][]string{{"", "abc", "", "", "d", "e"}, {"", "f", "", "", "g", "h"}},
|
||||
columnAlignments: nil,
|
||||
expectedOutput: "abc d e\nf g h",
|
||||
expectedColumnPositions: []int{0, 0, 4, 4, 4, 6},
|
||||
},
|
||||
{
|
||||
input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}},
|
||||
columnAlignments: []Alignment{AlignRight, AlignLeft},
|
||||
expected: "abc d\n e f",
|
||||
input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}},
|
||||
columnAlignments: []Alignment{AlignLeft, AlignLeft}, // same as nil (default)
|
||||
expectedOutput: "abc d\ne f",
|
||||
expectedColumnPositions: []int{0, 4, 4},
|
||||
},
|
||||
{
|
||||
input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}},
|
||||
columnAlignments: []Alignment{AlignRight}, // gracefully defaults unspecified columns to left-align
|
||||
expected: "abc d\n e f",
|
||||
input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}},
|
||||
columnAlignments: []Alignment{AlignRight, AlignLeft},
|
||||
expectedOutput: "abc d\n e f",
|
||||
expectedColumnPositions: []int{0, 4, 4},
|
||||
},
|
||||
{
|
||||
input: [][]string{{"a", "", "bcd", "efg", "h"}, {"i", "", "j", "k", "l"}},
|
||||
columnAlignments: []Alignment{AlignLeft, AlignLeft, AlignRight, AlignLeft},
|
||||
expectedOutput: "a bcd efg h\ni j k l",
|
||||
expectedColumnPositions: []int{0, 2, 2, 6, 10},
|
||||
},
|
||||
{
|
||||
input: [][]string{{"abc", "", "d", ""}, {"e", "", "f", ""}},
|
||||
columnAlignments: []Alignment{AlignRight}, // gracefully defaults unspecified columns to left-align
|
||||
expectedOutput: "abc d\n e f",
|
||||
expectedColumnPositions: []int{0, 4, 4},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
output := RenderDisplayStrings(test.input, test.columnAlignments)
|
||||
assert.EqualValues(t, test.expected, output)
|
||||
output, columnPositions := RenderDisplayStrings(test.input, test.columnAlignments)
|
||||
assert.EqualValues(t, test.expectedOutput, strings.Join(output, "\n"))
|
||||
assert.EqualValues(t, test.expectedColumnPositions, columnPositions)
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user