Merge remote-tracking branch 'origin/master'

# Conflicts:
#	pkg/gocui/gui.go
This commit is contained in:
Trương Duy Khánh 2026-08-20 15:15:24 +07:00
commit 1f51ab4715
141 changed files with 3304 additions and 5839 deletions

View file

@ -195,7 +195,7 @@ jobs:
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
with:
# If you change this, make sure to also update scripts/golangci-lint-shim.sh
version: v2.4.0
version: v2.12.2
upload-coverage:
# List all jobs that produce coverage files
needs: [unit-tests, integration-tests]

View file

@ -13,7 +13,7 @@ jobs:
uses: actions/checkout@v7
- name: Generate Sponsors 💖
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1.6.0
uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1
with:
token: ${{ secrets.SPONSORS_TOKEN }}
file: "README.md"

View file

@ -99,8 +99,6 @@ linters:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- vendor/

View file

@ -82,6 +82,10 @@ while still being meaningful and self-contained.
- **Wrap message body to 72 characters**. The subject is allowed to go up to 80
characters, or even a little more if needed to convey a good single-line
summary; the body should be wrapped at 72 exactly, no more, no less.
- **End every commit message with the `Co-authored-by:` trailer** naming the
model that wrote it, exactly as your harness instructions spell it. Nothing
in `just check` catches a missing one, so it has to be part of writing the
message rather than something to notice afterwards.
## Iterate with `fixup!` commits
@ -100,6 +104,16 @@ separate, reviewable commit that the user decides when to fold in. A bare
`--amend` rewrites the commit on the spot and skips that checkpoint. Don't
treat "I'm only touching the tip commit" as an exception.
**When the tip is the wrong place for a fixup, insert it mid-branch.**
Committing a fixup at the tip of the branch only works while the code it
touches still looks the same there; once later commits have rewritten that
code — or the target has since been split — the fixup won't apply, and
rewriting the later commits to accommodate it defeats the point. Check out the
target, make the change, `git commit --fixup=<target>`, then
`git rebase --onto <the fixup> <target> <branch>` to replay the rest of the
branch. The fixup stays a separate, reviewable commit; only its position
changes.
If the changes don't map cleanly onto existing commits — say they cut
across several of them, or restructure something at a different layer
than any existing commit naturally owns — stop and ask the user how to

View file

@ -757,6 +757,7 @@ keybinding:
copyFileInfoToClipboard: "y"
collapseAll: '-'
expandAll: =
collapseParent: <backspace>
branches:
createPullRequest: o
viewPullRequestOptions: O

View file

@ -27,7 +27,7 @@ Fields only for `extDiff`:
Fields only for `rawGit`:
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`)
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`), as an array of strings.
Here's an example for a multi-renderer setup:
@ -40,7 +40,7 @@ git:
- type: extDiff
command: difft --color=always --context={{diffContext}}
- type: rawGit
args: --color-words
args: [--color-words]
name: color-words
- type: rawGit # git's default diff
name: default

View file

@ -27,7 +27,7 @@ Fields only for `extDiff`:
Fields only for `rawGit`:
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`)
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`), as an array of strings.
Here's an example for a multi-renderer setup:
@ -40,7 +40,7 @@ git:
- type: extDiff
command: difft --color=always --context={{diffContext}}
- type: rawGit
args: --color-words
args: [--color-words]
name: color-words
- type: rawGit # git's default diff
name: default

12
go.mod
View file

