mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-24 10:13:41 -05:00
A cmd/pty re-render used to overwrite the displayed buffer from the top down as lines arrived, relying on keeping the previous render's view-line tail to avoid a blank frame. That left the view showing a mixture of old and new content while loading, and any reader (draw, clicks, the view-line mapping) could observe a half-written buffer at the wrong scroll. Instead, build the new content in a second, off-screen viewBuffer: until the task has read enough to paint, writes go there and the displayed buffer — and so everything every reader sees — is left untouched. Once the task reaches its first-paint point (InitialRefreshAfter, or EOF for short content) it swaps the off-screen buffer in atomically, so the view jumps straight from the previous render to the new one with no intermediate frame. Subsequent lines append to the now-displayed buffer. Swapping at the first-paint point means the displayed buffer is only a viewport tall when it appears and then grows as the rest streams in toward the count needed for an accurate scrollbar. The scrollbar is sized from the displayed buffer's height, so left to itself the thumb would shrink and snap back during that growth (most visibly: the files panel's periodic refresh making the thumb jump while scrolled down). The total height the scrollbar needs is a strictly later quantity than the viewport-fill paint, so no single early swap can have both right. FreezeScrollbarHeight therefore records the view's height when a load begins and the scrollbar is held there — growing only if the new content turns out taller — until the load ends; a synchronous render superseding the load releases it. This mirrors the layout clamp, which already ignores the partial content height while a view loads. With the swap doing a wholesale replace, refreshViewLinesIfNeeded can truncate the view lines to the current buffer: there is no longer a half-loaded shorter buffer whose tail we must keep showing, so a stale tail never forms. clear()/Reset() abandon any in-progress off-screen render so a synchronous SetContent after a stopped task writes to the display. The swap holds writeMutex for now; it could later move to the main thread. Flicker behaviour still needs interactive verification (LAZYGIT_SLOW_RENDER + a real diff renderer). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
209 lines
7.7 KiB
Go
209 lines
7.7 KiB
Go
package gui
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
|
|
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
|
"github.com/jesseduffield/lazygit/pkg/config"
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/tasks"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/samber/lo"
|
|
)
|
|
|
|
func (gui *Gui) desiredPtySize(view *gocui.View) (cols, rows uint16) {
|
|
width, height := view.InnerSize()
|
|
return uint16(width), uint16(height)
|
|
}
|
|
|
|
func (gui *Gui) onResize() error {
|
|
gui.Mutexes.PtyMutex.Lock()
|
|
defer gui.Mutexes.PtyMutex.Unlock()
|
|
|
|
for viewName, p := range gui.viewPtmxMap {
|
|
// TODO: handle resizing properly: we need to actually clear the main view
|
|
// and re-read the output from our pty. Or we could just re-run the original
|
|
// command from scratch
|
|
view, _ := gui.g.View(viewName)
|
|
cols, rows := gui.desiredPtySize(view)
|
|
if err := p.Resize(cols, rows); err != nil {
|
|
return utils.WrapError(err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ptyCmd adapts an oscommands.StartedPty result into the tasks.Cmd shape.
|
|
// On Windows the original *exec.Cmd was never Start()ed, so we go through
|
|
// the explicit Process handle rather than cmd.Process.
|
|
type ptyCmd struct {
|
|
cmd *exec.Cmd
|
|
process *os.Process
|
|
wait func() error
|
|
}
|
|
|
|
func (p ptyCmd) Wait() error { return p.wait() }
|
|
func (p ptyCmd) String() string { return p.cmd.String() }
|
|
func (p ptyCmd) Terminate() error { return oscommands.TerminateProcessGracefully(p.process) }
|
|
|
|
// Some commands need to output for a terminal to active certain behaviour.
|
|
// For example, git won't invoke the GIT_PAGER env var unless it thinks it's
|
|
// talking to a terminal. We typically write cmd outputs straight to a view,
|
|
// which is just an io.Reader. the pty package lets us wrap a command in a
|
|
// pseudo-terminal meaning we'll get the behaviour we want from the underlying
|
|
// command.
|
|
func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error {
|
|
width := view.InnerWidth()
|
|
|
|
// Set LAZYGIT_COLUMNS for diff renderer scripts that can't query the terminal width directly.
|
|
cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", width))
|
|
|
|
if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit {
|
|
// If we're not using a custom diff renderer, then we don't need to use a pty
|
|
return gui.newCmdTask(view, cmd, prefix)
|
|
}
|
|
|
|
cmd.Args = withPtyGitConfig(cmd.Args, runtime.GOOS)
|
|
|
|
// 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()
|
|
// Hold the scrollbar at its current height while the re-render loads, so the
|
|
// thumb doesn't shrink and snap back when the first partial paint swaps in
|
|
// (see the matching call in newCmdTask).
|
|
view.FreezeScrollbarHeight()
|
|
|
|
// Run the pty after layout so that it gets the correct size
|
|
gui.afterLayout(func() error {
|
|
// Need to get the width and the pager command again because the layout might have
|
|
// changed the size of the view
|
|
width = view.InnerWidth()
|
|
pager := gui.stateAccessor.GetDiffRendererConfigManager().GetStdinFilterCommand(width)
|
|
|
|
cmdStr := strings.Join(cmd.Args, " ")
|
|
|
|
// This communicates to diff renderers that we're in a very simple
|
|
// terminal that they should not expect to have much capabilities.
|
|
// Moving the cursor, clearing the screen, or querying for colors are among such "advanced" capabilities.
|
|
// Context: https://github.com/jesseduffield/lazygit/issues/3419
|
|
cmd.Env = removeExistingTermEnvVars(cmd.Env)
|
|
cmd.Env = append(cmd.Env, "TERM=dumb")
|
|
|
|
cmd.Env = append(cmd.Env, "GIT_PAGER="+pager)
|
|
|
|
manager := gui.getManager(view)
|
|
|
|
// Size the pty from the view's dimensions here, on the UI thread; the
|
|
// start func below runs on the task's goroutine, which must not read the
|
|
// view's live dimensions while the UI thread is laying it out.
|
|
cols, rows := gui.desiredPtySize(view)
|
|
|
|
var p oscommands.Pty
|
|
var fallbackPipe io.ReadCloser
|
|
start := func() (tasks.Cmd, io.Reader) {
|
|
// The pty (and diff renderer) wrap to this width; apply it here, on the
|
|
// task's goroutine once the previous task has stopped, so it doesn't
|
|
// race that task's writes (see View.SetContentWidth).
|
|
view.SetContentWidth(width)
|
|
|
|
sp, err := oscommands.StartPty(cmd, cols, rows)
|
|
if err != nil {
|
|
gui.c.Log.Error(err)
|
|
// Fall back to running the command without a pty: the diff renderer is
|
|
// lost, but the command's output still renders.
|
|
execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log)
|
|
fallbackPipe = pipe
|
|
return execCmd, pipe
|
|
}
|
|
p = sp.Pty
|
|
|
|
gui.Mutexes.PtyMutex.Lock()
|
|
gui.viewPtmxMap[view.Name()] = p
|
|
gui.Mutexes.PtyMutex.Unlock()
|
|
|
|
return ptyCmd{cmd: cmd, process: sp.Process, wait: sp.Wait}, p
|
|
}
|
|
|
|
onClose := func() {
|
|
gui.Mutexes.PtyMutex.Lock()
|
|
if p != nil {
|
|
p.Close()
|
|
}
|
|
if fallbackPipe != nil {
|
|
fallbackPipe.Close()
|
|
fallbackPipe = nil
|
|
}
|
|
delete(gui.viewPtmxMap, view.Name())
|
|
gui.Mutexes.PtyMutex.Unlock()
|
|
}
|
|
|
|
linesToRead := gui.linesToReadFromCmdTask(view)
|
|
return manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), cmdStr)
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// withPtyGitConfig returns args with extra git configuration for commands
|
|
// that render into a pty. On Windows, such a command is terminated at an
|
|
// arbitrary point of its execution when its task stops: tearing down the
|
|
// pseudoconsole delivers CTRL_CLOSE_EVENT, which git leaves to the default
|
|
// handler, which just calls ExitProcess. git's automatic index refresh
|
|
// (diff.autoRefreshIndex, on by default) takes index.lock at the end of a
|
|
// diff against the worktree to write back refreshed stat information —
|
|
// GIT_OPTIONAL_LOCKS does not cover this lock — and a termination landing
|
|
// in that window leaves a stale index.lock behind that the next git command
|
|
// chokes on. So don't let pty-rendered commands refresh the index;
|
|
// lazygit's foreground `git status` refreshes, which never run in a pty,
|
|
// keep the stat cache fresh instead.
|
|
//
|
|
// On Unix a stopped pty child gets SIGTERM, and git's signal handlers remove
|
|
// its lock files, so the refresh can stay enabled there and keep healing
|
|
// stale stat info.
|
|
func withPtyGitConfig(args []string, goos string) []string {
|
|
if goos != "windows" {
|
|
return args
|
|
}
|
|
// Most pty commands are direct git invocations, but the user-configured
|
|
// ones can be arbitrary command lines (e.g. a branchLogCmd wrapping git
|
|
// in `sh -c`), and injecting git flags into those would corrupt them.
|
|
// Only direct git invocations get the config; that loses nothing, since
|
|
// the wrapped commands are log commands, which never take the index
|
|
// lock. (For direct invocations other than worktree diffs the config is
|
|
// simply a no-op.)
|
|
base := strings.TrimSuffix(strings.ToLower(filepath.Base(args[0])), ".exe")
|
|
if base != "git" {
|
|
return args
|
|
}
|
|
result := make([]string, 0, len(args)+2)
|
|
result = append(result, args[0])
|
|
result = append(result, "-c", "diff.autoRefreshIndex=false")
|
|
return append(result, args[1:]...)
|
|
}
|
|
|
|
func removeExistingTermEnvVars(env []string) []string {
|
|
return lo.Filter(env, func(envVar string, _ int) bool {
|
|
return !isTermEnvVar(envVar)
|
|
})
|
|
}
|
|
|
|
// Terminals set a variety of different environment variables
|
|
// to identify themselves to processes. This list should catch the most common among them.
|
|
func isTermEnvVar(envVar string) bool {
|
|
return strings.HasPrefix(envVar, "TERM=") ||
|
|
strings.HasPrefix(envVar, "TERM_PROGRAM=") ||
|
|
strings.HasPrefix(envVar, "TERM_PROGRAM_VERSION=") ||
|
|
strings.HasPrefix(envVar, "TERMINAL_EMULATOR=") ||
|
|
strings.HasPrefix(envVar, "TERMINAL_NAME=") ||
|
|
strings.HasPrefix(envVar, "TERMINAL_VERSION_")
|
|
}
|