Merge branch 'master' into f/open-in-terminal

This commit is contained in:
Ramon Vermeulen 2026-07-21 10:26:03 +02:00 committed by GitHub
commit 840f448476
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
202 changed files with 3727 additions and 2445 deletions

View file

@ -53,8 +53,16 @@ jobs:
- 2.38.2 # first version that supports the rebase.updateRefs config
- 2.44.0
- latest # We rely on github to have the latest version installed on their VMs
race:
- false
# Additionally run the whole suite once under the race detector. Data
# races live in lazygit's own Go code rather than in git, so a single
# git version is enough; use the latest to skip the git-build steps.
include:
- git-version: latest
race: true
runs-on: ubuntu-latest
name: "Integration Tests - git ${{matrix.git-version}}"
name: "Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }}"
env:
GOFLAGS: -mod=vendor
steps:
@ -92,12 +100,23 @@ jobs:
run: git --version
- name: Test code
env:
# See https://go.dev/blog/integration-test-coverage
LAZYGIT_GOCOVERDIR: /tmp/code_coverage
# See https://go.dev/blog/integration-test-coverage. The race variant
# skips coverage: it's redundant with the non-race latest job and
# would only slow the -race build down further. Leaving the dir unset
# makes run_integration_tests.sh take its non-coverage path.
LAZYGIT_GOCOVERDIR: ${{ !matrix.race && '/tmp/code_coverage' || '' }}
# Only set for the race variant. The race detector needs cgo; it's on
# by default on the Linux runner, but we set it explicitly to be safe.
LAZYGIT_RACE_DETECTOR: ${{ matrix.race && '1' || '' }}
CGO_ENABLED: ${{ matrix.race && '1' || '' }}
# Append each test's duration to this file; run_integration_tests.sh
# prints the slowest at the end, to spot slow/anomalous tests.
LAZYGIT_TEST_TIMING: /tmp/test_timings.txt
run: |
mkdir -p /tmp/code_coverage
./scripts/run_integration_tests.sh
- name: Upload code coverage artifacts
if: ${{ !matrix.race }}
uses: actions/upload-artifact@v7
with:
name: coverage-integration-${{ matrix.git-version }}-${{ github.run_id }}

View file

