mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-06-20 01:19:23 +02:00
Drive the side panel layout from gui.sidePanels
Replace the hard-coded side panel order, tab groupings, and window assignments with values resolved from the gui.sidePanels config. The panel order (SideWindows and the layout boxes), the tab strips (viewTabMap), the per-context window names, each window's default view, and the jump-label groups all now come from the config rather than from five separate hard-coded lists. A panel's window name is the name of its first tab, and panels not listed in the config get their own window name so their views stay hidden instead of overlapping a visible panel. Three small lookups translate config names into views, tab titles, and contexts; a test keeps them in sync with the set of valid names. The lookups are split this way (rather than one resolver) because configureViewProperties runs before the context tree exists, so the title/view lookups must not depend on it. The config is applied to a repo's contexts via applySidePanelConfig on every repo entry, including the cached-repo path: a repo's per-repo config can differ from the previously visited one's, so each repo's contexts must be (re)assigned from its own config rather than kept from when they were first built. With the default config this reproduces today's layout exactly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -428,7 +428,7 @@ func getDefaultStashWindowBox(args WindowArrangementArgs, window string) *boxlay
|
||||
|
||||
func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) []*boxlayout.Box {
|
||||
return func(width int, height int) []*boxlayout.Box {
|
||||
windows := []string{"status", "files", "branches", "commits", "stash"}
|
||||
windows := sideWindowNames(args.UserConfig)
|
||||
|
||||
boxForEachWindow := func(boxForWindow func(window string) *boxlayout.Box) []*boxlayout.Box {
|
||||
boxes := make([]*boxlayout.Box, 0, len(windows))
|
||||
|
||||
@@ -124,6 +124,152 @@ func TestGetWindowDimensions(t *testing.T) {
|
||||
B: information
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "worktrees promoted to its own side panel",
|
||||
mutateArgs: func(args *WindowArrangementArgs) {
|
||||
args.UserConfig.Gui.SidePanels = []config.SidePanel{
|
||||
{"status"},
|
||||
{"files", "submodules"},
|
||||
{"worktrees"},
|
||||
{"branches", "remotes", "tags"},
|
||||
{"commits", "reflog"},
|
||||
{"stash"},
|
||||
}
|
||||
},
|
||||
expected: `
|
||||
╭status─────────────────╮╭main────────────────────────────────────────────╮
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭files──────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭worktrees──────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭branches───────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭commits────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭stash──────────────────╮│ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯╰────────────────────────────────────────────────╯
|
||||
<options──────────────────────────────────────────────────────>A<B────────>
|
||||
A: statusSpacer1
|
||||
B: information
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "stash side panel hidden",
|
||||
mutateArgs: func(args *WindowArrangementArgs) {
|
||||
args.UserConfig.Gui.SidePanels = []config.SidePanel{
|
||||
{"status"},
|
||||
{"files", "worktrees", "submodules"},
|
||||
{"branches", "remotes", "tags"},
|
||||
{"commits", "reflog"},
|
||||
}
|
||||
},
|
||||
expected: `
|
||||
╭status─────────────────╮╭main────────────────────────────────────────────╮
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭files──────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭branches───────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭commits────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯╰────────────────────────────────────────────────╯
|
||||
<options──────────────────────────────────────────────────────>A<B────────>
|
||||
A: statusSpacer1
|
||||
B: information
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "stash leading a grouped panel doesn't squash its other tabs",
|
||||
mutateArgs: func(args *WindowArrangementArgs) {
|
||||
args.UserConfig.Gui.SidePanels = []config.SidePanel{
|
||||
{"status"},
|
||||
{"files", "worktrees", "submodules"},
|
||||
{"stash", "branches", "remotes", "tags"},
|
||||
{"commits", "reflog"},
|
||||
}
|
||||
// The third panel is named after its first tab, stash, but is
|
||||
// currently showing the branches tab, which must get full height
|
||||
// rather than stash's compact height.
|
||||
args.ActiveViewForWindow = func(window string) string {
|
||||
if window == "stash" {
|
||||
return "branches"
|
||||
}
|
||||
return window
|
||||
}
|
||||
},
|
||||
expected: `
|
||||
╭status─────────────────╮╭main────────────────────────────────────────────╮
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭files──────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭stash──────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯│ │
|
||||
╭commits────────────────╮│ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰───────────────────────╯╰────────────────────────────────────────────────╯
|
||||
<options──────────────────────────────────────────────────────>A<B────────>
|
||||
A: statusSpacer1
|
||||
B: information
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "expandFocusedSidePanel",
|
||||
mutateArgs: func(args *WindowArrangementArgs) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
@@ -135,5 +136,13 @@ func (self *WindowHelper) WindowForView(viewName string) string {
|
||||
}
|
||||
|
||||
func (self *WindowHelper) SideWindows() []string {
|
||||
return []string{"status", "files", "branches", "commits", "stash"}
|
||||
return sideWindowNames(self.c.UserConfig())
|
||||
}
|
||||
|
||||
// sideWindowNames returns the side panel window names in order, derived from the
|
||||
// gui.sidePanels config. A panel's window name is the name of its first tab.
|
||||
func sideWindowNames(userConfig *config.UserConfig) []string {
|
||||
return lo.Map(userConfig.Gui.SidePanels, func(panel config.SidePanel, _ int) string {
|
||||
return panel[0]
|
||||
})
|
||||
}
|
||||
|
||||
+28
-46
@@ -579,8 +579,9 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
|
||||
gui.State = state
|
||||
gui.State.ViewsSetup = false
|
||||
|
||||
contextTree := gui.State.Contexts
|
||||
gui.State.WindowViewNameMap = initialWindowViewNameMap(contextTree)
|
||||
// The repo we're switching to may have a per-repo config with a different
|
||||
// side panel layout, so re-apply it to this repo's contexts.
|
||||
gui.applySidePanelConfig()
|
||||
|
||||
// setting this to nil so we don't get stuck based on a popup that was
|
||||
// previously opened
|
||||
@@ -620,14 +621,15 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
|
||||
},
|
||||
ScreenMode: initialScreenMode,
|
||||
// TODO: only use contexts from context manager
|
||||
ContextMgr: NewContextMgr(gui, contextTree),
|
||||
Contexts: contextTree,
|
||||
WindowViewNameMap: initialWindowViewNameMap(contextTree),
|
||||
SearchState: types.NewSearchState(),
|
||||
ContextMgr: NewContextMgr(gui, contextTree),
|
||||
Contexts: contextTree,
|
||||
SearchState: types.NewSearchState(),
|
||||
}
|
||||
|
||||
gui.RepoStateMap[Repo(worktreePath)] = gui.State
|
||||
|
||||
gui.applySidePanelConfig()
|
||||
|
||||
return initialContext(contextTree, startArgs)
|
||||
}
|
||||
|
||||
@@ -658,13 +660,19 @@ func (gui *Gui) getViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferM
|
||||
return manager
|
||||
}
|
||||
|
||||
func initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] {
|
||||
func (gui *Gui) initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] {
|
||||
result := utils.NewThreadSafeMap[string, string]()
|
||||
|
||||
for _, context := range contextTree.Flatten() {
|
||||
result.Set(context.GetWindowName(), context.GetViewName())
|
||||
}
|
||||
|
||||
// A side panel's window shows its first configured tab by default, which is
|
||||
// not necessarily the context that won the loop above.
|
||||
for _, panel := range gui.c.UserConfig().Gui.SidePanels {
|
||||
result.Set(panel[0], sidePanelViewNames[panel[0]])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -834,45 +842,19 @@ func (gui *Gui) initGocui(headless bool, test integrationTypes.IntegrationTest)
|
||||
}
|
||||
|
||||
func (gui *Gui) viewTabMap() map[string][]context.TabView {
|
||||
result := map[string][]context.TabView{
|
||||
"branches": {
|
||||
{
|
||||
Tab: gui.c.Tr.LocalBranchesTitle,
|
||||
ViewName: "localBranches",
|
||||
},
|
||||
{
|
||||
Tab: gui.c.Tr.RemotesTitle,
|
||||
ViewName: "remotes",
|
||||
},
|
||||
{
|
||||
Tab: gui.c.Tr.TagsTitle,
|
||||
ViewName: "tags",
|
||||
},
|
||||
},
|
||||
"commits": {
|
||||
{
|
||||
Tab: gui.c.Tr.CommitsTitle,
|
||||
ViewName: "commits",
|
||||
},
|
||||
{
|
||||
Tab: gui.c.Tr.ReflogCommitsTitle,
|
||||
ViewName: "reflogCommits",
|
||||
},
|
||||
},
|
||||
"files": {
|
||||
{
|
||||
Tab: gui.c.Tr.FilesTitle,
|
||||
ViewName: "files",
|
||||
},
|
||||
context.TabView{
|
||||
Tab: gui.c.Tr.WorktreesTitle,
|
||||
ViewName: "worktrees",
|
||||
},
|
||||
{
|
||||
Tab: gui.c.Tr.SubmodulesTitle,
|
||||
ViewName: "submodules",
|
||||
},
|
||||
},
|
||||
titles := gui.sidePanelTabTitles()
|
||||
result := map[string][]context.TabView{}
|
||||
for _, panel := range gui.c.UserConfig().Gui.SidePanels {
|
||||
if len(panel) < 2 {
|
||||
// A single-tab panel shows its view's own title, not a tab strip.
|
||||
continue
|
||||
}
|
||||
result[panel[0]] = lo.Map(panel, func(name string, _ int) context.TabView {
|
||||
return context.TabView{
|
||||
Tab: titles[name],
|
||||
ViewName: sidePanelViewNames[name],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
// sidePanelViewNames maps each gui.sidePanels name to the gocui view it controls.
|
||||
// A panel's window name is the name of its first tab, so for a panel's first tab
|
||||
// this also gives the default view of its window. The keys must match
|
||||
// config.ValidSidePanelTabs (enforced by a test).
|
||||
var sidePanelViewNames = map[string]string{
|
||||
"status": "status",
|
||||
"files": "files",
|
||||
"worktrees": "worktrees",
|
||||
"submodules": "submodules",
|
||||
"branches": "localBranches",
|
||||
"remotes": "remotes",
|
||||
"tags": "tags",
|
||||
"commits": "commits",
|
||||
"reflog": "reflogCommits",
|
||||
"stash": "stash",
|
||||
}
|
||||
|
||||
// sidePanelTabTitles maps each gui.sidePanels name to the title shown on its tab.
|
||||
func (gui *Gui) sidePanelTabTitles() map[string]string {
|
||||
tr := gui.c.Tr
|
||||
return map[string]string{
|
||||
"status": tr.StatusTitle,
|
||||
"files": tr.FilesTitle,
|
||||
"worktrees": tr.WorktreesTitle,
|
||||
"submodules": tr.SubmodulesTitle,
|
||||
"branches": tr.LocalBranchesTitle,
|
||||
"remotes": tr.RemotesTitle,
|
||||
"tags": tr.TagsTitle,
|
||||
"commits": tr.CommitsTitle,
|
||||
"reflog": tr.ReflogCommitsTitle,
|
||||
"stash": tr.StashTitle,
|
||||
}
|
||||
}
|
||||
|
||||
// sidePanelContexts maps each gui.sidePanels name to the context it controls.
|
||||
func sidePanelContexts(contextTree *context.ContextTree) map[string]types.Context {
|
||||
return map[string]types.Context{
|
||||
"status": contextTree.Status,
|
||||
"files": contextTree.Files,
|
||||
"worktrees": contextTree.Worktrees,
|
||||
"submodules": contextTree.Submodules,
|
||||
"branches": contextTree.Branches,
|
||||
"remotes": contextTree.Remotes,
|
||||
"tags": contextTree.Tags,
|
||||
"commits": contextTree.LocalCommits,
|
||||
"reflog": contextTree.ReflogCommits,
|
||||
"stash": contextTree.Stash,
|
||||
}
|
||||
}
|
||||
|
||||
// applySidePanelConfig (re)assigns each side context's window and resets each
|
||||
// window's default view from the current gui.sidePanels config. It runs against
|
||||
// the current repo's contexts, so gui.State must already be set. We call it on
|
||||
// every repo entry (a repo's per-repo config can differ from the previous one's).
|
||||
func (gui *Gui) applySidePanelConfig() {
|
||||
contextTree := gui.State.Contexts
|
||||
gui.assignSidePanelWindows(contextTree)
|
||||
gui.State.WindowViewNameMap = gui.initialWindowViewNameMap(contextTree)
|
||||
}
|
||||
|
||||
// assignSidePanelWindows sets each side context's window name from the config so
|
||||
// that contexts grouped into one panel share a window (the window name being the
|
||||
// panel's first tab). Side panels the user hasn't listed get their own window
|
||||
// name; since the layout produces no dimensions for those windows, their views
|
||||
// stay hidden rather than overlapping a visible panel.
|
||||
func (gui *Gui) assignSidePanelWindows(contextTree *context.ContextTree) {
|
||||
contexts := sidePanelContexts(contextTree)
|
||||
assigned := make(map[string]bool, len(contexts))
|
||||
|
||||
for _, panel := range gui.c.UserConfig().Gui.SidePanels {
|
||||
windowName := panel[0]
|
||||
for _, name := range panel {
|
||||
contexts[name].SetWindowName(windowName)
|
||||
assigned[name] = true
|
||||
}
|
||||
}
|
||||
|
||||
for name, ctx := range contexts {
|
||||
if !assigned[name] {
|
||||
ctx.SetWindowName(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
keys := lo.Keys(m)
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// The three lookups that translate gui.sidePanels names into views, titles, and
|
||||
// contexts must each cover exactly the set of valid names, or a config that uses
|
||||
// a name missing from one of them would hit a nil lookup at runtime.
|
||||
func TestSidePanelLookupsCoverAllValidTabs(t *testing.T) {
|
||||
want := lo.Uniq(config.ValidSidePanelTabs)
|
||||
sort.Strings(want)
|
||||
|
||||
gui := NewDummyGui()
|
||||
|
||||
assert.Equal(t, want, sortedKeys(sidePanelViewNames))
|
||||
assert.Equal(t, want, sortedKeys(gui.sidePanelTabTitles()))
|
||||
assert.Equal(t, want, sortedKeys(sidePanelContexts(gui.contextTree())))
|
||||
}
|
||||
+6
-7
@@ -219,13 +219,12 @@ func (gui *Gui) configureViewProperties() {
|
||||
|
||||
// The views that make up each side panel, in panel order. The whole group
|
||||
// shares the panel's jump label.
|
||||
panelViewGroups := [][]*gocui.View{
|
||||
{gui.Views.Status},
|
||||
{gui.Views.Files, gui.Views.Worktrees, gui.Views.Submodules},
|
||||
{gui.Views.Branches, gui.Views.Remotes, gui.Views.Tags},
|
||||
{gui.Views.Commits, gui.Views.ReflogCommits},
|
||||
{gui.Views.Stash},
|
||||
}
|
||||
panelViewGroups := lo.Map(gui.c.UserConfig().Gui.SidePanels, func(panel config.SidePanel, _ int) []*gocui.View {
|
||||
return lo.Map(panel, func(name string, _ int) *gocui.View {
|
||||
view, _ := gui.g.View(sidePanelViewNames[name])
|
||||
return view
|
||||
})
|
||||
})
|
||||
|
||||
jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock
|
||||
jumpLabelForPanel := func(panelIndex int) string {
|
||||
|
||||
Reference in New Issue
Block a user