diff --git a/docs-master/Config.md b/docs-master/Config.md index fa6b3eeacc7..e56ef8f2b7a 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -95,6 +95,13 @@ gui: # a branch that is checked out in that worktree skipSwitchWorktreeOnCheckoutWarning: false + # If true, show worktrees as its own side panel group instead of a tab in the + # files group + worktreesInSeparateGroup: false + + # If true, hide the status panel from the side panel + hideStatusPanel: false + # Fraction of the total screen width to use for the left side section. You may # want to pick a small number (e.g. 0.2) if you're using a narrow screen, so # that you can see more of the main section. diff --git a/docs/Config.md b/docs/Config.md index 9f79218217f..ae4b1640e53 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -95,6 +95,13 @@ gui: # a branch that is checked out in that worktree skipSwitchWorktreeOnCheckoutWarning: false + # If true, show worktrees as its own side panel group instead of a tab in the + # files group + worktreesInSeparateGroup: false + + # If true, hide the status panel from the side panel + hideStatusPanel: false + # Fraction of the total screen width to use for the left side section. You may # want to pick a small number (e.g. 0.2) if you're using a narrow screen, so # that you can see more of the main section. diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index d1f760ed1d0..7ee42ad76aa 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -95,6 +95,10 @@ type GuiConfig struct { SkipRewordInEditorWarning bool `yaml:"skipRewordInEditorWarning"` // If true, switch to a different worktree without confirmation when checking out a branch that is checked out in that worktree SkipSwitchWorktreeOnCheckoutWarning bool `yaml:"skipSwitchWorktreeOnCheckoutWarning"` + // If true, show worktrees as its own side panel group instead of a tab in the files group + WorktreesInSeparateGroup bool `yaml:"worktreesInSeparateGroup"` + // If true, hide the status panel from the side panel + HideStatusPanel bool `yaml:"hideStatusPanel"` // Fraction of the total screen width to use for the left side section. You may want to pick a small number (e.g. 0.2) if you're using a narrow screen, so that you can see more of the main section. // Number from 0 to 1.0. SidePanelWidth float64 `yaml:"sidePanelWidth" jsonschema:"maximum=1,minimum=0"` diff --git a/pkg/gui/context/worktrees_context.go b/pkg/gui/context/worktrees_context.go index 3e45f2d4581..568c480011d 100644 --- a/pkg/gui/context/worktrees_context.go +++ b/pkg/gui/context/worktrees_context.go @@ -28,12 +28,17 @@ func NewWorktreesContext(c *ContextCommon) *WorktreesContext { ) } + windowName := "files" + if c.UserConfig().Gui.WorktreesInSeparateGroup { + windowName = "worktrees" + } + return &WorktreesContext{ FilteredListViewModel: viewModel, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Worktrees, - WindowName: "files", + WindowName: windowName, Key: WORKTREES_CONTEXT_KEY, Kind: types.SIDE_CONTEXT, Focusable: true, diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 9061d517729..39a06c1525c 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -421,6 +421,9 @@ 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 { + hideStatus := args.UserConfig.Gui.HideStatusPanel + separateWorktrees := args.UserConfig.Gui.WorktreesInSeparateGroup + if args.ScreenMode == types.SCREEN_FULL || args.ScreenMode == types.SCREEN_HALF { fullHeightBox := func(window string) *boxlayout.Box { if window == args.CurrentSideWindow { @@ -436,13 +439,20 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ } } - return []*boxlayout.Box{ - fullHeightBox("status"), + boxes := []*boxlayout.Box{} + if !hideStatus { + boxes = append(boxes, fullHeightBox("status")) + } + if separateWorktrees { + boxes = append(boxes, fullHeightBox("worktrees")) + } + boxes = append(boxes, fullHeightBox("files"), fullHeightBox("branches"), fullHeightBox("commits"), fullHeightBox("stash"), - } + ) + return boxes } else if height >= 28 { accordionMode := args.UserConfig.Gui.ExpandFocusedSidePanel accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box { @@ -456,16 +466,23 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ return defaultBox } - return []*boxlayout.Box{ - { + boxes := []*boxlayout.Box{} + if !hideStatus { + boxes = append(boxes, &boxlayout.Box{ Window: "status", Size: 3, - }, + }) + } + if separateWorktrees { + boxes = append(boxes, accordionBox(&boxlayout.Box{Window: "worktrees", Weight: 1})) + } + boxes = append(boxes, accordionBox(&boxlayout.Box{Window: "files", Weight: 1}), accordionBox(&boxlayout.Box{Window: "branches", Weight: 1}), accordionBox(&boxlayout.Box{Window: "commits", Weight: 1}), accordionBox(getDefaultStashWindowBox(args)), - } + ) + return boxes } squashedHeight := 1 @@ -487,12 +504,19 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ } } - return []*boxlayout.Box{ - squashedSidePanelBox("status"), + boxes := []*boxlayout.Box{} + if !hideStatus { + boxes = append(boxes, squashedSidePanelBox("status")) + } + if separateWorktrees { + boxes = append(boxes, squashedSidePanelBox("worktrees")) + } + boxes = append(boxes, squashedSidePanelBox("files"), squashedSidePanelBox("branches"), squashedSidePanelBox("commits"), squashedSidePanelBox("stash"), - } + ) + return boxes } } diff --git a/pkg/gui/controllers/helpers/window_helper.go b/pkg/gui/controllers/helpers/window_helper.go index 53531c2ffa8..09d5e34b6e0 100644 --- a/pkg/gui/controllers/helpers/window_helper.go +++ b/pkg/gui/controllers/helpers/window_helper.go @@ -135,5 +135,13 @@ func (self *WindowHelper) WindowForView(viewName string) string { } func (self *WindowHelper) SideWindows() []string { - return []string{"status", "files", "branches", "commits", "stash"} + windows := []string{} + if !self.c.UserConfig().Gui.HideStatusPanel { + windows = append(windows, "status") + } + if self.c.UserConfig().Gui.WorktreesInSeparateGroup { + windows = append(windows, "worktrees") + } + windows = append(windows, "files", "branches", "commits", "stash") + return windows } diff --git a/pkg/gui/controllers/jump_to_side_window_controller.go b/pkg/gui/controllers/jump_to_side_window_controller.go index 2ea8ac76234..5d9925c728a 100644 --- a/pkg/gui/controllers/jump_to_side_window_controller.go +++ b/pkg/gui/controllers/jump_to_side_window_controller.go @@ -1,8 +1,9 @@ package controllers import ( - "log" + "fmt" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) @@ -30,16 +31,18 @@ func (self *JumpToSideWindowController) Context() types.Context { func (self *JumpToSideWindowController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { windows := self.c.Helpers().Window.SideWindows() + jumpBindings := opts.Config.Universal.JumpToBlock - if len(opts.Config.Universal.JumpToBlock) != len(windows) { - log.Fatal("Jump to block keybindings cannot be set. Exactly 5 keybindings must be supplied.") + // Auto-extend jump bindings if there are more windows than bindings + for len(jumpBindings) < len(windows) { + jumpBindings = append(jumpBindings, config.Keybinding{fmt.Sprintf("%d", len(jumpBindings)+1)}) } return lo.Map(windows, func(window string, index int) *types.Binding { return &types.Binding{ ViewName: "", // by default the keys are 1, 2, 3, etc - Keys: opts.GetKeys(opts.Config.Universal.JumpToBlock[index]), + Keys: opts.GetKeys(jumpBindings[index]), Handler: opts.Guards.NoPopupPanel(self.goToSideWindow(window)), } }) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e2881cca148..1bd73521553 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -472,6 +472,14 @@ func (gui *Gui) onUserConfigLoaded() error { gui.setColorScheme() gui.configureViewProperties() + if gui.State != nil && gui.State.Contexts != nil { + if userConfig.Gui.WorktreesInSeparateGroup { + gui.State.Contexts.Worktrees.SetWindowName("worktrees") + } else { + gui.State.Contexts.Worktrees.SetWindowName("files") + } + } + gui.g.SearchEscapeKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.Return) gui.g.NextSearchMatchKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.NextMatch) gui.g.PrevSearchMatchKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.PrevMatch) @@ -859,20 +867,25 @@ func (gui *Gui) viewTabMap() map[string][]context.TabView { ViewName: "reflogCommits", }, }, - "files": { - { - Tab: gui.c.Tr.FilesTitle, - ViewName: "files", - }, - context.TabView{ - Tab: gui.c.Tr.WorktreesTitle, - ViewName: "worktrees", - }, - { + "files": func() []context.TabView { + tabs := []context.TabView{ + { + Tab: gui.c.Tr.FilesTitle, + ViewName: "files", + }, + } + if !gui.c.UserConfig().Gui.WorktreesInSeparateGroup { + tabs = append(tabs, context.TabView{ + Tab: gui.c.Tr.WorktreesTitle, + ViewName: "worktrees", + }) + } + tabs = append(tabs, context.TabView{ Tab: gui.c.Tr.SubmodulesTitle, ViewName: "submodules", - }, - }, + }) + return tabs + }(), } return result diff --git a/pkg/gui/views.go b/pkg/gui/views.go index ecfc0ddcd21..372aef5fe72 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -218,24 +218,49 @@ func (gui *Gui) configureViewProperties() { return fmt.Sprintf("[%s]", binding[0]) } jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock + sideWindows := gui.sideWindows() + + // Auto-extend jump bindings if there are more windows than bindings + for len(jumpBindings) < len(sideWindows) { + jumpBindings = append(jumpBindings, config.Keybinding{fmt.Sprintf("%d", len(jumpBindings)+1)}) + } + jumpLabels := lo.Map(jumpBindings, func(binding config.Keybinding, _ int) string { return keyToTitlePrefix(binding) }) - gui.Views.Status.TitlePrefix = jumpLabels[0] + // Build a map from window name to its jump label + windowLabel := func(window string) string { + for i, w := range sideWindows { + if w == window { + return jumpLabels[i] + } + } + return "" + } + + gui.Views.Status.TitlePrefix = windowLabel("status") - gui.Views.Files.TitlePrefix = jumpLabels[1] - gui.Views.Worktrees.TitlePrefix = jumpLabels[1] - gui.Views.Submodules.TitlePrefix = jumpLabels[1] + filesLabel := windowLabel("files") + gui.Views.Files.TitlePrefix = filesLabel + gui.Views.Submodules.TitlePrefix = filesLabel - gui.Views.Branches.TitlePrefix = jumpLabels[2] - gui.Views.Remotes.TitlePrefix = jumpLabels[2] - gui.Views.Tags.TitlePrefix = jumpLabels[2] + if gui.c.UserConfig().Gui.WorktreesInSeparateGroup { + gui.Views.Worktrees.TitlePrefix = windowLabel("worktrees") + } else { + gui.Views.Worktrees.TitlePrefix = filesLabel + } + + branchesLabel := windowLabel("branches") + gui.Views.Branches.TitlePrefix = branchesLabel + gui.Views.Remotes.TitlePrefix = branchesLabel + gui.Views.Tags.TitlePrefix = branchesLabel - gui.Views.Commits.TitlePrefix = jumpLabels[3] - gui.Views.ReflogCommits.TitlePrefix = jumpLabels[3] + commitsLabel := windowLabel("commits") + gui.Views.Commits.TitlePrefix = commitsLabel + gui.Views.ReflogCommits.TitlePrefix = commitsLabel - gui.Views.Stash.TitlePrefix = jumpLabels[4] + gui.Views.Stash.TitlePrefix = windowLabel("stash") gui.Views.Main.TitlePrefix = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView) } else { @@ -273,3 +298,18 @@ func (gui *Gui) configureViewProperties() { } } } + +// sideWindows returns the list of side windows based on config. +// This duplicates the logic from WindowHelper.SideWindows() so it can be used +// before helpers are initialized. +func (gui *Gui) sideWindows() []string { + windows := []string{} + if !gui.c.UserConfig().Gui.HideStatusPanel { + windows = append(windows, "status") + } + if gui.c.UserConfig().Gui.WorktreesInSeparateGroup { + windows = append(windows, "worktrees") + } + windows = append(windows, "files", "branches", "commits", "stash") + return windows +} diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 1b264e50dc9..ceaecdfd58f 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -473,6 +473,7 @@ var tests = []*components.IntegrationTest{ ui.Accordion, ui.DisableSwitchTabWithPanelJumpKeys, ui.EmptyMenu, + ui.HideStatusPanel, ui.KeybindingSuggestionsDontCrashOnDisabledBindings, ui.KeybindingSuggestionsWhenSwitchingRepos, ui.ModeSpecificKeybindingSuggestions, @@ -480,6 +481,7 @@ var tests = []*components.IntegrationTest{ ui.RangeSelect, ui.SwitchTabFromMenu, ui.SwitchTabWithPanelJumpKeys, + ui.WorktreesInSeparateGroup, undo.UndoCheckoutAndDrop, undo.UndoCommit, undo.UndoDrop, diff --git a/pkg/integration/tests/ui/hide_status_panel.go b/pkg/integration/tests/ui/hide_status_panel.go new file mode 100644 index 00000000000..2699a0096b5 --- /dev/null +++ b/pkg/integration/tests/ui/hide_status_panel.go @@ -0,0 +1,41 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var HideStatusPanel = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Verify status panel is hidden when hideStatusPanel is enabled", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.HideStatusPanel = true + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Default focus is files + t.Views().Files().IsFocused() + + // Next block should go to branches (skipping status) + t.Views().Files().Press(keys.Universal.NextBlock) + t.Views().Branches().IsFocused() + + // Continue forward: commits, stash + t.Views().Branches().Press(keys.Universal.NextBlock) + t.Views().Commits().IsFocused() + + t.Views().Commits().Press(keys.Universal.NextBlock) + t.Views().Stash().IsFocused() + + // Wrapping forward from stash should go to files (skipping status) + t.Views().Stash().Press(keys.Universal.NextBlock) + t.Views().Files().IsFocused() + + // Cycling backwards from files should wrap to stash (skipping status) + t.Views().Files().Press(keys.Universal.PrevBlock) + t.Views().Stash().IsFocused() + }, +}) diff --git a/pkg/integration/tests/ui/worktrees_in_separate_group.go b/pkg/integration/tests/ui/worktrees_in_separate_group.go new file mode 100644 index 00000000000..dab33d72fe8 --- /dev/null +++ b/pkg/integration/tests/ui/worktrees_in_separate_group.go @@ -0,0 +1,45 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var WorktreesInSeparateGroup = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Verify worktrees appears as its own side panel group when worktreesInSeparateGroup is enabled", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.WorktreesInSeparateGroup = true + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Default focus is files. Go prev to reach worktrees (its own group now). + t.Views().Files().IsFocused(). + Press(keys.Universal.PrevBlock) + + t.Views().Worktrees().IsFocused(). + Press(keys.Universal.PrevBlock) + + // Prev from worktrees should go to status + t.Views().Status().IsFocused() + + // Navigate forward: status -> worktrees -> files -> branches + t.Views().Status().Press(keys.Universal.NextBlock) + t.Views().Worktrees().IsFocused(). + Press(keys.Universal.NextBlock) + t.Views().Files().IsFocused(). + Press(keys.Universal.NextBlock) + t.Views().Branches().IsFocused() + + // Test jump keys: key 2 should jump to worktrees + t.GlobalPress(keys.Universal.JumpToBlock[1]) + t.Views().Worktrees().IsFocused() + + // Key 3 should jump to files + t.GlobalPress(keys.Universal.JumpToBlock[2]) + t.Views().Files().IsFocused() + }, +}) diff --git a/schema-master/config.json b/schema-master/config.json index b95c5c98053..59b21c8a123 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -568,6 +568,16 @@ "description": "If true, switch to a different worktree without confirmation when checking out a branch that is checked out in that worktree", "default": false }, + "worktreesInSeparateGroup": { + "type": "boolean", + "description": "If true, show worktrees as its own side panel group instead of a tab in the files group", + "default": false + }, + "hideStatusPanel": { + "type": "boolean", + "description": "If true, hide the status panel from the side panel", + "default": false + }, "sidePanelWidth": { "type": "number", "maximum": 1, diff --git a/schema/config.json b/schema/config.json index 2e968ba8f95..81b13d6185b 100644 --- a/schema/config.json +++ b/schema/config.json @@ -568,6 +568,16 @@ "description": "If true, switch to a different worktree without confirmation when checking out a branch that is checked out in that worktree", "default": false }, + "worktreesInSeparateGroup": { + "type": "boolean", + "description": "If true, show worktrees as its own side panel group instead of a tab in the files group", + "default": false + }, + "hideStatusPanel": { + "type": "boolean", + "description": "If true, hide the status panel from the side panel", + "default": false + }, "sidePanelWidth": { "type": "number", "maximum": 1,