@ -5,6 +5,9 @@ go 1.25.0
// This is necessary to ignore test files when executing gofumpt.
ignore ./test
// Likewise for worktrees that are nested in the main tree.
ignore ./.worktrees
require (
dario.cat/mergo v1.0.2
github.com/adrg/xdg v0.5.3
@ -22,7 +25,7 @@ require (
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3
github.com/kyokomi/emoji/v2 v2.2.14
github.com/lucasb-eyer/go-colorful v1.4.0
github.com/lucasb-eyer/go-colorful v1.4.1
github.com/mgutz/str v1.2.0
github.com/mitchellh/go-ps v1.0.0
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe
@ -54,7 +57,6 @@ require (
github.com/fatih/color v1.9.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/go-logfmt/logfmt v0.5.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hpcloud/tail v1.0.0 // indirect
github.com/invopop/jsonschema v0.10.0 // indirect
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect
@ -65,14 +67,14 @@ require (
github.com/onsi/gomega v1.34.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
golang.org/x/tools v0.48.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/fsnotify.v1 v1.4.7 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
mvdan.cc/gofumpt v0.11.0 // indirect
)
tool mvdan.cc/gofumpt

28
go.sum
View file

@ -39,8 +39,8 @@ github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
@ -75,8 +75,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@ -104,8 +104,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=
github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
@ -139,14 +139,14 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -181,8 +181,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@ -196,5 +196,5 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
mvdan.cc/gofumpt v0.11.0 h1:0H01XB95PnN2QgCSR9ELdZyTlJqNZ7181B0BTMh5VZc=
mvdan.cc/gofumpt v0.11.0/go.mod h1:BeT5wCsOJt6J9zT2MZIOGszjUHzFkn1/l9g6xAzqsXo=

View file

@ -196,7 +196,7 @@ func getHeader(binding *types.Binding, tr *i18n.TranslationSet) header {
func formatSections(tr *i18n.TranslationSet, bindingSections []*bindingSection) string {
var content strings.Builder
content.WriteString(fmt.Sprintf("# Lazygit %s\n", tr.Keybindings))
fmt.Fprintf(&content, "# Lazygit %s\n", tr.Keybindings)
for _, section := range bindingSections {
content.WriteString(formatTitle(section.title))

View file

@ -2,7 +2,6 @@ package git_commands
import (
"fmt"
"path/filepath"
"strconv"
"strings"
@ -91,26 +90,6 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
self.setConflictMarkerSizes(files)
// Go through the files to see if any of these files are actually worktrees
// so that we can render them correctly
worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath())
for _, file := range files {
for _, worktreePath := range worktreePaths {
absFilePath, err := filepath.Abs(file.Path)
if err != nil {
self.Log.Error(err)
continue
}
if absFilePath == worktreePath {
file.IsWorktree = true
// `git status` renders this worktree as a folder with a trailing slash but we'll represent it as a singular worktree
// If we include the slash, it will be rendered as a folder with a null file inside.
file.Path = strings.TrimSuffix(file.Path, "/")
break
}
}
}
return files
}

View file

@ -6,6 +6,8 @@ import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"regexp"
"strings"
"time"
@ -160,9 +162,51 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
return queryString, variables
}
// GetAuthToken returns the token to authenticate against the given host with,
// or an empty string if there is none.
//
// The token has to come from gh itself rather than from an in-process lookup
// with go-gh: that reads gh's config file once per process and answers from
// that snapshot ever after, whereas gh rewrites the file whenever the active
// account changes, and keeps the active account's token either there or in the
// system keyring. Under a long-running lazygit the snapshot therefore drifts
// out of date, leaving us with a token for an account that is no longer active,
// or with no token at all.
func (self *GitHubCommands) GetAuthToken(host string) string {
token, _ := auth.TokenForHost(host)
return token
ghExe := ghExecutable()
if ghExe == "" {
// Without gh installed, the environment variables and config file that
// gh would have consulted are still worth a look.
token, _ := auth.TokenFromEnvOrConfig(host)
return token
}
cmdArgs := []string{ghExe, "auth", "token", "--hostname", host}
output, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
if err != nil {
// Not being logged in to this host is a normal state rather than
// something to report; the runner logs gh's stderr for the rest.
return ""
}
return strings.TrimSpace(output)
}
// ghExecutable returns the path of the gh binary, or an empty string if it
// isn't installed.
func ghExecutable() string {
if ghExe := os.Getenv("GH_PATH"); ghExe != "" {
return ghExe
}
// A gh found in the current directory rather than on PATH comes back as
// exec.ErrDot, which we treat as not having found one at all.
ghExe, err := exec.LookPath("gh")
if err != nil {
return ""
}
return ghExe
}
// FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo

View file

@ -1,7 +1,6 @@
package git_commands
import (
ioFs "io/fs"
"os"
"path/filepath"
"strings"
@ -10,7 +9,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/env"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/spf13/afero"
)
type RepoPaths struct {
@ -302,41 +300,3 @@ func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) {
}
return strings.TrimSpace(res), nil
}
// Returns the paths of linked worktrees
func linkedWortkreePaths(fs afero.Fs, repoGitDirPath string) []string {
result := []string{}
// For each directory in this path we're going to cat the `gitdir` file and append its contents to our result
// That file points us to the `.git` file in the worktree.
worktreeGitDirsPath := filepath.Join(repoGitDirPath, "worktrees")
// ensure the directory exists
_, err := fs.Stat(worktreeGitDirsPath)
if err != nil {
return result
}
_ = afero.Walk(fs, worktreeGitDirsPath, func(currPath string, info ioFs.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
return nil
}
gitDirPath := filepath.Join(currPath, "gitdir")
gitDirBytes, err := afero.ReadFile(fs, gitDirPath)
if err != nil {
// ignoring error
return nil
}
trimmedGitDir := strings.TrimSpace(string(gitDirBytes))
// removing the .git part
worktreeDir := filepath.Dir(trimmedGitDir)
result = append(result, worktreeDir)
return nil
})
return result
}

View file

@ -385,27 +385,22 @@ func (self *WorkingTreeCommands) Exclude(filename string) error {
// WorktreeFileDiff returns the diff of a file
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string {
// for now we assume an error means the file was deleted
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, nil).RunWithOutput()
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, file.Names()).RunWithOutput()
return s
}
// WorktreeFileDiffCmdObj returns a command object for diffing a file or directory
// in the working tree. When pathOverrides is non-empty, those paths are used instead of
// the node's path (used to diff only filtered/visible files within a directory).
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj {
// WorktreeFileDiffCmdObj returns a command object for diffing the given paths
// in the working tree. node is the item they belong to; all it decides is
// whether git has to compare against /dev/null, which is the case for a file
// that isn't in the index yet.
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, paths []string) *oscommands.CmdObj {
colorArg := self.diffRendererConfigManager.GetColorArg()
if plain {
colorArg = "never"
}
prevPath := node.GetPreviousPath()
noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile()
paths := pathOverrides
if len(paths) == 0 {
paths = []string{node.GetPath()}
}
cmdArgs := NewGitCmd("diff").
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
Arg("--submodule").
@ -415,7 +410,6 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
Arg("--").
ArgIf(noIndex, "/dev/null").
Arg(paths...).
ArgIf(prevPath != "", prevPath).
Dir(self.repoPaths.worktreePath).
ToArgv()

View file

@ -204,7 +204,8 @@ func (p *winPty) Close() error {
// slave closes on child exit, but ConPTY keeps the pipe alive until we call
// ClosePseudoConsole explicitly. Without doing that on child exit, the
// scanner in pkg/tasks.NewCmdTask would block forever on the next read and
// the post-content view never gets cleared (FlushStaleCells never fires).
// the render would never reach its end of input, so the new content would
// never be swapped in.
func startWaiter(proc *os.Process, p *winPty) func() error {
done := make(chan struct{})
var waitErr error

View file

@ -44,7 +44,8 @@ func (self *Hunk) lineCount() int {
// Returns all lines in the hunk, including the header line
func (self *Hunk) allLines() []*PatchLine {
lines := []*PatchLine{{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}}
lines := make([]*PatchLine, 1, 1+len(self.bodyLines))
lines[0] = &PatchLine{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}
lines = append(lines, self.bodyLines...)
return lines
}

View file

@ -50,13 +50,13 @@ type editPreset struct {
suspend func() bool
}
func returnBool(a bool) func() bool { return (func() bool { return a }) }
func returnBool(a bool) func() bool { return func() bool { return a } }
// IF YOU ADD A PRESET TO THIS FUNCTION YOU MUST UPDATE THE `Supported presets` SECTION OF docs/Config.md
func getPreset(shell string, osConfig *OSConfig, guessDefaultEditor func() string) *editPreset {
var nvimRemoteEditTemplate, nvimRemoteEditAtLineTemplate, nvimRemoteOpenDirInEditorTemplate string
// By default fish doesn't have SHELL variable set, but it does have FISH_VERSION since Nov 2012.
if (strings.HasSuffix(shell, "fish")) || (os.Getenv("FISH_VERSION") != "") {
if strings.HasSuffix(shell, "fish") || (os.Getenv("FISH_VERSION") != "") {
nvimRemoteEditTemplate = `begin; if test -z "$NVIM"; nvim -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; end; end`
nvimRemoteEditAtLineTemplate = `begin; if test -z "$NVIM"; nvim +{{line}} -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; nvim --server "$NVIM" --remote-send ":{{line}}<CR>"; end; end`
nvimRemoteOpenDirInEditorTemplate = `begin; if test -z "$NVIM"; nvim -- {{dir}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{dir}}; end; end`

View file

@ -38,6 +38,11 @@ var (
// ErrKeybindingNotHandled is returned when a keybinding is not handled, so that the key can be dispatched further
ErrKeybindingNotHandled = standardErrors.New("keybinding not handled")
// ErrLoopExited is returned by OnUIThreadAndWait when MainLoop has already
// returned. Nothing dequeues user events after that, so the callback it was
// asked to run on the main goroutine never will be.
ErrLoopExited = standardErrors.New("main loop exited")
)
const (
@ -217,6 +222,11 @@ type Gui struct {
// worker goroutines, so it's atomic.
uiThreadID atomic.Int64
// focused says whether the terminal we're running in has focus, as far as
// its focus reports tell us (see IsFocused). Written by the event loop,
// readable from anywhere, so it's atomic.
focused atomic.Bool
// blockInputCount, when greater than zero, withholds keyboard input from
// the handlers: key events are buffered into bufferedKeyEvents and replayed
// once the count drops back to zero, while mouse clicks and hover are
@ -301,6 +311,12 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
// runs during startup, before we reach MainLoop.
g.uiThreadID.Store(goid.Get())
// Assume we start out focused: a terminal that supports focus reports sends
// one for the state it is already in when we turn reporting on in MainLoop,
// and passing that on as a change would have the app react to a change that
// never happened.
g.focused.Store(true)
return g, nil
}
@ -893,46 +909,48 @@ func (g *Gui) EndBlockingEvents() error {
}
// OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the
// caller until f has run, returning f's error. Use it to read UI-thread-owned
// state (the model, contexts) from a worker without racing the UI thread.
// caller until f has run. Use it to read UI-thread-owned state (the model,
// contexts) from a worker without racing the UI thread.
//
// The error it returns is the wait's own, never f's: it reports that f was not
// run at all, which happens when the main loop has exited (ErrLoopExited). f
// doesn't report an error because what callers want on the UI thread — reading
// and mutating state — doesn't fail.
//
// It must be called from a worker goroutine, never from the UI thread itself:
// the UI thread would block waiting for a callback only it can run, which
// deadlocks. Callers arrange this by construction (see the refresh helper's
// RefreshFromWorker); a debug-only assertion there guards against getting it
// wrong.
func (g *Gui) OnUIThreadAndWait(f func() error) error {
func (g *Gui) OnUIThreadAndWait(f func()) error {
return g.onUIThreadAndWait(f, false)
}
// Like OnUIThreadAndWait, but the enqueued work belongs to a background routine,
// so it doesn't count towards the program being busy (see UpdateBackground).
func (g *Gui) OnUIThreadAndWaitBackground(f func() error) error {
func (g *Gui) OnUIThreadAndWaitBackground(f func()) error {
return g.onUIThreadAndWait(f, true)
}
func (g *Gui) onUIThreadAndWait(f func() error, background bool) error {
func (g *Gui) onUIThreadAndWait(f func(), background bool) error {
enqueue := g.Update
if background {
enqueue = g.UpdateBackground
}
result := make(chan error, 1)
ran := make(chan struct{})
enqueue(func(*Gui) error {
result <- f()
f()
close(ran)
return nil
})
// MainLoop stops draining the event queue the instant it returns, so an
// enqueue racing shutdown (e.g. a background refresh mid-flight when the
// user quits) would otherwise sit on result forever, wedging whatever
// caller is waiting on this call (and, transitively, on quit-time
// teardown that waits on that caller). Bail out once the loop is gone
// instead of blocking past it.
select {
case err := <-result:
return err
case <-ran:
return nil
case <-g.loopExited:
return ErrQuit
// The queue we just enqueued onto is no longer being served, so waiting
// on `ran` here would mean waiting for the rest of the process's life.
return ErrLoopExited
}
}
@ -1257,7 +1275,7 @@ func calcScrollbarRune(
func calcRealScrollbarStartEnd(v *View) (bool, int, int) {
height := v.InnerHeight()
fullHeight := v.ViewLinesHeight() - v.scrollMargin()
fullHeight := v.scrollbarContentHeight() - v.scrollMargin()
if v.CanScrollPastBottom {
fullHeight += height
@ -1439,7 +1457,7 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error {
currentBgColor = v.BgColor
}
if i >= currentTabStart && i <= currentTabEnd {
if i >= currentTabStart && i <= currentTabEnd && g.IsFocused() {
currentFgColor = v.SelFgColor
if v != g.currentView {
currentFgColor &= ^AttrBold
@ -1478,7 +1496,7 @@ func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error {
// drawListFooter draws the footer of a list view, showing something like '1 of 10'
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
if len(v.lines) == 0 {
if len(v.buf.lines) == 0 {
return nil
}
@ -1620,11 +1638,11 @@ func (g *Gui) draw(v *View) error {
Screen.HideCursor()
}
v.draw()
v.draw(g.IsFocused())
if v.Frame {
var fgColor, bgColor, frameColor Attribute
if g.Highlight && v == g.currentView {
if g.Highlight && v == g.currentView && g.IsFocused() {
fgColor = g.SelFgColor
bgColor = g.SelBgColor
frameColor = g.SelFrameColor
@ -1728,13 +1746,13 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
if newY < 0 {
newY = 0
newCy = -v.oy
} else if newY >= len(v.lines) {
newY = len(v.lines) - 1
} else if newY >= len(v.buf.lines) {
newY = len(v.buf.lines) - 1
newCy = newY - v.oy
}
visibleLineWidth := 0
for _, c := range v.lines[newY].cells {
for _, c := range v.buf.lines[newY].cells {
visibleLineWidth += c.width
}
if visibleLineWidth < newX {
@ -1744,10 +1762,8 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
}
if ev.Key.KeyName() == MouseLeft && (ev.Key.Mod()&ModMotion) == 0 && !v.Editable && g.openHyperlink != nil {
if newY >= 0 && newY <= len(v.viewLines)-1 && newX >= 0 && newX <= len(v.viewLines[newY].line)-1 {
if link := v.viewLines[newY].line[newX].hyperlink; link != "" {
return g.openHyperlink(link, v.name)
}
if link := v.hyperlinkAt(newX, newY); link != "" {
return g.openHyperlink(link, v.name)
}
}
@ -2001,7 +2017,21 @@ func (g *Gui) execKeybinding(v *View, kb *keybinding) error {
return nil
}
// IsFocused reports whether the terminal we're running in has focus. Terminals
// that don't report focus at all leave this true for good.
func (g *Gui) IsFocused() bool {
return g.focused.Load()
}
func (g *Gui) onFocus(ev *GocuiEvent) error {
// Terminals report their focus state when we turn focus reporting on, and
// some report it again when their window is activated, so only pass on the
// reports that actually change it.
if ev.Focused == g.focused.Load() {
return nil
}
g.focused.Store(ev.Focused)
if g.focusHandler != nil {
return g.focusHandler(ev.Focused)
}

View file

@ -300,15 +300,15 @@ func (g *Gui) pollEvent() GocuiEvent {
if g.playRecording {
select {
case ev := <-g.replayedEvents.Keys:
tev = (ev).toTcellEvent()
tev = ev.toTcellEvent()
task = ev.task
case ev := <-g.replayedEvents.Resizes:
tev = (ev).toTcellEvent()
tev = ev.toTcellEvent()
case ev := <-g.replayedEvents.MouseEvents:
tev = (ev).toTcellEvent()
tev = ev.toTcellEvent()
task = ev.task
case ev := <-g.replayedEvents.FocusEvents:
tev = (ev).toTcellEvent()
tev = ev.toTcellEvent()
task = ev.task
}
} else {

View file

@ -0,0 +1,43 @@
package gocui
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// errStillWaiting stands in for the result of a wait that hasn't produced one.
var errStillWaiting = errors.New("still waiting")
// resultOrTimeout reports what a wait returned, or errStillWaiting if it hasn't
// returned by the time we give up on it.
func resultOrTimeout(result chan error) error {
select {
case err := <-result:
return err
case <-time.After(time.Second):
return errStillWaiting
}
}
// A worker waiting for the UI thread must not be left parked there once the
// main loop has stopped: nothing will ever run its callback, and the shutdown
// that follows blocks until such workers have finished (see
// tasks.ViewBufferManager.Close).
func TestOnUIThreadAndWaitGivesUpWhenTheLoopExits(t *testing.T) {
g := newTestGui(t)
// Closing this is what MainLoop returning does. From here on nothing
// dequeues user events, so the callback below is never going to run.
close(g.loopExited)
result := make(chan error, 1)
go func() {
result <- g.OnUIThreadAndWait(func() {})
}()
err := resultOrTimeout(result)
assert.ErrorIs(t, err, ErrLoopExited)
}

View file

@ -25,17 +25,51 @@ const (
RIGHT = 8 // view is overlapping at right edge
)
// viewBuffer holds a view's content as cells, together with the cursor and
// escape-sequence decoder state used to turn incoming bytes into those cells.
// A view normally has a single buffer (the one it displays), but bundling this
// state lets a re-render build a second, off-screen buffer and swap it in
// atomically once the new content is ready, so no reader ever sees a
// half-written buffer.
type viewBuffer struct {
// the view's content: one []cell per unwrapped line
lines []lineType
// write cursor into lines
wx, wy int
// decodes ESC sequences as bytes are written
ei *escapeInterpreter
// If the last character written was a newline, we don't write it but instead
// set pendingNewline to true. If more text is written, we write the newline
// then. This avoids an extra blank line at the end of the view.
pendingNewline bool
}
// A View is a window. It maintains its own internal buffer and cursor
// position.
type View struct {
name string
x0, y0, x1, y1 int // left top right bottom
ox, oy int // view offsets
cx, cy int // cursor position
rx, ry int // Read() offsets
wx, wy int // Write() offsets
lines []lineType // All the data
x0, y0, x1, y1 int // left top right bottom
ox, oy int // view offsets
cx, cy int // cursor position
rx, ry int // Read() offsets
outMode OutputMode
// buf bundles the view's cell buffer and the cursor / escape-parser state
// used to write into it (see the viewBuffer type). It is the buffer every
// reader sees.
buf *viewBuffer
// While non-nil, writes go here instead of buf, so an async re-render can
// build its new content without disturbing what readers (draw, clicks,
// scrolling, …) see. The task swaps it into buf once it has read enough to
// paint (SwapInOffscreenRender), so the displayed content jumps straight
// from the previous render to the new one with no half-written frame in
// between. nil during normal (non-async) writes.
offscreen *viewBuffer
// The y position of the first line of a range selection.
// This is not relative to the view's origin: it is relative to the first line
// of the view's content, so you can scroll the view and this value will remain
@ -74,17 +108,20 @@ type View struct {
// true and viewLines to nil
viewLines []viewLine
// If the last character written was a newline, we don't write it but
// instead set pendingNewline to true. If more text is written, we write the
// newline then. This is to avoid having an extra blank at the end of the view.
pendingNewline bool
// While a re-render is loading new content (see offscreen), the displayed
// buffer is only partially filled once we've swapped the off-screen render
// in: the task keeps appending lines after the first paint, up to the count
// needed for an accurate scrollbar. Sizing the scrollbar from that partial
// view-line count would make the thumb shrink and snap back as the rest
// streams in. So while a load is in progress we hold the scrollbar's height
// at this value — the height the view had when the load began — and let it
// grow only if the new content turns out taller. Zero means no load is in
// progress and the scrollbar tracks the content directly.
scrollbarHeightFloor int
// writeMutex protects locks the write process
writeMutex sync.Mutex
// ei is used to decode ESC sequences on Write
ei *escapeInterpreter
// Visible specifies whether the view is visible.
Visible bool
@ -402,7 +439,7 @@ func (v *View) FocusPoint(cx int, cy int, scrollIntoView bool) {
if scrollIntoView {
height := v.InnerHeight()
v.oy = calculateNewOrigin(cy, v.oy, lineCount, height)
v.SetOriginY(calculateNewOrigin(cy, v.oy, lineCount, height))
}
v.cx = cx
@ -461,7 +498,7 @@ type SearchPosition struct {
}
type viewLine struct {
linesX, linesY int // coordinates relative to v.lines
linesX, linesY int // coordinates relative to v.buf.lines
line []cell
// Colors used to extend the bg past this wrapped segment's content.
@ -470,7 +507,7 @@ type viewLine struct {
trailingFillAttributes *trailingFillAttributes
}
// lineType is one of v.lines: the cells of a source line, plus optional
// lineType is one of v.buf.lines: the cells of a source line, plus optional
// trailingFillAttributes recording the colors used to extend the bg
// past the line's content when the writer emitted '\x1b[K'.
type lineType struct {
@ -536,7 +573,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
Editor: DefaultEditor,
tainted: true,
outMode: mode,
ei: newEscapeInterpreter(mode),
buf: &viewBuffer{ei: newEscapeInterpreter(mode)},
searcher: &searcher{},
TextArea: &TextArea{},
rangeSelectStartY: -1,
@ -547,7 +584,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault
v.InactiveViewSelBgColor = ColorDefault
v.TitleColor, v.FrameColor = ColorDefault, ColorDefault
v.ei.screenColMax = v.InnerWidth()
v.buf.ei.screenColMax = v.InnerWidth()
return v
}
@ -558,7 +595,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
// content can consult this snapshot instead of reading the view's live
// dimensions (which the UI thread mutates during layout).
func (v *View) SetContentWidth(width int) {
v.ei.screenColMax = width
v.buf.ei.screenColMax = width
}
// Dimensions returns the dimensions of the View
@ -616,7 +653,7 @@ func (v *View) Name() string {
// 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.
func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute) {
func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isWindowFocused bool) {
maxX, maxY := v.Size()
if x < 0 || x >= maxX || y < 0 || y >= maxY {
return
@ -642,7 +679,7 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute) {
fgColor += 8
}
fgColor = fgColor | AttrBold
if v.HighlightInactive {
if v.HighlightInactive || !isWindowFocused {
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
} else {
bgColor = (bgColor & AttrStyleBits) | v.SelBgColor
@ -707,15 +744,8 @@ func (v *View) CursorY() int {
// implement Horizontal and Vertical scrolling with just incrementing
// or decrementing ox and oy.
func (v *View) SetOrigin(x, y int) {
if x < 0 {
x = 0
}
if y < 0 {
y = 0
}
v.ox = x
v.oy = y
v.SetOriginX(x)
v.SetOriginY(y)
}
func (v *View) SetOriginX(x int) {
@ -755,16 +785,16 @@ func (v *View) SetWritePos(x, y int) {
y = 0
}
v.wx = x
v.wy = y
v.buf.wx = x
v.buf.wy = y
// Changing the write position makes a pending newline obsolete
v.pendingNewline = false
v.buf.pendingNewline = false
}
// WritePos returns the current write position of the view's internal buffer.
func (v *View) WritePos() (x, y int) {
return v.wx, v.wy
return v.buf.wx, v.buf.wy
}
// SetReadPos sets the read position of the view's internal buffer.
@ -788,56 +818,56 @@ func (v *View) ReadPos() (x, y int) {
}
// makeWriteable creates empty cells if required to make position (x, y) writeable.
func (v *View) makeWriteable(x, y int) {
func (b *viewBuffer) makeWriteable(x, y int) {
// TODO: make this more efficient
// line `y` must be index-able (that's why `<=`)
for len(v.lines) <= y {
if cap(v.lines) > len(v.lines) {
newLen := cap(v.lines)
for len(b.lines) <= y {
if cap(b.lines) > len(b.lines) {
newLen := cap(b.lines)
if newLen > y {
newLen = y + 1
}
v.lines = v.lines[:newLen]
b.lines = b.lines[:newLen]
} else {
v.lines = append(v.lines, lineType{})
b.lines = append(b.lines, lineType{})
}
}
// cell `x` need not be index-able (that's why `<`)
// append should be used by `lines[y]` user if he wants to write beyond `x`
for len(v.lines[y].cells) < x {
if cap(v.lines[y].cells) > len(v.lines[y].cells) {
newLen := cap(v.lines[y].cells)
for len(b.lines[y].cells) < x {
if cap(b.lines[y].cells) > len(b.lines[y].cells) {
newLen := cap(b.lines[y].cells)
if newLen > x {
newLen = x
}
v.lines[y].cells = v.lines[y].cells[:newLen]
b.lines[y].cells = b.lines[y].cells[:newLen]
} else {
v.lines[y].cells = append(v.lines[y].cells, cell{})
b.lines[y].cells = append(b.lines[y].cells, cell{})
}
}
}
// writeCells copies []cell to (v.wx, v.wy), and advances v.wx accordingly.
// writeCells copies []cell to (b.wx, b.wy), and advances b.wx accordingly.
// !!! caller MUST ensure that specified location (x, y) is writeable by calling makeWriteable
func (v *View) writeCells(cells []cell) {
func (b *viewBuffer) writeCells(cells []cell) {
var newLen int
// use maximum len available
line := v.lines[v.wy].cells[:cap(v.lines[v.wy].cells)]
maxCopy := len(line) - v.wx
line := b.lines[b.wy].cells[:cap(b.lines[b.wy].cells)]
maxCopy := len(line) - b.wx
if maxCopy < len(cells) {
copy(line[v.wx:], cells[:maxCopy])
copy(line[b.wx:], cells[:maxCopy])
line = append(line, cells[maxCopy:]...)
newLen = len(line)
} else { // maxCopy >= len(cells)
copy(line[v.wx:], cells)
newLen = v.wx + len(cells)
if newLen < len(v.lines[v.wy].cells) {
newLen = len(v.lines[v.wy].cells)
copy(line[b.wx:], cells)
newLen = b.wx + len(cells)
if newLen < len(b.lines[b.wy].cells) {
newLen = len(b.lines[b.wy].cells)
}
}
v.lines[v.wy].cells = line[:newLen]
v.wx += len(cells)
b.lines[b.wy].cells = line[:newLen]
b.wx += len(cells)
}
// Write appends a byte slice into the view's internal buffer. Because
@ -854,36 +884,54 @@ func (v *View) Write(p []byte) (n int, err error) {
}
func (v *View) write(p []byte) {
// An async re-render builds into the off-screen buffer (see View.offscreen)
// until it swaps in; until then the displayed buffer, and so everything
// readers see, is left untouched.
if v.offscreen != nil {
v.offscreen.write(v, p)
return
}
v.tainted = true
// write only ever touches lines from v.wy onwards, so any cached wrapping
// write only ever touches lines from v.buf.wy onwards, so any cached wrapping
// below that stays valid.
v.firstDirtyLine = min(v.firstDirtyLine, v.wy)
v.firstDirtyLine = min(v.firstDirtyLine, v.buf.wy)
v.clearHover()
v.buf.write(v, p)
v.updateSearchPositions()
}
// write parses p into cells and appends them to the buffer at its write cursor.
// It only touches the buffer; the View wrapper above handles display-side
// effects (tainting, hover, search). v supplies render config (Editable, colors,
// width, tab width, hyperlink auto-rendering).
func (b *viewBuffer) write(v *View, p []byte) {
// Fill with empty cells, if writing outside current view buffer
v.makeWriteable(v.wx, v.wy)
b.makeWriteable(b.wx, b.wy)
finishLine := func() {
v.autoRenderHyperlinksInCurrentLine()
b.autoRenderHyperlinksInCurrentLine(v)
}
advanceToNextLine := func() {
v.wx = 0
v.wy++
if v.wy >= len(v.lines) {
v.lines = append(v.lines, lineType{})
b.wx = 0
b.wy++
if b.wy >= len(b.lines) {
b.lines = append(b.lines, lineType{})
}
}
if v.pendingNewline {
if b.pendingNewline {
advanceToNextLine()
v.ei.notifyRowAdvance()
v.pendingNewline = false
b.ei.notifyRowAdvance()
b.pendingNewline = false
}
until := len(p)
if !v.Editable && until > 0 && p[until-1] == '\n' {
v.pendingNewline = true
b.pendingNewline = true
until--
}
@ -899,26 +947,26 @@ func (v *View) write(p []byte) {
case characterEquals(chr, '\n') || isCRLF(chr):
finishLine()
advanceToNextLine()
v.ei.notifyRowAdvance()
b.ei.notifyRowAdvance()
case characterEquals(chr, '\r'):
finishLine()
v.wx = 0
v.ei.notifyColumnReset()
b.wx = 0
b.ei.notifyColumnReset()
default:
truncateLine, cells := v.parseInput(chr, width, v.wx, v.wy)
if cd, ok := v.ei.instruction.(cursorDown); ok {
v.ei.instructionRead()
truncateLine, cells := b.parseInput(v, chr, width, b.wx, b.wy)
if cd, ok := b.ei.instruction.(cursorDown); ok {
b.ei.instructionRead()
for range cd.n {
v.autoRenderHyperlinksInCurrentLine()
b.autoRenderHyperlinksInCurrentLine(v)
advanceToNextLine()
}
}
if cells == nil {
continue
}
v.writeCells(cells)
b.writeCells(cells)
if truncateLine {
v.lines[v.wy].cells = v.lines[v.wy].cells[:v.wx]
b.lines[b.wy].cells = b.lines[b.wy].cells[:b.wx]
}
// Soft-wrap tracking. truncateLine is true exactly when the
// cells are from \x1b[K filling to end of line — ConPTY
@ -929,18 +977,16 @@ func (v *View) write(p []byte) {
for _, c := range cells {
totalWidth += c.width
}
v.ei.notifyCellsWritten(totalWidth)
b.ei.notifyCellsWritten(totalWidth)
}
}
}
if v.pendingNewline {
if b.pendingNewline {
finishLine()
} else {
v.autoRenderHyperlinksInCurrentLine()
b.autoRenderHyperlinksInCurrentLine(v)
}
v.updateSearchPositions()
}
// exported functions use the mutex. Non-exported functions are for internal use
@ -983,12 +1029,12 @@ var lineEndCharacters = map[string]bool{
")": true,
}
func (v *View) autoRenderHyperlinksInCurrentLine() {
func (b *viewBuffer) autoRenderHyperlinksInCurrentLine(v *View) {
if !v.AutoRenderHyperLinks {
return
}
line := v.lines[v.wy].cells
line := b.lines[b.wy].cells
start := 0
for {
linkStart := findLinkStart(line[start:])
@ -1005,7 +1051,7 @@ func (v *View) autoRenderHyperlinksInCurrentLine() {
link.WriteString(line[linkEnd].chr)
}
for i := linkStart; i < linkEnd; i++ {
v.lines[v.wy].cells[i].hyperlink = link.String()
b.lines[b.wy].cells[i].hyperlink = link.String()
}
start = linkEnd
}
@ -1014,13 +1060,13 @@ func (v *View) autoRenderHyperlinksInCurrentLine() {
// parseInput parses char by char the input written to the View. It returns nil
// while processing ESC sequences. Otherwise, it returns a cell slice that
// contains the processed data.
func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bool, []cell) {
cells := []cell{}
truncateLine := false
isEscape, err := v.ei.parseOne(ch)
isEscape, err := b.ei.parseOne(ch)
if err != nil {
for _, chr := range v.ei.characters() {
for _, chr := range b.ei.characters() {
c := cell{
fgColor: v.FgColor,
bgColor: v.BgColor,
@ -1029,28 +1075,28 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
}
cells = append(cells, c)
}
v.ei.reset()
b.ei.reset()
} else {
repeatCount := 1
if _, ok := v.ei.instruction.(eraseInLineFromCursor); ok {
if _, ok := b.ei.instruction.(eraseInLineFromCursor); ok {
// Discard any old content past the cursor and record the
// fill colors so draw() paints the trailing area with them.
// This extends the bg to the right edge in both the
// content-fits and content-wraps cases — for the latter,
// the metadata is what reaches every wrapped segment past
// the last word.
v.ei.instructionRead()
b.ei.instructionRead()
truncateLine = true
v.lines[v.wy].trailingFillAttributes = &trailingFillAttributes{
fg: v.ei.curFgColor,
bg: v.ei.curBgColor,
b.lines[b.wy].trailingFillAttributes = &trailingFillAttributes{
fg: b.ei.curFgColor,
bg: b.ei.curBgColor,
}
return truncateLine, []cell{}
} else if cf, ok := v.ei.instruction.(cursorForward); ok {
} else if cf, ok := b.ei.instruction.(cursorForward); ok {
// emit `n` space cells under the parser-tracked SGR — used
// to materialize ConPTY's compressed runs of spaces (which
// it emits as ECH+CUF instead of literal whitespace).
v.ei.instructionRead()
b.ei.instructionRead()
repeatCount = cf.n
ch = []byte{' '}
width = 1
@ -1068,9 +1114,9 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
repeatCount = tabWidth - (x % tabWidth)
}
c := cell{
fgColor: v.ei.curFgColor,
bgColor: v.ei.curBgColor,
hyperlink: v.ei.hyperlink.String(),
fgColor: b.ei.curFgColor,
bgColor: b.ei.curBgColor,
hyperlink: b.ei.hyperlink.String(),
chr: string(ch),
width: width,
}
@ -1098,9 +1144,9 @@ func (v *View) Read(p []byte) (n int, err error) {
}
v.readBuffer = nil
}
for v.ry < len(v.lines) {
for v.rx < len(v.lines[v.ry].cells) {
s := v.lines[v.ry].cells[v.rx].chr
for v.ry < len(v.buf.lines) {
for v.rx < len(v.buf.lines[v.ry].cells) {
s := v.buf.lines[v.ry].cells[v.rx].chr
count := len(s)
copy(p[offset:], s)
v.rx++
@ -1122,8 +1168,17 @@ func (v *View) Read(p []byte) (n int, err error) {
// only use this if the calling function has a lock on writeMutex
func (v *View) clear() {
v.rewind()
v.lines = nil
v.buf.lines = nil
v.clearViewLines()
// Abandon any in-progress off-screen render: a synchronous SetContent/Clear
// is taking over the displayed buffer, so writes must go there, not into a
// stale off-screen buffer left by a stopped task.
v.offscreen = nil
// Likewise release any held scrollbar height: the new content is defined
// synchronously (e.g. a string render superseding a still-loading diff), so
// there's no async growth left to smooth over and the scrollbar should track
// the new content directly.
v.scrollbarHeightFloor = 0
}
// Clear empties the view's internal buffer.
@ -1164,10 +1219,10 @@ func (v *View) CopyContent(from *View) {
// This is a shallow clone -- the per-row cell data is immutable once written
// and stays shared, so the cost is proportional to the number of rows, not
// their contents.
v.lines = slices.Clone(from.lines)
v.buf.lines = slices.Clone(from.buf.lines)
v.viewLines = slices.Clone(from.viewLines)
v.ox = from.ox
v.oy = from.oy
v.SetOriginX(from.ox)
v.SetOriginY(from.oy)
v.cx = from.cx
v.cy = from.cy
}
@ -1187,23 +1242,88 @@ func (v *View) Reset() {
defer v.writeMutex.Unlock()
v.rewind()
v.lines = nil
v.buf.lines = nil
// As in clear(): abandon any in-progress off-screen render so writes after a
// reset go to the displayed buffer.
v.offscreen = nil
}
// This is for when we've done a restart for the sake of avoiding a flicker and
// we've reached the end of the new content to display: we need to clear the remaining
// content from the previous round. We do this by setting v.viewLines to nil so that
// we just render the new content from v.lines directly
func (v *View) FlushStaleCells() {
// BeginOffscreenRender starts building a re-render into an off-screen buffer.
// Until SwapInOffscreenRender promotes it, writes go to that buffer and the
// displayed buffer — what every reader sees — is left as it was. This is how an
// async re-render avoids exposing a half-written buffer: it accumulates
// off-screen and swaps in once it has read enough to paint.
func (v *View) BeginOffscreenRender() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.clearViewLines()
ei := newEscapeInterpreter(v.outMode)
// The screen width content is wrapped at is render configuration set by
// SetContentWidth, not per-buffer state, so the off-screen buffer's parser
// needs it too — otherwise it counts no soft wraps and cursor-positioning
// escapes land on the wrong rows.
ei.screenColMax = v.buf.ei.screenColMax
v.offscreen = &viewBuffer{ei: ei}
}
// SwapInOffscreenRender promotes the off-screen buffer (see BeginOffscreenRender)
// to the displayed buffer in one step, so the view jumps straight from the
// previous render to the new one with no half-written frame. Writes after this
// append to the now-displayed buffer directly. It is a no-op if no off-screen
// render is in progress, so it is safe to call more than once (e.g. again at EOF
// after an earlier paint already swapped).
func (v *View) SwapInOffscreenRender() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if v.offscreen == nil {
return
}
v.buf = v.offscreen
v.offscreen = nil
v.tainted = true
v.clearHover()
}
// FreezeScrollbarHeight records the view's current content height so the
// scrollbar keeps that size while a re-render loads, instead of shrinking and
// snapping back as the partially-loaded content streams in past the first paint
// (see scrollbarHeightFloor). Call it when a load begins, while the view still
// shows the previous render; UnfreezeScrollbarHeight clears it when the load
// ends.
func (v *View) FreezeScrollbarHeight() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
v.scrollbarHeightFloor = len(v.viewLines)
}
// UnfreezeScrollbarHeight clears the height held by FreezeScrollbarHeight, so
// the scrollbar tracks the view's content directly again. Call it when a load
// ends.
func (v *View) UnfreezeScrollbarHeight() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.scrollbarHeightFloor = 0
}
// scrollbarContentHeight is the view-line height the scrollbar is sized from.
// While a re-render is loading it is held at the height the view had when the
// load began (see FreezeScrollbarHeight), so the thumb doesn't shrink and jump
// as partially-loaded content streams in.
func (v *View) scrollbarContentHeight() int {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.refreshViewLinesIfNeeded()
return max(len(v.viewLines), v.scrollbarHeightFloor)
}
func (v *View) rewind() {
v.ei.reset()
v.ei.resetScreenCursor()
v.buf.ei.reset()
v.buf.ei.resetScreenCursor()
v.SetReadPos(0, 0)
v.SetWritePos(0, 0)
@ -1275,14 +1395,14 @@ func (v *View) updateSearchPositions() {
for _, result := range v.searcher.modelSearchResults {
// This code only works when v.Wrap is false.
if result.Y >= len(v.lines) {
if result.Y >= len(v.buf.lines) {
break
}
// If a view line exists for this line index:
if v.lines[result.Y].cells != nil {
if v.buf.lines[result.Y].cells != nil {
// search this view line for the search string
positions := searchPositionsForLine(v.lines[result.Y].cells, result.Y)
positions := searchPositionsForLine(v.buf.lines[result.Y].cells, result.Y)
if len(positions) > 0 {
// If we found any occurrences, add them
v.searcher.searchPositions = append(v.searcher.searchPositions, positions...)
@ -1319,7 +1439,7 @@ func (v *View) IsTainted() bool {
}
// draw re-draws the view's contents.
func (v *View) draw() {
func (v *View) draw(isWindowFocused bool) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
@ -1335,14 +1455,14 @@ func (v *View) draw() {
if maxX == 0 {
return
}
v.ox = 0
v.SetOriginX(0)
}
v.refreshViewLinesIfNeeded()
visibleViewLinesHeight := v.viewLineLengthIgnoringTrailingBlankLines()
if v.Autoscroll && visibleViewLinesHeight > maxY {
v.oy = visibleViewLinesHeight - maxY
v.SetOriginY(visibleViewLinesHeight - maxY)
}
if len(v.viewLines) == 0 {
@ -1409,7 +1529,7 @@ func (v *View) draw() {
fgColor |= AttrUnderline
}
v.setCharacter(x, y, c.chr, fgColor, bgColor)
v.setCharacter(x, y, c.chr, fgColor, bgColor, isWindowFocused)
x += c.width
cellIdx++
@ -1429,7 +1549,7 @@ func (v *View) refreshViewLinesIfNeeded() {
}
lineIdx := 0
lines := v.lines
lines := v.buf.lines
for i := range lines {
line := &lines[i]
@ -1475,6 +1595,13 @@ func (v *View) refreshViewLinesIfNeeded() {
}
v.firstDirtyLine = len(lines)
// Truncate any entries left over from a previous, longer render. An async
// re-render builds its content off-screen and swaps it in whole (see
// View.offscreen), so the buffer this rebuilds from is always a complete
// render — there is no half-loaded shorter buffer whose tail we'd need to
// keep showing to avoid a flicker, and a leftover tail would just be stale
// lines mapping to the wrong buffer rows.
v.viewLines = v.viewLines[:lineIdx]
v.tainted = false
}
@ -1553,8 +1680,8 @@ func (v *View) BufferLines() []string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
lines := make([]string, len(v.lines))
for i, l := range v.lines {
lines := make([]string, len(v.buf.lines))
for i, l := range v.buf.lines {
lines[i] = l.cells.String()
}
return lines
@ -1566,7 +1693,7 @@ func (v *View) Buffer() string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return linesToString(v.lines)
return linesToString(v.buf.lines)
}
// ViewBufferLines returns the lines in the view's internal
@ -1586,7 +1713,7 @@ func (v *View) ViewBufferLines() []string {
// LinesHeight is the count of view lines (i.e. lines excluding wrapping)
func (v *View) LinesHeight() int {
return len(v.lines)
return len(v.buf.lines)
}
// ViewLinesHeight is the count of view lines (i.e. lines including wrapping)
@ -1617,11 +1744,11 @@ func (v *View) Line(y int) (string, bool) {
return "", false
}
if y < 0 || y >= len(v.lines) {
if y < 0 || y >= len(v.buf.lines) {
return "", false
}
return v.lines[y].cells.String(), true
return v.buf.lines[y].cells.String(), true
}
// Word returns a string with the word of the view's internal buffer
@ -1632,11 +1759,11 @@ func (v *View) Word(x, y int) (string, bool) {
return "", false
}
if x < 0 || y < 0 || y >= len(v.lines) || x >= len(v.lines[y].cells) {
if x < 0 || y < 0 || y >= len(v.buf.lines) || x >= len(v.buf.lines[y].cells) {
return "", false
}
str := v.lines[y].cells.String()
str := v.buf.lines[y].cells.String()
nl := strings.LastIndexFunc(str[:x], indexFunc)
if nl == -1 {
@ -1662,12 +1789,12 @@ func indexFunc(r rune) bool {
// SetHighlight toggles highlighting of separate lines, for custom lists
// or multiple selection in views.
func (v *View) SetHighlight(y int, on bool) {
if y < 0 || y >= len(v.lines) {
if y < 0 || y >= len(v.buf.lines) {
return
}
cells := make([]cell, 0, len(v.lines[y].cells))
for _, c := range v.lines[y].cells {
cells := make([]cell, 0, len(v.buf.lines[y].cells))
for _, c := range v.buf.lines[y].cells {
if on {
c.bgColor = v.SelBgColor
c.fgColor = v.SelFgColor
@ -1679,7 +1806,7 @@ func (v *View) SetHighlight(y int, on bool) {
}
v.tainted = true
v.firstDirtyLine = min(v.firstDirtyLine, y)
v.lines[y].cells = cells
v.buf.lines[y].cells = cells
v.clearHover()
}
@ -1791,7 +1918,7 @@ func (v *View) SelectedLine() string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if len(v.lines) == 0 {
if len(v.buf.lines) == 0 {
return ""
}
@ -1803,7 +1930,7 @@ func (v *View) SelectedLines() []string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if len(v.lines) == 0 {
if len(v.buf.lines) == 0 {
return nil
}
@ -1818,7 +1945,7 @@ func (v *View) SelectedLines() []string {
}
func (v *View) lineContentAtIdx(idx int) string {
return v.lines[idx].cells.String()
return v.buf.lines[idx].cells.String()
}
func (v *View) SelectedPoint() (int, int) {
@ -1891,8 +2018,8 @@ func (v *View) ClearTextArea() {
func (v *View) overwriteLines(y int, content string) {
// break by newline, then for each line, write it, then add that erase command
v.wx = 0
v.wy = y
v.buf.wx = 0
v.buf.wy = y
v.clearViewLines()
lines := strings.ReplaceAll(content, "\n", "\x1b[K\n")
@ -1904,7 +2031,7 @@ func (v *View) overwriteLines(y int, content string) {
v.writeString(lines)
}
// only call this function if you don't care where v.wx and v.wy end up
// only call this function if you don't care where v.buf.wx and v.buf.wy end up
func (v *View) OverwriteLines(y int, content string) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
@ -1912,7 +2039,7 @@ func (v *View) OverwriteLines(y int, content string) {
v.overwriteLines(y, content)
}
// only call this function if you don't care where v.wx and v.wy end up
// only call this function if you don't care where v.buf.wx and v.buf.wy end up
func (v *View) OverwriteLinesAndClearEverythingElse(lineCount int, y int, content string) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
@ -1922,19 +2049,19 @@ func (v *View) OverwriteLinesAndClearEverythingElse(lineCount int, y int, conten
v.overwriteLines(y, content)
for i := range y {
v.lines[i] = lineType{}
v.buf.lines[i] = lineType{}
}
for i := v.wy + 1; i < len(v.lines); i += 1 {
v.lines[i] = lineType{}
for i := v.buf.wy + 1; i < len(v.buf.lines); i += 1 {
v.buf.lines[i] = lineType{}
}
}
func (v *View) setContentLineCount(lineCount int) {
if lineCount > 0 {
v.makeWriteable(0, lineCount-1)
v.buf.makeWriteable(0, lineCount-1)
}
v.lines = v.lines[:lineCount]
v.buf.lines = v.buf.lines[:lineCount]
}
// If the current search result is no longer visible after a scroll up, select the last search
@ -1989,7 +2116,7 @@ func (v *View) ScrollUp(amount int) {
}
if amount != 0 {
v.oy -= amount
v.SetOriginY(v.oy - amount)
v.cy += amount
v.clearHover()
@ -2001,7 +2128,7 @@ func (v *View) ScrollUp(amount int) {
func (v *View) ScrollDown(amount int) {
adjustedAmount := v.adjustDownwardScrollAmount(amount)
if adjustedAmount > 0 {
v.oy += adjustedAmount
v.SetOriginY(v.oy + adjustedAmount)
v.cy -= adjustedAmount
v.clearHover()
@ -2015,7 +2142,7 @@ func (v *View) ScrollLeft(amount int) {
newOx = 0
}
if newOx != v.ox {
v.ox = newOx
v.SetOriginX(newOx)
v.clearHover()
}
@ -2023,7 +2150,7 @@ func (v *View) ScrollLeft(amount int) {
// not applying any limits to this
func (v *View) ScrollRight(amount int) {
v.ox += amount
v.SetOriginX(v.ox + amount)
v.clearHover()
}
@ -2068,7 +2195,7 @@ func (v *View) scrollMargin() int {
// Returns true if the view contains a line containing the given text with the given
// foreground color
func (v *View) ContainsColoredText(fgColor string, text string) bool {
for _, line := range v.lines {
for _, line := range v.buf.lines {
if containsColoredTextInLine(fgColor, text, line.cells) {
return true
}
@ -2105,6 +2232,9 @@ func (v *View) onMouseMove(x int, y int) {
return
}
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
// newCx and newCy are relative to the view port, i.e. to the visible area of the view
newCx := x - v.x0 - 1
newCy := y - v.y0 - 1
@ -2123,6 +2253,19 @@ func (v *View) onMouseMove(x int, y int) {
}
}
// hyperlinkAt returns the hyperlink at the given position of the view's
// content, or an empty string if there is none.
func (v *View) hyperlinkAt(x, y int) string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
if y < 0 || y >= len(v.viewLines) || x < 0 || x >= len(v.viewLines[y].line) {
return ""
}
return v.viewLines[y].line[x].hyperlink
}
func (v *View) findHyperlinkAt(x, y int) *SearchPosition {
linkStr := v.viewLines[y].line[x].hyperlink
if linkStr == "" {

View file

@ -11,6 +11,7 @@ import (
"github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/color"
"github.com/rivo/uniseg"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
@ -101,15 +102,13 @@ func TestWriteString(t *testing.T) {
for _, test := range tests {
v := NewView("name", 0, 0, 10, 10, OutputNormal)
for _, l := range test.existingLines {
v.lines = append(v.lines, lineType{cells: stringToCells(l)})
v.buf.lines = append(v.buf.lines, lineType{cells: stringToCells(l)})
}
for _, s := range test.stringsToWrite {
v.writeString(s)
}
var resultingLines [][]string
for _, l := range v.lines {
resultingLines = append(resultingLines, cellsToStrings(l.cells))
}
resultingLines := lo.Map(v.buf.lines,
func(l lineType, _ int) []string { return cellsToStrings(l.cells) })
assert.Equal(t, test.expectedLines, resultingLines)
}
}
@ -144,19 +143,115 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
v.writeString("htt")
// No hyperlinks are generated for incomplete URLs
assert.Equal(t, "", v.lines[0].cells[0].hyperlink)
assert.Equal(t, "", v.buf.lines[0].cells[0].hyperlink)
// Writing more characters to the same line makes the link complete (even
// though we didn't see a newline yet)
v.writeString("ps://example.com")
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
v.Clear()
// Valid but incomplete URL
v.writeString("https://exa")
assert.Equal(t, "https://exa", v.lines[0].cells[0].hyperlink)
assert.Equal(t, "https://exa", v.buf.lines[0].cells[0].hyperlink)
// Writing more characters to the same fixes the link
v.writeString("mple.com")
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
}
// An async re-render builds into an off-screen buffer and swaps it in once it
// has enough to paint, so readers keep seeing the previous render — coherent and
// consistent — until the new content appears in one step. See View.offscreen.
func TestOffscreenRender(t *testing.T) {
v := NewView("name", 0, 0, 80, 10, OutputNormal)
v.writeString("a\nb\nc")
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
// Render new, longer content off-screen.
v.BeginOffscreenRender()
v.writeString("w\nx\ny\nz")
// The displayed buffer is untouched: readers still see the previous render.
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
// Swapping in reveals the new content in one step.
v.SwapInOffscreenRender()
assert.Equal(t, []string{"w", "x", "y", "z"}, v.ViewBufferLines())
// A further write now appends to the displayed buffer directly.
v.writeString("\nmore")
assert.Equal(t, []string{"w", "x", "y", "z", "more"}, v.ViewBufferLines())
}
// When a render produces fewer view lines than the previous one,
// refreshViewLinesIfNeeded must truncate viewLines to the new content rather
// than leaving the previous render's entries in the tail: with the off-screen
// render there is no half-loaded buffer whose tail we'd want to keep showing,
// and a leftover tail is just stale lines describing content that is gone.
func TestViewLinesTruncatedByShorterRender(t *testing.T) {
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
v.Wrap = true
// Two lines of 27 characters each wrap into 3 view lines apiece.
v.writeString(strings.Repeat("a", 27) + "\n" + strings.Repeat("b", 27))
assert.Equal(t, 6, v.ViewLinesHeight())
// Re-render with three short, unwrapped lines: only 3 view lines remain.
v.BeginOffscreenRender()
v.writeString("aaa\nbbb\nccc")
v.SwapInOffscreenRender()
assert.Equal(t, 3, v.ViewLinesHeight())
assert.Equal(t, []string{"aaa", "bbb", "ccc"}, v.ViewBufferLines())
}
// While an async re-render loads, it swaps in only a partially-filled buffer at
// its first paint and keeps appending lines afterwards. The scrollbar must keep
// using the pre-load height until the load ends, so the thumb doesn't shrink and
// snap back as the rest streams in. See View.scrollbarHeightFloor.
func TestScrollbarHeightHeldWhileLoading(t *testing.T) {
v := NewView("name", 0, 0, 80, 12, OutputNormal)
// Initial render: 100 lines, scrolled well down.
v.writeString(strings.Repeat("x\n", 100))
v.SetOrigin(0, 80)
assert.Equal(t, 100, v.scrollbarContentHeight())
// A re-render begins while the previous render is still shown: hold the
// scrollbar height at the current value.
v.FreezeScrollbarHeight()
// The off-screen render swaps in only a screenful at its first paint.
v.BeginOffscreenRender()
v.writeString(strings.Repeat("y\n", 30))
v.SwapInOffscreenRender()
// The displayed buffer is now short, but the scrollbar height stays held, so
// the thumb keeps its position instead of jumping.
assert.Equal(t, 30, v.ViewLinesHeight())
assert.Equal(t, 100, v.scrollbarContentHeight())
// The rest of the content streams in.
v.writeString(strings.Repeat("y\n", 70))
assert.Equal(t, 100, v.scrollbarContentHeight())
// Once the load ends, the scrollbar tracks the real content directly again.
v.UnfreezeScrollbarHeight()
assert.Equal(t, 100, v.scrollbarContentHeight())
}
// If a synchronous render (e.g. a string render) supersedes a still-loading diff
// before it reaches its end, the held scrollbar height must be released, so the
// scrollbar reflects the new content rather than the abandoned load's height.
func TestScrollbarHeightReleasedWhenContentReplaced(t *testing.T) {
v := NewView("name", 0, 0, 80, 12, OutputNormal)
v.writeString(strings.Repeat("x\n", 100))
v.FreezeScrollbarHeight()
assert.Equal(t, 100, v.scrollbarContentHeight())
// A synchronous render replaces the content before the (notional) load ends.
v.SetContent("just a few\nshort lines\nhere")
assert.Equal(t, 3, v.scrollbarContentHeight())
}
func TestContainsColoredText(t *testing.T) {
@ -233,7 +328,7 @@ func TestContainsColoredText(t *testing.T) {
for j, cells := range test.lines {
lines[j] = lineType{cells: cells}
}
v := &View{lines: lines}
v := &View{buf: &viewBuffer{lines: lines}}
assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i)
}
}
@ -248,8 +343,8 @@ func TestWriteCursorPositionEscape(t *testing.T) {
// "a", then "skip to row 3" (i.e. one blank row), then "b".
v.writeString("a\r\n\x1b[3;1Hb\r\n")
got := make([][]string, 0, len(v.lines))
for _, l := range v.lines {
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
@ -269,8 +364,8 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
// ConPTY is on row 3 here; CUP to row 5 should skip exactly one row.
v.writeString("c\x1b[5;1Hd\n")
got := make([][]string, 0, len(v.lines))
for _, l := range v.lines {
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
assert.Equal(t, [][]string{
@ -282,6 +377,31 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
}, got)
}
func TestWriteCursorPositionEscapeInOffscreenRender(t *testing.T) {
// Soft-wrap counting has to work in an off-screen render too: the content
// width the parser counts wraps against is set by SetContentWidth before the
// render starts, so the off-screen buffer's parser has to pick it up. If it
// doesn't, no wraps are counted and the CUP below is evaluated against a
// stale row, overshooting into an extra blank line.
v := NewView("name", 0, 0, 30, 30, OutputNormal)
v.SetContentWidth(5)
v.BeginOffscreenRender()
// Seven characters soft-wrap once on a 5-column screen, putting ConPTY on
// row 2; CUP to row 3 should then skip no rows at all.
v.writeString("aaaaaaa\x1b[3;1Hb\n")
v.SwapInOffscreenRender()
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
assert.Equal(t, [][]string{
{"a", "a", "a", "a", "a", "a", "a"},
{"b"},
}, got)
}
func TestWriteCursorForwardEscape(t *testing.T) {
// ConPTY compresses runs of default-colored spaces into ECH (\x1b[NX,
// "clear N cells, cursor stationary") + CUF (\x1b[NC, "cursor forward
@ -292,8 +412,8 @@ func TestWriteCursorForwardEscape(t *testing.T) {
// "a" + ECH 5 + CUF 5 + "b" — visually "a b".
v.writeString("a\x1b[5X\x1b[5Cb\n")
got := make([][]string, 0, len(v.lines))
for _, l := range v.lines {
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
@ -312,8 +432,8 @@ func TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) {
v.writeString("abcdefghij\n")
v.writeString("\x1b[4;1Hxyz\n")
got := make([][]string, 0, len(v.lines))
for _, l := range v.lines {
got := make([][]string, 0, len(v.buf.lines))
for _, l := range v.buf.lines {
got = append(got, cellsToStrings(l.cells))
}
assert.Equal(t, [][]string{
@ -344,11 +464,7 @@ func cellsToString(cells []cell) string {
}
func cellsToStrings(cells []cell) []string {
s := []string{}
for _, c := range cells {
s = append(s, c.chr)
}
return s
return lo.Map(cells, func(c cell, _ int) string { return c.chr })
}
func TestLineWrap(t *testing.T) {
@ -534,7 +650,7 @@ func TestNewlineTerminatedLineClearsTrailingBg(t *testing.T) {
// renders with bg=red. The trailing area past "foo" must NOT extend
// the red bg because '\n' marks the line as cleanly terminated.
v.writeString("\x1b[7m\x1b[31mfoo\x1b[0m\n")
v.draw()
v.draw(true)
// First row: cells 1..3 are "foo" (render with red bg via reverse),
// cells 4..10 are trailing and should be plain default.
@ -560,7 +676,7 @@ func TestUnterminatedReverseLineDoesNotExtend(t *testing.T) {
// Reverse + red fg, "foo", no termination. The trailing cells past
// "foo" should be plain default, NOT a continuation of the red bg.
v.writeString("\x1b[7m\x1b[31mfoo")
v.draw()
v.draw(true)
// Cells 4..10 are trailing and should be default with no reverse.
for x := 4; x <= 10; x++ {
@ -583,7 +699,7 @@ func TestShortFilledLineExtendsBgWithoutWrap(t *testing.T) {
// \x1b[41m sets bg=red. "hi" fits within InnerWidth=10; \x1b[K should
// fill the remaining 8 cells with red.
v.writeString("\x1b[41mhi\x1b[K\x1b[0m\n")
v.draw()
v.draw(true)
// All ten cells at (1..10, 1) should have red bg.
for x := 1; x <= 10; x++ {
@ -611,7 +727,7 @@ func TestWrappedFilledLineExtendsBgToEdge(t *testing.T) {
// segments — "aaa bbb" / "ccc ddd" / "eee". Each row's trailing area
// must pick up the red fill from \x1b[K.
v.writeString("\x1b[41m" + "aaa bbb ccc ddd eee" + "\x1b[0m\x1b[41m\x1b[K\x1b[0m\n")
v.draw()
v.draw(true)
// All three wrapped rows should have the red fill background across
// the full InnerWidth, including the trailing cells past each row's
@ -645,7 +761,7 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
// last cell red) and segment 2 is "ccc" (green, last cell green).
// \x1b[K records the green bg on the source line.
v.writeString("\x1b[41maaa bbb\x1b[42m ccc\x1b[K\x1b[0m\n")
v.draw()
v.draw(true)
// Row 1's content ends with a red cell at x=7, so trailing columns
// 8..10 should pick up red rather than the \x1b[K's green.

View file

@ -119,13 +119,12 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() {
var appStatusHelper *helpers.AppStatusHelper
var branchesHelper *helpers.BranchesHelper
var fetchGeneration int
if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
if err := self.gui.g.OnUIThreadAndWaitBackground(func() {
git = self.gui.git
appStatusHelper = self.gui.helpers.AppStatus
branchesHelper = self.gui.helpers.BranchesHelper
fetchGeneration = self.gui.c.State().GetRepoGeneration()
self.gui.State.LastBackgroundFetchTime = time.Now()
return nil
}); err != nil {
return err
}
@ -184,10 +183,9 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() {
// reading them from this background goroutine would race the reassignment.
var git *commands.GitCommand
var refreshHelper *helpers.RefreshHelper
if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
if err := self.gui.g.OnUIThreadAndWaitBackground(func() {
git = self.gui.git
refreshHelper = self.gui.helpers.Refresh
return nil
}); err != nil {
return
}

View file

@ -119,7 +119,7 @@ func (self *BaseContext) GetKey() types.ContextKey {
}
func (self *BaseContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
bindings := []*types.Binding{}
bindings := make([]*types.Binding, 0, len(self.keybindingsFns))
for i := range self.keybindingsFns {
// the first binding in the bindings array takes precedence but we want the
// last keybindingsFn to take precedence to we add them in reverse
@ -216,7 +216,7 @@ func (self *BaseContext) AddOnQuitFn(fn func()) {
}
func (self *BaseContext) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
bindings := []*gocui.ViewMouseBinding{}
bindings := make([]*gocui.ViewMouseBinding, 0, len(self.mouseKeybindingsFns))
for i := range self.mouseKeybindingsFns {
// the first binding in the bindings array takes precedence but we want the
// last keybindingsFn to take precedence to we add them in reverse

View file

@ -89,7 +89,7 @@ func formatListFooter(selectedLineIdx int, length int) string {
}
func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) {
self.FocusLine(opts.ScrollSelectionIntoView)
self.FocusLine(!opts.KeepScrollPosition)
self.GetViewTrait().SetHighlight(self.list.Len() > 0)

View file

@ -157,6 +157,18 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e
}
}
commitTagsItem := &types.MenuItem{
Label: self.c.Tr.CommitTags,
OnPress: func() error {
return self.copyCommitTagsToClipboard(commit)
},
Keys: menuKey('t'),
}
if len(commit.Tags) == 0 {
commitTagsItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CommitHasNoTags}
}
items := []*types.MenuItem{
{
Label: self.c.Tr.CommitHash,
@ -207,22 +219,9 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e
},
Keys: menuKey('a'),
},
commitTagsItem,
}
commitTagsItem := types.MenuItem{
Label: self.c.Tr.CommitTags,
OnPress: func() error {
return self.copyCommitTagsToClipboard(commit)
},
Keys: menuKey('t'),
}
if len(commit.Tags) == 0 {
commitTagsItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CommitHasNoTags}
}
items = append(items, &commitTagsItem)
return self.c.Menu(types.CreateMenuOptions{
Title: self.c.Tr.Actions.CopyCommitAttributeToClipboard,
Items: items,

View file

@ -644,24 +644,9 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName
}
}
// pathsForDiff returns the file paths to use for a diff command. When a text
// filter is active and the node is a directory, only the visible (filtered)
// file paths are returned so the diff reflects what the user sees.
func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string {
if !node.IsFile() && self.context().IsFiltering() {
var paths []string
_ = node.ForEachFile(func(file *models.CommitFile) error {
// For a rename we need to pass both paths so that git detects it as
// a rename rather than an unrelated delete and add.
paths = append(paths, file.Names()...)
return nil
})
return paths
}
if file := node.GetFile(); file != nil {
return file.Names()
}
return []string{node.GetPath()}
return diffPathsForNode(
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering())
}
// NOTE: these functions are identical to those in files_controller.go (except for types) and

View file

@ -0,0 +1,132 @@
package controllers
import (
"path"
"strings"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/samber/lo"
)
// Both models.File and models.CommitFile satisfy this. Names returns the file's
// path, plus the path it was renamed from if it is a rename.
type fileWithNames[T any] interface {
*T
GetPath() string
GetPreviousPath() string
Names() []string
}
// diffPathsForNode returns the paths to limit a diff command to for showing the
// changes of the given node. files are all the files that the diff contains,
// while root is the root of the tree the node belongs to, which holds only the
// files matching the text filter when there is one.
func diffPathsForNode[T any, PT fileWithNames[T]](node *filetree.Node[T], root *filetree.Node[T], files []*T, isFiltering bool) []string {
if file := node.GetFile(); file != nil {
return PT(file).Names()
}
dir := node.GetPath()
if isFiltering {
// Passing the directory would bring back the files that the filter hides,
// so we spell out the ones it leaves.
var paths []string
for _, file := range filesInDir[T, PT](filesInTree(root), dir) {
paths = append(paths, PT(file).Names()...)
}
return paths
}
// The directory covers everything below it, but git only pairs up the two
// ends of a rename if both are in the pathspec, and one end can well be
// outside the directory. Without that end we would get an addition or a
// deletion where the diff has a rename.
var outsidePaths []string
for _, f := range filesInDir[T, PT](files, dir) {
file := PT(f)
if p := file.GetPath(); !isInDir(p, dir) {
outsidePaths = append(outsidePaths, p)
}
if p := file.GetPreviousPath(); p != "" && !isInDir(p, dir) {
outsidePaths = append(outsidePaths, p)
}
}
return dropContainedPaths(append([]string{dir}, collapseToDirs[T, PT](outsidePaths, files, dir)...))
}
// dropContainedPaths removes the paths that another one of them contains, since
// a pathspec that matches a directory matches everything below it anyway.
func dropContainedPaths(paths []string) []string {
return lo.Filter(paths, func(p string, _ int) bool {
return !lo.SomeBy(paths, func(other string) bool {
return other != p && isInDir(p, other)
})
})
}
// collapseToDirs replaces each of the given paths with the highest directory
// that can stand in for it, so that moving a whole directory elsewhere costs a
// single pathspec rather than one per file. There is a limit to how long a
// command line may get, and a commit can move a great many files at once.
func collapseToDirs[T any, PT fileWithNames[T]](paths []string, files []*T, dir string) []string {
if len(paths) == 0 {
return nil
}
// A directory can stand in for the paths under it as long as everything it
// contains ends up in the diff anyway, which is to say as long as all of it
// is in the directory we are diffing too.
canStandIn := make(map[string]bool)
standsIn := func(candidate string) bool {
if result, ok := canStandIn[candidate]; ok {
return result
}
result := lo.EveryBy(files, func(file *T) bool {
return !fileIsInDir[T, PT](file, candidate) || fileIsInDir[T, PT](file, dir)
})
canStandIn[candidate] = result
return result
}
return lo.Uniq(lo.Map(paths, func(p string, _ int) string {
// A directory that can't stand in for the path rules out its parents
// too, since they contain everything it contains. We stop short of the
// repository root: it would leave the command with nothing to say about
// the directory whose diff we are showing.
for candidate := path.Dir(p); candidate != "." && standsIn(candidate); candidate = path.Dir(candidate) {
p = candidate
}
return p
}))
}
func filesInTree[T any](root *filetree.Node[T]) []*T {
files := []*T{}
_ = root.ForEachFile(func(file *T) error {
files = append(files, file)
return nil
})
return files
}
// filesInDir returns the files that the given directory contains, either at
// their current or at their previous path.
func filesInDir[T any, PT fileWithNames[T]](files []*T, dir string) []*T {
return lo.Filter(files, func(file *T, _ int) bool {
return fileIsInDir[T, PT](file, dir)
})
}
func fileIsInDir[T any, PT fileWithNames[T]](f *T, dir string) bool {
file := PT(f)
previousPath := file.GetPreviousPath()
return isInDir(file.GetPath(), dir) || (previousPath != "" && isInDir(previousPath, dir))
}
func isInDir(path string, dir string) bool {
// "." is the root item, which contains every file
return dir == "." || strings.HasPrefix(path, dir+"/")
}

View file

@ -0,0 +1,113 @@
package controllers
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
)
func TestDiffPathsForNode(t *testing.T) {
files := []*models.CommitFile{
{Path: "dir/file1", PreviousPath: "file1", ChangeStatus: "R"},
{Path: "dir/file2-renamed", PreviousPath: "dir/file2", ChangeStatus: "R"},
{Path: "dir/sub/file3", ChangeStatus: "M"},
{Path: "file4", PreviousPath: "dir/sub/file4", ChangeStatus: "R"},
{Path: "file5", ChangeStatus: "M"},
}
scenarios := []struct {
testName string
files []*models.CommitFile // defaults to the files above
selectedPath string
isFiltering bool
expectedPaths []string
}{
{
testName: "file",
selectedPath: "dir/sub/file3",
expectedPaths: []string{"dir/sub/file3"},
},
{
testName: "renamed file",
selectedPath: "dir/file1",
expectedPaths: []string{"dir/file1", "file1"},
},
{
testName: "directory: pass the other end of each rename that crosses its boundary",
selectedPath: "dir",
// dir/file2-renamed was renamed within the directory, so both of its
// paths are covered by it already
expectedPaths: []string{"dir", "file1", "file4"},
},
{
testName: "directory without renames crossing its boundary",
selectedPath: "dir/sub",
expectedPaths: []string{"dir/sub", "file4"},
},
{
testName: "root",
selectedPath: ".",
expectedPaths: []string{"."},
},
{
testName: "a whole directory moved into the selected one collapses to that directory",
files: []*models.CommitFile{
{Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"},
{Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"},
{Path: "dir/c", PreviousPath: "src/nested/c", ChangeStatus: "R"},
{Path: "unrelated", ChangeStatus: "M"},
},
selectedPath: "dir",
expectedPaths: []string{"dir", "src"},
},
{
testName: "a directory that stands in for the selected one as well",
files: []*models.CommitFile{
{Path: "a/b/c", PreviousPath: "a/c", ChangeStatus: "R"},
{Path: "a/b/d", ChangeStatus: "M"},
{Path: "unrelated", ChangeStatus: "M"},
},
selectedPath: "a/b",
expectedPaths: []string{"a"},
},
{
testName: "a directory with changes of its own doesn't collapse",
files: []*models.CommitFile{
{Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"},
{Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"},
{Path: "src/nested/c", ChangeStatus: "M"},
},
selectedPath: "dir",
// src/nested is left out of it, so that only src/a stays behind
expectedPaths: []string{"dir", "src/a", "src/nested/b"},
},
{
testName: "directory while filtering",
selectedPath: "dir",
isFiltering: true,
expectedPaths: []string{
"dir/file1", "file1",
"dir/file2-renamed", "dir/file2",
"dir/sub/file3",
"file4", "dir/sub/file4",
},
},
}
for _, s := range scenarios {
t.Run(s.testName, func(t *testing.T) {
files := lo.Ternary(s.files != nil, s.files, files)
cmp := filetree.NodeSortComparator[models.CommitFile]("mixed", true)
root := filetree.BuildTreeFromCommitFiles(files, true, cmp)
node, found := lo.Find(root.Flatten(filetree.NewCollapsedPaths()), func(node *filetree.Node[models.CommitFile]) bool {
return node.GetPath() == s.selectedPath
})
assert.True(t, found, "no node for path %s", s.selectedPath)
assert.Equal(t, s.expectedPaths, diffPathsForNode(node, root, files, s.isFiltering))
})
}
}

View file

@ -130,10 +130,11 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
OpensMenu: true,
},
{
Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll),
Handler: self.toggleStagedAll,
Description: self.c.Tr.ToggleStagedAll,
Tooltip: self.c.Tr.ToggleStagedAllTooltip,
Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll),
Handler: self.toggleStagedAll,
GetDisabledReason: self.require(self.anyFilesDisplayed),
Description: self.c.Tr.ToggleStagedAll,
Tooltip: self.c.Tr.ToggleStagedAllTooltip,
},
{
Keys: opts.GetKeys(opts.Config.Universal.GoInto),
@ -375,8 +376,8 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges())
mainShowsStaged := !split && node.GetHasStagedChanges()
pathOverrides := self.pathOverridesForDiff(node)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides)
paths := self.pathsForDiff(node)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths)
title := self.c.Tr.UnstagedChanges
if mainShowsStaged {
title = self.c.Tr.StagedChanges
@ -391,7 +392,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) {
}
if split {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths)
title := self.c.Tr.StagedChanges
if mainShowsStaged {
@ -649,19 +650,9 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error {
return nil
}
// pathOverridesForDiff returns file paths to override the node's path in diff
// commands when a text filter is active and the node is a directory. This
// ensures the diff only shows filtered/visible files.
func (self *FilesController) pathOverridesForDiff(node *filetree.FileNode) []string {
if !node.IsFile() && self.context().IsFiltering() {
var paths []string
_ = node.ForEachFile(func(file *models.File) error {
paths = append(paths, file.Path)
return nil
})
return paths
}
return nil
func (self *FilesController) pathsForDiff(node *filetree.FileNode) []string {
return diffPathsForNode(
node.Raw(), self.context().GetRoot().Raw(), self.c.Model().Files, self.context().IsFiltering())
}
// unstageFilteredFiles unstages only the visible (filtered) files from the
@ -970,6 +961,17 @@ func (self *FilesController) openSubmoduleConflictMenu(file *models.File) error
})
}
// The stage-all command acts on the file tree as it is displayed, so there has
// to be something in it. This is also the case before the first files refresh
// has come in, when there is no tree at all yet.
func (self *FilesController) anyFilesDisplayed() *types.DisabledReason {
if self.context().FileTreeViewModel.Len() == 0 {
return &types.DisabledReason{Text: self.c.Tr.NoChangedFiles}
}
return nil
}
func (self *FilesController) toggleStagedAll() error {
if err := self.toggleStagedAllWithLock(); err != nil {
return err

View file

@ -141,7 +141,6 @@ func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error {
}
self.c.Contexts().LocalCommits.SetSelection(index)
self.c.Contexts().LocalCommits.FocusLine(true)
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
return nil
},

View file

@ -188,9 +188,8 @@ func (self *MergeAndRebaseHelper) hasExecTodos(calledFromWorker bool) bool {
}
result := false
_ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
_ = self.c.GocuiGui().OnUIThreadAndWait(func() {
result = check()
return nil
})
return result
}

View file

@ -270,11 +270,6 @@ func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) e
selectCommit()
self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits)
// The list we just selected in has nothing to do with the one
// that was showing, so wherever it was scrolled to says nothing
// about where the selection now is. PostRefreshUpdate leaves the
// scroll position alone, so ask for it separately.
self.c.Contexts().LocalCommits.FocusLine(true)
return nil
},
})

View file

@ -111,6 +111,14 @@ type refreshEnv struct {
// persist its refreshed stat cache.
backgroundRoutine bool
// Whether the views this refresh updates must keep the scroll position they
// have. Focusing a list scrolls its selection into view, which is what a
// user action should do — but a refresh that no user action is behind must
// leave the viewport wherever the user last scrolled it to. That's the case
// for the unattended background routines, and for the refreshes that merely
// reload state (see RefreshOptions.DontBlockRepoSwitch).
keepScrollPosition bool
// the repo generation captured when the refresh started
generation int
@ -220,13 +228,16 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// against the repo it started in, and the generation guard drops its
// writes.
env := refreshEnv{
background: options.Background || options.DontBlockRepoSwitch,
backgroundRoutine: options.Background,
background: options.Background || options.DontBlockRepoSwitch,
backgroundRoutine: options.Background,
keepScrollPosition: options.Background || options.DontBlockRepoSwitch,
}
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
env.generation = self.c.State().GetRepoGeneration()
env.git = self.c.Git()
})
}) {
return
}
if options.BatchUIUpdates {
env.batch = &refreshBounceBatch{}
}
@ -262,6 +273,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// - merge conflicts are part of what the files refresh produces
// - pull requests are fetched for the tracking branches against the
// remotes, so refresh both alongside to fetch against fresh data
// - commits and branches always go together: changing commits changes
// the branches' upstream/downstream counts, and changing branches
// (e.g. checking one out) changes the commits we show. This one comes
// last, so that it also covers the branches the rules above add.
if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) {
scopeSet.Add(types.COMMITS, types.BRANCHES)
}
@ -274,6 +289,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
if scopeSet.Includes(types.PULL_REQUESTS) {
scopeSet.Add(types.BRANCHES, types.REMOTES)
}
if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) {
scopeSet.Add(types.COMMITS, types.BRANCHES)
}
// Capture the refs snapshot now, before we start reading git's state
// below, rather than after. This is important to guard against the race
@ -300,6 +318,23 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
})
}
// The branches view shows worktrees against branches, so a branches render
// that happens before the refreshed worktrees have landed in the model shows
// stale ones, and rendering again once they land makes the view flicker.
// Refresh the worktrees first, then, and let the branches refresh wait for
// them: waitForWorktrees returns once the worktrees model write is queued,
// so the branches write that follows is queued behind it and the view
// renders once, with both.
worktreesWg := sync.WaitGroup{}
waitForWorktrees := func() { worktreesWg.Wait() }
if scopeSet.Includes(types.WORKTREES) {
worktreesWg.Add(1)
refresh("worktrees", func() {
defer worktreesWg.Done()
self.refreshWorktrees(env, scopeSet.Includes(types.BRANCHES))
})
}
branchesAndRemotesWg := sync.WaitGroup{}
// The pull-request fetch (below) needs the just-loaded branches and
// remotes. Their model writes are bounced onto the UI thread, so the
@ -309,32 +344,49 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// branchesAndRemotesWg gives the fetch the happens-before to read them.
var loadedBranches []*models.Branch
var loadedRemotes []*models.Remote
includeWorktreesWithBranches := false
if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) {
// whenever we change commits, we should update branches because the upstream/downstream
// counts can change. Whenever we change branches we should also change commits
// e.g. in the case of switching branches.
// Capture the commits, reflog and branches refresh inputs (model,
// contexts, modes) on the UI thread, before the git work is dispatched
// to a worker, so the workers compute from an immutable snapshot
// instead of reading state the UI thread concurrently mutates.
if scopeSet.Includes(types.COMMITS) {
// Capture the refresh's inputs (model, contexts, modes) on the UI
// thread, before the git work is dispatched to a worker, so the worker
// computes from an immutable snapshot instead of reading state the UI
// thread concurrently mutates. Every scope below does the same.
var capturedCommits capturedCommitState
var capturedReflog capturedReflogState
var capturedBranches capturedBranchState
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedCommits = self.captureCommitsState()
capturedReflog = self.captureReflogState()
capturedBranches = self.captureBranchState()
})
}) {
return
}
refresh("commits and commit files", func() {
self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env)
})
} else if scopeSet.Includes(types.REBASE_COMMITS) {
// the commits refresh above loads the rebase commits as well, so we only
// need this one when the rebase commits are all that was asked for
var rebaseHashPool *utils.StringPool
var rebaseCommits []*models.Commit
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
rebaseHashPool, rebaseCommits = self.captureRebaseCommitState()
}) {
return
}
refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) })
}
if scopeSet.Includes(types.BRANCHES) {
// The reflog is refreshed here rather than in a scope of its own,
// because sorting the branches by recency needs it to be loaded first.
var capturedReflog capturedReflogState
var capturedBranches capturedBranchState
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedReflog = self.captureReflogState()
capturedBranches = self.captureBranchState()
}) {
return
}
includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES)
if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" {
branchesAndRemotesWg.Add(1)
refresh("reflog and branches", func() {
loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env)
loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, waitForWorktrees, options.BranchSelection, options.SelectTopReflogCommit, env)
branchesAndRemotesWg.Done()
})
} else {
@ -343,47 +395,44 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// Not a recency sort, so branches doesn't depend on the reflog
// being fresh; it runs concurrently with the reflog refresh
// below and uses the reflog we captured up front, as it always has.
loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, env)
loadedBranches = self.refreshBranches(capturedBranches, waitForWorktrees, options.BranchSelection, true, capturedReflog.reflogCommits, env)
branchesAndRemotesWg.Done()
})
refresh("reflog", func() {
_, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit)
})
}
} else if scopeSet.Includes(types.REBASE_COMMITS) {
// the above block handles rebase commits so we only need to call this one
// if we've asked specifically for rebase commits and not those other things
var rebaseHashPool *utils.StringPool
var rebaseCommits []*models.Commit
self.captureOnUIThread(calledFromWorker, env.background, func() {
rebaseHashPool, rebaseCommits = self.captureRebaseCommitState()
})
refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) })
}
if scopeSet.Includes(types.SUB_COMMITS) {
var capturedSubCommits capturedSubCommitState
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedSubCommits = self.captureSubCommitState()
})
}) {
return
}
refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) })
}
// reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway
if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) {
var capturedCommitFiles capturedCommitFilesState
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedCommitFiles = self.captureCommitFilesState()
})
}) {
return
}
refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) })
}
fileWg := sync.WaitGroup{}
if scopeSet.Includes(types.FILES) {
var capturedFiles capturedFilesState
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedFiles = self.captureFilesState()
})
}) {
return
}
fileWg.Add(1)
refresh("files", func() {
_ = self.refreshFilesAndSubmodules(capturedFiles, env)
@ -393,9 +442,11 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
if scopeSet.Includes(types.STASH) {
var stashFilterPath string
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
stashFilterPath = self.c.Modes().Filtering.GetPath()
})
}) {
return
}
refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) })
}
@ -408,9 +459,11 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// needs it to keep the remote-branches selection valid, and reading
// the Remotes context off the UI thread races its render.
var prevSelectedRemote *models.Remote
self.captureOnUIThread(calledFromWorker, env.background, func() {
if !self.captureOnUIThread(calledFromWorker, env.background, func() {
prevSelectedRemote = self.c.Contexts().Remotes.GetSelected()
})
}) {
return
}
branchesAndRemotesWg.Add(1)
refresh("remotes", func() {
loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env)
@ -443,10 +496,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
})
}
if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches {
refresh("worktrees", func() { self.refreshWorktrees(env) })
}
if scopeSet.Includes(types.STAGING) {
refresh("staging", func() {
fileWg.Wait()
@ -673,17 +722,19 @@ func (self *RefreshHelper) captureBranchState() capturedBranchState {
}
}
func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch {
func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch {
switch self.c.State().GetRepoState().GetStartupStage() {
case types.INITIAL:
// Return the immediate (non-recency) load's branches; the recency-sorted
// reload below runs on its own worker after we return. Both hold the same
// set of branches, which is all the caller (the PR fetch) needs.
branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, env)
branches := self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, false, capturedReflog.reflogCommits, env)
self.onWorker(env.background, func(_ gocui.Task) error {
reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, false)
self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, env)
// The load above already waited for the worktrees, so this one has
// nothing left to wait for.
self.refreshBranches(capturedBranches, func() {}, types.SelectCheckedOutBranch, true, reflogCommits, env)
self.c.State().GetRepoState().SetStartupStage(types.COMPLETE)
return nil
})
@ -692,7 +743,7 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo
case types.COMPLETE:
reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, selectTopReflogCommit)
return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, env)
return self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, true, reflogCommits, env)
}
return nil
@ -810,9 +861,11 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState,
self.onUIThreadUnlessRepoChanged(env, func() {
var selectionRange *localCommitSelectionRange
var newConflictedCommitIdx *int
if commitSelection == types.KeepCommitSelectionByHash {
selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode()
selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode)
newConflictedCommitIdx = findNewConflictedCommit(self.c.Model().Commits, commits)
}
self.c.Model().BisectInfo = bisectInfo
@ -826,33 +879,23 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState,
self.c.Model().CheckedOutBranch = ""
}
scrollSelectionIntoView := false
switch commitSelection {
case types.SelectHeadCommit:
if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 {
self.c.Contexts().LocalCommits.SetSelection(headCommitIdx)
scrollSelectionIntoView = true
}
case types.KeepCommitSelectionByHash:
if selectionRange != nil {
selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange)
if newConflictedCommitIdx != nil {
self.c.Contexts().LocalCommits.SetSelection(*newConflictedCommitIdx)
} else if selectionRange != nil {
selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(commits, selectionRange)
if found {
self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode)
scrollSelectionIntoView = didMove
}
}
case types.KeepCommitSelectionIndex:
// The caller set the selection index deliberately; leave it untouched.
}
if scrollSelectionIntoView {
// Enqueued from within this bounce so it runs after refreshView's
// render below (which was enqueued first), matching the previous
// ordering where FocusLine ran after the view was re-rendered.
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Contexts().LocalCommits.FocusLine(true)
})
}
})
self.refreshView(self.c.Contexts().LocalCommits, env)
@ -864,8 +907,6 @@ type localCommitSelectionRange struct {
selectedIsTODO bool
rangeStartHash string
rangeStartIsTODO bool
selectedIdx int
rangeStartIdx int
mode traits.RangeSelectMode
}
@ -884,8 +925,6 @@ func captureLocalCommitSelectionRange(
selectedIsTODO: commits[selectedIdx].IsTODO(),
rangeStartHash: commits[rangeStartIdx].Hash(),
rangeStartIsTODO: commits[rangeStartIdx].IsTODO(),
selectedIdx: selectedIdx,
rangeStartIdx: rangeStartIdx,
mode: mode,
}
}
@ -893,17 +932,16 @@ func captureLocalCommitSelectionRange(
func findLocalCommitSelectionRange(
commits []*models.Commit,
selectionRange *localCommitSelectionRange,
) (int, int, bool, bool) {
) (int, int, bool) {
selectedIdx, foundSelected := findCommitByHashPreferringTODOStatus(
commits, selectionRange.selectedHash, selectionRange.selectedIsTODO)
rangeStartIdx, foundRangeStart := findCommitByHashPreferringTODOStatus(
commits, selectionRange.rangeStartHash, selectionRange.rangeStartIsTODO)
if !foundSelected || !foundRangeStart {
return 0, 0, false, false
return 0, 0, false
}
didMove := selectedIdx != selectionRange.selectedIdx || rangeStartIdx != selectionRange.rangeStartIdx
return selectedIdx, rangeStartIdx, didMove, true
return selectedIdx, rangeStartIdx, true
}
// findCommitByHashPreferringTODOStatus finds the commit with the given hash.
@ -934,6 +972,24 @@ func hasRestorableCommitHash(commits []*models.Commit, idx int) bool {
return idx >= 0 && idx < len(commits) && commits[idx].Hash() != ""
}
// Returns the index of the conflicted commit in the new commits slice, if there is one and it has a
// different hash than the one before had (or there wasn't one before). Otherwise returns nil.
func findNewConflictedCommit(previousCommits []*models.Commit, commits []*models.Commit) *int {
previousConflictedCommit, _ := lo.Find(previousCommits, func(commit *models.Commit) bool {
return commit.Status == models.StatusConflicted
})
newConflictedCommit, idx, hasConflict := lo.FindIndexOf(commits, func(commit *models.Commit) bool {
return commit.Status == models.StatusConflicted
})
if hasConflict && (previousConflictedCommit == nil || previousConflictedCommit.Hash() != newConflictedCommit.Hash()) {
return &idx
}
return nil
}
// capturedSubCommitState holds the sub-commits refresh's model/context/mode
// inputs, gathered on the UI thread (see captureSubCommitState) before the git
// work is dispatched to a worker.
@ -1074,7 +1130,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*mode
// self.refreshStatus is called at the end of this because that's when we can
// be sure there is a State.Model.Branches array to pick the current branch from
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch {
loadSeq := self.branchLoadSeq.Add(1)
branches, err := env.git.Loaders.BranchLoader.Load(
@ -1108,10 +1164,9 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
self.c.Log.Error(err)
}
var worktrees []*models.Worktree
if refreshWorktrees {
worktrees = self.loadWorktrees(env)
}
// Render only once the refreshed worktrees are in the model; the branches
// view shows them against the branches (see performRefresh).
waitForWorktrees()
self.onUIThreadUnlessRepoChanged(env, func() {
// Drop this write if a branch load that started later has already applied
@ -1134,11 +1189,6 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
// the branches we just wrote, on the UI thread.
self.rebuildPullRequestsMap()
if refreshWorktrees {
self.c.Model().Worktrees = worktrees
self.refreshView(self.c.Contexts().Worktrees, env)
}
// Setting the selection here, in the same bounce that writes the list,
// keeps it on the UI thread and keeps the list and selection updating in
// the same frame.
@ -1154,10 +1204,8 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
}
}
case types.SelectCheckedOutBranch:
// The checked-out branch is always at the top of the list. Setting
// the selection doesn't scroll the view, so also reset the origin.
// The checked-out branch is always at the top of the list.
self.c.Contexts().Branches.SetSelectedLineIdx(0)
self.c.Contexts().Branches.GetView().SetOriginY(0)
}
// Need to re-render the commits view because the visualization of local
@ -1248,21 +1296,20 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) {
// waiting for a callback that only it can run), and capturing inline also
// guarantees the snapshot reflects the state at the moment Refresh was called,
// before the calling handler regains control and can mutate it.
func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) {
//
// It returns false when fn didn't run because the app is shutting down, in
// which case the caller must abandon the refresh rather than compute from a
// snapshot that was never taken.
func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) bool {
if !calledFromWorker {
fn()
return
return true
}
wrapped := func() error {
fn()
return nil
}
if background {
_ = self.c.GocuiGui().OnUIThreadAndWaitBackground(wrapped)
} else {
_ = self.c.GocuiGui().OnUIThreadAndWait(wrapped)
return self.c.GocuiGui().OnUIThreadAndWaitBackground(fn) == nil
}
return self.c.GocuiGui().OnUIThreadAndWait(fn) == nil
}
// capturedFilesState holds the files refresh's context/model inputs, gathered
@ -1329,12 +1376,9 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
Background: env.backgroundRoutine,
})
conflictFileCount := 0
for _, file := range files {
if file.HasMergeConflicts {
conflictFileCount++
}
}
conflictedPaths := lo.FilterMap(files, func(file *models.File, _ int) (string, bool) {
return file.Path, file.HasMergeConflicts
})
repoState := self.c.State().GetRepoState()
workingTreeState := env.git.Status.WorkingTreeState()
@ -1344,7 +1388,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
repoState.SetMergeOrRebaseStartedInLazygit(false)
}
if workingTreeState.Any() && conflictFileCount == 0 {
if workingTreeState.Any() && len(conflictedPaths) == 0 {
if prevConflictFileCount > 0 && repoState.GetMergeOrRebaseStartedInLazygit() {
// The conflicts of an operation we started have just been resolved
// (e.g. in the user's editor). Offer to continue it. We only do this
@ -1382,24 +1426,61 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
self.onUIThreadUnlessRepoChanged(env, func() {
// only taking over the filter if it hasn't already been set by the user.
if conflictFileCount > 0 && prevConflictFileCount == 0 {
if len(conflictedPaths) > 0 && prevConflictFileCount == 0 {
if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll {
fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted)
self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles
}
} else if conflictFileCount == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
fileTreeViewModel.SetStatusFilter(filetree.DisplayAll)
} else if len(conflictedPaths) == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
fileTreeViewModel.SetStatusFilterPreservingSelection(filetree.DisplayAll)
self.c.Contexts().Files.GetView().Subtitle = ""
}
if fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted {
fileTreeViewModel.RememberConflictedPaths(conflictedPaths)
}
self.c.Model().Submodules = submoduleConfigs
self.c.Model().Files = files
markWorktreeFiles(files, self.c.Model().Worktrees, env.git.RepoPaths.WorktreePath())
fileTreeViewModel.SetTree()
})
return nil
}
// markWorktreeFiles marks the files that are linked worktrees of this repo, so
// that the files view can render them as such. `git status` reports a worktree
// as an untracked directory, i.e. with a trailing slash, which we take off:
// keeping it would build a directory node with a nameless file inside it.
//
// It must run on the UI thread, as it works on the model. Both models it needs
// are written by refreshes of their own, so it is called after either of them
// lands; it reports whether it changed anything.
func markWorktreeFiles(files []*models.File, worktrees []*models.Worktree, worktreePath string) bool {
changed := false
for _, file := range files {
absPath := filepath.Join(worktreePath, file.Path)
isWorktree := lo.SomeBy(worktrees, func(worktree *models.Worktree) bool {
return worktree.Path == absPath
})
if isWorktree != file.IsWorktree {
file.IsWorktree = isWorktree
changed = true
}
if isWorktree {
if trimmed := strings.TrimSuffix(file.Path, "/"); trimmed != file.Path {
file.Path = trimmed
changed = true
}
}
}
return changed
}
// the reflogs panel is the only panel where we cache data, in that we only
// load entries that have been created since we last ran the call. This means
// we need to be more careful with how we use this, and to ensure we're emptying
@ -1449,11 +1530,9 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en
self.c.Model().ReflogCommits = reflogCommits
self.c.Model().FilteredReflogCommits = filteredReflogCommits
// Setting the selection here, in the same bounce that writes the list,
// keeps it on the UI thread and atomic with the list update. Setting the
// selection doesn't scroll the view, so also reset the origin.
// keeps it on the UI thread and atomic with the list update.
if selectTopEntry {
self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0)
self.c.Contexts().ReflogCommits.GetView().SetOriginY(0)
}
})
@ -1503,16 +1582,27 @@ func (self *RefreshHelper) loadWorktrees(env refreshEnv) []*models.Worktree {
return worktrees
}
func (self *RefreshHelper) refreshWorktrees(env refreshEnv) {
func (self *RefreshHelper) refreshWorktrees(env refreshEnv, branchesAreRefreshing bool) {
worktrees := self.loadWorktrees(env)
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().Worktrees = worktrees
// A worktree inside our working tree is one of the files, so the files
// view has to be told about the ones we just loaded (see
// markWorktreeFiles). Rebuild the tree because a file's path can change.
if markWorktreeFiles(self.c.Model().Files, worktrees, env.git.RepoPaths.WorktreePath()) {
self.c.Contexts().Files.FileTreeViewModel.SetTree()
self.refreshView(self.c.Contexts().Files, env)
}
})
// need to refresh branches because the branches view shows worktrees against
// branches
self.refreshView(self.c.Contexts().Branches, env)
// The branches view shows worktrees against branches, so it needs to be
// rendered again as well. When the branches are being refreshed too, they
// render after waiting for the write above, so leave it to them.
if !branchesAreRefreshing {
self.refreshView(self.c.Contexts().Branches, env)
}
self.refreshView(self.c.Contexts().Worktrees, env)
}
@ -1580,7 +1670,11 @@ func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) {
// the filtered list model is up to date for rendering.
self.searchHelper.ReApplyFilter(context)
self.c.PostRefreshUpdate(context)
if env.keepScrollPosition {
self.c.PostRefreshUpdateKeepingScrollPosition(context)
} else {
self.c.PostRefreshUpdate(context)
}
self.c.AfterLayout(func() error {
// Re-applying the search must be done after re-rendering the view though,
@ -1756,7 +1850,10 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra
// the branches and remotes as they are on the UI thread, after their
// own refreshes' bounces have applied.
self.rebuildPullRequestsMap()
self.c.PostRefreshUpdate(self.c.Contexts().Branches)
// This lands whenever the network call happens to return, and only
// changes how the branches are rendered, not which one is selected, so
// it has no business moving the viewport.
self.c.PostRefreshUpdateKeepingScrollPosition(self.c.Contexts().Branches)
})
}

View file

@ -1,6 +1,7 @@
package helpers
import (
"path/filepath"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
@ -28,8 +29,6 @@ func TestCaptureLocalCommitSelectionRange(t *testing.T) {
expected: &localCommitSelectionRange{
selectedHash: "b",
rangeStartHash: "a",
selectedIdx: 1,
rangeStartIdx: 0,
mode: traits.RangeSelectModeSticky,
},
},
@ -74,15 +73,12 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
type expectation struct {
selectedIdx int
rangeStartIdx int
moved bool
found bool
}
selectionRange := localCommitSelectionRange{
selectedHash: "b",
rangeStartHash: "c",
selectedIdx: 1,
rangeStartIdx: 2,
mode: traits.RangeSelectModeSticky,
}
@ -97,7 +93,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
expected: expectation{
selectedIdx: 2,
rangeStartIdx: 3,
moved: true,
found: true,
},
},
@ -126,7 +121,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
expected: expectation{
selectedIdx: 2,
rangeStartIdx: 3,
moved: true,
found: true,
},
},
@ -139,7 +133,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
expected: expectation{
selectedIdx: 0,
rangeStartIdx: 1,
moved: true,
found: true,
},
},
@ -147,11 +140,10 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
selectedIdx, rangeStartIdx, moved, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange)
selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange)
actual := expectation{
selectedIdx: selectedIdx,
rangeStartIdx: rangeStartIdx,
moved: moved,
found: found,
}
@ -160,6 +152,62 @@ func TestFindLocalCommitSelectionRange(t *testing.T) {
}
}
func TestFindNewConflictedCommit(t *testing.T) {
testCases := []struct {
name string
previousCommits []*models.Commit
commits []*models.Commit
expectedIdx *int
}{
{
name: "finds a newly conflicted commit",
previousCommits: makeCommits("a", "b"),
commits: []*models.Commit{
makeCommits("a")[0],
makeConflictedCommit("b"),
},
expectedIdx: lo.ToPtr(1),
},
{
name: "finds a different conflicted commit",
previousCommits: []*models.Commit{
makeConflictedCommit("a"),
},
commits: []*models.Commit{
makeConflictedCommit("b"),
},
expectedIdx: lo.ToPtr(0),
},
{
name: "ignores the same conflicted commit",
previousCommits: []*models.Commit{
makeConflictedCommit("a"),
},
commits: []*models.Commit{
makeConflictedCommit("a"),
},
expectedIdx: nil,
},
{
name: "reports not found when there is no conflict",
previousCommits: makeCommits("a"),
commits: makeCommits("a", "b"),
expectedIdx: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
idx := findNewConflictedCommit(testCase.previousCommits, testCase.commits)
assert.Equal(t, testCase.expectedIdx != nil, idx != nil)
if idx != nil {
assert.Equal(t, *testCase.expectedIdx, *idx)
}
})
}
}
func TestGetGithubBaseRemote(t *testing.T) {
cases := []struct {
name string
@ -252,6 +300,46 @@ func TestGetAuthenticatedGithubRemotes(t *testing.T) {
}, callsByHost)
}
func TestMarkWorktreeFiles(t *testing.T) {
worktreePath := filepath.Join("/", "path", "to", "repo")
worktrees := []*models.Worktree{
{Path: worktreePath},
{Path: filepath.Join(worktreePath, "worktree1")},
{Path: filepath.Join(worktreePath, "dir", "worktree2")},
{Path: filepath.Join("/", "path", "to", "worktree3")},
}
t.Run("marks the files that are worktrees, and takes their slash off", func(t *testing.T) {
files := []*models.File{
{Path: "file"},
{Path: "worktree1/"},
{Path: "dir/worktree2/"},
{Path: "dir/"},
}
assert.True(t, markWorktreeFiles(files, worktrees, worktreePath))
assert.Equal(t, []*models.File{
{Path: "file"},
{Path: "worktree1", IsWorktree: true},
{Path: "dir/worktree2", IsWorktree: true},
{Path: "dir/"},
}, files)
})
t.Run("reports no change when there is nothing to mark", func(t *testing.T) {
files := []*models.File{{Path: "file"}, {Path: "dir/"}}
assert.False(t, markWorktreeFiles(files, worktrees, worktreePath))
})
t.Run("unmarks a file whose worktree is gone", func(t *testing.T) {
files := []*models.File{{Path: "worktree1", IsWorktree: true}}
assert.True(t, markWorktreeFiles(files, nil, worktreePath))
assert.Equal(t, []*models.File{{Path: "worktree1"}}, files)
})
}
func makeGithubRemoteInfoList(names ...string) []githubRemoteInfo {
return lo.Map(names, func(name string, _ int) githubRemoteInfo {
return makeGithubRemoteInfo(name, name)
@ -288,3 +376,7 @@ func makeTodoCommit(action todo.TodoCommand) *models.Commit {
func makeTodoCommitWithHash(hash string, action todo.TodoCommand) *models.Commit {
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Action: action})
}
func makeConflictedCommit(hash string) *models.Commit {
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Status: models.StatusConflicted})
}

View file

@ -225,7 +225,6 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) {
switch context := state.Context.(type) {
case types.IFilterableContext:
context.SetSelection(0)
context.GetView().SetOriginY(0)
context.SetFilter(searchString, self.c.UserConfig().Gui.UseFuzzySearch())
self.c.PostRefreshUpdate(context)
case types.ISearchableContext:
@ -241,6 +240,9 @@ func (self *SearchHelper) ReApplyFilter(context types.Context) {
state := self.searchState()
if context == state.Context && self.c.Context().Current().GetKey() == self.c.Contexts().Search.GetKey() {
filterableContext.SetSelection(0)
// This runs as part of a refresh, and a refresh that no user action
// is behind keeps the scroll position, which would leave the view
// scrolled somewhere the filtered list no longer has anything at.
filterableContext.GetView().SetOriginY(0)
}
filterableContext.ReApplyFilter(self.c.UserConfig().Gui.UseFuzzySearch())

View file

@ -66,7 +66,6 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error {
subCommitsContext.GetView().TitlePrefix = opts.Context.GetView().TitlePrefix
self.c.PostRefreshUpdate(self.c.Contexts().SubCommits)
subCommitsContext.FocusLine(true)
self.c.Context().Push(self.c.Contexts().SubCommits, types.OnFocusOpts{})
return nil

View file

@ -136,7 +136,7 @@ func (self *ListController) handleLineChangeAux(f func(int), change int) error {
self.context.SetNeedRerenderVisibleLines()
}
self.context.HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
self.context.HandleFocus(types.OnFocusOpts{})
} else {
// If the selection did not change (because, for example, we are at the top of the list and
// press up), we still want to ensure that the selection is visible. This is useful after
@ -205,9 +205,10 @@ func (self *ListController) handlePageChange(delta int) error {
// must tell it explicitly to rerender.
self.context.SetNeedRerenderVisibleLines()
// Since we are maintaining the scroll position ourselves above, there's no point in passing
// ScrollSelectionIntoView=true here.
self.context.HandleFocus(types.OnFocusOpts{})
// This function scrolls the view itself, keeping the selection at the edge of
// the viewport rather than in its middle, so the scroll position is ours to
// maintain, not the focus mechanism's.
self.context.HandleFocus(types.OnFocusOpts{KeepScrollPosition: true})
return nil
}
@ -280,7 +281,10 @@ func (self *ListController) selectRangeThroughViewIndex(viewIndex int) {
newSelectedLineIdx := self.context.ViewIndexToModelIndex(viewIndex)
list.ExpandNonStickyRange(newSelectedLineIdx - list.GetSelectedLineIdx())
self.context.HandleFocus(types.OnFocusOpts{})
// The pointer can be outside the viewport, in which case so is the end of
// the range; the drag autoscroller takes care of following it, one line at a
// time, for as long as the pointer stays there.
self.context.HandleFocus(types.OnFocusOpts{KeepScrollPosition: true})
}
func (self *ListController) handleDragAutoscroll(viewIndex int) bool {

View file

@ -1171,7 +1171,7 @@ func (self *LocalCommitsController) move(
return err
}
self.context().MoveSelection(offset)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
self.context().HandleFocus(types.OnFocusOpts{})
// Block input until the refresh has landed: a quick second press must
// read the moved todo from the refreshed model, not grab whatever the
@ -1204,7 +1204,7 @@ func (self *LocalCommitsController) move(
Then: func() error {
if err == nil {
self.context().MoveSelection(offset)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
self.context().HandleFocus(types.OnFocusOpts{})
}
if onComplete != nil {
return onComplete()

View file

@ -230,9 +230,8 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error {
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
// Escape pops the patch-building context, so run it on the UI thread
// before the refresh below.
_ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
_ = self.c.GocuiGui().OnUIThreadAndWait(func() {
self.c.Helpers().PatchBuilding.Escape()
return nil
})
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{})

View file

@ -251,7 +251,6 @@ func (self *StashController) handleRenameStashEntry(stashEntry *models.StashEntr
return err
}
self.context().SetSelection(0) // Select the renamed stash
self.context().FocusLine(true)
// Renaming re-creates the stash at the top, shifting the other
// entries' indices; block input so that a quick next action sees
// the refreshed list rather than the stale indices.

View file

@ -123,7 +123,7 @@ func (self *SubmodulesController) GetOnRenderToMain() func() {
if file == nil {
task = types.NewRenderStringTask(prefix)
} else {
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, nil)
cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, file.Names())
task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix)
}
}

View file

@ -3,6 +3,7 @@ package filetree
import (
"fmt"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/gui/types"
@ -42,6 +43,7 @@ type IFileTree interface {
FilterFiles(test func(*models.File) bool) []*models.File
SetStatusFilter(filter FileTreeDisplayFilter)
RememberConflictedPaths(paths []string)
ForceShowUntracked() bool
Get(index int) *FileNode
GetFile(path string) *models.File
@ -54,25 +56,31 @@ type IFileTree interface {
}
type FileTree struct {
getFiles func() []*models.File
tree *Node[models.File]
showTree bool
common *common.Common
filter FileTreeDisplayFilter
collapsedPaths *CollapsedPaths
textFilter string
useFuzzySearch bool
getFiles func() []*models.File
tree *Node[models.File]
showTree bool
common *common.Common
filter FileTreeDisplayFilter
// Paths of the files that had conflicts while the current filter has been
// active. The DisplayConflicted filter keeps showing them after their
// conflicts have been resolved, so that their diffs can be reviewed while
// the remaining files are still being worked on.
conflictedPaths *set.Set[string]
collapsedPaths *CollapsedPaths
textFilter string
useFuzzySearch bool
}
var _ IFileTree = &FileTree{}
func NewFileTree(getFiles func() []*models.File, common *common.Common, showTree bool) *FileTree {
return &FileTree{
getFiles: getFiles,
common: common,
showTree: showTree,
filter: DisplayAll,
collapsedPaths: NewCollapsedPaths(),
getFiles: getFiles,
common: common,
showTree: showTree,
filter: DisplayAll,
conflictedPaths: set.New[string](),
collapsedPaths: NewCollapsedPaths(),
}
}
@ -100,7 +108,9 @@ func (self *FileTree) getFilesForDisplay() []*models.File {
case DisplayUntracked:
files = self.FilterFiles(func(file *models.File) bool { return !(file.Tracked || file.HasStagedChanges) })
case DisplayConflicted:
files = self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts })
files = self.FilterFiles(func(file *models.File) bool {
return file.HasMergeConflicts || self.conflictedPaths.Includes(file.Path)
})
default:
panic(fmt.Sprintf("Unexpected files display filter: %d", self.filter))
}
@ -122,9 +132,16 @@ func (self *FileTree) FilterFiles(test func(*models.File) bool) []*models.File {
func (self *FileTree) SetStatusFilter(filter FileTreeDisplayFilter) {
self.filter = filter
self.conflictedPaths = set.New[string]()
self.SetTree()
}
// RememberConflictedPaths records which files have conflicts right now, so that
// the DisplayConflicted filter keeps showing them once they are resolved.
func (self *FileTree) RememberConflictedPaths(paths []string) {
self.conflictedPaths.Add(paths...)
}
func (self *FileTree) ToggleShowTree() {
self.showTree = !self.showTree
self.SetTree()

View file

@ -4,6 +4,7 @@ import (
"fmt"
"testing"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
@ -12,10 +13,11 @@ import (
func TestFilterAction(t *testing.T) {
scenarios := []struct {
name string
filter FileTreeDisplayFilter
files []*models.File
expected []*models.File
name string
filter FileTreeDisplayFilter
conflictedPaths []string
files []*models.File
expected []*models.File
}{
{
name: "filter files with unstaged changes",
@ -84,11 +86,29 @@ func TestFilterAction(t *testing.T) {
{Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true},
},
},
{
name: "keep showing conflicted files whose conflicts have been resolved",
filter: DisplayConflicted,
conflictedPaths: []string{"dir2/dir2/file4", "file1"},
files: []*models.File{
{Path: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
{Path: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
{Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true},
},
expected: []*models.File{
{Path: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true},
{Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true},
},
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
mngr := &FileTree{getFiles: func() []*models.File { return s.files }, filter: s.filter}
mngr := &FileTree{
getFiles: func() []*models.File { return s.files },
filter: s.filter,
conflictedPaths: set.NewFromSlice(s.conflictedPaths),
}
result := mngr.getFilesForDisplay()
assert.EqualValues(t, s.expected, result)
})

View file

@ -167,6 +167,31 @@ func (self *FileTreeViewModel) SetStatusFilter(filter FileTreeDisplayFilter) {
self.IListCursor.SetSelection(0)
}
func (self *FileTreeViewModel) SetStatusFilterPreservingSelection(filter FileTreeDisplayFilter) {
self.preserveSelection(func() {
self.SetStatusFilter(filter)
})
}
func (self *FileTreeViewModel) preserveSelection(f func()) {
selectedNode := self.GetSelected()
var selectedPath string
if selectedNode != nil {
selectedPath = selectedNode.GetInternalPath()
}
f()
if selectedPath != "" {
self.ExpandToPath(selectedPath)
if idx, found := self.GetIndexForPath(selectedPath); found {
self.SetSelection(idx)
return
}
}
self.ClampSelection()
}
// If we're going from flat to tree we want to select the same file.
// If we're going from tree to flat and we have a file selected we want to select that.
// If instead we've selected a directory we need to select the first file in that directory.
@ -233,22 +258,9 @@ func (self *FileTreeViewModel) GetFilter() string {
}
func (self *FileTreeViewModel) ClearFilter() {
selectedNode := self.GetSelected()
var selectedPath string
if selectedNode != nil {
selectedPath = selectedNode.GetInternalPath()
}
self.IFileTree.SetTextFilter("", false)
if selectedPath != "" {
self.ExpandToPath(selectedPath)
if idx, found := self.GetIndexForPath(selectedPath); found {
self.SetSelection(idx)
return
}
}
self.ClampSelection()
self.preserveSelection(func() {
self.IFileTree.SetTextFilter("", false)
})
}
func (self *FileTreeViewModel) ReApplyFilter(useFuzzySearch bool) {

View file

@ -0,0 +1,32 @@
package filetree
import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/stretchr/testify/assert"
)
func TestSetStatusFilterPreservingSelection(t *testing.T) {
files := []*models.File{
{Path: "file1"},
{Path: "file2", HasMergeConflicts: true},
{Path: "file3", HasMergeConflicts: true},
}
viewModel := NewFileTreeViewModel(
func() []*models.File { return files },
common.NewDummyCommon(),
false,
)
viewModel.SetTree()
viewModel.SetStatusFilter(DisplayConflicted)
viewModel.SetSelection(viewModel.Len() - 2)
viewModel.ToggleStickyRange()
viewModel.MoveSelectedLine(1)
viewModel.SetStatusFilterPreservingSelection(DisplayAll)
assert.Equal(t, "file3", viewModel.GetSelectedPath())
assert.False(t, viewModel.IsSelectingRange())
}

View file

@ -39,7 +39,11 @@ func (self *guiCommon) RefreshFromWorker(opts types.RefreshOptions) {
}
func (self *guiCommon) PostRefreshUpdate(context types.Context) {
self.gui.postRefreshUpdate(context)
self.gui.postRefreshUpdate(context, false)
}
func (self *guiCommon) PostRefreshUpdateKeepingScrollPosition(context types.Context) {
self.gui.postRefreshUpdate(context, true)
}
func (self *guiCommon) RunSubprocessAndRefresh(cmdObj *oscommands.CmdObj) error {

View file

@ -69,6 +69,10 @@ func (self *GuiDriver) MouseMove(x, y int) {
self.replayMouseEvent(x, y, tcell.ButtonPrimary)
}
func (self *GuiDriver) ScrollWheelDown(x, y int) {
self.replayMouseEvent(x, y, tcell.WheelDown)
}
func (self *GuiDriver) MouseRelease(x, y int) {
self.replayMouseEvent(x, y, tcell.ButtonNone)
}
@ -82,7 +86,7 @@ func (self *GuiDriver) WaitUntilIdle() {
}
func (self *GuiDriver) OnUIThreadAndWait(f func()) {
_ = self.gui.g.OnUIThreadAndWait(func() error { f(); return nil })
_ = self.gui.g.OnUIThreadAndWait(f)
}
func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) {
@ -97,14 +101,25 @@ func (self *GuiDriver) replayMouseEventWithoutWaiting(x, y int, buttons tcell.Bu
))
}
// FocusIn simulates the terminal window regaining focus, which is how lazygit
// learns to reload changed config files. Tests use it to exercise the live
// config-reload path.
func (self *GuiDriver) FocusIn() {
// replayFocusIn takes the focus away before handing it back, because that's the
// only way a terminal can report regaining it, and lazygit only reacts to focus
// reports that change the focus (see gocui.Gui.IsFocused).
func (self *GuiDriver) replayFocusIn() {
self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper(
tcell.NewEventFocus(false),
0,
))
self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper(
tcell.NewEventFocus(true),
0,
))
}
// FocusIn simulates the terminal window regaining focus, which is how lazygit
// learns to reload changed config files. Tests use it to exercise the live
// config-reload path.
func (self *GuiDriver) FocusIn() {
self.replayFocusIn()
self.waitTillIdle()
}
@ -112,10 +127,7 @@ func (self *GuiDriver) FocusIn() {
func (self *GuiDriver) FocusInAndClick(x, y int) {
self.CheckAllToastsAcknowledged()
self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper(
tcell.NewEventFocus(true),
0,
))
self.replayFocusIn()
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0),
0,
@ -128,6 +140,16 @@ func (self *GuiDriver) FocusInAndClick(x, y int) {
self.waitTillIdle()
}
// RefreshInBackground performs the refresh that the background routines perform
// on a timer (see BackgroundRoutineMgr). Tests drive it directly rather than
// turning those routines on, so that they neither wait for a timer nor depend on
// one firing at a particular moment.
func (self *GuiDriver) RefreshInBackground() {
self.gui.c.RefreshFromWorker(types.RefreshOptions{Background: true})
self.waitTillIdle()
}
func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() {
self.gui.onUIThread(func() error {
self.gui.State.SetMergeOrRebaseStartedInLazygit(true)

View file

@ -295,8 +295,9 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin
},
}
mouseKeybindings := []*gocui.ViewMouseBinding{}
for _, c := range gui.State.Contexts.Flatten() {
contexts := gui.State.Contexts.Flatten()
mouseKeybindings := make([]*gocui.ViewMouseBinding, 0, len(contexts))
for _, c := range contexts {
viewName := c.GetViewName()
for _, binding := range c.GetKeybindings(opts) {
// TODO: move all mouse keybindings into the mouse keybindings approach below

View file

@ -88,7 +88,13 @@ func (gui *Gui) layout(g *gocui.Gui) error {
if !view.CanScrollPastBottom {
maxOriginY -= newHeight - 1
}
if oldOriginY := view.OriginY(); oldOriginY > maxOriginY {
// Don't scroll up while the view's content is still being loaded: its
// height only reflects what has been read so far, so clamping to it now
// would yank the view to the top even though more content is on the way
// (e.g. when re-rendering a diff the user was scrolled into).
manager := gui.getViewBufferManagerForView(view)
stillLoading := manager != nil && manager.IsLoading()
if oldOriginY := view.OriginY(); oldOriginY > maxOriginY && !stillLoading {
view.ScrollUp(oldOriginY - maxOriginY)
// the view might not have scrolled actually (if it was at the limit
// already), so we need to check if it did

View file

@ -107,16 +107,6 @@ func (gui *Gui) allMainContextPairs() []types.MainContextPair {
}
func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
// need to reset scroll positions of all other main views
for _, pair := range gui.allMainContextPairs() {
if pair.Main != opts.Pair.Main {
pair.Main.GetView().SetOrigin(0, 0)
}
if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary {
pair.Secondary.GetView().SetOrigin(0, 0)
}
}
gui.moveMainContextPairToTop(opts.Pair)
if opts.Main != nil {
@ -129,6 +119,20 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) {
opts.Pair.Secondary.GetView().Clear()
}
// Reset the scroll positions of all the other main views. We do this after
// moving this pair to the top (which copies the previously-shown view's
// content into the now-visible one to avoid a blank frame): resetting first
// would zero that source view's scroll before it gets copied, forcing the
// placeholder to the top instead of leaving it where the screen already was.
for _, pair := range gui.allMainContextPairs() {
if pair.Main != opts.Pair.Main {
pair.Main.GetView().SetOrigin(0, 0)
}
if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary {
pair.Secondary.GetView().SetOrigin(0, 0)
}
}
gui.splitMainPanel(opts.Secondary != nil)
}

View file

@ -72,8 +72,6 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error {
gui.State.Contexts.Menu.SetOnCancel(opts.OnCancel)
gui.State.Contexts.Menu.SetSelection(0)
gui.Views.Menu.SetOriginY(0)
gui.Views.Menu.Title = opts.Title
gui.Views.Menu.FgColor = theme.GocuiDefaultTextColor

View file

@ -72,6 +72,16 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
cmd.Args = withPtyGitConfig(cmd.Args, runtime.GOOS)
// Mark the view as loading synchronously now, before the layout pass: the
// actual task is created in afterLayout (below), which runs after layout, so
// without this the next layout pass would clamp the scroll position to the
// not-yet-loaded content.
gui.getManager(view).StartLoading()
// Hold the scrollbar at its current height while the re-render loads, so the
// thumb doesn't shrink and snap back when the first partial paint swaps in
// (see the matching call in newCmdTask).
view.FreezeScrollbarHeight()
// Run the pty after layout so that it gets the correct size
gui.afterLayout(func() error {
// Need to get the width and the pager command again because the layout might have

View file

@ -18,6 +18,15 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
).Debug("RunCommand")
manager := gui.getManager(view)
// Mark the view as loading synchronously (before the task's goroutine runs
// and before the next layout pass) so the layout doesn't clamp the scroll
// position to the not-yet-loaded content.
manager.StartLoading()
// Hold the scrollbar at the height the view has now (the previous render),
// while it still shows that render: once the re-render swaps in its first
// partial paint the displayed buffer is briefly short, and we don't want the
// thumb to shrink and snap back as the rest loads.
view.FreezeScrollbarHeight()
// Snapshot the view width here, on the UI thread, so the task goroutine
// doesn't read the view's live dimensions while it streams output. It's
@ -80,9 +89,8 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
manager := gui.getManager(view)
f := func(tasks.TaskOpts) error {
return gui.g.OnUIThreadAndWaitBackground(func() error {
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.SetViewContent(view, str)
return nil
})
}
@ -97,10 +105,9 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in
manager := gui.getManager(view)
f := func(tasks.TaskOpts) error {
return gui.g.OnUIThreadAndWaitBackground(func() error {
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.SetViewContent(view, str)
view.SetOrigin(originX, originY)
return nil
})
}
@ -115,10 +122,9 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
manager := gui.getManager(view)
f := func(tasks.TaskOpts) error {
return gui.g.OnUIThreadAndWaitBackground(func() error {
return gui.g.OnUIThreadAndWaitBackground(func() {
gui.c.ResetViewOrigin(view)
gui.c.SetViewContent(view, str)
return nil
})
}
@ -136,12 +142,10 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
gui.Log,
view,
func() {
// we could clear here, but that actually has the effect of causing a flicker
// where the view may contain no content momentarily as the gui refreshes.
// Instead, we're rewinding the write pointer so that we will just start
// overwriting the existing content from the top down. Once we've reached
// the end of the content do display, we call view.FlushStaleCells() to
// clear out the remaining content from the previous render.
// Called before showing the "loading..." indicator: clear the
// displayed buffer so only "loading..." is shown. The actual content
// is rendered off-screen (beginRender below) and swapped in, so it
// never overwrites the displayed buffer incrementally.
view.Reset()
},
func() {
@ -153,6 +157,11 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
gui.renderContentOnly()
},
func() {
// The content is fully loaded now, so let the scrollbar track it
// directly again (it was held at the previous render's height while
// loading, see FreezeScrollbarHeight).
view.UnfreezeScrollbarHeight()
// Need to check if the content of the view is well past the origin.
linesHeight := view.ViewLinesHeight()
_, originY := view.Origin()
@ -161,12 +170,12 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
view.SetOrigin(0, newOriginY)
}
view.FlushStaleCells()
},
func() {
view.SetOrigin(0, 0)
},
view.BeginOffscreenRender,
view.SwapInOffscreenRender,
func() gocui.Task {
// A background task: rendering content into a view is display
// work, not lazygit driving a git operation, so it must not

View file

@ -48,8 +48,13 @@ type IGuiCommon interface {
RefreshFromWorker(RefreshOptions)
// we call this when we've changed something in the view model but not the actual model,
// e.g. expanding or collapsing a folder in a file view. Calling 'Refresh' in this
// case would be overkill, although refresh will internally call 'PostRefreshUpdate'
// case would be overkill, although refresh will internally call 'PostRefreshUpdate'.
// It re-focuses the context's selection, which scrolls it into view.
PostRefreshUpdate(Context)
// Like PostRefreshUpdate, but leaves the view scrolled where it is. For
// refreshes that no user action is behind: those must not move the viewport
// away from wherever the user last put it.
PostRefreshUpdateKeepingScrollPosition(Context)
// renders string to a view without resetting its origin
SetViewContent(view *gocui.View, content string)

View file

@ -227,9 +227,13 @@ type IViewTrait interface {
}
type OnFocusOpts struct {
ClickedWindowName string
ClickedViewLineIdx int
ScrollSelectionIntoView bool
ClickedWindowName string
ClickedViewLineIdx int
// Focusing a list context scrolls its selection into view. Set this to leave
// the view's scroll position alone instead; only for callers that maintain
// it themselves, e.g. by keeping the selection at the edge of the viewport.
KeepScrollPosition bool
}
type OnFocusLostOpts struct {

View file

@ -132,7 +132,7 @@ func (gui *Gui) renderContentOnly() {
// postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed
// if the context's view is set to another context we do nothing.
// if the context's view is the current view we trigger a focus; re-selecting the current item.
func (gui *Gui) postRefreshUpdate(c types.Context) {
func (gui *Gui) postRefreshUpdate(c types.Context, keepScrollPosition bool) {
t := time.Now()
defer func() {
gui.Log.Infof("postRefreshUpdate for %s took %s", c.GetKey(), time.Since(t))
@ -141,14 +141,14 @@ func (gui *Gui) postRefreshUpdate(c types.Context) {
c.HandleRender()
if gui.currentViewName() == c.GetViewName() {
c.HandleFocus(types.OnFocusOpts{})
c.HandleFocus(types.OnFocusOpts{KeepScrollPosition: keepScrollPosition})
} else {
// The FocusLine call is included in the HandleFocus method which we
// call for focused views above; but we need to call it here for
// non-focused views to ensure that an inactive selection is painted
// correctly, and that integration tests see the up to date selection
// state.
c.FocusLine(false)
c.FocusLine(!keepScrollPosition)
currentCtx := gui.State.ContextMgr.Current()
if currentCtx.GetKey() == context.NORMAL_MAIN_CONTEXT_KEY || currentCtx.GetKey() == context.NORMAL_SECONDARY_CONTEXT_KEY {

View file

@ -3,6 +3,8 @@ package components
import (
"fmt"
"os"
"github.com/samber/lo"
)
const (
@ -43,11 +45,9 @@ var hostEnvironmentAllowlist = [...]string{
// Returns a copy of the environment filtered by
// hostEnvironmentAllowlist
func allowedHostEnvironment() []string {
env := []string{}
for _, envVar := range hostEnvironmentAllowlist {
env = append(env, fmt.Sprintf("%s=%s", envVar, os.Getenv(envVar)))
}
return env
return lo.Map(hostEnvironmentAllowlist[:], func(envVar string, _ int) string {
return fmt.Sprintf("%s=%s", envVar, os.Getenv(envVar))
})
}
func NewTestEnvironment(rootDir string) []string {

View file

@ -256,14 +256,15 @@ func getLazygitCommand(
return nil, err
}
cmdArgs := []string{tempLazygitPath(), "-debug", "--use-config-dir=" + paths.Config()}
resolvedExtraArgs := lo.Map(test.ExtraCmdArgs(), func(arg string, _ int) string {
return utils.ResolvePlaceholderString(arg, map[string]string{
"actualPath": paths.Actual(),
"actualRepoPath": paths.ActualRepo(),
})
})
cmdArgs := make([]string, 0, 3+len(resolvedExtraArgs))
cmdArgs = append(cmdArgs, tempLazygitPath(), "-debug", "--use-config-dir="+paths.Config())
cmdArgs = append(cmdArgs, resolvedExtraArgs...)
// Use a limited environment for test isolation, including pass through

View file

@ -78,6 +78,12 @@ func (self *TestDriver) repeatMouseMove() {
self.mouseMove(self.mouseX, self.mouseY)
}
func (self *TestDriver) scrollWheelDown(x, y int) {
self.SetCaption(fmt.Sprintf("Scrolling down at %d, %d", x, y))
self.gui.ScrollWheelDown(x, y)
self.Wait(self.inputDelay)
}
func (self *TestDriver) mouseRelease() {
self.SetCaption(fmt.Sprintf("Releasing mouse at %d, %d", self.mouseX, self.mouseY))
self.gui.MouseRelease(self.mouseX, self.mouseY)
@ -136,6 +142,15 @@ func (self *TestDriver) Log(message string) {
self.gui.LogUI(message)
}
// RefreshInBackground performs the refresh that lazygit's background routines
// perform on a timer, e.g. to pick up changes made by RunCommand. Tests use this
// rather than turning those routines on and waiting for them.
func (self *TestDriver) RefreshInBackground() {
self.SetCaption("Refreshing in the background")
self.gui.RefreshInBackground()
self.Wait(self.inputDelay)
}
// allows the user to run shell commands during the test to emulate background activity
func (self *TestDriver) Shell() *Shell {
return self.shell

View file

@ -56,6 +56,12 @@ func (self *fakeGuiDriver) MouseRelease(x, y int) {
self.releasedCoordinates = append(self.releasedCoordinates, coordinate{x: x, y: y})
}
func (self *fakeGuiDriver) ScrollWheelDown(x, y int) {
}
func (self *fakeGuiDriver) RefreshInBackground() {
}
func (self *fakeGuiDriver) OnUIThreadAndWait(f func()) {
f()
}

View file

@ -355,6 +355,31 @@ func (self *ViewDriver) SelectedLineIdxAtLeast(expected int) *ViewDriver {
return self
}
// asserts on the scroll position of the view, i.e. the index of the line that
// is shown at the top of the view.
func (self *ViewDriver) OriginY(expected int) *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
actual := self.getView().OriginY()
return expected == actual, fmt.Sprintf("%s: Expected origin Y to be %d, got %d", self.context, expected, actual)
})
return self
}
// asserts that the selected line is inside the visible area of the view
func (self *ViewDriver) SelectedLineIsVisible() *ViewDriver {
self.t.assertWithRetries(func() (bool, string) {
view := self.getView()
firstVisible, lastVisible := view.OriginY(), view.OriginY()+view.InnerHeight()-1
actual := view.SelectedLineIdx()
return actual >= firstVisible && actual <= lastVisible,
fmt.Sprintf("%s: Expected the selected line (%d) to be visible, but only lines %d to %d are",
self.context, actual, firstVisible, lastVisible)
})
return self
}
func (self *ViewDriver) OriginYAtLeast(expected int) *ViewDriver {
self.t.assertEventually(func() (bool, string) {
var actual int
@ -533,6 +558,15 @@ func (self *ViewDriver) MouseMoveToBottom(x int) *ViewDriver {
return self.MouseMove(x, self.getView().InnerHeight()-1)
}
// scrolls the view down by one notch of the mouse wheel, i.e. by
// gui.scrollHeight lines. This moves the scroll position without moving the
// selection.
func (self *ViewDriver) ScrollWheelDown() *ViewDriver {
offsetX, offsetY, _, _ := self.getView().Dimensions()
self.t.scrollWheelDown(offsetX+1, offsetY+1)
return self
}
func (self *ViewDriver) RepeatMouseMove() *ViewDriver {
self.t.repeatMouseMove()
return self

View file

@ -54,15 +54,15 @@ var RebaseAndDrop = NewIntegrationTest(NewIntegrationTestArgs{
Focus().
TopLines(
Contains("─── Pending rebase todos"),
MatchesRegexp(`pick.*to keep`).IsSelected(),
MatchesRegexp(`pick.*to keep`),
MatchesRegexp(`pick.*to remove`),
MatchesRegexp(`pick.*CONFLICT.*first change`),
MatchesRegexp(`pick.*CONFLICT.*first change`).IsSelected(),
Contains("─── Commits"),
MatchesRegexp("second-change-branch unrelated change"),
MatchesRegexp("second change"),
MatchesRegexp("original"),
).
SelectNextItem().
NavigateToLine(Contains("to remove")).
Press(keys.Universal.Remove).
TopLines(
Contains("─── Pending rebase todos"),

View file

@ -75,8 +75,8 @@ var RebaseConflictsFixBuildErrorsWithOutOfDateSubmodule = NewIntegrationTest(New
t.Views().Files().
Lines(
Equals("▼ /").IsSelected(),
Equals(" MM file"),
Equals("▼ /"),
Equals(" MM file").IsSelected(),
Equals(" M submodule (submodule)"),
Equals(" ?? untracked-file"),
)
@ -90,8 +90,8 @@ var RebaseConflictsFixBuildErrorsWithOutOfDateSubmodule = NewIntegrationTest(New
t.Views().Files().
Lines(
Equals("▼ /").IsSelected(),
Equals(" M submodule (submodule)"),
Equals("▼ /"),
Equals(" M submodule (submodule)").IsSelected(),
Equals(" ?? untracked-file"),
)

View file

@ -79,10 +79,9 @@ var CherryPickConflicts = NewIntegrationTest(NewIntegrationTestArgs{
Focus().
TopLines(
Contains("second-change-branch unrelated change"),
Contains("second change"),
Contains("first change").IsSelected(),
Contains("second change").IsSelected(),
Contains("first change"),
).
SelectPreviousItem().
Tap(func() {
// because we picked 'Second change' when resolving the conflict,
// we now see this commit as having replaced First Change with Second Change,

View file

@ -31,7 +31,7 @@ var AmendWhenThereAreConflictsAndAmend = NewIntegrationTest(NewIntegrationTestAr
Lines(
Contains("─── Pending rebase todos"),
Contains("pick").Contains("commit three"),
Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"),
Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch").IsSelected(),
Contains("─── Commits"),
Contains("commit two"),
Contains("file1 changed in master"),

View file

@ -35,7 +35,7 @@ var AmendWhenThereAreConflictsAndCancel = NewIntegrationTest(NewIntegrationTestA
Lines(
Contains("─── Pending rebase todos"),
Contains("pick").Contains("commit three"),
Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"),
Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch").IsSelected(),
Contains("─── Commits"),
Contains("commit two"),
Contains("file1 changed in master"),

View file

@ -0,0 +1,90 @@
package commit
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Selecting a directory in the commit files panel shows the renames of files that were moved into or out of it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateDir("dir")
shell.CreateDir("dir/nested")
shell.CreateFileAndAdd("file1", "file1 content\n")
shell.CreateFileAndAdd("dir/file2", "file2 content\n")
shell.CreateFileAndAdd("dir/nested/file3", "file3 content\n")
shell.Commit("initial commit")
shell.RenameFileInGit("file1", "dir/file1")
shell.RenameFileInGit("dir/file2", "dir/file2-renamed")
shell.RenameFileInGit("dir/nested/file3", "file3")
shell.Commit("move files")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Lines(
Contains("move files").IsSelected(),
Contains("initial commit"),
).
PressEnter()
t.Views().CommitFiles().
IsFocused().
Lines(
Equals("▼ /").IsSelected(),
Equals(" ▼ dir"),
Equals(" R file1 → file1"),
Equals(" R file2 → file2-renamed"),
Equals(" R dir/nested/file3 → file3"),
)
t.Views().Main().ContainsLines(
Equals("diff --git a/file1 b/dir/file1"),
Equals("similarity index 100%"),
Equals("rename from file1"),
Equals("rename to dir/file1"),
Equals("diff --git a/dir/file2 b/dir/file2-renamed"),
Equals("similarity index 100%"),
Equals("rename from dir/file2"),
Equals("rename to dir/file2-renamed"),
Equals("diff --git a/dir/nested/file3 b/file3"),
Equals("similarity index 100%"),
Equals("rename from dir/nested/file3"),
Equals("rename to file3"),
)
t.Views().CommitFiles().
SelectNextItem().
SelectedLine(Equals(" ▼ dir"))
t.Views().Main().
ContainsLines(
Equals("diff --git a/file1 b/dir/file1"),
Equals("similarity index 100%"),
Equals("rename from file1"),
Equals("rename to dir/file1"),
Equals("diff --git a/dir/file2 b/dir/file2-renamed"),
Equals("similarity index 100%"),
Equals("rename from dir/file2"),
Equals("rename to dir/file2-renamed"),
Equals("diff --git a/dir/nested/file3 b/file3"),
Equals("similarity index 100%"),
Equals("rename from dir/nested/file3"),
Equals("rename to file3"),
)
t.Views().CommitFiles().
SelectNextItem().
SelectedLine(Equals(" R file1 → file1"))
t.Views().Main().ContainsLines(
Equals("diff --git a/file1 b/dir/file1"),
Equals("similarity index 100%"),
Equals("rename from file1"),
Equals("rename to dir/file1"),
)
},
})

View file

@ -46,7 +46,7 @@ var RevertWithConflictMultipleCommits = NewIntegrationTest(NewIntegrationTestArg
Lines(
Contains("─── Pending reverts"),
Contains("revert").Contains("CI unrelated change"),
Contains("revert").Contains("CI <-- CONFLICT --- add first line"),
Contains("revert").Contains("CI <-- CONFLICT --- add first line").IsSelected(),
Contains("─── Commits"),
Contains("CI ○ add second line"),
Contains("CI ○ add first line"),

View file

@ -40,7 +40,7 @@ var RevertWithConflictSingleCommit = NewIntegrationTest(NewIntegrationTestArgs{
}).
Lines(
Contains("─── Pending reverts"),
Contains("revert").Contains("CI <-- CONFLICT --- add first line"),
Contains("revert").Contains("CI <-- CONFLICT --- add first line").IsSelected(),
Contains("─── Commits"),
Contains("CI ○ add second line"),
Contains("CI ○ add first line"),

View file

@ -45,7 +45,7 @@ func doTheRebaseForAmendTests(t *TestDriver, keys config.KeybindingConfig) {
Lines(
Contains("─── Pending rebase todos"),
Contains("pick").Contains("commit three"),
Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"),
Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch").IsSelected(),
Contains("─── Commits"),
Contains("commit two"),
Contains("file1 changed in master"),

View file

@ -7,7 +7,7 @@ import (
)
var ResolveMultipleFiles = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Ensures that upon resolving conflicts for one file, the next file is selected",
Description: "Ensures that a file whose conflicts have been resolved keeps being shown while other files still have conflicts",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
@ -34,25 +34,40 @@ var ResolveMultipleFiles = NewIntegrationTest(NewIntegrationTestArgs{
Contains("First Change"),
Contains("======="),
).
SelectNextItem().
PressPrimaryAction()
// The resolved file is still shown, and stays selected so that its diff
// can be reviewed
t.Views().Files().
IsFocused().
Lines(
Equals("UU file2").IsSelected(),
Equals("▼ /"),
Equals(" M file1").IsSelected(),
Equals(" UU file2"),
).
SelectNextItem().
PressEnter()
// coincidentally these files have the same conflict
t.Views().MergeConflicts().
IsFocused().
SelectedLines(
Contains("<<<<<<< HEAD"),
Contains("First Change"),
Contains("======="),
Contains("Second Change"),
Contains(">>>>>>>"),
).
PressPrimaryAction()
// Now that all conflicts are resolved, the filter is turned off again
t.Views().Files().
Lines(
Equals("▼ /"),
Equals(" M file1"),
Equals(" M file2").IsSelected(),
Equals(" A file3"),
)
t.Common().ContinueOnConflictsResolved("merge")
},
})

View file

@ -0,0 +1,86 @@
package file
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Selecting a directory in the files panel shows the renames of files that were moved into or out of it",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateDir("dir")
shell.CreateDir("dir/nested")
shell.CreateFileAndAdd("file1", "file1 content\n")
shell.CreateFileAndAdd("dir/file2", "file2 content\n")
shell.CreateFileAndAdd("dir/nested/file3", "file3 content\n")
shell.Commit("initial commit")
shell.RenameFileInGit("file1", "dir/file1")
shell.RenameFileInGit("dir/file2", "dir/file2-renamed")
shell.RenameFileInGit("dir/nested/file3", "file3")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Lines(
Equals("▼ /").IsSelected(),
Equals(" ▼ dir"),
Equals(" R file1 → file1"),
Equals(" R file2 → file2-renamed"),
Equals(" R dir/nested/file3 → file3"),
)
t.Views().Main().ContainsLines(
Equals("diff --git a/file1 b/dir/file1"),
Equals("similarity index 100%"),
Equals("rename from file1"),
Equals("rename to dir/file1"),
Equals("diff --git a/dir/file2 b/dir/file2-renamed"),
Equals("similarity index 100%"),
Equals("rename from dir/file2"),
Equals("rename to dir/file2-renamed"),
Equals("diff --git a/dir/nested/file3 b/file3"),
Equals("similarity index 100%"),
Equals("rename from dir/nested/file3"),
Equals("rename to file3"),
)
t.Views().Files().
SelectNextItem().
SelectedLine(Equals(" ▼ dir"))
t.Views().Main().
ContainsLines(
Equals("diff --git a/file1 b/dir/file1"),
Equals("similarity index 100%"),
Equals("rename from file1"),
Equals("rename to dir/file1"),
Equals("diff --git a/dir/file2 b/dir/file2-renamed"),
Equals("similarity index 100%"),
Equals("rename from dir/file2"),
Equals("rename to dir/file2-renamed"),
Equals("diff --git a/dir/nested/file3 b/file3"),
Equals("similarity index 100%"),
Equals("rename from dir/nested/file3"),
Equals("rename to file3"),
)
// The same applies when a filter reduces the directory to a single file
t.Views().Files().
FilterOrSearch("file1").
Lines(
Equals("▼ dir").IsSelected(),
Equals(" R file1 → file1"),
)
t.Views().Main().
ContainsLines(
Equals("diff --git a/file1 b/dir/file1"),
Equals("similarity index 100%"),
Equals("rename from file1"),
Equals("rename to dir/file1"),
)
},
})

View file

@ -46,12 +46,12 @@ var DiscardVariousChangesRangeSelect = NewIntegrationTest(NewIntegrationTestArgs
Cancel()
}).
Lines(
Equals("▼ /").IsSelected(),
Equals("▼ /"),
Equals(" AM added-changed.txt"),
Equals(" MD change-delete.txt"),
Equals(" D delete-change.txt"),
Equals(" D deleted-staged.txt"),
Equals(" D deleted.txt"),
Equals(" D deleted.txt").IsSelected(),
Equals(" MM double-modded.txt"),
Equals(" M modded-staged.txt"),
Equals(" M modded.txt"),
@ -59,6 +59,7 @@ var DiscardVariousChangesRangeSelect = NewIntegrationTest(NewIntegrationTestArgs
Equals(" ?? new.txt"),
Equals(" R renamed.txt → renamed2.txt"),
).
NavigateToLine(Equals("▼ /")).
Press(keys.Universal.ToggleRangeSelect).
NavigateToLine(Contains("renamed.txt")).
Press(keys.Universal.Remove).

View file

@ -0,0 +1,25 @@
package file
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var StageAllWithoutChangedFiles = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Pressing the stage-all key when there are no changed files says that there are none",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("first commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
IsEmpty().
Press(keys.Files.ToggleStagedAll).
Tap(func() {
t.ExpectToast(Contains("No changed files"))
})
},
})

View file

@ -36,7 +36,7 @@ var AmendCommitWithConflict = NewIntegrationTest(NewIntegrationTestArgs{
Lines(
Contains("─── Pending rebase todos"),
Contains("pick").Contains("three"),
Contains("fixup").Contains("<-- CONFLICT --- fixup! two"),
Contains("fixup").Contains("<-- CONFLICT --- fixup! two").IsSelected(),
Contains("─── Commits"),
Contains("two"),
Contains("one"),
@ -69,7 +69,7 @@ var AmendCommitWithConflict = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Commits().
Lines(
Contains("─── Pending rebase todos"),
Contains("<-- CONFLICT --- three"),
Contains("<-- CONFLICT --- three").IsSelected(),
Contains("─── Commits"),
Contains("two"),
Contains("one"),

View file

@ -33,7 +33,7 @@ var EditTheConflCommit = NewIntegrationTest(NewIntegrationTestArgs{
Focus().
Lines(
Contains("─── Pending rebase todos"),
Contains("pick").Contains("commit two"),
Contains("pick").Contains("commit two").IsSelected(),
Contains("pick").Contains("<-- CONFLICT --- commit three"),
Contains("─── Commits"),
Contains("commit one"),

View file

@ -55,7 +55,7 @@ var RevertMultipleCommitsInInteractiveRebase = NewIntegrationTest(NewIntegration
Contains("CI unrelated change 2"),
Contains("─── Pending reverts"),
Contains("revert").Contains("CI unrelated change 1"),
Contains("revert").Contains("CI <-- CONFLICT --- add first line"),
Contains("revert").Contains("CI <-- CONFLICT --- add first line").IsSelected(),
Contains("─── Commits"),
Contains("CI ○ add second line"),
Contains("CI ○ add first line"),

View file

@ -49,10 +49,10 @@ var RevertSingleCommitInInteractiveRebase = NewIntegrationTest(NewIntegrationTes
Contains("CI unrelated change 2"),
Contains("CI unrelated change 1"),
Contains("─── Pending reverts"),
Contains("revert").Contains("CI <-- CONFLICT --- add first line"),
Contains("revert").Contains("CI <-- CONFLICT --- add first line").IsSelected(),
Contains("─── Commits"),
Contains("CI ○ add second line"),
Contains("CI ○ add first line").IsSelected(),
Contains("CI ○ add first line"),
Contains("CI ○ add empty file"),
).
Press(keys.Commits.MoveDownCommit).

View file

@ -4,14 +4,25 @@ import (
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
func handleConflictsFromSwap(t *TestDriver, expectedCommand string) {
func handleConflictsFromSwap(t *TestDriver, expectedCommand string, selectConflict bool) {
t.Common().AcknowledgeConflicts()
// If the conflict comes from directly moving a commit, we want to keep the moved commit
// selected, so selectConflict is false. In other cases (e.g. a conflict after "continue
// rebase") we want to select the conflict commit.
commitTwoMatcher := Contains("pick").Contains("commit two")
conflictMatcher := Contains(expectedCommand).Contains("<-- CONFLICT --- commit three")
if selectConflict {
conflictMatcher.IsSelected()
} else {
commitTwoMatcher.IsSelected()
}
t.Views().Commits().
Lines(
Contains("─── Pending rebase todos"),
Contains("pick").Contains("commit two"),
Contains(expectedCommand).Contains("<-- CONFLICT --- commit three"),
commitTwoMatcher,
conflictMatcher,
Contains("─── Commits"),
Contains("commit one"),
)

View file

@ -48,6 +48,6 @@ var SwapInRebaseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{
t.Common().ContinueRebase()
})
handleConflictsFromSwap(t, "pick")
handleConflictsFromSwap(t, "pick", true)
},
})

View file

@ -51,6 +51,6 @@ var SwapInRebaseWithConflictAndEdit = NewIntegrationTest(NewIntegrationTestArgs{
t.Common().ContinueRebase()
})
handleConflictsFromSwap(t, "edit")
handleConflictsFromSwap(t, "edit", true)
},
})

View file

@ -28,6 +28,6 @@ var SwapWithConflict = NewIntegrationTest(NewIntegrationTestArgs{
).
Press(keys.Commits.MoveDownCommit)
handleConflictsFromSwap(t, "pick")
handleConflictsFromSwap(t, "pick", false)
},
})

View file

@ -83,11 +83,10 @@ var ApplyInReverseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Files().
Focus().
Lines(
Equals("▼ /").IsSelected(),
Equals(" M file1"),
Equals("▼ /"),
Equals(" M file1").IsSelected(),
Equals(" M file2"),
).
SelectNextItem()
)
t.Views().Main().
ContainsLines(

View file

@ -76,10 +76,11 @@ var MoveToEarlierCommitFromAddedFile = NewIntegrationTest(NewIntegrationTestArgs
t.Views().Commits().
Focus().
Lines(
Contains("commit to move from"),
Contains("destination commit").IsSelected(),
Contains("commit to move from").IsSelected(),
Contains("destination commit"),
Contains("first commit"),
).
NavigateToLine(Contains("destination commit")).
PressEnter()
t.Views().CommitFiles().

View file

@ -50,7 +50,7 @@ var PullRebaseInteractiveConflict = NewIntegrationTest(NewIntegrationTestArgs{
Lines(
Contains("─── Pending rebase todos"),
Contains("pick").Contains("five"),
Contains("pick").Contains("CONFLICT").Contains("four"),
Contains("pick").Contains("CONFLICT").Contains("four").IsSelected(),
Contains("─── Commits"),
Contains("three"),
Contains("two"),
@ -83,13 +83,12 @@ var PullRebaseInteractiveConflict = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Commits().
Focus().
Lines(
Contains("five").IsSelected(),
Contains("four"),
Contains("five"),
Contains("four").IsSelected(),
Contains("three"),
Contains("two"),
Contains("one"),
).
SelectNextItem()
)
t.Views().Main().
Content(

View file

@ -50,13 +50,14 @@ var PullRebaseInteractiveConflictDrop = NewIntegrationTest(NewIntegrationTestArg
Focus().
Lines(
Contains("─── Pending rebase todos"),
Contains("pick").Contains("five").IsSelected(),
Contains("pick").Contains("CONFLICT").Contains("four"),
Contains("pick").Contains("five"),
Contains("pick").Contains("CONFLICT").Contains("four").IsSelected(),
Contains("─── Commits"),
Contains("three"),
Contains("two"),
Contains("one"),
).
NavigateToLine(Contains("five")).
Press(keys.Universal.Remove).
Lines(
Contains("─── Pending rebase todos"),

View file

@ -127,6 +127,7 @@ var tests = []*components.IntegrationTest{
commit.CreateAmendCommit,
commit.CreateFixupCommitInBranchStack,
commit.CreateTag,
commit.DirectoryDiffWithRenamedFiles,
commit.DisableCopyCommitMessageBody,
commit.DiscardOldFileChanges,
commit.DiscardRenamedFile,
@ -233,6 +234,7 @@ var tests = []*components.IntegrationTest{
file.CollapseParent,
file.CopyMenu,
file.DirWithUntrackedFile,
file.DirectoryDiffWithRenamedFiles,
file.DiscardAllDirChanges,
file.DiscardAllDirChangesWhenFiltering,
file.DiscardRangeSelect,
@ -250,6 +252,7 @@ var tests = []*components.IntegrationTest{
file.RenameSimilarityThresholdChange,
file.RenamedFiles,
file.RenamedFilesNoRootItem,
file.StageAllWithoutChangedFiles,
file.StageChildrenRangeSelect,
file.StageDeletedRangeSelect,
file.StageRangeSelect,
@ -498,20 +501,28 @@ var tests = []*components.IntegrationTest{
tag.Reset,
tag.ResetToDuplicateNamedBranch,
ui.Accordion,
ui.BackgroundRefreshKeepsScrollPosition,
ui.BranchesNotFirstTab,
ui.CommitsNotFirstTab,
ui.DisableSwitchTabWithPanelJumpKeys,
ui.DragBeyondViewport,
ui.EmptyMenu,
ui.FilteringScrollsSelectionIntoView,
ui.FindBaseCommitForFixupScrollsIntoView,
ui.HideSidePanel,
ui.KeybindingSuggestionsDontCrashOnDisabledBindings,
ui.KeybindingSuggestionsWhenSwitchingRepos,
ui.MenuScrollPositionIsReset,
ui.ModeSpecificKeybindingSuggestions,
ui.MoveCommitScrollsSelectionIntoView,
ui.OpenLinkFailure,
ui.PageUpAndDown,
ui.PromoteTabToSidePanel,
ui.RangeSelect,
ui.RangeSelectWithAutoscroll,
ui.ReloadSidePanels,
ui.ReorderSidePanels,
ui.SubCommitsScrollPositionIsReset,
ui.SwitchTabFromMenu,
ui.SwitchTabWithPanelJumpKeys,
undo.UndoCheckoutAndDrop,
@ -550,4 +561,5 @@ var tests = []*components.IntegrationTest{
worktree.SeparateWorkTreeConfig,
worktree.SymlinkIntoRepoSubdir,
worktree.WorktreeInRepo,
worktree.WorktreeInsideRepo,
}

View file

@ -0,0 +1,41 @@
package ui
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var BackgroundRefreshKeepsScrollPosition = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A background refresh doesn't scroll the selection back into view",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("initial commit")
for i := range 20 {
shell.CreateFile(fmt.Sprintf("file%02d", i), "")
}
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
SelectNextItem().
SelectedLine(Contains("file00")).
// Scroll the selection out of view with the mouse wheel
ScrollWheelDown().
ScrollWheelDown().
OriginY(4).
Tap(func() {
t.Shell().CreateFile("aaa", "")
t.RefreshInBackground()
}).
// The new file sorts before the selected one, so the selection has
// moved down a line; the view must stay where the user left it though
SelectedLineIdx(2).
OriginY(4)
},
})

View file

@ -0,0 +1,37 @@
package ui
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DragBeyondViewport = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Dragging a range selection beyond the bottom of the panel doesn't scroll the view",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("initial commit")
for i := range 20 {
shell.CreateFile(fmt.Sprintf("file%02d", i), "")
}
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
OriginY(0).
// The pointer ends up below the panel, so the range extends to a line
// that isn't visible. Scrolling there is the drag autoscroller's job,
// which scrolls line by line for as long as the pointer stays there;
// the drag itself must leave the scroll position alone.
ClickAndHold(1, 1).
MouseMove(1, 8).
MouseRelease().
SelectedLineIdx(8).
OriginY(0)
},
})

View file

@ -0,0 +1,62 @@
package ui
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FilteringScrollsSelectionIntoView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Entering and leaving filtering mode scrolls the selected commit into view",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
for i := range 40 {
file := "otherFile"
if i%2 == 0 {
file = "filterFile"
}
shell.UpdateFileAndAdd(file, fmt.Sprintf("content %02d", i))
shell.Commit(fmt.Sprintf("commit %02d", i))
}
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Press(keys.Universal.GotoBottom).
SelectedLine(Contains("commit 00")).
OriginYAtLeast(1).
Press(keys.Universal.FilteringMenu)
t.ExpectPopup().Menu().
Title(Equals("Filtering")).
Select(Contains("Enter path to filter by")).
Confirm()
t.ExpectPopup().Prompt().
Title(Equals("Enter path:")).
Type("filterFile").
Confirm()
// The filtered list has nothing to do with the one that was showing, so
// its scroll position doesn't either: we start at the top again
t.Views().Commits().
IsFocused().
SelectedLine(Contains("commit 38")).
SelectedLineIdx(0).
OriginY(0).
Press(keys.Universal.GotoBottom).
SelectedLine(Contains("commit 00")).
PressEscape()
// Leaving filtering mode keeps the commit selected, at its position in
// the full list, which needs scrolling to again
t.Views().Commits().
IsFocused().
SelectedLine(Contains("commit 00")).
SelectedLineIsVisible()
},
})

View file

@ -0,0 +1,35 @@
package ui
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var FindBaseCommitForFixupScrollsIntoView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Finding the base commit for a fixup scrolls it into view",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.NewBranch("mybranch").
EmptyCommit("1st commit").
CreateFileAndAdd("file1", "line 1\nline 2\nline 3\n").
Commit("base commit").
CreateNCommits(40).
UpdateFile("file1", "line 1\nline 2 changed\nline 3\n")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
Focus().
Press(keys.Files.FindBaseCommitForFixup)
// The base commit is at the very bottom of the list, far below the
// visible area
t.Views().Commits().
IsFocused().
SelectedLine(Contains("base commit")).
SelectedLineIsVisible()
},
})

View file

@ -0,0 +1,39 @@
package ui
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var MenuScrollPositionIsReset = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A menu that is opened after a scrolled down one starts at the top again",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateFile("myfile", "myfile")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press(keys.Universal.OptionMenu)
t.Views().Menu().
IsFocused().
// The first line is a section header, so the first item is at index 1
SelectedLineIdx(1).
OriginY(0).
Press(keys.Universal.GotoBottom).
OriginYAtLeast(1).
PressEscape()
t.Views().Files().
IsFocused().
Press(keys.Universal.OptionMenu)
t.Views().Menu().
IsFocused().
SelectedLineIdx(1).
OriginY(0)
},
})

View file

@ -0,0 +1,31 @@
package ui
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var MoveCommitScrollsSelectionIntoView = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Moving a commit down scrolls it into view if it isn't visible",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(40)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
SelectedLine(Contains("commit-40")).
// Scroll the selected commit out of view with the mouse wheel
ScrollWheelDown().
ScrollWheelDown().
OriginY(4).
Press(keys.Commits.MoveDownCommit).
SelectedLine(Contains("commit-40")).
SelectedLineIdx(1).
SelectedLineIsVisible()
},
})

View file

@ -0,0 +1,47 @@
package ui
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
const (
// The height of the commits panel in this test's window, in lines.
commitsPanelHeight = 5
// Paging keeps one line of overlap between the old and the new page.
pageDelta = commitsPanelHeight - 1
)
var PageUpAndDown = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Paging down and up keeps the selection at the edge of the viewport",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(40)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
SelectedLineIdx(0).
OriginY(0).
Press(keys.Universal.NextPage).
// The selection moves to the bottom of the viewport; nothing scrolls yet
SelectedLineIdx(commitsPanelHeight - 1).
OriginY(0).
Press(keys.Universal.NextPage).
// Now the view scrolls by a page, and the selection stays at the bottom
SelectedLineIdx(commitsPanelHeight - 1 + pageDelta).
OriginY(pageDelta).
Press(keys.Universal.PrevPage).
// The selection moves to the top of the viewport; nothing scrolls
SelectedLineIdx(pageDelta).
OriginY(pageDelta).
Press(keys.Universal.PrevPage).
// And back a page, with the selection staying at the top
SelectedLineIdx(0).
OriginY(0)
},
})

View file

@ -0,0 +1,39 @@
package ui
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var SubCommitsScrollPositionIsReset = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Viewing the commits of a branch again after scrolling down starts at the top again",
ExtraCmdArgs: []string{},
Skip: false,
Width: 120,
Height: 30,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(40)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Branches().
Focus().
PressEnter()
t.Views().SubCommits().
IsFocused().
OriginY(0).
Press(keys.Universal.GotoBottom).
OriginYAtLeast(1).
PressEscape()
t.Views().Branches().
IsFocused().
PressEnter()
t.Views().SubCommits().
IsFocused().
SelectedLineIdx(0).
OriginY(0)
},
})

View file

@ -0,0 +1,28 @@
package worktree
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var WorktreeInsideRepo = NewIntegrationTest(NewIntegrationTestArgs{
Description: "A worktree that lives inside the repo's working tree is shown as a single item in the files panel",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.NerdFontsVersion = "3"
},
SetupRepo: func(shell *Shell) {
shell.NewBranch("mybranch")
shell.CreateFileAndAdd("README.md", "hello world")
shell.Commit("initial commit")
shell.AddWorktree("mybranch", "nested-worktree", "newbranch")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Lines(
Equals("?? 󰌹 nested-worktree").IsSelected(),
)
},
})

View file

@ -31,6 +31,9 @@ type GuiDriver interface {
ClickAndHold(int, int)
MouseMove(int, int)
MouseRelease(int, int)
ScrollWheelDown(int, int)
// Perform the refresh that a background routine would perform on a timer
RefreshInBackground()
// Can be used to avoid data races with the UI thread in the uncommon cases that
// the test driver needs to assert state while the gui is not idle.
OnUIThreadAndWait(func())

View file

@ -144,7 +144,7 @@ func setDefaultVals(rootSchema, schema *jsonschema.Schema, defaults any) {
t := reflect.TypeOf(defaults)
v := reflect.ValueOf(defaults)
if t.Kind() == reflect.Ptr || t.Kind() == reflect.Interface {
if t.Kind() == reflect.Pointer || t.Kind() == reflect.Interface {
t = t.Elem()
v = v.Elem()
}
@ -202,7 +202,7 @@ func isZeroValue(v any) bool {
switch rv.Kind() {
case reflect.Slice, reflect.Map:
return rv.Len() == 0
case reflect.Ptr, reflect.Interface:
case reflect.Pointer, reflect.Interface:
return rv.IsNil()
case reflect.Struct:
for i := range rv.NumField() {

View file

@ -4,6 +4,8 @@ import (
"io"
"log"
"os"
"sync"
"time"
"github.com/sirupsen/logrus"
)
@ -34,6 +36,11 @@ func NewProductionLogger() *logrus.Entry {
return formatted(logger)
}
// Separates one run's log entries from the previous run's. Only the first
// logger of a run writes it: with LAZYGIT_LOG_PATH set there are two of them
// for the same file, the global one and the app's.
var runSeparator sync.Once
func NewDevelopmentLogger(logPath string) *logrus.Entry {
logger := logrus.New()
logger.SetLevel(getLogLevel())
@ -42,6 +49,9 @@ func NewDevelopmentLogger(logPath string) *logrus.Entry {
if err != nil {
log.Fatalf("Unable to log to log file: %v", err)
}
runSeparator.Do(func() {
_, _ = file.WriteString("\n")
})
logger.SetOutput(file)
return formatted(logger)
}
@ -49,7 +59,7 @@ func NewDevelopmentLogger(logPath string) *logrus.Entry {
func formatted(log *logrus.Logger) *logrus.Entry {
// highly recommended: tail -f development.log | humanlog
// https://github.com/aybabtme/humanlog
log.Formatter = &logrus.JSONFormatter{}
log.Formatter = &logrus.JSONFormatter{TimestampFormat: time.RFC3339Nano}
return log.WithFields(logrus.Fields{})
}

Some files were not shown because too many files have changed in this diff Show more