diff --git a/docs-master/Config.md b/docs-master/Config.md index 80f8cfd23..0a4d9f3fa 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -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. diff --git a/pkg/config/side_panel.go b/pkg/config/side_panel.go new file mode 100644 index 000000000..307ed0c1d --- /dev/null +++ b/pkg/config/side_panel.go @@ -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}, + } +} diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index e96f4aff4..bb7b5f424 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -109,6 +109,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 @@ -848,6 +853,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, diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 109b3f1d0..3dd5b4b59 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -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 } diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index 26c9b7145..02e64b02a 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -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 diff --git a/schema-master/config.json b/schema-master/config.json index e0871940c..6e9a825ad 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -590,6 +590,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": [ @@ -3565,6 +3594,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": {