diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go index daf710eaa..3fece6235 100644 --- a/pkg/commands/git_commands/diff.go +++ b/pkg/commands/git_commands/diff.go @@ -2,10 +2,135 @@ package git_commands import ( "fmt" + "os" + "strings" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/mgutz/str" ) +// metadataHandshake is the record a diff renderer that speaks the OSC 1717 protocol +// emits before anything else, to announce that it does: a version-only record, with +// none of the fields a line's record has. See ProbeDiffRendererEmitsMetadata, and +// gocui's escape interpreter for how it is kept off the screen on a real render. +const metadataHandshake = "\x1b]1717" + +// probeWidth is the width a probe asks the renderer to lay out to. A probe has +// nothing to render, so any width does, as long as it is one a renderer will +// accept: a renderer that lays its rendering out in columns may well refuse a +// width of zero, and would then never get as far as announcing itself. +const probeWidth = 80 + +// ProbeDiffRendererEmitsMetadata reports whether the configured diff renderer states +// which line of which file it is rendering, by running it on empty input and looking +// for the handshake. The answer decides whether a diff the renderer produced can be +// acted on at all, or has to be replaced by git's own when the user wants to act on it +// (see DiffLineHelper.MainViewDiffMode). +// +// Asking rather than watching a real render: the handshake is the renderer's first +// output whatever the diff, so the answer is a property of the renderer, known before +// we render anything — where watching would have to see a diff go by first, and would +// be fooled by a diff with no lines to describe. +// +// No terminal is needed. git only invokes a stdin filter when it thinks it is talking +// to one, but the renderer itself doesn't care: it announces itself whenever OSC1717 is +// set, so it can be run directly with empty input. +func (self *DiffCommands) ProbeDiffRendererEmitsMetadata() bool { + manager := self.diffRendererConfigManager + values := config.DiffRendererValues{Width: probeWidth, DiffContext: 3} + + switch manager.GetDiffRendererType() { + case config.DiffRendererType_StdinFilter: + if command, err := manager.GetStdinFilterCommand(values); err == nil && command != "" { + return self.probeEmitsMetadata(self.cmd.NewShell(command, "")) + } + case config.DiffRendererType_ExtDiff: + // An empty command means git's own diff.external config, which picks a driver + // per file through .gitattributes: there is no one renderer to ask, and a single + // diff can be produced by several, so we take it that it says nothing. + if command, err := manager.GetExternalDiffCommand(values); err == nil && command != "" { + return self.externalDiffEmitsMetadata(command) + } + case config.DiffRendererType_RawGit: + // git describes only the formats whose output can't be read back as a diff, and + // asked with the renderer's own arguments it answers for exactly the format + // those select: a handshake for a word diff, silence for a unified one. With no + // arguments there is nothing to fall back to anyway, since this already is git's + // own diff. + if args := manager.GetRawGitArgs(); len(args) > 0 { + return self.rawGitEmitsMetadata(args) + } + } + + return false +} + +// rawGitEmitsMetadata asks git itself, run with the diff renderer's own arguments. +func (self *DiffCommands) rawGitEmitsMetadata(rawGitArgs []string) bool { + oldPath, newPath, cleanup, ok := self.probeFiles() + if !ok { + return false + } + defer cleanup() + + return self.probeEmitsMetadata(self.cmd.New( + NewGitCmd("diff"). + Arg("--no-index"). + Arg(rawGitArgs...). + Arg(oldPath, newPath). + ToArgv(), + )) +} + +// externalDiffEmitsMetadata asks an external diff command, invoking it the way git +// invokes one — with the seven positional arguments of git's diff.external convention — +// over two empty files, so that it announces itself without having a diff to render. +func (self *DiffCommands) externalDiffEmitsMetadata(externalDiffCommand string) bool { + oldPath, newPath, cleanup, ok := self.probeFiles() + if !ok { + return false + } + defer cleanup() + + args := append(str.ToArgv(externalDiffCommand), + "probe", oldPath, "0000000", "100644", newPath, "0000000", "100644") + return self.probeEmitsMetadata(self.cmd.New(args)) +} + +// probeFiles makes the two empty files a probe stands a diff up from, and the cleanup +// that removes them. Empty, because what the probe wants is for the renderer to announce +// itself, not for it to have anything to say. +func (self *DiffCommands) probeFiles() (string, string, func(), bool) { + tempDir := self.os.GetTempDir() + + oldFile, err := os.CreateTemp(tempDir, "lazygit-probe-old-*") + if err != nil { + return "", "", nil, false + } + oldFile.Close() + + newFile, err := os.CreateTemp(tempDir, "lazygit-probe-new-*") + if err != nil { + os.Remove(oldFile.Name()) + return "", "", nil, false + } + newFile.Close() + + return oldFile.Name(), newFile.Name(), func() { + os.Remove(oldFile.Name()) + os.Remove(newFile.Name()) + }, true +} + +func (self *DiffCommands) probeEmitsMetadata(cmdObj *oscommands.CmdObj) bool { + cmdObj.AddEnvVars("OSC1717=V1") + // A renderer may well object to being handed nothing to render. We want to know + // whatever it said before objecting, and that is captured either way. + output, _ := cmdObj.RunWithOutput() + return strings.Contains(output, metadataHandshake) +} + type DiffCommands struct { *GitCommon } diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index f59056da1..bb197ae73 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -175,9 +175,10 @@ func (self *CommitFilesController) GetOnRenderToMain() func() { from, to := self.context().GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) + mode := self.c.Helpers().DiffLine.MainViewDiffMode() paths := self.pathsForDiff(node) - cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, git_commands.DiffModeRendered) - task := types.NewRunDiffRendererTask(cmdObj.GetCmd()) + cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, mode) + task := types.NewMainViewDiffTask(cmdObj.GetCmd(), mode) self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 2b9f37064..2828f8a4d 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -380,13 +380,17 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { showStaged := node.GetHasStagedChanges() || alwaysSplit showUnstaged := node.GetHasUnstagedChanges() || alwaysSplit || !showStaged + // While the main view is focused to act on this diff, it may have to be git's own + // rather than the diff renderer's; both panes have to agree about that. + mode := self.c.Helpers().DiffLine.MainViewDiffMode() + paths := self.pathsForDiff(node) refreshOpts := types.RefreshMainOpts{Pair: self.c.MainViewPairs().Normal} if showUnstaged { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, git_commands.DiffModeRendered, false, paths) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, mode, false, paths) refreshOpts.Main = &types.ViewUpdateOpts{ - Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()), + Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode), SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Title: self.c.Tr.UnstagedChanges, NothingToActOn: !node.GetHasUnstagedChanges(), @@ -394,9 +398,9 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { } if showStaged { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, git_commands.DiffModeRendered, true, paths) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, mode, true, paths) refreshOpts.Secondary = &types.ViewUpdateOpts{ - Task: types.NewRunDiffRendererTask(cmdObj.GetCmd()), + Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode), SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Title: self.c.Tr.StagedChanges, NothingToActOn: !node.GetHasStagedChanges(), diff --git a/pkg/gui/controllers/helpers/diff_helper.go b/pkg/gui/controllers/helpers/diff_helper.go index 3a8306fc2..fed353d82 100644 --- a/pkg/gui/controllers/helpers/diff_helper.go +++ b/pkg/gui/controllers/helpers/diff_helper.go @@ -55,6 +55,8 @@ func (self *DiffHelper) DiffArgs() []string { // either there's no range, or it can't be diffed for some reason), then we want // to fall back to rendering the diff for the single commit. func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Commit, refRange *types.RefRange) types.UpdateTask { + mode := self.diffLineHelper.MainViewDiffMode() + if refRange != nil { from, to := refRange.From, refRange.To args := []string{from.ParentRefName(), to.RefName(), "--stat", "-p"} @@ -74,13 +76,13 @@ func (self *DiffHelper) GetUpdateTaskForRenderingCommitsDiff(commit *models.Comm args = append(args, filterPath) } } - cmdObj := self.c.Git().Diff.DiffCmdObj(args, git_commands.DiffModeRendered) + cmdObj := self.c.Git().Diff.DiffCmdObj(args, mode) prefix := style.FgYellow.Sprintf("%s %s-%s\n\n", self.c.Tr.ShowingDiffForRange, from.ShortRefName(), to.ShortRefName()) - return types.NewRunDiffRendererTaskWithPrefix(cmdObj.GetCmd(), prefix) + return types.NewMainViewDiffTaskWithPrefix(cmdObj.GetCmd(), prefix, mode) } - cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit), git_commands.DiffModeRendered) - return types.NewRunDiffRendererTask(cmdObj.GetCmd()) + cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.FilterPathsForCommit(commit), mode) + return types.NewMainViewDiffTask(cmdObj.GetCmd(), mode) } // PlainDiffBetweenRefs returns the diff of the given files between two refs as git diff --git a/pkg/gui/controllers/helpers/diff_line_helper.go b/pkg/gui/controllers/helpers/diff_line_helper.go index 95f95cc59..b26262bba 100644 --- a/pkg/gui/controllers/helpers/diff_line_helper.go +++ b/pkg/gui/controllers/helpers/diff_line_helper.go @@ -9,6 +9,11 @@ import ( type DiffLineHelper struct { c *HelperCommon + + // What the probe said about the diff renderer that rendererSignature names, or nil + // before it has been asked about any (see diffRendererEmitsMetadata). + rendererEmitsMetadata *bool + rendererSignature string } func NewDiffLineHelper(c *HelperCommon) *DiffLineHelper { diff --git a/pkg/gui/controllers/helpers/diff_line_raw_fallback.go b/pkg/gui/controllers/helpers/diff_line_raw_fallback.go new file mode 100644 index 000000000..6f967cd64 --- /dev/null +++ b/pkg/gui/controllers/helpers/diff_line_raw_fallback.go @@ -0,0 +1,112 @@ +package helpers + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/tasks" +) + +// Falling back to git's own diff when the configured one can't be acted on. +// +// A diff renderer is free to lay a diff out however it likes, and once it has, we can +// only tell which line of which file a row shows if the renderer says so. A renderer +// that doesn't produces a diff that can be read but not staged, edited or copied from, +// so when the user focuses the main view to act on it, we show git's own diff instead. +// Browsing keeps the renderer's version; only acting on it needs one we can follow. + +// MainViewDiffMode says how a side panel should produce the diff it renders into the +// main view: as the user configured it, or as git's own — while the main view holds +// focus and what the renderer would produce couldn't be acted on. +// +// Every panel that renders a diff into the main view asks, so that a re-render while +// focused — after staging a hunk, say — stays with git's own diff rather than flipping +// back to the renderer's. +func (self *DiffLineHelper) MainViewDiffMode() git_commands.DiffMode { + if self.mainViewIsFocused() && self.diffNeedsMetadata() && !self.diffRendererEmitsMetadata() { + return git_commands.DiffModeRaw + } + return git_commands.DiffModeRendered +} + +// RenderFocusedMainViewAgain has the panel beneath the focused main view render its +// diff again — which, the main view now holding focus, is git's own diff rather than +// the renderer's — and calls place once that is on screen. +// +// The whole diff is read before it is shown, rather than the first screenful: place +// looks at what is there to decide where to put the selection, and a change line +// further down would otherwise be missed. +// +// It is the same diff of the same files, so the view keeps the scroll position it has +// rather than starting from the top: git lays the changes out its own way and the line +// the user was on is somewhere else now, but the offset still puts them among the same +// part of the file — and the selection is then established from what that leaves on +// screen. +func (self *DiffLineHelper) RenderFocusedMainViewAgain(view *gocui.View, sidePanel types.Context, place func()) { + manager := self.c.GetOrCreateViewBufferManagerForView(view) + if manager == nil { + return + } + + manager.SetKeepScrollPositionForNextTask() + manager.SetRestoreForNextTask(&tasks.RenderRestore{ + FirstPaintReady: func() bool { return false }, + Apply: func(swapIn func()) { + swapIn() + place() + }, + }) + + sidePanel.HandleRenderToMain() +} + +func (self *DiffLineHelper) mainViewIsFocused() bool { + current := self.c.Context().CurrentStatic().GetKey() + return current == self.c.Contexts().Normal.GetKey() || + current == self.c.Contexts().NormalSecondary.GetKey() +} + +// diffNeedsMetadata reports whether the diff we would show is one whose rows can only +// be placed in the file by the records the renderer states. Any custom renderer may +// restructure the diff; so may git itself, once the renderer's arguments ask for a word +// diff, whose markup is inline. Plain git output describes itself, and needs no records. +func (self *DiffLineHelper) diffNeedsMetadata() bool { + manager := self.c.State().GetDiffRendererConfigManager() + if manager.GetDiffRendererType() != config.DiffRendererType_RawGit { + return true + } + return len(manager.GetRawGitArgs()) > 0 +} + +// diffRendererEmitsMetadata is the probed verdict about the current diff renderer, asked +// once and remembered until the renderer changes — the user cycling to another one, or a +// changed config being reloaded. +func (self *DiffLineHelper) diffRendererEmitsMetadata() bool { + signature := self.diffRendererSignature() + if self.rendererEmitsMetadata == nil || signature != self.rendererSignature { + verdict := self.c.Git().Diff.ProbeDiffRendererEmitsMetadata() + self.rendererEmitsMetadata = &verdict + self.rendererSignature = signature + } + return *self.rendererEmitsMetadata +} + +// diffRendererSignature identifies the current diff renderer, so that the remembered +// verdict is dropped when it stops describing the renderer we have. The values a command +// is resolved with are no part of its identity, so fixed ones are used. +func (self *DiffLineHelper) diffRendererSignature() string { + manager := self.c.State().GetDiffRendererConfigManager() + index, _ := manager.CurrentDiffRendererIndex() + values := config.DiffRendererValues{} + externalDiff, _ := manager.GetExternalDiffCommand(values) + stdinFilter, _ := manager.GetStdinFilterCommand(values) + return fmt.Sprintf("%d\x00%s\x00%s\x00%s", + index, + externalDiff, + stdinFilter, + strings.Join(manager.GetRawGitArgs(), "\x00")) +} diff --git a/pkg/gui/controllers/reflog_commits_controller.go b/pkg/gui/controllers/reflog_commits_controller.go index bd000214f..10b641d93 100644 --- a/pkg/gui/controllers/reflog_commits_controller.go +++ b/pkg/gui/controllers/reflog_commits_controller.go @@ -1,7 +1,6 @@ package controllers import ( - "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -60,9 +59,10 @@ func (self *ReflogCommitsController) GetOnRenderToMain() func() { if commit == nil { task = types.NewRenderStringTask("No reflog history") } else { - cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit), git_commands.DiffModeRendered) + mode := self.c.Helpers().DiffLine.MainViewDiffMode() + cmdObj := self.c.Git().Commit.ShowCmdObj(commit.Hash(), self.c.Helpers().Diff.FilterPathsForCommit(commit), mode) - task = types.NewRunDiffRendererTask(cmdObj.GetCmd()) + task = types.NewMainViewDiffTask(cmdObj.GetCmd(), mode) } self.c.RenderToMainViews(types.RefreshMainOpts{ diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 762090ae6..7c0b61c66 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -3,8 +3,6 @@ package controllers import ( "fmt" - "github.com/jesseduffield/lazygit/pkg/commands/git_commands" - "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" @@ -94,10 +92,12 @@ func (self *StashController) GetOnRenderToMain() func() { if stashEntry == nil { task = types.NewRenderStringTask(self.c.Tr.NoStashEntries) } else { + mode := self.c.Helpers().DiffLine.MainViewDiffMode() prefix := style.FgYellow.Sprintf("%s\n\n", stashEntry.Description()) - task = types.NewRunDiffRendererTaskWithPrefix( - self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index, git_commands.DiffModeRendered).GetCmd(), + task = types.NewMainViewDiffTaskWithPrefix( + self.c.Git().Stash.ShowStashEntryCmdObj(stashEntry.Index, mode).GetCmd(), prefix, + mode, ) } 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 189d3c6ac..b2e85914c 100644 --- a/pkg/gui/controllers/switch_to_focused_main_view_controller.go +++ b/pkg/gui/controllers/switch_to_focused_main_view_controller.go @@ -1,6 +1,7 @@ package controllers import ( + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -83,9 +84,22 @@ func (self *SwitchToFocusedMainViewController) focusMainView(mainViewContext *co mainViewContext.ClearSearchString() self.c.Context().Push(mainViewContext, types.OnFocusOpts{}) - if _, ok := self.context.(types.DiffMainViewContext); ok { - self.c.Helpers().DiffLine.EstablishSelection(mainViewContext, clickedLineIdx) + if _, ok := self.context.(types.DiffMainViewContext); !ok { + return nil } + // The diff on screen was produced for reading, and the renderer that produced it may + // have laid it out in a way that says nothing about which line of which file each row + // is. Now that the user wants to act on it, it is re-rendered as git's own diff — the + // panel below decides that for itself, from the same question — and the selection + // goes on that instead of on rows we can't place. + if self.c.Helpers().DiffLine.MainViewDiffMode() == git_commands.DiffModeRaw { + self.c.Helpers().DiffLine.RenderFocusedMainViewAgain(mainViewContext.GetView(), self.context, func() { + self.c.Helpers().DiffLine.EstablishSelection(mainViewContext, clickedLineIdx) + }) + return nil + } + + self.c.Helpers().DiffLine.EstablishSelection(mainViewContext, clickedLineIdx) return nil } diff --git a/pkg/gui/types/rendering.go b/pkg/gui/types/rendering.go index f88a207b6..6f791d1bf 100644 --- a/pkg/gui/types/rendering.go +++ b/pkg/gui/types/rendering.go @@ -2,6 +2,8 @@ package types import ( "os/exec" + + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" ) type MainContextPair struct { @@ -116,3 +118,18 @@ func NewRunDiffRendererTask(cmd *exec.Cmd) *RunDiffRendererTask { func NewRunDiffRendererTaskWithPrefix(cmd *exec.Cmd, prefix string) *RunDiffRendererTask { return &RunDiffRendererTask{Cmd: cmd, Prefix: prefix} } + +// NewMainViewDiffTask returns the task for rendering a diff into a main view. A diff +// normally goes through the diff renderer, however the render arranges to feed it. A +// diff we are producing with git itself, because the renderer's version of it couldn't +// be acted on, has to keep the renderer out, so it runs as a plain command instead. +func NewMainViewDiffTask(cmd *exec.Cmd, mode git_commands.DiffMode) UpdateTask { + return NewMainViewDiffTaskWithPrefix(cmd, "", mode) +} + +func NewMainViewDiffTaskWithPrefix(cmd *exec.Cmd, prefix string, mode git_commands.DiffMode) UpdateTask { + if mode == git_commands.DiffModeRaw { + return NewRunCommandTaskWithPrefix(cmd, prefix) + } + return NewRunDiffRendererTaskWithPrefix(cmd, prefix) +} diff --git a/pkg/integration/tests/main_view/copy_selected_diff_lines.go b/pkg/integration/tests/main_view/copy_selected_diff_lines.go index 812f4e8fe..26ac538c7 100644 --- a/pkg/integration/tests/main_view/copy_selected_diff_lines.go +++ b/pkg/integration/tests/main_view/copy_selected_diff_lines.go @@ -23,11 +23,13 @@ var CopySelectedDiffLines = NewIntegrationTest(NewIntegrationTestArgs{ // Emulate the clipboard by writing to a file. cfg.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard" // A renderer that decorates every line of a diff's body, so that what is on - // screen is not what the diff says. It reads the +/- column, so it wants its - // input uncoloured. + // screen is not what the diff says. It announces the metadata protocol, so that + // its output is taken at its word rather than replaced by git's own; and it + // reads the +/- column, so it wants its input uncoloured. cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ { - Command: `awk '/^@@/ { body = 1 } body && /^[-+ ]/ { print $0 " <<<"; next } { print }'`, + Command: `printf '\033]1717;1\007'; ` + + `awk '/^@@/ { body = 1 } body && /^[-+ ]/ { print $0 " <<<"; next } { print }'`, ColorArg: "never", }, } diff --git a/pkg/integration/tests/main_view/keep_position_when_switching_diff_renderers.go b/pkg/integration/tests/main_view/keep_position_when_switching_diff_renderers.go index 7241ac9bf..b99917a52 100644 --- a/pkg/integration/tests/main_view/keep_position_when_switching_diff_renderers.go +++ b/pkg/integration/tests/main_view/keep_position_when_switching_diff_renderers.go @@ -16,11 +16,14 @@ var KeepPositionWhenSwitchingDiffRenderers = NewIntegrationTest(NewIntegrationTe Height: 30, SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false + // Both announce the metadata protocol, so that their output is taken at its + // word and shown as it is; a renderer that says nothing about what it renders + // is replaced by git's own diff as soon as the main view is focused. cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ - {Name: "plain", Command: "cat"}, + {Name: "plain", Command: `printf '\033]1717;1\007'; cat`}, // The same diff, three lines further down the view. (Lines before the // diff's own header aren't part of it, so it still reads the same.) - {Name: "banner", Command: `printf 'rendered for you\n\n\n'; cat`}, + {Name: "banner", Command: `printf '\033]1717;1\007rendered for you\n\n\n'; cat`}, } }, SetupRepo: func(shell *Shell) { diff --git a/pkg/integration/tests/main_view/keep_selection_visible_when_diff_shrinks.go b/pkg/integration/tests/main_view/keep_selection_visible_when_diff_shrinks.go index 0e8690a0c..657e62e21 100644 --- a/pkg/integration/tests/main_view/keep_selection_visible_when_diff_shrinks.go +++ b/pkg/integration/tests/main_view/keep_selection_visible_when_diff_shrinks.go @@ -12,14 +12,15 @@ var KeepSelectionVisibleWhenDiffShrinks = NewIntegrationTest(NewIntegrationTestA SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ - {Command: "cat"}, + {Name: "plain", Command: `printf '\033]1717;1\007'; cat`}, // The same diff in fewer lines, as a renderer that collapses or elides // parts of it would give us: the addition at the end goes, and the hunk // header says so, since a diff that contradicts its own header can't be // read as one. (It has to read all of its input: one that exits early // leaves the render looking like it is still loading, which holds off the // clamping this test is about.) - {Name: "shrinking", Command: `sed -e 's/@@ -1,5 +1,5 @@/@@ -1,5 +1,4 @@/' -e '$d'`}, + {Name: "shrinking", Command: `printf '\033]1717;1\007'; ` + + `sed -e 's/@@ -1,5 +1,5 @@/@@ -1,5 +1,4 @@/' -e '$d'`}, } }, SetupRepo: func(shell *Shell) { diff --git a/pkg/integration/tests/main_view/raw_fallback_under_an_external_diff.go b/pkg/integration/tests/main_view/raw_fallback_under_an_external_diff.go new file mode 100644 index 000000000..4d46f5d74 --- /dev/null +++ b/pkg/integration/tests/main_view/raw_fallback_under_an_external_diff.go @@ -0,0 +1,63 @@ +package main_view + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RawFallbackUnderAnExternalDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Focusing the main view under an external diff that says nothing about its rows brings git's own diff, keeping the scroll position", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false + // An external diff whose output has nothing to do with the diff it was given, + // let alone anything to say about which line of which file each row shows. It + // is long enough to be scrolled about in. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Name: "opaque", Type: "extDiff", Command: `sh -c 'seq -f "EXT-%g" 40'`}, + } + }, + SetupRepo: func(shell *Shell) { + lines := make([]string, 40) + for i := range lines { + lines[i] = fmt.Sprintf("line%02d", i+1) + } + shell.CreateFileAndAdd("file1", strings.Join(lines, "\n")+"\n") + shell.Commit("one") + + for _, i := range []int{5, 15, 25, 35} { + lines[i-1] = strings.ToUpper(lines[i-1]) + } + shell.UpdateFile("file1", strings.Join(lines, "\n")+"\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Browsing shows what the renderer produced, whatever that is. + t.Views().Main(). + Content(Contains("EXT-1")). + Tap(func() { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain). + Press(keys.Universal.ScrollDownMain) + }). + OriginY(6). + Tap(func() { + t.Views().Files().Press(keys.Universal.FocusMainView) + }). + IsFocused(). + // Focusing the view to act on it brings git's own diff instead — the + // renderer's rows can't be placed in the file — and leaves the view at the + // offset it was at, rather than at the top. + Content(Contains("+LINE05")). + Content(DoesNotContain("EXT-1")). + OriginY(6). + SelectionIsActive() + }, +}) diff --git a/pkg/integration/tests/main_view/stage_under_conforming_diff_renderer.go b/pkg/integration/tests/main_view/stage_under_conforming_diff_renderer.go new file mode 100644 index 000000000..3b5d24c59 --- /dev/null +++ b/pkg/integration/tests/main_view/stage_under_conforming_diff_renderer.go @@ -0,0 +1,55 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageUnderConformingDiffRenderer = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A diff renderer that announces the metadata protocol is taken at its word, so its diff is what stays on screen when the main view is focused", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false + // A renderer that announces the protocol with a version-only record before + // anything else, then says who it is and passes the diff through. The diff it + // passes through keeps its structure, so the rows can be placed by reading it. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Command: `printf '\033]1717;1\007RENDERED BY ME\n'; cat`}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files().IsFocused() + + // The announcement itself leaves nothing on the screen; the line after it shows + // whose output this is. + t.Views().Main().Content(Contains("RENDERED BY ME")) + + t.Views().Files().Press(keys.Universal.FocusMainView) + + // Focusing left the renderer's output alone, and the selection went on it. + t.Views().Main(). + IsFocused(). + Content(Contains("RENDERED BY ME")). + SelectedLines( + Contains("-three"), + ). + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-three"), + Contains("+THREE"), + ). + PressPrimaryAction() + + t.Views().Secondary().ContainsLines( + Contains("-three"), + Contains("+THREE"), + ) + }, +}) diff --git a/pkg/integration/tests/main_view/stage_under_unsupported_diff_renderer.go b/pkg/integration/tests/main_view/stage_under_unsupported_diff_renderer.go new file mode 100644 index 000000000..d229f0ab6 --- /dev/null +++ b/pkg/integration/tests/main_view/stage_under_unsupported_diff_renderer.go @@ -0,0 +1,71 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageUnderUnsupportedDiffRenderer = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A diff renderer that says nothing about what it renders is replaced by git's own diff when the main view is focused, so the diff can still be staged from", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false + // `cat -n` numbers every line, which pushes the +/- column off the start of it: + // the diff can't be read back from the text, and cat says nothing about what it + // is rendering, so there is no way to act on what it produces. + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + {Command: "cat -n"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("one") + + shell.UpdateFile("file1", "one\ntwo\nTHREE\nfour\nfive\nsix\nseven\neight\nNINE\nten\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // While browsing, the view shows the renderer's output. + t.Views().Files(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ) + t.Views().Main().Content(Contains("1 diff --git a/file1 b/file1")) + + // Focusing it to act on it brings git's own diff instead, and the selection goes + // on that. + t.Views().Files().Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + Content(DoesNotContain("1 diff --git")). + SelectedLines( + Contains("-three"), + ). + Press(keys.Main.ToggleSelectHunk). + SelectedLines( + Contains("-three"), + Contains("+THREE"), + ). + PressPrimaryAction() + + t.Views().Secondary().ContainsLines( + Contains("-three"), + Contains("+THREE"), + ) + // The re-render after staging stays with git's diff, so the next hunk can be + // staged as well. + t.Views().Main(). + IsFocused(). + SelectedLines( + Contains("-nine"), + Contains("+NINE"), + ). + PressPrimaryAction() + + t.Views().Files().Lines( + Contains("M file1"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 79f42c278..6add968cf 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -403,6 +403,7 @@ var tests = []*components.IntegrationTest{ main_view.NoSelectionOverACommitLog, main_view.NoSelectionWhenNoChanges, main_view.RangeSelectDiffLines, + main_view.RawFallbackUnderAnExternalDiff, main_view.SearchCollapsesTheSelection, main_view.SearchFollowsTheSelection, main_view.SelectBelowALongCommitMessage, @@ -422,6 +423,8 @@ var tests = []*components.IntegrationTest{ main_view.StageDiffLines, main_view.StageDiffLinesOfAPathWithASpace, main_view.StageRangeSpanningFiles, + main_view.StageUnderConformingDiffRenderer, + main_view.StageUnderUnsupportedDiffRenderer, main_view.UnstageDiffLines, misc.ConfirmOnQuit, misc.CopyConfirmationMessageToClipboard,