Lazygit's side panels were a fixed set of five (status, files, branches, commits, stash); this lets anyone editing their config reorder them, group several into tabs on one panel, hide the optional ones, or split a tab out into its own panel.
SidePanel) rather than one fixed name, so tabs can be grouped onto a shared panel or split into their ownfiles, branches, and commits stay mandatory because other code focuses them directly; every other panel can be hidden or reorderedShowing 59 packages and symbols, 50 relations between them. 41 changed symbols drawn as nodes, 0 listed below. 17 relations between a package and a package inside it are not drawn because the containment box already shows them. The symbols level holds 46 boxes, too many to open unasked, so this starts on packages. Switch with the button above to draw it.
Adds the SidePanel type, the SidePanels config field, and validateSidePanels validation for the new gui.sidePanels key.
Defines the SidePanel type and the list of valid tab names backing the new gui.sidePanels config.
SidePanel is a list of tab names; MarshalYAML writes it in flow style [a, b], and JSONSchema restricts entries to ValidSidePanelTabs.
@@ -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}, + } +}
Validates the new gui.sidePanels config and drops the now-stale assumption that jumpToBlock always has 5 entries.
validateSidePanels rejects empty or duplicate/unknown tab names and requires files, branches, commits to stay present since code focuses them directly.
@@ -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 }
validateKeybindings drops the check that Universal.JumpToBlock must have exactly 5 elements, since panel count is no longer fixed.
@@ -141,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 {
Wires the new SidePanels field into GuiConfig and seeds its default panel/tab layout.
GuiConfig gains SidePanels []SidePanel, read from the sidePanels yaml key, ordering top-level panels and their grouped tabs.
@@ -109,6 +109,11 @@ type GuiConfig struct { ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"` // The weight of the expanded side panel, relative to the other panels. 2 means twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true. ExpandedSidePanelWeight int `yaml:"expandedSidePanelWeight"` + // The side panels, in the order they appear from top to bottom. + // Each entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys). + // Omit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel. + // Valid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and 'commits' must always be included; they can't be hidden. + SidePanels []SidePanel `yaml:"sidePanels"` // Sometimes the main window is split in two (e.g. when the selected file has both staged and unstaged changes). This setting controls how the two sections are split. // Options are: // - 'horizontal': split the window horizontally
GetDefaultConfigForPlatform seeds SidePanels with the current layout: status; files/worktrees/submodules; branches/remotes/tags; commits/reflog; stash.
@@ -848,6 +853,13 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { SidePanelWidth: 0.3333, ExpandFocusedSidePanel: false, ExpandedSidePanelWeight: 2, + SidePanels: []SidePanel{ + {"status"}, + {"files", "worktrees", "submodules"}, + {"branches", "remotes", "tags"}, + {"commits", "reflog"}, + {"stash"}, + }, MainPanelSplitMode: "flexible", EnlargedSideViewLocation: "left", WrapLinesInStagingView: true,
Updates the relaxed jumpToBlock length test cases and adds coverage for sidePanels validation.
jumpToBlock cases for 1,2,3 and 1,2,3,4,5,6 now expect valid: true, matching the dropped fixed-length-5 requirement.
@@ -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}, }, }, {
TestUserConfigValidate_sidePanels covers reordering, hiding, and promoting tabs, and rejects empty/duplicate/unknown names or a missing files/branches/commits.
@@ -324,6 +325,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
SideWindows and sidePanelChildren size and key panels by ActiveViewForWindow instead of a fixed five-panel list, and jump keys and window names follow the same config.
Threads a per-window active-tab lookup through window sizing so grouped side panels size by whichever tab is currently shown, not by the window's fixed first-tab name.
ActiveViewForWindow returns a window's currently selected tab, so the status/stash height special-cases key off the active tab rather than the window's first-tab name.
@@ -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
Populates the new ActiveViewForWindow field with self.windowHelper.GetViewNameForWindow so GetWindowDimensions can resolve each window's active tab.
@@ -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)
getDefaultStashWindowBox now takes a window param and sizes that box directly instead of hardcoding "stash", since stash may not be a panel's first tab.
@@ -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
sidePanelChildren now derives windows from sideWindowNames(args.UserConfig) and a boxForEachWindow helper, replacing the fixed five-window list.
@@ -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 := sideWindowNames(args.UserConfig) + + 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 {
Full/half screen mode now builds boxes via boxForEachWindow(fullHeightBox) instead of listing status, files, branches, commits, stash literally.
@@ -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 {
normalBox switches on args.ActiveViewForWindow(window): only a window actually showing status or stash gets their special height, not just one named after them.
@@ -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
The squashed layout also switches to boxForEachWindow(squashedSidePanelBox), dropping the hardcoded window list so it follows the configured panels too.
@@ -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) } }
Adjusts the minimum-height floor and default-tab ordering to account for a configurable number and order of side panels.
minimumHeight is now max(9, len(SideWindows())+4), since more panels reserve more one-row unfocused slots before the focused panel gets squeezed.
@@ -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
onRepoViewReset now calls gui.moveDefaultTabsToTop() since the fixed view-ordering loop doesn't put each panel's configured default tab on top.
@@ -249,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 }
Derives the side window list from user config instead of a hardcoded set, so reordering, hiding, or regrouping tabs in gui.sidePanels takes effect.
@@ -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"
SideWindows now delegates to the new sideWindowNames, which maps userConfig.Gui.SidePanels to each panel's first tab (panel[0]), so window order and membership follow the gui.sidePanels config.
@@ -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] + }) }
Lets jump-to-panel keybindings degrade gracefully when the number of configured keys and side panels differ.
@@ -1,10 +1,7 @@ package controllers import ( - "log" - "github.com/jesseduffield/lazygit/pkg/gui/types" - "github.com/samber/lo" ) type JumpToSideWindowController struct {
Loop bound is min(len(windows), len(jumpKeys)), replacing the exact-length log.Fatal check, so mismatched counts no longer crash.
@@ -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 {
Keeps a remote's branches context in the same window as its parent when drilling into a remote.
remoteBranchesContext.SetWindowName now inherits the parent's GetWindowName(), so remote branches land in the same panel window as remotes.
@@ -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)
Adds test coverage for configurable, grouped, and hidden side panels driven by the new gui.sidePanels config.
Default test args now set ActiveViewForWindow to func(window) string { return window }, matching prior behavior where each window's active tab equals its own name.
@@ -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: "", } }
New cases set UserConfig.Gui.SidePanels to promote worktrees into its own panel, omit stash to hide it, and group stash with branches while ActiveViewForWindow reports branches active, so the panel gets full height instead of stash's compact size.
@@ -121,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──────────────────╮│ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + <options──────────────────────────────────────────────────────>A<B────────> + 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────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + <options──────────────────────────────────────────────────────>A<B────────> + 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────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + <options──────────────────────────────────────────────────────>A<B────────> + A: statusSpacer1 + B: information + `, + }, { name: "expandFocusedSidePanel", mutateArgs: func(args *WindowArrangementArgs) {
applySidePanelConfig and assignSidePanelWindows assign windows and tabs from gui.sidePanels, and a new FocusEvents channel lets reloadSidePanels re-apply it live when the terminal regains focus.
Derives view titles, jump-label prefixes, and tab strips from the configurable gui.sidePanels layout instead of hardcoded panel groupings
@@ -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 {
Sets gui.Views.ReflogCommits.Title and gui.Views.Submodules.Title directly, since they can now be promoted to standalone panels instead of always being tabs.
@@ -182,10 +181,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
Builds panelViewGroups from gui.c.UserConfig().Gui.SidePanels and assigns each group's jumpLabelForPanel prefix in a loop, replacing the old fixed jumpLabels[0..4] assignments per view. Tab strips are now computed into tabsByView and applied to every view (clearing stale Tabs/TabIndex on views no longer grouped), instead of only setting tabs on views found in gui.viewTabMap().
@@ -210,66 +211,64 @@ 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 := 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 + }) + }) - 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 = "" } - 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 + } }
Adds an IsActiveTab assertion to check which view is the visibly displayed tab of its window, separate from keyboard focus
IsActiveTab compares the view's name against TopViewInWindow(context.GetWindowName()), since a reload can make the focused view differ from the one actually drawn on top.
@@ -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()
Adds integration-test driver hooks for simulating focus-triggered config reload and reading the active tab of a window.
FocusIn sends a synthetic FocusEvents event so tests can exercise the config-reload path triggered by terminal focus.
@@ -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
TopViewInWindow exposes Window.TopViewInWindow so tests can assert which tab is frontmost in a given window.
@@ -141,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()
Implements TcellFocusEventWrapper so terminal focus/unfocus events round-trip through gocui's event-replay system.
TcellFocusEventWrapper wraps tcell.EventFocus's Focused bool with a Timestamp; toTcellEvent rebuilds it via tcell.NewEventFocus.
@@ -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
pollEvent adds a case on g.ReplayedEvents.FocusEvents, converting replayed focus events back to a 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()
Extends the GuiDriver test interface with hooks needed to drive and assert the new side-panel focus/reload behavior.
Adds FocusIn to GuiDriver so tests can simulate the terminal regaining focus, which triggers a live config reload.
@@ -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
Adds TopViewInWindow to GuiDriver so tests can assert which tab is frontmost in a given window.
@@ -41,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
Adds a replayable FocusEvents channel so terminal focus events can be recorded and replayed like keys and mouse events.
replayedEvents gains a FocusEvents channel of *TcellFocusEventWrapper so recordings can replay terminal focus/unfocus events.
@@ -103,6 +103,7 @@ type replayedEvents struct { Keys chan *TcellKeyEventWrapper Resizes chan *TcellResizeEventWrapper MouseEvents chan *TcellMouseEventWrapper + FocusEvents chan *TcellFocusEventWrapper } type RecordingConfig struct {
NewGui initializes the new FocusEvents channel alongside Keys, Resizes, and MouseEvents for recording playback.
@@ -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), } }
Applies the new gui.sidePanels config on repo load and live reload, and builds panel tab maps dynamically from it.
Config reload now calls gui.reloadSidePanels() before resetKeybindings, so gui.sidePanels changes take effect without restarting.
@@ -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 }
Repo switch now calls gui.applySidePanelConfig() instead of building WindowViewNameMap inline, since a per-repo config can reorder panels.
@@ -581,8 +582,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
State literal drops the inline WindowViewNameMap field; gui.applySidePanelConfig() after construction now sets it via assignSidePanelWindows.
@@ -622,14 +624,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) }
initialWindowViewNameMap becomes a gui method so it can add each SidePanels entry's sidePanelViewNames[panel[0]] as that window's default view.
@@ -660,13 +663,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 }
viewTabMap builds tabs from gui.c.UserConfig().Gui.SidePanels and sidePanelTabTitles() instead of hardcoded branches/commits/files maps, skipping single-tab panels.
@@ -836,45 +845,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
New module implementing the gui.sidePanels config: grouping tabs into windows, assigning default tabs, and reapplying on reload.
Defines sidePanelViewNames/sidePanelContexts lookup maps plus applySidePanelConfig, assignSidePanelWindows, moveDefaultTabsToTop, reloadSidePanels that group tabs into windows per gui.sidePanels.
@@ -0,0 +1,128 @@ +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. +// 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) +// and on a live config reload. +func (gui *Gui) applySidePanelConfig() { + contextTree := gui.State.Contexts + gui.assignSidePanelWindows(contextTree) + 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]]) + } +} + +// 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 +// 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) + } + } +}
Guards that the three tab-name lookups stay in sync with the valid gui.sidePanels tab set
TestSidePanelLookupsCoverAllValidTabs compares sidePanelViewNames, sidePanelTabTitles, and sidePanelContexts keys against config.ValidSidePanelTabs; a missing entry means a nil lookup at runtime.
@@ -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()))) +}
Adds a test-driver helper to simulate the terminal window regaining focus, so integration tests can exercise config-reload-on-focus behavior
FocusIn calls self.gui.FocusIn() to simulate the window regaining focus, which triggers lazygit's config reload.
@@ -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))
Stubs the new FocusIn and TopViewInWindow driver methods on the fake GUI driver used by component unit tests
@@ -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{} }
TopViewInWindow stub returns nil since the fake driver has no real window/tab state.
@@ -72,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) { }
Integration tests ReorderSidePanels, HideSidePanel, PromoteTabToSidePanel, and ReloadSidePanels exercise the new layouts, plus SidePanelsInPerRepoConfig for per-repo overrides.
Integration test proving each repo's own gui.sidePanels layout re-applies correctly when switching between repos
SidePanelsInPerRepoConfig sets a per-repo sidePanels layout swapping commits/reflog and branches/remotes/tags, then checks JumpToBlock[2] lands on Branches vs Commits depending on which repo is active.
@@ -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() + }, +})
Integration test proving edits to sidePanels in the config file reload live on window focus without losing the focused tab.
Writes .git/lazygit.yml with sidePanels, edits it, then calls FocusIn to confirm the config reloads live while Worktrees stays the focused, active tab.
@@ -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() + }, +})
Integration test proving a tab can be promoted to its own top-level side panel via gui.sidePanels.
Sets Gui.SidePanels to pull worktrees into its own panel; verifies JumpToBlock reaches it and NextTab then skips to Submodules.
@@ -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() + }, +})
Integration test verifying that omitting a panel (stash) from gui.sidePanels hides it and cycling skips over it
HideSidePanel configures Gui.SidePanels without stash, then checks NextBlock from Commits wraps to Status, skipping the hidden panel.
@@ -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() + }, +})
Integration test proving gui.sidePanels can reorder panels, shifting which jump keys reach them.
Swaps commits and branches order in Gui.SidePanels, so JumpToBlock[2]/JumpToBlock[3] now land on Commits/Branches respectively.
@@ -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() + }, +})
Registers the new per-repo-config and side-panel-configurability integration tests in the test suite
Adds config.SidePanelsInPerRepoConfig to the registered test list.
@@ -162,6 +162,7 @@ var tests = []*components.IntegrationTest{ config.CustomCommandsInPerRepoConfig, config.NegativeRefspec, config.RemoteNamedStar, + config.SidePanelsInPerRepoConfig, conflicts.Filter, conflicts.MergeFileBoth, conflicts.MergeFileCurrent,
Adds ui.HideSidePanel, ui.PromoteTabToSidePanel, ui.ReloadSidePanels, and ui.ReorderSidePanels to the registered test list.
@@ -474,11 +475,15 @@ 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.ReloadSidePanels, + ui.ReorderSidePanels, ui.SwitchTabFromMenu, ui.SwitchTabWithPanelJumpKeys, undo.UndoCheckoutAndDrop,
Documents the new sidePanels key in the config reference and JSON schema, alongside an unrelated contributor-process note added in the same PR.
JSON schema for the new gui.sidePanels config field and its SidePanel value type.
Documents the sidePanels array: each entry lists tab names sharing a panel; omitting hides one, a single-name entry promotes it; files/branches/commits can't be hidden.
@@ -590,6 +590,35 @@ "description": "The weight of the expanded side panel, relative to the other panels. 2 means twice as tall as the other panels. Only relevant if `expandFocusedSidePanel` is true.", "default": 2 }, + "sidePanels": { + "items": { + "$ref": "#/$defs/SidePanel" + }, + "type": "array", + "description": "The side panels, in the order they appear from top to bottom.\nEach entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys).\nOmit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel.\nValid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and 'commits' must always be included; they can't be hidden.", + "default": [ + [ + "status" + ], + [ + "files", + "worktrees", + "submodules" + ], + [ + "branches", + "remotes", + "tags" + ], + [ + "commits", + "reflog" + ], + [ + "stash" + ] + ] + }, "mainPanelSplitMode": { "type": "string", "enum": [
Defines SidePanel as an array of the ten valid panel-name enum values used by sidePanels.
@@ -3565,6 +3594,24 @@ "type": "object", "description": "Background refreshes" }, + "SidePanel": { + "items": { + "type": "string", + "enum": [ + "status", + "files", + "worktrees", + "submodules", + "branches", + "remotes", + "tags", + "commits", + "reflog", + "stash" + ] + }, + "type": "array" + }, "SpinnerConfig": { "properties": { "frames": {
Adds a contributor guideline to pause and surface mid-implementation decisions rather than deciding solo.
@@ -138,6 +138,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
Documents the new gui.sidePanels config key, its layout rules, and valid tab names.
@@ -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.