From ed48988a9bcad65e7d18640f75bc3889cf6120f2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Jun 2026 11:48:45 +0200 Subject: [PATCH 01/24] Add LAZYGIT_SLOW_RENDER debug knob for watching async render frames Re-rendering a diff into a main view is asynchronous and lazy: the read loop fills the view a screenful at a time and refreshes as it goes. When debugging scroll-restore and flicker behaviour, the individual frames go by too fast to see. Setting LAZYGIT_SLOW_RENDER= sleeps that long after each line is written, stretching the load out so the frames become visible. It has no effect when unset, so it's safe to leave in. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index c2964a8b91f..2aa1e00fe06 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -4,7 +4,9 @@ import ( "bufio" "fmt" "io" + "os" "os/exec" + "strconv" "sync" "time" @@ -248,6 +250,17 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p } } + // Set LAZYGIT_SLOW_RENDER= to sleep that long after each + // line is written to the view, stretching async loads out so the frames + // of a re-render become visible. Useful for debugging scroll/flicker + // behaviour; has no effect when the variable is unset. + var slowRenderPerLine time.Duration + if v := os.Getenv("LAZYGIT_SLOW_RENDER"); v != "" { + if ms, err := strconv.Atoi(v); err == nil { + slowRenderPerLine = time.Duration(ms) * time.Millisecond + } + } + outer: for { select { @@ -290,6 +303,10 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p writeToView(append(line, '\n')) lineWrittenChan <- struct{}{} + if slowRenderPerLine > 0 { + time.Sleep(slowRenderPerLine) + } + if i+1 == linesToRead.InitialRefreshAfter { // We have read enough lines to fill the view, so do a first refresh // here to show what we have. Continue reading and refresh again at From 8a26bebbb9abb2e813575fc08137df5b045fb080 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 1 Apr 2025 16:14:10 +0200 Subject: [PATCH 02/24] Add user config gui.showSelectionInFocusedMainView --- docs/Config.md | 3 ++ pkg/config/user_config.go | 41 ++++++++++--------- pkg/gui/context/base_context.go | 4 ++ pkg/gui/context/main_context.go | 2 +- pkg/gui/controllers/main_view_controller.go | 4 ++ .../controllers/view_selection_controller.go | 30 +++++++++++--- pkg/gui/gui.go | 5 +++ schema/config.json | 5 +++ 8 files changed, 69 insertions(+), 25 deletions(-) diff --git a/docs/Config.md b/docs/Config.md index 9f79218217f..5a198b25f49 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -137,6 +137,9 @@ gui: # staging view. useHunkModeInStagingView: true + # If true, show a selection when the main view is focused. + showSelectionInFocusedMainView: false + # One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' # | 'ru' | 'pt' language: auto diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index cac87ec9157..2376d4fb514 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -117,6 +117,8 @@ type GuiConfig struct { WrapLinesInStagingView bool `yaml:"wrapLinesInStagingView"` // If true, hunk selection mode will be enabled by default when entering the staging view. UseHunkModeInStagingView bool `yaml:"useHunkModeInStagingView"` + // If true, show a selection when the main view is focused. + ShowSelectionInFocusedMainView bool `yaml:"showSelectionInFocusedMainView"` // One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru' | 'pt' Language string `yaml:"language" jsonschema:"enum=auto,enum=en,enum=zh-TW,enum=zh-CN,enum=pl,enum=nl,enum=ja,enum=ko,enum=ru"` // Format used when displaying time e.g. commit time. @@ -817,25 +819,26 @@ func GetDefaultConfig() *UserConfig { func GetDefaultConfigForPlatform(platform string) *UserConfig { return &UserConfig{ Gui: GuiConfig{ - ScrollHeight: 2, - ScrollPastBottom: true, - ScrollOffMargin: 2, - ScrollOffBehavior: "margin", - TabWidth: 4, - MouseEvents: true, - SkipAmendWarning: false, - SkipDiscardChangeWarning: false, - SkipStashWarning: false, - SidePanelWidth: 0.3333, - ExpandFocusedSidePanel: false, - ExpandedSidePanelWeight: 2, - MainPanelSplitMode: "flexible", - EnlargedSideViewLocation: "left", - WrapLinesInStagingView: true, - UseHunkModeInStagingView: true, - Language: "auto", - TimeFormat: "02 Jan 06", - ShortTimeFormat: time.Kitchen, + ScrollHeight: 2, + ScrollPastBottom: true, + ScrollOffMargin: 2, + ScrollOffBehavior: "margin", + TabWidth: 4, + MouseEvents: true, + SkipAmendWarning: false, + SkipDiscardChangeWarning: false, + SkipStashWarning: false, + SidePanelWidth: 0.3333, + ExpandFocusedSidePanel: false, + ExpandedSidePanelWeight: 2, + MainPanelSplitMode: "flexible", + EnlargedSideViewLocation: "left", + WrapLinesInStagingView: true, + UseHunkModeInStagingView: true, + ShowSelectionInFocusedMainView: false, + Language: "auto", + TimeFormat: "02 Jan 06", + ShortTimeFormat: time.Kitchen, Theme: ThemeConfig{ ActiveBorderColor: []string{"green", "bold"}, SearchingActiveBorderColor: []string{"cyan", "bold"}, diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index 7584b5a1251..d684d7fac9d 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -253,3 +253,7 @@ func (self *BaseContext) Title() string { func (self *BaseContext) TotalContentHeight() int { return self.view.ViewLinesHeight() } + +func (self *BaseContext) SetHighlightOnFocus(value bool) { + self.highlightOnFocus = value +} diff --git a/pkg/gui/context/main_context.go b/pkg/gui/context/main_context.go index c8b6edadebb..2960f8ffeb5 100644 --- a/pkg/gui/context/main_context.go +++ b/pkg/gui/context/main_context.go @@ -26,7 +26,7 @@ func NewMainContext( WindowName: windowName, Key: key, Focusable: true, - HighlightOnFocus: false, + HighlightOnFocus: c.UserConfig().Gui.ShowSelectionInFocusedMainView, })), SearchTrait: NewSearchTrait(c), } diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 6eb6c86e370..73dec8ec0f6 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -89,6 +89,10 @@ func (self *MainViewController) escape() error { } func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error { + if self.context.GetView().Highlight && !opts.IsDoubleClick { + return nil + } + sidePanelContext := self.c.Context().NextInStack(self.context) if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil { return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), opts.Y) diff --git a/pkg/gui/controllers/view_selection_controller.go b/pkg/gui/controllers/view_selection_controller.go index 31cbd36956c..6120875817c 100644 --- a/pkg/gui/controllers/view_selection_controller.go +++ b/pkg/gui/controllers/view_selection_controller.go @@ -3,6 +3,7 @@ package controllers import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" ) type ViewSelectionControllerFactory struct { @@ -57,10 +58,21 @@ func (self *ViewSelectionController) handleLineChange(delta int) { } v := self.Context().GetView() - if delta < 0 { - v.ScrollUp(-delta) + if self.context.GetView().Highlight { + lineIdxBefore := v.CursorY() + v.OriginY() + lineIdxAfter := lo.Clamp(lineIdxBefore+delta, 0, v.ViewLinesHeight()-1) + if delta == -1 { + checkScrollUp(self.Context().GetViewTrait(), self.c.UserConfig(), lineIdxBefore, lineIdxAfter) + } else if delta == 1 { + checkScrollDown(self.Context().GetViewTrait(), self.c.UserConfig(), lineIdxBefore, lineIdxAfter) + } + v.FocusPoint(0, lineIdxAfter, true) } else { - v.ScrollDown(delta) + if delta < 0 { + v.ScrollUp(-delta) + } else { + v.ScrollDown(delta) + } } } @@ -86,7 +98,11 @@ func (self *ViewSelectionController) handleNextPage() error { func (self *ViewSelectionController) handleGotoTop() error { v := self.Context().GetView() - self.handleLineChange(-v.ViewLinesHeight()) + if self.context.GetView().Highlight { + v.FocusPoint(0, 0, true) + } else { + self.handleLineChange(-v.ViewLinesHeight()) + } return nil } @@ -95,7 +111,11 @@ func (self *ViewSelectionController) handleGotoBottom() error { manager.ReadToEnd(func() { self.c.OnUIThread(func() error { v := self.Context().GetView() - self.handleLineChange(v.ViewLinesHeight()) + if self.context.GetView().Highlight { + v.FocusPoint(0, v.ViewLinesHeight()-1, true) + } else { + self.handleLineChange(v.ViewLinesHeight()) + } return nil }) }) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e2881cca148..9229b45ffa1 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -487,6 +487,11 @@ func (gui *Gui) onUserConfigLoaded() error { gui.g.Mouse = userConfig.Gui.MouseEvents + if gui.State != nil { + gui.Contexts().Normal.SetHighlightOnFocus(userConfig.Gui.ShowSelectionInFocusedMainView) + gui.Contexts().NormalSecondary.SetHighlightOnFocus(userConfig.Gui.ShowSelectionInFocusedMainView) + } + // originally we could only hide the command log permanently via the config // but now we do it via state. So we need to still support the config for the // sake of backwards compatibility. We're making use of short circuiting here diff --git a/schema/config.json b/schema/config.json index 2e968ba8f95..e6aa5f9b5cd 100644 --- a/schema/config.json +++ b/schema/config.json @@ -610,6 +610,11 @@ "description": "If true, hunk selection mode will be enabled by default when entering the staging view.", "default": true }, + "showSelectionInFocusedMainView": { + "type": "boolean", + "description": "If true, show a selection when the main view is focused.", + "default": false + }, "language": { "type": "string", "enum": [ From fff7a0d1996e36c442f3fadd45affaa4ea43bd67 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 1 Apr 2025 23:03:55 +0200 Subject: [PATCH 03/24] Press enter in focused main view when user config is on --- pkg/gui/controllers/main_view_controller.go | 21 +++++++++++++++++++++ pkg/i18n/english.go | 2 ++ 2 files changed, 23 insertions(+) diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 73dec8ec0f6..fd561f5ec64 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -30,6 +30,13 @@ func NewMainViewController( } func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { + var goIntoDescription string + // We only want to show the "enter" menu item if the user config is true; + // leaving the description empty causes it to be hidden + if self.c.UserConfig().Gui.ShowSelectionInFocusedMainView { + goIntoDescription = self.c.Tr.EnterStaging + } + return []*types.Binding{ { Keys: opts.GetKeys(opts.Config.Universal.TogglePanel), @@ -44,6 +51,11 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty Description: self.c.Tr.ExitFocusedMainView, DisplayOnScreen: true, }, + { + Keys: opts.GetKeys(opts.Config.Universal.GoInto), + Handler: self.enter, + Description: goIntoDescription, + }, { // overriding this because we want to read all of the task's output before we start searching Keys: opts.GetKeys(opts.Config.Universal.StartSearch), @@ -88,6 +100,15 @@ func (self *MainViewController) escape() error { return nil } +func (self *MainViewController) enter() error { + sidePanelContext := self.c.Context().NextInStack(self.context) + if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil { + return sidePanelContext.GetOnClickFocusedMainView()( + self.context.GetViewName(), self.context.GetView().SelectedLineIdx()) + } + return nil +} + func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error { if self.context.GetView().Highlight && !opts.IsDoubleClick { return nil diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d112c037925..8f0b5c92205 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -530,6 +530,7 @@ type TranslationSet struct { EmptyPatchError string EnterCommitFile string EnterCommitFileTooltip string + EnterStaging string ExitCustomPatchBuilder string ExitFocusedMainView string EnterUpstream string @@ -1659,6 +1660,7 @@ func EnglishTranslationSet() *TranslationSet { EmptyPatchError: "Patch is still empty. Add some files or lines to your patch first.", EnterCommitFile: "Enter file / Toggle directory collapsed", EnterCommitFileTooltip: "If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory.", + EnterStaging: "Enter staging/patch building", ExitCustomPatchBuilder: `Exit custom patch builder`, ExitFocusedMainView: "Exit back to side panel", EnterUpstream: `Enter upstream as ' '`, From 1e5f31dd6f280f5d0f8c458b81ecf1f0c11720d3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 25 Mar 2025 15:01:16 +0100 Subject: [PATCH 04/24] Select line that is in the middle of the screen --- pkg/gui/controllers/main_view_controller.go | 8 ++++++++ .../switch_to_focused_main_view_controller.go | 19 ++++++++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index fd561f5ec64..65fcad01c14 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -87,6 +87,14 @@ func (self *MainViewController) Context() types.Context { return self.context } +func (self *MainViewController) GetOnFocus() func(types.OnFocusOpts) { + return func(opts types.OnFocusOpts) { + if opts.ClickedWindowName != "" { + self.context.GetView().FocusPoint(0, opts.ClickedViewLineIdx, false) + } + } +} + func (self *MainViewController) togglePanel() error { if self.otherContext.GetView().Visible { self.c.Context().Push(self.otherContext, types.OnFocusOpts{}) diff --git a/pkg/gui/controllers/switch_to_focused_main_view_controller.go b/pkg/gui/controllers/switch_to_focused_main_view_controller.go index 5606a0bab59..3c3ecd207ad 100644 --- a/pkg/gui/controllers/switch_to_focused_main_view_controller.go +++ b/pkg/gui/controllers/switch_to_focused_main_view_controller.go @@ -3,6 +3,7 @@ package controllers import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" ) // This controller is for all contexts that can focus their main view. @@ -61,21 +62,29 @@ func (self *SwitchToFocusedMainViewController) Context() types.Context { } func (self *SwitchToFocusedMainViewController) onClickMain(opts gocui.ViewMouseBindingOpts) error { - return self.focusMainView(self.c.Contexts().Normal) + return self.focusMainView(self.c.Contexts().Normal, opts.Y) } func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error { - return self.focusMainView(self.c.Contexts().NormalSecondary) + return self.focusMainView(self.c.Contexts().NormalSecondary, opts.Y) } func (self *SwitchToFocusedMainViewController) handleFocusMainView() error { - return self.focusMainView(self.c.Contexts().Normal) + return self.focusMainView(self.c.Contexts().Normal, -1) } -func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context) error { +func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context, clickedViewLineIdx int) error { if context, ok := mainViewContext.(types.ISearchableContext); ok { context.ClearSearchString() } - self.c.Context().Push(mainViewContext, types.OnFocusOpts{}) + onFocusOpts := types.OnFocusOpts{ClickedWindowName: mainViewContext.GetWindowName()} + if clickedViewLineIdx >= 0 { + onFocusOpts.ClickedViewLineIdx = clickedViewLineIdx + } else { + mainView := mainViewContext.GetView() + lineIdx := mainView.OriginY() + mainView.Height()/2 + onFocusOpts.ClickedViewLineIdx = lo.Clamp(lineIdx, 0, mainView.LinesHeight()-1) + } + self.c.Context().Push(mainViewContext, onFocusOpts) return nil } From ed2015cacff0f590f296f5cac38209d52d76fe0a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 16 Sep 2024 20:38:22 +0200 Subject: [PATCH 05/24] Press enter in main view of files/commitFiles to enter staging/patch-building This was already possible, but only when a file was selected, and it woudln't always land on the right line when a pager was used. Now it's also possible to do this for directories, and it jumps to the right line. At the moment this is a hack that relies on delta's hyperlinks, so it only works on lines that have hyperlinks (added and context). The implementation is very hacky for other reasons too (e.g. the addition of the weirdly named ClickedViewRealLineIdx to OnFocusOpts). --- pkg/commands/patch/patch.go | 32 +++++++++++++++++ pkg/gocui/view.go | 14 ++++++++ pkg/gui/controllers.go | 4 +-- .../controllers/commits_files_controller.go | 32 ++++++++++++++--- pkg/gui/controllers/files_controller.go | 33 ++++++++++++++--- .../helpers/patch_building_helper.go | 4 ++- pkg/gui/controllers/helpers/staging_helper.go | 35 ++++++++++++++++--- .../controllers/patch_explorer_controller.go | 10 ++++-- pkg/gui/patch_exploring/state.go | 12 +++++-- pkg/gui/types/context.go | 8 +++-- 10 files changed, 162 insertions(+), 22 deletions(-) diff --git a/pkg/commands/patch/patch.go b/pkg/commands/patch/patch.go index fbbf3c93554..e197b8f36ef 100644 --- a/pkg/commands/patch/patch.go +++ b/pkg/commands/patch/patch.go @@ -114,6 +114,38 @@ func (self *Patch) LineNumberOfLine(idx int) int { return hunk.newStart + offset } +// Takes a line number in the new file and returns the line index in the patch. +// This is the opposite of LineNumberOfLine. +// If the line number is not contained in any of the hunks, it returns the +// closest position. +func (self *Patch) PatchLineForLineNumber(lineNumber int) int { + if len(self.hunks) == 0 { + return len(self.header) + } + + for hunkIdx, hunk := range self.hunks { + if lineNumber <= hunk.newStart { + return self.HunkStartIdx(hunkIdx) + } + + if lineNumber < hunk.newStart+hunk.newLength() { + lines := hunk.bodyLines + offset := lineNumber - hunk.newStart + for i, line := range lines { + if offset == 0 { + return self.HunkStartIdx(hunkIdx) + i + 1 + } + + if line.Kind == ADDITION || line.Kind == CONTEXT { + offset-- + } + } + } + } + + return self.LineCount() - 1 +} + // Returns hunk index containing the line at the given patch line index func (self *Patch) HunkContainingLine(idx int) int { for hunkIdx, hunk := range self.hunks { diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 166cb0e2cf8..59ea79e6a2c 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -1510,6 +1510,20 @@ func (v *View) Word(x, y int) (string, bool) { return str[nl:nr], true } +func (v *View) HyperLinkInLine(y int, urlScheme string) (string, bool) { + if y < 0 || y >= len(v.viewLines) { + return "", false + } + + for _, c := range v.lines[v.viewLines[y].linesY] { + if strings.HasPrefix(c.hyperlink, urlScheme) { + return c.hyperlink, true + } + } + + return "", false +} + // indexFunc allows to split lines by words taking into account spaces // and 0. func indexFunc(r rune) bool { diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 51e240a5d55..e280d3a8536 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -52,8 +52,9 @@ func (gui *Gui) resetHelpersAndControllers() { gpgHelper := helpers.NewGpgHelper(helperCommon) viewHelper := helpers.NewViewHelper(helperCommon, gui.State.Contexts) + windowHelper := helpers.NewWindowHelper(helperCommon, viewHelper) patchBuildingHelper := helpers.NewPatchBuildingHelper(helperCommon) - stagingHelper := helpers.NewStagingHelper(helperCommon) + stagingHelper := helpers.NewStagingHelper(helperCommon, windowHelper) mergeConflictsHelper := helpers.NewMergeConflictsHelper(helperCommon) searchHelper := helpers.NewSearchHelper(helperCommon) @@ -73,7 +74,6 @@ func (gui *Gui) resetHelpersAndControllers() { rebaseHelper, ) bisectHelper := helpers.NewBisectHelper(helperCommon) - windowHelper := helpers.NewWindowHelper(helperCommon, viewHelper) modeHelper := helpers.NewModeHelper( helperCommon, diffHelper, diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index eed9d02b91c..38b86254830 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -524,7 +524,7 @@ func (self *CommitFilesController) currentFromToReverseForPatchBuilding() (strin } func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error { - return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1}) + return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1}) } func (self *CommitFilesController) enterCommitFile(node *filetree.CommitFileNode, opts types.OnFocusOpts) error { @@ -595,11 +595,35 @@ func (self *CommitFilesController) expandAll() error { func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { return func(mainViewName string, clickedLineIdx int) error { + clickedFile, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(mainViewName, clickedLineIdx) + if !ok { + line = -1 + } + node := self.getSelectedItem() - if node != nil && node.File != nil { - return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx}) + if node == nil { + return nil } - return nil + + if !node.IsFile() && ok { + relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), clickedFile) + if err != nil { + return err + } + relativePath = "./" + relativePath + self.context().CommitFileTreeViewModel.ExpandToPath(relativePath) + self.c.PostRefreshUpdate(self.context()) + + idx, ok := self.context().CommitFileTreeViewModel.GetIndexForPath(relativePath) + if ok { + self.context().SetSelectedLineIdx(idx) + self.context().GetViewTrait().FocusPoint( + self.context().ModelIndexToViewIndex(idx), false) + node = self.context().GetSelected() + } + } + + return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) } } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 09f654e2bd9..38374a7ac03 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -361,11 +361,34 @@ func (self *FilesController) GetOnDoubleClick() func() error { func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { return func(mainViewName string, clickedLineIdx int) error { - node := self.getSelectedItem() - if node != nil && node.File != nil { - return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: clickedLineIdx}) + clickedFile, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(mainViewName, clickedLineIdx) + if !ok { + line = -1 } - return nil + + node := self.context().GetSelected() + if node == nil { + return nil + } + + if !node.IsFile() && ok { + relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), clickedFile) + if err != nil { + return err + } + relativePath = "./" + relativePath + self.context().FileTreeViewModel.ExpandToPath(relativePath) + self.c.PostRefreshUpdate(self.context()) + + idx, ok := self.context().FileTreeViewModel.GetIndexForPath(relativePath) + if ok { + self.context().SetSelectedLineIdx(idx) + self.context().GetViewTrait().FocusPoint( + self.context().ModelIndexToViewIndex(idx), false) + } + } + + return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) } } @@ -652,7 +675,7 @@ func (self *FilesController) getSelectedFile() *models.File { } func (self *FilesController) enter() error { - return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1}) + return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1}) } func (self *FilesController) collapseAll() error { diff --git a/pkg/gui/controllers/helpers/patch_building_helper.go b/pkg/gui/controllers/helpers/patch_building_helper.go index 9369cca93fb..ff3e3199009 100644 --- a/pkg/gui/controllers/helpers/patch_building_helper.go +++ b/pkg/gui/controllers/helpers/patch_building_helper.go @@ -56,8 +56,10 @@ func (self *PatchBuildingHelper) Reset() error { func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpts) { selectedLineIdx := -1 + selectedRealLineIdx := -1 if opts.ClickedWindowName == "main" { selectedLineIdx = opts.ClickedViewLineIdx + selectedRealLineIdx = opts.ClickedViewRealLineIdx } if !self.c.Git().Patch.PatchBuilder.Active() { @@ -89,7 +91,7 @@ func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpt oldState := context.GetState() - state := patch_exploring.NewState(diff, selectedLineIdx, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView) + state := patch_exploring.NewState(diff, selectedLineIdx, selectedRealLineIdx, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView) context.SetState(state) if state == nil { self.Escape() diff --git a/pkg/gui/controllers/helpers/staging_helper.go b/pkg/gui/controllers/helpers/staging_helper.go index 55b9c133bd0..2c5aeddcea4 100644 --- a/pkg/gui/controllers/helpers/staging_helper.go +++ b/pkg/gui/controllers/helpers/staging_helper.go @@ -1,20 +1,26 @@ package helpers import ( + "regexp" + "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/patch_exploring" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" ) type StagingHelper struct { - c *HelperCommon + c *HelperCommon + windowHelper *WindowHelper } func NewStagingHelper( c *HelperCommon, + windowHelper *WindowHelper, ) *StagingHelper { return &StagingHelper{ - c: c, + c: c, + windowHelper: windowHelper, } } @@ -30,12 +36,16 @@ func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) { } mainSelectedLineIdx := -1 + mainSelectedRealLineIdx := -1 secondarySelectedLineIdx := -1 + secondarySelectedRealLineIdx := -1 if focusOpts.ClickedViewLineIdx > 0 { if secondaryFocused { secondarySelectedLineIdx = focusOpts.ClickedViewLineIdx + secondarySelectedRealLineIdx = focusOpts.ClickedViewRealLineIdx } else { mainSelectedLineIdx = focusOpts.ClickedViewLineIdx + mainSelectedRealLineIdx = focusOpts.ClickedViewRealLineIdx } } @@ -64,11 +74,11 @@ func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) { hunkMode := self.c.UserConfig().Gui.UseHunkModeInStagingView mainContext.SetState( - patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainContext.GetView(), mainContext.GetState(), hunkMode), + patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainSelectedRealLineIdx, mainContext.GetView(), mainContext.GetState(), hunkMode), ) secondaryContext.SetState( - patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode), + patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondarySelectedRealLineIdx, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode), ) mainState := mainContext.GetState() @@ -125,3 +135,20 @@ func (self *StagingHelper) secondaryStagingFocused() bool { func (self *StagingHelper) mainStagingFocused() bool { return self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().Staging.GetKey() } + +func (self *StagingHelper) GetFileAndLineForClickedDiffLine(windowName string, lineIdx int) (string, int, bool) { + v, _ := self.c.GocuiGui().View(self.windowHelper.GetViewNameForWindow(windowName)) + hyperlink, ok := v.HyperLinkInLine(lineIdx, "lazygit-edit:") + if !ok { + return "", 0, false + } + + re := regexp.MustCompile(`^lazygit-edit://(.+?):(\d+)$`) + matches := re.FindStringSubmatch(hyperlink) + if matches == nil { + return "", 0, false + } + filepath := matches[1] + lineNumber := utils.MustConvertToInt(matches[2]) + return filepath, lineNumber, true +} diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go index aa5fd54bb56..d98b65fabf8 100644 --- a/pkg/gui/controllers/patch_explorer_controller.go +++ b/pkg/gui/controllers/patch_explorer_controller.go @@ -141,9 +141,15 @@ func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsO return self.withRenderAndFocus(self.HandleMouseDown)() } + _, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(self.context.GetWindowName(), opts.Y) + if !ok { + line = -1 + } + self.c.Context().Push(self.context, types.OnFocusOpts{ - ClickedWindowName: self.context.GetWindowName(), - ClickedViewLineIdx: opts.Y, + ClickedWindowName: self.context.GetWindowName(), + ClickedViewLineIdx: opts.Y, + ClickedViewRealLineIdx: line, }) return nil diff --git a/pkg/gui/patch_exploring/state.go b/pkg/gui/patch_exploring/state.go index 5f1a29e6171..941af2a8ab7 100644 --- a/pkg/gui/patch_exploring/state.go +++ b/pkg/gui/patch_exploring/state.go @@ -45,7 +45,7 @@ const ( HUNK ) -func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *State, useHunkModeByDefault bool) *State { +func NewState(diff string, selectedLineIdx int, selectedRealLineIdx int, view *gocui.View, oldState *State, useHunkModeByDefault bool) *State { if oldState != nil && diff == oldState.diff && selectedLineIdx == -1 { // if we're here then we can return the old state. If selectedLineIdx was not -1 // then that would mean we were trying to click and potentially drag a range, which @@ -61,6 +61,14 @@ func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *Stat viewLineIndices, patchLineIndices := wrapPatchLines(diff, view) + if selectedRealLineIdx != -1 { + // PatchLineForLineNumber returns a patch line index, but selectedLineIdx + // is in view-line (wrapped) space, so convert it. Without this the + // landing line is off by the number of wrapped lines above it. + patchLineIdx := patch.PatchLineForLineNumber(selectedRealLineIdx) + selectedLineIdx = viewLineIndices[lo.Clamp(patchLineIdx, 0, len(viewLineIndices)-1)] + } + rangeStartLineIdx := 0 if oldState != nil { rangeStartLineIdx = oldState.rangeStartLineIdx @@ -80,7 +88,7 @@ func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *Stat if selectedLineIdx >= 0 { // Clamp to the number of wrapped view lines; index might be out of // bounds if a custom pager is being used which produces more lines - selectedLineIdx = min(selectedLineIdx, len(viewLineIndices)-1) + selectedLineIdx = min(selectedLineIdx, len(patchLineIndices)-1) selectMode = RANGE rangeStartLineIdx = selectedLineIdx diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 416b39b957f..d2328f19017 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -227,8 +227,12 @@ type IViewTrait interface { } type OnFocusOpts struct { - ClickedWindowName string - ClickedViewLineIdx int + ClickedWindowName string + ClickedViewLineIdx int + + // If not -1, takes precedence over ClickedViewLineIdx. + ClickedViewRealLineIdx int + ScrollSelectionIntoView bool } From ec50f31226caf5cebf370514a6a6ce5f80effd32 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 11 Oct 2024 18:09:17 +0200 Subject: [PATCH 06/24] Extract some functions from CommitFilesController to a new CommitFilesHelper --- pkg/gui/controllers.go | 1 + .../controllers/commits_files_controller.go | 68 +-------------- .../helpers/commit_files_helper.go | 85 +++++++++++++++++++ pkg/gui/controllers/helpers/helpers.go | 2 + 4 files changed, 92 insertions(+), 64 deletions(-) create mode 100644 pkg/gui/controllers/helpers/commit_files_helper.go diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index e280d3a8536..a1d6eb12d3b 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -108,6 +108,7 @@ func (gui *Gui) resetHelpersAndControllers() { FixupHelper: helpers.NewFixupHelper(helperCommon), Commits: commitsHelper, SuspendResume: helpers.NewSuspendResumeHelper(helperCommon), + CommitFiles: helpers.NewCommitFilesHelper(helperCommon, patchBuildingHelper), Snake: helpers.NewSnakeHelper(helperCommon), Diff: diffHelper, Repos: reposHelper, diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 38b86254830..4160d4bce99 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -442,7 +442,7 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm toggle := func() error { return self.c.WithWaitingStatus(self.c.Tr.UpdatingPatch, func(gocui.Task) error { if !self.c.Git().Patch.PatchBuilder.Active() { - if err := self.startPatchBuilder(); err != nil { + if err := self.c.Helpers().CommitFiles.StartPatchBuilder(); err != nil { return err } } @@ -485,7 +485,7 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm }) } - from, to, reverse := self.currentFromToReverseForPatchBuilding() + from, to, reverse := self.c.Helpers().CommitFiles.CurrentFromToReverseForPatchBuilding() mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse) return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{ Title: self.c.Tr.DiscardPatch, @@ -505,68 +505,8 @@ func (self *CommitFilesController) toggleAllForPatch(_ *filetree.CommitFileNode) return self.toggleForPatch([]*filetree.CommitFileNode{root}) } -func (self *CommitFilesController) startPatchBuilder() error { - commitFilesContext := self.context() - - canRebase := commitFilesContext.GetCanRebase() - from, to, reverse := self.currentFromToReverseForPatchBuilding() - - self.c.Git().Patch.PatchBuilder.Start(from, to, reverse, canRebase) - return nil -} - -func (self *CommitFilesController) currentFromToReverseForPatchBuilding() (string, string, bool) { - commitFilesContext := self.context() - - from, to := commitFilesContext.GetFromAndToForDiff() - from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) - return from, to, reverse -} - func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error { - return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1}) -} - -func (self *CommitFilesController) enterCommitFile(node *filetree.CommitFileNode, opts types.OnFocusOpts) error { - if node.File == nil { - return self.handleToggleCommitFileDirCollapsed(node) - } - - if self.c.UserConfig().Git.DiffContextSize == 0 { - return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch, - self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) - } - - from, to, reverse := self.currentFromToReverseForPatchBuilding() - mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse) - return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{ - Title: self.c.Tr.DiscardPatch, - Prompt: self.c.Tr.DiscardPatchConfirm, - HandleConfirm: func() error { - if mustDiscardPatch { - self.c.Git().Patch.PatchBuilder.Reset() - } - - if !self.c.Git().Patch.PatchBuilder.Active() { - if err := self.startPatchBuilder(); err != nil { - return err - } - } - - self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts) - self.c.Helpers().PatchBuilding.ShowHunkStagingHint() - - return nil - }, - }) -} - -func (self *CommitFilesController) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) error { - self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetInternalPath()) - - self.c.PostRefreshUpdate(self.context()) - - return nil + return self.c.Helpers().CommitFiles.EnterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1}) } // NOTE: this is very similar to handleToggleFileTreeView, could be DRY'd with generics @@ -623,7 +563,7 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName } } - return self.enterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) + return self.c.Helpers().CommitFiles.EnterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) } } diff --git a/pkg/gui/controllers/helpers/commit_files_helper.go b/pkg/gui/controllers/helpers/commit_files_helper.go new file mode 100644 index 00000000000..bd553a605d4 --- /dev/null +++ b/pkg/gui/controllers/helpers/commit_files_helper.go @@ -0,0 +1,85 @@ +package helpers + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/filetree" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +type CommitFilesHelper struct { + c *HelperCommon + + patchBuildingHelper *PatchBuildingHelper +} + +func NewCommitFilesHelper(c *HelperCommon, patchBuildingHelper *PatchBuildingHelper) *CommitFilesHelper { + return &CommitFilesHelper{ + c: c, + patchBuildingHelper: patchBuildingHelper, + } +} + +func (self *CommitFilesHelper) EnterCommitFile(node *filetree.CommitFileNode, opts types.OnFocusOpts) error { + if node.File == nil { + self.handleToggleCommitFileDirCollapsed(node) + return nil + } + + if self.c.UserConfig().Git.DiffContextSize == 0 { + return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextToStage, + self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) + } + + from, to, reverse := self.CurrentFromToReverseForPatchBuilding() + mustDiscardPatch := self.c.Git().Patch.PatchBuilder.Active() && self.c.Git().Patch.PatchBuilder.NewPatchRequired(from, to, reverse) + return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{ + Title: self.c.Tr.DiscardPatch, + Prompt: self.c.Tr.DiscardPatchConfirm, + HandleConfirm: func() error { + if mustDiscardPatch { + self.c.Git().Patch.PatchBuilder.Reset() + } + + if !self.c.Git().Patch.PatchBuilder.Active() { + if err := self.StartPatchBuilder(); err != nil { + return err + } + } + + self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts) + self.patchBuildingHelper.ShowHunkStagingHint() + + return nil + }, + }) +} + +func (self *CommitFilesHelper) context() *context.CommitFilesContext { + return self.c.Contexts().CommitFiles +} + +func (self *CommitFilesHelper) handleToggleCommitFileDirCollapsed(node *filetree.CommitFileNode) { + self.context().CommitFileTreeViewModel.ToggleCollapsed(node.GetInternalPath()) + + self.c.PostRefreshUpdate(self.context()) +} + +func (self *CommitFilesHelper) StartPatchBuilder() error { + commitFilesContext := self.context() + + canRebase := commitFilesContext.GetCanRebase() + from, to, reverse := self.CurrentFromToReverseForPatchBuilding() + + self.c.Git().Patch.PatchBuilder.Start(from, to, reverse, canRebase) + return nil +} + +func (self *CommitFilesHelper) CurrentFromToReverseForPatchBuilding() (string, string, bool) { + commitFilesContext := self.context() + + from, to := commitFilesContext.GetFromAndToForDiff() + from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) + return from, to, reverse +} diff --git a/pkg/gui/controllers/helpers/helpers.go b/pkg/gui/controllers/helpers/helpers.go index 4c9c79f3d81..4ff7f24c0d9 100644 --- a/pkg/gui/controllers/helpers/helpers.go +++ b/pkg/gui/controllers/helpers/helpers.go @@ -36,6 +36,7 @@ type Helpers struct { FixupHelper *FixupHelper Commits *CommitsHelper SuspendResume *SuspendResumeHelper + CommitFiles *CommitFilesHelper Snake *SnakeHelper // lives in context package because our contexts need it to render to main Diff *DiffHelper @@ -74,6 +75,7 @@ func NewStubHelpers() *Helpers { AmendHelper: &AmendHelper{}, FixupHelper: &FixupHelper{}, Commits: &CommitsHelper{}, + CommitFiles: &CommitFilesHelper{}, Snake: &SnakeHelper{}, Diff: &DiffHelper{}, Repos: &ReposHelper{}, From 0088f26c13724cfd20a2f70b184cc337ddcca92f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 11 Oct 2024 16:44:59 +0200 Subject: [PATCH 07/24] Press enter in main view of commits panel to enter patch building for clicked line This involves first switching to the commit files view, and then entering the clicked file from there. --- .../switch_to_diff_files_controller.go | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index c2ff4d6747d..745dfc9f877 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -4,6 +4,8 @@ import ( "path/filepath" "github.com/jesseduffield/lazygit/pkg/commands/models" + + "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -50,6 +52,41 @@ func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOp return bindings } +func (self *SwitchToDiffFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { + return func(mainViewName string, clickedLineIdx int) error { + clickedFile, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(mainViewName, clickedLineIdx) + if !ok { + return nil + } + + if err := self.enter(); err != nil { + return err + } + + context := self.c.Contexts().CommitFiles + var node *filetree.CommitFileNode + + relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), clickedFile) + if err != nil { + return err + } + relativePath = "./" + relativePath + context.CommitFileTreeViewModel.ExpandToPath(relativePath) + self.c.PostRefreshUpdate(context) + + idx, ok := context.CommitFileTreeViewModel.GetIndexForPath(relativePath) + if !ok { + return nil + } + + context.SetSelectedLineIdx(idx) + context.GetViewTrait().FocusPoint( + context.ModelIndexToViewIndex(idx), false) + node = context.GetSelected() + return self.c.Helpers().CommitFiles.EnterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) + } +} + func (self *SwitchToDiffFilesController) Context() types.Context { return self.context } From 877812c6ade627fc502e943365b877cf09baa4e1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 29 Mar 2025 16:52:55 +0100 Subject: [PATCH 08/24] WIP After going straight to patch building from main view, esc goes all the way back out I *think* I like it better this way, but it needs more testing. --- .../controllers/commits_files_controller.go | 6 ++++-- .../helpers/commit_files_helper.go | 11 +++++++++- .../helpers/patch_building_helper.go | 20 +++++++++++++++++-- .../switch_to_diff_files_controller.go | 5 ++++- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 4160d4bce99..1e17549c72c 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -506,7 +506,7 @@ func (self *CommitFilesController) toggleAllForPatch(_ *filetree.CommitFileNode) } func (self *CommitFilesController) enter(node *filetree.CommitFileNode) error { - return self.c.Helpers().CommitFiles.EnterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1}) + return self.c.Helpers().CommitFiles.EnterCommitFile(node, nil, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1}) } // NOTE: this is very similar to handleToggleFileTreeView, could be DRY'd with generics @@ -563,7 +563,9 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName } } - return self.c.Helpers().CommitFiles.EnterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) + // Entered from the commit files panel's own focused main view, so escape + // should just pop back to it; no special escape context needed. + return self.c.Helpers().CommitFiles.EnterCommitFile(node, nil, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) } } diff --git a/pkg/gui/controllers/helpers/commit_files_helper.go b/pkg/gui/controllers/helpers/commit_files_helper.go index bd553a605d4..e03e1f8cebf 100644 --- a/pkg/gui/controllers/helpers/commit_files_helper.go +++ b/pkg/gui/controllers/helpers/commit_files_helper.go @@ -21,7 +21,11 @@ func NewCommitFilesHelper(c *HelperCommon, patchBuildingHelper *PatchBuildingHel } } -func (self *CommitFilesHelper) EnterCommitFile(node *filetree.CommitFileNode, opts types.OnFocusOpts) error { +// escapeContext is the side panel that escaping the patch builder should return +// to, for the case where we're entering it straight from a focused main view; +// it's nil for the normal flow that goes through the commit files panel. See +// PatchBuildingHelper.escapeContext. +func (self *CommitFilesHelper) EnterCommitFile(node *filetree.CommitFileNode, escapeContext types.Context, opts types.OnFocusOpts) error { if node.File == nil { self.handleToggleCommitFileDirCollapsed(node) return nil @@ -48,6 +52,11 @@ func (self *CommitFilesHelper) EnterCommitFile(node *filetree.CommitFileNode, op } } + // Set on every entry (so it can't leak from a previous main-view + // entry into a subsequent normal one), right as we push the patch + // builder. + self.patchBuildingHelper.escapeContext = escapeContext + self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts) self.patchBuildingHelper.ShowHunkStagingHint() diff --git a/pkg/gui/controllers/helpers/patch_building_helper.go b/pkg/gui/controllers/helpers/patch_building_helper.go index ff3e3199009..b66f7ef1237 100644 --- a/pkg/gui/controllers/helpers/patch_building_helper.go +++ b/pkg/gui/controllers/helpers/patch_building_helper.go @@ -10,6 +10,14 @@ import ( type PatchBuildingHelper struct { c *HelperCommon + + // When patch building is entered straight from a focused main view (rather + // than from the commit files panel), this records the side panel to return + // to on escape, so that we skip the commit files panel we never really + // visited. It is nil for the normal flow, where escape just pops back to the + // commit files panel. Set on every entry into patch building (see + // CommitFilesHelper.EnterCommitFile) so it can't leak between flows. + escapeContext types.Context } func NewPatchBuildingHelper( @@ -32,9 +40,17 @@ func (self *PatchBuildingHelper) ShowHunkStagingHint() { } } -// takes us from the patch building panel back to the commit files panel +// takes us from the patch building panel back to the commit files panel, or +// straight back to the side panel if we entered patch building from a focused +// main view (see escapeContext) func (self *PatchBuildingHelper) Escape() { - self.c.Context().Pop() + if self.escapeContext != nil { + escapeContext := self.escapeContext + self.escapeContext = nil + self.c.Context().Push(escapeContext, types.OnFocusOpts{}) + } else { + self.c.Context().Pop() + } } // kills the custom patch and returns us back to the commit files panel if needed diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index 745dfc9f877..ce157ebef26 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -83,7 +83,10 @@ func (self *SwitchToDiffFilesController) GetOnClickFocusedMainView() func(mainVi context.GetViewTrait().FocusPoint( context.ModelIndexToViewIndex(idx), false) node = context.GetSelected() - return self.c.Helpers().CommitFiles.EnterCommitFile(node, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) + // We entered patch building straight from the focused main view, so + // escaping it should take us all the way back out to this side panel, + // skipping the commit files panel we never really visited. + return self.c.Helpers().CommitFiles.EnterCommitFile(node, self.context, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) } } From 55922f81ae090201d63674a62540873148ff9d28 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 26 May 2026 14:50:18 +0200 Subject: [PATCH 09/24] Replace gui.showSelectionInFocusedMainView config with on-demand selection Instead of a user config that always shows a selection on focus, the focused main view starts without a selection (matching master). Pressing shows a selection in the middle of the view (no scrolling); pressing hides it again before falling through to exiting the view. --- docs-master/keybindings/Keybindings_en.md | 2 + docs-master/keybindings/Keybindings_ja.md | 2 + docs-master/keybindings/Keybindings_ko.md | 2 + docs-master/keybindings/Keybindings_nl.md | 2 + docs-master/keybindings/Keybindings_pl.md | 2 + docs-master/keybindings/Keybindings_pt.md | 2 + docs-master/keybindings/Keybindings_ru.md | 2 + docs-master/keybindings/Keybindings_zh-CN.md | 2 + docs-master/keybindings/Keybindings_zh-TW.md | 2 + docs/Config.md | 3 - pkg/config/user_config.go | 41 ++++++------- pkg/gui/context/base_context.go | 4 -- pkg/gui/context/main_context.go | 2 +- pkg/gui/controllers/main_view_controller.go | 59 +++++++++++++++---- .../switch_to_focused_main_view_controller.go | 19 ++---- pkg/gui/gui.go | 5 -- pkg/i18n/english.go | 2 + schema/config.json | 5 -- 18 files changed, 92 insertions(+), 66 deletions(-) diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index d63058d8252..5b4a7270fd9 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -222,6 +222,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | Scroll up | | | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Search the current view by text | | ## Main panel (patch building) @@ -327,6 +328,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Search the current view by text | | ## Stash diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index d9b87d747cc..33471a1c7c3 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -192,6 +192,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | | `` `` | サイドパネルに戻る | | +| `` `` | Show/hide selection | | | `` / `` | 現在のビューをテキストで検索 | | ## タグ @@ -305,6 +306,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | 上にスクロール | | | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | | `` `` | サイドパネルに戻る | | +| `` `` | Show/hide selection | | | `` / `` | 現在のビューをテキストで検索 | | ## メニュー diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 089543c5fb9..d7539eada0b 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -83,6 +83,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | 검색 시작 | | ## Stash @@ -161,6 +162,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | 위로 스크롤 | | | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | 검색 시작 | | ## 메인 패널 (Patch Building) diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 1715c597efc..fb71cba8879 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -230,6 +230,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | Scroll omhoog | | | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Start met zoeken | | ## Patch bouwen @@ -305,6 +306,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Start met zoeken | | ## Staging diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index b032a660604..9d036af4d4c 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -98,6 +98,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Drzewa pracy @@ -200,6 +201,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | Przewiń w górę | | | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Panel główny (scalanie) diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index c19619191c8..c0637c99400 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -234,6 +234,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | Rolar para cima | | | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Pesquisar na visualização atual por texto | | ## Painel Principal (preparação) @@ -336,6 +337,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Pesquisar na visualização atual por texto | | ## Stash diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index c802678b3eb..79675b94103 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -73,6 +73,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Найти | | ## Главная панель (Индексирование) @@ -105,6 +106,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | Прокрутить вверх | | | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | Найти | | ## Главная панель (Слияние) diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 9cb7d5186a7..c5b0c00b702 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -285,6 +285,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` `` | 退出回到侧边面板 | | +| `` `` | Show/hide selection | | | `` / `` | 开始搜索 | | ## 正在合并 @@ -333,6 +334,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | 向上滚动 | | | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` `` | 退出回到侧边面板 | | +| `` `` | Show/hide selection | | | `` / `` | 开始搜索 | | ## 状态 diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index d6526b5b2b6..a9665eefe23 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -81,6 +81,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` (fn+down) `` | 向上捲動 | | | `` `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | 搜尋 | | ## 主面板(合併) @@ -362,6 +363,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | +| `` `` | Show/hide selection | | | `` / `` | 搜尋 | | ## 狀態 diff --git a/docs/Config.md b/docs/Config.md index 5a198b25f49..9f79218217f 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -137,9 +137,6 @@ gui: # staging view. useHunkModeInStagingView: true - # If true, show a selection when the main view is focused. - showSelectionInFocusedMainView: false - # One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' # | 'ru' | 'pt' language: auto diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 2376d4fb514..cac87ec9157 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -117,8 +117,6 @@ type GuiConfig struct { WrapLinesInStagingView bool `yaml:"wrapLinesInStagingView"` // If true, hunk selection mode will be enabled by default when entering the staging view. UseHunkModeInStagingView bool `yaml:"useHunkModeInStagingView"` - // If true, show a selection when the main view is focused. - ShowSelectionInFocusedMainView bool `yaml:"showSelectionInFocusedMainView"` // One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru' | 'pt' Language string `yaml:"language" jsonschema:"enum=auto,enum=en,enum=zh-TW,enum=zh-CN,enum=pl,enum=nl,enum=ja,enum=ko,enum=ru"` // Format used when displaying time e.g. commit time. @@ -819,26 +817,25 @@ func GetDefaultConfig() *UserConfig { func GetDefaultConfigForPlatform(platform string) *UserConfig { return &UserConfig{ Gui: GuiConfig{ - ScrollHeight: 2, - ScrollPastBottom: true, - ScrollOffMargin: 2, - ScrollOffBehavior: "margin", - TabWidth: 4, - MouseEvents: true, - SkipAmendWarning: false, - SkipDiscardChangeWarning: false, - SkipStashWarning: false, - SidePanelWidth: 0.3333, - ExpandFocusedSidePanel: false, - ExpandedSidePanelWeight: 2, - MainPanelSplitMode: "flexible", - EnlargedSideViewLocation: "left", - WrapLinesInStagingView: true, - UseHunkModeInStagingView: true, - ShowSelectionInFocusedMainView: false, - Language: "auto", - TimeFormat: "02 Jan 06", - ShortTimeFormat: time.Kitchen, + ScrollHeight: 2, + ScrollPastBottom: true, + ScrollOffMargin: 2, + ScrollOffBehavior: "margin", + TabWidth: 4, + MouseEvents: true, + SkipAmendWarning: false, + SkipDiscardChangeWarning: false, + SkipStashWarning: false, + SidePanelWidth: 0.3333, + ExpandFocusedSidePanel: false, + ExpandedSidePanelWeight: 2, + MainPanelSplitMode: "flexible", + EnlargedSideViewLocation: "left", + WrapLinesInStagingView: true, + UseHunkModeInStagingView: true, + Language: "auto", + TimeFormat: "02 Jan 06", + ShortTimeFormat: time.Kitchen, Theme: ThemeConfig{ ActiveBorderColor: []string{"green", "bold"}, SearchingActiveBorderColor: []string{"cyan", "bold"}, diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index d684d7fac9d..7584b5a1251 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -253,7 +253,3 @@ func (self *BaseContext) Title() string { func (self *BaseContext) TotalContentHeight() int { return self.view.ViewLinesHeight() } - -func (self *BaseContext) SetHighlightOnFocus(value bool) { - self.highlightOnFocus = value -} diff --git a/pkg/gui/context/main_context.go b/pkg/gui/context/main_context.go index 2960f8ffeb5..c8b6edadebb 100644 --- a/pkg/gui/context/main_context.go +++ b/pkg/gui/context/main_context.go @@ -26,7 +26,7 @@ func NewMainContext( WindowName: windowName, Key: key, Focusable: true, - HighlightOnFocus: c.UserConfig().Gui.ShowSelectionInFocusedMainView, + HighlightOnFocus: false, })), SearchTrait: NewSearchTrait(c), } diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 65fcad01c14..77b9b949f0e 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -4,6 +4,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" ) type MainViewController struct { @@ -30,11 +31,13 @@ func NewMainViewController( } func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { - var goIntoDescription string - // We only want to show the "enter" menu item if the user config is true; - // leaving the description empty causes it to be hidden - if self.c.UserConfig().Gui.ShowSelectionInFocusedMainView { - goIntoDescription = self.c.Tr.EnterStaging + // When a selection is shown, we surface the bindings that act on it + // (enter to dive into staging, escape to hide the selection). + selectionShown := self.context.GetView().Highlight + + var enterDescription string + if selectionShown { + enterDescription = self.c.Tr.EnterStaging } return []*types.Binding{ @@ -52,9 +55,16 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty DisplayOnScreen: true, }, { - Keys: opts.GetKeys(opts.Config.Universal.GoInto), - Handler: self.enter, - Description: goIntoDescription, + Keys: opts.GetKeys(opts.Config.Universal.Select), + Handler: self.toggleSelection, + Description: self.c.Tr.ToggleSelectionInFocusedMainView, + DisplayOnScreen: !selectionShown, + }, + { + Keys: opts.GetKeys(opts.Config.Universal.GoInto), + Handler: self.enter, + Description: enterDescription, + DisplayOnScreen: selectionShown, }, { // overriding this because we want to read all of the task's output before we start searching @@ -87,11 +97,14 @@ func (self *MainViewController) Context() types.Context { return self.context } +// Transient focus shifts (popups, search) leave HighlightInactive=true on our +// view (set by ContextMgr.Activate when a different view becomes current). Our +// context's highlightOnFocus is false, so SimpleContext.HandleFocus never +// resets it. Reset it here on the way back in, so that if we still hold a +// selection it's drawn as active. The flag is a no-op when Highlight is false. func (self *MainViewController) GetOnFocus() func(types.OnFocusOpts) { - return func(opts types.OnFocusOpts) { - if opts.ClickedWindowName != "" { - self.context.GetView().FocusPoint(0, opts.ClickedViewLineIdx, false) - } + return func(types.OnFocusOpts) { + self.context.GetView().HighlightInactive = false } } @@ -104,11 +117,33 @@ func (self *MainViewController) togglePanel() error { } func (self *MainViewController) escape() error { + v := self.context.GetView() + if v.Highlight { + v.Highlight = false + return nil + } self.c.Context().Pop() return nil } +func (self *MainViewController) toggleSelection() error { + v := self.context.GetView() + if v.Highlight { + v.Highlight = false + return nil + } + v.Highlight = true + v.HighlightInactive = false + lineIdx := v.OriginY() + v.InnerHeight()/2 + lineIdx = lo.Clamp(lineIdx, 0, v.ViewLinesHeight()-1) + v.FocusPoint(0, lineIdx, false) + return nil +} + func (self *MainViewController) enter() error { + if !self.context.GetView().Highlight { + return nil + } sidePanelContext := self.c.Context().NextInStack(self.context) if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil { return sidePanelContext.GetOnClickFocusedMainView()( diff --git a/pkg/gui/controllers/switch_to_focused_main_view_controller.go b/pkg/gui/controllers/switch_to_focused_main_view_controller.go index 3c3ecd207ad..5606a0bab59 100644 --- a/pkg/gui/controllers/switch_to_focused_main_view_controller.go +++ b/pkg/gui/controllers/switch_to_focused_main_view_controller.go @@ -3,7 +3,6 @@ package controllers import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" - "github.com/samber/lo" ) // This controller is for all contexts that can focus their main view. @@ -62,29 +61,21 @@ func (self *SwitchToFocusedMainViewController) Context() types.Context { } func (self *SwitchToFocusedMainViewController) onClickMain(opts gocui.ViewMouseBindingOpts) error { - return self.focusMainView(self.c.Contexts().Normal, opts.Y) + return self.focusMainView(self.c.Contexts().Normal) } func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error { - return self.focusMainView(self.c.Contexts().NormalSecondary, opts.Y) + return self.focusMainView(self.c.Contexts().NormalSecondary) } func (self *SwitchToFocusedMainViewController) handleFocusMainView() error { - return self.focusMainView(self.c.Contexts().Normal, -1) + return self.focusMainView(self.c.Contexts().Normal) } -func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context, clickedViewLineIdx int) error { +func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context) error { if context, ok := mainViewContext.(types.ISearchableContext); ok { context.ClearSearchString() } - onFocusOpts := types.OnFocusOpts{ClickedWindowName: mainViewContext.GetWindowName()} - if clickedViewLineIdx >= 0 { - onFocusOpts.ClickedViewLineIdx = clickedViewLineIdx - } else { - mainView := mainViewContext.GetView() - lineIdx := mainView.OriginY() + mainView.Height()/2 - onFocusOpts.ClickedViewLineIdx = lo.Clamp(lineIdx, 0, mainView.LinesHeight()-1) - } - self.c.Context().Push(mainViewContext, onFocusOpts) + self.c.Context().Push(mainViewContext, types.OnFocusOpts{}) return nil } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 9229b45ffa1..e2881cca148 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -487,11 +487,6 @@ func (gui *Gui) onUserConfigLoaded() error { gui.g.Mouse = userConfig.Gui.MouseEvents - if gui.State != nil { - gui.Contexts().Normal.SetHighlightOnFocus(userConfig.Gui.ShowSelectionInFocusedMainView) - gui.Contexts().NormalSecondary.SetHighlightOnFocus(userConfig.Gui.ShowSelectionInFocusedMainView) - } - // originally we could only hide the command log permanently via the config // but now we do it via state. So we need to still support the config for the // sake of backwards compatibility. We're making use of short circuiting here diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 8f0b5c92205..6561d5947a1 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -533,6 +533,7 @@ type TranslationSet struct { EnterStaging string ExitCustomPatchBuilder string ExitFocusedMainView string + ToggleSelectionInFocusedMainView string EnterUpstream string InvalidUpstream string NewRemote string @@ -1663,6 +1664,7 @@ func EnglishTranslationSet() *TranslationSet { EnterStaging: "Enter staging/patch building", ExitCustomPatchBuilder: `Exit custom patch builder`, ExitFocusedMainView: "Exit back to side panel", + ToggleSelectionInFocusedMainView: "Show/hide selection", EnterUpstream: `Enter upstream as ' '`, InvalidUpstream: "Invalid upstream. Must be in the format ' '", NewRemote: `New remote`, diff --git a/schema/config.json b/schema/config.json index e6aa5f9b5cd..2e968ba8f95 100644 --- a/schema/config.json +++ b/schema/config.json @@ -610,11 +610,6 @@ "description": "If true, hunk selection mode will be enabled by default when entering the staging view.", "default": true }, - "showSelectionInFocusedMainView": { - "type": "boolean", - "description": "If true, show a selection when the main view is focused.", - "default": false - }, "language": { "type": "string", "enum": [ From c5dd8ddc6adcdd9db9d41c6ba18b7302b55e99d5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 31 May 2026 13:58:53 +0200 Subject: [PATCH 10/24] Press `e` in focused main view (when selection is showing) to edit that line --- pkg/gui/controllers/main_view_controller.go | 27 ++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 77b9b949f0e..4f837bd8db5 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -32,12 +32,17 @@ func NewMainViewController( func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { // When a selection is shown, we surface the bindings that act on it - // (enter to dive into staging, escape to hide the selection). + // (enter to dive into staging, e to edit the selected line, escape to hide + // the selection). selectionShown := self.context.GetView().Highlight var enterDescription string + var editDescription string + var editTooltip string if selectionShown { enterDescription = self.c.Tr.EnterStaging + editDescription = self.c.Tr.EditFile + editTooltip = self.c.Tr.EditFileTooltip } return []*types.Binding{ @@ -66,6 +71,12 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty Description: enterDescription, DisplayOnScreen: selectionShown, }, + { + Keys: opts.GetKeys(opts.Config.Universal.Edit), + Handler: self.editLine, + Description: editDescription, + Tooltip: editTooltip, + }, { // overriding this because we want to read all of the task's output before we start searching Keys: opts.GetKeys(opts.Config.Universal.StartSearch), @@ -152,6 +163,20 @@ func (self *MainViewController) enter() error { return nil } +func (self *MainViewController) editLine() error { + if !self.context.GetView().Highlight { + return nil + } + // Figure out the clicked file and line the same way entering staging does. + path, lineNumber, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine( + self.context.GetViewName(), self.context.GetView().SelectedLineIdx()) + if !ok { + return nil + } + lineNumber = self.c.Helpers().Diff.AdjustLineNumber(path, lineNumber, self.context.GetViewName()) + return self.c.Helpers().Files.EditFileAtLine(path, lineNumber) +} + func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error { if self.context.GetView().Highlight && !opts.IsDoubleClick { return nil From 385d2e9dd1ca2b1dc7dd28038cb69037a2eb962f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 31 May 2026 15:06:39 +0200 Subject: [PATCH 11/24] Open a browser at the selected line in the diff of the current branch's PR --- pkg/gui/controllers/main_view_controller.go | 110 +- pkg/i18n/english.go | 1172 ++++++++++--------- 2 files changed, 696 insertions(+), 586 deletions(-) diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 4f837bd8db5..e118b8c1132 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -1,6 +1,12 @@ package controllers import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "path/filepath" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -32,17 +38,21 @@ func NewMainViewController( func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { // When a selection is shown, we surface the bindings that act on it - // (enter to dive into staging, e to edit the selected line, escape to hide - // the selection). + // (enter to dive into staging, e to edit the selected line, G to open the + // line in the branch's pull request, escape to hide the selection). selectionShown := self.context.GetView().Highlight var enterDescription string var editDescription string var editTooltip string + var openPullRequestDescription string + var openPullRequestTooltip string if selectionShown { enterDescription = self.c.Tr.EnterStaging editDescription = self.c.Tr.EditFile editTooltip = self.c.Tr.EditFileTooltip + openPullRequestDescription = self.c.Tr.OpenPullRequestForSelectedLine + openPullRequestTooltip = self.c.Tr.OpenPullRequestForSelectedLineTooltip } return []*types.Binding{ @@ -77,6 +87,12 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty Description: editDescription, Tooltip: editTooltip, }, + { + Keys: opts.GetKeys(opts.Config.Commits.OpenPullRequestInBrowser), + Handler: self.openPullRequestForSelectedLine, + Description: openPullRequestDescription, + Tooltip: openPullRequestTooltip, + }, { // overriding this because we want to read all of the task's output before we start searching Keys: opts.GetKeys(opts.Config.Universal.StartSearch), @@ -177,6 +193,96 @@ func (self *MainViewController) editLine() error { return self.c.Helpers().Files.EditFileAtLine(path, lineNumber) } +func (self *MainViewController) openPullRequestForSelectedLine() error { + if !self.context.GetView().Highlight { + return nil + } + + sidePanelContext := self.c.Context().NextInStack(self.context) + if sidePanelContext == nil { + return nil + } + + // The branch whose PR to open depends on where we navigated from: the + // checked-out branch when looking at its own commits, but the branch we + // drilled into when in the sub-commits or commit-files panels. + branchName, ok := self.branchForPullRequest(sidePanelContext) + if !ok { + return nil + } + + pr, ok := self.c.Model().PullRequestsMap[branchName] + if !ok { + return errors.New(self.c.Tr.NoPullRequestForBranch) + } + + // The diff shown is the diff of a particular commit, so we deep-link into + // that commit's view of the PR; its right-side line numbers match what we're + // showing, so (unlike editLine) no line-number adjustment is needed. + diffableContext, ok := sidePanelContext.(types.DiffableContext) + if !ok { + return nil + } + commitSha := diffableContext.RefForAdjustingLineNumberInDiff() + if commitSha == "" { + return nil + } + + // Figure out the clicked file and line the same way entering staging does. + path, lineNumber, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine( + self.context.GetViewName(), self.context.GetView().SelectedLineIdx()) + if !ok { + return nil + } + + relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path) + if err != nil { + return err + } + + self.c.LogAction(self.c.Tr.Actions.OpenPullRequest) + return self.c.OS().OpenLink( + githubPullRequestLineURL(pr.Url, commitSha, filepath.ToSlash(relativePath), lineNumber)) +} + +// branchForPullRequest returns the local branch whose pull request applies to +// the diff currently shown in the focused main view, given the side panel +// beneath it. It returns false for contexts that don't map to a local branch +// (e.g. the working-tree files panel, stashes, tags, or remote branches). +func (self *MainViewController) branchForPullRequest(sidePanelContext types.Context) (string, bool) { + switch sidePanelContext.GetKey() { + case context.LOCAL_COMMITS_CONTEXT_KEY: + return self.c.Model().CheckedOutBranch, true + case context.SUB_COMMITS_CONTEXT_KEY: + ref := self.c.Contexts().SubCommits.GetRef() + if ref == nil { + return "", false + } + return ref.RefName(), true + case context.COMMIT_FILES_CONTEXT_KEY: + // The commit files panel doesn't itself know which branch it belongs to; + // that's determined by the panel we entered it from. + parent := self.c.Contexts().CommitFiles.GetParentContext() + if parent == nil { + return "", false + } + return self.branchForPullRequest(parent) + default: + return "", false + } +} + +// githubPullRequestLineURL builds a URL that opens the given line of a file in +// the diff of a specific commit within a GitHub pull request. The file is +// identified by the SHA-256 of its repo-relative path, and R targets the +// right (new) side of the diff. See +// https://github.com/orgs/community/discussions/55764. +func githubPullRequestLineURL(prURL string, commitSha string, relativePath string, lineNumber int) string { + pathHash := sha256.Sum256([]byte(relativePath)) + anchor := fmt.Sprintf("diff-%sR%d", hex.EncodeToString(pathHash[:]), lineNumber) + return fmt.Sprintf("%s/changes/%s#%s", prURL, commitSha, anchor) +} + func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error { if self.context.GetView().Highlight && !opts.IsDoubleClick { return nil diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 6561d5947a1..f0a1f90ef3d 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -534,6 +534,8 @@ type TranslationSet struct { ExitCustomPatchBuilder string ExitFocusedMainView string ToggleSelectionInFocusedMainView string + OpenPullRequestForSelectedLine string + OpenPullRequestForSelectedLineTooltip string EnterUpstream string InvalidUpstream string NewRemote string @@ -1134,590 +1136,592 @@ to your lazygit config.` // exporting this so we can use it in tests func EnglishTranslationSet() *TranslationSet { return &TranslationSet{ - NotEnoughSpace: "Not enough space to render panels", - DiffTitle: "Diff", - FilesTitle: "Files", - BranchesTitle: "Branches", - CommitsTitle: "Commits", - StashTitle: "Stash", - SnakeTitle: "Snake", - EasterEgg: "Easter egg", - UnstagedChanges: "Unstaged changes", - StagedChanges: "Staged changes", - StagingTitle: "Main panel (staging)", - MergingTitle: "Main panel (merging)", - NormalTitle: "Main panel (normal)", - LogTitle: "Log", - LogXOfYTitle: "Log (%d of %d)", - CommitSummary: "Commit summary", - CredentialsUsername: "Username", - CredentialsPassword: "Password", - CredentialsPassphrase: "Enter passphrase for SSH key", - CredentialsPIN: "Enter PIN for SSH key", - CredentialsToken: "Enter Token for SSH key", - PassUnameWrong: "Password, passphrase and/or username wrong", - Commit: "Commit", - CommitTooltip: "Commit staged changes.", - AmendLastCommit: "Amend last commit", - AmendLastCommitTitle: "Amend last commit", - SureToAmend: "Are you sure you want to amend last commit? Afterwards, you can change the commit message from the commits panel.", - NoCommitToAmend: "There's no commit to amend.", - CommitChangesWithEditor: "Commit changes using git editor", - FindBaseCommitForFixup: "Find base commit for fixup", - FindBaseCommitForFixupTooltip: "Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: ", - NoBaseCommitsFound: "No base commits found", - MultipleBaseCommitsFoundStaged: "Multiple base commits found. (Try staging fewer changes at once)", - MultipleBaseCommitsFoundUnstaged: "Multiple base commits found. (Try staging some of the changes)", - BaseCommitIsAlreadyOnMainBranch: "The base commit for this change is already on the main branch", - BaseCommitIsNotInCurrentView: "Base commit is not in current view", - HunksWithOnlyAddedLinesWarning: "There are ranges of only added lines in the diff; be careful to check that these belong in the found base commit.\n\nProceed?", - StatusTitle: "Status", - Execute: "Execute", - Stage: "Stage", - StageTooltip: "Toggle staged for selected file.", - ToggleStagedAll: "Stage all", - ToggleStagedAllTooltip: "Toggle staged/unstaged for all files in working tree.", - ToggleTreeView: "Toggle file tree view", - ToggleTreeViewTooltip: "Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.\n\nThe default can be changed in the config file with the key 'gui.showFileTree'.", - OpenDiffTool: "Open external diff tool (git difftool)", - OpenMergeTool: "Open external merge tool", - Refresh: "Refresh", - RefreshTooltip: "Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`.", - Push: "Push", - PushTooltip: "Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch.", - Pull: "Pull", - PullTooltip: "Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch.", - MergeConflictsTitle: "Merge conflicts", - MergeConflictDescription_DD: "Conflict: this file was moved or renamed both in the current and the incoming changes, but to different destinations. I don't know which ones, but they should both show up as conflicts too (marked 'AU' and 'UA', respectively). The most likely resolution is to delete this file, and pick one of the destinations and delete the other.", - MergeConflictDescription_AU: "Conflict: this file is the destination of a move or rename in the current changes, but was moved or renamed to a different destination in the incoming changes. That other destination should also show up as a conflict (marked 'UA'), as well as the file that both were renamed from (marked 'DD').", - MergeConflictDescription_UA: "Conflict: this file is the destination of a move or rename in the incoming changes, but was moved or renamed to a different destination in the current changes. That other destination should also show up as a conflict (marked 'AU'), as well as the file that both were renamed from (marked 'DD').", - MergeConflictDescription_DU: "Conflict: this file was deleted in the current changes and modified in the incoming changes.\n\nThe most likely resolution is to delete the file after applying the incoming modifications manually to some other place in the code.", - MergeConflictDescription_UD: "Conflict: this file was modified in the current changes and deleted in incoming changes.\n\nThe most likely resolution is to delete the file after applying the current modifications manually to some other place in the code.", - MergeConflictIncomingDiff: "Incoming changes:", - MergeConflictCurrentDiff: "Current changes:", - MergeConflictPressEnterToResolve: "Press %s to resolve.", - MergeConflictKeepFile: "Keep file", - MergeConflictDeleteFile: "Delete file", - Checkout: "Checkout", - CheckoutTooltip: "Checkout selected item.", - CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch", - TagCheckoutTooltip: "Checkout the selected tag as a detached HEAD.", - RemoteBranchCheckoutTooltip: "Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head.", - CantPullOrPushSameBranchTwice: "You cannot push or pull a branch while it is already being pushed or pulled", - FileFilter: "Filter files by status", - CopyToClipboardMenu: "Copy to clipboard", - CopyFileName: "File name", - CopyRelativeFilePath: "Relative path", - CopyAbsoluteFilePath: "Absolute path", - CopyFileDiffTooltip: "If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.", - CopySelectedDiff: "Diff of selected file", - CopyAllFilesDiff: "Diff of all files", - CopyFileContent: "Content of selected file", - NoContentToCopyError: "Nothing to copy", - FileNameCopiedToast: "File name copied to clipboard", - FilePathCopiedToast: "File path copied to clipboard", - FileDiffCopiedToast: "File diff copied to clipboard", - AllFilesDiffCopiedToast: "All files diff copied to clipboard", - FileContentCopiedToast: "File content copied to clipboard", - FilterStagedFiles: "Show only staged files", - FilterUnstagedFiles: "Show only unstaged files", - FilterTrackedFiles: "Show only tracked files", - FilterUntrackedFiles: "Show only untracked files", - NoFilter: "No filter", - FilterLabelStagedFiles: "(only staged)", - FilterLabelUnstagedFiles: "(only unstaged)", - FilterLabelTrackedFiles: "(only tracked)", - FilterLabelUntrackedFiles: "(only untracked)", - FilterLabelConflictingFiles: "(only conflicting)", - NoChangedFiles: "No changed files", - SoftReset: "Soft reset", - AlreadyCheckedOutBranch: "You have already checked out this branch", - SureForceCheckout: "Are you sure you want force checkout? You will lose all local changes", - ForceCheckoutBranch: "Force checkout branch", - BranchName: "Branch name", - NewBranchNameBranchOff: "New branch name (branch is off of '{{.branchName}}')", - CantDeleteCheckOutBranch: "You cannot delete the checked out branch!", - DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?", - DeleteBranchesTitle: "Delete selected branches?", - DeleteLocalBranch: "Delete local branch", - DeleteLocalBranches: "Delete local branches", - DeleteRemoteBranchPrompt: "Are you sure you want to delete the remote branch '{{.selectedBranchName}}' from '{{.upstream}}'?", - DeleteRemoteBranchesPrompt: "Are you sure you want to delete the remote branches of the selected branches from their respective remotes?", - DeleteLocalAndRemoteBranchPrompt: "Are you sure you want to delete both '{{.localBranchName}}' from your machine, and '{{.remoteBranchName}}' from '{{.remoteName}}'?", - DeleteLocalAndRemoteBranchesPrompt: "Are you sure you want to delete both the selected branches from your machine, and their remote branches from their respective remotes?", - ForceDeleteBranchTitle: "Force delete branch", - ForceDeleteBranchMessage: "'{{.selectedBranchName}}' is not fully merged. Are you sure you want to delete it?", - ForceDeleteBranchesMessage: "Some of the selected branches are not fully merged. Are you sure you want to delete them?", - RebaseBranch: "Rebase", - RebaseBranchTooltip: "Rebase the checked-out branch onto the selected branch.", - CantRebaseOntoSelf: "You cannot rebase a branch onto itself", - CantMergeBranchIntoItself: "You cannot merge a branch into itself", - ForceCheckout: "Force checkout", - ForceCheckoutTooltip: "Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch.", - CheckoutByName: "Checkout by name", - CheckoutByNameTooltip: "Checkout by name. In the input box you can enter '-' to switch to the previous branch.", - CheckoutPreviousBranch: "Checkout previous branch", - RemoteBranchCheckoutTitle: "Checkout {{.branchName}}", - RemoteBranchCheckoutPrompt: "How would you like to check out this branch?", - CheckoutTypeNewBranch: "New local branch", - CheckoutTypeNewBranchTooltip: "Checkout the remote branch as a local branch, tracking the remote branch.", - CheckoutTypeDetachedHead: "Detached head", - CheckoutTypeDetachedHeadTooltip: "Checkout the remote branch as a detached head, which can be useful if you just want to test the branch but not work on it yourself. You can still create a local branch from it later.", - NewBranch: "New branch", - NewBranchFromStashTooltip: "Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit.", - MoveCommitsToNewBranch: "Move commits to new branch", - MoveCommitsToNewBranchTooltip: "Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.\n\nNote that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which).", - MoveCommitsToNewBranchFromMainPrompt: "This will take all unpushed commits and move them to a new branch (off of {{.baseBranchName}}). It will then hard-reset the current branch to its upstream branch. Do you want to continue?", - MoveCommitsToNewBranchMenuPrompt: "This will take all unpushed commits and move them to a new branch. This new branch can either be created from the main branch ({{.baseBranchName}}) or stacked on top of the current branch. Which of these would you like to do?", - MoveCommitsToNewBranchFromBaseItem: "New branch from base branch (%s)", - MoveCommitsToNewBranchStackedItem: "New branch stacked on current branch (%s)", - CannotMoveCommitsFromDetachedHead: "Cannot move commits from a detached head", - CannotMoveCommitsNoUpstream: "Cannot move commits from a branch that has no upstream branch", - CannotMoveCommitsBehindUpstream: "Cannot move commits from a branch that is behind its upstream branch", - CannotMoveCommitsNoUnpushedCommits: "There are no unpushed commits to move to a new branch", - NoBranchesThisRepo: "No branches for this repo", - CommitWithoutMessageErr: "You cannot commit without a commit message", - Close: "Close", - CloseCancel: "Close/Cancel", - Confirm: "Confirm", - Quit: "Quit", - SquashTooltip: "Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it.", - NoCommitsThisBranch: "No commits for this branch", - UpdateRefHere: "Update branch '{{.ref}}' here", - ExecCommandHere: "Execute the following command here:", - CannotSquashOrFixupFirstCommit: "There's no commit below to squash into", - CannotSquashOrFixupMergeCommit: "Cannot squash or fixup a merge commit", - Fixup: "Fixup", - FixupKeepMessage: "Fixup and use this commit's message", - FixupKeepMessageTooltip: "Squash the selected commit into the commit below, using this commit's message, discarding the message of the commit below.", - SetFixupMessage: "Set fixup message", - SetFixupMessageTooltip: "Set the message option for the fixup commit. The -C option means to use this commit's message instead of the target commit's message.", - FixupDiscardMessage: "Fixup and discard this commit's message", - FixupDiscardMessageTooltip: "Squash the selected commit into the commit below, discarding this commit's message.", - SureSquashThisCommit: "Are you sure you want to squash the selected commit(s) into the commit below?", - Squash: "Squash", - PickCommitTooltip: "Mark the selected commit to be picked (when mid-rebase). This means that the commit will be retained upon continuing the rebase.", - Pick: "Pick", - Edit: "Edit", - Revert: "Revert", - RevertCommitTooltip: "Create a revert commit for the selected commit, which applies the selected commit's changes in reverse.", - Reword: "Reword", - CommitRewordTooltip: "Reword the selected commit's message.", - DropCommit: "Drop", - DropCommitTooltip: "Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts.", - MoveDownCommit: "Move commit down one", - MoveUpCommit: "Move commit up one", - CannotMoveAnyFurther: "Cannot move any further", - CannotMoveMergeCommit: "Cannot move a merge commit", - EditCommit: "Edit (start interactive rebase)", - EditCommitTooltip: "Edit the selected commit. Use this to start an interactive rebase from the selected commit. When already mid-rebase, this will mark the selected commit for editing, which means that upon continuing the rebase, the rebase will pause at the selected commit to allow you to make changes.", - AmendCommitTooltip: "Amend commit with staged changes. If the selected commit is the HEAD commit, this will perform `git commit --amend`. Otherwise the commit will be amended via a rebase.", - Amend: "Amend", - ResetAuthor: "Reset author", - ResetAuthorTooltip: "Reset the commit's author to the currently configured user. This will also renew the author timestamp", - SetAuthor: "Set author", - SetAuthorTooltip: "Set the author based on a prompt", - AddCoAuthor: "Add co-author", - AmendCommitAttribute: "Amend commit attribute", - AmendCommitAttributeTooltip: "Set/Reset commit author or set co-author.", - SetAuthorPromptTitle: "Set author (must look like 'Name ')", - AddCoAuthorPromptTitle: "Add co-author (must look like 'Name ')", - AddCoAuthorTooltip: "Add co-author using the Github/Gitlab metadata Co-authored-by.", - RewordCommitEditor: "Reword with editor", - Error: "Error", - PickHunk: "Pick hunk", - PickAllHunks: "Pick all hunks", - Undo: "Undo", - UndoReflog: "Undo", - RedoReflog: "Redo", - UndoTooltip: "The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration.", - RedoTooltip: "The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration.", - UndoMergeResolveTooltip: "Undo last merge conflict resolution.", - DiscardAllTooltip: "Discard both staged and unstaged changes in '{{.path}}'.", - DiscardUnstagedTooltip: "Discard unstaged changes in '{{.path}}'.", - DiscardUnstagedDisabled: "The selected items don't have both staged and unstaged changes.", - Pop: "Pop", - StashPopTooltip: "Apply the stash entry to your working directory and remove the stash entry.", - Drop: "Drop", - StashDropTooltip: "Remove the stash entry from the stash list.", - Apply: "Apply", - StashApplyTooltip: "Apply the stash entry to your working directory.", - NoStashEntries: "No stash entries", - StashDrop: "Stash drop", - SureDropStashEntry: "Are you sure you want to drop the selected stash entry(ies)?", - StashPop: "Stash pop", - SurePopStashEntry: "Are you sure you want to pop this stash entry?", - StashApply: "Stash apply", - SureApplyStashEntry: "Are you sure you want to apply this stash entry?", - NoTrackedStagedFilesStash: "You have no tracked/staged files to stash", - NoFilesToStash: "You have no files to stash", - StashChanges: "Stash changes", - RenameStash: "Rename stash", - RenameStashPrompt: "Rename stash: {{.stashName}}", - OpenConfig: "Open config file", - EditConfig: "Edit config file", - ForcePush: "Force push", - ForcePushPrompt: "Your branch has diverged from the remote branch. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.", - ForcePushDisabled: "Your branch has diverged from the remote branch and you've disabled force pushing", - UpdatesRejected: "Updates were rejected. Please fetch and examine the remote changes before pushing again.", - UpdatesRejectedAndForcePushDisabled: "Updates were rejected and you have disabled force pushing", - CheckForUpdate: "Check for update", - CheckingForUpdates: "Checking for updates...", - UpdateAvailableTitle: "Update available!", - UpdateAvailable: "Download and install version {{.newVersion}}?", - UpdateInProgressWaitingStatus: "Updating", - UpdateCompletedTitle: "Update completed!", - UpdateCompleted: "Update has been installed successfully. Restart lazygit for it to take effect.", - FailedToRetrieveLatestVersionErr: "Failed to retrieve version information", - OnLatestVersionErr: "You already have the latest version", - MajorVersionErr: "New version ({{.newVersion}}) has non-backwards compatible changes compared to the current version ({{.currentVersion}})", - CouldNotFindBinaryErr: "Could not find any binary at {{.url}}", - UpdateFailedErr: "Update failed: {{.errMessage}}", - ConfirmQuitDuringUpdateTitle: "Currently updating", - ConfirmQuitDuringUpdate: "An update is in progress. Are you sure you want to quit?", - IntroPopupMessage: englishIntroPopupMessage, - NonReloadableConfigWarningTitle: "Config changed", - NonReloadableConfigWarning: englishNonReloadableConfigWarning, - GitconfigParseErr: `Gogit failed to parse your gitconfig file due to the presence of unquoted '\' characters. Removing these should fix the issue.`, - EditFile: `Edit file`, - EditFileTooltip: "Open file in external editor.", - OpenFile: `Open file`, - OpenFileTooltip: "Open file in default application.", - OpenInEditor: "Open in editor", - IgnoreFile: `Add to .gitignore`, - ExcludeFile: `Add to .git/info/exclude`, - RefreshFiles: `Refresh files`, - FocusMainView: "Focus main view", - Merge: `Merge`, - MergeBranchTooltip: "View options for merging the selected item into the current branch (regular merge, squash merge)", - RegularMergeFastForward: "Regular merge (fast-forward)", - RegularMergeFastForwardTooltip: "Fast-forward '{{.checkedOutBranch}}' to '{{.selectedBranch}}' without creating a merge commit.", - CannotFastForwardMerge: "Cannot fast-forward '{{.checkedOutBranch}}' to '{{.selectedBranch}}'", - RegularMergeNonFastForward: "Regular merge (with merge commit)", - RegularMergeNonFastForwardTooltip: "Merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}', creating a merge commit.", - SquashMergeUncommitted: "Squash merge and leave uncommitted", - SquashMergeUncommittedTooltip: "Squash merge '{{.selectedBranch}}' into the working tree.", - SquashMergeCommitted: "Squash merge and commit", - SquashMergeCommittedTooltip: "Squash merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}' as a single commit.", - ConfirmQuit: `Are you sure you want to quit?`, - SwitchRepo: `Switch to a recent repo`, - AllBranchesLogGraph: `Show/cycle all branch logs`, - AllBranchesLogGraphReverse: `Show/cycle all branch logs (reverse)`, - UnsupportedGitService: `Unsupported git service`, - CreatePullRequest: `Create pull request`, - CopyPullRequestURL: `Copy pull request URL to clipboard`, - OpenPullRequestInBrowser: `Open pull request in browser`, - NoPullRequestForBranch: `No pull request found for this branch`, - NoBranchOnRemote: `This branch doesn't exist on remote. You need to push it to remote first.`, - Fetch: `Fetch`, - FetchTooltip: "Fetch changes from remote.", - CollapseAll: "Collapse all files", - CollapseAllTooltip: "Collapse all directories in the files tree", - ExpandAll: "Expand all files", - ExpandAllTooltip: "Expand all directories in the file tree", - DisabledInFlatView: "Not available in flat view", - FileEnter: `Stage lines / Collapse directory`, - FileEnterTooltip: "If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it.", - StageSelectionTooltip: `Toggle selection staged / unstaged.`, - DiscardSelection: `Discard`, - DiscardSelectionTooltip: "When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change.", - ToggleRangeSelect: "Toggle range select", - DismissRangeSelect: "Dismiss range select", - ToggleSelectHunk: "Toggle hunk selection", - SelectHunk: "Select hunks", - SelectLineByLine: "Select line-by-line", - ToggleSelectHunkTooltip: "Toggle line-by-line vs. hunk selection mode.", - HunkStagingHint: englishHunkStagingHint, - ToggleSelectionForPatch: `Toggle lines in patch`, - RemoveSelectionFromPatch: `Remove lines from commit`, - RemoveSelectionFromPatchTooltip: "Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines.", - EditHunk: `Edit hunk`, - EditHunkTooltip: "Edit selected hunk in external editor.", - ToggleStagingView: "Switch view", - ToggleStagingViewTooltip: "Switch to other view (staged/unstaged changes).", - ReturnToFilesPanel: `Return to files panel`, - FastForward: `Fast-forward`, - FastForwardTooltip: "Fast-forward selected branch from its upstream.", - FastForwarding: "Fast-forwarding", - FoundConflictsTitle: "Conflicts!", - ViewConflictsMenuItem: "View conflicts", - AbortMenuItem: "Abort the %s", - ViewMergeRebaseOptions: "View merge/rebase options", - ViewMergeRebaseOptionsTooltip: "View options to abort/continue/skip the current merge/rebase.", - ViewMergeOptions: "View merge options", - ViewRebaseOptions: "View rebase options", - ViewCherryPickOptions: "View cherry-pick options", - ViewRevertOptions: "View revert options", - NotMergingOrRebasing: "You are currently neither rebasing nor merging", - AlreadyRebasing: "Can't perform this action during a rebase", - NotMidRebase: "This action only works during an interactive rebase", - MustSelectFixupCommit: "This action only works on fixup commits", - RecentRepos: "Recent repositories", - MergeOptionsTitle: "Merge options", - RebaseOptionsTitle: "Rebase options", - CherryPickOptionsTitle: "Cherry-pick options", - RevertOptionsTitle: "Revert options", - CommitSummaryTitle: "Commit summary", - CommitDescriptionTitle: "Commit description", - CommitDescriptionSubTitle: "Press {{.togglePanelKeyBinding}} to toggle focus, {{.commitMenuKeybinding}} to open menu", - CommitDescriptionFooter: "Press {{.confirmInEditorKeybinding}} to submit", - CommitHooksDisabledSubTitle: "(hooks disabled)", - LocalBranchesTitle: "Local branches", - SearchTitle: "Search", - TagsTitle: "Tags", - MenuTitle: "Menu", - CommitMenuTitle: "Commit Menu", - RemotesTitle: "Remotes", - RemoteBranchesTitle: "Remote branches", - PatchBuildingTitle: "Main panel (patch building)", - InformationTitle: "Information", - SecondaryTitle: "Secondary", - ReflogCommitsTitle: "Reflog", - GlobalTitle: "Global keybindings", - ConflictsResolved: "All merge conflicts resolved. Continue the %s?", - Continue: "Continue", - UnstagedFilesAfterConflictsResolved: "Files have been modified since conflicts were resolved. Auto-stage them and continue?", - Keybindings: "Keybindings", - KeybindingsMenuSectionLocal: "Local", - KeybindingsMenuSectionGlobal: "Global", - KeybindingsMenuSectionNavigation: "Navigation", - KeybindingsTooltip: "Keybindings: ", - RebasingTitle: "Rebase '{{.checkedOutBranch}}'", - RebasingFromBaseCommitTitle: "Rebase '{{.checkedOutBranch}}' from marked base", - SimpleRebase: "Simple rebase onto '{{.ref}}'", - InteractiveRebase: "Interactive rebase onto '{{.ref}}'", - RebaseOntoBaseBranch: "Rebase onto base branch ({{.baseBranch}})", - InteractiveRebaseTooltip: "Begin an interactive rebase with a break at the start, so you can update the TODO commits before continuing.", - RebaseOntoBaseBranchTooltip: "Rebase the checked out branch onto its base branch (i.e. the closest main branch).", - MustSelectTodoCommits: "When rebasing, this action only works on a selection of TODO commits.", - FwdNoUpstream: "Cannot fast-forward a branch with no upstream", - FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally", - FwdCommitsToPush: "Cannot fast-forward a branch with commits to push", - PullRequestNoUpstream: "Cannot open a pull request for a branch with no upstream", - ErrorOccurred: "An error occurred! Please create an issue at", - ConflictLabel: "CONFLICT", - PendingRebaseTodosSectionHeader: "Pending rebase todos", - PendingCherryPicksSectionHeader: "Pending cherry-picks", - PendingRevertsSectionHeader: "Pending reverts", - CommitsSectionHeader: "Commits", - YouDied: "YOU DIED!", - RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported", - ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed", - NotAllowedMidCherryPickOrRevert: "This action is not allowed while cherry-picking or reverting", - PickIsOnlyAllowedDuringRebase: "This action is only allowed while rebasing", - DroppingMergeRequiresSingleSelection: "Dropping a merge commit requires a single selected item", - CherryPickCopy: "Copy (cherry-pick)", - CherryPickCopyTooltip: "Mark commit as copied. Then, within the local commits view, you can press `{{.paste}}` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `{{.escape}}` to cancel the selection.", - PasteCommits: "Paste (cherry-pick)", - SureCherryPick: "Are you sure you want to cherry-pick the {{.numCommits}} copied commit(s) onto this branch?", - CherryPick: "Cherry-pick", - CannotCherryPickNonCommit: "Cannot cherry-pick this kind of todo item", - Donate: "Donate", - AskQuestion: "Ask Question", - PrevHunk: "Go to previous hunk", - NextHunk: "Go to next hunk", - PrevConflict: "Previous conflict", - NextConflict: "Next conflict", - SelectPrevHunk: "Previous hunk", - SelectNextHunk: "Next hunk", - ScrollDown: "Scroll down", - ScrollUp: "Scroll up", - ScrollUpMainWindow: "Scroll up main window", - ScrollDownMainWindow: "Scroll down main window", - SuspendApp: "Suspend the application", - CannotSuspendApp: "Suspending the application is not supported on Windows", - AmendCommitTitle: "Amend commit", - AmendCommitPrompt: "Are you sure you want to amend this commit with your staged files?", - AmendCommitWithConflictsMenuPrompt: "WARNING: you are about to amend the last finished commit with your resolved conflicts. This is very unlikely to be what you want at this point. More likely, you simply want to continue the rebase instead.\n\nDo you still want to amend the previous commit?", - AmendCommitWithConflictsContinue: "No, continue rebase", - AmendCommitWithConflictsAmend: "Yes, amend previous commit", - DropCommitTitle: "Drop commit", - DropCommitPrompt: "Are you sure you want to drop the selected commit(s)?", - DropMergeCommitPrompt: "Are you sure you want to drop the selected merge commit? Note that it will also drop all the commits that were merged in by it.", - DropUpdateRefPrompt: "Are you sure you want to delete the selected update-ref todo(s)? This is irreversible except by aborting the rebase.", - PullingStatus: "Pulling", - PushingStatus: "Pushing", - FetchingStatus: "Fetching", - SquashingStatus: "Squashing", - FixingStatus: "Fixing up", - DeletingStatus: "Deleting", - DroppingStatus: "Dropping", - MovingStatus: "Moving", - RebasingStatus: "Rebasing", - MergingStatus: "Merging", - LowercaseRebasingStatus: "rebasing", // lowercase because it shows up in parentheses - LowercaseMergingStatus: "merging", // lowercase because it shows up in parentheses - LowercaseCherryPickingStatus: "cherry-picking", // lowercase because it shows up in parentheses - LowercaseRevertingStatus: "reverting", // lowercase because it shows up in parentheses - AmendingStatus: "Amending", - CherryPickingStatus: "Cherry-picking", - UndoingStatus: "Undoing", - RedoingStatus: "Redoing", - CheckingOutStatus: "Checking out", - CommittingStatus: "Committing", - RewordingStatus: "Rewording", - RevertingStatus: "Reverting", - CreatingFixupCommitStatus: "Creating fixup commit", - MovingCommitsToNewBranchStatus: "Moving commits to new branch", - CommitFiles: "Commit files", - SubCommitsDynamicTitle: "Commits (%s)", - CommitFilesDynamicTitle: "Diff files (%s)", - RemoteBranchesDynamicTitle: "Remote branches (%s)", - ViewItemFiles: "View files", - CommitFilesTitle: "Commit files", - CheckoutCommitFileTooltip: "Checkout file. This replaces the file in your working tree with the version from the selected commit.", - CannotCheckoutWithModifiedFilesErr: "You have local modifications for the file(s) you are trying to check out. You need to stash or discard these first.", - CanOnlyDiscardFromLocalCommits: "Changes can only be discarded from local commits", - CannotDiscardFromMultipleCommits: "Changes cannot be discarded from a multiselection of commits", - Remove: "Remove", - DiscardOldFileChangeTooltip: "Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file.", - DiscardFileChangesTitle: "Discard file changes", - DiscardFileChangesPrompt: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.", - DiscardFileChangesPromptResetPatch: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\n\nNote: This will reset the active custom patch!", - DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.", - CreateRepo: "Not in a git repository. Create a new git repository? (y/N): ", - BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not yet support bare repos. Open most recent repo? (y/n) ", - InitialBranch: "Branch name? (leave empty for git's default): ", - NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.", - IncorrectNotARepository: "The value of 'notARepository' is incorrect. It should be one of 'prompt', 'create', 'skip', or 'quit'.", - AutoStashTitle: "Autostash?", - AutoStashPrompt: "You must stash and pop your changes to bring them across. Do this automatically? (enter/esc)", - AutoStashForUndo: "Auto-stashing changes for undoing to %s", - AutoStashForCheckout: "Auto-stashing changes for checking out %s", - AutoStashForNewBranch: "Auto-stashing changes for creating new branch %s", - AutoStashForMovingPatchToIndex: "Auto-stashing changes for moving custom patch to index from %s", - AutoStashForCherryPicking: "Auto-stashing changes for cherry-picking commits", - AutoStashForReverting: "Auto-stashing changes for reverting commits", - Discard: "Discard", - DiscardFileChangesTooltip: "View options for discarding changes to the selected file.", - DiscardChangesTitle: "Discard changes", - Cancel: "Cancel", - DiscardAllChanges: "Discard all changes", - DiscardUnstagedChanges: "Discard unstaged changes", - DiscardAllChangesToAllFiles: "Nuke working tree", - DiscardAnyUnstagedChanges: "Discard unstaged changes", - DiscardUntrackedFiles: "Discard untracked files", - DiscardStagedChanges: "Discard staged changes", - HardReset: "Hard reset", - BranchDeleteTooltip: "View delete options for local/remote branch.", - TagDeleteTooltip: "View delete options for local/remote tag.", - Delete: "Delete", - Reset: "Reset", - ResetTooltip: "View reset options (soft/mixed/hard) for resetting onto selected item.", - ResetSoftTooltip: "Reset HEAD to the chosen commit, and keep the changes between the current and chosen commit as staged changes.", - ResetMixedTooltip: "Reset HEAD to the chosen commit, and keep the changes between the current and chosen commit as unstaged changes.", - ResetHardTooltip: "Reset HEAD to the chosen commit, and discard all changes between the current and chosen commit, as well as all current modifications in the working tree.", - ResetHardConfirmation: "Are you sure you want to do a hard reset? This will discard all uncommitted changes (both staged and unstaged), which is not undoable.", - ViewResetOptions: `Reset`, - FileResetOptionsTooltip: "View reset options for working tree (e.g. nuking the working tree).", - FixupTooltip: "Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded.", - CreateFixupCommit: "Create fixup commit", - CreateFixupCommitTooltip: "Create 'fixup!' commit for the selected commit. Later on, you can press `{{.squashAbove}}` on this same commit to apply all above fixup commits.", - CreateAmendCommit: `Create "amend!" commit`, - FixupMenu_Fixup: "fixup! commit", - FixupMenu_FixupTooltip: "Lets you fixup another commit and keep the original commit's message.", - FixupMenu_AmendWithChanges: "amend! commit with changes", - FixupMenu_AmendWithChangesTooltip: "Lets you fixup another commit and also change its commit message.", - FixupMenu_AmendWithoutChanges: "amend! commit without changes (pure reword)", - FixupMenu_AmendWithoutChangesTooltip: "Lets you change the commit message of another commit without changing its content.", - SquashAboveCommits: "Apply fixup commits", - SquashAboveCommitsTooltip: `Squash all 'fixup!' commits, either above the selected commit, or all in current branch (autosquash).`, - SquashCommitsAboveSelectedTooltip: `Squash all 'fixup!' commits above the selected commit (autosquash).`, - SquashCommitsInCurrentBranchTooltip: `Squash all 'fixup!' commits in the current branch (autosquash).`, - SquashCommitsInCurrentBranch: "In current branch", - SquashCommitsAboveSelectedCommit: "Above the selected commit", - CannotSquashCommitsInCurrentBranch: "Cannot squash commits in current branch: the HEAD commit is a merge commit or is present on the main branch.", - ExecuteShellCommand: "Execute shell command", - ExecuteShellCommandTooltip: "Bring up a prompt where you can enter a shell command to execute.", - ShellCommand: "Shell command:", - CommitChangesWithoutHook: "Commit changes without pre-commit hook", - ResetTo: `Reset to`, - PressEnterToReturn: "Press enter to return to lazygit", - ViewStashOptions: "View stash options", - ViewStashOptionsTooltip: "View stash options (e.g. stash all, stash staged, stash unstaged).", - Stash: "Stash", - StashTooltip: "Stash all changes. For other variations of stashing, use the view stash options keybinding.", - StashAllChanges: "Stash all changes", - StashStagedChanges: "Stash staged changes", - StashAllChangesKeepIndex: "Stash all changes and keep index", - StashUnstagedChanges: "Stash unstaged changes", - StashIncludeUntrackedChanges: "Stash all changes including untracked files", - StashOptions: "Stash options", - NotARepository: "Error: must be run inside a git repository", - WorkingDirectoryDoesNotExist: "Error: the current working directory does not exist", - ScrollLeft: "Scroll left", - ScrollRight: "Scroll right", - DiscardPatch: "Discard patch", - DiscardPatchConfirm: "You can only build a patch from one commit/stash-entry at a time. Discard current patch?", - CantPatchWhileRebasingError: "You cannot build a patch or run patch commands while in a merging or rebasing state", - ToggleAddToPatch: "Toggle file included in patch", - ToggleAddToPatchTooltip: "Toggle whether the file is included in the custom patch. See {{.doc}}.", - ToggleAllInPatch: "Toggle all files", - ToggleAllInPatchTooltip: "Add/remove all commit's files to custom patch. See {{.doc}}.", - UpdatingPatch: "Updating patch", - ViewPatchOptions: "View custom patch options", - PatchOptionsTitle: "Patch options", - NoPatchError: "No patch created yet. To start building a patch, use 'space' on a commit file or enter to add specific lines", - EmptyPatchError: "Patch is still empty. Add some files or lines to your patch first.", - EnterCommitFile: "Enter file / Toggle directory collapsed", - EnterCommitFileTooltip: "If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory.", - EnterStaging: "Enter staging/patch building", - ExitCustomPatchBuilder: `Exit custom patch builder`, - ExitFocusedMainView: "Exit back to side panel", - ToggleSelectionInFocusedMainView: "Show/hide selection", - EnterUpstream: `Enter upstream as ' '`, - InvalidUpstream: "Invalid upstream. Must be in the format ' '", - NewRemote: `New remote`, - NewRemoteName: `New remote name:`, - NewRemoteUrl: `New remote url:`, - AddForkRemoteUsername: `Fork owner (username/org). Use username:branch to check out a branch`, - AddForkRemote: `Add fork remote`, - AddForkRemoteTooltip: `Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote.`, - IncompatibleForkAlreadyExistsError: `Remote {{.remoteName}} already exists and has different URL`, - NoOriginRemote: "Action needs 'origin' remote", - ViewBranches: "View branches", - EditRemoteName: `Enter updated remote name for {{.remoteName}}:`, - EditRemoteUrl: `Enter updated remote url for {{.remoteName}}:`, - RemoveRemote: `Remove remote`, - RemoveRemoteTooltip: `Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected.`, - RemoveRemotePrompt: "Are you sure you want to remove remote?", - DeleteRemoteBranch: "Delete remote branch", - DeleteRemoteBranches: "Delete remote branches", - DeleteRemoteBranchTooltip: "Delete the remote branch from the remote.", - DeleteLocalAndRemoteBranch: "Delete local and remote branch", - DeleteLocalAndRemoteBranches: "Delete local and remote branches", - SetAsUpstream: "Set as upstream", - SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.", - SetUpstream: "Set upstream of selected branch", - UnsetUpstream: "Unset upstream of selected branch", - ViewDivergenceFromUpstream: "View divergence from upstream", - ViewDivergenceFromBaseBranch: "View divergence from base branch ({{.baseBranch}})", - CouldNotDetermineBaseBranch: "Couldn't determine base branch", - DivergenceSectionHeaderLocal: "Local", - DivergenceSectionHeaderRemote: "Remote", - ViewUpstreamResetOptions: "Reset checked-out branch onto {{.upstream}}", - ViewUpstreamResetOptionsTooltip: "View options for resetting the checked-out branch onto {{upstream}}. Note: this will not reset the selected branch onto the upstream, it will reset the checked-out branch onto the upstream.", - ViewUpstreamRebaseOptions: "Rebase checked-out branch onto {{.upstream}}", - ViewUpstreamRebaseOptionsTooltip: "View options for rebasing the checked-out branch onto {{upstream}}. Note: this will not rebase the selected branch onto the upstream, it will rebase the checked-out branch onto the upstream.", - UpstreamGenericName: "upstream of selected branch", - SetUpstreamTitle: "Set upstream branch", - SetUpstreamMessage: "Are you sure you want to set the upstream branch of '{{.checkedOut}}' to '{{.selected}}'?", - EditRemoteTooltip: "Edit the selected remote's name or URL.", - TagCommit: "Tag commit", - TagCommitTooltip: "Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description.", - TagNameTitle: "Tag name", - TagMessageTitle: "Tag description", - AnnotatedTag: "Annotated tag", - LightweightTag: "Lightweight tag", - DeleteTagTitle: "Delete tag '{{.tagName}}'?", - DeleteLocalTag: "Delete local tag", - DeleteRemoteTag: "Delete remote tag", - DeleteLocalAndRemoteTag: "Delete local and remote tag", - RemoteTagDeletedMessage: "Remote tag deleted", - SelectRemoteTagUpstream: "Remote from which to remove tag '{{.tagName}}':", - DeleteRemoteTagPrompt: "Are you sure you want to delete the remote tag '{{.tagName}}' from '{{.upstream}}'?", - DeleteLocalAndRemoteTagPrompt: "Are you sure you want to delete '{{.tagName}}' from both your machine and from '{{.upstream}}'?", - PushTagTitle: "Remote to push tag '{{.tagName}}' to:", + NotEnoughSpace: "Not enough space to render panels", + DiffTitle: "Diff", + FilesTitle: "Files", + BranchesTitle: "Branches", + CommitsTitle: "Commits", + StashTitle: "Stash", + SnakeTitle: "Snake", + EasterEgg: "Easter egg", + UnstagedChanges: "Unstaged changes", + StagedChanges: "Staged changes", + StagingTitle: "Main panel (staging)", + MergingTitle: "Main panel (merging)", + NormalTitle: "Main panel (normal)", + LogTitle: "Log", + LogXOfYTitle: "Log (%d of %d)", + CommitSummary: "Commit summary", + CredentialsUsername: "Username", + CredentialsPassword: "Password", + CredentialsPassphrase: "Enter passphrase for SSH key", + CredentialsPIN: "Enter PIN for SSH key", + CredentialsToken: "Enter Token for SSH key", + PassUnameWrong: "Password, passphrase and/or username wrong", + Commit: "Commit", + CommitTooltip: "Commit staged changes.", + AmendLastCommit: "Amend last commit", + AmendLastCommitTitle: "Amend last commit", + SureToAmend: "Are you sure you want to amend last commit? Afterwards, you can change the commit message from the commits panel.", + NoCommitToAmend: "There's no commit to amend.", + CommitChangesWithEditor: "Commit changes using git editor", + FindBaseCommitForFixup: "Find base commit for fixup", + FindBaseCommitForFixupTooltip: "Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: ", + NoBaseCommitsFound: "No base commits found", + MultipleBaseCommitsFoundStaged: "Multiple base commits found. (Try staging fewer changes at once)", + MultipleBaseCommitsFoundUnstaged: "Multiple base commits found. (Try staging some of the changes)", + BaseCommitIsAlreadyOnMainBranch: "The base commit for this change is already on the main branch", + BaseCommitIsNotInCurrentView: "Base commit is not in current view", + HunksWithOnlyAddedLinesWarning: "There are ranges of only added lines in the diff; be careful to check that these belong in the found base commit.\n\nProceed?", + StatusTitle: "Status", + Execute: "Execute", + Stage: "Stage", + StageTooltip: "Toggle staged for selected file.", + ToggleStagedAll: "Stage all", + ToggleStagedAllTooltip: "Toggle staged/unstaged for all files in working tree.", + ToggleTreeView: "Toggle file tree view", + ToggleTreeViewTooltip: "Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.\n\nThe default can be changed in the config file with the key 'gui.showFileTree'.", + OpenDiffTool: "Open external diff tool (git difftool)", + OpenMergeTool: "Open external merge tool", + Refresh: "Refresh", + RefreshTooltip: "Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`.", + Push: "Push", + PushTooltip: "Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch.", + Pull: "Pull", + PullTooltip: "Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch.", + MergeConflictsTitle: "Merge conflicts", + MergeConflictDescription_DD: "Conflict: this file was moved or renamed both in the current and the incoming changes, but to different destinations. I don't know which ones, but they should both show up as conflicts too (marked 'AU' and 'UA', respectively). The most likely resolution is to delete this file, and pick one of the destinations and delete the other.", + MergeConflictDescription_AU: "Conflict: this file is the destination of a move or rename in the current changes, but was moved or renamed to a different destination in the incoming changes. That other destination should also show up as a conflict (marked 'UA'), as well as the file that both were renamed from (marked 'DD').", + MergeConflictDescription_UA: "Conflict: this file is the destination of a move or rename in the incoming changes, but was moved or renamed to a different destination in the current changes. That other destination should also show up as a conflict (marked 'AU'), as well as the file that both were renamed from (marked 'DD').", + MergeConflictDescription_DU: "Conflict: this file was deleted in the current changes and modified in the incoming changes.\n\nThe most likely resolution is to delete the file after applying the incoming modifications manually to some other place in the code.", + MergeConflictDescription_UD: "Conflict: this file was modified in the current changes and deleted in incoming changes.\n\nThe most likely resolution is to delete the file after applying the current modifications manually to some other place in the code.", + MergeConflictIncomingDiff: "Incoming changes:", + MergeConflictCurrentDiff: "Current changes:", + MergeConflictPressEnterToResolve: "Press %s to resolve.", + MergeConflictKeepFile: "Keep file", + MergeConflictDeleteFile: "Delete file", + Checkout: "Checkout", + CheckoutTooltip: "Checkout selected item.", + CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch", + TagCheckoutTooltip: "Checkout the selected tag as a detached HEAD.", + RemoteBranchCheckoutTooltip: "Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head.", + CantPullOrPushSameBranchTwice: "You cannot push or pull a branch while it is already being pushed or pulled", + FileFilter: "Filter files by status", + CopyToClipboardMenu: "Copy to clipboard", + CopyFileName: "File name", + CopyRelativeFilePath: "Relative path", + CopyAbsoluteFilePath: "Absolute path", + CopyFileDiffTooltip: "If there are staged items, this command considers only them. Otherwise, it considers all the unstaged ones.", + CopySelectedDiff: "Diff of selected file", + CopyAllFilesDiff: "Diff of all files", + CopyFileContent: "Content of selected file", + NoContentToCopyError: "Nothing to copy", + FileNameCopiedToast: "File name copied to clipboard", + FilePathCopiedToast: "File path copied to clipboard", + FileDiffCopiedToast: "File diff copied to clipboard", + AllFilesDiffCopiedToast: "All files diff copied to clipboard", + FileContentCopiedToast: "File content copied to clipboard", + FilterStagedFiles: "Show only staged files", + FilterUnstagedFiles: "Show only unstaged files", + FilterTrackedFiles: "Show only tracked files", + FilterUntrackedFiles: "Show only untracked files", + NoFilter: "No filter", + FilterLabelStagedFiles: "(only staged)", + FilterLabelUnstagedFiles: "(only unstaged)", + FilterLabelTrackedFiles: "(only tracked)", + FilterLabelUntrackedFiles: "(only untracked)", + FilterLabelConflictingFiles: "(only conflicting)", + NoChangedFiles: "No changed files", + SoftReset: "Soft reset", + AlreadyCheckedOutBranch: "You have already checked out this branch", + SureForceCheckout: "Are you sure you want force checkout? You will lose all local changes", + ForceCheckoutBranch: "Force checkout branch", + BranchName: "Branch name", + NewBranchNameBranchOff: "New branch name (branch is off of '{{.branchName}}')", + CantDeleteCheckOutBranch: "You cannot delete the checked out branch!", + DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?", + DeleteBranchesTitle: "Delete selected branches?", + DeleteLocalBranch: "Delete local branch", + DeleteLocalBranches: "Delete local branches", + DeleteRemoteBranchPrompt: "Are you sure you want to delete the remote branch '{{.selectedBranchName}}' from '{{.upstream}}'?", + DeleteRemoteBranchesPrompt: "Are you sure you want to delete the remote branches of the selected branches from their respective remotes?", + DeleteLocalAndRemoteBranchPrompt: "Are you sure you want to delete both '{{.localBranchName}}' from your machine, and '{{.remoteBranchName}}' from '{{.remoteName}}'?", + DeleteLocalAndRemoteBranchesPrompt: "Are you sure you want to delete both the selected branches from your machine, and their remote branches from their respective remotes?", + ForceDeleteBranchTitle: "Force delete branch", + ForceDeleteBranchMessage: "'{{.selectedBranchName}}' is not fully merged. Are you sure you want to delete it?", + ForceDeleteBranchesMessage: "Some of the selected branches are not fully merged. Are you sure you want to delete them?", + RebaseBranch: "Rebase", + RebaseBranchTooltip: "Rebase the checked-out branch onto the selected branch.", + CantRebaseOntoSelf: "You cannot rebase a branch onto itself", + CantMergeBranchIntoItself: "You cannot merge a branch into itself", + ForceCheckout: "Force checkout", + ForceCheckoutTooltip: "Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch.", + CheckoutByName: "Checkout by name", + CheckoutByNameTooltip: "Checkout by name. In the input box you can enter '-' to switch to the previous branch.", + CheckoutPreviousBranch: "Checkout previous branch", + RemoteBranchCheckoutTitle: "Checkout {{.branchName}}", + RemoteBranchCheckoutPrompt: "How would you like to check out this branch?", + CheckoutTypeNewBranch: "New local branch", + CheckoutTypeNewBranchTooltip: "Checkout the remote branch as a local branch, tracking the remote branch.", + CheckoutTypeDetachedHead: "Detached head", + CheckoutTypeDetachedHeadTooltip: "Checkout the remote branch as a detached head, which can be useful if you just want to test the branch but not work on it yourself. You can still create a local branch from it later.", + NewBranch: "New branch", + NewBranchFromStashTooltip: "Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit.", + MoveCommitsToNewBranch: "Move commits to new branch", + MoveCommitsToNewBranchTooltip: "Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.\n\nNote that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which).", + MoveCommitsToNewBranchFromMainPrompt: "This will take all unpushed commits and move them to a new branch (off of {{.baseBranchName}}). It will then hard-reset the current branch to its upstream branch. Do you want to continue?", + MoveCommitsToNewBranchMenuPrompt: "This will take all unpushed commits and move them to a new branch. This new branch can either be created from the main branch ({{.baseBranchName}}) or stacked on top of the current branch. Which of these would you like to do?", + MoveCommitsToNewBranchFromBaseItem: "New branch from base branch (%s)", + MoveCommitsToNewBranchStackedItem: "New branch stacked on current branch (%s)", + CannotMoveCommitsFromDetachedHead: "Cannot move commits from a detached head", + CannotMoveCommitsNoUpstream: "Cannot move commits from a branch that has no upstream branch", + CannotMoveCommitsBehindUpstream: "Cannot move commits from a branch that is behind its upstream branch", + CannotMoveCommitsNoUnpushedCommits: "There are no unpushed commits to move to a new branch", + NoBranchesThisRepo: "No branches for this repo", + CommitWithoutMessageErr: "You cannot commit without a commit message", + Close: "Close", + CloseCancel: "Close/Cancel", + Confirm: "Confirm", + Quit: "Quit", + SquashTooltip: "Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it.", + NoCommitsThisBranch: "No commits for this branch", + UpdateRefHere: "Update branch '{{.ref}}' here", + ExecCommandHere: "Execute the following command here:", + CannotSquashOrFixupFirstCommit: "There's no commit below to squash into", + CannotSquashOrFixupMergeCommit: "Cannot squash or fixup a merge commit", + Fixup: "Fixup", + FixupKeepMessage: "Fixup and use this commit's message", + FixupKeepMessageTooltip: "Squash the selected commit into the commit below, using this commit's message, discarding the message of the commit below.", + SetFixupMessage: "Set fixup message", + SetFixupMessageTooltip: "Set the message option for the fixup commit. The -C option means to use this commit's message instead of the target commit's message.", + FixupDiscardMessage: "Fixup and discard this commit's message", + FixupDiscardMessageTooltip: "Squash the selected commit into the commit below, discarding this commit's message.", + SureSquashThisCommit: "Are you sure you want to squash the selected commit(s) into the commit below?", + Squash: "Squash", + PickCommitTooltip: "Mark the selected commit to be picked (when mid-rebase). This means that the commit will be retained upon continuing the rebase.", + Pick: "Pick", + Edit: "Edit", + Revert: "Revert", + RevertCommitTooltip: "Create a revert commit for the selected commit, which applies the selected commit's changes in reverse.", + Reword: "Reword", + CommitRewordTooltip: "Reword the selected commit's message.", + DropCommit: "Drop", + DropCommitTooltip: "Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts.", + MoveDownCommit: "Move commit down one", + MoveUpCommit: "Move commit up one", + CannotMoveAnyFurther: "Cannot move any further", + CannotMoveMergeCommit: "Cannot move a merge commit", + EditCommit: "Edit (start interactive rebase)", + EditCommitTooltip: "Edit the selected commit. Use this to start an interactive rebase from the selected commit. When already mid-rebase, this will mark the selected commit for editing, which means that upon continuing the rebase, the rebase will pause at the selected commit to allow you to make changes.", + AmendCommitTooltip: "Amend commit with staged changes. If the selected commit is the HEAD commit, this will perform `git commit --amend`. Otherwise the commit will be amended via a rebase.", + Amend: "Amend", + ResetAuthor: "Reset author", + ResetAuthorTooltip: "Reset the commit's author to the currently configured user. This will also renew the author timestamp", + SetAuthor: "Set author", + SetAuthorTooltip: "Set the author based on a prompt", + AddCoAuthor: "Add co-author", + AmendCommitAttribute: "Amend commit attribute", + AmendCommitAttributeTooltip: "Set/Reset commit author or set co-author.", + SetAuthorPromptTitle: "Set author (must look like 'Name ')", + AddCoAuthorPromptTitle: "Add co-author (must look like 'Name ')", + AddCoAuthorTooltip: "Add co-author using the Github/Gitlab metadata Co-authored-by.", + RewordCommitEditor: "Reword with editor", + Error: "Error", + PickHunk: "Pick hunk", + PickAllHunks: "Pick all hunks", + Undo: "Undo", + UndoReflog: "Undo", + RedoReflog: "Redo", + UndoTooltip: "The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration.", + RedoTooltip: "The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration.", + UndoMergeResolveTooltip: "Undo last merge conflict resolution.", + DiscardAllTooltip: "Discard both staged and unstaged changes in '{{.path}}'.", + DiscardUnstagedTooltip: "Discard unstaged changes in '{{.path}}'.", + DiscardUnstagedDisabled: "The selected items don't have both staged and unstaged changes.", + Pop: "Pop", + StashPopTooltip: "Apply the stash entry to your working directory and remove the stash entry.", + Drop: "Drop", + StashDropTooltip: "Remove the stash entry from the stash list.", + Apply: "Apply", + StashApplyTooltip: "Apply the stash entry to your working directory.", + NoStashEntries: "No stash entries", + StashDrop: "Stash drop", + SureDropStashEntry: "Are you sure you want to drop the selected stash entry(ies)?", + StashPop: "Stash pop", + SurePopStashEntry: "Are you sure you want to pop this stash entry?", + StashApply: "Stash apply", + SureApplyStashEntry: "Are you sure you want to apply this stash entry?", + NoTrackedStagedFilesStash: "You have no tracked/staged files to stash", + NoFilesToStash: "You have no files to stash", + StashChanges: "Stash changes", + RenameStash: "Rename stash", + RenameStashPrompt: "Rename stash: {{.stashName}}", + OpenConfig: "Open config file", + EditConfig: "Edit config file", + ForcePush: "Force push", + ForcePushPrompt: "Your branch has diverged from the remote branch. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.", + ForcePushDisabled: "Your branch has diverged from the remote branch and you've disabled force pushing", + UpdatesRejected: "Updates were rejected. Please fetch and examine the remote changes before pushing again.", + UpdatesRejectedAndForcePushDisabled: "Updates were rejected and you have disabled force pushing", + CheckForUpdate: "Check for update", + CheckingForUpdates: "Checking for updates...", + UpdateAvailableTitle: "Update available!", + UpdateAvailable: "Download and install version {{.newVersion}}?", + UpdateInProgressWaitingStatus: "Updating", + UpdateCompletedTitle: "Update completed!", + UpdateCompleted: "Update has been installed successfully. Restart lazygit for it to take effect.", + FailedToRetrieveLatestVersionErr: "Failed to retrieve version information", + OnLatestVersionErr: "You already have the latest version", + MajorVersionErr: "New version ({{.newVersion}}) has non-backwards compatible changes compared to the current version ({{.currentVersion}})", + CouldNotFindBinaryErr: "Could not find any binary at {{.url}}", + UpdateFailedErr: "Update failed: {{.errMessage}}", + ConfirmQuitDuringUpdateTitle: "Currently updating", + ConfirmQuitDuringUpdate: "An update is in progress. Are you sure you want to quit?", + IntroPopupMessage: englishIntroPopupMessage, + NonReloadableConfigWarningTitle: "Config changed", + NonReloadableConfigWarning: englishNonReloadableConfigWarning, + GitconfigParseErr: `Gogit failed to parse your gitconfig file due to the presence of unquoted '\' characters. Removing these should fix the issue.`, + EditFile: `Edit file`, + EditFileTooltip: "Open file in external editor.", + OpenFile: `Open file`, + OpenFileTooltip: "Open file in default application.", + OpenInEditor: "Open in editor", + IgnoreFile: `Add to .gitignore`, + ExcludeFile: `Add to .git/info/exclude`, + RefreshFiles: `Refresh files`, + FocusMainView: "Focus main view", + Merge: `Merge`, + MergeBranchTooltip: "View options for merging the selected item into the current branch (regular merge, squash merge)", + RegularMergeFastForward: "Regular merge (fast-forward)", + RegularMergeFastForwardTooltip: "Fast-forward '{{.checkedOutBranch}}' to '{{.selectedBranch}}' without creating a merge commit.", + CannotFastForwardMerge: "Cannot fast-forward '{{.checkedOutBranch}}' to '{{.selectedBranch}}'", + RegularMergeNonFastForward: "Regular merge (with merge commit)", + RegularMergeNonFastForwardTooltip: "Merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}', creating a merge commit.", + SquashMergeUncommitted: "Squash merge and leave uncommitted", + SquashMergeUncommittedTooltip: "Squash merge '{{.selectedBranch}}' into the working tree.", + SquashMergeCommitted: "Squash merge and commit", + SquashMergeCommittedTooltip: "Squash merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}' as a single commit.", + ConfirmQuit: `Are you sure you want to quit?`, + SwitchRepo: `Switch to a recent repo`, + AllBranchesLogGraph: `Show/cycle all branch logs`, + AllBranchesLogGraphReverse: `Show/cycle all branch logs (reverse)`, + UnsupportedGitService: `Unsupported git service`, + CreatePullRequest: `Create pull request`, + CopyPullRequestURL: `Copy pull request URL to clipboard`, + OpenPullRequestInBrowser: `Open pull request in browser`, + NoPullRequestForBranch: `No pull request found for this branch`, + NoBranchOnRemote: `This branch doesn't exist on remote. You need to push it to remote first.`, + Fetch: `Fetch`, + FetchTooltip: "Fetch changes from remote.", + CollapseAll: "Collapse all files", + CollapseAllTooltip: "Collapse all directories in the files tree", + ExpandAll: "Expand all files", + ExpandAllTooltip: "Expand all directories in the file tree", + DisabledInFlatView: "Not available in flat view", + FileEnter: `Stage lines / Collapse directory`, + FileEnterTooltip: "If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it.", + StageSelectionTooltip: `Toggle selection staged / unstaged.`, + DiscardSelection: `Discard`, + DiscardSelectionTooltip: "When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change.", + ToggleRangeSelect: "Toggle range select", + DismissRangeSelect: "Dismiss range select", + ToggleSelectHunk: "Toggle hunk selection", + SelectHunk: "Select hunks", + SelectLineByLine: "Select line-by-line", + ToggleSelectHunkTooltip: "Toggle line-by-line vs. hunk selection mode.", + HunkStagingHint: englishHunkStagingHint, + ToggleSelectionForPatch: `Toggle lines in patch`, + RemoveSelectionFromPatch: `Remove lines from commit`, + RemoveSelectionFromPatchTooltip: "Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines.", + EditHunk: `Edit hunk`, + EditHunkTooltip: "Edit selected hunk in external editor.", + ToggleStagingView: "Switch view", + ToggleStagingViewTooltip: "Switch to other view (staged/unstaged changes).", + ReturnToFilesPanel: `Return to files panel`, + FastForward: `Fast-forward`, + FastForwardTooltip: "Fast-forward selected branch from its upstream.", + FastForwarding: "Fast-forwarding", + FoundConflictsTitle: "Conflicts!", + ViewConflictsMenuItem: "View conflicts", + AbortMenuItem: "Abort the %s", + ViewMergeRebaseOptions: "View merge/rebase options", + ViewMergeRebaseOptionsTooltip: "View options to abort/continue/skip the current merge/rebase.", + ViewMergeOptions: "View merge options", + ViewRebaseOptions: "View rebase options", + ViewCherryPickOptions: "View cherry-pick options", + ViewRevertOptions: "View revert options", + NotMergingOrRebasing: "You are currently neither rebasing nor merging", + AlreadyRebasing: "Can't perform this action during a rebase", + NotMidRebase: "This action only works during an interactive rebase", + MustSelectFixupCommit: "This action only works on fixup commits", + RecentRepos: "Recent repositories", + MergeOptionsTitle: "Merge options", + RebaseOptionsTitle: "Rebase options", + CherryPickOptionsTitle: "Cherry-pick options", + RevertOptionsTitle: "Revert options", + CommitSummaryTitle: "Commit summary", + CommitDescriptionTitle: "Commit description", + CommitDescriptionSubTitle: "Press {{.togglePanelKeyBinding}} to toggle focus, {{.commitMenuKeybinding}} to open menu", + CommitDescriptionFooter: "Press {{.confirmInEditorKeybinding}} to submit", + CommitHooksDisabledSubTitle: "(hooks disabled)", + LocalBranchesTitle: "Local branches", + SearchTitle: "Search", + TagsTitle: "Tags", + MenuTitle: "Menu", + CommitMenuTitle: "Commit Menu", + RemotesTitle: "Remotes", + RemoteBranchesTitle: "Remote branches", + PatchBuildingTitle: "Main panel (patch building)", + InformationTitle: "Information", + SecondaryTitle: "Secondary", + ReflogCommitsTitle: "Reflog", + GlobalTitle: "Global keybindings", + ConflictsResolved: "All merge conflicts resolved. Continue the %s?", + Continue: "Continue", + UnstagedFilesAfterConflictsResolved: "Files have been modified since conflicts were resolved. Auto-stage them and continue?", + Keybindings: "Keybindings", + KeybindingsMenuSectionLocal: "Local", + KeybindingsMenuSectionGlobal: "Global", + KeybindingsMenuSectionNavigation: "Navigation", + KeybindingsTooltip: "Keybindings: ", + RebasingTitle: "Rebase '{{.checkedOutBranch}}'", + RebasingFromBaseCommitTitle: "Rebase '{{.checkedOutBranch}}' from marked base", + SimpleRebase: "Simple rebase onto '{{.ref}}'", + InteractiveRebase: "Interactive rebase onto '{{.ref}}'", + RebaseOntoBaseBranch: "Rebase onto base branch ({{.baseBranch}})", + InteractiveRebaseTooltip: "Begin an interactive rebase with a break at the start, so you can update the TODO commits before continuing.", + RebaseOntoBaseBranchTooltip: "Rebase the checked out branch onto its base branch (i.e. the closest main branch).", + MustSelectTodoCommits: "When rebasing, this action only works on a selection of TODO commits.", + FwdNoUpstream: "Cannot fast-forward a branch with no upstream", + FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally", + FwdCommitsToPush: "Cannot fast-forward a branch with commits to push", + PullRequestNoUpstream: "Cannot open a pull request for a branch with no upstream", + ErrorOccurred: "An error occurred! Please create an issue at", + ConflictLabel: "CONFLICT", + PendingRebaseTodosSectionHeader: "Pending rebase todos", + PendingCherryPicksSectionHeader: "Pending cherry-picks", + PendingRevertsSectionHeader: "Pending reverts", + CommitsSectionHeader: "Commits", + YouDied: "YOU DIED!", + RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported", + ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed", + NotAllowedMidCherryPickOrRevert: "This action is not allowed while cherry-picking or reverting", + PickIsOnlyAllowedDuringRebase: "This action is only allowed while rebasing", + DroppingMergeRequiresSingleSelection: "Dropping a merge commit requires a single selected item", + CherryPickCopy: "Copy (cherry-pick)", + CherryPickCopyTooltip: "Mark commit as copied. Then, within the local commits view, you can press `{{.paste}}` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `{{.escape}}` to cancel the selection.", + PasteCommits: "Paste (cherry-pick)", + SureCherryPick: "Are you sure you want to cherry-pick the {{.numCommits}} copied commit(s) onto this branch?", + CherryPick: "Cherry-pick", + CannotCherryPickNonCommit: "Cannot cherry-pick this kind of todo item", + Donate: "Donate", + AskQuestion: "Ask Question", + PrevHunk: "Go to previous hunk", + NextHunk: "Go to next hunk", + PrevConflict: "Previous conflict", + NextConflict: "Next conflict", + SelectPrevHunk: "Previous hunk", + SelectNextHunk: "Next hunk", + ScrollDown: "Scroll down", + ScrollUp: "Scroll up", + ScrollUpMainWindow: "Scroll up main window", + ScrollDownMainWindow: "Scroll down main window", + SuspendApp: "Suspend the application", + CannotSuspendApp: "Suspending the application is not supported on Windows", + AmendCommitTitle: "Amend commit", + AmendCommitPrompt: "Are you sure you want to amend this commit with your staged files?", + AmendCommitWithConflictsMenuPrompt: "WARNING: you are about to amend the last finished commit with your resolved conflicts. This is very unlikely to be what you want at this point. More likely, you simply want to continue the rebase instead.\n\nDo you still want to amend the previous commit?", + AmendCommitWithConflictsContinue: "No, continue rebase", + AmendCommitWithConflictsAmend: "Yes, amend previous commit", + DropCommitTitle: "Drop commit", + DropCommitPrompt: "Are you sure you want to drop the selected commit(s)?", + DropMergeCommitPrompt: "Are you sure you want to drop the selected merge commit? Note that it will also drop all the commits that were merged in by it.", + DropUpdateRefPrompt: "Are you sure you want to delete the selected update-ref todo(s)? This is irreversible except by aborting the rebase.", + PullingStatus: "Pulling", + PushingStatus: "Pushing", + FetchingStatus: "Fetching", + SquashingStatus: "Squashing", + FixingStatus: "Fixing up", + DeletingStatus: "Deleting", + DroppingStatus: "Dropping", + MovingStatus: "Moving", + RebasingStatus: "Rebasing", + MergingStatus: "Merging", + LowercaseRebasingStatus: "rebasing", // lowercase because it shows up in parentheses + LowercaseMergingStatus: "merging", // lowercase because it shows up in parentheses + LowercaseCherryPickingStatus: "cherry-picking", // lowercase because it shows up in parentheses + LowercaseRevertingStatus: "reverting", // lowercase because it shows up in parentheses + AmendingStatus: "Amending", + CherryPickingStatus: "Cherry-picking", + UndoingStatus: "Undoing", + RedoingStatus: "Redoing", + CheckingOutStatus: "Checking out", + CommittingStatus: "Committing", + RewordingStatus: "Rewording", + RevertingStatus: "Reverting", + CreatingFixupCommitStatus: "Creating fixup commit", + MovingCommitsToNewBranchStatus: "Moving commits to new branch", + CommitFiles: "Commit files", + SubCommitsDynamicTitle: "Commits (%s)", + CommitFilesDynamicTitle: "Diff files (%s)", + RemoteBranchesDynamicTitle: "Remote branches (%s)", + ViewItemFiles: "View files", + CommitFilesTitle: "Commit files", + CheckoutCommitFileTooltip: "Checkout file. This replaces the file in your working tree with the version from the selected commit.", + CannotCheckoutWithModifiedFilesErr: "You have local modifications for the file(s) you are trying to check out. You need to stash or discard these first.", + CanOnlyDiscardFromLocalCommits: "Changes can only be discarded from local commits", + CannotDiscardFromMultipleCommits: "Changes cannot be discarded from a multiselection of commits", + Remove: "Remove", + DiscardOldFileChangeTooltip: "Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file.", + DiscardFileChangesTitle: "Discard file changes", + DiscardFileChangesPrompt: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.", + DiscardFileChangesPromptResetPatch: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\n\nNote: This will reset the active custom patch!", + DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.", + CreateRepo: "Not in a git repository. Create a new git repository? (y/N): ", + BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not yet support bare repos. Open most recent repo? (y/n) ", + InitialBranch: "Branch name? (leave empty for git's default): ", + NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.", + IncorrectNotARepository: "The value of 'notARepository' is incorrect. It should be one of 'prompt', 'create', 'skip', or 'quit'.", + AutoStashTitle: "Autostash?", + AutoStashPrompt: "You must stash and pop your changes to bring them across. Do this automatically? (enter/esc)", + AutoStashForUndo: "Auto-stashing changes for undoing to %s", + AutoStashForCheckout: "Auto-stashing changes for checking out %s", + AutoStashForNewBranch: "Auto-stashing changes for creating new branch %s", + AutoStashForMovingPatchToIndex: "Auto-stashing changes for moving custom patch to index from %s", + AutoStashForCherryPicking: "Auto-stashing changes for cherry-picking commits", + AutoStashForReverting: "Auto-stashing changes for reverting commits", + Discard: "Discard", + DiscardFileChangesTooltip: "View options for discarding changes to the selected file.", + DiscardChangesTitle: "Discard changes", + Cancel: "Cancel", + DiscardAllChanges: "Discard all changes", + DiscardUnstagedChanges: "Discard unstaged changes", + DiscardAllChangesToAllFiles: "Nuke working tree", + DiscardAnyUnstagedChanges: "Discard unstaged changes", + DiscardUntrackedFiles: "Discard untracked files", + DiscardStagedChanges: "Discard staged changes", + HardReset: "Hard reset", + BranchDeleteTooltip: "View delete options for local/remote branch.", + TagDeleteTooltip: "View delete options for local/remote tag.", + Delete: "Delete", + Reset: "Reset", + ResetTooltip: "View reset options (soft/mixed/hard) for resetting onto selected item.", + ResetSoftTooltip: "Reset HEAD to the chosen commit, and keep the changes between the current and chosen commit as staged changes.", + ResetMixedTooltip: "Reset HEAD to the chosen commit, and keep the changes between the current and chosen commit as unstaged changes.", + ResetHardTooltip: "Reset HEAD to the chosen commit, and discard all changes between the current and chosen commit, as well as all current modifications in the working tree.", + ResetHardConfirmation: "Are you sure you want to do a hard reset? This will discard all uncommitted changes (both staged and unstaged), which is not undoable.", + ViewResetOptions: `Reset`, + FileResetOptionsTooltip: "View reset options for working tree (e.g. nuking the working tree).", + FixupTooltip: "Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded.", + CreateFixupCommit: "Create fixup commit", + CreateFixupCommitTooltip: "Create 'fixup!' commit for the selected commit. Later on, you can press `{{.squashAbove}}` on this same commit to apply all above fixup commits.", + CreateAmendCommit: `Create "amend!" commit`, + FixupMenu_Fixup: "fixup! commit", + FixupMenu_FixupTooltip: "Lets you fixup another commit and keep the original commit's message.", + FixupMenu_AmendWithChanges: "amend! commit with changes", + FixupMenu_AmendWithChangesTooltip: "Lets you fixup another commit and also change its commit message.", + FixupMenu_AmendWithoutChanges: "amend! commit without changes (pure reword)", + FixupMenu_AmendWithoutChangesTooltip: "Lets you change the commit message of another commit without changing its content.", + SquashAboveCommits: "Apply fixup commits", + SquashAboveCommitsTooltip: `Squash all 'fixup!' commits, either above the selected commit, or all in current branch (autosquash).`, + SquashCommitsAboveSelectedTooltip: `Squash all 'fixup!' commits above the selected commit (autosquash).`, + SquashCommitsInCurrentBranchTooltip: `Squash all 'fixup!' commits in the current branch (autosquash).`, + SquashCommitsInCurrentBranch: "In current branch", + SquashCommitsAboveSelectedCommit: "Above the selected commit", + CannotSquashCommitsInCurrentBranch: "Cannot squash commits in current branch: the HEAD commit is a merge commit or is present on the main branch.", + ExecuteShellCommand: "Execute shell command", + ExecuteShellCommandTooltip: "Bring up a prompt where you can enter a shell command to execute.", + ShellCommand: "Shell command:", + CommitChangesWithoutHook: "Commit changes without pre-commit hook", + ResetTo: `Reset to`, + PressEnterToReturn: "Press enter to return to lazygit", + ViewStashOptions: "View stash options", + ViewStashOptionsTooltip: "View stash options (e.g. stash all, stash staged, stash unstaged).", + Stash: "Stash", + StashTooltip: "Stash all changes. For other variations of stashing, use the view stash options keybinding.", + StashAllChanges: "Stash all changes", + StashStagedChanges: "Stash staged changes", + StashAllChangesKeepIndex: "Stash all changes and keep index", + StashUnstagedChanges: "Stash unstaged changes", + StashIncludeUntrackedChanges: "Stash all changes including untracked files", + StashOptions: "Stash options", + NotARepository: "Error: must be run inside a git repository", + WorkingDirectoryDoesNotExist: "Error: the current working directory does not exist", + ScrollLeft: "Scroll left", + ScrollRight: "Scroll right", + DiscardPatch: "Discard patch", + DiscardPatchConfirm: "You can only build a patch from one commit/stash-entry at a time. Discard current patch?", + CantPatchWhileRebasingError: "You cannot build a patch or run patch commands while in a merging or rebasing state", + ToggleAddToPatch: "Toggle file included in patch", + ToggleAddToPatchTooltip: "Toggle whether the file is included in the custom patch. See {{.doc}}.", + ToggleAllInPatch: "Toggle all files", + ToggleAllInPatchTooltip: "Add/remove all commit's files to custom patch. See {{.doc}}.", + UpdatingPatch: "Updating patch", + ViewPatchOptions: "View custom patch options", + PatchOptionsTitle: "Patch options", + NoPatchError: "No patch created yet. To start building a patch, use 'space' on a commit file or enter to add specific lines", + EmptyPatchError: "Patch is still empty. Add some files or lines to your patch first.", + EnterCommitFile: "Enter file / Toggle directory collapsed", + EnterCommitFileTooltip: "If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory.", + EnterStaging: "Enter staging/patch building", + ExitCustomPatchBuilder: `Exit custom patch builder`, + ExitFocusedMainView: "Exit back to side panel", + ToggleSelectionInFocusedMainView: "Show/hide selection", + OpenPullRequestForSelectedLine: "Open pull request for selected line", + OpenPullRequestForSelectedLineTooltip: "Open a browser at the selected line in the diff of the current branch's pull request, so that you can comment on it. Only works for local branches that have a pull request on GitHub.", + EnterUpstream: `Enter upstream as ' '`, + InvalidUpstream: "Invalid upstream. Must be in the format ' '", + NewRemote: `New remote`, + NewRemoteName: `New remote name:`, + NewRemoteUrl: `New remote url:`, + AddForkRemoteUsername: `Fork owner (username/org). Use username:branch to check out a branch`, + AddForkRemote: `Add fork remote`, + AddForkRemoteTooltip: `Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote.`, + IncompatibleForkAlreadyExistsError: `Remote {{.remoteName}} already exists and has different URL`, + NoOriginRemote: "Action needs 'origin' remote", + ViewBranches: "View branches", + EditRemoteName: `Enter updated remote name for {{.remoteName}}:`, + EditRemoteUrl: `Enter updated remote url for {{.remoteName}}:`, + RemoveRemote: `Remove remote`, + RemoveRemoteTooltip: `Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected.`, + RemoveRemotePrompt: "Are you sure you want to remove remote?", + DeleteRemoteBranch: "Delete remote branch", + DeleteRemoteBranches: "Delete remote branches", + DeleteRemoteBranchTooltip: "Delete the remote branch from the remote.", + DeleteLocalAndRemoteBranch: "Delete local and remote branch", + DeleteLocalAndRemoteBranches: "Delete local and remote branches", + SetAsUpstream: "Set as upstream", + SetAsUpstreamTooltip: "Set the selected remote branch as the upstream of the checked-out branch.", + SetUpstream: "Set upstream of selected branch", + UnsetUpstream: "Unset upstream of selected branch", + ViewDivergenceFromUpstream: "View divergence from upstream", + ViewDivergenceFromBaseBranch: "View divergence from base branch ({{.baseBranch}})", + CouldNotDetermineBaseBranch: "Couldn't determine base branch", + DivergenceSectionHeaderLocal: "Local", + DivergenceSectionHeaderRemote: "Remote", + ViewUpstreamResetOptions: "Reset checked-out branch onto {{.upstream}}", + ViewUpstreamResetOptionsTooltip: "View options for resetting the checked-out branch onto {{upstream}}. Note: this will not reset the selected branch onto the upstream, it will reset the checked-out branch onto the upstream.", + ViewUpstreamRebaseOptions: "Rebase checked-out branch onto {{.upstream}}", + ViewUpstreamRebaseOptionsTooltip: "View options for rebasing the checked-out branch onto {{upstream}}. Note: this will not rebase the selected branch onto the upstream, it will rebase the checked-out branch onto the upstream.", + UpstreamGenericName: "upstream of selected branch", + SetUpstreamTitle: "Set upstream branch", + SetUpstreamMessage: "Are you sure you want to set the upstream branch of '{{.checkedOut}}' to '{{.selected}}'?", + EditRemoteTooltip: "Edit the selected remote's name or URL.", + TagCommit: "Tag commit", + TagCommitTooltip: "Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description.", + TagNameTitle: "Tag name", + TagMessageTitle: "Tag description", + AnnotatedTag: "Annotated tag", + LightweightTag: "Lightweight tag", + DeleteTagTitle: "Delete tag '{{.tagName}}'?", + DeleteLocalTag: "Delete local tag", + DeleteRemoteTag: "Delete remote tag", + DeleteLocalAndRemoteTag: "Delete local and remote tag", + RemoteTagDeletedMessage: "Remote tag deleted", + SelectRemoteTagUpstream: "Remote from which to remove tag '{{.tagName}}':", + DeleteRemoteTagPrompt: "Are you sure you want to delete the remote tag '{{.tagName}}' from '{{.upstream}}'?", + DeleteLocalAndRemoteTagPrompt: "Are you sure you want to delete '{{.tagName}}' from both your machine and from '{{.upstream}}'?", + PushTagTitle: "Remote to push tag '{{.tagName}}' to:", // Using 'push tag' rather than just 'push' to disambiguate from a global push PushTag: "Push tag", PushTagTooltip: "Push the selected tag to a remote. You'll be prompted to select a remote.", From 207927e0d3034fa5ce5b444009a08bbcd3ff32b4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 31 May 2026 18:43:37 +0200 Subject: [PATCH 12/24] WIP New click behavior --- pkg/gui/controllers/main_view_controller.go | 48 +++++++++++-------- .../switch_to_focused_main_view_controller.go | 14 ++++-- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index e118b8c1132..b5a05a6a52a 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -159,11 +159,8 @@ func (self *MainViewController) toggleSelection() error { v.Highlight = false return nil } - v.Highlight = true - v.HighlightInactive = false - lineIdx := v.OriginY() + v.InnerHeight()/2 - lineIdx = lo.Clamp(lineIdx, 0, v.ViewLinesHeight()-1) - v.FocusPoint(0, lineIdx, false) + // Start the selection in the middle of the visible area. + showSelectionAtLine(v, v.OriginY()+v.InnerHeight()/2) return nil } @@ -171,14 +168,29 @@ func (self *MainViewController) enter() error { if !self.context.GetView().Highlight { return nil } + return self.enterForLine(self.context.GetView().SelectedLineIdx()) +} + +// enterForLine dives into staging/patch-building for the given line, by +// delegating to the side panel beneath the focused main view (the same handler +// used when clicking). +func (self *MainViewController) enterForLine(lineIdx int) error { sidePanelContext := self.c.Context().NextInStack(self.context) if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil { - return sidePanelContext.GetOnClickFocusedMainView()( - self.context.GetViewName(), self.context.GetView().SelectedLineIdx()) + return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), lineIdx) } return nil } +// showSelectionAtLine turns on the focused main view's selection and moves it to +// the given view line, clamped to the content. +func showSelectionAtLine(view *gocui.View, lineIdx int) { + view.Highlight = true + view.HighlightInactive = false + lineIdx = lo.Clamp(lineIdx, 0, view.ViewLinesHeight()-1) + view.FocusPoint(0, lineIdx, false) +} + func (self *MainViewController) editLine() error { if !self.context.GetView().Highlight { return nil @@ -284,23 +296,21 @@ func githubPullRequestLineURL(prURL string, commitSha string, relativePath strin } func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error { - if self.context.GetView().Highlight && !opts.IsDoubleClick { - return nil - } - - sidePanelContext := self.c.Context().NextInStack(self.context) - if sidePanelContext != nil && sidePanelContext.GetOnClickFocusedMainView() != nil { - return sidePanelContext.GetOnClickFocusedMainView()(self.context.GetViewName(), opts.Y) + // A click points at a line, so it sets the selection there; a double-click + // additionally dives into staging/patch-building for that line. + showSelectionAtLine(self.context.GetView(), opts.Y) + if opts.IsDoubleClick { + return self.enterForLine(opts.Y) } return nil } func (self *MainViewController) onClickInOtherViewOfMainViewPair(opts gocui.ViewMouseBindingOpts) error { - self.c.Context().Push(self.context, types.OnFocusOpts{ - ClickedWindowName: self.context.GetWindowName(), - ClickedViewLineIdx: opts.Y, - }) - + self.c.Context().Push(self.context, types.OnFocusOpts{}) + showSelectionAtLine(self.context.GetView(), opts.Y) + if opts.IsDoubleClick { + return self.enterForLine(opts.Y) + } return nil } diff --git a/pkg/gui/controllers/switch_to_focused_main_view_controller.go b/pkg/gui/controllers/switch_to_focused_main_view_controller.go index 5606a0bab59..c7888135cef 100644 --- a/pkg/gui/controllers/switch_to_focused_main_view_controller.go +++ b/pkg/gui/controllers/switch_to_focused_main_view_controller.go @@ -61,21 +61,27 @@ func (self *SwitchToFocusedMainViewController) Context() types.Context { } func (self *SwitchToFocusedMainViewController) onClickMain(opts gocui.ViewMouseBindingOpts) error { - return self.focusMainView(self.c.Contexts().Normal) + return self.focusMainView(self.c.Contexts().Normal, opts.Y) } func (self *SwitchToFocusedMainViewController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error { - return self.focusMainView(self.c.Contexts().NormalSecondary) + return self.focusMainView(self.c.Contexts().NormalSecondary, opts.Y) } func (self *SwitchToFocusedMainViewController) handleFocusMainView() error { - return self.focusMainView(self.c.Contexts().Normal) + // Focusing by keyboard doesn't point at any particular line, so we don't + // show a selection; the user is free to scroll. Clicking does point at a + // line, so it selects it (see focusMainView's clickedLineIdx). + return self.focusMainView(self.c.Contexts().Normal, -1) } -func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context) error { +func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext types.Context, clickedLineIdx int) error { if context, ok := mainViewContext.(types.ISearchableContext); ok { context.ClearSearchString() } self.c.Context().Push(mainViewContext, types.OnFocusOpts{}) + if clickedLineIdx >= 0 { + showSelectionAtLine(mainViewContext.GetView(), clickedLineIdx) + } return nil } From 5f500893a3b639e5da055e1a73a3df348b41811c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 3 Jun 2026 09:10:22 +0200 Subject: [PATCH 13/24] WIP FocusedMainViewSnapshot approach --- pkg/gui/context/patch_explorer_context.go | 13 +++++ .../controllers/commits_files_controller.go | 8 ++- pkg/gui/controllers/files_controller.go | 18 ++++-- .../helpers/commit_files_helper.go | 12 ++-- .../helpers/patch_building_helper.go | 56 +++++++++++++------ pkg/gui/controllers/main_view_controller.go | 31 ++++++++++ pkg/gui/controllers/staging_controller.go | 3 +- .../switch_to_diff_files_controller.go | 10 ++-- pkg/gui/types/context.go | 28 ++++++++++ 9 files changed, 144 insertions(+), 35 deletions(-) diff --git a/pkg/gui/context/patch_explorer_context.go b/pkg/gui/context/patch_explorer_context.go index 334c2e374bc..d1a935d5406 100644 --- a/pkg/gui/context/patch_explorer_context.go +++ b/pkg/gui/context/patch_explorer_context.go @@ -20,6 +20,11 @@ type PatchExplorerContext struct { // true if we're inside the OnSelectItem callback; in that case we don't want to update the // search result index. inOnSelectItemCallback bool + + // Set when this patch explorer was entered from a focused main view, so that + // escaping returns there; nil for the normal flow. See + // types.FocusedMainViewSnapshot. + focusedMainViewSnapshot *types.FocusedMainViewSnapshot } var ( @@ -60,6 +65,14 @@ func NewPatchExplorerContext( func (self *PatchExplorerContext) IsPatchExplorerContext() {} +func (self *PatchExplorerContext) GetFocusedMainViewSnapshot() *types.FocusedMainViewSnapshot { + return self.focusedMainViewSnapshot +} + +func (self *PatchExplorerContext) SetFocusedMainViewSnapshot(snapshot *types.FocusedMainViewSnapshot) { + self.focusedMainViewSnapshot = snapshot +} + func (self *PatchExplorerContext) GetState() *patch_exploring.State { return self.state } diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 1e17549c72c..ab815ab4d0e 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -535,6 +535,9 @@ func (self *CommitFilesController) expandAll() error { func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { return func(mainViewName string, clickedLineIdx int) error { + // Capture before any mutation below that might re-render the main view. + snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context(), clickedLineIdx) + clickedFile, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(mainViewName, clickedLineIdx) if !ok { line = -1 @@ -563,9 +566,8 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName } } - // Entered from the commit files panel's own focused main view, so escape - // should just pop back to it; no special escape context needed. - return self.c.Helpers().CommitFiles.EnterCommitFile(node, nil, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) + // Entered from the focused main view, so escaping returns there. + return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) } } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 38374a7ac03..0441c733c79 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -361,6 +361,9 @@ func (self *FilesController) GetOnDoubleClick() func() error { func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error { return func(mainViewName string, clickedLineIdx int) error { + // Capture before any mutation below that might re-render the main view. + snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context(), clickedLineIdx) + clickedFile, line, ok := self.c.Helpers().Staging.GetFileAndLineForClickedDiffLine(mainViewName, clickedLineIdx) if !ok { line = -1 @@ -388,7 +391,7 @@ func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName strin } } - return self.EnterFile(types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) + return self.EnterFile(snapshot, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) } } @@ -675,7 +678,7 @@ func (self *FilesController) getSelectedFile() *models.File { } func (self *FilesController) enter() error { - return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1}) + return self.EnterFile(nil, types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1, ClickedViewRealLineIdx: -1}) } func (self *FilesController) collapseAll() error { @@ -694,7 +697,11 @@ func (self *FilesController) expandAll() error { return nil } -func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { +// focusedMainViewSnapshot records the focused main view to return to when +// escaping the staging view, for the case where we're entering it straight from +// there; it's nil for the normal flow that goes through the files panel. See +// types.FocusedMainViewSnapshot. +func (self *FilesController) EnterFile(focusedMainViewSnapshot *types.FocusedMainViewSnapshot, opts types.OnFocusOpts) error { node := self.context().GetSelected() if node == nil { return nil @@ -720,6 +727,9 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { } context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging) + // Set on every entry (so it can't leak from a previous main-view entry into a + // subsequent normal one), right as we push the staging view. + context.SetFocusedMainViewSnapshot(focusedMainViewSnapshot) self.c.Context().Push(context, opts) self.c.Helpers().PatchBuilding.ShowHunkStagingHint() @@ -1383,7 +1393,7 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro } func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error { - return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y}) + return self.EnterFile(nil, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y}) } func (self *FilesController) fetch() error { diff --git a/pkg/gui/controllers/helpers/commit_files_helper.go b/pkg/gui/controllers/helpers/commit_files_helper.go index e03e1f8cebf..be0df21d997 100644 --- a/pkg/gui/controllers/helpers/commit_files_helper.go +++ b/pkg/gui/controllers/helpers/commit_files_helper.go @@ -21,11 +21,11 @@ func NewCommitFilesHelper(c *HelperCommon, patchBuildingHelper *PatchBuildingHel } } -// escapeContext is the side panel that escaping the patch builder should return -// to, for the case where we're entering it straight from a focused main view; -// it's nil for the normal flow that goes through the commit files panel. See -// PatchBuildingHelper.escapeContext. -func (self *CommitFilesHelper) EnterCommitFile(node *filetree.CommitFileNode, escapeContext types.Context, opts types.OnFocusOpts) error { +// focusedMainViewSnapshot records the focused main view to return to when +// escaping the patch builder, for the case where we're entering it straight from +// there; it's nil for the normal flow that goes through the commit files panel. +// See types.FocusedMainViewSnapshot. +func (self *CommitFilesHelper) EnterCommitFile(node *filetree.CommitFileNode, focusedMainViewSnapshot *types.FocusedMainViewSnapshot, opts types.OnFocusOpts) error { if node.File == nil { self.handleToggleCommitFileDirCollapsed(node) return nil @@ -55,7 +55,7 @@ func (self *CommitFilesHelper) EnterCommitFile(node *filetree.CommitFileNode, es // Set on every entry (so it can't leak from a previous main-view // entry into a subsequent normal one), right as we push the patch // builder. - self.patchBuildingHelper.escapeContext = escapeContext + self.c.Contexts().CustomPatchBuilder.SetFocusedMainViewSnapshot(focusedMainViewSnapshot) self.c.Context().Push(self.c.Contexts().CustomPatchBuilder, opts) self.patchBuildingHelper.ShowHunkStagingHint() diff --git a/pkg/gui/controllers/helpers/patch_building_helper.go b/pkg/gui/controllers/helpers/patch_building_helper.go index b66f7ef1237..13861d61553 100644 --- a/pkg/gui/controllers/helpers/patch_building_helper.go +++ b/pkg/gui/controllers/helpers/patch_building_helper.go @@ -10,14 +10,6 @@ import ( type PatchBuildingHelper struct { c *HelperCommon - - // When patch building is entered straight from a focused main view (rather - // than from the commit files panel), this records the side panel to return - // to on escape, so that we skip the commit files panel we never really - // visited. It is nil for the normal flow, where escape just pops back to the - // commit files panel. Set on every entry into patch building (see - // CommitFilesHelper.EnterCommitFile) so it can't leak between flows. - escapeContext types.Context } func NewPatchBuildingHelper( @@ -40,17 +32,47 @@ func (self *PatchBuildingHelper) ShowHunkStagingHint() { } } -// takes us from the patch building panel back to the commit files panel, or -// straight back to the side panel if we entered patch building from a focused -// main view (see escapeContext) +// takes us from the patch building panel back to the commit files panel, or to +// the focused main view if that's where we entered it from func (self *PatchBuildingHelper) Escape() { - if self.escapeContext != nil { - escapeContext := self.escapeContext - self.escapeContext = nil - self.c.Context().Push(escapeContext, types.OnFocusOpts{}) - } else { - self.c.Context().Pop() + EscapeFromPatchExplorer(self.c, self.c.Contexts().CustomPatchBuilder) +} + +// EscapeFromPatchExplorer returns from a patch explorer context (staging or +// patch building). If we entered it from a focused main view, we go back to +// where we came from (re-rendering the side panel's content into the main view, +// like the plain escape does), then focus the main view and restore its scroll +// position and selection. Otherwise we just pop to the side panel. +func EscapeFromPatchExplorer(c *HelperCommon, context types.IPatchExplorerContext) { + snapshot := context.GetFocusedMainViewSnapshot() + if snapshot == nil { + c.Context().Pop() + return } + + context.SetFocusedMainViewSnapshot(nil) + + // Restore the side panel's selection before we render it, so it shows the + // same content the main view had (diving into staging can change it, e.g. + // from a directory to a file in the files panel). + if listContext, ok := snapshot.SidePanel.(types.IListContext); ok && snapshot.SidePanelSelectedLineIdx >= 0 { + listContext.GetList().SetSelectedLineIdx(snapshot.SidePanelSelectedLineIdx) + } + + // Land on the side panel first (this re-renders the original content into the + // main view), then focus the main view on top of it. + c.Context().Push(snapshot.SidePanel, types.OnFocusOpts{}) + c.Context().Push(snapshot.MainView, types.OnFocusOpts{}) + + // Restore the scroll position and selection on the next UI tick. + view := snapshot.MainView.GetView() + c.OnUIThread(func() error { + view.SetOrigin(view.OriginX(), snapshot.OriginY) + view.FocusPoint(0, snapshot.SelectedLineIdx, false) + view.Highlight = true + view.HighlightInactive = false + return nil + }) } // kills the custom patch and returns us back to the commit files panel if needed diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index b5a05a6a52a..3dd4876f7f3 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -191,6 +191,37 @@ func showSelectionAtLine(view *gocui.View, lineIdx int) { view.FocusPoint(0, lineIdx, false) } +// focusedMainViewContextForViewName maps a focused main view's view name (as +// passed to GetOnClickFocusedMainView) to its context. +func focusedMainViewContextForViewName(c *ControllerCommon, viewName string) types.Context { + if viewName == c.Contexts().NormalSecondary.GetViewName() { + return c.Contexts().NormalSecondary + } + return c.Contexts().Normal +} + +// focusedMainViewSnapshot captures where a focused main view is (scroll + +// selected line) when diving into a patch explorer from it, so escaping can +// return there with the main view focused. sidePanel is the panel to land on +// first (which re-renders the content); for commits/stash it's the originating +// panel, skipping the commit files panel we pass through. selectedLineIdx is the +// view line that was selected in the focused main view. Call this before any +// mutation that might re-render the main view. +func focusedMainViewSnapshot(c *ControllerCommon, mainViewName string, sidePanel types.Context, selectedLineIdx int) *types.FocusedMainViewSnapshot { + mainView := focusedMainViewContextForViewName(c, mainViewName) + sidePanelSelectedLineIdx := -1 + if listContext, ok := sidePanel.(types.IListContext); ok { + sidePanelSelectedLineIdx = listContext.GetList().GetSelectedLineIdx() + } + return &types.FocusedMainViewSnapshot{ + SidePanel: sidePanel, + SidePanelSelectedLineIdx: sidePanelSelectedLineIdx, + MainView: mainView, + OriginY: mainView.GetView().OriginY(), + SelectedLineIdx: selectedLineIdx, + } +} + func (self *MainViewController) editLine() error { if !self.context.GetView().Highlight { return nil diff --git a/pkg/gui/controllers/staging_controller.go b/pkg/gui/controllers/staging_controller.go index 8d876acdae2..c3360173be9 100644 --- a/pkg/gui/controllers/staging_controller.go +++ b/pkg/gui/controllers/staging_controller.go @@ -7,6 +7,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/patch" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -175,7 +176,7 @@ func (self *StagingController) Escape() error { return nil } - self.c.Context().Pop() + helpers.EscapeFromPatchExplorer(self.c.HelperCommon, self.context) return nil } diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index ce157ebef26..ace759bd752 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -59,6 +59,11 @@ func (self *SwitchToDiffFilesController) GetOnClickFocusedMainView() func(mainVi return nil } + // Capture before self.enter() pushes the commit files panel, which + // re-renders the main view. We escape "all the way out" to this side + // panel (skipping the commit files panel), then focus the main view. + snapshot := focusedMainViewSnapshot(self.c, mainViewName, self.context, clickedLineIdx) + if err := self.enter(); err != nil { return err } @@ -83,10 +88,7 @@ func (self *SwitchToDiffFilesController) GetOnClickFocusedMainView() func(mainVi context.GetViewTrait().FocusPoint( context.ModelIndexToViewIndex(idx), false) node = context.GetSelected() - // We entered patch building straight from the focused main view, so - // escaping it should take us all the way back out to this side panel, - // skipping the commit files panel we never really visited. - return self.c.Helpers().CommitFiles.EnterCommitFile(node, self.context, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) + return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) } } diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index d2328f19017..40883fc04fc 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -205,6 +205,34 @@ type IPatchExplorerContext interface { NavigateTo(selectedLineIdx int) GetMutex() *deadlock.Mutex IsPatchExplorerContext() // used for type switch + + // See FocusedMainViewSnapshot. Nil unless this patch explorer was entered + // from a focused main view. + GetFocusedMainViewSnapshot() *FocusedMainViewSnapshot + SetFocusedMainViewSnapshot(*FocusedMainViewSnapshot) +} + +// FocusedMainViewSnapshot records where a focused main view was when we dived +// into a patch explorer (staging or patch building) from it, so that escaping +// returns us to the same place with the main view focused again. It is nil when +// the patch explorer was entered the normal way (through a side panel), in which +// case escape just pops to that side panel. +type FocusedMainViewSnapshot struct { + // The side panel to land on first; pushing it re-renders the original + // content into the main view. For commits/stash this is the originating side + // panel (skipping the commit files panel we passed through), preserving the + // pre-existing "escape all the way out" behavior. + SidePanel Context + // The side panel's selected line, to restore before re-rendering it. Diving + // into staging can change the side panel's selection (e.g. from a directory + // to a file in the files panel); restoring it makes the main view show the + // same content again. -1 if the side panel isn't a list. + SidePanelSelectedLineIdx int + // The focused main view context to focus afterwards. + MainView Context + // The scroll position and selected line to restore in the main view. + OriginY int + SelectedLineIdx int } type IViewTrait interface { From 63221c3dd99b6463b1d5cda0703db3b27fc18b05 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 3 Jun 2026 09:10:29 +0200 Subject: [PATCH 14/24] Session notes --- focused-main-view-notes.md | 459 +++++++++++++++++++++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 focused-main-view-notes.md diff --git a/focused-main-view-notes.md b/focused-main-view-notes.md new file mode 100644 index 00000000000..6644ac8600e --- /dev/null +++ b/focused-main-view-notes.md @@ -0,0 +1,459 @@ +# Focused main view — session notes + +A working document capturing everything we discussed, built, and learned in this +session. It is meant as a **starting point for future sessions**, which might: + +1. **Continue** solving the problem we were in the middle of (restoring scroll + + selection when escaping back to a focused main view) — still at prototype + quality. +2. **Enhance** the prototype with a few more missing pieces. +3. **Productionize** the whole thing: better quality, a clean commit history, and + tests. (We did *not* make that plan; this doc gives a future session enough + context to make it.) + +> Status at end of session: branch `use-delta-hyperlinks-for-clicking-in-diff`, +> with a pile of **uncommitted** changes implementing the escape/restore work +> (see "Uncommitted work" below). The tree builds (`just build`), is +> `gofumpt`-clean, and `just vet` passes. + +--- + +## 1. The big picture: what this feature is + +lazygit has a "focused main view": you press `0` (`Universal.FocusMainView`), +or click, to move focus from a side panel (files, commits, commit-files, +stash, branches, …) **into the main view** that shows its diff, so you can +scroll and interact with the diff itself. The branch builds this out into a +real interaction model: + +- A **selection** can be shown in the focused main view (a highlighted line), + toggled on demand. +- With a selection showing you can: + - **`enter` / double-click** → dive into staging (files) or patch building + (commits / commit-files) **for the clicked line**. + - **`e`** → edit that line in your editor (like the staging view's `e`). + - **`G`** → open the selected line in the current branch's GitHub PR diff + (so you can comment on it). +- **Clicking** sets the selection at the clicked line; **double-click** + activates (dives in). `0` focuses *without* a selection (scroll mode). + +This all relies on `delta` emitting `lazygit-edit://:` OSC-8 +hyperlinks in the rendered diff (hence the branch name); lazygit parses those +to know which file/line a view line corresponds to. + +--- + +## 2. Branch state + +Branch: `use-delta-hyperlinks-for-clicking-in-diff` (off lazygit master). + +### Committed commits (most recent last), the feature-relevant ones: + +``` +45eebc679 Add user config gui.showSelectionInFocusedMainView +686c829d5 Press enter in focused main view when user config is on +dcd658bb7 Select line that is in the middle of the screen +c8a2bc5e7 Press enter in main view of files/commitFiles to enter staging/patch-building +0688099ee Extract some functions from CommitFilesController to a new CommitFilesHelper +a2a675fe0 Press enter in main view of commits panel to enter patch building for clicked line +673b90c10 WIP After going straight to patch building from main view, esc goes all the way back out +c4aba31c9 Replace gui.showSelectionInFocusedMainView config with on-demand selection +ee9f07a67 Press `e` in focused main view (when selection is showing) to edit that line +77157c5ad Open a browser at the selected line in the diff of the current branch's PR +30e625a8d WIP New click behavior +``` + +Note the two **`WIP`** commits (`673b90c10`, `30e625a8d`) — these will need +rework/squashing for productionization. + +### Uncommitted work (the in-progress escape/restore feature) + +``` + M AGENTS.md (unrelated: see §8) + M pkg/gui/context/patch_explorer_context.go + M pkg/gui/controllers/commits_files_controller.go + M pkg/gui/controllers/files_controller.go + M pkg/gui/controllers/helpers/commit_files_helper.go + M pkg/gui/controllers/helpers/patch_building_helper.go + M pkg/gui/controllers/main_view_controller.go + M pkg/gui/controllers/staging_controller.go + M pkg/gui/controllers/switch_to_diff_files_controller.go + M pkg/gui/types/context.go +``` + +--- + +## 3. Architecture primer (what we learned about lazygit internals) + +### Contexts, the stack, and `NextInStack` + +- Each panel/view is a **context**. The `ContextMgr` keeps a **stack** + (`pkg/gui/context.go`). `Push`/`Pop` manage it. Kinds: `SIDE_CONTEXT`, + `MAIN_CONTEXT`, popups, etc. +- Pushing a `SIDE_CONTEXT` **wipes the stack** down to just it. Pushing a + `MAIN_CONTEXT` **evicts other main contexts** but keeps non-main ones beneath. + Only **one main context** is ever on the stack at a time. +- A focused main view's "side panel" is found via + **`ContextMgr.NextInStack(ctx)`** — the entry just below it on the stack. + This was introduced on master in commit `bbd17abc43a` + ("Add ContextMgr.NextInStack…") specifically to **stop abusing the + parent-context mechanism** for this. Earlier prototype code on this branch + assumed the focused main view's *parent context* was its side panel; that + assumption is gone now — use `NextInStack`. (Memory: + `worktree-path-vs-repo-path` is unrelated; this is a different gotcha.) + +### The focused main view contexts vs. the patch-explorer contexts + +`pkg/gui/context/setup.go`: + +- `Normal` → `Main` view, window `"main"`; `NormalSecondary` → `Secondary` + view, window `"secondary"`. These are `MainContext` (a `SimpleContext`). + **This is the focused main view.** +- `Staging` → `Staging` view, window `"main"`; `StagingSecondary` → + `StagingSecondary` view, window `"secondary"`; `CustomPatchBuilder` → + `PatchBuilding` view, window `"main"`. These are `PatchExplorerContext` + (also `MAIN_CONTEXT`). +- **Crucial:** `Normal` and `Staging`/`CustomPatchBuilder` share the same + *window* but are **separate gocui views**. Only one view per window is shown + at a time; the others are hidden **but retain their buffer (content, scroll, + selection)**. So entering staging *hides* the `Main` view rather than + overwriting it — its scroll/selection survive **unless something explicitly + re-renders the `Main` view** (see "the clobber" below). + +### Dispatch: `GetOnClickFocusedMainView` + +- Controllers expose `GetOnClickFocusedMainView() func(mainViewName string, clickedLineIdx int) error`. +- `pkg/gui/controllers/attach.go` registers it on the context + (`AddOnClickFocusedMainViewFn`). +- `MainViewController.enterForLine` / `onClickInAlreadyFocusedView` call + `NextInStack(self.context).GetOnClickFocusedMainView()(viewName, lineIdx)`. +- Implementers: `FilesController` (→ staging), `CommitFilesController` (→ patch + building), `SwitchToDiffFilesController` (commits/stash → patch building). +- The line/file is resolved from the `lazygit-edit://` hyperlink via + `StagingHelper.GetFileAndLineForClickedDiffLine(viewName, lineIdx)` — this + reads the hyperlink on the given **view line** (so it accounts for wrapping) + and parses `lazygit-edit://:`. + +### The async render-task system (`pkg/tasks/tasks.go`) — the crux of our blocker + +Rendering a diff into a view is **asynchronous** and **lazy**: + +- A view has a `ViewBufferManager`. `RenderToMainViews` → a **cmd task** keyed + on the **command string**. +- The initial render reads only **`linesToReadFromCmdTask(view)` lines (one + screenful, ~37)**, then the task **waits** on its `readLines` channel for + more (e.g. when you scroll down, `ViewSelectionController` requests more). +- `ViewBufferManager.ReadToEnd(then)` sends `{Total:-1, Then:then}` to + `readLines`; the loop reads to EOF, runs `onEndOfInput`, then calls `then`. + **But** if `self.readLines == nil` (no live task), `ReadToEnd` calls `then()` + **immediately/synchronously** — this is a premature-fire trap. +- A task's `readLines` is created **inside the task goroutine** (async), so + right after `Push`/render the channel may not exist yet. +- `onNewKey` (`view.SetOrigin(0,0)`) runs at task start **iff the key changed**. + Same command/key ⇒ origin preserved; different key ⇒ origin reset to top. +- `view.Reset()` (beforeStart) rewinds the write pointer; it does **not** reset + origin. `onEndOfInput` clamps origin if the new content is shorter. +- `MainViewController.openSearch` is the existing precedent that uses + `GetViewBufferManagerForView(view).ReadToEnd(func(){ OnUIThread(...) })` + — but it does so on a view that's **already focused with a live task**, which + is exactly the precondition we keep failing to establish. + +### Gocui view bits we used + +- `view.OriginY()` / `view.SetOrigin(x,y)` — scroll. `SetOrigin` clamps `<0` + only (not to content length). +- `view.SelectedLineIdx()` = `OriginY + CursorY` (absolute view-line). +- `view.FocusPoint(cx, cy, scrollIntoView)` — sets cursor to absolute `cy` + (`v.cy = cy - v.oy`); with `scrollIntoView` it adjusts origin via + `calculateNewOrigin`. **Returns early if `cy < 0 || cy > lineCount`** — so it + silently no-ops if the content isn't loaded that far. (This is why a deep + selection "doesn't take" when only a screenful is loaded.) +- `view.Highlight` / `view.HighlightInactive` — whether/how the selection is + drawn. `SimpleContext.HandleFocusLost` sets `Highlight=false` (so the + focused-main selection is cleared whenever the view loses focus). We added + `MainViewController.GetOnFocus` to reset `HighlightInactive=false` on the way + back in. + +--- + +## 4. The decided UX (don't relitigate without reason) + +- **Click = point at a line ⇒ select it.** Single-click sets/moves the + selection to the clicked line and does nothing else. **Double-click** = the + "activate/open" gesture ⇒ dive into staging/patch building for that line. + Clicking an unfocused view focuses **and** selects (one click → ready for + `e`/`G`/enter). `0` focuses with **no** selection (scroll mode) — because it + doesn't point at a line. +- **Escape from staging/patch-building should return to the focused main view + you came from**, showing the **same main-view content** again (fresh, not + stale), with the **same scroll position and selection**, and with the **main + view focused** (not the side panel). One `enter` in → one `esc` out. +- For commits/stash, "the same content" means the **whole-commit diff** you were + looking at — **not** a different focused main view (e.g. not the + commit-files file diff). Landing on a *different* focused main view was + explicitly rejected. +- "Stale content is out of the question" — when the underlying file changed + (e.g. after staging), the returned main view must re-render fresh. (We accept + that the selection may then be slightly off, since the diff changed — no fix + planned.) + +### Keybindings (focused main view, when a selection is showing) + +In `MainViewController.GetKeybindings`: `Universal.Select` (space) toggles +selection; `Universal.GoInto` (enter) dives in; `Universal.Edit` (`e`) edits; +`Commits.OpenPullRequestInBrowser` (`G`) opens the PR line; +`Universal.Return` (esc) hides selection / exits. `<`/`>` are goto top/bottom +(so `G` is free). + +--- + +## 5. The GitHub PR-line feature (working, committed `77157c5ad`) + +`MainViewController.openPullRequestForSelectedLine`: + +- URL form: `/changes/#diff-R`. + - `` = `DiffableContext.RefForAdjustingLineNumberInDiff()` of the + side panel (selected commit / the commit-files "to" ref). Using the + specific commit's view means the right-side line numbers match what's shown, + so **no `AdjustLineNumber` needed** here (unlike `e`). + - `relPath` = repo-relative path via + `filepath.Rel(RepoPaths.WorktreePath(), abs)` then `filepath.ToSlash`. + **The anchor is `sha256(relPath)` — exact bytes, forward slashes, original + case, no trailing newline.** (Verified empirically; the `#diff-…` hash is + SHA-256 of the new-file path. `R` = right/new side; `L` = left/old.) +- Branch resolution (`branchForPullRequest`): `commits` → `CheckedOutBranch`; + `subCommits` → `SubCommits.GetRef().RefName()`; `commitFiles` → recurse into + its parent context. GitHub-only (driven by `Model().PullRequestsMap`). + +### GOTCHA recorded to memory + +`WorktreePath()` vs `RepoPath()`: to make a working-tree path repo-relative use +`RepoPaths.WorktreePath()`, **not** `RepoPath()` — they differ in **linked +worktrees** (this dev setup uses `.worktrees/scratch`), and `RepoPath()` +silently produced the wrong relative path → wrong `sha256` anchor. See memory +`worktree-path-vs-repo-path`. + +--- + +## 6. THE IN-PROGRESS PROBLEM (where to resume) + +**Goal:** escaping staging/patch building that was entered from a focused main +view should return to that focused main view, fresh content, **scroll + +selection restored**, main view focused. + +### The mechanism we built (uncommitted) + +- `types.FocusedMainViewSnapshot { SidePanel, SidePanelSelectedLineIdx, + MainView, OriginY, SelectedLineIdx }` (`pkg/gui/types/context.go`). +- Stored on `PatchExplorerContext.focusedMainViewSnapshot` with + `Get/SetFocusedMainViewSnapshot` on the `IPatchExplorerContext` interface + (`pkg/gui/context/patch_explorer_context.go`). `nil` ⇒ entered the normal way + ⇒ plain `Pop()`. +- **Capture** at entry via `focusedMainViewSnapshot(c, mainViewName, sidePanel, + selectedLineIdx)` in `main_view_controller.go`, called at the **start** of + each `GetOnClickFocusedMainView` (files, commit-files, commits) **before** any + mutation that re-renders the main view. It records the side panel, its + selected line (so we can put it back — e.g. files-panel directory→file), the + main view context, and the main view's `OriginY` + selected line. +- **Thread** the snapshot through `FilesController.EnterFile(snapshot, opts)` + and `CommitFilesHelper.EnterCommitFile(node, snapshot, opts)`, which set it on + the `Staging`/`CustomPatchBuilder` context right as they push it (set on + *every* entry so it can't leak; `nil` for the normal flow). +- **Escape**: `helpers.EscapeFromPatchExplorer(c, ctx)` (shared by + `StagingController.Escape` and `PatchBuildingHelper.Escape`). If a snapshot is + present: restore the side panel's selection, `Push(SidePanel)`, + `Push(MainView)`, then on the next UI tick restore origin + selection. + Otherwise `Pop()`. + +### What works ✅ + +- Escape lands on the **focused main view** (main focused), content re-rendered. +- **Selection restore works perfectly when the original scroll was at/near the + top** (selection within the initially-loaded screenful). User confirmed this + "feels exactly as expected." +- The files/commit-files panels and the commits/stash "all the way out" routing + are correct. + +### What does NOT work ❌ (the remaining detail) + +When the focused main view was **scrolled down**: + +- The restored **scroll resets to the top**, and +- the **selection lands off by roughly the scroll amount** (≈ the original + `OriginY`). + +### Diagnosis (confirmed with debug logging — high confidence) + +The captured snapshot is correct (e.g. `originY=92 selectedLineIdx=144`). The +problem is purely **content not loaded far enough when we restore**: + +- On restore the view had **only the initial screenful (`height=37`)**. So + `SetOrigin(0,92)` can't really show line 92, and **`FocusPoint(0,144,false)` + returns early** (`144-92=52 > 37`), leaving the cursor where the render left + it → "off by the scroll amount." At `OriginY=0` everything fits in the + screenful, which is why the top case works. +- We need to **force the task to read down to (at least) the target line before + restoring**. `ReadToEnd` is the right primitive, but every attempt mis-timed + it against the async task lifecycle. + +### The "clobber" insight (why only commits/stash is hard) + +- **files / commit-files:** entering staging/patch-building renders into the + *Staging*/*PatchBuilding* view, so the `Main` view is **never touched** — its + content, scroll, and full loaded range survive. In principle exit there can be + "focus main + restore selection" with **no re-render and no loading**, because + everything is still there. (We did not specialize this yet — current code + re-renders uniformly.) +- **commits / stash:** entering goes through `SwitchToDiffFilesController.enter()` + which **pushes the commit-files panel**, and *that* renders the commit-files + diff **into the `Main` view** (a different command/key) — clobbering the + commit diff and resetting origin. So on exit we must **rebuild** the commit + diff from scratch (re-render), and that's where the async loading race lives. + +### Approaches tried and why each failed (don't repeat blindly) + +1. **`OnUIThread(restore)` only (no read).** Restore runs after one tick, but + only the screenful is loaded → scroll/deep-selection fail. *(This is the + current reverted "Version A" state: best UX so far — main focused, selection + works at top.)* +2. **`ReadToEnd(restore)` synchronously after the pushes.** `self.readLines` is + `nil` at that instant (task not set up yet) → `ReadToEnd` calls `restore()` + immediately → still `height=37`. +3. **Defer `ReadToEnd` one UI tick, with `Push(MainView)` already done.** + `ReadToEnd`'s `then` **never fired** → restore never ran → main not even + focused. Hypothesis: focusing the main view **stops the side panel's render + task**, so there's no live task to read. +4. **Reorder: keep side panel focused, `ReadToEnd`, then focus main + restore.** + `then` **still never fired**. So the "focus change kills the task" theory is + at best incomplete — even with the side panel focused, the task isn't reading + to end on our request. **This is the live mystery.** + +### Current code state + +Reverted to approach (1) ("Version A"): `EscapeFromPatchExplorer` does +`Push(SidePanel)` → `Push(MainView)` → `OnUIThread`(SetOrigin + FocusPoint + +Highlight). Debug logging removed. Main focused; selection good at top; scroll +not restored when scrolled. + +### Concrete next steps to investigate + +- **Understand the task lifecycle precisely** in `pkg/tasks/tasks.go`: when does + `self.readLines` become the *new* task's channel after `Push`? Does deactivating + a context (`ContextMgr.deactivate` / `HandleFocusLost`) **stop** the + main view's render task? Does pushing a second context create a second task on + the `Main` view that stops the first? +- Re-add the temporary logging (snapshot values; `manager nil?`; "ReadToEnd then + fired, height=…"; "restore before/after oy/sel/height") and trace **which task + is live and whether/when its `then` fires** in each of the four approaches. +- Strongly consider the **"avoid the clobber"** route (approach 2 in §6's + options below): if entering patch building from the commits focused main view + did **not** overwrite the `Main` view, exit would need no re-render at all and + the whole async problem disappears. Open question: can we push the commit-files + panel as the side panel **without** it rendering into the `Main` view (or + render it elsewhere), given they share the `"main"` window? +- Compare with how `MainViewController.openSearch` successfully uses + `ReadToEnd` — replicate its precondition (a single, already-live task on the + view) rather than reading right after a `Push`. + +### Options on the table (we paused to choose) + +1. Find/learn the right loading primitive: "render to main, wait until loaded + through line N, then set origin+cursor," that survives the focus dance. +2. Avoid the re-render for commits by not clobbering the `Main` view on the way + in (then exit is the easy files/commit-files path). +3. Scope down: ship "focus main + re-render + restore selection when within the + loaded region," accept that deep scroll resets to top; revisit later. + +The user wants to **persevere on (1)/(2) together** — they don't know the task +system much better than we reconstructed it here, so it's genuinely joint +exploration. (3) is the fallback. + +--- + +## 7. Prototype enhancements still missing (for an "enhance" session) + +- **Directory case follow-up:** entering staging from a files/commit-files + **directory** selection expands the tree and changes the selection to the + clicked file. We restore the side panel's selected line on exit + (`SidePanelSelectedLineIdx`) so the main view shows the directory's combined + diff again — but we **don't restore the tree's expanded/collapsed state**, so + the panel comes back more expanded than it was. Decide whether to restore that + too. Also, the directory case shares the scroll/selection-restore bug above. +- **`onClickInOtherViewOfMainViewPair`** (clicking the other pane of a main + view pair) now also selects + double-click-stages for consistency; double-check + this is desired and that the secondary-pane paths behave. +- **Stale selection after stage/unstage:** explicitly accepted as out of scope; + no fix planned. +- **No integration tests** exist for any of the focused-main-view interactions + (click/double-click/`enter`/`e`/`G`/escape-restore). They were skipped on + purpose during prototyping. + +--- + +## 8. Productionization notes (for a future planning session — do NOT plan yet) + +Context a planning session will need: + +- **Commit history needs rework.** Two `WIP` commits (`673b90c10` "esc goes all + the way back out", `30e625a8d` "New click behavior") plus the large + uncommitted escape/restore change. AGENTS.md (this repo) mandates: small, + self-contained, compiling, `gofumpt`-clean commits; "why not what" messages; + prep-refactors split from behavior changes; `fixup!`/`amend!` against the + right commit and `git rebase --autosquash`; no conventional-commit prefixes. + The escape/restore work especially will want to be re-sequenced into clean + commits (and the `escapeContext` → `FocusedMainViewSnapshot` evolution + collapsed, since it was iterated heavily). +- **Demonstrate-bugs-before-fixing** pattern (AGENTS.md) with `EXPECTED`/`ACTUAL` + — relevant if any of this lands as bug-fix-shaped commits. +- **Tests:** integration tests live under `pkg/integration/tests/...`; conventions + in AGENTS.md (chain `t.Views().()` fluently, no local view vars; use + `stretchr/testify`). A unit-testable seam worth noting: the scroll/selection + restore and the GitHub-anchor URL builder + (`githubPullRequestLineURL`) are pure-ish and could be unit-tested; the patch + index↔view line wrapping logic lives in `pkg/gui/patch_exploring/state.go`. +- **Config:** `gui.showSelectionInFocusedMainView` was added then **removed** + (`c4aba31c9`) in favor of on-demand selection — don't reintroduce a config + toggle for this without reason. +- **Commands:** use the `justfile` recipes (`just generate` regenerates the test + list + cheatsheets and CI fails if stale; `just format`, `just build`, + `just unit-test`, `just e2e-all`, `just lint`). Prefer `just` over `make`. + Adding/renaming a keybinding ⇒ run `just generate` and commit the result + (note: gated descriptions — the focused-main bindings use empty descriptions + when no selection is shown, so they don't appear in cheatsheets, matching the + existing `enter` binding). +- The unrelated `M AGENTS.md` in the working tree is the "Common commands" + section documenting `just` — keep or commit separately. + +--- + +## 9. Key files (quick map) + +- `pkg/gui/controllers/main_view_controller.go` — the focused main view + controller: keybindings, `toggleSelection`, `enter`/`enterForLine`, `editLine`, + `openPullRequestForSelectedLine`, `branchForPullRequest`, click handlers, + `showSelectionAtLine`, `focusedMainViewContextForViewName`, + `focusedMainViewSnapshot`, `githubPullRequestLineURL`. +- `pkg/gui/controllers/switch_to_focused_main_view_controller.go` — focuses the + main view from a side panel (`0` / click); click passes a line so it selects, + `0` passes -1 so it doesn't. +- `pkg/gui/controllers/switch_to_diff_files_controller.go` — commits/stash → + patch building entry (`GetOnClickFocusedMainView`, `enter`). +- `pkg/gui/controllers/files_controller.go` — files → staging entry + (`GetOnClickFocusedMainView`, `EnterFile`). +- `pkg/gui/controllers/commits_files_controller.go` — commit-files → patch + building entry. +- `pkg/gui/controllers/helpers/commit_files_helper.go` — `EnterCommitFile`. +- `pkg/gui/controllers/helpers/patch_building_helper.go` — `Escape` + + `EscapeFromPatchExplorer` (the shared escape/restore logic). +- `pkg/gui/controllers/staging_controller.go` — `Escape` (calls + `EscapeFromPatchExplorer`). +- `pkg/gui/context/patch_explorer_context.go` — `FocusedMainViewSnapshot` + storage. +- `pkg/gui/types/context.go` — `FocusedMainViewSnapshot`, `IPatchExplorerContext` + additions. +- `pkg/gui/controllers/helpers/staging_helper.go` — + `GetFileAndLineForClickedDiffLine` (hyperlink parsing). +- `pkg/tasks/tasks.go` — the async render-task system (`ViewBufferManager`, + `ReadToEnd`, the read loop) — **the thing to master to finish §6**. +- `pkg/gui/tasks_adapter.go` — string/cmd task wrappers and the origin-reset + callbacks. From 788d959adf0da101e5dc80ced32da0c2a576b619 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Jun 2026 10:41:04 +0200 Subject: [PATCH 15/24] Lock the view and guard the line index when reading a hyperlink HyperLinkInLine read v.lines/v.viewLines without holding writeMutex, so it could race a concurrent re-render rebuilding the buffer. It also indexed v.lines by viewLines[y].linesY after only checking y against len(viewLines); since refreshViewLinesIfNeeded overwrites viewLines in place without truncating, the tail can hold stale entries pointing past a shrunk v.lines, giving an out-of-range panic while a shorter diff is still loading. Take writeMutex (as the sibling view methods do) and bounds-check linesY against len(v.lines), returning "no link" rather than panicking. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/view.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 59ea79e6a2c..01b408ea75c 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -1511,11 +1511,26 @@ func (v *View) Word(x, y int) (string, bool) { } func (v *View) HyperLinkInLine(y int, urlScheme string) (string, bool) { + // Take the lock so we don't race a concurrent re-render that is rebuilding the + // buffer. + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + v.refreshViewLinesIfNeeded() + if y < 0 || y >= len(v.viewLines) { return "", false } - for _, c := range v.lines[v.viewLines[y].linesY] { + // refreshViewLinesIfNeeded overwrites viewLines in place without truncating, + // so while a shorter re-render is loading, the tail of viewLines can still + // hold stale entries pointing past the (shrunk) v.lines. Guard against that. + linesY := v.viewLines[y].linesY + if linesY >= len(v.lines) { + return "", false + } + + for _, c := range v.lines[linesY] { if strings.HasPrefix(c.hyperlink, urlScheme) { return c.hyperlink, true } From b7470af272562e6e806d8521077eda35204e5196 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Jun 2026 10:43:21 +0200 Subject: [PATCH 16/24] Don't scroll a view up to fill blank space while its content is loading The layout scrolls a view up if its origin is past the bottom of its content, to avoid showing blank space (e.g. after a resize). But it measures content height by the lines loaded so far, and command/pty tasks load asynchronously. So when a view is re-rendered while scrolled down, the layout would yank it to the top because only a fraction of the content has been read yet, then leave it there once loading finished. Track whether a command task is actively reading (set synchronously when the task is created, so a layout pass in between sees it; cleared at EOF, but not when stopped, since that means a newer task is taking over) and skip the scroll-up clamp for such views. onEndOfInput already re-clamps once loading completes. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/layout.go | 8 +++++++- pkg/gui/pty.go | 6 ++++++ pkg/gui/tasks_adapter.go | 4 ++++ pkg/tasks/tasks.go | 26 ++++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index dacd93f68bc..4e310a32411 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -83,7 +83,13 @@ func (gui *Gui) layout(g *gocui.Gui) error { if !view.CanScrollPastBottom { maxOriginY -= newHeight - 1 } - if oldOriginY := view.OriginY(); oldOriginY > maxOriginY { + // Don't scroll up while the view's content is still being loaded: its + // height only reflects what has been read so far, so clamping to it now + // would yank the view to the top even though more content is on the way + // (e.g. when re-rendering a diff the user was scrolled into). + manager := gui.getViewBufferManagerForView(view) + stillLoading := manager != nil && manager.IsLoading() + if oldOriginY := view.OriginY(); oldOriginY > maxOriginY && !stillLoading { view.ScrollUp(oldOriginY - maxOriginY) // the view might not have scrolled actually (if it was at the limit // already), so we need to check if it did diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index f6356b9c0a4..c489185bea5 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -54,6 +54,12 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error return gui.newCmdTask(view, cmd, prefix) } + // Mark the view as loading synchronously now, before the layout pass: the + // actual task is created in afterLayout (below), which runs after layout, so + // without this the next layout pass would clamp the scroll position to the + // not-yet-loaded content. + gui.getManager(view).StartLoading() + // Run the pty after layout so that it gets the correct size gui.afterLayout(func() error { // Need to get the width and the pager again because the layout might have diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 3bfc6410002..25b2325a4a5 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -17,6 +17,10 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error ).Debug("RunCommand") manager := gui.getManager(view) + // Mark the view as loading synchronously (before the task's goroutine runs + // and before the next layout pass) so the layout doesn't clamp the scroll + // position to the not-yet-loaded content. + manager.StartLoading() var r io.ReadCloser start := func() (*exec.Cmd, io.Reader) { diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 2aa1e00fe06..1e76e972844 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -8,6 +8,7 @@ import ( "os/exec" "strconv" "sync" + "sync/atomic" "time" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" @@ -45,6 +46,11 @@ type ViewBufferManager struct { taskKey string onNewKey func() + // Whether a command task is currently reading content into the view. While + // this is true the content is still growing, so callers (e.g. the layout) + // must not clamp the view's scroll position to the amount loaded so far. + loading atomic.Bool + // beforeStart is the function that is called before starting a new task beforeStart func() refreshView func() @@ -109,6 +115,21 @@ func (self *ViewBufferManager) ReadLines(n int) { } } +// IsLoading reports whether a command task is currently reading content into the +// view, meaning the content is still growing. +func (self *ViewBufferManager) IsLoading() bool { + return self.loading.Load() +} + +// StartLoading marks the view as loading content. It must be called +// synchronously when a command/pty task is started, before the task's goroutine +// runs, so that a layout pass happening in between doesn't clamp the scroll +// position to the not-yet-loaded content. It is cleared when the task reaches +// the end of its input. +func (self *ViewBufferManager) StartLoading() { + self.loading.Store(true) +} + func (self *ViewBufferManager) ReadToEnd(then func()) { if self.readLines != nil { go utils.Safe(func() { @@ -297,6 +318,11 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p // if we're here then there's nothing left to scan from the source // so we're at the EOF and can flush the stale content self.onEndOfInput() + // The content is fully loaded now, so it's safe again for the + // layout to clamp the scroll position to it. We deliberately + // don't clear this when stopped (rather than EOF'd), because that + // means a newer task is taking over and is still loading. + self.loading.Store(false) callThen() break outer } From 86f4b348601f64c97688e50f614979f29ffad9ca Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Jun 2026 10:43:43 +0200 Subject: [PATCH 17/24] Fire queued ReadToEnd callbacks when the initial read reaches EOF A task's read loop processes one LinesToRead request at a time. The initial request has a large line count and no Then callback; if the content is shorter than that, the loop hits EOF on the initial request and breaks out, abandoning any further requests still sitting in the readLines channel. So a ReadToEnd call that races a still-loading-but-shorter-than-its-initial-read view has its Then silently dropped: it isn't fired immediately (the channel was non-nil at call time) and it's never dequeued. On EOF, drain the queued requests and fire their Then callbacks before breaking out, since reaching EOF trivially satisfies any "read more" request. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 1e76e972844..c3852b9d292 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -324,6 +324,21 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p // means a newer task is taking over and is still loading. self.loading.Store(false) callThen() + // Any read requests that were queued while we were reading are + // now trivially satisfied, since we've read everything. Fire + // their callbacks instead of dropping them when we break out of + // the loop below (and nil out readLines). + drain: + for { + select { + case queued := <-self.readLines: + if queued.Then != nil { + queued.Then() + } + default: + break drain + } + } break outer } writeToView(append(line, '\n')) From 89e6f6b1422889b0fbb6f0cd6db30d03bbeec73a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Jun 2026 10:48:27 +0200 Subject: [PATCH 18/24] Session notes: corrected flicker diagnosis and the 3 bug fixes Records session 2's findings: the escape flicker was caused by the layout's scroll-up-to-fill clamp running against partially-loaded async content (not the "only a screenful loaded" mechanism session 1 guessed); the full origin-reset chain (onNewKey / CopyContent / layout clamp) and how each is handled; the three standalone bug fixes that landed; and the precise remaining task (apply the saved origin at the pty task's first repaint, i.e. a cmd-task RenderWithScroll). Also captures the reusable debug tooling that was stripped from the tree. Co-Authored-By: Claude Opus 4.8 (1M context) --- focused-main-view-notes.md | 304 +++++++++++++++++++++---------------- 1 file changed, 177 insertions(+), 127 deletions(-) diff --git a/focused-main-view-notes.md b/focused-main-view-notes.md index 6644ac8600e..b1c179eced1 100644 --- a/focused-main-view-notes.md +++ b/focused-main-view-notes.md @@ -11,10 +11,15 @@ session. It is meant as a **starting point for future sessions**, which might: tests. (We did *not* make that plan; this doc gives a future session enough context to make it.) -> Status at end of session: branch `use-delta-hyperlinks-for-clicking-in-diff`, -> with a pile of **uncommitted** changes implementing the escape/restore work -> (see "Uncommitted work" below). The tree builds (`just build`), is -> `gofumpt`-clean, and `just vet` passes. +> Status at end of **session 2** (the latest): branch +> `use-delta-hyperlinks-for-clicking-in-diff`. The escape/restore work that was +> uncommitted at the end of session 1 is now committed (`d901a9711` "WIP +> FocusedMainViewSnapshot approach"). Session 2 dug into the **flicker on +> escape** and landed three standalone bug fixes (see "§6" and the new commits +> on top of `e5326c3a6`); the remaining flicker is understood but not yet fully +> solved, and a small pile of **uncommitted** feature machinery +> (`keepOrigin` + the `ReadToEnd`-based restore) is left in the working tree. +> The tree builds (`just build`), is `gofumpt`-clean, and the unit tests pass. --- @@ -241,132 +246,108 @@ silently produced the wrong relative path → wrong `sha256` anchor. See memory view should return to that focused main view, fresh content, **scroll + selection restored**, main view focused. -### The mechanism we built (uncommitted) +### The mechanism (now committed + a little still uncommitted) - `types.FocusedMainViewSnapshot { SidePanel, SidePanelSelectedLineIdx, MainView, OriginY, SelectedLineIdx }` (`pkg/gui/types/context.go`). -- Stored on `PatchExplorerContext.focusedMainViewSnapshot` with - `Get/SetFocusedMainViewSnapshot` on the `IPatchExplorerContext` interface - (`pkg/gui/context/patch_explorer_context.go`). `nil` ⇒ entered the normal way - ⇒ plain `Pop()`. -- **Capture** at entry via `focusedMainViewSnapshot(c, mainViewName, sidePanel, - selectedLineIdx)` in `main_view_controller.go`, called at the **start** of - each `GetOnClickFocusedMainView` (files, commit-files, commits) **before** any - mutation that re-renders the main view. It records the side panel, its - selected line (so we can put it back — e.g. files-panel directory→file), the - main view context, and the main view's `OriginY` + selected line. -- **Thread** the snapshot through `FilesController.EnterFile(snapshot, opts)` - and `CommitFilesHelper.EnterCommitFile(node, snapshot, opts)`, which set it on - the `Staging`/`CustomPatchBuilder` context right as they push it (set on - *every* entry so it can't leak; `nil` for the normal flow). -- **Escape**: `helpers.EscapeFromPatchExplorer(c, ctx)` (shared by - `StagingController.Escape` and `PatchBuildingHelper.Escape`). If a snapshot is - present: restore the side panel's selection, `Push(SidePanel)`, - `Push(MainView)`, then on the next UI tick restore origin + selection. - Otherwise `Pop()`. - -### What works ✅ - -- Escape lands on the **focused main view** (main focused), content re-rendered. -- **Selection restore works perfectly when the original scroll was at/near the - top** (selection within the initially-loaded screenful). User confirmed this - "feels exactly as expected." -- The files/commit-files panels and the commits/stash "all the way out" routing - are correct. - -### What does NOT work ❌ (the remaining detail) - -When the focused main view was **scrolled down**: - -- The restored **scroll resets to the top**, and -- the **selection lands off by roughly the scroll amount** (≈ the original - `OriginY`). - -### Diagnosis (confirmed with debug logging — high confidence) - -The captured snapshot is correct (e.g. `originY=92 selectedLineIdx=144`). The -problem is purely **content not loaded far enough when we restore**: - -- On restore the view had **only the initial screenful (`height=37`)**. So - `SetOrigin(0,92)` can't really show line 92, and **`FocusPoint(0,144,false)` - returns early** (`144-92=52 > 37`), leaving the cursor where the render left - it → "off by the scroll amount." At `OriginY=0` everything fits in the - screenful, which is why the top case works. -- We need to **force the task to read down to (at least) the target line before - restoring**. `ReadToEnd` is the right primitive, but every attempt mis-timed - it against the async task lifecycle. - -### The "clobber" insight (why only commits/stash is hard) - -- **files / commit-files:** entering staging/patch-building renders into the - *Staging*/*PatchBuilding* view, so the `Main` view is **never touched** — its - content, scroll, and full loaded range survive. In principle exit there can be - "focus main + restore selection" with **no re-render and no loading**, because - everything is still there. (We did not specialize this yet — current code - re-renders uniformly.) -- **commits / stash:** entering goes through `SwitchToDiffFilesController.enter()` - which **pushes the commit-files panel**, and *that* renders the commit-files - diff **into the `Main` view** (a different command/key) — clobbering the - commit diff and resetting origin. So on exit we must **rebuild** the commit - diff from scratch (re-render), and that's where the async loading race lives. - -### Approaches tried and why each failed (don't repeat blindly) - -1. **`OnUIThread(restore)` only (no read).** Restore runs after one tick, but - only the screenful is loaded → scroll/deep-selection fail. *(This is the - current reverted "Version A" state: best UX so far — main focused, selection - works at top.)* -2. **`ReadToEnd(restore)` synchronously after the pushes.** `self.readLines` is - `nil` at that instant (task not set up yet) → `ReadToEnd` calls `restore()` - immediately → still `height=37`. -3. **Defer `ReadToEnd` one UI tick, with `Push(MainView)` already done.** - `ReadToEnd`'s `then` **never fired** → restore never ran → main not even - focused. Hypothesis: focusing the main view **stops the side panel's render - task**, so there's no live task to read. -4. **Reorder: keep side panel focused, `ReadToEnd`, then focus main + restore.** - `then` **still never fired**. So the "focus change kills the task" theory is - at best incomplete — even with the side panel focused, the task isn't reading - to end on our request. **This is the live mystery.** - -### Current code state - -Reverted to approach (1) ("Version A"): `EscapeFromPatchExplorer` does -`Push(SidePanel)` → `Push(MainView)` → `OnUIThread`(SetOrigin + FocusPoint + -Highlight). Debug logging removed. Main focused; selection good at top; scroll -not restored when scrolled. - -### Concrete next steps to investigate - -- **Understand the task lifecycle precisely** in `pkg/tasks/tasks.go`: when does - `self.readLines` become the *new* task's channel after `Push`? Does deactivating - a context (`ContextMgr.deactivate` / `HandleFocusLost`) **stop** the - main view's render task? Does pushing a second context create a second task on - the `Main` view that stops the first? -- Re-add the temporary logging (snapshot values; `manager nil?`; "ReadToEnd then - fired, height=…"; "restore before/after oy/sel/height") and trace **which task - is live and whether/when its `then` fires** in each of the four approaches. -- Strongly consider the **"avoid the clobber"** route (approach 2 in §6's - options below): if entering patch building from the commits focused main view - did **not** overwrite the `Main` view, exit would need no re-render at all and - the whole async problem disappears. Open question: can we push the commit-files - panel as the side panel **without** it rendering into the `Main` view (or - render it elsewhere), given they share the `"main"` window? -- Compare with how `MainViewController.openSearch` successfully uses - `ReadToEnd` — replicate its precondition (a single, already-live task on the - view) rather than reading right after a `Push`. - -### Options on the table (we paused to choose) - -1. Find/learn the right loading primitive: "render to main, wait until loaded - through line N, then set origin+cursor," that survives the focus dance. -2. Avoid the re-render for commits by not clobbering the `Main` view on the way - in (then exit is the easy files/commit-files path). -3. Scope down: ship "focus main + re-render + restore selection when within the - loaded region," accept that deep scroll resets to top; revisit later. - -The user wants to **persevere on (1)/(2) together** — they don't know the task -system much better than we reconstructed it here, so it's genuinely joint -exploration. (3) is the fallback. +- Stored on `PatchExplorerContext.focusedMainViewSnapshot` (`nil` ⇒ entered the + normal way ⇒ plain `Pop()`), captured at entry in `focusedMainViewSnapshot(…)` + (`main_view_controller.go`), threaded through `FilesController.EnterFile` / + `CommitFilesHelper.EnterCommitFile`. All of this is committed in `d901a9711`. +- **Escape**: `helpers.EscapeFromPatchExplorer(c, ctx)` restores the side panel's + selection, `Push(SidePanel)`, `Push(MainView)`, then restores origin + + selection. The current version of this (the `ReadToEnd`-based restore plus the + `keepOrigin` machinery below) is the **uncommitted** part left in the working + tree — it's WIP because the flicker isn't fully solved. + +### Where session 2 landed: the flicker is fully diagnosed; 3 bug fixes committed + +Restoring scroll + selection on escape **works** (the final state is correct). +What remained was a **flicker on the way in**: a brief intermediate frame before +the view settles at the saved position. Chasing it uncovered three genuine, +independent bugs (all now committed on top of `e5326c3a6`): + +1. **`6c7d9a295` Lock the view + guard the line index in `HyperLinkInLine`.** + It read `v.lines`/`v.viewLines` with no `writeMutex`, racing a concurrent + re-render, and indexed `v.lines[viewLines[y].linesY]` after only checking `y` + against `len(viewLines)`. Because `refreshViewLinesIfNeeded` overwrites + `viewLines` *in place without truncating*, the tail keeps stale entries + whose `linesY` points past a shrunk `v.lines` → out-of-range panic on `enter` + while a shorter diff was still loading. +2. **`3b31cfe01` Don't scroll a view up to fill blank space while loading.** + The layout's scroll-up clamp ([`layout.go`], added in `6114f69ee5ef`) clamps + a view's origin to `TotalContentHeight()` — which for a main view is just the + **lines loaded so far**. During an async re-render that's a fraction of the + eventual content, so it yanked the view to the top. Fix: a synchronously-set + `ViewBufferManager.loading` flag (set in the cmd/pty wrappers *before* the + layout pass, cleared at EOF but **not** on stop), and the layout skips the + clamp while loading. +3. **`a4b72a6f6` Fire queued `ReadToEnd` callbacks when the initial read hits + EOF.** The read loop processes one request at a time; the initial request has + no `Then` and a large line count, so if the content is shorter it hits EOF on + that request and `break`s out, abandoning any queued `ReadToEnd` request in + the channel → its `Then` silently dropped (this was session 1's "ReadToEnd's + `then` never fired" mystery!). Fix: drain queued requests and fire their + `Then`s on EOF. + +### Corrected diagnosis (session 1's §6 diagnosis was WRONG in its mechanism) + +Session 1 said "on restore only the initial screenful (`height=37`) is loaded, +so `FocusPoint` returns early." **That was inaccurate.** The truth, confirmed by +instrumenting **every** write to the main view's `oy` (see Debug tooling §10): + +- `linesToReadFromCmdTask` reads `height*(height-1)+oy` lines (≈1332+, capped at + 5000) — **not** one screenful. For typical diffs the whole thing loads quickly. +- The scroll wasn't failing because content was unloaded at *restore* time (the + `ReadToEnd` restore, once the drain fix above made it fire, sets the final + position correctly). It was failing because the **layout clamp** (bug #2) was + resetting `oy` to 0 on *every layout pass* during the async load, until the + content caught up. That is the real cause of "scroll resets to the top." + +### The full origin-reset chain on escape (and how each is handled now) + +Tracing every `oy` write during a commits-scrolled-down escape, three different +things were all moving the origin off the saved value: + +1. **`onNewKey`** (`tasks_adapter.go`) resets `oy` to 0 when the re-render's + command key differs from the last one (it does, because the commit-files + render clobbered the main view on entry). → handled by + `ViewBufferManager.KeepOriginForNextTask()` (uncommitted feature machinery), + which suppresses that one reset. +2. **`CopyContent`** (`view.go`, via `moveMainContextToTop`) copies the + *previous top view's* buffer **and origin** into the main view to avoid a + blank frame. → handled by re-asserting `SetOrigin(saved)` after the pushes. +3. **The layout scroll-up clamp** → handled by bug fix #2 (the `loading` flag). + +### The one remaining flicker (and the correct fix — not yet implemented) + +With all three handled, the *scroll no longer jumps*. But there's still a brief +intermediate frame, and we found exactly what it is: **`CopyContent` seeds the +main view with the patch-building view's buffer**, and since we set the origin +to the saved position (far down) while that placeholder is shorter, the draw +shows the placeholder's *last line* at the top with blank below — until the pty +task finishes loading the real diff and repaints at the saved position. (It +"appears scrolled up by a varying amount" purely because what shows at the saved +`oy` depends on the patch's *length*, via `min(oy, patchLines-1)`.) + +**NOTE — a rejected red herring:** "avoid clobbering the main view on entry" +does **not** fix this. `CopyContent` overwrites the main view's buffer +regardless of what was there, so preserving the original commit diff on entry +wouldn't change the placeholder frame. + +**The correct fix (user's conclusion, agreed):** we're applying the saved origin +*too early*. It must be applied *exactly* when the pty task does its first +repaint (when it has read enough to fill the view at the saved scroll). The +catch: `InitialRefreshAfter` — which decides *when* that first repaint happens — +is computed from the view's `OriginY` **at task-creation time**. So the target +origin must be known at creation (so the task reads enough), but the view must +keep showing the placeholder until that first paint, and only snap to the saved +position *as part of* that paint. Concretely: **a cmd/pty analogue of +`RenderStringWithScrollTask`** — "render this command and scroll to Y once +you've read enough" — applying the origin at the `InitialRefreshAfter` refresh +rather than up front. This is the concrete next step; it's bounded but real +work, and likely lets us drop the `keepOrigin` + after-push `SetOrigin` +machinery (they'd be subsumed by the task setting the origin itself). --- @@ -457,3 +438,72 @@ Context a planning session will need: `ReadToEnd`, the read loop) — **the thing to master to finish §6**. - `pkg/gui/tasks_adapter.go` — string/cmd task wrappers and the origin-reset callbacks. +- `pkg/gui/layout.go` — the scroll-up-to-fill clamp (`setViewFromDimensions`); + now skipped while a view's task `IsLoading()`. + +--- + +## 10. Debug tooling (stripped from the tree; paste back when needed) + +These two general-purpose debugging tools were invaluable in session 2 and were +removed from the working tree when cleaning up. They are recorded here so they +can be reapplied without re-deriving them. + +### Slow down rendering (`LAZYGIT_SLOW_RENDER=`) + +Stretches the async load so you can watch the frames of a re-render. Add to the +read goroutine in `ViewBufferManager.NewCmdTask` (`pkg/tasks/tasks.go`), just +before the `outer:` label, plus the per-line sleep inside the inner read loop +right after `lineWrittenChan <- struct{}{}`. Needs `os` and `strconv` imports. + +```go +// DEBUG: artificially slow down rendering so transitions are visible. +var slowRenderPerLine time.Duration +if v := os.Getenv("LAZYGIT_SLOW_RENDER"); v != "" { + if ms, err := strconv.Atoi(v); err == nil { + slowRenderPerLine = time.Duration(ms) * time.Millisecond + } +} +// ... and inside the inner loop, after lineWrittenChan <- struct{}{}: +if slowRenderPerLine > 0 { + time.Sleep(slowRenderPerLine) +} +``` + +Run as `LAZYGIT_SLOW_RENDER=20 just debug`. + +### Trace every change to a view's scroll position + +Catches *who* moves `oy` (the trick that finally found the layout clamp). Add to +`pkg/gocui/view.go` (needs `os`, `runtime` imports) and call +`debugMainOriginReset(v, )` immediately before **every** write to `v.oy`: +`SetOrigin`, `SetOriginY`, `CopyContent`, `FocusPoint` (the `calculateNewOrigin` +branch), the `Autoscroll` branch in `draw`, and `ScrollUp`/`ScrollDown`. Filter +by `v.name == "main"` (or whatever view you're chasing). + +```go +func debugMainOriginReset(v *View, newY int) { + if v.name != "main" || newY == v.oy { + return + } + pc := make([]uintptr, 6) + n := runtime.Callers(3, pc) + frames := runtime.CallersFrames(pc[:n]) + var b strings.Builder + for i := 0; i < 4; i++ { + fr, more := frames.Next() + fmt.Fprintf(&b, " <- %s:%d", fr.File[strings.LastIndex(fr.File, "/")+1:], fr.Line) + if !more { + break + } + } + if f, err := os.OpenFile("/tmp/fmvs_origin.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644); err == nil { + fmt.Fprintf(f, "main oy %d->%d%s\n", v.oy, newY, b.String()) + f.Close() + } +} +``` + +The full session-2 diff (including these and the per-feature `FMVS` `Log.Infof` +breadcrumbs) was also saved to `/tmp/fmv-session-full.patch` during the +cleanup — though `/tmp` is ephemeral, so this section is the durable copy. From fe79d18b63fb906df23df07c4bace90dbff86e49 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Jun 2026 14:33:58 +0200 Subject: [PATCH 19/24] Route all view origin writes through SetOriginX and SetOriginY Several methods assigned v.ox and v.oy directly: SetOrigin, CopyContent, the wrap/autoscroll branches in draw, FocusPoint, and Scroll{Up,Down,Left,Right}. Funnelling them all through SetOriginX and SetOriginY gives a single place to observe (or set a breakpoint on) every change to a view's scroll position, which makes debugging scroll behaviour much easier. This means those call sites now also get the setters' `< 0` clamps, but that is behaviour-preserving in every case: each assigned value is already >= 0. calculateNewOrigin never returns a negative number; CopyContent copies origins that are themselves always >= 0; and the draw and scroll writes are all guarded (or fed only non-negative amounts) so the result can't go below zero. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/view.go | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 01b408ea75c..eb4fb001124 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -383,7 +383,7 @@ func (v *View) FocusPoint(cx int, cy int, scrollIntoView bool) { if scrollIntoView { height := v.InnerHeight() - v.oy = calculateNewOrigin(cy, v.oy, lineCount, height) + v.SetOriginY(calculateNewOrigin(cy, v.oy, lineCount, height)) } v.cx = cx @@ -645,15 +645,8 @@ func (v *View) CursorY() int { // implement Horizontal and Vertical scrolling with just incrementing // or decrementing ox and oy. func (v *View) SetOrigin(x, y int) { - if x < 0 { - x = 0 - } - if y < 0 { - y = 0 - } - - v.ox = x - v.oy = y + v.SetOriginX(x) + v.SetOriginY(y) } func (v *View) SetOriginX(x int) { @@ -1062,8 +1055,8 @@ func (v *View) CopyContent(from *View) { v.lines = from.lines v.viewLines = from.viewLines - v.ox = from.ox - v.oy = from.oy + v.SetOriginX(from.ox) + v.SetOriginY(from.oy) v.cx = from.cx v.cy = from.cy } @@ -1228,14 +1221,14 @@ func (v *View) draw() { if maxX == 0 { return } - v.ox = 0 + v.SetOriginX(0) } v.refreshViewLinesIfNeeded() visibleViewLinesHeight := v.viewLineLengthIgnoringTrailingBlankLines() if v.Autoscroll && visibleViewLinesHeight > maxY { - v.oy = visibleViewLinesHeight - maxY + v.SetOriginY(visibleViewLinesHeight - maxY) } if len(v.viewLines) == 0 { @@ -1884,7 +1877,7 @@ func (v *View) ScrollUp(amount int) { } if amount != 0 { - v.oy -= amount + v.SetOriginY(v.oy - amount) v.cy += amount v.clearHover() @@ -1896,7 +1889,7 @@ func (v *View) ScrollUp(amount int) { func (v *View) ScrollDown(amount int) { adjustedAmount := v.adjustDownwardScrollAmount(amount) if adjustedAmount > 0 { - v.oy += adjustedAmount + v.SetOriginY(v.oy + adjustedAmount) v.cy -= adjustedAmount v.clearHover() @@ -1910,7 +1903,7 @@ func (v *View) ScrollLeft(amount int) { newOx = 0 } if newOx != v.ox { - v.ox = newOx + v.SetOriginX(newOx) v.clearHover() } @@ -1918,7 +1911,7 @@ func (v *View) ScrollLeft(amount int) { // not applying any limits to this func (v *View) ScrollRight(amount int) { - v.ox += amount + v.SetOriginX(v.ox + amount) v.clearHover() } From 7f547a5a307074dff0be7372accb567efc984199 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Jun 2026 17:03:58 +0200 Subject: [PATCH 20/24] Reset other main views' scroll after copying content, not before MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshMainViews reset the scroll position of every other main view at the very top, before moveMainContextPairToTop runs its CopyContent. CopyContent copies the previously-shown view's content into the now-visible one to avoid a blank frame during the async re-render — but because the reset ran first, it had already zeroed the origin of that soon-to-be-copied source view. The placeholder therefore always appeared scrolled to the top, jumping away from wherever the screen actually was, on every cross-pair transition. Move the reset to after the copy. The end state is unchanged (each other main view still ends at origin 0, and the destination always re-renders), but the brief placeholder now stays at the source view's real scroll position until the real content paints. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/main_panels.go | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/pkg/gui/main_panels.go b/pkg/gui/main_panels.go index 82f4fcac0bc..a90c68bca7a 100644 --- a/pkg/gui/main_panels.go +++ b/pkg/gui/main_panels.go @@ -107,16 +107,6 @@ func (gui *Gui) allMainContextPairs() []types.MainContextPair { } func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) { - // need to reset scroll positions of all other main views - for _, pair := range gui.allMainContextPairs() { - if pair.Main != opts.Pair.Main { - pair.Main.GetView().SetOrigin(0, 0) - } - if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary { - pair.Secondary.GetView().SetOrigin(0, 0) - } - } - if opts.Main != nil { gui.RefreshMainView(opts.Main, opts.Pair.Main) } @@ -129,6 +119,20 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) { gui.moveMainContextPairToTop(opts.Pair) + // Reset the scroll positions of all the other main views. We do this after + // moving this pair to the top (which copies the previously-shown view's + // content into the now-visible one to avoid a blank frame): resetting first + // would zero that source view's scroll before it gets copied, forcing the + // placeholder to the top instead of leaving it where the screen already was. + for _, pair := range gui.allMainContextPairs() { + if pair.Main != opts.Pair.Main { + pair.Main.GetView().SetOrigin(0, 0) + } + if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary { + pair.Secondary.GetView().SetOrigin(0, 0) + } + } + gui.splitMainPanel(opts.Secondary != nil) } From 054d139fed39c4fec9ff671100390dde6876ba08 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Jun 2026 17:04:08 +0200 Subject: [PATCH 21/24] Let a cmd/pty task restore a saved scroll position at its first paint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When re-rendering content the user was already scrolled into, we want the saved scroll position applied exactly when the real content first paints — not before. Setting the origin up front instead paints it onto whatever placeholder is currently in the view (e.g. the shorter buffer CopyContent left there), which flickers: either a blank frame past the placeholder's end, or a jump to the top when the task resets the origin at startup. Add ViewBufferManager.ScrollToOriginYForNextTask: the next cmd/pty task then (a) does not reset the view to the top at startup even though the command key changed, so the placeholder stays put, (b) sizes its initial read to the saved position so enough content is loaded to fill the view there, and (c) scrolls to it as part of the first refresh, in the same paint that shows the real content. This is the cmd/pty analogue of RenderStringWithScrollTask. No caller sets it yet, so this is behaviour-preserving on its own. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/pty.go | 7 +++- pkg/gui/tasks_adapter.go | 7 +++- pkg/gui/view_helpers.go | 16 +++++++++- pkg/tasks/tasks.go | 69 +++++++++++++++++++++++++++++++++++++--- pkg/tasks/tasks_test.go | 16 +++++----- 5 files changed, 100 insertions(+), 15 deletions(-) diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index c489185bea5..a4ef9cc44e2 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -60,6 +60,11 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error // not-yet-loaded content. gui.getManager(view).StartLoading() + // Read any requested scroll-restore now so we can size the initial read to it + // in afterLayout; the task itself clears the request and applies the scroll at + // its first paint. + targetOriginY := gui.getManager(view).GetScrollToOriginYForNextTask() + // Run the pty after layout so that it gets the correct size gui.afterLayout(func() error { // Need to get the width and the pager again because the layout might have @@ -102,7 +107,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error gui.Mutexes.PtyMutex.Unlock() } - linesToRead := gui.linesToReadFromCmdTask(view) + linesToRead := gui.linesToReadFromCmdTask(view, targetOriginY) return manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr) }) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 25b2325a4a5..214f1399e6a 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -22,6 +22,11 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error // position to the not-yet-loaded content. manager.StartLoading() + // If a caller asked us to restore a scroll position for this render, size the + // initial read to it (below) and let the task scroll there at its first paint. + // The task clears the request and suppresses the origin reset when it starts. + targetOriginY := manager.GetScrollToOriginYForNextTask() + var r io.ReadCloser start := func() (*exec.Cmd, io.Reader) { var err error @@ -46,7 +51,7 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error } } - linesToRead := gui.linesToReadFromCmdTask(view) + linesToRead := gui.linesToReadFromCmdTask(view, targetOriginY) if err := manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr); err != nil { gui.c.Log.Error(err) } diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 453ccd6c932..b760bdac7f7 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -19,9 +19,22 @@ func (gui *Gui) resetViewOrigin(v *gocui.View) { // Returns the number of lines that we should read initially from a cmd task so // that the scrollbar has the correct size, along with the number of lines after // which the view is filled and we can do a first refresh. -func (gui *Gui) linesToReadFromCmdTask(v *gocui.View) tasks.LinesToRead { +// +// If targetOriginY is non-nil, the read is sized to that scroll position rather +// than the view's current one, and the returned LinesToRead carries an +// ApplyInitialScroll that scrolls the view there at the first refresh. This is +// used when re-rendering content the user was already scrolled into, so the +// saved position is applied exactly when the content first paints. +func (gui *Gui) linesToReadFromCmdTask(v *gocui.View, targetOriginY *int) tasks.LinesToRead { height := v.InnerHeight() oy := v.OriginY() + var applyInitialScroll func() + if targetOriginY != nil { + oy = *targetOriginY + applyInitialScroll = func() { + v.SetOrigin(v.OriginX(), *targetOriginY) + } + } linesForFirstRefresh := height + oy + 10 @@ -37,6 +50,7 @@ func (gui *Gui) linesToReadFromCmdTask(v *gocui.View) tasks.LinesToRead { return tasks.LinesToRead{ Total: linesToReadForAccurateScrollbar, InitialRefreshAfter: linesForFirstRefresh, + ApplyInitialScroll: applyInitialScroll, } } diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index c3852b9d292..15fb2f37cfc 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -46,6 +46,22 @@ type ViewBufferManager struct { taskKey string onNewKey func() + // When non-nil, the next cmd/pty task restores this scroll position. Used + // when re-rendering content the user was already scrolled into (e.g. when + // returning to a focused main view on escape). It has two effects: + // + // - The task does not reset the view's origin to the top at start, even if + // its command key differs from the previous task's. This keeps the + // placeholder that CopyContent left in the view showing at its current + // scroll position (i.e. "as if nothing changed") until the real content + // is ready, rather than flicking it to the top. + // - The task sizes its initial read to this position and scrolls there as + // part of its first refresh, so the saved scroll is applied in the same + // paint that shows the real content. + // + // Cleared once the next task has started. + scrollToOriginYForNextTask *int + // Whether a command task is currently reading content into the view. While // this is true the content is still growing, so callers (e.g. the layout) // must not clamp the view's scroll position to the amount loaded so far. @@ -78,6 +94,12 @@ type LinesToRead struct { // subsequent requests. InitialRefreshAfter int + // When set, called once, just before the view is first refreshed, to scroll + // it to a saved position. Used so that content the user was scrolled into is + // painted at the saved scroll position the first time it appears, rather than + // at the top. Only set for the initial read request. + ApplyInitialScroll func() + // Function to call after reading the lines is done Then func() } @@ -115,6 +137,22 @@ func (self *ViewBufferManager) ReadLines(n int) { } } +// ScrollToOriginYForNextTask makes the next cmd/pty task restore the given +// scroll position instead of rendering at the top. Call this right before +// triggering a re-render of content the view is already scrolled into (e.g. +// when returning to a focused main view on escape). See the field doc for the +// two effects this has. It is cleared once the next task starts. +func (self *ViewBufferManager) ScrollToOriginYForNextTask(originY int) { + self.scrollToOriginYForNextTask = &originY +} + +// GetScrollToOriginYForNextTask returns the scroll position requested by a +// preceding ScrollToOriginYForNextTask call, or nil if none. It does not clear +// it; the task clears it when it starts. +func (self *ViewBufferManager) GetScrollToOriginYForNextTask() *int { + return self.scrollToOriginYForNextTask +} + // IsLoading reports whether a command task is currently reading content into the // view, meaning the content is still growing. func (self *ViewBufferManager) IsLoading() bool { @@ -271,6 +309,18 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p } } + // The initial read request carries an optional scroll-to function (see + // LinesToRead.ApplyInitialScroll). We apply it exactly once, right before + // the view is first refreshed, so that content the user was scrolled into + // is painted at the saved position the first time it appears. + initialScroll := linesToRead.ApplyInitialScroll + var applyInitialScrollOnce sync.Once + applyInitialScroll := func() { + if initialScroll != nil { + applyInitialScrollOnce.Do(initialScroll) + } + } + // Set LAZYGIT_SLOW_RENDER= to sleep that long after each // line is written to the view, stretching async loads out so the frames // of a re-render become visible. Useful for debugging scroll/flicker @@ -316,7 +366,10 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p if !ok { // if we're here then there's nothing left to scan from the source - // so we're at the EOF and can flush the stale content + // so we're at the EOF and can flush the stale content. Apply the + // saved scroll first (if any) so that onEndOfInput clamps it back + // into range when the new content turned out shorter than expected. + applyInitialScroll() self.onEndOfInput() // The content is fully loaded now, so it's safe again for the // layout to clamp the scroll position to it. We deliberately @@ -350,8 +403,11 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p if i+1 == linesToRead.InitialRefreshAfter { // We have read enough lines to fill the view, so do a first refresh - // here to show what we have. Continue reading and refresh again at - // the end to make sure the scrollbar has the right size. + // here to show what we have. Apply the saved scroll first (if any) + // so the first paint already lands at it. Continue reading and + // refresh again at the end to make sure the scrollbar has the right + // size. + applyInitialScroll() refreshViewIfStale() } } @@ -446,9 +502,14 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error self.newTaskID++ taskID := self.newTaskID - if self.GetTaskKey() != key && self.onNewKey != nil { + // Reset the origin to the top when the command changed, unless a caller + // asked us to restore a scroll position: in that case we keep the + // placeholder showing at its current scroll until the task scrolls to the + // saved position as part of its first paint (see scrollToOriginYForNextTask). + if self.GetTaskKey() != key && self.onNewKey != nil && self.scrollToOriginYForNextTask == nil { self.onNewKey() } + self.scrollToOriginYForNextTask = nil self.taskKey = key self.taskIDMutex.Unlock() diff --git a/pkg/tasks/tasks_test.go b/pkg/tasks/tasks_test.go index 40ff0033d6e..9942c059dcd 100644 --- a/pkg/tasks/tasks_test.go +++ b/pkg/tasks/tasks_test.go @@ -52,7 +52,7 @@ func TestNewCmdTaskInstantStop(t *testing.T) { return cmd, reader } - fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone) + fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{Total: 20, InitialRefreshAfter: -1}, onDone) _ = fn(TaskOpts{Stop: stop, InitialContentLoaded: func() { task.Done() }}) @@ -115,7 +115,7 @@ func TestNewCmdTask(t *testing.T) { return cmd, reader } - fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone) + fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{Total: 20, InitialRefreshAfter: -1}, onDone) wg := sync.WaitGroup{} wg.Go(func() { time.Sleep(100 * time.Millisecond) @@ -182,37 +182,37 @@ func TestNewCmdTaskRefresh(t *testing.T) { { "total < initialRefreshAfter", 150, - LinesToRead{100, 120, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 120}, []int{100}, }, { "total == initialRefreshAfter", 150, - LinesToRead{100, 100, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 100}, []int{100}, }, { "total > initialRefreshAfter", 150, - LinesToRead{100, 50, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 50}, []int{50, 100}, }, { "initialRefreshAfter == -1", 150, - LinesToRead{100, -1, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: -1}, []int{100}, }, { "totalTaskLines < initialRefreshAfter", 25, - LinesToRead{100, 50, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 50}, []int{25}, }, { "totalTaskLines between total and initialRefreshAfter", 75, - LinesToRead{100, 50, nil}, + LinesToRead{Total: 100, InitialRefreshAfter: 50}, []int{50, 75}, }, } From 625e7dbad175784400250e395a401256dec2f481 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Jun 2026 17:04:48 +0200 Subject: [PATCH 22/24] Restore scroll and selection seamlessly when escaping to a focused main view EscapeFromPatchExplorer re-renders the side panel's content back into the main view and wants to land at the scroll position and selection the user had before diving into staging/patch building. The previous version set the origin on the next UI tick, after the placeholder had already been painted at the wrong position, so the restore was visible as a jump. Instead, ask the re-render itself to restore the scroll (via ScrollToOriginYForNextTask), so the saved position is applied in the first paint that shows the real content. The selection still needs the diff loaded down to the selected line, so restore it via ReadToEnd once the content is fully read; the scroll is no longer touched there. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/patch_building_helper.go | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/helpers/patch_building_helper.go b/pkg/gui/controllers/helpers/patch_building_helper.go index 13861d61553..ff292427bc8 100644 --- a/pkg/gui/controllers/helpers/patch_building_helper.go +++ b/pkg/gui/controllers/helpers/patch_building_helper.go @@ -59,18 +59,47 @@ func EscapeFromPatchExplorer(c *HelperCommon, context types.IPatchExplorerContex listContext.GetList().SetSelectedLineIdx(snapshot.SidePanelSelectedLineIdx) } + view := snapshot.MainView.GetView() + + // Ask the upcoming re-render to restore the scroll position. Pushing the side + // panel re-renders its content into the main view via a cmd/pty task. Until + // that content is ready, the main view keeps showing the placeholder that + // CopyContent left in it (the view we're leaving) at its current scroll; the + // task then scrolls to the saved position as part of the first paint that + // shows the real content. Doing it this way (rather than setting the origin up + // front) avoids both a jump to the top and a misplaced placeholder frame. + if manager := c.GetViewBufferManagerForView(view); manager != nil { + manager.ScrollToOriginYForNextTask(snapshot.OriginY) + } + // Land on the side panel first (this re-renders the original content into the // main view), then focus the main view on top of it. c.Context().Push(snapshot.SidePanel, types.OnFocusOpts{}) c.Context().Push(snapshot.MainView, types.OnFocusOpts{}) - // Restore the scroll position and selection on the next UI tick. - view := snapshot.MainView.GetView() - c.OnUIThread(func() error { - view.SetOrigin(view.OriginX(), snapshot.OriginY) + restore := func() { view.FocusPoint(0, snapshot.SelectedLineIdx, false) view.Highlight = true view.HighlightInactive = false + } + + // The scroll position is handled by the re-render above, but the selection + // still needs the content loaded down to the selected line, which happens + // asynchronously. Wait until the diff has been fully read before restoring + // it. We do this on the next UI tick, by which point the re-render task is + // live and ReadToEnd can hook into it. + c.OnUIThread(func() error { + manager := c.GetViewBufferManagerForView(view) + if manager == nil { + restore() + return nil + } + manager.ReadToEnd(func() { + c.OnUIThread(func() error { + restore() + return nil + }) + }) return nil }) } From f26a998476f74ab650a38c15cf13f377e23a4c11 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Jun 2026 17:09:06 +0200 Subject: [PATCH 23/24] Session notes: escape flicker fix implemented; timing races remain Records session 3: the cmd/pty scroll-restore mechanism, the refreshMainViews reset reorder, the corrected onNewKey understanding, and the remaining timing-race investigation to do before productionizing. Co-Authored-By: Claude Opus 4.8 (1M context) --- focused-main-view-notes.md | 265 ++++++++++++++++++++++++++----------- 1 file changed, 185 insertions(+), 80 deletions(-) diff --git a/focused-main-view-notes.md b/focused-main-view-notes.md index b1c179eced1..f7ca609641e 100644 --- a/focused-main-view-notes.md +++ b/focused-main-view-notes.md @@ -11,15 +11,24 @@ session. It is meant as a **starting point for future sessions**, which might: tests. (We did *not* make that plan; this doc gives a future session enough context to make it.) -> Status at end of **session 2** (the latest): branch -> `use-delta-hyperlinks-for-clicking-in-diff`. The escape/restore work that was -> uncommitted at the end of session 1 is now committed (`d901a9711` "WIP -> FocusedMainViewSnapshot approach"). Session 2 dug into the **flicker on -> escape** and landed three standalone bug fixes (see "§6" and the new commits -> on top of `e5326c3a6`); the remaining flicker is understood but not yet fully -> solved, and a small pile of **uncommitted** feature machinery -> (`keepOrigin` + the `ReadToEnd`-based restore) is left in the working tree. -> The tree builds (`just build`), is `gofumpt`-clean, and the unit tests pass. +> Status at end of **session 3** (the latest): branch +> `use-delta-hyperlinks-for-clicking-in-diff`. The escape scroll/selection +> restore is **implemented and committed** — at normal speed there is **no +> visible flicker**. Session 3 implemented §6's proposed fix (a cmd/pty analogue +> of `RenderStringWithScrollTask`), discovered and fixed a *second* cause of the +> top-flicker (the scroll-reset loop in `refreshMainViews` ran *before* +> `CopyContent`), and corrected session 2's belief that the `onNewKey` +> suppression could be dropped (it can't — it's folded into the same mechanism). +> Full story in the new **§11**, which supersedes §6's "correct fix" subsection. +> The working tree is now **clean** (everything committed); the tree builds, is +> `gofumpt`-clean, unit tests pass, and `e2e-all` is green except one +> pre-existing **direnv-environmental** worktree test. +> +> **What's left before productionizing:** under `LAZYGIT_SLOW_RENDER` a few +> imperfect intermediate frames still appear *occasionally* — real timing races +> we agreed to investigate and eliminate (not paper over with "fine at normal +> speed"). See §11 "Remaining timing races". Memory: +> `focused-main-view-flicker-timing-races`. --- @@ -50,41 +59,44 @@ to know which file/line a view line corresponds to. ## 2. Branch state -Branch: `use-delta-hyperlinks-for-clicking-in-diff` (off lazygit master). +Branch: `use-delta-hyperlinks-for-clicking-in-diff` (off lazygit master). **The +working tree is clean — everything is committed.** The branch was **rebased** +in session 3 (SHAs below are current): the `LAZYGIT_SLOW_RENDER` knob was moved +to the **base** of the branch (so it can be tested against master), and the +`SetOriginX/Y` chokepoint refactor was squashed into one commit. -### Committed commits (most recent last), the feature-relevant ones: +### Current commit list (most recent first), `master..HEAD`: ``` -45eebc679 Add user config gui.showSelectionInFocusedMainView -686c829d5 Press enter in focused main view when user config is on -dcd658bb7 Select line that is in the middle of the screen -c8a2bc5e7 Press enter in main view of files/commitFiles to enter staging/patch-building -0688099ee Extract some functions from CommitFilesController to a new CommitFilesHelper -a2a675fe0 Press enter in main view of commits panel to enter patch building for clicked line -673b90c10 WIP After going straight to patch building from main view, esc goes all the way back out -c4aba31c9 Replace gui.showSelectionInFocusedMainView config with on-demand selection -ee9f07a67 Press `e` in focused main view (when selection is showing) to edit that line -77157c5ad Open a browser at the selected line in the diff of the current branch's PR -30e625a8d WIP New click behavior +625e7dbad Restore scroll and selection seamlessly when escaping to a focused main view ← session 3 +054d139fe Let a cmd/pty task restore a saved scroll position at its first paint ← session 3 +7f547a5a3 Reset other main views' scroll after copying content, not before ← session 3 +fe79d18b6 Route all view origin writes through SetOriginX and SetOriginY ← session 3 (chokepoint refactor; candidate for master) +89e6f6b14 Session notes: corrected flicker diagnosis and the 3 bug fixes +86f4b3486 Fire queued ReadToEnd callbacks when the initial read reaches EOF ← session 2 bug fix +b7470af27 Don't scroll a view up to fill blank space while its content is loading ← session 2 bug fix +788d959ad Lock the view and guard the line index when reading a hyperlink ← session 2 bug fix +63221c3dd Session notes +5f500893a WIP FocusedMainViewSnapshot approach ← WIP (needs rework) +207927e0d WIP New click behavior ← WIP (needs rework) +385d2e9dd Open a browser at the selected line in the diff of the current branch's PR +c5dd8ddc6 Press `e` in focused main view (when selection is showing) to edit that line +55922f81a Replace gui.showSelectionInFocusedMainView config with on-demand selection +877812c6a WIP After going straight to patch building from main view, esc goes all the way back out ← WIP (needs rework) +0088f26c1 Press enter in main view of commits panel to enter patch building for clicked line +ec50f3122 Extract some functions from CommitFilesController to a new CommitFilesHelper +ed2015cac Press enter in main view of files/commitFiles to enter staging/patch-building +1e5f31dd6 Select line that is in the middle of the screen +fff7a0d19 Press enter in focused main view when user config is on +8a26bebbb Add user config gui.showSelectionInFocusedMainView +ed48988a9 Add LAZYGIT_SLOW_RENDER debug knob for watching async render frames ← base; candidate for master ``` -Note the two **`WIP`** commits (`673b90c10`, `30e625a8d`) — these will need -rework/squashing for productionization. - -### Uncommitted work (the in-progress escape/restore feature) - -``` - M AGENTS.md (unrelated: see §8) - M pkg/gui/context/patch_explorer_context.go - M pkg/gui/controllers/commits_files_controller.go - M pkg/gui/controllers/files_controller.go - M pkg/gui/controllers/helpers/commit_files_helper.go - M pkg/gui/controllers/helpers/patch_building_helper.go - M pkg/gui/controllers/main_view_controller.go - M pkg/gui/controllers/staging_controller.go - M pkg/gui/controllers/switch_to_diff_files_controller.go - M pkg/gui/types/context.go -``` +The three **`WIP`** commits and the heavily-iterated `FocusedMainViewSnapshot` +machinery will need re-sequencing for productionization (see §8). The two +clearly-standalone, master-worthy commits (`ed48988a9` slow-render at the base, +`fe79d18b6` the `SetOriginX/Y` chokepoint) are deliberately isolated so they can +be cherry-picked off. --- @@ -319,7 +331,13 @@ things were all moving the origin off the saved value: blank frame. → handled by re-asserting `SetOrigin(saved)` after the pushes. 3. **The layout scroll-up clamp** → handled by bug fix #2 (the `loading` flag). -### The one remaining flicker (and the correct fix — not yet implemented) +### The one remaining flicker (and the correct fix — IMPLEMENTED in session 3, see §11) + +> **Update (session 3):** the fix described below was implemented, but the +> diagnosis here was *incomplete* in two ways that §11 corrects: (a) the +> `onNewKey` suppression could **not** be dropped, and (b) there was a **second** +> source of the top-flicker — the scroll-reset loop in `refreshMainViews`. Read +> §11 as the current truth; the text below is session 2's understanding. With all three handled, the *scroll no longer jumps*. But there's still a brief intermediate frame, and we found exactly what it is: **`CopyContent` seeds the @@ -435,51 +453,45 @@ Context a planning session will need: - `pkg/gui/controllers/helpers/staging_helper.go` — `GetFileAndLineForClickedDiffLine` (hyperlink parsing). - `pkg/tasks/tasks.go` — the async render-task system (`ViewBufferManager`, - `ReadToEnd`, the read loop) — **the thing to master to finish §6**. -- `pkg/gui/tasks_adapter.go` — string/cmd task wrappers and the origin-reset - callbacks. + `ReadToEnd`, the read loop). Session 3 added `ScrollToOriginYForNextTask` / + `GetScrollToOriginYForNextTask`, `LinesToRead.ApplyInitialScroll`, the + first-paint apply in the read loop, and the `onNewKey` suppression (§11). Also + hosts the committed `LAZYGIT_SLOW_RENDER` knob. +- `pkg/gui/tasks_adapter.go` + `pkg/gui/pty.go` — cmd/pty task wrappers; both now + peek the manager's pending scroll and pass it to + `linesToReadFromCmdTask(view, targetOriginY)` (`view_helpers.go`). +- `pkg/gui/main_panels.go` — `refreshMainViews` (the scroll-reset loop, **now + after** `moveMainContextPairToTop`, §11) and `moveMainContextToTop` → + `CopyContent`. - `pkg/gui/layout.go` — the scroll-up-to-fill clamp (`setViewFromDimensions`); - now skipped while a view's task `IsLoading()`. + skipped while a view's task `IsLoading()`. +- `pkg/gocui/view.go` — `SetOriginX`/`SetOriginY` are now the **single + chokepoints** for all `ox`/`oy` writes (`fe79d18b6`); ideal breakpoint spot. --- -## 10. Debug tooling (stripped from the tree; paste back when needed) +## 10. Debug tooling -These two general-purpose debugging tools were invaluable in session 2 and were -removed from the working tree when cleaning up. They are recorded here so they -can be reapplied without re-deriving them. +### Slow down rendering (`LAZYGIT_SLOW_RENDER=`) — now COMMITTED -### Slow down rendering (`LAZYGIT_SLOW_RENDER=`) +This is no longer a paste-back snippet: it's committed at the **base** of the +branch (`ed48988a9`). Sleeps `` after each line written to a view, so the +frames of an async re-render become visible. No effect when unset. Run as +`LAZYGIT_SLOW_RENDER=40 just debug` (with `just print-log` in another tab). +**This is the tool that makes the remaining timing races (§11) visible** — they +are essentially invisible at normal speed. -Stretches the async load so you can watch the frames of a re-render. Add to the -read goroutine in `ViewBufferManager.NewCmdTask` (`pkg/tasks/tasks.go`), just -before the `outer:` label, plus the per-line sleep inside the inner read loop -right after `lineWrittenChan <- struct{}{}`. Needs `os` and `strconv` imports. +### Trace every change to a view's scroll position — now a single chokepoint -```go -// DEBUG: artificially slow down rendering so transitions are visible. -var slowRenderPerLine time.Duration -if v := os.Getenv("LAZYGIT_SLOW_RENDER"); v != "" { - if ms, err := strconv.Atoi(v); err == nil { - slowRenderPerLine = time.Duration(ms) * time.Millisecond - } -} -// ... and inside the inner loop, after lineWrittenChan <- struct{}{}: -if slowRenderPerLine > 0 { - time.Sleep(slowRenderPerLine) -} -``` - -Run as `LAZYGIT_SLOW_RENDER=20 just debug`. - -### Trace every change to a view's scroll position - -Catches *who* moves `oy` (the trick that finally found the layout clamp). Add to -`pkg/gocui/view.go` (needs `os`, `runtime` imports) and call -`debugMainOriginReset(v, )` immediately before **every** write to `v.oy`: -`SetOrigin`, `SetOriginY`, `CopyContent`, `FocusPoint` (the `calculateNewOrigin` -branch), the `Autoscroll` branch in `draw`, and `ScrollUp`/`ScrollDown`. Filter -by `v.name == "main"` (or whatever view you're chasing). +Session 3's `SetOriginX`/`SetOriginY` refactor (`fe79d18b6`) routed **every** +write to `v.oy`/`v.ox` through `SetOriginY`/`SetOriginX`. So you no longer need +to scatter the tracer across `SetOrigin`/`CopyContent`/`FocusPoint`/`draw`/ +`ScrollUp`/`ScrollDown` — **set one breakpoint (or one log line) inside +`SetOriginY` in `pkg/gocui/view.go`** and you catch all of them, with the +`bt`/Call-Stack giving the caller. (This is exactly how session 3 found the +`refreshMainViews` reset-loop cause — see §11.) The old multi-site +`debugMainOriginReset(v, newY)` helper still works if you want a `/tmp` log with +a trimmed call stack; drop it into `SetOriginY` and filter by `v.name`: ```go func debugMainOriginReset(v *View, newY int) { @@ -504,6 +516,99 @@ func debugMainOriginReset(v *View, newY int) { } ``` -The full session-2 diff (including these and the per-feature `FMVS` `Log.Infof` -breadcrumbs) was also saved to `/tmp/fmv-session-full.patch` during the -cleanup — though `/tmp` is ephemeral, so this section is the durable copy. +--- + +## 11. Session 3: the flicker fix (implemented) + remaining timing races + +Session 3 turned §6's proposal into working, committed code, and corrected the +diagnosis twice along the way. At **normal speed the escape is now flicker-free**. + +### What "applying the saved scroll at first paint" became (commit `054d139fe`) + +The cmd/pty analogue of `RenderStringWithScrollTask`, driven by one field on +`ViewBufferManager`: + +- **`ScrollToOriginYForNextTask(originY int)`** sets `scrollToOriginYForNextTask + *int`. The escape calls it on the main view's manager **before** the re-render + is triggered. It has *two* effects on the next cmd/pty task: + 1. **Suppresses the start-of-task origin reset** (`onNewKey`) — so the + `CopyContent` placeholder keeps showing at *its* scroll instead of being + yanked to the top. (This is the part session 2 thought we could drop. We + can't — see below.) + 2. **Sizes the initial read to `originY`** (`linesToReadFromCmdTask(view, + targetOriginY *int)` uses it instead of the view's current `OriginY`) **and + scrolls there at the first refresh** via a new `LinesToRead.ApplyInitialScroll` + callback, applied once (guarded by `sync.Once`) — at the `InitialRefreshAfter` + point, and in the EOF branch *before* `onEndOfInput` (so a now-shorter diff + gets clamped back into range). +- The field is **peeked** (`GetScrollToOriginYForNextTask`) by the cmd/pty + wrappers (`tasks_adapter.go`, `pty.go`) to size the read, and **cleared in + `NewTask`** after the `onNewKey` decision — so it survives long enough to drive + both effects, and applies to exactly one task. (Per-view managers, so the + secondary view isn't affected.) +- Behaviour-preserving until a caller sets it. + +### Escape wiring simplified (commit `625e7dbad`) + +`EscapeFromPatchExplorer` now just calls `ScrollToOriginYForNextTask(snapshot.OriginY)` +before the pushes, and restores the **selection only** (`FocusPoint` + highlight) +via `ReadToEnd` once the diff is fully loaded. The session-2 dance — up-front +`SetOrigin`, after-push `SetOrigin`, and `KeepOriginForNextTask` — is **gone**; +the task owns the scroll now. + +### Correction #1: `onNewKey` suppression could NOT be dropped + +§6 predicted the new mechanism would let us drop the `onNewKey` suppression. It +didn't. `CopyContent`'s entire purpose is that the newly-revealed view keeps +showing the previous view's content **at its scroll** ("as if nothing changed") +until the real content paints. Letting `onNewKey` reset that to the top *is* a +flicker. So the suppression is kept — folded into the same +`scrollToOriginYForNextTask` field (effect #1 above) rather than a separate +`keepOrigin` flag. + +### Correction #2: there was a SECOND cause of the top-flicker — `refreshMainViews` (commit `7f547a5a3`) + +Even with `onNewKey` suppressed, the placeholder still flicked to the top under +slow render. A `SetOriginY` breakpoint (trivial now, thanks to the chokepoint +refactor) caught it: `refreshMainViews` (`main_panels.go`) reset the scroll of +every *other* main view at the **top** of the function — i.e. it zeroed the +patch-building view's origin **before** `moveMainContextPairToTop` → +`CopyContent` copied that view (now at origin 0) into the Normal view. So the 0 +came from the reset feeding `CopyContent`, *independent of* `onNewKey`. + +**Fix:** move the reset loop to **after** `moveMainContextPairToTop`. End state is +unchanged (every other main still ends at 0, and the destination always +re-renders), but `CopyContent` now copies the source at its real scroll, so the +placeholder stays put. This also makes *every* cross-pair transition's +placeholder seamless, not just our escape. + +### Verification + +- `just build` / `just lint` / `just unit-test` all green. (`TestNewCmdTaskInstantStop` + is a **pre-existing timing flake** that only trips under the full suite's + parallel load; passes 10/10 in isolation, and the session-3 task changes are + inert on its instant-stop path.) +- `just e2e-all`: green **except** `worktree/associate_branch_rebase`, which + fails *environmentally* — `cd`-ing into the linked worktree triggers lazygit's + direnv integration to pop a "Press to run 'direnv allow'" confirmation + (this checkout's `.envrc` is blocked), stealing focus from the `.Focus()` + assertion. Run `direnv allow` (or confirm it fails the same on `master`). + +### Remaining timing races (DO THIS before productionizing) + +At normal speed there's no visible flicker, but under `LAZYGIT_SLOW_RENDER` +**occasional** imperfect intermediate frames remain. The user's explicit call: +these point to **real timing races** in the async render/scroll path, and we +should *eliminate* them rather than rely on normal timing masking them. Not yet +characterised — next session should: + +- Reproduce under `LAZYGIT_SLOW_RENDER` (try a range of values; the races are + intermittent) across the three transitions (files→staging, commit→patch-building, + and the escape), all while **scrolled down**. +- Use the single `SetOriginY` chokepoint + `bt` and/or the §10 tracer, plus the + `ReadToEnd`/`InitialRefreshAfter`/`ApplyInitialScroll` ordering, to pin which + interleavings produce a bad frame. Suspects worth scrutinising: the ordering + between the task's first `ApplyInitialScroll` paint and the `ReadToEnd`-driven + selection restore; the `afterLayout`-deferred pty task creation racing a layout + pass; and `CopyContent` vs. the task's first write. +- Memory: `focused-main-view-flicker-timing-races`. From 94313a616dcf2bed21ff03058ca5ddbb3a44e750 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Jun 2026 18:04:34 +0200 Subject: [PATCH 24/24] Use the default select mode when diving into a patch explorer from a focused main view Diving into staging or patch building from a focused main view (by double-clicking a line, or pressing enter on the selected line) always landed on a single-line selection. Entering the same views through the side panel honours the UseHunkModeInStagingView config and selects the whole hunk by default. The two ways in should agree, so that diving in from the main view feels like the established flow. A non-negative line index in NewState was overloaded for two intents: clicking directly on the patch explorer view (where a single-line range is the start of a drag) and diving in from the main view (where we want the default select mode). Distinguish them with SelectLineInDefaultMode on OnFocusOpts: the main-view entry points set it; the click-to-drag path does not. In hunk mode the selection covers the block of changes around the clicked line. A context line has no surrounding changes, so we snap to the next change line (as toggling hunk mode does); the clicked context line itself is then not part of the selection. This is a separate commit only because the branch is a throwaway prototype; in a real history it would be folded into the commit that introduces the focused-main-view enter behavior rather than landing on top of it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/commits_files_controller.go | 2 +- pkg/gui/controllers/files_controller.go | 2 +- .../helpers/patch_building_helper.go | 2 +- pkg/gui/controllers/helpers/staging_helper.go | 4 ++-- .../switch_to_diff_files_controller.go | 2 +- pkg/gui/patch_exploring/state.go | 22 +++++++++++++++---- pkg/gui/types/context.go | 8 +++++++ 7 files changed, 32 insertions(+), 10 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index ab815ab4d0e..a6f76eaf9ad 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -567,7 +567,7 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName } // Entered from the focused main view, so escaping returns there. - return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) + return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, SelectLineInDefaultMode: true}) } } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 0441c733c79..b637cf68047 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -391,7 +391,7 @@ func (self *FilesController) GetOnClickFocusedMainView() func(mainViewName strin } } - return self.EnterFile(snapshot, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) + return self.EnterFile(snapshot, types.OnFocusOpts{ClickedWindowName: mainViewName, ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, SelectLineInDefaultMode: true}) } } diff --git a/pkg/gui/controllers/helpers/patch_building_helper.go b/pkg/gui/controllers/helpers/patch_building_helper.go index ff292427bc8..6255fb27adb 100644 --- a/pkg/gui/controllers/helpers/patch_building_helper.go +++ b/pkg/gui/controllers/helpers/patch_building_helper.go @@ -158,7 +158,7 @@ func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpt oldState := context.GetState() - state := patch_exploring.NewState(diff, selectedLineIdx, selectedRealLineIdx, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView) + state := patch_exploring.NewState(diff, selectedLineIdx, selectedRealLineIdx, context.GetView(), oldState, self.c.UserConfig().Gui.UseHunkModeInStagingView, opts.SelectLineInDefaultMode) context.SetState(state) if state == nil { self.Escape() diff --git a/pkg/gui/controllers/helpers/staging_helper.go b/pkg/gui/controllers/helpers/staging_helper.go index 2c5aeddcea4..16b21e7773c 100644 --- a/pkg/gui/controllers/helpers/staging_helper.go +++ b/pkg/gui/controllers/helpers/staging_helper.go @@ -74,11 +74,11 @@ func (self *StagingHelper) RefreshStagingPanel(focusOpts types.OnFocusOpts) { hunkMode := self.c.UserConfig().Gui.UseHunkModeInStagingView mainContext.SetState( - patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainSelectedRealLineIdx, mainContext.GetView(), mainContext.GetState(), hunkMode), + patch_exploring.NewState(mainDiff, mainSelectedLineIdx, mainSelectedRealLineIdx, mainContext.GetView(), mainContext.GetState(), hunkMode, focusOpts.SelectLineInDefaultMode), ) secondaryContext.SetState( - patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondarySelectedRealLineIdx, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode), + patch_exploring.NewState(secondaryDiff, secondarySelectedLineIdx, secondarySelectedRealLineIdx, secondaryContext.GetView(), secondaryContext.GetState(), hunkMode, focusOpts.SelectLineInDefaultMode), ) mainState := mainContext.GetState() diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index ace759bd752..a9de6f37845 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -88,7 +88,7 @@ func (self *SwitchToDiffFilesController) GetOnClickFocusedMainView() func(mainVi context.GetViewTrait().FocusPoint( context.ModelIndexToViewIndex(idx), false) node = context.GetSelected() - return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line}) + return self.c.Helpers().CommitFiles.EnterCommitFile(node, snapshot, types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: line, ClickedViewRealLineIdx: line, SelectLineInDefaultMode: true}) } } diff --git a/pkg/gui/patch_exploring/state.go b/pkg/gui/patch_exploring/state.go index 941af2a8ab7..d87291c5cd3 100644 --- a/pkg/gui/patch_exploring/state.go +++ b/pkg/gui/patch_exploring/state.go @@ -45,7 +45,7 @@ const ( HUNK ) -func NewState(diff string, selectedLineIdx int, selectedRealLineIdx int, view *gocui.View, oldState *State, useHunkModeByDefault bool) *State { +func NewState(diff string, selectedLineIdx int, selectedRealLineIdx int, view *gocui.View, oldState *State, useHunkModeByDefault bool, selectLineInDefaultMode bool) *State { if oldState != nil && diff == oldState.diff && selectedLineIdx == -1 { // if we're here then we can return the old state. If selectedLineIdx was not -1 // then that would mean we were trying to click and potentially drag a range, which @@ -84,14 +84,28 @@ func NewState(diff string, selectedLineIdx int, selectedRealLineIdx int, view *g userEnabledHunkMode = oldState.userEnabledHunkMode } - // if we have clicked from the outside to focus the main view we'll pass in a non-negative line index so that we can instantly select that line + // A non-negative line index means we were given a specific line to select: + // either by clicking or pressing enter on a line in a focused main view, or + // by clicking directly on the patch explorer view to focus it. if selectedLineIdx >= 0 { // Clamp to the number of wrapped view lines; index might be out of // bounds if a custom pager is being used which produces more lines selectedLineIdx = min(selectedLineIdx, len(patchLineIndices)-1) - selectMode = RANGE - rangeStartLineIdx = selectedLineIdx + if selectLineInDefaultMode { + // Diving in from a focused main view: keep the default select mode + // computed above. In hunk mode the selection covers the block of + // changes around the line, so snap to a change line if the given one + // is a context line (just as toggling hunk mode does). + if selectMode == HUNK { + selectedLineIdx = viewLineIndices[patch.GetNextChangeIdx(patchLineIndices[selectedLineIdx])] + } + } else { + // Clicking directly on the view starts a range selection that can be + // extended by dragging. + selectMode = RANGE + rangeStartLineIdx = selectedLineIdx + } } else if oldState != nil { // if we previously had a selectMode of RANGE, we want that to now be line again (or hunk, if that's the default) if oldState.selectMode != RANGE { diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 40883fc04fc..0e2d551c23a 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -261,6 +261,14 @@ type OnFocusOpts struct { // If not -1, takes precedence over ClickedViewLineIdx. ClickedViewRealLineIdx int + // When entering a patch explorer (staging or patch building) by clicking or + // pressing enter on a line in a focused main view, we select that line using + // the default select mode (hunk or line, per the UseHunkModeInStagingView + // config), the same as when entering through the side panel. Clicking + // directly on the patch explorer view instead starts a range selection that + // can be extended by dragging. + SelectLineInDefaultMode bool + ScrollSelectionIntoView bool }