From c2b3d7949f9d411a49d66bee105ca4de6202abe0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 18:24:51 +0200 Subject: [PATCH 01/16] Ask agents to surface mid-implementation decisions Record the working preference that calls which come up during implementation (and weren't settled in planning) should be raised and decided together, not made unilaterally and discovered later in the diff. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 2fb36392efd..3dc2af4dbea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,22 @@ commit. If you have two independent refinements for the same target, make two separate fixups. Reviewability of the intermediate state matters even when the end state after autosquash would be identical. +## Surface mid-implementation decisions; decide them together + +Planning can't anticipate everything. When a decision surfaces while you're +implementing — a design choice, a tradeoff, a scope cut, a "this turned out +harder than expected, so maybe X" — don't quietly make the call and keep +going, even if you have a clear recommendation and even if the call seems +small. Stop, lay out the options and your recommendation, and let me weigh in. +I want to make these calls _with_ you, not discover them after the fact in the +diff. + +This isn't a request to stop and ask about every trivial detail; obvious +mechanical choices with one sensible answer don't need a checkpoint. It's about +genuine forks — the ones where a reasonable person might pick differently, or +where you'd be trading away something the plan assumed (scope, UX, performance, +reload behavior, …). When in doubt, surface it. + ## Prefer the cleaner design over the smaller diff When a task could be implemented either by tacking onto existing code or by From e88fc1331a2b2fc22874a04afb4277fd18ced273 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 08:24:01 +0200 Subject: [PATCH 02/16] Drive side-panel layout from a single window list The three branches of sidePanelChildren each spelled out the five side windows by name, so the panel order lived in three places and the status/stash sizing special-cases were tangled into positional literals. Map each branch over one `windows` slice instead, and fold the normal-height special-cases (status's fixed height, stash's collapse-unless-focused) into a single per-window function. Behavior is unchanged; this isolates the ordering so it can later come from config. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/window_arrangement_helper.go | 98 +++++++++++-------- .../helpers/window_arrangement_helper_test.go | 21 ++-- 2 files changed, 67 insertions(+), 52 deletions(-) diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 9061d517729..315e6b47b17 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -50,6 +50,11 @@ type WindowArrangementArgs struct { // Name of the current side window (i.e. the current window in the left // section of the UI) CurrentSideWindow string + // Returns the view currently shown in the given window. When a window holds + // several tabbed views this is the selected tab, which is what the status and + // stash height special-cases key off (rather than the window itself, whose + // name is just its first tab). + ActiveViewForWindow func(window string) string // Whether the main panel is split (as is the case e.g. when a file has both // staged and unstaged changes) SplitMainPanel bool @@ -86,20 +91,21 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string, } args := WindowArrangementArgs{ - Width: width, - Height: height, - UserConfig: self.c.UserConfig(), - CurrentWindow: self.c.Context().CurrentStatic().GetWindowName(), - CurrentSideWindow: self.c.Context().CurrentSide().GetWindowName(), - SplitMainPanel: repoState.GetSplitMainPanel(), - ScreenMode: repoState.GetScreenMode(), - AppStatus: appStatus, - InformationStr: informationStr, - ShowExtrasWindow: self.c.State().GetShowExtrasWindow(), - InDemo: self.c.InDemo(), - IsAnyModeActive: self.modeHelper.IsAnyModeActive(), - InSearchPrompt: repoState.InSearchPrompt(), - SearchPrefix: searchPrefix, + Width: width, + Height: height, + UserConfig: self.c.UserConfig(), + CurrentWindow: self.c.Context().CurrentStatic().GetWindowName(), + CurrentSideWindow: self.c.Context().CurrentSide().GetWindowName(), + ActiveViewForWindow: self.windowHelper.GetViewNameForWindow, + SplitMainPanel: repoState.GetSplitMainPanel(), + ScreenMode: repoState.GetScreenMode(), + AppStatus: appStatus, + InformationStr: informationStr, + ShowExtrasWindow: self.c.State().GetShowExtrasWindow(), + InDemo: self.c.InDemo(), + IsAnyModeActive: self.modeHelper.IsAnyModeActive(), + InSearchPrompt: repoState.InSearchPrompt(), + SearchPrefix: searchPrefix, } return GetWindowDimensions(args) @@ -403,14 +409,15 @@ func getExtrasWindowSize(args WindowArrangementArgs) int { return baseSize + frameSize } -// The stash window by default only contains one line so that it's not hogging +// The stash view by default only contains one line so that it's not hogging // too much space, but if you access it it should take up some space. This is // the default behaviour when accordion mode is NOT in effect. If it is in effect -// then when it's accessed it will have weight 2, not 1. -func getDefaultStashWindowBox(args WindowArrangementArgs) *boxlayout.Box { - box := &boxlayout.Box{Window: "stash"} - // if the stash window is anywhere in our stack we should enlargen it - if args.CurrentSideWindow == "stash" { +// then when it's accessed it will have weight 2, not 1. The window is passed in +// because stash may be a tab of a window named after a different first tab. +func getDefaultStashWindowBox(args WindowArrangementArgs, window string) *boxlayout.Box { + box := &boxlayout.Box{Window: window} + // if the window showing stash is focused we should enlargen it + if args.CurrentSideWindow == window { box.Weight = 1 } else { box.Size = 3 @@ -421,6 +428,16 @@ func getDefaultStashWindowBox(args WindowArrangementArgs) *boxlayout.Box { 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"} + + boxForEachWindow := func(boxForWindow func(window string) *boxlayout.Box) []*boxlayout.Box { + boxes := make([]*boxlayout.Box, 0, len(windows)) + for _, window := range windows { + boxes = append(boxes, boxForWindow(window)) + } + return boxes + } + if args.ScreenMode == types.SCREEN_FULL || args.ScreenMode == types.SCREEN_HALF { fullHeightBox := func(window string) *boxlayout.Box { if window == args.CurrentSideWindow { @@ -436,13 +453,7 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ } } - return []*boxlayout.Box{ - fullHeightBox("status"), - fullHeightBox("files"), - fullHeightBox("branches"), - fullHeightBox("commits"), - fullHeightBox("stash"), - } + return boxForEachWindow(fullHeightBox) } else if height >= 28 { accordionMode := args.UserConfig.Gui.ExpandFocusedSidePanel accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box { @@ -456,16 +467,23 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ return defaultBox } - return []*boxlayout.Box{ - { - Window: "status", - Size: 3, - }, - accordionBox(&boxlayout.Box{Window: "files", Weight: 1}), - accordionBox(&boxlayout.Box{Window: "branches", Weight: 1}), - accordionBox(&boxlayout.Box{Window: "commits", Weight: 1}), - accordionBox(getDefaultStashWindowBox(args)), + normalBox := func(window string) *boxlayout.Box { + // The status and stash sizing is a property of those views, so we key + // off the tab the window is currently showing, not the window's name + // (its first tab): otherwise grouping other tabs behind status or + // stash would wrongly impose their compact height on those tabs. + switch args.ActiveViewForWindow(window) { + case "status": + // The status view has a fixed height and is not expanded by accordion mode. + return &boxlayout.Box{Window: window, Size: 3} + case "stash": + return accordionBox(getDefaultStashWindowBox(args, window)) + default: + return accordionBox(&boxlayout.Box{Window: window, Weight: 1}) + } } + + return boxForEachWindow(normalBox) } squashedHeight := 1 @@ -487,12 +505,6 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ } } - return []*boxlayout.Box{ - squashedSidePanelBox("status"), - squashedSidePanelBox("files"), - squashedSidePanelBox("branches"), - squashedSidePanelBox("commits"), - squashedSidePanelBox("stash"), - } + return boxForEachWindow(squashedSidePanelBox) } } diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go index c63755ef24e..168fc9972c6 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go @@ -24,15 +24,18 @@ func TestGetWindowDimensions(t *testing.T) { UserConfig: config.GetDefaultConfig(), CurrentWindow: "files", CurrentSideWindow: "files", - SplitMainPanel: false, - ScreenMode: types.SCREEN_NORMAL, - AppStatus: "", - InformationStr: "information", - ShowExtrasWindow: false, - InDemo: false, - IsAnyModeActive: false, - InSearchPrompt: false, - SearchPrefix: "", + // Each panel shows its first tab by default; for the special-cased + // panels (status, stash) the view name matches the window name. + ActiveViewForWindow: func(window string) string { return window }, + SplitMainPanel: false, + ScreenMode: types.SCREEN_NORMAL, + AppStatus: "", + InformationStr: "information", + ShowExtrasWindow: false, + InDemo: false, + IsAnyModeActive: false, + InSearchPrompt: false, + SearchPrefix: "", } } From b71ae11c20a199a878e2f18fb03b6fe1ae7ad7c5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 08:30:52 +0200 Subject: [PATCH 03/16] Make the remote-branches view follow its parent's window The remote-branches context is a transient guest that takes over a host window when you drill into a remote. SubCommits and CommitFiles already adopt their parent's window via SetWindowName when shown; RemoteBranches relied instead on its static window name ("branches") matching the remotes context's window. That assumption only holds while remotes lives in the branches panel. Adopt the parent's window like the other transient guests so remote branches render in the right place once panels are configurable. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/remotes_controller.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index b2e30f231a9..bd5b73e5d70 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -140,6 +140,7 @@ func (self *RemotesController) enter(remote *models.Remote) error { remoteBranchesContext.SetSelection(newSelectedLine) remoteBranchesContext.SetTitleRef(remote.Name) remoteBranchesContext.SetParentContext(self.Context()) + remoteBranchesContext.SetWindowName(self.Context().GetWindowName()) remoteBranchesContext.GetView().TitlePrefix = self.Context().GetView().TitlePrefix self.c.PostRefreshUpdate(remoteBranchesContext) From 7d21aae650ba82882f3064d284dbdd940d61abf8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 08:33:24 +0200 Subject: [PATCH 04/16] Assign panel jump labels by iterating panel groups The jump-label prefixes were assigned to each side view by name, twice (once for the on case, once for off), so the panel-to-views grouping and the panel order were baked into 28 positional statements. Express the grouping once as a slice of view groups and loop over it, deriving each panel's label from its index. The label lookup is now bounds-checked, so it no longer assumes exactly as many jump bindings as panels. Behavior is unchanged; this prepares the grouping to come from config. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/views.go | 65 +++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 37 deletions(-) diff --git a/pkg/gui/views.go b/pkg/gui/views.go index ecfc0ddcd21..423c0193ebf 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -210,50 +210,41 @@ func (gui *Gui) configureViewProperties() { gui.Views.CommitDescription.TextArea.AutoWrap = gui.c.UserConfig().Git.Commit.AutoWrapCommitMessage gui.Views.CommitDescription.TextArea.AutoWrapWidth = gui.c.UserConfig().Git.Commit.AutoWrapWidth - if gui.c.UserConfig().Gui.ShowPanelJumps { - keyToTitlePrefix := func(binding config.Keybinding) string { - if len(binding) == 0 { - return "" - } - return fmt.Sprintf("[%s]", binding[0]) + keyToTitlePrefix := func(binding config.Keybinding) string { + if len(binding) == 0 { + return "" } - jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock - jumpLabels := lo.Map(jumpBindings, func(binding config.Keybinding, _ int) string { - return keyToTitlePrefix(binding) - }) - - gui.Views.Status.TitlePrefix = jumpLabels[0] - - gui.Views.Files.TitlePrefix = jumpLabels[1] - gui.Views.Worktrees.TitlePrefix = jumpLabels[1] - gui.Views.Submodules.TitlePrefix = jumpLabels[1] + return fmt.Sprintf("[%s]", binding[0]) + } - gui.Views.Branches.TitlePrefix = jumpLabels[2] - gui.Views.Remotes.TitlePrefix = jumpLabels[2] - gui.Views.Tags.TitlePrefix = jumpLabels[2] + // 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}, + } - gui.Views.Commits.TitlePrefix = jumpLabels[3] - gui.Views.ReflogCommits.TitlePrefix = jumpLabels[3] + jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock + jumpLabelForPanel := func(panelIndex int) string { + if !gui.c.UserConfig().Gui.ShowPanelJumps || panelIndex >= len(jumpBindings) { + return "" + } + return keyToTitlePrefix(jumpBindings[panelIndex]) + } - gui.Views.Stash.TitlePrefix = jumpLabels[4] + for panelIndex, views := range panelViewGroups { + prefix := jumpLabelForPanel(panelIndex) + for _, view := range views { + view.TitlePrefix = prefix + } + } + if gui.c.UserConfig().Gui.ShowPanelJumps { gui.Views.Main.TitlePrefix = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView) } else { - gui.Views.Status.TitlePrefix = "" - - gui.Views.Files.TitlePrefix = "" - gui.Views.Worktrees.TitlePrefix = "" - gui.Views.Submodules.TitlePrefix = "" - - gui.Views.Branches.TitlePrefix = "" - gui.Views.Remotes.TitlePrefix = "" - gui.Views.Tags.TitlePrefix = "" - - gui.Views.Commits.TitlePrefix = "" - gui.Views.ReflogCommits.TitlePrefix = "" - - gui.Views.Stash.TitlePrefix = "" - gui.Views.Main.TitlePrefix = "" } From e00ae277a4d103d6c2fa1843e652efad6f022ee2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 08:46:32 +0200 Subject: [PATCH 05/16] 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) --- docs-master/Config.md | 15 +++++++ pkg/config/side_panel.go | 54 +++++++++++++++++++++++ pkg/config/user_config.go | 12 +++++ pkg/config/user_config_validation.go | 36 +++++++++++++++ pkg/config/user_config_validation_test.go | 36 +++++++++++++++ schema-master/config.json | 47 ++++++++++++++++++++ 6 files changed, 200 insertions(+) create mode 100644 pkg/config/side_panel.go diff --git a/docs-master/Config.md b/docs-master/Config.md index fa6b3eeacc7..81b29739560 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 00000000000..307ed0c1d86 --- /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 d1f760ed1d0..36962ff78d2 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -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, diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 109b3f1d038..3dd5b4b590a 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 26c9b7145da..02e64b02af5 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 b95c5c98053..21ddaab7468 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -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": { From 4f942b1dcb1ebb332e83bb9b955cb7ac2754bf96 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 08:50:12 +0200 Subject: [PATCH 06/16] Stop requiring jumpToBlock to have exactly five entries The number of side panels is about to become configurable, so a fixed count of jump-to-panel keys no longer makes sense: a user who configures six panels shouldn't be forced to also extend jumpToBlock, and one who hides a panel shouldn't have to trim it. Drop the count check entirely (individual keys are still validated) and assign keys to panels positionally, for as many panels as there are keys. Surplus panels go without a jump key but remain reachable via the next/previous-panel keys. This also removes the log.Fatal that the count check guarded against. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/config/user_config_validation.go | 11 +------ pkg/config/user_config_validation_test.go | 7 +++-- .../jump_to_side_window_controller.go | 31 ++++++++++--------- 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 3dd5b4b590a..9550e916064 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -177,16 +177,7 @@ func validateKeybindingsRecurse(path string, node any) error { } func validateKeybindings(keybindingConfig KeybindingConfig) error { - if err := validateKeybindingsRecurse("", keybindingConfig); err != nil { - return err - } - - if len(keybindingConfig.Universal.JumpToBlock) != 5 { - return fmt.Errorf("keybinding.universal.jumpToBlock must have 5 elements; found %d.", - len(keybindingConfig.Universal.JumpToBlock)) - } - - return nil + return validateKeybindingsRecurse("", keybindingConfig) } func validateCustomCommandKey(key Keybinding) error { diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index 02e64b02af5..a0c17636dbd 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -134,11 +134,12 @@ func TestUserConfigValidate_enums(t *testing.T) { }) }, testCases: []testCase{ - {value: "", valid: false}, - {value: "1,2,3", valid: false}, + // The number of entries no longer has to match the number of side + // panels, so only the validity of the individual keys matters. + {value: "1,2,3", valid: true}, {value: "1,2,3,4,5", valid: true}, + {value: "1,2,3,4,5,6", valid: true}, {value: "1,2,3,4,invalid", valid: false}, - {value: "1,2,3,4,5,6", valid: false}, }, }, { diff --git a/pkg/gui/controllers/jump_to_side_window_controller.go b/pkg/gui/controllers/jump_to_side_window_controller.go index 2ea8ac76234..37829849d2c 100644 --- a/pkg/gui/controllers/jump_to_side_window_controller.go +++ b/pkg/gui/controllers/jump_to_side_window_controller.go @@ -1,10 +1,7 @@ package controllers import ( - "log" - "github.com/jesseduffield/lazygit/pkg/gui/types" - "github.com/samber/lo" ) type JumpToSideWindowController struct { @@ -30,19 +27,23 @@ func (self *JumpToSideWindowController) Context() types.Context { func (self *JumpToSideWindowController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { windows := self.c.Helpers().Window.SideWindows() - - if len(opts.Config.Universal.JumpToBlock) != len(windows) { - log.Fatal("Jump to block keybindings cannot be set. Exactly 5 keybindings must be supplied.") - } - - return lo.Map(windows, func(window string, index int) *types.Binding { - return &types.Binding{ + jumpKeys := opts.Config.Universal.JumpToBlock + + // Assign jump keys to panels positionally (by default 1 to the first panel, + // 2 to the second, etc.), for as many panels as there are keys. If there are + // more panels than keys the extra panels just have no jump key, and if there + // are more keys than panels the extra keys are unused; either way panels stay + // reachable via the next/previous-panel keys. + count := min(len(windows), len(jumpKeys)) + bindings := make([]*types.Binding, 0, count) + for i := range count { + bindings = append(bindings, &types.Binding{ ViewName: "", - // by default the keys are 1, 2, 3, etc - Keys: opts.GetKeys(opts.Config.Universal.JumpToBlock[index]), - Handler: opts.Guards.NoPopupPanel(self.goToSideWindow(window)), - } - }) + Keys: opts.GetKeys(jumpKeys[i]), + Handler: opts.Guards.NoPopupPanel(self.goToSideWindow(windows[i])), + }) + } + return bindings } func (self *JumpToSideWindowController) goToSideWindow(window string) func() error { From 1da9ff74dfdee8bec7bccfaaab482e228a8b98d1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 17:54:16 +0200 Subject: [PATCH 07/16] 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) --- .../helpers/window_arrangement_helper.go | 2 +- .../helpers/window_arrangement_helper_test.go | 146 ++++++++++++++++++ pkg/gui/controllers/helpers/window_helper.go | 11 +- pkg/gui/gui.go | 74 ++++----- pkg/gui/side_panels.go | 90 +++++++++++ pkg/gui/side_panels_test.go | 30 ++++ pkg/gui/views.go | 13 +- 7 files changed, 311 insertions(+), 55 deletions(-) create mode 100644 pkg/gui/side_panels.go create mode 100644 pkg/gui/side_panels_test.go diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 315e6b47b17..610c57f52c7 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -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)) diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go index 168fc9972c6..63d7642b6a7 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go @@ -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──────────────────╮│ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + 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────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + 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────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + A: statusSpacer1 + B: information + `, + }, { name: "expandFocusedSidePanel", mutateArgs: func(args *WindowArrangementArgs) { diff --git a/pkg/gui/controllers/helpers/window_helper.go b/pkg/gui/controllers/helpers/window_helper.go index 53531c2ffa8..d9fd017f774 100644 --- a/pkg/gui/controllers/helpers/window_helper.go +++ b/pkg/gui/controllers/helpers/window_helper.go @@ -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] + }) } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e2881cca148..1930b41ecf7 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -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 @@ -620,14 +621,15 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { }, ScreenMode: initialScreenMode, // TODO: only use contexts from context manager - ContextMgr: NewContextMgr(gui, contextTree), - Contexts: contextTree, - WindowViewNameMap: initialWindowViewNameMap(contextTree), - SearchState: types.NewSearchState(), + ContextMgr: NewContextMgr(gui, contextTree), + Contexts: 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 diff --git a/pkg/gui/side_panels.go b/pkg/gui/side_panels.go new file mode 100644 index 00000000000..ee0e70ada4d --- /dev/null +++ b/pkg/gui/side_panels.go @@ -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) + } + } +} diff --git a/pkg/gui/side_panels_test.go b/pkg/gui/side_panels_test.go new file mode 100644 index 00000000000..b1240c35705 --- /dev/null +++ b/pkg/gui/side_panels_test.go @@ -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()))) +} diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 423c0193ebf..bcd0b166efb 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -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 { From 4ca90ebedefe2fa0f83d3bd6864f839b38a286e1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 17:57:38 +0200 Subject: [PATCH 08/16] Give the submodules and reflog views standalone titles These two views only ever appeared as tabs (of the files and commits panels), so unlike the other side views they had no title set; the tab strip supplied their label. Once a tab can be promoted to its own panel they can appear without a tab strip, so set their titles like the others. This has no effect in the default layout, where both are always tabs. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/views.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/gui/views.go b/pkg/gui/views.go index bcd0b166efb..17cbf331687 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -182,10 +182,12 @@ func (gui *Gui) configureViewProperties() { gui.Views.Stash.Title = gui.c.Tr.StashTitle gui.Views.Commits.Title = gui.c.Tr.CommitsTitle + gui.Views.ReflogCommits.Title = gui.c.Tr.ReflogCommitsTitle gui.Views.CommitFiles.Title = gui.c.Tr.CommitFiles gui.Views.Branches.Title = gui.c.Tr.BranchesTitle gui.Views.Remotes.Title = gui.c.Tr.RemotesTitle gui.Views.Worktrees.Title = gui.c.Tr.WorktreesTitle + gui.Views.Submodules.Title = gui.c.Tr.SubmodulesTitle gui.Views.Tags.Title = gui.c.Tr.TagsTitle gui.Views.Files.Title = gui.c.Tr.FilesTitle gui.Views.PatchBuilding.Title = gui.c.Tr.Patch From c54f3859e586aebaa5dbbb38e2eca0319a3d51c5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 18:07:40 +0200 Subject: [PATCH 09/16] Scale the minimum window height with the panel count In squashed mode (short terminals) the unfocused side panels each reserve a row and the focused panel takes whatever is left, so once the unfocused panels' rows fill the height the focused panel collapses to nothing and panels below it render off-screen. The fixed floor of 9 was tuned for five panels; with the panel count now configurable (and promotion allowing up to ten), grow the floor by one per panel so we show the "not enough space" view instead of a broken layout. Five panels still floor at 9. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/layout.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index dacd93f68bc..290d851c7df 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -133,7 +133,12 @@ func (gui *Gui) layout(g *gocui.Gui) error { } } - minimumHeight := 9 + // When the screen is too short the side panels are squashed, with the + // unfocused ones taking one row each and the focused one taking the rest. The + // more panels there are, the more rows the unfocused ones reserve, so the + // floor below which there's no room left for the focused panel grows with the + // panel count. Keep the historical floor of 9 for the default five panels. + minimumHeight := max(9, len(gui.helpers.Window.SideWindows())+4) minimumWidth := 10 gui.Views.Limit.Visible = height < minimumHeight || width < minimumWidth From da48008ba32abb6d8d8e084b23dc28584d4e6dee Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 18:13:54 +0200 Subject: [PATCH 10/16] Show each panel's first configured tab by default Within a window the visible tab is whichever view sits on top in the z-order, and onRepoViewReset establishes that z-order from a fixed list that needn't agree with the configured tab order. After ordering the views, bring each panel's first configured tab to the top so that, for a panel whose tabs have been reordered, the configured first tab is the one shown before the panel is focused. No effect on the default layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/layout.go | 4 ++++ pkg/gui/side_panels.go | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index 290d851c7df..6f7dc71873e 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -254,6 +254,10 @@ func (gui *Gui) onRepoViewReset() error { } } + // The loop above orders views by a fixed list, which doesn't necessarily put + // each panel's first configured tab on top. + gui.moveDefaultTabsToTop() + return nil } diff --git a/pkg/gui/side_panels.go b/pkg/gui/side_panels.go index ee0e70ada4d..2b41eb9c14e 100644 --- a/pkg/gui/side_panels.go +++ b/pkg/gui/side_panels.go @@ -65,6 +65,17 @@ func (gui *Gui) applySidePanelConfig() { gui.State.WindowViewNameMap = gui.initialWindowViewNameMap(contextTree) } +// moveDefaultTabsToTop brings each panel's first configured tab to the top of +// its window, so the configured default tab is the one shown when a panel hasn't +// been focused yet (the view z-order is otherwise set from a fixed list that +// need not match the configured tab order). +func (gui *Gui) moveDefaultTabsToTop() { + contexts := sidePanelContexts(gui.State.Contexts) + for _, panel := range gui.c.UserConfig().Gui.SidePanels { + gui.helpers.Window.MoveToTopOfWindow(contexts[panel[0]]) + } +} + // 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 From f3a0d7c95d568b79239572adb2b6a2ed9aae7dc7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 18:16:22 +0200 Subject: [PATCH 11/16] Add integration tests for configuring the side panels Cover the three things gui.sidePanels enables: reordering the panels (swapping branches and commits, checked via their jump keys), hiding a panel (omitting stash, checked by cycling past the last panel and wrapping to the first), and promoting a tab to its own panel (worktrees becomes a top-level panel reachable by a jump key, and the files panel's remaining tabs cycle straight to submodules). The tests drive focus with explicit jump keys rather than ViewDriver.Focus, which assumes the default panel layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/integration/tests/test_list.go | 3 ++ pkg/integration/tests/ui/hide_side_panel.go | 33 +++++++++++++++ .../tests/ui/promote_tab_to_side_panel.go | 40 +++++++++++++++++++ .../tests/ui/reorder_side_panels.go | 33 +++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 pkg/integration/tests/ui/hide_side_panel.go create mode 100644 pkg/integration/tests/ui/promote_tab_to_side_panel.go create mode 100644 pkg/integration/tests/ui/reorder_side_panels.go diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 1b264e50dc9..f4559127dac 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -473,11 +473,14 @@ var tests = []*components.IntegrationTest{ ui.Accordion, ui.DisableSwitchTabWithPanelJumpKeys, ui.EmptyMenu, + ui.HideSidePanel, ui.KeybindingSuggestionsDontCrashOnDisabledBindings, ui.KeybindingSuggestionsWhenSwitchingRepos, ui.ModeSpecificKeybindingSuggestions, ui.OpenLinkFailure, + ui.PromoteTabToSidePanel, ui.RangeSelect, + ui.ReorderSidePanels, ui.SwitchTabFromMenu, ui.SwitchTabWithPanelJumpKeys, undo.UndoCheckoutAndDrop, diff --git a/pkg/integration/tests/ui/hide_side_panel.go b/pkg/integration/tests/ui/hide_side_panel.go new file mode 100644 index 00000000000..95f611daa39 --- /dev/null +++ b/pkg/integration/tests/ui/hide_side_panel.go @@ -0,0 +1,33 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var HideSidePanel = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Hide a side panel by omitting it from gui.sidePanels", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + // No stash panel. + cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{ + {"status"}, + {"files", "worktrees", "submodules"}, + {"branches", "remotes", "tags"}, + {"commits", "reflog"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(2) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Commits is now the last panel; cycling forward from it wraps around to + // the status panel, skipping the hidden stash panel entirely. + t.Views().Files().IsFocused(). + Press(keys.Universal.JumpToBlock[3]) + t.Views().Commits().IsFocused(). + Press(keys.Universal.NextBlock) + t.Views().Status().IsFocused() + }, +}) diff --git a/pkg/integration/tests/ui/promote_tab_to_side_panel.go b/pkg/integration/tests/ui/promote_tab_to_side_panel.go new file mode 100644 index 00000000000..ea0fe82d066 --- /dev/null +++ b/pkg/integration/tests/ui/promote_tab_to_side_panel.go @@ -0,0 +1,40 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PromoteTabToSidePanel = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Promote the worktrees tab to its own top-level side panel via gui.sidePanels", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + // Worktrees is pulled out of the files panel into its own panel. + cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{ + {"status"}, + {"files", "submodules"}, + {"worktrees"}, + {"branches", "remotes", "tags"}, + {"commits", "reflog"}, + {"stash"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(2) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Worktrees is now its own panel in the third position, reachable by its + // jump key rather than as a tab of the files panel. + t.Views().Files().IsFocused(). + Press(keys.Universal.JumpToBlock[2]) + t.Views().Worktrees().IsFocused(). + Press(keys.Universal.JumpToBlock[1]) + + // The files panel's tabs are now just files and submodules, so cycling + // tabs from files goes straight to submodules. + t.Views().Files().IsFocused(). + Press(keys.Universal.NextTab) + t.Views().Submodules().IsFocused() + }, +}) diff --git a/pkg/integration/tests/ui/reorder_side_panels.go b/pkg/integration/tests/ui/reorder_side_panels.go new file mode 100644 index 00000000000..1d1f241f03d --- /dev/null +++ b/pkg/integration/tests/ui/reorder_side_panels.go @@ -0,0 +1,33 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ReorderSidePanels = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Reorder the side panels with gui.sidePanels, swapping the branches and commits panels", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{ + {"status"}, + {"files", "worktrees", "submodules"}, + {"commits", "reflog"}, + {"branches", "remotes", "tags"}, + {"stash"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(2) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // The third panel is now commits and the fourth is branches (the reverse + // of the default order), so their jump keys are swapped. + t.Views().Files().IsFocused(). + Press(keys.Universal.JumpToBlock[2]) + t.Views().Commits().IsFocused(). + Press(keys.Universal.JumpToBlock[3]) + t.Views().Branches().IsFocused() + }, +}) From f9253eace2e7c57a55b822de69d26cd7f72778ff Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 18:54:15 +0200 Subject: [PATCH 12/16] Clear tab strips on views that are no longer tabs The tab-assignment loop only ever set a view's tabs; it never cleared them. That was fine when the groupings were fixed, but with gui.sidePanels a config reload can turn a tab into a standalone panel, and the old tab strip would linger on its title. Index the tab strips by view name and assign to every view, so views that dropped out of a multi-tab panel get their tabs cleared. No change for a given config; this only matters across a reload. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/views.go | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 17cbf331687..b47e76c5a97 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -9,7 +9,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/samber/lo" - "golang.org/x/exp/slices" ) type viewNameMapping struct { @@ -249,19 +248,27 @@ func (gui *Gui) configureViewProperties() { gui.Views.Main.TitlePrefix = "" } - for _, view := range gui.g.Views() { - // if the view is in our mapping, we'll set the tabs and the tab index - for _, values := range gui.viewTabMap() { - index := slices.IndexFunc(values, func(tabContext context.TabView) bool { - return tabContext.ViewName == view.Name() - }) - - if index != -1 { - view.Tabs = lo.Map(values, func(tabContext context.TabView, _ int) string { - return tabContext.Tab - }) - view.TabIndex = index - } + // Index the tab strips by view so we can both set them on views that are + // part of a multi-tab panel and clear them on views that no longer are + // (which matters when the config is reloaded and a tab becomes a standalone + // panel). + type viewTabs struct { + tabs []string + index int + } + tabsByView := map[string]viewTabs{} + for _, values := range gui.viewTabMap() { + labels := lo.Map(values, func(tabContext context.TabView, _ int) string { + return tabContext.Tab + }) + for index, tabContext := range values { + tabsByView[tabContext.ViewName] = viewTabs{tabs: labels, index: index} } } + + for _, view := range gui.g.Views() { + vt := tabsByView[view.Name()] + view.Tabs = vt.tabs + view.TabIndex = vt.index + } } From b38c0df0eedfb6e439eb0e2da465726d0cc9d168 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 15 Jun 2026 11:29:05 +0200 Subject: [PATCH 13/16] Let integration tests post a focus event Lazygit reloads changed config files when its terminal window regains focus, but the test harness had no way to simulate that focus event, so the live config-reload path was untestable. Add a focus event to the replayed-events queue and expose it through the GuiDriver as FocusIn. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 2 ++ pkg/gocui/tcell_driver.go | 18 ++++++++++++++++++ pkg/gui/gui_driver.go | 12 ++++++++++++ pkg/integration/components/test_driver.go | 8 ++++++++ pkg/integration/components/test_test.go | 3 +++ pkg/integration/types/types.go | 3 +++ 6 files changed, 46 insertions(+) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 7b691f6d74f..64dec4c066a 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -103,6 +103,7 @@ type replayedEvents struct { Keys chan *TcellKeyEventWrapper Resizes chan *TcellResizeEventWrapper MouseEvents chan *TcellMouseEventWrapper + FocusEvents chan *TcellFocusEventWrapper } type RecordingConfig struct { @@ -245,6 +246,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { Keys: make(chan *TcellKeyEventWrapper), Resizes: make(chan *TcellResizeEventWrapper), MouseEvents: make(chan *TcellMouseEventWrapper), + FocusEvents: make(chan *TcellFocusEventWrapper), } } diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index b2fd40c19a9..226ee05802c 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -266,6 +266,22 @@ func (wrapper TcellResizeEventWrapper) toTcellEvent() tcell.Event { return tcell.NewEventResize(wrapper.Width, wrapper.Height) } +type TcellFocusEventWrapper struct { + Timestamp int64 + Focused bool +} + +func NewTcellFocusEventWrapper(event *tcell.EventFocus, timestamp int64) *TcellFocusEventWrapper { + return &TcellFocusEventWrapper{ + Timestamp: timestamp, + Focused: event.Focused, + } +} + +func (wrapper TcellFocusEventWrapper) toTcellEvent() tcell.Event { + return tcell.NewEventFocus(wrapper.Focused) +} + // pollEvent get tcell.Event and transform it into gocuiEvent func (g *Gui) pollEvent() GocuiEvent { var tev tcell.Event @@ -277,6 +293,8 @@ func (g *Gui) pollEvent() GocuiEvent { tev = (ev).toTcellEvent() case ev := <-g.ReplayedEvents.MouseEvents: tev = (ev).toTcellEvent() + case ev := <-g.ReplayedEvents.FocusEvents: + tev = (ev).toTcellEvent() } } else { tev = <-Screen.EventQ() diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 08f3ecf6229..632e271c3fd 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -56,6 +56,18 @@ func (self *GuiDriver) Click(x, y int) { self.waitTillIdle() } +// FocusIn simulates the terminal window regaining focus, which is how lazygit +// learns to reload changed config files. Tests use it to exercise the live +// config-reload path. +func (self *GuiDriver) FocusIn() { + self.gui.g.ReplayedEvents.FocusEvents <- gocui.NewTcellFocusEventWrapper( + tcell.NewEventFocus(true), + 0, + ) + + self.waitTillIdle() +} + // wait until lazygit is idle (i.e. all processing is done) before continuing func (self *GuiDriver) waitTillIdle() { <-self.isIdleChan diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 8294f3b46fa..301ab386294 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -56,6 +56,14 @@ func (self *TestDriver) GlobalPress(key config.Keybinding) { self.press(key[0]) } +// FocusIn simulates the terminal window regaining focus, which causes lazygit +// to reload any config files that changed while it was in the background. +func (self *TestDriver) FocusIn() { + self.SetCaption("Focusing window") + self.gui.FocusIn() + self.Wait(self.inputDelay) +} + func (self *TestDriver) typeContent(content string) { for _, char := range content { self.pressFast(string(char)) diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index ab32f9f899f..b00a2a672c4 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -34,6 +34,9 @@ func (self *fakeGuiDriver) Click(x, y int) { self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) } +func (self *fakeGuiDriver) FocusIn() { +} + func (self *fakeGuiDriver) Keys() config.KeybindingConfig { return config.KeybindingConfig{} } diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 75263905839..3d87e7d6e84 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -24,6 +24,9 @@ type IntegrationTest interface { type GuiDriver interface { PressKey(string) Click(int, int) + // Simulate the terminal window regaining focus (which triggers a reload of + // changed config files) + FocusIn() Keys() config.KeybindingConfig CurrentContext() types.Context ContextForView(viewName string) types.Context From 615e3b146b91cdc02a4b5fbc02993426cb925a6d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 15 Jun 2026 11:32:24 +0200 Subject: [PATCH 14/16] Add an IsActiveTab assertion for integration tests Side panel tabs share a window, so which tab is shown is decided by view z-order rather than the visibility flag (every tab in a window is 'visible'). Tests had no way to assert which tab is actually drawn in front, which is distinct from which view has keyboard focus. Expose the window's top view and add an IsActiveTab assertion built on it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/gui_driver.go | 6 ++++++ pkg/integration/components/test_test.go | 4 ++++ pkg/integration/components/view_driver.go | 22 ++++++++++++++++++++++ pkg/integration/types/types.go | 2 ++ 4 files changed, 34 insertions(+) diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 632e271c3fd..57425231ad9 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -153,6 +153,12 @@ func (self *GuiDriver) View(viewName string) *gocui.View { return view } +// TopViewInWindow returns the frontmost visible view in the given window, i.e. +// the tab that is currently shown when a window holds several tabbed views. +func (self *GuiDriver) TopViewInWindow(windowName string) *gocui.View { + return self.gui.helpers.Window.TopViewInWindow(windowName, false) +} + func (self *GuiDriver) SetCaption(caption string) { self.gui.setCaption(caption) self.waitTillIdle() diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index b00a2a672c4..e7d03ada968 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -75,6 +75,10 @@ func (self *fakeGuiDriver) View(viewName string) *gocui.View { return nil } +func (self *fakeGuiDriver) TopViewInWindow(windowName string) *gocui.View { + return nil +} + func (self *fakeGuiDriver) SetCaption(string) { } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index e9e5fbbc70e..df4b9d7d898 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -408,6 +408,28 @@ func (self *ViewDriver) IsFocused() *ViewDriver { return self } +// asserts that the view is the one currently shown in its window, i.e. it's the +// active tab of its panel (drawn in front of the window's other tabs). Unlike +// IsFocused, this is about what's displayed rather than which view has keyboard +// focus; the two can disagree, e.g. if a config reload reshuffles the tabs. +func (self *ViewDriver) IsActiveTab() *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + expected := self.getView().Name() + context := self.t.gui.ContextForView(expected) + if context == nil { + return false, fmt.Sprintf("%s: Could not find context for view, so can't determine its window", expected) + } + topView := self.t.gui.TopViewInWindow(context.GetWindowName()) + actual := "" + if topView != nil { + actual = topView.Name() + } + return actual == expected, fmt.Sprintf("%s: Expected view to be the active tab of its window, but it was %s", expected, actual) + }) + + return self +} + func (self *ViewDriver) Press(key config.Keybinding) *ViewDriver { self.IsFocused() diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 3d87e7d6e84..cd6102cdfca 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -44,6 +44,8 @@ type GuiDriver interface { // e.g. when we're showing both staged and unstaged changes SecondaryView() *gocui.View View(viewName string) *gocui.View + // the frontmost visible view in the given window, i.e. the currently shown tab + TopViewInWindow(windowName string) *gocui.View SetCaption(caption string) SetCaptionPrefix(prefix string) // Pop the next toast that was displayed; returns nil if there was none From d4392a119567c3a80784ec233e8b6c9e8a6a7bb2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 15 Jun 2026 09:03:44 +0200 Subject: [PATCH 15/16] Re-apply the side panel config on a live config reload When the config file changes and lazygit regains focus it reloads the config, but the side panel window assignments, default views, tab strips, and z-order were only ever set up on repo entry, so a changed sidePanels wouldn't take effect until restart. Re-apply it from the reload path: reassign windows and default views and restore each panel's default tab. The focused panel needs care: resetting it to its default tab would leave the focused tab hidden behind that default tab, so the panel looks unfocused even though its tab is selected. Re-focus the current context so its tab stays shown and highlighted; only when the new config hides the focused panel entirely do we move focus to the default side panel. Tab strips are already refreshed via configureViewProperties. --- pkg/gui/gui.go | 1 + pkg/gui/side_panels.go | 29 ++++++++++- pkg/integration/tests/test_list.go | 1 + .../tests/ui/reload_side_panels.go | 51 +++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/ui/reload_side_panels.go diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 1930b41ecf7..7f488d436bf 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -357,6 +357,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context if didChange && reloadErr == nil { gui.c.Log.Info("User config changed - reloading") reloadErr = gui.onUserConfigLoaded() + gui.reloadSidePanels() if err := gui.resetKeybindings(); err != nil { return err } diff --git a/pkg/gui/side_panels.go b/pkg/gui/side_panels.go index 2b41eb9c14e..361d54fb1cb 100644 --- a/pkg/gui/side_panels.go +++ b/pkg/gui/side_panels.go @@ -3,6 +3,7 @@ package gui import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" ) // sidePanelViewNames maps each gui.sidePanels name to the gocui view it controls. @@ -58,7 +59,8 @@ func sidePanelContexts(contextTree *context.ContextTree) map[string]types.Contex // 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). +// every repo entry (a repo's per-repo config can differ from the previous one's) +// and on a live config reload. func (gui *Gui) applySidePanelConfig() { contextTree := gui.State.Contexts gui.assignSidePanelWindows(contextTree) @@ -76,6 +78,31 @@ func (gui *Gui) moveDefaultTabsToTop() { } } +// reloadSidePanels re-applies the side panel config to the current repo after a +// live config reload: it reassigns windows and default views, restores each +// panel's default tab, and keeps the focused panel in a consistent state. +func (gui *Gui) reloadSidePanels() { + gui.applySidePanelConfig() + gui.moveDefaultTabsToTop() + + // applySidePanelConfig reset every window to show its first configured tab, + // which would leave the focused tab hidden behind its panel's default tab + // (the panel would look unfocused even though its tab is selected). Re-focus + // the current context so its tab stays shown and highlighted. If the new + // config has hidden the focused panel entirely, move focus to the default + // side panel instead. + current := gui.c.Context().Current() + if current.GetKind() != types.SIDE_CONTEXT { + return + } + + if lo.Contains(gui.helpers.Window.SideWindows(), current.GetWindowName()) { + gui.c.Context().Activate(current, types.OnFocusOpts{}) + } else { + gui.c.Context().Push(gui.defaultSideContext(), types.OnFocusOpts{}) + } +} + // 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 diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index f4559127dac..886dad7c257 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -480,6 +480,7 @@ var tests = []*components.IntegrationTest{ ui.OpenLinkFailure, ui.PromoteTabToSidePanel, ui.RangeSelect, + ui.ReloadSidePanels, ui.ReorderSidePanels, ui.SwitchTabFromMenu, ui.SwitchTabWithPanelJumpKeys, diff --git a/pkg/integration/tests/ui/reload_side_panels.go b/pkg/integration/tests/ui/reload_side_panels.go new file mode 100644 index 00000000000..9d8b7769341 --- /dev/null +++ b/pkg/integration/tests/ui/reload_side_panels.go @@ -0,0 +1,51 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ReloadSidePanels = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Editing the side panel config and refocusing the window re-applies the layout live, keeping the focused panel focused", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(2) + // Start with worktrees promoted to its own panel. + shell.CreateFile(".git/lazygit.yml", ` +gui: + sidePanels: + - [status] + - [files, submodules] + - [worktrees] + - [branches, remotes, tags] + - [commits, reflog] + - [stash]`) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Worktrees is its own panel in the third position. + t.Views().Files().IsFocused(). + Press(keys.Universal.JumpToBlock[2]) + t.Views().Worktrees().IsFocused() + + // Demote worktrees back into the files panel, then refocus the window to + // trigger a live reload of the changed config. + t.Shell().UpdateFile(".git/lazygit.yml", ` +gui: + sidePanels: + - [status] + - [files, worktrees, submodules] + - [branches, remotes, tags] + - [commits, reflog] + - [stash]`) + t.FocusIn() + + // Worktrees is now a tab of the files panel. It stays focused, and is shown + // in front rather than being hidden behind the files tab (which would leave + // the panel looking unfocused). + t.Views().Worktrees().IsActiveTab().IsFocused(). + Press(keys.Universal.PrevTab) + t.Views().Files().IsActiveTab().IsFocused() + }, +}) From 7b37c2be59610fe12d642b6a83ae3291c8b0ac42 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 19:02:39 +0200 Subject: [PATCH 16/16] Test per-repo side panel config and re-application on repo switch Exercises the path the live reload relies on: a per-repo lazygit.yml sets a different side panel layout, and switching between repos re-applies each one's own layout (the new-repo path for the cloned repo, the cached-repo path on switching back). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../config/side_panels_in_per_repo_config.go | 58 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 59 insertions(+) create mode 100644 pkg/integration/tests/config/side_panels_in_per_repo_config.go diff --git a/pkg/integration/tests/config/side_panels_in_per_repo_config.go b/pkg/integration/tests/config/side_panels_in_per_repo_config.go new file mode 100644 index 00000000000..ad2f63a1a40 --- /dev/null +++ b/pkg/integration/tests/config/side_panels_in_per_repo_config.go @@ -0,0 +1,58 @@ +package config + +import ( + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SidePanelsInPerRepoConfig = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A per-repo config can set the side panel layout, and switching repos re-applies each repo's own layout", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + otherRepo, _ := filepath.Abs("../other") + cfg.GetAppState().RecentRepos = []string{otherRepo} + }, + SetupRepo: func(shell *Shell) { + shell.CloneNonBare("other") + // The other repo swaps the branches and commits panels. + shell.CreateFile("../other/.git/lazygit.yml", ` +gui: + sidePanels: + - [status] + - [files, worktrees, submodules] + - [commits, reflog] + - [branches, remotes, tags] + - [stash]`) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // This repo uses the default layout, so the third panel is branches. + t.GlobalPress(keys.Universal.JumpToBlock[2]) + t.Views().Branches().IsFocused() + + // Switch to the other repo, whose per-repo config swaps branches and commits. + t.GlobalPress(keys.Universal.OpenRecentRepos) + t.ExpectPopup().Menu().Title(Equals("Recent repositories")). + Lines( + Contains("other").IsSelected(), + Contains("Cancel"), + ).Confirm() + t.Views().Status().Content(Contains("other → master")) + + // Now the third panel is commits. + t.GlobalPress(keys.Universal.JumpToBlock[2]) + t.Views().Commits().IsFocused() + + // Switch back to the first repo; its default layout is intact even though + // its contexts were built before we visited the other repo. + t.GlobalPress(keys.Universal.JumpToBlock[1]) + t.Views().Files().IsFocused() + t.GlobalPress(keys.Universal.OpenRecentRepos) + t.ExpectPopup().Menu().Title(Equals("Recent repositories")).Confirm() + + t.GlobalPress(keys.Universal.JumpToBlock[2]) + t.Views().Branches().IsFocused() + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 886dad7c257..ed2a34a9ac5 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -161,6 +161,7 @@ var tests = []*components.IntegrationTest{ config.CustomCommandsInPerRepoConfig, config.NegativeRefspec, config.RemoteNamedStar, + config.SidePanelsInPerRepoConfig, conflicts.Filter, conflicts.MergeFileBoth, conflicts.MergeFileCurrent,