1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2026-06-20 01:19:23 +02:00

Add the gui.sidePanels config option

This adds the user-facing surface for configuring the side panels: their
order, which ones are visible, and how tabs are grouped into panels. Each
entry is either a single panel name or a list of names sharing one panel
as tabs, mirroring how the Keybinding type accepts a scalar or a sequence;
the JSON schema restricts the names to the known set so editors can offer
completion and catch typos. The default reproduces today's layout exactly.

Validation rejects unknown or duplicated names, and requires the files,
branches, and commits panels to always be present: a lot of code focuses
those directly (e.g. after resolving a conflict or popping a stash), so
allowing them to be hidden would let that code focus a hidden panel.

Nothing reads the option yet; the layout still uses the hard-coded order.
Wiring follows in a later commit so the inert surface (and its generated
docs and schema) can be reviewed on its own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Haller
2026-06-14 08:46:32 +02:00
parent 7d21aae650
commit e00ae277a4
6 changed files with 200 additions and 0 deletions
+15
View File
@@ -110,6 +110,21 @@ gui:
# is true. # is true.
expandedSidePanelWeight: 2 expandedSidePanelWeight: 2
# The side panels, in the order they appear from top to bottom.
# Each entry is a list of one or more names that share a single panel as tabs
# (cycle through them with the next-tab/previous-tab keys).
# Omit a name to hide it; give a name its own one-element list to promote a tab
# to a top-level panel.
# Valid names are: 'status', 'files', 'worktrees', 'submodules', 'branches',
# 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and
# 'commits' must always be included; they can't be hidden.
sidePanels:
- [status]
- [files, worktrees, submodules]
- [branches, remotes, tags]
- [commits, reflog]
- [stash]
# Sometimes the main window is split in two (e.g. when the selected file has # Sometimes the main window is split in two (e.g. when the selected file has
# both staged and unstaged changes). This setting controls how the two sections # both staged and unstaged changes). This setting controls how the two sections
# are split. # are split.
+54
View File
@@ -0,0 +1,54 @@
package config
import (
"github.com/karimkhaleel/jsonschema"
"github.com/samber/lo"
"gopkg.in/yaml.v3"
)
// SidePanel is one entry in gui.sidePanels: a side panel made up of one or more
// tabs, written in YAML as a list of tab names (e.g. [files, worktrees]).
type SidePanel []string
// ValidSidePanelTabs lists every name that may appear in gui.sidePanels. Each
// names a list that can stand alone as a panel or be grouped with others as the
// tabs of one panel. The resolver in the gui package must handle every entry
// here; a test enforces that the two stay in sync.
var ValidSidePanelTabs = []string{
"status",
"files",
"worktrees",
"submodules",
"branches",
"remotes",
"tags",
"commits",
"reflog",
"stash",
}
func (p SidePanel) MarshalYAML() (any, error) {
// Render in flow style (`[a, b]`) rather than the default block style, which
// is more compact and reads better in the generated docs.
node := &yaml.Node{
Kind: yaml.SequenceNode,
Style: yaml.FlowStyle,
}
for _, s := range p {
node.Content = append(node.Content, &yaml.Node{
Kind: yaml.ScalarNode,
Value: s,
})
}
return node, nil
}
// JSONSchema describes a side panel as a list of tab names, restricted to the
// known names.
func (SidePanel) JSONSchema() *jsonschema.Schema {
names := lo.Map(ValidSidePanelTabs, func(name string, _ int) any { return name })
return &jsonschema.Schema{
Type: "array",
Items: &jsonschema.Schema{Type: "string", Enum: names},
}
}
+12
View File
@@ -102,6 +102,11 @@ type GuiConfig struct {
ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"` ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"`
// The weight of the expanded side panel, relative to the other panels. 2 means twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true. // The weight of the expanded side panel, relative to the other panels. 2 means twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true.
ExpandedSidePanelWeight int `yaml:"expandedSidePanelWeight"` ExpandedSidePanelWeight int `yaml:"expandedSidePanelWeight"`
// The side panels, in the order they appear from top to bottom.
// Each entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys).
// Omit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel.
// Valid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and 'commits' must always be included; they can't be hidden.
SidePanels []SidePanel `yaml:"sidePanels"`
// Sometimes the main window is split in two (e.g. when the selected file has both staged and unstaged changes). This setting controls how the two sections are split. // Sometimes the main window is split in two (e.g. when the selected file has both staged and unstaged changes). This setting controls how the two sections are split.
// Options are: // Options are:
// - 'horizontal': split the window horizontally // - 'horizontal': split the window horizontally
@@ -839,6 +844,13 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
SidePanelWidth: 0.3333, SidePanelWidth: 0.3333,
ExpandFocusedSidePanel: false, ExpandFocusedSidePanel: false,
ExpandedSidePanelWeight: 2, ExpandedSidePanelWeight: 2,
SidePanels: []SidePanel{
{"status"},
{"files", "worktrees", "submodules"},
{"branches", "remotes", "tags"},
{"commits", "reflog"},
{"stash"},
},
MainPanelSplitMode: "flexible", MainPanelSplitMode: "flexible",
EnlargedSideViewLocation: "left", EnlargedSideViewLocation: "left",
WrapLinesInStagingView: true, WrapLinesInStagingView: true,
+36
View File
@@ -58,6 +58,42 @@ func (config *UserConfig) Validate() error {
if err := validateSpinner(config.Gui.Spinner); err != nil { if err := validateSpinner(config.Gui.Spinner); err != nil {
return err return err
} }
if err := validateSidePanels(config.Gui.SidePanels); err != nil {
return err
}
return nil
}
func validateSidePanels(panels []SidePanel) error {
seen := map[string]bool{}
total := 0
for _, panel := range panels {
if len(panel) == 0 {
return errors.New("gui.sidePanels: a side panel must have at least one tab.")
}
for _, name := range panel {
if !slices.Contains(ValidSidePanelTabs, name) {
return fmt.Errorf("gui.sidePanels: unknown side panel '%s'. Allowed values: %s",
name, strings.Join(ValidSidePanelTabs, ", "))
}
if seen[name] {
return fmt.Errorf("gui.sidePanels: '%s' is listed more than once; each side panel may appear only once.", name)
}
seen[name] = true
total++
}
}
if total == 0 {
return errors.New("gui.sidePanels must not be empty.")
}
// A lot of code focuses these panels directly (e.g. after resolving a
// conflict or popping a stash), so they must always be present; otherwise
// that code would focus a hidden panel.
for _, required := range []string{"files", "branches", "commits"} {
if !seen[required] {
return fmt.Errorf("gui.sidePanels: '%s' must be included; it can't be hidden.", required)
}
}
return nil return nil
} }
+36
View File
@@ -324,6 +324,42 @@ func TestUserConfigValidate_spinnerFrames(t *testing.T) {
} }
} }
func TestUserConfigValidate_sidePanels(t *testing.T) {
scenarios := []struct {
name string
panels []SidePanel
valid bool
}{
{name: "default layout", panels: []SidePanel{{"status"}, {"files", "worktrees", "submodules"}, {"branches", "remotes", "tags"}, {"commits", "reflog"}, {"stash"}}, valid: true},
{name: "reordered", panels: []SidePanel{{"status"}, {"files"}, {"commits"}, {"branches"}, {"stash"}}, valid: true},
{name: "hidden stash panel", panels: []SidePanel{{"status"}, {"files"}, {"branches"}, {"commits"}}, valid: true},
{name: "promoted tab", panels: []SidePanel{{"files", "submodules"}, {"worktrees"}, {"branches"}, {"commits"}}, valid: true},
{name: "core panels only", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}}, valid: true},
{name: "empty", panels: []SidePanel{}, valid: false},
{name: "empty panel", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}, {}}, valid: false},
{name: "unknown name", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}, {"bogus"}}, valid: false},
{name: "duplicate within panel", panels: []SidePanel{{"files", "files"}, {"branches"}, {"commits"}}, valid: false},
{name: "duplicate across panels", panels: []SidePanel{{"files"}, {"branches", "files"}, {"commits"}}, valid: false},
{name: "missing files", panels: []SidePanel{{"branches"}, {"commits"}}, valid: false},
{name: "missing branches", panels: []SidePanel{{"files"}, {"commits"}}, valid: false},
{name: "missing commits", panels: []SidePanel{{"files"}, {"branches"}}, valid: false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
config := GetDefaultConfig()
config.Gui.SidePanels = s.panels
err := config.Validate()
if s.valid {
assert.NoError(t, err)
} else {
assert.Error(t, err)
}
})
}
}
func TestUserConfigValidate_pagers(t *testing.T) { func TestUserConfigValidate_pagers(t *testing.T) {
scenarios := []struct { scenarios := []struct {
name string name string
+47
View File
@@ -585,6 +585,35 @@
"description": "The weight of the expanded side panel, relative to the other panels. 2 means twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true.", "description": "The weight of the expanded side panel, relative to the other panels. 2 means twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true.",
"default": 2 "default": 2
}, },
"sidePanels": {
"items": {
"$ref": "#/$defs/SidePanel"
},
"type": "array",
"description": "The side panels, in the order they appear from top to bottom.\nEach entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys).\nOmit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel.\nValid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and 'commits' must always be included; they can't be hidden.",
"default": [
[
"status"
],
[
"files",
"worktrees",
"submodules"
],
[
"branches",
"remotes",
"tags"
],
[
"commits",
"reflog"
],
[
"stash"
]
]
},
"mainPanelSplitMode": { "mainPanelSplitMode": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -3554,6 +3583,24 @@
"type": "object", "type": "object",
"description": "Background refreshes" "description": "Background refreshes"
}, },
"SidePanel": {
"items": {
"type": "string",
"enum": [
"status",
"files",
"worktrees",
"submodules",
"branches",
"remotes",
"tags",
"commits",
"reflog",
"stash"
]
},
"type": "array"
},
"SpinnerConfig": { "SpinnerConfig": {
"properties": { "properties": {
"frames": { "frames": {