mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-16 02:26:24 -04:00
Merge remote-tracking branch 'origin/master'
# Conflicts: # pkg/gocui/gui.go
This commit is contained in:
commit
1f51ab4715
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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]
|
||||
|
|
|
|||
2
.github/workflows/sponsors.yml
vendored
2
.github/workflows/sponsors.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -99,8 +99,6 @@ linters:
|
|||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- vendor/
|
||||
|
|
|
|||
14
AGENTS.md
14
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -757,6 +757,7 @@ keybinding:
|
|||
copyFileInfoToClipboard: "y"
|
||||
collapseAll: '-'
|
||||
expandAll: =
|
||||
collapseParent: <backspace>
|
||||
branches:
|
||||
createPullRequest: o
|
||||
viewPullRequestOptions: O
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
12
go.mod
|
|
@ -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
28
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
43
pkg/gocui/ui_thread_test.go
Normal file
43
pkg/gocui/ui_thread_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
132
pkg/gui/controllers/diff_paths.go
Normal file
132
pkg/gui/controllers/diff_paths.go
Normal 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+"/")
|
||||
}
|
||||
113
pkg/gui/controllers/diff_paths_test.go
Normal file
113
pkg/gui/controllers/diff_paths_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
32
pkg/gui/filetree/file_tree_view_model_test.go
Normal file
32
pkg/gui/filetree/file_tree_view_model_test.go
Normal 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())
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,6 @@ var SwapInRebaseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{
|
|||
t.Common().ContinueRebase()
|
||||
})
|
||||
|
||||
handleConflictsFromSwap(t, "pick")
|
||||
handleConflictsFromSwap(t, "pick", true)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -51,6 +51,6 @@ var SwapInRebaseWithConflictAndEdit = NewIntegrationTest(NewIntegrationTestArgs{
|
|||
t.Common().ContinueRebase()
|
||||
})
|
||||
|
||||
handleConflictsFromSwap(t, "edit")
|
||||
handleConflictsFromSwap(t, "edit", true)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,6 +28,6 @@ var SwapWithConflict = NewIntegrationTest(NewIntegrationTestArgs{
|
|||
).
|
||||
Press(keys.Commits.MoveDownCommit)
|
||||
|
||||
handleConflictsFromSwap(t, "pick")
|
||||
handleConflictsFromSwap(t, "pick", false)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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().
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
37
pkg/integration/tests/ui/drag_beyond_viewport.go
Normal file
37
pkg/integration/tests/ui/drag_beyond_viewport.go
Normal 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)
|
||||
},
|
||||
})
|
||||
|
|
@ -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()
|
||||
},
|
||||
})
|
||||
|
|
@ -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()
|
||||
},
|
||||
})
|
||||
39
pkg/integration/tests/ui/menu_scroll_position_is_reset.go
Normal file
39
pkg/integration/tests/ui/menu_scroll_position_is_reset.go
Normal 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)
|
||||
},
|
||||
})
|
||||
|
|
@ -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()
|
||||
},
|
||||
})
|
||||
47
pkg/integration/tests/ui/page_up_and_down.go
Normal file
47
pkg/integration/tests/ui/page_up_and_down.go
Normal 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)
|
||||
},
|
||||
})
|
||||
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
28
pkg/integration/tests/worktree/worktree_inside_repo.go
Normal file
28
pkg/integration/tests/worktree/worktree_inside_repo.go
Normal 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(),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue