Files
lazygit/pkg/gui/main_panels.go
T
Stefan HallerandClaude Opus 5.5 205fad57fa Keep an emptied pane empty
Emptying a pane of the main view clears it right away, but leaves the
view's tasks alone. If a diff was asked for before, and its task is
only created after the layout, that task fills the pane again. A diff's
task that is still reading can also write into the emptied view.

Give the pane an empty render as well. The render takes its place among
the view's tasks, so the earlier diff's task isn't created, and it stops
a task that is still reading. Once that task has stopped, the render
empties the view again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 14:55:13 +02:00

552 lines
22 KiB
Go

package gui
import (
"strings"
"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"
)
func (gui *Gui) runTaskForView(view *gocui.View, task types.UpdateTask) error {
switch v := task.(type) {
case *types.RenderStringTask:
return gui.newStringTask(view, v.Str)
case *types.RenderStringWithoutScrollTask:
return gui.newStringTaskWithoutScroll(view, v.Str)
case *types.RenderStringWithScrollTask:
return gui.newStringTaskWithScroll(view, v.Str, v.OriginX, v.OriginY)
case *types.RunCommandTask:
return gui.newCmdTask(view, v.Cmd, v.Prefix)
case *types.RunDiffRendererTask:
return gui.newRenderTask(view, v.Cmd, v.Prefix)
}
return nil
}
func (gui *Gui) moveMainContextPairToTop(pair types.MainContextPair) {
gui.moveMainContextToTop(pair.Main)
if pair.Secondary != nil {
gui.moveMainContextToTop(pair.Secondary)
}
}
func (gui *Gui) moveMainContextToTop(context types.Context) {
gui.helpers.Window.SetWindowContext(context)
view := context.GetView()
topView := gui.helpers.Window.TopViewInWindow(context.GetWindowName(), true)
if topView != nil && topView != view {
// We need to copy the content to avoid a flicker effect: If we're flicking
// through files in the files panel, we use a different view to render the
// files vs the directories, and if you select dir A, then file B, then dir
// C, you'll briefly see dir A's contents again before the view is updated.
// So here we're copying the content from the top window to avoid that
// flicker effect.
gui.g.CopyContent(topView, view)
if err := gui.g.SetViewOnTopOf(view.Name(), topView.Name()); err != nil {
gui.Log.Error(err)
}
}
}
func (gui *Gui) RefreshMainView(opts *types.ViewUpdateOpts, context types.Context) {
view := context.GetView()
if opts.Title != "" {
view.Title = opts.Title
}
view.Subtitle = opts.SubTitle
if mainContext := gui.mainContextForView(view); mainContext != nil {
mainContext.SetContentIsDiff(types.ContentIsDiff(opts.Task))
// A diff is wrapped as the user asks; anything else a main pane shows is prose
// or a log, and reads as badly cut off at the edge of the pane as it would
// anywhere else.
view.Wrap = !mainContext.ContentIsDiff() || gui.c.UserConfig().Gui.WrapLinesInDiffView
// The files named in the diffstat are linked to where their diff begins, over a
// render that has both: the panel's own diff, and rows that can be placed in the
// files they show. The writer is told here, on the UI thread, since it is asked
// on the one reading the command's output.
gui.diffStatLinkWriter(view).BeginRender(
mainContext.ContentIsDiff() && gui.helpers.DiffLine.DiffRowsCanBePlaced())
}
if err := gui.runTaskForView(view, opts.Task); err != nil {
gui.c.Log.Error(err)
}
}
func (gui *Gui) normalMainContextPair() types.MainContextPair {
return types.NewMainContextPair(
gui.State.Contexts.Normal,
gui.State.Contexts.NormalSecondary,
)
}
func (gui *Gui) mergingMainContextPair() types.MainContextPair {
return types.NewMainContextPair(
gui.State.Contexts.MergeConflicts,
nil,
)
}
func (gui *Gui) allMainContextPairs() []types.MainContextPair {
return []types.MainContextPair{
gui.normalMainContextPair(),
gui.mergingMainContextPair(),
}
}
func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
panes := mainPanesFor(opts)
// Before the render is triggered, so that the pane the focus moves into can be
// told where to put its selection as it renders.
gui.followFocusIntoWorkablePane(opts)
gui.keepDiffSelectionAcrossACommitRewrite(opts)
gui.moveMainContextPairToTop(opts.Pair)
gui.handOverMainSection(opts.Pair, panes)
if opts.Main != nil {
gui.RefreshMainView(opts.Main, opts.Pair.Main)
} else {
gui.clearMainView(opts.Pair.Main)
}
if opts.Secondary != nil {
gui.RefreshMainView(opts.Secondary, opts.Pair.Secondary)
} else if opts.Pair.Secondary != nil {
gui.clearMainView(opts.Pair.Secondary)
}
// 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.setMainPanes(panes)
}
// handOverMainSection carries the content of the main section from the pane that has
// been showing it on its own to the pane about to, when a render moves the section's
// content from one to the other — a file's changes going from unstaged to staged, say.
//
// The section is one region of the screen to the user, so a change of which pane holds
// it has to look like that region re-rendering rather than blanking and filling in
// again: the incoming pane shows what the outgoing one was showing, where it was
// showing it, until its own render has read enough to be swapped in. It renders from
// the top when it does, the content it took over not being its own (see
// clearMainView).
func (gui *Gui) handOverMainSection(pair types.MainContextPair, panes types.MainPanes) {
// The lower pane is always the same view, being the only one a render can leave
// holding the section on its own; the upper one is whichever view of the main
// window this render is for, which moveMainContextPairToTop has just given a copy
// of what that window was showing.
upper, lower := pair.Main.GetView(), gui.Views.Secondary
var from, to *gocui.View
switch {
case gui.State.MainPanes == types.MainPaneOnly && panes == types.SecondaryPaneOnly:
from, to = upper, lower
case gui.State.MainPanes == types.SecondaryPaneOnly && panes == types.MainPaneOnly:
from, to = lower, upper
default:
return
}
gui.g.CopyContent(from, to)
}
// mainPanesFor says which panes the given render occupies: the one it has content for,
// or both when it has content for both.
func mainPanesFor(opts types.RefreshMainOpts) types.MainPanes {
switch {
case opts.Secondary == nil:
return types.MainPaneOnly
case opts.Main == nil:
return types.SecondaryPaneOnly
default:
return types.BothMainPanes
}
}
// followFocusIntoWorkablePane moves the focus out of a main pane that the render about
// to happen leaves nothing to work on, and into the one it does.
//
// Each side of a file's diff has a pane of its own, and a pane holds something only
// while its side of the file does. So anything that empties the side the focus is on
// leaves that pane with nothing: staging the last unstaged change, committing what was
// staged, or either of those happening outside lazygit and arriving with a refresh.
// Usually the pane goes away with its content; configured to always split the diff it
// stays, empty. Either way the focus has nothing left to act on where it is.
//
// The pane moved into gets its selection once the render has finished and there is
// something to put one on, and shows none until then, so that the selection it was
// left with the last time it was used doesn't appear for a frame. A pane that has
// already been told where to put its selection — by the action that caused all this —
// keeps what it was told.
func (gui *Gui) followFocusIntoWorkablePane(opts types.RefreshMainOpts) {
// The focused main view's two panes only: the staging and patch-building views
// arrange theirs for themselves, and the merge-conflicts view has just the one.
if opts.Pair.Main.GetKey() != context.NORMAL_MAIN_CONTEXT_KEY {
return
}
current := gui.State.ContextMgr.CurrentStatic().GetKey()
if current != opts.Pair.Main.GetKey() && current != opts.Pair.Secondary.GetKey() {
return
}
pane := onlyWorkablePane(opts)
if pane == nil || pane.GetKey() == current {
return
}
target := gui.mainContextForView(pane.GetView())
target.SetHasSelectableContent(false)
gui.State.ContextMgr.UpdateSelectionHighlights()
if manager := gui.getManager(target.GetView()); !manager.HasRestoreForNextTask() {
manager.SetRestoreForNextTask(&tasks.RenderRestore{
// The whole render is read before it is shown: where the selection goes
// is decided from what is there, and a change line further down would
// otherwise be missed.
FirstPaintReady: func() bool { return false },
Apply: func(swapIn func()) {
swapIn()
gui.helpers.DiffLine.EstablishSelection(target, -1)
},
})
}
gui.State.ContextMgr.Push(target, types.OnFocusOpts{})
}
// keepDiffSelectionAcrossACommitRewrite arranges for a selection in the focused main
// view to come back on the same change of the diff when the render about to happen is
// of a different diff from the one on screen. This happens when a commit is rewritten
// under the user, by moving a patch out of it, discarding lines from it, or undoing
// either. The selection is then left at a position in a rendering that no longer exists.
//
// What is remembered is which change of the diff the selection was on rather than which
// line of which file, a rewrite being precisely a change to those lines: the change that
// takes its place is where the work carries on.
//
// It is asked of every render, and does nothing unless all three of these hold: there
// is a selection to keep; nothing more precise is already waiting to be put back (the
// position preserves and the post-action reveals know better where their selection
// belongs); and the diff really is another one. A plain refresh re-renders the same
// diff, where the selection, possibly a range the user is in the middle of making, is
// still exactly right.
func (gui *Gui) keepDiffSelectionAcrossACommitRewrite(opts types.RefreshMainOpts) {
// The focused main view's two panes only: no other pair has a diff selection.
if opts.Pair.Main.GetKey() != context.NORMAL_MAIN_CONTEXT_KEY {
return
}
current := gui.State.ContextMgr.CurrentStatic().GetKey()
for _, pane := range []struct {
context types.Context
update *types.ViewUpdateOpts
}{
{opts.Pair.Main, opts.Main},
{opts.Pair.Secondary, opts.Secondary},
} {
if pane.update == nil || pane.context.GetKey() != current {
continue
}
mainContext := gui.mainContextForView(pane.context.GetView())
if mainContext == nil || !mainContext.GetView().Highlight {
continue
}
manager := gui.getViewBufferManagerForView(mainContext.GetView())
if manager == nil || manager.HasRestoreForNextTask() {
continue
}
key, ok := diffTaskCommandKey(pane.update.Task)
if !ok || key == manager.GetTaskKey() {
continue
}
first, _, ok := mainContext.GetView().SelectedBufferLineRange()
if !ok {
continue
}
gui.helpers.DiffLine.RevealSelectionAfterAction(mainContext, mainContext, first, 0, nil)
}
}
// diffTaskCommandKey returns the key the given render will be remembered under. Two
// renders of the same diff have the same key, so comparing keys says whether a render
// is of the diff already on screen. ok is false for a render that is a message rather
// than a diff.
func diffTaskCommandKey(task types.UpdateTask) (string, bool) {
switch task := task.(type) {
case *types.RunCommandTask:
return strings.Join(task.Cmd.Args, " "), true
case *types.RunDiffRendererTask:
return strings.Join(task.Cmd.Args, " "), true
}
return "", false
}
// onlyWorkablePane returns the main pane a render leaves as the only one worth having
// the focus in, or nil when that is true of both of them or of neither. Being shown is
// not the same as being worth working in: a pane the layout keeps around for the sake
// of always splitting the diff shows an empty side of the file.
func onlyWorkablePane(opts types.RefreshMainOpts) types.Context {
main := opts.Main != nil && !opts.Main.NothingToActOn
secondary := opts.Secondary != nil && !opts.Secondary.NothingToActOn
if main == secondary {
return nil
}
if main {
return opts.Pair.Main
}
return opts.Pair.Secondary
}
// clampDiffSelectionToContent brings the focused main view's selection back onto the
// content when the render that just finished left the diff with fewer lines than the
// selection was on — a diff renderer that renders the same diff more compactly, a
// smaller context size. That selection lives in the view rather than in a model, so
// nothing else re-derives it, and past the end of the content it isn't drawn at all,
// which reads as having no selection until an arrow key brings it back.
//
// Called at end of input, when the content is final: doing it while the render is
// still loading would drag the selection to a line that only looks like the last one.
// Only these two views need it; every other view's selection is derived from a model
// as it renders, and so is clamped along with it.
func (gui *Gui) clampDiffSelectionToContent(view *gocui.View) {
if gui.mainContextForView(view) == nil {
return
}
if !view.Highlight {
return
}
if lastLine := view.ViewLinesHeight() - 1; view.SelectedLineIdx() > lastLine {
view.FocusPoint(0, max(0, lastLine), true)
}
}
// clearMainView empties a pane that is being given nothing to show, selection and all.
//
// An emptied pane is showing nothing, so it also goes back to the top and stops
// claiming the render it was showing: whatever it is given next is content the user
// hasn't seen there, and is shown from the top like any other.
//
// The pane is emptied right away, and it is also given an empty render. The render
// takes its place among the view's tasks, so that a render asked for before it can't
// fill the pane again, whether that render's task is still to be created or is still
// reading. Like any render that isn't a re-render of what the pane was showing, it
// drops a position waiting to be put back: whoever is waiting for the view to be back
// where it belongs has to hear that it never will be.
func (gui *Gui) clearMainView(mainContext types.Context) {
view := mainContext.GetView()
view.Clear()
view.SetOrigin(0, 0)
mainContext.SetHasSelectableContent(false)
if pane := gui.mainContextForView(view); pane != nil {
pane.SetContentIsDiff(false)
}
gui.State.ContextMgr.UpdateSelectionHighlights()
if manager := gui.getViewBufferManagerForView(view); manager != nil {
manager.ForgetRenderedContent()
if err := gui.newStringTask(view, ""); err != nil {
gui.c.Log.Error(err)
}
}
}
// updateDiffPaneDecorations re-derives what is drawn over a main pane's content, rather
// than being part of it: whether a selection is shown, and which lines are marked as
// being in the custom patch.
//
// A pane holds something for a selection to sit on only beneath a panel whose main
// view is a diff, only while the pane is showing that diff rather than a message like
// "No changed files", and only while the diff holds something to select — never over
// one with nothing in it, such as a binary file's or an empty commit's. Whether the
// selection is then drawn, and drawn as the active one, follows from the context stack.
//
// It is asked wherever the pane's content changes: as a string is rendered, at the
// paint that reveals a command's output, with every further batch of that output, and
// once it has been read to the end. contentIsComplete tells those apart, since a render
// still being read can leave the question open (see diffPaneHasSomethingToSelect). The
// pane never answers from the render before it, and a render that leaves the question
// open is read on until it doesn't, so the answer is always about what is there.
func (gui *Gui) updateDiffPaneDecorations(view *gocui.View, contentIsComplete bool) {
mainContext := gui.mainContextForView(view)
if mainContext == nil {
return
}
gui.dropAnAnswerAboutAnotherRender(mainContext, view)
if hasSomethingToSelect, known := gui.diffPaneHasSomethingToSelect(
mainContext, view, contentIsComplete,
); known {
mainContext.SetHasSelectableContent(hasSomethingToSelect)
gui.State.ContextMgr.UpdateSelectionHighlights()
} else {
gui.readOnUntilTheDiffPaneCanTell(view)
}
// The marks are over the diff in the upper pane; the lower one shows the patch
// they are marks of.
if view == gui.Views.Main {
gui.helpers.DiffLine.RefreshInclusionGutter()
}
}
// dropAnAnswerAboutAnotherRender takes away what the pane worked out about the content
// of an earlier render, so that this one starts from no answer rather than inheriting
// one. An answer about other content says nothing about this content: carried over, it
// shows a selection over a diff that may have nothing to select, or hides one over a
// diff that has.
//
// A re-render of the same content keeps its answer, and with it the selection drawn
// over it, since that answer is still about what the pane is showing.
func (gui *Gui) dropAnAnswerAboutAnotherRender(mainContext *context.MainContext, view *gocui.View) {
manager := gui.getViewBufferManagerForView(view)
if manager == nil {
return
}
if key := manager.GetTaskKey(); key != mainContext.SelectableContentRenderKey() {
mainContext.SetSelectableContentRenderKey(key)
mainContext.SetHasSelectableContent(false)
gui.State.ContextMgr.UpdateSelectionHighlights()
}
}
// readOnUntilTheDiffPaneCanTell keeps a render going past the lines that were asked of
// it, while the pane still can't say whether there is anything in it to select.
//
// A render is asked for as many lines as the scrollbar needs (see
// linesToReadFromCmdTask), and what a commit's diff opens with can run past that: the
// diffstat of a commit touching thousands of files, or a commit message thousands of
// lines long. Without this the pane would be left with no answer until the user
// scrolled far enough to ask for the rest themselves, which is no way to find out
// whether a diff can be acted on. Another render's worth is asked for each time, so the
// reading stops soon after the first change line, and only runs to the end of a diff
// that has none.
func (gui *Gui) readOnUntilTheDiffPaneCanTell(view *gocui.View) {
manager := gui.getViewBufferManagerForView(view)
if manager == nil {
return
}
step := gui.linesToReadFromCmdTask(view).Total
if step < 0 {
// A view that is being searched is already being read to the end.
return
}
manager.ReadLinesAndWait(view.LinesHeight() + step)
}
// diffPaneHasSomethingToSelect answers whether the given main pane holds anything for a
// selection to sit on, from what it is showing so far. known is false while a render
// still being read leaves the question open.
func (gui *Gui) diffPaneHasSomethingToSelect(
mainContext *context.MainContext, view *gocui.View, contentIsComplete bool,
) (bool, bool) {
if !mainContext.ContentIsDiff() {
// The pane is holding something other than the panel's diff: a message, or the
// hint shown for a merge conflict that has to be resolved by picking a side,
// which explains itself with a diff of what the sides did. Whatever is in there
// is not ours to act on, so this needs no content to answer either.
return false, true
}
if _, showsDiff := gui.State.ContextMgr.CurrentSide().(types.DiffMainViewContext); !showsDiff {
// Under a panel that shows no diff there is nothing to select whatever the pane
// ends up holding, so this needs no content to answer. Answering it now matters,
// because a render may never reach an end. The rest of a long commit log is read
// only as far as the user scrolls, and until then the pane would go on showing
// the selection it was left with under the panel before.
return false, true
}
if !contentIsComplete && mainContext.HasSelectableContent() {
// This render has already found something to select, and its content only grows
// from here, so there is nothing to ask again — nor to read the diff for. An
// answer the render before it gave has been dropped by now (see
// dropAnAnswerAboutAnotherRender), so this really is about the content in hand.
return true, true
}
hasChangeLines := gui.helpers.DiffLine.ViewHasChangeLines(view)
// One change line among those read settles it. Finding none in a render that is
// still going may only mean the changes are in the part still to come. A commit's
// diff opens with a diffstat, and for a commit touching hundreds of files that runs
// well past the screenful the first paint reveals, so that answer waits.
return hasChangeLines, contentIsComplete || hasChangeLines
}
// mainContextForView returns the context of the main pane the given view is, or nil for
// any other view.
func (gui *Gui) mainContextForView(view *gocui.View) *context.MainContext {
switch view {
case gui.Views.Main:
return gui.State.Contexts.Normal
case gui.Views.Secondary:
return gui.State.Contexts.NormalSecondary
}
return nil
}
func (gui *Gui) setMainPanes(panes types.MainPanes) {
gui.State.MainPanes = panes
// The label for the key that focuses the main view belongs on the pane that key
// focuses, which is the secondary one while it is the only one shown.
if panes == types.SecondaryPaneOnly {
gui.showFocusMainViewJumpLabelOn(gui.Views.Secondary)
} else {
gui.showFocusMainViewJumpLabelOn(gui.Views.Main)
}
}
// showFocusMainViewJumpLabelOn puts the main view's jump label on the given pane and
// takes it off the other one, so that only the pane the key focuses wears it.
func (gui *Gui) showFocusMainViewJumpLabelOn(view *gocui.View) {
gui.Views.Main.TitlePrefix = ""
gui.Views.Secondary.TitlePrefix = ""
view.TitlePrefix = gui.focusMainViewJumpLabel
}
// reApplySearch runs a search the view holds again over the content a render has just
// finished putting there, so that the matches highlighted and the "x of y" status
// describe what the view shows now rather than what it showed when the search was
// typed. Call it once the content is final.
func (gui *Gui) reApplySearch(view *gocui.View) {
// While the prompt is open, the search view holds what the user is typing, and the
// status would be written over it.
if gui.State.ContextMgr.Current().GetKey() == context.SEARCH_CONTEXT_KEY {
return
}
view.RefreshSearch()
}