@ -13,10 +13,15 @@ on:
description: 'Version bump type'
type: choice
required: true
default: 'patch'
default: 'minor (normal)'
options:
- minor
- patch
- minor (normal)
- patch (hotfix)
branch:
description: 'Branch to release from'
type: string
required: true
default: 'master'
ignore_blocks:
description: 'Ignore blocking PRs/issues'
type: boolean
@ -49,12 +54,13 @@ jobs:
uses: actions/checkout@v7
with:
repository: jesseduffield/lazygit
ref: ${{ inputs.branch }}
token: ${{ secrets.LAZYGIT_RELEASE_PAT }}
fetch-depth: 0
- name: Get Latest Tag
run: |
latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1) || echo "v0.0.0")
latest_tag=$(git describe --tags --abbrev=0 || echo "v0.0.0")
if ! [[ $latest_tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Tag format is invalid. Expected format: vX.X.X"
@ -121,7 +127,7 @@ jobs:
IFS='.' read -r major minor patch <<< "$LATEST_TAG"
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
if [[ "$VERSION_BUMP" == "patch" ]]; then
if [[ "$VERSION_BUMP" == "patch (hotfix)" ]]; then
patch=$((patch + 1))
else
minor=$((minor + 1))
@ -151,7 +157,7 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$NEW_TAG" -a -m "Release $NEW_TAG"
git push origin "$NEW_TAG"
git push origin "refs/tags/$NEW_TAG"
- name: Setup Go
uses: actions/setup-go@v6

View file

@ -26,6 +26,24 @@ Windows box has only `just`).
(most useful with `--sandbox` or `--slow`).
- `just lint` — run golangci-lint.
## Prefer gopls MCP tools for Go symbol questions
When the gopls MCP tools are available in the session, prefer them over grep
for type-aware questions about Go code: who calls a function or method
(`go_symbol_references`), finding a symbol by fuzzy name (`go_search`), or
inspecting a package's API (`go_package_api`). Method names in this codebase
collide a lot (`draw`, `Show`, `Refresh` exist on several types), and grep
needs manual filtering that gopls doesn't. This includes code under
`vendor/`, which gopls resolves as part of the module build.
Grep remains the right tool for strings, comments, config keys, non-Go
files, and anything textual. Don't adopt the full workflow from
`gopls mcp -instructions` (vulncheck on session start, `go_file_context`
after every file read); that overhead isn't worth it here.
If the tools aren't available in a session, fall back to grep silently —
don't try to install, register, or start the server.
## When to commit
Do not leave completed work uncommitted. Once a logical unit of work is done
@ -411,3 +429,12 @@ Never run `find` (or similar) from `/` or other paths outside the project. All
third-party code we use is vendored under `vendor/`, so dependency sources are
reachable from inside the working tree — search there instead of the host
filesystem.
## gocui is in-tree, not a dependency
The `gocui` TUI library is a fork maintained directly in this repo under
`pkg/gocui` — it's an ordinary package, not a Go module dependency. Don't look
for it in `go.mod`/`go.sum` or the module cache (`$GOMODCACHE`); it isn't
there. When you need to read or change gocui internals (the task manager, the
event loop, worker/UI-thread dispatch, view rendering), edit `pkg/gocui`
directly.

View file

@ -1,273 +1,34 @@
# Contributing
This project does not accept pull requests.
## The short version
In todays world of agentic coding I have decided that it no longer makes sense for me to look at incoming pull requests. As far as I can tell, the vast majority of these is AI-generated these days, which in itself is not necessarily a bad thing; however, there's no way for me to tell whether the person posting the PR actually understands anything about the code that is being contributed or not, and I don't feel like spending time and energy on finding out whether they do.
This project does not accept pull requests. Don't bother making one, it won't be merged.
Now you might ask why this even matters; coding agents are capable of producing amazingly high-quality code, so why is it important that the person opening the PR understands it, as long as the code works and tests are green? It does actually matter very much to me. AI generated code needs to be carefully reviewed and iterated on, and it is the contributor's job to do that, not mine. And I have no idea to what extent the contributor has done this, or whether they are even capable of it.
However, there are other forms of contributions that are very welcome and encouraged; see below for what those are.
Every PR needs work and iterations until it is mergeable, whether manually coded or AI generated (even very good ones do), and if I don't know whether the person posting the PR will act on my review feedback themselves or just pass it on to their coding agent (which I guess is the much more likely case today), then it doesn't make sense for me to work with them.
## Why no PRs?
For this reason I will close incoming pull requests by default from now on, without comment. Sorry if this sounds hostile, but honestly I don't feel I have much of a choice if I want maintaining this project to still be enjoyable for me.
There are two main reasons for this, and I want to be very honest about them:
With that said, if you are indeed serious about contributing a high-quality PR to lazygit, and you are familiar with go, and you have learned enough about lazygit's code base to tell whether your changes are good, then do raise an issue and explain what you are planning to do, and somehow make it plausible that your PR will be worth my time reviewing it. In such a case I might make an exception from the default rule.
- I am maintaining lazygit for fun, as a hobby in my free time (which is quite limited). I'd like to spend my free time on things that I enjoy doing. I enjoy working on lazygit's code and improving it myself; I don't enjoy reviewing PRs. It's that simple, really. Reviewing PRs takes a lot of time; time that I would rather spend on developing lazygit myself.
- Even if I had the time and inclination to review PRs, this has become quite difficult today: most PRs nowadays are AI-generated to some extent (often completely), which in itself is not necessarily a bad thing; I heavily use AI myself these days, and I get great results from it. However, agentic coding needs to be guided by humans so that the results are good, and for contributed PRs I can't tell to what extent the human contributor did this, or is even capable of it; and I don't want to do the work of guiding a contributor's coding agent. If I post PR review feedback and have to suspect that the contributor simply passes it on to their coding agent, then that is a work mode that doesn't make sense to me, and I would rather just drive my own agent to do the work.
In the future I might also consider adopting a vouch system similar to [Ghostty's](https://github.com/ghostty-org/ghostty/blob/main/CONTRIBUTING.md#first-time-contributors), but right now I feel the effort needed to set this up and maintain is not justified given the rather low number of high-quality contributions I have seen in recent times.
### Why it might still make sense to post a PR
Even though we no longer accept pull requests, I find it important to emphasize that Lazygit is still a community project, and non-PR contributions are still very welcome. Do file issues for bug reports or feature requests, and help shape the future of lazygit by actively participating in discussing UX designs. Also, the localization system very much depends on everybody's help with translating texts (see https://crowdin.com/project/lazygit).
I can think of two such reasons:
---
- You implemented a lazygit improvement that you want to use yourself; in this case it could make sense to let others merge this change into their forks if they find it useful too. And if enough people say they want the feature, this can persuade me to add it, so putting it out there to give it visibility can be helpful.
- You posted an issue for a feature request, and have a prototype that implements it; it could be useful to publish the branch as a draft PR to better illustrate how the feature works.
The remainder of this document is the old version from a time when contributing pull requests was still encouraged. Keeping it here in case I reconsider my policy in the future.
For this reason I usually don't close pull requests to give them more visibility. Just don't expect your PR to be merged.
## PR walkthrough
## So how can I contribute then?
[This video](https://www.youtube.com/watch?v=kNavnhzZHtk) walks through the process of adding a small feature to lazygit. If you have no idea where to start, watching that video is a good first step.
There are other forms of contributions to a project besides source code that are very welcome and encouraged; for instance:
## Design principles
- File issues for bugs that you find, and I'll do my best to take care of fixing them (if they are important enough).
- File feature requests for new functionality that you want to see in lazygit. I have a lot of ideas for future improvement myself, but I have also implemented a lot of feature ideas that weren't mine, and I'm grateful for those ideas. (Of course, there are also lots of feature requests that I don't implement, so don't be disappointed if I don't jump on yours.)
- Help make other people's bug reports reproducible. Sometimes people report bugs that they have only seen once, and in such a case it can be helpful to come up with reproducible scenarios.
- Help complete or improve the translation into other languages; join https://crowdin.com/project/lazygit for that.
See [here](./VISION.md) for a set of design principles that we want to consider when building a feature or making a change.
## Codebase guide
[This doc](./docs/dev/Codebase_Guide.md) explains:
- what the different packages in the codebase are for
- where important files live
- important concepts in the code
- how the event loop works
- other useful information
## All code changes happen through Pull Requests
Pull requests are the best way to propose changes to the codebase. We actively
welcome your pull requests:
1. Fork the repo and create your branch from `master`.
2. If you've added code that should be tested, add tests.
3. If you've added code that needs documentation, update the documentation.
4. Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
5. Issue that pull request!
Please do not raise pull request from your fork's master branch: make a feature branch instead. Lazygit maintainers will sometimes push changes to your branch when reviewing a PR and we often can't do this if you use your master branch.
If you've never written Go in your life, then join the club! Lazygit was the maintainer's first Go program, and most contributors have never used Go before. Go is widely considered an easy-to-learn language, so if you're looking for an open source project to gain dev experience, you've come to the right place.
## Commit history
We value a clean and useful commit history, so please take some time to organize your commits so that they make sense. Don't assume that they will be squashed on merge anyway; we don't do that here.
In particular:
- Refactorings and behavior changes should be in separate commits. There are very few exceptions where this is not possible, but in my experience they are very rare.
- Strive for minimal commits; every change that is independent from other changes should be in a commit of its own (with a good commit message that explains why the change is made).
- When you need to iterate over your implementation during review (e.g. because you discovered a bug, or a maintainer requested changes), don't just pile new commits on top. Use fixup commits to make your changes transparent while still maintaining a good commit history. If you don't know what that means, [here's a brief introduction](docs/Fixup_Commits.md).
## A note about AI
It has become common recently to throw an issue at a coding agent and submit whatever comes out of it as a PR. This is not appreciated here, and I will close PRs where I can tell this was the case, or where I even suspect it was the case.
Some of these PRs may actually be good and useful, but many are not, and it's not a good use of my time as a maintainer to look at generated PRs to decide. This is the job of the PR's contributor, and if you don't speak enough go or can't be bothered to get familiar enough with lazygit's codebase to tell, then don't contribute the PR.
## Running in a VSCode dev container
If you want to spare yourself the hassle of setting up your dev environment yourself (i.e. installing Go, extensions, and extra tools), you can run the Lazygit code in a VSCode dev container like so:
![image](https://user-images.githubusercontent.com/8456633/201500508-0d55f99f-5035-4a6f-a0f8-eaea5c003e5d.png)
This requires that:
- you have docker installed
- you have the dev containers extension installed in VSCode
See [here](https://code.visualstudio.com/docs/devcontainers/containers) for more info about dev containers.
## Running in a Github Codespace
If you want to start contributing to Lazygit with the click of a button, you can open the lazygit codebase in a Codespace. First fork the repo, then click to create a codespace:
![image](https://user-images.githubusercontent.com/8456633/201500566-ffe9105d-6030-4cc7-a525-6570b0b413a2.png)
To run lazygit from within the integrated terminal just go `go run main.go`
This allows you to contribute to Lazygit without needing to install anything on your local machine. The Codespace has all the necessary tools and extensions pre-installed.
## Using Nix for development
If you use Nix, you can leverage the included flake to set up a complete development environment with all necessary dependencies:
```sh
nix develop
```
This will drop you into a development shell that includes:
- Latest Go toolchain
- golangci-lint for code linting
- git and make
You can also build and run lazygit using nix:
```sh
# Build lazygit
nix build
# Run lazygit directly
nix run
```
The nix flake supports multiple architectures (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin) and provides a consistent development environment across different systems.
## Code of conduct
Please note by participating in this project, you agree to abide by the [code of conduct].
[code of conduct]: https://github.com/jesseduffield/lazygit/blob/master/CODE-OF-CONDUCT.md
## Any contributions you make will be under the MIT Software License
In short, when you submit code changes, your submissions are understood to be
under the same [MIT License](http://choosealicense.com/licenses/mit/) that
covers the project. Feel free to contact the maintainers if that's a concern.
## Report bugs using Github's [issues](https://github.com/jesseduffield/lazygit/issues)
We use GitHub issues to track public bugs. Report a bug by [opening a new
issue](https://github.com/jesseduffield/lazygit/issues/new); it's that easy!
## Go
This project is written in Go. Go is an opinionated language with strict idioms, but some of those idioms are a little extreme. Some things we do differently:
1. There is no shame in using `self` as a receiver name in a struct method. In fact we encourage it
2. There is no shame in prefixing an interface with 'I' instead of suffixing with 'er' when there are several methods on the interface.
3. If a struct implements an interface, we make it explicit with something like:
```go
var _ MyInterface = &MyStruct{}
```
This makes the intent clearer and means that if we fail to satisfy the interface we'll get an error in the file that needs fixing.
### Code Formatting
To check code formatting [gofumpt](https://pkg.go.dev/mvdan.cc/gofumpt#section-readme) (which is a bit stricter than [gofmt](https://pkg.go.dev/cmd/gofmt)) is used.
VSCode will format the code correctly if you tell the Go extension to use `gofumpt` via your [`settings.json`](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson)
by setting [`formatting.gofumpt`](https://github.com/golang/tools/blob/master/gopls/doc/settings.md#gofumpt-bool) to `true`:
```jsonc
// .vscode/settings.json
{
"gopls": {
"formatting.gofumpt": true
}
}
```
To run gofumpt from your terminal go:
```
go install mvdan.cc/gofumpt@latest && gofumpt -l -w .
```
## Programming Font
Lazygit supports [Nerd Fonts](https://www.nerdfonts.com) to render certain icons. Sometimes we use some of these icons verbatim in string literals in the code (mainly in tests), so you need to set your development environment to use a nerd font to see these.
## Internationalisation
Boy that's a hard word to spell. Anyway, lazygit is translated into several languages within the pkg/i18n package.
### For developers adding new text
If you need to render text to the user, you should add a new field to the TranslationSet struct in `pkg/i18n/english.go` and add the actual content within the `EnglishTranslationSet()` method in the same file. Then you can access via `gui.Tr.YourNewText` (or `self.c.Tr.YourNewText`, etc).
Note, we use 'Sentence case' for everything (so no 'Title Case' or 'whatever-it's-called-when-there's-no-capital-letters-case')
### For translators
Lazygit translations are managed through [Crowdin](https://crowdin.com/project/lazygit/). If you'd like to contribute translations:
1. Join the Crowdin project at https://crowdin.com/project/lazygit/
2. Select your target language and help translate missing strings
3. The translation files in `pkg/i18n/translations/` are managed by the maintainers - please don't edit them directly
For detailed information about the translation process, including how maintainers sync translations, see `pkg/i18n/translations/README.md`.
## Debugging
The easiest way to debug lazygit is to have two terminal tabs open at once: one for running lazygit (via `go run main.go -debug` in the project root) and one for viewing lazygit's logs (which can be done via `go run main.go --logs` or just `lazygit --logs`).
From most places in the codebase you have access to a logger e.g. `gui.Log.Warn("blah")` or `self.c.Log.Warn("blah")`.
If you find that the existing logs are too noisy, you can set the log level with e.g. `LOG_LEVEL=warn go run main.go -debug` and then only use `Warn` logs yourself.
If you need to log from code in the vendor directory (e.g. the `gocui` package), you won't have access to the logger, but you can easily add logging support by setting the `LAZYGIT_LOG_PATH` environment variable and using `logs.Global.Warn("blah")`. This is a global logger that's only intended for development purposes.
If you keep having to do some setup steps to reproduce an issue, read the Testing section below to see how to create an integration test by recording a lazygit session. It's pretty easy!
### VSCode debugger
If you want to trigger a debug session from VSCode, you can use the following snippet. Note that the `console` key is, at the time of writing, still an experimental feature.
```jsonc
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "debug lazygit",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "main.go",
"args": ["--debug"],
"console": "externalTerminal" // <-- you need this to actually see the lazygit UI in a window while debugging
}
]
}
```
## Profiling
If you want to investigate what's contributing to CPU or memory usage, see [this separate document](docs/dev/Profiling.md).
## Testing
Lazygit has two kinds of tests: unit tests and integration tests. Unit tests go in files that end in `_test.go`, and are written in Go. For integration tests, see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md)
## Updating Gocui
Sometimes you will need to make a change in the gocui fork (https://github.com/jesseduffield/gocui). Gocui is the package responsible for rendering windows and handling user input. Here's the typical process to follow:
1. Make the changes in gocui inside lazygit's vendor directory so it's easy to test against lazygit
2. Copy the changes over to the actual gocui repo (clone it if you haven't already, and use the `awesome` branch, not `master`)
3. Raise a PR on the gocui repo with your changes
4. After that PR is merged, make a PR in lazygit bumping the gocui version. You can bump the version by running the following at the lazygit repo root:
```sh
./scripts/bump_gocui.sh
```
5. Raise a PR in lazygit with those changes
## Updating Lazycore
[Lazycore](https://github.com/jesseduffield/lazycore) is a repo containing shared functionality between lazygit and lazydocker. Sometimes you will need to make a change to that repo and import the changes into lazygit. Similar to updating Gocui, here's what you do:
1. Make the changes in lazycore inside lazygit's vendor directory so it's easy to test against lazygit
2. Copy the changes over to the actual lazycore repo (clone it if you haven't already, and use the `master` branch)
3. Raise a PR on the lazycore repo with your changes
4. After that PR is merged, make a PR in lazygit bumping the lazycore version. You can bump the version by running the following at the lazygit repo root:
```sh
./scripts/bump_lazycore.sh
```
Or if you're using VSCode, there is a bump lazycore task you can find by going `cmd+shift+p` and typing 'Run task'
5. Raise a PR in lazygit with those changes
## Improvements
If you can think of any way to improve these docs let us know.
Importantly, if you file issues (whether bug reports or feature requests), stay around to answer questions and discuss your issue. There are few things that I find more annoying than spending time on responding to someone's issue (sometimes even making a PR that addresses it), and to then never hear from the OP again. So please set up your Github notifications so that you see when there's activity on your issue, and continue to participate.

BIN
cpu.out Normal file

Binary file not shown.

View file

@ -79,6 +79,22 @@ git:
- externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off
```
This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`:
```sh
#!/bin/sh
git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5"
```
And then use it in your git config like so:
```yaml
git:
pagers:
- externalDiffCommand: ~/bin/color-words.sh
```
Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using
```yaml

View file

@ -79,6 +79,22 @@ git:
- externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off
```
This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`:
```sh
#!/bin/sh
git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5"
```
And then use it in your git config like so:
```yaml
git:
pagers:
- externalDiffCommand: ~/bin/color-words.sh
```
Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using
```yaml

15
go.mod
View file

@ -13,7 +13,7 @@ require (
github.com/cli/go-gh/v2 v2.13.0
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
github.com/creack/pty v1.1.24
github.com/gdamore/tcell/v3 v3.4.0
github.com/gdamore/tcell/v3 v3.4.1
github.com/go-errors/errors v1.5.1
github.com/gookit/color v1.6.1
github.com/integrii/flaggy v1.8.0
@ -38,8 +38,8 @@ require (
github.com/stretchr/testify v1.11.1
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0
golang.org/x/sync v0.22.0
golang.org/x/sys v0.47.0
gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0
gopkg.in/yaml.v3 v3.0.1
)
@ -65,11 +65,10 @@ 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.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/mod v0.37.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
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

32
go.sum
View file

@ -32,8 +32,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v3 v3.4.0 h1:VUym1HQZiYodA5PGQrqLxF7QwqQndcAUwQD7G7XUy5E=
github.com/gdamore/tcell/v3 v3.4.0/go.mod h1:fjKxNiIFwbzTxDU+i+AAMz+xPOgXVaZq5tbShsKseHc=
github.com/gdamore/tcell/v3 v3.4.1 h1:22227t1EUwqxTlmCX9vw0RUE2IEPGw6oYcNan+bPe4w=
github.com/gdamore/tcell/v3 v3.4.1/go.mod h1:YWwuxZNi14VGQC5g2VGNEDRXpBraTwvVjMovRH6G6hw=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
@ -139,19 +139,19 @@ 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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
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/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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
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/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=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20170407050850-f3918c30c5c2/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -163,26 +163,26 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
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/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=

View file

@ -40,7 +40,7 @@ lint:
./scripts/gofumpt-check.sh
./scripts/golangci-lint-shim.sh run
e2e-test-command := "go test pkg/integration/clients/*.go"
e2e-test-command := "go test -timeout 30m pkg/integration/clients/*.go"
# Run integration tests headlessly: no args runs all tests, a test name (or path) runs just that one. Use e2e-cli for a visible UI.
e2e *args:

View file

@ -25,8 +25,9 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild
// the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase)
updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner {
return &gitCmdObjRunner{
log: log,
innerRunner: runner,
log: log,
innerRunner: runner,
initialRetryDelay: defaultInitialRetryDelay,
}
})

View file

@ -11,20 +11,42 @@ import (
// here we're wrapping the default command runner in some git-specific stuff e.g. retry logic if we get an error due to the presence of .git/index.lock
const (
WaitTime = 50 * time.Millisecond
RetryCount = 5
// defaultInitialRetryDelay is how long we wait before the first retry of a
// command that failed with a transient lock error. We double it before each
// subsequent retry (see retryOnLockError), so across maxRetries attempts we
// wait for a bit over a second in total. That's long enough to outlast the
// brief window during which another git process holds a lock we need —
// typically our own foreground `git status` refresh, which takes index.lock
// to persist its refreshed stat-cache.
defaultInitialRetryDelay = 20 * time.Millisecond
maxRetries = 7
)
type gitCmdObjRunner struct {
log *logrus.Entry
innerRunner oscommands.ICmdObjRunner
// initialRetryDelay is the wait before the first lock-error retry. It's a
// field rather than the constant directly so tests can set it to zero and
// not actually sleep.
initialRetryDelay time.Duration
}
// isRetryableError returns true if the error output indicates a transient
// lock-related error that may succeed on retry
func isRetryableError(output string) bool {
return strings.Contains(output, ".git/index.lock") ||
strings.Contains(output, "cannot lock ref")
// isRetryableError returns true if a failed command hit a transient
// lock-related condition that may succeed on retry. The lock message can reach
// us either in the command's captured output or, for streamed commands whose
// output we don't capture, only in the returned error, so we check both.
//
// We match the bare "index.lock" fragment rather than a fuller path or message
// so we catch the lock wherever git puts it: the main .git dir, a linked
// worktree's git dir (.git/worktrees/<name>/index.lock), or a submodule's git
// dir.
func isRetryableError(output string, err error) bool {
text := output
if err != nil {
text += "\n" + err.Error()
}
return strings.Contains(text, "index.lock") ||
strings.Contains(text, "cannot lock ref")
}
func (self *gitCmdObjRunner) Run(cmdObj *oscommands.CmdObj) error {
@ -33,41 +55,44 @@ func (self *gitCmdObjRunner) Run(cmdObj *oscommands.CmdObj) error {
}
func (self *gitCmdObjRunner) RunWithOutput(cmdObj *oscommands.CmdObj) (string, error) {
var output string
var err error
for range RetryCount {
newCmdObj := cmdObj.Clone()
output, err = self.innerRunner.RunWithOutput(newCmdObj)
if err == nil || !isRetryableError(output) {
return output, err
}
// if we have an error based on a lock, we should wait a bit and then retry
self.log.Warn("lock error prevented command from running. Retrying command after a small wait")
time.Sleep(WaitTime)
}
return output, err
return self.retryOnLockError(func() (string, error) {
return self.innerRunner.RunWithOutput(cmdObj.Clone())
})
}
func (self *gitCmdObjRunner) RunWithOutputs(cmdObj *oscommands.CmdObj) (string, string, error) {
var stdout, stderr string
var err error
for range RetryCount {
newCmdObj := cmdObj.Clone()
stdout, stderr, err = self.innerRunner.RunWithOutputs(newCmdObj)
_, err := self.retryOnLockError(func() (string, error) {
var runErr error
stdout, stderr, runErr = self.innerRunner.RunWithOutputs(cmdObj.Clone())
return stdout + stderr, runErr
})
return stdout, stderr, err
}
if err == nil || !isRetryableError(stdout+stderr) {
return stdout, stderr, err
// retryOnLockError runs the given function, retrying if it fails with a
// transient lock error (see isRetryableError). The string returned by run is
// the command output we inspect to classify the failure. We clone the command
// for each attempt (inside run) because an *exec.Cmd can only be run once.
func (self *gitCmdObjRunner) retryOnLockError(run func() (string, error)) (string, error) {
delay := self.initialRetryDelay
var output string
var err error
for attempt := range maxRetries {
output, err = run()
if err == nil || !isRetryableError(output, err) {
break
}
// if we have an error based on a lock, we should wait a bit and then retry
self.log.Warn("lock error prevented command from running. Retrying command after a small wait")
time.Sleep(WaitTime)
if attempt < maxRetries-1 {
self.log.Warnf("lock error prevented command from running; retrying in %s", delay)
time.Sleep(delay)
delay *= 2
}
}
return stdout, stderr, err
return output, err
}
// Retry logic not implemented here, but these commands typically don't need to obtain a lock.

View file

@ -0,0 +1,137 @@
package commands
import (
"errors"
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/stretchr/testify/assert"
)
type runnerResult struct {
output string
err error
}
// scriptedRunner is an ICmdObjRunner stub that returns a preconfigured result
// for each successive call, letting us drive the retry loop deterministically.
// It counts calls so tests can assert whether a command was retried.
type scriptedRunner struct {
results []runnerResult
calls int
}
func (self *scriptedRunner) next() (string, error) {
result := self.results[self.calls]
self.calls++
return result.output, result.err
}
func (self *scriptedRunner) Run(*oscommands.CmdObj) error {
_, err := self.next()
return err
}
func (self *scriptedRunner) RunWithOutput(*oscommands.CmdObj) (string, error) {
return self.next()
}
func (self *scriptedRunner) RunWithOutputs(*oscommands.CmdObj) (string, string, error) {
output, err := self.next()
return output, "", err
}
func (self *scriptedRunner) RunAndProcessLines(*oscommands.CmdObj, func(string) (bool, error)) error {
panic("not implemented")
}
func newTestRunner(inner *scriptedRunner) *gitCmdObjRunner {
return &gitCmdObjRunner{
log: utils.NewDummyLog(),
innerRunner: inner,
// don't actually sleep between retries
initialRetryDelay: 0,
}
}
// dummyCmdObj returns a throwaway command; only its clonability matters, since
// the scriptedRunner ignores it and returns preconfigured results.
func dummyCmdObj() *oscommands.CmdObj {
return oscommands.NewDummyCmdObjBuilder(nil).New([]string{"git", "status"})
}
func TestRunWithOutputReturnsSuccessWithoutRetrying(t *testing.T) {
inner := &scriptedRunner{results: []runnerResult{{output: "done", err: nil}}}
output, err := newTestRunner(inner).RunWithOutput(dummyCmdObj())
assert.NoError(t, err)
assert.Equal(t, "done", output)
assert.Equal(t, 1, inner.calls)
}
func TestRunWithOutputDoesNotRetryNonLockError(t *testing.T) {
inner := &scriptedRunner{results: []runnerResult{{output: "boom", err: errors.New("boom")}}}
_, err := newTestRunner(inner).RunWithOutput(dummyCmdObj())
assert.Error(t, err)
assert.Equal(t, 1, inner.calls)
}
func TestRunWithOutputRetriesWhenLockErrorIsInOutput(t *testing.T) {
inner := &scriptedRunner{results: []runnerResult{
{output: "fatal: Unable to create '/repo/.git/index.lock': File exists.", err: errors.New("exit status 128")},
{output: "done", err: nil},
}}
output, err := newTestRunner(inner).RunWithOutput(dummyCmdObj())
assert.NoError(t, err)
assert.Equal(t, "done", output)
assert.Equal(t, 2, inner.calls)
}
func TestRunWithOutputRetriesWhenLockErrorIsOnlyInError(t *testing.T) {
// A streamed command (e.g. an amend run through the gpg helper) doesn't
// capture its output, so a lock failure surfaces only in the returned error
// with an empty output string. The retry logic must still recognize it.
inner := &scriptedRunner{results: []runnerResult{
{output: "", err: errors.New("fatal: Unable to create '/repo/.git/index.lock': File exists.")},
{output: "", err: nil},
}}
_, err := newTestRunner(inner).RunWithOutput(dummyCmdObj())
assert.NoError(t, err)
assert.Equal(t, 2, inner.calls)
}
func TestRunWithOutputGivesUpAfterMaxRetries(t *testing.T) {
results := make([]runnerResult, maxRetries)
for i := range results {
results[i] = runnerResult{err: errors.New("fatal: Unable to create '/repo/.git/index.lock': File exists.")}
}
inner := &scriptedRunner{results: results}
_, err := newTestRunner(inner).RunWithOutput(dummyCmdObj())
assert.Error(t, err)
assert.Equal(t, maxRetries, inner.calls)
}
func TestRunWithOutputRetriesLockErrorInLinkedWorktree(t *testing.T) {
// In a linked worktree the lock lives at .git/worktrees/<name>/index.lock
// rather than .git/index.lock, so only matching the bare "index.lock"
// fragment lets the retry fire there too.
inner := &scriptedRunner{results: []runnerResult{
{output: "", err: errors.New("fatal: Unable to create '/repo/.git/worktrees/feature/index.lock': File exists.")},
{output: "", err: nil},
}}
_, err := newTestRunner(inner).RunWithOutput(dummyCmdObj())
assert.NoError(t, err)
assert.Equal(t, 2, inner.calls)
}

View file

@ -29,5 +29,5 @@ func (self *BlameCommands) BlameLineRange(filename string, commit string, firstL
Arg("--").
Arg(filename)
return self.cmd.New(cmdArgs.ToArgv()).RunWithOutput()
return self.cmd.New(cmdArgs.ToArgv()).DontLog().RunWithOutput()
}

View file

@ -210,7 +210,10 @@ func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string,
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
// Bound the request so that a dead or extremely slow network can't leave
// the pull-request refresh in flight for minutes. The data is auxiliary,
// so giving up and retrying on the next refresh beats waiting.
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err

View file

@ -43,7 +43,7 @@ func (self *TagCommands) HasTag(tagName string) bool {
Arg("refs/tags/" + tagName).
ToArgv()
return self.cmd.New(cmdArgs).Run() == nil
return self.cmd.New(cmdArgs).DontLog().Run() == nil
}
func (self *TagCommands) LocalDelete(tagName string) error {
@ -74,7 +74,7 @@ func (self *TagCommands) ShowAnnotationInfo(tagName string) (string, error) {
Arg("refs/tags/" + tagName).
ToArgv()
return self.cmd.New(cmdArgs).RunWithOutput()
return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
}
func (self *TagCommands) IsTagAnnotated(tagName string) (bool, error) {
@ -83,6 +83,6 @@ func (self *TagCommands) IsTagAnnotated(tagName string) (bool, error) {
Arg("refs/tags/" + tagName).
ToArgv()
output, err := self.cmd.New(cmdArgs).RunWithOutput()
output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
return strings.TrimSpace(output) == "tag", err
}

View file

@ -227,9 +227,7 @@ type cmdHandler struct {
func (self *cmdObjRunner) runAndStream(cmdObj *CmdObj) error {
return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) {
go func() {
_, _ = io.Copy(cmdWriter, handler.stdoutPipe)
}()
_, _ = io.Copy(cmdWriter, handler.stdoutPipe)
})
}
@ -244,6 +242,10 @@ func (self *cmdObjRunner) runAndStreamAux(
} else {
cmdWriter = self.guiIO.newCmdWriterFn()
}
// The command's stdout and stderr are streamed to cmdWriter concurrently
// from separate goroutines (stderr via the MultiWriter below, stdout via
// onRun), so it must be safe for concurrent writes.
cmdWriter = &synchronizedWriter{writer: cmdWriter}
if cmdObj.ShouldLog() {
self.logCmdObj(cmdObj)
@ -276,10 +278,29 @@ func (self *cmdObjRunner) runAndStreamAux(
t := time.Now()
onRun(handler, cmdWriter)
// Stream the command's output on a goroutine while it runs, but keep a
// handle on it: the buffers it fills (stdout, and combinedOutput when
// output is suppressed) must not be read below until it has finished.
streamingDone := make(chan struct{})
go utils.Safe(func() {
defer close(streamingDone)
onRun(handler, cmdWriter)
})
err = handler.wait()
// The command has exited; wait for the streaming goroutine to drain the
// last of its output before reading those buffers. A pty reader reaches
// EOF on its own now the process is gone, but the non-pty pipe never does,
// so close it to unblock the reader — the pipe is synchronous, so all
// output has already been read by now and nothing is lost.
if !cmdObj.ShouldUsePty() {
if closeErr := handler.close(); closeErr != nil {
self.log.Error(closeErr)
}
}
<-streamingDone
self.log.Infof("%s (%s)", cmdObj.ToString(), time.Since(t))
if err != nil {
@ -354,10 +375,7 @@ func (self *cmdObjRunner) runAndDetectCredentialRequest(
return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) {
tr := io.TeeReader(handler.stdoutPipe, cmdWriter)
go utils.Safe(func() {
self.processOutput(tr, handler.stdinPipe, promptUserForCredential, handler.close, cmdObj)
})
self.processOutput(tr, handler.stdinPipe, promptUserForCredential, handler.close, cmdObj)
})
}
@ -451,6 +469,20 @@ func (self *cmdObjRunner) getCheckForCredentialRequestFunc() func([]byte) (Crede
}
}
// synchronizedWriter serializes writes to its underlying writer so that it can
// be written from multiple goroutines at once (see runAndStreamAux, which
// streams a command's stdout and stderr to one writer from two goroutines).
type synchronizedWriter struct {
mutex deadlock.Mutex
writer io.Writer
}
func (self *synchronizedWriter) Write(p []byte) (int, error) {
self.mutex.Lock()
defer self.mutex.Unlock()
return self.writer.Write(p)
}
type Buffer struct {
b bytes.Buffer
m deadlock.Mutex
@ -482,8 +514,11 @@ func (self *cmdObjRunner) getCmdHandlerNonPty(cmd *exec.Cmd) (*cmdHandler, error
return &cmdHandler{
stdoutPipe: stdoutReader,
stdinPipe: buf,
close: func() error { return nil },
wait: cmd.Wait,
// Closing the read end makes a blocked read on it return, which is how
// runAndStreamAux unblocks and joins the streaming goroutine once the
// command has finished (the pipe delivers no EOF of its own).
close: func() error { return stdoutReader.Close() },
wait: cmd.Wait,
}, nil
}

View file

@ -7,6 +7,7 @@ import (
"sync"
"unsafe"
"github.com/jesseduffield/lazygit/pkg/utils"
"golang.org/x/sys/windows"
)
@ -15,15 +16,13 @@ type winPty struct {
inWrite *os.File
outRead *os.File
// mu guards the teardown state below and serializes it against Resize.
// hpcClosed gates ClosePseudoConsole (it must run exactly once) and also
// keeps Resize from touching the HPCON once it's been freed: the
// background waiter in StartPty closes the pseudoconsole on child exit,
// which would otherwise race a concurrent onResize and hand
// ResizePseudoConsole a freed handle.
// mu guards hpcClosed, which gates ClosePseudoConsole (it must run
// exactly once) and also keeps Resize from touching the HPCON once it's
// been freed: the background waiter in StartPty closes the pseudoconsole
// on child exit, which would otherwise race a concurrent onResize and
// hand ResizePseudoConsole a freed handle.
mu sync.Mutex
hpcClosed bool
closed bool
}
func (p *winPty) Read(buf []byte) (int, error) { return p.outRead.Read(buf) }
@ -49,11 +48,6 @@ func (p *winPty) Resize(cols, rows uint16) error {
func (p *winPty) closeHpc() {
p.mu.Lock()
defer p.mu.Unlock()
p.closeHpcLocked()
}
// closeHpcLocked closes the pseudoconsole; the caller must hold p.mu.
func (p *winPty) closeHpcLocked() {
if p.hpcClosed {
return
}
@ -61,18 +55,31 @@ func (p *winPty) closeHpcLocked() {
windows.ClosePseudoConsole(p.hpc)
}
// Close tears the pty down without waiting for it: the teardown runs on a
// background goroutine and Close returns immediately.
//
// It has to, because ClosePseudoConsole can block for a long time: before
// Windows 11 24H2 it waits for the console host to exit, and since closing
// only delivers CTRL_CLOSE_EVENT to the attached client without terminating
// it, a client that keeps running (git still computing an expensive diff, a
// pager waiting for input) keeps the host — and with it ClosePseudoConsole —
// alive arbitrarily long. Close is called while holding the global PtyMutex
// and while the task's onDone once is executing, where blocking wedges every
// subsequent task for the view (and with it the UI), so none of this may
// happen on the caller's thread.
//
// Within the teardown, the pipe ends must be closed before the
// pseudoconsole, and without holding p.mu: closing the pseudoconsole flushes
// the client's pending output into the out pipe, and with the task stopped
// nobody is reading anymore, so that flush can only complete once the pipe
// is broken. The background waiter's closeHpc may already be wedged in such
// a flush while holding p.mu; closing the pipes is what unblocks it.
func (p *winPty) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return nil
}
p.closed = true
// Closing the pseudoconsole breaks the pipes; the child's next write
// fails and it exits. Then we close our ends of the pipes.
p.closeHpcLocked()
p.inWrite.Close()
p.outRead.Close()
go utils.Safe(func() {
p.inWrite.Close()
p.outRead.Close()
p.closeHpc()
})
return nil
}

View file

@ -6,6 +6,7 @@ import (
"github.com/jesseduffield/generics/maps"
"github.com/samber/lo"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
)
@ -50,6 +51,13 @@ type PatchBuilder struct {
fileInfoMap map[string]*fileInfo
Log *logrus.Entry
// mutex guards the fields that a git worker can mutate (via Reset, at the
// end of a patch-consuming operation) while the UI thread reads them to
// render — chiefly To and the fileInfoMap pointer. The map's *entries* are
// only ever touched on the UI thread, so we only hold the lock long enough
// to read or swap the fields, never across the git I/O in getFileInfo.
mutex deadlock.Mutex
// loadFileDiff loads the diff of a file, for a given to (typically a commit hash)
loadFileDiff loadFileDiffFunc
}
@ -62,6 +70,9 @@ func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBui
}
func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
p.mutex.Lock()
defer p.mutex.Unlock()
p.To = to
p.From = from
p.reverse = reverse
@ -69,10 +80,21 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) {
p.fileInfoMap = map[string]*fileInfo{}
}
// snapshotFileInfoMap returns the current fileInfoMap under the lock. The map's
// entries are only mutated on the UI thread, so callers can read the returned
// map without holding the lock; the lock only serializes the pointer swap that
// Reset/Start do (potentially from a git worker) against these reads.
func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.fileInfoMap
}
func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string {
var patch strings.Builder
for filename, info := range p.fileInfoMap {
for filename, info := range p.snapshotFileInfoMap() {
if info.mode == UNSELECTED {
continue
}
@ -130,12 +152,17 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error {
}
func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileInfo, error) {
info, ok := p.fileInfoMap[filename]
p.mutex.Lock()
fileInfoMap := p.fileInfoMap
from, to, reverse := p.From, p.To, p.reverse
p.mutex.Unlock()
info, ok := fileInfoMap[filename]
if ok {
return info, nil
}
diff, err := p.loadFileDiff(p.From, p.To, p.reverse, filename, previousPath, true)
diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath, true)
if err != nil {
return nil, err
}
@ -145,7 +172,7 @@ func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileI
previousPath: previousPath,
}
p.fileInfoMap[filename] = info
fileInfoMap[filename] = info
return info, nil
}
@ -220,14 +247,16 @@ func (p *PatchBuilder) RenderPatchForFile(opts RenderPatchForFileOpts) string {
}
func (p *PatchBuilder) renderEachFilePatch(plain bool) []string {
fileInfoMap := p.snapshotFileInfoMap()
// sort files by name then iterate through and render each patch
filenames := maps.Keys(p.fileInfoMap)
filenames := maps.Keys(fileInfoMap)
sort.Strings(filenames)
patches := lo.Map(filenames, func(filename string, _ int) string {
return p.RenderPatchForFile(RenderPatchForFileOpts{
Filename: filename,
PreviousPath: p.fileInfoMap[filename].previousPath,
PreviousPath: fileInfoMap[filename].previousPath,
Plain: plain,
Reverse: false,
TurnAddedFilesIntoDiffAgainstEmptyFile: true,
@ -245,11 +274,16 @@ func (p *PatchBuilder) RenderAggregatedPatch(plain bool) string {
}
func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus {
if parent != p.To {
p.mutex.Lock()
to := p.To
fileInfoMap := p.fileInfoMap
p.mutex.Unlock()
if parent != to {
return UNSELECTED
}
info, ok := p.fileInfoMap[filename]
info, ok := fileInfoMap[filename]
if !ok {
return UNSELECTED
}
@ -267,16 +301,22 @@ func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath strin
// clears the patch
func (p *PatchBuilder) Reset() {
p.mutex.Lock()
defer p.mutex.Unlock()
p.To = ""
p.fileInfoMap = map[string]*fileInfo{}
}
func (p *PatchBuilder) Active() bool {
p.mutex.Lock()
defer p.mutex.Unlock()
return p.To != ""
}
func (p *PatchBuilder) IsEmpty() bool {
for _, fileInfo := range p.fileInfoMap {
for _, fileInfo := range p.snapshotFileInfoMap() {
if fileInfo.mode == WHOLE || (fileInfo.mode == PART && len(fileInfo.includedLineIndices) > 0) {
return false
}
@ -287,9 +327,12 @@ func (p *PatchBuilder) IsEmpty() bool {
// if any of these things change we'll need to reset and start a new patch
func (p *PatchBuilder) NewPatchRequired(from string, to string, reverse bool) bool {
p.mutex.Lock()
defer p.mutex.Unlock()
return from != p.From || to != p.To || reverse != p.reverse
}
func (p *PatchBuilder) AllFilesInPatch() []string {
return lo.Keys(p.fileInfoMap)
return lo.Keys(p.snapshotFileInfoMap())
}

View file

@ -0,0 +1,98 @@
package gocui
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestEventWithheldWhileBlocking(t *testing.T) {
scenarios := []struct {
name string
event GocuiEvent
withheld bool
}{
{"key", GocuiEvent{Type: eventKey, Key: NewKeyRune('x')}, true},
{"mouse click", GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)}, true},
{"mouse scroll", GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseWheelDown)}, false},
{"mouse move", GocuiEvent{Type: eventMouseMove}, true},
{"resize", GocuiEvent{Type: eventResize}, false},
{"focus", GocuiEvent{Type: eventFocus}, false},
{"paste", GocuiEvent{Type: eventPaste}, false},
{"error", GocuiEvent{Type: eventError}, false},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
assert.Equal(t, s.withheld, eventWithheldWhileBlocking(&s.event))
})
}
}
// setupKeyRecorder wires a keybinding on a focused view that records each time
// it fires, and returns the key event that triggers it plus the record slice.
func setupKeyRecorder(t *testing.T, g *Gui) (GocuiEvent, *[]int) {
t.Helper()
_, _ = g.SetView("main", 0, 0, 80, 22, 0)
_, err := g.SetCurrentView("main")
assert.NoError(t, err)
fired := []int{}
callCount := 0
key := NewKeyRune('x')
g.SetKeybinding("main", key, func(*Gui, *View) error {
callCount++
fired = append(fired, callCount)
return nil
})
return GocuiEvent{Type: eventKey, Key: key}, &fired
}
func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) {
g := newTestGui(t)
keyEvent, fired := setupKeyRecorder(t, g)
// Not blocking: the key dispatches immediately.
assert.NoError(t, g.handleEvent(&keyEvent))
assert.Len(t, *fired, 1)
// While blocking: the key is buffered, not dispatched.
g.BeginBlockingEvents()
assert.NoError(t, g.handleEvent(&keyEvent))
assert.NoError(t, g.handleEvent(&keyEvent))
assert.Len(t, *fired, 1, "buffered keys must not dispatch while blocking")
// Unblocking replays the buffered keys.
assert.NoError(t, g.EndBlockingEvents())
assert.Len(t, *fired, 3, "both buffered keys should replay on unblock")
assert.Empty(t, g.bufferedKeyEvents)
}
func TestBlockingEvents_NestsWithCounter(t *testing.T) {
g := newTestGui(t)
keyEvent, fired := setupKeyRecorder(t, g)
g.BeginBlockingEvents()
g.BeginBlockingEvents()
assert.NoError(t, g.handleEvent(&keyEvent))
// The inner block ending still leaves us blocked: no replay yet.
assert.NoError(t, g.EndBlockingEvents())
assert.Empty(t, *fired)
// Only the outermost block ending replays.
assert.NoError(t, g.EndBlockingEvents())
assert.Len(t, *fired, 1)
}
func TestBlockingEvents_MouseClicksDroppedNotBuffered(t *testing.T) {
g := newTestGui(t)
g.BeginBlockingEvents()
click := GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)}
assert.NoError(t, g.handleEvent(&click))
assert.Empty(t, g.bufferedKeyEvents, "mouse clicks must be dropped, not buffered")
assert.NoError(t, g.EndBlockingEvents())
}

View file

@ -34,6 +34,14 @@ type escapeInterpreter struct {
// modelled — we don't track the col argument of CUPs, and most
// pager-style emitters use col 1 anyway.
screenRow, screenCol int
// The screen width that soft-wraps are counted against (see
// notifyCellsWritten). It's a snapshot of the view's InnerWidth taken on
// the UI thread (in NewView, and refreshed per render via
// View.SetContentWidth), rather than read live from the view's dimensions:
// a view's output is written from a task goroutine, and reading the live
// dimensions there would race the UI thread updating them during layout.
screenColMax int
}
type (
@ -175,8 +183,8 @@ func (ei *escapeInterpreter) notifyColumnReset() {
// columns; if that crosses the right edge of a `screenColMax`-wide pty
// screen, the corresponding number of soft-wraps are added to screenRow
// so subsequent CUPs land on the right line.
func (ei *escapeInterpreter) notifyCellsWritten(width, screenColMax int) {
if screenColMax <= 0 {
func (ei *escapeInterpreter) notifyCellsWritten(width int) {
if ei.screenColMax <= 0 {
return
}
// One column at a time: matches ConPTY's "pending wrap" semantics
@ -185,7 +193,7 @@ func (ei *escapeInterpreter) notifyCellsWritten(width, screenColMax int) {
// columns rather than doing the math in one shot so wide cells on a
// row boundary still wrap cleanly.
for range width {
if ei.screenCol > screenColMax {
if ei.screenCol > ei.screenColMax {
ei.screenRow++
ei.screenCol = 1
}

View file

@ -39,15 +39,15 @@ func setupViews(t *testing.T, g *Gui) (*View, *View) {
return status, main
}
// pushContentOnly pushes a content-only event directly to the channel
// (synchronous, deterministic — unlike Update which spawns a goroutine).
// pushContentOnly enqueues a content-only event directly, letting the test
// control the contentOnly flag (which Update/UpdateContentOnly hard-code).
func pushContentOnly(g *Gui, f func(*Gui) error) {
g.userEvents <- userEvent{f: f, task: g.NewTask(), contentOnly: true}
g.userEvents.enqueue(userEvent{f: f, task: g.NewTask(), contentOnly: true})
}
// pushRegular pushes a regular event directly to the channel.
// pushRegular enqueues a regular (non-content-only) event directly.
func pushRegular(g *Gui, f func(*Gui) error) {
g.userEvents <- userEvent{f: f, task: g.NewTask(), contentOnly: false}
g.userEvents.enqueue(userEvent{f: f, task: g.NewTask(), contentOnly: false})
}
func TestFlushContentOnly_SkipsUntaintedViews(t *testing.T) {

View file

@ -125,15 +125,18 @@ type clickInfo struct {
// and keybindings.
type Gui struct {
RecordingConfig
// ReplayedEvents is for passing pre-recorded input events, for the purposes of testing
ReplayedEvents replayedEvents
// replayedEvents is for passing simulated input events, for the purposes
// of testing. Events must be submitted through the Replay* methods, which
// attach a task to each event; pushing into the channels directly would
// bypass the busy-tracking that integration tests rely on.
replayedEvents replayedEvents
playRecording bool
tabClickBindings []*tabClickBinding
viewMouseBindings []*ViewMouseBinding
lastClick *clickInfo
gEvents chan GocuiEvent
userEvents chan userEvent
userEvents *userEventQueue
views []*View
currentView *View
managers []Manager
@ -145,6 +148,10 @@ type Gui struct {
maxX, maxY int
outputMode OutputMode
stop chan struct{}
// loopExited is closed when MainLoop returns, so callers (e.g. the
// integration-test harness) can wait for the event loop to actually finish
// rather than polling or sleeping a fixed interval.
loopExited chan struct{}
// BgColor and FgColor allow to configure the background and foreground
// colors of the GUI.
@ -207,6 +214,14 @@ type Gui struct {
// MainLoop starts. IsUIThread compares against it. Written once, read from
// worker goroutines, so it's atomic.
uiThreadID atomic.Int64
// 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
// dropped outright. It's a counter so blocking can nest. Both fields are
// only touched on the UI thread. See BeginBlockingEvents.
blockInputCount int
bufferedKeyEvents []GocuiEvent
}
type NewGuiOpts struct {
@ -249,18 +264,14 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
g.outputMode = opts.OutputMode
g.stop = make(chan struct{})
g.loopExited = make(chan struct{})
g.gEvents = make(chan GocuiEvent, 20)
// Update does a non-blocking send and panics on a full channel rather than
// blocking (which would deadlock the UI goroutine against itself) or
// silently reordering. The buffer is sized well above the peak occupancy we
// see in practice, so the panic stays unreachable in normal use; if it ever
// fires, that's a real anomaly to investigate, not a cue to grow the buffer.
g.userEvents = make(chan userEvent, 256)
g.userEvents = newUserEventQueue()
g.taskManager = newTaskManager()
if opts.PlayRecording {
g.ReplayedEvents = replayedEvents{
g.replayedEvents = replayedEvents{
Keys: make(chan *TcellKeyEventWrapper),
Resizes: make(chan *TcellResizeEventWrapper),
MouseEvents: make(chan *TcellMouseEventWrapper),
@ -282,6 +293,12 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
g.playRecording = opts.PlayRecording
// Record the UI thread here, at construction. This assumes NewGui is called
// on the same goroutine that will run MainLoop, which holds for all our
// callers -- and it means IsUIThread is already correct for the UI work that
// runs during startup, before we reach MainLoop.
g.uiThreadID.Store(goid.Get())
return g, nil
}
@ -296,6 +313,30 @@ func (g *Gui) NewBackgroundTask() *TaskImpl {
return g.taskManager.NewTask(true)
}
// ReplayKeyEvent simulates a key press, as if the user had typed it. It's used
// by integration tests. The event carries a task, so that the program counts
// as busy from before the event is submitted until the main loop has fully
// processed it; the test driver relies on this when it waits for the program
// to go idle after submitting an event. (If the task were only created once
// the main loop picks the event up, there would be a window in which the event
// is still in flight but nothing counts as busy.)
func (g *Gui) ReplayKeyEvent(ev *TcellKeyEventWrapper) {
ev.task = g.NewTask()
g.replayedEvents.Keys <- ev
}
// ReplayMouseEvent is like ReplayKeyEvent, but for mouse events.
func (g *Gui) ReplayMouseEvent(ev *TcellMouseEventWrapper) {
ev.task = g.NewTask()
g.replayedEvents.MouseEvents <- ev
}
// ReplayFocusEvent is like ReplayKeyEvent, but for focus events.
func (g *Gui) ReplayFocusEvent(ev *TcellFocusEventWrapper) {
ev.task = g.NewTask()
g.replayedEvents.FocusEvents <- ev
}
// Busy reports whether any foreground work is in flight, ignoring the event
// currently being processed on the main goroutine (see currentTask). Background
// routines (auto-fetch etc.) don't count. It's used to decide whether it's safe
@ -304,11 +345,11 @@ func (g *Gui) Busy() bool {
return g.taskManager.hasBusyForegroundTaskExcept(g.currentTask)
}
// An idle listener listens for when the program is idle. This is useful for
// integration tests which can wait for the program to be idle before taking
// the next step in the test.
func (g *Gui) AddIdleListener(c chan struct{}) {
g.taskManager.addIdleListener(c)
// WaitUntilIdle blocks until the program is idle (no busy tasks). This is
// useful for integration tests which want to wait for the program to finish
// processing before taking the next step in the test.
func (g *Gui) WaitUntilIdle() {
g.taskManager.WaitUntilIdle()
}
// Close finalizes the library. It should be called after a successful
@ -318,6 +359,11 @@ func (g *Gui) Close() {
Screen.Fini()
}
// LoopExited returns a channel that is closed once MainLoop has returned.
func (g *Gui) LoopExited() <-chan struct{} {
return g.loopExited
}
// Size returns the terminal's size.
func (g *Gui) Size() (x, y int) {
return g.maxX, g.maxY
@ -355,7 +401,7 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er
v.y1 = y1
if sizeChanged {
v.clearViewLines()
v.ClearViewLines()
if v.Editable {
cursorX, cursorY := v.TextArea.GetCursorXY()
@ -636,6 +682,13 @@ func (g *Gui) SetRenderSearchStatusFunc(renderSearchStatusFunc func(*View, int,
g.renderSearchStatusFunc = renderSearchStatusFunc
}
// SetUpdateQueueHighWaterMarkHandler registers a diagnostic callback invoked
// with the new depth whenever the queue of pending Update callbacks reaches a
// new maximum. It may be called from any goroutine.
func (g *Gui) SetUpdateQueueHighWaterMarkHandler(f func(depth int)) {
g.userEvents.setHighWaterMarkHandler(f)
}
// userEvent represents an event triggered by the user.
type userEvent struct {
f func(*Gui) error
@ -646,15 +699,99 @@ type userEvent struct {
contentOnly bool
}
// Update enqueues f on the user-events channel for the UI loop to run on its
// next iteration. Multiple Update calls from the same goroutine arrive in
// source order via the channel's FIFO. The send is non-blocking — if the
// channel is full we panic rather than block or silently reorder, since a
// blocked send from the UI goroutine would deadlock against itself and
// silently switching to inline execution would break the ordering guarantee
// callers rely on. The buffer is sized generously enough that this should
// never fire in practice; if it does, that's a signal to investigate, not
// to grow the buffer reflexively.
// userEventQueue is an unbounded, order-preserving FIFO of work enqueued by
// Update and friends for the main loop to run.
//
// It's unbounded (rather than a fixed-size channel) because producers must
// never block or lose work. Update can be called from the UI goroutine itself,
// where a blocking send would deadlock against the loop that drains the queue;
// and it can be called from arbitrary worker goroutines that may enqueue faster
// than the loop drains. That happens while the loop is stalled — suspended for
// a subprocess (the editor runs on the UI thread), or hung in a long handler —
// and also when a long-running worker operation emits a steady stream of
// updates that outpaces the loop (e.g. the waiting-status spinner ticks while a
// large directory is toggled into a custom patch). A fixed channel forces a
// choice between blocking (deadlock), dropping or reordering, and panicking on
// overflow; an unbounded queue avoids all three while preserving FIFO order.
//
// enqueue appends under the mutex and rings the doorbell; the main loop selects
// on the doorbell to wake, then drains the slice to empty. The doorbell is
// buffered(1) and rung with a non-blocking send, so it's a coalescing "work
// pending" flag rather than a per-event signal: a burst of appends leaves at
// most one token, and the loop drains everything the token represents on a
// single wake. A token left over after a drain (because the drain happened to
// empty the slice after the ring) just causes one harmless empty wake.
type userEventQueue struct {
mutex sync.Mutex
events []userEvent
doorbell chan struct{}
// highWaterMark is the deepest the queue has ever been, and
// onHighWaterMark (if set) is called with the new depth each time that
// record is broken. Purely diagnostic: it lets us see how deep the queue
// gets in practice (see SetUpdateQueueHighWaterMarkHandler).
highWaterMark int
onHighWaterMark func(int)
}
func newUserEventQueue() *userEventQueue {
return &userEventQueue{doorbell: make(chan struct{}, 1)}
}
// enqueue appends an event and wakes the main loop. It never blocks.
func (q *userEventQueue) enqueue(ev userEvent) {
q.mutex.Lock()
q.events = append(q.events, ev)
newHighWaterMark := 0
if len(q.events) > q.highWaterMark {
q.highWaterMark = len(q.events)
newHighWaterMark = q.highWaterMark
}
onHighWaterMark := q.onHighWaterMark
q.mutex.Unlock()
// Report outside the lock: the handler does I/O (logging) and must not
// stall other producers or the draining loop.
if newHighWaterMark > 0 && onHighWaterMark != nil {
onHighWaterMark(newHighWaterMark)
}
select {
case q.doorbell <- struct{}{}:
default:
}
}
func (q *userEventQueue) setHighWaterMarkHandler(f func(int)) {
q.mutex.Lock()
q.onHighWaterMark = f
q.mutex.Unlock()
}
// dequeue pops the oldest event, reporting false when the queue is empty.
func (q *userEventQueue) dequeue() (userEvent, bool) {
q.mutex.Lock()
defer q.mutex.Unlock()
if len(q.events) == 0 {
return userEvent{}, false
}
ev := q.events[0]
if len(q.events) == 1 {
// Release the backing array whenever the queue drains, so a one-off
// burst doesn't pin its peak size for the rest of the session.
q.events = nil
} else {
q.events[0] = userEvent{}
q.events = q.events[1:]
}
return ev, true
}
// Update enqueues f for the UI loop to run on its next iteration. Multiple
// Update calls from the same goroutine arrive in source order (the queue is
// FIFO). The enqueue never blocks and never drops work; see userEventQueue for
// why the queue is unbounded.
func (g *Gui) Update(f func(*Gui) error) {
g.update(f, false)
}
@ -668,12 +805,7 @@ func (g *Gui) UpdateBackground(f func(*Gui) error) {
func (g *Gui) update(f func(*Gui) error, background bool) {
task := g.taskManager.NewTask(background)
select {
case g.userEvents <- userEvent{f: f, task: task}:
default:
panic("gocui: userEvents channel full; refusing to block or reorder")
}
g.userEvents.enqueue(userEvent{f: f, task: task})
}
// Like Update, but signals that the callback only modifies content.
@ -688,7 +820,7 @@ func (g *Gui) UpdateContentOnlyBackground(f func(*Gui) error) {
func (g *Gui) updateContentOnly(f func(*Gui) error, background bool) {
task := g.taskManager.NewTask(background)
g.userEvents <- userEvent{f: f, task: task, contentOnly: true}
g.userEvents.enqueue(userEvent{f: f, task: task, contentOnly: true})
}
// IsUIThread reports whether the caller is running on the main event-loop
@ -698,6 +830,42 @@ func (g *Gui) IsUIThread() bool {
return goid.Get() == g.uiThreadID.Load()
}
// BeginBlockingEvents starts withholding keyboard input from the handlers, so a
// long-running operation can't be disrupted by keys the user presses while it
// runs. Keys are buffered and replayed once EndBlockingEvents balances this
// call; mouse clicks and hover are dropped for the duration. Scrolling,
// resizing, focus changes and all rendering keep working throughout. It's a
// counter, so blocking nests; every call must be paired with EndBlockingEvents.
//
// Must be called on the UI thread. Callers arrange this by beginning the block
// synchronously from the keybinding handler, before dispatching the operation
// to a worker — beginning it from the worker would race the next queued
// keypress, which is exactly the input we mean to withhold.
func (g *Gui) BeginBlockingEvents() {
g.blockInputCount++
}
// EndBlockingEvents balances a BeginBlockingEvents call. When the last nested
// block ends, the keys buffered while blocked are replayed in order through the
// normal dispatch path, so they act on the now-current context (a key whose
// binding no longer exists is simply ignored, just as if it had been pressed
// now). Must be called on the UI thread.
func (g *Gui) EndBlockingEvents() error {
g.blockInputCount--
if g.blockInputCount > 0 {
return nil
}
buffered := g.bufferedKeyEvents
g.bufferedKeyEvents = nil
for i := range buffered {
if err := g.handleEvent(&buffered[i]); err != nil {
return err
}
}
return nil
}
// 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.
@ -813,7 +981,7 @@ func (g *Gui) SetManagerFunc(manager func(*Gui) error) {
// MainLoop runs the main loop until an error is returned. A successful
// finish should return ErrQuit.
func (g *Gui) MainLoop() error {
g.uiThreadID.Store(goid.Get())
defer close(g.loopExited)
go func() {
for {
@ -867,14 +1035,26 @@ func (g *Gui) processEvent() error {
// are always the primary event here.
select {
case ev := <-g.gEvents:
task := g.NewTask()
// Replayed test events already carry their task (see ReplayKeyEvent);
// organic events get theirs here.
task := ev.task
if task == nil {
task = g.NewTask()
}
g.currentTask = task
defer func() { g.currentTask = nil; task.Done() }()
if err := g.handleError(g.handleEvent(&ev)); err != nil {
return err
}
case ev := <-g.userEvents:
case <-g.userEvents.doorbell:
ev, ok := g.userEvents.dequeue()
if !ok {
// A leftover doorbell token whose events were already drained by a
// previous iteration's processRemainingEvents: nothing to run and
// nothing new to render.
return nil
}
contentOnly = ev.contentOnly
g.currentTask = ev.task
defer func() { g.currentTask = nil; ev.task.Done() }()
@ -904,18 +1084,27 @@ func (g *Gui) processRemainingEvents() (bool, error) {
select {
case ev := <-g.gEvents:
contentOnly = false
if err := g.handleError(g.handleEvent(&ev)); err != nil {
err := g.handleError(g.handleEvent(&ev))
if ev.task != nil {
ev.task.Done()
}
if err != nil {
return false, err
}
case ev := <-g.userEvents:
default:
// No gui event is pending; drain a queued user event instead.
// gui events take priority so input stays responsive, but they're
// bounded (buffer of 20), so this can't starve the user-event queue.
ev, ok := g.userEvents.dequeue()
if !ok {
return contentOnly, nil
}
contentOnly = ev.contentOnly && contentOnly
err := g.handleError(ev.f(g))
ev.task.Done()
if err != nil {
return false, err
}
default:
return contentOnly, nil
}
}
}
@ -923,6 +1112,17 @@ func (g *Gui) processRemainingEvents() (bool, error) {
// handleEvent handles an event, based on its type (key-press, error,
// etc.)
func (g *Gui) handleEvent(ev *GocuiEvent) error {
if g.blockInputCount > 0 && eventWithheldWhileBlocking(ev) {
if ev.Type == eventKey {
// Buffer keys so they replay against fresh state on unblock.
g.bufferedKeyEvents = append(g.bufferedKeyEvents, *ev)
}
// Mouse clicks and hover fall through to here without being buffered:
// replaying them once the operation has changed the layout underneath
// them would target the wrong thing, so we drop them outright.
return nil
}
switch ev.Type {
case eventKey, eventMouse, eventMouseMove:
return g.onKey(ev)
@ -941,6 +1141,24 @@ func (g *Gui) handleEvent(ev *GocuiEvent) error {
}
}
// eventWithheldWhileBlocking reports whether an event must not reach the
// handlers while input is blocked (see BeginBlockingEvents). Key events are
// withheld (buffered for replay); mouse clicks and hover are withheld (dropped).
// Everything else — mouse scrolling, resize, focus, paste, errors — flows
// through as usual.
func eventWithheldWhileBlocking(ev *GocuiEvent) bool {
switch ev.Type {
case eventKey:
return true
case eventMouse:
return !IsMouseScrollKey(ev.Key.KeyName())
case eventMouseMove:
return true
default:
return false
}
}
func (g *Gui) onResize() {
// not sure if we actually need this
// g.screen.Sync()
@ -1252,6 +1470,11 @@ func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
// flush updates the gui, re-drawing frames and buffers.
func (g *Gui) flush() error {
// The screen must not be touched while suspended (see Suspend).
if g.isSuspended() {
return nil
}
// pretty sure we don't need this, but keeping it here in case we get weird visual artifacts
// g.clear(g.FgColor, g.BgColor)
@ -1259,7 +1482,7 @@ func (g *Gui) flush() error {
// if GUI's size has changed, we need to redraw all views
if maxX != g.maxX || maxY != g.maxY {
for _, v := range g.views {
v.clearViewLines()
v.ClearViewLines()
}
}
g.maxX, g.maxY = maxX, maxY
@ -1284,6 +1507,11 @@ func (g *Gui) flush() error {
// actually-changed cells are emitted to the terminal.
// Will also redraw any views that overlap tainted views
func (g *Gui) flushContentOnly(views []*View) error {
// The screen must not be touched while suspended (see Suspend).
if g.isSuspended() {
return nil
}
for _, v := range viewsToRedrawContentOnly(views) {
if err := g.draw(v); err != nil {
return err
@ -1298,7 +1526,7 @@ func viewsToRedrawContentOnly(views []*View) []*View {
redrawIndexes := set.New[int]()
for i, v := range views {
if !v.tainted && !redrawIndexes.Includes(i) {
if !v.IsTainted() && !redrawIndexes.Includes(i) {
continue
}
@ -1337,10 +1565,6 @@ func (g *Gui) ForceFlushViewsContentOnly(views []*View) error {
// draw manages the cursor and calls the draw function of a view.
func (g *Gui) draw(v *View) error {
if g.suspended {
return nil
}
if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 {
return nil
}
@ -1712,6 +1936,14 @@ func (g *Gui) onFocus(ev *GocuiEvent) error {
return nil
}
// While g.suspended is true, nothing must be drawn to the screen: tcell
// releases the screen's cell buffer when disengaging, and drawing to a
// disengaged screen spins forever inside tcell while holding the screen lock,
// which then blocks Resume (and with it all further input) forever. For the
// flag to guarantee that, it must only ever be false while the screen is
// engaged: Suspend sets it before disengaging, and Resume clears it only
// after re-engaging.
func (g *Gui) Suspend() error {
g.suspendedMutex.Lock()
defer g.suspendedMutex.Unlock()
@ -1722,7 +1954,12 @@ func (g *Gui) Suspend() error {
g.suspended = true
return g.screen.Suspend()
if err := g.screen.Suspend(); err != nil {
g.suspended = false
return err
}
return nil
}
func (g *Gui) Resume() error {
@ -1733,9 +1970,25 @@ func (g *Gui) Resume() error {
return errors.New("Cannot resume because we are not suspended")
}
if err := g.screen.Resume(); err != nil {
return err
}
g.suspended = false
return g.screen.Resume()
// Schedule a redraw of the whole screen. Nothing else guarantees one:
// flushes are skipped while suspended, and after re-engaging the screen
// the terminal shows nothing until we draw again.
go func() { g.gEvents <- GocuiEvent{Type: eventResize} }()
return nil
}
func (g *Gui) isSuspended() bool {
g.suspendedMutex.Lock()
defer g.suspendedMutex.Unlock()
return g.suspended
}
// matchView returns if the keybinding matches the current view (and the view's context)

69
pkg/gocui/suspend_test.go Normal file
View file

@ -0,0 +1,69 @@
package gocui
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
// A flush while suspended must return without touching the screen: tcell
// releases the screen's cell buffer when disengaging, and drawing to a
// disengaged screen spins forever inside tcell while holding the screen lock,
// blocking the resume triggered by fg (#5309). The flush runs in a goroutine
// so that a regression fails the test instead of hanging the suite.
func TestFlushIsNoOpWhileSuspended(t *testing.T) {
tests := []struct {
name string
flush func(g *Gui) error
}{
{"flush", func(g *Gui) error { return g.flush() }},
{"flushContentOnly", func(g *Gui) error { return g.flushContentOnly(g.views) }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Deliberately not newTestGui: its cleanup closes the screen,
// which would deadlock on the screen lock if a regression makes
// the flush below spin.
g, err := NewGui(NewGuiOpts{
OutputMode: OutputNormal,
Headless: true,
Width: 80,
Height: 24,
})
assert.NoError(t, err)
assert.NoError(t, g.Suspend())
flushReturned := make(chan error, 1)
go func() { flushReturned <- tc.flush(g) }()
select {
case err := <-flushReturned:
assert.NoError(t, err)
case <-time.After(time.Second):
t.Fatal("flush touched the suspended screen and got stuck")
}
assert.NoError(t, g.Resume())
g.Close()
})
}
}
func TestResumeSchedulesRedraw(t *testing.T) {
g := newTestGui(t)
assert.NoError(t, g.Suspend())
assert.NoError(t, g.Resume())
ev := GocuiEvent{Type: eventNone}
select {
case ev = <-g.gEvents:
case <-time.After(100 * time.Millisecond):
}
assert.Equal(t, eventResize, ev.Type,
"resuming must schedule a redraw; without one the screen stays blank until the next event arrives")
}

View file

@ -6,20 +6,23 @@ import "sync"
// the main goroutine or a worker goroutine). Used by integration tests
// to wait until the program is idle before progressing.
type TaskManager struct {
// each of these listeners will be notified when the program goes from busy to idle
idleListeners []chan struct{}
tasks map[int]Task
tasks map[int]Task
// auto-incrementing id for new tasks
nextId int
mutex sync.Mutex
// signalled whenever the program transitions from busy to idle; used by
// WaitUntilIdle
idleCond *sync.Cond
}
func newTaskManager() *TaskManager {
return &TaskManager{
tasks: make(map[int]Task),
idleListeners: []chan struct{}{},
self := &TaskManager{
tasks: make(map[int]Task),
}
self.idleCond = sync.NewCond(&self.mutex)
return self
}
func (self *TaskManager) NewTask(background bool) *TaskImpl {
@ -58,8 +61,26 @@ func (self *TaskManager) hasBusyForegroundTaskExcept(ignore Task) bool {
return false
}
func (self *TaskManager) addIdleListener(c chan struct{}) {
self.idleListeners = append(self.idleListeners, c)
// WaitUntilIdle blocks until no task is busy. Integration tests use it to wait
// for the program to finish processing before taking the next step.
func (self *TaskManager) WaitUntilIdle() {
self.mutex.Lock()
defer self.mutex.Unlock()
for self.hasBusyTask() {
self.idleCond.Wait()
}
}
// caller must hold self.mutex
func (self *TaskManager) hasBusyTask() bool {
for _, task := range self.tasks {
if task.isBusy() {
return true
}
}
return false
}
func (self *TaskManager) withMutex(f func()) {
@ -68,17 +89,12 @@ func (self *TaskManager) withMutex(f func()) {
f()
// Check if all tasks are done
for _, task := range self.tasks {
if task.isBusy() {
return
}
}
// If we get here, all tasks are done, so
// notify listeners that the program is idle
for _, listener := range self.idleListeners {
listener <- struct{}{}
// Wake up any goroutine blocked in WaitUntilIdle. This must not block on
// the waiter (we hold the mutex, and the waiter may itself be trying to
// acquire it, e.g. by creating a task, before it next waits) — which is
// exactly what Broadcast guarantees.
if !self.hasBusyTask() {
self.idleCond.Broadcast()
}
}

View file

@ -2,6 +2,7 @@ package gocui
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
@ -61,3 +62,68 @@ func TestTaskManagerHasBusyForegroundTaskExcept(t *testing.T) {
assert.False(t, tm.hasBusyForegroundTaskExcept(current))
})
}
func TestTaskManagerWaitUntilIdle(t *testing.T) {
// returnsWithin reports whether f returns within the given duration.
returnsWithin := func(d time.Duration, f func()) bool {
done := make(chan struct{})
go func() {
f()
close(done)
}()
select {
case <-done:
return true
case <-time.After(d):
return false
}
}
t.Run("returns immediately when no task was ever created", func(t *testing.T) {
tm := newTaskManager()
assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle))
})
t.Run("blocks while a task is busy", func(t *testing.T) {
tm := newTaskManager()
tm.NewTask(false)
assert.False(t, returnsWithin(50*time.Millisecond, tm.WaitUntilIdle))
})
t.Run("wakes up when the last busy task completes", func(t *testing.T) {
tm := newTaskManager()
task := tm.NewTask(false)
go func() {
time.Sleep(10 * time.Millisecond)
task.Done()
}()
assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle))
})
t.Run("a paused task counts as idle", func(t *testing.T) {
tm := newTaskManager()
task := tm.NewTask(false)
task.Pause()
assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle))
})
t.Run("a task completing while nobody waits must not block", func(t *testing.T) {
// This is the deadlock case: the waiter (the integration-test runner)
// is between waits, and itself needs the task manager's mutex (it
// creates a task whenever it enqueues work) before it waits again. The
// idle notification must neither block the completing task while it
// holds the mutex, nor get lost.
tm := newTaskManager()
assert.True(t, returnsWithin(time.Second, func() {
// the program goes idle with nobody waiting...
tm.NewTask(true).Done()
// ...and creating and completing more tasks afterwards must still
// be possible
task := tm.NewTask(false)
tm.NewTask(false).Done()
task.Done()
}))
assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle))
})
}

View file

@ -172,6 +172,12 @@ type GocuiEvent struct {
Focused bool
Start bool
N int
// task tracks the processing of this event for idle detection. Events
// replayed by integration tests carry a task from the moment they are
// submitted (see Gui.ReplayKeyEvent); for organic events it is nil, and
// the main loop creates a task when it picks the event up.
task Task
}
// Event types.
@ -208,6 +214,8 @@ type TcellKeyEventWrapper struct {
Mod tcell.ModMask
Key tcell.Key
Ch string
task Task // see GocuiEvent.task
}
func NewTcellKeyEventWrapper(event *tcell.EventKey, timestamp int64) *TcellKeyEventWrapper {
@ -229,6 +237,8 @@ type TcellMouseEventWrapper struct {
Y int
ButtonMask tcell.ButtonMask
ModMask tcell.ModMask
task Task // see GocuiEvent.task
}
func NewTcellMouseEventWrapper(event *tcell.EventMouse, timestamp int64) *TcellMouseEventWrapper {
@ -269,6 +279,8 @@ func (wrapper TcellResizeEventWrapper) toTcellEvent() tcell.Event {
type TcellFocusEventWrapper struct {
Timestamp int64
Focused bool
task Task // see GocuiEvent.task
}
func NewTcellFocusEventWrapper(event *tcell.EventFocus, timestamp int64) *TcellFocusEventWrapper {
@ -285,21 +297,31 @@ func (wrapper TcellFocusEventWrapper) toTcellEvent() tcell.Event {
// pollEvent get tcell.Event and transform it into gocuiEvent
func (g *Gui) pollEvent() GocuiEvent {
var tev tcell.Event
var task Task
if g.playRecording {
select {
case ev := <-g.ReplayedEvents.Keys:
case ev := <-g.replayedEvents.Keys:
tev = (ev).toTcellEvent()
case ev := <-g.ReplayedEvents.Resizes:
task = ev.task
case ev := <-g.replayedEvents.Resizes:
tev = (ev).toTcellEvent()
case ev := <-g.ReplayedEvents.MouseEvents:
case ev := <-g.replayedEvents.MouseEvents:
tev = (ev).toTcellEvent()
case ev := <-g.ReplayedEvents.FocusEvents:
task = ev.task
case ev := <-g.replayedEvents.FocusEvents:
tev = (ev).toTcellEvent()
task = ev.task
}
} else {
tev = <-Screen.EventQ()
}
event := gocuiEventFromTcellEvent(tev)
event.task = task
return event
}
func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
switch tev := tev.(type) {
case *tcell.EventInterrupt:
return GocuiEvent{Type: eventInterrupt}

View file

@ -0,0 +1,111 @@
package gocui
import (
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
// Enqueuing far more events than the old fixed 256-slot buffer, without the
// main loop draining them, used to panic ("userEvents channel full"). It must
// not: producers can legitimately burst faster than a stalled UI loop drains
// (e.g. one command-log entry per git command when adding a large directory to
// a custom patch, or any producer while the loop is blocked in a subprocess).
// The events must also stay in FIFO order.
func TestUpdateIsUnboundedAndPreservesOrder(t *testing.T) {
g := newTestGui(t)
const n = 1000
var got []int
for i := range n {
g.Update(func(*Gui) error {
got = append(got, i)
return nil
})
}
// Drain the whole queue the way the main loop's inner drain does.
_, err := g.processRemainingEvents()
assert.NoError(t, err)
want := make([]int, n)
for i := range want {
want[i] = i
}
assert.Equal(t, want, got)
}
// The high-water-mark handler fires only when the queue reaches a new maximum
// depth, reporting that depth. It does not reset when the queue drains.
func TestUpdateQueueHighWaterMark(t *testing.T) {
g := newTestGui(t)
var marks []int
g.SetUpdateQueueHighWaterMarkHandler(func(depth int) { marks = append(marks, depth) })
noop := func(*Gui) error { return nil }
// Three enqueues with no drain: new highs 1, 2, 3.
g.Update(noop)
g.Update(noop)
g.Update(noop)
_, err := g.processRemainingEvents()
assert.NoError(t, err)
// Two enqueues stay below the previous high of 3: no new marks.
g.Update(noop)
g.Update(noop)
_, err = g.processRemainingEvents()
assert.NoError(t, err)
// Four enqueues with no drain: only depth 4 beats the previous high.
for range 4 {
g.Update(noop)
}
assert.Equal(t, []int{1, 2, 3, 4}, marks)
}
// Concurrent producers must be able to enqueue safely (run under -race). Only
// same-goroutine order is guaranteed, so we check that every event is delivered
// exactly once and that each producer's own events stay in order.
func TestUpdateConcurrentProducers(t *testing.T) {
g := newTestGui(t)
const producers = 8
const perProducer = 500
type item struct{ producer, seq int }
var got []item
var wg sync.WaitGroup
for p := range producers {
wg.Add(1)
go func() {
defer wg.Done()
for seq := range perProducer {
g.Update(func(*Gui) error {
got = append(got, item{p, seq})
return nil
})
}
}()
}
// Update is a synchronous, non-blocking enqueue, so once every producer has
// returned, every event is in the queue and a single drain sees them all.
wg.Wait()
_, err := g.processRemainingEvents()
assert.NoError(t, err)
assert.Len(t, got, producers*perProducer)
lastSeq := make([]int, producers)
for p := range lastSeq {
lastSeq[p] = -1
}
for _, it := range got {
assert.Equal(t, lastSeq[it.producer]+1, it.seq, "producer %d events out of order", it.producer)
lastSeq[it.producer] = it.seq
}
}

View file

@ -7,6 +7,7 @@ package gocui
import (
"fmt"
"io"
"slices"
"strings"
"sync"
"unicode"
@ -50,6 +51,14 @@ type View struct {
// tained is true if the viewLines must be updated
tainted bool
// firstDirtyLine is the index of the lowest line in `lines` that has been
// written to or highlighted since viewLines was last refreshed, and whose
// cached wrapping (lineType.wrappedCells) may therefore be stale. Lines
// below it are unchanged and can reuse their cached wrapping instead of
// being re-wrapped, which keeps refreshViewLinesIfNeeded cheap while
// scrolling appends new lines to a long buffer.
firstDirtyLine int
// the last position that the mouse was hovering over; nil if the mouse is outside of
// this view, or not hovering over a cell
lastHoverPosition *pos
@ -206,6 +215,16 @@ func (v *View) clearViewLines() {
v.clearHover()
}
// ClearViewLines is clearViewLines guarded by writeMutex. It's for callers on
// the UI thread (the layout pass) that touch a view whose content a task
// goroutine may be writing concurrently: viewLines/tainted/hover are all
// buffer state that writeMutex protects.
func (v *View) ClearViewLines() {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
v.clearViewLines()
}
type searcher struct {
searchString string
searchPositions []SearchPosition
@ -457,6 +476,16 @@ type viewLine struct {
type lineType struct {
cells cells
trailingFillAttributes *trailingFillAttributes
// wrappedCells caches the result of wrapping `cells` to `wrappedColumns`
// columns, so that unchanged lines don't have to be re-wrapped on every
// refreshViewLinesIfNeeded (which runs on every scroll event, via
// ViewLinesHeight). Wrapping measures every cell's width and allocates, so
// for a long buffer that dominates the cost of scrolling. The cache is used
// only for lines below View.firstDirtyLine whose wrappedColumns still
// matches the current width; nil means nothing is cached yet.
wrappedCells [][]cell
wrappedColumns int
}
// trailingFillAttributes describes the fg/bg colors that draw() should
@ -518,9 +547,20 @@ 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()
return v
}
// SetContentWidth tells the view the screen width that content written to it
// should count soft-wraps against (see escapeInterpreter.notifyCellsWritten).
// Callers pass the view's InnerWidth; it's a separate call, made on the UI
// thread when a render starts, so that the task goroutine that streams the
// 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
}
// Dimensions returns the dimensions of the View
func (v *View) Dimensions() (int, int, int, int) {
return v.x0, v.y0, v.x1, v.y1
@ -815,6 +855,9 @@ func (v *View) Write(p []byte) (n int, err error) {
func (v *View) write(p []byte) {
v.tainted = true
// write only ever touches lines from v.wy onwards, so any cached wrapping
// below that stays valid.
v.firstDirtyLine = min(v.firstDirtyLine, v.wy)
v.clearHover()
// Fill with empty cells, if writing outside current view buffer
@ -886,7 +929,7 @@ func (v *View) write(p []byte) {
for _, c := range cells {
totalWidth += c.width
}
v.ei.notifyCellsWritten(totalWidth, v.InnerWidth())
v.ei.notifyCellsWritten(totalWidth)
}
}
}
@ -1104,10 +1147,25 @@ func (v *View) CopyContent(from *View) {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
// A background task may be streaming output into the source view's buffer
// via Write, so read it under its own lock. The source is always a
// different view than the destination (see the sole caller,
// moveMainContextToTop), and no other code holds two view write locks at
// once, so this can't deadlock.
from.writeMutex.Lock()
defer from.writeMutex.Unlock()
v.clear()
v.lines = from.lines
v.viewLines = from.viewLines
// Clone the row slices rather than sharing them: the source view stays
// live (its streaming task keeps appending rows, and refreshViewLinesIfNeeded
// fills each row's wrapping cache in place via &lines[i]), so sharing the
// backing arrays would race those writes against this view's own rendering.
// 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.viewLines = slices.Clone(from.viewLines)
v.ox = from.ox
v.oy = from.oy
v.cx = from.cx
@ -1255,6 +1313,8 @@ func (v *View) updateSearchPositions() {
// IsTainted tells us if the view is tainted
func (v *View) IsTainted() bool {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return v.tainted
}
@ -1358,48 +1418,64 @@ func (v *View) draw() {
}
func (v *View) refreshViewLinesIfNeeded() {
if v.tainted {
maxX := v.InnerWidth()
lineIdx := 0
lines := v.lines
for i, line := range lines {
wrap := 0
if v.Wrap {
wrap = maxX
}
ls := lineWrap(line.cells, wrap)
for j := range ls {
// Per-segment trailing fill. When the source line opted in
// via '\x1b[K', the LAST wrapped segment uses those colors
// directly; earlier segments use the colors of their own
// last cell, so the trailing area matches the bg active
// where that segment ended rather than bleeding the
// '\x1b[K' bg back across color changes in the line.
var attrs *trailingFillAttributes
if line.trailingFillAttributes != nil {
if j == len(ls)-1 {
attrs = line.trailingFillAttributes
} else if len(ls[j]) > 0 {
last := ls[j][len(ls[j])-1]
attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor}
}
}
vline := viewLine{
linesX: j, linesY: i, line: ls[j],
trailingFillAttributes: attrs,
}
if lineIdx > len(v.viewLines)-1 {
v.viewLines = append(v.viewLines, vline)
} else {
v.viewLines[lineIdx] = vline
}
lineIdx++
}
}
v.tainted = false
if !v.tainted {
return
}
maxX := v.InnerWidth()
wrap := 0
if v.Wrap {
wrap = maxX
}
lineIdx := 0
lines := v.lines
for i := range lines {
line := &lines[i]
// Reuse the previously wrapped result for lines that haven't changed
// since the last refresh (i.e. below firstDirtyLine) and were wrapped at
// the current width. Wrapping is expensive and this loop runs on every
// scroll event, so only the lines that were actually just read (or
// re-highlighted) should be wrapped afresh.
if line.wrappedCells == nil || line.wrappedColumns != wrap || i >= v.firstDirtyLine {
line.wrappedCells = lineWrap(line.cells, wrap)
line.wrappedColumns = wrap
}
ls := line.wrappedCells
for j := range ls {
// Per-segment trailing fill. When the source line opted in
// via '\x1b[K', the LAST wrapped segment uses those colors
// directly; earlier segments use the colors of their own
// last cell, so the trailing area matches the bg active
// where that segment ended rather than bleeding the
// '\x1b[K' bg back across color changes in the line.
var attrs *trailingFillAttributes
if line.trailingFillAttributes != nil {
if j == len(ls)-1 {
attrs = line.trailingFillAttributes
} else if len(ls[j]) > 0 {
last := ls[j][len(ls[j])-1]
attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor}
}
}
vline := viewLine{
linesX: j, linesY: i, line: ls[j],
trailingFillAttributes: attrs,
}
if lineIdx > len(v.viewLines)-1 {
v.viewLines = append(v.viewLines, vline)
} else {
v.viewLines[lineIdx] = vline
}
lineIdx++
}
}
v.firstDirtyLine = len(lines)
v.tainted = false
}
// if autoscroll is enabled but we only have a single row of cells shown to the
@ -1487,6 +1563,9 @@ func (v *View) BufferLines() []string {
// Buffer returns a string with the contents of the view's internal
// buffer.
func (v *View) Buffer() string {
v.writeMutex.Lock()
defer v.writeMutex.Unlock()
return linesToString(v.lines)
}
@ -1599,6 +1678,7 @@ func (v *View) SetHighlight(y int, on bool) {
cells = append(cells, c)
}
v.tainted = true
v.firstDirtyLine = min(v.firstDirtyLine, y)
v.lines[y].cells = cells
v.clearHover()
}

View file

@ -43,6 +43,11 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() {
if userConfig.Git.AutoFetch {
fetchInterval := userConfig.Refresher.FetchInterval
if fetchInterval > 0 {
// The channel must be created here, on the UI thread and before
// the fetch goroutine spawns, so that triggerImmediateFetch (also
// running on the UI thread) can read the field without racing the
// write. See triggerImmediateFetch for why it is buffered.
self.triggerFetch = make(chan struct{}, 1)
go utils.Safe(self.startBackgroundFetch)
} else {
self.gui.c.Log.Errorf(
@ -74,7 +79,7 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() {
}
if self.gui.Config.GetDebug() {
self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, func(_ bool) error {
self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, nil, func(_ bool) error {
formatBytes := func(b uint64) string {
const unit = 1000
if b < unit {
@ -114,7 +119,7 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() {
if self.gui.UserConfig().Gui.ShowBottomLine || firstTimeOrRetriggered {
return self.gui.helpers.AppStatus.WithWaitingStatusImpl(self.gui.Tr.FetchingStatus, func(gocui.Task) error {
return self.backgroundFetch()
}, nil, true)
}, nil)
}
return self.backgroundFetch()
@ -125,14 +130,14 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() {
_ = fetch(true)
userConfig := self.gui.UserConfig()
self.triggerFetch = self.goEvery(userConfig.Refresher.FetchIntervalDuration(), self.gui.stopChan, fetch)
self.goEvery(userConfig.Refresher.FetchIntervalDuration(), self.gui.stopChan, self.triggerFetch, fetch)
}
func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() {
self.gui.waitForIntro.Wait()
userConfig := self.gui.UserConfig()
self.goEvery(userConfig.Refresher.RefreshIntervalDuration(), self.gui.stopChan, func(_ bool) error {
self.goEvery(userConfig.Refresher.RefreshIntervalDuration(), self.gui.stopChan, nil, func(_ bool) error {
self.gui.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Background: true})
return nil
})
@ -151,6 +156,7 @@ func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() {
self.goEvery(
userConfig.Refresher.ExternalChangeCheckIntervalDuration(),
self.gui.stopChan,
nil,
func(_ bool) error {
self.checkForExternalChanges()
return nil
@ -187,10 +193,10 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() {
self.gui.c.RefreshFromWorker(types.RefreshOptions{Background: true})
}
// returns a channel that can be used to trigger the callback immediately
func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan struct{}, function func(bool) error) chan struct{} {
// Runs function every interval until stop is closed. A send on retrigger (if
// non-nil) runs the callback immediately and restarts the interval.
func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop, retrigger chan struct{}, function func(bool) error) {
done := make(chan struct{})
retrigger := make(chan struct{})
go utils.Safe(func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
@ -223,7 +229,6 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru
}
}
})
return retrigger
}
func (self *BackgroundRoutineMgr) backgroundFetch() (err error) {
@ -234,6 +239,16 @@ func (self *BackgroundRoutineMgr) backgroundFetch() (err error) {
func (self *BackgroundRoutineMgr) triggerImmediateFetch() {
if self.triggerFetch != nil {
self.triggerFetch <- struct{}{}
// This runs on the UI thread, which must never block waiting for a
// background routine; in particular, the goEvery loop only receives
// between callbacks, and an in-flight fetch can itself be waiting for
// the UI thread to perform its post-fetch refresh, so a blocking send
// here would deadlock. The channel has a buffer of one, so the trigger
// is latched even when the loop isn't currently receiving; if one is
// already pending, the two coalesce.
select {
case self.triggerFetch <- struct{}{}:
default:
}
}
}

View file

@ -27,10 +27,20 @@ func (gui *Gui) LogAction(action string) {
return
}
gui.Views.Extras.Autoscroll = true
// LogAction and LogCommand are called both from the UI thread and from git
// worker goroutines, so bounce the writes onto the UI thread: they touch the
// view's autoscroll flag and the GuiLog slice, which the layout/draw code
// reads. Ordering between successive log calls is preserved by the FIFO the
// bounce enqueues onto. It's a background bounce because writing the command
// log is incidental display work that must not count towards lazygit being
// busy (otherwise it could block a repo switch).
gui.onUIThreadBackground(func() error {
gui.Views.Extras.Autoscroll = true
gui.GuiLog = append(gui.GuiLog, action)
fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action))
gui.GuiLog = append(gui.GuiLog, action)
fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action))
return nil
})
}
func (gui *Gui) LogCommand(cmdStr string, commandLine bool) {
@ -38,17 +48,23 @@ func (gui *Gui) LogCommand(cmdStr string, commandLine bool) {
return
}
gui.Views.Extras.Autoscroll = true
textStyle := theme.DefaultTextColor
if !commandLine {
// if we're not dealing with a direct command that could be run on the command line,
// we style it differently to communicate that
textStyle = style.FgMagenta
}
gui.GuiLog = append(gui.GuiLog, cmdStr)
indentedCmdStr := " " + strings.ReplaceAll(cmdStr, "\n", "\n ")
fmt.Fprint(gui.Views.Extras, "\n"+textStyle.Sprint(indentedCmdStr))
// See the comment in LogAction: bounce onto the UI thread since we may be
// called from a git worker, in the background so it can't block a repo switch.
gui.onUIThreadBackground(func() error {
gui.Views.Extras.Autoscroll = true
gui.GuiLog = append(gui.GuiLog, cmdStr)
fmt.Fprint(gui.Views.Extras, "\n"+textStyle.Sprint(indentedCmdStr))
return nil
})
}
func (gui *Gui) printCommandLogHeader() {

View file

@ -32,34 +32,76 @@ type ListRenderer struct {
getNonModelItems func() []*NonModelItem
// The remaining fields are private and shouldn't be initialized by clients
numNonModelItems int
viewIndicesByModelIndex []int
modelIndicesByViewIndex []int
columnPositions []int
columnPositions []int
}
func (self *ListRenderer) GetList() types.IList {
return self.list
}
func (self *ListRenderer) ModelIndexToViewIndex(modelIndex int) int {
modelIndex = lo.Clamp(modelIndex, 0, self.list.Len())
if self.viewIndicesByModelIndex != nil {
return self.viewIndicesByModelIndex[modelIndex]
func (self *ListRenderer) getNonModelItemList() []*NonModelItem {
if self.getNonModelItems == nil {
return nil
}
return self.getNonModelItems()
}
return modelIndex
func (self *ListRenderer) ModelIndexToViewIndex(modelIndex int) int {
return modelIndexToViewIndex(self.list.Len(), self.getNonModelItemList(), modelIndex)
}
func (self *ListRenderer) ViewIndexToModelIndex(viewIndex int) int {
viewIndex = lo.Clamp(viewIndex, 0, self.list.Len()+self.numNonModelItems)
if self.modelIndicesByViewIndex != nil {
return self.modelIndicesByViewIndex[viewIndex]
}
return viewIndexToModelIndex(self.list.Len(), self.getNonModelItemList(), viewIndex)
}
// modelToViewIndexConverter returns a model-to-view index conversion that
// reuses a single snapshot of the non-model items. Callers that convert many
// indices in a row (e.g. search, which converts every commit) should use this
// rather than calling ModelIndexToViewIndex per index, which would rebuild the
// non-model items each time.
func (self *ListRenderer) modelToViewIndexConverter() func(modelIndex int) int {
listLength := self.list.Len()
nonModelItems := self.getNonModelItemList()
return func(modelIndex int) int {
return modelIndexToViewIndex(listLength, nonModelItems, modelIndex)
}
}
// The view shows the model items with the non-model items (e.g. section
// headers) inserted at their model indices. The two conversions below are
// computed directly from the current list length and non-model items, so they
// don't depend on the list having been rendered, and they can never be stale
// with respect to a model that changed since the last render (which used to
// cause both wrong results and index-out-of-range panics).
//
// The non-model items are assumed to be ordered by their Index, which is how
// all producers build them; the i-th one therefore ends up at view index
// Index+i.
func modelIndexToViewIndex(listLength int, nonModelItems []*NonModelItem, modelIndex int) int {
modelIndex = lo.Clamp(modelIndex, 0, listLength)
// Each non-model item inserted at or before this model item pushes it down
// by one row in the view.
viewIndex := modelIndex
for _, item := range nonModelItems {
if item.Index <= modelIndex {
viewIndex++
}
}
return viewIndex
}
func viewIndexToModelIndex(listLength int, nonModelItems []*NonModelItem, viewIndex int) int {
viewIndex = lo.Clamp(viewIndex, 0, listLength+len(nonModelItems))
// Subtract the non-model items that appear before this view index.
modelIndex := viewIndex
for i, item := range nonModelItems {
if item.Index+i < viewIndex {
modelIndex--
}
}
return modelIndex
}
func (self *ListRenderer) ColumnPositions() []int {
return self.columnPositions
}
@ -71,23 +113,18 @@ func (self *ListRenderer) renderLines(startIdx int, endIdx int) string {
if self.getColumnAlignments != nil {
columnAlignments = self.getColumnAlignments()
}
nonModelItems := []*NonModelItem{}
self.numNonModelItems = 0
if self.getNonModelItems != nil {
nonModelItems = self.getNonModelItems()
self.prepareConversionArrays(nonModelItems)
}
nonModelItems := self.getNonModelItemList()
startModelIdx := 0
if startIdx == -1 {
startIdx = 0
} else {
startModelIdx = self.ViewIndexToModelIndex(startIdx)
startModelIdx = viewIndexToModelIndex(self.list.Len(), nonModelItems, startIdx)
}
endModelIdx := self.list.Len()
if endIdx == -1 {
endIdx = endModelIdx + len(nonModelItems)
} else {
endModelIdx = self.ViewIndexToModelIndex(endIdx)
endModelIdx = viewIndexToModelIndex(self.list.Len(), nonModelItems, endIdx)
}
lines, columnPositions := utils.RenderDisplayStrings(
self.getDisplayStrings(startModelIdx, endModelIdx),
@ -97,23 +134,6 @@ func (self *ListRenderer) renderLines(startIdx int, endIdx int) string {
return strings.Join(lines, "\n")
}
func (self *ListRenderer) prepareConversionArrays(nonModelItems []*NonModelItem) {
self.numNonModelItems = len(nonModelItems)
viewIndicesByModelIndex := lo.Range(self.list.Len() + 1)
modelIndicesByViewIndex := lo.Range(self.list.Len() + 1)
offset := 0
for _, item := range nonModelItems {
for i := item.Index; i <= self.list.Len(); i++ {
viewIndicesByModelIndex[i]++
}
modelIndicesByViewIndex = slices.Insert(
modelIndicesByViewIndex, item.Index+offset, modelIndicesByViewIndex[item.Index+offset])
offset++
}
self.viewIndicesByModelIndex = viewIndicesByModelIndex
self.modelIndicesByViewIndex = modelIndicesByViewIndex
}
func (self *ListRenderer) insertNonModelItems(
nonModelItems []*NonModelItem, endIdx int, startIdx int, lines []string, columnPositions []int,
) []string {

View file

@ -254,9 +254,6 @@ func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) {
getNonModelItems: getNonModelItems,
}
// Need to render first so that it knows the non-model items
self.renderLines(-1, -1)
for i := range len(s.modelIndices) {
assert.Equal(t, s.expectedViewIndices[i], self.ModelIndexToViewIndex(s.modelIndices[i]))
}
@ -267,3 +264,27 @@ func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) {
})
}
}
// The index conversions must not depend on the list having been rendered
// first. It used to be renderLines that populated the conversion arrays, so
// converting an index before the first render silently ignored the non-model
// items (and converting after the model changed used a stale snapshot).
func TestListRenderer_IndexConversionsAreRenderIndependent(t *testing.T) {
modelInts := lo.Map(lo.Range(3), func(i int, _ int) myint { return myint(i) })
self := &ListRenderer{
list: NewListViewModel(func() []myint { return modelInts }),
getDisplayStrings: func(startIdx int, endIdx int) [][]string {
return lo.Map(modelInts[startIdx:endIdx],
func(i myint, _ int) []string { return []string{fmt.Sprint(i)} })
},
// A section header sits at model index 1, so model item 1 is pushed down
// to view index 2, and view index 2 maps back to model item 1.
getNonModelItems: func() []*NonModelItem {
return []*NonModelItem{{Index: 1, Content: "--- header ---"}}
},
}
// Deliberately convert without rendering first.
assert.Equal(t, 2, self.ModelIndexToViewIndex(1))
assert.Equal(t, 1, self.ViewIndexToModelIndex(2))
}

View file

@ -224,7 +224,7 @@ func (self *LocalCommitsContext) RefForAdjustingLineNumberInDiff() string {
}
func (self *LocalCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.ModelIndexToViewIndex, searchStr)
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.modelToViewIndexConverter(), searchStr)
}
func (self *LocalCommitsViewModel) SetLimitCommits(value bool) {

View file

@ -223,7 +223,7 @@ func (self *SubCommitsContext) RefForAdjustingLineNumberInDiff() string {
}
func (self *SubCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.ModelIndexToViewIndex, searchStr)
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.modelToViewIndexConverter(), searchStr)
}
func (self *SubCommitsContext) IndexForGotoBottom() int {

View file

@ -81,10 +81,17 @@ func (self *SuggestionsContext) SetSuggestions(suggestions []*types.Suggestion)
}
func (self *SuggestionsContext) RefreshSuggestions() {
// Capture the suggestions function and the prompt input here, on the UI
// thread, rather than inside the worker below: the main thread rewrites both
// (State.FindSuggestions and the prompt's TextArea) when it (re)creates a
// prompt panel, so reading them from the worker races those writes. It's
// also more correct -- we search for the input as it was when dispatched,
// which is what this request's AsyncHandler id corresponds to.
findSuggestionsFn := self.State.FindSuggestions
promptInput := self.c.GetPromptInput()
self.State.AsyncHandler.Do(func() func() {
findSuggestionsFn := self.State.FindSuggestions
if findSuggestionsFn != nil {
suggestions := findSuggestionsFn(self.c.GetPromptInput())
suggestions := findSuggestionsFn(promptInput)
return func() { self.SetSuggestions(suggestions) }
}
return func() {}

View file

@ -282,7 +282,7 @@ func (self *BisectController) afterBisectMarkRefresh(selectCurrent bool, waitToR
}
if waitToReselect {
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{}, Then: selectFn})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{}, Then: selectFn})
return nil
}

View file

@ -331,7 +331,6 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc
return err
}
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{
types.BRANCHES,
types.COMMITS,
@ -355,7 +354,6 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc
return err
}
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{
types.BRANCHES,
types.COMMITS,
@ -546,7 +544,7 @@ func (self *BranchesController) forceCheckout() error {
if err := self.c.Git().Branch.Checkout(branch.Name, git_commands.CheckoutOptions{Force: true}); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
})
@ -600,7 +598,6 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er
}
self.c.Refresh(types.RefreshOptions{
Mode: types.ASYNC,
BranchSelection: types.SelectCheckedOutBranch,
CommitSelection: types.SelectHeadCommit,
SelectTopReflogCommit: true,
@ -734,7 +731,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error {
WorktreePath: worktreePath,
},
)
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
return err
}
@ -743,7 +740,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error {
err := self.c.Git().Sync.FastForward(
task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch,
)
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
return err
})
}
@ -760,7 +757,7 @@ func (self *BranchesController) createSortMenu() error {
if self.c.UserConfig().Git.LocalBranchSortOrder != sortOrder {
self.c.UserConfig().Git.LocalBranchSortOrder = sortOrder
self.c.Contexts().Branches.SetSelection(0)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
return nil
}
return nil
@ -788,7 +785,6 @@ func (self *BranchesController) rename(branch *models.Branch) error {
// onto the UI thread, so the re-selection (which reads Model.Branches) has to run in
// Then; reading it inline here would see the previous model.
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{types.BRANCHES, types.WORKTREES},
Then: func() error {
// now that we've got our stuff again we need to find that branch and reselect it.

View file

@ -324,7 +324,7 @@ func (self *CommitFilesController) checkout(node *filetree.CommitFileNode) error
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
}
@ -339,7 +339,7 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN
HandleConfirm: func() error {
commits := self.c.Model().Commits
selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
var filePaths []string
selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes)

View file

@ -269,7 +269,7 @@ func (self *CustomPatchOptionsMenuAction) handleApplyPatch(reverse bool) error {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
})

View file

@ -22,7 +22,7 @@ func (self *DiffingMenuAction) Call() error {
OnPress: func() error {
self.c.Modes().Diffing.Ref = name
// can scope this down based on current view but too lazy right now
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
},
@ -38,7 +38,7 @@ func (self *DiffingMenuAction) Call() error {
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRefsSuggestionsFunc(),
HandleConfirm: func(response string) error {
self.c.Modes().Diffing.Ref = response
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
})
@ -54,7 +54,7 @@ func (self *DiffingMenuAction) Call() error {
Label: self.c.Tr.SwapDiff,
OnPress: func() error {
self.c.Modes().Diffing.Reverse = !self.c.Modes().Diffing.Reverse
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
},
@ -62,7 +62,7 @@ func (self *DiffingMenuAction) Call() error {
Label: self.c.Tr.ExitDiffMode,
OnPress: func() error {
self.c.Modes().Diffing = diffing.New()
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
},

View file

@ -636,7 +636,7 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
self.context().HandleFocus(types.OnFocusOpts{})
return nil
@ -921,7 +921,7 @@ func (self *FilesController) toggleStagedAll() error {
return err
}
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
self.context().HandleFocus(types.OnFocusOpts{})
return nil
@ -1204,7 +1204,7 @@ func (self *FilesController) setStatusFiltering(filter filetree.FileTreeDisplayF
// Whenever we switch between untracked and other filters, we need to refresh the files view
// because the untracked files filter applies when running `git status`.
if previousFilter != filter && (previousFilter == filetree.DisplayUntracked || filter == filetree.DisplayUntracked) {
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
} else {
self.c.PostRefreshUpdate(self.context())
}
@ -1740,7 +1740,7 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}})
return nil
},
Keys: self.c.KeybindingsOpts().GetKeys(self.c.UserConfig().Keybinding.Files.ConfirmDiscard),
@ -1766,7 +1766,7 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}})
return nil
},
Keys: menuKey('u'),
@ -1808,7 +1808,7 @@ func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) e
return err
}
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}})
return nil
})
}

View file

@ -158,7 +158,7 @@ func (self *GlobalController) createCustomPatchOptionsMenu() error {
}
func (self *GlobalController) refresh() error {
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
}

View file

@ -34,12 +34,7 @@ func (self *AppStatusHelper) Toast(message string, kind types.ToastKind) {
self.statusMgr().AddToastStatus(message, kind)
// Render the toast in the background: it's a transient notification, not
// lazygit driving an operation, so it must not count towards being busy —
// otherwise a toast (e.g. the "can't switch, operation in progress" one)
// would itself block a repo switch until it faded. A real operation showing
// a toast still keeps its own foreground task busy independently.
self.renderAppStatus(true)
self.renderAppStatus()
}
// A custom task for WithWaitingStatus calls; it wraps the original one and
@ -66,14 +61,15 @@ func (self appStatusHelperTask) Continue() {
// WithWaitingStatus wraps a function and shows a waiting status while the function is still executing
func (self *AppStatusHelper) WithWaitingStatus(message string, f func(gocui.Task) error) {
self.c.OnWorker(func(task gocui.Task) error {
return self.WithWaitingStatusImpl(message, f, task, false)
return self.WithWaitingStatusImpl(message, f, task)
})
}
// background reports whether this waiting status belongs to a background routine
// (the auto-fetch poller); when it does, the spinner it drives must not count
// towards lazygit being busy, or it'd block repo switches while a fetch runs.
func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task, background bool) error {
// WithWaitingStatusImpl is WithWaitingStatus for callers that already run on a
// goroutine of their own (e.g. the auto-fetch poller) rather than wanting the
// work dispatched to a worker. task is used to hide the status while the task
// is paused; it may be nil for callers whose f ignores its task.
func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task) error {
// A waiting status means lazygit is driving a git operation itself (often
// one that internally runs a rebase and continues it). Pause the background
// routines for its duration so they don't refresh from an intermediate
@ -81,21 +77,38 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.
self.c.PauseBackgroundRefreshes(true)
defer self.c.PauseBackgroundRefreshes(false)
return self.statusMgr().WithWaitingStatus(message, func() { self.renderAppStatus(background) }, func(waitingStatusHandle *status.WaitingStatusHandle) error {
return self.statusMgr().WithWaitingStatus(message, self.renderAppStatus, func(waitingStatusHandle *status.WaitingStatusHandle) error {
return f(appStatusHelperTask{task, waitingStatusHandle})
})
}
func (self *AppStatusHelper) WithWaitingStatusSync(message string, f func() error) error {
self.c.PauseBackgroundRefreshes(true)
defer self.c.PauseBackgroundRefreshes(false)
return self.statusMgr().WithWaitingStatus(message, func() {}, func(*status.WaitingStatusHandle) error {
stop := make(chan struct{})
defer func() { close(stop) }()
self.renderAppStatusSync(stop)
return f()
// WithWaitingStatusBlockingInput is like WithWaitingStatus, but it also blocks
// keyboard input for the whole duration of the operation: keys the user presses
// while it runs are buffered and replayed against the post-operation state (see
// gocui.BeginBlockingEvents). Use it for operations that manipulate an
// in-progress rebase or otherwise rewrite commits, where a racing keypress
// would target the wrong commit or todo.
//
// Must be called on the UI thread: the block is begun synchronously here, before
// the operation is dispatched to a worker, so no keypress can slip through in
// between.
func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) {
self.c.GocuiGui().BeginBlockingEvents()
// Hide the rebasing-mode indicator (and its reset button) while we drive the
// rebase ourselves; it reflects the transient on-disk state and would
// otherwise flash on for the duration of the operation.
self.modeHelper.SetSuppressRebasingMode(true)
self.c.OnWorker(func(task gocui.Task) error {
// End the block and restore the mode indicator once the operation and its
// refresh have applied their UI updates: OnUIThread queues this after the
// refresh's model bounces and Then (which RefreshFromWorker has already
// enqueued by the time f returns), so the replayed keys act on the
// refreshed state and any resulting rebase state shows correctly.
defer self.c.OnUIThread(func() error {
self.modeHelper.SetSuppressRebasingMode(false)
return self.c.GocuiGui().EndBlockingEvents()
})
return self.WithWaitingStatusImpl(message, f, task)
})
}
@ -108,33 +121,36 @@ func (self *AppStatusHelper) GetStatusString() string {
return appStatus
}
func (self *AppStatusHelper) renderAppStatus(background bool) {
// A background waiting status (auto-fetch) must not count towards lazygit
// being busy, so its spinner worker and per-frame UI updates go through the
// background variants.
onWorker := self.c.OnWorker
onUIThread := self.c.OnUIThread
onUIThreadContentOnly := self.c.OnUIThreadContentOnly
if background {
onWorker = self.c.OnWorkerBackground
onUIThread = self.c.OnUIThreadBackground
onUIThreadContentOnly = self.c.OnUIThreadContentOnlyBackground
// renderAppStatus ensures the render loop that keeps the app-status view up to
// date is running. There is one loop for the whole status stack, no matter how
// many statuses are showing: it draws whatever the top status currently is,
// and exits after drawing a final empty frame once the last status is removed.
//
// The loop always runs as a background task, regardless of what kind of
// operation owns a status: rendering runs no git commands, so it must never
// count towards lazygit being busy — otherwise it would block repo switching
// for as long as anything is showing (e.g. for the whole duration of a hung
// background fetch, or of a toast fading). A foreground operation's busy-ness
// is carried by its own worker task, not by the renderer.
func (self *AppStatusHelper) renderAppStatus() {
if !self.statusMgr().ClaimRenderLoop() {
return
}
onWorker(func(_ gocui.Task) error {
self.c.OnWorkerBackground(func(_ gocui.Task) error {
ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate))
defer ticker.Stop()
prevAppStatus := ""
for range ticker.C {
appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig())
update := onUIThreadContentOnly
update := self.c.OnUIThreadContentOnlyBackground
if utils.StringWidth(appStatus) != utils.StringWidth(prevAppStatus) {
// Need a full layout whenever the width of the status string changes. This can't
// happen during normal spinning because we validate that all spinner frames have
// the same width, so typically this will only be triggered at the beginning and end
// of a status, or if the status string changes midway for some reason.
update = onUIThread
update = self.c.OnUIThreadBackground
}
update(func() error {
self.c.Views().AppStatus.FgColor = color
@ -143,64 +159,12 @@ func (self *AppStatusHelper) renderAppStatus(background bool) {
})
prevAppStatus = appStatus
if appStatus == "" {
// Checked after rendering, so that the frame which clears the view
// has already been drawn when we exit.
if self.statusMgr().ReleaseRenderLoopIfEmpty() {
break
}
}
return nil
})
}
func (self *AppStatusHelper) renderAppStatusSync(stop chan struct{}) {
go func() {
ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate))
defer ticker.Stop()
// Write the status into the view before the first layout below, so that
// layout (which sizes the bottom line based on the actual content of the
// AppStatus view) leaves room for it and it shows right away. The ticker
// only updates the spinner frame using ForceFlushViewsContentOnly, so this
// doesn't re-layout.
self.setAppStatusContent()
// Forcing a re-layout and redraw after we added the waiting status;
// this is needed in case the gui.showBottomLine config is set to false,
// to make sure the bottom line appears. It's also useful for redrawing
// once after each of several consecutive keypresses, e.g. pressing
// ctrl-j to move a commit down several steps.
_ = self.c.GocuiGui().ForceLayoutAndRedraw()
self.modeHelper.SetSuppressRebasingMode(true)
defer func() { self.modeHelper.SetSuppressRebasingMode(false) }()
outer:
for {
select {
case <-ticker.C:
self.setAppStatusContent()
// Redraw all views of the bottom line:
bottomLineViews := []*gocui.View{
self.c.Views().AppStatus, self.c.Views().Options, self.c.Views().Information,
self.c.Views().StatusSpacer1, self.c.Views().StatusSpacer2,
}
_ = self.c.GocuiGui().ForceFlushViewsContentOnly(bottomLineViews)
case <-stop:
// Clear the status from the view and re-layout, otherwise the
// stale content would keep layout reserving room for it forever.
// The UI thread is free again at this point, so we go through
// OnUIThread like the async renderAppStatus does.
self.c.OnUIThread(func() error {
self.c.SetViewContent(self.c.Views().AppStatus, "")
return nil
})
break outer
}
}
}()
}
func (self *AppStatusHelper) setAppStatusContent() {
appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig())
self.c.Views().AppStatus.FgColor = color
self.c.SetViewContent(self.c.Views().AppStatus, appStatus)
}

View file

@ -31,5 +31,5 @@ func (self *BisectHelper) Reset() error {
}
func (self *BisectHelper) PostBisectCommandRefresh() {
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{}})
}

View file

@ -49,7 +49,7 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
return nil
})
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
return nil
})
})
@ -87,7 +87,7 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteB
if err := self.deleteRemoteBranches(remoteBranches, task); err != nil {
return err
}
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
if resetRemoteBranchesSelection {
self.c.OnUIThread(func() error {
self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop()
@ -161,7 +161,7 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branc
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
return nil
})
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
return nil
})
},
@ -325,7 +325,6 @@ func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.B
return nil
})
self.c.RefreshFromWorker(types.RefreshOptions{
Mode: types.ASYNC,
Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES},
})
return nil
@ -346,7 +345,6 @@ func (self *BranchesHelper) deleteLocalAndRemoteBranchesContinuation(branches []
return nil
})
self.c.RefreshFromWorker(types.RefreshOptions{
Mode: types.ASYNC,
Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.REMOTES, types.FILES},
})
return nil
@ -407,7 +405,6 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er
// returns (where it would still see the previous branches).
self.c.RefreshFromWorker(types.RefreshOptions{
Scope: scope,
Mode: types.SYNC,
Background: background,
Then: func() error {
if fetchErr != nil {
@ -458,7 +455,7 @@ func (self *BranchesHelper) AutoForwardBranches(background bool) error {
self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false)
err := self.c.Git().Branch.UpdateBranchRefs(updateCommands)
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Mode: types.SYNC, Background: background})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Background: background})
return err
}

View file

@ -4,6 +4,7 @@ import (
"strconv"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
@ -82,9 +83,9 @@ func (self *CherryPickHelper) Paste() error {
"numCommits": strconv.Itoa(len(self.getData().CherryPickedCommits)),
}),
HandleConfirm: func() error {
return self.c.WithWaitingStatusSync(self.c.Tr.CherryPickingStatus, func() error {
mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
cherryPickedCommits := self.getData().CherryPickedCommits
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CherryPickingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.CherryPick)
if mustStash {
@ -93,9 +94,9 @@ func (self *CherryPickHelper) Paste() error {
}
}
cherryPickedCommits := self.getData().CherryPickedCommits
result := self.c.Git().Rebase.CherryPickCommits(cherryPickedCommits)
err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{Mode: types.SYNC})
err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(result,
types.RefreshOptions{BatchUIUpdates: true})
if err != nil {
return result
}
@ -109,14 +110,19 @@ func (self *CherryPickHelper) Paste() error {
return result
}
if !isInCherryPick {
self.getData().DidPaste = true
self.rerender()
// DidPaste and the re-render touch mode state and contexts,
// so run them on the UI thread.
self.c.OnUIThread(func() error {
self.getData().DidPaste = true
self.rerender()
return nil
})
if mustStash {
if err := self.c.Git().Stash.Pop(0); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{
self.c.RefreshFromWorker(types.RefreshOptions{
Scope: []types.RefreshableView{types.STASH, types.FILES},
})
}

View file

@ -77,9 +77,7 @@ func (self *ConfirmationHelper) wrappedPromptConfirmationFunction(
}
func (self *ConfirmationHelper) DeactivateConfirmation() {
self.c.Mutexes().PopupMutex.Lock()
self.c.State().GetRepoState().SetCurrentPopupOpts(nil)
self.c.Mutexes().PopupMutex.Unlock()
self.c.Views().Confirmation.Visible = false
@ -87,9 +85,7 @@ func (self *ConfirmationHelper) DeactivateConfirmation() {
}
func (self *ConfirmationHelper) DeactivatePrompt() {
self.c.Mutexes().PopupMutex.Lock()
self.c.State().GetRepoState().SetCurrentPopupOpts(nil)
self.c.Mutexes().PopupMutex.Unlock()
self.c.Views().Prompt.Visible = false
self.c.Views().Suggestions.Visible = false
@ -188,9 +184,6 @@ func characterForMask(mask bool) string {
}
func (self *ConfirmationHelper) CreatePopupPanel(ctx goContext.Context, opts types.CreatePopupPanelOpts) {
self.c.Mutexes().PopupMutex.Lock()
defer self.c.Mutexes().PopupMutex.Unlock()
_, cancel := goContext.WithCancel(ctx)
// we don't allow interruptions of non-loader popups in case we get stuck somehow

View file

@ -33,7 +33,7 @@ func (self *CredentialsHelper) PromptUserForCredential(passOrUname oscommands.Cr
HandleConfirm: func(input string) error {
ch <- input + "\n"
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
},
HandleClose: func() error {

View file

@ -94,7 +94,7 @@ func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string {
func (self *DiffHelper) ExitDiffMode() error {
self.c.Modes().Diffing = diffing.New()
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
}

View file

@ -137,7 +137,7 @@ func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error {
if err := self.c.Git().WorkingTree.StageAll(true); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
}
self.c.Contexts().LocalCommits.SetSelection(index)
@ -199,12 +199,12 @@ func (self *FixupHelper) getDiff() (string, bool, error) {
// Try staged changes first
hasStagedChanges := true
diff, err := self.c.Git().Diff.DiffIndexCmdObj(append([]string{"--cached"}, args...)...).RunWithOutput()
diff, err := self.c.Git().Diff.DiffIndexCmdObj(append([]string{"--cached"}, args...)...).DontLog().RunWithOutput()
if err == nil && diff == "" {
hasStagedChanges = false
// If there are no staged changes, try unstaged changes
diff, err = self.c.Git().Diff.DiffIndexCmdObj(args...).RunWithOutput()
diff, err = self.c.Git().Diff.DiffIndexCmdObj(args...).DontLog().RunWithOutput()
}
return diff, hasStagedChanges, err

View file

@ -26,7 +26,7 @@ func (self *GpgHelper) WithGpgHandling(
onSuccess func() error,
refreshScope []types.RefreshableView,
) error {
refreshOptions := types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope}
refreshOptions := types.RefreshOptions{Scope: refreshScope}
return self.withGpgHandling(
cmdObj, configKey, waitingStatus, onSuccess, refreshOptions, refreshOptions)
}
@ -40,8 +40,8 @@ func (self *GpgHelper) WithGpgHandlingAndSelectHeadCommit(
waitingStatus string,
onSuccess func() error,
) error {
failureRefreshOptions := types.RefreshOptions{Mode: types.ASYNC}
successRefreshOptions := types.RefreshOptions{Mode: types.ASYNC, CommitSelection: types.SelectHeadCommit}
failureRefreshOptions := types.RefreshOptions{}
successRefreshOptions := types.RefreshOptions{CommitSelection: types.SelectHeadCommit}
return self.withGpgHandling(
cmdObj, configKey, waitingStatus, onSuccess, failureRefreshOptions, successRefreshOptions)
}

View file

@ -89,11 +89,11 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error {
// non-subprocess path runs on a worker with a waiting status.
//
// showWaitingStatus is false only for the recursive auto-skip in
// checkMergeOrRebaseImpl: that call already runs on the caller's thread (the
// worker of the enclosing waiting status, or the UI thread for the synchronous
// callers), so it must not spin up a second one. calledFromWorker says which of
// those two the body runs on, so the post-action refresh picks Refresh vs
// RefreshFromWorker correctly.
// CheckMergeOrRebaseWithRefreshOptions, which already runs on a worker, so it
// must not spin up a second waiting status. calledFromWorker is used only by the
// subprocess path below: it's true for that recursive worker skip and false for
// genericMergeCommand's UI-thread invocation, so the post-action refresh picks
// RefreshFromWorker vs Refresh correctly.
func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWaitingStatus bool, calledFromWorker bool) error {
status := self.c.Git().Status.WorkingTreeState()
@ -127,35 +127,35 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa
needsSubprocess := (effectiveStatus == models.WORKING_TREE_STATE_MERGING && command != REBASE_OPTION_ABORT && self.c.UserConfig().Git.Merging.ManualCommit) ||
// but we'll also use a subprocess if we have exec todos; those are likely to be lengthy build
// tasks whose output the user will want to see in the terminal
(effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos())
(effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos(calledFromWorker))
if needsSubprocess {
// TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction
success, err := self.c.RunSubprocess(self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command))
self.refreshAfterMergeOrRebase(types.RefreshOptions{
Mode: types.ASYNC,
CommitSelection: commitSelectionAfterMerge(success && selectHeadCommitOnSuccess),
}, calledFromWorker)
self.RecordWhetherMergeOrRebaseStartedInLazygit()
return err
}
runAction := func(calledFromWorker bool) error {
// runAction always ends up on a worker: either the waiting status below spins
// one up, or we're the recursive auto-skip reached from
// CheckMergeOrRebaseWithRefreshOptions, which already runs on one.
runAction := func() error {
result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command)
return self.checkMergeOrRebaseImpl(result,
return self.CheckMergeOrRebaseWithRefreshOptions(result,
types.RefreshOptions{
Mode: types.ASYNC,
CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess),
}, calledFromWorker)
})
}
if showWaitingStatus {
return self.c.WithWaitingStatus(status.Title(self.c.Tr), func(gocui.Task) error {
// The waiting status ran runAction on a worker.
return runAction(true)
return runAction()
})
}
return runAction(calledFromWorker)
return runAction()
}
// commitSelectionAfterMerge maps whether a merge/rebase/pull created a new
@ -168,16 +168,31 @@ func commitSelectionAfterMerge(createdNewCommit bool) types.CommitSelectionBehav
return types.KeepCommitSelectionByHash
}
func (self *MergeAndRebaseHelper) hasExecTodos() bool {
for _, commit := range self.c.Model().Commits {
if !commit.IsTODO() {
break
}
if commit.Action == todo.Exec {
return true
func (self *MergeAndRebaseHelper) hasExecTodos(calledFromWorker bool) bool {
check := func() bool {
for _, commit := range self.c.Model().Commits {
if !commit.IsTODO() {
break
}
if commit.Action == todo.Exec {
return true
}
}
return false
}
return false
// This reads the model, which is only safe on the UI thread, so bounce there
// when we're being called from a worker.
if !calledFromWorker {
return check()
}
result := false
_ = self.c.GocuiGui().OnUIThreadAndWait(func() error {
result = check()
return nil
})
return result
}
var conflictStrings = []string{
@ -211,33 +226,19 @@ func (self *MergeAndRebaseHelper) RecordWhetherMergeOrRebaseStartedInLazygit() {
}
// CheckMergeOrRebaseWithRefreshOptions handles the result of a merge/rebase
// step and refreshes. It's for callers running on a worker (the
// WithWaitingStatus / WithInlineStatus handlers), which is the large majority;
// UI-thread callers use CheckMergeOrRebaseWithRefreshOptionsFromUIThread.
// step and refreshes. It always runs on a worker (the WithWaitingStatus /
// WithWaitingStatusBlockingInput / WithInlineStatus handlers).
func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result error, refreshOptions types.RefreshOptions) error {
return self.checkMergeOrRebaseImpl(result, refreshOptions, true)
}
// CheckMergeOrRebaseWithRefreshOptionsFromUIThread is like
// CheckMergeOrRebaseWithRefreshOptions, but for the callers that run the
// merge/rebase synchronously on the UI thread (the WithWaitingStatusSync
// move/revert/squash-fixups/cherry-pick-paste/patch-discard handlers, kept sync
// so rapid key presses batch) rather than on a worker.
func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result error, refreshOptions types.RefreshOptions) error {
return self.checkMergeOrRebaseImpl(result, refreshOptions, false)
}
func (self *MergeAndRebaseHelper) checkMergeOrRebaseImpl(result error, refreshOptions types.RefreshOptions, calledFromWorker bool) error {
self.refreshAfterMergeOrRebase(refreshOptions, calledFromWorker)
self.refreshAfterMergeOrRebase(refreshOptions, true)
self.RecordWhetherMergeOrRebaseStartedInLazygit()
if result == nil {
return nil
} else if strings.Contains(result.Error(), "No changes - did you forget to use") {
return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, calledFromWorker)
return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, true)
} else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") {
return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, calledFromWorker)
return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, true)
} else if strings.Contains(result.Error(), "No rebase in progress?") {
// assume in this case that we're already done
return nil
@ -247,8 +248,8 @@ func (self *MergeAndRebaseHelper) checkMergeOrRebaseImpl(result error, refreshOp
// refreshAfterMergeOrRebase issues the post-action refresh on the entry point
// that matches the thread the merge/rebase ran on: RefreshFromWorker for the
// worker callers, Refresh for the ones that stayed synchronously on the UI
// thread.
// worker callers, Refresh for the merge/rebase-continue subprocess path that
// stays on the UI thread.
func (self *MergeAndRebaseHelper) refreshAfterMergeOrRebase(refreshOptions types.RefreshOptions, calledFromWorker bool) {
if calledFromWorker {
self.c.RefreshFromWorker(refreshOptions)
@ -258,7 +259,7 @@ func (self *MergeAndRebaseHelper) refreshAfterMergeOrRebase(refreshOptions types
}
func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error {
return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.ASYNC})
return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{})
}
// Like CheckMergeOrRebase, but for operations that create a new commit at HEAD
@ -267,7 +268,7 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error {
// before the refresh.
func (self *MergeAndRebaseHelper) CheckMergeOrRebaseAndSelectHeadCommit(result error) error {
return self.CheckMergeOrRebaseWithRefreshOptions(result,
types.RefreshOptions{Mode: types.SYNC, CommitSelection: commitSelectionAfterMerge(result == nil)})
types.RefreshOptions{CommitSelection: commitSelectionAfterMerge(result == nil)})
}
func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error {
@ -321,7 +322,7 @@ func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error {
}
// PromptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress
func (self *MergeAndRebaseHelper) PromptToContinueRebase() error {
func (self *MergeAndRebaseHelper) PromptToContinueRebase() {
self.continueRebasePromptShowing = true
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.Continue,
@ -346,7 +347,7 @@ func (self *MergeAndRebaseHelper) PromptToContinueRebase() error {
// to read it in Then; reading it inline here would see the previous
// model.
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES},
Scope: []types.RefreshableView{types.FILES},
Then: func() error {
unstagedFiles := GetUnstagedFilesExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
if len(unstagedFiles) > 0 {
@ -373,8 +374,6 @@ func (self *MergeAndRebaseHelper) PromptToContinueRebase() error {
return nil
},
})
return nil
}
// DismissContinueRebasePromptIfShowing closes the "continue the rebase/merge?"
@ -669,7 +668,7 @@ func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranch
if err != nil {
return err
}
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
})
}

View file

@ -51,32 +51,28 @@ func (self *MergeConflictsHelper) resetMergeState() {
self.context().GetState().Reset()
}
func (self *MergeConflictsHelper) EscapeMerge(background bool) error {
self.resetMergeState()
// EscapeMerge returns from the merge conflicts view to the files context. It
// must be called on the UI thread, without the merge-conflicts mutex held:
// pushing the files context renders the newly focused file to the main view,
// which can take the mutex again (via SetMergeState).
func (self *MergeConflictsHelper) EscapeMerge() {
self.ResetMergeState()
// doing this in separate UI thread so that we're not still holding the lock by the time refresh the file
onUIThread := self.c.OnUIThread
if background {
// Reached from a background files refresh; keep it off the busy count
// (see the *Background dispatch methods) so it doesn't block a repo switch.
onUIThread = self.c.OnUIThreadBackground
// The files refresh may already have opened the prompt to continue the
// rebase/merge on top of us (if all conflicts are resolved); in that case
// don't push the files context over it.
if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) {
self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{})
}
onUIThread(func() error {
// There is a race condition here: refreshing the files scope can trigger the
// confirmation context to be pushed if all conflicts are resolved (prompting
// to continue the merge/rebase. In that case, we don't want to then push the
// files context over it.
// So long as both places call OnUIThread, we're fine.
if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) {
self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{})
}
return nil
})
return nil
}
func (self *MergeConflictsHelper) SetConflictsAndRender(path string) (bool, error) {
hasConflicts, err := self.setMergeStateWithoutLock(path)
// SetConflictsAndRender re-reads the file being merged and re-renders the
// merge conflicts view. Returns whether the file still has conflicts.
func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) {
self.context().GetMutex().Lock()
defer self.context().GetMutex().Unlock()
hasConflicts, err := self.setMergeStateWithoutLock(self.context().GetState().GetPath())
if err != nil {
return false, err
}
@ -126,21 +122,18 @@ func (self *MergeConflictsHelper) Render() {
})
}
func (self *MergeConflictsHelper) RefreshMergeState(background bool) error {
self.c.Contexts().MergeConflicts.GetMutex().Lock()
defer self.c.Contexts().MergeConflicts.GetMutex().Unlock()
func (self *MergeConflictsHelper) RefreshMergeState() error {
if self.c.Context().Current().GetKey() != context.MERGE_CONFLICTS_CONTEXT_KEY {
return nil
}
hasConflicts, err := self.SetConflictsAndRender(self.c.Contexts().MergeConflicts.GetState().GetPath())
hasConflicts, err := self.SetConflictsAndRender()
if err != nil {
return err
}
if !hasConflicts {
return self.EscapeMerge(background)
self.EscapeMerge()
}
return nil

View file

@ -96,287 +96,361 @@ type refreshEnv struct {
// the repo generation captured when the refresh started
generation int
// When non-nil, each scope's UI-thread bounce is collected here instead of
// being dispatched as it's produced, so they can all be applied in a single
// frame once the whole refresh is done (see RefreshOptions.BatchUIUpdates).
// Held by pointer so the copies of env that flow through the scope functions
// all share the one batch.
batch *refreshBounceBatch
}
// refreshBounceBatch collects the UI-thread bounces of a batched refresh so they
// can be applied together in one frame rather than one scope at a time. The
// scopes run on separate worker goroutines and add concurrently, hence the
// mutex. Once the refresh starts flushing it closes the batch, so that any
// bounces enqueued afterwards — the nested ones a flushed bounce produces in
// turn, e.g. scrolling the selection into view — are dispatched immediately as
// ordinary follow-ups instead of being collected into a batch that nothing
// will drain.
type refreshBounceBatch struct {
mutex deadlock.Mutex
funcs []func()
closed bool
}
// add collects f and returns true. Once the batch is closed it collects nothing
// and returns false, telling the caller to dispatch f immediately instead.
func (self *refreshBounceBatch) add(f func()) bool {
self.mutex.Lock()
defer self.mutex.Unlock()
if self.closed {
return false
}
self.funcs = append(self.funcs, f)
return true
}
// close marks the batch flushed and returns everything collected so far.
func (self *refreshBounceBatch) close() []func() {
self.mutex.Lock()
defer self.mutex.Unlock()
self.closed = true
return self.funcs
}
func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) {
if options.Mode == types.ASYNC && options.Then != nil {
panic("RefreshOptions.Then doesn't work with mode ASYNC")
startTime := time.Now()
// A refresh from a worker blocks that worker until it's done; one from the
// UI thread returns immediately and finishes in the background.
syncOrAsync := "async"
if calledFromWorker {
syncOrAsync = "sync"
}
t := time.Now()
defer func() {
self.c.Log.Infof("Refresh took %s", time.Since(t))
}()
if options.Scope == nil {
self.c.Log.Infof(
"refreshing all scopes in %s mode",
getModeName(options.Mode),
)
self.c.Log.Infof("refreshing all scopes (%s)", syncOrAsync)
} else {
self.c.Log.Infof(
"refreshing the following scopes in %s mode: %s",
getModeName(options.Mode),
"refreshing the following scopes (%s): %s",
syncOrAsync,
strings.Join(getScopeNames(options.Scope), ","),
)
}
// f runs on the UI thread when the refresh was initiated there, and also for
// BLOCK_UI, which dispatches f onto the UI thread regardless of the caller.
// Only a SYNC/ASYNC refresh initiated from a worker runs f on that worker.
// This, not calledFromWorker alone, is what decides whether a scope capture
// runs inline or has to hop (see captureOnUIThread).
fRunsOnUIThread := options.Mode == types.BLOCK_UI || !calledFromWorker
// Debug-only guard: every refresh must be issued from the entry point that
// matches its goroutine — Refresh on the UI thread, RefreshFromWorker on a
// worker. We check the caller's own goroutine here, before a BLOCK_UI
// refresh dispatches f onto the UI thread, so it holds regardless of the
// mode. goid stays out of production control flow (debug only).
// worker. goid stays out of production control flow (debug only).
if self.c.GetConfig().GetDebug() && self.c.GocuiGui().IsUIThread() == calledFromWorker {
panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread")
}
f := func() {
// Capture the repo generation once, here at the start, so every scope's
// bounce is guarded against the same baseline.
env := refreshEnv{
background: options.Background,
generation: self.c.State().GetRepoGeneration(),
}
// Capture the repo generation once, here at the start, so every scope's
// bounce is guarded against the same baseline.
env := refreshEnv{
background: options.Background,
generation: self.c.State().GetRepoGeneration(),
}
if options.BatchUIUpdates {
env.batch = &refreshBounceBatch{}
}
var scopeSet *set.Set[types.RefreshableView]
if len(options.Scope) == 0 {
// not refreshing staging/patch-building unless explicitly requested because we only need
// to refresh those while focused.
scopeSet = set.NewFromSlice([]types.RefreshableView{
types.COMMITS,
types.BRANCHES,
types.FILES,
types.STASH,
types.REFLOG,
types.TAGS,
types.REMOTES,
types.WORKTREES,
types.STATUS,
types.BISECT_INFO,
types.STAGING,
types.PULL_REQUESTS,
})
} else {
scopeSet = set.NewFromSlice(options.Scope)
}
var scopeSet *set.Set[types.RefreshableView]
if len(options.Scope) == 0 {
// not refreshing staging/patch-building unless explicitly requested because we only need
// to refresh those while focused.
scopeSet = set.NewFromSlice([]types.RefreshableView{
types.COMMITS,
types.BRANCHES,
types.FILES,
types.STASH,
types.REFLOG,
types.TAGS,
types.REMOTES,
types.WORKTREES,
types.STATUS,
types.BISECT_INFO,
types.STAGING,
types.PULL_REQUESTS,
})
} else {
scopeSet = set.NewFromSlice(options.Scope)
}
// Expand co-refreshing scopes up front so downstream conditions can be
// simple single-scope checks. The relationships are:
// - whenever the reflog or bisect info changes, commits and branches
// can change too (e.g. switching branches updates the reflog and
// can move HEAD), so refresh commits + branches alongside
// - submodules are refreshed as part of the files refresh
// - 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
if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) {
scopeSet.Add(types.COMMITS, types.BRANCHES)
}
if scopeSet.Includes(types.SUBMODULES) {
scopeSet.Add(types.FILES)
}
if scopeSet.Includes(types.FILES) {
scopeSet.Add(types.MERGE_CONFLICTS)
}
if scopeSet.Includes(types.PULL_REQUESTS) {
scopeSet.Add(types.BRANCHES, types.REMOTES)
}
// Expand co-refreshing scopes up front so downstream conditions can be
// simple single-scope checks. The relationships are:
// - whenever the reflog or bisect info changes, commits and branches
// can change too (e.g. switching branches updates the reflog and
// can move HEAD), so refresh commits + branches alongside
// - submodules are refreshed as part of the files refresh
// - 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
if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) {
scopeSet.Add(types.COMMITS, types.BRANCHES)
}
if scopeSet.Includes(types.SUBMODULES) {
scopeSet.Add(types.FILES)
}
if scopeSet.Includes(types.FILES) {
scopeSet.Add(types.MERGE_CONFLICTS)
}
if scopeSet.Includes(types.PULL_REQUESTS) {
scopeSet.Add(types.BRANCHES, types.REMOTES)
}
// Capture the refs snapshot now, before we start reading git's state
// below, rather than after. This is important to guard against the race
// of git's state changing externally while (or right after) we are
// refreshing; the risk is one potential extra refresh, but capturing the
// snapshot at the end would risk missing one, which is worse.
self.updateRefsSnapshotIfRelevant(scopeSet)
// Capture the refs snapshot now, before we start reading git's state
// below, rather than after. This is important to guard against the race
// of git's state changing externally while (or right after) we are
// refreshing; the risk is one potential extra refresh, but capturing the
// snapshot at the end would risk missing one, which is worse.
self.updateRefsSnapshotIfRelevant(scopeSet)
wg := sync.WaitGroup{}
refresh := func(name string, f func()) {
// if we're in a demo we don't want any async refreshes because
// everything happens fast and it's better to have everything update
// in the one frame
if !self.c.InDemo() && options.Mode == types.ASYNC {
self.onWorker(env.background, func(t gocui.Task) error {
f()
return nil
})
} else {
wg.Add(1)
go utils.Safe(func() {
t := time.Now()
defer wg.Done()
f()
self.c.Log.Infof("refreshed %s in %s", name, time.Since(t))
})
}
}
wg := sync.WaitGroup{}
refresh := func(name string, f func()) {
wg.Add(1)
// Each scope runs on its own goroutine, joined by the wg.Wait in
// waitAndFinalize. They don't need to be registered as gocui tasks for
// repo-switch safety: performRefresh always runs under a task that stays
// busy until that wg.Wait returns — the calling worker's task when
// called from a worker, or the waitAndFinalize worker task when called
// from the UI thread (created before the triggering event's task ends,
// so there's no gap) — and that task already covers the whole refresh.
go utils.Safe(func() {
t := time.Now()
defer wg.Done()
f()
self.c.Log.Infof("refreshed %s in %s", name, time.Since(t))
})
}
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
// fetch worker can't read them back from the model without racing (and
// would see the pre-refresh values); instead the branches and remotes
// loads stash what they loaded here, and the wait on
// 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.
var capturedCommits capturedCommitState
var capturedReflog capturedReflogState
var capturedBranches capturedBranchState
self.captureOnUIThread(fRunsOnUIThread, env.background, func() {
capturedCommits = self.captureCommitsState(options.CommitSelection)
capturedReflog = self.captureReflogState()
capturedBranches = self.captureBranchState()
})
refresh("commits and commit files", func() {
self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env)
})
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
// fetch worker can't read them back from the model without racing (and
// would see the pre-refresh values); instead the branches and remotes
// loads stash what they loaded here, and the wait on
// 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.
var capturedCommits capturedCommitState
var capturedReflog capturedReflogState
var capturedBranches capturedBranchState
self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedCommits = self.captureCommitsState(options.CommitSelection)
capturedReflog = self.captureReflogState()
capturedBranches = self.captureBranchState()
})
refresh("commits and commit files", func() {
self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env)
})
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)
branchesAndRemotesWg.Done()
})
} else {
branchesAndRemotesWg.Add(1)
refresh("branches", func() {
// 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)
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(fRunsOnUIThread, 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(fRunsOnUIThread, env.background, func() {
capturedSubCommits = self.captureSubCommitState()
})
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(fRunsOnUIThread, env.background, func() {
capturedCommitFiles = self.captureCommitFilesState()
})
refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) })
}
fileWg := sync.WaitGroup{}
if scopeSet.Includes(types.FILES) {
var capturedFiles capturedFilesState
self.captureOnUIThread(fRunsOnUIThread, env.background, func() {
capturedFiles = self.captureFilesState()
})
fileWg.Add(1)
refresh("files", func() {
_ = self.refreshFilesAndSubmodules(capturedFiles, env)
fileWg.Done()
})
}
if scopeSet.Includes(types.STASH) {
var stashFilterPath string
self.captureOnUIThread(fRunsOnUIThread, env.background, func() {
stashFilterPath = self.c.Modes().Filtering.GetPath()
})
refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) })
}
if scopeSet.Includes(types.TAGS) {
refresh("tags", func() { _ = self.refreshTags(env) })
}
if scopeSet.Includes(types.REMOTES) {
// Capture the previously-selected remote on the UI thread; the worker
// 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(fRunsOnUIThread, env.background, func() {
prevSelectedRemote = self.c.Contexts().Remotes.GetSelected()
})
includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES)
if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" {
branchesAndRemotesWg.Add(1)
refresh("remotes", func() {
loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env)
refresh("reflog and branches", func() {
loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env)
branchesAndRemotesWg.Done()
})
}
if scopeSet.Includes(types.PULL_REQUESTS) {
refresh("pull requests", func() {
branchesAndRemotesWg.Wait()
// Use the branches and remotes the loads above stashed, not
// Model().Branches/Remotes: those writes are bounced onto the
// UI thread and may not have landed on this worker yet. The
// wait above orders us after both loads have stashed theirs.
self.refreshGithubPullRequests(loadedBranches, loadedRemotes, env)
} else {
branchesAndRemotesWg.Add(1)
refresh("branches", func() {
// 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)
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.WORKTREES) && !includeWorktreesWithBranches {
refresh("worktrees", func() { self.refreshWorktrees(env) })
}
if scopeSet.Includes(types.SUB_COMMITS) {
var capturedSubCommits capturedSubCommitState
self.captureOnUIThread(calledFromWorker, env.background, func() {
capturedSubCommits = self.captureSubCommitState()
})
refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) })
}
if scopeSet.Includes(types.STAGING) {
refresh("staging", func() {
fileWg.Wait()
// Bounce onto the UI thread so this runs after the files
// scope's model-update bounce — RefreshStagingPanel reads
// Model.Files (via Files.GetSelected) and would otherwise
// see the pre-refresh model. Guard on the generation so a
// repo switch mid-refresh drops it, like the model bounces.
self.onUIThreadUnlessRepoChanged(env, func() error {
self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{})
return nil
})
// 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() {
capturedCommitFiles = self.captureCommitFilesState()
})
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() {
capturedFiles = self.captureFilesState()
})
fileWg.Add(1)
refresh("files", func() {
_ = self.refreshFilesAndSubmodules(capturedFiles, env)
fileWg.Done()
})
}
if scopeSet.Includes(types.STASH) {
var stashFilterPath string
self.captureOnUIThread(calledFromWorker, env.background, func() {
stashFilterPath = self.c.Modes().Filtering.GetPath()
})
refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) })
}
if scopeSet.Includes(types.TAGS) {
refresh("tags", func() { _ = self.refreshTags(env) })
}
if scopeSet.Includes(types.REMOTES) {
// Capture the previously-selected remote on the UI thread; the worker
// 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() {
prevSelectedRemote = self.c.Contexts().Remotes.GetSelected()
})
branchesAndRemotesWg.Add(1)
refresh("remotes", func() {
loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env)
branchesAndRemotesWg.Done()
})
}
if scopeSet.Includes(types.PULL_REQUESTS) {
// Fetching pull requests talks to the GitHub API over the network; on
// a bad connection that request can stall for a long time. It runs no
// git commands against the repo, and its model writes are guarded by
// the repo generation (a repo switch mid-fetch simply drops the
// result), so it is safe to run as a background task even when the
// enclosing refresh is a foreground one — a foreground task would
// block repo switching for as long as the request takes. The env copy
// makes the downstream UI-thread bounces background as well.
prEnv := env
prEnv.background = true
self.c.OnWorkerBackground(func(gocui.Task) error {
branchesAndRemotesWg.Wait()
t := time.Now()
// Use the branches and remotes the loads above stashed, not
// Model().Branches/Remotes: those writes are bounced onto the
// UI thread and may not have landed on this worker yet. The
// wait above orders us after both loads have stashed theirs.
self.refreshGithubPullRequests(loadedBranches, loadedRemotes, prEnv)
self.c.Log.Infof("refreshed pull requests in %s", time.Since(t))
return nil
})
}
if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches {
refresh("worktrees", func() { self.refreshWorktrees(env) })
}
if scopeSet.Includes(types.STAGING) {
refresh("staging", func() {
fileWg.Wait()
// Bounce onto the UI thread so this runs after the files
// scope's model-update bounce — RefreshStagingPanel reads
// Model.Files (via Files.GetSelected) and would otherwise
// see the pre-refresh model. Guard on the generation so a
// repo switch mid-refresh drops it, like the model bounces.
self.onUIThreadUnlessRepoChanged(env, func() {
self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{})
})
}
})
}
if scopeSet.Includes(types.PATCH_BUILDING) {
refresh("patch building", func() { self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) })
}
if scopeSet.Includes(types.PATCH_BUILDING) {
refresh("patch building", func() {
// Bounce onto the UI thread, like the staging panel above:
// RefreshPatchBuildingPanel reads the commit-files selection and
// sets the patch view's origin, neither of which may run off the UI
// thread. Guard on the generation so a repo switch mid-refresh drops
// it, like the model bounces.
self.onUIThreadUnlessRepoChanged(env, func() {
self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{})
})
})
}
if scopeSet.Includes(types.MERGE_CONFLICTS) {
refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState(env.background) })
}
if scopeSet.Includes(types.MERGE_CONFLICTS) {
refresh("merge conflicts", func() {
// Bounce onto the UI thread, like the staging and patch-building
// panels above: RefreshMergeState reads the current context and
// renders (or escapes) the merge-conflicts view, none of which may
// run off the UI thread.
self.onUIThreadUnlessRepoChanged(env, func() {
_ = self.mergeConflictsHelper.RefreshMergeState()
})
})
}
self.refreshStatus(env)
self.refreshStatus(env)
waitAndFinalize := func() {
wg.Wait()
if env.batch != nil {
// Apply all the scopes' collected bounces in a single UI-thread task,
// so they land in one frame: gocui drains every queued event before it
// redraws, so one task means one repaint. Bounces enqueued from within
// these (see refreshBounceBatch) run as ordinary follow-ups.
bounces := env.batch.close()
self.onUIThread(env.background, func() error {
for _, bounce := range bounces {
bounce()
}
return nil
})
}
if options.Then != nil {
// Queue Then via OnUIThread so it runs *after* the refresh-scope
// functions' model-update bounces (which are already queued by
@ -386,17 +460,21 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr
// still pre-refresh.
self.onUIThread(env.background, options.Then)
}
self.c.Log.Infof("Refresh took %s", time.Since(startTime))
}
if options.Mode == types.BLOCK_UI {
self.c.OnUIThread(func() error {
f()
// waitAndFinalize blocks until every scope is done. Run it inline when we're
// already on a worker (or in a demo, for a deterministic single frame); when
// we're on the UI thread, dispatch it to a worker so it doesn't block the UI.
if calledFromWorker || self.c.InDemo() {
waitAndFinalize()
} else {
self.onWorker(env.background, func(t gocui.Task) error {
waitAndFinalize()
return nil
})
return
}
f()
}
// SetRefsSnapshot stores the given snapshot as the last observed refs state.
@ -477,19 +555,6 @@ func getScopeNames(scopes []types.RefreshableView) []string {
})
}
func getModeName(mode types.RefreshMode) string {
switch mode {
case types.SYNC:
return "sync"
case types.ASYNC:
return "async"
case types.BLOCK_UI:
return "block-ui"
default:
return "unknown mode"
}
}
// During startup, the bottleneck is fetching the reflog entries, which we need
// in order to sort the branches by recency. So we have two phases: INITIAL and
// COMPLETE. In the INITIAL phase we don't have any reflog commits yet, so we
@ -622,7 +687,7 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS
// The commit selection is restored in refreshCommitsWithLimit's bounce,
// so read it on the UI thread after that bounce; then load the commit
// files back on a worker (refreshCommitFilesContext does git work).
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
commit := self.c.Contexts().LocalCommits.GetSelected()
if commit != nil && commit.RefName() != "" {
refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles()
@ -635,7 +700,6 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS
return nil
})
}
return nil
})
}
}
@ -687,7 +751,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState,
}
workingTreeState := self.c.Git().Status.WorkingTreeState()
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().BisectInfo = bisectInfo
self.c.Model().Commits = commits
self.RefreshAuthors(commits)
@ -721,12 +785,10 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState,
// 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() error {
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Contexts().LocalCommits.FocusLine(true)
return nil
})
}
return nil
})
self.refreshView(self.c.Contexts().LocalCommits, env)
@ -856,10 +918,9 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommit
if err != nil {
return err
}
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().SubCommits = commits
self.RefreshAuthors(commits)
return nil
})
self.refreshView(self.c.Contexts().SubCommits, env)
@ -899,10 +960,9 @@ func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFile
if err != nil {
return err
}
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().CommitFiles = files
self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree()
return nil
})
self.refreshView(self.c.Contexts().CommitFiles, env)
return nil
@ -921,10 +981,9 @@ func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, comm
}
workingTreeState := self.c.Git().Status.WorkingTreeState()
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().Commits = updatedCommits
self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState
return nil
})
self.refreshView(self.c.Contexts().LocalCommits, env)
@ -937,9 +996,8 @@ func (self *RefreshHelper) refreshTags(env refreshEnv) error {
return err
}
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().Tags = tags
return nil
})
self.refreshView(self.c.Contexts().Tags, env)
@ -966,10 +1024,9 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
})
},
func() {
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Contexts().Branches.HandleRender()
self.refreshStatus(env)
return nil
})
})
if err != nil {
@ -981,14 +1038,14 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
worktrees = self.loadWorktrees()
}
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
// Drop this write if a branch load that started later has already applied
// its result. At the INITIAL startup stage an immediate load (not
// recency-sorted) and an async recency-sorted load run concurrently; this
// makes the later-started (recency-sorted) one win regardless of which
// finishes first, so its result isn't clobbered by the stale immediate one.
if loadSeq < self.appliedBranchLoadSeq {
return nil
return
}
self.appliedBranchLoadSeq = loadSeq
@ -1031,7 +1088,6 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh
// Need to re-render the commits view because the visualization of local
// branch heads might have changed
self.c.Contexts().LocalCommits.HandleRender()
return nil
})
self.refreshView(self.c.Contexts().Branches, env)
@ -1066,13 +1122,22 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState
// bumps the generation, so a write captured under the old generation must not
// clobber the new repo's state. The generation is captured once at the start of
// the refresh and carried in env (see refreshEnv).
func (self *RefreshHelper) onUIThreadUnlessRepoChanged(env refreshEnv, f func() error) {
self.onUIThread(env.background, func() error {
func (self *RefreshHelper) onUIThreadUnlessRepoChanged(env refreshEnv, f func()) {
wrapper := func() {
if self.c.State().GetRepoGeneration() != env.generation {
return nil
return
}
return f()
})
f()
}
// A batched refresh collects its bounces and fires them together at the end
// (see refreshBounceBatch); add reports false once the batch is flushing, so
// bounces enqueued from within a flushed bounce dispatch immediately.
if env.batch != nil && env.batch.add(wrapper) {
return
}
self.onUIThread(env.background, func() error { wrapper(); return nil })
}
// onWorker and onUIThread pick the foreground or background variant of the
@ -1100,17 +1165,16 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) {
// reads the model/context/mode state a refresh scope needs into locals, so the
// worker that follows computes from an immutable snapshot instead of reading
// state the UI thread concurrently mutates. When the enclosing refresh function
// runs on the UI thread (fRunsOnUIThread is true) fn runs inline; when it runs
// runs on the UI thread (calledFromWorker is false) fn runs inline; when it runs
// on a worker, fn is dispatched to the UI thread and we block for it.
//
// The inline case matters for correctness as much as the hop: a SYNC or
// BLOCK_UI refresh parks the UI thread in a wg.Wait while its scope workers
// run, so a scope worker that tried to hop to the UI thread there would
// The inline case matters for correctness as much as the hop: a SYNC refresh
// initiated on the UI thread parks that thread in a wg.Wait while its scope
// workers run, so a scope worker that tried to hop to the UI thread there would
// deadlock. Capturing before those workers are spawned — inline, on the UI
// thread — avoids that entirely. This is why BLOCK_UI (which always runs on the
// UI thread, even from a worker caller) captures inline rather than hopping.
func (self *RefreshHelper) captureOnUIThread(fRunsOnUIThread bool, background bool, fn func()) {
if fRunsOnUIThread {
// thread — avoids that entirely.
func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) {
if !calledFromWorker {
fn()
return
}
@ -1206,8 +1270,21 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
// (e.g. in the user's editor). Offer to continue it. We only do this
// for operations we started ourselves; prompting for one that was
// started outside lazygit (e.g. by a coding agent) would be confusing.
self.onUIThreadUnlessRepoChanged(env, func() error {
return self.mergeAndRebaseHelper.PromptToContinueRebase()
self.onUIThreadUnlessRepoChanged(env, func() {
// The merge-conflicts scope of this refresh also notices that
// the conflicts are gone and escapes from the merge conflicts
// view to the files context (see RefreshMergeState), but it
// runs concurrently with us, and its escape refuses to push
// the files context over a popup. So if our prompt opens
// first, the escape does nothing, and closing the prompt
// would land the user in the dead merge conflicts view.
// Escape it ourselves before opening the prompt, so that the
// prompt always opens on top of the files context.
if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) {
self.mergeConflictsHelper.ResetMergeState()
self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{})
}
self.mergeAndRebaseHelper.PromptToContinueRebase()
})
}
} else {
@ -1218,13 +1295,12 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
// Guard on the generation like the sibling PromptToContinueRebase
// bounce above: if the repo was switched while this refresh was in
// flight, a prompt showing now belongs to the new repo, so leave it be.
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
self.mergeAndRebaseHelper.DismissContinueRebasePromptIfShowing()
return nil
})
}
self.onUIThreadUnlessRepoChanged(env, func() error {
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 fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll {
@ -1239,7 +1315,6 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
self.c.Model().Submodules = submoduleConfigs
self.c.Model().Files = files
fileTreeViewModel.SetTree()
return nil
})
return nil
@ -1256,10 +1331,6 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re
// that a subsequent branches refresh can use them for recency sorting without
// having to read them back out of the model.
func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, env refreshEnv, selectTopEntry bool) ([]*models.Commit, error) {
// pulling state into its own variable in case it gets swapped out for another state
// and we get an out of bounds exception
model := self.c.Model()
// load does the git work on the worker and returns the new value for a
// reflog slice, reading the existing slice (captured on the UI thread) for
// the incremental fetch. The caller writes the result in the bounce.
@ -1294,9 +1365,9 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en
}
}
self.onUIThreadUnlessRepoChanged(env, func() error {
model.ReflogCommits = reflogCommits
model.FilteredReflogCommits = filteredReflogCommits
self.onUIThreadUnlessRepoChanged(env, func() {
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.
@ -1304,7 +1375,6 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en
self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0)
self.c.Contexts().ReflogCommits.GetView().SetOriginY(0)
}
return nil
})
self.refreshView(self.c.Contexts().ReflogCommits, env)
@ -1317,7 +1387,7 @@ func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env
return nil, err
}
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().Remotes = remotes
hadPrs := len(self.c.Model().PullRequestsMap) != 0
@ -1337,7 +1407,6 @@ func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env
}
}
}
return nil
})
self.refreshView(self.c.Contexts().Remotes, env)
@ -1357,9 +1426,8 @@ func (self *RefreshHelper) loadWorktrees() []*models.Worktree {
func (self *RefreshHelper) refreshWorktrees(env refreshEnv) {
worktrees := self.loadWorktrees()
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().Worktrees = worktrees
return nil
})
// need to refresh branches because the branches view shows worktrees against
@ -1372,9 +1440,8 @@ func (self *RefreshHelper) refreshStashEntries(filterPath string, env refreshEnv
stashEntries := self.c.Git().Loaders.StashLoader.
GetStashEntries(filterPath)
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().StashEntries = stashEntries
return nil
})
self.refreshView(self.c.Contexts().Stash, env)
@ -1385,7 +1452,7 @@ func (self *RefreshHelper) refreshStatus(env refreshEnv) {
workingTreeState := self.c.Git().Status.WorkingTreeState()
repoName := self.c.Git().RepoPaths.RepoName()
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
// Read the checked-out branch and the linked worktree name here on the UI
// thread: both derive from models (Branches, Worktrees) that their
// refreshes now write via bounces, so reading them on the worker would
@ -1393,13 +1460,12 @@ func (self *RefreshHelper) refreshStatus(env refreshEnv) {
currentBranch := self.refsHelper.GetCheckedOutRef()
if currentBranch == nil {
// need to wait for branches to refresh
return nil
return
}
linkedWorktreeName := self.worktreeHelper.GetLinkedWorktreeName()
status := presentation.FormatStatus(repoName, currentBranch, types.ItemOperationNone, linkedWorktreeName, workingTreeState, self.c.Tr, self.c.UserConfig())
self.c.SetViewContent(self.c.Views().Status, status)
return nil
})
}
@ -1429,7 +1495,7 @@ func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) {
// switched while the refresh was in flight, its model write was already
// dropped, so there's nothing fresh to render — and the captured context
// belongs to the old repo's now-replaced context tree anyway.
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
// Re-applying the filter must be done before re-rendering the view, so that
// the filtered list model is up to date for rendering.
self.searchHelper.ReApplyFilter(context)
@ -1448,16 +1514,14 @@ func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) {
self.searchHelper.ReApplySearch(context)
return nil
})
return nil
})
}
func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, remotes []*models.Remote, env refreshEnv) {
clearPullRequests := func() {
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().PullRequests = nil
self.c.Model().PullRequestsMap = nil
return nil
})
}
@ -1610,14 +1674,13 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra
self.savePullRequestsToCache(prs)
self.onUIThreadUnlessRepoChanged(env, func() error {
self.onUIThreadUnlessRepoChanged(env, func() {
self.c.Model().PullRequests = prs
// Rebuilding here rather than on the worker means the map is built from
// 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)
return nil
})
}

View file

@ -56,7 +56,7 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions
scope = append(scope, types.PULL_REQUESTS)
}
self.c.RefreshFromWorker(types.RefreshOptions{
Mode: types.BLOCK_UI,
BatchUIUpdates: true,
Scope: scope,
BranchSelection: types.SelectCheckedOutBranch,
CommitSelection: types.SelectHeadCommit,
@ -160,7 +160,6 @@ func (self *RefsHelper) CheckoutRemoteBranch(fullBranchName string, localBranchN
// Do a sync refresh to make sure the new branch is visible,
// so that we see an inline status when checking it out
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{types.BRANCHES},
})
return checkout(localBranchName, true)
@ -363,15 +362,21 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest
}
refresh := func() {
if self.c.Context().Current() != self.c.Contexts().Branches {
self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{})
}
self.c.Refresh(types.RefreshOptions{
Mode: types.BLOCK_UI,
self.c.RefreshFromWorker(types.RefreshOptions{
BatchUIUpdates: true,
BranchSelection: types.SelectCheckedOutBranch,
CommitSelection: types.SelectHeadCommit,
SelectTopReflogCommit: true,
Then: func() error {
// Switch to the branches panel only now, in the same batched
// frame that applies the refreshed data, so the panel switch
// and the new branch appear together rather than flashing the
// old branch list while the checkout is still in progress.
if self.c.Context().Current() != self.c.Contexts().Branches {
self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{})
}
return nil
},
})
}
@ -385,34 +390,44 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest
if newBranchName != suggestedBranchName {
newBranchFunc = self.c.Git().Branch.NewWithoutTracking
}
if err := newBranchFunc(newBranchName, from); err != nil {
if IsSwitchBranchUncommittedChangesError(err) {
// offer to autostash changes
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.AutoStashTitle,
Prompt: self.c.Tr.AutoStashPrompt,
HandleConfirm: func() error {
if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil {
return err
}
if err := newBranchFunc(newBranchName, from); err != nil {
return err
}
err := self.c.Git().Stash.Pop(0)
// Branch switch successful so re-render the UI even if the pop operation failed (e.g. conflict).
refresh()
return err
},
})
return nil
// Creating the branch checks it out, which can take a while when
// the ref we're branching off is distant, so do it on a worker.
return self.c.WithWaitingStatus(self.c.Tr.CreatingBranchStatus, func(gocui.Task) error {
if err := newBranchFunc(newBranchName, from); err != nil {
if IsSwitchBranchUncommittedChangesError(err) {
// offer to autostash changes
self.c.OnUIThread(func() error {
self.c.Confirm(types.ConfirmOpts{
Title: self.c.Tr.AutoStashTitle,
Prompt: self.c.Tr.AutoStashPrompt,
HandleConfirm: func() error {
return self.c.WithWaitingStatus(self.c.Tr.CreatingBranchStatus, func(gocui.Task) error {
if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil {
return err
}
if err := newBranchFunc(newBranchName, from); err != nil {
return err
}
err := self.c.Git().Stash.Pop(0)
// Branch switch successful so re-render the UI even if the pop operation failed (e.g. conflict).
refresh()
return err
})
},
})
return nil
})
return nil
}
return err
}
return err
}
refresh()
return nil
refresh()
return nil
})
},
})
@ -534,7 +549,7 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa
}
self.c.RefreshFromWorker(types.RefreshOptions{
Mode: types.BLOCK_UI,
BatchUIUpdates: true,
BranchSelection: types.SelectCheckedOutBranch,
CommitSelection: types.SelectHeadCommit,
SelectTopReflogCommit: true,
@ -558,7 +573,7 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri
}
err := self.c.Git().Rebase.CherryPickCommits(commitsToCherryPick)
err = self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(err, types.RefreshOptions{Mode: types.SYNC})
err = self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(err, types.RefreshOptions{})
if err != nil {
return err
}
@ -570,7 +585,7 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri
}
self.c.RefreshFromWorker(types.RefreshOptions{
Mode: types.BLOCK_UI,
BatchUIUpdates: true,
BranchSelection: types.SelectCheckedOutBranch,
CommitSelection: types.SelectHeadCommit,
SelectTopReflogCommit: true,

View file

@ -3,6 +3,7 @@ package helpers
import (
"fmt"
"strings"
"sync/atomic"
"github.com/jesseduffield/generics/set"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
@ -28,14 +29,20 @@ import (
type SuggestionsHelper struct {
c *HelperCommon
// filesTrie holds the repo's file paths for file-path suggestions. It's
// rebuilt asynchronously and read from the suggestions worker goroutine, so
// it lives here as an atomic pointer rather than in the (UI-thread-only)
// model.
filesTrie atomic.Pointer[patricia.Trie]
}
func NewSuggestionsHelper(
c *HelperCommon,
) *SuggestionsHelper {
return &SuggestionsHelper{
c: c,
}
self := &SuggestionsHelper{c: c}
self.filesTrie.Store(patricia.NewTrie())
return self
}
func (self *SuggestionsHelper) getRemoteNames() []string {
@ -137,9 +144,9 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type
trie.Insert(patricia.Prefix(file), file)
}
// cache the trie for future use
self.filesTrie.Store(trie)
self.c.OnUIThread(func() error {
// cache the trie for future use
self.c.Model().FilesTrie = trie
self.c.Contexts().Suggestions.RefreshSuggestions()
return nil
})
@ -148,9 +155,10 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type
})
return func(input string) []*types.Suggestion {
filesTrie := self.filesTrie.Load()
matchingNames := []string{}
if self.c.UserConfig().Gui.UseFuzzySearch() {
_ = self.c.Model().FilesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error {
_ = filesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error {
matchingNames = append(matchingNames, item.(string))
return nil
})
@ -159,7 +167,7 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type
matchingNames = utils.FilterStrings(input, matchingNames, true)
} else {
substrings := strings.Fields(input)
_ = self.c.Model().FilesTrie.Visit(func(prefix patricia.Prefix, item patricia.Item) error {
_ = filesTrie.Visit(func(prefix patricia.Prefix, item patricia.Item) error {
for _, sub := range substrings {
if !utils.CaseAwareContains(item.(string), sub) {
return nil

View file

@ -238,7 +238,6 @@ func (self *WorkingTreeHelper) WithEnsureCommittableFiles(handler func() error)
return err
}
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{types.FILES},
Then: handler,
})
@ -260,7 +259,7 @@ func (self *WorkingTreeHelper) promptToStageAllAndRetry(retry func() error) erro
if err := self.c.Git().WorkingTree.StageAll(false); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
return retry()
},
@ -360,7 +359,7 @@ func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []strin
}
err := self.c.Git().WorkingTree.StageFiles(selectedFilepaths, nil)
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
return err
}

View file

@ -163,7 +163,7 @@ func (self *WorktreeHelper) remove(worktree *models.Worktree, force bool, then f
return then(task)
}
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}})
return nil
})
}
@ -181,7 +181,7 @@ func (self *WorktreeHelper) Detach(worktree *models.Worktree, then func(gocui.Ta
return then(task)
}
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}})
return nil
})
}

View file

@ -342,7 +342,7 @@ func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit,
HandleConfirm: func() error {
commits := self.c.Model().Commits
self.selectRebaseResultCommit(startIdx)
return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.SquashCommitDown)
return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx)
})
@ -366,7 +366,7 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star
OnPress: func() error {
commits := self.c.Model().Commits
self.selectRebaseResultCommit(startIdx)
return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.FixupCommit)
return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx)
})
@ -379,7 +379,7 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star
OnPress: func() error {
commits := self.c.Model().Commits
self.selectRebaseResultCommit(startIdx)
return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage)
return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C")
})
@ -476,7 +476,7 @@ func (self *LocalCommitsController) switchFromCommitMessagePanelToEditor(filepat
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
}
@ -490,12 +490,12 @@ func (self *LocalCommitsController) handleReword(summary string, description str
self.c.Tr.RewordingStatus, nil, nil)
}
return self.c.WithWaitingStatus(self.c.Tr.RewordingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RewordingStatus, func(gocui.Task) error {
err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description)
if err != nil {
return err
}
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
})
}
@ -576,7 +576,7 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start
if !isMerge {
self.selectRebaseResultCommit(startIdx)
}
return self.c.WithWaitingStatus(self.c.Tr.DroppingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.DroppingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.DropCommit)
if isMerge {
return self.dropMergeCommit(commits, startIdx)
@ -601,10 +601,10 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start
commits := self.c.Model().Commits
if !commits[endIdx].IsMerge() {
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "")
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{Mode: types.BLOCK_UI})
err, types.RefreshOptions{BatchUIUpdates: true})
})
}
@ -623,12 +623,12 @@ func (self *LocalCommitsController) quickStartInteractiveRebase() error {
func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
commitsToEdit []*models.Commit,
) error {
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.EditCommit)
err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash())
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err,
types.RefreshOptions{Mode: types.BLOCK_UI, Then: func() error {
types.RefreshOptions{BatchUIUpdates: true, Then: func() error {
todos := make([]*models.Commit, 0, len(commitsToEdit)-1)
for _, c := range commitsToEdit[:len(commitsToEdit)-1] {
// Merge commits can't be set to "edit", so just skip them
@ -700,7 +700,7 @@ func (self *LocalCommitsController) updateTodosWithFlag(action todo.TodoCommand,
}
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS},
Scope: []types.RefreshableView{types.REBASE_COMMITS},
})
return nil
@ -742,22 +742,30 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{types.REBASE_COMMITS},
CommitSelection: types.KeepCommitSelectionIndex,
})
return nil
}
return self.c.WithWaitingStatusSync(self.c.Tr.MovingStatus, func() error {
commits := self.c.Model().Commits
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.MoveCommitDown)
err := self.c.Git().Rebase.MoveCommitsDown(self.c.Model().Commits, startIdx, endIdx)
if err == nil {
self.context().MoveSelection(1)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
}
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(
err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex})
err := self.c.Git().Rebase.MoveCommitsDown(commits, startIdx, endIdx)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{
BatchUIUpdates: true,
CommitSelection: types.KeepCommitSelectionIndex,
// Move the selection to follow the moved commit, in Then so it
// lands in the same frame as the refreshed commit list.
Then: func() error {
if err == nil {
self.context().MoveSelection(1)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
}
return nil
},
})
})
}
@ -770,22 +778,30 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
self.c.Refresh(types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{types.REBASE_COMMITS},
CommitSelection: types.KeepCommitSelectionIndex,
})
return nil
}
return self.c.WithWaitingStatusSync(self.c.Tr.MovingStatus, func() error {
commits := self.c.Model().Commits
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.MoveCommitUp)
err := self.c.Git().Rebase.MoveCommitsUp(self.c.Model().Commits, startIdx, endIdx)
if err == nil {
self.context().MoveSelection(-1)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
}
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(
err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex})
err := self.c.Git().Rebase.MoveCommitsUp(commits, startIdx, endIdx)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{
BatchUIUpdates: true,
CommitSelection: types.KeepCommitSelectionIndex,
// Move the selection to follow the moved commit, in Then so it
// lands in the same frame as the refreshed commit list.
Then: func() error {
if err == nil {
self.context().MoveSelection(-1)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
}
return nil
},
})
})
}
@ -798,7 +814,7 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error {
if err := self.c.Helpers().AmendHelper.AmendHead(); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
self.c.Refresh(types.RefreshOptions{})
return nil
})
}
@ -807,7 +823,7 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error {
selectedIdx := self.context().GetView().SelectedLineIdx()
handleCommit = func() error {
return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error {
return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.AmendCommit)
err := self.c.Git().Rebase.AmendTo(commits, selectedIdx)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
@ -869,13 +885,13 @@ func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, en
}
func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error {
return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor)
if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil {
return err
}
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
})
}
@ -885,13 +901,13 @@ func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, e
Title: self.c.Tr.SetAuthorPromptTitle,
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
HandleConfirm: func(value string) error {
return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor)
if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil {
return err
}
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
})
},
@ -905,12 +921,12 @@ func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start,
Title: self.c.Tr.AddCoAuthorPromptTitle,
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
HandleConfirm: func(value string) error {
return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error {
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor)
if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil {
return err
}
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
})
},
@ -938,9 +954,8 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end
Prompt: promptText,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.RevertCommit)
return self.c.WithWaitingStatusSync(self.c.Tr.RevertingStatus, func() error {
mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RevertingStatus, func(gocui.Task) error {
if mustStash {
if err := self.c.Git().Stash.Push(self.c.Tr.AutoStashForReverting); err != nil {
return err
@ -948,7 +963,8 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end
}
result := self.c.Git().Commit.Revert(hashes, isMerge)
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{Mode: types.SYNC}); err != nil {
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(result,
types.RefreshOptions{BatchUIUpdates: true}); err != nil {
return err
}
@ -956,7 +972,7 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end
if err := self.c.Git().Stash.Pop(0); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{
self.c.RefreshFromWorker(types.RefreshOptions{
Scope: []types.RefreshableView{types.STASH, types.FILES},
})
}
@ -987,16 +1003,19 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err
OnPress: func() error {
return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error {
self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit)
return self.c.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error {
selectedIdx := self.context().GetSelectedLineIdx()
commits := self.c.Model().Commits
branches := self.c.Model().Branches
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error {
if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil {
return err
}
if err := self.moveFixupCommitToOwnerStackedBranch(commit); err != nil {
if err := self.moveFixupCommitToOwnerStackedBranch(commit, selectedIdx, commits, branches); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC})
self.c.RefreshFromWorker(types.RefreshOptions{BatchUIUpdates: true})
return nil
})
})
@ -1025,7 +1044,12 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err
})
}
func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(targetCommit *models.Commit) error {
// moveFixupCommitToOwnerStackedBranch takes state captured on the UI thread
// (the selected index and the commits and branches models) so that it can run
// its rebase on a worker without reading the model there.
func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(
targetCommit *models.Commit, selectedIdx int, commits []*models.Commit, branches []*models.Branch,
) error {
if self.c.Git().Version.IsOlderThan(2, 38, 0) {
// Git 2.38.0 introduced the `rebase.updateRefs` config option. Don't
// move the commit down with older versions, as it would break the stack.
@ -1053,9 +1077,9 @@ func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(targetCo
}
headOfOwnerBranchIdx := -1
for i := self.context().GetSelectedLineIdx(); i > 0; i-- {
if lo.SomeBy(self.c.Model().Branches, func(b *models.Branch) bool {
return b.CommitHash == self.c.Model().Commits[i].Hash()
for i := selectedIdx; i > 0; i-- {
if lo.SomeBy(branches, func(b *models.Branch) bool {
return b.CommitHash == commits[i].Hash()
}) {
headOfOwnerBranchIdx = i
break
@ -1066,7 +1090,7 @@ func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(targetCo
return nil
}
return self.c.Git().Rebase.MoveFixupCommitDown(self.c.Model().Commits, headOfOwnerBranchIdx)
return self.c.Git().Rebase.MoveFixupCommitDown(commits, headOfOwnerBranchIdx)
}
func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, includeFileChanges bool) error {
@ -1087,16 +1111,19 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc
PreserveMessage: false,
OnConfirm: func(summary string, description string) error {
self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit)
return self.c.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error {
selectedIdx := self.context().GetSelectedLineIdx()
commits := self.c.Model().Commits
branches := self.c.Model().Branches
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error {
if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil {
return err
}
if err := self.moveFixupCommitToOwnerStackedBranch(commit); err != nil {
if err := self.moveFixupCommitToOwnerStackedBranch(commit, selectedIdx, commits, branches); err != nil {
return err
}
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC})
self.c.RefreshFromWorker(types.RefreshOptions{BatchUIUpdates: true})
return nil
})
},
@ -1144,12 +1171,28 @@ func (self *LocalCommitsController) squashAllFixupsInCurrentBranch() error {
func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, rebaseStartIdx int) error {
selectionOffset := countSquashableCommitsAbove(self.c.Model().Commits, self.context().GetSelectedLineIdx(), rebaseStartIdx)
return self.c.WithWaitingStatusSync(self.c.Tr.SquashingStatus, func() error {
// The squashed fixups above the selection are removed, so the selection moves
// up by that many rows to stay on the same commit. Compute the target as an
// absolute index now, on the current list.
targetIdx := self.context().GetSelectedLineIdx() - selectionOffset
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits)
err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit)
self.context().MoveSelectedLine(-selectionOffset)
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(
err, types.RefreshOptions{Mode: types.SYNC})
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{
BatchUIUpdates: true,
// Set the selection in Then so it lands in the same frame as the
// refreshed commit list. It has to be an absolute index: the new
// list is shorter, so a relative move from the (clamped) old index
// could overshoot. PostRefreshUpdate repaints the moved selection.
Then: func() error {
if err == nil {
self.context().SetSelectedLineIdx(targetIdx)
self.c.PostRefreshUpdate(self.context())
}
return nil
},
})
})
}
@ -1196,7 +1239,7 @@ func (self *LocalCommitsController) openSearch() error {
// we usually lazyload these commits but now that we're searching we need to load them now
if self.context().GetLimitCommits() {
self.context().SetLimitCommits(false)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}})
}
return self.c.Helpers().Search.OpenSearchPrompt(self.context())
@ -1217,7 +1260,7 @@ func (self *LocalCommitsController) handleOpenLogMenu() error {
return self.c.WithWaitingStatus(self.c.Tr.LoadingCommits, func(gocui.Task) error {
self.c.Refresh(
types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}},
types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}},
)
return nil
})
@ -1271,7 +1314,6 @@ func (self *LocalCommitsController) handleOpenLogMenu() error {
return self.c.WithWaitingStatus(self.c.Tr.LoadingCommits, func(gocui.Task) error {
self.c.Refresh(
types.RefreshOptions{
Mode: types.SYNC,
Scope: []types.RefreshableView{types.COMMITS},
},
)
@ -1316,7 +1358,7 @@ func (self *LocalCommitsController) GetOnFocus() func(types.OnFocusOpts) {
context := self.context()
if context.GetSelectedLineIdx() > COMMIT_THRESHOLD && context.GetLimitCommits() {
context.SetLimitCommits(false)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}})
}
}
}

