From 2ad928d123e62a93ff07acd8a9ee9b1891afa9e5 Mon Sep 17 00:00:00 2001 From: alcroito Date: Thu, 30 Jul 2026 18:38:09 +0200 Subject: [PATCH 1/2] Widen initialContext's return type to types.Context The status context is a plain types.Context rather than a list context, so it can't be returned as an IListContext. Widening the return type lets a later change resolve the initial context from any side panel name. The only caller assigns it to a types.Context and does its own IListContext assertion where it needs one. --- pkg/gui/gui.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 912d46567b2..5e0c8e6e188 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -745,8 +745,8 @@ func parseScreenModeArg(screenModeArg string) types.ScreenMode { } } -func initialContext(contextTree *context.ContextTree, startArgs appTypes.StartArgs) types.IListContext { - var initialContext types.IListContext = contextTree.Files +func initialContext(contextTree *context.ContextTree, startArgs appTypes.StartArgs) types.Context { + var initialContext types.Context = contextTree.Files if startArgs.FilterPath != "" { initialContext = contextTree.LocalCommits From a98ee2d1f82220a3c19c53b2a2f12d3333f45db9 Mon Sep 17 00:00:00 2001 From: alcroito Date: Thu, 30 Jul 2026 18:42:11 +0200 Subject: [PATCH 2/2] Add gui.initialSidePanel for choosing the panel focused on startup Add a new option that allows the user to choose which side panel is focused when lazygit starts or when switching repositories. The new option takes the same values as the gui.sidePanels setting. The default "files" value is kept when the option is not set. Setting a hidden panel as the value is rejected at config validation time. Passing a path filter or a git subcommand still overrides the option. Closes: #5877 --- docs-master/Config.md | 8 +++++ pkg/config/side_panel.go | 16 +++++++-- pkg/config/user_config.go | 5 +++ pkg/config/user_config_validation.go | 17 +++++++++ pkg/config/user_config_validation_test.go | 32 +++++++++++++++++ pkg/gui/gui.go | 10 ++++-- pkg/integration/tests/test_list.go | 2 ++ .../tests/ui/initial_side_panel.go | 25 +++++++++++++ .../ui/initial_side_panel_not_first_tab.go | 35 +++++++++++++++++++ schema-master/config.json | 21 +++++++++++ 10 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 pkg/integration/tests/ui/initial_side_panel.go create mode 100644 pkg/integration/tests/ui/initial_side_panel_not_first_tab.go diff --git a/docs-master/Config.md b/docs-master/Config.md index 1d101be1863..15e92ec6b15 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -130,6 +130,14 @@ gui: - [commits, reflog] - [stash] + # The side panel that is focused when lazygit starts, or when you switch to + # another repository. + # Must be one of the names listed in `sidePanels`; you can't focus a panel you + # have hidden. + # Starting lazygit with a path filter or with a git subcommand (e.g. `lazygit + # log`) takes precedence over this setting. + initialSidePanel: files + # 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 index 307ed0c1d86..fd97a826964 100644 --- a/pkg/config/side_panel.go +++ b/pkg/config/side_panel.go @@ -27,6 +27,19 @@ var ValidSidePanelTabs = []string{ "stash", } +// SidePanelName is the name of a single side panel, i.e. one of +// ValidSidePanelTabs. +type SidePanelName string + +// JSONSchema restricts a side panel name to the known names. +func (SidePanelName) JSONSchema() *jsonschema.Schema { + return &jsonschema.Schema{Type: "string", Enum: validSidePanelTabsAsAny()} +} + +func validSidePanelTabsAsAny() []any { + return lo.Map(ValidSidePanelTabs, func(name string, _ int) any { return name }) +} + 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. @@ -46,9 +59,8 @@ func (p SidePanel) MarshalYAML() (any, error) { // 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}, + Items: &jsonschema.Schema{Type: "string", Enum: validSidePanelTabsAsAny()}, } } diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 30ce0377d5a..91b22aca1d3 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -118,6 +118,10 @@ type GuiConfig struct { // 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"` + // The side panel that is focused when lazygit starts, or when you switch to another repository. + // Must be one of the names listed in `sidePanels`; you can't focus a panel you have hidden. + // Starting lazygit with a path filter or with a git subcommand (e.g. `lazygit log`) takes precedence over this setting. + InitialSidePanel SidePanelName `yaml:"initialSidePanel"` // 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 @@ -869,6 +873,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { {"commits", "reflog"}, {"stash"}, }, + InitialSidePanel: "files", MainPanelSplitMode: "flexible", EnlargedSideViewLocation: "left", WrapLinesInStagingView: true, diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 9550e916064..f9e655748ae 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -61,9 +61,26 @@ func (config *UserConfig) Validate() error { if err := validateSidePanels(config.Gui.SidePanels); err != nil { return err } + if err := validateInitialSidePanel(config.Gui.SidePanels, config.Gui.InitialSidePanel); err != nil { + return err + } return nil } +func validateInitialSidePanel(panels []SidePanel, initial SidePanelName) error { + name := string(initial) + if !slices.Contains(ValidSidePanelTabs, name) { + return fmt.Errorf("gui.initialSidePanel: unknown side panel '%s'. Allowed values: %s", + name, strings.Join(ValidSidePanelTabs, ", ")) + } + for _, panel := range panels { + if slices.Contains(panel, name) { + return nil + } + } + return fmt.Errorf("gui.initialSidePanel: '%s' is not listed in gui.sidePanels; a hidden side panel can't be focused.", name) +} + func validateSidePanels(panels []SidePanel) error { seen := map[string]bool{} total := 0 diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index a0c17636dbd..c10399e2645 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -361,6 +361,38 @@ func TestUserConfigValidate_sidePanels(t *testing.T) { } } +func TestUserConfigValidate_initialSidePanel(t *testing.T) { + scenarios := []struct { + name string + panels []SidePanel + initial SidePanelName + valid bool + }{ + {name: "default", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}}, initial: "files", valid: true}, + {name: "non-default panel", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}}, initial: "commits", valid: true}, + {name: "non-first tab of a panel", panels: []SidePanel{{"files"}, {"branches"}, {"reflog", "commits"}}, initial: "commits", valid: true}, + {name: "status panel", panels: []SidePanel{{"status"}, {"files"}, {"branches"}, {"commits"}}, initial: "status", valid: true}, + {name: "empty", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}}, initial: "", valid: false}, + {name: "unknown name", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}}, initial: "bogus", valid: false}, + {name: "hidden panel", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}}, initial: "stash", valid: false}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + config := GetDefaultConfig() + config.Gui.SidePanels = s.panels + config.Gui.InitialSidePanel = s.initial + 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/pkg/gui/gui.go b/pkg/gui/gui.go index 5e0c8e6e188..79e17ca404b 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -661,7 +661,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { gui.applySidePanelConfig() - return initialContext(contextTree, startArgs) + return initialContext(contextTree, startArgs, gui.c.UserConfig().Gui.InitialSidePanel) } func (gui *Gui) loadCachedPullRequests() []*models.GithubPullRequest { @@ -745,8 +745,12 @@ func parseScreenModeArg(screenModeArg string) types.ScreenMode { } } -func initialContext(contextTree *context.ContextTree, startArgs appTypes.StartArgs) types.Context { - var initialContext types.Context = contextTree.Files +func initialContext( + contextTree *context.ContextTree, + startArgs appTypes.StartArgs, + initialSidePanel config.SidePanelName, +) types.Context { + initialContext := sidePanelContexts(contextTree)[string(initialSidePanel)] if startArgs.FilterPath != "" { initialContext = contextTree.LocalCommits diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 77e54e26509..c068ae9810c 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -493,6 +493,8 @@ var tests = []*components.IntegrationTest{ ui.DisableSwitchTabWithPanelJumpKeys, ui.EmptyMenu, ui.HideSidePanel, + ui.InitialSidePanel, + ui.InitialSidePanelNotFirstTab, ui.KeybindingSuggestionsDontCrashOnDisabledBindings, ui.KeybindingSuggestionsWhenSwitchingRepos, ui.ModeSpecificKeybindingSuggestions, diff --git a/pkg/integration/tests/ui/initial_side_panel.go b/pkg/integration/tests/ui/initial_side_panel.go new file mode 100644 index 00000000000..d21c8266f5f --- /dev/null +++ b/pkg/integration/tests/ui/initial_side_panel.go @@ -0,0 +1,25 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var InitialSidePanel = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The side panel named by gui.initialSidePanel is focused at startup", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.InitialSidePanel = "commits" + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + IsFocused(). + Lines( + Contains("one"), + ) + }, +}) diff --git a/pkg/integration/tests/ui/initial_side_panel_not_first_tab.go b/pkg/integration/tests/ui/initial_side_panel_not_first_tab.go new file mode 100644 index 00000000000..831bc9fe37c --- /dev/null +++ b/pkg/integration/tests/ui/initial_side_panel_not_first_tab.go @@ -0,0 +1,35 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var InitialSidePanelNotFirstTab = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "gui.initialSidePanel names a tab that isn't the first one of its panel, so it must be brought to the front", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{ + {"status"}, + {"files"}, + {"branches"}, + {"reflog", "commits"}, + {"stash"}, + } + cfg.GetUserConfig().Gui.InitialSidePanel = "commits" + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Commits is shown in front of the reflog tab it shares a panel with, + // rather than being focused behind it. + t.Views().Commits(). + IsActiveTab(). + IsFocused(). + Lines( + Contains("one"), + ) + }, +}) diff --git a/schema-master/config.json b/schema-master/config.json index 82dbebb0b7f..c6ab03a466d 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -624,6 +624,10 @@ ] ] }, + "initialSidePanel": { + "$ref": "#/$defs/SidePanelName", + "description": "The side panel that is focused when lazygit starts, or when you switch to another repository.\nMust be one of the names listed in `sidePanels`; you can't focus a panel you have hidden.\nStarting lazygit with a path filter or with a git subcommand (e.g. `lazygit log`) takes precedence over this setting." + }, "mainPanelSplitMode": { "type": "string", "enum": [ @@ -3622,6 +3626,23 @@ }, "type": "array" }, + "SidePanelName": { + "type": "string", + "enum": [ + "status", + "files", + "worktrees", + "submodules", + "branches", + "remotes", + "tags", + "commits", + "reflog", + "stash" + ], + "description": "The side panel that is focused when lazygit starts, or when you switch to another repository.\nMust be one of the names listed in `sidePanels`; you can't focus a panel you have hidden.\nStarting lazygit with a path filter or with a git subcommand (e.g. `lazygit log`) takes precedence over this setting.", + "default": "files" + }, "SpinnerConfig": { "properties": { "frames": {