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:
@@ -110,6 +110,21 @@ gui:
|
||||
# is true.
|
||||
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
|
||||
# both staged and unstaged changes). This setting controls how the two sections
|
||||
# are split.
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,11 @@ type GuiConfig struct {
|
||||
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.
|
||||
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.
|
||||
// Options are:
|
||||
// - 'horizontal': split the window horizontally
|
||||
@@ -839,6 +844,13 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
SidePanelWidth: 0.3333,
|
||||
ExpandFocusedSidePanel: false,
|
||||
ExpandedSidePanelWeight: 2,
|
||||
SidePanels: []SidePanel{
|
||||
{"status"},
|
||||
{"files", "worktrees", "submodules"},
|
||||
{"branches", "remotes", "tags"},
|
||||
{"commits", "reflog"},
|
||||
{"stash"},
|
||||
},
|
||||
MainPanelSplitMode: "flexible",
|
||||
EnlargedSideViewLocation: "left",
|
||||
WrapLinesInStagingView: true,
|
||||
|
||||
@@ -58,6 +58,42 @@ func (config *UserConfig) Validate() error {
|
||||
if err := validateSpinner(config.Gui.Spinner); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
|
||||
@@ -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.",
|
||||
"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": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -3554,6 +3583,24 @@
|
||||
"type": "object",
|
||||
"description": "Background refreshes"
|
||||
},
|
||||
"SidePanel": {
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"status",
|
||||
"files",
|
||||
"worktrees",
|
||||
"submodules",
|
||||
"branches",
|
||||
"remotes",
|
||||
"tags",
|
||||
"commits",
|
||||
"reflog",
|
||||
"stash"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"SpinnerConfig": {
|
||||
"properties": {
|
||||
"frames": {
|
||||
|
||||
Reference in New Issue
Block a user