View file

@ -302,7 +302,7 @@ func (self *MergeConflictsController) resolveConflict(selection mergeconflicts.S
func (self *MergeConflictsController) onLastConflictResolved() {
// as part of refreshing files, we handle the situation where a file has had
// its merge conflicts resolved.
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
}
func (self *MergeConflictsController) openMergeConflictMenu() error {

View file

@ -223,13 +223,19 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error {
return nil
}
return self.c.WithWaitingStatusSync(self.c.Tr.RebasingStatus, func() error {
commitIndex := self.getPatchCommitIndex()
commits := self.c.Model().Commits
commitIndex := self.getPatchCommitIndex()
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex)
self.c.Helpers().PatchBuilding.Escape()
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(
err, types.RefreshOptions{Mode: types.SYNC})
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.Helpers().PatchBuilding.Escape()
return nil
})
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
err, types.RefreshOptions{})
})
}

View file

@ -158,7 +158,7 @@ func (self *RemoteBranchesController) createSortMenu() error {
if self.c.UserConfig().Git.RemoteBranchSortOrder != sortOrder {
self.c.UserConfig().Git.RemoteBranchSortOrder = sortOrder
self.c.Contexts().RemoteBranches.SetSelection(0)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.REMOTES}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.REMOTES}})
}
return nil
},

