mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-27 17:56:37 -04:00
Render a diff without a pty on Windows
ConPTY doesn't carry a diff renderer's output to us as the renderer wrote it. It parses the output into a screen buffer and re-encodes that for the terminal side, and a sequence it can't represent there goes out the moment it is parsed, separately from the text around it. The OSC 1717 records a renderer states its diff lines in therefore arrive detached from the rows they describe, and the identity layer attributes rows to the wrong diff line or to none. Feed the renderer through a pipe there instead, so that its bytes reach us unaltered. A stdin filter becomes a command of our own, since git only invokes the one named by GIT_PAGER when it talks to a terminal; an external diff renderer is git's own business either way and needs nothing but the pipe. Unix keeps the pty. A renderer reads the width to lay out to off it, so taking it away would leave every configuration that doesn't name a width rendering at whatever the renderer falls back to, and diff renderers have worked on Unix far too long for that. LAZYGIT_RENDER_WITHOUT_PTY asks for the piped path anyway, which is how the integration tests cover it on a platform where they run at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bf948ba782
commit
087bdcd57f
@@ -108,6 +108,14 @@ func (self *CmdObj) GetEnvVars() []string {
|
||||
return self.cmd.Env
|
||||
}
|
||||
|
||||
// SetEnviron replaces the command's whole environment, for a command that has
|
||||
// to run in the same one as another command rather than in this process's.
|
||||
func (self *CmdObj) SetEnviron(env []string) *CmdObj {
|
||||
self.cmd.Env = env
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// sets the working directory
|
||||
func (self *CmdObj) SetWd(wd string) *CmdObj {
|
||||
self.cmd.Dir = wd
|
||||
|
||||
@@ -33,6 +33,16 @@ func (self *CmdObjBuilder) New(args []string) *CmdObj {
|
||||
return cmdObj
|
||||
}
|
||||
|
||||
// NewFromCmd wraps a command that has already been built, for a caller that
|
||||
// holds an *exec.Cmd and needs it as a CmdObj. The command itself is shared,
|
||||
// not copied, so whatever was set on it still applies.
|
||||
func (self *CmdObjBuilder) NewFromCmd(cmd *exec.Cmd) *CmdObj {
|
||||
return &CmdObj{
|
||||
cmd: cmd,
|
||||
runner: self.runner,
|
||||
}
|
||||
}
|
||||
|
||||
// A command with explicit environment from env
|
||||
func (self *CmdObjBuilder) NewWithEnviron(args []string, env []string) *CmdObj {
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
|
||||
@@ -3,6 +3,7 @@ package gui
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -85,7 +86,11 @@ func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) er
|
||||
width: width,
|
||||
stdinFilter: stdinFilter,
|
||||
}
|
||||
return gui.newTaskForRender(spec, prefix, cmdStr, gui.ptyRender)
|
||||
run := gui.ptyRender
|
||||
if rendersThroughAPipe() {
|
||||
run = gui.pipedRender
|
||||
}
|
||||
return gui.newTaskForRender(spec, prefix, cmdStr, run)
|
||||
})
|
||||
|
||||
return nil
|
||||
@@ -100,7 +105,8 @@ type (
|
||||
)
|
||||
|
||||
// runRender is a way of running a render's command and getting at its output:
|
||||
// plainly, or in a pty. It returns the functions the task drives the command by.
|
||||
// plainly, in a pty, or through a pipe with the stdin filter as a command of our
|
||||
// own. It returns the functions the task drives the command by.
|
||||
type runRender func(spec renderSpec) (startRender, onCloseRender)
|
||||
|
||||
// newTaskForRender creates the task that reads the render's output into its
|
||||
@@ -116,6 +122,78 @@ func (gui *Gui) newTaskForRender(spec renderSpec, prefix string, key string, run
|
||||
return manager.NewTask(manager.NewCmdTask(start, prefix, linesToRead, onClose), key)
|
||||
}
|
||||
|
||||
// renderWithoutPtyEnvVar makes a render take the piped path on a platform that
|
||||
// would otherwise use a pty, so that tests can exercise it anywhere.
|
||||
const renderWithoutPtyEnvVar = "LAZYGIT_RENDER_WITHOUT_PTY"
|
||||
|
||||
// rendersThroughAPipe reports whether a render feeds the diff renderer the
|
||||
// command's output through a pipe rather than running it in a pty.
|
||||
//
|
||||
// On Windows it has to. ConPTY doesn't pass a command's output through; it
|
||||
// parses it into a screen buffer and re-encodes that for the terminal side,
|
||||
// and it hands a sequence it can't represent there the moment it parses it,
|
||||
// separately from the text around it. So what a renderer writes is not what
|
||||
// lazygit reads. A pipe carries the bytes as the renderer wrote them.
|
||||
//
|
||||
// Everywhere else the pty is kept, since a renderer can read the width it
|
||||
// should lay out to off it, and a configuration that doesn't name a width would
|
||||
// otherwise render at whatever width the renderer falls back to.
|
||||
func rendersThroughAPipe() bool {
|
||||
return runtime.GOOS == "windows" || os.Getenv(renderWithoutPtyEnvVar) != ""
|
||||
}
|
||||
|
||||
// pipedRender feeds the diff renderer the command's output through a pipe.
|
||||
//
|
||||
// A stdin filter becomes a command of our own here, because git only invokes
|
||||
// the one named by GIT_PAGER when it thinks it is talking to a terminal, so
|
||||
// with a pipe the filter would never run. An external diff renderer is git's
|
||||
// own business, named in the environment and run by git per file, so with one
|
||||
// the command runs alone.
|
||||
func (gui *Gui) pipedRender(spec renderSpec) (startRender, onCloseRender) {
|
||||
if spec.stdinFilter == "" {
|
||||
return gui.plainRender(spec)
|
||||
}
|
||||
|
||||
view := spec.view
|
||||
cmd := spec.cmd
|
||||
|
||||
var pipe io.ReadCloser
|
||||
start := func() (tasks.Cmd, io.Reader) {
|
||||
// See the matching call in ptyRender for why this happens here.
|
||||
view.SetContentWidth(spec.width)
|
||||
|
||||
// The filter runs in a plain shell, without lazygit's shell functions
|
||||
// sourced, since that is the shell git would have run it in. It is
|
||||
// handed git's environment for the same reason: as git's child it
|
||||
// would have inherited exactly that.
|
||||
pipeline, reader, err := gui.os.StartPipeline(
|
||||
gui.os.Cmd.NewFromCmd(cmd).DontLog(),
|
||||
gui.os.Cmd.NewShell(spec.stdinFilter, "").SetEnviron(cmd.Env).DontLog(),
|
||||
)
|
||||
if err != nil {
|
||||
gui.c.Log.Error(err)
|
||||
// The command has been started and stopped again by now, so it
|
||||
// can't be run a second time without the renderer. Show what went
|
||||
// wrong where the diff would have been.
|
||||
return tasks.ExecCmd{Cmd: cmd}, strings.NewReader(err.Error())
|
||||
}
|
||||
pipe = reader
|
||||
return pipeline, reader
|
||||
}
|
||||
|
||||
onClose := func() {
|
||||
// Closing the reader brings the pipeline down. The renderer's next write
|
||||
// fails, so it exits, and git's write into the pipe the renderer was
|
||||
// reading fails in turn.
|
||||
if pipe != nil {
|
||||
pipe.Close()
|
||||
pipe = nil
|
||||
}
|
||||
}
|
||||
|
||||
return start, onClose
|
||||
}
|
||||
|
||||
// setColumnsEnvVar tells a command how wide the view its output goes into is.
|
||||
// git reads COLUMNS in preference to the size of the terminal it is talking to,
|
||||
// and lays the diffstat graph out to it; a diff renderer with no terminal to
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package diff
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var RenderThroughAPipe = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "A stdin filter renders the diff when it is fed through a pipe rather than run in a pty",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
// This is how a render works on Windows, where a pty can't carry a
|
||||
// renderer's output faithfully. Ask for it here so that the path is
|
||||
// covered on the platforms the integration tests do run on.
|
||||
ExtraEnvVars: map[string]string{"LAZYGIT_RENDER_WITHOUT_PTY": "1"},
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
// Reports the width it was given, then passes the diff through. git
|
||||
// only runs a filter of its own when it talks to a terminal, so the
|
||||
// filter running at all says the pipeline was built here.
|
||||
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
|
||||
{Command: `echo "rendered at {{width}} columns"; cat`},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\n")
|
||||
shell.Commit("one")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("one").IsSelected(),
|
||||
)
|
||||
|
||||
t.Views().Main().
|
||||
// The width reaches the filter on its command line, since with no
|
||||
// terminal it has nowhere to read it from.
|
||||
Content(MatchesRegexp(`rendered at \d+ columns`)).
|
||||
ContainsLines(
|
||||
Equals("diff --git a/file1 b/file1"),
|
||||
Contains("new file mode"),
|
||||
Contains("index "),
|
||||
Equals("--- /dev/null"),
|
||||
Equals("+++ b/file1"),
|
||||
Equals("@@ -0,0 +1 @@"),
|
||||
Equals("+one"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -230,6 +230,7 @@ var tests = []*components.IntegrationTest{
|
||||
diff.DiffNonStickyRange,
|
||||
diff.IgnoreWhitespace,
|
||||
diff.RenameSimilarityThresholdChange,
|
||||
diff.RenderThroughAPipe,
|
||||
diff.StatUsesTheViewWidth,
|
||||
file.ClickArrowToCollapse,
|
||||
file.CollapseExpand,
|
||||
|
||||
Reference in New Issue
Block a user