mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-02 03:56:45 -04:00
Compare commits
174
Commits
@@ -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
|
||||
|
||||
@@ -48,6 +48,9 @@ while still being meaningful and self-contained.
|
||||
commits that leave the tree broken and rely on a follow-up to fix it.
|
||||
- **Every commit must be `gofumpt`-formatted.** Run `just format` before
|
||||
committing.
|
||||
- **Every commit must be lint-clean.** Run `just lint` before committing —
|
||||
don't introduce a lint warning in one commit and rely on a later commit
|
||||
(or the user) to clean it up.
|
||||
- **Commit messages explain _why_, not _what_.** The diff already shows what
|
||||
changed; the message should capture the motivation, the constraint, or the
|
||||
bug being fixed. If the reason is obvious from a one-line subject, no body
|
||||
@@ -157,6 +160,16 @@ genuine forks — the ones where a reasonable person might pick differently, or
|
||||
where you'd be trading away something the plan assumed (scope, UX, performance,
|
||||
reload behavior, …). When in doubt, surface it.
|
||||
|
||||
This applies with equal force to unforeseen _discoveries_, not just to
|
||||
decisions you set out to make. If you find something the plan didn't account
|
||||
for — a latent bug, a race, a wrong assumption, a case that turns out
|
||||
unhandled — stop and raise it before designing or writing a fix, even when the
|
||||
fix seems obvious and even when it's "just correctness." Finding the problem is
|
||||
itself the fork: whether to fix it here or in a separate change, how generally
|
||||
to solve it, and whether it reshapes the current work are all calls for me to
|
||||
make with you. Don't quietly fold a self-directed fix for a newly-found problem
|
||||
into the branch and let me discover it in the diff.
|
||||
|
||||
## Prefer the cleaner design over the smaller diff
|
||||
|
||||
When a task could be implemented either by tacking onto existing code or by
|
||||
@@ -243,6 +256,34 @@ Follow this even when the need for the refactor is only discovered in the middle
|
||||
of working on the branch; suggest to the user to rewrite the history to move the
|
||||
refactor to an earlier commit (but don't do it without asking first).
|
||||
|
||||
## Don't read model state right after a `Refresh`
|
||||
|
||||
A `Refresh` (or `RefreshFromWorker`) does its git work on a worker and then
|
||||
*enqueues* the model update onto the UI thread. So when `Refresh` returns, the
|
||||
model is **not** updated yet — the write is still queued. Reading a field
|
||||
synchronously right after refreshing its scope reads the stale, pre-refresh
|
||||
value (and this is true even for SYNC refreshes):
|
||||
|
||||
```go
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
||||
files := self.c.Model().Files // BUG: still the pre-refresh value
|
||||
```
|
||||
|
||||
Put the read in `RefreshOptions.Then` instead — it's queued after the scope's
|
||||
model writes, so it sees the fresh value:
|
||||
|
||||
```go
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Scope: []types.RefreshableView{types.FILES},
|
||||
Then: func() error {
|
||||
files := self.c.Model().Files // fresh
|
||||
return nil
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`Then` is a `func() error` and works with any non-`ASYNC` mode.
|
||||
|
||||
## Integration test conventions
|
||||
|
||||
Don't bind views to local variables. Always chain method calls directly from
|
||||
@@ -370,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.
|
||||
|
||||
+19
-258
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -25,6 +25,7 @@ require (
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0
|
||||
github.com/mgutz/str v1.2.0
|
||||
github.com/mitchellh/go-ps v1.0.0
|
||||
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe
|
||||
github.com/rivo/uniseg v0.4.7
|
||||
github.com/sahilm/fuzzy v0.1.3
|
||||
github.com/samber/lo v1.53.0
|
||||
@@ -62,14 +63,13 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/onsi/ginkgo v1.10.3 // indirect
|
||||
github.com/onsi/gomega v1.34.1 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
+11
-3
@@ -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) {
|
||||
|
||||
+370
-43
@@ -9,11 +9,13 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/generics/set"
|
||||
"github.com/petermattis/goid"
|
||||
"github.com/rivo/uniseg"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
@@ -123,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
|
||||
@@ -143,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.
|
||||
@@ -193,7 +202,26 @@ type Gui struct {
|
||||
|
||||
taskManager *TaskManager
|
||||
|
||||
// The task of the event currently being processed on the main goroutine, if
|
||||
// any. Only touched from the main goroutine (in processEvent). It's excluded
|
||||
// from the Busy() check so that an event handler asking "is anything else
|
||||
// busy?" doesn't count itself.
|
||||
currentTask Task
|
||||
|
||||
lastHoverView *View
|
||||
|
||||
// uiThreadID is the goroutine id of the main event loop, recorded when
|
||||
// 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 {
|
||||
@@ -236,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),
|
||||
@@ -269,18 +293,63 @@ 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
|
||||
}
|
||||
|
||||
func (g *Gui) NewTask() *TaskImpl {
|
||||
return g.taskManager.NewTask()
|
||||
return g.taskManager.NewTask(false)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// NewBackgroundTask creates a task that is tracked for idle detection but does
|
||||
// not count towards the program being busy for repo-switch safety. See
|
||||
// TaskImpl.background.
|
||||
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
|
||||
// to switch repos. Must be called on the main goroutine.
|
||||
func (g *Gui) Busy() bool {
|
||||
return g.taskManager.hasBusyForegroundTaskExcept(g.currentTask)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -290,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
|
||||
@@ -327,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()
|
||||
@@ -608,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
|
||||
@@ -618,29 +699,204 @@ 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.
|
||||
func (g *Gui) Update(f func(*Gui) error) {
|
||||
task := g.NewTask()
|
||||
// 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 g.userEvents <- userEvent{f: f, task: task}:
|
||||
case q.doorbell <- struct{}{}:
|
||||
default:
|
||||
panic("gocui: userEvents channel full; refusing to block or reorder")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Like Update, but the enqueued work is a background routine (or triggered by
|
||||
// one), so it doesn't count towards the program being busy for repo-switch
|
||||
// safety. See TaskImpl.background.
|
||||
func (g *Gui) UpdateBackground(f func(*Gui) error) {
|
||||
g.update(f, true)
|
||||
}
|
||||
|
||||
func (g *Gui) update(f func(*Gui) error, background bool) {
|
||||
task := g.taskManager.NewTask(background)
|
||||
g.userEvents.enqueue(userEvent{f: f, task: task})
|
||||
}
|
||||
|
||||
// Like Update, but signals that the callback only modifies content.
|
||||
func (g *Gui) UpdateContentOnly(f func(*Gui) error) {
|
||||
task := g.NewTask()
|
||||
g.userEvents <- userEvent{f: f, task: task, contentOnly: true}
|
||||
g.updateContentOnly(f, false)
|
||||
}
|
||||
|
||||
// Like UpdateContentOnly, but for background work (see UpdateBackground).
|
||||
func (g *Gui) UpdateContentOnlyBackground(f func(*Gui) error) {
|
||||
g.updateContentOnly(f, true)
|
||||
}
|
||||
|
||||
func (g *Gui) updateContentOnly(f func(*Gui) error, background bool) {
|
||||
task := g.taskManager.NewTask(background)
|
||||
g.userEvents.enqueue(userEvent{f: f, task: task, contentOnly: true})
|
||||
}
|
||||
|
||||
// IsUIThread reports whether the caller is running on the main event-loop
|
||||
// goroutine (the one running MainLoop). It calls goid.Get, so use it only for
|
||||
// debug assertions, not to drive production control flow.
|
||||
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.
|
||||
//
|
||||
// It must be called from a worker goroutine, never from the UI thread itself:
|
||||
// the UI thread would block waiting for a callback only it can run, which
|
||||
// deadlocks. Callers arrange this by construction (see the refresh helper's
|
||||
// RefreshFromWorker); a debug-only assertion there guards against getting it
|
||||
// wrong.
|
||||
func (g *Gui) OnUIThreadAndWait(f func() error) error {
|
||||
return g.onUIThreadAndWait(f, false)
|
||||
}
|
||||
|
||||
// Like OnUIThreadAndWait, but the enqueued work belongs to a background routine,
|
||||
// so it doesn't count towards the program being busy (see UpdateBackground).
|
||||
func (g *Gui) OnUIThreadAndWaitBackground(f func() error) error {
|
||||
return g.onUIThreadAndWait(f, true)
|
||||
}
|
||||
|
||||
func (g *Gui) onUIThreadAndWait(f func() error, background bool) error {
|
||||
enqueue := g.Update
|
||||
if background {
|
||||
enqueue = g.UpdateBackground
|
||||
}
|
||||
|
||||
result := make(chan error, 1)
|
||||
enqueue(func(*Gui) error {
|
||||
result <- f()
|
||||
return nil
|
||||
})
|
||||
return <-result
|
||||
}
|
||||
|
||||
// Calls a function in a goroutine. Handles panics gracefully and tracks
|
||||
@@ -650,7 +906,18 @@ func (g *Gui) UpdateContentOnly(f func(*Gui) error) {
|
||||
// background goroutines where you wouldn't want lazygit to be considered busy
|
||||
// (i.e. when you wouldn't want a loader to be shown to the user)
|
||||
func (g *Gui) OnWorker(f func(Task) error) {
|
||||
task := g.NewTask()
|
||||
g.onWorker(f, false)
|
||||
}
|
||||
|
||||
// Like OnWorker, but for a background routine (or work triggered by one), so it
|
||||
// doesn't count towards the program being busy for repo-switch safety. See
|
||||
// TaskImpl.background.
|
||||
func (g *Gui) OnWorkerBackground(f func(Task) error) {
|
||||
g.onWorker(f, true)
|
||||
}
|
||||
|
||||
func (g *Gui) onWorker(f func(Task) error, background bool) {
|
||||
task := g.taskManager.NewTask(background)
|
||||
go func() {
|
||||
g.onWorkerAux(f, task)
|
||||
task.Done()
|
||||
@@ -714,6 +981,8 @@ 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 {
|
||||
defer close(g.loopExited)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
@@ -758,17 +1027,37 @@ func (g *Gui) handleError(err error) error {
|
||||
func (g *Gui) processEvent() error {
|
||||
contentOnly := false
|
||||
|
||||
// currentTask is the task of the event we're about to handle; recording it
|
||||
// lets Busy() ignore it, so a handler asking "is anything else busy?" (the
|
||||
// repo-switch guard does) doesn't count itself. Handlers of the remaining
|
||||
// events drained below run with currentTask still set to this primary event;
|
||||
// that's fine because the only Busy() callers are keybinding handlers, which
|
||||
// are always the primary event here.
|
||||
select {
|
||||
case ev := <-g.gEvents:
|
||||
task := g.NewTask()
|
||||
defer func() { task.Done() }()
|
||||
// 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
|
||||
defer func() { ev.task.Done() }()
|
||||
g.currentTask = ev.task
|
||||
defer func() { g.currentTask = nil; ev.task.Done() }()
|
||||
|
||||
if err := g.handleError(ev.f(g)); err != nil {
|
||||
return err
|
||||
@@ -795,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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -814,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)
|
||||
@@ -832,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()
|
||||
@@ -1150,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
|
||||
@@ -1189,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
|
||||
}
|
||||
|
||||
|
||||
+21
-1
@@ -8,8 +8,9 @@ type Task interface {
|
||||
Done()
|
||||
Pause()
|
||||
Continue()
|
||||
// not exporting because we don't need to
|
||||
// not exporting these because we don't need to
|
||||
isBusy() bool
|
||||
isBackground() bool
|
||||
}
|
||||
|
||||
type TaskImpl struct {
|
||||
@@ -17,6 +18,17 @@ type TaskImpl struct {
|
||||
busy bool
|
||||
onDone func()
|
||||
withMutex func(func())
|
||||
// Background tasks don't count towards the program being "busy" for the
|
||||
// purpose of deciding whether a repo switch is safe (see
|
||||
// TaskManager.hasBusyForegroundTaskExcept). Two kinds of work are tagged
|
||||
// this way: the ongoing background routines (auto-fetch, files refresh,
|
||||
// external-change detection) and the refreshes they trigger, whose model
|
||||
// writes are already guarded against a concurrent repo switch by the repo
|
||||
// generation; and view-buffer content rendering, which only paints a view
|
||||
// and so is harmless to leave running across a switch. What stays
|
||||
// foreground is lazygit driving a git operation and applying its results
|
||||
// to the model — exactly the work a repo switch must not run underneath.
|
||||
background bool
|
||||
}
|
||||
|
||||
func (self *TaskImpl) Done() {
|
||||
@@ -39,6 +51,10 @@ func (self *TaskImpl) isBusy() bool {
|
||||
return self.busy
|
||||
}
|
||||
|
||||
func (self *TaskImpl) isBackground() bool {
|
||||
return self.background
|
||||
}
|
||||
|
||||
type TaskStatus int
|
||||
|
||||
const (
|
||||
@@ -73,6 +89,10 @@ func (self *FakeTask) isBusy() bool {
|
||||
return self.status == TaskStatusBusy
|
||||
}
|
||||
|
||||
func (self *FakeTask) isBackground() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (self *FakeTask) Status() TaskStatus {
|
||||
return self.status
|
||||
}
|
||||
|
||||
+59
-21
@@ -6,23 +6,26 @@ 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() *TaskImpl {
|
||||
func (self *TaskManager) NewTask(background bool) *TaskImpl {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
@@ -30,14 +33,54 @@ func (self *TaskManager) NewTask() *TaskImpl {
|
||||
taskId := self.nextId
|
||||
|
||||
onDone := func() { self.delete(taskId) }
|
||||
task := &TaskImpl{id: taskId, busy: true, onDone: onDone, withMutex: self.withMutex}
|
||||
task := &TaskImpl{id: taskId, busy: true, background: background, onDone: onDone, withMutex: self.withMutex}
|
||||
self.tasks[taskId] = task
|
||||
|
||||
return task
|
||||
}
|
||||
|
||||
func (self *TaskManager) addIdleListener(c chan struct{}) {
|
||||
self.idleListeners = append(self.idleListeners, c)
|
||||
// hasBusyForegroundTaskExcept reports whether any task other than `ignore` is
|
||||
// currently busy and not a background task. It's used to decide whether a repo
|
||||
// switch is safe: a foreground operation (or the refresh it triggers, or that
|
||||
// refresh's follow-up callbacks) still in flight means the switch must wait, so
|
||||
// it doesn't run against a repo that's about to be swapped out.
|
||||
//
|
||||
// `ignore` is the event currently being processed on the UI thread — the switch
|
||||
// attempt itself — which is always busy and so must not count as a reason to
|
||||
// refuse itself.
|
||||
func (self *TaskManager) hasBusyForegroundTaskExcept(ignore Task) bool {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
for _, task := range self.tasks {
|
||||
if task != ignore && task.isBusy() && !task.isBackground() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
@@ -46,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTaskManagerHasBusyForegroundTaskExcept(t *testing.T) {
|
||||
t.Run("no tasks", func(t *testing.T) {
|
||||
tm := newTaskManager()
|
||||
assert.False(t, tm.hasBusyForegroundTaskExcept(nil))
|
||||
})
|
||||
|
||||
t.Run("a busy foreground task counts", func(t *testing.T) {
|
||||
tm := newTaskManager()
|
||||
tm.NewTask(false)
|
||||
assert.True(t, tm.hasBusyForegroundTaskExcept(nil))
|
||||
})
|
||||
|
||||
t.Run("a busy background task does not count", func(t *testing.T) {
|
||||
tm := newTaskManager()
|
||||
tm.NewTask(true)
|
||||
assert.False(t, tm.hasBusyForegroundTaskExcept(nil))
|
||||
})
|
||||
|
||||
t.Run("a done foreground task does not count", func(t *testing.T) {
|
||||
tm := newTaskManager()
|
||||
task := tm.NewTask(false)
|
||||
task.Done()
|
||||
assert.False(t, tm.hasBusyForegroundTaskExcept(nil))
|
||||
})
|
||||
|
||||
t.Run("a paused foreground task does not count", func(t *testing.T) {
|
||||
tm := newTaskManager()
|
||||
task := tm.NewTask(false)
|
||||
task.Pause()
|
||||
assert.False(t, tm.hasBusyForegroundTaskExcept(nil))
|
||||
})
|
||||
|
||||
t.Run("the ignored task does not count", func(t *testing.T) {
|
||||
tm := newTaskManager()
|
||||
task := tm.NewTask(false)
|
||||
assert.False(t, tm.hasBusyForegroundTaskExcept(task))
|
||||
})
|
||||
|
||||
t.Run("another foreground task counts even when one is ignored", func(t *testing.T) {
|
||||
tm := newTaskManager()
|
||||
ignored := tm.NewTask(false)
|
||||
tm.NewTask(false)
|
||||
assert.True(t, tm.hasBusyForegroundTaskExcept(ignored))
|
||||
})
|
||||
|
||||
t.Run("only a background task alongside the ignored current event", func(t *testing.T) {
|
||||
// This is the repo-switch case: the switch is handled as the current
|
||||
// event (ignored) while a background refresh is in flight; it must not
|
||||
// be considered busy.
|
||||
tm := newTaskManager()
|
||||
current := tm.NewTask(false)
|
||||
tm.NewTask(true)
|
||||
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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+124
-44
@@ -7,6 +7,7 @@ package gocui
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
@@ -50,6 +51,14 @@ type View struct {
|
||||
// tained is true if the viewLines must be updated
|
||||
tainted bool
|
||||
|
||||
// firstDirtyLine is the index of the lowest line in `lines` that has been
|
||||
// written to or highlighted since viewLines was last refreshed, and whose
|
||||
// cached wrapping (lineType.wrappedCells) may therefore be stale. Lines
|
||||
// below it are unchanged and can reuse their cached wrapping instead of
|
||||
// being re-wrapped, which keeps refreshViewLinesIfNeeded cheap while
|
||||
// scrolling appends new lines to a long buffer.
|
||||
firstDirtyLine int
|
||||
|
||||
// the last position that the mouse was hovering over; nil if the mouse is outside of
|
||||
// this view, or not hovering over a cell
|
||||
lastHoverPosition *pos
|
||||
@@ -206,6 +215,16 @@ func (v *View) clearViewLines() {
|
||||
v.clearHover()
|
||||
}
|
||||
|
||||
// ClearViewLines is clearViewLines guarded by writeMutex. It's for callers on
|
||||
// the UI thread (the layout pass) that touch a view whose content a task
|
||||
// goroutine may be writing concurrently: viewLines/tainted/hover are all
|
||||
// buffer state that writeMutex protects.
|
||||
func (v *View) ClearViewLines() {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
v.clearViewLines()
|
||||
}
|
||||
|
||||
type searcher struct {
|
||||
searchString string
|
||||
searchPositions []SearchPosition
|
||||
@@ -457,6 +476,16 @@ type viewLine struct {
|
||||
type lineType struct {
|
||||
cells cells
|
||||
trailingFillAttributes *trailingFillAttributes
|
||||
|
||||
// wrappedCells caches the result of wrapping `cells` to `wrappedColumns`
|
||||
// columns, so that unchanged lines don't have to be re-wrapped on every
|
||||
// refreshViewLinesIfNeeded (which runs on every scroll event, via
|
||||
// ViewLinesHeight). Wrapping measures every cell's width and allocates, so
|
||||
// for a long buffer that dominates the cost of scrolling. The cache is used
|
||||
// only for lines below View.firstDirtyLine whose wrappedColumns still
|
||||
// matches the current width; nil means nothing is cached yet.
|
||||
wrappedCells [][]cell
|
||||
wrappedColumns int
|
||||
}
|
||||
|
||||
// trailingFillAttributes describes the fg/bg colors that draw() should
|
||||
@@ -518,9 +547,20 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
|
||||
v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault
|
||||
v.InactiveViewSelBgColor = ColorDefault
|
||||
v.TitleColor, v.FrameColor = ColorDefault, ColorDefault
|
||||
v.ei.screenColMax = v.InnerWidth()
|
||||
return v
|
||||
}
|
||||
|
||||
// SetContentWidth tells the view the screen width that content written to it
|
||||
// should count soft-wraps against (see escapeInterpreter.notifyCellsWritten).
|
||||
// Callers pass the view's InnerWidth; it's a separate call, made on the UI
|
||||
// thread when a render starts, so that the task goroutine that streams the
|
||||
// content can consult this snapshot instead of reading the view's live
|
||||
// dimensions (which the UI thread mutates during layout).
|
||||
func (v *View) SetContentWidth(width int) {
|
||||
v.ei.screenColMax = width
|
||||
}
|
||||
|
||||
// Dimensions returns the dimensions of the View
|
||||
func (v *View) Dimensions() (int, int, int, int) {
|
||||
return v.x0, v.y0, v.x1, v.y1
|
||||
@@ -815,6 +855,9 @@ func (v *View) Write(p []byte) (n int, err error) {
|
||||
|
||||
func (v *View) write(p []byte) {
|
||||
v.tainted = true
|
||||
// write only ever touches lines from v.wy onwards, so any cached wrapping
|
||||
// below that stays valid.
|
||||
v.firstDirtyLine = min(v.firstDirtyLine, v.wy)
|
||||
v.clearHover()
|
||||
|
||||
// Fill with empty cells, if writing outside current view buffer
|
||||
@@ -886,7 +929,7 @@ func (v *View) write(p []byte) {
|
||||
for _, c := range cells {
|
||||
totalWidth += c.width
|
||||
}
|
||||
v.ei.notifyCellsWritten(totalWidth, v.InnerWidth())
|
||||
v.ei.notifyCellsWritten(totalWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1104,10 +1147,25 @@ func (v *View) CopyContent(from *View) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
// A background task may be streaming output into the source view's buffer
|
||||
// via Write, so read it under its own lock. The source is always a
|
||||
// different view than the destination (see the sole caller,
|
||||
// moveMainContextToTop), and no other code holds two view write locks at
|
||||
// once, so this can't deadlock.
|
||||
from.writeMutex.Lock()
|
||||
defer from.writeMutex.Unlock()
|
||||
|
||||
v.clear()
|
||||
|
||||
v.lines = from.lines
|
||||
v.viewLines = from.viewLines
|
||||
// Clone the row slices rather than sharing them: the source view stays
|
||||
// live (its streaming task keeps appending rows, and refreshViewLinesIfNeeded
|
||||
// fills each row's wrapping cache in place via &lines[i]), so sharing the
|
||||
// backing arrays would race those writes against this view's own rendering.
|
||||
// This is a shallow clone -- the per-row cell data is immutable once written
|
||||
// and stays shared, so the cost is proportional to the number of rows, not
|
||||
// their contents.
|
||||
v.lines = slices.Clone(from.lines)
|
||||
v.viewLines = slices.Clone(from.viewLines)
|
||||
v.ox = from.ox
|
||||
v.oy = from.oy
|
||||
v.cx = from.cx
|
||||
@@ -1255,6 +1313,8 @@ func (v *View) updateSearchPositions() {
|
||||
|
||||
// IsTainted tells us if the view is tainted
|
||||
func (v *View) IsTainted() bool {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
return v.tainted
|
||||
}
|
||||
|
||||
@@ -1358,48 +1418,64 @@ func (v *View) draw() {
|
||||
}
|
||||
|
||||
func (v *View) refreshViewLinesIfNeeded() {
|
||||
if v.tainted {
|
||||
maxX := v.InnerWidth()
|
||||
lineIdx := 0
|
||||
lines := v.lines
|
||||
for i, line := range lines {
|
||||
wrap := 0
|
||||
if v.Wrap {
|
||||
wrap = maxX
|
||||
}
|
||||
|
||||
ls := lineWrap(line.cells, wrap)
|
||||
for j := range ls {
|
||||
// Per-segment trailing fill. When the source line opted in
|
||||
// via '\x1b[K', the LAST wrapped segment uses those colors
|
||||
// directly; earlier segments use the colors of their own
|
||||
// last cell, so the trailing area matches the bg active
|
||||
// where that segment ended rather than bleeding the
|
||||
// '\x1b[K' bg back across color changes in the line.
|
||||
var attrs *trailingFillAttributes
|
||||
if line.trailingFillAttributes != nil {
|
||||
if j == len(ls)-1 {
|
||||
attrs = line.trailingFillAttributes
|
||||
} else if len(ls[j]) > 0 {
|
||||
last := ls[j][len(ls[j])-1]
|
||||
attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor}
|
||||
}
|
||||
}
|
||||
vline := viewLine{
|
||||
linesX: j, linesY: i, line: ls[j],
|
||||
trailingFillAttributes: attrs,
|
||||
}
|
||||
|
||||
if lineIdx > len(v.viewLines)-1 {
|
||||
v.viewLines = append(v.viewLines, vline)
|
||||
} else {
|
||||
v.viewLines[lineIdx] = vline
|
||||
}
|
||||
lineIdx++
|
||||
}
|
||||
}
|
||||
v.tainted = false
|
||||
if !v.tainted {
|
||||
return
|
||||
}
|
||||
|
||||
maxX := v.InnerWidth()
|
||||
wrap := 0
|
||||
if v.Wrap {
|
||||
wrap = maxX
|
||||
}
|
||||
|
||||
lineIdx := 0
|
||||
lines := v.lines
|
||||
for i := range lines {
|
||||
line := &lines[i]
|
||||
|
||||
// Reuse the previously wrapped result for lines that haven't changed
|
||||
// since the last refresh (i.e. below firstDirtyLine) and were wrapped at
|
||||
// the current width. Wrapping is expensive and this loop runs on every
|
||||
// scroll event, so only the lines that were actually just read (or
|
||||
// re-highlighted) should be wrapped afresh.
|
||||
if line.wrappedCells == nil || line.wrappedColumns != wrap || i >= v.firstDirtyLine {
|
||||
line.wrappedCells = lineWrap(line.cells, wrap)
|
||||
line.wrappedColumns = wrap
|
||||
}
|
||||
ls := line.wrappedCells
|
||||
|
||||
for j := range ls {
|
||||
// Per-segment trailing fill. When the source line opted in
|
||||
// via '\x1b[K', the LAST wrapped segment uses those colors
|
||||
// directly; earlier segments use the colors of their own
|
||||
// last cell, so the trailing area matches the bg active
|
||||
// where that segment ended rather than bleeding the
|
||||
// '\x1b[K' bg back across color changes in the line.
|
||||
var attrs *trailingFillAttributes
|
||||
if line.trailingFillAttributes != nil {
|
||||
if j == len(ls)-1 {
|
||||
attrs = line.trailingFillAttributes
|
||||
} else if len(ls[j]) > 0 {
|
||||
last := ls[j][len(ls[j])-1]
|
||||
attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor}
|
||||
}
|
||||
}
|
||||
vline := viewLine{
|
||||
linesX: j, linesY: i, line: ls[j],
|
||||
trailingFillAttributes: attrs,
|
||||
}
|
||||
|
||||
if lineIdx > len(v.viewLines)-1 {
|
||||
v.viewLines = append(v.viewLines, vline)
|
||||
} else {
|
||||
v.viewLines[lineIdx] = vline
|
||||
}
|
||||
lineIdx++
|
||||
}
|
||||
}
|
||||
|
||||
v.firstDirtyLine = len(lines)
|
||||
v.tainted = false
|
||||
}
|
||||
|
||||
// if autoscroll is enabled but we only have a single row of cells shown to the
|
||||
@@ -1487,6 +1563,9 @@ func (v *View) BufferLines() []string {
|
||||
// Buffer returns a string with the contents of the view's internal
|
||||
// buffer.
|
||||
func (v *View) Buffer() string {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
return linesToString(v.lines)
|
||||
}
|
||||
|
||||
@@ -1599,6 +1678,7 @@ func (v *View) SetHighlight(y int, on bool) {
|
||||
cells = append(cells, c)
|
||||
}
|
||||
v.tainted = true
|
||||
v.firstDirtyLine = min(v.firstDirtyLine, y)
|
||||
v.lines[y].cells = cells
|
||||
v.clearHover()
|
||||
}
|
||||
|
||||
+29
-11
@@ -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 {
|
||||
@@ -125,15 +130,15 @@ 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.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Background: true})
|
||||
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
|
||||
@@ -184,13 +190,13 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() {
|
||||
|
||||
// No need to update the stored snapshot here; Refresh does that.
|
||||
self.gui.c.Log.Info("External ref change detected — refreshing")
|
||||
self.gui.c.Refresh(types.RefreshOptions{Background: true})
|
||||
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()
|
||||
@@ -198,7 +204,10 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru
|
||||
if self.backgroundRefreshesPaused() {
|
||||
return
|
||||
}
|
||||
self.gui.c.OnWorker(func(gocui.Task) error {
|
||||
// OnWorkerBackground, not OnWorker: these routines and the refreshes
|
||||
// they trigger must not count towards lazygit being busy, or they'd
|
||||
// spuriously block a repo switch every time one happens to be running.
|
||||
self.gui.c.OnWorkerBackground(func(gocui.Task) error {
|
||||
_ = function(retriggered)
|
||||
done <- struct{}{}
|
||||
return nil
|
||||
@@ -220,7 +229,6 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru
|
||||
}
|
||||
}
|
||||
})
|
||||
return retrigger
|
||||
}
|
||||
|
||||
func (self *BackgroundRoutineMgr) backgroundFetch() (err error) {
|
||||
@@ -231,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:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,20 @@ func (gui *Gui) LogAction(action string) {
|
||||
return
|
||||
}
|
||||
|
||||
gui.Views.Extras.Autoscroll = true
|
||||
// LogAction and LogCommand are called both from the UI thread and from git
|
||||
// worker goroutines, so bounce the writes onto the UI thread: they touch the
|
||||
// view's autoscroll flag and the GuiLog slice, which the layout/draw code
|
||||
// reads. Ordering between successive log calls is preserved by the FIFO the
|
||||
// bounce enqueues onto. It's a background bounce because writing the command
|
||||
// log is incidental display work that must not count towards lazygit being
|
||||
// busy (otherwise it could block a repo switch).
|
||||
gui.onUIThreadBackground(func() error {
|
||||
gui.Views.Extras.Autoscroll = true
|
||||
|
||||
gui.GuiLog = append(gui.GuiLog, action)
|
||||
fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action))
|
||||
gui.GuiLog = append(gui.GuiLog, action)
|
||||
fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) LogCommand(cmdStr string, commandLine bool) {
|
||||
@@ -38,17 +48,23 @@ func (gui *Gui) LogCommand(cmdStr string, commandLine bool) {
|
||||
return
|
||||
}
|
||||
|
||||
gui.Views.Extras.Autoscroll = true
|
||||
|
||||
textStyle := theme.DefaultTextColor
|
||||
if !commandLine {
|
||||
// if we're not dealing with a direct command that could be run on the command line,
|
||||
// we style it differently to communicate that
|
||||
textStyle = style.FgMagenta
|
||||
}
|
||||
gui.GuiLog = append(gui.GuiLog, cmdStr)
|
||||
indentedCmdStr := " " + strings.ReplaceAll(cmdStr, "\n", "\n ")
|
||||
fmt.Fprint(gui.Views.Extras, "\n"+textStyle.Sprint(indentedCmdStr))
|
||||
|
||||
// See the comment in LogAction: bounce onto the UI thread since we may be
|
||||
// called from a git worker, in the background so it can't block a repo switch.
|
||||
gui.onUIThreadBackground(func() error {
|
||||
gui.Views.Extras.Autoscroll = true
|
||||
|
||||
gui.GuiLog = append(gui.GuiLog, cmdStr)
|
||||
fmt.Fprint(gui.Views.Extras, "\n"+textStyle.Sprint(indentedCmdStr))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) printCommandLogHeader() {
|
||||
|
||||
@@ -32,34 +32,76 @@ type ListRenderer struct {
|
||||
getNonModelItems func() []*NonModelItem
|
||||
|
||||
// The remaining fields are private and shouldn't be initialized by clients
|
||||
numNonModelItems int
|
||||
viewIndicesByModelIndex []int
|
||||
modelIndicesByViewIndex []int
|
||||
columnPositions []int
|
||||
columnPositions []int
|
||||
}
|
||||
|
||||
func (self *ListRenderer) GetList() types.IList {
|
||||
return self.list
|
||||
}
|
||||
|
||||
func (self *ListRenderer) ModelIndexToViewIndex(modelIndex int) int {
|
||||
modelIndex = lo.Clamp(modelIndex, 0, self.list.Len())
|
||||
if self.viewIndicesByModelIndex != nil {
|
||||
return self.viewIndicesByModelIndex[modelIndex]
|
||||
func (self *ListRenderer) getNonModelItemList() []*NonModelItem {
|
||||
if self.getNonModelItems == nil {
|
||||
return nil
|
||||
}
|
||||
return self.getNonModelItems()
|
||||
}
|
||||
|
||||
return modelIndex
|
||||
func (self *ListRenderer) ModelIndexToViewIndex(modelIndex int) int {
|
||||
return modelIndexToViewIndex(self.list.Len(), self.getNonModelItemList(), modelIndex)
|
||||
}
|
||||
|
||||
func (self *ListRenderer) ViewIndexToModelIndex(viewIndex int) int {
|
||||
viewIndex = lo.Clamp(viewIndex, 0, self.list.Len()+self.numNonModelItems)
|
||||
if self.modelIndicesByViewIndex != nil {
|
||||
return self.modelIndicesByViewIndex[viewIndex]
|
||||
}
|
||||
return viewIndexToModelIndex(self.list.Len(), self.getNonModelItemList(), viewIndex)
|
||||
}
|
||||
|
||||
// modelToViewIndexConverter returns a model-to-view index conversion that
|
||||
// reuses a single snapshot of the non-model items. Callers that convert many
|
||||
// indices in a row (e.g. search, which converts every commit) should use this
|
||||
// rather than calling ModelIndexToViewIndex per index, which would rebuild the
|
||||
// non-model items each time.
|
||||
func (self *ListRenderer) modelToViewIndexConverter() func(modelIndex int) int {
|
||||
listLength := self.list.Len()
|
||||
nonModelItems := self.getNonModelItemList()
|
||||
return func(modelIndex int) int {
|
||||
return modelIndexToViewIndex(listLength, nonModelItems, modelIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// The view shows the model items with the non-model items (e.g. section
|
||||
// headers) inserted at their model indices. The two conversions below are
|
||||
// computed directly from the current list length and non-model items, so they
|
||||
// don't depend on the list having been rendered, and they can never be stale
|
||||
// with respect to a model that changed since the last render (which used to
|
||||
// cause both wrong results and index-out-of-range panics).
|
||||
//
|
||||
// The non-model items are assumed to be ordered by their Index, which is how
|
||||
// all producers build them; the i-th one therefore ends up at view index
|
||||
// Index+i.
|
||||
func modelIndexToViewIndex(listLength int, nonModelItems []*NonModelItem, modelIndex int) int {
|
||||
modelIndex = lo.Clamp(modelIndex, 0, listLength)
|
||||
// Each non-model item inserted at or before this model item pushes it down
|
||||
// by one row in the view.
|
||||
viewIndex := modelIndex
|
||||
for _, item := range nonModelItems {
|
||||
if item.Index <= modelIndex {
|
||||
viewIndex++
|
||||
}
|
||||
}
|
||||
return viewIndex
|
||||
}
|
||||
|
||||
func viewIndexToModelIndex(listLength int, nonModelItems []*NonModelItem, viewIndex int) int {
|
||||
viewIndex = lo.Clamp(viewIndex, 0, listLength+len(nonModelItems))
|
||||
// Subtract the non-model items that appear before this view index.
|
||||
modelIndex := viewIndex
|
||||
for i, item := range nonModelItems {
|
||||
if item.Index+i < viewIndex {
|
||||
modelIndex--
|
||||
}
|
||||
}
|
||||
return modelIndex
|
||||
}
|
||||
|
||||
func (self *ListRenderer) ColumnPositions() []int {
|
||||
return self.columnPositions
|
||||
}
|
||||
@@ -71,23 +113,18 @@ func (self *ListRenderer) renderLines(startIdx int, endIdx int) string {
|
||||
if self.getColumnAlignments != nil {
|
||||
columnAlignments = self.getColumnAlignments()
|
||||
}
|
||||
nonModelItems := []*NonModelItem{}
|
||||
self.numNonModelItems = 0
|
||||
if self.getNonModelItems != nil {
|
||||
nonModelItems = self.getNonModelItems()
|
||||
self.prepareConversionArrays(nonModelItems)
|
||||
}
|
||||
nonModelItems := self.getNonModelItemList()
|
||||
startModelIdx := 0
|
||||
if startIdx == -1 {
|
||||
startIdx = 0
|
||||
} else {
|
||||
startModelIdx = self.ViewIndexToModelIndex(startIdx)
|
||||
startModelIdx = viewIndexToModelIndex(self.list.Len(), nonModelItems, startIdx)
|
||||
}
|
||||
endModelIdx := self.list.Len()
|
||||
if endIdx == -1 {
|
||||
endIdx = endModelIdx + len(nonModelItems)
|
||||
} else {
|
||||
endModelIdx = self.ViewIndexToModelIndex(endIdx)
|
||||
endModelIdx = viewIndexToModelIndex(self.list.Len(), nonModelItems, endIdx)
|
||||
}
|
||||
lines, columnPositions := utils.RenderDisplayStrings(
|
||||
self.getDisplayStrings(startModelIdx, endModelIdx),
|
||||
@@ -97,23 +134,6 @@ func (self *ListRenderer) renderLines(startIdx int, endIdx int) string {
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (self *ListRenderer) prepareConversionArrays(nonModelItems []*NonModelItem) {
|
||||
self.numNonModelItems = len(nonModelItems)
|
||||
viewIndicesByModelIndex := lo.Range(self.list.Len() + 1)
|
||||
modelIndicesByViewIndex := lo.Range(self.list.Len() + 1)
|
||||
offset := 0
|
||||
for _, item := range nonModelItems {
|
||||
for i := item.Index; i <= self.list.Len(); i++ {
|
||||
viewIndicesByModelIndex[i]++
|
||||
}
|
||||
modelIndicesByViewIndex = slices.Insert(
|
||||
modelIndicesByViewIndex, item.Index+offset, modelIndicesByViewIndex[item.Index+offset])
|
||||
offset++
|
||||
}
|
||||
self.viewIndicesByModelIndex = viewIndicesByModelIndex
|
||||
self.modelIndicesByViewIndex = modelIndicesByViewIndex
|
||||
}
|
||||
|
||||
func (self *ListRenderer) insertNonModelItems(
|
||||
nonModelItems []*NonModelItem, endIdx int, startIdx int, lines []string, columnPositions []int,
|
||||
) []string {
|
||||
|
||||
@@ -254,9 +254,6 @@ func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) {
|
||||
getNonModelItems: getNonModelItems,
|
||||
}
|
||||
|
||||
// Need to render first so that it knows the non-model items
|
||||
self.renderLines(-1, -1)
|
||||
|
||||
for i := range len(s.modelIndices) {
|
||||
assert.Equal(t, s.expectedViewIndices[i], self.ModelIndexToViewIndex(s.modelIndices[i]))
|
||||
}
|
||||
@@ -267,3 +264,27 @@ func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The index conversions must not depend on the list having been rendered
|
||||
// first. It used to be renderLines that populated the conversion arrays, so
|
||||
// converting an index before the first render silently ignored the non-model
|
||||
// items (and converting after the model changed used a stale snapshot).
|
||||
func TestListRenderer_IndexConversionsAreRenderIndependent(t *testing.T) {
|
||||
modelInts := lo.Map(lo.Range(3), func(i int, _ int) myint { return myint(i) })
|
||||
self := &ListRenderer{
|
||||
list: NewListViewModel(func() []myint { return modelInts }),
|
||||
getDisplayStrings: func(startIdx int, endIdx int) [][]string {
|
||||
return lo.Map(modelInts[startIdx:endIdx],
|
||||
func(i myint, _ int) []string { return []string{fmt.Sprint(i)} })
|
||||
},
|
||||
// A section header sits at model index 1, so model item 1 is pushed down
|
||||
// to view index 2, and view index 2 maps back to model item 1.
|
||||
getNonModelItems: func() []*NonModelItem {
|
||||
return []*NonModelItem{{Index: 1, Content: "--- header ---"}}
|
||||
},
|
||||
}
|
||||
|
||||
// Deliberately convert without rendering first.
|
||||
assert.Equal(t, 2, self.ModelIndexToViewIndex(1))
|
||||
assert.Equal(t, 1, self.ViewIndexToModelIndex(2))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
@@ -142,7 +143,9 @@ type LocalCommitsViewModel struct {
|
||||
|
||||
// If this is true we limit the amount of commits we load, for the sake of keeping things fast.
|
||||
// If the user attempts to scroll past the end of the list, we will load more commits.
|
||||
limitCommits bool
|
||||
// Atomic because a checkout or reset sets it from a worker goroutine while the
|
||||
// commits refresh reads it on the UI thread to decide how many commits to load.
|
||||
limitCommits atomic.Bool
|
||||
|
||||
// If this is true we'll use git log --all when fetching the commits.
|
||||
showWholeGitGraph bool
|
||||
@@ -151,9 +154,9 @@ type LocalCommitsViewModel struct {
|
||||
func NewLocalCommitsViewModel(getModel func() []*models.Commit, c *ContextCommon) *LocalCommitsViewModel {
|
||||
self := &LocalCommitsViewModel{
|
||||
ListViewModel: NewListViewModel(getModel),
|
||||
limitCommits: true,
|
||||
showWholeGitGraph: c.UserConfig().Git.Log.ShowWholeGraph,
|
||||
}
|
||||
self.limitCommits.Store(true)
|
||||
|
||||
return self
|
||||
}
|
||||
@@ -221,15 +224,15 @@ func (self *LocalCommitsContext) RefForAdjustingLineNumberInDiff() string {
|
||||
}
|
||||
|
||||
func (self *LocalCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
|
||||
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.ModelIndexToViewIndex, searchStr)
|
||||
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.modelToViewIndexConverter(), searchStr)
|
||||
}
|
||||
|
||||
func (self *LocalCommitsViewModel) SetLimitCommits(value bool) {
|
||||
self.limitCommits = value
|
||||
self.limitCommits.Store(value)
|
||||
}
|
||||
|
||||
func (self *LocalCommitsViewModel) GetLimitCommits() bool {
|
||||
return self.limitCommits
|
||||
return self.limitCommits.Load()
|
||||
}
|
||||
|
||||
func (self *LocalCommitsViewModel) SetShowWholeGitGraph(value bool) {
|
||||
|
||||
@@ -223,7 +223,7 @@ func (self *SubCommitsContext) RefForAdjustingLineNumberInDiff() string {
|
||||
}
|
||||
|
||||
func (self *SubCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition {
|
||||
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.ModelIndexToViewIndex, searchStr)
|
||||
return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.modelToViewIndexConverter(), searchStr)
|
||||
}
|
||||
|
||||
func (self *SubCommitsContext) IndexForGotoBottom() int {
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
@@ -274,18 +274,21 @@ func (self *BisectController) afterMark(selectCurrent bool, waitToReselect bool)
|
||||
}
|
||||
|
||||
func (self *BisectController) afterBisectMarkRefresh(selectCurrent bool, waitToReselect bool) error {
|
||||
selectFn := func() {
|
||||
selectFn := func() error {
|
||||
if selectCurrent {
|
||||
self.selectCurrentBisectCommit()
|
||||
}
|
||||
}
|
||||
|
||||
if waitToReselect {
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{}, Then: selectFn})
|
||||
return nil
|
||||
}
|
||||
|
||||
selectFn()
|
||||
if waitToReselect {
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{}, Then: selectFn})
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := selectFn(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Helpers().Bisect.PostBisectCommandRefresh()
|
||||
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
|
||||
},
|
||||
})
|
||||
@@ -599,11 +597,10 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit()
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.ASYNC,
|
||||
KeepBranchSelectionIndex: true,
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
BranchSelection: types.SelectCheckedOutBranch,
|
||||
CommitSelection: types.SelectHeadCommit,
|
||||
SelectTopReflogCommit: true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -710,9 +707,9 @@ func (self *BranchesController) fastForward(branch *models.Branch) error {
|
||||
}
|
||||
|
||||
action := self.c.Tr.Actions.FastForwardBranch
|
||||
worktree, ok := self.worktreeForBranch(branch)
|
||||
|
||||
return self.c.WithInlineStatus(branch, types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
worktree, ok := self.worktreeForBranch(branch)
|
||||
if ok {
|
||||
self.c.LogAction(action)
|
||||
|
||||
@@ -734,7 +731,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error {
|
||||
WorktreePath: worktreePath,
|
||||
},
|
||||
)
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
|
||||
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.Refresh(types.RefreshOptions{Mode: types.ASYNC, 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
|
||||
@@ -783,20 +780,24 @@ func (self *BranchesController) rename(branch *models.Branch) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// need to find where the branch is now so that we can re-select it. That means we need to refetch the branches synchronously and then find our branch
|
||||
// need to find where the branch is now so that we can re-select it. That means we need to
|
||||
// refetch the branches and then find our branch. The branches model update is bounced
|
||||
// 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.
|
||||
for i, newBranch := range self.c.Model().Branches {
|
||||
if newBranch.Name == newBranchName {
|
||||
self.context().SetSelection(i)
|
||||
self.context().HandleRender()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
// now that we've got our stuff again we need to find that branch and reselect it.
|
||||
for i, newBranch := range self.c.Model().Branches {
|
||||
if newBranch.Name == newBranchName {
|
||||
self.context().SetSelection(i)
|
||||
self.context().HandleRender()
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -337,7 +337,9 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN
|
||||
Title: self.c.Tr.DiscardFileChangesTitle,
|
||||
Prompt: prompt,
|
||||
HandleConfirm: func() error {
|
||||
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
|
||||
commits := self.c.Model().Commits
|
||||
selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error {
|
||||
var filePaths []string
|
||||
selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes)
|
||||
|
||||
@@ -356,14 +358,17 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN
|
||||
})
|
||||
}
|
||||
|
||||
err := self.c.Git().Rebase.DiscardOldFileChanges(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx(), filePaths)
|
||||
err := self.c.Git().Rebase.DiscardOldFileChanges(commits, selectedLineIdx, filePaths)
|
||||
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if self.context().RangeSelectEnabled() {
|
||||
self.context().GetList().CancelRangeSelect()
|
||||
}
|
||||
self.c.OnUIThread(func() error {
|
||||
if self.context().RangeSelectEnabled() {
|
||||
self.context().GetList().CancelRangeSelect()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
@@ -442,20 +447,16 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
|
||||
self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView)
|
||||
}
|
||||
|
||||
refName := self.context().GetRef().RefName()
|
||||
|
||||
toggle := func() error {
|
||||
return self.c.WithWaitingStatus(self.c.Tr.UpdatingPatch, func(gocui.Task) error {
|
||||
if !self.c.Git().Patch.PatchBuilder.Active() {
|
||||
if err := self.startPatchBuilder(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes)
|
||||
|
||||
// Find if any file in the selection is unselected or partially added
|
||||
adding := lo.SomeBy(selectedNodes, func(node *filetree.CommitFileNode) bool {
|
||||
return node.SomeFile(func(file *models.CommitFile) bool {
|
||||
fileStatus := self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Path, self.context().GetRef().RefName())
|
||||
fileStatus := self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Path, refName)
|
||||
return fileStatus == patch.PART || fileStatus == patch.UNSELECTED
|
||||
})
|
||||
})
|
||||
@@ -498,6 +499,12 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm
|
||||
self.c.Git().Patch.PatchBuilder.Reset()
|
||||
}
|
||||
|
||||
if !self.c.Git().Patch.PatchBuilder.Active() {
|
||||
if err := self.startPatchBuilder(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return toggle()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -132,10 +132,11 @@ func (self *CustomPatchOptionsMenuAction) returnFocusFromPatchExplorerIfNecessar
|
||||
func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error {
|
||||
self.returnFocusFromPatchExplorerIfNecessary()
|
||||
|
||||
commits := self.c.Model().Commits
|
||||
commitIndex := self.getPatchCommitIndex()
|
||||
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
|
||||
commitIndex := self.getPatchCommitIndex()
|
||||
self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit)
|
||||
err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex)
|
||||
err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex)
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
||||
})
|
||||
}
|
||||
@@ -143,10 +144,12 @@ func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error {
|
||||
func (self *CustomPatchOptionsMenuAction) handleMovePatchToSelectedCommit() error {
|
||||
self.returnFocusFromPatchExplorerIfNecessary()
|
||||
|
||||
commits := self.c.Model().Commits
|
||||
commitIndex := self.getPatchCommitIndex()
|
||||
toCommitIndex := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
|
||||
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
|
||||
commitIndex := self.getPatchCommitIndex()
|
||||
self.c.LogAction(self.c.Tr.Actions.MovePatchToSelectedCommit)
|
||||
err := self.c.Git().Patch.MovePatchToSelectedCommit(self.c.Model().Commits, commitIndex, self.c.Contexts().LocalCommits.GetSelectedLineIdx())
|
||||
err := self.c.Git().Patch.MovePatchToSelectedCommit(commits, commitIndex, toCommitIndex)
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
||||
})
|
||||
}
|
||||
@@ -159,10 +162,11 @@ func (self *CustomPatchOptionsMenuAction) handleMovePatchIntoWorkingTree() error
|
||||
Title: self.c.Tr.MustStashTitle,
|
||||
Prompt: self.c.Tr.MustStashWarning,
|
||||
HandleConfirm: func() error {
|
||||
commits := self.c.Model().Commits
|
||||
commitIndex := self.getPatchCommitIndex()
|
||||
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
|
||||
commitIndex := self.getPatchCommitIndex()
|
||||
self.c.LogAction(self.c.Tr.Actions.MovePatchIntoIndex)
|
||||
err := self.c.Git().Patch.MovePatchIntoIndex(self.c.Model().Commits, commitIndex, mustStash)
|
||||
err := self.c.Git().Patch.MovePatchIntoIndex(commits, commitIndex, mustStash)
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
||||
})
|
||||
},
|
||||
@@ -183,14 +187,18 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error {
|
||||
DescriptionTitle: self.c.Tr.CommitDescriptionTitle,
|
||||
PreserveMessage: false,
|
||||
OnConfirm: func(summary string, description string) error {
|
||||
commits := self.c.Model().Commits
|
||||
self.c.Helpers().Commits.CloseCommitMessagePanel()
|
||||
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
|
||||
self.c.Helpers().Commits.CloseCommitMessagePanel()
|
||||
self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit)
|
||||
err := self.c.Git().Patch.PullPatchIntoNewCommit(self.c.Model().Commits, commitIndex, summary, description)
|
||||
err := self.c.Git().Patch.PullPatchIntoNewCommit(commits, commitIndex, summary, description)
|
||||
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
|
||||
self.c.OnUIThread(func() error {
|
||||
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -214,14 +222,18 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() e
|
||||
DescriptionTitle: self.c.Tr.CommitDescriptionTitle,
|
||||
PreserveMessage: false,
|
||||
OnConfirm: func(summary string, description string) error {
|
||||
commits := self.c.Model().Commits
|
||||
self.c.Helpers().Commits.CloseCommitMessagePanel()
|
||||
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error {
|
||||
self.c.Helpers().Commits.CloseCommitMessagePanel()
|
||||
self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit)
|
||||
err := self.c.Git().Patch.PullPatchIntoNewCommitBefore(self.c.Model().Commits, commitIndex, summary, description)
|
||||
err := self.c.Git().Patch.PullPatchIntoNewCommitBefore(commits, commitIndex, summary, description)
|
||||
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
|
||||
self.c.OnUIThread(func() error {
|
||||
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -257,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
|
||||
},
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Select),
|
||||
Handler: self.withItems(self.press),
|
||||
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canStageSelection))),
|
||||
GetDisabledReason: self.require(self.itemsSelected(self.canStageSelection)),
|
||||
Description: self.c.Tr.Stage,
|
||||
Tooltip: self.c.Tr.StageTooltip,
|
||||
DisplayOnScreen: true,
|
||||
@@ -91,7 +91,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Edit),
|
||||
Handler: self.withItems(self.edit),
|
||||
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canEditFiles))),
|
||||
GetDisabledReason: self.require(self.itemsSelected(self.canEditFiles)),
|
||||
Description: self.c.Tr.Edit,
|
||||
Tooltip: self.c.Tr.EditFileTooltip,
|
||||
DisplayOnScreen: true,
|
||||
@@ -145,7 +145,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Remove),
|
||||
Handler: self.withItems(self.remove),
|
||||
GetDisabledReason: self.withFileTreeViewModelMutex(self.require(self.itemsSelected(self.canRemove))),
|
||||
GetDisabledReason: self.require(self.itemsSelected(self.canRemove)),
|
||||
Description: self.c.Tr.Discard,
|
||||
Tooltip: self.c.Tr.DiscardFileChangesTooltip,
|
||||
OpensMenu: true,
|
||||
@@ -182,7 +182,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
|
||||
Handler: self.withItems(self.openMergeConflictMenu),
|
||||
Description: self.c.Tr.ViewMergeConflictOptions,
|
||||
Tooltip: self.c.Tr.ViewMergeConflictOptionsTooltip,
|
||||
GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canOpenMergeConflictMenu))),
|
||||
GetDisabledReason: self.require(self.itemsSelected(self.canOpenMergeConflictMenu)),
|
||||
OpensMenu: true,
|
||||
DisplayOnScreen: true,
|
||||
},
|
||||
@@ -209,15 +209,6 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types
|
||||
}
|
||||
}
|
||||
|
||||
func (self *FilesController) withFileTreeViewModelMutex(callback func() *types.DisabledReason) func() *types.DisabledReason {
|
||||
return func() *types.DisabledReason {
|
||||
self.c.Contexts().Files.FileTreeViewModel.RWMutex.RLock()
|
||||
defer self.c.Contexts().Files.FileTreeViewModel.RWMutex.RUnlock()
|
||||
|
||||
return callback()
|
||||
}
|
||||
}
|
||||
|
||||
func (self *FilesController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding {
|
||||
return []*gocui.ViewMouseBinding{
|
||||
{
|
||||
@@ -574,11 +565,6 @@ func (self *FilesController) toggleStaged(
|
||||
}
|
||||
|
||||
func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) error {
|
||||
// Obtaining this lock because optimistic rendering requires us to mutate
|
||||
// the files in our model.
|
||||
self.c.Mutexes().RefreshingFilesMutex.Lock()
|
||||
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
|
||||
|
||||
// When filtering, expand directory nodes to individual visible file paths
|
||||
// so that only filtered files are staged/unstaged.
|
||||
toPaths := func(nodes []*filetree.FileNode) []string {
|
||||
@@ -650,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
|
||||
@@ -935,16 +921,13 @@ 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
|
||||
}
|
||||
|
||||
func (self *FilesController) toggleStagedAllWithLock() error {
|
||||
self.c.Mutexes().RefreshingFilesMutex.Lock()
|
||||
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
|
||||
|
||||
root := self.context().FileTreeViewModel.GetRoot()
|
||||
|
||||
stage := func(unstagedNodes []*filetree.FileNode) error {
|
||||
@@ -1221,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())
|
||||
}
|
||||
@@ -1757,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),
|
||||
@@ -1783,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,10 +1791,10 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error {
|
||||
}
|
||||
|
||||
func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) error {
|
||||
file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule)
|
||||
return self.c.WithWaitingStatus(self.c.Tr.ResettingSubmoduleStatus, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.ResetSubmodule)
|
||||
|
||||
file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule)
|
||||
if file != nil {
|
||||
if err := self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
|
||||
return err
|
||||
@@ -1825,7 +1808,7 @@ func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) e
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Refresh(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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -122,9 +122,10 @@ func (self *FilteringMenuAction) setFiltering() error {
|
||||
|
||||
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: helpers.ScopesToRefreshWhenFilteringModeChanges(), Then: func() {
|
||||
self.c.Refresh(types.RefreshOptions{Scope: helpers.ScopesToRefreshWhenFilteringModeChanges(), Then: func() error {
|
||||
self.c.Contexts().LocalCommits.SetSelection(0)
|
||||
self.c.Contexts().LocalCommits.HandleFocus(types.OnFocusOpts{})
|
||||
return nil
|
||||
}})
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,10 @@ func (self *AppStatusHelper) WithWaitingStatus(message string, f func(gocui.Task
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -78,16 +82,33 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,21 +121,36 @@ func (self *AppStatusHelper) GetStatusString() string {
|
||||
return appStatus
|
||||
}
|
||||
|
||||
// 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() {
|
||||
self.c.OnWorker(func(_ gocui.Task) error {
|
||||
if !self.statusMgr().ClaimRenderLoop() {
|
||||
return
|
||||
}
|
||||
|
||||
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 := self.c.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 = self.c.OnUIThread
|
||||
update = self.c.OnUIThreadBackground
|
||||
}
|
||||
update(func() error {
|
||||
self.c.Views().AppStatus.FgColor = color
|
||||
@@ -123,64 +159,12 @@ func (self *AppStatusHelper) renderAppStatus() {
|
||||
})
|
||||
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{}})
|
||||
}
|
||||
|
||||
@@ -45,8 +45,11 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}})
|
||||
self.c.OnUIThread(func() error {
|
||||
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
|
||||
return nil
|
||||
})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}})
|
||||
return nil
|
||||
})
|
||||
})
|
||||
@@ -84,9 +87,12 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteB
|
||||
if err := self.deleteRemoteBranches(remoteBranches, task); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Refresh(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.Contexts().RemoteBranches.CollapseRangeSelectionToTop()
|
||||
self.c.OnUIThread(func() error {
|
||||
self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -151,8 +157,11 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branc
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
|
||||
self.c.OnUIThread(func() error {
|
||||
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
|
||||
return nil
|
||||
})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -311,9 +320,11 @@ func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.B
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.ASYNC,
|
||||
self.c.OnUIThread(func() error {
|
||||
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
|
||||
return nil
|
||||
})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{
|
||||
Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES},
|
||||
})
|
||||
return nil
|
||||
@@ -329,9 +340,11 @@ func (self *BranchesHelper) deleteLocalAndRemoteBranchesContinuation(branches []
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.ASYNC,
|
||||
self.c.OnUIThread(func() error {
|
||||
self.c.Contexts().Branches.CollapseRangeSelectionToTop()
|
||||
return nil
|
||||
})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{
|
||||
Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.REMOTES, types.FILES},
|
||||
})
|
||||
return nil
|
||||
@@ -387,14 +400,30 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er
|
||||
if self.c.UserConfig().Git.AutoForwardBranches != "none" {
|
||||
scope = append(scope, types.WORKTREES)
|
||||
}
|
||||
self.c.Refresh(types.RefreshOptions{Scope: scope, Mode: types.SYNC, Background: background})
|
||||
if fetchErr != nil {
|
||||
return fetchErr
|
||||
}
|
||||
return self.AutoForwardBranches()
|
||||
// AutoForwardBranches reads Model.Branches, which the branches refresh writes
|
||||
// via a bounce, so it has to run in Then rather than right after Refresh
|
||||
// returns (where it would still see the previous branches).
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{
|
||||
Scope: scope,
|
||||
Background: background,
|
||||
Then: func() error {
|
||||
if fetchErr != nil {
|
||||
return nil
|
||||
}
|
||||
err := self.AutoForwardBranches(background)
|
||||
if background && err != nil {
|
||||
// The background poller discards this return value, so surface
|
||||
// the error in the log rather than as a popup for background work.
|
||||
self.c.Log.Error(err)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
},
|
||||
})
|
||||
return fetchErr
|
||||
}
|
||||
|
||||
func (self *BranchesHelper) AutoForwardBranches() error {
|
||||
func (self *BranchesHelper) AutoForwardBranches(background bool) error {
|
||||
if self.c.UserConfig().Git.AutoForwardBranches == "none" {
|
||||
return nil
|
||||
}
|
||||
@@ -426,7 +455,7 @@ func (self *BranchesHelper) AutoForwardBranches() 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})
|
||||
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.CheckMergeOrRebaseWithRefreshOptions(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)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func (self *GpgHelper) runAndStream(
|
||||
) error {
|
||||
return self.c.WithWaitingStatus(waitingStatus, func(gocui.Task) error {
|
||||
if err := cmdObj.StreamOutput().Run(); err != nil {
|
||||
self.c.Refresh(failureRefreshOptions)
|
||||
self.c.RefreshFromWorker(failureRefreshOptions)
|
||||
return fmt.Errorf(
|
||||
self.c.Tr.GitCommandFailed, self.c.UserConfig().Keybinding.Universal.ExtrasMenu,
|
||||
)
|
||||
@@ -100,7 +100,7 @@ func (self *GpgHelper) runAndStream(
|
||||
}
|
||||
}
|
||||
|
||||
self.c.Refresh(successRefreshOptions)
|
||||
self.c.RefreshFromWorker(successRefreshOptions)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,22 +138,18 @@ func (self *InlineStatusHelper) stop(opts InlineStatusOpts) {
|
||||
|
||||
self.c.State().ClearItemOperation(opts.Item)
|
||||
|
||||
// When recording a demo we need to re-render the context again here to
|
||||
// remove the inline status. In normal usage we don't want to do this
|
||||
// because in the case of pushing a branch this would first reveal the ↑3↓7
|
||||
// status from before the push for a brief moment, to be replaced by a green
|
||||
// checkmark a moment later when the async refresh is done. This looks
|
||||
// jarring, so normally we rely on the async refresh to redraw with the
|
||||
// status removed. (In some rare cases, where there's no refresh at all, we
|
||||
// need to redraw manually in the controller; see TagsController.push() for
|
||||
// an example.)
|
||||
//
|
||||
// In demos, however, we turn all async refreshes into sync ones, because
|
||||
// this looks better in demos. In this case the refresh happens while the
|
||||
// status is still set, so we need to render again after removing it.
|
||||
if self.c.InDemo() {
|
||||
self.renderContext(opts.ContextKey)
|
||||
}
|
||||
// Re-render the context to remove the inline status now that the operation
|
||||
// finished. Any refresh it triggered must be synchronous, not async: by the
|
||||
// time we get here a synchronous refresh has already updated the model and
|
||||
// queued its own re-render, and since UI-thread callbacks run in order, the
|
||||
// render we queue here runs after it and draws the up-to-date model without
|
||||
// the inline status. An async refresh might not have updated the model yet,
|
||||
// so this render could briefly show the stale, pre-operation model: when
|
||||
// pushing a branch, for example, it would flash the old ↑3↓7 ahead/behind
|
||||
// counts for a moment before the refresh replaced them with a green
|
||||
// checkmark. (Operations that don't refresh at all are fine too: there's
|
||||
// nothing stale to show, so this just drops the status.)
|
||||
self.renderContext(opts.ContextKey)
|
||||
}
|
||||
|
||||
func (self *InlineStatusHelper) renderContext(contextKey types.ContextKey) {
|
||||
|
||||
@@ -79,6 +79,22 @@ func (self *MergeAndRebaseHelper) ContinueRebase() error {
|
||||
}
|
||||
|
||||
func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error {
|
||||
// The menu/prompt/confirm handlers that reach here run on the UI thread and
|
||||
// spin up a worker (via the waiting status below) to do the actual work.
|
||||
return self.genericMergeCommandImpl(command, true, false)
|
||||
}
|
||||
|
||||
// genericMergeCommandImpl runs a merge/rebase continue/skip/abort and handles
|
||||
// the result. Continuing can be slow (it may replay many commits), so the
|
||||
// non-subprocess path runs on a worker with a waiting status.
|
||||
//
|
||||
// showWaitingStatus is false only for the recursive auto-skip in
|
||||
// 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()
|
||||
|
||||
if status.None() {
|
||||
@@ -111,24 +127,35 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error {
|
||||
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.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.ASYNC,
|
||||
self.refreshAfterMergeOrRebase(types.RefreshOptions{
|
||||
CommitSelection: commitSelectionAfterMerge(success && selectHeadCommitOnSuccess),
|
||||
})
|
||||
}, calledFromWorker)
|
||||
self.RecordWhetherMergeOrRebaseStartedInLazygit()
|
||||
return err
|
||||
}
|
||||
result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command)
|
||||
return self.CheckMergeOrRebaseWithRefreshOptions(result,
|
||||
types.RefreshOptions{
|
||||
Mode: types.ASYNC,
|
||||
CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess),
|
||||
|
||||
// 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.CheckMergeOrRebaseWithRefreshOptions(result,
|
||||
types.RefreshOptions{
|
||||
CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess),
|
||||
})
|
||||
}
|
||||
|
||||
if showWaitingStatus {
|
||||
return self.c.WithWaitingStatus(status.Title(self.c.Tr), func(gocui.Task) error {
|
||||
return runAction()
|
||||
})
|
||||
}
|
||||
return runAction()
|
||||
}
|
||||
|
||||
// commitSelectionAfterMerge maps whether a merge/rebase/pull created a new
|
||||
@@ -141,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{
|
||||
@@ -183,17 +225,20 @@ func (self *MergeAndRebaseHelper) RecordWhetherMergeOrRebaseStartedInLazygit() {
|
||||
self.c.Git().Status.WorkingTreeState().Any())
|
||||
}
|
||||
|
||||
// CheckMergeOrRebaseWithRefreshOptions handles the result of a merge/rebase
|
||||
// step and refreshes. It always runs on a worker (the WithWaitingStatus /
|
||||
// WithWaitingStatusBlockingInput / WithInlineStatus handlers).
|
||||
func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result error, refreshOptions types.RefreshOptions) error {
|
||||
self.c.Refresh(refreshOptions)
|
||||
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.genericMergeCommand(REBASE_OPTION_SKIP)
|
||||
return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, true)
|
||||
} else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") {
|
||||
return self.genericMergeCommand(REBASE_OPTION_SKIP)
|
||||
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
|
||||
@@ -201,8 +246,20 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result er
|
||||
return self.CheckForConflicts(result)
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
} else {
|
||||
self.c.Refresh(refreshOptions)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -211,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.ASYNC, CommitSelection: commitSelectionAfterMerge(result == nil)})
|
||||
types.RefreshOptions{CommitSelection: commitSelectionAfterMerge(result == nil)})
|
||||
}
|
||||
|
||||
func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error {
|
||||
@@ -265,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,
|
||||
@@ -285,33 +342,38 @@ func (self *MergeAndRebaseHelper) PromptToContinueRebase() error {
|
||||
// Need to refresh the files to be really sure if this is the case.
|
||||
// We would otherwise be relying on lazygit's auto-refresh on focus,
|
||||
// but this is not supported by all terminals or on all platforms.
|
||||
//
|
||||
// The model.Files update is bounced onto the UI thread, so we have
|
||||
// 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 {
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: self.c.Tr.Continue,
|
||||
Prompt: self.c.Tr.UnstagedFilesAfterConflictsResolved,
|
||||
HandleConfirm: func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
|
||||
if err := self.c.Git().WorkingTree.StageFiles(unstagedFiles, []string{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
|
||||
},
|
||||
})
|
||||
|
||||
unstagedFiles := GetUnstagedFilesExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
|
||||
if len(unstagedFiles) > 0 {
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: self.c.Tr.Continue,
|
||||
Prompt: self.c.Tr.UnstagedFilesAfterConflictsResolved,
|
||||
HandleConfirm: func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
|
||||
if err := self.c.Git().WorkingTree.StageFiles(unstagedFiles, []string{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.genericMergeCommand(REBASE_OPTION_CONTINUE)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DismissContinueRebasePromptIfShowing closes the "continue the rebase/merge?"
|
||||
@@ -361,8 +423,8 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
|
||||
DisabledReason: disabledReason,
|
||||
OnPress: func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.RebaseBranch)
|
||||
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
|
||||
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error {
|
||||
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
|
||||
var err error
|
||||
if baseCommit != "" {
|
||||
err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(ref, baseCommit)
|
||||
@@ -371,7 +433,9 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
|
||||
}
|
||||
err = self.CheckMergeOrRebase(err)
|
||||
if err == nil {
|
||||
return self.ResetMarkedBaseCommit()
|
||||
self.c.OnUIThread(func() error {
|
||||
return self.ResetMarkedBaseCommit()
|
||||
})
|
||||
}
|
||||
return err
|
||||
})
|
||||
@@ -387,20 +451,25 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
|
||||
OnPress: func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.RebaseBranch)
|
||||
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
|
||||
var err error
|
||||
if baseCommit != "" {
|
||||
err = self.c.Git().Rebase.EditRebaseFromBaseCommit(ref, baseCommit)
|
||||
} else {
|
||||
err = self.c.Git().Rebase.EditRebase(ref)
|
||||
}
|
||||
if err = self.CheckMergeOrRebase(err); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = self.ResetMarkedBaseCommit(); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
|
||||
return nil
|
||||
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error {
|
||||
var err error
|
||||
if baseCommit != "" {
|
||||
err = self.c.Git().Rebase.EditRebaseFromBaseCommit(ref, baseCommit)
|
||||
} else {
|
||||
err = self.c.Git().Rebase.EditRebase(ref)
|
||||
}
|
||||
if err = self.CheckMergeOrRebase(err); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.OnUIThread(func() error {
|
||||
if err := self.ResetMarkedBaseCommit(); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{})
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -412,8 +481,8 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
|
||||
Tooltip: self.c.Tr.RebaseOntoBaseBranchTooltip,
|
||||
OnPress: func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.RebaseBranch)
|
||||
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
|
||||
return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error {
|
||||
baseCommit := self.c.Modes().MarkedBaseCommit.GetHash()
|
||||
var err error
|
||||
if baseCommit != "" {
|
||||
err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(baseBranch, baseCommit)
|
||||
@@ -422,7 +491,9 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error {
|
||||
}
|
||||
err = self.CheckMergeOrRebase(err)
|
||||
if err == nil {
|
||||
return self.ResetMarkedBaseCommit()
|
||||
self.c.OnUIThread(func() error {
|
||||
return self.ResetMarkedBaseCommit()
|
||||
})
|
||||
}
|
||||
return err
|
||||
})
|
||||
@@ -564,36 +635,42 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e
|
||||
func (self *MergeAndRebaseHelper) RegularMerge(refName string, variant git_commands.MergeVariant) func() error {
|
||||
return func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.Merge)
|
||||
err := self.c.Git().Branch.Merge(refName, variant)
|
||||
return self.CheckMergeOrRebaseAndSelectHeadCommit(err)
|
||||
return self.c.WithWaitingStatus(self.c.Tr.MergingStatus, func(gocui.Task) error {
|
||||
err := self.c.Git().Branch.Merge(refName, variant)
|
||||
return self.CheckMergeOrRebaseAndSelectHeadCommit(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (self *MergeAndRebaseHelper) SquashMergeUncommitted(refName string) func() error {
|
||||
return func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.SquashMerge)
|
||||
err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH)
|
||||
return self.CheckMergeOrRebase(err)
|
||||
return self.c.WithWaitingStatus(self.c.Tr.MergingStatus, func(gocui.Task) error {
|
||||
err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH)
|
||||
return self.CheckMergeOrRebase(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranchName string) func() error {
|
||||
return func() error {
|
||||
self.c.LogAction(self.c.Tr.Actions.SquashMerge)
|
||||
err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH)
|
||||
if err = self.CheckMergeOrRebase(err); err != nil {
|
||||
return err
|
||||
}
|
||||
message := utils.ResolvePlaceholderString(self.c.UserConfig().Git.Merging.SquashMergeMessage, map[string]string{
|
||||
"selectedRef": refName,
|
||||
"currentBranch": checkedOutBranchName,
|
||||
return self.c.WithWaitingStatus(self.c.Tr.MergingStatus, func(gocui.Task) error {
|
||||
err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH)
|
||||
if err = self.CheckMergeOrRebase(err); err != nil {
|
||||
return err
|
||||
}
|
||||
message := utils.ResolvePlaceholderString(self.c.UserConfig().Git.Merging.SquashMergeMessage, map[string]string{
|
||||
"selectedRef": refName,
|
||||
"currentBranch": checkedOutBranchName,
|
||||
})
|
||||
err = self.c.Git().Commit.CommitCmdObj(message, "", false).Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
return nil
|
||||
})
|
||||
err = self.c.Git().Commit.CommitCmdObj(message, "", false).Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,26 +51,28 @@ func (self *MergeConflictsHelper) resetMergeState() {
|
||||
self.context().GetState().Reset()
|
||||
}
|
||||
|
||||
func (self *MergeConflictsHelper) EscapeMerge() 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
|
||||
self.c.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
|
||||
// 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{})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -121,20 +123,17 @@ func (self *MergeConflictsHelper) Render() {
|
||||
}
|
||||
|
||||
func (self *MergeConflictsHelper) RefreshMergeState() error {
|
||||
self.c.Contexts().MergeConflicts.GetMutex().Lock()
|
||||
defer self.c.Contexts().MergeConflicts.GetMutex().Unlock()
|
||||
|
||||
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()
|
||||
self.EscapeMerge()
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -191,7 +191,7 @@ func (self *ModeHelper) ClearFiltering() error {
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Scope: ScopesToRefreshWhenFilteringModeChanges(),
|
||||
Then: func() {
|
||||
Then: func() error {
|
||||
// Find the commit that was last selected in filtering mode, and select it again after refreshing
|
||||
if !self.c.Contexts().LocalCommits.SelectCommitByHash(selectedCommitHash) {
|
||||
// If we couldn't find it (either because no commit was selected
|
||||
@@ -202,6 +202,7 @@ func (self *ModeHelper) ClearFiltering() error {
|
||||
}
|
||||
|
||||
self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return nil
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,15 +31,6 @@ func NewRefsHelper(
|
||||
}
|
||||
}
|
||||
|
||||
func (self *RefsHelper) SelectFirstBranchAndFirstCommit() {
|
||||
self.c.Contexts().Branches.SetSelection(0)
|
||||
self.c.Contexts().ReflogCommits.SetSelection(0)
|
||||
self.c.Contexts().LocalCommits.SetSelection(0)
|
||||
self.c.Contexts().Branches.GetView().SetOriginY(0)
|
||||
self.c.Contexts().ReflogCommits.GetView().SetOriginY(0)
|
||||
self.c.Contexts().LocalCommits.GetView().SetOriginY(0)
|
||||
}
|
||||
|
||||
func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions) error {
|
||||
waitingStatus := options.WaitingStatus
|
||||
if waitingStatus == "" {
|
||||
@@ -49,8 +40,6 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions
|
||||
cmdOptions := git_commands.CheckoutOptions{Force: false, EnvVars: options.EnvVars}
|
||||
|
||||
refresh := func() {
|
||||
self.SelectFirstBranchAndFirstCommit()
|
||||
|
||||
// loading a heap of commits is slow so we limit them whenever doing a reset
|
||||
self.c.Contexts().LocalCommits.SetLimitCommits(true)
|
||||
|
||||
@@ -66,11 +55,12 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions
|
||||
if options.RefreshPullRequests {
|
||||
scope = append(scope, types.PULL_REQUESTS)
|
||||
}
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.BLOCK_UI,
|
||||
Scope: scope,
|
||||
KeepBranchSelectionIndex: true,
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{
|
||||
BatchUIUpdates: true,
|
||||
Scope: scope,
|
||||
BranchSelection: types.SelectCheckedOutBranch,
|
||||
CommitSelection: types.SelectHeadCommit,
|
||||
SelectTopReflogCommit: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -170,7 +160,6 @@ func (self *RefsHelper) CheckoutRemoteBranch(fullBranchName string, localBranchN
|
||||
// Do a sync refresh to make sure the new branch is visible,
|
||||
// so that we see an inline status when checking it out
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.SYNC,
|
||||
Scope: []types.RefreshableView{types.BRANCHES},
|
||||
})
|
||||
return checkout(localBranchName, true)
|
||||
@@ -209,12 +198,14 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Contexts().LocalCommits.SetSelection(0)
|
||||
self.c.Contexts().ReflogCommits.SetSelection(0)
|
||||
// loading a heap of commits is slow so we limit them whenever doing a reset
|
||||
self.c.Contexts().LocalCommits.SetLimitCommits(true)
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}, CommitSelection: types.KeepCommitSelectionIndex})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{
|
||||
Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS},
|
||||
CommitSelection: types.SelectHeadCommit,
|
||||
SelectTopReflogCommit: true,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -288,7 +279,9 @@ func (self *RefsHelper) CreateGitResetMenu(name string, ref string) error {
|
||||
Prompt: self.c.Tr.ResetHardConfirmation,
|
||||
HandleConfirm: func() error {
|
||||
self.c.LogAction("Reset")
|
||||
return self.ResetToRef(ref, row.strength, []string{})
|
||||
return self.c.WithWaitingStatus(self.c.Tr.ResettingStatus, func(gocui.Task) error {
|
||||
return self.ResetToRef(ref, row.strength, []string{})
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -369,16 +362,21 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest
|
||||
}
|
||||
|
||||
refresh := func() {
|
||||
if self.c.Context().Current() != self.c.Contexts().Branches {
|
||||
self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{})
|
||||
}
|
||||
|
||||
self.SelectFirstBranchAndFirstCommit()
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.BLOCK_UI,
|
||||
KeepBranchSelectionIndex: true,
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -392,34 +390,44 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest
|
||||
if newBranchName != suggestedBranchName {
|
||||
newBranchFunc = self.c.Git().Branch.NewWithoutTracking
|
||||
}
|
||||
if err := newBranchFunc(newBranchName, from); err != nil {
|
||||
if IsSwitchBranchUncommittedChangesError(err) {
|
||||
// offer to autostash changes
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: self.c.Tr.AutoStashTitle,
|
||||
Prompt: self.c.Tr.AutoStashPrompt,
|
||||
HandleConfirm: func() error {
|
||||
if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := newBranchFunc(newBranchName, from); err != nil {
|
||||
return err
|
||||
}
|
||||
err := self.c.Git().Stash.Pop(0)
|
||||
// Branch switch successful so re-render the UI even if the pop operation failed (e.g. conflict).
|
||||
refresh()
|
||||
return err
|
||||
},
|
||||
})
|
||||
|
||||
return nil
|
||||
// Creating the branch checks it out, which can take a while when
|
||||
// the ref we're branching off is distant, so do it on a worker.
|
||||
return self.c.WithWaitingStatus(self.c.Tr.CreatingBranchStatus, func(gocui.Task) error {
|
||||
if err := newBranchFunc(newBranchName, from); err != nil {
|
||||
if IsSwitchBranchUncommittedChangesError(err) {
|
||||
// offer to autostash changes
|
||||
self.c.OnUIThread(func() error {
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: self.c.Tr.AutoStashTitle,
|
||||
Prompt: self.c.Tr.AutoStashPrompt,
|
||||
HandleConfirm: func() error {
|
||||
return self.c.WithWaitingStatus(self.c.Tr.CreatingBranchStatus, func(gocui.Task) error {
|
||||
if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := newBranchFunc(newBranchName, from); err != nil {
|
||||
return err
|
||||
}
|
||||
err := self.c.Git().Stash.Pop(0)
|
||||
// Branch switch successful so re-render the UI even if the pop operation failed (e.g. conflict).
|
||||
refresh()
|
||||
return err
|
||||
})
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
refresh()
|
||||
return nil
|
||||
refresh()
|
||||
return nil
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -433,6 +441,8 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error {
|
||||
return err
|
||||
}
|
||||
|
||||
mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
|
||||
|
||||
withNewBranchNamePrompt := func(baseBranchName string, f func(string) error) error {
|
||||
prompt := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.NewBranchNameBranchOff,
|
||||
@@ -471,7 +481,9 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error {
|
||||
Title: self.c.Tr.MoveCommitsToNewBranch,
|
||||
Prompt: prompt,
|
||||
HandleConfirm: func() error {
|
||||
return withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch)
|
||||
return withNewBranchNamePrompt(currentBranch.Name, func(newBranchName string) error {
|
||||
return self.moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName, mustStash)
|
||||
})
|
||||
},
|
||||
})
|
||||
return nil
|
||||
@@ -491,27 +503,31 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error {
|
||||
{
|
||||
Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, shortBaseBranchName),
|
||||
OnPress: func() error {
|
||||
commitsToCherryPick := lo.Filter(self.c.Model().Commits, func(commit *models.Commit, _ int) bool {
|
||||
return commit.Status == models.StatusUnpushed
|
||||
})
|
||||
return withNewBranchNamePrompt(shortBaseBranchName, func(newBranchName string) error {
|
||||
return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, baseBranchRef)
|
||||
return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, baseBranchRef, commitsToCherryPick, mustStash)
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchStackedItem, currentBranch.Name),
|
||||
OnPress: func() error {
|
||||
return withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch)
|
||||
return withNewBranchNamePrompt(currentBranch.Name, func(newBranchName string) error {
|
||||
return self.moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName, mustStash)
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName string) error {
|
||||
func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName string, mustStash bool) error {
|
||||
if err := self.c.Git().Branch.NewWithoutCheckout(newBranchName, "HEAD"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
|
||||
if mustStash {
|
||||
if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil {
|
||||
return err
|
||||
@@ -532,22 +548,16 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa
|
||||
}
|
||||
}
|
||||
|
||||
self.SelectFirstBranchAndFirstCommit()
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.BLOCK_UI,
|
||||
KeepBranchSelectionIndex: true,
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{
|
||||
BatchUIUpdates: true,
|
||||
BranchSelection: types.SelectCheckedOutBranch,
|
||||
CommitSelection: types.SelectHeadCommit,
|
||||
SelectTopReflogCommit: true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName string, baseBranchRef string) error {
|
||||
commitsToCherryPick := lo.Filter(self.c.Model().Commits, func(commit *models.Commit, _ int) bool {
|
||||
return commit.Status == models.StatusUnpushed
|
||||
})
|
||||
|
||||
mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
|
||||
func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName string, baseBranchRef string, commitsToCherryPick []*models.Commit, mustStash bool) error {
|
||||
if mustStash {
|
||||
if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil {
|
||||
return err
|
||||
@@ -563,7 +573,7 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri
|
||||
}
|
||||
|
||||
err := self.c.Git().Rebase.CherryPickCommits(commitsToCherryPick)
|
||||
err = self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(err, types.RefreshOptions{Mode: types.SYNC})
|
||||
err = self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(err, types.RefreshOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -574,12 +584,11 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri
|
||||
}
|
||||
}
|
||||
|
||||
self.SelectFirstBranchAndFirstCommit()
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Mode: types.BLOCK_UI,
|
||||
KeepBranchSelectionIndex: true,
|
||||
CommitSelection: types.KeepCommitSelectionIndex,
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{
|
||||
BatchUIUpdates: true,
|
||||
BranchSelection: types.SelectCheckedOutBranch,
|
||||
CommitSelection: types.SelectHeadCommit,
|
||||
SelectTopReflogCommit: true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/direnv"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/env"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
@@ -44,13 +43,20 @@ func NewRecentReposHelper(
|
||||
}
|
||||
|
||||
func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error {
|
||||
// Check before pushing onto the repo-path stack, so a refused switch
|
||||
// doesn't leave a stale entry there (which escape would later switch back
|
||||
// to, needlessly reloading the current repo).
|
||||
if self.switchRefusedBecauseBusy() {
|
||||
return nil
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.State().GetRepoPathStack().Push(wd)
|
||||
|
||||
return self.DispatchSwitchToRepo(submodule.FullPath(), context.NO_CONTEXT)
|
||||
return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
|
||||
}
|
||||
|
||||
func (self *ReposHelper) getCurrentBranch(path string) string {
|
||||
@@ -130,10 +136,16 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
|
||||
style.FgMagenta.Sprint(path),
|
||||
},
|
||||
OnPress: func() error {
|
||||
// Check before clearing the stack, so a refused switch doesn't
|
||||
// forget the submodule breadcrumb (which would leave escape
|
||||
// unable to return to the parent repo).
|
||||
if self.switchRefusedBecauseBusy() {
|
||||
return nil
|
||||
}
|
||||
// if we were in a submodule, we want to forget about that stack of repos
|
||||
// so that hitting escape in the new repo does nothing
|
||||
self.c.State().GetRepoPathStack().Clear()
|
||||
return self.DispatchSwitchToRepo(path, context.NO_CONTEXT)
|
||||
return self.switchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -141,59 +153,89 @@ func (self *ReposHelper) CreateRecentReposMenu() error {
|
||||
return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems})
|
||||
}
|
||||
|
||||
func (self *ReposHelper) DispatchSwitchToRepo(path string, contextKey types.ContextKey) error {
|
||||
return self.DispatchSwitchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, contextKey)
|
||||
// SwitchToParentRepo switches back to the repo the current submodule was
|
||||
// entered from (the top of the repo-path stack). Like the other callers that do
|
||||
// work before switching, it checks for an in-flight operation *before* popping
|
||||
// the stack, so a refused switch leaves the stack intact — otherwise the entry
|
||||
// would be consumed and escape would no longer return to the parent once the
|
||||
// operation finished. The caller must only call this when the stack is
|
||||
// non-empty.
|
||||
func (self *ReposHelper) SwitchToParentRepo() error {
|
||||
if self.switchRefusedBecauseBusy() {
|
||||
return nil
|
||||
}
|
||||
return self.switchTo(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT)
|
||||
}
|
||||
|
||||
func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error {
|
||||
return self.c.WithWaitingStatus(self.c.Tr.Switching, func(gocui.Task) error {
|
||||
env.UnsetGitLocationEnvVars()
|
||||
originalPath, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil
|
||||
if self.switchRefusedBecauseBusy() {
|
||||
return nil
|
||||
}
|
||||
return self.switchTo(path, errMsg, contextKey)
|
||||
}
|
||||
|
||||
// switchRefusedBecauseBusy reports (and shows a toast) whether a repo switch
|
||||
// must be refused because a foreground git operation is in flight. Switching
|
||||
// reassigns gui.git and the process cwd, so switching mid-operation would run
|
||||
// the operation's remaining git commands against the wrong repo. Callers that
|
||||
// do work before the switch (creating a worktree, recording the repo-path
|
||||
// stack) check this up front, so they don't do that work only to have the
|
||||
// switch refused; the switch itself (switchTo) is then unguarded.
|
||||
func (self *ReposHelper) switchRefusedBecauseBusy() bool {
|
||||
if self.c.GocuiGui().Busy() {
|
||||
self.c.ErrorToast(self.c.Tr.CantSwitchWhileOperationInProgress)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// switchTo switches lazygit to the repository (or worktree) at the given path.
|
||||
// It runs synchronously on the UI thread: the switch swaps gui.State (in
|
||||
// resetState) and reassigns gui.git and the process cwd, all of which the UI
|
||||
// thread also reads, so doing it here rather than on a worker avoids racing
|
||||
// those reads. The heavy data loading is still dispatched asynchronously by the
|
||||
// refresh that onNewRepo kicks off.
|
||||
func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.ContextKey) error {
|
||||
env.UnsetGitLocationEnvVars()
|
||||
originalPath, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path})
|
||||
self.c.LogCommand(msg, false)
|
||||
|
||||
if err := os.Chdir(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errors.New(errMsg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path})
|
||||
self.c.LogCommand(msg, false)
|
||||
|
||||
if err := os.Chdir(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return errors.New(errMsg)
|
||||
}
|
||||
if err := commands.VerifyInGitRepo(self.c.OS()); err != nil {
|
||||
if err := os.Chdir(originalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := commands.VerifyInGitRepo(self.c.OS()); err != nil {
|
||||
if err := os.Chdir(originalPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd))
|
||||
|
||||
direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd))
|
||||
if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil {
|
||||
self.c.Log.Errorf("error recording current directory: %v", err)
|
||||
}
|
||||
|
||||
if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil {
|
||||
self.c.Log.Errorf("error recording current directory: %v", err)
|
||||
}
|
||||
if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Mutexes().RefreshingFilesMutex.Lock()
|
||||
defer self.c.Mutexes().RefreshingFilesMutex.Unlock()
|
||||
if direnvResult.Blocked {
|
||||
self.promptDirenvApproval(direnvResult.EnvrcPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if direnvResult.Blocked {
|
||||
self.c.OnUIThread(func() error {
|
||||
self.promptDirenvApproval(direnvResult.EnvrcPath)
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
return direnvResult.Err
|
||||
})
|
||||
return direnvResult.Err
|
||||
}
|
||||
|
||||
// logDirenvResult writes whatever direnv emitted to the command log and the
|
||||
|
||||
@@ -49,7 +49,7 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error {
|
||||
return err
|
||||
}
|
||||
|
||||
self.setSubCommits(commits)
|
||||
self.c.Model().SubCommits = commits
|
||||
self.refreshHelper.RefreshAuthors(commits)
|
||||
|
||||
subCommitsContext := self.c.Contexts().SubCommits
|
||||
@@ -71,10 +71,3 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error {
|
||||
self.c.Context().Push(self.c.Contexts().SubCommits, types.OnFocusOpts{})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *SubCommitsHelper) setSubCommits(commits []*models.Commit) {
|
||||
self.c.Mutexes().SubCommitsMutex.Lock()
|
||||
defer self.c.Mutexes().SubCommitsMutex.Unlock()
|
||||
|
||||
self.c.Model().SubCommits = commits
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -138,17 +145,20 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type
|
||||
}
|
||||
|
||||
// cache the trie for future use
|
||||
self.c.Model().FilesTrie = trie
|
||||
|
||||
self.c.Contexts().Suggestions.RefreshSuggestions()
|
||||
self.filesTrie.Store(trie)
|
||||
self.c.OnUIThread(func() error {
|
||||
self.c.Contexts().Suggestions.RefreshSuggestions()
|
||||
return nil
|
||||
})
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
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
|
||||
})
|
||||
@@ -157,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
|
||||
|
||||
@@ -149,7 +149,12 @@ func (self *WorkingTreeHelper) handleCommit(summary string, description string,
|
||||
self.c.LogAction(self.c.Tr.Actions.Commit)
|
||||
return self.gpgHelper.WithGpgHandlingAndSelectHeadCommit(cmdObj, git_commands.CommitGpgSign, self.c.Tr.CommittingStatus,
|
||||
func() error {
|
||||
self.commitsHelper.ClearPreservedCommitMessage()
|
||||
// This runs on a worker when the commit output is streamed, so
|
||||
// bounce the preserved-message write to the UI thread.
|
||||
self.c.OnUIThread(func() error {
|
||||
self.commitsHelper.ClearPreservedCommitMessage()
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -222,15 +227,23 @@ func (self *WorkingTreeHelper) HandleCommitPress() error {
|
||||
}
|
||||
|
||||
func (self *WorkingTreeHelper) WithEnsureCommittableFiles(handler func() error) error {
|
||||
if err := self.prepareFilesForCommit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(self.c.Model().Files) == 0 {
|
||||
return errors.New(self.c.Tr.NoFilesStagedTitle)
|
||||
}
|
||||
|
||||
if !self.AnyStagedFiles() {
|
||||
if self.c.UserConfig().Gui.SkipNoStagedFilesWarning {
|
||||
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
|
||||
if err := self.c.Git().WorkingTree.StageAll(false); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Scope: []types.RefreshableView{types.FILES},
|
||||
Then: handler,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.promptToStageAllAndRetry(handler)
|
||||
}
|
||||
|
||||
@@ -246,7 +259,7 @@ func (self *WorkingTreeHelper) promptToStageAllAndRetry(retry func() error) erro
|
||||
if err := self.c.Git().WorkingTree.StageAll(false); err != nil {
|
||||
return err
|
||||
}
|
||||
self.syncRefresh()
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}})
|
||||
|
||||
return retry()
|
||||
},
|
||||
@@ -255,26 +268,6 @@ func (self *WorkingTreeHelper) promptToStageAllAndRetry(retry func() error) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// for when you need to refetch files before continuing an action. Runs synchronously.
|
||||
func (self *WorkingTreeHelper) syncRefresh() {
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}})
|
||||
}
|
||||
|
||||
func (self *WorkingTreeHelper) prepareFilesForCommit() error {
|
||||
noStagedFiles := !self.AnyStagedFiles()
|
||||
if noStagedFiles && self.c.UserConfig().Gui.SkipNoStagedFilesWarning {
|
||||
self.c.LogAction(self.c.Tr.Actions.StageAllFiles)
|
||||
err := self.c.Git().WorkingTree.StageAll(false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.syncRefresh()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *WorkingTreeHelper) commitPrefixConfigsForRepo() []config.CommitPrefixConfig {
|
||||
cfg, ok := self.c.UserConfig().Git.CommitPrefixes[self.c.Git().RepoPaths.RepoName()]
|
||||
if ok {
|
||||
@@ -366,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.Refresh(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.Refresh(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
|
||||
})
|
||||
}
|
||||
@@ -426,12 +426,27 @@ func (self *WorktreeHelper) promptForWorktreeLocation(dirName string, prompt str
|
||||
}
|
||||
|
||||
func (self *WorktreeHelper) createWorktree(opts git_commands.NewWorktreeOpts, contextKey types.ContextKey) error {
|
||||
// Check now, before we create the worktree, rather than when we come to
|
||||
// switch to it afterwards: by then this operation's own waiting-status
|
||||
// spinner would make Busy() true and refuse our own switch.
|
||||
if self.reposHelper.switchRefusedBecauseBusy() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.c.WithWaitingStatus(self.c.Tr.AddingWorktree, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.AddWorktree)
|
||||
if err := self.c.Git().Worktree.New(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey)
|
||||
// The switch swaps gui.State and must run on the UI thread, but
|
||||
// we're on a worker here (creating the worktree is git work), so
|
||||
// dispatch it. It's unguarded (switchTo, not DispatchSwitchTo)
|
||||
// because we checked above and creating the worktree is now
|
||||
// complete, so switching to it is safe.
|
||||
self.c.OnUIThread(func() error {
|
||||
return self.reposHelper.switchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey)
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -340,9 +340,11 @@ func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit,
|
||||
Title: self.c.Tr.Squash,
|
||||
Prompt: self.c.Tr.SureSquashThisCommit,
|
||||
HandleConfirm: func() error {
|
||||
return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func(gocui.Task) error {
|
||||
commits := self.c.Model().Commits
|
||||
self.selectRebaseResultCommit(startIdx)
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.SquashCommitDown)
|
||||
return self.interactiveRebase(todo.Squash, startIdx, endIdx)
|
||||
return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -362,9 +364,11 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star
|
||||
Label: self.c.Tr.Fixup,
|
||||
Keys: menuKey('f'),
|
||||
OnPress: func() error {
|
||||
return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error {
|
||||
commits := self.c.Model().Commits
|
||||
self.selectRebaseResultCommit(startIdx)
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.FixupCommit)
|
||||
return self.interactiveRebase(todo.Fixup, startIdx, endIdx)
|
||||
return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx)
|
||||
})
|
||||
},
|
||||
Tooltip: self.c.Tr.FixupTooltip,
|
||||
@@ -373,9 +377,11 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star
|
||||
Label: self.c.Tr.FixupKeepMessage,
|
||||
Keys: menuKey('c'),
|
||||
OnPress: func() error {
|
||||
return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error {
|
||||
commits := self.c.Model().Commits
|
||||
self.selectRebaseResultCommit(startIdx)
|
||||
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage)
|
||||
return self.interactiveRebaseWithFlag(todo.Fixup, startIdx, endIdx, "-C")
|
||||
return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C")
|
||||
})
|
||||
},
|
||||
Tooltip: self.c.Tr.FixupKeepMessageTooltip,
|
||||
@@ -470,24 +476,26 @@ func (self *LocalCommitsController) switchFromCommitMessagePanelToEditor(filepat
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
|
||||
self.c.Refresh(types.RefreshOptions{})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) handleReword(summary string, description string) error {
|
||||
if models.IsHeadCommit(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx()) {
|
||||
commits := self.c.Model().Commits
|
||||
selectedIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
|
||||
if models.IsHeadCommit(commits, selectedIdx) {
|
||||
// we've selected the top commit so no rebase is required
|
||||
return self.c.Helpers().GPG.WithGpgHandling(self.c.Git().Commit.RewordLastCommit(summary, description),
|
||||
git_commands.CommitGpgSign,
|
||||
self.c.Tr.RewordingStatus, nil, nil)
|
||||
}
|
||||
|
||||
return self.c.WithWaitingStatus(self.c.Tr.RewordingStatus, func(gocui.Task) error {
|
||||
err := self.c.Git().Rebase.RewordCommit(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx(), summary, description)
|
||||
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.Refresh(types.RefreshOptions{Mode: types.ASYNC})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -564,12 +572,16 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start
|
||||
Title: self.c.Tr.DropCommitTitle,
|
||||
Prompt: lo.Ternary(isMerge, self.c.Tr.DropMergeCommitPrompt, self.c.Tr.DropCommitPrompt),
|
||||
HandleConfirm: func() error {
|
||||
return self.c.WithWaitingStatus(self.c.Tr.DroppingStatus, func(gocui.Task) error {
|
||||
commits := self.c.Model().Commits
|
||||
if !isMerge {
|
||||
self.selectRebaseResultCommit(startIdx)
|
||||
}
|
||||
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(startIdx)
|
||||
return self.dropMergeCommit(commits, startIdx)
|
||||
}
|
||||
return self.interactiveRebase(todo.Drop, startIdx, endIdx)
|
||||
return self.interactiveRebase(commits, todo.Drop, startIdx, endIdx)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -577,8 +589,8 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) dropMergeCommit(commitIdx int) error {
|
||||
err := self.c.Git().Rebase.DropMergeCommit(self.c.Model().Commits, commitIdx)
|
||||
func (self *LocalCommitsController) dropMergeCommit(commits []*models.Commit, commitIdx int) error {
|
||||
err := self.c.Git().Rebase.DropMergeCommit(commits, commitIdx)
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
||||
}
|
||||
|
||||
@@ -589,9 +601,11 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start
|
||||
|
||||
commits := self.c.Model().Commits
|
||||
if !commits[endIdx].IsMerge() {
|
||||
err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "")
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
||||
err, types.RefreshOptions{Mode: types.BLOCK_UI})
|
||||
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{BatchUIUpdates: true})
|
||||
})
|
||||
}
|
||||
|
||||
return self.startInteractiveRebaseWithEdit(selectedCommits)
|
||||
@@ -609,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() {
|
||||
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
|
||||
@@ -623,11 +637,9 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
|
||||
}
|
||||
}
|
||||
if len(todos) > 0 {
|
||||
err := self.updateTodos(todo.Edit, todos)
|
||||
if err != nil {
|
||||
self.c.Log.Errorf("error when updating todos: %v", err)
|
||||
}
|
||||
return self.updateTodos(todo.Edit, todos)
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
})
|
||||
}
|
||||
@@ -656,22 +668,25 @@ func (self *LocalCommitsController) pick(selectedCommits []*models.Commit) error
|
||||
panic("should be disabled when not rebasing")
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) interactiveRebase(action todo.TodoCommand, startIdx int, endIdx int) error {
|
||||
return self.interactiveRebaseWithFlag(action, startIdx, endIdx, "")
|
||||
func (self *LocalCommitsController) interactiveRebase(commits []*models.Commit, action todo.TodoCommand, startIdx int, endIdx int) error {
|
||||
return self.interactiveRebaseWithFlag(commits, action, startIdx, endIdx, "")
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) interactiveRebaseWithFlag(action todo.TodoCommand, startIdx int, endIdx int, flag string) error {
|
||||
// When performing an action that will remove the selected commits, we need to select the
|
||||
// next commit down (which will end up at the start index after the action is performed)
|
||||
if action == todo.Drop || action == todo.Fixup || action == todo.Squash {
|
||||
self.context().SetSelection(startIdx)
|
||||
}
|
||||
|
||||
err := self.c.Git().Rebase.InteractiveRebase(self.c.Model().Commits, startIdx, endIdx, action, flag)
|
||||
func (self *LocalCommitsController) interactiveRebaseWithFlag(commits []*models.Commit, action todo.TodoCommand, startIdx int, endIdx int, flag string) error {
|
||||
err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, action, flag)
|
||||
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
||||
}
|
||||
|
||||
// selectRebaseResultCommit selects the commit that a drop/fixup/squash starting
|
||||
// at startIdx will leave there. It must run on the UI thread before the rebase:
|
||||
// the commit currently at startIdx is removed, so the refresh's
|
||||
// keep-selection-by-hash can't restore it and falls back to the index, which by
|
||||
// then holds the commit that shifted up into its place.
|
||||
func (self *LocalCommitsController) selectRebaseResultCommit(startIdx int) {
|
||||
self.context().SetSelection(startIdx)
|
||||
}
|
||||
|
||||
// updateTodos sees if the selected commit is in fact a rebasing
|
||||
// commit meaning you are trying to edit the todo file rather than actually
|
||||
// begin a rebase. It then updates the todo file with that action
|
||||
@@ -685,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
|
||||
@@ -727,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})
|
||||
}
|
||||
err := self.c.Git().Rebase.MoveCommitsDown(commits, startIdx, endIdx)
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
||||
err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex})
|
||||
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
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -755,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})
|
||||
}
|
||||
err := self.c.Git().Rebase.MoveCommitsUp(commits, startIdx, endIdx)
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
||||
err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex})
|
||||
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
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -783,16 +814,18 @@ 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
|
||||
})
|
||||
}
|
||||
} else {
|
||||
commits := self.c.Model().Commits
|
||||
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(self.c.Model().Commits, self.context().GetView().SelectedLineIdx())
|
||||
err := self.c.Git().Rebase.AmendTo(commits, selectedIdx)
|
||||
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
||||
})
|
||||
})
|
||||
@@ -820,26 +853,30 @@ func (self *LocalCommitsController) canAmend(_ *models.Commit) *types.DisabledRe
|
||||
return self.canAmendRange(self.c.Model().Commits, idx, idx)
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) amendAttribute(commits []*models.Commit, start, end int) error {
|
||||
func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, end int) error {
|
||||
// The author operations index into the full commit list by absolute
|
||||
// start/end, so capture that here on the UI thread rather than reading
|
||||
// Model().Commits from the worker the menu items dispatch to.
|
||||
commits := self.c.Model().Commits
|
||||
opts := self.c.KeybindingsOpts()
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: "Amend commit attribute",
|
||||
Items: []*types.MenuItem{
|
||||
{
|
||||
Label: self.c.Tr.ResetAuthor,
|
||||
OnPress: func() error { return self.resetAuthor(start, end) },
|
||||
OnPress: func() error { return self.resetAuthor(commits, start, end) },
|
||||
Keys: opts.GetKeys(opts.Config.AmendAttribute.ResetAuthor),
|
||||
Tooltip: self.c.Tr.ResetAuthorTooltip,
|
||||
},
|
||||
{
|
||||
Label: self.c.Tr.SetAuthor,
|
||||
OnPress: func() error { return self.setAuthor(start, end) },
|
||||
OnPress: func() error { return self.setAuthor(commits, start, end) },
|
||||
Keys: opts.GetKeys(opts.Config.AmendAttribute.SetAuthor),
|
||||
Tooltip: self.c.Tr.SetAuthorTooltip,
|
||||
},
|
||||
{
|
||||
Label: self.c.Tr.AddCoAuthor,
|
||||
OnPress: func() error { return self.addCoAuthor(start, end) },
|
||||
OnPress: func() error { return self.addCoAuthor(commits, start, end) },
|
||||
Keys: opts.GetKeys(opts.Config.AmendAttribute.AddCoAuthor),
|
||||
Tooltip: self.c.Tr.AddCoAuthorTooltip,
|
||||
},
|
||||
@@ -847,30 +884,30 @@ func (self *LocalCommitsController) amendAttribute(commits []*models.Commit, sta
|
||||
})
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) resetAuthor(start, end int) error {
|
||||
return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error {
|
||||
func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) 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(self.c.Model().Commits, start, end); err != nil {
|
||||
if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) setAuthor(start, end int) error {
|
||||
func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, end int) error {
|
||||
self.c.Prompt(types.PromptOpts{
|
||||
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(self.c.Model().Commits, start, end, value); err != nil {
|
||||
if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -879,17 +916,17 @@ func (self *LocalCommitsController) setAuthor(start, end int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *LocalCommitsController) addCoAuthor(start, end int) error {
|
||||
func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start, end int) error {
|
||||
self.c.Prompt(types.PromptOpts{
|
||||
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(self.c.Model().Commits, start, end, value); err != nil {
|
||||
if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -917,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
|
||||
@@ -927,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.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.SYNC}); err != nil {
|
||||
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(result,
|
||||
types.RefreshOptions{BatchUIUpdates: true}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -935,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},
|
||||
})
|
||||
}
|
||||
@@ -966,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
|
||||
})
|
||||
})
|
||||
@@ -1004,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.
|
||||
@@ -1032,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
|
||||
@@ -1045,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 {
|
||||
@@ -1066,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
|
||||
})
|
||||
},
|
||||
@@ -1123,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.CheckMergeOrRebaseWithRefreshOptions(
|
||||
err, types.RefreshOptions{Mode: types.SYNC})
|
||||
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
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1175,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())
|
||||
@@ -1196,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
|
||||
})
|
||||
@@ -1250,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},
|
||||
},
|
||||
)
|
||||
@@ -1295,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()
|
||||
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{Mode: types.SYNC})
|
||||
err, types.RefreshOptions{})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package controllers
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
)
|
||||
|
||||
@@ -81,9 +80,8 @@ func (self *QuitActions) Escape() error {
|
||||
}
|
||||
}
|
||||
|
||||
repoPathStack := self.c.State().GetRepoPathStack()
|
||||
if !repoPathStack.IsEmpty() {
|
||||
return self.c.Helpers().Repos.DispatchSwitchToRepo(repoPathStack.Pop(), context.NO_CONTEXT)
|
||||
if !self.c.State().GetRepoPathStack().IsEmpty() {
|
||||
return self.c.Helpers().Repos.SwitchToParentRepo()
|
||||
}
|
||||
|
||||
if self.c.UserConfig().QuitOnTopLevelReturn {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -156,24 +156,27 @@ func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl
|
||||
return err
|
||||
}
|
||||
|
||||
// Do a sync refresh of the remotes so that we can select
|
||||
// the new one. Loading remotes is not expensive, so we can
|
||||
// afford it.
|
||||
// Refresh the remotes so that we can select the new one. The remotes model
|
||||
// update is bounced onto the UI thread, so the selection (which reads
|
||||
// Model.Remotes) has to run in Then; reading it inline here would see the
|
||||
// previous model. Loading remotes is not expensive, so a sync refresh is
|
||||
// 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 {
|
||||
if remote.Name == remoteName {
|
||||
self.c.Contexts().Remotes.SetSelection(idx)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the remote
|
||||
return self.fetchAndCheckout(self.c.Contexts().Remotes.GetSelected(), branchToCheckout)
|
||||
},
|
||||
})
|
||||
|
||||
// Select the remote
|
||||
for idx, remote := range self.c.Model().Remotes {
|
||||
if remote.Name == remoteName {
|
||||
self.c.Contexts().Remotes.SetSelection(idx)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the remote
|
||||
return self.fetchAndCheckout(self.c.Contexts().Remotes.GetSelected(), branchToCheckout)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensures the fork remote exists (matching the given URL).
|
||||
@@ -367,18 +370,26 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam
|
||||
}
|
||||
refreshOptions := types.RefreshOptions{
|
||||
Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES},
|
||||
Mode: types.ASYNC,
|
||||
}
|
||||
if branchName != "" {
|
||||
err = self.c.Git().Branch.New(branchName, remote.Name+"/"+branchName)
|
||||
if err == nil {
|
||||
self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{})
|
||||
self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit()
|
||||
refreshOptions.KeepBranchSelectionIndex = true
|
||||
refreshOptions.CommitSelection = types.KeepCommitSelectionIndex
|
||||
// Branch.New checks the new branch out, so HEAD moves: refresh the
|
||||
// reflog (and, via scope expansion, the commits) as well, and select
|
||||
// the newly checked-out branch and its head commit.
|
||||
refreshOptions.Scope = append(refreshOptions.Scope, types.REFLOG)
|
||||
refreshOptions.BranchSelection = types.SelectCheckedOutBranch
|
||||
refreshOptions.CommitSelection = types.SelectHeadCommit
|
||||
refreshOptions.SelectTopReflogCommit = true
|
||||
// Focus the branches panel on the UI thread once the refresh has
|
||||
// selected the newly checked-out branch.
|
||||
refreshOptions.Then = func() error {
|
||||
self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
self.c.Refresh(refreshOptions)
|
||||
self.c.RefreshFromWorker(refreshOptions)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ func (self *SubmodulesController) add() error {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -193,7 +193,7 @@ func (self *SubmodulesController) editURL(submodule *models.SubmoduleConfig) err
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -210,7 +210,7 @@ func (self *SubmodulesController) init(submodule *models.SubmoduleConfig) error
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -229,7 +229,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -244,7 +244,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -259,7 +259,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -274,7 +274,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
@@ -292,7 +292,7 @@ func (self *SubmodulesController) update(submodule *models.SubmoduleConfig) erro
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,18 +90,19 @@ func (self *SwitchToDiffFilesController) enter() error {
|
||||
|
||||
self.c.Refresh(types.RefreshOptions{
|
||||
Scope: []types.RefreshableView{types.COMMIT_FILES},
|
||||
Then: func() error {
|
||||
if filterPath := self.c.Modes().Filtering.GetPath(); filterPath != "" {
|
||||
path, err := filepath.Rel(self.c.Git().RepoPaths.RepoPath(), filterPath)
|
||||
if err != nil {
|
||||
path = filterPath
|
||||
}
|
||||
commitFilesContext.CommitFileTreeViewModel.SelectPath(
|
||||
filepath.ToSlash(path), self.c.UserConfig().Gui.ShowRootItemInFileTree)
|
||||
}
|
||||
self.c.Context().Push(commitFilesContext, types.OnFocusOpts{})
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
if filterPath := self.c.Modes().Filtering.GetPath(); filterPath != "" {
|
||||
path, err := filepath.Rel(self.c.Git().RepoPaths.RepoPath(), filterPath)
|
||||
if err != nil {
|
||||
path = filterPath
|
||||
}
|
||||
commitFilesContext.CommitFileTreeViewModel.SelectPath(
|
||||
filepath.ToSlash(path), self.c.UserConfig().Gui.ShowRootItemInFileTree)
|
||||
}
|
||||
|
||||
self.c.Context().Push(commitFilesContext, types.OnFocusOpts{})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts)
|
||||
}
|
||||
return err
|
||||
}
|
||||
self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC})
|
||||
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.Refresh(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.Refresh(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 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.Refresh(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 nil
|
||||
})
|
||||
},
|
||||
@@ -332,15 +332,7 @@ func (self *TagsController) push(tag *models.Tag) error {
|
||||
HandleConfirm: func(response string) error {
|
||||
return self.c.WithInlineStatus(tag, types.ItemOperationPushing, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.PushTag)
|
||||
err := self.c.Git().Tag.Push(task, response, tag.Name)
|
||||
|
||||
// Render again to remove the inline status:
|
||||
self.c.OnUIThread(func() error {
|
||||
self.c.Contexts().Tags.HandleRender()
|
||||
return nil
|
||||
})
|
||||
|
||||
return err
|
||||
return self.c.Git().Tag.Push(task, response, tag.Name)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -271,7 +271,7 @@ func (self *UndoController) hardResetWithAutoStash(commitHash string, options ha
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Refresh(types.RefreshOptions{})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,12 +66,8 @@ func (self *VerticalScrollController) HandleScrollUp() error {
|
||||
}
|
||||
|
||||
func (self *VerticalScrollController) HandleScrollDown() error {
|
||||
scrollHeight := self.c.UserConfig().Gui.ScrollHeight
|
||||
self.context.GetViewTrait().ScrollDown(scrollHeight)
|
||||
|
||||
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
|
||||
manager.ReadLines(scrollHeight)
|
||||
}
|
||||
self.context.GetViewTrait().ScrollDown(self.c.UserConfig().Gui.ScrollHeight)
|
||||
self.c.ReadLinesToFillView(self.context.GetView())
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -50,17 +50,12 @@ func (self *ViewSelectionController) GetMouseKeybindings(opts types.KeybindingsO
|
||||
}
|
||||
|
||||
func (self *ViewSelectionController) handleLineChange(delta int) {
|
||||
if delta > 0 {
|
||||
if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil {
|
||||
manager.ReadLines(delta)
|
||||
}
|
||||
}
|
||||
|
||||
v := self.Context().GetView()
|
||||
if delta < 0 {
|
||||
v.ScrollUp(-delta)
|
||||
} else {
|
||||
v.ScrollDown(delta)
|
||||
self.c.ReadLinesToFillView(v)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
+5
-2
@@ -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) }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package filetree
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
@@ -26,7 +25,6 @@ type ICommitFileTreeViewModel interface {
|
||||
}
|
||||
|
||||
type CommitFileTreeViewModel struct {
|
||||
sync.RWMutex
|
||||
types.IListCursor
|
||||
ICommitFileTree
|
||||
|
||||
@@ -144,6 +142,22 @@ func (self *CommitFileTreeViewModel) GetSelectedPath() string {
|
||||
return node.GetPath()
|
||||
}
|
||||
|
||||
// SetTree rebuilds the tree and clamps the selection so it stays in range. The
|
||||
// embedded tree's SetTree only rebuilds the node list and doesn't touch the
|
||||
// cursor, so after a shrinking rebuild (e.g. moving a patch out into the index)
|
||||
// the selection index could be left past the end of the tree; GetSelectedItems
|
||||
// would then return a nil node and crash callers such as canEditFiles when the
|
||||
// options map is rendered during layout.
|
||||
//
|
||||
// Unlike FileTreeViewModel.SetTree we don't re-find the selected node by path
|
||||
// afterwards: that walk lands on the containing directory when a file is removed
|
||||
// from a dir that then collapses, whereas keeping the (clamped) index lands on
|
||||
// the sibling file, which is what we want here.
|
||||
func (self *CommitFileTreeViewModel) SetTree() {
|
||||
self.ICommitFileTree.SetTree()
|
||||
self.ClampSelection()
|
||||
}
|
||||
|
||||
// duplicated from file_tree_view_model.go. Generics will help here
|
||||
func (self *CommitFileTreeViewModel) ToggleShowTree() {
|
||||
selectedNode := self.GetSelected()
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package filetree
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// When the tree shrinks under the selection - e.g. moving a patch out into the
|
||||
// index removes a file - SetTree must keep the selection in range. Otherwise
|
||||
// GetSelectedItems returns a nil node, which crashes callers such as
|
||||
// canEditFiles when the options map is rendered during layout.
|
||||
func TestCommitFileTreeViewModelSetTreeClampsSelectionOnShrink(t *testing.T) {
|
||||
files := []*models.CommitFile{
|
||||
{Path: "file1"},
|
||||
{Path: "file2"},
|
||||
{Path: "file3"},
|
||||
}
|
||||
viewModel := NewCommitFileTreeViewModel(
|
||||
func() []*models.CommitFile { return files },
|
||||
common.NewDummyCommon(),
|
||||
false, // flat list
|
||||
)
|
||||
viewModel.SetTree()
|
||||
viewModel.SetSelectedLineIdx(viewModel.Len() - 1)
|
||||
|
||||
// The file under the cursor goes away and the tree shrinks.
|
||||
files = []*models.CommitFile{{Path: "file1"}}
|
||||
viewModel.SetTree()
|
||||
|
||||
assert.Less(t, viewModel.GetSelectedLineIdx(), viewModel.Len())
|
||||
assert.NotNil(t, viewModel.GetSelected())
|
||||
items, _, _ := viewModel.GetSelectedItems()
|
||||
assert.NotEmpty(t, items)
|
||||
for _, item := range items {
|
||||
assert.NotNil(t, item)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package filetree
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
@@ -22,7 +21,6 @@ type IFileTreeViewModel interface {
|
||||
// which item is selected. It also contains logic for repositioning that cursor
|
||||
// after the files are refreshed
|
||||
type FileTreeViewModel struct {
|
||||
sync.RWMutex
|
||||
types.IListCursor
|
||||
IFileTree
|
||||
searchHistory *utils.HistoryBuffer[string]
|
||||
|
||||
@@ -17,12 +17,8 @@ func (gui *Gui) scrollUpView(view *gocui.View) {
|
||||
}
|
||||
|
||||
func (gui *Gui) scrollDownView(view *gocui.View) {
|
||||
scrollHeight := gui.c.UserConfig().Gui.ScrollHeight
|
||||
view.ScrollDown(scrollHeight)
|
||||
|
||||
if manager := gui.getViewBufferManagerForView(view); manager != nil {
|
||||
manager.ReadLines(scrollHeight)
|
||||
}
|
||||
view.ScrollDown(gui.c.UserConfig().Gui.ScrollHeight)
|
||||
gui.readLinesToFillView(view)
|
||||
}
|
||||
|
||||
func (gui *Gui) scrollUpMain() error {
|
||||
|
||||
+85
-28
@@ -12,6 +12,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazycore/pkg/boxlayout"
|
||||
@@ -48,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
|
||||
@@ -111,7 +111,10 @@ type Gui struct {
|
||||
|
||||
PopupHandler types.IPopupHandler
|
||||
|
||||
IsRefreshingFiles bool
|
||||
// Bumped every time we switch to a different repository (in resetState).
|
||||
// Used to drop refresh results that were computed for a repo we've since
|
||||
// navigated away from. See RefreshHelper.onUIThreadUnlessRepoChanged.
|
||||
repoGeneration atomic.Int32
|
||||
|
||||
// we use this to decide whether we'll return to the original directory that
|
||||
// lazygit was opened in, or if we'll retain the one we're currently in.
|
||||
@@ -171,18 +174,14 @@ func (self *StateAccessor) GetRepoState() types.IRepoStateAccessor {
|
||||
return self.gui.State
|
||||
}
|
||||
|
||||
func (self *StateAccessor) GetRepoGeneration() int {
|
||||
return int(self.gui.repoGeneration.Load())
|
||||
}
|
||||
|
||||
func (self *StateAccessor) GetPagerConfig() *config.PagerConfig {
|
||||
return self.gui.pagerConfig
|
||||
}
|
||||
|
||||
func (self *StateAccessor) GetIsRefreshingFiles() bool {
|
||||
return self.gui.IsRefreshingFiles
|
||||
}
|
||||
|
||||
func (self *StateAccessor) SetIsRefreshingFiles(value bool) {
|
||||
self.gui.IsRefreshingFiles = value
|
||||
}
|
||||
|
||||
func (self *StateAccessor) GetShowExtrasWindow() bool {
|
||||
return self.gui.ShowExtrasWindow
|
||||
}
|
||||
@@ -234,8 +233,11 @@ type GuiRepoState struct {
|
||||
|
||||
SplitMainPanel bool
|
||||
|
||||
SearchState *types.SearchState
|
||||
StartupStage types.StartupStage // Allows us to not load everything at once
|
||||
SearchState *types.SearchState
|
||||
// Lets us not load everything at once. Written and read from refresh
|
||||
// workers (the reflog/branches load transitions it INITIAL->COMPLETE), so
|
||||
// it's atomic. Holds a types.StartupStage.
|
||||
startupStage atomic.Int32
|
||||
|
||||
ContextMgr *ContextMgr
|
||||
Contexts *context.ContextTree
|
||||
@@ -262,7 +264,11 @@ type GuiRepoState struct {
|
||||
// continue such an operation once its conflicts are resolved if we started
|
||||
// it ourselves; for an externally started one, popping up unbidden would be
|
||||
// confusing. Reset whenever we observe that no operation is in progress.
|
||||
mergeOrRebaseStartedInLazygit bool
|
||||
//
|
||||
// Written from both the files refresh worker and the merge/rebase result
|
||||
// path (which runs on a worker for the async callers), and read from the
|
||||
// files refresh worker, so it's atomic.
|
||||
mergeOrRebaseStartedInLazygit atomic.Bool
|
||||
}
|
||||
|
||||
var _ types.IRepoStateAccessor = new(GuiRepoState)
|
||||
@@ -276,11 +282,11 @@ func (self *GuiRepoState) GetWindowViewNameMap() *utils.ThreadSafeMap[string, st
|
||||
}
|
||||
|
||||
func (self *GuiRepoState) GetStartupStage() types.StartupStage {
|
||||
return self.StartupStage
|
||||
return types.StartupStage(self.startupStage.Load())
|
||||
}
|
||||
|
||||
func (self *GuiRepoState) SetStartupStage(value types.StartupStage) {
|
||||
self.StartupStage = value
|
||||
self.startupStage.Store(int32(value))
|
||||
}
|
||||
|
||||
func (self *GuiRepoState) GetCurrentPopupOpts() *types.CreatePopupPanelOpts {
|
||||
@@ -292,11 +298,11 @@ func (self *GuiRepoState) SetCurrentPopupOpts(value *types.CreatePopupPanelOpts)
|
||||
}
|
||||
|
||||
func (self *GuiRepoState) GetMergeOrRebaseStartedInLazygit() bool {
|
||||
return self.mergeOrRebaseStartedInLazygit
|
||||
return self.mergeOrRebaseStartedInLazygit.Load()
|
||||
}
|
||||
|
||||
func (self *GuiRepoState) SetMergeOrRebaseStartedInLazygit(value bool) {
|
||||
self.mergeOrRebaseStartedInLazygit = value
|
||||
self.mergeOrRebaseStartedInLazygit.Store(value)
|
||||
}
|
||||
|
||||
func (self *GuiRepoState) GetScreenMode() types.ScreenMode {
|
||||
@@ -384,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
|
||||
}
|
||||
|
||||
@@ -414,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 {
|
||||
@@ -585,6 +595,11 @@ func (gui *Gui) checkForChangedConfigsThatDontAutoReload(oldConfig *config.UserC
|
||||
// resetState reuses the repo state from our repo state map, if the repo was
|
||||
// open before; otherwise it creates a new one.
|
||||
func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context {
|
||||
// Bump the repo generation so that any refresh still in flight for the
|
||||
// previous repo drops its model update instead of applying it here (see
|
||||
// RefreshHelper.onUIThreadUnlessRepoChanged).
|
||||
gui.repoGeneration.Add(1)
|
||||
|
||||
// Un-highlight the current view if there is one. The reason we do this is
|
||||
// that the repo we are switching to might have a different view focused,
|
||||
// and would then show an inactive highlight for the previous view.
|
||||
@@ -604,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()
|
||||
}
|
||||
@@ -625,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{},
|
||||
@@ -679,6 +691,23 @@ func (gui *Gui) getViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferM
|
||||
return manager
|
||||
}
|
||||
|
||||
// When scrolling a lazy-loaded view, we read enough lines to fill the viewport
|
||||
// plus this many extra screenfuls, so that further scrolling has some runway
|
||||
// and doesn't have to block on reading (and re-rendering) more lines on every
|
||||
// wheel notch.
|
||||
const scrollReadAheadScreenfuls = 3
|
||||
|
||||
// readLinesToFillView reads enough lines into the view's buffer to cover
|
||||
// everything currently scrolled into view, plus a few screenfuls of read-ahead.
|
||||
// Reading is idempotent (see ViewBufferManager.ReadLines), so if the buffer
|
||||
// already extends far enough this does nothing.
|
||||
func (gui *Gui) readLinesToFillView(view *gocui.View) {
|
||||
if manager := gui.getViewBufferManagerForView(view); manager != nil {
|
||||
viewportBottom := view.OriginY() + view.InnerHeight()
|
||||
manager.ReadLines(viewportBottom + scrollReadAheadScreenfuls*view.InnerHeight())
|
||||
}
|
||||
}
|
||||
|
||||
func (gui *Gui) initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] {
|
||||
result := utils.NewThreadSafeMap[string, string]()
|
||||
|
||||
@@ -779,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() },
|
||||
@@ -990,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
|
||||
}
|
||||
@@ -1067,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
|
||||
@@ -1189,16 +1230,32 @@ func (gui *Gui) onUIThread(f func() error) {
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) onUIThreadBackground(f func() error) {
|
||||
gui.g.UpdateBackground(func(*gocui.Gui) error {
|
||||
return f()
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) onUIThreadContentOnly(f func() error) {
|
||||
gui.g.UpdateContentOnly(func(*gocui.Gui) error {
|
||||
return f()
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) onUIThreadContentOnlyBackground(f func() error) {
|
||||
gui.g.UpdateContentOnlyBackground(func(*gocui.Gui) error {
|
||||
return f()
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) onWorker(f func(gocui.Task) error) {
|
||||
gui.g.OnWorker(f)
|
||||
}
|
||||
|
||||
func (gui *Gui) onWorkerBackground(f func(gocui.Task) error) {
|
||||
gui.g.OnWorkerBackground(f)
|
||||
}
|
||||
|
||||
func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map[string]boxlayout.Dimensions {
|
||||
return gui.helpers.WindowArrangement.GetWindowDimensions(informationStr, appStatus)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ func (self *guiCommon) Refresh(opts types.RefreshOptions) {
|
||||
self.gui.helpers.Refresh.Refresh(opts)
|
||||
}
|
||||
|
||||
func (self *guiCommon) RefreshFromWorker(opts types.RefreshOptions) {
|
||||
self.gui.helpers.Refresh.RefreshFromWorker(opts)
|
||||
}
|
||||
|
||||
func (self *guiCommon) PostRefreshUpdate(context types.Context) {
|
||||
self.gui.postRefreshUpdate(context)
|
||||
}
|
||||
@@ -54,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
|
||||
}
|
||||
|
||||
@@ -109,6 +124,7 @@ func (self *guiCommon) Modes() *types.Modes {
|
||||
}
|
||||
|
||||
func (self *guiCommon) Model() *types.Model {
|
||||
self.assertOnUIThread("Model()")
|
||||
return self.gui.State.Model
|
||||
}
|
||||
|
||||
@@ -124,14 +140,26 @@ func (self *guiCommon) OnUIThread(f func() error) {
|
||||
self.gui.onUIThread(f)
|
||||
}
|
||||
|
||||
func (self *guiCommon) OnUIThreadBackground(f func() error) {
|
||||
self.gui.onUIThreadBackground(f)
|
||||
}
|
||||
|
||||
func (self *guiCommon) OnUIThreadContentOnly(f func() error) {
|
||||
self.gui.onUIThreadContentOnly(f)
|
||||
}
|
||||
|
||||
func (self *guiCommon) OnUIThreadContentOnlyBackground(f func() error) {
|
||||
self.gui.onUIThreadContentOnlyBackground(f)
|
||||
}
|
||||
|
||||
func (self *guiCommon) OnWorker(f func(gocui.Task) error) {
|
||||
self.gui.onWorker(f)
|
||||
}
|
||||
|
||||
func (self *guiCommon) OnWorkerBackground(f func(gocui.Task) error) {
|
||||
self.gui.onWorkerBackground(f)
|
||||
}
|
||||
|
||||
func (self *guiCommon) RenderToMainViews(opts types.RefreshMainOpts) {
|
||||
self.gui.refreshMainViews(opts)
|
||||
}
|
||||
@@ -149,6 +177,10 @@ func (self *guiCommon) GetViewBufferManagerForView(view *gocui.View) *tasks.View
|
||||
return self.gui.getViewBufferManagerForView(view)
|
||||
}
|
||||
|
||||
func (self *guiCommon) ReadLinesToFillView(view *gocui.View) {
|
||||
self.gui.readLinesToFillView(view)
|
||||
}
|
||||
|
||||
func (self *guiCommon) State() types.IStateAccessor {
|
||||
return self.gui.stateAccessor
|
||||
}
|
||||
|
||||
+16
-14
@@ -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 {
|
||||
|
||||
+17
-5
@@ -37,13 +37,18 @@ func (gui *Gui) layout(g *gocui.Gui) error {
|
||||
if prevMainView != nil {
|
||||
prevMainHeight := prevMainView.Height()
|
||||
newMainHeight := viewDimensions["main"].Y1 - viewDimensions["main"].Y0 + 1
|
||||
heightDiff := newMainHeight - prevMainHeight
|
||||
if heightDiff > 0 {
|
||||
if newMainHeight > prevMainHeight {
|
||||
// The main views have grown taller, so make sure enough lines are
|
||||
// loaded to fill them. The views haven't been resized yet at this
|
||||
// point, so we can't rely on their current height; compute the target
|
||||
// total from the new height instead. (Reading past the actual content
|
||||
// is harmless: ReadLines stops at the end of input.)
|
||||
linesToRead := prevMainView.OriginY() + newMainHeight
|
||||
if manager := gui.getViewBufferManagerForView(gui.Views.Main); manager != nil {
|
||||
manager.ReadLines(heightDiff)
|
||||
manager.ReadLines(linesToRead)
|
||||
}
|
||||
if manager := gui.getViewBufferManagerForView(gui.Views.Secondary); manager != nil {
|
||||
manager.ReadLines(heightDiff)
|
||||
manager.ReadLines(linesToRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,7 +154,14 @@ func (gui *Gui) layout(g *gocui.Gui) error {
|
||||
if err != nil && !errors.Is(err, gocui.ErrUnknownView) {
|
||||
return err
|
||||
}
|
||||
view.Visible = gui.helpers.Window.GetViewNameForWindow(context.GetWindowName()) == context.GetViewName()
|
||||
// A transient view is visible if it is the view its window is currently
|
||||
// showing — but only if that window is part of the layout at all. For a
|
||||
// window without dimensions, setViewFromDimensions parks the view at full
|
||||
// screen size in the background, so making it visible would cover all
|
||||
// windows below it.
|
||||
_, windowHasDimensions := viewDimensions[context.GetWindowName()]
|
||||
view.Visible = windowHasDimensions &&
|
||||
gui.helpers.Window.GetViewNameForWindow(context.GetWindowName()) == context.GetViewName()
|
||||
}
|
||||
|
||||
if gui.PrevLayout.Information != informationStr {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-1
@@ -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.Refresh(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
|
||||
@@ -70,6 +75,9 @@ func (self *StatusManager) AddToastStatus(message string, kind types.ToastKind)
|
||||
}
|
||||
|
||||
func (self *StatusManager) GetStatusString(userConfig *config.UserConfig) (string, gocui.Attribute) {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
if len(self.statuses) == 0 {
|
||||
return "", gocui.ColorDefault
|
||||
}
|
||||
@@ -81,9 +89,45 @@ func (self *StatusManager) GetStatusString(userConfig *config.UserConfig) (strin
|
||||
}
|
||||
|
||||
func (self *StatusManager) HasStatus() bool {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
return len(self.statuses) > 0
|
||||
}
|
||||
|
||||
// ClaimRenderLoop is called by whoever just added a status; it reports whether
|
||||
// they must start the render loop. When it returns false, a loop is already
|
||||
// running and will pick the new status up on its next tick.
|
||||
func (self *StatusManager) ClaimRenderLoop() bool {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
if self.renderLoopRunning {
|
||||
return false
|
||||
}
|
||||
|
||||
self.renderLoopRunning = true
|
||||
return true
|
||||
}
|
||||
|
||||
// ReleaseRenderLoopIfEmpty is called by the render loop after each frame it
|
||||
// draws; a true result releases the loop's claim and tells it to exit, because
|
||||
// there are no statuses left to draw. The emptiness check and the release are
|
||||
// atomic with respect to ClaimRenderLoop, so a status added around this moment
|
||||
// either sees the still-running loop or starts a fresh one — it can't end up
|
||||
// unrendered.
|
||||
func (self *StatusManager) ReleaseRenderLoopIfEmpty() bool {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
if len(self.statuses) > 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
self.renderLoopRunning = false
|
||||
return true
|
||||
}
|
||||
|
||||
func (self *StatusManager) addStatus(message string, statusType string, kind types.ToastKind) int {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
+40
-10
@@ -18,8 +18,17 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error
|
||||
|
||||
manager := gui.getManager(view)
|
||||
|
||||
// Snapshot the view width here, on the UI thread, so the task goroutine
|
||||
// doesn't read the view's live dimensions while it streams output. It's
|
||||
// applied inside start() below rather than now, because start() runs once
|
||||
// the previous task has stopped -- applying it here would race that task's
|
||||
// still-running writes (see View.SetContentWidth).
|
||||
contentWidth := view.InnerWidth()
|
||||
|
||||
var r io.ReadCloser
|
||||
start := func() (tasks.Cmd, io.Reader) {
|
||||
view.SetContentWidth(contentWidth)
|
||||
|
||||
var err error
|
||||
r, err = cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
@@ -59,8 +68,10 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error {
|
||||
manager := gui.getManager(view)
|
||||
|
||||
f := func(tasks.TaskOpts) error {
|
||||
gui.c.SetViewContent(view, str)
|
||||
return nil
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
gui.c.SetViewContent(view, str)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := manager.NewTask(f, manager.GetTaskKey()); err != nil {
|
||||
@@ -74,9 +85,11 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in
|
||||
manager := gui.getManager(view)
|
||||
|
||||
f := func(tasks.TaskOpts) error {
|
||||
gui.c.SetViewContent(view, str)
|
||||
view.SetOrigin(originX, originY)
|
||||
return nil
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
gui.c.SetViewContent(view, str)
|
||||
view.SetOrigin(originX, originY)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := manager.NewTask(f, manager.GetTaskKey()); err != nil {
|
||||
@@ -90,9 +103,11 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e
|
||||
manager := gui.getManager(view)
|
||||
|
||||
f := func(tasks.TaskOpts) error {
|
||||
gui.c.ResetViewOrigin(view)
|
||||
gui.c.SetViewContent(view, str)
|
||||
return nil
|
||||
return gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
gui.c.ResetViewOrigin(view)
|
||||
gui.c.SetViewContent(view, str)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := manager.NewTask(f, key); err != nil {
|
||||
@@ -118,7 +133,12 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
view.Reset()
|
||||
},
|
||||
func() {
|
||||
gui.render()
|
||||
// As the task reads more lines, the only thing that changes is the
|
||||
// view's content (and its scrollbar); the window layout doesn't. So a
|
||||
// content-only render is enough, and it's much cheaper than a full
|
||||
// layout-and-redraw on every read - which matters a lot when reading
|
||||
// a long diff, where reads happen repeatedly as the user scrolls.
|
||||
gui.renderContentOnly()
|
||||
},
|
||||
func() {
|
||||
// Need to check if the content of the view is well past the origin.
|
||||
@@ -136,8 +156,18 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager {
|
||||
view.SetOrigin(0, 0)
|
||||
},
|
||||
func() gocui.Task {
|
||||
return gui.c.GocuiGui().NewTask()
|
||||
// A background task: rendering content into a view is display
|
||||
// work, not lazygit driving a git operation, so it must not
|
||||
// count towards being busy and block a repo switch. These
|
||||
// renders fire on nearly every focus/selection change, including
|
||||
// the context activation that happens right before a menu/prompt
|
||||
// handler runs (e.g. confirming worktree creation), which would
|
||||
// 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
|
||||
}
|
||||
|
||||
+4
-11
@@ -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) == "" {
|
||||
|
||||
+29
-17
@@ -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 {
|
||||
@@ -30,6 +29,12 @@ type IGuiCommon interface {
|
||||
LogCommand(cmdStr string, isCommandLine bool)
|
||||
// we call this when we want to refetch some models and render the result. Internally calls PostRefreshUpdate
|
||||
Refresh(RefreshOptions)
|
||||
// Like Refresh, but for callers running on a worker goroutine (e.g. inside
|
||||
// a WithWaitingStatus handler) rather than the UI thread. The refresh
|
||||
// captures the model/context state it needs on the UI thread before doing
|
||||
// its git work; knowing which thread the caller is on lets it capture
|
||||
// inline (UI thread) or hop across (worker) without racing or deadlocking.
|
||||
RefreshFromWorker(RefreshOptions)
|
||||
// we call this when we've changed something in the view model but not the actual model,
|
||||
// e.g. expanding or collapsing a folder in a file view. Calling 'Refresh' in this
|
||||
// case would be overkill, although refresh will internally call 'PostRefreshUpdate'
|
||||
@@ -52,6 +57,10 @@ type IGuiCommon interface {
|
||||
// return the view buffer manager for the given view, or nil if it doesn't have one
|
||||
GetViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager
|
||||
|
||||
// read enough lines into the given view's buffer to fill it at its current
|
||||
// scroll position, plus some read-ahead for smooth scrolling
|
||||
ReadLinesToFillView(view *gocui.View)
|
||||
|
||||
// returns true if command completed successfully
|
||||
RunSubprocess(cmdObj *oscommands.CmdObj) (bool, error)
|
||||
RunSubprocessAndRefresh(*oscommands.CmdObj) error
|
||||
@@ -75,13 +84,22 @@ type IGuiCommon interface {
|
||||
// Only necessary to call if you're not already on the UI thread i.e. you're inside a goroutine.
|
||||
// All controller handlers are executed on the UI thread.
|
||||
OnUIThread(f func() error)
|
||||
// Like OnUIThread, but for work triggered by a background routine, so it
|
||||
// doesn't count towards lazygit being busy (see the *Background methods on
|
||||
// gocui.Gui and repo-switch safety).
|
||||
OnUIThreadBackground(f func() error)
|
||||
// Like OnUIThread, but signals that the callback only modifies view
|
||||
// content (e.g. spinner), allows the event loop to skip
|
||||
// the expensive layout recalculation when only content changed.
|
||||
OnUIThreadContentOnly(f func() error)
|
||||
// Like OnUIThreadContentOnly, but for background work (see OnUIThreadBackground).
|
||||
OnUIThreadContentOnlyBackground(f func() error)
|
||||
// Runs a function in a goroutine. Use this whenever you want to run a goroutine and keep track of the fact
|
||||
// that lazygit is still busy. See docs/dev/Busy.md
|
||||
OnWorker(f func(gocui.Task) error)
|
||||
// Like OnWorker, but for a background routine (or work it triggers), so it
|
||||
// doesn't count towards lazygit being busy (see OnUIThreadBackground).
|
||||
OnWorkerBackground(f func(gocui.Task) error)
|
||||
// Function to call at the end of our 'layout' function which renders views
|
||||
// For example, you may want a view's line to be focused only after that view is
|
||||
// resized, if in accordion mode.
|
||||
@@ -142,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)
|
||||
@@ -329,25 +347,14 @@ 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
|
||||
}
|
||||
|
||||
type Mutexes struct {
|
||||
RefreshingFilesMutex deadlock.Mutex
|
||||
RefreshingBranchesMutex deadlock.Mutex
|
||||
RefreshingStatusMutex deadlock.Mutex
|
||||
RefreshingPullRequestsMutex deadlock.Mutex
|
||||
LocalCommitsMutex deadlock.Mutex
|
||||
SubCommitsMutex deadlock.Mutex
|
||||
AuthorsMutex deadlock.Mutex
|
||||
SubprocessMutex deadlock.Mutex
|
||||
PopupMutex deadlock.Mutex
|
||||
PtyMutex deadlock.Mutex
|
||||
SubprocessMutex deadlock.Mutex
|
||||
PtyMutex deadlock.Mutex
|
||||
}
|
||||
|
||||
// A long-running operation associated with an item. For example, we'll show
|
||||
@@ -377,8 +384,6 @@ type IStateAccessor interface {
|
||||
// tells us whether we're currently updating lazygit
|
||||
GetUpdating() bool
|
||||
SetUpdating(bool)
|
||||
SetIsRefreshingFiles(bool)
|
||||
GetIsRefreshingFiles() bool
|
||||
GetShowExtrasWindow() bool
|
||||
SetShowExtrasWindow(bool)
|
||||
GetRetainOriginalDir() bool
|
||||
@@ -386,6 +391,13 @@ type IStateAccessor interface {
|
||||
GetItemOperation(item HasUrn) ItemOperation
|
||||
SetItemOperation(item HasUrn, operation ItemOperation)
|
||||
ClearItemOperation(item HasUrn)
|
||||
|
||||
// A counter that is bumped every time we switch to a different repository
|
||||
// (see Gui.resetState). A refresh captures it when it starts and carries it
|
||||
// through to onUIThreadUnlessRepoChanged, so that a model update computed for
|
||||
// one repo can be dropped rather than applied to another if the user switched
|
||||
// repos while the refresh was in flight.
|
||||
GetRepoGeneration() int
|
||||
}
|
||||
|
||||
type IRepoStateAccessor interface {
|
||||
|
||||
+33
-18
@@ -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
|
||||
@@ -55,22 +47,45 @@ const (
|
||||
SelectHeadCommit
|
||||
)
|
||||
|
||||
type RefreshOptions struct {
|
||||
Then func()
|
||||
Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything
|
||||
Mode RefreshMode // one of SYNC (default), ASYNC, and BLOCK_UI
|
||||
// BranchSelectionBehavior controls which local branch is selected after the
|
||||
// branches list is reloaded by a refresh.
|
||||
type BranchSelectionBehavior int
|
||||
|
||||
// Normally a refresh of the branches tries to keep the same branch selected
|
||||
// (by name); this is usually important in case the order of branches
|
||||
// changes. Passing true for KeepBranchSelectionIndex suppresses this and
|
||||
// keeps the selection index the same. Useful after checking out a detached
|
||||
// head, and selecting index 0.
|
||||
KeepBranchSelectionIndex bool
|
||||
const (
|
||||
// Keep the same branch selected by name, restoring it at its new position if
|
||||
// the order changed. This is the right default whenever the list reloads
|
||||
// underneath a selection the user hasn't deliberately changed.
|
||||
KeepBranchSelectionByName BranchSelectionBehavior = iota
|
||||
|
||||
// Select the checked-out branch (the one at the top of the list). Used after
|
||||
// operations that check something out - checkout, creating a branch, moving
|
||||
// commits to a new branch - so the newly checked-out ref ends up selected.
|
||||
SelectCheckedOutBranch
|
||||
)
|
||||
|
||||
type RefreshOptions struct {
|
||||
Then func() error
|
||||
Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything
|
||||
|
||||
// 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.
|
||||
BranchSelection BranchSelectionBehavior
|
||||
|
||||
// Controls which local commit is selected after the refresh. Defaults to
|
||||
// KeepCommitSelectionByHash.
|
||||
CommitSelection CommitSelectionBehavior
|
||||
|
||||
// When true, select the top (most recent) reflog entry after the refresh.
|
||||
// Used alongside SelectCheckedOutBranch by operations that check something
|
||||
// out, since the checkout adds a new reflog entry at the top. Defaults to
|
||||
// keeping the reflog selection where it is.
|
||||
SelectTopReflogCommit bool
|
||||
|
||||
// When true, this refresh was initiated by a background routine rather than
|
||||
// by a user action. Every git command suppresses optional locks by default
|
||||
// so it can't contend for index.lock (see git_commands.OptionalLocksEnvVar);
|
||||
|
||||
@@ -121,6 +121,14 @@ func (gui *Gui) render() {
|
||||
gui.c.OnUIThread(func() error { return nil })
|
||||
}
|
||||
|
||||
// renderContentOnly triggers a re-render that skips the layout pass and only
|
||||
// redraws the views whose content changed (relying on tcell's cell-level dirty
|
||||
// tracking to emit just the cells that actually differ). Use it when only a
|
||||
// view's content changed, not the window layout.
|
||||
func (gui *Gui) renderContentOnly() {
|
||||
gui.c.OnUIThreadContentOnly(func() error { return nil })
|
||||
}
|
||||
|
||||
// postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed
|
||||
// if the context's view is set to another context we do nothing.
|
||||
// if the context's view is the current view we trigger a focus; re-selecting the current item.
|
||||
|
||||
+7
-6
@@ -427,9 +427,11 @@ type TranslationSet struct {
|
||||
UndoingStatus string
|
||||
RedoingStatus string
|
||||
CheckingOutStatus string
|
||||
CreatingBranchStatus string
|
||||
CommittingStatus string
|
||||
RewordingStatus string
|
||||
RevertingStatus string
|
||||
ResettingStatus string
|
||||
CreatingFixupCommitStatus string
|
||||
MovingCommitsToNewBranchStatus string
|
||||
CommitFiles string
|
||||
@@ -768,6 +770,7 @@ type TranslationSet struct {
|
||||
ErrStageDirWithInlineMergeConflicts string
|
||||
ErrRepositoryMovedOrDeleted string
|
||||
ErrWorktreeMovedOrRemoved string
|
||||
CantSwitchWhileOperationInProgress string
|
||||
CommandLog string
|
||||
ToggleShowCommandLog string
|
||||
FocusCommandLog string
|
||||
@@ -1128,12 +1131,7 @@ Thanks for using lazygit! Seriously you rock. Three things to share with you:
|
||||
2) Be sure to read the latest release notes at:
|
||||
https://github.com/jesseduffield/lazygit/releases
|
||||
|
||||
3) If you're using git, that makes you a programmer! With your help we can make
|
||||
lazygit better, so consider becoming a contributor and joining the fun at
|
||||
https://github.com/jesseduffield/lazygit
|
||||
Or even just star the repo to share the love!
|
||||
|
||||
4) If lazygit has made your life easier, you can say thanks by clicking the
|
||||
3) If lazygit has made your life easier, you can say thanks by clicking the
|
||||
donate button at the bottom right. Donation does not grant priority support,
|
||||
but it is much appreciated.
|
||||
|
||||
@@ -1579,9 +1577,11 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
UndoingStatus: "Undoing",
|
||||
RedoingStatus: "Redoing",
|
||||
CheckingOutStatus: "Checking out",
|
||||
CreatingBranchStatus: "Creating branch",
|
||||
CommittingStatus: "Committing",
|
||||
RewordingStatus: "Rewording",
|
||||
RevertingStatus: "Reverting",
|
||||
ResettingStatus: "Resetting",
|
||||
CreatingFixupCommitStatus: "Creating fixup commit",
|
||||
MovingCommitsToNewBranchStatus: "Moving commits to new branch",
|
||||
CommitFiles: "Commit files",
|
||||
@@ -1919,6 +1919,7 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
ErrRepositoryMovedOrDeleted: "Cannot find repo. It might have been moved or deleted ¯\\_(ツ)_/¯",
|
||||
CommandLog: "Command log",
|
||||
ErrWorktreeMovedOrRemoved: "Cannot find worktree. It might have been moved or removed ¯\\_(ツ)_/¯",
|
||||
CantSwitchWhileOperationInProgress: "Can't switch repositories while an operation is in progress",
|
||||
ToggleShowCommandLog: "Toggle show/hide command log",
|
||||
FocusCommandLog: "Focus command log",
|
||||
CommandLogHeader: "You can hide/focus this panel by pressing '%s'\n",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -256,7 +256,7 @@ func (self *Shell) CreateNCommitsStartingAt(n, startIndex int) *Shell {
|
||||
fmt.Sprintf("file%02d.txt", i),
|
||||
fmt.Sprintf("file%02d content", i),
|
||||
).
|
||||
Commit(fmt.Sprintf("commit %02d", i))
|
||||
Commit(fmt.Sprintf("commit-%02d", i))
|
||||
}
|
||||
|
||||
return self
|
||||
|
||||
@@ -34,29 +34,29 @@ var Basic = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
|
||||
t.Views().Commits().
|
||||
Focus().
|
||||
SelectedLine(Contains("CI commit 10")).
|
||||
NavigateToLine(Contains("CI commit 09")).
|
||||
SelectedLine(Contains("CI commit-10")).
|
||||
NavigateToLine(Contains("CI commit-09")).
|
||||
Tap(func() {
|
||||
markCommitAsBad()
|
||||
|
||||
t.Views().Information().Content(Contains("Bisecting"))
|
||||
}).
|
||||
SelectedLine(Contains("<-- bad")).
|
||||
NavigateToLine(Contains("CI commit 02")).
|
||||
NavigateToLine(Contains("CI commit-02")).
|
||||
Tap(markCommitAsGood).
|
||||
TopLines(Contains("CI commit 10")).
|
||||
TopLines(Contains("CI commit-10")).
|
||||
// lazygit will land us in the commit between our good and bad commits.
|
||||
SelectedLine(Contains("CI commit 05").Contains("<-- current")).
|
||||
SelectedLine(Contains("CI commit-05").Contains("<-- current")).
|
||||
Tap(markCommitAsBad).
|
||||
SelectedLine(Contains("CI commit 04").Contains("<-- current")).
|
||||
SelectedLine(Contains("CI commit-04").Contains("<-- current")).
|
||||
Tap(func() {
|
||||
markCommitAsGood()
|
||||
|
||||
// commit 5 is the culprit because we marked 4 as good and 5 as bad.
|
||||
t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 05.*Do you want to reset")).Confirm()
|
||||
t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit-05.*Do you want to reset")).Confirm()
|
||||
}).
|
||||
IsFocused().
|
||||
Content(Contains("CI commit 04"))
|
||||
Content(Contains("CI commit-04"))
|
||||
|
||||
t.Views().Information().Content(DoesNotContain("Bisecting"))
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user