View file

@ -163,7 +163,6 @@ func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl
// affordable.
self.c.Refresh(types.RefreshOptions{
Scope: []types.RefreshableView{types.REMOTES},
Mode: types.SYNC,
Then: func() error {
// Select the remote
for idx, remote := range self.c.Model().Remotes {
@ -371,7 +370,6 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam
}
refreshOptions := types.RefreshOptions{
Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES},
Mode: types.SYNC,
}
if branchName != "" {
err = self.c.Git().Branch.New(branchName, remote.Name+"/"+branchName)

View file

@ -170,11 +170,16 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry)
Prompt: self.c.Tr.SureDropStashEntry,
HandleConfirm: func() error {
self.c.LogAction(self.c.Tr.Actions.DropStash)
// Refresh once at the end rather than after each drop: an async
// refresh from the UI thread finishes in the background, so firing
// one per iteration lets the workers race and an earlier, stale
// result can land last. The indices are captured up front and we
// drop highest-first, so the remaining lower indices stay valid
// without an intervening refresh.
defer self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}})
for i := len(stashEntries) - 1; i >= 0; i-- {
self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.DroppingStash, stashEntries[i].Hash), false)
err := self.c.Git().Stash.Drop(stashEntries[i].Index)
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}})
if err != nil {
if err := self.c.Git().Stash.Drop(stashEntries[i].Index); err != nil {
return err
}
}

