mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-04 13:06:51 -04:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9c1403ab9 | ||
|
|
02831c7157 | ||
|
|
d920db5129 | ||
|
|
c38e85045a | ||
|
|
caeba3799f | ||
|
|
68cb50a986 | ||
|
|
e4ebbc7463 | ||
|
|
aa797aa51f | ||
|
|
40ca85c108 | ||
|
|
f802afa0ee |
@@ -139,6 +139,11 @@ func NewGitCommandAux(
|
||||
// A patch is built from git's own diff: what a diff renderer would make of it
|
||||
// is a picture of it, not something that can be applied.
|
||||
return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, git_commands.DiffModePlain)
|
||||
},
|
||||
func() (string, error) {
|
||||
// Under lazygit's own temp dir, so that it honours the configured location
|
||||
// and is cleaned up with everything else when we exit.
|
||||
return os.MkdirTemp(osCommand.GetTempDir(), "custom-patch-")
|
||||
})
|
||||
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
|
||||
bisectCommands := git_commands.NewBisectCommands(gitCommon)
|
||||
|
||||
@@ -156,6 +156,28 @@ func (self *DiffCommands) DiffCmdObj(diffArgs []string, mode DiffMode) *oscomman
|
||||
)
|
||||
}
|
||||
|
||||
// CustomPatchDiffCmdObj is the command that renders the custom patch being built: a diff
|
||||
// of the two trees the patch was materialized into (PatchCommands.WriteCustomPatchDiffTrees),
|
||||
// under the directory holding them. It goes through the same wiring as any other diff we
|
||||
// show, so the patch is rendered by whatever renders the rest of them, and git works out
|
||||
// how much context to give it.
|
||||
//
|
||||
// git's own path prefixes are suppressed because the trees are named a and b themselves,
|
||||
// which leaves the paths reading like an ordinary diff's over the repo's own paths.
|
||||
func (self *DiffCommands) CustomPatchDiffCmdObj(dir string, mode DiffMode) *oscommands.CmdObj {
|
||||
return self.cmd.New(
|
||||
NewGitCmd("diff").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), mode).
|
||||
NoLineEndingConversion().
|
||||
Arg("--no-index").
|
||||
Arg("--no-prefix").
|
||||
Arg(fmt.Sprintf("--color=%s", mode.colorArg(self.diffRendererConfigManager))).
|
||||
Arg("a", "b").
|
||||
Dir(dir).
|
||||
ToArgv(),
|
||||
)
|
||||
}
|
||||
|
||||
// This is a basic generic diff command that can be used for any diff operation
|
||||
// (e.g. copying a diff to the clipboard). It will not use a custom diff renderer,
|
||||
// and does not use user configs such as ignore whitespace.
|
||||
|
||||
@@ -123,6 +123,27 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand
|
||||
return self
|
||||
}
|
||||
|
||||
// NoLineEndingConversion keeps git's line-ending machinery away from files that are not
|
||||
// a working tree's. The trees the custom patch is materialized into hold the bytes git
|
||||
// states the patch in, so a command over them has to read and write those bytes as they
|
||||
// are. On a machine that checks files out with CRLF, `git apply` writes the after tree
|
||||
// in that form while the before tree keeps the LF it was written with. git's own diff
|
||||
// converts both back, but it warns about a round trip through a working tree these
|
||||
// files never belong to, and an external diff renderer is handed the two files as they
|
||||
// stand, one line ending apart in every line.
|
||||
func (self *GitCommandBuilder) NoLineEndingConversion() *GitCommandBuilder {
|
||||
return self.
|
||||
// The setting that converts on most machines, and the one Git for Windows
|
||||
// installs itself with.
|
||||
Config("core.autocrlf=false").
|
||||
// An attributes file outside the repo can still mark the files as text. The
|
||||
// form to keep them in is then the form they are written in.
|
||||
Config("core.eol=lf").
|
||||
// An attribute naming CRLF outright overrides that, and git converts after all.
|
||||
// The warning it gives is about a checkout these files never have.
|
||||
Config("core.safecrlf=false")
|
||||
}
|
||||
|
||||
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, mode DiffMode) *GitCommandBuilder {
|
||||
contextSize := userConfig.Git.DiffContextSize
|
||||
useExtDiff := mode == DiffModeRendered && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
|
||||
|
||||
@@ -2,13 +2,16 @@ package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/app/daemon"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stefanhaller/git-todo-parser/todo"
|
||||
)
|
||||
|
||||
@@ -20,6 +23,10 @@ type PatchCommands struct {
|
||||
stash *StashCommands
|
||||
|
||||
PatchBuilder *patch.PatchBuilder
|
||||
|
||||
// The version of the patch the diff trees were last written for, so that they are
|
||||
// written again when, and only when, the patch has changed since.
|
||||
treesWrittenForGeneration int
|
||||
}
|
||||
|
||||
func NewPatchCommands(
|
||||
@@ -40,6 +47,88 @@ func NewPatchCommands(
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureCustomPatchDiffTrees writes the custom patch's diff trees if what is there no
|
||||
// longer describes the patch. Call it before rendering the patch, which is often — every
|
||||
// time the panel showing it re-renders — while the patch itself changes rarely.
|
||||
func (self *PatchCommands) EnsureCustomPatchDiffTrees() error {
|
||||
if self.PatchBuilder.Generation() == self.treesWrittenForGeneration {
|
||||
return nil
|
||||
}
|
||||
if err := self.WriteCustomPatchDiffTrees(); err != nil {
|
||||
return err
|
||||
}
|
||||
self.treesWrittenForGeneration = self.PatchBuilder.Generation()
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteCustomPatchDiffTrees materializes the custom patch as two file trees under the
|
||||
// directory the patch builder keeps for it: `a` holds each of the patch's files as it is
|
||||
// before the patch, `b` as it is after. Diffing those two trees against each other
|
||||
// (DiffCommands.CustomPatchDiffCmdObj) turns the patch into a diff of real files, which
|
||||
// can then be rendered exactly as any other diff is — through a diff renderer of any
|
||||
// kind, and with git's own idea of how much context to show.
|
||||
//
|
||||
// The trees are named a and b so that the diff's paths, with git's own prefixes
|
||||
// suppressed, come out reading like the a/ and b/ of an ordinary diff, over the real
|
||||
// repo-relative paths.
|
||||
func (self *PatchCommands) WriteCustomPatchDiffTrees() error {
|
||||
dir := self.PatchBuilder.TempDir()
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
before := filepath.Join(dir, "a")
|
||||
after := filepath.Join(dir, "b")
|
||||
for _, tree := range []string{before, after} {
|
||||
if err := os.RemoveAll(tree); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(tree, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, file := range self.PatchBuilder.FilesInPatch() {
|
||||
content, err := self.commit.ShowFileContentCmdObj(self.PatchBuilder.From, file.ContentPath).RunWithOutput()
|
||||
// A file the patch adds has no content on the before side, so git has nothing to
|
||||
// show for it.
|
||||
added := err != nil
|
||||
|
||||
// The before side holds an added file as an empty file rather than not at all, so
|
||||
// that the diff pairs the two sides up and states the file's real path, instead of
|
||||
// reporting a file that only one of the trees has.
|
||||
if err := self.os.CreateFileWithContent(filepath.Join(before, file.Path),
|
||||
lo.Ternary(added, "", content)); err != nil {
|
||||
return err
|
||||
}
|
||||
// The after side is seeded with the same content, for the patch to change; a file
|
||||
// the patch adds is left absent, for the patch to create.
|
||||
if !added {
|
||||
if err := self.os.CreateFileWithContent(filepath.Join(after, file.Path), content); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write added files as creations rather than as diffs against an empty file: the
|
||||
// patch is applied in one go, so a file it expects to be there already would make
|
||||
// the whole of it fail.
|
||||
patchText := self.PatchBuilder.PatchToApply(false, false)
|
||||
if strings.TrimSpace(patchText) == "" {
|
||||
// Nothing in the patch, so the two trees are alike and the diff is empty.
|
||||
return nil
|
||||
}
|
||||
patchFilePath, err := self.SaveTemporaryPatch(patchText)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return self.cmd.New(NewGitCmd("apply").
|
||||
NoLineEndingConversion().
|
||||
Arg(patchFilePath).
|
||||
Dir(after).
|
||||
ToArgv()).Run()
|
||||
}
|
||||
|
||||
type ApplyPatchOpts struct {
|
||||
ThreeWay bool
|
||||
Cached bool
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/generics/maps"
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/samber/lo"
|
||||
"github.com/sasha-s/go-deadlock"
|
||||
"github.com/sirupsen/logrus"
|
||||
@@ -60,12 +62,27 @@ type PatchBuilder struct {
|
||||
|
||||
// loadFileDiff loads the diff of a file, for a given to (typically a commit hash)
|
||||
loadFileDiff loadFileDiffFunc
|
||||
|
||||
// newTempDir makes a directory for the current patch to be materialized into, as
|
||||
// two file trees that can be diffed against each other and so rendered like any
|
||||
// other diff (see PatchCommands.WriteCustomPatchDiffTrees). Its lifetime is the
|
||||
// patch's: made when one is started, removed when it is given up.
|
||||
newTempDir func() (string, error)
|
||||
tempDir string
|
||||
|
||||
// generation counts the changes made to the patch, so that whoever materializes it
|
||||
// can tell whether what they last built still describes it — and rebuild only then,
|
||||
// rather than on every render of it.
|
||||
generation int
|
||||
}
|
||||
|
||||
func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBuilder {
|
||||
func NewPatchBuilder(
|
||||
log *logrus.Entry, loadFileDiff loadFileDiffFunc, newTempDir func() (string, error),
|
||||
) *PatchBuilder {
|
||||
return &PatchBuilder{
|
||||
Log: log,
|
||||
loadFileDiff: loadFileDiff,
|
||||
newTempDir: newTempDir,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +90,9 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
p.generation++
|
||||
p.makeTempDir()
|
||||
|
||||
p.To = to
|
||||
p.From = from
|
||||
p.reverse = reverse
|
||||
@@ -91,6 +111,89 @@ func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo {
|
||||
return p.fileInfoMap
|
||||
}
|
||||
|
||||
// TempDir is the directory the patch is materialized into for rendering, and "" when
|
||||
// there is none — no patch, or a directory we failed to make.
|
||||
func (p *PatchBuilder) TempDir() string {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
return p.tempDir
|
||||
}
|
||||
|
||||
// Generation says which version of the patch this is; see the field.
|
||||
func (p *PatchBuilder) Generation() int {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
return p.generation
|
||||
}
|
||||
|
||||
// makeTempDir replaces the directory the patch is materialized into with a fresh one.
|
||||
// Only call this with the lock held.
|
||||
func (p *PatchBuilder) makeTempDir() {
|
||||
p.removeTempDir()
|
||||
if p.newTempDir == nil {
|
||||
return
|
||||
}
|
||||
dir, err := p.newTempDir()
|
||||
if err != nil {
|
||||
p.Log.Error(err)
|
||||
return
|
||||
}
|
||||
p.tempDir = dir
|
||||
}
|
||||
|
||||
// removeTempDir takes the patch's materialized form away with the patch. Only call this
|
||||
// with the lock held.
|
||||
func (p *PatchBuilder) removeTempDir() {
|
||||
if p.tempDir == "" {
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(p.tempDir); err != nil {
|
||||
p.Log.Error(err)
|
||||
}
|
||||
p.tempDir = ""
|
||||
}
|
||||
|
||||
// PatchFile records what materializing the patch needs to know about one of its files:
|
||||
// where the patch expects to find it, and where its content before the patch comes from.
|
||||
type PatchFile struct {
|
||||
// Path is the name the patch knows the file by: for a renamed file, the name it had
|
||||
// before where the patch carries the rename, and the name it was renamed to where the
|
||||
// patch keeps only a content change and leaves the rename behind.
|
||||
Path string
|
||||
// ContentPath is where the file's content before the patch is to be found in the
|
||||
// commit the patch is built from — for a renamed file always the name it had there,
|
||||
// whatever the patch calls it.
|
||||
ContentPath string
|
||||
}
|
||||
|
||||
// FilesInPatch says which files the patch touches, in a stable order, and where each of
|
||||
// them comes from.
|
||||
func (p *PatchBuilder) FilesInPatch() []PatchFile {
|
||||
fileInfoMap := p.snapshotFileInfoMap()
|
||||
|
||||
filenames := maps.Keys(fileInfoMap)
|
||||
sort.Strings(filenames)
|
||||
|
||||
files := make([]PatchFile, 0, len(filenames))
|
||||
for _, filename := range filenames {
|
||||
info := fileInfoMap[filename]
|
||||
if info.mode == UNSELECTED {
|
||||
continue
|
||||
}
|
||||
file := PatchFile{Path: filename, ContentPath: filename}
|
||||
if info.previousPath != "" {
|
||||
file.ContentPath = info.previousPath
|
||||
if info.mode == WHOLE {
|
||||
file.Path = info.previousPath
|
||||
}
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string {
|
||||
var patch strings.Builder
|
||||
|
||||
@@ -135,6 +238,7 @@ func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error
|
||||
return err
|
||||
}
|
||||
|
||||
p.generation++
|
||||
p.addFileWhole(info)
|
||||
|
||||
return nil
|
||||
@@ -146,6 +250,7 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
p.generation++
|
||||
p.removeFile(info)
|
||||
|
||||
return nil
|
||||
@@ -182,6 +287,7 @@ func (p *PatchBuilder) AddFileLineRange(filename string, previousPath string, li
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.generation++
|
||||
info.mode = PART
|
||||
info.includedLineIndices = lo.Union(info.includedLineIndices, lineIndices)
|
||||
|
||||
@@ -193,6 +299,7 @@ func (p *PatchBuilder) RemoveFileLineRange(filename string, previousPath string,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.generation++
|
||||
info.mode = PART
|
||||
info.includedLineIndices, _ = lo.Difference(info.includedLineIndices, lineIndices)
|
||||
if len(info.includedLineIndices) == 0 {
|
||||
@@ -291,6 +398,104 @@ func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus
|
||||
return info.mode
|
||||
}
|
||||
|
||||
// LineIdentity says which change line of a file is meant — the line number it has on
|
||||
// the side it belongs to, and whether it is a deletion — without reference to where
|
||||
// that line sits in the file's parsed diff.
|
||||
//
|
||||
// It is how a diff shown in the main view speaks about its lines: what a rendered row
|
||||
// resolves to is a line of a file, while the index of that line in the diff depends on
|
||||
// how much of the diff is being shown and in what order a renderer laid it out.
|
||||
type LineIdentity struct {
|
||||
LineNumber int
|
||||
IsDeletion bool
|
||||
}
|
||||
|
||||
// ChangeLineIndexByIdentity indexes a parsed diff's change lines by their identity. An
|
||||
// addition is numbered in the new file and a deletion in the old one. Two consecutive
|
||||
// deletions share the one new-file position between them, and numbering them in the
|
||||
// old file keeps them apart.
|
||||
func ChangeLineIndexByIdentity(parsed *Patch) map[LineIdentity]int {
|
||||
byIdentity := map[LineIdentity]int{}
|
||||
for idx, line := range parsed.Lines() {
|
||||
switch {
|
||||
case line.IsAddition():
|
||||
byIdentity[LineIdentity{parsed.LineNumberOfLine(idx), false}] = idx
|
||||
case line.IsDeletion():
|
||||
byIdentity[LineIdentity{parsed.OldLineNumberOfLine(idx), true}] = idx
|
||||
}
|
||||
}
|
||||
return byIdentity
|
||||
}
|
||||
|
||||
// ChangeLineIndicesForLines maps the given change lines of a parsed diff to their
|
||||
// indices in it. A line that names no change line of the diff — a context line, or a
|
||||
// line that isn't in the diff at all — contributes nothing.
|
||||
func ChangeLineIndicesForLines(parsed *Patch, lines []LineIdentity) []int {
|
||||
byIdentity := ChangeLineIndexByIdentity(parsed)
|
||||
indices := make([]int, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if idx, ok := byIdentity[line]; ok {
|
||||
indices = append(indices, idx)
|
||||
}
|
||||
}
|
||||
return indices
|
||||
}
|
||||
|
||||
// PatchLineIndicesForLines maps change lines of filename to their indices in that
|
||||
// file's diff, which is what the patch is built in terms of.
|
||||
func (p *PatchBuilder) PatchLineIndicesForLines(
|
||||
filename string, previousPath string, lines []LineIdentity,
|
||||
) ([]int, error) {
|
||||
info, err := p.getFileInfo(filename, previousPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ChangeLineIndicesForLines(Parse(info.diff), lines), nil
|
||||
}
|
||||
|
||||
// IncludedLineIdentities says which change lines of filename are in the patch, as the
|
||||
// identities a diff of that file shown anywhere can be compared against. Empty for a
|
||||
// file that is no part of the patch.
|
||||
func (p *PatchBuilder) IncludedLineIdentities(filename string) []LineIdentity {
|
||||
info, ok := p.snapshotFileInfoMap()[filename]
|
||||
if !ok || info.mode == UNSELECTED {
|
||||
return nil
|
||||
}
|
||||
|
||||
included := set.NewFromSlice(info.includedLineIndices)
|
||||
identities := []LineIdentity{}
|
||||
for identity, idx := range ChangeLineIndexByIdentity(Parse(info.diff)) {
|
||||
if included.Includes(idx) {
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
// IncludedChangeLineIndices says which of filename's change lines are in the patch, as
|
||||
// their indices in the file's diff and in the order the file has them.
|
||||
//
|
||||
// It is how a line of the patch as it is shown names the line of the diff it came from:
|
||||
// all that can be said about a line of the patch is which of the file's changes it is,
|
||||
// its line numbers being the patch's own — a patch that leaves an earlier addition out
|
||||
// numbers everything after it differently from the diff it was built from.
|
||||
func (p *PatchBuilder) IncludedChangeLineIndices(filename string) []int {
|
||||
info, ok := p.snapshotFileInfoMap()[filename]
|
||||
if !ok || info.mode == UNSELECTED {
|
||||
return nil
|
||||
}
|
||||
|
||||
included := set.NewFromSlice(info.includedLineIndices)
|
||||
indices := []int{}
|
||||
for idx, line := range Parse(info.diff).Lines() {
|
||||
if (line.IsAddition() || line.IsDeletion()) && included.Includes(idx) {
|
||||
indices = append(indices, idx)
|
||||
}
|
||||
}
|
||||
return indices
|
||||
}
|
||||
|
||||
func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath string) ([]int, error) {
|
||||
info, err := p.getFileInfo(filename, previousPath)
|
||||
if err != nil {
|
||||
@@ -304,6 +509,9 @@ func (p *PatchBuilder) Reset() {
|
||||
p.mutex.Lock()
|
||||
defer p.mutex.Unlock()
|
||||
|
||||
p.generation++
|
||||
p.removeTempDir()
|
||||
|
||||
p.To = ""
|
||||
p.fileInfoMap = map[string]*fileInfo{}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// newTestPatchBuilder returns a patch builder started for a dummy commit, in which
|
||||
// every file's diff is the given one.
|
||||
func newTestPatchBuilder(diff string) *PatchBuilder {
|
||||
patchBuilder := NewPatchBuilder(logrus.New().WithField("test", "test"),
|
||||
func(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
|
||||
return diff, nil
|
||||
},
|
||||
// Nothing here renders the patch, so it needs no directory to be
|
||||
// materialized into.
|
||||
nil)
|
||||
patchBuilder.Start("from", "to", false, true)
|
||||
return patchBuilder
|
||||
}
|
||||
|
||||
// In simpleDiff the deletion "-orange" is line index 6 of the parsed diff (line 2 of
|
||||
// the old file) and the addition "+grape" is index 7 (line 2 of the new file).
|
||||
func TestPatchLineIndicesForLines(t *testing.T) {
|
||||
patchBuilder := newTestPatchBuilder(simpleDiff)
|
||||
|
||||
indices, err := patchBuilder.PatchLineIndicesForLines("filename", "", []LineIdentity{
|
||||
{LineNumber: 2, IsDeletion: true}, // -orange
|
||||
{LineNumber: 2, IsDeletion: false}, // +grape
|
||||
{LineNumber: 1, IsDeletion: false}, // " apple", a context line
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []int{6, 7}, indices, "the context line names no change line")
|
||||
}
|
||||
|
||||
// A renamed file's rename header makes its change lines sit further down the diff, and
|
||||
// its old-file line numbers are of the file under its previous name.
|
||||
func TestPatchLineIndicesForLinesOfARenamedFile(t *testing.T) {
|
||||
patchBuilder := newTestPatchBuilder(renameWithModificationDiff)
|
||||
|
||||
indices, err := patchBuilder.PatchLineIndicesForLines("newname", "oldname", []LineIdentity{
|
||||
{LineNumber: 2, IsDeletion: true}, // -orange
|
||||
{LineNumber: 2, IsDeletion: false}, // +grape
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []int{9, 10}, indices)
|
||||
}
|
||||
|
||||
func TestIncludedLineIdentities(t *testing.T) {
|
||||
patchBuilder := newTestPatchBuilder(simpleDiff)
|
||||
|
||||
// A file no part of the patch has nothing included.
|
||||
assert.Empty(t, patchBuilder.IncludedLineIdentities("filename"))
|
||||
|
||||
// With only the deletion in, only its identity comes back.
|
||||
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6}))
|
||||
assert.Equal(t,
|
||||
[]LineIdentity{{LineNumber: 2, IsDeletion: true}},
|
||||
patchBuilder.IncludedLineIdentities("filename"))
|
||||
|
||||
// With the addition in as well, both do.
|
||||
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{7}))
|
||||
assert.ElementsMatch(t,
|
||||
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
|
||||
patchBuilder.IncludedLineIdentities("filename"))
|
||||
}
|
||||
|
||||
func TestFilesInPatch(t *testing.T) {
|
||||
patchBuilder := newTestPatchBuilder(simpleDiff)
|
||||
|
||||
// A file no part of the patch is no part of what the patch is materialized from.
|
||||
assert.Empty(t, patchBuilder.FilesInPatch())
|
||||
|
||||
assert.NoError(t, patchBuilder.AddFileLineRange("filename", "", []int{6}))
|
||||
assert.Equal(t,
|
||||
[]PatchFile{{Path: "filename", ContentPath: "filename"}},
|
||||
patchBuilder.FilesInPatch())
|
||||
}
|
||||
|
||||
// A renamed file's content is under the name it had before whatever the patch calls the
|
||||
// file, and the patch calls it by the name it had before only where it carries the
|
||||
// rename — a partial selection has the rename stripped and names the file by the new one.
|
||||
func TestFilesInPatchOfARenamedFile(t *testing.T) {
|
||||
patchBuilder := newTestPatchBuilder(renameWithModificationDiff)
|
||||
|
||||
assert.NoError(t, patchBuilder.AddFileLineRange("newname", "oldname", []int{9}))
|
||||
assert.Equal(t,
|
||||
[]PatchFile{{Path: "newname", ContentPath: "oldname"}},
|
||||
patchBuilder.FilesInPatch())
|
||||
|
||||
assert.NoError(t, patchBuilder.AddFileWhole("newname", "oldname"))
|
||||
assert.Equal(t,
|
||||
[]PatchFile{{Path: "oldname", ContentPath: "oldname"}},
|
||||
patchBuilder.FilesInPatch())
|
||||
}
|
||||
|
||||
// A file taken into the patch whole has every one of its change lines in it.
|
||||
func TestIncludedLineIdentitiesOfAWholeFile(t *testing.T) {
|
||||
patchBuilder := newTestPatchBuilder(simpleDiff)
|
||||
|
||||
assert.NoError(t, patchBuilder.AddFileWhole("filename", ""))
|
||||
assert.ElementsMatch(t,
|
||||
[]LineIdentity{{LineNumber: 2, IsDeletion: true}, {LineNumber: 2, IsDeletion: false}},
|
||||
patchBuilder.IncludedLineIdentities("filename"))
|
||||
}
|
||||
+88
-4
@@ -170,6 +170,17 @@ type View struct {
|
||||
// For content that conveys meaning by color of its own.
|
||||
SelectedLineColorWidth int
|
||||
|
||||
// InclusionGutterMarker is the glyph the inclusion gutter draws on a marked line
|
||||
// (see SetInclusionGutter), and InclusionGutterMarkerColor its color. Both are
|
||||
// set once, when the view is created.
|
||||
InclusionGutterMarker string
|
||||
InclusionGutterMarkerColor Attribute
|
||||
// showInclusionGutter reserves the gutter's columns at the left of every line,
|
||||
// and inclusionGutterMarks, indexed by line of the content, says which lines get
|
||||
// the marker. Set together, via SetInclusionGutter.
|
||||
showInclusionGutter bool
|
||||
inclusionGutterMarks []bool
|
||||
|
||||
// If Frame is true, a border will be drawn around the view.
|
||||
Frame bool
|
||||
|
||||
@@ -860,6 +871,46 @@ func (v *View) Name() string {
|
||||
return v.name
|
||||
}
|
||||
|
||||
// SetInclusionGutter shows or hides a column reserved at the left of every line, in
|
||||
// which marks — indexed by line of the content — say which lines get
|
||||
// InclusionGutterMarker drawn, on every segment of a line the view wrapped. The
|
||||
// content is drawn shifted past it.
|
||||
//
|
||||
// It is drawn over the content rather than written into it, so the content itself —
|
||||
// and with it what each line of the view means, where a click lands, and how the
|
||||
// lines wrap — is untouched but for the width the gutter takes.
|
||||
func (v *View) SetInclusionGutter(show bool, marks []bool) {
|
||||
v.writeMutex.Lock()
|
||||
changed := v.showInclusionGutter != show
|
||||
v.showInclusionGutter = show
|
||||
v.inclusionGutterMarks = marks
|
||||
v.writeMutex.Unlock()
|
||||
|
||||
if changed {
|
||||
// The gutter takes its columns from the content, so what is left of it wraps
|
||||
// differently, and everything pointing into it has to come along.
|
||||
v.RewrapContent()
|
||||
}
|
||||
}
|
||||
|
||||
// inclusionGutterWidth is how many columns the inclusion gutter takes from the content
|
||||
// now: InclusionGutterWidthWhenShown while it is shown, and 0 while it is not. Only call
|
||||
// this with a lock on writeMutex.
|
||||
func (v *View) inclusionGutterWidth() int {
|
||||
if !v.showInclusionGutter {
|
||||
return 0
|
||||
}
|
||||
return v.InclusionGutterWidthWhenShown()
|
||||
}
|
||||
|
||||
// InclusionGutterWidthWhenShown is how many columns the inclusion gutter takes from the
|
||||
// content while it is shown, whether or not it is shown now: the marker plus a column of
|
||||
// space before the content. Content laid out before the gutter appears has to be laid
|
||||
// out this much narrower to fit beside it.
|
||||
func (v *View) InclusionGutterWidthWhenShown() int {
|
||||
return uniseg.StringWidth(v.InclusionGutterMarker) + 1
|
||||
}
|
||||
|
||||
// setCharacter sets a character (grapheme cluster) at the given point relative to the view. It applies
|
||||
// the specified colors, taking into account if the cell must be highlighted. Also, it checks if the
|
||||
// position is valid.
|
||||
@@ -1764,6 +1815,8 @@ func (v *View) draw(isWindowFocused bool) {
|
||||
|
||||
emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault}
|
||||
|
||||
gutterWidth := v.inclusionGutterWidth()
|
||||
|
||||
for y, vline := range v.viewLines[start:] {
|
||||
if y >= maxY {
|
||||
break
|
||||
@@ -1778,10 +1831,20 @@ func (v *View) draw(isWindowFocused bool) {
|
||||
trailingCell.bgColor = attrs.bg
|
||||
}
|
||||
|
||||
// The inclusion gutter is blank but for the marker on a marked line, and the
|
||||
// content begins after it. The blanks go through setCharacter like everything
|
||||
// else, so that a selection reaching the left edge covers the gutter too.
|
||||
for gx := range gutterWidth {
|
||||
v.setCharacter(gx, y, " ", v.FgColor, v.BgColor, isWindowFocused)
|
||||
}
|
||||
if gutterWidth > 0 && vline.linesY < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[vline.linesY] {
|
||||
v.setCharacter(0, y, v.InclusionGutterMarker, v.InclusionGutterMarkerColor, v.BgColor, isWindowFocused)
|
||||
}
|
||||
|
||||
// x tracks the current x position in the view, and cellIdx tracks the
|
||||
// index of the cell. If we print a double-sized rune, we increment cellIdx
|
||||
// by one but x by two.
|
||||
x := -v.ox
|
||||
x := gutterWidth - v.ox
|
||||
cellIdx := 0
|
||||
|
||||
var c cell
|
||||
@@ -1795,7 +1858,7 @@ func (v *View) draw(isWindowFocused bool) {
|
||||
|
||||
// no more characters to write so we're only going to be printing empty cells
|
||||
// past this point
|
||||
x = 0
|
||||
x = gutterWidth
|
||||
}
|
||||
|
||||
// if we're out of cells to write, we'll just print empty cells.
|
||||
@@ -1830,10 +1893,11 @@ func (v *View) refreshViewLinesIfNeeded() {
|
||||
return
|
||||
}
|
||||
|
||||
maxX := v.InnerWidth()
|
||||
wrap := 0
|
||||
if v.Wrap {
|
||||
wrap = maxX
|
||||
// The inclusion gutter, while it is shown, takes its columns out of the width
|
||||
// the content has to wrap in.
|
||||
wrap = max(0, v.InnerWidth()-v.inclusionGutterWidth())
|
||||
}
|
||||
|
||||
lineIdx := 0
|
||||
@@ -1975,6 +2039,26 @@ func (v *View) BufferLines() []string {
|
||||
return lines
|
||||
}
|
||||
|
||||
// MarkedLines returns the lines of the view's content that the inclusion gutter is
|
||||
// marking (see SetInclusionGutter), in the order they appear. Empty while the gutter
|
||||
// is hidden.
|
||||
func (v *View) MarkedLines() []string {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if !v.showInclusionGutter {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := []string{}
|
||||
for i, line := range v.buf.lines {
|
||||
if i < len(v.inclusionGutterMarks) && v.inclusionGutterMarks[i] {
|
||||
lines = append(lines, line.cells.String())
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// DiffLineContent holds what one line of a rendered diff offers to a reader trying
|
||||
// to recover which line of which file it came from: the line's text, which can be
|
||||
// parsed as a unified diff when the rendering preserves one, and the OSC 1717
|
||||
|
||||
@@ -1139,3 +1139,91 @@ func TestResizingAWrappingViewKeepsItsPlaceInTheContent(t *testing.T) {
|
||||
|
||||
assert.Equal(t, []string{"another wrapping line"}, v.SelectedLines())
|
||||
}
|
||||
|
||||
// The inclusion gutter reserves columns at the left of every line, draws its marker
|
||||
// on the marked lines only, and moves the content out of the way.
|
||||
func TestInclusionGutter(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 6)
|
||||
|
||||
// InnerWidth 10; the frame puts view x=0 at screen x=1.
|
||||
v := NewView("name", 0, 0, 11, 5, OutputNormal)
|
||||
v.Wrap = true
|
||||
v.InclusionGutterMarker = "✓"
|
||||
|
||||
v.writeString("aaa\nbbb\nccc\n")
|
||||
|
||||
// The gutter is two columns wide — the marker and a space; mark the middle line.
|
||||
v.SetInclusionGutter(true, []bool{false, true, false})
|
||||
v.draw(true)
|
||||
|
||||
chr, _, _ := Screen.Get(1, 1)
|
||||
assert.Equal(t, " ", chr, "an unmarked line has no marker")
|
||||
chr, _, _ = Screen.Get(1, 2)
|
||||
assert.Equal(t, "✓", chr, "a marked line has one")
|
||||
chr, _, _ = Screen.Get(1, 3)
|
||||
assert.Equal(t, " ", chr, "an unmarked line has no marker")
|
||||
|
||||
// The content begins after the gutter: view x=2, i.e. screen x=3.
|
||||
chr, _, _ = Screen.Get(3, 1)
|
||||
assert.Equal(t, "a", chr)
|
||||
chr, _, _ = Screen.Get(3, 2)
|
||||
assert.Equal(t, "b", chr)
|
||||
chr, _, _ = Screen.Get(3, 3)
|
||||
assert.Equal(t, "c", chr)
|
||||
|
||||
// Hiding the gutter puts the content back at the left edge.
|
||||
v.SetInclusionGutter(false, nil)
|
||||
v.draw(true)
|
||||
chr, _, _ = Screen.Get(1, 1)
|
||||
assert.Equal(t, "a", chr)
|
||||
}
|
||||
|
||||
// A marked line the view wraps is marked on every segment it is drawn as, so that
|
||||
// the mark doesn't look like it belongs to the first part of the line alone. The
|
||||
// gutter takes its columns out of the width the content wraps in.
|
||||
func TestInclusionGutterMarksEverySegmentOfAWrappedLine(t *testing.T) {
|
||||
WithSimulationScreen(t, 14, 6)
|
||||
|
||||
v := NewView("name", 0, 0, 11, 5, OutputNormal) // InnerWidth 10
|
||||
v.Wrap = true
|
||||
v.InclusionGutterMarker = "✓"
|
||||
|
||||
// Ten cells, wrapping at eight once the two-column gutter is shown.
|
||||
v.writeString("0123456789\n")
|
||||
v.SetInclusionGutter(true, []bool{true})
|
||||
v.draw(true)
|
||||
|
||||
chr, _, _ := Screen.Get(1, 1)
|
||||
assert.Equal(t, "✓", chr)
|
||||
chr, _, _ = Screen.Get(3, 1)
|
||||
assert.Equal(t, "0", chr)
|
||||
chr, _, _ = Screen.Get(10, 1)
|
||||
assert.Equal(t, "7", chr, "the content wraps at the width the gutter leaves it")
|
||||
|
||||
chr, _, _ = Screen.Get(1, 2)
|
||||
assert.Equal(t, "✓", chr, "the line's second segment is marked too")
|
||||
chr, _, _ = Screen.Get(3, 2)
|
||||
assert.Equal(t, "8", chr)
|
||||
}
|
||||
|
||||
// Showing the gutter narrows the content, so the content wraps again — and the
|
||||
// positions into it, which count the segments lines are drawn as, have to come
|
||||
// along, as they do for any other change of width.
|
||||
func TestShowingTheInclusionGutterKeepsThePlaceInTheContent(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 11, 10, OutputNormal) // InnerWidth 10
|
||||
v.Wrap = true
|
||||
v.Highlight = true
|
||||
v.InclusionGutterMarker = "✓"
|
||||
|
||||
v.writeString("one\ntwo\nthree\nsomethingfartoolong\n")
|
||||
assert.Equal(t, 5, v.ViewLinesHeight())
|
||||
|
||||
v.FocusPoint(0, 2, false)
|
||||
assert.Equal(t, "three", v.SelectedLine())
|
||||
|
||||
// With eight columns left for the content, the last line wraps into three
|
||||
// segments rather than two.
|
||||
v.SetInclusionGutter(true, []bool{false, false, true, false})
|
||||
assert.Equal(t, 6, v.ViewLinesHeight())
|
||||
assert.Equal(t, "three", v.SelectedLine())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
// CommitDiffActions implements what a panel showing a commit's diff offers on that diff
|
||||
// in the focused main view. Five panels do: the commit files panel shows the diff of one
|
||||
// file of a commit, and the commits, sub-commits, stash and reflog panels the whole diff
|
||||
// of whatever they have selected. They all offer the same thing and differ only in which
|
||||
// diff they show, so they share this, each saying which diff that is.
|
||||
type CommitDiffActions struct {
|
||||
c *ControllerCommon
|
||||
|
||||
// The panel this belongs to, and what it is showing the diff of — nil when it has
|
||||
// nothing selected, and so no diff.
|
||||
panel types.Context
|
||||
target func() *commitDiffTarget
|
||||
}
|
||||
|
||||
// commitDiffTarget is the diff a panel is showing: the two ends of it, and whether it
|
||||
// belongs to a commit lazygit may rewrite.
|
||||
type commitDiffTarget struct {
|
||||
from string
|
||||
to string
|
||||
canRebase bool
|
||||
}
|
||||
|
||||
var _ types.FocusedMainViewActions = &CommitDiffActions{}
|
||||
|
||||
func NewCommitDiffActions(
|
||||
c *ControllerCommon, panel types.Context, target func() *commitDiffTarget,
|
||||
) *CommitDiffActions {
|
||||
return &CommitDiffActions{c: c, panel: panel, target: target}
|
||||
}
|
||||
|
||||
// PlainDiff hands out the diff the asking pane is showing, for the given files — the
|
||||
// commit's diff as in the main view, only without the commit's message and stat above it,
|
||||
// or the diff the custom patch is previewed as, whose lines are the patch's own rather
|
||||
// than the commit's.
|
||||
//
|
||||
// The patch's own diff is handed out whole: it is only ever as big as the patch, and it
|
||||
// names its files under the trees the patch was materialized into rather than under the
|
||||
// paths asked for.
|
||||
func (self *CommitDiffActions) PlainDiff(pane types.DiffPaneContext, paths []string) string {
|
||||
if self.showsCustomPatch(pane) {
|
||||
return self.customPatchDiff()
|
||||
}
|
||||
|
||||
target := self.target()
|
||||
if target == nil {
|
||||
return ""
|
||||
}
|
||||
return self.c.Helpers().Diff.PlainDiffBetweenRefs(target.from, target.to, paths)
|
||||
}
|
||||
|
||||
// customPatchDiff is the diff the custom patch is previewed as, as git writes it — the
|
||||
// diff behind what the pane previewing the patch shows, in which the lines shown there
|
||||
// can be found again.
|
||||
func (self *CommitDiffActions) customPatchDiff() string {
|
||||
treesDir := self.c.Git().Patch.PatchBuilder.TempDir()
|
||||
if treesDir == "" {
|
||||
return ""
|
||||
}
|
||||
// An error means the two trees differ, as they do for any patch with something in
|
||||
// it. We are after the diff itself either way.
|
||||
diff, _ := self.c.Git().Diff.
|
||||
CustomPatchDiffCmdObj(treesDir, git_commands.DiffModePlain).
|
||||
RunWithOutput()
|
||||
return diff
|
||||
}
|
||||
|
||||
// PrimaryAction takes the selected lines into the custom patch being built from this
|
||||
// diff, or back out of it when the first of them is already in — the same toggling the
|
||||
// commit files panel does to a whole file at a time.
|
||||
//
|
||||
// The commit is not touched, so the diff stays as it is: what changes is the patch
|
||||
// beside it, and which of its lines are marked as being in that patch.
|
||||
func (self *CommitDiffActions) PrimaryAction(pane types.DiffPaneContext, firstBufferLine int, lastBufferLine int) error {
|
||||
// In the pane showing the patch, the lines are the patch's own, so there they only
|
||||
// come back out of it.
|
||||
if self.showsCustomPatch(pane) {
|
||||
return self.removePatchLines(pane, firstBufferLine, lastBufferLine)
|
||||
}
|
||||
|
||||
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
||||
return fmt.Errorf(self.c.Tr.Actions.NotEnoughContextForCustomPatch,
|
||||
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
||||
}
|
||||
|
||||
target := self.target()
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
lines := self.c.Helpers().DiffLine.ChangeLinesInBufferRange(pane.GetView(), firstBufferLine, lastBufferLine)
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
patchBuilder := self.c.Git().Patch.PatchBuilder
|
||||
from, reverse := self.patchEndpoints(target)
|
||||
// A patch is built from one diff, so building from another one means giving up the
|
||||
// patch there is — which the user is asked about, as entering the patch builder asks.
|
||||
mustDiscardPatch := patchBuilder.Active() && patchBuilder.NewPatchRequired(from, target.to, reverse)
|
||||
return self.c.ConfirmIf(mustDiscardPatch, types.ConfirmOpts{
|
||||
Title: self.c.Tr.DiscardPatch,
|
||||
Prompt: self.c.Tr.DiscardPatchConfirm,
|
||||
HandleConfirm: func() error {
|
||||
if mustDiscardPatch {
|
||||
patchBuilder.Reset()
|
||||
}
|
||||
if !patchBuilder.Active() {
|
||||
patchBuilder.Start(from, target.to, reverse, target.canRebase)
|
||||
}
|
||||
|
||||
if err := self.togglePatchLines(lines); err != nil {
|
||||
return err
|
||||
}
|
||||
// Taking the last line back out ends the patch rather than leaving an empty
|
||||
// one, so that the pane previewing it and the marks over the diff go with it.
|
||||
if patchBuilder.IsEmpty() {
|
||||
patchBuilder.Reset()
|
||||
}
|
||||
|
||||
// The diff on screen is the one the marks belong to, so they can be brought up
|
||||
// to date at once rather than waiting for the render below.
|
||||
self.c.Helpers().DiffLine.RefreshInclusionGutter()
|
||||
|
||||
// The selection moves on past the lines just toggled, to the next change of
|
||||
// the diff — which is still there, a toggle leaving the diff as it was, so
|
||||
// hold input back until it has moved: a second press meanwhile would toggle
|
||||
// the same lines straight back.
|
||||
self.c.GocuiGui().BeginBlockingEvents()
|
||||
self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, pane, firstBufferLine, len(lines),
|
||||
self.c.GocuiGui().EndBlockingEvents)
|
||||
|
||||
// The panel's own render, which is all that is needed: the marks over the diff
|
||||
// and the patch previewed beside it have changed, while the commit has not.
|
||||
self.c.PostRefreshUpdate(self.panel)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// removePatchLines takes the selected lines of the custom patch out of it. The primary
|
||||
// action does this in the pane showing the patch: everything shown there is in the patch
|
||||
// already, so there is nothing else it could mean.
|
||||
//
|
||||
// A line of the patch is named by its position among its file's changes, counted in the
|
||||
// diff the patch is shown as. That position is the same one the line has among the
|
||||
// changes the patch holds for that file. Line numbers would not do: a patch that leaves
|
||||
// an earlier addition out numbers everything after it differently from the commit's diff.
|
||||
func (self *CommitDiffActions) removePatchLines(
|
||||
pane types.DiffPaneContext, firstBufferLine int, lastBufferLine int,
|
||||
) error {
|
||||
lines := self.c.Helpers().DiffLine.ChangeLinesInBufferRange(pane.GetView(), firstBufferLine, lastBufferLine)
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
patchBuilder := self.c.Git().Patch.PatchBuilder
|
||||
previousPaths := self.previousPaths()
|
||||
for path, ordinals := range self.c.Helpers().DiffLine.ChangeLineOrdinals(self.customPatchDiff(), lines) {
|
||||
filename := self.patchBuilderPath(path)
|
||||
if filename == "" {
|
||||
continue
|
||||
}
|
||||
included := patchBuilder.IncludedChangeLineIndices(filename)
|
||||
indices := []int{}
|
||||
for _, ordinal := range ordinals {
|
||||
if ordinal < len(included) {
|
||||
indices = append(indices, included[ordinal])
|
||||
}
|
||||
}
|
||||
if len(indices) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := patchBuilder.RemoveFileLineRange(filename, previousPaths[filename], indices); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Taking the last line out ends the patch rather than leaving an empty one, as it does
|
||||
// in the diff beside this pane.
|
||||
if patchBuilder.IsEmpty() {
|
||||
patchBuilder.Reset()
|
||||
}
|
||||
|
||||
self.c.Helpers().DiffLine.RefreshInclusionGutter()
|
||||
|
||||
// The lines are gone from the patch, so the selection carries on from where they were,
|
||||
// as unstaging leaves it. Input is held until it has moved, so that a second press acts
|
||||
// on the patch as it now is.
|
||||
self.c.GocuiGui().BeginBlockingEvents()
|
||||
self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, pane, firstBufferLine, 0,
|
||||
self.c.GocuiGui().EndBlockingEvents)
|
||||
|
||||
self.c.PostRefreshUpdate(self.panel)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscardSelection takes the selected lines out of the commit they are part of, by
|
||||
// building a patch of exactly those lines and removing that patch from the commit. It is
|
||||
// a rebase, so a later commit that touches the same lines can conflict with it.
|
||||
//
|
||||
// The patch it needs is its own, so a patch being built is given up first — which the
|
||||
// prompt says, there being no way to get it back.
|
||||
func (self *CommitDiffActions) DiscardSelection(pane types.DiffPaneContext, firstBufferLine int, lastBufferLine int) error {
|
||||
target := self.target()
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
lines := self.c.Helpers().DiffLine.ChangeLinesInBufferRange(pane.GetView(), firstBufferLine, lastBufferLine)
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
commitIndex := self.indexOfTargetCommit(target)
|
||||
if commitIndex == -1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
patchBuilder := self.c.Git().Patch.PatchBuilder
|
||||
prompt := lo.Ternary(patchBuilder.IsEmpty(),
|
||||
self.c.Tr.DiscardLinesFromCommitPrompt,
|
||||
self.c.Tr.DiscardLinesFromCommitPromptWithReset)
|
||||
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: self.c.Tr.DiscardLinesFromCommitTitle,
|
||||
Prompt: prompt,
|
||||
HandleConfirm: func() error {
|
||||
from, reverse := self.patchEndpoints(target)
|
||||
patchBuilder.Reset()
|
||||
patchBuilder.Start(from, target.to, reverse, target.canRebase)
|
||||
if err := self.togglePatchLines(lines); err != nil {
|
||||
return err
|
||||
}
|
||||
if patchBuilder.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// The rebase runs on a worker, which may not read the model, so the commits
|
||||
// it rewrites are taken here.
|
||||
commits := self.c.Model().Commits
|
||||
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
||||
Message: self.c.Tr.RebasingStatus,
|
||||
HideWorkingTreeState: true,
|
||||
}, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
|
||||
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
||||
})
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscardSelectionDisabledReason says why the selected lines can't be taken out of the
|
||||
// commit: doing so rewrites it, which is only ours to do for a commit of the branch we
|
||||
// are on, and not while a rebase is already under way. In the pane previewing the custom
|
||||
// patch there is nothing to discard from — the lines there are the patch's, and space
|
||||
// takes them back out of it.
|
||||
func (self *CommitDiffActions) DiscardSelectionDisabledReason(pane types.DiffPaneContext) *types.DisabledReason {
|
||||
if self.showsCustomPatch(pane) {
|
||||
return &types.DisabledReason{Text: self.c.Tr.CannotDiscardFromCustomPatchView, ShowErrorInPanel: true}
|
||||
}
|
||||
target := self.target()
|
||||
if target == nil || !target.canRebase {
|
||||
return &types.DisabledReason{Text: self.c.Tr.CanOnlyDiscardFromLocalCommits, ShowErrorInPanel: true}
|
||||
}
|
||||
if self.c.Git().Status.WorkingTreeState().Any() {
|
||||
return &types.DisabledReason{Text: self.c.Tr.CantPatchWhileRebasingError, ShowErrorInPanel: true}
|
||||
}
|
||||
if self.c.UserConfig().Git.DiffContextSize == 0 {
|
||||
return &types.DisabledReason{
|
||||
Text: fmt.Sprintf(self.c.Tr.Actions.NotEnoughContextToRemoveLines,
|
||||
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView),
|
||||
ShowErrorInPanel: true,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PatchInclusion says which lines of the commit's diff are in the custom patch being
|
||||
// built from it. nil when there is no such patch: none is being built at all, or the one
|
||||
// being built is of another diff, whose lines are not these however alike they look.
|
||||
func (self *CommitDiffActions) PatchInclusion() func(types.DiffLineInfo) bool {
|
||||
patchBuilder := self.c.Git().Patch.PatchBuilder
|
||||
target := self.target()
|
||||
if !patchBuilder.Active() || target == nil {
|
||||
return nil
|
||||
}
|
||||
from, reverse := self.patchEndpoints(target)
|
||||
if patchBuilder.NewPatchRequired(from, target.to, reverse) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Which lines of a file are in the patch is asked of the patch builder per file, and
|
||||
// a diff can span many, so each is asked about when a line of it first comes up.
|
||||
includedByPath := map[string]*set.Set[patch.LineIdentity]{}
|
||||
return func(info types.DiffLineInfo) bool {
|
||||
path := self.patchBuilderPath(info.Path)
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
included, asked := includedByPath[path]
|
||||
if !asked {
|
||||
included = set.NewFromSlice(patchBuilder.IncludedLineIdentities(path))
|
||||
includedByPath[path] = included
|
||||
}
|
||||
return included.Includes(info.PatchLineIdentity())
|
||||
}
|
||||
}
|
||||
|
||||
// togglePatchLines takes the given lines of the commit's diff into the custom patch, or
|
||||
// out of it. The first line of the selection decides which of the two happens, once for
|
||||
// the whole selection: pointing at a line that is already in the patch takes the whole
|
||||
// selection out of it, as toggling a selection of files in the commit files panel does.
|
||||
func (self *CommitDiffActions) togglePatchLines(lines []types.DiffLineInfo) error {
|
||||
patchBuilder := self.c.Git().Patch.PatchBuilder
|
||||
|
||||
// The files the selection covers, in the order the diff shows them, and per file the
|
||||
// lines of it that are selected: a patch is built a file at a time, while a selection
|
||||
// can span several of them.
|
||||
paths := []string{}
|
||||
linesByPath := map[string][]patch.LineIdentity{}
|
||||
for _, line := range lines {
|
||||
path := self.patchBuilderPath(line.Path)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if _, seen := linesByPath[path]; !seen {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
linesByPath[path] = append(linesByPath[path], line.PatchLineIdentity())
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
previousPaths := self.previousPaths()
|
||||
indicesByPath := map[string][]int{}
|
||||
for _, path := range paths {
|
||||
indices, err := patchBuilder.PatchLineIndicesForLines(path, previousPaths[path], linesByPath[path])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
indicesByPath[path] = indices
|
||||
}
|
||||
|
||||
included, err := patchBuilder.GetFileIncLineIndices(paths[0], previousPaths[paths[0]])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toggle := patchBuilder.AddFileLineRange
|
||||
if first := indicesByPath[paths[0]]; len(first) > 0 && lo.Contains(included, first[0]) {
|
||||
toggle = patchBuilder.RemoveFileLineRange
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if len(indicesByPath[path]) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := toggle(path, previousPaths[path], indicesByPath[path]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// previousPaths says which files of the diff were renamed, and what they were called
|
||||
// before. A renamed file's diff only comes out as a rename when git is asked about both
|
||||
// of its paths, and its lines are numbered in the file under its old name, so the patch
|
||||
// builder has to be told the old path along with them.
|
||||
func (self *CommitDiffActions) previousPaths() map[string]string {
|
||||
target := self.target()
|
||||
if target == nil {
|
||||
return nil
|
||||
}
|
||||
from, reverse := self.patchEndpoints(target)
|
||||
files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(from, target.to, reverse)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
previousPaths := map[string]string{}
|
||||
for _, file := range files {
|
||||
if file.PreviousPath != "" {
|
||||
previousPaths[file.Path] = file.PreviousPath
|
||||
}
|
||||
}
|
||||
return previousPaths
|
||||
}
|
||||
|
||||
// patchEndpoints gives the two ends of the diff a patch is built from. They are the ends
|
||||
// of the diff shown, except in diffing mode, where what is shown is a diff against
|
||||
// another ref, possibly the other way around.
|
||||
func (self *CommitDiffActions) patchEndpoints(target *commitDiffTarget) (string, bool) {
|
||||
return self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(target.from)
|
||||
}
|
||||
|
||||
// patchBuilderPath turns the absolute path a diff line carries into the repo-relative
|
||||
// one the patch builder keys a file by, and "" for a path that is no file of this repo.
|
||||
func (self *CommitDiffActions) patchBuilderPath(path string) string {
|
||||
relativePath, err := filepath.Rel(self.c.Git().RepoPaths.WorktreePath(), path)
|
||||
if err != nil || strings.HasPrefix(relativePath, "..") {
|
||||
return ""
|
||||
}
|
||||
return filepath.ToSlash(relativePath)
|
||||
}
|
||||
|
||||
// indexOfTargetCommit finds the commit the diff belongs to among the commits of the
|
||||
// branch we are on, which is how a rebase is told which commit to rewrite. -1 when it
|
||||
// isn't one of them, in which case there is nothing we can rewrite.
|
||||
func (self *CommitDiffActions) indexOfTargetCommit(target *commitDiffTarget) int {
|
||||
return lo.IndexOf(
|
||||
lo.Map(self.c.Model().Commits, func(commit *models.Commit, _ int) string { return commit.Hash() }),
|
||||
target.to)
|
||||
}
|
||||
|
||||
// showsCustomPatch reports whether the given main pane is the one previewing the custom
|
||||
// patch being built, rather than the commit's diff — which for a commit's diff is always
|
||||
// the lower one.
|
||||
func (self *CommitDiffActions) showsCustomPatch(pane types.DiffPaneContext) bool {
|
||||
return pane.GetKey() == self.c.Contexts().NormalSecondary.GetKey()
|
||||
}
|
||||
@@ -23,6 +23,9 @@ type CommitFilesController struct {
|
||||
baseController
|
||||
*ListControllerTrait[*filetree.CommitFileNode]
|
||||
c *ControllerCommon
|
||||
|
||||
// what this panel offers on the diff it shows in the focused main view
|
||||
diffActions *CommitDiffActions
|
||||
}
|
||||
|
||||
var _ types.IController = &CommitFilesController{}
|
||||
@@ -30,7 +33,7 @@ var _ types.IController = &CommitFilesController{}
|
||||
func NewCommitFilesController(
|
||||
c *ControllerCommon,
|
||||
) *CommitFilesController {
|
||||
return &CommitFilesController{
|
||||
controller := &CommitFilesController{
|
||||
baseController: baseController{},
|
||||
c: c,
|
||||
ListControllerTrait: NewListControllerTrait(
|
||||
@@ -40,6 +43,18 @@ func NewCommitFilesController(
|
||||
c.Contexts().CommitFiles.GetSelectedItems,
|
||||
),
|
||||
}
|
||||
controller.diffActions = NewCommitDiffActions(c, c.Contexts().CommitFiles, controller.diffTarget)
|
||||
return controller
|
||||
}
|
||||
|
||||
// diffTarget is the commit whose files this panel is showing. Its main view shows the
|
||||
// diff of that commit.
|
||||
func (self *CommitFilesController) diffTarget() *commitDiffTarget {
|
||||
if self.context().GetRef() == nil && self.context().GetRefRange() == nil {
|
||||
return nil
|
||||
}
|
||||
from, to := self.context().GetFromAndToForDiff()
|
||||
return &commitDiffTarget{from: from, to: to, canRebase: self.context().GetCanRebase()}
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
@@ -193,15 +208,7 @@ func (self *CommitFilesController) GetOnRenderToMain() func() {
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
|
||||
return self
|
||||
}
|
||||
|
||||
// PlainDiff hands out the commit's diff for the given files. Both panes show the same
|
||||
// diff here — the secondary one shows the custom patch built from it, which is not a
|
||||
// diff of the commit — so which pane asks makes no difference.
|
||||
func (self *CommitFilesController) PlainDiff(_ types.DiffPaneContext, paths []string) string {
|
||||
from, to := self.context().GetFromAndToForDiff()
|
||||
return self.c.Helpers().Diff.PlainDiffBetweenRefs(from, to, paths)
|
||||
return self.diffActions
|
||||
}
|
||||
|
||||
func (self *CommitFilesController) copyDiffToClipboard(paths []string, toastMessage string) error {
|
||||
|
||||
@@ -66,7 +66,7 @@ func (self *DiffLineHelper) diffLineIdentitiesAt(
|
||||
|
||||
if renderingStatesDiffLines(contents) {
|
||||
if identities := self.diffLineIdentitiesFromRecords(contents[bufferLineIdx].Metadata); len(identities) > 0 {
|
||||
return identities, true
|
||||
return self.inRepoTerms(view, identities), true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func (self *DiffLineHelper) diffLineIdentitiesAt(
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return []types.DiffLineInfo{self.diffLineInfo(parsed)}, true
|
||||
return self.inRepoTerms(view, []types.DiffLineInfo{self.diffLineInfo(parsed)}), true
|
||||
}
|
||||
|
||||
// diffLineIdentitiesFromRecords recovers the identity of every diff line the row's
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
@@ -31,7 +34,81 @@ func (self *DiffLineHelper) DiffLinesInBufferRange(view *gocui.View, first int,
|
||||
for bufferLine := first; bufferLine <= min(last, len(identities)-1); bufferLine++ {
|
||||
infos = append(infos, identities[bufferLine]...)
|
||||
}
|
||||
return infos
|
||||
return self.inRepoTerms(view, infos)
|
||||
}
|
||||
|
||||
// ChangeLineOrdinals says, for each of the given change lines, which of its file's
|
||||
// changes it is in the given diff — its place among them, counted from the top of the
|
||||
// file — keyed by file. Lines the diff doesn't have are left out.
|
||||
//
|
||||
// It is how a line is named in something built out of a diff rather than being that diff:
|
||||
// the custom patch holds the lines it was given in the order the file has them, so a
|
||||
// place among a file's changes is a line of the patch.
|
||||
func (self *DiffLineHelper) ChangeLineOrdinals(
|
||||
diff string, infos []types.DiffLineInfo,
|
||||
) map[string][]int {
|
||||
ordinals := map[patchLine]int{}
|
||||
counts := map[string]int{}
|
||||
for _, parsed := range parseAllDiffLinesFromBuffer(strings.Split(diff, "\n")) {
|
||||
if !parsed.ok {
|
||||
continue
|
||||
}
|
||||
info := self.diffLineInfo(parsed.parsed)
|
||||
if !info.IsChange() {
|
||||
continue
|
||||
}
|
||||
ordinals[patchLineOf(info)] = counts[info.Path]
|
||||
counts[info.Path]++
|
||||
}
|
||||
|
||||
ordinalsByPath := map[string][]int{}
|
||||
for _, info := range infos {
|
||||
if ordinal, ok := ordinals[patchLineOf(info)]; ok {
|
||||
ordinalsByPath[info.Path] = append(ordinalsByPath[info.Path], ordinal)
|
||||
}
|
||||
}
|
||||
return ordinalsByPath
|
||||
}
|
||||
|
||||
// inRepoTerms brings the paths of lines recovered from a view into the repo's terms.
|
||||
//
|
||||
// They are in them already for a diff of the repo's own files. The pane previewing the
|
||||
// custom patch, though, shows a diff of the two trees the patch was materialized into: a
|
||||
// diff renderer states the path it was handed there, which is under the tree's own name,
|
||||
// while the diff's text names the trees where an ordinary diff has git's a/ and b/
|
||||
// prefixes and so needs nothing.
|
||||
func (self *DiffLineHelper) inRepoTerms(view *gocui.View, infos []types.DiffLineInfo) []types.DiffLineInfo {
|
||||
if !self.ShowsCustomPatch(view) {
|
||||
return infos
|
||||
}
|
||||
|
||||
worktreePath := self.c.Git().RepoPaths.WorktreePath()
|
||||
treesDir := self.c.Git().Patch.PatchBuilder.TempDir()
|
||||
return lo.Map(infos, func(info types.DiffLineInfo, _ int) types.DiffLineInfo {
|
||||
info.Path = repoPathOfTreePath(info.Path, treesDir, worktreePath)
|
||||
return info
|
||||
})
|
||||
}
|
||||
|
||||
// repoPathOfTreePath maps a path under one of the trees the custom patch was materialized
|
||||
// into to the file of the repo it stands for: the path is the tree's name followed by the
|
||||
// file's own, stated either against the directory holding the trees or against the repo,
|
||||
// depending on how the renderer that stated it was given it.
|
||||
func repoPathOfTreePath(path string, treesDir string, worktreePath string) string {
|
||||
root := worktreePath
|
||||
if treesDir != "" && strings.HasPrefix(path, treesDir+string(filepath.Separator)) {
|
||||
root = treesDir
|
||||
}
|
||||
relativePath, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
|
||||
segments := strings.Split(filepath.ToSlash(relativePath), "/")
|
||||
if len(segments) > 1 && (segments[0] == "a" || segments[0] == "b") {
|
||||
relativePath = filepath.Join(segments[1:]...)
|
||||
}
|
||||
return filepath.Join(worktreePath, relativePath)
|
||||
}
|
||||
|
||||
// ChangeLinesInBufferRange returns the change lines — the additions and deletions —
|
||||
|
||||
@@ -543,3 +543,53 @@ func patchLineOf(info types.DiffLineInfo) patchLine {
|
||||
return patchLine{path: info.Path, kind: types.DiffLineContext, line: info.NewLine}
|
||||
}
|
||||
}
|
||||
|
||||
// RevealSelectionAfterAction moves a diff pane's selection to the change that takes the
|
||||
// place of the one just acted on, once the changed diff has re-rendered. Call it with
|
||||
// the pane acted in, the pane the work carries on in, and the buffer line the selection
|
||||
// starts on, before triggering the re-render.
|
||||
//
|
||||
// The line acted on is gone from the diff, so what is remembered is its place among the
|
||||
// diff's changes: the next change moves up into it, which is where you want to be to
|
||||
// carry on. A range collapses to a single line at its start, and hunk mode selects the
|
||||
// whole block it lands in, so that pressing the key again acts on the next hunk. The
|
||||
// target pane inherits that select mode, this being the same piece of work continuing
|
||||
// in another pane — and shows no selection until the restore places one, so that what
|
||||
// it was left showing the last time it was used doesn't appear for a frame.
|
||||
//
|
||||
// advanceBy moves on by that many changes past the place remembered, for an action that
|
||||
// leaves the diff as it was: lines taken into a custom patch are still in the commit's
|
||||
// diff, so the place remembered is still the line acted on, and carrying on means going
|
||||
// past the lines just dealt with rather than staying on them.
|
||||
//
|
||||
// done, which may be nil, is called once the selection is where it belongs, or once it
|
||||
// turns out that no render is coming to put it there — for a caller that must not let
|
||||
// the user act again in between.
|
||||
func (self *DiffLineHelper) RevealSelectionAfterAction(
|
||||
source types.DiffPaneContext, target types.DiffPaneContext, firstBufferLine int, advanceBy int, done func(),
|
||||
) {
|
||||
ordinal := self.ChangeLineOrdinal(source.GetView(), firstBufferLine)
|
||||
|
||||
sel := source.DiffSelectState()
|
||||
if sel.Mode == types.DiffSelectModeRange {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
sel.RangeIsSticky = false
|
||||
}
|
||||
*target.DiffSelectState() = *sel
|
||||
selectHunk := sel.Mode == types.DiffSelectModeHunk
|
||||
|
||||
targetView := target.GetView()
|
||||
if target != source {
|
||||
target.SetHasSelectableContent(false)
|
||||
self.c.Context().UpdateSelectionHighlights()
|
||||
}
|
||||
|
||||
self.RevealChangeLineAtOrdinal(targetView, ordinal+advanceBy, func(viewLine int) {
|
||||
if selectHunk {
|
||||
self.SelectChangeBlock(target, viewLine, true)
|
||||
return
|
||||
}
|
||||
targetView.CancelRangeSelect()
|
||||
self.ShowSelectionAtLine(targetView, viewLine, true)
|
||||
}, done)
|
||||
}
|
||||
|
||||
@@ -131,3 +131,73 @@ func (self *DiffLineHelper) SelectedHunkBounds(view *gocui.View) (int, int, bool
|
||||
}
|
||||
return self.ChangeBlockBounds(view, anchor)
|
||||
}
|
||||
|
||||
// RefreshInclusionGutter updates the marks drawn over the diff in the main pane, which
|
||||
// say which of its lines are in the custom patch being built from it.
|
||||
//
|
||||
// They are shown whenever the pane shows a diff the patch is being built from, whether
|
||||
// or not it has the focus. The pane beside it previews the patch all the while, and the
|
||||
// marks show the same lines from the side of the diff. A diff of some other commit gets
|
||||
// none, since a patch built from another commit says nothing about the lines of this one.
|
||||
//
|
||||
// Call it whenever either of those can have changed: as a pane's content settles, and
|
||||
// when the patch itself changes.
|
||||
func (self *DiffLineHelper) RefreshInclusionGutter() {
|
||||
view := self.c.Contexts().Normal.GetView()
|
||||
|
||||
included := self.gutterInclusion()
|
||||
if included == nil {
|
||||
view.SetInclusionGutter(false, nil)
|
||||
return
|
||||
}
|
||||
|
||||
resolved := self.resolveDiffLines(view.DiffLineContents())
|
||||
marks := make([]bool, len(resolved))
|
||||
for i, row := range resolved {
|
||||
if row.ok && row.info.IsChange() {
|
||||
marks[i] = included(row.info)
|
||||
}
|
||||
}
|
||||
view.SetInclusionGutter(true, marks)
|
||||
}
|
||||
|
||||
// ShowsInclusionGutter reports whether the custom patch's marks are drawn over what the
|
||||
// given view is being given to show: the upper main pane, while it shows the diff of a
|
||||
// panel the patch is being built from.
|
||||
//
|
||||
// It goes by what is known before the content arrives, so that a render can be laid out
|
||||
// to the width the gutter leaves it. Whether the diff turns out to have any change lines
|
||||
// to mark doesn't come into it.
|
||||
func (self *DiffLineHelper) ShowsInclusionGutter(view *gocui.View) bool {
|
||||
return view == self.c.Contexts().Normal.GetView() && self.gutterInclusion() != nil
|
||||
}
|
||||
|
||||
// gutterInclusion is patchInclusion for the gutter over the upper main pane, which is
|
||||
// shown only over the panel's diff and not, say, over a message in its place.
|
||||
func (self *DiffLineHelper) gutterInclusion() func(types.DiffLineInfo) bool {
|
||||
if !self.c.Contexts().Normal.ContentIsDiff() {
|
||||
return nil
|
||||
}
|
||||
return self.patchInclusion()
|
||||
}
|
||||
|
||||
// patchInclusion asks the panel whose diff the main pane is showing which of that diff's
|
||||
// lines are in the custom patch being built from it, and answers nil where there is no
|
||||
// such patch.
|
||||
func (self *DiffLineHelper) patchInclusion() func(types.DiffLineInfo) bool {
|
||||
// The side panel nearest the top of the stack, whether it holds the focus itself or
|
||||
// either main pane above it does.
|
||||
sidePanel := self.c.Context().CurrentSide()
|
||||
actions, ok := sidePanel.GetFocusedMainViewDiffSource().(types.FocusedMainViewActions)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return actions.PatchInclusion()
|
||||
}
|
||||
|
||||
// ShowsCustomPatch reports whether the given view is the one previewing the custom patch
|
||||
// being built, which is the lower pane while a patch is being built from the diff in the
|
||||
// upper one.
|
||||
func (self *DiffLineHelper) ShowsCustomPatch(view *gocui.View) bool {
|
||||
return view == self.c.Contexts().NormalSecondary.GetView() && self.patchInclusion() != nil
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ func (self *PatchBuildingHelper) Escape() {
|
||||
func (self *PatchBuildingHelper) Reset() error {
|
||||
self.c.Git().Patch.PatchBuilder.Reset()
|
||||
|
||||
if self.c.Context().CurrentStatic().GetKind() != types.SIDE_CONTEXT {
|
||||
// The patch-building view is the one thing with nothing left to show once the patch is
|
||||
// gone, so it is the one thing left behind. Everywhere else the user is looking at
|
||||
// something of their own — a commit's diff, the working tree's — which is still there.
|
||||
if self.c.Context().CurrentStatic().GetKey() == self.c.Contexts().CustomPatchBuilder.GetKey() {
|
||||
self.Escape()
|
||||
}
|
||||
|
||||
@@ -51,8 +54,11 @@ func (self *PatchBuildingHelper) Reset() error {
|
||||
Scope: []types.RefreshableView{types.COMMIT_FILES},
|
||||
})
|
||||
|
||||
// refreshing the current context so that the secondary panel is hidden if necessary.
|
||||
self.c.PostRefreshUpdate(self.c.Context().Current())
|
||||
// Render again so that the pane that was previewing the patch goes with it. The
|
||||
// panel asked to do that is the side panel rather than whichever context has the
|
||||
// focus. Both main panes are rendered by the panel beneath them, so a reset from
|
||||
// within the focused main view has to go through that panel too.
|
||||
self.c.PostRefreshUpdate(self.c.Context().CurrentSide())
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1086,17 +1086,31 @@ type capturedCommitFilesState struct {
|
||||
from string
|
||||
to string
|
||||
reverse bool
|
||||
// Whether there is a commit to load the files of at all. The panel is only ever
|
||||
// pointed at one by being entered, and a patch can now be built from a commit's diff
|
||||
// without that — after which anything that refreshes the panel would otherwise be
|
||||
// asking for the files of nothing.
|
||||
hasCommit bool
|
||||
}
|
||||
|
||||
// captureCommitFilesState reads the commit-files refresh's diff endpoints into
|
||||
// an immutable snapshot. It must run on the UI thread.
|
||||
func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState {
|
||||
from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff()
|
||||
commitFilesContext := self.c.Contexts().CommitFiles
|
||||
if commitFilesContext.GetRef() == nil && commitFilesContext.GetRefRange() == nil {
|
||||
return capturedCommitFilesState{}
|
||||
}
|
||||
|
||||
from, to := commitFilesContext.GetFromAndToForDiff()
|
||||
from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from)
|
||||
return capturedCommitFilesState{from: from, to: to, reverse: reverse}
|
||||
return capturedCommitFilesState{from: from, to: to, reverse: reverse, hasCommit: true}
|
||||
}
|
||||
|
||||
func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, env refreshEnv) error {
|
||||
if !captured.hasCommit {
|
||||
return nil
|
||||
}
|
||||
|
||||
files, err := env.git.Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -722,17 +722,30 @@ func (self *LocalCommitsController) GetOnRenderToMain() func() {
|
||||
}
|
||||
}
|
||||
|
||||
// secondaryPatchPanelUpdateOpts renders the custom patch being built into the pane
|
||||
// beside the diff it is being built from, as a diff of the two trees the patch is
|
||||
// materialized into. This way it is shown by whatever renders the rest of the diffs,
|
||||
// and its lines can be pointed at and taken back out of the patch.
|
||||
func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts {
|
||||
if c.Git().Patch.PatchBuilder.Active() {
|
||||
patch := c.Git().Patch.PatchBuilder.RenderAggregatedPatch(false)
|
||||
|
||||
return &types.ViewUpdateOpts{
|
||||
Task: types.NewMainViewDiffStringTask(patch),
|
||||
Title: c.Tr.CustomPatch,
|
||||
}
|
||||
if !c.Git().Patch.PatchBuilder.Active() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
// A render of the same patch reuses the trees; only a change to the patch writes them
|
||||
// again.
|
||||
if err := c.Git().Patch.EnsureCustomPatchDiffTrees(); err != nil {
|
||||
c.Log.Error(err)
|
||||
}
|
||||
|
||||
// The same mode as the diff beside it: both panes of the pair have to agree about
|
||||
// whether what they show can be acted on.
|
||||
mode := c.Helpers().DiffLine.MainViewDiffMode()
|
||||
cmdObj := c.Git().Diff.CustomPatchDiffCmdObj(c.Git().Patch.PatchBuilder.TempDir(), mode)
|
||||
|
||||
return &types.ViewUpdateOpts{
|
||||
Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode),
|
||||
Title: c.Tr.CustomPatch,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
|
||||
|
||||
@@ -80,22 +80,31 @@ func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*ty
|
||||
Tooltip: self.c.Tr.EditFileTooltip,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Select),
|
||||
Handler: self.primaryAction,
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Select),
|
||||
Handler: self.primaryAction,
|
||||
// The description is of the working tree's diff, which is where the key does
|
||||
// the thing users know it for; over a commit's diff it says so for itself.
|
||||
Description: self.c.Tr.Stage,
|
||||
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.Stage),
|
||||
DescriptionFunc: self.diffActionDescription(self.c.Tr.Stage, self.c.Tr.ToggleSelectionForPatch),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
Tooltip: self.c.Tr.StageSelectionTooltip,
|
||||
DisplayOnScreen: true,
|
||||
// Over a commit's diff the key toggles lines in the custom patch, which the
|
||||
// description says for itself; there is nothing to add to it.
|
||||
TooltipFunc: self.diffActionDescription(self.c.Tr.StageSelectionTooltip, ""),
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Remove),
|
||||
Handler: self.discardSelection,
|
||||
Description: self.c.Tr.DiscardSelection,
|
||||
DescriptionFunc: self.workingTreeActionDescription(self.c.Tr.DiscardSelection),
|
||||
GetDisabledReason: self.diffSelectionDisabledReason,
|
||||
DescriptionFunc: self.diffActionDescription(self.c.Tr.DiscardSelection, self.c.Tr.RemoveSelectionFromPatch),
|
||||
GetDisabledReason: self.discardSelectionDisabledReason,
|
||||
Tooltip: self.c.Tr.DiscardSelectionTooltip,
|
||||
DisplayOnScreen: true,
|
||||
// Over a commit's diff the key rewrites the commit rather than touching the
|
||||
// index, which is worth the warning the other tooltip carries.
|
||||
TooltipFunc: self.diffActionDescription(
|
||||
self.c.Tr.DiscardSelectionTooltip, self.c.Tr.RemoveSelectionFromPatchTooltip),
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Main.EditSelectHunk),
|
||||
@@ -343,52 +352,6 @@ func (self *MainViewController) primaryAction() error {
|
||||
return actions.PrimaryAction(self.context, first, last)
|
||||
}
|
||||
|
||||
// revealSelectionAfterAction moves the selection to the change that takes the place of
|
||||
// the one just acted on, once the changed diff has re-rendered. Call it from the panel's
|
||||
// action handler with the pane it acted in, the pane the work carries on in, and the
|
||||
// buffer line the selection starts on, before triggering the re-render.
|
||||
//
|
||||
// The line acted on is gone from the diff, so what is remembered is its place among the
|
||||
// diff's changes: the next change moves up into it, which is where you want to be to
|
||||
// carry on. A range collapses to a single line at its start, and hunk mode selects the
|
||||
// whole block it lands in, so that pressing the key again acts on the next hunk. The
|
||||
// target pane inherits that select mode, this being the same piece of work continuing
|
||||
// in another pane — and shows no selection until the restore places one, so that what
|
||||
// it was left showing the last time it was used doesn't appear for a frame.
|
||||
//
|
||||
// done is called once the selection is where it belongs, or once it turns out that no
|
||||
// render is coming to put it there, for a caller that must not let the user act again
|
||||
// in between.
|
||||
func revealSelectionAfterAction(
|
||||
c *ControllerCommon, source types.DiffPaneContext, target types.DiffPaneContext,
|
||||
firstBufferLine int, done func(),
|
||||
) {
|
||||
ordinal := c.Helpers().DiffLine.ChangeLineOrdinal(source.GetView(), firstBufferLine)
|
||||
|
||||
sel := source.DiffSelectState()
|
||||
if sel.Mode == types.DiffSelectModeRange {
|
||||
sel.Mode = types.DiffSelectModeLine
|
||||
sel.RangeIsSticky = false
|
||||
}
|
||||
*target.DiffSelectState() = *sel
|
||||
selectHunk := sel.Mode == types.DiffSelectModeHunk
|
||||
|
||||
targetView := target.GetView()
|
||||
if target != source {
|
||||
target.SetHasSelectableContent(false)
|
||||
c.Context().UpdateSelectionHighlights()
|
||||
}
|
||||
|
||||
c.Helpers().DiffLine.RevealChangeLineAtOrdinal(targetView, ordinal, func(viewLine int) {
|
||||
if selectHunk {
|
||||
c.Helpers().DiffLine.SelectChangeBlock(target, viewLine, true)
|
||||
return
|
||||
}
|
||||
targetView.CancelRangeSelect()
|
||||
c.Helpers().DiffLine.ShowSelectionAtLine(targetView, viewLine, true)
|
||||
}, done)
|
||||
}
|
||||
|
||||
// discardSelection takes the selected diff lines back out of what they are part of,
|
||||
// which — like the primary action — is the panel's business, and so is the re-render
|
||||
// that follows.
|
||||
@@ -436,11 +399,23 @@ func (self *MainViewController) workingTreeAction(action func() error) func() er
|
||||
// workingTreeActionDescription gives a command's description only where the command
|
||||
// applies — over the working tree's diff — so that it is listed there and nowhere else.
|
||||
func (self *MainViewController) workingTreeActionDescription(description string) func() string {
|
||||
return self.diffActionDescription(description, "")
|
||||
}
|
||||
|
||||
// diffActionDescription describes a command in the words that suit the diff it applies
|
||||
// to: acting on the working tree's diff stages, acting on a commit's builds a custom
|
||||
// patch. Over content that is no diff at all the command doesn't apply, and describes
|
||||
// itself as nothing, which keeps it out of the keybindings menu there.
|
||||
func (self *MainViewController) diffActionDescription(staging string, patchBuilding string) func() string {
|
||||
return func() string {
|
||||
if self.diffMainViewType() != types.DiffMainViewTypeStaging {
|
||||
switch self.diffMainViewType() {
|
||||
case types.DiffMainViewTypeStaging:
|
||||
return staging
|
||||
case types.DiffMainViewTypePatchBuilding:
|
||||
return patchBuilding
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
return description
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,6 +500,19 @@ func (self *MainViewController) diffSelectionDisabledReason() *types.DisabledRea
|
||||
return nil
|
||||
}
|
||||
|
||||
// discardSelectionDisabledReason disables discarding while there is nothing to discard,
|
||||
// and where the panel beneath won't have it: taking lines out of a commit means
|
||||
// rewriting it, which isn't always something we may do.
|
||||
func (self *MainViewController) discardSelectionDisabledReason() *types.DisabledReason {
|
||||
if reason := self.diffSelectionDisabledReason(); reason != nil {
|
||||
return reason
|
||||
}
|
||||
if actions := self.focusedMainViewActions(); actions != nil {
|
||||
return actions.DiscardSelectionDisabledReason(self.context)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *MainViewController) onClickInAlreadyFocusedView(opts gocui.ViewMouseBindingOpts) error {
|
||||
self.selectClickedDiffLine(opts.Y)
|
||||
return nil
|
||||
|
||||
@@ -27,7 +27,7 @@ func (self *OptionsMenuAction) Call() error {
|
||||
if binding.GetDisabledReason != nil {
|
||||
disabledReason = binding.GetDisabledReason()
|
||||
}
|
||||
tooltip := binding.Tooltip
|
||||
tooltip := binding.GetTooltip()
|
||||
if len(binding.Keys) > 1 {
|
||||
if tooltip != "" {
|
||||
tooltip += "\n\n"
|
||||
|
||||
@@ -10,6 +10,9 @@ type ReflogCommitsController struct {
|
||||
baseController
|
||||
*ListControllerTrait[*models.Commit]
|
||||
c *ControllerCommon
|
||||
|
||||
// what this panel offers on the diff it shows in the focused main view
|
||||
diffActions *CommitDiffActions
|
||||
}
|
||||
|
||||
var _ types.IController = &ReflogCommitsController{}
|
||||
@@ -17,7 +20,7 @@ var _ types.IController = &ReflogCommitsController{}
|
||||
func NewReflogCommitsController(
|
||||
c *ControllerCommon,
|
||||
) *ReflogCommitsController {
|
||||
return &ReflogCommitsController{
|
||||
controller := &ReflogCommitsController{
|
||||
baseController: baseController{},
|
||||
ListControllerTrait: NewListControllerTrait(
|
||||
c,
|
||||
@@ -27,6 +30,19 @@ func NewReflogCommitsController(
|
||||
),
|
||||
c: c,
|
||||
}
|
||||
controller.diffActions = NewCommitDiffActions(c, c.Contexts().ReflogCommits, controller.diffTarget)
|
||||
return controller
|
||||
}
|
||||
|
||||
// diffTarget is the reflog entry the panel has selected, whose diff its main view
|
||||
// shows. A reflog entry is never a commit of the checked-out branch as far as we are
|
||||
// concerned, so nothing here may be rewritten.
|
||||
func (self *ReflogCommitsController) diffTarget() *commitDiffTarget {
|
||||
commit := self.context().GetSelected()
|
||||
if commit == nil {
|
||||
return nil
|
||||
}
|
||||
return &commitDiffTarget{from: commit.ParentRefName(), to: commit.RefName()}
|
||||
}
|
||||
|
||||
func (self *ReflogCommitsController) Context() types.Context {
|
||||
@@ -38,17 +54,7 @@ func (self *ReflogCommitsController) context() *context.ReflogCommitsContext {
|
||||
}
|
||||
|
||||
func (self *ReflogCommitsController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
|
||||
return self
|
||||
}
|
||||
|
||||
// PlainDiff hands out the reflog entry's diff for the given files — the same diff its
|
||||
// main view shows, only without the entry's message and stat above it.
|
||||
func (self *ReflogCommitsController) PlainDiff(_ types.DiffPaneContext, paths []string) string {
|
||||
commit := self.context().GetSelected()
|
||||
if commit == nil {
|
||||
return ""
|
||||
}
|
||||
return self.c.Helpers().Diff.PlainDiffBetweenRefs(commit.ParentRefName(), commit.RefName(), paths)
|
||||
return self.diffActions
|
||||
}
|
||||
|
||||
func (self *ReflogCommitsController) GetOnRenderToMain() func() {
|
||||
@@ -71,6 +77,7 @@ func (self *ReflogCommitsController) GetOnRenderToMain() func() {
|
||||
Title: "Reflog Entry",
|
||||
Task: task,
|
||||
},
|
||||
Secondary: secondaryPatchPanelUpdateOpts(self.c),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ func (self *StashController) GetOnRenderToMain() func() {
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: task,
|
||||
},
|
||||
Secondary: secondaryPatchPanelUpdateOpts(self.c),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ func (self *SubCommitsController) GetOnRenderToMain() func() {
|
||||
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
||||
Task: task,
|
||||
},
|
||||
Secondary: secondaryPatchPanelUpdateOpts(self.c),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,17 +25,48 @@ type SwitchToDiffFilesController struct {
|
||||
baseController
|
||||
c *ControllerCommon
|
||||
context CanSwitchToDiffFiles
|
||||
|
||||
// what this panel offers on the diff it shows in the focused main view
|
||||
diffActions *CommitDiffActions
|
||||
}
|
||||
|
||||
func NewSwitchToDiffFilesController(
|
||||
c *ControllerCommon,
|
||||
context CanSwitchToDiffFiles,
|
||||
) *SwitchToDiffFilesController {
|
||||
return &SwitchToDiffFilesController{
|
||||
controller := &SwitchToDiffFilesController{
|
||||
baseController: baseController{},
|
||||
c: c,
|
||||
context: context,
|
||||
}
|
||||
controller.diffActions = NewCommitDiffActions(c, context, controller.diffTarget)
|
||||
return controller
|
||||
}
|
||||
|
||||
// diffTarget is the commit — or stash entry, or range of commits — the panel has
|
||||
// selected, whose whole diff its main view shows.
|
||||
func (self *SwitchToDiffFilesController) diffTarget() *commitDiffTarget {
|
||||
ref := self.context.GetSelectedRef()
|
||||
if ref == nil {
|
||||
return nil
|
||||
}
|
||||
refRange := self.context.GetSelectedRefRangeForDiffFiles()
|
||||
from, to := context.FromAndToForDiff(ref, refRange)
|
||||
return &commitDiffTarget{from: from, to: to, canRebase: self.canRebase(ref, refRange)}
|
||||
}
|
||||
|
||||
// canRebase reports whether the given selection is one lazygit may rewrite: the panel
|
||||
// has to allow it in the first place, a range of commits can't be rewritten as one,
|
||||
// and in diffing mode what the main view shows is a diff against another ref rather
|
||||
// than the commit itself, unless that other ref is the selected commit.
|
||||
func (self *SwitchToDiffFilesController) canRebase(ref models.Ref, refRange *types.RefRange) bool {
|
||||
if !self.context.CanRebase() {
|
||||
return false
|
||||
}
|
||||
if self.c.Modes().Diffing.Active() {
|
||||
return self.c.Modes().Diffing.Ref == ref.RefName()
|
||||
}
|
||||
return refRange == nil
|
||||
}
|
||||
|
||||
func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
||||
@@ -66,19 +97,7 @@ func (self *SwitchToDiffFilesController) GetOnDoubleClick() func() error {
|
||||
}
|
||||
|
||||
func (self *SwitchToDiffFilesController) GetFocusedMainViewDiffSource() types.FocusedMainViewDiffSource {
|
||||
return self
|
||||
}
|
||||
|
||||
// PlainDiff hands out the diff of the panel's selected commit (or range of them) for
|
||||
// the given files — the same diff its main view shows, only without the commit's
|
||||
// message and stat above it.
|
||||
func (self *SwitchToDiffFilesController) PlainDiff(_ types.DiffPaneContext, paths []string) string {
|
||||
ref := self.context.GetSelectedRef()
|
||||
if ref == nil {
|
||||
return ""
|
||||
}
|
||||
from, to := context.FromAndToForDiff(ref, self.context.GetSelectedRefRangeForDiffFiles())
|
||||
return self.c.Helpers().Diff.PlainDiffBetweenRefs(from, to, paths)
|
||||
return self.diffActions
|
||||
}
|
||||
|
||||
func (self *SwitchToDiffFilesController) enter() error {
|
||||
@@ -86,16 +105,7 @@ func (self *SwitchToDiffFilesController) enter() error {
|
||||
refsRange := self.context.GetSelectedRefRangeForDiffFiles()
|
||||
commitFilesContext := self.c.Contexts().CommitFiles
|
||||
|
||||
canRebase := self.context.CanRebase()
|
||||
if canRebase {
|
||||
if self.c.Modes().Diffing.Active() {
|
||||
if self.c.Modes().Diffing.Ref != ref.RefName() {
|
||||
canRebase = false
|
||||
}
|
||||
} else if refsRange != nil {
|
||||
canRebase = false
|
||||
}
|
||||
}
|
||||
canRebase := self.canRebase(ref, refsRange)
|
||||
|
||||
commitFilesContext.ClearFilter()
|
||||
commitFilesContext.ReInit(ref, refsRange)
|
||||
|
||||
@@ -100,6 +100,12 @@ func (self *WorkingTreeDiffActions) DiscardSelection(pane types.DiffPaneContext,
|
||||
})
|
||||
}
|
||||
|
||||
// DiscardSelectionDisabledReason is nil: a change of the working tree can always be
|
||||
// thrown away, and one in the index always taken back out of it.
|
||||
func (self *WorkingTreeDiffActions) DiscardSelectionDisabledReason(types.DiffPaneContext) *types.DisabledReason {
|
||||
return nil
|
||||
}
|
||||
|
||||
// EditHunk opens the git hunk holding the selection in an editor, as a patch against
|
||||
// the index, and applies whatever comes back. It is how you stage something the diff
|
||||
// can't express — half of a changed line, or a change written differently from either
|
||||
@@ -122,7 +128,8 @@ func (self *WorkingTreeDiffActions) EditHunk(
|
||||
}
|
||||
|
||||
parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, onStagedSide))
|
||||
lineIndices := changeLineIndices(parsedPatch, infos[:1])
|
||||
lineIndices := patch.ChangeLineIndicesForLines(parsedPatch,
|
||||
[]patch.LineIdentity{infos[0].PatchLineIdentity()})
|
||||
if len(lineIndices) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -181,6 +188,12 @@ func (self *WorkingTreeDiffActions) EditHunk(
|
||||
return nil
|
||||
}
|
||||
|
||||
// PatchInclusion is nil: a custom patch is built from a commit's diff, never from the
|
||||
// working tree's, so no line of this diff is ever in one.
|
||||
func (self *WorkingTreeDiffActions) PatchInclusion() func(types.DiffLineInfo) bool {
|
||||
return nil
|
||||
}
|
||||
|
||||
// diffLineSelection resolves what the user has selected in a pane of the focused main
|
||||
// view to the change lines to act on, and reports whether they are the staged side of
|
||||
// the diff — which is a question about the pane, so it is the same for every file of a
|
||||
@@ -280,7 +293,10 @@ func (self *WorkingTreeDiffActions) applyDiffLines(
|
||||
) (bool, error) {
|
||||
parsedPatch := patch.Parse(self.c.Git().WorkingTree.WorktreeFileDiff(file, git_commands.DiffModePlain, sourceCached))
|
||||
|
||||
patchLineIndices := changeLineIndices(parsedPatch, infos)
|
||||
patchLineIndices := patch.ChangeLineIndicesForLines(parsedPatch,
|
||||
lo.Map(infos, func(info types.DiffLineInfo, _ int) patch.LineIdentity {
|
||||
return info.PatchLineIdentity()
|
||||
}))
|
||||
|
||||
changesLeft := len(patchLineIndices) < changeLineCount(parsedPatch)
|
||||
|
||||
@@ -311,42 +327,6 @@ func (self *WorkingTreeDiffActions) applyDiffLines(
|
||||
return changesLeft, self.c.Git().Patch.ApplyPatch(patchToApply, opts)
|
||||
}
|
||||
|
||||
// changeLineIndices says which lines of a parsed patch the given diff rows are, as the
|
||||
// indices a patch is built in terms of. Each row is looked for by where it sits in the
|
||||
// file: an addition by its place in the new version, a deletion by its place in the
|
||||
// old one, which is what tells the two halves of a changed line apart.
|
||||
func changeLineIndices(parsedPatch *patch.Patch, infos []types.DiffLineInfo) []int {
|
||||
type changeLine struct {
|
||||
lineNumber int
|
||||
isDeletion bool
|
||||
}
|
||||
selected := set.New[changeLine]()
|
||||
for _, info := range infos {
|
||||
if info.Type == types.DiffLineDeleted {
|
||||
selected.Add(changeLine{info.OldLine, true})
|
||||
} else {
|
||||
selected.Add(changeLine{info.NewLine, false})
|
||||
}
|
||||
}
|
||||
|
||||
var indices []int
|
||||
for idx, line := range parsedPatch.Lines() {
|
||||
var key changeLine
|
||||
switch {
|
||||
case line.IsAddition():
|
||||
key = changeLine{parsedPatch.LineNumberOfLine(idx), false}
|
||||
case line.IsDeletion():
|
||||
key = changeLine{parsedPatch.OldLineNumberOfLine(idx), true}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if selected.Includes(key) {
|
||||
indices = append(indices, idx)
|
||||
}
|
||||
}
|
||||
return indices
|
||||
}
|
||||
|
||||
// changeLineCount returns how many of a patch's lines are changes rather than context
|
||||
// or header. A selection of the whole diff covers exactly that many.
|
||||
func changeLineCount(p *patch.Patch) int {
|
||||
@@ -378,7 +358,7 @@ func (self *WorkingTreeDiffActions) revealSelectionInPaneItLandsIn(
|
||||
// that, and until it has been the selection is still on lines that aren't there any
|
||||
// more — so a key pressed meanwhile would act on nothing.
|
||||
self.c.GocuiGui().BeginBlockingEvents()
|
||||
revealSelectionAfterAction(self.c, pane, target, firstBufferLine,
|
||||
self.c.Helpers().DiffLine.RevealSelectionAfterAction(pane, target, firstBufferLine, 0,
|
||||
self.c.GocuiGui().EndBlockingEvents)
|
||||
}
|
||||
|
||||
|
||||
+83
-3
@@ -1,6 +1,8 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
@@ -118,6 +120,7 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
|
||||
// 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)
|
||||
|
||||
@@ -244,6 +247,74 @@ func (gui *Gui) followFocusIntoWorkablePane(opts types.RefreshMainOpts) {
|
||||
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
|
||||
@@ -308,8 +379,11 @@ func (gui *Gui) clearMainView(mainContext types.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// updateDiffSelectionVisibility works out whether a main pane holds anything for a
|
||||
// selection to sit on, from what it is now showing: only beneath a panel whose main
|
||||
// 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
|
||||
@@ -321,7 +395,7 @@ func (gui *Gui) clearMainView(mainContext types.Context) {
|
||||
// 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) updateDiffSelectionVisibility(view *gocui.View, contentIsComplete bool) {
|
||||
func (gui *Gui) updateDiffPaneDecorations(view *gocui.View, contentIsComplete bool) {
|
||||
mainContext := gui.mainContextForView(view)
|
||||
if mainContext == nil {
|
||||
return
|
||||
@@ -337,6 +411,12 @@ func (gui *Gui) updateDiffSelectionVisibility(view *gocui.View, contentIsComplet
|
||||
} 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
|
||||
|
||||
@@ -51,6 +51,11 @@ func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) er
|
||||
return gui.newCmdTask(view, cmd, prefix)
|
||||
}
|
||||
|
||||
// The key the render is remembered under says which diff it is of, so that a
|
||||
// re-render of the same diff can be told from a render of another one. Take
|
||||
// it before anything else can touch the command's arguments.
|
||||
cmdStr := strings.Join(cmd.Args, " ")
|
||||
|
||||
// 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
|
||||
@@ -65,7 +70,7 @@ func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) er
|
||||
gui.afterLayout(func() error {
|
||||
// The layout may have changed the size of the view, so only now is the
|
||||
// width to render at known, and with it the renderer command.
|
||||
width := view.InnerWidth()
|
||||
width := gui.renderWidth(view)
|
||||
diffRendererConfigManager := gui.stateAccessor.GetDiffRendererConfigManager()
|
||||
values := config.DiffRendererValues{
|
||||
Width: width,
|
||||
@@ -81,8 +86,6 @@ func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) er
|
||||
gui.c.ErrorToast(err.Error())
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -116,6 +119,17 @@ func (gui *Gui) newRenderTask(view *gocui.View, cmd *exec.Cmd, prefix string) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderWidth is the width a render into view is laid out to: the view's own, less the
|
||||
// columns the custom patch's marks take from it where they are drawn over the render
|
||||
// (see DiffLineHelper.ShowsInclusionGutter).
|
||||
func (gui *Gui) renderWidth(view *gocui.View) int {
|
||||
width := view.InnerWidth()
|
||||
if gui.helpers.DiffLine.ShowsInclusionGutter(view) {
|
||||
width -= view.InclusionGutterWidthWhenShown()
|
||||
}
|
||||
return max(0, width)
|
||||
}
|
||||
|
||||
// The start and onClose functions a render hands to its task: how to get the
|
||||
// command running and the output reader for it, and how to tear it down again
|
||||
// once the task is stopped.
|
||||
|
||||
@@ -230,6 +230,7 @@ M file1
|
||||
func(from string, to string, reverse bool, filename string, previousPath string) (string, error) {
|
||||
return "", nil
|
||||
},
|
||||
nil,
|
||||
)
|
||||
patchBuilder.Start("from", "to", false, false)
|
||||
result := RenderCommitFileTree(viewModel, patchBuilder, false, &config.CustomIconsConfig{})
|
||||
|
||||
+1
-2
@@ -12,8 +12,7 @@ import (
|
||||
)
|
||||
|
||||
func (gui *Gui) desiredPtySize(view *gocui.View) (cols, rows uint16) {
|
||||
width, height := view.InnerSize()
|
||||
return uint16(width), uint16(height)
|
||||
return uint16(gui.renderWidth(view)), uint16(view.InnerHeight())
|
||||
}
|
||||
|
||||
func (gui *Gui) onResize() error {
|
||||
|
||||
@@ -33,7 +33,7 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
|
||||
// keeps the task goroutine from reading the view's live dimensions while it
|
||||
// streams output.
|
||||
gui.afterLayout(func() error {
|
||||
spec := renderSpec{view: view, cmd: cmd, width: view.InnerWidth()}
|
||||
spec := renderSpec{view: view, cmd: cmd, width: gui.renderWidth(view)}
|
||||
return gui.newTaskForRender(spec, prefix, cmdStr, gui.plainRender)
|
||||
})
|
||||
|
||||
@@ -100,7 +100,7 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
|
||||
f := func(tasks.TaskOpts) error {
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
gui.c.SetViewContent(view, str)
|
||||
gui.updateDiffSelectionVisibility(view, true)
|
||||
gui.updateDiffPaneDecorations(view, true)
|
||||
gui.reApplySearch(view)
|
||||
})
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
gui.c.SetViewContent(view, str)
|
||||
view.SetOrigin(originX, originY)
|
||||
gui.updateDiffSelectionVisibility(view, true)
|
||||
gui.updateDiffPaneDecorations(view, true)
|
||||
gui.reApplySearch(view)
|
||||
})
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
gui.c.ResetViewOrigin(view)
|
||||
gui.c.SetViewContent(view, str)
|
||||
gui.updateDiffSelectionVisibility(view, true)
|
||||
gui.updateDiffPaneDecorations(view, true)
|
||||
gui.reApplySearch(view)
|
||||
})
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
// to say whether there is anything to select, and for a diff that
|
||||
// opens with a long diffstat it isn't.
|
||||
gui.c.OnUIThreadContentOnly(func() error {
|
||||
gui.updateDiffSelectionVisibility(view, false)
|
||||
gui.updateDiffPaneDecorations(view, false)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -202,7 +202,7 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
view.SetOrigin(0, newOriginY)
|
||||
}
|
||||
|
||||
gui.updateDiffSelectionVisibility(view, true)
|
||||
gui.updateDiffPaneDecorations(view, true)
|
||||
gui.clampDiffSelectionToContent(view)
|
||||
gui.reApplySearch(view)
|
||||
},
|
||||
@@ -216,7 +216,7 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
// The content the pane is being given is on display from here on, so
|
||||
// what is drawn over it is settled against that content rather than
|
||||
// against the render before it.
|
||||
gui.updateDiffSelectionVisibility(view, false)
|
||||
gui.updateDiffPaneDecorations(view, false)
|
||||
},
|
||||
func() gocui.Task {
|
||||
// A background task: rendering content into a view is display
|
||||
|
||||
@@ -257,8 +257,20 @@ type FocusedMainViewActions interface {
|
||||
PrimaryAction(pane DiffPaneContext, firstBufferLine int, lastBufferLine int) error
|
||||
|
||||
// DiscardSelection takes the selected diff lines back out of whatever they are part
|
||||
// of: the working tree for the files panel.
|
||||
// of: the working tree for the files panel, the commit itself for the panels showing
|
||||
// a commit's diff.
|
||||
DiscardSelection(pane DiffPaneContext, firstBufferLine int, lastBufferLine int) error
|
||||
|
||||
// DiscardSelectionDisabledReason says why the selection can't be discarded where it
|
||||
// is, and nil when it can. Taking lines out of a commit means rewriting it, which
|
||||
// isn't always something we may do; the working tree has no such condition.
|
||||
DiscardSelectionDisabledReason(pane DiffPaneContext) *DisabledReason
|
||||
|
||||
// PatchInclusion says which lines of the diff this panel shows are in the custom
|
||||
// patch being built from it. The marks over those lines are drawn from this. nil
|
||||
// where nothing about this diff is being built into a patch, which is always so
|
||||
// for a diff that can't be.
|
||||
PatchInclusion() func(info DiffLineInfo) bool
|
||||
}
|
||||
|
||||
type IListContext interface {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package types
|
||||
|
||||
import "github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
|
||||
// DiffLineType classifies a row of a rendered diff.
|
||||
type DiffLineType int
|
||||
|
||||
@@ -41,6 +43,16 @@ func (self DiffLineInfo) IsChange() bool {
|
||||
return self.Type == DiffLineAdded || self.Type == DiffLineDeleted
|
||||
}
|
||||
|
||||
// PatchLineIdentity says which change line of the file the row is, in the terms a patch
|
||||
// of that file is built and read in: an addition by where it sits in the new version of
|
||||
// the file, a deletion by where it sat in the old one. Only meaningful for a change row.
|
||||
func (self DiffLineInfo) PatchLineIdentity() patch.LineIdentity {
|
||||
if self.Type == DiffLineDeleted {
|
||||
return patch.LineIdentity{LineNumber: self.OldLine, IsDeletion: true}
|
||||
}
|
||||
return patch.LineIdentity{LineNumber: self.NewLine}
|
||||
}
|
||||
|
||||
// IsContent reports whether the row is a line of the file itself — a change or a
|
||||
// context line — as opposed to a header or a marker. Those are the rows that have a
|
||||
// position in the file, and so can be looked for in another rendering of the same
|
||||
|
||||
@@ -39,6 +39,11 @@ type Binding struct {
|
||||
|
||||
// to be displayed if the keybinding is highlighted from within a menu
|
||||
Tooltip string
|
||||
// TooltipFunc is used instead of Tooltip if non-nil, for a command whose tooltip
|
||||
// depends on context, as DescriptionFunc is for its description — and with the
|
||||
// same two conditions: it must not be an expensive call, and a generic Tooltip
|
||||
// must still be given, since that is the one the cheatsheet prints.
|
||||
TooltipFunc func() string
|
||||
|
||||
// Function to decide whether the command is enabled, and why. If this
|
||||
// returns an empty string, it is; if it returns a non-empty string, it is
|
||||
@@ -59,6 +64,13 @@ func (b *Binding) GetDescription() string {
|
||||
return b.Description
|
||||
}
|
||||
|
||||
func (b *Binding) GetTooltip() string {
|
||||
if b.TooltipFunc != nil {
|
||||
return b.TooltipFunc()
|
||||
}
|
||||
return b.Tooltip
|
||||
}
|
||||
|
||||
func (b *Binding) GetShortDescription() string {
|
||||
if b.ShortDescriptionFunc != nil {
|
||||
return b.ShortDescriptionFunc()
|
||||
|
||||
@@ -69,9 +69,6 @@ func NewRenderStringTask(str string) *RenderStringTask {
|
||||
|
||||
type RenderStringWithoutScrollTask struct {
|
||||
Str string
|
||||
|
||||
// contentIsDiff marks a string that is a panel's own diff; see ContentIsDiff.
|
||||
contentIsDiff bool
|
||||
}
|
||||
|
||||
func (t *RenderStringWithoutScrollTask) IsUpdateTask() {}
|
||||
@@ -80,16 +77,6 @@ func NewRenderStringWithoutScrollTask(str string) *RenderStringWithoutScrollTask
|
||||
return &RenderStringWithoutScrollTask{Str: str}
|
||||
}
|
||||
|
||||
// NewMainViewDiffStringTask returns the task for rendering a diff we hold as text
|
||||
// rather than as a command to run — the custom patch being built, which we assemble
|
||||
// ourselves. The view stays where it is, the patch being rendered again on every
|
||||
// change to it.
|
||||
func NewMainViewDiffStringTask(str string) UpdateTask {
|
||||
task := NewRenderStringWithoutScrollTask(str)
|
||||
task.contentIsDiff = true
|
||||
return task
|
||||
}
|
||||
|
||||
type RenderStringWithScrollTask struct {
|
||||
Str string
|
||||
OriginX int
|
||||
@@ -166,8 +153,6 @@ func NewMainViewDiffTaskWithPrefix(cmd *exec.Cmd, prefix string, mode git_comman
|
||||
// such a render that there is anything to point at.
|
||||
func ContentIsDiff(task UpdateTask) bool {
|
||||
switch task := task.(type) {
|
||||
case *RenderStringWithoutScrollTask:
|
||||
return task.contentIsDiff
|
||||
case *RunCommandTask:
|
||||
return task.contentIsDiff
|
||||
case *RunDiffRendererTask:
|
||||
|
||||
@@ -177,6 +177,13 @@ func (gui *Gui) createAllViews() error {
|
||||
gui.Views.Main.SelectedLineColorWidth = 2
|
||||
gui.Views.Secondary.SelectedLineColorWidth = 2
|
||||
|
||||
// A tick, for the lines of a commit's diff that are in the custom patch being
|
||||
// built. A plus would collide with a diff's own plus column.
|
||||
gui.Views.Main.InclusionGutterMarker = "✓"
|
||||
gui.Views.Main.InclusionGutterMarkerColor = gocui.ColorGreen
|
||||
gui.Views.Secondary.InclusionGutterMarker = "✓"
|
||||
gui.Views.Secondary.InclusionGutterMarkerColor = gocui.ColorGreen
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -472,6 +472,7 @@ type TranslationSet struct {
|
||||
CheckoutCommitFileTooltip string
|
||||
CannotCheckoutWithModifiedFilesErr string
|
||||
CanOnlyDiscardFromLocalCommits string
|
||||
CannotDiscardFromCustomPatchView string
|
||||
CannotDiscardFromMultipleCommits string
|
||||
Remove string
|
||||
DiscardOldFileChangeTooltip string
|
||||
@@ -1665,6 +1666,7 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
CheckoutCommitFileTooltip: "Checkout file. This replaces the file in your working tree with the version from the selected commit.",
|
||||
CannotCheckoutWithModifiedFilesErr: "You have local modifications for the file(s) you are trying to check out. You need to stash or discard these first.",
|
||||
CanOnlyDiscardFromLocalCommits: "Changes can only be discarded from local commits",
|
||||
CannotDiscardFromCustomPatchView: "Lines shown here are the custom patch's; press space to take them back out of it",
|
||||
CannotDiscardFromMultipleCommits: "Changes cannot be discarded from a multiselection of commits",
|
||||
Remove: "Remove",
|
||||
DiscardOldFileChangeTooltip: "Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file.",
|
||||
|
||||
@@ -437,6 +437,48 @@ func (self *ViewDriver) Content(matcher *TextMatcher) *ViewDriver {
|
||||
return self
|
||||
}
|
||||
|
||||
// MarkedLines asserts which lines of the view are marked as being in the custom patch
|
||||
// being built. The marks are drawn over the content rather than being part of it, so
|
||||
// they are read from the view rather than matched against what Content returns.
|
||||
func (self *ViewDriver) MarkedLines(matchers ...*TextMatcher) *ViewDriver {
|
||||
self.validateMatchersPassed(matchers)
|
||||
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
markedLines := self.getView().MarkedLines()
|
||||
|
||||
markedContent := strings.Join(markedLines, "\n")
|
||||
expectedContent := expectedContentFromMatchers(matchers)
|
||||
|
||||
if len(markedLines) != len(matchers) {
|
||||
return false, fmt.Sprintf("%s: Expected the following lines to be marked as being in the custom patch:\n-----\n%s\n-----\nBut got:\n-----\n%s\n-----", self.context, expectedContent, markedContent)
|
||||
}
|
||||
|
||||
for i, line := range markedLines {
|
||||
ok, message := matchers[i].test(line)
|
||||
if !ok {
|
||||
return false, fmt.Sprintf("%s: Error: %s. Expected the following lines to be marked as being in the custom patch:\n-----\n%s\n-----\nBut got:\n-----\n%s\n-----", self.context, message, expectedContent, markedContent)
|
||||
}
|
||||
}
|
||||
|
||||
return true, ""
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// NoMarkedLines asserts that no line of the view is marked as being in the custom
|
||||
// patch, which is also what a view showing no marks at all reports.
|
||||
func (self *ViewDriver) NoMarkedLines() *ViewDriver {
|
||||
self.t.assertWithRetries(func() (bool, string) {
|
||||
markedLines := self.getView().MarkedLines()
|
||||
return len(markedLines) == 0, fmt.Sprintf(
|
||||
"%s: Expected no line to be marked as being in the custom patch, but these were:\n-----\n%s\n-----",
|
||||
self.context, strings.Join(markedLines, "\n"))
|
||||
})
|
||||
|
||||
return self
|
||||
}
|
||||
|
||||
// SelectionIsActive asserts that the view draws its selection as the one the user
|
||||
// is working in. These three assertions read the highlight flags rather than the
|
||||
// selected lines, which say nothing about whether the selection is drawn at all.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var BuildPatchFromACommitsDiff = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Take lines of a commit's diff into a custom patch, and back out of it, from the focused main view",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "ONE\ntwo\nTHREE\nfour\nfive\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("second commit").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
PressEnter()
|
||||
|
||||
t.Views().CommitFiles().
|
||||
IsFocused().
|
||||
Lines(
|
||||
Contains("file1").IsSelected(),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-one"),
|
||||
).
|
||||
// The line goes into the patch, which the pane beside the diff previews, and
|
||||
// the selection moves on to the next change rather than staying where a
|
||||
// second press would take it straight back out.
|
||||
PressPrimaryAction().
|
||||
SelectedLines(
|
||||
Contains("+ONE"),
|
||||
)
|
||||
|
||||
t.Views().Information().Content(Contains("Building patch"))
|
||||
t.Views().Secondary().ContainsLines(
|
||||
Contains("-one"),
|
||||
Contains(" two"),
|
||||
)
|
||||
// The line that is in the patch is marked as such over the diff itself.
|
||||
t.Views().Main().MarkedLines(
|
||||
Contains("-one"),
|
||||
)
|
||||
|
||||
// The addition of the same modification goes in too, and the patch holds both.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
PressPrimaryAction().
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
)
|
||||
|
||||
t.Views().Secondary().ContainsLines(
|
||||
Contains("-one"),
|
||||
Contains("+ONE"),
|
||||
Contains(" two"),
|
||||
)
|
||||
t.Views().Main().MarkedLines(
|
||||
Contains("-one"),
|
||||
Contains("+ONE"),
|
||||
)
|
||||
|
||||
// Pointing at a line that is in the patch takes it back out.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
NavigateToLine(Contains("+ONE")).
|
||||
PressPrimaryAction()
|
||||
|
||||
t.Views().Secondary().
|
||||
ContainsLines(
|
||||
Contains("-one"),
|
||||
Contains(" two"),
|
||||
).
|
||||
Content(DoesNotContain("+ONE"))
|
||||
t.Views().Main().MarkedLines(
|
||||
Contains("-one"),
|
||||
)
|
||||
|
||||
// Taking the last line out ends the patch, so the pane previewing it goes away.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
NavigateToLine(Contains("-one")).
|
||||
PressPrimaryAction()
|
||||
|
||||
t.Views().Information().Content(DoesNotContain("Building patch"))
|
||||
t.Views().Main().NoMarkedLines()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var BuildPatchFromAReflogEntry = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Take lines of the diff of a reflog entry into a custom patch, which can then be applied",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().ReflogCommits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("second commit").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
t.Views().Information().Content(Contains("Building patch"))
|
||||
t.Views().Secondary().ContainsLines(
|
||||
Contains("-two"),
|
||||
Contains(" three"),
|
||||
)
|
||||
|
||||
// A reflog entry is never a commit we may rewrite, so the patch can be applied
|
||||
// but not moved out of the commit it came from.
|
||||
t.Common().SelectPatchOption(Contains("Apply patch in reverse"))
|
||||
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("M").Contains("file1"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var BuildPatchFromAWholeCommitsDiff = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Take lines of two files into a custom patch from the whole diff of a commit, without entering its files first",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = true
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.CreateFileAndAdd("file2", "alpha\nbeta\ngamma\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
|
||||
shell.UpdateFileAndAdd("file2", "alpha\nBETA\ngamma\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("second commit").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// The commit's whole diff spans both files, and hunk mode offers the first
|
||||
// changed block of the first of them.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
Contains("+TWO"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
// The selection moves past the block just taken in, which is file2's block.
|
||||
SelectedLines(
|
||||
Contains("-beta"),
|
||||
Contains("+BETA"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
// The patch spans both files.
|
||||
t.Views().Secondary().
|
||||
Content(Contains("file1")).
|
||||
Content(Contains("file2")).
|
||||
ContainsLines(
|
||||
Contains("-two"),
|
||||
Contains("+TWO"),
|
||||
Contains(" three"),
|
||||
).
|
||||
ContainsLines(
|
||||
Contains("-beta"),
|
||||
Contains("+BETA"),
|
||||
Contains(" gamma"),
|
||||
)
|
||||
|
||||
// Applying it to the working tree puts both files' changes there, which is the
|
||||
// proof that the patch really holds what the preview says it does.
|
||||
t.Common().SelectPatchOption(Contains("Apply patch in reverse"))
|
||||
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
ContainsLines(
|
||||
Contains("M").Contains("file1"),
|
||||
Contains("M").Contains("file2"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -139,5 +139,25 @@ var CopySelectedDiffLines = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
`\A {4}two\n---\n file1 \|[^\n]*\n 1 file changed[^\n]*\n\n`+
|
||||
`diff --git a/file1 b/file1\nindex [0-9a-f]+\.\.[0-9a-f]+ 100644\n`+
|
||||
`--- a/file1\n\+\+\+ b/file1\n@@ -1,3 \+1,3 @@\n one\n-two\n\z`))
|
||||
|
||||
// The pane showing the custom patch is a diff too, of the patch's own lines.
|
||||
t.Views().Main().
|
||||
Press(keys.Main.ToggleSelectHunk).
|
||||
SelectedLines(
|
||||
Contains("-two <<<"),
|
||||
Contains("+TWO <<<"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
Press(keys.Universal.TogglePanel)
|
||||
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two <<<"),
|
||||
Contains("+TWO <<<"),
|
||||
).
|
||||
Press(keys.Universal.CopyToClipboard)
|
||||
|
||||
expectClipboard(t, Equals("-two\n+TWO\n"))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var CustomPatchGoesThroughTheDiffRenderer = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "The custom patch is shown by the configured diff renderer, being rendered as a diff of real files rather than assembled by us",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
// A renderer that announces the metadata protocol — so that focusing the main
|
||||
// view keeps its output — and says who it is above the diff it passes through.
|
||||
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\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Content(Contains("RENDERED BY ME")).
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
// The patch is a diff like any other, so the renderer has had it too. git worked
|
||||
// out its context, and that is where the unchanged line either side comes from.
|
||||
t.Views().Secondary().
|
||||
Content(Contains("RENDERED BY ME")).
|
||||
ContainsLines(
|
||||
Contains(" one"),
|
||||
Contains("-two"),
|
||||
Contains(" three"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var CustomPatchIgnoresLineEndingConversion = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "The custom patch is shown as git states it on a machine that checks files out with CRLF",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
// The patch is materialized into trees of its own outside the repo, so what the repo
|
||||
// says about line endings never reaches the commands over them. These variables are
|
||||
// how a setting reaches a git command wherever it runs.
|
||||
ExtraEnvVars: map[string]string{
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "core.autocrlf",
|
||||
"GIT_CONFIG_VALUE_0": "true",
|
||||
},
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nthree\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
// The patch is one line and its context, so that is all the two trees differ in.
|
||||
// A tree written in the form the platform checks files out in would differ from
|
||||
// the other in every line instead.
|
||||
t.Views().Secondary().
|
||||
ContainsLines(
|
||||
Contains(" one"),
|
||||
Contains("-two"),
|
||||
Contains(" three"),
|
||||
).
|
||||
// git says nothing about a round trip these files never take.
|
||||
Content(DoesNotContain("warning"))
|
||||
},
|
||||
})
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var DiscardFromACommitOnlyWhereItCanBeRewritten = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Discarding lines is refused over a diff that belongs to no commit we may rewrite, and over the custom patch itself",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFile("file1", "one\nTWO\nthree\n")
|
||||
shell.Stash("a stashed change")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// A stash entry is no commit of ours to rewrite, so its lines can go into a
|
||||
// patch but can't be taken out of what they are part of.
|
||||
t.Views().Stash().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("a stashed change").IsSelected(),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
Press(keys.Universal.Remove)
|
||||
|
||||
t.ExpectPopup().Alert().
|
||||
Title(Equals("Error")).
|
||||
Content(Contains("Changes can only be discarded from local commits")).
|
||||
Confirm()
|
||||
|
||||
// The pane previewing the patch shows the patch's own lines, which are not the
|
||||
// commit's to discard; space takes them back out of the patch instead.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
PressPrimaryAction().
|
||||
Press(keys.Universal.TogglePanel)
|
||||
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
Press(keys.Universal.Remove)
|
||||
|
||||
t.ExpectPopup().Alert().
|
||||
Title(Equals("Error")).
|
||||
Content(Contains("Lines shown here are the custom patch's")).
|
||||
Confirm()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var DiscardLinesFromACommit = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Discard the selected lines of a commit's diff from the commit itself, in the focused main view",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\nFOUR\nfive\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("second commit").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
NavigateToLine(Contains("+TWO")).
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
Contains("+TWO"),
|
||||
).
|
||||
Press(keys.Universal.Remove)
|
||||
|
||||
t.ExpectPopup().Confirmation().
|
||||
Title(Equals("Discard lines from commit")).
|
||||
Content(Contains("Are you sure you want to discard the selected lines from this commit?")).
|
||||
Confirm()
|
||||
|
||||
// The commit keeps its other change and has given up the one discarded, and the
|
||||
// selection carries on from the change that has taken its place.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Content(DoesNotContain("+TWO")).
|
||||
SelectedLines(
|
||||
Contains("-four"),
|
||||
)
|
||||
|
||||
// The rewrite is the commit's own business: nothing is left lying in the working
|
||||
// tree.
|
||||
t.Views().Files().IsEmpty()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var MoveOnWhenThePatchMarksRewrapTheDiff = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Taking the first line into a custom patch moves the selection on to the next change, although the marks that come with the patch narrow the diff and wrap a line above the selection",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file", "one\nTWO\nthree\nFOUR\nfive\n")
|
||||
// git draws the diffstat line of a file with this many changes as wide as the
|
||||
// view, so the columns the marks take make that line wrap.
|
||||
shell.CreateFileAndAdd("many", strings.Repeat("line\n", 300))
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
NavigateToLine(Contains("+TWO")).
|
||||
PressPrimaryAction().
|
||||
MarkedLines(
|
||||
Contains("+TWO"),
|
||||
).
|
||||
SelectedLines(
|
||||
Contains("-four"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var PatchMarksFollowARendererSwitch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Switching diff renderers mid-build leaves the marks on the lines that are in the custom patch, wherever the new rendering puts them",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
// Two renderers that announce the metadata protocol — so that focusing the main
|
||||
// view keeps their output rather than falling back to git's own — and pass the
|
||||
// diff through under a banner of their own. The second one's banner is a line
|
||||
// longer, so every line of the diff it renders is a line further down than the
|
||||
// first one's.
|
||||
cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{
|
||||
{Name: "one", Command: `printf '\033]1717;1\007RENDERED BY ONE\n'; cat`},
|
||||
{Name: "two", Command: `printf '\033]1717;1\007RENDERED BY TWO\nAND ONE MORE LINE\n'; cat`},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Content(Contains("RENDERED BY ONE")).
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
MarkedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
Press(keys.Universal.CycleDiffRenderers)
|
||||
|
||||
t.ExpectToast(Equals("Diff renderer: two (2 of 2)"))
|
||||
|
||||
// The marks are of lines of the diff, not of rows of the rendering, so the new
|
||||
// rendering has them on the same line of the file.
|
||||
t.Views().Main().
|
||||
Content(Contains("AND ONE MORE LINE")).
|
||||
MarkedLines(
|
||||
Contains("-two"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var PatchMarksShowWheneverTheirDiffIsOnScreen = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "The marks over the lines in the custom patch are shown whenever the main view shows the diff the patch is built from, whether or not it has the focus, and not over the diff of another commit",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
MarkedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
Press(keys.Universal.TogglePanel)
|
||||
|
||||
t.Views().Secondary().IsFocused()
|
||||
t.Views().Main().MarkedLines(
|
||||
Contains("-two"),
|
||||
)
|
||||
|
||||
// Leaving the main view keeps them, as it keeps the patch previewed beside the
|
||||
// diff.
|
||||
t.Views().Secondary().Press(keys.Universal.Return)
|
||||
|
||||
t.Views().Commits().IsFocused()
|
||||
t.Views().Main().MarkedLines(
|
||||
Contains("-two"),
|
||||
)
|
||||
|
||||
// The diff of another commit has none of the patch's lines.
|
||||
t.Views().Commits().
|
||||
Lines(
|
||||
Contains("second commit").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
SelectNextItem()
|
||||
|
||||
t.Views().Main().NoMarkedLines()
|
||||
|
||||
// And they are back with the diff they belong to.
|
||||
t.Views().Commits().SelectPreviousItem()
|
||||
|
||||
t.Views().Main().MarkedLines(
|
||||
Contains("-two"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var RemoveLinesFromTheCustomPatch = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Take a line back out of the custom patch from the pane previewing it, where the patch's own numbering is not the commit's",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
// Additions with a line of the file between them, so that leaving the first of
|
||||
// them out of the patch puts the others at line numbers the commit's diff has
|
||||
// unchanged lines at: a line of the patch can only be found by counting the
|
||||
// patch's own changes.
|
||||
shell.UpdateFileAndAdd("file1", "one\nadded a\ntwo\nadded b\nthree\nadded c\nfour\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
// Take the second and third additions into the patch, leaving the first out.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+added a"),
|
||||
).
|
||||
NavigateToLine(Contains("+added b")).
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
NavigateToLine(Contains("+added c")).
|
||||
PressPrimaryAction().
|
||||
MarkedLines(
|
||||
Contains("+added b"),
|
||||
Contains("+added c"),
|
||||
)
|
||||
|
||||
t.Views().Secondary().ContainsLines(
|
||||
Contains("+added b"),
|
||||
Contains(" three"),
|
||||
Contains("+added c"),
|
||||
)
|
||||
|
||||
// Point at the first of the patch's lines and take it back out.
|
||||
t.Views().Main().Press(keys.Universal.TogglePanel)
|
||||
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("+added b"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
// What is left is the line that wasn't pointed at, and the selection has
|
||||
// stayed with it.
|
||||
SelectedLines(
|
||||
Contains("+added c"),
|
||||
).
|
||||
Content(DoesNotContain("+added b"))
|
||||
|
||||
t.Views().Main().MarkedLines(
|
||||
Contains("+added c"),
|
||||
)
|
||||
|
||||
// And the patch really is only that line: applying it to the working tree brings
|
||||
// back nothing else.
|
||||
t.Common().SelectPatchOption(Contains("Apply patch in reverse"))
|
||||
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("M").Contains("file1"),
|
||||
)
|
||||
|
||||
// The patch went to the index as well as the working tree, so the file's changes
|
||||
// are on the staged side of its diff.
|
||||
t.Views().Secondary().
|
||||
ContainsLines(
|
||||
Contains("-added c"),
|
||||
).
|
||||
Content(DoesNotContain("+added b"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var RenderTheDiffBesideThePatchMarks = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "A diff that the custom patch's marks are shown over is rendered to the width they leave it, so that a line as wide as the view still fits beside them, whether or not the view has the focus",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file", "one\nTWO\nthree\n")
|
||||
// git draws the diffstat line of a file with this many changes as wide as it is
|
||||
// told the view is.
|
||||
shell.CreateFileAndAdd("many", strings.Repeat("line\n", 300))
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
PressPrimaryAction().
|
||||
MarkedLines(
|
||||
Contains("-two"),
|
||||
Contains("+TWO"),
|
||||
).
|
||||
ContainsViewLines(
|
||||
Contains("many | 300"),
|
||||
Contains("2 files changed"),
|
||||
)
|
||||
|
||||
// The commits panel renders the diff again as it takes the focus back, and the
|
||||
// marks are still shown over it.
|
||||
t.Views().Main().Press(keys.Universal.Return)
|
||||
|
||||
t.Views().Commits().IsFocused()
|
||||
t.Views().Main().
|
||||
MarkedLines(
|
||||
Contains("-two"),
|
||||
Contains("+TWO"),
|
||||
).
|
||||
ContainsViewLines(
|
||||
Contains("many | 300"),
|
||||
Contains("2 files changed"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var ResetAPatchBuiltFromACommitsDiff = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Reset a custom patch built from a commit's diff without ever entering the commit's files, and stay in the diff",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// Build the patch straight from the commit's diff, so that nothing has ever told
|
||||
// the commit files panel which commit it would be showing.
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
).
|
||||
PressPrimaryAction()
|
||||
|
||||
t.Views().Information().Content(Contains("Building patch"))
|
||||
t.Views().Secondary().IsVisible().Content(Contains("-two"))
|
||||
|
||||
t.Common().SelectPatchOption(Contains("Reset patch"))
|
||||
|
||||
// Giving up the patch leaves the diff it was being built from, and the focus in it,
|
||||
// while the pane that was previewing the patch goes with it.
|
||||
t.Views().Information().Content(DoesNotContain("Building patch"))
|
||||
t.Views().Secondary().IsInvisible()
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Content(Contains("-two"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var ResetThePatchFromThePaneShowingIt = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Reset a custom patch while the focus is in the pane previewing it",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "one\nTWO\nthree\n")
|
||||
shell.Commit("second commit")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-two"),
|
||||
Contains("+TWO"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
Press(keys.Universal.TogglePanel)
|
||||
|
||||
t.Views().Information().Content(Contains("Building patch"))
|
||||
t.Views().Secondary().
|
||||
IsFocused().
|
||||
Content(Contains("-two"))
|
||||
|
||||
t.Common().SelectPatchOption(Contains("Reset patch"))
|
||||
|
||||
// The pane goes with the patch it was previewing, and the focus follows into the
|
||||
// one showing the diff the patch was built from.
|
||||
t.Views().Information().Content(DoesNotContain("Building patch"))
|
||||
t.Views().Secondary().IsInvisible()
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Content(Contains("-two"))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
package main_view
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var SelectionCommandTooltipsFollowTheDiff = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "The tooltips of the selection commands describe what they do over the diff they are offered on",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\n")
|
||||
shell.Commit("one")
|
||||
|
||||
shell.UpdateFile("file1", "one\nTWO\nthree\n")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// Over the working tree's diff the two keys act on the index.
|
||||
t.Views().Files().
|
||||
Focus().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Press(keys.Universal.OptionMenu)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Keybindings")).
|
||||
Select(Contains("Stage")).
|
||||
Tooltip(Equals("Toggle selection staged / unstaged.")).
|
||||
Select(Contains("Discard")).
|
||||
Tooltip(Contains("discard the change using `git reset`")).
|
||||
Cancel()
|
||||
|
||||
t.Views().Main().PressEscape()
|
||||
|
||||
// Over a commit's diff they build a custom patch and rewrite the commit, so
|
||||
// the index wording would be wrong; taking lines out of a commit is worth a
|
||||
// warning of its own.
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
PressEnter()
|
||||
|
||||
t.Views().CommitFiles().
|
||||
IsFocused().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Press(keys.Universal.OptionMenu)
|
||||
|
||||
t.ExpectPopup().Menu().
|
||||
Title(Equals("Keybindings")).
|
||||
Select(Contains("Toggle lines in patch")).
|
||||
Tooltip(Equals("")).
|
||||
Select(Contains("Remove lines from commit")).
|
||||
Tooltip(Contains("runs an interactive rebase in the background")).
|
||||
Cancel()
|
||||
},
|
||||
})
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package patch_building
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var KeepSelectionAfterMovingPatchOutMainView = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Moving a custom patch out of a commit leaves the focused main view's selection on a change that is still there, rather than painted over the diff the rewrite left behind",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {
|
||||
config.GetUserConfig().Gui.UseHunkModeInStagingView = false
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.CreateFileAndAdd("file1", "one\ntwo\nthree\nfour\nfive\n")
|
||||
shell.Commit("first commit")
|
||||
|
||||
shell.UpdateFileAndAdd("file1", "ONE\ntwo\nTHREE\nfour\nFIVE\n")
|
||||
shell.Commit("commit to move a patch out of")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("commit to move a patch out of").IsSelected(),
|
||||
Contains("first commit"),
|
||||
).
|
||||
PressEnter()
|
||||
|
||||
// Take the first of the commit's three changed lines into a custom patch.
|
||||
t.Views().CommitFiles().
|
||||
IsFocused().
|
||||
PressEnter()
|
||||
|
||||
t.Views().PatchBuilding().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-one"),
|
||||
).
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
Press(keys.Universal.NextItem).
|
||||
SelectedLines(
|
||||
Contains("-one"),
|
||||
Contains("+ONE"),
|
||||
).
|
||||
PressPrimaryAction().
|
||||
Press(keys.Universal.Return)
|
||||
|
||||
// Leave a range selected over the diff, spanning the lines the patch holds. The
|
||||
// patch move doesn't go through the main view at all, so without a net nothing
|
||||
// would move this selection off lines that the rewrite takes away.
|
||||
t.Views().CommitFiles().
|
||||
IsFocused().
|
||||
Press(keys.Universal.FocusMainView)
|
||||
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
SelectedLines(
|
||||
Contains("-one"),
|
||||
).
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
NavigateToLine(Contains("+THREE")).
|
||||
SelectedLines(
|
||||
Contains("-one"),
|
||||
Contains("+ONE"),
|
||||
Contains(" two"),
|
||||
Contains("-three"),
|
||||
Contains("+THREE"),
|
||||
)
|
||||
|
||||
t.Common().SelectPatchOption(Contains("Move patch out into index"))
|
||||
|
||||
// The moved lines are gone from the commit, so the range collapses onto the
|
||||
// change that has taken its place — the same place in the diff's changes, which
|
||||
// is where the user was.
|
||||
t.Views().Main().
|
||||
IsFocused().
|
||||
Content(DoesNotContain("+ONE")).
|
||||
SelectedLines(
|
||||
Contains("-three"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -36,11 +36,16 @@ var RenamedFileWhole = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
|
||||
t.Views().Information().Content(Contains("Building patch"))
|
||||
|
||||
// The whole file is added, so the patch carries the rename itself.
|
||||
// The whole file is added, so the patch carries the rename itself, and the diff
|
||||
// the patch is shown as carries it too. The trees the patch is materialized into
|
||||
// are what git names in the rename lines, the paths of the two sides being all it
|
||||
// has to go by.
|
||||
t.Views().Secondary().
|
||||
ContainsLines(
|
||||
Contains("rename from original"),
|
||||
Contains("rename to renamed"),
|
||||
Contains("diff --git a/original b/renamed"),
|
||||
Contains("similarity index"),
|
||||
Contains("rename from a/original"),
|
||||
Contains("rename to b/renamed"),
|
||||
)
|
||||
|
||||
t.Common().SelectPatchOption(Contains("Remove patch from original commit"))
|
||||
|
||||
@@ -134,14 +134,14 @@ var SpecificSelection = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Contains(`index`),
|
||||
Contains(`--- a/hunk-file`),
|
||||
Contains(`+++ b/hunk-file`),
|
||||
Contains(`@@ -1,6 +1,6 @@`),
|
||||
// git works out the context of the patch's own hunks, which is why
|
||||
// this is not the six lines the file's diff had
|
||||
Contains(`@@ -1,4 +1,4 @@`),
|
||||
Contains(`-1a`),
|
||||
Contains(`+aa`),
|
||||
Contains(` 1b`),
|
||||
Contains(` 1c`),
|
||||
Contains(` 1d`),
|
||||
Contains(` 1e`),
|
||||
Contains(` 1f`),
|
||||
// line-file patch
|
||||
Contains(`diff --git a/line-file b/line-file`),
|
||||
Contains(`index`),
|
||||
|
||||
@@ -368,11 +368,18 @@ var tests = []*components.IntegrationTest{
|
||||
interactive_rebase.SwapWithConflict,
|
||||
interactive_rebase.ViewFilesOfTodoEntries,
|
||||
main_view.AdvanceAfterStagingShiftsLineNumbers,
|
||||
main_view.BuildPatchFromACommitsDiff,
|
||||
main_view.BuildPatchFromAReflogEntry,
|
||||
main_view.BuildPatchFromAWholeCommitsDiff,
|
||||
main_view.ClickSelectsDiffLine,
|
||||
main_view.CommitFromMainView,
|
||||
main_view.CopyRowsThatAreNoDiffLine,
|
||||
main_view.CopySelectedDiffLines,
|
||||
main_view.CustomPatchGoesThroughTheDiffRenderer,
|
||||
main_view.CustomPatchIgnoresLineEndingConversion,
|
||||
main_view.DiscardDiffLines,
|
||||
main_view.DiscardFromACommitOnlyWhereItCanBeRewritten,
|
||||
main_view.DiscardLinesFromACommit,
|
||||
main_view.DragRangeWithAutoscroll,
|
||||
main_view.DragSelectsDiffLineRange,
|
||||
main_view.EditHunkInFocusedDiff,
|
||||
@@ -400,13 +407,20 @@ var tests = []*components.IntegrationTest{
|
||||
main_view.KeepScrollWhenTheDiffCantBeRead,
|
||||
main_view.KeepSelectedRangeWhenChangingContextSize,
|
||||
main_view.KeepSelectionVisibleWhenDiffShrinks,
|
||||
main_view.MoveOnWhenThePatchMarksRewrapTheDiff,
|
||||
main_view.NavigateByHunkAndFile,
|
||||
main_view.NoSelectionOverABinaryDiff,
|
||||
main_view.NoSelectionOverACommitLog,
|
||||
main_view.NoSelectionOverAConflictHint,
|
||||
main_view.NoSelectionWhenNoChanges,
|
||||
main_view.PatchMarksFollowARendererSwitch,
|
||||
main_view.PatchMarksShowWheneverTheirDiffIsOnScreen,
|
||||
main_view.RangeSelectDiffLines,
|
||||
main_view.RawFallbackUnderAnExternalDiff,
|
||||
main_view.RemoveLinesFromTheCustomPatch,
|
||||
main_view.RenderTheDiffBesideThePatchMarks,
|
||||
main_view.ResetAPatchBuiltFromACommitsDiff,
|
||||
main_view.ResetThePatchFromThePaneShowingIt,
|
||||
main_view.SearchCollapsesTheSelection,
|
||||
main_view.SearchFollowsTheSelection,
|
||||
main_view.SelectBelowALongCommitMessage,
|
||||
@@ -422,6 +436,7 @@ var tests = []*components.IntegrationTest{
|
||||
main_view.SelectNextDeletionAfterStagingOne,
|
||||
main_view.SelectVisibleChangeOnFocusingMainView,
|
||||
main_view.SelectVisibleHunkOnFocusingMainView,
|
||||
main_view.SelectionCommandTooltipsFollowTheDiff,
|
||||
main_view.SelectionCommandsOnlyWhereTheyApply,
|
||||
main_view.SelectionOverTheCustomPatch,
|
||||
main_view.StageDeletedFile,
|
||||
@@ -454,6 +469,7 @@ var tests = []*components.IntegrationTest{
|
||||
patch_building.CopyRenamedFileDiff,
|
||||
patch_building.DiscardLinesFromCommit,
|
||||
patch_building.EditLineInPatchBuildingPanel,
|
||||
patch_building.KeepSelectionAfterMovingPatchOutMainView,
|
||||
patch_building.MoveRangeToIndex,
|
||||
patch_building.MoveToEarlierCommit,
|
||||
patch_building.MoveToEarlierCommitFromAddedFile,
|
||||
|
||||
Reference in New Issue
Block a user