Compare commits

...

20 Commits

Author SHA1 Message Date
Jesse Duffield
7985e31020 Properly fix accordion issue (#2792) 2023-07-19 22:24:42 +10:00
Jesse Duffield
866e0a618b Add integration test for accordion mode 2023-07-19 22:17:29 +10:00
Jesse Duffield
a5ee61c117 Properly fix accordion issue
The true issue was that we were focusing the line in the view before it gets resized in the layout function.
This meant if the view was squashed in accordion mode, the view wouldn't know how to set the cursor/origin to
focus the line.

Now we've got a queue of 'after layout' functions i.e. functions to call at the end of the layout function,
right before views are drawn.

The only caveat is that we can't have an infinite buffer so we're arbitrarily capping it at 1000 and dropping
functions if we exceed that limit. But that really should never happen.
2023-07-19 21:16:27 +10:00
Jesse Duffield
08aad924d7 Fix accordion issue (#2791) 2023-07-19 21:07:25 +10:00
Jesse Duffield
be02786dad Fix accordion issue
This fixes the issue in accordion mode where the current line wasn't in the viewport upon focus.

It doesn't perfectly fix it: the current line always appears at the top of the view. But it's good enough
to cut a new release. The proper fix is to only focus the line after the view has had its height adjusted.
2023-07-19 20:39:10 +10:00
README-bot
949022db8c Updated README.md 2023-07-19 08:19:33 +00:00
Stefan Haller
b1a090d4c0 Fix crash when a background fetch prompts for credentials (#2789) 2023-07-19 10:19:18 +02:00
Stefan Haller
39f3f150ed Fix crash when a background fetch prompts for credentials
This happens consistently for my when I close my MacBook's lid. It seems that
MacOS locks the user's keychain in this case, and since I have my keychain
provide the pass phrases for my ssh keys, fetching fails because it tries to
prompt me for a pass phrase.

This all worked correctly already, we have the FailOnCredentialRequest()
mechanism specifically for this situation, so all is great. The only problem was
that it was trying to pause the ongoing task while prompting the user for input;
but the task is nil for a background fetch (and should be).
2023-07-18 18:53:35 +02:00
Stefan Haller
7e9f669421 Show all tags in commits panel (#2776) 2023-07-15 13:11:44 +02:00
Stefan Haller
6b769fb138 Fix populating the Commit.Tags field
We now store all tags in this field if there are several.
2023-07-15 13:07:02 +02:00
Stefan Haller
cc835a813e Extend commit_loader test to show how the Tags field is populated
It shows that right now, we take only the first tag if there are multiple.
Judging from how the code is written, I'm not sure this was intentional.
2023-07-15 13:07:02 +02:00
Stefan Haller
6103a4d13c Fix potentially wrong help text in commit message panel (#2777) 2023-07-15 13:06:16 +02:00
Stefan Haller
69575dd4f3 Fix potentially wrong help text in commit message panel
It said "Press tab to toggle focus", which is wrong for people who remapped
their togglePanel key binding to something else. Print the actual key binding
instead.
2023-07-15 13:03:13 +02:00
README-bot
bfcff3222c Updated README.md 2023-07-15 06:44:44 +00:00
Jesse Duffield
5adea789d0 Add test for cmd obj cloning (#2780) 2023-07-15 16:44:30 +10:00
Jesse Duffield
78bbdca757 Add test for cmd obj cloning 2023-07-15 11:05:43 +10:00
Stefan Haller
5cb82a49f8 config: rely on .gitconfig for verbose commit messages (#2664)
As discussed in https://github.com/jesseduffield/lazygit/pull/2599, it
makes more sense to have the user specify whether they want verbose
commits from their own git config, rather than lazygit config.

This means that we can remove all the code (including test coverage)
associated with the custom verbose flag, and lazygit will just inherit
the .gitconfig settings automatically.

---

Tested visually locally, as well as running the tests that all pass.
2023-07-14 08:05:22 +02:00
Scott Callaway
9617737352 config: rely on .gitconfig for verbose commit messages
As discussed in https://github.com/jesseduffield/lazygit/pull/2599, it
makes more sense to have the user specify whether they want verbose
commits from their own git config, rather than lazygit config.

This means that we can remove all the code (including test coverage)
associated with the custom verbose flag, and lazygit will just inherit
the .gitconfig settings automatically.
2023-07-14 07:56:09 +02:00
Jesse Duffield
a251f6ad6c Allow checking for merge conflicts after running a custom command (#2773) 2023-07-13 18:43:25 +10:00
Jesse Duffield
b61ca21a84 Allow checking for merge conflicts after running a custom command
We have a use-case to rebind 'm' to the merge action in the branches panel. There's three ways to handle this:
1) For all global keybindings, define a per-panel key that invokes it
2) Give a name to all controller actions and allow them to be invoked in custom commands
3) Allow checking for merge conflicts after running a custom command so that users can add their own 'git merge' custom command
that matches the in-built action

Option 1 is hairy, Option 2 though good for users introduces new backwards compatibility issues that I don't want to do
right now, and option 3 is trivially easy to implement so that's what I'm doing.

I've put this under an 'after' key so that we can add more things later. I'm imagining other things like being able to
move the cursor to a newly added item etc.

I considered always running this hook by default but I'd rather not: it's matching on the output text and I'd rather something
like that be explicitly opted-into to avoid cases where we erroneously believe that there are conflicts.
2023-07-13 18:40:34 +10:00
33 changed files with 398 additions and 129 deletions

File diff suppressed because one or more lines are too long

View File

@@ -77,7 +77,6 @@ git:
useConfig: false
commit:
signOff: false
verbose: default # one of 'default' | 'always' | 'never'
merging:
# only applicable to unix users
manualCommit: false

View File

@@ -59,6 +59,12 @@ For a given custom command, here are the allowed fields:
| description | Label for the custom command when displayed in the keybindings menu | no |
| stream | Whether you want to stream the command's output to the Command Log panel | no |
| showOutput | Whether you want to show the command's output in a popup within Lazygit | no |
| after | Actions to take after the command has completed | no |
Here are the options for the `after` key:
| _field_ | _description_ | required |
|-----------------|----------------------|-|
| checkForConflicts | true/false. If true, check for merge conflicts | no |
## Contexts

2
go.mod
View File

@@ -18,7 +18,7 @@ require (
github.com/integrii/flaggy v1.4.0
github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68
github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d
github.com/jesseduffield/gocui v0.3.1-0.20230710004407-9bbfd873713b
github.com/jesseduffield/gocui v0.3.1-0.20230719120401-398f4965241f
github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5
github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e

4
go.sum
View File

@@ -72,8 +72,8 @@ github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68 h1:EQP2Tv8T
github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68/go.mod h1:+LLj9/WUPAP8LqCchs7P+7X0R98HiFujVFANdNaxhGk=
github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d h1:bO+OmbreIv91rCe8NmscRwhFSqkDJtzWCPV4Y+SQuXE=
github.com/jesseduffield/go-git/v5 v5.1.2-0.20221018185014-fdd53fef665d/go.mod h1:nGNEErzf+NRznT+N2SWqmHnDnF9aLgANB1CUNEan09o=
github.com/jesseduffield/gocui v0.3.1-0.20230710004407-9bbfd873713b h1:8FmmdaYHes1m3oNyNdS+VIgkgkFpNZAWuwTnvp0tG14=
github.com/jesseduffield/gocui v0.3.1-0.20230710004407-9bbfd873713b/go.mod h1:dJ/BEUt3OWtaRg/PmuJWendRqREhre9JQ1SLvqrVJ8s=
github.com/jesseduffield/gocui v0.3.1-0.20230719120401-398f4965241f h1:w/pxI34XepTAx4HwxUu8ipimbVRgSTS+7ahmgFQwH80=
github.com/jesseduffield/gocui v0.3.1-0.20230719120401-398f4965241f/go.mod h1:dJ/BEUt3OWtaRg/PmuJWendRqREhre9JQ1SLvqrVJ8s=
github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10 h1:jmpr7KpX2+2GRiE91zTgfq49QvgiqB0nbmlwZ8UnOx0=
github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10/go.mod h1:aA97kHeNA+sj2Hbki0pvLslmE4CbDyhBeSSTUUnOuVo=
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY=

View File

@@ -95,7 +95,6 @@ func (self *CommitCommands) commitMessageArgs(message string) []string {
func (self *CommitCommands) CommitEditorCmdObj() oscommands.ICmdObj {
cmdArgs := NewGitCmd("commit").
ArgIf(self.signoffFlag() != "", self.signoffFlag()).
ArgIf(self.verboseFlag() != "", self.verboseFlag()).
ToArgv()
return self.cmd.New(cmdArgs)
@@ -109,17 +108,6 @@ func (self *CommitCommands) signoffFlag() string {
}
}
func (self *CommitCommands) verboseFlag() string {
switch self.config.UserConfig.Git.Commit.Verbose {
case "always":
return "--verbose"
case "never":
return "--no-verbose"
default:
return ""
}
}
// Get the subject of the HEAD commit
func (self *CommitCommands) GetHeadCommitMessage() (string, error) {
cmdArgs := NewGitCmd("log").Arg("-1", "--pretty=%s").ToArgv()

View File

@@ -162,11 +162,17 @@ func (self *CommitLoader) extractCommitFromLine(line string) *models.Commit {
tags := []string{}
if extraInfo != "" {
re := regexp.MustCompile(`tag: ([^,\)]+)`)
tagMatch := re.FindStringSubmatch(extraInfo)
if len(tagMatch) > 1 {
tags = append(tags, tagMatch[1])
extraInfoFields := strings.Split(extraInfo, ",")
for _, extraInfoField := range extraInfoFields {
extraInfoField = strings.TrimSpace(extraInfoField)
re := regexp.MustCompile(`tag: (.+)`)
tagMatch := re.FindStringSubmatch(extraInfoField)
if len(tagMatch) > 1 {
tags = append(tags, tagMatch[1])
}
}
extraInfo = "(" + extraInfo + ")"
}
unitTimestampInt, _ := strconv.Atoi(unixTimestamp)
@@ -585,4 +591,4 @@ func (self *CommitLoader) getLogCmd(opts GetCommitsOptions) oscommands.ICmdObj {
return self.cmd.New(cmdArgs).DontLog()
}
const prettyFormat = `--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s`
const prettyFormat = `--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s`

View File

@@ -14,16 +14,16 @@ import (
"github.com/stretchr/testify/assert"
)
var commitsOutput = strings.Replace(`0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com| (HEAD -> better-tests)|b21997d6b4cbdf84b149|better typing for rebase mode
b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164|1640824515|Jesse Duffield|jessedduffield@gmail.com| (origin/better-tests)|e94e8fc5b6fab4cb755f|fix logging
e94e8fc5b6fab4cb755f29f1bdb3ee5e001df35c|1640823749|Jesse Duffield|jessedduffield@gmail.com||d8084cd558925eb7c9c3|refactor
var commitsOutput = strings.Replace(`0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com|HEAD -> better-tests|b21997d6b4cbdf84b149|better typing for rebase mode
b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164|1640824515|Jesse Duffield|jessedduffield@gmail.com|origin/better-tests|e94e8fc5b6fab4cb755f|fix logging
e94e8fc5b6fab4cb755f29f1bdb3ee5e001df35c|1640823749|Jesse Duffield|jessedduffield@gmail.com|tag: 123, tag: 456|d8084cd558925eb7c9c3|refactor
d8084cd558925eb7c9c38afeed5725c21653ab90|1640821426|Jesse Duffield|jessedduffield@gmail.com||65f910ebd85283b5cce9|WIP
65f910ebd85283b5cce9bf67d03d3f1a9ea3813a|1640821275|Jesse Duffield|jessedduffield@gmail.com||26c07b1ab33860a1a759|WIP
26c07b1ab33860a1a7591a0638f9925ccf497ffa|1640750752|Jesse Duffield|jessedduffield@gmail.com||3d4470a6c072208722e5|WIP
3d4470a6c072208722e5ae9a54bcb9634959a1c5|1640748818|Jesse Duffield|jessedduffield@gmail.com||053a66a7be3da43aacdc|WIP
053a66a7be3da43aacdc7aa78e1fe757b82c4dd2|1640739815|Jesse Duffield|jessedduffield@gmail.com||985fe482e806b172aea4|refactoring the config struct`, "|", "\x00", -1)
var singleCommitOutput = strings.Replace(`0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com| (HEAD -> better-tests)|b21997d6b4cbdf84b149|better typing for rebase mode`, "|", "\x00", -1)
var singleCommitOutput = strings.Replace(`0eea75e8c631fba6b58135697835d58ba4c18dbc|1640826609|Jesse Duffield|jessedduffield@gmail.com|HEAD -> better-tests|b21997d6b4cbdf84b149|better typing for rebase mode`, "|", "\x00", -1)
func TestGetCommits(t *testing.T) {
type scenario struct {
@@ -45,7 +45,7 @@ func TestGetCommits(t *testing.T) {
opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false},
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"merge-base", "HEAD", "HEAD@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
expectedCommits: []*models.Commit{},
expectedError: nil,
@@ -57,7 +57,7 @@ func TestGetCommits(t *testing.T) {
opts: GetCommitsOptions{RefName: "refs/heads/mybranch", IncludeRebaseCommits: false},
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"merge-base", "refs/heads/mybranch", "mybranch@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
ExpectGitArgs([]string{"log", "refs/heads/mybranch", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
ExpectGitArgs([]string{"log", "refs/heads/mybranch", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
expectedCommits: []*models.Commit{},
expectedError: nil,
@@ -72,7 +72,7 @@ func TestGetCommits(t *testing.T) {
// here it's seeing which commits are yet to be pushed
ExpectGitArgs([]string{"merge-base", "HEAD", "HEAD@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
// here it's actually getting all the commits in a formatted form, one per line
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, commitsOutput, nil).
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, commitsOutput, nil).
// here it's testing which of the configured main branches have an upstream
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "refs/remotes/origin/master", nil). // this one does
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "main@{u}"}, "", errors.New("error")). // this one doesn't, so it checks origin instead
@@ -117,8 +117,8 @@ func TestGetCommits(t *testing.T) {
Name: "refactor",
Status: models.StatusPushed,
Action: models.ActionNone,
Tags: []string{},
ExtraInfo: "",
Tags: []string{"123", "456"},
ExtraInfo: "(tag: 123, tag: 456)",
AuthorName: "Jesse Duffield",
AuthorEmail: "jessedduffield@gmail.com",
UnixTimestamp: 1640823749,
@@ -209,7 +209,7 @@ func TestGetCommits(t *testing.T) {
// here it's seeing which commits are yet to be pushed
ExpectGitArgs([]string{"merge-base", "HEAD", "HEAD@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
// here it's actually getting all the commits in a formatted form, one per line
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil).
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil).
// here it's testing which of the configured main branches exist; neither does
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "", errors.New("error")).
ExpectGitArgs([]string{"rev-parse", "--verify", "--quiet", "refs/remotes/origin/master"}, "", errors.New("error")).
@@ -246,7 +246,7 @@ func TestGetCommits(t *testing.T) {
// here it's seeing which commits are yet to be pushed
ExpectGitArgs([]string{"merge-base", "HEAD", "HEAD@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
// here it's actually getting all the commits in a formatted form, one per line
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil).
ExpectGitArgs([]string{"log", "HEAD", "--topo-order", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, singleCommitOutput, nil).
// here it's testing which of the configured main branches exist
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "master@{u}"}, "refs/remotes/origin/master", nil).
ExpectGitArgs([]string{"rev-parse", "--symbolic-full-name", "main@{u}"}, "", errors.New("error")).
@@ -282,7 +282,7 @@ func TestGetCommits(t *testing.T) {
opts: GetCommitsOptions{RefName: "HEAD", IncludeRebaseCommits: false},
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"merge-base", "HEAD", "HEAD@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--no-show-signature", "--"}, "", nil),
expectedCommits: []*models.Commit{},
expectedError: nil,
@@ -294,7 +294,7 @@ func TestGetCommits(t *testing.T) {
opts: GetCommitsOptions{RefName: "HEAD", FilterPath: "src"},
runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"merge-base", "HEAD", "HEAD@{u}"}, "b21997d6b4cbdf84b149d8e6a2c4d06a8e9ec164", nil).
ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%d%x00%p%x00%s", "--abbrev=40", "--follow", "--no-show-signature", "--", "src"}, "", nil),
ExpectGitArgs([]string{"log", "HEAD", "--oneline", "--pretty=format:%H%x00%at%x00%aN%x00%ae%x00%D%x00%p%x00%s", "--abbrev=40", "--follow", "--no-show-signature", "--", "src"}, "", nil),
expectedCommits: []*models.Commit{},
expectedError: nil,

View File

@@ -114,7 +114,6 @@ func TestCommitCommitEditorCmdObj(t *testing.T) {
type scenario struct {
testName string
configSignoff bool
configVerbose string
expected []string
}
@@ -122,33 +121,13 @@ func TestCommitCommitEditorCmdObj(t *testing.T) {
{
testName: "Commit using editor",
configSignoff: false,
configVerbose: "default",
expected: []string{"commit"},
},
{
testName: "Commit with --no-verbose flag",
configSignoff: false,
configVerbose: "never",
expected: []string{"commit", "--no-verbose"},
},
{
testName: "Commit with --verbose flag",
configSignoff: false,
configVerbose: "always",
expected: []string{"commit", "--verbose"},
},
{
testName: "Commit with --signoff",
configSignoff: true,
configVerbose: "default",
expected: []string{"commit", "--signoff"},
},
{
testName: "Commit with --signoff and --no-verbose",
configSignoff: true,
configVerbose: "never",
expected: []string{"commit", "--signoff", "--no-verbose"},
},
}
for _, s := range scenarios {
@@ -156,7 +135,6 @@ func TestCommitCommitEditorCmdObj(t *testing.T) {
t.Run(s.testName, func(t *testing.T) {
userConfig := config.GetDefaultConfig()
userConfig.Git.Commit.SignOff = s.configSignoff
userConfig.Git.Commit.Verbose = s.configVerbose
runner := oscommands.NewFakeRunner(t).ExpectGitArgs(s.expected, "", nil)
instance := buildCommitCommands(commonDeps{userConfig: userConfig, runner: runner})

View File

@@ -331,9 +331,13 @@ func (self *cmdObjRunner) processOutput(
askFor, ok := checkForCredentialRequest(newBytes)
if ok {
responseChan := promptUserForCredential(askFor)
task.Pause()
if task != nil {
task.Pause()
}
toInput := <-responseChan
task.Continue()
if task != nil {
task.Continue()
}
// If the return data is empty we don't write anything to stdin
if toInput != "" {
_, _ = writer.Write([]byte(toInput))

View File

@@ -1,7 +1,10 @@
package oscommands
import (
"os/exec"
"testing"
"github.com/jesseduffield/gocui"
)
func TestCmdObjToString(t *testing.T) {
@@ -31,3 +34,20 @@ func TestCmdObjToString(t *testing.T) {
}
}
}
func TestClone(t *testing.T) {
task := gocui.NewFakeTask()
cmdObj := &CmdObj{task: task, cmd: &exec.Cmd{}}
clone := cmdObj.Clone()
if clone == cmdObj {
t.Errorf("Clone should not return the same object")
}
if clone.GetTask() == nil {
t.Errorf("Clone task should not be nil")
}
if clone.GetTask() != task {
t.Errorf("Clone should have the same task")
}
}

View File

@@ -103,8 +103,7 @@ type PagingConfig struct {
}
type CommitConfig struct {
SignOff bool `yaml:"signOff"`
Verbose string `yaml:"verbose"`
SignOff bool `yaml:"signOff"`
}
type MergingConfig struct {
@@ -349,16 +348,21 @@ type OSConfig struct {
OpenLinkCommand string `yaml:"openLinkCommand,omitempty"`
}
type CustomCommandAfterHook struct {
CheckForConflicts bool `yaml:"checkForConflicts"`
}
type CustomCommand struct {
Key string `yaml:"key"`
Context string `yaml:"context"`
Command string `yaml:"command"`
Subprocess bool `yaml:"subprocess"`
Prompts []CustomCommandPrompt `yaml:"prompts"`
LoadingText string `yaml:"loadingText"`
Description string `yaml:"description"`
Stream bool `yaml:"stream"`
ShowOutput bool `yaml:"showOutput"`
Key string `yaml:"key"`
Context string `yaml:"context"`
Command string `yaml:"command"`
Subprocess bool `yaml:"subprocess"`
Prompts []CustomCommandPrompt `yaml:"prompts"`
LoadingText string `yaml:"loadingText"`
Description string `yaml:"description"`
Stream bool `yaml:"stream"`
ShowOutput bool `yaml:"showOutput"`
After CustomCommandAfterHook `yaml:"after"`
}
type CustomCommandPrompt struct {
@@ -445,7 +449,6 @@ func GetDefaultConfig() *UserConfig {
},
Commit: CommitConfig{
SignOff: false,
Verbose: "default",
},
Merging: MergingConfig{
ManualCommit: false,

View File

@@ -31,7 +31,14 @@ func (self *ListContextTrait) GetList() types.IList {
}
func (self *ListContextTrait) FocusLine() {
self.GetViewTrait().FocusPoint(self.list.GetSelectedLineIdx())
// Doing this at the end of the layout function because we need the view to be
// resized before we focus the line, otherwise if we're in accordion mode
// the view could be squashed and won't how to adjust the cursor/origin
self.c.AfterLayout(func() error {
self.GetViewTrait().FocusPoint(self.list.GetSelectedLineIdx())
return nil
})
self.setFooter()
if self.refreshViewportOnChange {

View File

@@ -137,33 +137,47 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error {
} else if strings.Contains(result.Error(), "No rebase in progress?") {
// assume in this case that we're already done
return nil
} else if isMergeConflictErr(result.Error()) {
mode := self.workingTreeStateNoun()
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.FoundConflictsTitle,
Items: []*types.MenuItem{
{
Label: self.c.Tr.ViewConflictsMenuItem,
OnPress: func() error {
return self.c.PushContext(self.c.Contexts().Files)
},
Key: 'v',
},
{
Label: fmt.Sprintf(self.c.Tr.AbortMenuItem, mode),
OnPress: func() error {
return self.genericMergeCommand(REBASE_OPTION_ABORT)
},
Key: 'a',
},
},
HideCancel: true,
})
} else {
return self.CheckForConflicts(result)
}
}
func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error {
if result == nil {
return nil
}
if isMergeConflictErr(result.Error()) {
return self.PromptForConflictHandling()
} else {
return self.c.ErrorMsg(result.Error())
}
}
func (self *MergeAndRebaseHelper) PromptForConflictHandling() error {
mode := self.workingTreeStateNoun()
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.FoundConflictsTitle,
Items: []*types.MenuItem{
{
Label: self.c.Tr.ViewConflictsMenuItem,
OnPress: func() error {
return self.c.PushContext(self.c.Contexts().Files)
},
Key: 'v',
},
{
Label: fmt.Sprintf(self.c.Tr.AbortMenuItem, mode),
OnPress: func() error {
return self.genericMergeCommand(REBASE_OPTION_ABORT)
},
Key: 'a',
},
},
HideCancel: true,
})
}
func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error {
// prompt user to confirm that they want to abort, then do it
mode := self.workingTreeStateNoun()

View File

@@ -132,6 +132,8 @@ type Gui struct {
helpers *helpers.Helpers
integrationTest integrationTypes.IntegrationTest
afterLayoutFuncs chan func() error
}
type StateAccessor struct {
@@ -458,7 +460,8 @@ func NewGui(
PopupMutex: &deadlock.Mutex{},
PtyMutex: &deadlock.Mutex{},
},
InitialDir: initialDir,
InitialDir: initialDir,
afterLayoutFuncs: make(chan func() error, 1000),
}
gui.WatchFilesForChanges()
@@ -519,9 +522,29 @@ var RuneReplacements = map[rune]string{
}
func (gui *Gui) initGocui(headless bool, test integrationTypes.IntegrationTest) (*gocui.Gui, error) {
playRecording := test != nil && os.Getenv(components.SANDBOX_ENV_VAR) != "true"
runInSandbox := os.Getenv(components.SANDBOX_ENV_VAR) == "true"
playRecording := test != nil && !runInSandbox
g, err := gocui.NewGui(gocui.OutputTrue, OverlappingEdges, playRecording, headless, RuneReplacements)
width, height := 0, 0
if test != nil {
if test.RequiresHeadless() {
if runInSandbox {
panic("Test requires headless, can't run in sandbox")
}
headless = true
}
width, height = test.HeadlessDimensions()
}
g, err := gocui.NewGui(gocui.NewGuiOpts{
OutputMode: gocui.OutputTrue,
SupportOverlaps: OverlappingEdges,
PlayRecording: playRecording,
Headless: headless,
RuneReplacements: RuneReplacements,
Width: width,
Height: height,
})
if err != nil {
return nil, err
}

View File

@@ -168,3 +168,12 @@ func (self *guiCommon) IsAnyModeActive() bool {
func (self *guiCommon) GetInitialKeybindingsWithCustomCommands() ([]*types.Binding, []*gocui.ViewMouseBinding) {
return self.gui.GetInitialKeybindingsWithCustomCommands()
}
func (self *guiCommon) AfterLayout(f func() error) {
select {
case self.gui.afterLayoutFuncs <- f:
default:
// hopefully this never happens
self.gui.c.Log.Error("afterLayoutFuncs channel is full, skipping function")
}
}

View File

@@ -149,7 +149,23 @@ func (gui *Gui) layout(g *gocui.Gui) error {
// if you run `lazygit --logs`
// this will let you see these branches as prettified json
// gui.c.Log.Info(utils.AsJson(gui.State.Model.Branches[0:4]))
return gui.helpers.Confirmation.ResizeCurrentPopupPanel()
if err := gui.helpers.Confirmation.ResizeCurrentPopupPanel(); err != nil {
return err
}
outer:
for {
select {
case f := <-gui.afterLayoutFuncs:
if err := f(); err != nil {
return err
}
default:
break outer
}
}
return nil
}
func (gui *Gui) prepareView(viewName string) (*gocui.View, error) {

View File

@@ -19,7 +19,12 @@ func NewClient(
helpers *helpers.Helpers,
) *Client {
sessionStateLoader := NewSessionStateLoader(c, helpers.Refs)
handlerCreator := NewHandlerCreator(c, sessionStateLoader, helpers.Suggestions)
handlerCreator := NewHandlerCreator(
c,
sessionStateLoader,
helpers.Suggestions,
helpers.MergeAndRebase,
)
keybindingCreator := NewKeybindingCreator(c)
customCommands := c.UserConfig.CustomCommands

View File

@@ -17,27 +17,30 @@ import (
// takes a custom command and returns a function that will be called when the corresponding user-defined keybinding is pressed
type HandlerCreator struct {
c *helpers.HelperCommon
sessionStateLoader *SessionStateLoader
resolver *Resolver
menuGenerator *MenuGenerator
suggestionsHelper *helpers.SuggestionsHelper
c *helpers.HelperCommon
sessionStateLoader *SessionStateLoader
resolver *Resolver
menuGenerator *MenuGenerator
suggestionsHelper *helpers.SuggestionsHelper
mergeAndRebaseHelper *helpers.MergeAndRebaseHelper
}
func NewHandlerCreator(
c *helpers.HelperCommon,
sessionStateLoader *SessionStateLoader,
suggestionsHelper *helpers.SuggestionsHelper,
mergeAndRebaseHelper *helpers.MergeAndRebaseHelper,
) *HandlerCreator {
resolver := NewResolver(c.Common)
menuGenerator := NewMenuGenerator(c.Common)
return &HandlerCreator{
c: c,
sessionStateLoader: sessionStateLoader,
resolver: resolver,
menuGenerator: menuGenerator,
suggestionsHelper: suggestionsHelper,
c: c,
sessionStateLoader: sessionStateLoader,
resolver: resolver,
menuGenerator: menuGenerator,
suggestionsHelper: suggestionsHelper,
mergeAndRebaseHelper: mergeAndRebaseHelper,
}
}
@@ -272,7 +275,16 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
cmdObj.StreamOutput()
}
output, err := cmdObj.RunWithOutput()
if refreshErr := self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); err != nil {
self.c.Log.Error(refreshErr)
}
if err != nil {
if customCommand.After.CheckForConflicts {
return self.mergeAndRebaseHelper.CheckForConflicts(err)
}
return self.c.Error(err)
}
@@ -280,11 +292,9 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
if strings.TrimSpace(output) == "" {
output = self.c.Tr.EmptyOutput
}
if err = self.c.Alert(cmdStr, output); err != nil {
return self.c.Error(err)
}
return self.c.Refresh(types.RefreshOptions{})
return self.c.Alert(cmdStr, output)
}
return self.c.Refresh(types.RefreshOptions{})
return nil
})
}

View File

@@ -80,6 +80,10 @@ type IGuiCommon interface {
// Runs a function in a goroutine. Use this whenever you want to run a goroutine and keep track of the fact
// that lazygit is still busy. See docs/dev/Busy.md
OnWorker(f func(gocui.Task))
// Function to call at the end of our 'layout' function which renders views
// For example, you may want a view's line to be focused only after that view is
// resized, if in accordion mode.
AfterLayout(f func() error)
// returns the gocui Gui struct. There is a good chance you don't actually want to use
// this struct and instead want to use another method above

View File

@@ -3,7 +3,9 @@ package gui
import (
"github.com/jesseduffield/generics/slices"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/theme"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type viewNameMapping struct {
@@ -159,7 +161,10 @@ func (gui *Gui) createAllViews() error {
gui.Views.CommitDescription.Visible = false
gui.Views.CommitDescription.Title = gui.c.Tr.CommitDescriptionTitle
gui.Views.CommitDescription.Subtitle = gui.Tr.CommitDescriptionSubTitle
gui.Views.CommitDescription.Subtitle = utils.ResolvePlaceholderString(gui.Tr.CommitDescriptionSubTitle,
map[string]string{
"togglePanelKeyBinding": keybindings.Label(gui.UserConfig.Keybinding.Universal.TogglePanel),
})
gui.Views.CommitDescription.FgColor = theme.GocuiDefaultTextColor
gui.Views.CommitDescription.Editable = true
gui.Views.CommitDescription.Editor = gocui.EditorFunc(gui.commitDescriptionEditor)

View File

@@ -886,7 +886,7 @@ func EnglishTranslationSet() TranslationSet {
RebaseOptionsTitle: "Rebase options",
CommitMessageTitle: "Commit summary",
CommitDescriptionTitle: "Commit description",
CommitDescriptionSubTitle: "Press tab to toggle focus",
CommitDescriptionSubTitle: "Press {{.togglePanelKeyBinding}} to toggle focus",
LocalBranchesTitle: "Local branches",
SearchTitle: "Search",
TagsTitle: "Tags",

View File

@@ -28,7 +28,10 @@ func RunTUI() {
app := newApp(testDir)
app.loadTests()
g, err := gocui.NewGui(gocui.OutputTrue, false, false, false, gui.RuneReplacements)
g, err := gocui.NewGui(gocui.NewGuiOpts{
OutputMode: gocui.OutputTrue,
RuneReplacements: gui.RuneReplacements,
})
if err != nil {
log.Panicln(err)
}

View File

@@ -19,6 +19,11 @@ import (
// to get the test's name via it's file's path.
const unitTestDescription = "test test"
const (
defaultWidth = 100
defaultHeight = 100
)
type IntegrationTest struct {
name string
description string
@@ -32,6 +37,8 @@ type IntegrationTest struct {
keys config.KeybindingConfig,
)
gitVersion GitVersionRestriction
width int
height int
}
var _ integrationTypes.IntegrationTest = &IntegrationTest{}
@@ -52,6 +59,11 @@ type NewIntegrationTestArgs struct {
Skip bool
// to run a test only on certain git versions
GitVersion GitVersionRestriction
// width and height when running in headless mode, for testing
// the UI in different sizes.
// If these are set, the test must be run in headless mode
Width int
Height int
}
type GitVersionRestriction struct {
@@ -120,6 +132,8 @@ func NewIntegrationTest(args NewIntegrationTestArgs) *IntegrationTest {
setupConfig: args.SetupConfig,
run: args.Run,
gitVersion: args.GitVersion,
width: args.Width,
height: args.Height,
}
}
@@ -172,6 +186,18 @@ func (self *IntegrationTest) Run(gui integrationTypes.GuiDriver) {
}
}
func (self *IntegrationTest) HeadlessDimensions() (int, int) {
if self.width == 0 && self.height == 0 {
return defaultWidth, defaultHeight
}
return self.width, self.height
}
func (self *IntegrationTest) RequiresHeadless() bool {
return self.width != 0 && self.height != 0
}
func testNameFromCurrentFilePath() string {
path := utils.FilePath(3)
return TestNameFromFilePath(path)

View File

@@ -82,6 +82,20 @@ func (self *ViewDriver) TopLines(matchers ...*TextMatcher) *ViewDriver {
return self.assertLines(0, matchers...)
}
// Asserts on the visible lines of the view.
// Note, this assumes that the view's viewport is filled with lines
func (self *ViewDriver) VisibleLines(matchers ...*TextMatcher) *ViewDriver {
self.validateMatchersPassed(matchers)
self.validateVisibleLineCount(matchers)
// Get the origin of the view and offset that.
// Note that we don't do any retrying here so if we want to bring back retry logic
// we'll need to update this.
originY := self.getView().OriginY()
return self.assertLines(originY, matchers...)
}
// asserts that somewhere in the view there are consequetive lines matching the given matchers.
func (self *ViewDriver) ContainsLines(matchers ...*TextMatcher) *ViewDriver {
self.validateMatchersPassed(matchers)
@@ -212,6 +226,16 @@ func (self *ViewDriver) validateEnoughLines(matchers []*TextMatcher) {
})
}
// assumes the view's viewport is filled with lines
func (self *ViewDriver) validateVisibleLineCount(matchers []*TextMatcher) {
view := self.getView()
self.t.assertWithRetries(func() (bool, string) {
count := view.InnerHeight() + 1
return count == len(matchers), fmt.Sprintf("unexpected number of visible lines in view '%s'. Expected exactly %d, got %d", view.Name(), len(matchers), count)
})
}
func (self *ViewDriver) assertLines(offset int, matchers ...*TextMatcher) *ViewDriver {
view := self.getView()

View File

@@ -0,0 +1,40 @@
package custom_commands
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
"github.com/jesseduffield/lazygit/pkg/integration/tests/shared"
)
var CheckForConflicts = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Run a command and check for conflicts after",
ExtraCmdArgs: []string{},
Skip: false,
SetupRepo: func(shell *Shell) {
shared.MergeConflictsSetup(shell)
},
SetupConfig: func(cfg *config.AppConfig) {
cfg.UserConfig.CustomCommands = []config.CustomCommand{
{
Key: "m",
Context: "localBranches",
Command: "git merge {{ .SelectedLocalBranch.Name | quote }}",
After: config.CustomCommandAfterHook{
CheckForConflicts: true,
},
},
}
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
TopLines(
Contains("first-change-branch"),
Contains("second-change-branch"),
).
NavigateToLine(Contains("second-change-branch")).
Press("m")
t.Common().AcknowledgeConflicts()
},
})

View File

@@ -75,6 +75,7 @@ var tests = []*components.IntegrationTest{
conflicts.UndoChooseHunk,
custom_commands.BasicCmdAtRuntime,
custom_commands.BasicCmdFromConfig,
custom_commands.CheckForConflicts,
custom_commands.ComplexCmdAtRuntime,
custom_commands.FormPrompts,
custom_commands.MenuFromCommand,
@@ -208,6 +209,7 @@ var tests = []*components.IntegrationTest{
tag.CrudAnnotated,
tag.CrudLightweight,
tag.Reset,
ui.Accordion,
ui.DoublePopup,
ui.SwitchTabFromMenu,
undo.UndoCheckoutAndDrop,

View File

@@ -0,0 +1,61 @@
package ui
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
// When in acccordion mode, Lazygit looks like this:
//
// ╶─Status─────────────────────────╴┌─Patch──────────────────────────────────────────────────────────┐
// ╶─Files - Submodules──────0 of 0─╴│commit 6e56dd04b70e548976f7f2928c4d9c359574e2bc ▲
// ╶─Local branches - Remotes1 of 1─╴│Author: CI <CI@example.com> █
// ┌─Commits - Reflog───────────────┐│Date: Wed Jul 19 22:00:03 2023 +1000 │
// │7fe02805 CI commit 12 ▲│ ▼
// │6e56dd04 CI commit 11 █└────────────────────────────────────────────────────────────────┘
// │a35c687d CI commit 10 ▼┌─Command log────────────────────────────────────────────────────┐
// └───────────────────────10 of 20─┘│Random tip: To filter commits by path, press '<c-s>' │
// ╶─Stash───────────────────0 of 0─╴└────────────────────────────────────────────────────────────────┘
// <pgup>/<pgdown>: Scroll, <esc>: Cancel, q: Quit, ?: Keybindings, 1-Donate Ask Question unversioned
var Accordion = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Verify accordion mode kicks in when the screen height is too small",
ExtraCmdArgs: []string{},
Width: 100,
Height: 10,
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(20)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
VisibleLines(
Contains("commit 20").IsSelected(),
Contains("commit 19"),
Contains("commit 18"),
).
// go past commit 11, then come back, so that it ends up in the centre of the viewport
NavigateToLine(Contains("commit 11")).
NavigateToLine(Contains("commit 10")).
NavigateToLine(Contains("commit 11")).
VisibleLines(
Contains("commit 12"),
Contains("commit 11").IsSelected(),
Contains("commit 10"),
)
t.Views().Files().
Focus()
// ensure we retain the same viewport upon re-focus
t.Views().Commits().
Focus().
VisibleLines(
Contains("commit 12"),
Contains("commit 11").IsSelected(),
Contains("commit 10"),
)
},
})

View File

@@ -13,6 +13,9 @@ import (
type IntegrationTest interface {
Run(GuiDriver)
SetupConfig(config *config.AppConfig)
RequiresHeadless() bool
// width and height when running headless
HeadlessDimensions() (int, int)
}
// this is the interface through which our integration tests interact with the lazygit gui

View File

@@ -177,13 +177,26 @@ type Gui struct {
taskManager *TaskManager
}
type NewGuiOpts struct {
OutputMode OutputMode
SupportOverlaps bool
PlayRecording bool
Headless bool
// only applicable when Headless is true
Width int
// only applicable when Headless is true
Height int
RuneReplacements map[rune]string
}
// NewGui returns a new Gui object with a given output mode.
func NewGui(mode OutputMode, supportOverlaps bool, playRecording bool, headless bool, runeReplacements map[rune]string) (*Gui, error) {
func NewGui(opts NewGuiOpts) (*Gui, error) {
g := &Gui{}
var err error
if headless {
err = g.tcellInitSimulation()
if opts.Headless {
err = g.tcellInitSimulation(opts.Width, opts.Height)
} else {
err = g.tcellInit(runeReplacements)
}
@@ -191,7 +204,7 @@ func NewGui(mode OutputMode, supportOverlaps bool, playRecording bool, headless
return nil, err
}
if headless || runtime.GOOS == "windows" {
if opts.Headless || runtime.GOOS == "windows" {
g.maxX, g.maxY = g.screen.Size()
} else {
// TODO: find out if we actually need this bespoke logic for linux
@@ -201,7 +214,7 @@ func NewGui(mode OutputMode, supportOverlaps bool, playRecording bool, headless
}
}
g.outputMode = mode
g.outputMode = opts.OutputMode
g.stop = make(chan struct{})
@@ -209,7 +222,7 @@ func NewGui(mode OutputMode, supportOverlaps bool, playRecording bool, headless
g.userEvents = make(chan userEvent, 20)
g.taskManager = newTaskManager()
if playRecording {
if opts.PlayRecording {
g.ReplayedEvents = replayedEvents{
Keys: make(chan *TcellKeyEventWrapper),
Resizes: make(chan *TcellResizeEventWrapper),
@@ -221,14 +234,14 @@ func NewGui(mode OutputMode, supportOverlaps bool, playRecording bool, headless
// SupportOverlaps is true when we allow for view edges to overlap with other
// view edges
g.SupportOverlaps = supportOverlaps
g.SupportOverlaps = opts.SupportOverlaps
// default keys for when searching strings in a view
g.SearchEscapeKey = KeyEsc
g.NextSearchMatchKey = 'n'
g.PrevSearchMatchKey = 'N'
g.playRecording = playRecording
g.playRecording = opts.PlayRecording
return g, nil
}

View File

@@ -81,7 +81,7 @@ func registerRuneFallbacks(s tcell.Screen, additional map[rune]string) {
}
// tcellInitSimulation initializes tcell screen for use.
func (g *Gui) tcellInitSimulation() error {
func (g *Gui) tcellInitSimulation(width int, height int) error {
s := tcell.NewSimulationScreen("")
if e := s.Init(); e != nil {
return e
@@ -90,7 +90,7 @@ func (g *Gui) tcellInitSimulation() error {
Screen = s
// setting to a larger value than the typical terminal size
// so that during a test we're more likely to see an item to select in a view.
s.SetSize(100, 100)
s.SetSize(width, height)
s.Sync()
return nil
}

View File

@@ -269,7 +269,7 @@ func (v *View) FocusPoint(cx int, cy int) {
_, height := v.Size()
ly := height - 1
if ly == -1 {
if ly < 0 {
ly = 0
}

2
vendor/modules.txt vendored
View File

@@ -172,7 +172,7 @@ github.com/jesseduffield/go-git/v5/utils/merkletrie/filesystem
github.com/jesseduffield/go-git/v5/utils/merkletrie/index
github.com/jesseduffield/go-git/v5/utils/merkletrie/internal/frame
github.com/jesseduffield/go-git/v5/utils/merkletrie/noder
# github.com/jesseduffield/gocui v0.3.1-0.20230710004407-9bbfd873713b
# github.com/jesseduffield/gocui v0.3.1-0.20230719120401-398f4965241f
## explicit; go 1.12
github.com/jesseduffield/gocui
# github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10