View file

@ -66,7 +66,7 @@ func (self *SubCommitsController) GetOnFocus() func(types.OnFocusOpts) {
context := self.context()
if context.GetSelectedLineIdx() > COMMIT_THRESHOLD && context.GetLimitCommits() {
context.SetLimitCommits(false)
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.SUB_COMMITS}})
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUB_COMMITS}})
}
}
}

View file

@ -229,7 +229,7 @@ func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts)
}
return err
}
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
return nil
})
}

View file

@ -168,7 +168,7 @@ func (self *TagsController) localDelete(tag *models.Tag) error {
return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag)
err := self.c.Git().Tag.LocalDelete(tag.Name)
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
return err
})
}
@ -210,7 +210,7 @@ func (self *TagsController) remoteDelete(tag *models.Tag) error {
return err
}
self.c.Toast(self.c.Tr.RemoteTagDeletedMessage)
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
return nil
})
},
@ -264,7 +264,7 @@ func (self *TagsController) localAndRemoteDelete(tag *models.Tag) error {
if err := self.c.Git().Tag.LocalDelete(tag.Name); err != nil {
return err
}
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
return nil
})
},

View file

@ -66,12 +66,8 @@ func (self *VerticalScrollController) HandleScrollUp() error {
}
func (self *VerticalScrollController) HandleScrollDown() error {
scrollHeight := self.c.UserConfig().Gui.ScrollHeight
self.context.GetViewTrait().ScrollDown(scrollHeight)
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadLines(scrollHeight)
}
self.context.GetViewTrait().ScrollDown(self.c.UserConfig().Gui.ScrollHeight)
self.c.ReadLinesToFillView(self.context.GetView())
return nil
}

