1
0
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:
Stefan Haller
2026-06-14 17:54:16 +02:00
parent 4f942b1dcb
commit 1da9ff74df
7 changed files with 311 additions and 55 deletions
@@ -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) {
+10 -1
View File
@@ -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]
})
}
+25 -43
View File
@@ -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
@@ -622,12 +623,13 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
// TODO: only use contexts from context manager
ContextMgr: NewContextMgr(gui, contextTree),
Contexts: contextTree,
WindowViewNameMap: initialWindowViewNameMap(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
+90
View File
@@ -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)
}
}
}
+30
View File
@@ -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
View File
@@ -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 {