Files
lazygit/pkg/gui/view_helpers.go
T
Stefan HallerandClaude Opus 5 81851dcbbf Show a selection in the focused main view
The focused main view could be scrolled but not pointed at: there was no
way to say "this line" or "these lines", which is what every line-level
interaction needs — editing a line, copying part of a diff, and, later,
staging. So a diff main view now always carries a selection while focused,
starting at the first change already on screen so that focusing doesn't
move the view.

The mode of the selection lives on the main context (a single line, a
range from a fixed anchor, or the change block around the cursor); the
selected line and the range anchor stay in the view itself, whose native
range select draws them, so there is no new highlight machinery. The modes
and their keys are the ones the staging view has, including that the arrow
keys step from block to block in hunk mode.

A pane holds something to select only when what it is showing is a diff
with changes in it, which the render works out once its content is final: a
placeholder message is not a diff, and neither is a diff with nothing in it
at all, such as a binary file's or an empty commit's. Whether a selection
is then drawn there follows from the context stack, as it does for every
other view.

A click in the focused main view now has a meaning again: it selects the
line it points at.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-05 10:03:31 +02:00

178 lines
5.2 KiB
Go

package gui
import (
"time"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/spkg/bom"
)
func (gui *Gui) resetViewOrigin(v *gocui.View) {
v.SetCursor(0, 0)
v.SetOrigin(0, 0)
}
// 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 {
height := v.InnerHeight()
oy := v.OriginY()
linesForFirstRefresh := height + oy + 10
// A search counts the matches in everything the view holds, so a re-render of a
// view that is being searched is read all the way to the end (as opening the
// search prompt reads it, see MainViewController.openSearch). Lines left unread
// hold matches the search doesn't know about, and would add themselves to the
// "x of y" as the user scrolled far enough to load them.
if v.IsSearching() {
return tasks.LinesToRead{
Total: -1,
InitialRefreshAfter: linesForFirstRefresh,
}
}
// We want to read as many lines initially as necessary to let the
// scrollbar go to its minimum height, so that the scrollbar thumb doesn't
// change size as you scroll down.
minScrollbarHeight := 1
linesToReadForAccurateScrollbar := min(
// However, cap it at some arbitrary max limit, so that we don't get
// performance problems for huge monitors or tiny font sizes
height*(height-1)/minScrollbarHeight+oy, 5000)
return tasks.LinesToRead{
Total: linesToReadForAccurateScrollbar,
InitialRefreshAfter: linesForFirstRefresh,
}
}
func (gui *Gui) cleanString(s string) string {
output := string(bom.Clean([]byte(s)))
return utils.NormalizeLinefeeds(output)
}
func (gui *Gui) setViewContent(v *gocui.View, s string) {
v.SetContent(gui.cleanString(s))
}
func (gui *Gui) currentViewName() string {
currentView := gui.g.CurrentView()
if currentView == nil {
return ""
}
return currentView.Name()
}
func (gui *Gui) onViewTabClick(windowName string, tabIndex int) error {
tabs := gui.viewTabMap()[windowName]
if len(tabs) == 0 {
return nil
}
viewName := tabs[tabIndex].ViewName
context, ok := gui.helpers.View.ContextForView(viewName)
if !ok {
return nil
}
gui.c.Context().Push(context, types.OnFocusOpts{})
return nil
}
func (gui *Gui) handleNextTab() error {
view := getTabbedView(gui)
if view == nil {
return nil
}
for _, context := range gui.State.Contexts.Flatten() {
if context.GetViewName() == view.Name() {
return gui.onViewTabClick(
context.GetWindowName(),
utils.ModuloWithWrap(view.TabIndex+1, len(view.Tabs)),
)
}
}
return nil
}
func (gui *Gui) handlePrevTab() error {
view := getTabbedView(gui)
if view == nil {
return nil
}
for _, context := range gui.State.Contexts.Flatten() {
if context.GetViewName() == view.Name() {
return gui.onViewTabClick(
context.GetWindowName(),
utils.ModuloWithWrap(view.TabIndex-1, len(view.Tabs)),
)
}
}
return nil
}
func getTabbedView(gui *Gui) *gocui.View {
// It safe assumption that only static contexts have tabs
context := gui.c.Context().CurrentStatic()
view, _ := gui.g.View(context.GetViewName())
return view
}
func (gui *Gui) render() {
gui.c.OnUIThread(func() error { return nil })
}
// postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed
// if the context's view is set to another context we do nothing.
// if the context's view is the current view we trigger a focus; re-selecting the current item.
func (gui *Gui) postRefreshUpdate(c types.Context, opts types.OnFocusOpts) {
t := time.Now()
defer func() {
gui.Log.Infof("postRefreshUpdate for %s took %s", c.GetKey(), time.Since(t))
}()
c.HandleRender()
// The render may have given the context its first item, or taken its last one
// away, which decides whether its view draws a selection at all.
gui.State.ContextMgr.UpdateSelectionHighlights()
if gui.currentViewName() == c.GetInputViewName() {
c.HandleFocus(opts)
} else {
// The FocusLine call is included in the HandleFocus method which we
// call for focused views above; but we need to call it here for
// non-focused views to ensure that an inactive selection is painted
// correctly, and that integration tests see the up to date selection
// state.
c.FocusLine(!opts.KeepScrollPosition)
if opts.SkipMainViewUpdate {
return
}
currentCtx := gui.State.ContextMgr.Current()
if currentCtx.GetKey() == context.NORMAL_MAIN_CONTEXT_KEY || currentCtx.GetKey() == context.NORMAL_SECONDARY_CONTEXT_KEY {
sidePanelContext := gui.State.ContextMgr.NextInStack(currentCtx)
if sidePanelContext != nil && sidePanelContext.GetKey() == c.GetKey() {
sidePanelContext.HandleRenderToMain()
}
} else if c.GetKey() == gui.State.ContextMgr.CurrentStatic().GetKey() {
// If our view is not the current one, but it is the current static context, then this
// can only mean that a popup is showing. In that case we want to refresh the main view
// behind the popup.
c.HandleRenderToMain()
}
}
}