View file

@ -50,17 +50,12 @@ func (self *ViewSelectionController) GetMouseKeybindings(opts types.KeybindingsO
}
func (self *ViewSelectionController) handleLineChange(delta int) {
if delta > 0 {
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
manager.ReadLines(delta)
}
}
v := self.Context().GetView()
if delta < 0 {
v.ScrollUp(-delta)
} else {
v.ScrollDown(delta)
self.c.ReadLinesToFillView(v)
}
}

View file

@ -46,7 +46,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},
@ -68,7 +68,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},
@ -86,7 +86,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},
@ -111,7 +111,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},
@ -129,7 +129,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},
@ -147,7 +147,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},
@ -170,7 +170,7 @@ func (self *FilesController) createResetMenu() error {
}
self.c.Refresh(
types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}},
types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}},
)
return nil
},

View file

@ -35,10 +35,13 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key) bool {
v.RenderTextArea()
suggestionsContext := gui.State.Contexts.Suggestions
if suggestionsContext.State.FindSuggestions != nil {
// Capture the suggestions function and the input here, on the UI thread; the
// main thread rewrites State.FindSuggestions when it (re)creates a prompt
// panel, so reading it from the worker below would race that write.
if findSuggestions := suggestionsContext.State.FindSuggestions; findSuggestions != nil {
input := v.TextArea.GetContent()
suggestionsContext.State.AsyncHandler.Do(func() func() {
suggestions := suggestionsContext.State.FindSuggestions(input)
suggestions := findSuggestions(input)
return func() { suggestionsContext.SetSuggestions(suggestions) }
})
}

View file

@ -17,12 +17,8 @@ func (gui *Gui) scrollUpView(view *gocui.View) {
}
func (gui *Gui) scrollDownView(view *gocui.View) {
scrollHeight := gui.c.UserConfig().Gui.ScrollHeight
view.ScrollDown(scrollHeight)
if manager := gui.getViewBufferManagerForView(view); manager != nil {
manager.ReadLines(scrollHeight)
}
view.ScrollDown(gui.c.UserConfig().Gui.ScrollHeight)
gui.readLinesToFillView(view)
}
func (gui *Gui) scrollUpMain() error {

View file

@ -49,7 +49,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/sasha-s/go-deadlock"
"gopkg.in/ozeidan/fuzzy-patricia.v3/patricia"
)
const StartupPopupVersion = 5
@ -391,7 +390,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
}
gui.c.Log.Info("Receiving focus - refreshing")
gui.helpers.Refresh.Refresh(types.RefreshOptions{Mode: types.ASYNC})
gui.helpers.Refresh.Refresh(types.RefreshOptions{})
return reloadErr
}
@ -421,6 +420,10 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context
return nil
})
gui.g.SetUpdateQueueHighWaterMarkHandler(func(depth int) {
gui.c.Log.Infof("User-event queue reached a new high-water mark: %d", depth)
})
gui.g.SetOnSelectSearchResultFunc(func(v *gocui.View, selectedLineIdx int) {
ctx, ok := gui.helpers.View.ContextForView(v.Name())
if ok {
@ -616,9 +619,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
// setting this to nil so we don't get stuck based on a popup that was
// previously opened
gui.Mutexes.PopupMutex.Lock()
gui.State.CurrentPopupOpts = nil
gui.Mutexes.PopupMutex.Unlock()
return gui.c.Context().Current()
}
@ -637,7 +638,6 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
FilteredReflogCommits: make([]*models.Commit, 0),
ReflogCommits: make([]*models.Commit, 0),
BisectInfo: git_commands.NewNullBisectInfo(),
FilesTrie: patricia.NewTrie(),
Authors: map[string]*models.Author{},
MainBranches: git_commands.NewMainBranches(gui.c.Common, gui.os.Cmd),
HashPool: &utils.StringPool{},
@ -691,6 +691,23 @@ func (gui *Gui) getViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferM
return manager
}
// When scrolling a lazy-loaded view, we read enough lines to fill the viewport
// plus this many extra screenfuls, so that further scrolling has some runway
// and doesn't have to block on reading (and re-rendering) more lines on every
// wheel notch.
const scrollReadAheadScreenfuls = 3
// readLinesToFillView reads enough lines into the view's buffer to cover
// everything currently scrolled into view, plus a few screenfuls of read-ahead.
// Reading is idempotent (see ViewBufferManager.ReadLines), so if the buffer
// already extends far enough this does nothing.
func (gui *Gui) readLinesToFillView(view *gocui.View) {
if manager := gui.getViewBufferManagerForView(view); manager != nil {
viewportBottom := view.OriginY() + view.InnerHeight()
manager.ReadLines(viewportBottom + scrollReadAheadScreenfuls*view.InnerHeight())
}
}
func (gui *Gui) initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] {
result := utils.NewThreadSafeMap[string, string]()
@ -791,16 +808,28 @@ func NewGui(
gui.PopupHandler = popup.NewPopupHandler(
cmn,
// Raising a popup or menu pushes a context and mutates the popup views,
// and it can be triggered from a worker goroutine (e.g. a
// WithWaitingStatus handler that hits a merge conflict and asks the user
// how to proceed). Bounce the creation onto the UI thread so it can't
// race the layout/draw code. Doing it here, at the one point where these
// producers are injected, keeps every caller oblivious to the threading.
func(ctx goContext.Context, opts types.CreatePopupPanelOpts) {
gui.helpers.Confirmation.CreatePopupPanel(ctx, opts)
gui.onUIThread(func() error {
gui.helpers.Confirmation.CreatePopupPanel(ctx, opts)
return nil
})
},
func() error { gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); return nil },
func() error { gui.c.Refresh(types.RefreshOptions{}); return nil },
func() { gui.State.ContextMgr.Pop() },
func() types.Context { return gui.State.ContextMgr.Current() },
gui.createMenu,
func(opts types.CreateMenuOptions) error {
gui.onUIThread(func() error { return gui.createMenu(opts) })
return nil
},
func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatus(message, f) },
func(message string, f func() error) error {
return gui.helpers.AppStatus.WithWaitingStatusSync(message, f)
func(message string, f func(gocui.Task) error) {
gui.helpers.AppStatus.WithWaitingStatusBlockingInput(message, f)
},
func(message string, kind types.ToastKind) { gui.helpers.AppStatus.Toast(message, kind) },
func() string { return gui.Views.Prompt.TextArea.GetContent() },
@ -1002,7 +1031,7 @@ func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess *oscommands.CmdOb
return err
}
gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
gui.c.Refresh(types.RefreshOptions{})
return nil
}
@ -1079,7 +1108,7 @@ func (gui *Gui) loadNewRepo() error {
return err
}
gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
gui.c.Refresh(types.RefreshOptions{})
if err := gui.os.UpdateWindowTitle(); err != nil {
return err

View file

@ -58,7 +58,18 @@ func (self *guiCommon) PauseBackgroundRefreshes(pause bool) {
self.gui.BackgroundRoutineMgr.PauseBackgroundRefreshes(pause)
}
// assertOnUIThread panics (in debug builds) if called from a worker goroutine.
// Use it to guard accessors for state that only the UI thread may touch, so
// that a stray worker access fails deterministically -- and points at itself --
// rather than surfacing later as a probabilistic data race.
func (self *guiCommon) assertOnUIThread(accessor string) {
if self.GetConfig().GetDebug() && !self.GocuiGui().IsUIThread() {
panic(accessor + " accessed from a worker")
}
}
func (self *guiCommon) Context() types.IContextMgr {
self.assertOnUIThread("Context()")
return self.gui.State.ContextMgr
}
@ -113,6 +124,7 @@ func (self *guiCommon) Modes() *types.Modes {
}
func (self *guiCommon) Model() *types.Model {
self.assertOnUIThread("Model()")
return self.gui.State.Model
}
@ -165,6 +177,10 @@ func (self *guiCommon) GetViewBufferManagerForView(view *gocui.View) *tasks.View
return self.gui.getViewBufferManagerForView(view)
}
func (self *guiCommon) ReadLinesToFillView(view *gocui.View) {
self.gui.readLinesToFillView(view)
}
func (self *guiCommon) State() types.IStateAccessor {
return self.gui.stateAccessor
}

View file

