mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-12 16:46:25 -04:00
Merge branch 'master' into feat/reset-to-custom-ref
This commit is contained in:
commit
55f6cc72ce
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -411,3 +411,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.
|
||||
|
|
|
|||
277
CONTRIBUTING.md
277
CONTRIBUTING.md
|
|
@ -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:
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
go.mod
10
go.mod
|
|
@ -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-0.20260703153331-243630d2fb59
|
||||
github.com/go-errors/errors v1.5.1
|
||||
github.com/gookit/color v1.6.1
|
||||
github.com/integrii/flaggy v1.8.0
|
||||
|
|
@ -65,11 +65,11 @@ 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/mod v0.36.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/term v0.44.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/tools v0.45.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
|
||||
|
|
|
|||
20
go.sum
20
go.sum
|
|
@ -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-0.20260703153331-243630d2fb59 h1:kUXexBZYoVdAJIOIuP6uLgK3k0G7ClDIRO27Z3epgtU=
|
||||
github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59/go.mod h1:Ev/2PFhL0QtVmu6XPZG9NEuITAZ6XH7i7/BF3wupBdw=
|
||||
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,8 +139,8 @@ 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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
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=
|
||||
|
|
@ -168,21 +168,21 @@ golang.org/x/sys v0.46.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.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
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.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
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.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
137
pkg/commands/git_cmd_obj_runner_test.go
Normal file
137
pkg/commands/git_cmd_obj_runner_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
98
pkg/gocui/block_events_test.go
Normal file
98
pkg/gocui/block_events_test.go
Normal 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())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
300
pkg/gocui/gui.go
300
pkg/gocui/gui.go
|
|
@ -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()
|
||||
|
|
@ -1259,7 +1477,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
|
||||
|
|
@ -1298,7 +1516,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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
111
pkg/gocui/user_event_queue_test.go
Normal file
111
pkg/gocui/user_event_queue_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ package gocui
|
|||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
|
@ -214,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
|
||||
|
|
@ -536,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
|
||||
|
|
@ -907,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1125,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
|
||||
|
|
@ -1276,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
|
||||
}
|
||||
|
||||
|
|
@ -1524,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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,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,
|
||||
|
|
@ -163,7 +163,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)
|
||||
|
|
@ -384,15 +383,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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -406,34 +411,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
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -555,7 +570,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,
|
||||
|
|
@ -579,7 +594,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
|
||||
}
|
||||
|
|
@ -591,7 +606,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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
|
|
@ -808,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() },
|
||||
|
|
@ -1019,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
|
||||
}
|
||||
|
|
@ -1096,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -154,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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -90,6 +95,39 @@ func (self *StatusManager) HasStatus() bool {
|
|||
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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -150,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,12 +23,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,17 +34,14 @@ 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) == "" {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -161,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)
|
||||
|
|
@ -348,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
|
||||
|
|
@ -358,7 +354,6 @@ type Model struct {
|
|||
|
||||
type Mutexes struct {
|
||||
SubprocessMutex deadlock.Mutex
|
||||
PopupMutex deadlock.Mutex
|
||||
PtyMutex deadlock.Mutex
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -427,6 +427,7 @@ type TranslationSet struct {
|
|||
UndoingStatus string
|
||||
RedoingStatus string
|
||||
CheckingOutStatus string
|
||||
CreatingBranchStatus string
|
||||
CommittingStatus string
|
||||
RewordingStatus string
|
||||
RevertingStatus string
|
||||
|
|
@ -1132,12 +1133,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.
|
||||
|
||||
|
|
@ -1583,6 +1579,7 @@ func EnglishTranslationSet() *TranslationSet {
|
|||
UndoingStatus: "Undoing",
|
||||
RedoingStatus: "Redoing",
|
||||
CheckingOutStatus: "Checking out",
|
||||
CreatingBranchStatus: "Creating branch",
|
||||
CommittingStatus: "Committing",
|
||||
RewordingStatus: "Rewording",
|
||||
RevertingStatus: "Reverting",
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ func TestIntegration(t *testing.T) {
|
|||
CodeCoverageDir: codeCoverageDir,
|
||||
InputDelay: 0,
|
||||
// Allow two attempts at each test to get around flakiness
|
||||
MaxAttempts: 2,
|
||||
MaxAttempts: 1,
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -484,6 +484,8 @@ var tests = []*components.IntegrationTest{
|
|||
tag.Reset,
|
||||
tag.ResetToDuplicateNamedBranch,
|
||||
ui.Accordion,
|
||||
ui.BranchesNotFirstTab,
|
||||
ui.CommitsNotFirstTab,
|
||||
ui.DisableSwitchTabWithPanelJumpKeys,
|
||||
ui.EmptyMenu,
|
||||
ui.HideSidePanel,
|
||||
|
|
|
|||
31
pkg/integration/tests/ui/branches_not_first_tab.go
Normal file
31
pkg/integration/tests/ui/branches_not_first_tab.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var BranchesNotFirstTab = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "With gui.sidePanels grouping branches behind another tab, no ghost view must appear over the side panels",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{
|
||||
{"worktrees", "branches", "remotes"},
|
||||
{"files"},
|
||||
{"commits", "tags"},
|
||||
{"stash"},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("one")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// The remote branches and sub-commits views are only shown after
|
||||
// drilling into a remote or a branch; at startup both must be hidden,
|
||||
// or they'd cover the side panels.
|
||||
t.Views().RemoteBranches().
|
||||
IsInvisible()
|
||||
t.Views().SubCommits().IsInvisible()
|
||||
},
|
||||
})
|
||||
29
pkg/integration/tests/ui/commits_not_first_tab.go
Normal file
29
pkg/integration/tests/ui/commits_not_first_tab.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var CommitsNotFirstTab = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "With gui.sidePanels grouping commits behind another tab, no ghost view must appear over the side panels",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(cfg *config.AppConfig) {
|
||||
cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{
|
||||
{"branches", "worktrees", "remotes"},
|
||||
{"files"},
|
||||
{"tags", "commits"},
|
||||
{"stash"},
|
||||
}
|
||||
},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("one")
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
// The commit files view is only shown after drilling into a commit; at
|
||||
// startup it must be hidden, or it'd cover the side panels.
|
||||
t.Views().CommitFiles().
|
||||
IsInvisible()
|
||||
},
|
||||
})
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
|
|
@ -59,9 +60,13 @@ type ViewBufferManager struct {
|
|||
taskIDMutex deadlock.Mutex
|
||||
Log *logrus.Entry
|
||||
newTaskID int
|
||||
readLines chan LinesToRead
|
||||
taskKey string
|
||||
onNewKey func()
|
||||
// The channel by which the currently-running task is told to read more
|
||||
// lines (e.g. as the user scrolls). Held in an atomic because it's swapped
|
||||
// out as tasks come and go while ReadLines/ReadToEnd read it from the UI
|
||||
// thread; nil when no task is running.
|
||||
readLines atomic.Pointer[chan LinesToRead]
|
||||
taskKey string
|
||||
onNewKey func()
|
||||
|
||||
// beforeStart is the function that is called before starting a new task
|
||||
beforeStart func()
|
||||
|
|
@ -74,11 +79,18 @@ type ViewBufferManager struct {
|
|||
// whereas the tasks in this file are about rendering content to a view.
|
||||
newGocuiTask func() gocui.Task
|
||||
|
||||
// Runs f on the UI thread and blocks until it has completed. All mutations
|
||||
// of the view happen through this, so that the view is only ever touched on
|
||||
// the UI thread (where it is also laid out and drawn), never on the task's
|
||||
// own goroutine.
|
||||
onUIThread func(f func() error) error
|
||||
|
||||
// if the user flicks through a heap of items, with each one
|
||||
// spawning a process to render something to the main view,
|
||||
// it can slow things down quite a bit. In these situations we
|
||||
// want to throttle the spawning of processes.
|
||||
throttle bool
|
||||
// want to throttle the spawning of processes. Atomic because it's set
|
||||
// from one task's stop goroutine and read when the next task starts.
|
||||
throttle atomic.Bool
|
||||
}
|
||||
|
||||
type LinesToRead struct {
|
||||
|
|
@ -110,6 +122,7 @@ func NewViewBufferManager(
|
|||
onEndOfInput func(),
|
||||
onNewKey func(),
|
||||
newGocuiTask func() gocui.Task,
|
||||
onUIThread func(f func() error) error,
|
||||
) *ViewBufferManager {
|
||||
return &ViewBufferManager{
|
||||
Log: log,
|
||||
|
|
@ -117,9 +130,9 @@ func NewViewBufferManager(
|
|||
beforeStart: beforeStart,
|
||||
refreshView: refreshView,
|
||||
onEndOfInput: onEndOfInput,
|
||||
readLines: nil,
|
||||
onNewKey: onNewKey,
|
||||
newGocuiTask: newGocuiTask,
|
||||
onUIThread: onUIThread,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,17 +141,19 @@ func NewViewBufferManager(
|
|||
// (e.g. as the user scrolls down, back up, and down again) don't re-read lines
|
||||
// that have already been read: the task only ever reads the shortfall.
|
||||
func (self *ViewBufferManager) ReadLines(totalLines int) {
|
||||
if self.readLines != nil {
|
||||
if ch := self.readLines.Load(); ch != nil {
|
||||
readLines := *ch
|
||||
go utils.Safe(func() {
|
||||
self.readLines <- LinesToRead{Total: totalLines, InitialRefreshAfter: -1}
|
||||
readLines <- LinesToRead{Total: totalLines, InitialRefreshAfter: -1}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (self *ViewBufferManager) ReadToEnd(then func()) {
|
||||
if self.readLines != nil {
|
||||
if ch := self.readLines.Load(); ch != nil {
|
||||
readLines := *ch
|
||||
go utils.Safe(func() {
|
||||
self.readLines <- LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: then}
|
||||
readLines <- LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: then}
|
||||
})
|
||||
} else if then != nil {
|
||||
then()
|
||||
|
|
@ -163,7 +178,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
|
|||
onFirstPageShown()
|
||||
}
|
||||
|
||||
if self.throttle {
|
||||
if self.throttle.Load() {
|
||||
self.Log.Info("throttling task")
|
||||
time.Sleep(THROTTLE_TIME)
|
||||
}
|
||||
|
|
@ -186,13 +201,13 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
|
|||
case <-done:
|
||||
// The command finished and did not have to be preemptively stopped before the next command.
|
||||
// No need to throttle.
|
||||
self.throttle = false
|
||||
self.throttle.Store(false)
|
||||
case <-opts.Stop:
|
||||
// we use the time it took to start the program as a way of checking if things
|
||||
// are running slow at the moment. This is admittedly a crude estimate, but
|
||||
// the point is that we only want to throttle when things are running slow
|
||||
// and the user is flicking through a bunch of items.
|
||||
self.throttle = time.Since(startTime) < THROTTLE_TIME && timeToStart > COMMAND_START_THRESHOLD
|
||||
self.throttle.Store(time.Since(startTime) < THROTTLE_TIME && timeToStart > COMMAND_START_THRESHOLD)
|
||||
|
||||
// Kill the still-running command. The only reason to do this is to save CPU usage
|
||||
// when flicking through several very long diffs when diff.algorithm = histogram is
|
||||
|
|
@ -212,7 +227,8 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
|
|||
|
||||
loadingMutex := deadlock.Mutex{}
|
||||
|
||||
self.readLines = make(chan LinesToRead, 1024)
|
||||
readLines := make(chan LinesToRead, 1024)
|
||||
self.readLines.Store(&readLines)
|
||||
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Split(utils.ScanLinesAndTruncateWhenLongerThanBuffer(bufio.MaxScanTokenSize))
|
||||
|
|
@ -304,7 +320,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
|
|||
select {
|
||||
case <-opts.Stop:
|
||||
break outer
|
||||
case linesToRead := <-self.readLines:
|
||||
case linesToRead := <-readLines:
|
||||
callThen := func() {
|
||||
if linesToRead.Then != nil {
|
||||
linesToRead.Then()
|
||||
|
|
@ -337,8 +353,14 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
|
|||
|
||||
if !ok {
|
||||
// if we're here then there's nothing left to scan from the source
|
||||
// so we're at the EOF and can flush the stale content
|
||||
self.onEndOfInput()
|
||||
// so we're at the EOF and can flush the stale content.
|
||||
// onEndOfInput reads the view's dimensions (to decide
|
||||
// whether to scroll) and sets the origin, both of which
|
||||
// are UI-thread-only, so run it there.
|
||||
_ = self.onUIThread(func() error {
|
||||
self.onEndOfInput()
|
||||
return nil
|
||||
})
|
||||
callThen()
|
||||
break outer
|
||||
}
|
||||
|
|
@ -359,7 +381,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
|
|||
}
|
||||
}
|
||||
|
||||
self.readLines = nil
|
||||
self.readLines.Store(nil)
|
||||
|
||||
refreshViewIfStale()
|
||||
|
||||
|
|
@ -383,7 +405,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix
|
|||
close(lineWrittenChan)
|
||||
})
|
||||
|
||||
self.readLines <- linesToRead
|
||||
readLines <- linesToRead
|
||||
|
||||
<-done
|
||||
|
||||
|
|
@ -460,21 +482,31 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error
|
|||
self.taskIDMutex.Lock()
|
||||
|
||||
// Bail out before touching shared view state if a newer task has
|
||||
// already been queued: if we ran onNewKey here we'd reset the view
|
||||
// for a task that's about to exit, potentially wiping output the
|
||||
// winning task has already written.
|
||||
// already been queued: if we reset the view here we'd do it for a task
|
||||
// that's about to exit, potentially wiping output the winning task has
|
||||
// already written.
|
||||
if taskID < self.newTaskID {
|
||||
self.taskIDMutex.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if self.GetTaskKey() != key && self.onNewKey != nil {
|
||||
self.onNewKey()
|
||||
}
|
||||
resetOrigin := self.GetTaskKey() != key && self.onNewKey != nil
|
||||
self.taskKey = key
|
||||
|
||||
self.taskIDMutex.Unlock()
|
||||
|
||||
if resetOrigin {
|
||||
// onNewKey resets the view's scroll origin, which is view state the
|
||||
// UI thread reads while laying out and drawing, so do it there. This
|
||||
// must happen after releasing taskIDMutex: it blocks until the UI
|
||||
// thread runs it, and a NewTask call on the UI thread takes
|
||||
// taskIDMutex, so holding it here would deadlock.
|
||||
_ = self.onUIThread(func() error {
|
||||
self.onNewKey()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
self.waitingMutex.Lock()
|
||||
|
||||
// Re-check staleness after acquiring waitingMutex: a newer task
|
||||
|
|
@ -491,7 +523,7 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error
|
|||
self.stopCurrentTask()
|
||||
}
|
||||
|
||||
self.readLines = nil
|
||||
self.readLines.Store(nil)
|
||||
|
||||
stop := make(chan struct{})
|
||||
notifyStopped := make(chan struct{})
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ func TestNewCmdTaskInstantStop(t *testing.T) {
|
|||
onEndOfInput,
|
||||
onNewKey,
|
||||
newTask,
|
||||
// no UI thread in the test; run the view mutations inline
|
||||
func(f func() error) error { return f() },
|
||||
)
|
||||
|
||||
stop := make(chan struct{})
|
||||
|
|
@ -104,6 +106,8 @@ func TestNewCmdTask(t *testing.T) {
|
|||
onEndOfInput,
|
||||
onNewKey,
|
||||
newTask,
|
||||
// no UI thread in the test; run the view mutations inline
|
||||
func(f func() error) error { return f() },
|
||||
)
|
||||
|
||||
stop := make(chan struct{})
|
||||
|
|
@ -237,6 +241,8 @@ func TestNewCmdTaskRefresh(t *testing.T) {
|
|||
func() {},
|
||||
func() {},
|
||||
newTask,
|
||||
// no UI thread in the test; run the view mutations inline
|
||||
func(f func() error) error { return f() },
|
||||
)
|
||||
|
||||
stop := make(chan struct{})
|
||||
|
|
|
|||
18
vendor/github.com/gdamore/tcell/v3/cell.go
generated
vendored
18
vendor/github.com/gdamore/tcell/v3/cell.go
generated
vendored
|
|
@ -72,12 +72,18 @@ func (cb *CellBuffer) put(x int, y int, str string, style Style) (string, int) {
|
|||
if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
|
||||
var cl string
|
||||
c := &cb.cells[(y*cb.w)+x]
|
||||
g := textWidthOptions.StringGraphemes(str)
|
||||
for width == 0 && g.Next() {
|
||||
cluster := g.Value()
|
||||
cl += cluster
|
||||
width = g.Width()
|
||||
str = str[len(cluster):]
|
||||
if str == c.currStr && c.width > 0 {
|
||||
// Identical re-Put (a full-screen redraw): the grapheme split is
|
||||
// unchanged, so reuse the measured width instead of segmenting.
|
||||
cl, width, str = str, c.width, ""
|
||||
} else {
|
||||
g := textWidthOptions.StringGraphemes(str)
|
||||
for width == 0 && g.Next() {
|
||||
cluster := g.Value()
|
||||
cl += cluster
|
||||
width = g.Width()
|
||||
str = str[len(cluster):]
|
||||
}
|
||||
}
|
||||
|
||||
// Wide characters: we want to mark the "wide" cells
|
||||
|
|
|
|||
28
vendor/github.com/gdamore/tcell/v3/tscreen.go
generated
vendored
28
vendor/github.com/gdamore/tcell/v3/tscreen.go
generated
vendored
|
|
@ -243,6 +243,7 @@ type tScreen struct {
|
|||
legacy bool
|
||||
hasClipboard bool // true if OSC 52 reported via DA1
|
||||
finiOnce sync.Once
|
||||
initFiniLock sync.Mutex
|
||||
enterUrl string
|
||||
exitUrl string
|
||||
setWinSize string
|
||||
|
|
@ -258,6 +259,7 @@ type tScreen struct {
|
|||
running bool
|
||||
startTime time.Time
|
||||
wg sync.WaitGroup
|
||||
eventWg sync.WaitGroup
|
||||
mouseFlags MouseFlags
|
||||
pasteEnabled bool
|
||||
focusEnabled bool
|
||||
|
|
@ -355,6 +357,20 @@ func (t *tScreen) applyEnvironmentOverrides() {
|
|||
}
|
||||
|
||||
func (t *tScreen) Init() error {
|
||||
t.initFiniLock.Lock()
|
||||
defer t.initFiniLock.Unlock()
|
||||
|
||||
t.Lock()
|
||||
if t.fini {
|
||||
t.Unlock()
|
||||
return errors.New("screen finalized")
|
||||
}
|
||||
if t.running {
|
||||
t.Unlock()
|
||||
return errors.New("already initialized")
|
||||
}
|
||||
t.Unlock()
|
||||
|
||||
if e := t.initialize(); e != nil {
|
||||
return e
|
||||
}
|
||||
|
|
@ -525,7 +541,9 @@ func (t *tScreen) processInitQ() {
|
|||
|
||||
func (t *tScreen) filterEvents() chan Event {
|
||||
inQ := make(chan Event, 128)
|
||||
t.eventWg.Add(1)
|
||||
go func() {
|
||||
defer t.eventWg.Done()
|
||||
for {
|
||||
var ev Event
|
||||
select {
|
||||
|
|
@ -541,7 +559,11 @@ func (t *tScreen) filterEvents() chan Event {
|
|||
}
|
||||
|
||||
default:
|
||||
t.eventQ <- ev
|
||||
select {
|
||||
case t.eventQ <- ev:
|
||||
case <-t.quit:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
@ -596,6 +618,9 @@ func (t *tScreen) prepareCursorStyles() {
|
|||
}
|
||||
|
||||
func (t *tScreen) Fini() {
|
||||
t.initFiniLock.Lock()
|
||||
defer t.initFiniLock.Unlock()
|
||||
|
||||
// Ensure that enough time passes for terminals to finish sending
|
||||
// their initial response (gnome-terminal sends terminal dimensions
|
||||
// asynchronously later than the response to primary DA for some reason.)
|
||||
|
|
@ -1659,6 +1684,7 @@ func (t *tScreen) Beep() error {
|
|||
func (t *tScreen) finalize() {
|
||||
t.disengage()
|
||||
_ = t.tty.Close()
|
||||
t.eventWg.Wait()
|
||||
close(t.eventQ)
|
||||
}
|
||||
|
||||
|
|
|
|||
49
vendor/github.com/gdamore/tcell/v3/tty/tty_win.go
generated
vendored
49
vendor/github.com/gdamore/tcell/v3/tty/tty_win.go
generated
vendored
|
|
@ -20,6 +20,7 @@ package tty
|
|||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
|
@ -98,6 +99,38 @@ type inputRecord struct {
|
|||
data [16]byte
|
||||
}
|
||||
|
||||
func encodeWinKeyRecord(data [16]byte, surrogate *rune) []byte {
|
||||
keyDown := binary.LittleEndian.Uint32(data[0:]) != 0
|
||||
repeat := binary.LittleEndian.Uint16(data[4:])
|
||||
virtualKey := binary.LittleEndian.Uint16(data[6:])
|
||||
scanCode := binary.LittleEndian.Uint16(data[8:])
|
||||
// we normally only expect to see ascii, but paste data may come in as UTF-16.
|
||||
wc := rune(binary.LittleEndian.Uint16(data[10:]))
|
||||
controlState := binary.LittleEndian.Uint32(data[12:])
|
||||
|
||||
if virtualKey != 0 || scanCode != 0 {
|
||||
kd := 0
|
||||
if keyDown {
|
||||
kd = 1
|
||||
}
|
||||
return fmt.Appendf(nil, "\x1b[%d;%d;%d;%d;%d;%d_",
|
||||
virtualKey, scanCode, wc, kd, controlState, max(1, repeat))
|
||||
}
|
||||
|
||||
if !keyDown {
|
||||
return nil
|
||||
}
|
||||
|
||||
var encoded []byte
|
||||
decodedRunes := decodeUTF16Rune(surrogate, wc)
|
||||
for range max(1, repeat) {
|
||||
for _, decoded := range decodedRunes {
|
||||
encoded = append(encoded, []byte(string(decoded))...)
|
||||
}
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
type winTty struct {
|
||||
buf chan byte
|
||||
out syscall.Handle
|
||||
|
|
@ -207,17 +240,11 @@ func (w *winTty) getConsoleInput() error {
|
|||
ir := rec[i]
|
||||
switch ir.typ {
|
||||
case keyEvent:
|
||||
// we normally only expect to see ascii, but paste data may come in as UTF-16.
|
||||
wc := rune(binary.LittleEndian.Uint16(ir.data[10:]))
|
||||
for _, decoded := range decodeUTF16Rune(&w.surrogate, wc) {
|
||||
for _, chr := range []byte(string(decoded)) {
|
||||
// We normally expect only to see ASCII (win32-input-mode),
|
||||
// but apparently pasted data can arrive in UTF-16 here.
|
||||
select {
|
||||
case w.buf <- chr:
|
||||
case <-w.stopQ:
|
||||
break loop
|
||||
}
|
||||
for _, chr := range encodeWinKeyRecord(ir.data, &w.surrogate) {
|
||||
select {
|
||||
case w.buf <- chr:
|
||||
case <-w.stopQ:
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
10
vendor/modules.txt
vendored
10
vendor/modules.txt
vendored
|
|
@ -48,7 +48,7 @@ github.com/fatih/color
|
|||
# github.com/gdamore/encoding v1.0.1
|
||||
## explicit; go 1.9
|
||||
github.com/gdamore/encoding
|
||||
# github.com/gdamore/tcell/v3 v3.4.0
|
||||
# github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59
|
||||
## explicit; go 1.25.0
|
||||
github.com/gdamore/tcell/v3
|
||||
github.com/gdamore/tcell/v3/color
|
||||
|
|
@ -175,7 +175,7 @@ github.com/xo/terminfo
|
|||
## explicit; go 1.20
|
||||
golang.org/x/exp/constraints
|
||||
golang.org/x/exp/slices
|
||||
# golang.org/x/mod v0.35.0
|
||||
# golang.org/x/mod v0.36.0
|
||||
## explicit; go 1.25.0
|
||||
golang.org/x/mod/internal/lazyregexp
|
||||
golang.org/x/mod/modfile
|
||||
|
|
@ -192,10 +192,10 @@ golang.org/x/sync/semaphore
|
|||
golang.org/x/sys/plan9
|
||||
golang.org/x/sys/unix
|
||||
golang.org/x/sys/windows
|
||||
# golang.org/x/term v0.43.0
|
||||
# golang.org/x/term v0.44.0
|
||||
## explicit; go 1.25.0
|
||||
golang.org/x/term
|
||||
# golang.org/x/text v0.37.0
|
||||
# golang.org/x/text v0.38.0
|
||||
## explicit; go 1.25.0
|
||||
golang.org/x/text/cases
|
||||
golang.org/x/text/encoding
|
||||
|
|
@ -208,7 +208,7 @@ golang.org/x/text/language
|
|||
golang.org/x/text/runes
|
||||
golang.org/x/text/transform
|
||||
golang.org/x/text/unicode/norm
|
||||
# golang.org/x/tools v0.44.0
|
||||
# golang.org/x/tools v0.45.0
|
||||
## explicit; go 1.25.0
|
||||
golang.org/x/tools/go/ast/astutil
|
||||
# gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
|
||||
|
|
|
|||
Loading…
Reference in a new issue