@ -17,10 +17,9 @@ import (
// this gives our integration test a way of interacting with the gui for sending keypresses
// and reading state.
type GuiDriver struct {
gui *Gui
isIdleChan chan struct{}
toastChan chan string
headless bool
gui *Gui
toastChan chan string
headless bool
}
var _ integrationTypes.GuiDriver = &GuiDriver{}
@ -33,10 +32,10 @@ func (self *GuiDriver) PressKey(keyStr string) {
self.Fail("Unrecognized key: " + keyStr)
}
self.gui.g.ReplayedEvents.Keys <- gocui.NewTcellKeyEventWrapper(
self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper(
tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())),
0,
)
))
self.waitTillIdle()
}
@ -44,15 +43,15 @@ func (self *GuiDriver) PressKey(keyStr string) {
func (self *GuiDriver) Click(x, y int) {
self.CheckAllToastsAcknowledged()
self.gui.g.ReplayedEvents.MouseEvents <- gocui.NewTcellMouseEventWrapper(
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0),
0,
)
))
self.waitTillIdle()
self.gui.g.ReplayedEvents.MouseEvents <- gocui.NewTcellMouseEventWrapper(
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
tcell.NewEventMouse(x, y, tcell.ButtonNone, 0),
0,
)
))
self.waitTillIdle()
}
@ -60,10 +59,10 @@ func (self *GuiDriver) Click(x, y int) {
// learns to reload changed config files. Tests use it to exercise the live
// config-reload path.
func (self *GuiDriver) FocusIn() {
self.gui.g.ReplayedEvents.FocusEvents <- gocui.NewTcellFocusEventWrapper(
self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper(
tcell.NewEventFocus(true),
0,
)
))
self.waitTillIdle()
}
@ -79,7 +78,7 @@ func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() {
// wait until lazygit is idle (i.e. all processing is done) before continuing
func (self *GuiDriver) waitTillIdle() {
<-self.isIdleChan
self.gui.g.WaitUntilIdle()
}
func (self *GuiDriver) CheckAllToastsAcknowledged() {
@ -93,7 +92,10 @@ func (self *GuiDriver) Keys() config.KeybindingConfig {
}
func (self *GuiDriver) CurrentContext() types.Context {
return self.gui.c.Context().Current()
// Read the context manager directly rather than through c.Context(): the
// driver runs on the test goroutine, not the UI thread, so it must bypass
// the UI-thread assertion that accessor carries.
return self.gui.State.ContextMgr.Current()
}
func (self *GuiDriver) ContextForView(viewName string) types.Context {

View file

@ -37,13 +37,18 @@ func (gui *Gui) layout(g *gocui.Gui) error {
if prevMainView != nil {
prevMainHeight := prevMainView.Height()
newMainHeight := viewDimensions["main"].Y1 - viewDimensions["main"].Y0 + 1
heightDiff := newMainHeight - prevMainHeight
if heightDiff > 0 {
if newMainHeight > prevMainHeight {
// The main views have grown taller, so make sure enough lines are
// loaded to fill them. The views haven't been resized yet at this
// point, so we can't rely on their current height; compute the target
// total from the new height instead. (Reading past the actual content
// is harmless: ReadLines stops at the end of input.)
linesToRead := prevMainView.OriginY() + newMainHeight
if manager := gui.getViewBufferManagerForView(gui.Views.Main); manager != nil {
manager.ReadLines(heightDiff)
manager.ReadLines(linesToRead)
}
if manager := gui.getViewBufferManagerForView(gui.Views.Secondary); manager != nil {
manager.ReadLines(heightDiff)
manager.ReadLines(linesToRead)
}
}
}
@ -149,7 +154,14 @@ func (gui *Gui) layout(g *gocui.Gui) error {
if err != nil && !errors.Is(err, gocui.ErrUnknownView) {
return err
}
view.Visible = gui.helpers.Window.GetViewNameForWindow(context.GetWindowName()) == context.GetViewName()
// A transient view is visible if it is the view its window is currently
// showing — but only if that window is part of the layout at all. For a
// window without dimensions, setViewFromDimensions parks the view at full
// screen size in the background, so making it visible would cover all
// windows below it.
_, windowHasDimensions := viewDimensions[context.GetWindowName()]
view.Visible = windowHasDimensions &&
gui.helpers.Window.GetViewNameForWindow(context.GetWindowName()) == context.GetViewName()
}
if gui.PrevLayout.Information != informationStr {

View file

@ -13,16 +13,16 @@ import (
type PopupHandler struct {
*common.Common
createPopupPanelFn func(context.Context, types.CreatePopupPanelOpts)
onErrorFn func() error
popContextFn func()
currentContextFn func() types.Context
createMenuFn func(types.CreateMenuOptions) error
withWaitingStatusFn func(message string, f func(gocui.Task) error)
withWaitingStatusSyncFn func(message string, f func() error) error
toastFn func(message string, kind types.ToastKind)
getPromptInputFn func() string
inDemo func() bool
createPopupPanelFn func(context.Context, types.CreatePopupPanelOpts)
onErrorFn func() error
popContextFn func()
currentContextFn func() types.Context
createMenuFn func(types.CreateMenuOptions) error
withWaitingStatusFn func(message string, f func(gocui.Task) error)
withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error)
toastFn func(message string, kind types.ToastKind)
getPromptInputFn func() string
inDemo func() bool
}
var _ types.IPopupHandler = &PopupHandler{}
@ -35,23 +35,23 @@ func NewPopupHandler(
currentContextFn func() types.Context,
createMenuFn func(types.CreateMenuOptions) error,
withWaitingStatusFn func(message string, f func(gocui.Task) error),
withWaitingStatusSyncFn func(message string, f func() error) error,
withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error),
toastFn func(message string, kind types.ToastKind),
getPromptInputFn func() string,
inDemo func() bool,
) *PopupHandler {
return &PopupHandler{
Common: common,
createPopupPanelFn: createPopupPanelFn,
onErrorFn: onErrorFn,
popContextFn: popContextFn,
currentContextFn: currentContextFn,
createMenuFn: createMenuFn,
withWaitingStatusFn: withWaitingStatusFn,
withWaitingStatusSyncFn: withWaitingStatusSyncFn,
toastFn: toastFn,
getPromptInputFn: getPromptInputFn,
inDemo: inDemo,
Common: common,
createPopupPanelFn: createPopupPanelFn,
onErrorFn: onErrorFn,
popContextFn: popContextFn,
currentContextFn: currentContextFn,
createMenuFn: createMenuFn,
withWaitingStatusFn: withWaitingStatusFn,
withWaitingStatusBlockingInputFn: withWaitingStatusBlockingInputFn,
toastFn: toastFn,
getPromptInputFn: getPromptInputFn,
inDemo: inDemo,
}
}
@ -76,8 +76,9 @@ func (self *PopupHandler) WithWaitingStatus(message string, f func(gocui.Task) e
return nil
}
func (self *PopupHandler) WithWaitingStatusSync(message string, f func() error) error {
return self.withWaitingStatusSyncFn(message, f)
func (self *PopupHandler) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error {
self.withWaitingStatusBlockingInputFn(message, f)
return nil
}
func (self *PopupHandler) ErrorHandler(err error) error {

View file

@ -93,9 +93,18 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
manager := gui.getManager(view)
// Size the pty from the view's dimensions here, on the UI thread; the
// start func below runs on the task's goroutine, which must not read the
// view's live dimensions while the UI thread is laying it out.
cols, rows := gui.desiredPtySize(view)
var p oscommands.Pty
start := func() (tasks.Cmd, io.Reader) {
cols, rows := gui.desiredPtySize(view)
// The pty (and pager) wrap to this width; apply it here, on the
// task's goroutine once the previous task has stopped, so it doesn't
// race that task's writes (see View.SetContentWidth).
view.SetContentWidth(width)
sp, err := oscommands.StartPty(cmd, cols, rows)
if err != nil {
gui.c.Log.Error(err)

View file

@ -314,7 +314,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses
}
output, err := cmdObj.RunWithOutput()
self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC})
self.c.RefreshFromWorker(types.RefreshOptions{})
if err != nil {
if customCommand.After != nil && customCommand.After.CheckForConflicts {

View file

@ -125,4 +125,13 @@ func (gui *Gui) assignSidePanelWindows(contextTree *context.ContextTree) {
ctx.SetWindowName(name)
}
}
// The transient contexts take over the window of the context they are
// drilled into from, but they need a valid initial window before their
// first use. Assign the window hosting branches or commits, respectively;
// unlike e.g. remotes, those tabs can't be hidden, so their windows are
// always part of the layout.
contextTree.RemoteBranches.SetWindowName(contextTree.Branches.GetWindowName())
contextTree.SubCommits.SetWindowName(contextTree.Branches.GetWindowName())
contextTree.CommitFiles.SetWindowName(contextTree.LocalCommits.GetWindowName())
}

View file

@ -28,3 +28,23 @@ func TestSidePanelLookupsCoverAllValidTabs(t *testing.T) {
assert.Equal(t, want, sortedKeys(gui.sidePanelTabTitles()))
assert.Equal(t, want, sortedKeys(sidePanelContexts(gui.contextTree())))
}
// The transient contexts must end up in windows that exist under the configured
// panel layout, or their views would be laid out for a window that is never
// shown.
func TestAssignSidePanelWindowsCoversTransientContexts(t *testing.T) {
gui := NewDummyGui()
gui.c.UserConfig().Gui.SidePanels = []config.SidePanel{
{"worktrees", "branches", "remotes"},
{"files"},
{"tags", "commits"},
{"stash"},
}
contextTree := gui.contextTree()
gui.assignSidePanelWindows(contextTree)
assert.Equal(t, "worktrees", contextTree.RemoteBranches.GetWindowName())
assert.Equal(t, "worktrees", contextTree.SubCommits.GetWindowName())
assert.Equal(t, "tags", contextTree.CommitFiles.GetWindowName())
}

View file

@ -17,6 +17,11 @@ type StatusManager struct {
statuses []appStatus
nextId int
mutex deadlock.Mutex
// Whether a render loop is currently drawing the statuses. Guarded by
// mutex, so that claiming and releasing the loop stay atomic with the
// changes to statuses; see ClaimRenderLoop and ReleaseRenderLoopIfEmpty.
renderLoopRunning bool
}
// Can be used to manipulate a waiting status while it is running (e.g. pause
@ -70,6 +75,9 @@ func (self *StatusManager) AddToastStatus(message string, kind types.ToastKind)
}
func (self *StatusManager) GetStatusString(userConfig *config.UserConfig) (string, gocui.Attribute) {
self.mutex.Lock()
defer self.mutex.Unlock()
if len(self.statuses) == 0 {
return "", gocui.ColorDefault
}
@ -81,9 +89,45 @@ func (self *StatusManager) GetStatusString(userConfig *config.UserConfig) (strin
}
func (self *StatusManager) HasStatus() bool {
self.mutex.Lock()
defer self.mutex.Unlock()
return len(self.statuses) > 0
}
// ClaimRenderLoop is called by whoever just added a status; it reports whether
// they must start the render loop. When it returns false, a loop is already
// running and will pick the new status up on its next tick.
func (self *StatusManager) ClaimRenderLoop() bool {
self.mutex.Lock()
defer self.mutex.Unlock()
if self.renderLoopRunning {
return false
}
self.renderLoopRunning = true
return true
}
// ReleaseRenderLoopIfEmpty is called by the render loop after each frame it
// draws; a true result releases the loop's claim and tells it to exit, because
// there are no statuses left to draw. The emptiness check and the release are
// atomic with respect to ClaimRenderLoop, so a status added around this moment
// either sees the still-running loop or starts a fresh one — it can't end up
// unrendered.
func (self *StatusManager) ReleaseRenderLoopIfEmpty() bool {
self.mutex.Lock()
defer self.mutex.Unlock()
if len(self.statuses) > 0 {
return false
}
self.renderLoopRunning = false
return true
}
func (self *StatusManager) addStatus(message string, statusType string, kind types.ToastKind) int {
self.mutex.Lock()
defer self.mutex.Unlock()

View file

@ -18,8 +18,17 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
manager := gui.getManager(view)
// 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
// applied inside start() below rather than now, because start() runs once
// the previous task has stopped -- applying it here would race that task's
// still-running writes (see View.SetContentWidth).
contentWidth := view.InnerWidth()
var r io.ReadCloser
start := func() (tasks.Cmd, io.Reader) {
view.SetContentWidth(contentWidth)
var err error
r, err = cmd.StdoutPipe()
if err != nil {
@ -59,8 +68,10 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
manager := gui.getManager(view)
f := func(tasks.TaskOpts) error {
gui.c.SetViewContent(view, str)
return nil
return gui.g.OnUIThreadAndWaitBackground(func() error {
gui.c.SetViewContent(view, str)
return nil
})
}
if err := manager.NewTask(f, manager.GetTaskKey()); err != nil {
@ -74,9 +85,11 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in
manager := gui.getManager(view)
f := func(tasks.TaskOpts) error {
gui.c.SetViewContent(view, str)
view.SetOrigin(originX, originY)
return nil
return gui.g.OnUIThreadAndWaitBackground(func() error {
gui.c.SetViewContent(view, str)
view.SetOrigin(originX, originY)
return nil
})
}
if err := manager.NewTask(f, manager.GetTaskKey()); err != nil {
@ -90,9 +103,11 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
manager := gui.getManager(view)
f := func(tasks.TaskOpts) error {
gui.c.ResetViewOrigin(view)
gui.c.SetViewContent(view, str)
return nil
return gui.g.OnUIThreadAndWaitBackground(func() error {
gui.c.ResetViewOrigin(view)
gui.c.SetViewContent(view, str)
return nil
})
}
if err := manager.NewTask(f, key); err != nil {
@ -118,7 +133,12 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
view.Reset()
},
func() {
gui.render()
// As the task reads more lines, the only thing that changes is the
// view's content (and its scrollbar); the window layout doesn't. So a
// content-only render is enough, and it's much cheaper than a full
// layout-and-redraw on every read - which matters a lot when reading
// a long diff, where reads happen repeatedly as the user scrolls.
gui.renderContentOnly()
},
func() {
// Need to check if the content of the view is well past the origin.
@ -145,6 +165,9 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
// otherwise make the switch that handler triggers refuse itself.
return gui.c.GocuiGui().NewBackgroundTask()
},
// Rendering is background work too (see above), so the view mutations
// it bounces onto the UI thread mustn't count towards being busy.
gui.g.OnUIThreadAndWaitBackground,
)
gui.viewBufferManagerMap[view.Name()] = manager
}

View file

@ -3,6 +3,7 @@ package gui
import (
"log"
"os"
"runtime/pprof"
"time"
"github.com/jesseduffield/lazygit/pkg/gocui"
@ -23,12 +24,8 @@ func (gui *Gui) handleTestMode() {
}
if test != nil {
isIdleChan := make(chan struct{})
gui.c.GocuiGui().AddIdleListener(isIdleChan)
waitUntilIdle := func() {
<-isIdleChan
gui.c.GocuiGui().WaitUntilIdle()
}
go func() {
@ -38,23 +35,25 @@ func (gui *Gui) handleTestMode() {
gui.PopupHandler.(*popup.PopupHandler).SetToastFunc(
func(message string, kind types.ToastKind) { toastChan <- message })
test.Run(&GuiDriver{gui: gui, isIdleChan: isIdleChan, toastChan: toastChan, headless: Headless()})
test.Run(&GuiDriver{gui: gui, toastChan: toastChan, headless: Headless()})
gui.g.Update(func(*gocui.Gui) error {
return gocui.ErrQuit
})
waitUntilIdle()
time.Sleep(time.Second * 1)
log.Fatal("gocui should have already exited")
// Wait for the event loop to actually exit.
<-gui.g.LoopExited()
}()
if os.Getenv(components.WAIT_FOR_DEBUGGER_ENV_VAR) == "" {
timeout := 40 * time.Second * testTimeoutMultiplier
go utils.Safe(func() {
time.Sleep(time.Second * 40)
log.Fatal("40 seconds is up, lazygit recording took too long to complete")
time.Sleep(timeout)
// Dump all goroutine stacks before dying, so a hung test shows
// where it got stuck rather than just that it timed out. The
// test harness surfaces this process's stderr on failure.
_ = pprof.Lookup("goroutine").WriteTo(os.Stderr, 2)
log.Fatalf("%v is up, lazygit integration test took too long to complete", timeout)
})
}
}

View file

@ -0,0 +1,5 @@
//go:build !race
package gui
const testTimeoutMultiplier = 1

View file

@ -0,0 +1,10 @@
//go:build race
package gui
// The race detector makes everything run several times slower, so the
// recording watchdog needs a correspondingly longer timeout; otherwise it
// fires on tests that are merely slow under -race rather than actually stuck.
// The `race` build tag is set automatically when the binary is built with
// -race, so this can't drift out of sync with the actual build.
const testTimeoutMultiplier = 4

View file

@ -11,7 +11,6 @@ import (
"github.com/jesseduffield/lazygit/pkg/tasks"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sasha-s/go-deadlock"
"gopkg.in/ozeidan/fuzzy-patricia.v3/patricia"
)
type HelperCommon struct {
@ -58,6 +57,10 @@ type IGuiCommon interface {
// return the view buffer manager for the given view, or nil if it doesn't have one
GetViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager
// read enough lines into the given view's buffer to fill it at its current
// scroll position, plus some read-ahead for smooth scrolling
ReadLinesToFillView(view *gocui.View)
// returns true if command completed successfully
RunSubprocess(cmdObj *oscommands.CmdObj) (bool, error)
RunSubprocessAndRefresh(*oscommands.CmdObj) error
@ -157,7 +160,7 @@ type IPopupHandler interface {
// Shows a popup prompting the user for input.
Prompt(opts PromptOpts)
WithWaitingStatus(message string, f func(gocui.Task) error) error
WithWaitingStatusSync(message string, f func() error) error
WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error
Menu(opts CreateMenuOptions) error
Toast(message string)
ErrorToast(message string)
@ -344,9 +347,6 @@ type Model struct {
MainBranches *git_commands.MainBranches
// for displaying suggestions while typing in a file name
FilesTrie *patricia.Trie
Authors map[string]*models.Author
HashPool *utils.StringPool
@ -354,7 +354,6 @@ type Model struct {
type Mutexes struct {
SubprocessMutex deadlock.Mutex
PopupMutex deadlock.Mutex
PtyMutex deadlock.Mutex
}

View file

@ -25,14 +25,6 @@ const (
PULL_REQUESTS
)
type RefreshMode int
const (
SYNC RefreshMode = iota // wait until everything is done before returning
ASYNC // return immediately, allowing each independent thing to update itself
BLOCK_UI // wrap code in an update call to ensure UI updates all at once and keybindings aren't executed till complete
)
// CommitSelectionBehavior controls which local commit is selected after the
// commits list is reloaded by a refresh.
type CommitSelectionBehavior int
@ -74,7 +66,11 @@ const (
type RefreshOptions struct {
Then func() error
Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything
Mode RefreshMode // one of SYNC (default), ASYNC, and BLOCK_UI
// If true, hold off on updating the UI until all scopes have finished
// refreshing and then apply them together in a single frame, rather than
// letting each scope update the UI as soon as it's done.
BatchUIUpdates bool
// Controls which local branch is selected after the refresh. Defaults to
// KeepBranchSelectionByName.

View file

@ -121,6 +121,14 @@ func (gui *Gui) render() {
gui.c.OnUIThread(func() error { return nil })
}
// renderContentOnly triggers a re-render that skips the layout pass and only
// redraws the views whose content changed (relying on tcell's cell-level dirty
// tracking to emit just the cells that actually differ). Use it when only a
// view's content changed, not the window layout.
func (gui *Gui) renderContentOnly() {
gui.c.OnUIThreadContentOnly(func() error { return nil })
}
// 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.

View file

@ -427,6 +427,7 @@ type TranslationSet struct {
UndoingStatus string
RedoingStatus string
CheckingOutStatus string
CreatingBranchStatus string
CommittingStatus string
RewordingStatus string
RevertingStatus string
@ -1130,12 +1131,7 @@ Thanks for using lazygit! Seriously you rock. Three things to share with you:
2) Be sure to read the latest release notes at:
https://github.com/jesseduffield/lazygit/releases
3) If you're using git, that makes you a programmer! With your help we can make
lazygit better, so consider becoming a contributor and joining the fun at
https://github.com/jesseduffield/lazygit
Or even just star the repo to share the love!
4) If lazygit has made your life easier, you can say thanks by clicking the
3) If lazygit has made your life easier, you can say thanks by clicking the
donate button at the bottom right. Donation does not grant priority support,
but it is much appreciated.
@ -1581,6 +1577,7 @@ func EnglishTranslationSet() *TranslationSet {
UndoingStatus: "Undoing",
RedoingStatus: "Redoing",
CheckingOutStatus: "Checking out",
CreatingBranchStatus: "Creating branch",
CommittingStatus: "Committing",
RewordingStatus: "Rewording",
RevertingStatus: "Reverting",

View file

@ -11,7 +11,9 @@ import (
"io"
"os"
"os/exec"
"syscall"
"testing"
"time"
"github.com/creack/pty"
"github.com/jesseduffield/lazycore/pkg/utils"
@ -28,6 +30,7 @@ func TestIntegration(t *testing.T) {
parallelTotal := tryConvert(os.Getenv("PARALLEL_TOTAL"), 1)
parallelIndex := tryConvert(os.Getenv("PARALLEL_INDEX"), 0)
raceDetector := os.Getenv("LAZYGIT_RACE_DETECTOR") != ""
logTimingsPath := os.Getenv("LAZYGIT_TEST_TIMING")
// LAZYGIT_GOCOVERDIR is the directory where we write coverage files to. If this directory
// is defined, go binaries built with the -cover flag will write coverage files to
// to it.
@ -56,7 +59,8 @@ func TestIntegration(t *testing.T) {
CodeCoverageDir: codeCoverageDir,
InputDelay: 0,
// Allow two attempts at each test to get around flakiness
MaxAttempts: 2,
MaxAttempts: 1,
LogTimingsPath: logTimingsPath,
})
assert.NoError(t, err)
@ -75,6 +79,17 @@ func runCmdHeadless(cmd *exec.Cmd) (int, error) {
stderr := new(bytes.Buffer)
cmd.Stderr = stderr
// If lazygit exits but leaves behind a subprocess that inherited its stderr
// pipe, cmd.Wait blocks waiting for that pipe to reach EOF for as long as the
// subprocess stays alive. Unbounded, that hangs the whole test binary until
// its global timeout fires, and the timeout throws away whatever lazygit
// wrote to stderr before exiting (a panic, a -race report) -- the very output
// needed to diagnose the failure. WaitDelay caps the wait: once the process
// has exited, Wait gives the stderr goroutine at most this long to drain,
// then closes the pipe and returns ErrWaitDelay, so the captured stderr
// surfaces as the test error instead of being lost.
cmd.WaitDelay = 5 * time.Second
// these rows and columns are ignored because internally we use tcell's
// simulation screen. However we still need the pty for the sake of
// running other commands in a pty.
@ -83,12 +98,32 @@ func runCmdHeadless(cmd *exec.Cmd) (int, error) {
return -1, err
}
// pty.StartWithSize starts lazygit in its own process group, so we can signal
// the whole group at once. Capture the id now, while the process is alive:
// once Wait has reaped it we can no longer look it up.
pgid, pgidErr := syscall.Getpgid(cmd.Process.Pid)
_, _ = io.Copy(io.Discard, f)
if cmd.Wait() != nil {
waitErr := cmd.Wait()
// On any failure -- including a WaitDelay expiry caused by a leaked
// subprocess -- kill the whole process group so a straggler can't linger and
// wedge a later test or pile up across a CI run. Best effort: usually the
// group is already gone (ESRCH), and a subprocess that called setsid to
// detach into its own group is out of reach, but WaitDelay still unblocks us.
if waitErr != nil && pgidErr == nil {
_ = syscall.Kill(-pgid, syscall.SIGKILL)
}
if waitErr != nil {
_ = f.Close()
// return an error with the stderr output
return cmd.Process.Pid, errors.New(stderr.String())
// Prefer lazygit's own stderr as the error; fall back to the wait error
// itself (e.g. ErrWaitDelay) when it exited without printing anything.
if stderr.Len() > 0 {
return cmd.Process.Pid, errors.New(stderr.String())
}
return cmd.Process.Pid, waitErr
}
return cmd.Process.Pid, f.Close()

View file

@ -5,6 +5,8 @@ import (
"os"
"os/exec"
"path/filepath"
"sync"
"time"
lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
@ -24,6 +26,12 @@ type RunTestArgs struct {
CodeCoverageDir string
InputDelay int
MaxAttempts int
// If set, each test's run duration is appended to this file (as
// "<seconds> <test name>"). run_integration_tests.sh prints the slowest at
// the end, so slow or anomalous tests can be spotted across CI runs. We
// write to a file rather than stdout/stderr because `go test` captures
// those and only shows them with -v. Empty disables it.
LogTimingsPath string
}
// This function lets you run tests either from within `go test` or from a regular binary.
@ -47,6 +55,11 @@ func RunTests(args RunTestArgs) error {
return err
}
// Start each run with a fresh timings file (see RunTestArgs.LogTimingsPath).
if args.LogTimingsPath != "" {
_ = os.Remove(args.LogTimingsPath)
}
for _, test := range args.Tests {
args.TestWrapper(test, func() error {
paths := NewPaths(
@ -99,7 +112,11 @@ func runTest(
return err
}
start := time.Now()
pid, err := args.RunCmd(cmd)
if args.LogTimingsPath != "" {
logTestTiming(args.LogTimingsPath, test.Name(), time.Since(start))
}
// Print race detector log regardless of the command's exit status
if args.RaceDetector {
@ -112,6 +129,23 @@ func runTest(
return err
}
// timingsMutex serializes appends to the timings file, since tests run in
// parallel.
var timingsMutex sync.Mutex
func logTestTiming(path, name string, duration time.Duration) {
timingsMutex.Lock()
defer timingsMutex.Unlock()
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, "%.2f %s\n", duration.Seconds(), name)
}
func prepareTestDir(
test *IntegrationTest,
paths Paths,

View file

@ -256,7 +256,7 @@ func (self *Shell) CreateNCommitsStartingAt(n, startIndex int) *Shell {
fmt.Sprintf("file%02d.txt", i),
fmt.Sprintf("file%02d content", i),
).
Commit(fmt.Sprintf("commit %02d", i))
Commit(fmt.Sprintf("commit-%02d", i))
}
return self

View file

@ -34,29 +34,29 @@ var Basic = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Commits().
Focus().
SelectedLine(Contains("CI commit 10")).
NavigateToLine(Contains("CI commit 09")).
SelectedLine(Contains("CI commit-10")).
NavigateToLine(Contains("CI commit-09")).
Tap(func() {
markCommitAsBad()
t.Views().Information().Content(Contains("Bisecting"))
}).
SelectedLine(Contains("<-- bad")).
NavigateToLine(Contains("CI commit 02")).
NavigateToLine(Contains("CI commit-02")).
Tap(markCommitAsGood).
TopLines(Contains("CI commit 10")).
TopLines(Contains("CI commit-10")).
// lazygit will land us in the commit between our good and bad commits.
SelectedLine(Contains("CI commit 05").Contains("<-- current")).
SelectedLine(Contains("CI commit-05").Contains("<-- current")).
Tap(markCommitAsBad).
SelectedLine(Contains("CI commit 04").Contains("<-- current")).
SelectedLine(Contains("CI commit-04").Contains("<-- current")).
Tap(func() {
markCommitAsGood()
// commit 5 is the culprit because we marked 4 as good and 5 as bad.
t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 05.*Do you want to reset")).Confirm()
t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit-05.*Do you want to reset")).Confirm()
}).
IsFocused().
Content(Contains("CI commit 04"))
Content(Contains("CI commit-04"))
t.Views().Information().Content(DoesNotContain("Bisecting"))
},

View file

@ -34,40 +34,40 @@ var ChooseTerms = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Commits().
Focus().
SelectedLine(Contains("CI commit 10")).
SelectedLine(Contains("CI commit-10")).
Press(keys.Commits.ViewBisectOptions).
Tap(func() {
t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(Contains("Choose bisect terms")).Confirm()
t.ExpectPopup().Prompt().Title(Equals("Term for old/good commit:")).Type("broken").Confirm()
t.ExpectPopup().Prompt().Title(Equals("Term for new/bad commit:")).Type("fixed").Confirm()
}).
NavigateToLine(Contains("CI commit 09")).
NavigateToLine(Contains("CI commit-09")).
Tap(markCommitAsFixed).
SelectedLine(Contains("<-- fixed")).
NavigateToLine(Contains("CI commit 02")).
NavigateToLine(Contains("CI commit-02")).
Tap(markCommitAsBroken).
Lines(
Contains("CI commit 10").DoesNotContain("<--"),
Contains("CI commit 09").Contains("<-- fixed"),
Contains("CI commit 08").DoesNotContain("<--"),
Contains("CI commit 07").DoesNotContain("<--"),
Contains("CI commit 06").DoesNotContain("<--"),
Contains("CI commit 05").Contains("<-- current").IsSelected(),
Contains("CI commit 04").DoesNotContain("<--"),
Contains("CI commit 03").DoesNotContain("<--"),
Contains("CI commit 02").Contains("<-- broken"),
Contains("CI commit 01").DoesNotContain("<--"),
Contains("CI commit-10").DoesNotContain("<--"),
Contains("CI commit-09").Contains("<-- fixed"),
Contains("CI commit-08").DoesNotContain("<--"),
Contains("CI commit-07").DoesNotContain("<--"),
Contains("CI commit-06").DoesNotContain("<--"),
Contains("CI commit-05").Contains("<-- current").IsSelected(),
Contains("CI commit-04").DoesNotContain("<--"),
Contains("CI commit-03").DoesNotContain("<--"),
Contains("CI commit-02").Contains("<-- broken"),
Contains("CI commit-01").DoesNotContain("<--"),
).
Tap(markCommitAsFixed).
SelectedLine(Contains("CI commit 04").Contains("<-- current")).
SelectedLine(Contains("CI commit-04").Contains("<-- current")).
Tap(func() {
markCommitAsBroken()
// commit 5 is the culprit because we marked 4 as broken and 5 as fixed.
t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 05.*Do you want to reset")).Confirm()
t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit-05.*Do you want to reset")).Confirm()
}).
IsFocused().
Content(Contains("CI commit 04"))
Content(Contains("CI commit-04"))
t.Views().Information().Content(DoesNotContain("Bisecting"))
},

View file

@ -24,17 +24,17 @@ var FromOtherBranch = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Commits().
Focus().
TopLines(
MatchesRegexp(`<-- bad.*commit 08`),
MatchesRegexp(`<-- current.*commit 07`),
MatchesRegexp(`\?.*commit 06`),
MatchesRegexp(`<-- good.*commit 05`),
MatchesRegexp(`<-- bad.*commit-08`),
MatchesRegexp(`<-- current.*commit-07`),
MatchesRegexp(`\?.*commit-06`),
MatchesRegexp(`<-- good.*commit-05`),
).
SelectNextItem().
Press(keys.Commits.ViewBisectOptions).
Tap(func() {
t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as good`)).Confirm()
t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 08.*Do you want to reset")).Confirm()
t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit-08.*Do you want to reset")).Confirm()
t.Views().Information().Content(DoesNotContain("Bisecting"))
}).

View file

@ -19,28 +19,28 @@ var Skip = NewIntegrationTest(NewIntegrationTestArgs{
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
SelectedLine(Contains("commit 10")).
SelectedLine(Contains("commit-10")).
Press(keys.Commits.ViewBisectOptions).
Tap(func() {
t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as bad`)).Confirm()
}).
NavigateToLine(Contains("commit 01")).
NavigateToLine(Contains("commit-01")).
Press(keys.Commits.ViewBisectOptions).
Tap(func() {
t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as good`)).Confirm()
t.Views().Information().Content(Contains("Bisecting"))
}).
Lines(
Contains("CI commit 10").Contains("<-- bad"),
Contains("CI commit 09").DoesNotContain("<--"),
Contains("CI commit 08").DoesNotContain("<--"),
Contains("CI commit 07").DoesNotContain("<--"),
Contains("CI commit 06").DoesNotContain("<--"),
Contains("CI commit 05").Contains("<-- current").IsSelected(),
Contains("CI commit 04").DoesNotContain("<--"),
Contains("CI commit 03").DoesNotContain("<--"),
Contains("CI commit 02").DoesNotContain("<--"),
Contains("CI commit 01").Contains("<-- good"),
Contains("CI commit-10").Contains("<-- bad"),
Contains("CI commit-09").DoesNotContain("<--"),
Contains("CI commit-08").DoesNotContain("<--"),
Contains("CI commit-07").DoesNotContain("<--"),
Contains("CI commit-06").DoesNotContain("<--"),
Contains("CI commit-05").Contains("<-- current").IsSelected(),
Contains("CI commit-04").DoesNotContain("<--"),
Contains("CI commit-03").DoesNotContain("<--"),
Contains("CI commit-02").DoesNotContain("<--"),
Contains("CI commit-01").Contains("<-- good"),
).
Press(keys.Commits.ViewBisectOptions).
Tap(func() {
@ -57,18 +57,18 @@ var Skip = NewIntegrationTest(NewIntegrationTestArgs{
}).
// Skipping the current commit selects the new current commit:
Lines(
Contains("CI commit 10").Contains("<-- bad"),
Contains("CI commit 09").DoesNotContain("<--"),
Contains("CI commit 08").DoesNotContain("<--"),
Contains("CI commit 07").DoesNotContain("<--"),
Contains("CI commit 06").Contains("<-- current").IsSelected(),
Contains("CI commit 05").Contains("<-- skipped"),
Contains("CI commit 04").DoesNotContain("<--"),
Contains("CI commit 03").DoesNotContain("<--"),
Contains("CI commit 02").DoesNotContain("<--"),
Contains("CI commit 01").Contains("<-- good"),
Contains("CI commit-10").Contains("<-- bad"),
Contains("CI commit-09").DoesNotContain("<--"),
Contains("CI commit-08").DoesNotContain("<--"),
Contains("CI commit-07").DoesNotContain("<--"),
Contains("CI commit-06").Contains("<-- current").IsSelected(),
Contains("CI commit-05").Contains("<-- skipped"),
Contains("CI commit-04").DoesNotContain("<--"),
Contains("CI commit-03").DoesNotContain("<--"),
Contains("CI commit-02").DoesNotContain("<--"),
Contains("CI commit-01").Contains("<-- good"),
).
NavigateToLine(Contains("commit 07")).
NavigateToLine(Contains("commit-07")).
Press(keys.Commits.ViewBisectOptions).
Tap(func() {
t.ExpectPopup().Menu().Title(Equals("Bisect")).
@ -85,6 +85,6 @@ var Skip = NewIntegrationTest(NewIntegrationTestArgs{
}).
// Skipping a selected, non-current commit keeps the selection
// there:
SelectedLine(Contains("CI commit 07").Contains("<-- skipped"))
SelectedLine(Contains("CI commit-07").Contains("<-- skipped"))
},
})

View file

@ -22,25 +22,25 @@ var SelectCommitsOfCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Commits().
Focus().
Lines(
Contains("commit 03").IsSelected(),
Contains("commit 02"),
Contains("commit 01"),
Contains("commit-03").IsSelected(),
Contains("commit-02"),
Contains("commit-01"),
Contains("master 02"),
Contains("master 01"),
).
Press(keys.Commits.SelectCommitsOfCurrentBranch).
Lines(
Contains("commit 03").IsSelected(),
Contains("commit 02").IsSelected(),
Contains("commit 01").IsSelected(),
Contains("commit-03").IsSelected(),
Contains("commit-02").IsSelected(),
Contains("commit-01").IsSelected(),
Contains("master 02"),
Contains("master 01"),
).
PressEscape().
Lines(
Contains("commit 03").IsSelected(),
Contains("commit 02"),
Contains("commit 01"),
Contains("commit-03").IsSelected(),
Contains("commit-02"),
Contains("commit-01"),
Contains("master 02"),
Contains("master 01"),
)
@ -58,15 +58,15 @@ var SelectCommitsOfCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().SubCommits().
IsFocused().
Lines(
Contains("commit 02").IsSelected(),
Contains("commit 01"),
Contains("commit-02").IsSelected(),
Contains("commit-01"),
Contains("master 02"),
Contains("master 01"),
).
Press(keys.Commits.SelectCommitsOfCurrentBranch).
Lines(
Contains("commit 02").IsSelected(),
Contains("commit 01").IsSelected(),
Contains("commit-02").IsSelected(),
Contains("commit-01").IsSelected(),
Contains("master 02"),
Contains("master 01"),
)

View file

@ -19,11 +19,11 @@ var CreateAmendCommit = NewIntegrationTest(NewIntegrationTestArgs{
t.Views().Commits().
Focus().
Lines(
Contains("commit 03"),
Contains("commit 02"),
Contains("commit 01"),
Contains("commit-03"),
Contains("commit-02"),
Contains("commit-01"),
).
NavigateToLine(Contains("commit 02")).
NavigateToLine(Contains("commit-02")).
Press(keys.Commits.CreateFixupCommit).
Tap(func() {
t.ExpectPopup().Menu().
@ -31,14 +31,14 @@ var CreateAmendCommit = NewIntegrationTest(NewIntegrationTestArgs{
Select(Contains("amend! commit with changes")).
Confirm()
t.ExpectPopup().CommitMessagePanel().
Content(Equals("commit 02")).
Content(Equals("commit-02")).
Type(" amended").Confirm()
}).
Lines(
Contains("amend! commit 02"),
Contains("commit 03"),
Contains("commit 02").IsSelected(),
Contains("commit 01"),
Contains("amend! commit-02"),
Contains("commit-03"),
Contains("commit-02").IsSelected(),
Contains("commit-01"),
)
t.Views().Commits().
@ -50,9 +50,9 @@ var CreateAmendCommit = NewIntegrationTest(NewIntegrationTestArgs{
Confirm()
}).
Lines(
Contains("commit 03"),
Contains("commit 02 amended").IsSelected(),
Contains("commit 01"),
Contains("commit-03"),
Contains("commit-02 amended").IsSelected(),
Contains("commit-01"),
)
},
})

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