mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-01 19:46:44 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aee0e40ec1 | ||
|
|
a1a542479c | ||
|
|
fc0a3a4e2a | ||
|
|
1f231c9262 | ||
|
|
cca82cfe48 | ||
|
|
1edd432d2f | ||
|
|
ccdfa8f4e3 | ||
|
|
d251faddec | ||
|
|
b4dee2af0e | ||
|
|
6048fee49d | ||
|
|
d52c0a604f | ||
|
|
9b22c7dc77 | ||
|
|
0a92529b14 | ||
|
|
6871959caa | ||
|
|
b6deefacd2 | ||
|
|
9811bc7ea8 | ||
|
|
47471c501b | ||
|
|
669c445eb6 | ||
|
|
2669842445 | ||
|
|
320d33a8ef | ||
|
|
ff26f61ffd | ||
|
|
67ec23e08a | ||
|
|
d8d09e1f94 | ||
|
|
e296845aae | ||
|
|
4598c7b548 | ||
|
|
8731d8a51b | ||
|
|
ac4a1f575c | ||
|
|
1cf632002e | ||
|
|
2694aa774a | ||
|
|
1ebb65b775 | ||
|
|
4479d34e37 | ||
|
|
91dcd4cbb3 | ||
|
|
104fdf34a9 | ||
|
|
cefec1c5c9 | ||
|
|
0738d55551 | ||
|
|
eda4ad1192 | ||
|
|
b055d28fb1 | ||
|
|
e756511042 | ||
|
|
b85483ecc0 | ||
|
|
4085b6462d | ||
|
|
14434015c7 | ||
|
|
e7998356be | ||
|
|
10bac9dbf6 | ||
|
|
b682fb7635 | ||
|
|
f5a069342b | ||
|
|
ba024ed6c9 | ||
|
|
98613957ae | ||
|
|
a199a0a680 | ||
|
|
4609985029 | ||
|
|
44a2bbeb7c | ||
|
|
38d2293a10 | ||
|
|
a965db2a7d | ||
|
|
ff53a3ed8c | ||
|
|
c5fe27dfa5 | ||
|
|
5e0a2e97ef | ||
|
|
31cbabe48f | ||
|
|
22db808a01 | ||
|
|
67c81c5b14 | ||
|
|
940d7cc80f | ||
|
|
f15f713c68 | ||
|
|
df0943ad33 | ||
|
|
1d107721f2 | ||
|
|
c7acf38399 | ||
|
|
8fefe2b933 | ||
|
|
6a022241d2 | ||
|
|
292035709f | ||
|
|
9d7ca51ee7 | ||
|
|
338d017191 | ||
|
|
3d9318e2a7 | ||
|
|
5aa003612c | ||
|
|
a5b4477a20 | ||
|
|
02c8ba3073 | ||
|
|
c217084c90 | ||
|
|
f000ce9f1c | ||
|
|
400faea60c | ||
|
|
5209294a56 | ||
|
|
1dc7d4f589 | ||
|
|
87d9537de9 | ||
|
|
d8b07ee4f5 | ||
|
|
975da9b8a9 | ||
|
|
fc975f32a8 | ||
|
|
055184f997 | ||
|
|
200042a57c | ||
|
|
b96b8a9753 | ||
|
|
963db76ab6 | ||
|
|
7a902b56cc | ||
|
|
2e653ceeba | ||
|
|
00cef799ce | ||
|
|
4b3e5f123f | ||
|
|
5fc678dde6 | ||
|
|
efed9d0407 | ||
|
|
568a4276d7 | ||
|
|
e0b8dbf48c | ||
|
|
ae095f276b | ||
|
|
8e045653be | ||
|
|
7c0fa9fe33 | ||
|
|
527124d0e0 | ||
|
|
096a710761 | ||
|
|
132f656480 | ||
|
|
4b788d5eb0 | ||
|
|
b6b5436d57 | ||
|
|
03a914c04c | ||
|
|
840a66e733 | ||
|
|
334daccfab | ||
|
|
7fce58b09b | ||
|
|
46c1fa7db2 | ||
|
|
4390e1fb08 | ||
|
|
94e5f570f6 | ||
|
|
319f43e166 | ||
|
|
d887a41ad2 | ||
|
|
3ed6ce8f67 | ||
|
|
b371411567 | ||
|
|
e91dcb7056 | ||
|
|
197916aafb | ||
|
|
33a2dc302e | ||
|
|
15c83e6356 | ||
|
|
a1561a5e69 | ||
|
|
7360a8459d | ||
|
|
5055c4fb65 | ||
|
|
4cf12a5b7b | ||
|
|
5769ab2190 | ||
|
|
e299de3270 | ||
|
|
c23bcd6d94 | ||
|
|
87ef96974e | ||
|
|
9f2886f96f | ||
|
|
25a3689c01 | ||
|
|
2a7b74d3f3 | ||
|
|
d36ce51559 | ||
|
|
1b0cc02e1e | ||
|
|
1efcfcc148 | ||
|
|
d48c8174d5 | ||
|
|
33b8d497c2 | ||
|
|
59ed1517bc | ||
|
|
435e02efa8 | ||
|
|
9754a77b64 | ||
|
|
eda2151330 | ||
|
|
65cb439076 | ||
|
|
f6eaed8cd4 | ||
|
|
e75688c101 | ||
|
|
40868a9389 | ||
|
|
99c1bcbf23 | ||
|
|
303372d917 | ||
|
|
c58aa2a23b | ||
|
|
4ff161b48d | ||
|
|
2f60280eb6 | ||
|
|
9bb9fc8933 | ||
|
|
a247dfd76d | ||
|
|
a324f8aef1 | ||
|
|
d802cbdddf | ||
|
|
352883c52b | ||
|
|
62098ca603 | ||
|
|
707b04a8c2 | ||
|
|
6893d9a759 | ||
|
|
88811e6795 | ||
|
|
8580c78cc0 | ||
|
|
63bd2d98c0 | ||
|
|
bfd3b7b47e | ||
|
|
f319522d5b | ||
|
|
d70d70aad2 | ||
|
|
4acfc88065 | ||
|
|
504e5b3f74 | ||
|
|
36f193a2e8 | ||
|
|
2765147b71 | ||
|
|
edec427746 | ||
|
|
a61be44e92 | ||
|
|
d097519c05 | ||
|
|
4b97c2ba61 | ||
|
|
74a77e58be | ||
|
|
bf4f5827e7 | ||
|
|
38e1fe0493 | ||
|
|
0162839f0c | ||
|
|
14d717d77f | ||
|
|
07745afc57 | ||
|
|
d786c9d79b | ||
|
|
080da5cacf | ||
|
|
0ce857c717 | ||
|
|
664a65d584 | ||
|
|
7e1073a0ee | ||
|
|
4fea011021 | ||
|
|
733c1a487f | ||
|
|
bb2d6e8bbd | ||
|
|
50122e6886 | ||
|
|
76ad5a3552 | ||
|
|
bea025f5b7 | ||
|
|
3a0ba6bf4d | ||
|
|
58e121b933 | ||
|
|
a61727cd5e | ||
|
|
c81c08071f | ||
|
|
e59c1d1cb7 | ||
|
|
4e907c6b3e | ||
|
|
d4a606c685 | ||
|
|
d94ca63e6d | ||
|
|
c21ce61729 | ||
|
|
bda505148b | ||
|
|
d181615c31 | ||
|
|
8d6d1f0908 | ||
|
|
585c7f126d | ||
|
|
73d7b443ec | ||
|
|
cbf220c497 | ||
|
|
3491a15f6e | ||
|
|
1268a589d6 | ||
|
|
73714a3b38 | ||
|
|
2420fc7b76 | ||
|
|
fe4c195370 | ||
|
|
4d33d9df8b | ||
|
|
19b34851ff | ||
|
|
2c3a6acafa | ||
|
|
fefb3b632e | ||
|
|
12757e2723 | ||
|
|
6cd93de5b9 | ||
|
|
e7105a3138 | ||
|
|
67b0a6b1a4 | ||
|
|
f07e94afe0 | ||
|
|
5d8c893497 | ||
|
|
6c38ddc9a7 | ||
|
|
6d21efb515 | ||
|
|
2edfeac538 | ||
|
|
462d75232b | ||
|
|
fceba31212 | ||
|
|
b4a976834f | ||
|
|
eb95ae15f3 | ||
|
|
5162a768eb | ||
|
|
fd6b20847a | ||
|
|
3e5c99e1e4 | ||
|
|
56989922e4 | ||
|
|
988d04bda9 | ||
|
|
558fd2c9d3 | ||
|
|
080542c9fb | ||
|
|
1def541acb | ||
|
|
f0ea537956 | ||
|
|
23cfa9b070 | ||
|
|
7c4d8045f9 | ||
|
|
bd6081d601 | ||
|
|
5414daf492 | ||
|
|
56932abe06 | ||
|
|
8655d3f5a5 | ||
|
|
d95900ccd0 | ||
|
|
e352cafd43 | ||
|
|
cf7c3d82e6 | ||
|
|
3103fe97ea | ||
|
|
6d8ab1d063 | ||
|
|
805738034f | ||
|
|
f7a61443fa | ||
|
|
549df17279 | ||
|
|
4c3f8b51ea | ||
|
|
4c9fdc4221 | ||
|
|
063bba6b45 | ||
|
|
bd47106d03 | ||
|
|
db5eb6fd39 | ||
|
|
0f85c2b2b4 | ||
|
|
559b4bf298 | ||
|
|
d6f6d0ceba | ||
|
|
ff7ecf2d2a | ||
|
|
21f1dc3366 | ||
|
|
b203ec57ac | ||
|
|
6203a4e411 | ||
|
|
2c139b6ac1 | ||
|
|
be897ce55e | ||
|
|
ea83f50dc3 | ||
|
|
717448f105 | ||
|
|
b54d4c369b | ||
|
|
5350b6c37b | ||
|
|
dd1576138a | ||
|
|
fa9429f8c7 | ||
|
|
78dd678ce5 | ||
|
|
775ebc9e44 | ||
|
|
3537f855b0 | ||
|
|
6681ba7eda | ||
|
|
ccf49c8112 | ||
|
|
2bae29c6fd | ||
|
|
539ede2e1b | ||
|
|
44ba0d539f |
@@ -53,8 +53,16 @@ jobs:
|
||||
- 2.38.2 # first version that supports the rebase.updateRefs config
|
||||
- 2.44.0
|
||||
- latest # We rely on github to have the latest version installed on their VMs
|
||||
race:
|
||||
- false
|
||||
# Additionally run the whole suite once under the race detector. Data
|
||||
# races live in lazygit's own Go code rather than in git, so a single
|
||||
# git version is enough; use the latest to skip the git-build steps.
|
||||
include:
|
||||
- git-version: latest
|
||||
race: true
|
||||
runs-on: ubuntu-latest
|
||||
name: "Integration Tests - git ${{matrix.git-version}}"
|
||||
name: "Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }}"
|
||||
env:
|
||||
GOFLAGS: -mod=vendor
|
||||
steps:
|
||||
@@ -92,12 +100,23 @@ jobs:
|
||||
run: git --version
|
||||
- name: Test code
|
||||
env:
|
||||
# See https://go.dev/blog/integration-test-coverage
|
||||
LAZYGIT_GOCOVERDIR: /tmp/code_coverage
|
||||
# See https://go.dev/blog/integration-test-coverage. The race variant
|
||||
# skips coverage: it's redundant with the non-race latest job and
|
||||
# would only slow the -race build down further. Leaving the dir unset
|
||||
# makes run_integration_tests.sh take its non-coverage path.
|
||||
LAZYGIT_GOCOVERDIR: ${{ !matrix.race && '/tmp/code_coverage' || '' }}
|
||||
# Only set for the race variant. The race detector needs cgo; it's on
|
||||
# by default on the Linux runner, but we set it explicitly to be safe.
|
||||
LAZYGIT_RACE_DETECTOR: ${{ matrix.race && '1' || '' }}
|
||||
CGO_ENABLED: ${{ matrix.race && '1' || '' }}
|
||||
# Append each test's duration to this file; run_integration_tests.sh
|
||||
# prints the slowest at the end, to spot slow/anomalous tests.
|
||||
LAZYGIT_TEST_TIMING: /tmp/test_timings.txt
|
||||
run: |
|
||||
mkdir -p /tmp/code_coverage
|
||||
./scripts/run_integration_tests.sh
|
||||
- name: Upload code coverage artifacts
|
||||
if: ${{ !matrix.race }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-integration-${{ matrix.git-version }}-${{ github.run_id }}
|
||||
|
||||
@@ -26,6 +26,24 @@ Windows box has only `just`).
|
||||
(most useful with `--sandbox` or `--slow`).
|
||||
- `just lint` — run golangci-lint.
|
||||
|
||||
## Prefer gopls MCP tools for Go symbol questions
|
||||
|
||||
When the gopls MCP tools are available in the session, prefer them over grep
|
||||
for type-aware questions about Go code: who calls a function or method
|
||||
(`go_symbol_references`), finding a symbol by fuzzy name (`go_search`), or
|
||||
inspecting a package's API (`go_package_api`). Method names in this codebase
|
||||
collide a lot (`draw`, `Show`, `Refresh` exist on several types), and grep
|
||||
needs manual filtering that gopls doesn't. This includes code under
|
||||
`vendor/`, which gopls resolves as part of the module build.
|
||||
|
||||
Grep remains the right tool for strings, comments, config keys, non-Go
|
||||
files, and anything textual. Don't adopt the full workflow from
|
||||
`gopls mcp -instructions` (vulncheck on session start, `go_file_context`
|
||||
after every file read); that overhead isn't worth it here.
|
||||
|
||||
If the tools aren't available in a session, fall back to grep silently —
|
||||
don't try to install, register, or start the server.
|
||||
|
||||
## When to commit
|
||||
|
||||
Do not leave completed work uncommitted. Once a logical unit of work is done
|
||||
@@ -48,6 +66,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 +178,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
|
||||
@@ -182,6 +213,16 @@ that changes the relevant test(s) or adds new ones to demonstrate the bug, then
|
||||
fix the bug in a follow-up commit. This gives reviewers (and `git bisect`) a
|
||||
clear before/after and proves the test actually exercises the broken code path.
|
||||
|
||||
This applies only to defects that existed before the entire branch or branch
|
||||
stack. Never use the bug-demonstration pattern for a regression introduced by
|
||||
an earlier commit in the current stack. Fix or rewrite the commit that
|
||||
introduced the regression so that no commit in the final history contains it.
|
||||
Put the regression test in a preparatory commit before the introducing commit,
|
||||
so it guards that commit in the final history. If the test cannot pass before
|
||||
the feature exists, restructure the implementation or test seam until it can;
|
||||
if that would require a design tradeoff, stop and discuss it rather than adding
|
||||
a later demonstration/fix pair.
|
||||
|
||||
Use the `EXPECTED` / `ACTUAL` pattern in the bug-demonstrating commit. The test
|
||||
asserts the current (wrong) behavior so it passes on the broken code, with the
|
||||
correct expectation preserved inline as a comment. The fix commit then swaps
|
||||
@@ -224,7 +265,11 @@ If you find yourself reaching for a local variable so that both forms can be
|
||||
expressed against the same receiver, the structure isn't right yet — go back
|
||||
and fix it instead of papering over it with a binding.
|
||||
|
||||
Use this pattern only where it makes sense; don't apply it by default.
|
||||
Use this pattern only where it makes sense; don't apply it by default. Only
|
||||
ever use it for bugs, never for added features or behavior changes that aren't
|
||||
bugfixes; it is useful to demonstrate how a bug existed before fixing it, but
|
||||
it is never useful to demonstrate how a feature didn't exist before implementing
|
||||
it.
|
||||
|
||||
## Unify duplicated logic before you change it
|
||||
|
||||
@@ -243,6 +288,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 +443,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.
|
||||
|
||||
+20
-258
@@ -1,273 +1,35 @@
|
||||
# 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.
|
||||
- Run a master build! This is probably the most valuable way to help me. Test the latest master not just by occasionally trying it, but by actually using it for your daily work; report any issues that you find. This will help prevent having to release hotfix updates for regressions that are only noticed by users updating to a new release.
|
||||
|
||||
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.
|
||||
|
||||
@@ -118,7 +118,7 @@ If you're a mere mortal like me and you're tired of hearing how powerful git is
|
||||
- [Changing Directory On Exit](#changing-directory-on-exit)
|
||||
- [Undo/Redo](#undoredo)
|
||||
- [Configuration](#configuration)
|
||||
- [Custom Pagers](#custom-pagers)
|
||||
- [Custom Diff Renderers](#custom-diff-renderers)
|
||||
- [Custom Commands](#custom-commands)
|
||||
- [Git flow support](#git-flow-support)
|
||||
- [Contributing](#contributing)
|
||||
@@ -423,6 +423,7 @@ nix-shell -p lazygit
|
||||
# or with flakes enabled
|
||||
nix run nixpkgs#lazygit
|
||||
```
|
||||
|
||||
Or you can add lazygit to your `configuration.nix` using the `environment.systemPackages` option.
|
||||
More details can be found via NixOS search [page](https://search.nixos.org/).
|
||||
|
||||
@@ -431,6 +432,7 @@ More details can be found via NixOS search [page](https://search.nixos.org/).
|
||||
This repository includes a nix flake that provides the latest development version and additional development tools:
|
||||
|
||||
**Run lazygit directly from the repository:**
|
||||
|
||||
```sh
|
||||
nix run github:jesseduffield/lazygit
|
||||
# or from a local clone
|
||||
@@ -438,6 +440,7 @@ nix run .
|
||||
```
|
||||
|
||||
**Build lazygit from source:**
|
||||
|
||||
```sh
|
||||
nix build github:jesseduffield/lazygit
|
||||
# or from a local clone
|
||||
@@ -446,6 +449,7 @@ nix build .
|
||||
|
||||
**Development environment:**
|
||||
For contributors, the flake provides a development shell with Go toolchain, development tools, and dependencies:
|
||||
|
||||
```sh
|
||||
nix develop github:jesseduffield/lazygit
|
||||
# or from a local clone
|
||||
@@ -453,12 +457,14 @@ nix develop
|
||||
```
|
||||
|
||||
The development shell includes:
|
||||
|
||||
- Go toolchain
|
||||
- git and make
|
||||
- Proper environment variables for development
|
||||
|
||||
**Using in other flakes:**
|
||||
The flake also provides an overlay for easy integration into other flake-based projects:
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs.lazygit.url = "github:jesseduffield/lazygit";
|
||||
@@ -584,9 +590,9 @@ See the [docs](/docs/Undoing.md)
|
||||
|
||||
Check out the [configuration docs](docs/Config.md).
|
||||
|
||||
### Custom Pagers
|
||||
### Custom Diff Renderers
|
||||
|
||||
See the [docs](docs/Custom_Pagers.md)
|
||||
See the [docs](docs/Custom_DiffRenderers.md)
|
||||
|
||||
### Custom Commands
|
||||
|
||||
|
||||
+31
-30
@@ -66,8 +66,8 @@ gui:
|
||||
|
||||
# The number of spaces per tab; used for everything that's shown in the main
|
||||
# view, but probably mostly relevant for diffs.
|
||||
# Note that when using a pager, the pager has its own tab width setting, so you
|
||||
# need to pass it separately in the pager command.
|
||||
# Note that when using a diff renderer, the renderer has its own tab width
|
||||
# setting, so you need to pass it separately in the renderer command.
|
||||
tabWidth: 4
|
||||
|
||||
# If true, capture mouse events.
|
||||
@@ -336,13 +336,13 @@ gui:
|
||||
spinner:
|
||||
# The frames of the spinner animation.
|
||||
frames:
|
||||
- '|'
|
||||
- /
|
||||
- '-'
|
||||
- \
|
||||
- ●∙∙
|
||||
- ∙●∙
|
||||
- ∙∙●
|
||||
- ∙●∙
|
||||
|
||||
# The "speed" of the spinner in milliseconds.
|
||||
rate: 50
|
||||
rate: 180
|
||||
|
||||
# Status panel view.
|
||||
# One of 'dashboard' (default) | 'allBranchesLog'
|
||||
@@ -360,38 +360,39 @@ gui:
|
||||
|
||||
# Config relating to git
|
||||
git:
|
||||
# Array of pagers. Each entry has the following format:
|
||||
# Array of diff renderers. Each entry has the following format:
|
||||
#
|
||||
# # A name for the pager, shown in the notification when cycling pagers.
|
||||
# # If not set, the name is derived from the first word of the pager
|
||||
# # command (or of the external diff command).
|
||||
# # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff'
|
||||
# # | 'rawGit'
|
||||
# type: "stdinFilter"
|
||||
#
|
||||
# # A name for the diff renderer, shown in the notification when cycling
|
||||
# # renderers. If not set, the name is derived from the first word of the
|
||||
# # renderer command.
|
||||
# name: ""
|
||||
#
|
||||
# # Value of the --color arg in the git diff command. Some pagers want
|
||||
# # this to be set to 'always' and some want it set to 'never'
|
||||
# # Value of the --color arg in the git diff command. Only used for type
|
||||
# # 'stdinFilter'. Some renderers want this to be set to 'always' and some
|
||||
# # want it set to 'never'.
|
||||
# colorArg: "always"
|
||||
#
|
||||
# # The command to use for rendering diffs. This is either a stdinFilter or
|
||||
# # an external diff command, depending on the type field; not applicable if
|
||||
# # the type is 'rawGit'.
|
||||
# # e.g.
|
||||
# # diff-so-fancy
|
||||
# # delta --dark --paging=never
|
||||
# # ydiff -p cat -s --wrap --width={{columnWidth}}
|
||||
# pager: ""
|
||||
# # ydiff -p cat
|
||||
# # difft --color=always
|
||||
# command: ""
|
||||
#
|
||||
# # e.g. 'difft --color=always'
|
||||
# externalDiffCommand: ""
|
||||
# # Extra arguments (array of strings) passed to the git command. Only
|
||||
# # applicable if the type is 'rawGit'.
|
||||
# args: []
|
||||
#
|
||||
# # If true, Lazygit will use git's `diff.external` config for paging.
|
||||
# # The advantage over `externalDiffCommand` is that this can be
|
||||
# # configured per file type in .gitattributes; see
|
||||
# # https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
|
||||
# useExternalDiffGitConfig: false
|
||||
#
|
||||
# 'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' are mutually
|
||||
# exclusive; set at most one per entry.
|
||||
#
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md
|
||||
# for more information.
|
||||
pagers: []
|
||||
diffRenderers: []
|
||||
|
||||
# Config relating to committing
|
||||
commit:
|
||||
@@ -714,8 +715,8 @@ keybinding:
|
||||
prevTab: '['
|
||||
nextScreenMode: +
|
||||
prevScreenMode: _
|
||||
cyclePagers: '|'
|
||||
cyclePagersReverse: \
|
||||
cycleDiffRenderers: '|'
|
||||
cycleDiffRenderersReverse: \
|
||||
undo: z
|
||||
redo: Z
|
||||
filteringMenu: <ctrl+s>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Custom Diff Renderers
|
||||
|
||||
Custom diff renderers are useful for showing a better rendering of a diff than git's builtin raw diff, and using one is strongly recommended (I personally prefer delta myself, but that's a matter of personal preference). There are three types of diff renderers that lazygit supports:
|
||||
|
||||
- **stdin filters**, e.g. [delta](#delta) and [diff-so-fancy](#diff-so-fancy). They take git's raw output as stdin and produce something nicer on stdout, and they are hooked up using git's GIT_PAGER mechanism. (These used to be called "custom pagers" in earlier lazygit versions.)
|
||||
- **external diff programs**, e.g. difftastic; these are called using git's `--ext-diff` flag, and they take over diff generation from git completely rather than post-processing git's output.
|
||||
- **git's raw output using custom arguments**; mainly useful for `--color-words` (or `--word-diff` if you are color blind).
|
||||
|
||||
Diff renderers are configured with the `diffRenderers` array in the `git` section of lazygit's config file; it is an array because you can have multiple entries that you can cycle through with the `|` key. This can be useful if you usually prefer a particular diff renderer, but want to use a different one for certain kinds of diffs.
|
||||
|
||||
Fields that are shared by all renderer types:
|
||||
|
||||
- **type** The type of diff renderer; choices are `stdinFilter`, `extDiff`, or `rawGit`. `stdinFilter` is the default, because it's the most common one; so you can omit this if you use delta.
|
||||
- **name** A name that is shown in the status bar toast when cycling renderers; defaults to the first word of the renderer command, but can be useful e.g. to distinguish "delta" from "delta side-by-side" if you have entries for both.
|
||||
|
||||
Fields only for `stdinFilter`:
|
||||
|
||||
- **command** The command line to use for `GIT_PAGER`.
|
||||
|
||||
- **colorArg** whether you want the `--color=always` arg in your `git diff` command. Some diff renderers want it set to `always`, others want it set to `never`. The default is `always`, since that's what most renderers need.
|
||||
|
||||
Fields only for `extDiff`:
|
||||
|
||||
- **command** The command line to use for the `diff.external` git config. If left empty, it uses the global value of git's `diff.external` config; this can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
|
||||
|
||||
You can include the `{{diffContext}}` template variable to pass lazygit's current diff context size (the value controlled by the `{`/`}` keybindings) to the diff tool.
|
||||
|
||||
Fields only for `rawGit`:
|
||||
|
||||
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`)
|
||||
|
||||
Here's an example for a multi-renderer setup:
|
||||
|
||||
```yaml
|
||||
git:
|
||||
diffRenderers:
|
||||
- command: delta --dark --paging=never
|
||||
- command: ydiff -p cat
|
||||
colorArg: never
|
||||
- type: extDiff
|
||||
command: difft --color=always --context={{diffContext}}
|
||||
- type: rawGit
|
||||
args: --color-words
|
||||
name: color-words
|
||||
- type: rawGit # git's default diff
|
||||
name: default
|
||||
```
|
||||
|
||||
## Delta:
|
||||
|
||||
```yaml
|
||||
git:
|
||||
diffRenderers:
|
||||
- command: delta --dark --paging=never
|
||||
```
|
||||
|
||||

|
||||
|
||||
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `command:` field to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
|
||||
|
||||
Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons.
|
||||
|
||||
## Diff-so-fancy
|
||||
|
||||
```yaml
|
||||
git:
|
||||
diffRenderers:
|
||||
- command: diff-so-fancy
|
||||
```
|
||||
|
||||

|
||||
|
||||
## ydiff
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
sidePanelWidth: 0.2 # gives you more space to show things side-by-side
|
||||
git:
|
||||
diffRenderers:
|
||||
- colorArg: never
|
||||
command: ydiff -p cat
|
||||
```
|
||||
|
||||

|
||||
@@ -1,92 +0,0 @@
|
||||
# Custom Pagers
|
||||
|
||||
Lazygit supports custom pagers, [configured](/docs/Config.md) in the config.yml file (which can be opened by pressing `e` in the Status panel).
|
||||
|
||||
Multiple pagers are supported; you can cycle through them with the `|` key. This can be useful if you usually prefer a particular pager, but want to use a different one for certain kinds of diffs.
|
||||
|
||||
Pagers are configured with the `pagers` array in the git section; here's an example for a multi-pager setup (use an empty object `{}` for the default builtin diff display that doesn't use a pager):
|
||||
|
||||
```yaml
|
||||
git:
|
||||
pagers:
|
||||
- pager: delta --dark --paging=never
|
||||
- pager: ydiff -p cat -s --wrap --width={{columnWidth}}
|
||||
colorArg: never
|
||||
- externalDiffCommand: difft --color=always
|
||||
- {} # default, no pager used
|
||||
```
|
||||
|
||||
The `colorArg` key is for whether you want the `--color=always` arg in your `git diff` command. Some pagers want it set to `always`, others want it set to `never`. The default is `always`, since that's what most pagers need.
|
||||
|
||||
## Delta:
|
||||
|
||||
```yaml
|
||||
git:
|
||||
pagers:
|
||||
- pager: delta --dark --paging=never
|
||||
```
|
||||
|
||||

|
||||
|
||||
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `pager:` config to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
|
||||
|
||||
Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons.
|
||||
|
||||
## Diff-so-fancy
|
||||
|
||||
```yaml
|
||||
git:
|
||||
pagers:
|
||||
- pager: diff-so-fancy
|
||||
```
|
||||
|
||||

|
||||
|
||||
## ydiff
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
sidePanelWidth: 0.2 # gives you more space to show things side-by-side
|
||||
git:
|
||||
pagers:
|
||||
- colorArg: never
|
||||
pager: ydiff -p cat -s --wrap --width={{columnWidth}}
|
||||
```
|
||||
|
||||

|
||||
|
||||
Be careful with this one, I think the homebrew and pip versions are behind master. I needed to directly download the ydiff script to get the no-pager functionality working.
|
||||
|
||||
## Using external diff commands
|
||||
|
||||
Some diff tools can't work as a simple pager like the ones above do, because they need access to the entire diff, so just post-processing git's diff is not enough for them. The most notable example is probably [difftastic](https://difftastic.wilfred.me.uk).
|
||||
|
||||
These can be used in lazygit by using the `externalDiffCommand` config; in the case of difftastic, that could be
|
||||
|
||||
```yaml
|
||||
git:
|
||||
pagers:
|
||||
- externalDiffCommand: difft --color=always
|
||||
```
|
||||
|
||||
The `colorArg` option is not used in this case.
|
||||
|
||||
You can add whatever extra arguments you prefer for your difftool; for instance
|
||||
|
||||
```yaml
|
||||
git:
|
||||
pagers:
|
||||
- externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off
|
||||
```
|
||||
|
||||
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
|
||||
git:
|
||||
pagers:
|
||||
- useExternalDiffGitConfig: true
|
||||
```
|
||||
|
||||
This can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
|
||||
|
||||
`pager`, `externalDiffCommand`, and `useExternalDiffGitConfig` are alternative ways of producing the diff, so a pager entry may use at most one of them.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
* [Configuration](./Config.md).
|
||||
* [Custom Commands](./Custom_Command_Keybindings.md)
|
||||
* [Custom Pagers](./Custom_Pagers.md)
|
||||
* [Custom Diff Renderers](./Custom_DiffRenderers.md)
|
||||
* [Dev docs](./dev)
|
||||
* [Keybindings](./keybindings)
|
||||
* [Undo/Redo](./Undoing.md)
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | Refresh | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` + `` | Next screen mode (normal/half/fullscreen) | |
|
||||
| `` _ `` | Prev screen mode | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | Cancel | |
|
||||
| `` ? `` | Open keybindings menu | |
|
||||
| `` <ctrl+s> `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | 更新 | Gitの状態を更新します(`git status`、`git branch`などをバックグラウンドで実行してパネルの内容を更新します)。これは`git fetch`を実行しません。 |
|
||||
| `` + `` | 次の画面モード(通常/半分/全画面) | |
|
||||
| `` _ `` | 前の画面モード | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | キャンセル | |
|
||||
| `` ? `` | キーバインディングメニューを開く | |
|
||||
| `` <ctrl+s> `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | 새로고침 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` + `` | 다음 스크린 모드 (normal/half/fullscreen) | |
|
||||
| `` _ `` | 이전 스크린 모드 | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | 취소 | |
|
||||
| `` ? `` | 매뉴 열기 | |
|
||||
| `` <ctrl+s> `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
|
||||
@@ -9,31 +9,31 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <ctrl+r> `` | Wissel naar een recente repo | |
|
||||
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Scroll naar beneden vanaf hoofdpaneel | |
|
||||
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Scroll naar beneden vanaf hoofdpaneel | |
|
||||
| `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. |
|
||||
| `` @ `` | Commandolog opties weergeven | Bekijk commandolog opties, bijv. commandolog tonen/verbergen en focus. |
|
||||
| `` P `` | Push | Push de huidige branch naar de bijbehorende upstream-branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren. |
|
||||
| `` p `` | Pull | Pull wijzigingen van de remote voor de huidige branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren. |
|
||||
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
| `` { `` | Decrease diff context size | Decrease the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
| `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. |
|
||||
| `` : `` | Voer shellcommando uit | Bring up a prompt where you can enter a shell command to execute. |
|
||||
| `` <ctrl+p> `` | Bekijk aangepaste patch opties | |
|
||||
| `` m `` | Bekijk merge/rebase opties | Toon abort/continue/skip opties voor huidige merge/rebase. |
|
||||
| `` R `` | Verversen | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` + `` | Volgende scherm modus (normaal/half/groot) | |
|
||||
| `` _ `` | Vorige scherm modus | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | Annuleren | |
|
||||
| `` ? `` | Open menu | |
|
||||
| `` <ctrl+s> `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
| `` W, <ctrl+e> `` | Open diff menu | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. |
|
||||
| `` q, <ctrl+c> `` | Afsluiten | |
|
||||
| `` <ctrl+z> `` | Pauzeer de applicatie | |
|
||||
| `` <ctrl+w> `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
|
||||
| `` <ctrl+w> `` | Witruimte weergeven in-/uitschakelen | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
|
||||
| `` <alt+shift+c> `` | Verander config bestand | Open bestand in externe editor. |
|
||||
| `` z `` | Ongedaan maken (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
|
||||
| `` Z `` | Redo (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
|
||||
| `` Z `` | Redo (via reflog) (experimenteel) | Het reflog wordt gebruikt om te bepalen welk git commando moet worden gebruikt om het laatste git commando te herhalen. Wijzigingen aan de working tree worden niet meegenomen, alleen command's zijn kandidaten. |
|
||||
|
||||
## Lijstpaneel navigatie
|
||||
|
||||
@@ -161,25 +161,25 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` s `` | Squash | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. |
|
||||
| `` f `` | Fixup | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. |
|
||||
| `` c `` | Set fixup message | Set the message option for the fixup commit. The -C option means to use this commit's message instead of the target commit's message. |
|
||||
| `` r `` | Hernoem commit | Reword the selected commit's message. |
|
||||
| `` r `` | Hernoem commit | Herschrijf de commit message van de geselecteerde commit. |
|
||||
| `` R `` | Hernoem commit met editor | |
|
||||
| `` d `` | Verwijder commit | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. |
|
||||
| `` e `` | Edit (start interactive rebase) | Wijzig commit |
|
||||
| `` i `` | Start interactive rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.<br>If you would instead like to start an interactive rebase from the selected commit, press `e`. |
|
||||
| `` e `` | Bewerken (start interactieve rebase) | Wijzig commit |
|
||||
| `` i `` | Start interactieve rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.<br>If you would instead like to start an interactive rebase from the selected commit, press `e`. |
|
||||
| `` p `` | Pick | Kies commit (wanneer midden in rebase) |
|
||||
| `` F `` | Creëer fixup commit | Creëer fixup commit |
|
||||
| `` S `` | Apply fixup commits | Squash bovenstaande commits |
|
||||
| `` <ctrl+j>, <alt+down> `` | Verplaats commit 1 naar beneden | |
|
||||
| `` <ctrl+k>, <alt+up> `` | Verplaats commit 1 naar boven | |
|
||||
| `` V `` | Plak commits (cherry-pick) | |
|
||||
| `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. |
|
||||
| `` B `` | Markeer als basiscommit voor rebase | Selecteer een basiscommit voor de volgende rebase. Als je rebased op een branch worden alleen commits boven de basiscommit meegenomen. Hiervoor wordt het `git rebase --onto` commando gebruikt. |
|
||||
| `` A `` | Amend | Wijzig commit met staged veranderingen |
|
||||
| `` a `` | Amend commit attribute | Set/Reset commit author or set co-author. |
|
||||
| `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
|
||||
| `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
|
||||
| `` <ctrl+l> `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
|
||||
| `` t `` | Revert | Maak een revert commit voor de geselecteerde commit, die de wijzigingen in deze commit terugdraait. |
|
||||
| `` T `` | Tag commit | Maak een nieuwe tag die naar de geselecteerde commit wijst. Je wordt gevraagd om een tag naam en optionele omschrijving. |
|
||||
| `` <ctrl+l> `` | Log opties weergeven | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
|
||||
| `` G `` | Open pull request in browser | |
|
||||
| `` <space> `` | Uitchecken | Checkout the selected commit as a detached HEAD. |
|
||||
| `` <space> `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. |
|
||||
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` o `` | Open commit in browser | |
|
||||
| `` n `` | Creëer nieuwe branch van commit | |
|
||||
@@ -241,7 +241,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <left>, h `` | Selecteer de vorige hunk | |
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` o `` | Open bestand | Open bestand in standaardapplicatie. |
|
||||
| `` e `` | Verander bestand | Open bestand in externe editor. |
|
||||
@@ -255,7 +255,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <space> `` | Uitchecken | Checkout the selected commit as a detached HEAD. |
|
||||
| `` <space> `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. |
|
||||
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` o `` | Open commit in browser | |
|
||||
| `` n `` | Creëer nieuwe branch van commit | |
|
||||
@@ -316,7 +316,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <left>, h `` | Selecteer de vorige hunk | |
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
|
||||
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
@@ -361,7 +361,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <space> `` | Uitchecken | Checkout the selected commit as a detached HEAD. |
|
||||
| `` <space> `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. |
|
||||
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` o `` | Open commit in browser | |
|
||||
| `` n `` | Creëer nieuwe branch van commit | |
|
||||
@@ -396,10 +396,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy tag to clipboard | |
|
||||
| `` <space> `` | Uitchecken | Geselecteerde tag uitchecken als detached HEAD. |
|
||||
| `` n `` | Creëer tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. |
|
||||
| `` n `` | Creëer tag | Maak een nieuwe tag die naar de huidige commit wijst. Je wordt gevraagd om een tag naam en optionele beschrijving. |
|
||||
| `` w `` | New worktree | |
|
||||
| `` d `` | Verwijderen | View delete options for local/remote tag. |
|
||||
| `` P `` | Push tag | Push the selected tag to a remote. You'll be prompted to select a remote. |
|
||||
| `` P `` | Tag pushen | Push de geselecteerde tag naar een remote. Je krijgt de optie een remote te selecteren. |
|
||||
| `` g `` | Resetten | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` <ctrl+t> `` | Open externe diff applicatie (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | Odśwież | Odśwież stan git (tj. uruchom `git status`, `git branch`, itp. w tle, aby zaktualizować zawartość paneli). To nie uruchamia `git fetch`. |
|
||||
| `` + `` | Następny tryb ekranu (normalny/półpełny/pełnoekranowy) | |
|
||||
| `` _ `` | Poprzedni tryb ekranu | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | Anuluj | |
|
||||
| `` ? `` | Otwórz menu przypisań klawiszy | |
|
||||
| `` <ctrl+s> `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | Atualizar | Atualize o estado do git (ou seja, execute `git status`, `git branch`, etc em segundo plano para atualizar o conteúdo de painéis). Isso não executa `git fetch`. |
|
||||
| `` + `` | Modo de tela seguinte (normal/metade/tela cheia) | |
|
||||
| `` _ `` | Modo de tela anterior | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | Cancelar | |
|
||||
| `` ? `` | Abrir o menu de atalhos do teclado | |
|
||||
| `` <ctrl+s> `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | Обновить | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` + `` | Следующий режим экрана (нормальный/полуэкранный/полноэкранный) | |
|
||||
| `` _ `` | Предыдущий режим экрана | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | Отменить | |
|
||||
| `` ? `` | Открыть меню | |
|
||||
| `` <ctrl+s> `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | 刷新 | 刷新Git状态(即在后台运行`git status`、`git branch`等命令以更新面板内容)。此操作不会执行`git fetch`。 |
|
||||
| `` + `` | 下一屏模式(正常/半屏/全屏) | |
|
||||
| `` _ `` | 上一屏模式 | |
|
||||
| `` \| `` | 切换分页器 | 从已配置的分页器列表中选择下一个分页器 |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | 取消 | |
|
||||
| `` ? `` | 打开菜单 | |
|
||||
| `` <ctrl+s> `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | 重新整理 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | |
|
||||
| `` _ `` | 上一個螢幕模式 | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | 取消 | |
|
||||
| `` ? `` | 開啟選單 | |
|
||||
| `` <ctrl+s> `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
|
||||
+31
-30
@@ -66,8 +66,8 @@ gui:
|
||||
|
||||
# The number of spaces per tab; used for everything that's shown in the main
|
||||
# view, but probably mostly relevant for diffs.
|
||||
# Note that when using a pager, the pager has its own tab width setting, so you
|
||||
# need to pass it separately in the pager command.
|
||||
# Note that when using a diff renderer, the renderer has its own tab width
|
||||
# setting, so you need to pass it separately in the renderer command.
|
||||
tabWidth: 4
|
||||
|
||||
# If true, capture mouse events.
|
||||
@@ -336,13 +336,13 @@ gui:
|
||||
spinner:
|
||||
# The frames of the spinner animation.
|
||||
frames:
|
||||
- '|'
|
||||
- /
|
||||
- '-'
|
||||
- \
|
||||
- ●∙∙
|
||||
- ∙●∙
|
||||
- ∙∙●
|
||||
- ∙●∙
|
||||
|
||||
# The "speed" of the spinner in milliseconds.
|
||||
rate: 50
|
||||
rate: 180
|
||||
|
||||
# Status panel view.
|
||||
# One of 'dashboard' (default) | 'allBranchesLog'
|
||||
@@ -360,38 +360,39 @@ gui:
|
||||
|
||||
# Config relating to git
|
||||
git:
|
||||
# Array of pagers. Each entry has the following format:
|
||||
# Array of diff renderers. Each entry has the following format:
|
||||
#
|
||||
# # A name for the pager, shown in the notification when cycling pagers.
|
||||
# # If not set, the name is derived from the first word of the pager
|
||||
# # command (or of the external diff command).
|
||||
# # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff'
|
||||
# # | 'rawGit'
|
||||
# type: "stdinFilter"
|
||||
#
|
||||
# # A name for the diff renderer, shown in the notification when cycling
|
||||
# # renderers. If not set, the name is derived from the first word of the
|
||||
# # renderer command.
|
||||
# name: ""
|
||||
#
|
||||
# # Value of the --color arg in the git diff command. Some pagers want
|
||||
# # this to be set to 'always' and some want it set to 'never'
|
||||
# # Value of the --color arg in the git diff command. Only used for type
|
||||
# # 'stdinFilter'. Some renderers want this to be set to 'always' and some
|
||||
# # want it set to 'never'.
|
||||
# colorArg: "always"
|
||||
#
|
||||
# # The command to use for rendering diffs. This is either a stdinFilter or
|
||||
# # an external diff command, depending on the type field; not applicable if
|
||||
# # the type is 'rawGit'.
|
||||
# # e.g.
|
||||
# # diff-so-fancy
|
||||
# # delta --dark --paging=never
|
||||
# # ydiff -p cat -s --wrap --width={{columnWidth}}
|
||||
# pager: ""
|
||||
# # ydiff -p cat
|
||||
# # difft --color=always
|
||||
# command: ""
|
||||
#
|
||||
# # e.g. 'difft --color=always'
|
||||
# externalDiffCommand: ""
|
||||
# # Extra arguments (array of strings) passed to the git command. Only
|
||||
# # applicable if the type is 'rawGit'.
|
||||
# args: []
|
||||
#
|
||||
# # If true, Lazygit will use git's `diff.external` config for paging.
|
||||
# # The advantage over `externalDiffCommand` is that this can be
|
||||
# # configured per file type in .gitattributes; see
|
||||
# # https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
|
||||
# useExternalDiffGitConfig: false
|
||||
#
|
||||
# 'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' are mutually
|
||||
# exclusive; set at most one per entry.
|
||||
#
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md
|
||||
# for more information.
|
||||
pagers: []
|
||||
diffRenderers: []
|
||||
|
||||
# Config relating to committing
|
||||
commit:
|
||||
@@ -714,8 +715,8 @@ keybinding:
|
||||
prevTab: '['
|
||||
nextScreenMode: +
|
||||
prevScreenMode: _
|
||||
cyclePagers: '|'
|
||||
cyclePagersReverse: \
|
||||
cycleDiffRenderers: '|'
|
||||
cycleDiffRenderersReverse: \
|
||||
undo: z
|
||||
redo: Z
|
||||
filteringMenu: <ctrl+s>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Custom Diff Renderers
|
||||
|
||||
Custom diff renderers are useful for showing a better rendering of a diff than git's builtin raw diff, and using one is strongly recommended (I personally prefer delta myself, but that's a matter of personal preference). There are three types of diff renderers that lazygit supports:
|
||||
|
||||
- **stdin filters**, e.g. [delta](#delta) and [diff-so-fancy](#diff-so-fancy). They take git's raw output as stdin and produce something nicer on stdout, and they are hooked up using git's GIT_PAGER mechanism. (These used to be called "custom pagers" in earlier lazygit versions.)
|
||||
- **external diff programs**, e.g. difftastic; these are called using git's `--ext-diff` flag, and they take over diff generation from git completely rather than post-processing git's output.
|
||||
- **git's raw output using custom arguments**; mainly useful for `--color-words` (or `--word-diff` if you are color blind).
|
||||
|
||||
Diff renderers are configured with the `diffRenderers` array in the `git` section of lazygit's config file; it is an array because you can have multiple entries that you can cycle through with the `|` key. This can be useful if you usually prefer a particular diff renderer, but want to use a different one for certain kinds of diffs.
|
||||
|
||||
Fields that are shared by all renderer types:
|
||||
|
||||
- **type** The type of diff renderer; choices are `stdinFilter`, `extDiff`, or `rawGit`. `stdinFilter` is the default, because it's the most common one; so you can omit this if you use delta.
|
||||
- **name** A name that is shown in the status bar toast when cycling renderers; defaults to the first word of the renderer command, but can be useful e.g. to distinguish "delta" from "delta side-by-side" if you have entries for both.
|
||||
|
||||
Fields only for `stdinFilter`:
|
||||
|
||||
- **command** The command line to use for `GIT_PAGER`.
|
||||
|
||||
- **colorArg** whether you want the `--color=always` arg in your `git diff` command. Some diff renderers want it set to `always`, others want it set to `never`. The default is `always`, since that's what most renderers need.
|
||||
|
||||
Fields only for `extDiff`:
|
||||
|
||||
- **command** The command line to use for the `diff.external` git config. If left empty, it uses the global value of git's `diff.external` config; this can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
|
||||
|
||||
You can include the `{{diffContext}}` template variable to pass lazygit's current diff context size (the value controlled by the `{`/`}` keybindings) to the diff tool.
|
||||
|
||||
Fields only for `rawGit`:
|
||||
|
||||
- **args** The additional arguments to use in the `git diff` or `git show` call (e.g. `--color-words`)
|
||||
|
||||
Here's an example for a multi-renderer setup:
|
||||
|
||||
```yaml
|
||||
git:
|
||||
diffRenderers:
|
||||
- command: delta --dark --paging=never
|
||||
- command: ydiff -p cat
|
||||
colorArg: never
|
||||
- type: extDiff
|
||||
command: difft --color=always --context={{diffContext}}
|
||||
- type: rawGit
|
||||
args: --color-words
|
||||
name: color-words
|
||||
- type: rawGit # git's default diff
|
||||
name: default
|
||||
```
|
||||
|
||||
## Delta:
|
||||
|
||||
```yaml
|
||||
git:
|
||||
diffRenderers:
|
||||
- command: delta --dark --paging=never
|
||||
```
|
||||
|
||||

|
||||
|
||||
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `command:` field to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
|
||||
|
||||
Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons.
|
||||
|
||||
## Diff-so-fancy
|
||||
|
||||
```yaml
|
||||
git:
|
||||
diffRenderers:
|
||||
- command: diff-so-fancy
|
||||
```
|
||||
|
||||

|
||||
|
||||
## ydiff
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
sidePanelWidth: 0.2 # gives you more space to show things side-by-side
|
||||
git:
|
||||
diffRenderers:
|
||||
- colorArg: never
|
||||
command: ydiff -p cat
|
||||
```
|
||||
|
||||

|
||||
@@ -1,92 +0,0 @@
|
||||
# Custom Pagers
|
||||
|
||||
Lazygit supports custom pagers, [configured](/docs/Config.md) in the config.yml file (which can be opened by pressing `e` in the Status panel).
|
||||
|
||||
Multiple pagers are supported; you can cycle through them with the `|` key. This can be useful if you usually prefer a particular pager, but want to use a different one for certain kinds of diffs.
|
||||
|
||||
Pagers are configured with the `pagers` array in the git section; here's an example for a multi-pager setup (use an empty object `{}` for the default builtin diff display that doesn't use a pager):
|
||||
|
||||
```yaml
|
||||
git:
|
||||
pagers:
|
||||
- pager: delta --dark --paging=never
|
||||
- pager: ydiff -p cat -s --wrap --width={{columnWidth}}
|
||||
colorArg: never
|
||||
- externalDiffCommand: difft --color=always
|
||||
- {} # default, no pager used
|
||||
```
|
||||
|
||||
The `colorArg` key is for whether you want the `--color=always` arg in your `git diff` command. Some pagers want it set to `always`, others want it set to `never`. The default is `always`, since that's what most pagers need.
|
||||
|
||||
## Delta:
|
||||
|
||||
```yaml
|
||||
git:
|
||||
pagers:
|
||||
- pager: delta --dark --paging=never
|
||||
```
|
||||
|
||||

|
||||
|
||||
A cool feature of delta is --hyperlinks, which renders clickable links for the line numbers in the left margin, and lazygit supports these. To use them, set the `pager:` config to `delta --dark --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"`; this allows you to click on an underlined line number in the diff to jump right to that same line in your editor.
|
||||
|
||||
Note that delta's `--navigate` option doesn't work in lazygit, for technical reasons.
|
||||
|
||||
## Diff-so-fancy
|
||||
|
||||
```yaml
|
||||
git:
|
||||
pagers:
|
||||
- pager: diff-so-fancy
|
||||
```
|
||||
|
||||

|
||||
|
||||
## ydiff
|
||||
|
||||
```yaml
|
||||
gui:
|
||||
sidePanelWidth: 0.2 # gives you more space to show things side-by-side
|
||||
git:
|
||||
pagers:
|
||||
- colorArg: never
|
||||
pager: ydiff -p cat -s --wrap --width={{columnWidth}}
|
||||
```
|
||||
|
||||

|
||||
|
||||
Be careful with this one, I think the homebrew and pip versions are behind master. I needed to directly download the ydiff script to get the no-pager functionality working.
|
||||
|
||||
## Using external diff commands
|
||||
|
||||
Some diff tools can't work as a simple pager like the ones above do, because they need access to the entire diff, so just post-processing git's diff is not enough for them. The most notable example is probably [difftastic](https://difftastic.wilfred.me.uk).
|
||||
|
||||
These can be used in lazygit by using the `externalDiffCommand` config; in the case of difftastic, that could be
|
||||
|
||||
```yaml
|
||||
git:
|
||||
pagers:
|
||||
- externalDiffCommand: difft --color=always
|
||||
```
|
||||
|
||||
The `colorArg` option is not used in this case.
|
||||
|
||||
You can add whatever extra arguments you prefer for your difftool; for instance
|
||||
|
||||
```yaml
|
||||
git:
|
||||
pagers:
|
||||
- externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off
|
||||
```
|
||||
|
||||
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
|
||||
git:
|
||||
pagers:
|
||||
- useExternalDiffGitConfig: true
|
||||
```
|
||||
|
||||
This can be useful if you also want to use it for diffs on the command line, and it also has the advantage that you can configure it per file type in `.gitattributes`; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
|
||||
|
||||
`pager`, `externalDiffCommand`, and `useExternalDiffGitConfig` are alternative ways of producing the diff, so a pager entry may use at most one of them.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
* [Configuration](./Config.md).
|
||||
* [Custom Commands](./Custom_Command_Keybindings.md)
|
||||
* [Custom Pagers](./Custom_Pagers.md)
|
||||
* [Custom Diff Renderers](./Custom_DiffRenderers.md)
|
||||
* [Dev docs](./dev)
|
||||
* [Keybindings](./keybindings)
|
||||
* [Undo/Redo](./Undoing.md)
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | Refresh | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` + `` | Next screen mode (normal/half/fullscreen) | |
|
||||
| `` _ `` | Prev screen mode | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | Cancel | |
|
||||
| `` ? `` | Open keybindings menu | |
|
||||
| `` <ctrl+s> `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | 更新 | Gitの状態を更新します(`git status`、`git branch`などをバックグラウンドで実行してパネルの内容を更新します)。これは`git fetch`を実行しません。 |
|
||||
| `` + `` | 次の画面モード(通常/半分/全画面) | |
|
||||
| `` _ `` | 前の画面モード | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | キャンセル | |
|
||||
| `` ? `` | キーバインディングメニューを開く | |
|
||||
| `` <ctrl+s> `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | 새로고침 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` + `` | 다음 스크린 모드 (normal/half/fullscreen) | |
|
||||
| `` _ `` | 이전 스크린 모드 | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | 취소 | |
|
||||
| `` ? `` | 매뉴 열기 | |
|
||||
| `` <ctrl+s> `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
|
||||
@@ -9,31 +9,31 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <ctrl+r> `` | Wissel naar een recente repo | |
|
||||
| `` <pgup>, K, <ctrl+u> (fn+up/shift+k) `` | Scroll naar beneden vanaf hoofdpaneel | |
|
||||
| `` <pgdown>, J, <ctrl+d> (fn+down/shift+j) `` | Scroll naar beneden vanaf hoofdpaneel | |
|
||||
| `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. |
|
||||
| `` @ `` | Commandolog opties weergeven | Bekijk commandolog opties, bijv. commandolog tonen/verbergen en focus. |
|
||||
| `` P `` | Push | Push de huidige branch naar de bijbehorende upstream-branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren. |
|
||||
| `` p `` | Pull | Pull wijzigingen van de remote voor de huidige branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren. |
|
||||
| `` ) `` | Increase rename similarity threshold | Increase the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` ( `` | Decrease rename similarity threshold | Decrease the similarity threshold for a deletion and addition pair to be treated as a rename.<br><br>The default can be changed in the config file with the key 'git.renameSimilarityThreshold'. |
|
||||
| `` } `` | Increase diff context size | Increase the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
| `` { `` | Decrease diff context size | Decrease the amount of the context shown around changes in the diff view.<br><br>The default can be changed in the config file with the key 'git.diffContextSize'. |
|
||||
| `` : `` | Execute shell command | Bring up a prompt where you can enter a shell command to execute. |
|
||||
| `` : `` | Voer shellcommando uit | Bring up a prompt where you can enter a shell command to execute. |
|
||||
| `` <ctrl+p> `` | Bekijk aangepaste patch opties | |
|
||||
| `` m `` | Bekijk merge/rebase opties | Toon abort/continue/skip opties voor huidige merge/rebase. |
|
||||
| `` R `` | Verversen | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` + `` | Volgende scherm modus (normaal/half/groot) | |
|
||||
| `` _ `` | Vorige scherm modus | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | Annuleren | |
|
||||
| `` ? `` | Open menu | |
|
||||
| `` <ctrl+s> `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
| `` W, <ctrl+e> `` | Open diff menu | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. |
|
||||
| `` q, <ctrl+c> `` | Afsluiten | |
|
||||
| `` <ctrl+z> `` | Pauzeer de applicatie | |
|
||||
| `` <ctrl+w> `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
|
||||
| `` <ctrl+w> `` | Witruimte weergeven in-/uitschakelen | Toggle whether or not whitespace changes are shown in the diff view.<br><br>The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. |
|
||||
| `` <alt+shift+c> `` | Verander config bestand | Open bestand in externe editor. |
|
||||
| `` z `` | Ongedaan maken (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to undo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
|
||||
| `` Z `` | Redo (via reflog) (experimenteel) | The reflog will be used to determine what git command to run to redo the last git command. This does not include changes to the working tree; only commits are taken into consideration. |
|
||||
| `` Z `` | Redo (via reflog) (experimenteel) | Het reflog wordt gebruikt om te bepalen welk git commando moet worden gebruikt om het laatste git commando te herhalen. Wijzigingen aan de working tree worden niet meegenomen, alleen command's zijn kandidaten. |
|
||||
|
||||
## Lijstpaneel navigatie
|
||||
|
||||
@@ -161,25 +161,25 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` s `` | Squash | Squash the selected commit into the commit below it. The selected commit's message will be appended to the commit below it. |
|
||||
| `` f `` | Fixup | Meld the selected commit into the commit below it. Similar to squash, but the selected commit's message will be discarded. |
|
||||
| `` c `` | Set fixup message | Set the message option for the fixup commit. The -C option means to use this commit's message instead of the target commit's message. |
|
||||
| `` r `` | Hernoem commit | Reword the selected commit's message. |
|
||||
| `` r `` | Hernoem commit | Herschrijf de commit message van de geselecteerde commit. |
|
||||
| `` R `` | Hernoem commit met editor | |
|
||||
| `` d `` | Verwijder commit | Drop the selected commit. This will remove the commit from the branch via a rebase. If the commit makes changes that later commits depend on, you may need to resolve merge conflicts. |
|
||||
| `` e `` | Edit (start interactive rebase) | Wijzig commit |
|
||||
| `` i `` | Start interactive rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.<br>If you would instead like to start an interactive rebase from the selected commit, press `e`. |
|
||||
| `` e `` | Bewerken (start interactieve rebase) | Wijzig commit |
|
||||
| `` i `` | Start interactieve rebase | Start an interactive rebase for the commits on your branch. This will include all commits from the HEAD commit down to the first merge commit or main branch commit.<br>If you would instead like to start an interactive rebase from the selected commit, press `e`. |
|
||||
| `` p `` | Pick | Kies commit (wanneer midden in rebase) |
|
||||
| `` F `` | Creëer fixup commit | Creëer fixup commit |
|
||||
| `` S `` | Apply fixup commits | Squash bovenstaande commits |
|
||||
| `` <ctrl+j>, <alt+down> `` | Verplaats commit 1 naar beneden | |
|
||||
| `` <ctrl+k>, <alt+up> `` | Verplaats commit 1 naar boven | |
|
||||
| `` V `` | Plak commits (cherry-pick) | |
|
||||
| `` B `` | Mark as base commit for rebase | Select a base commit for the next rebase. When you rebase onto a branch, only commits above the base commit will be brought across. This uses the `git rebase --onto` command. |
|
||||
| `` B `` | Markeer als basiscommit voor rebase | Selecteer een basiscommit voor de volgende rebase. Als je rebased op een branch worden alleen commits boven de basiscommit meegenomen. Hiervoor wordt het `git rebase --onto` commando gebruikt. |
|
||||
| `` A `` | Amend | Wijzig commit met staged veranderingen |
|
||||
| `` a `` | Amend commit attribute | Set/Reset commit author or set co-author. |
|
||||
| `` t `` | Revert | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. |
|
||||
| `` T `` | Tag commit | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. |
|
||||
| `` <ctrl+l> `` | View log options | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
|
||||
| `` t `` | Revert | Maak een revert commit voor de geselecteerde commit, die de wijzigingen in deze commit terugdraait. |
|
||||
| `` T `` | Tag commit | Maak een nieuwe tag die naar de geselecteerde commit wijst. Je wordt gevraagd om een tag naam en optionele omschrijving. |
|
||||
| `` <ctrl+l> `` | Log opties weergeven | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. |
|
||||
| `` G `` | Open pull request in browser | |
|
||||
| `` <space> `` | Uitchecken | Checkout the selected commit as a detached HEAD. |
|
||||
| `` <space> `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. |
|
||||
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` o `` | Open commit in browser | |
|
||||
| `` n `` | Creëer nieuwe branch van commit | |
|
||||
@@ -241,7 +241,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <left>, h `` | Selecteer de vorige hunk | |
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` o `` | Open bestand | Open bestand in standaardapplicatie. |
|
||||
| `` e `` | Verander bestand | Open bestand in externe editor. |
|
||||
@@ -255,7 +255,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <space> `` | Uitchecken | Checkout the selected commit as a detached HEAD. |
|
||||
| `` <space> `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. |
|
||||
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` o `` | Open commit in browser | |
|
||||
| `` n `` | Creëer nieuwe branch van commit | |
|
||||
@@ -316,7 +316,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` <left>, h `` | Selecteer de vorige hunk | |
|
||||
| `` <right>, l `` | Selecteer de volgende hunk | |
|
||||
| `` v `` | Toggle drag selecteer | |
|
||||
| `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. |
|
||||
| `` a `` | Wissel tussen hunk selectie aan of uit | Wissel tussen regel-voor-regel of hunk selectie modus. |
|
||||
| `` <ctrl+o> `` | Copy selected text to clipboard | |
|
||||
| `` <space> `` | Toggle staged | Toggle lijnen staged / unstaged |
|
||||
| `` d `` | Verwijdert change (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. |
|
||||
@@ -361,7 +361,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| Key | Action | Info |
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy abbreviated commit hash to clipboard | |
|
||||
| `` <space> `` | Uitchecken | Checkout the selected commit as a detached HEAD. |
|
||||
| `` <space> `` | Uitchecken | Check de geselecteerde branch uit als een detached HEAD. |
|
||||
| `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). |
|
||||
| `` o `` | Open commit in browser | |
|
||||
| `` n `` | Creëer nieuwe branch van commit | |
|
||||
@@ -396,10 +396,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
|-----|--------|-------------|
|
||||
| `` <ctrl+o> `` | Copy tag to clipboard | |
|
||||
| `` <space> `` | Uitchecken | Geselecteerde tag uitchecken als detached HEAD. |
|
||||
| `` n `` | Creëer tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. |
|
||||
| `` n `` | Creëer tag | Maak een nieuwe tag die naar de huidige commit wijst. Je wordt gevraagd om een tag naam en optionele beschrijving. |
|
||||
| `` w `` | New worktree | |
|
||||
| `` d `` | Verwijderen | View delete options for local/remote tag. |
|
||||
| `` P `` | Push tag | Push the selected tag to a remote. You'll be prompted to select a remote. |
|
||||
| `` P `` | Tag pushen | Push de geselecteerde tag naar een remote. Je krijgt de optie een remote te selecteren. |
|
||||
| `` g `` | Resetten | View reset options (soft/mixed/hard) for resetting onto selected item. |
|
||||
| `` <ctrl+t> `` | Open externe diff applicatie (git difftool) | |
|
||||
| `` 0 `` | Focus main view | |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | Odśwież | Odśwież stan git (tj. uruchom `git status`, `git branch`, itp. w tle, aby zaktualizować zawartość paneli). To nie uruchamia `git fetch`. |
|
||||
| `` + `` | Następny tryb ekranu (normalny/półpełny/pełnoekranowy) | |
|
||||
| `` _ `` | Poprzedni tryb ekranu | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | Anuluj | |
|
||||
| `` ? `` | Otwórz menu przypisań klawiszy | |
|
||||
| `` <ctrl+s> `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | Atualizar | Atualize o estado do git (ou seja, execute `git status`, `git branch`, etc em segundo plano para atualizar o conteúdo de painéis). Isso não executa `git fetch`. |
|
||||
| `` + `` | Modo de tela seguinte (normal/metade/tela cheia) | |
|
||||
| `` _ `` | Modo de tela anterior | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | Cancelar | |
|
||||
| `` ? `` | Abrir o menu de atalhos do teclado | |
|
||||
| `` <ctrl+s> `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | Обновить | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` + `` | Следующий режим экрана (нормальный/полуэкранный/полноэкранный) | |
|
||||
| `` _ `` | Предыдущий режим экрана | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | Отменить | |
|
||||
| `` ? `` | Открыть меню | |
|
||||
| `` <ctrl+s> `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | 刷新 | 刷新Git状态(即在后台运行`git status`、`git branch`等命令以更新面板内容)。此操作不会执行`git fetch`。 |
|
||||
| `` + `` | 下一屏模式(正常/半屏/全屏) | |
|
||||
| `` _ `` | 上一屏模式 | |
|
||||
| `` \| `` | 切换分页器 | 从已配置的分页器列表中选择下一个分页器 |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | 取消 | |
|
||||
| `` ? `` | 打开菜单 | |
|
||||
| `` <ctrl+s> `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 |
|
||||
|
||||
@@ -22,8 +22,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
|
||||
| `` R `` | 重新整理 | Refresh the git state (i.e. run `git status`, `git branch`, etc in background to update the contents of panels). This does not run `git fetch`. |
|
||||
| `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | |
|
||||
| `` _ `` | 上一個螢幕模式 | |
|
||||
| `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers. |
|
||||
| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. |
|
||||
| `` \| `` | Cycle diff renderers | Choose the next renderer in the list of configured diff renderers. |
|
||||
| `` \ `` | Cycle diff renderers (reverse) | Choose the previous renderer in the list of configured diff renderers. |
|
||||
| `` <esc> `` | 取消 | |
|
||||
| `` ? `` | 開啟選單 | |
|
||||
| `` <ctrl+s> `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. |
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
# Development tools
|
||||
git
|
||||
gnumake
|
||||
just
|
||||
];
|
||||
|
||||
# Environment variables for development
|
||||
|
||||
@@ -13,7 +13,7 @@ require (
|
||||
github.com/cli/go-gh/v2 v2.13.0
|
||||
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/gdamore/tcell/v3 v3.4.0
|
||||
github.com/gdamore/tcell/v3 v3.4.1
|
||||
github.com/go-errors/errors v1.5.1
|
||||
github.com/gookit/color v1.6.1
|
||||
github.com/integrii/flaggy v1.8.0
|
||||
@@ -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
|
||||
@@ -37,8 +38,8 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/sys v0.47.0
|
||||
gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -62,14 +63,12 @@ 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/net v0.55.0 // indirect
|
||||
golang.org/x/term v0.43.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/fsnotify.v1 v1.4.7 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
|
||||
@@ -32,8 +32,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
|
||||
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v3 v3.4.0 h1:VUym1HQZiYodA5PGQrqLxF7QwqQndcAUwQD7G7XUy5E=
|
||||
github.com/gdamore/tcell/v3 v3.4.0/go.mod h1:fjKxNiIFwbzTxDU+i+AAMz+xPOgXVaZq5tbShsKseHc=
|
||||
github.com/gdamore/tcell/v3 v3.4.1 h1:22227t1EUwqxTlmCX9vw0RUE2IEPGw6oYcNan+bPe4w=
|
||||
github.com/gdamore/tcell/v3 v3.4.1/go.mod h1:YWwuxZNi14VGQC5g2VGNEDRXpBraTwvVjMovRH6G6hw=
|
||||
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
|
||||
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
@@ -139,19 +139,19 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20170407050850-f3918c30c5c2/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -163,26 +163,26 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -40,7 +40,7 @@ lint:
|
||||
./scripts/gofumpt-check.sh
|
||||
./scripts/golangci-lint-shim.sh run
|
||||
|
||||
e2e-test-command := "go test pkg/integration/clients/*.go"
|
||||
e2e-test-command := "go test -timeout 30m pkg/integration/clients/*.go"
|
||||
|
||||
# Run integration tests headlessly: no args runs all tests, a test name (or path) runs just that one. Use e2e-cli for a visible UI.
|
||||
e2e *args:
|
||||
|
||||
@@ -263,12 +263,14 @@ func (self *MoveFixupCommitDownInstruction) run(common *common.Common) error {
|
||||
}
|
||||
|
||||
type MoveTodosUpInstruction struct {
|
||||
Hashes []string
|
||||
Hashes []string
|
||||
Distance int
|
||||
}
|
||||
|
||||
func NewMoveTodosUpInstruction(hashes []string) Instruction {
|
||||
func NewMoveTodosUpInstruction(hashes []string, distance int) Instruction {
|
||||
return &MoveTodosUpInstruction{
|
||||
Hashes: hashes,
|
||||
Hashes: hashes,
|
||||
Distance: distance,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,17 +290,19 @@ func (self *MoveTodosUpInstruction) run(common *common.Common) error {
|
||||
})
|
||||
|
||||
return handleInteractiveRebase(common, func(path string) error {
|
||||
return utils.MoveTodosUp(path, todosToMove, false, getCommentChar())
|
||||
return utils.MoveTodos(path, todosToMove, false, -self.Distance, getCommentChar())
|
||||
})
|
||||
}
|
||||
|
||||
type MoveTodosDownInstruction struct {
|
||||
Hashes []string
|
||||
Hashes []string
|
||||
Distance int
|
||||
}
|
||||
|
||||
func NewMoveTodosDownInstruction(hashes []string) Instruction {
|
||||
func NewMoveTodosDownInstruction(hashes []string, distance int) Instruction {
|
||||
return &MoveTodosDownInstruction{
|
||||
Hashes: hashes,
|
||||
Hashes: hashes,
|
||||
Distance: distance,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +322,7 @@ func (self *MoveTodosDownInstruction) run(common *common.Common) error {
|
||||
})
|
||||
|
||||
return handleInteractiveRebase(common, func(path string) error {
|
||||
return utils.MoveTodosDown(path, todosToMove, false, getCommentChar())
|
||||
return utils.MoveTodos(path, todosToMove, false, self.Distance, getCommentChar())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+9
-5
@@ -60,7 +60,7 @@ func NewGitCommand(
|
||||
version *git_commands.GitVersion,
|
||||
osCommand *oscommands.OSCommand,
|
||||
gitConfig git_config.IGitConfig,
|
||||
pagerConfig *config.PagerConfig,
|
||||
diffRendererConfigManager *config.DiffRendererConfigManager,
|
||||
) (*GitCommand, error) {
|
||||
repoPaths, err := git_commands.GetRepoPaths(osCommand.Cmd, version)
|
||||
if err != nil {
|
||||
@@ -72,13 +72,17 @@ func NewGitCommand(
|
||||
return nil, utils.WrapError(err)
|
||||
}
|
||||
|
||||
// Pin the config reads to the repo directory like all other git commands
|
||||
// (see NewGitCmdObjBuilder); the config commands run outside that builder.
|
||||
gitConfig.SetDir(repoPaths.WorktreePath())
|
||||
|
||||
return NewGitCommandAux(
|
||||
cmn,
|
||||
version,
|
||||
osCommand,
|
||||
gitConfig,
|
||||
repoPaths,
|
||||
pagerConfig,
|
||||
diffRendererConfigManager,
|
||||
), nil
|
||||
}
|
||||
|
||||
@@ -88,9 +92,9 @@ func NewGitCommandAux(
|
||||
osCommand *oscommands.OSCommand,
|
||||
gitConfig git_config.IGitConfig,
|
||||
repoPaths *git_commands.RepoPaths,
|
||||
pagerConfig *config.PagerConfig,
|
||||
diffRendererConfigManager *config.DiffRendererConfigManager,
|
||||
) *GitCommand {
|
||||
cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd)
|
||||
cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath())
|
||||
|
||||
// here we're doing a bunch of dependency injection for each of our commands structs.
|
||||
// This is admittedly messy, but allows us to test each command struct in isolation,
|
||||
@@ -99,7 +103,7 @@ func NewGitCommandAux(
|
||||
// common ones are: cmn, osCommand, dotGitDir, configCommands
|
||||
configCommands := git_commands.NewConfigCommands(cmn, gitConfig)
|
||||
|
||||
gitCommon := git_commands.NewGitCommon(cmn, version, cmd, osCommand, repoPaths, configCommands, pagerConfig)
|
||||
gitCommon := git_commands.NewGitCommon(cmn, version, cmd, osCommand, repoPaths, configCommands, diffRendererConfigManager)
|
||||
|
||||
fileLoader := git_commands.NewFileLoader(gitCommon, cmd, configCommands)
|
||||
statusCommands := git_commands.NewStatusCommands(gitCommon)
|
||||
|
||||
@@ -11,6 +11,15 @@ import (
|
||||
|
||||
type gitCmdObjBuilder struct {
|
||||
innerBuilder *oscommands.CmdObjBuilder
|
||||
|
||||
// The directory of the repo (or worktree) this builder was created for;
|
||||
// every command we produce runs there, regardless of the process's current
|
||||
// working directory. The two are the same until the user switches to
|
||||
// another repo: lazygit chdirs on a switch, but work still in flight for
|
||||
// the previous repo (e.g. a background refresh spawning commands through
|
||||
// the old builder) must keep running its commands against the repo it
|
||||
// started in, not whichever one the process has since moved to.
|
||||
repoDir string
|
||||
}
|
||||
|
||||
var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{}
|
||||
@@ -21,7 +30,7 @@ var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{}
|
||||
// only the foreground files refresh) opt back in via CmdObj.RemoveEnvVar.
|
||||
var defaultEnvVar = git_commands.OptionalLocksEnvVar + "=0"
|
||||
|
||||
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *gitCmdObjBuilder {
|
||||
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string) *gitCmdObjBuilder {
|
||||
// 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{
|
||||
@@ -33,15 +42,16 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild
|
||||
|
||||
return &gitCmdObjBuilder{
|
||||
innerBuilder: updatedBuilder,
|
||||
repoDir: repoDir,
|
||||
}
|
||||
}
|
||||
|
||||
func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj {
|
||||
return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar)
|
||||
return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar).SetWd(self.repoDir)
|
||||
}
|
||||
|
||||
func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj {
|
||||
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar)
|
||||
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar).SetWd(self.repoDir)
|
||||
}
|
||||
|
||||
func (self *gitCmdObjBuilder) Quote(str string) string {
|
||||
|
||||
@@ -17,8 +17,25 @@ func TestGitCmdObjBuilderDisablesOptionalLocksByDefault(t *testing.T) {
|
||||
builder := NewGitCmdObjBuilder(
|
||||
utils.NewDummyLog(),
|
||||
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
|
||||
"/path/to/repo",
|
||||
)
|
||||
|
||||
assert.Contains(t, builder.New([]string{"git", "status"}).GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0")
|
||||
assert.Contains(t, builder.NewShell("git status", "").GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0")
|
||||
}
|
||||
|
||||
// Every command the builder produces runs in the directory of the repo the
|
||||
// builder was created for, not in the process's current directory: lazygit
|
||||
// chdirs when switching repos, and commands built for the previous repo after
|
||||
// that (e.g. by a background refresh still in flight) must keep addressing the
|
||||
// repo they were built for.
|
||||
func TestGitCmdObjBuilderPinsCommandsToRepoDir(t *testing.T) {
|
||||
builder := NewGitCmdObjBuilder(
|
||||
utils.NewDummyLog(),
|
||||
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
|
||||
"/path/to/repo",
|
||||
)
|
||||
|
||||
assert.Equal(t, "/path/to/repo", builder.New([]string{"git", "status"}).GetCmd().Dir)
|
||||
assert.Equal(t, "/path/to/repo", builder.NewShell("git status", "").GetCmd().Dir)
|
||||
}
|
||||
|
||||
@@ -29,5 +29,5 @@ func (self *BlameCommands) BlameLineRange(filename string, commit string, firstL
|
||||
Arg("--").
|
||||
Arg(filename)
|
||||
|
||||
return self.cmd.New(cmdArgs.ToArgv()).RunWithOutput()
|
||||
return self.cmd.New(cmdArgs.ToArgv()).DontLog().RunWithOutput()
|
||||
}
|
||||
|
||||
@@ -241,23 +241,15 @@ func (self *CommitCommands) AmendHeadCmdObj() *oscommands.CmdObj {
|
||||
}
|
||||
|
||||
func (self *CommitCommands) ShowCmdObj(hash string, filterPaths []string) *oscommands.CmdObj {
|
||||
contextSize := self.UserConfig().Git.DiffContextSize
|
||||
|
||||
extDiffCmd := self.pagerConfig.GetExternalDiffCommand()
|
||||
useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig()
|
||||
cmdArgs := NewGitCmd("show").
|
||||
Config("diff.noprefix=false").
|
||||
ConfigIf(extDiffCmd != "", "diff.external="+extDiffCmd).
|
||||
ArgIfElse(extDiffCmd != "" || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
|
||||
Arg("--submodule").
|
||||
Arg("--color="+self.pagerConfig.GetColorArg()).
|
||||
Arg(fmt.Sprintf("--unified=%d", contextSize)).
|
||||
Arg("--color=" + self.diffRendererConfigManager.GetColorArg()).
|
||||
Arg("--stat").
|
||||
Arg("--decorate").
|
||||
Arg("-p").
|
||||
Arg(hash).
|
||||
ArgIf(self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
|
||||
Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)).
|
||||
Arg("--").
|
||||
Arg(filterPaths...).
|
||||
Dir(self.repoPaths.worktreePath).
|
||||
|
||||
@@ -255,7 +255,7 @@ func TestCommitShowCmdObj(t *testing.T) {
|
||||
contextSize uint64
|
||||
similarityThreshold int
|
||||
ignoreWhitespace bool
|
||||
pagerConfig *config.PagingConfig
|
||||
diffRendererConfig *config.DiffRendererConfig
|
||||
expected []string
|
||||
}
|
||||
|
||||
@@ -266,8 +266,8 @@ func TestCommitShowCmdObj(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: false,
|
||||
pagerConfig: nil,
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--"},
|
||||
diffRendererConfig: nil,
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
|
||||
},
|
||||
{
|
||||
testName: "Default case with filter path",
|
||||
@@ -275,8 +275,8 @@ func TestCommitShowCmdObj(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: false,
|
||||
pagerConfig: nil,
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--", "file.txt"},
|
||||
diffRendererConfig: nil,
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--", "file.txt"},
|
||||
},
|
||||
{
|
||||
testName: "Show diff with custom context size",
|
||||
@@ -284,8 +284,8 @@ func TestCommitShowCmdObj(t *testing.T) {
|
||||
contextSize: 77,
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: false,
|
||||
pagerConfig: nil,
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=77", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--"},
|
||||
diffRendererConfig: nil,
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=77", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
|
||||
},
|
||||
{
|
||||
testName: "Show diff with custom similarity threshold",
|
||||
@@ -293,8 +293,8 @@ func TestCommitShowCmdObj(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 33,
|
||||
ignoreWhitespace: false,
|
||||
pagerConfig: nil,
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=33%", "--"},
|
||||
diffRendererConfig: nil,
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=3", "--find-renames=33%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
|
||||
},
|
||||
{
|
||||
testName: "Show diff, ignoring whitespace",
|
||||
@@ -302,8 +302,8 @@ func TestCommitShowCmdObj(t *testing.T) {
|
||||
contextSize: 77,
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: true,
|
||||
pagerConfig: nil,
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--submodule", "--color=always", "--unified=77", "--stat", "--decorate", "-p", "1234567890", "--ignore-all-space", "--find-renames=50%", "--"},
|
||||
diffRendererConfig: nil,
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--no-ext-diff", "--unified=77", "--ignore-all-space", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
|
||||
},
|
||||
{
|
||||
testName: "Show diff with external diff command",
|
||||
@@ -311,8 +311,8 @@ func TestCommitShowCmdObj(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: false,
|
||||
pagerConfig: &config.PagingConfig{ExternalDiffCommand: "difft --color=always"},
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "-c", "diff.noprefix=false", "show", "--ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--"},
|
||||
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
|
||||
},
|
||||
{
|
||||
testName: "Show diff using git's external diff config",
|
||||
@@ -320,16 +320,16 @@ func TestCommitShowCmdObj(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: false,
|
||||
pagerConfig: &config.PagingConfig{UseExternalDiffGitConfig: true},
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--ext-diff", "--submodule", "--color=always", "--unified=3", "--stat", "--decorate", "-p", "1234567890", "--find-renames=50%", "--"},
|
||||
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff"},
|
||||
expected: []string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--stat", "--decorate", "-p", "1234567890", "--"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.testName, func(t *testing.T) {
|
||||
userConfig := config.GetDefaultConfig()
|
||||
if s.pagerConfig != nil {
|
||||
userConfig.Git.Pagers = []config.PagingConfig{*s.pagerConfig}
|
||||
if s.diffRendererConfig != nil {
|
||||
userConfig.Git.DiffRenderers = []config.DiffRendererConfig{*s.diffRendererConfig}
|
||||
}
|
||||
userConfig.Git.IgnoreWhitespaceInDiffView = s.ignoreWhitespace
|
||||
userConfig.Git.DiffContextSize = s.contextSize
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
|
||||
type GitCommon struct {
|
||||
*common.Common
|
||||
version *GitVersion
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
os *oscommands.OSCommand
|
||||
repoPaths *RepoPaths
|
||||
config *ConfigCommands
|
||||
pagerConfig *config.PagerConfig
|
||||
version *GitVersion
|
||||
cmd oscommands.ICmdObjBuilder
|
||||
os *oscommands.OSCommand
|
||||
repoPaths *RepoPaths
|
||||
config *ConfigCommands
|
||||
diffRendererConfigManager *config.DiffRendererConfigManager
|
||||
}
|
||||
|
||||
func NewGitCommon(
|
||||
@@ -23,15 +23,15 @@ func NewGitCommon(
|
||||
osCommand *oscommands.OSCommand,
|
||||
repoPaths *RepoPaths,
|
||||
config *ConfigCommands,
|
||||
pagerConfig *config.PagerConfig,
|
||||
diffRendererConfigManager *config.DiffRendererConfigManager,
|
||||
) *GitCommon {
|
||||
return &GitCommon{
|
||||
Common: cmn,
|
||||
version: version,
|
||||
cmd: cmd,
|
||||
os: osCommand,
|
||||
repoPaths: repoPaths,
|
||||
config: config,
|
||||
pagerConfig: pagerConfig,
|
||||
Common: cmn,
|
||||
version: version,
|
||||
cmd: cmd,
|
||||
os: osCommand,
|
||||
repoPaths: repoPaths,
|
||||
config: config,
|
||||
diffRendererConfigManager: diffRendererConfigManager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func buildGitCommon(deps commonDeps) *GitCommon {
|
||||
gitCommon.Common.SetUserConfig(config.GetDefaultConfig())
|
||||
}
|
||||
|
||||
gitCommon.pagerConfig = config.NewPagerConfig(func() *config.UserConfig {
|
||||
gitCommon.diffRendererConfigManager = config.NewDiffRendererConfigManager(func() *config.UserConfig {
|
||||
return gitCommon.Common.UserConfig()
|
||||
})
|
||||
|
||||
|
||||
@@ -17,22 +17,14 @@ func NewDiffCommands(gitCommon *GitCommon) *DiffCommands {
|
||||
}
|
||||
|
||||
// This is for generating diffs to be shown in the UI (e.g. rendering a range
|
||||
// diff to the main view). It uses a custom pager if one is configured.
|
||||
// diff to the main view). It uses a custom diff renderer if one is configured.
|
||||
func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj {
|
||||
extDiffCmd := self.pagerConfig.GetExternalDiffCommand()
|
||||
useExtDiff := extDiffCmd != ""
|
||||
useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig()
|
||||
ignoreWhitespace := self.UserConfig().Git.IgnoreWhitespaceInDiffView
|
||||
|
||||
return self.cmd.New(
|
||||
NewGitCmd("diff").
|
||||
Config("diff.noprefix=false").
|
||||
ConfigIf(useExtDiff, "diff.external="+extDiffCmd).
|
||||
ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
|
||||
Arg("--submodule").
|
||||
Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())).
|
||||
ArgIf(ignoreWhitespace, "--ignore-all-space").
|
||||
Arg(fmt.Sprintf("--unified=%d", self.UserConfig().Git.DiffContextSize)).
|
||||
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
|
||||
Arg(diffArgs...).
|
||||
Dir(self.repoPaths.worktreePath).
|
||||
ToArgv(),
|
||||
@@ -40,8 +32,8 @@ func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj {
|
||||
}
|
||||
|
||||
// This is a basic generic diff command that can be used for any diff operation
|
||||
// (e.g. copying a diff to the clipboard). It will not use a custom pager, and
|
||||
// does not use user configs such as ignore whitespace.
|
||||
// (e.g. copying a diff to the clipboard). It will not use a custom diff renderer,
|
||||
// and does not use user configs such as ignore whitespace.
|
||||
// If you want to diff specific refs (one or two), you need to add them yourself
|
||||
// in additionalArgs; it is recommended to also pass `--` after that. If you
|
||||
// want to restrict the diff to specific paths, pass them in additionalArgs
|
||||
|
||||
@@ -2,6 +2,7 @@ package git_commands
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -93,7 +94,7 @@ func (self *FileCommands) guessDefaultEditor() string {
|
||||
// At this point, it might be more than just the name of the editor;
|
||||
// e.g. it might be "code -w" or "vim -u myvim.rc". So assume that
|
||||
// everything up to the first space is the editor name.
|
||||
editor = strings.Split(editor, " ")[0]
|
||||
editor = filepath.Base(strings.Split(editor, " ")[0])
|
||||
}
|
||||
|
||||
return editor
|
||||
|
||||
@@ -203,6 +203,17 @@ func TestGuessDefaultEditor(t *testing.T) {
|
||||
},
|
||||
expectedResult: "bbedit",
|
||||
},
|
||||
{
|
||||
gitConfigMockResponses: nil,
|
||||
getenv: func(env string) string {
|
||||
if env == "EDITOR" {
|
||||
return "/usr/bin/nvim"
|
||||
}
|
||||
|
||||
return ""
|
||||
},
|
||||
expectedResult: "nvim",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
)
|
||||
|
||||
// OptionalLocksEnvVar is the name of the environment variable that tells git
|
||||
@@ -111,6 +113,20 @@ func (self *GitCommandBuilder) GitDirIf(condition bool, path string) *GitCommand
|
||||
return self
|
||||
}
|
||||
|
||||
func (self *GitCommandBuilder) AddCommonDiffArgs(diffRendererConfigManager *config.DiffRendererConfigManager, userConfig *config.UserConfig, forUI bool) *GitCommandBuilder {
|
||||
contextSize := userConfig.Git.DiffContextSize
|
||||
extDiffCmd := diffRendererConfigManager.GetExternalDiffCommand(contextSize)
|
||||
useExtDiff := forUI && diffRendererConfigManager.GetDiffRendererType() == config.DiffRendererType_ExtDiff
|
||||
|
||||
return self.
|
||||
ConfigIf(forUI && extDiffCmd != "", "diff.external="+extDiffCmd).
|
||||
ArgIfElse(useExtDiff, "--ext-diff", "--no-ext-diff").
|
||||
Arg(fmt.Sprintf("--unified=%d", contextSize)).
|
||||
ArgIf(forUI && userConfig.Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
|
||||
Arg(fmt.Sprintf("--find-renames=%d%%", userConfig.Git.RenameSimilarityThreshold)).
|
||||
ArgIf(forUI, diffRendererConfigManager.GetRawGitArgs()...)
|
||||
}
|
||||
|
||||
func (self *GitCommandBuilder) ToArgv() []string {
|
||||
return append([]string{"git"}, self.args...)
|
||||
}
|
||||
|
||||
@@ -85,12 +85,25 @@ type PullRequestNode struct {
|
||||
HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"`
|
||||
State string `json:"state"`
|
||||
IsDraft bool `json:"isDraft"`
|
||||
HeadRef GithubRef `json:"headRef"`
|
||||
}
|
||||
|
||||
type GithubRepositoryOwner struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
|
||||
type GithubRef struct {
|
||||
Target GithubGitObject `json:"target"`
|
||||
}
|
||||
|
||||
type GithubGitObject struct {
|
||||
StatusCheckRollup GithubStatusCheckRollup `json:"statusCheckRollup"`
|
||||
}
|
||||
|
||||
type GithubStatusCheckRollup struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
type graphQLRequest struct {
|
||||
Query string `json:"query"`
|
||||
Variables map[string]string `json:"variables"`
|
||||
@@ -121,6 +134,15 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
|
||||
number
|
||||
url
|
||||
isDraft
|
||||
headRef {
|
||||
target {
|
||||
... on Commit {
|
||||
statusCheckRollup {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
headRepositoryOwner {
|
||||
login
|
||||
}
|
||||
@@ -210,7 +232,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
|
||||
@@ -228,9 +253,12 @@ func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return parsePullRequestsResponse(respBytes)
|
||||
}
|
||||
|
||||
func parsePullRequestsResponse(respBytes []byte) ([]*models.GithubPullRequest, error) {
|
||||
var result Response
|
||||
err = json.Unmarshal(respBytes, &result)
|
||||
if err != nil {
|
||||
if err := json.Unmarshal(respBytes, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -243,6 +271,7 @@ func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string,
|
||||
Number: node.Number,
|
||||
Title: node.Title,
|
||||
State: lo.Ternary(node.IsDraft && node.State != "CLOSED", "DRAFT", node.State),
|
||||
ChecksState: node.HeadRef.Target.StatusCheckRollup.State,
|
||||
Url: node.Url,
|
||||
HeadRepositoryOwner: models.GithubRepositoryOwner{
|
||||
Login: node.HeadRepositoryOwner.Login,
|
||||
|
||||
@@ -76,6 +76,104 @@ func TestGraphQLEndpoint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPullRequestsQueryFetchesOnlyAggregateCheckState(t *testing.T) {
|
||||
query, variables := fetchPullRequestsQuery([]string{"feature"}, "owner", "repo")
|
||||
|
||||
assert.Contains(t, query, "headRef {")
|
||||
assert.Contains(t, query, "... on Commit {")
|
||||
assert.Contains(t, query, "statusCheckRollup {")
|
||||
assert.NotContains(t, query, "contexts")
|
||||
assert.Equal(t, map[string]string{
|
||||
"owner": "owner",
|
||||
"repo": "repo",
|
||||
"branch1": "feature",
|
||||
}, variables)
|
||||
}
|
||||
|
||||
func TestParsePullRequestsResponse(t *testing.T) {
|
||||
t.Run("flattens aliases and normalizes drafts", func(t *testing.T) {
|
||||
response := []byte(`{
|
||||
"data": {
|
||||
"repository": {
|
||||
"a1": {
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
"title": "Add feature",
|
||||
"headRefName": "feature",
|
||||
"number": 42,
|
||||
"url": "https://github.com/jesseduffield/lazygit/pull/42",
|
||||
"headRepositoryOwner": {"login": "contributor"},
|
||||
"state": "OPEN",
|
||||
"isDraft": false,
|
||||
"headRef": {
|
||||
"target": {
|
||||
"statusCheckRollup": {"state": "SUCCESS"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"a2": {
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
"title": "Draft feature",
|
||||
"headRefName": "draft-feature",
|
||||
"number": 43,
|
||||
"url": "https://github.com/jesseduffield/lazygit/pull/43",
|
||||
"headRepositoryOwner": {"login": "contributor"},
|
||||
"state": "OPEN",
|
||||
"isDraft": true,
|
||||
"headRef": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
|
||||
prs, err := parsePullRequestsResponse(response)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.ElementsMatch(t, []*models.GithubPullRequest{
|
||||
{
|
||||
HeadRefName: "feature",
|
||||
Number: 42,
|
||||
Title: "Add feature",
|
||||
State: "OPEN",
|
||||
ChecksState: "SUCCESS",
|
||||
Url: "https://github.com/jesseduffield/lazygit/pull/42",
|
||||
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "contributor"},
|
||||
},
|
||||
{
|
||||
HeadRefName: "draft-feature",
|
||||
Number: 43,
|
||||
Title: "Draft feature",
|
||||
State: "DRAFT",
|
||||
Url: "https://github.com/jesseduffield/lazygit/pull/43",
|
||||
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "contributor"},
|
||||
},
|
||||
}, prs)
|
||||
})
|
||||
|
||||
t.Run("returns an empty slice for an empty result", func(t *testing.T) {
|
||||
prs, err := parsePullRequestsResponse([]byte(`{"data":{"repository":{}}}`))
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, prs)
|
||||
})
|
||||
|
||||
t.Run("rejects malformed JSON", func(t *testing.T) {
|
||||
prs, err := parsePullRequestsResponse([]byte(`{"data":`))
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, prs)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateGithubPullRequestMap(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -99,6 +197,7 @@ func TestGenerateGithubPullRequestMap(t *testing.T) {
|
||||
Number: 42,
|
||||
Title: "Add feature",
|
||||
State: "OPEN",
|
||||
ChecksState: "PENDING",
|
||||
Url: "https://github.com/jesseduffield/lazygit/pull/42",
|
||||
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
|
||||
},
|
||||
@@ -122,6 +221,7 @@ func TestGenerateGithubPullRequestMap(t *testing.T) {
|
||||
Number: 42,
|
||||
Title: "Add feature",
|
||||
State: "OPEN",
|
||||
ChecksState: "PENDING",
|
||||
Url: "https://github.com/jesseduffield/lazygit/pull/42",
|
||||
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
|
||||
},
|
||||
|
||||
@@ -112,29 +112,30 @@ func (self *RebaseCommands) GenericAmend(commits []*models.Commit, start, end in
|
||||
}
|
||||
|
||||
func (self *RebaseCommands) MoveCommitsDown(commits []*models.Commit, startIdx int, endIdx int) error {
|
||||
baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+2)
|
||||
|
||||
hashes := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string {
|
||||
return commit.Hash()
|
||||
})
|
||||
|
||||
return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
|
||||
baseHashOrRoot: baseHashOrRoot,
|
||||
instruction: daemon.NewMoveTodosDownInstruction(hashes),
|
||||
overrideEditor: true,
|
||||
}).Run()
|
||||
return self.MoveCommits(commits, startIdx, endIdx, 1)
|
||||
}
|
||||
|
||||
func (self *RebaseCommands) MoveCommitsUp(commits []*models.Commit, startIdx int, endIdx int) error {
|
||||
baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+1)
|
||||
return self.MoveCommits(commits, startIdx, endIdx, -1)
|
||||
}
|
||||
|
||||
func (self *RebaseCommands) MoveCommits(commits []*models.Commit, startIdx int, endIdx int, offset int) error {
|
||||
baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+max(offset, 0)+1)
|
||||
|
||||
hashes := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string {
|
||||
return commit.Hash()
|
||||
})
|
||||
|
||||
var instruction daemon.Instruction
|
||||
if offset > 0 {
|
||||
instruction = daemon.NewMoveTodosDownInstruction(hashes, offset)
|
||||
} else {
|
||||
instruction = daemon.NewMoveTodosUpInstruction(hashes, -offset)
|
||||
}
|
||||
|
||||
return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
|
||||
baseHashOrRoot: baseHashOrRoot,
|
||||
instruction: daemon.NewMoveTodosUpInstruction(hashes),
|
||||
instruction: instruction,
|
||||
overrideEditor: true,
|
||||
}).Run()
|
||||
}
|
||||
@@ -369,21 +370,20 @@ func (self *RebaseCommands) DeleteUpdateRefTodos(commits []*models.Commit) error
|
||||
}
|
||||
|
||||
func (self *RebaseCommands) MoveTodosDown(commits []*models.Commit) error {
|
||||
fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo")
|
||||
todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo {
|
||||
return todoFromCommit(commit)
|
||||
})
|
||||
|
||||
return utils.MoveTodosDown(fileName, todosToMove, true, self.config.GetCoreCommentChar())
|
||||
return self.MoveTodos(commits, 1)
|
||||
}
|
||||
|
||||
func (self *RebaseCommands) MoveTodosUp(commits []*models.Commit) error {
|
||||
return self.MoveTodos(commits, -1)
|
||||
}
|
||||
|
||||
func (self *RebaseCommands) MoveTodos(commits []*models.Commit, offset int) error {
|
||||
fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo")
|
||||
todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo {
|
||||
return todoFromCommit(commit)
|
||||
})
|
||||
|
||||
return utils.MoveTodosUp(fileName, todosToMove, true, self.config.GetCoreCommentChar())
|
||||
return utils.MoveTodos(fileName, todosToMove, true, offset, self.config.GetCoreCommentChar())
|
||||
}
|
||||
|
||||
// SquashAllAboveFixupCommits squashes all fixup! commits above the given one
|
||||
|
||||
@@ -81,20 +81,13 @@ func (self *StashCommands) Hash(index int) (string, error) {
|
||||
}
|
||||
|
||||
func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj {
|
||||
extDiffCmd := self.pagerConfig.GetExternalDiffCommand()
|
||||
useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig()
|
||||
|
||||
// "-u" is the same as "--include-untracked", but the latter fails in older git versions for some reason
|
||||
cmdArgs := NewGitCmd("stash").Arg("show").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), true).
|
||||
Arg("-p").
|
||||
Arg("--stat").
|
||||
Arg("-u").
|
||||
ConfigIf(extDiffCmd != "", "diff.external="+extDiffCmd).
|
||||
ArgIfElse(extDiffCmd != "" || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff").
|
||||
Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())).
|
||||
Arg(fmt.Sprintf("--unified=%d", self.UserConfig().Git.DiffContextSize)).
|
||||
ArgIf(self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
|
||||
Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)).
|
||||
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
|
||||
Arg(fmt.Sprintf("refs/stash@{%d}", index)).
|
||||
Dir(self.repoPaths.worktreePath).
|
||||
ToArgv()
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
|
||||
contextSize uint64
|
||||
similarityThreshold int
|
||||
ignoreWhitespace bool
|
||||
pagerConfig *config.PagingConfig
|
||||
diffRendererConfig *config.DiffRendererConfig
|
||||
expected []string
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: false,
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--no-ext-diff", "--color=always", "--unified=3", "--find-renames=50%", "refs/stash@{5}"},
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--no-ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
|
||||
},
|
||||
{
|
||||
testName: "Show diff with custom context size",
|
||||
@@ -122,7 +122,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
|
||||
contextSize: 77,
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: false,
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--no-ext-diff", "--color=always", "--unified=77", "--find-renames=50%", "refs/stash@{5}"},
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--no-ext-diff", "--unified=77", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
|
||||
},
|
||||
{
|
||||
testName: "Show diff with custom similarity threshold",
|
||||
@@ -130,7 +130,7 @@ func TestStashStashEntryCmdObj(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 33,
|
||||
ignoreWhitespace: false,
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--no-ext-diff", "--color=always", "--unified=3", "--find-renames=33%", "refs/stash@{5}"},
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--no-ext-diff", "--unified=3", "--find-renames=33%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
|
||||
},
|
||||
{
|
||||
testName: "Show diff with external diff command",
|
||||
@@ -138,8 +138,8 @@ func TestStashStashEntryCmdObj(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: false,
|
||||
pagerConfig: &config.PagingConfig{ExternalDiffCommand: "difft --color=always"},
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "stash", "show", "-p", "--stat", "-u", "--ext-diff", "--color=always", "--unified=3", "--find-renames=50%", "refs/stash@{5}"},
|
||||
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "-c", "diff.external=difft --color=always", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
|
||||
},
|
||||
{
|
||||
testName: "Show diff using git's external diff config",
|
||||
@@ -147,16 +147,16 @@ func TestStashStashEntryCmdObj(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: false,
|
||||
pagerConfig: &config.PagingConfig{UseExternalDiffGitConfig: true},
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--ext-diff", "--color=always", "--unified=3", "--find-renames=50%", "refs/stash@{5}"},
|
||||
diffRendererConfig: &config.DiffRendererConfig{Type: "extDiff"},
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--ext-diff", "--unified=3", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
|
||||
},
|
||||
{
|
||||
testName: "Default case",
|
||||
testName: "Ignore whitespace",
|
||||
index: 5,
|
||||
contextSize: 3,
|
||||
similarityThreshold: 50,
|
||||
ignoreWhitespace: true,
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "-p", "--stat", "-u", "--no-ext-diff", "--color=always", "--unified=3", "--ignore-all-space", "--find-renames=50%", "refs/stash@{5}"},
|
||||
expected: []string{"git", "-C", "/path/to/worktree", "stash", "show", "--no-ext-diff", "--unified=3", "--ignore-all-space", "--find-renames=50%", "-p", "--stat", "-u", "--color=always", "refs/stash@{5}"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -166,8 +166,8 @@ func TestStashStashEntryCmdObj(t *testing.T) {
|
||||
userConfig.Git.IgnoreWhitespaceInDiffView = s.ignoreWhitespace
|
||||
userConfig.Git.DiffContextSize = s.contextSize
|
||||
userConfig.Git.RenameSimilarityThreshold = s.similarityThreshold
|
||||
if s.pagerConfig != nil {
|
||||
userConfig.Git.Pagers = []config.PagingConfig{*s.pagerConfig}
|
||||
if s.diffRendererConfig != nil {
|
||||
userConfig.Git.DiffRenderers = []config.DiffRendererConfig{*s.diffRendererConfig}
|
||||
}
|
||||
repoPaths := RepoPaths{
|
||||
worktreePath: "/path/to/worktree",
|
||||
|
||||
@@ -28,10 +28,15 @@ func NewSubmoduleCommands(gitCommon *GitCommon) *SubmoduleCommands {
|
||||
}
|
||||
|
||||
func (self *SubmoduleCommands) GetConfigs(parentModule *models.SubmoduleConfig) ([]*models.SubmoduleConfig, error) {
|
||||
gitModulesPath := ".gitmodules"
|
||||
// Resolve the path against the repo this commands object was created for
|
||||
// rather than the process working directory, so that a read from a
|
||||
// still-running refresh keeps addressing that repo after the user
|
||||
// switched to another one.
|
||||
dir := self.repoPaths.WorktreePath()
|
||||
if parentModule != nil {
|
||||
gitModulesPath = filepath.Join(parentModule.FullPath(), gitModulesPath)
|
||||
dir = filepath.Join(dir, parentModule.FullPath())
|
||||
}
|
||||
gitModulesPath := filepath.Join(dir, ".gitmodules")
|
||||
file, err := os.Open(gitModulesPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -180,7 +185,7 @@ func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSi
|
||||
func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
|
||||
// if the path does not exist then it hasn't yet been initialized so we'll swallow the error
|
||||
// because the intention here is to have no dirty worktree state
|
||||
if _, err := os.Stat(submodule.Path); os.IsNotExist(err) {
|
||||
if _, err := os.Stat(filepath.Join(self.repoPaths.WorktreePath(), submodule.FullPath())); os.IsNotExist(err) {
|
||||
self.Log.Infof("submodule path %s does not exist, returning", submodule.FullPath())
|
||||
return nil
|
||||
}
|
||||
@@ -213,51 +218,51 @@ func (self *SubmoduleCommands) UpdateAll() error {
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
}
|
||||
|
||||
// runInParentModule runs the given command in the submodule's parent module's
|
||||
// directory when the submodule is nested: its path arguments (and the
|
||||
// .gitmodules file the config commands touch) are relative to the parent
|
||||
// module. The directory is set on the command itself rather than by
|
||||
// temporarily chdir-ing the process there, which would leak the parent
|
||||
// module's directory into whatever other commands run concurrently (e.g. a
|
||||
// background refresh's).
|
||||
func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error {
|
||||
if submodule.ParentModule != nil {
|
||||
cmdObj.SetWd(submodule.ParentModule.FullPath())
|
||||
}
|
||||
return cmdObj.Run()
|
||||
}
|
||||
|
||||
func (self *SubmoduleCommands) Delete(submodule *models.SubmoduleConfig) error {
|
||||
// based on https://gist.github.com/myusuf3/7f645819ded92bda6677
|
||||
|
||||
if submodule.ParentModule != nil {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.Chdir(submodule.ParentModule.FullPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = os.Chdir(wd) }()
|
||||
}
|
||||
|
||||
if err := self.cmd.New(
|
||||
if err := self.runInParentModule(submodule, self.cmd.New(
|
||||
NewGitCmd("submodule").
|
||||
Arg("deinit", "--force", "--", submodule.Path).ToArgv(),
|
||||
).Run(); err != nil {
|
||||
)); err != nil {
|
||||
if !strings.Contains(err.Error(), "did not match any file(s) known to git") {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := self.cmd.New(
|
||||
if err := self.runInParentModule(submodule, self.cmd.New(
|
||||
NewGitCmd("config").
|
||||
Arg("--file", ".gitmodules", "--remove-section", "submodule."+submodule.Path).
|
||||
ToArgv(),
|
||||
).Run(); err != nil {
|
||||
)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := self.cmd.New(
|
||||
if err := self.runInParentModule(submodule, self.cmd.New(
|
||||
NewGitCmd("config").
|
||||
Arg("--remove-section", "submodule."+submodule.Path).
|
||||
ToArgv(),
|
||||
).Run(); err != nil {
|
||||
)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := self.cmd.New(
|
||||
if err := self.runInParentModule(submodule, self.cmd.New(
|
||||
NewGitCmd("rm").Arg("--force", "-r", submodule.Path).ToArgv(),
|
||||
).Run(); err != nil {
|
||||
)); err != nil {
|
||||
// if the directory isn't there then that's fine
|
||||
self.Log.Error(err)
|
||||
}
|
||||
@@ -282,20 +287,6 @@ func (self *SubmoduleCommands) Add(name string, path string, url string) error {
|
||||
}
|
||||
|
||||
func (self *SubmoduleCommands) UpdateUrl(submodule *models.SubmoduleConfig, newUrl string) error {
|
||||
if submodule.ParentModule != nil {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.Chdir(submodule.ParentModule.FullPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() { _ = os.Chdir(wd) }()
|
||||
}
|
||||
|
||||
setUrlCmdStr := NewGitCmd("config").
|
||||
Arg(
|
||||
"--file", ".gitmodules", "submodule."+submodule.Name+".url", newUrl,
|
||||
@@ -303,14 +294,14 @@ func (self *SubmoduleCommands) UpdateUrl(submodule *models.SubmoduleConfig, newU
|
||||
ToArgv()
|
||||
|
||||
// the set-url command is only for later git versions so we're doing it manually here
|
||||
if err := self.cmd.New(setUrlCmdStr).Run(); err != nil {
|
||||
if err := self.runInParentModule(submodule, self.cmd.New(setUrlCmdStr)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
syncCmdStr := NewGitCmd("submodule").Arg("sync", "--", submodule.Path).
|
||||
ToArgv()
|
||||
|
||||
if err := self.cmd.New(syncCmdStr).Run(); err != nil {
|
||||
if err := self.runInParentModule(submodule, self.cmd.New(syncCmdStr)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ func (self *TagCommands) HasTag(tagName string) bool {
|
||||
Arg("refs/tags/" + tagName).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run() == nil
|
||||
return self.cmd.New(cmdArgs).DontLog().Run() == nil
|
||||
}
|
||||
|
||||
func (self *TagCommands) LocalDelete(tagName string) error {
|
||||
@@ -74,7 +74,7 @@ func (self *TagCommands) ShowAnnotationInfo(tagName string) (string, error) {
|
||||
Arg("refs/tags/" + tagName).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).RunWithOutput()
|
||||
return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
|
||||
}
|
||||
|
||||
func (self *TagCommands) IsTagAnnotated(tagName string) (bool, error) {
|
||||
@@ -83,6 +83,6 @@ func (self *TagCommands) IsTagAnnotated(tagName string) (bool, error) {
|
||||
Arg("refs/tags/" + tagName).
|
||||
ToArgv()
|
||||
|
||||
output, err := self.cmd.New(cmdArgs).RunWithOutput()
|
||||
output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
|
||||
return strings.TrimSpace(output) == "tag", err
|
||||
}
|
||||
|
||||
@@ -393,17 +393,13 @@ func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool,
|
||||
// in the working tree. When pathOverrides is non-empty, those paths are used instead of
|
||||
// the node's path (used to diff only filtered/visible files within a directory).
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj {
|
||||
colorArg := self.pagerConfig.GetColorArg()
|
||||
colorArg := self.diffRendererConfigManager.GetColorArg()
|
||||
if plain {
|
||||
colorArg = "never"
|
||||
}
|
||||
|
||||
contextSize := self.UserConfig().Git.DiffContextSize
|
||||
prevPath := node.GetPreviousPath()
|
||||
noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile()
|
||||
extDiffCmd := self.pagerConfig.GetExternalDiffCommand()
|
||||
useExtDiff := extDiffCmd != "" && !plain
|
||||
useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain
|
||||
|
||||
paths := pathOverrides
|
||||
if len(paths) == 0 {
|
||||
@@ -411,13 +407,9 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain
|
||||
}
|
||||
|
||||
cmdArgs := NewGitCmd("diff").
|
||||
ConfigIf(useExtDiff, "diff.external="+extDiffCmd).
|
||||
ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
|
||||
Arg("--submodule").
|
||||
Arg(fmt.Sprintf("--unified=%d", contextSize)).
|
||||
Arg(fmt.Sprintf("--color=%s", colorArg)).
|
||||
ArgIf(!plain && self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
|
||||
Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)).
|
||||
ArgIf(cached, "--cached").
|
||||
ArgIf(noIndex, "--no-index").
|
||||
Arg("--").
|
||||
@@ -443,29 +435,19 @@ func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bo
|
||||
}
|
||||
|
||||
func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj {
|
||||
contextSize := self.UserConfig().Git.DiffContextSize
|
||||
|
||||
colorArg := self.pagerConfig.GetColorArg()
|
||||
colorArg := self.diffRendererConfigManager.GetColorArg()
|
||||
if plain {
|
||||
colorArg = "never"
|
||||
}
|
||||
|
||||
extDiffCmd := self.pagerConfig.GetExternalDiffCommand()
|
||||
useExtDiff := extDiffCmd != "" && !plain
|
||||
useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain
|
||||
|
||||
cmdArgs := NewGitCmd("diff").
|
||||
Config("diff.noprefix=false").
|
||||
ConfigIf(useExtDiff, "diff.external="+extDiffCmd).
|
||||
ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff").
|
||||
AddCommonDiffArgs(self.diffRendererConfigManager, self.UserConfig(), !plain).
|
||||
Arg("--submodule").
|
||||
Arg(fmt.Sprintf("--unified=%d", contextSize)).
|
||||
Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)).
|
||||
Arg(fmt.Sprintf("--color=%s", colorArg)).
|
||||
Arg(from).
|
||||
Arg(to).
|
||||
ArgIf(reverse, "-R").
|
||||
ArgIf(!plain && self.UserConfig().Git.IgnoreWhitespaceInDiffView, "--ignore-all-space").
|
||||
Arg("--").
|
||||
Arg(fileNames...).
|
||||
Dir(self.repoPaths.worktreePath).
|
||||
|
||||
@@ -221,7 +221,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 50,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil),
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil),
|
||||
},
|
||||
{
|
||||
testName: "cached",
|
||||
@@ -236,7 +236,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 50,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--cached", "--", "test.txt"}, expectedResult, nil),
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--cached", "--", "test.txt"}, expectedResult, nil),
|
||||
},
|
||||
{
|
||||
testName: "plain",
|
||||
@@ -251,7 +251,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 50,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=never", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil),
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=never", "--", "test.txt"}, expectedResult, nil),
|
||||
},
|
||||
{
|
||||
testName: "File not tracked and file has no staged changes",
|
||||
@@ -266,7 +266,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 50,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=50%", "--no-index", "--", "/dev/null", "test.txt"}, expectedResult, nil),
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "--no-index", "--", "/dev/null", "test.txt"}, expectedResult, nil),
|
||||
},
|
||||
{
|
||||
testName: "Default case (ignore whitespace)",
|
||||
@@ -281,7 +281,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 50,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--ignore-all-space", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil),
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--ignore-all-space", "--find-renames=50%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil),
|
||||
},
|
||||
{
|
||||
testName: "Show diff with custom context size",
|
||||
@@ -296,7 +296,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
contextSize: 17,
|
||||
similarityThreshold: 50,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=17", "--color=always", "--find-renames=50%", "--", "test.txt"}, expectedResult, nil),
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=17", "--find-renames=50%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil),
|
||||
},
|
||||
{
|
||||
testName: "Show diff with custom similarity threshold",
|
||||
@@ -311,7 +311,7 @@ func TestWorkingTreeDiff(t *testing.T) {
|
||||
contextSize: 3,
|
||||
similarityThreshold: 33,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--color=always", "--find-renames=33%", "--", "test.txt"}, expectedResult, nil),
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "diff", "--no-ext-diff", "--unified=3", "--find-renames=33%", "--submodule", "--color=always", "--", "test.txt"}, expectedResult, nil),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
|
||||
ignoreWhitespace: false,
|
||||
contextSize: 3,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
|
||||
},
|
||||
{
|
||||
testName: "Show diff with custom context size",
|
||||
@@ -372,7 +372,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
|
||||
ignoreWhitespace: false,
|
||||
contextSize: 123,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=123", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=123", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
|
||||
},
|
||||
{
|
||||
testName: "Default case (ignore whitespace)",
|
||||
@@ -384,7 +384,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
|
||||
ignoreWhitespace: true,
|
||||
contextSize: 3,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--ignore-all-space", "--", "test.txt"}, expectedResult, nil),
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=3", "--ignore-all-space", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil),
|
||||
},
|
||||
{
|
||||
testName: "Renamed file passes both paths so the rename is detected",
|
||||
@@ -397,7 +397,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) {
|
||||
ignoreWhitespace: false,
|
||||
contextSize: 3,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--", "new.txt", "old.txt"}, expectedResult, nil),
|
||||
ExpectGitArgs([]string{"-C", "/path/to/worktree", "-c", "diff.noprefix=false", "diff", "--no-ext-diff", "--unified=3", "--find-renames=50%", "--submodule", "--color=always", "1234567890", "0987654321", "--", "new.txt", "old.txt"}, expectedResult, nil),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,19 @@ type IGitConfig interface {
|
||||
// this is for when you want to pass 'mykey' and check if the result is truthy
|
||||
GetBool(string) bool
|
||||
|
||||
// SetDir pins the config commands to the given repo directory, so that
|
||||
// they keep reading that repo's local config even if the process working
|
||||
// directory changes later (i.e. the user switches repos while this
|
||||
// instance is still in use by in-flight work). Called once, before the
|
||||
// first read.
|
||||
SetDir(string)
|
||||
|
||||
DropCache()
|
||||
}
|
||||
|
||||
type CachedGitConfig struct {
|
||||
cache map[string]string
|
||||
dir string
|
||||
runGitConfigCmd func(*exec.Cmd) (string, error)
|
||||
log *logrus.Entry
|
||||
mutex sync.Mutex
|
||||
@@ -39,6 +47,13 @@ func NewCachedGitConfig(runGitConfigCmd func(*exec.Cmd) (string, error), log *lo
|
||||
}
|
||||
}
|
||||
|
||||
func (self *CachedGitConfig) SetDir(dir string) {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
|
||||
self.dir = dir
|
||||
}
|
||||
|
||||
func (self *CachedGitConfig) Get(key string) string {
|
||||
self.mutex.Lock()
|
||||
defer self.mutex.Unlock()
|
||||
@@ -69,6 +84,7 @@ func (self *CachedGitConfig) GetGeneral(args string) string {
|
||||
|
||||
func (self *CachedGitConfig) getGeneralAux(args string) string {
|
||||
cmd := getGitConfigGeneralCmd(args)
|
||||
cmd.Dir = self.dir
|
||||
value, err := self.runGitConfigCmd(cmd)
|
||||
if err != nil {
|
||||
self.log.Debugf("Error getting git config value for args: %s. Error: %v", args, err.Error())
|
||||
@@ -79,6 +95,7 @@ func (self *CachedGitConfig) getGeneralAux(args string) string {
|
||||
|
||||
func (self *CachedGitConfig) getAux(key string) string {
|
||||
cmd := getGitConfigCmd(key)
|
||||
cmd.Dir = self.dir
|
||||
value, err := self.runGitConfigCmd(cmd)
|
||||
if err != nil {
|
||||
self.log.Debugf("Error getting git config value for key: %s. Error: %v", key, err.Error())
|
||||
|
||||
@@ -116,3 +116,20 @@ func TestGet(t *testing.T) {
|
||||
assert.Equal(t, "blah", result)
|
||||
assert.Equal(t, 1, count)
|
||||
}
|
||||
|
||||
// The config commands run in the directory set by SetDir rather than in the
|
||||
// process's current directory: lazygit chdirs when switching repos, and config
|
||||
// reads issued for the previous repo after that must keep addressing the repo
|
||||
// they were created for.
|
||||
func TestSetDirPinsCommandsToDirectory(t *testing.T) {
|
||||
real := NewCachedGitConfig(
|
||||
func(cmd *exec.Cmd) (string, error) {
|
||||
assert.Equal(t, "/path/to/repo", cmd.Dir)
|
||||
return "blah", nil
|
||||
},
|
||||
utils.NewDummyLog(),
|
||||
)
|
||||
real.SetDir("/path/to/repo")
|
||||
real.Get("commit.gpgsign")
|
||||
real.GetGeneral("--local --get-regexp foo")
|
||||
}
|
||||
|
||||
@@ -28,5 +28,8 @@ func (self *FakeGitConfig) GetBool(key string) bool {
|
||||
return isTruthy(self.Get(key))
|
||||
}
|
||||
|
||||
func (self *FakeGitConfig) SetDir(dir string) {
|
||||
}
|
||||
|
||||
func (self *FakeGitConfig) DropCache() {
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ type GithubPullRequest struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"` // "MERGED", "OPEN", "CLOSED", "DRAFT"
|
||||
ChecksState string `json:"checksState"`
|
||||
Url string `json:"url"`
|
||||
HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -32,3 +32,9 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (StartedPty, error) {
|
||||
Wait: cmd.Wait,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TerminateLivePtys is a no-op on Unix: stopping a pty task signals the
|
||||
// child (SIGTERM, plus SIGHUP to the foreground process group when the
|
||||
// master closes), and the processes clean themselves up without lazygit
|
||||
// having to wait for them.
|
||||
func TerminateLivePtys() {}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
@@ -12,7 +14,14 @@ import (
|
||||
)
|
||||
|
||||
type winPty struct {
|
||||
hpc windows.Handle
|
||||
hpc windows.Handle
|
||||
// job holds the child and every descendant it spawns; terminating it
|
||||
// kills whatever is left of the process tree (see Close).
|
||||
job windows.Handle
|
||||
// conhost is a handle to the conhost.exe serving this pty, or 0 if it
|
||||
// couldn't be identified. Held so that the teardown in Close can reap
|
||||
// it on Windows builds whose conhost fails to run down on its own.
|
||||
conhost windows.Handle
|
||||
inWrite *os.File
|
||||
outRead *os.File
|
||||
|
||||
@@ -36,7 +45,16 @@ func (p *winPty) Resize(cols, rows uint16) error {
|
||||
// there is nothing left to resize.
|
||||
return nil
|
||||
}
|
||||
return windows.ResizePseudoConsole(p.hpc, windows.Coord{X: int16(cols), Y: int16(rows)})
|
||||
return windows.ResizePseudoConsole(p.hpc, clampPtySize(cols, rows))
|
||||
}
|
||||
|
||||
// clampPtySize clamps a requested pty size to the minimum that ConPTY
|
||||
// accepts: CreatePseudoConsole and ResizePseudoConsole reject zero
|
||||
// dimensions with E_INVALIDARG, but callers legitimately request them — the
|
||||
// pty is sized after the main view, which is zero-sized while hidden, e.g.
|
||||
// in full-screen mode with a side panel focused.
|
||||
func clampPtySize(cols, rows uint16) windows.Coord {
|
||||
return windows.Coord{X: int16(max(cols, 1)), Y: int16(max(rows, 1))}
|
||||
}
|
||||
|
||||
// closeHpc closes the pseudoconsole exactly once. Safe to call from multiple
|
||||
@@ -55,6 +73,46 @@ func (p *winPty) closeHpc() {
|
||||
windows.ClosePseudoConsole(p.hpc)
|
||||
}
|
||||
|
||||
// How long Close waits for the conhost to run itself down after its clients
|
||||
// are gone, before concluding that it never will (see Close) and reaping it.
|
||||
const conhostExitTimeout = time.Second
|
||||
|
||||
var (
|
||||
// ptyTeardowns counts the in-flight teardown goroutines spawned by
|
||||
// Close; TerminateLivePtys waits for them when lazygit exits.
|
||||
ptyTeardowns sync.WaitGroup
|
||||
// ptyQuit is closed by TerminateLivePtys. In-flight teardowns skip the
|
||||
// conhost rundown wait once it is closed: the conhost serves nothing
|
||||
// once its clients are gone, and the exit must not stall for its sake.
|
||||
ptyQuit = make(chan struct{})
|
||||
ptyQuitOnce sync.Once
|
||||
)
|
||||
|
||||
// TerminateLivePtys synchronously terminates the process trees and console
|
||||
// hosts of all ptys whose teardown hasn't finished yet. Call it when lazygit
|
||||
// is about to exit: the asynchronous teardowns in Close won't get to finish
|
||||
// (the conhost rundown wait outlives the process), and while
|
||||
// KILL_ON_JOB_CLOSE reaps the clients when the job handles are closed at
|
||||
// process death, nothing would reap the conhosts on the Windows builds that
|
||||
// need it (see Close). A long diff on screen keeps its git process running
|
||||
// the whole time it is shown, so quitting with such a teardown in flight is
|
||||
// the rule, not the exception.
|
||||
func TerminateLivePtys() {
|
||||
ptyQuitOnce.Do(func() { close(ptyQuit) })
|
||||
|
||||
done := make(chan struct{})
|
||||
go utils.Safe(func() {
|
||||
ptyTeardowns.Wait()
|
||||
close(done)
|
||||
})
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
// Don't hold up the exit any longer; the job handles' rundown
|
||||
// still covers the clients.
|
||||
}
|
||||
}
|
||||
|
||||
// Close tears the pty down without waiting for it: the teardown runs on a
|
||||
// background goroutine and Close returns immediately.
|
||||
//
|
||||
@@ -62,11 +120,11 @@ func (p *winPty) closeHpc() {
|
||||
// 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.
|
||||
// diff renderer 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
|
||||
@@ -74,11 +132,65 @@ func (p *winPty) closeHpc() {
|
||||
// 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.
|
||||
//
|
||||
// Closing the pseudoconsole delivers CTRL_CLOSE_EVENT only to the clients
|
||||
// attached to it at that moment. A child that is stopped right after being
|
||||
// spawned is still starting up and not attached yet, so the event misses it
|
||||
// and it survives, running its command to completion as an orphan — and
|
||||
// keeping its console host alive with it (#5879); the same holds for
|
||||
// grandchildren spawned while the console is going down, and for clients
|
||||
// that ignore the event (the Windows flavor of #5675). The job kill reaps
|
||||
// all of those. There is no point in delaying it: the close event is not a
|
||||
// graceful signal worth waiting on — git and the common diff tools leave it
|
||||
// to the default handler, which calls ExitProcess at whatever instruction
|
||||
// the process happens to execute — so clients that got the event are
|
||||
// already dying. Killing at an arbitrary point cannot leak a stale
|
||||
// index.lock, because pty-rendered commands don't take that lock (see
|
||||
// withPtyGitConfig in pkg/gui/pty.go).
|
||||
//
|
||||
// The pseudoconsole close gets its own goroutine because the kill must not
|
||||
// wait for it: on builds where ClosePseudoConsole blocks until the console
|
||||
// host exits (pre-24H2), the host keeps running as long as a surviving
|
||||
// client does, and that client only goes away through the job kill —
|
||||
// sequencing the kill after a blocking close would thus deadlock in
|
||||
// exactly the case the kill exists for.
|
||||
//
|
||||
// After the kill, the conhost serving the pty is reaped as well if it
|
||||
// doesn't exit by itself: a healthy conhost runs down once the reference
|
||||
// handle is closed and its clients are gone, but conhost builds before
|
||||
// Windows 11 24H2 fail to complete the rundown when a client attached
|
||||
// after the close event was delivered and was then killed — the fate of
|
||||
// exactly the clients the job kill is for — and such a conhost sits
|
||||
// around forever, serving nothing (#5879). The reap is inert on healthy
|
||||
// builds: the wait succeeds and only the handle is closed.
|
||||
//
|
||||
// When lazygit is quitting, the conhost rundown wait is skipped; see
|
||||
// TerminateLivePtys.
|
||||
func (p *winPty) Close() error {
|
||||
ptyTeardowns.Add(1)
|
||||
go utils.Safe(func() {
|
||||
defer ptyTeardowns.Done()
|
||||
|
||||
p.inWrite.Close()
|
||||
p.outRead.Close()
|
||||
p.closeHpc()
|
||||
go utils.Safe(p.closeHpc)
|
||||
|
||||
_ = windows.TerminateJobObject(p.job, 1)
|
||||
_ = windows.CloseHandle(p.job)
|
||||
|
||||
if p.conhost != 0 {
|
||||
timeout := conhostExitTimeout
|
||||
select {
|
||||
case <-ptyQuit:
|
||||
timeout = 0
|
||||
default:
|
||||
}
|
||||
event, err := windows.WaitForSingleObject(p.conhost, uint32(timeout/time.Millisecond))
|
||||
if err != nil || event != windows.WAIT_OBJECT_0 {
|
||||
_ = windows.TerminateProcess(p.conhost, 1)
|
||||
}
|
||||
_ = windows.CloseHandle(p.conhost)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -114,6 +226,52 @@ func startWaiter(proc *os.Process, p *winPty) func() error {
|
||||
}
|
||||
}
|
||||
|
||||
// conhostScanMu serializes CreatePseudoConsole and the child-process scans
|
||||
// around it, so that two concurrently starting ptys can't make each other's
|
||||
// "which conhost is new" diff ambiguous.
|
||||
var conhostScanMu sync.Mutex
|
||||
|
||||
// conhostChildren returns the pids of all conhost.exe processes that are
|
||||
// direct children of this process. Errors just yield a smaller (possibly
|
||||
// empty) set; the caller treats identification as best-effort.
|
||||
func conhostChildren() map[uint32]bool {
|
||||
pids := map[uint32]bool{}
|
||||
snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0)
|
||||
if err != nil {
|
||||
return pids
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(snap) }()
|
||||
me := uint32(os.Getpid())
|
||||
var pe windows.ProcessEntry32
|
||||
pe.Size = uint32(unsafe.Sizeof(pe))
|
||||
for err := windows.Process32First(snap, &pe); err == nil; err = windows.Process32Next(snap, &pe) {
|
||||
if pe.ParentProcessID == me && strings.EqualFold(windows.UTF16ToString(pe.ExeFile[:]), "conhost.exe") {
|
||||
pids[pe.ProcessID] = true
|
||||
}
|
||||
}
|
||||
return pids
|
||||
}
|
||||
|
||||
// openNewConhostChild returns a handle to the single conhost child that
|
||||
// appeared since the before scan, or 0 if there isn't exactly one candidate
|
||||
// or it can't be opened.
|
||||
func openNewConhostChild(before map[uint32]bool) windows.Handle {
|
||||
var found []uint32
|
||||
for pid := range conhostChildren() {
|
||||
if !before[pid] {
|
||||
found = append(found, pid)
|
||||
}
|
||||
}
|
||||
if len(found) != 1 {
|
||||
return 0
|
||||
}
|
||||
h, err := windows.OpenProcess(windows.SYNCHRONIZE|windows.PROCESS_TERMINATE, false, found[0])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) {
|
||||
// Two pipes: one for the child's stdin (we never write to it, but ConPTY
|
||||
// needs a handle), one for the child's stdout/stderr multiplexed through
|
||||
@@ -139,9 +297,24 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) {
|
||||
|
||||
// CreatePseudoConsole dupes the handles it needs internally; we release
|
||||
// our references to the child-side ends immediately after.
|
||||
var hpc windows.Handle
|
||||
size := windows.Coord{X: int16(cols), Y: int16(rows)}
|
||||
if err = windows.CreatePseudoConsole(size, inRead, outWrite, 0, &hpc); err != nil {
|
||||
//
|
||||
// It also spawns the conhost.exe serving the console session, as a
|
||||
// direct child of this process. The teardown in Close needs a handle to
|
||||
// that conhost (see there), but Windows offers no way to obtain one
|
||||
// from the HPCON, so identify it by diffing our conhost children around
|
||||
// the call. Open a real handle right away so that pid reuse can't later
|
||||
// misdirect the teardown's reap. If identification fails, the handle
|
||||
// stays 0 and the teardown skips the reap.
|
||||
var hpc, conhost windows.Handle
|
||||
size := clampPtySize(cols, rows)
|
||||
conhostScanMu.Lock()
|
||||
conhostsBefore := conhostChildren()
|
||||
err = windows.CreatePseudoConsole(size, inRead, outWrite, 0, &hpc)
|
||||
if err == nil {
|
||||
conhost = openNewConhostChild(conhostsBefore)
|
||||
}
|
||||
conhostScanMu.Unlock()
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(inRead)
|
||||
_ = windows.CloseHandle(outWrite)
|
||||
return StartedPty{}, fmt.Errorf("CreatePseudoConsole: %w", err)
|
||||
@@ -151,9 +324,40 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
windows.ClosePseudoConsole(hpc)
|
||||
if conhost != 0 {
|
||||
_ = windows.CloseHandle(conhost)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// The child goes into a job object so that the teardown in Close can
|
||||
// terminate the whole process tree. KILL_ON_JOB_CLOSE makes the OS do
|
||||
// that when the last handle to the job is closed, which doubles as a
|
||||
// safety net: if lazygit exits without running the teardown, the handle
|
||||
// is closed for it and the tree is reaped.
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return StartedPty{}, fmt.Errorf("CreateJobObject: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
// Kills the child on error paths where it was already assigned
|
||||
// to the job; plain handle cleanup before that.
|
||||
_ = windows.CloseHandle(job)
|
||||
}
|
||||
}()
|
||||
limits := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
|
||||
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
|
||||
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
},
|
||||
}
|
||||
if _, err = windows.SetInformationJobObject(
|
||||
job, windows.JobObjectExtendedLimitInformation,
|
||||
uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits)),
|
||||
); err != nil {
|
||||
return StartedPty{}, fmt.Errorf("SetInformationJobObject: %w", err)
|
||||
}
|
||||
|
||||
// Attach the pseudoconsole to the child via a process attribute list.
|
||||
attrList, err := windows.NewProcThreadAttributeList(1)
|
||||
if err != nil {
|
||||
@@ -212,7 +416,7 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) {
|
||||
nil, // process security
|
||||
nil, // thread security
|
||||
false,
|
||||
windows.EXTENDED_STARTUPINFO_PRESENT|windows.CREATE_UNICODE_ENVIRONMENT,
|
||||
windows.EXTENDED_STARTUPINFO_PRESENT|windows.CREATE_UNICODE_ENVIRONMENT|windows.CREATE_SUSPENDED,
|
||||
envPtr,
|
||||
dirPtr,
|
||||
&si.StartupInfo,
|
||||
@@ -221,6 +425,22 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) {
|
||||
if err != nil {
|
||||
return StartedPty{}, fmt.Errorf("CreateProcess: %w", err)
|
||||
}
|
||||
|
||||
// The child was created suspended so that it can be assigned to the job
|
||||
// before it runs its first instruction; that way every descendant it
|
||||
// ever spawns is in the job from the start.
|
||||
if err = windows.AssignProcessToJobObject(job, pi.Process); err != nil {
|
||||
// Not in the job yet, so the deferred job-handle close can't reap it.
|
||||
_ = windows.TerminateProcess(pi.Process, 1)
|
||||
_ = windows.CloseHandle(pi.Thread)
|
||||
_ = windows.CloseHandle(pi.Process)
|
||||
return StartedPty{}, fmt.Errorf("AssignProcessToJobObject: %w", err)
|
||||
}
|
||||
if _, err = windows.ResumeThread(pi.Thread); err != nil {
|
||||
_ = windows.CloseHandle(pi.Thread)
|
||||
_ = windows.CloseHandle(pi.Process)
|
||||
return StartedPty{}, fmt.Errorf("ResumeThread: %w", err)
|
||||
}
|
||||
_ = windows.CloseHandle(pi.Thread)
|
||||
|
||||
// Re-open the process by PID to get an *os.Process to wait on. Do this
|
||||
@@ -236,6 +456,8 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) {
|
||||
|
||||
wp := &winPty{
|
||||
hpc: hpc,
|
||||
job: job,
|
||||
conhost: conhost,
|
||||
inWrite: os.NewFile(uintptr(inWrite), "conpty-in"),
|
||||
outRead: os.NewFile(uintptr(outRead), "conpty-out"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package oscommands
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// The requested size can legitimately be zero: the pty inherits the main
|
||||
// view's dimensions, and that view is zero-sized while hidden, e.g. in
|
||||
// full-screen mode with a side panel focused.
|
||||
func TestStartPtyWithZeroSize(t *testing.T) {
|
||||
// The command deliberately produces no output: go test runs with
|
||||
// redirected std handles, which CreateProcess duplicates into the child
|
||||
// in place of handles to the attached pseudoconsole, so command output
|
||||
// would bypass the pty and pollute the test log.
|
||||
sp, err := StartPty(exec.Command("cmd", "/c", "exit 0"), 0, 0)
|
||||
assert.NoError(t, err)
|
||||
|
||||
if err == nil {
|
||||
_ = sp.Wait()
|
||||
_ = sp.Pty.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// StartPty must identify the conhost.exe that CreatePseudoConsole spawned to
|
||||
// serve the pty: the teardown in Close reaps it on Windows builds whose
|
||||
// conhost fails to run down on its own, and a failed identification silently
|
||||
// degrades to not reaping. If this fails, the child-scan in
|
||||
// openNewConhostChild no longer matches how Windows hosts pseudoconsoles.
|
||||
func TestStartPtyIdentifiesConhost(t *testing.T) {
|
||||
sp, err := StartPty(exec.Command("cmd", "/c", "exit 0"), 80, 24)
|
||||
assert.NoError(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.NotZero(t, sp.Pty.(*winPty).conhost)
|
||||
|
||||
_ = sp.Wait()
|
||||
_ = sp.Pty.Close()
|
||||
}
|
||||
|
||||
// TerminateLivePtys must reap a still-running pty synchronously: it runs
|
||||
// when lazygit is about to exit, where the asynchronous teardown would not
|
||||
// get to finish. Note that it switches the package's pty teardowns into
|
||||
// quit mode for the remainder of the test binary's lifetime; that's fine
|
||||
// for the other tests here, which must hold in either mode (quit mode only
|
||||
// shortens the teardown's conhost rundown wait).
|
||||
func TestTerminateLivePtysReapsRunningPty(t *testing.T) {
|
||||
// The output redirect is there for the reason described in
|
||||
// TestStartPtyWithZeroSize.
|
||||
sp, err := StartPty(exec.Command("cmd", "/c", "ping -n 30 127.0.0.1 >nul"), 80, 24)
|
||||
assert.NoError(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = sp.Pty.Close()
|
||||
TerminateLivePtys()
|
||||
|
||||
// The teardown has completed as part of TerminateLivePtys, so the child
|
||||
// must be gone already; the timeout is generosity, not a grace period.
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
_ = sp.Wait()
|
||||
close(exited)
|
||||
}()
|
||||
select {
|
||||
case <-exited:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("child process was not terminated by TerminateLivePtys")
|
||||
}
|
||||
}
|
||||
|
||||
// Closing the pty must terminate the process tree it was running, even when
|
||||
// it is closed so soon after starting that the child hasn't attached to the
|
||||
// pseudoconsole yet: such a child misses the CTRL_CLOSE_EVENT that the close
|
||||
// delivers to attached clients, and only the job-object kill reaps it.
|
||||
// Without the kill, cmd and its ping child keep running for ~30 seconds and
|
||||
// the Wait here times out.
|
||||
func TestClosePtyTerminatesChildProcessTree(t *testing.T) {
|
||||
// The output redirect is there for the reason described in
|
||||
// TestStartPtyWithZeroSize.
|
||||
sp, err := StartPty(exec.Command("cmd", "/c", "ping -n 30 127.0.0.1 >nul"), 80, 24)
|
||||
assert.NoError(t, err)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = sp.Pty.Close()
|
||||
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
_ = sp.Wait()
|
||||
close(exited)
|
||||
}()
|
||||
select {
|
||||
case <-exited:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("child process was not terminated by closing the pty")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
+143
-46
@@ -21,17 +21,18 @@ import (
|
||||
|
||||
// AppConfig contains the base configuration fields required for lazygit.
|
||||
type AppConfig struct {
|
||||
debug bool `long:"debug" env:"DEBUG" default:"false"`
|
||||
version string `long:"version" env:"VERSION" default:"unversioned"`
|
||||
buildDate string `long:"build-date" env:"BUILD_DATE"`
|
||||
name string `long:"name" env:"NAME" default:"lazygit"`
|
||||
buildSource string `long:"build-source" env:"BUILD_SOURCE" default:""`
|
||||
userConfig *UserConfig
|
||||
globalUserConfigFiles []*ConfigFile
|
||||
userConfigFiles []*ConfigFile
|
||||
userConfigDir string
|
||||
tempDir string
|
||||
appState *AppState
|
||||
debug bool `long:"debug" env:"DEBUG" default:"false"`
|
||||
version string `long:"version" env:"VERSION" default:"unversioned"`
|
||||
buildDate string `long:"build-date" env:"BUILD_DATE"`
|
||||
name string `long:"name" env:"NAME" default:"lazygit"`
|
||||
buildSource string `long:"build-source" env:"BUILD_SOURCE" default:""`
|
||||
userConfig *UserConfig
|
||||
globalUserConfigFiles []*ConfigFile
|
||||
userConfigFiles []*ConfigFile
|
||||
userConfigDir string
|
||||
tempDir string
|
||||
appState *AppState
|
||||
githubPullRequestCache *githubPullRequestCache
|
||||
}
|
||||
|
||||
type AppConfigurer interface {
|
||||
@@ -51,6 +52,8 @@ type AppConfigurer interface {
|
||||
|
||||
GetAppState() *AppState
|
||||
SaveAppState() error
|
||||
GetCachedGithubPullRequests(repoPath string) ([]CachedPullRequest, error)
|
||||
SaveCachedGithubPullRequests(repoPath string, pullRequests []CachedPullRequest) error
|
||||
}
|
||||
|
||||
type ConfigFilePolicy int
|
||||
@@ -107,19 +110,21 @@ func NewAppConfig(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
githubPullRequestCache := loadGithubPullRequestCache()
|
||||
|
||||
appConfig := &AppConfig{
|
||||
name: name,
|
||||
version: version,
|
||||
buildDate: date,
|
||||
debug: debuggingFlag,
|
||||
buildSource: buildSource,
|
||||
userConfig: userConfig,
|
||||
globalUserConfigFiles: configFiles,
|
||||
userConfigFiles: configFiles,
|
||||
userConfigDir: configDir,
|
||||
tempDir: tempDir,
|
||||
appState: appState,
|
||||
name: name,
|
||||
version: version,
|
||||
buildDate: date,
|
||||
debug: debuggingFlag,
|
||||
buildSource: buildSource,
|
||||
userConfig: userConfig,
|
||||
globalUserConfigFiles: configFiles,
|
||||
userConfigFiles: configFiles,
|
||||
userConfigDir: configDir,
|
||||
tempDir: tempDir,
|
||||
appState: appState,
|
||||
githubPullRequestCache: githubPullRequestCache,
|
||||
}
|
||||
|
||||
return appConfig, nil
|
||||
@@ -288,6 +293,8 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
|
||||
}{
|
||||
{[]string{"gui", "skipUnstageLineWarning"}, "skipDiscardChangeWarning"},
|
||||
{[]string{"keybinding", "universal", "executeCustomCommand"}, "executeShellCommand"},
|
||||
{[]string{"keybinding", "universal", "cyclePagers"}, "cycleDiffRenderers"},
|
||||
{[]string{"keybinding", "universal", "cyclePagersReverse"}, "cycleDiffRenderersReverse"},
|
||||
{[]string{"gui", "windowSize"}, "screenMode"},
|
||||
{[]string{"keybinding", "files", "openMergeTool"}, "openMergeOptions"},
|
||||
}
|
||||
@@ -347,7 +354,12 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([]
|
||||
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err)
|
||||
}
|
||||
|
||||
err = migratePagers(&rootNode, changes)
|
||||
err = migratePaging(&rootNode, changes)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err)
|
||||
}
|
||||
|
||||
err = migratePagersToDiffRenderers(&rootNode, changes)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err)
|
||||
}
|
||||
@@ -512,7 +524,9 @@ func migrateAllBranchesLogCmd(rootNode *yaml.Node, changes *ChangesSet) error {
|
||||
})
|
||||
}
|
||||
|
||||
func migratePagers(rootNode *yaml.Node, changes *ChangesSet) error {
|
||||
// Migrate the single 'paging' node to an array of 'pagers'. This is not the final structure, we
|
||||
// migrate it to diffRenderers from there in a separate step below.
|
||||
func migratePaging(rootNode *yaml.Node, changes *ChangesSet) error {
|
||||
return yaml_utils.TransformNode(rootNode, []string{"git"}, func(gitNode *yaml.Node) error {
|
||||
pagingKeyNode, pagingValueNode := yaml_utils.LookupKey(gitNode, "paging")
|
||||
if pagingKeyNode == nil || pagingValueNode.Kind != yaml.MappingNode {
|
||||
@@ -521,10 +535,11 @@ func migratePagers(rootNode *yaml.Node, changes *ChangesSet) error {
|
||||
}
|
||||
|
||||
pagersKeyNode, _ := yaml_utils.LookupKey(gitNode, "pagers")
|
||||
if pagersKeyNode != nil {
|
||||
// Conversely, if there *is* already a "pagers" array, we also have nothing to do.
|
||||
// This covers the case where the user keeps both the "paging" section and the "pagers"
|
||||
// array for the sake of easier testing of old versions.
|
||||
diffRenderersKeyNode, _ := yaml_utils.LookupKey(gitNode, "diffRenderers")
|
||||
if pagersKeyNode != nil || diffRenderersKeyNode != nil {
|
||||
// Conversely, if there is already a newer array config, we also have nothing to do.
|
||||
// This covers the case where the user keeps both formats for the sake of easier testing
|
||||
// of old versions.
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -532,6 +547,7 @@ func migratePagers(rootNode *yaml.Node, changes *ChangesSet) error {
|
||||
pagingContentCopy := pagingValueNode.Content
|
||||
pagingValueNode.Kind = yaml.SequenceNode
|
||||
pagingValueNode.Tag = "!!seq"
|
||||
pagingValueNode.Style &^= yaml.FlowStyle
|
||||
pagingValueNode.Content = []*yaml.Node{{
|
||||
Kind: yaml.MappingNode,
|
||||
Content: pagingContentCopy,
|
||||
@@ -543,6 +559,90 @@ func migratePagers(rootNode *yaml.Node, changes *ChangesSet) error {
|
||||
})
|
||||
}
|
||||
|
||||
func migratePagersToDiffRenderers(rootNode *yaml.Node, changes *ChangesSet) error {
|
||||
return yaml_utils.TransformNode(rootNode, []string{"git"}, func(gitNode *yaml.Node) error {
|
||||
pagersKeyNode, pagersValueNode := yaml_utils.LookupKey(gitNode, "pagers")
|
||||
if pagersKeyNode == nil || pagersValueNode.Kind != yaml.SequenceNode {
|
||||
// If there's no "pagers" section (or it's not a sequence), there's nothing to do
|
||||
return nil
|
||||
}
|
||||
|
||||
diffRenderersKeyNode, _ := yaml_utils.LookupKey(gitNode, "diffRenderers")
|
||||
if diffRenderersKeyNode != nil {
|
||||
// Conversely, if there *is* already a "diffRenderers" array, we also have nothing to do.
|
||||
// This covers the case where the user keeps both the "pagers" and the "diffRenderers"
|
||||
// arrays for the sake of easier testing of old versions.
|
||||
return nil
|
||||
}
|
||||
|
||||
pagersKeyNode.Value = "diffRenderers"
|
||||
changes.Add("Renamed git.pagers to git.diffRenderers")
|
||||
|
||||
for _, diffRendererNode := range pagersValueNode.Content {
|
||||
if diffRendererNode.Kind != yaml.MappingNode {
|
||||
continue
|
||||
}
|
||||
|
||||
pagerKeyNode, pagerValueNode := yaml_utils.LookupKey(diffRendererNode, "pager")
|
||||
externalDiffCommandKeyNode, externalDiffCommandValueNode := yaml_utils.LookupKey(diffRendererNode, "externalDiffCommand")
|
||||
useExternalDiffGitConfigKeyNode, useExternalDiffGitConfigValueNode := yaml_utils.LookupKey(diffRendererNode, "useExternalDiffGitConfig")
|
||||
|
||||
hasPager := hasNonNullScalarValue(pagerValueNode)
|
||||
hasExternalDiffCommand := hasNonNullScalarValue(externalDiffCommandValueNode)
|
||||
useExternalDiffGitConfig := yamlBoolValue(useExternalDiffGitConfigValueNode)
|
||||
|
||||
if hasPager {
|
||||
pagerKeyNode.Value = "command"
|
||||
changes.Add("Renamed 'pager' to 'command' in git pager")
|
||||
} else if hasExternalDiffCommand {
|
||||
externalDiffCommandKeyNode.Value = "command"
|
||||
yaml_utils.AddStringKey(diffRendererNode, "type", "extDiff")
|
||||
changes.Add("Changed 'externalDiffCommand' to 'command' with 'type: extDiff' in git pager")
|
||||
} else if useExternalDiffGitConfig {
|
||||
yaml_utils.RemoveKey(diffRendererNode, "useExternalDiffGitConfig")
|
||||
yaml_utils.AddStringKey(diffRendererNode, "type", "extDiff")
|
||||
changes.Add("Changed 'useExternalDiffGitConfig: true' to 'type: extDiff' in git pager")
|
||||
} else {
|
||||
yaml_utils.AddStringKey(diffRendererNode, "type", "rawGit")
|
||||
diffRendererNode.Style &^= yaml.FlowStyle
|
||||
changes.Add("Changed git pager without a command to 'type: rawGit'")
|
||||
}
|
||||
|
||||
if pagerKeyNode != nil && !hasPager {
|
||||
yaml_utils.RemoveKey(diffRendererNode, "pager")
|
||||
changes.Add("Removed empty 'pager' from git pager")
|
||||
}
|
||||
if externalDiffCommandKeyNode != nil && !hasExternalDiffCommand {
|
||||
yaml_utils.RemoveKey(diffRendererNode, "externalDiffCommand")
|
||||
changes.Add("Removed empty 'externalDiffCommand' from git pager")
|
||||
}
|
||||
if useExternalDiffGitConfigKeyNode != nil && !useExternalDiffGitConfig {
|
||||
yaml_utils.RemoveKey(diffRendererNode, "useExternalDiffGitConfig")
|
||||
if useExternalDiffGitConfigValueNode.Tag == "!!null" {
|
||||
changes.Add("Removed empty 'useExternalDiffGitConfig' from git pager")
|
||||
} else {
|
||||
changes.Add("Removed 'useExternalDiffGitConfig: false' from git pager")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func hasNonNullScalarValue(node *yaml.Node) bool {
|
||||
return node != nil && node.Kind == yaml.ScalarNode && node.Tag != "!!null" && node.Value != ""
|
||||
}
|
||||
|
||||
func yamlBoolValue(node *yaml.Node) bool {
|
||||
if node == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var value bool
|
||||
return node.Decode(&value) == nil && value
|
||||
}
|
||||
|
||||
func (c *AppConfig) GetDebug() bool {
|
||||
return c.debug
|
||||
}
|
||||
@@ -571,6 +671,20 @@ func (c *AppConfig) GetAppState() *AppState {
|
||||
return c.appState
|
||||
}
|
||||
|
||||
func (c *AppConfig) GetCachedGithubPullRequests(repoPath string) ([]CachedPullRequest, error) {
|
||||
if c.githubPullRequestCache == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return c.githubPullRequestCache.get(repoPath), c.githubPullRequestCache.takeLoadError()
|
||||
}
|
||||
|
||||
func (c *AppConfig) SaveCachedGithubPullRequests(repoPath string, pullRequests []CachedPullRequest) error {
|
||||
if c.githubPullRequestCache == nil {
|
||||
return nil
|
||||
}
|
||||
return c.githubPullRequestCache.save(repoPath, pullRequests)
|
||||
}
|
||||
|
||||
func (c *AppConfig) GetUserConfigPaths() []string {
|
||||
return lo.FilterMap(c.userConfigFiles, func(f *ConfigFile, _ int) (string, bool) {
|
||||
return f.Path, f.exists
|
||||
@@ -742,27 +856,10 @@ type AppState struct {
|
||||
ShellCommandsHistory []string `yaml:"customcommandshistory"`
|
||||
|
||||
HideCommandLog bool
|
||||
|
||||
// Cache of GitHub pull requests per repo path, so that PR info can be
|
||||
// shown instantly on startup before the async refresh completes.
|
||||
GithubPullRequests map[string][]CachedPullRequest `yaml:"githubPullRequests"`
|
||||
}
|
||||
|
||||
// CachedPullRequest stores the essential fields of a GitHub pull request
|
||||
// for persisting in the app state cache.
|
||||
type CachedPullRequest struct {
|
||||
HeadRefName string `yaml:"headRefName"`
|
||||
Number int `yaml:"number"`
|
||||
Title string `yaml:"title"`
|
||||
State string `yaml:"state"`
|
||||
Url string `yaml:"url"`
|
||||
HeadRepositoryOwner string `yaml:"headRepositoryOwner"`
|
||||
}
|
||||
|
||||
func getDefaultAppState() *AppState {
|
||||
return &AppState{
|
||||
GithubPullRequests: make(map[string][]CachedPullRequest),
|
||||
}
|
||||
return &AppState{}
|
||||
}
|
||||
|
||||
func LogPath() (string, error) {
|
||||
|
||||
+204
-687
@@ -83,24 +83,28 @@ func TestMigrationOfRenamedKeys(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "Rename several",
|
||||
input: `gui:
|
||||
windowSize: half
|
||||
skipUnstageLineWarning: true
|
||||
keybinding:
|
||||
universal:
|
||||
executeCustomCommand: a
|
||||
`,
|
||||
expected: `gui:
|
||||
screenMode: half
|
||||
skipDiscardChangeWarning: true
|
||||
keybinding:
|
||||
universal:
|
||||
executeShellCommand: a
|
||||
`,
|
||||
input: "gui:\n" +
|
||||
" windowSize: half\n" +
|
||||
" skipUnstageLineWarning: true\n" +
|
||||
"keybinding:\n" +
|
||||
" universal:\n" +
|
||||
" executeCustomCommand: a\n" +
|
||||
" cyclePagers: b\n" +
|
||||
" cyclePagersReverse: c\n",
|
||||
expected: "gui:\n" +
|
||||
" screenMode: half\n" +
|
||||
" skipDiscardChangeWarning: true\n" +
|
||||
"keybinding:\n" +
|
||||
" universal:\n" +
|
||||
" executeShellCommand: a\n" +
|
||||
" cycleDiffRenderers: b\n" +
|
||||
" cycleDiffRenderersReverse: c\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{
|
||||
"Renamed 'gui.skipUnstageLineWarning' to 'skipDiscardChangeWarning'",
|
||||
"Renamed 'keybinding.universal.executeCustomCommand' to 'executeShellCommand'",
|
||||
"Renamed 'keybinding.universal.cyclePagers' to 'cycleDiffRenderers'",
|
||||
"Renamed 'keybinding.universal.cyclePagersReverse' to 'cycleDiffRenderersReverse'",
|
||||
"Renamed 'gui.windowSize' to 'screenMode'",
|
||||
},
|
||||
},
|
||||
@@ -470,629 +474,6 @@ func TestCustomCommandsOutputMigration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
var largeConfiguration = []byte(`
|
||||
# Config relating to the Lazygit UI
|
||||
gui:
|
||||
# The number of lines you scroll by when scrolling the main window
|
||||
scrollHeight: 2
|
||||
|
||||
# If true, allow scrolling past the bottom of the content in the main window
|
||||
scrollPastBottom: true
|
||||
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#scroll-off-margin
|
||||
scrollOffMargin: 2
|
||||
|
||||
# One of: 'margin' (default) | 'jump'
|
||||
scrollOffBehavior: margin
|
||||
|
||||
# The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs.
|
||||
# Note that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command.
|
||||
tabWidth: 4
|
||||
|
||||
# If true, capture mouse events.
|
||||
# When mouse events are captured, it's a little harder to select text: e.g. requiring you to hold the option key when on macOS.
|
||||
mouseEvents: true
|
||||
|
||||
# If true, do not show a warning when amending a commit.
|
||||
skipAmendWarning: false
|
||||
|
||||
# If true, do not show a warning when discarding changes in the staging view.
|
||||
skipDiscardChangeWarning: false
|
||||
|
||||
# If true, do not show warning when applying/popping the stash
|
||||
skipStashWarning: false
|
||||
|
||||
# If true, do not show a warning when attempting to commit without any staged files; instead stage all unstaged files.
|
||||
skipNoStagedFilesWarning: false
|
||||
|
||||
# If true, do not show a warning when rewording a commit via an external editor
|
||||
skipRewordInEditorWarning: false
|
||||
|
||||
# Fraction of the total screen width to use for the left side section. You may want to pick a small number (e.g. 0.2) if you're using a narrow screen, so that you can see more of the main section.
|
||||
# Number from 0 to 1.0.
|
||||
sidePanelWidth: 0.3333
|
||||
|
||||
# If true, increase the height of the focused side window; creating an accordion effect.
|
||||
expandFocusedSidePanel: false
|
||||
|
||||
# The weight of the expanded side panel, relative to the other panels. 2 means
|
||||
# twice as tall as the other panels. Only relevant if expandFocusedSidePanel is true.
|
||||
expandedSidePanelWeight: 2
|
||||
|
||||
# Sometimes the main window is split in two (e.g. when the selected file has both staged and unstaged changes). This setting controls how the two sections are split.
|
||||
# Options are:
|
||||
# - 'horizontal': split the window horizontally
|
||||
# - 'vertical': split the window vertically
|
||||
# - 'flexible': (default) split the window horizontally if the window is wide enough, otherwise split vertically
|
||||
mainPanelSplitMode: flexible
|
||||
|
||||
# How the window is split when in half screen mode (i.e. after hitting '+' once).
|
||||
# Possible values:
|
||||
# - 'left': split the window horizontally (side panel on the left, main view on the right)
|
||||
# - 'top': split the window vertically (side panel on top, main view below)
|
||||
enlargedSideViewLocation: left
|
||||
|
||||
# If true, wrap lines in the staging view to the width of the view. This
|
||||
# makes it much easier to work with diffs that have long lines, e.g.
|
||||
# paragraphs of markdown text.
|
||||
wrapLinesInStagingView: true
|
||||
|
||||
# One of 'auto' (default) | 'en' | 'zh-CN' | 'zh-TW' | 'pl' | 'nl' | 'ja' | 'ko' | 'ru'
|
||||
language: auto
|
||||
|
||||
# Format used when displaying time e.g. commit time.
|
||||
# Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format
|
||||
timeFormat: 02 Jan 06
|
||||
|
||||
# Format used when displaying time if the time is less than 24 hours ago.
|
||||
# Uses Go's time format syntax: https://pkg.go.dev/time#Time.Format
|
||||
shortTimeFormat: 3:04PM
|
||||
|
||||
# Config relating to colors and styles.
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#color-attributes
|
||||
theme:
|
||||
# Border color of focused window
|
||||
activeBorderColor:
|
||||
- green
|
||||
- bold
|
||||
|
||||
# Border color of non-focused windows
|
||||
inactiveBorderColor:
|
||||
- default
|
||||
|
||||
# Border color of focused window when searching in that window
|
||||
searchingActiveBorderColor:
|
||||
- cyan
|
||||
- bold
|
||||
|
||||
# Color of keybindings help text in the bottom line
|
||||
optionsTextColor:
|
||||
- blue
|
||||
|
||||
# Background color of selected line.
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#highlighting-the-selected-line
|
||||
selectedLineBgColor:
|
||||
- blue
|
||||
|
||||
# Background color of selected line when view doesn't have focus.
|
||||
inactiveViewSelectedLineBgColor:
|
||||
- bold
|
||||
|
||||
# Foreground color of copied commit
|
||||
cherryPickedCommitFgColor:
|
||||
- blue
|
||||
|
||||
# Background color of copied commit
|
||||
cherryPickedCommitBgColor:
|
||||
- cyan
|
||||
|
||||
# Foreground color of marked base commit (for rebase)
|
||||
markedBaseCommitFgColor:
|
||||
- blue
|
||||
|
||||
# Background color of marked base commit (for rebase)
|
||||
markedBaseCommitBgColor:
|
||||
- yellow
|
||||
|
||||
# Color for file with unstaged changes
|
||||
unstagedChangesColor:
|
||||
- red
|
||||
|
||||
# Default text color
|
||||
defaultFgColor:
|
||||
- default
|
||||
|
||||
# Config relating to the commit length indicator
|
||||
commitLength:
|
||||
# If true, show an indicator of commit message length
|
||||
show: true
|
||||
|
||||
# If true, show the '5 of 20' footer at the bottom of list views
|
||||
showListFooter: true
|
||||
|
||||
# If true, display the files in the file views as a tree. If false, display the files as a flat list.
|
||||
# This can be toggled from within Lazygit with the '' key, but that will not change the default.
|
||||
showFileTree: true
|
||||
|
||||
# If true, show the number of lines changed per file in the Files view
|
||||
showNumstatInFilesView: false
|
||||
|
||||
# If true, show a random tip in the command log when Lazygit starts
|
||||
showRandomTip: true
|
||||
|
||||
# If true, show the command log
|
||||
showCommandLog: true
|
||||
|
||||
# If true, show the bottom line that contains keybinding info and useful buttons. If false, this line will be hidden except to display a loader for an in-progress action.
|
||||
showBottomLine: true
|
||||
|
||||
# If true, show jump-to-window keybindings in window titles.
|
||||
showPanelJumps: true
|
||||
|
||||
# Deprecated: use nerdFontsVersion instead
|
||||
showIcons: false
|
||||
|
||||
# Nerd fonts version to use.
|
||||
# One of: '2' | '3' | empty string (default)
|
||||
# If empty, do not show icons.
|
||||
nerdFontsVersion: ""
|
||||
|
||||
# If true (default), file icons are shown in the file views. Only relevant if NerdFontsVersion is not empty.
|
||||
showFileIcons: true
|
||||
|
||||
# Length of author name in (non-expanded) commits view. 2 means show initials only.
|
||||
commitAuthorShortLength: 2
|
||||
|
||||
# Length of author name in expanded commits view. 2 means show initials only.
|
||||
commitAuthorLongLength: 17
|
||||
|
||||
# Length of commit hash in commits view. 0 shows '*' if NF icons aren't on.
|
||||
commitHashLength: 8
|
||||
|
||||
# If true, show commit hashes alongside branch names in the branches view.
|
||||
showBranchCommitHash: false
|
||||
|
||||
# Whether to show the divergence from the base branch in the branches view.
|
||||
# One of: 'none' | 'onlyArrow' | 'arrowAndNumber'
|
||||
showDivergenceFromBaseBranch: none
|
||||
|
||||
# Height of the command log view
|
||||
commandLogSize: 8
|
||||
|
||||
# Whether to split the main window when viewing file changes.
|
||||
# One of: 'auto' | 'always'
|
||||
# If 'auto', only split the main window when a file has both staged and unstaged changes
|
||||
splitDiff: auto
|
||||
|
||||
# Default size for focused window. Can be changed from within Lazygit with '+' and '_' (but this won't change the default).
|
||||
# One of: 'normal' (default) | 'half' | 'full'
|
||||
screenMode: normal
|
||||
|
||||
# Window border style.
|
||||
# One of 'rounded' (default) | 'single' | 'double' | 'hidden' | 'bold'
|
||||
border: rounded
|
||||
|
||||
# If true, show a seriously epic explosion animation when nuking the working tree.
|
||||
animateExplosion: true
|
||||
|
||||
# Whether to stack UI components on top of each other.
|
||||
# One of 'auto' (default) | 'always' | 'never'
|
||||
portraitMode: auto
|
||||
|
||||
# How things are filtered when typing '/'.
|
||||
# One of 'substring' (default) | 'fuzzy'
|
||||
filterMode: substring
|
||||
|
||||
# Config relating to the spinner.
|
||||
spinner:
|
||||
# The frames of the spinner animation.
|
||||
frames:
|
||||
- '|'
|
||||
- /
|
||||
- '-'
|
||||
- \
|
||||
|
||||
# The "speed" of the spinner in milliseconds.
|
||||
rate: 50
|
||||
|
||||
# Status panel view.
|
||||
# One of 'dashboard' (default) | 'allBranchesLog'
|
||||
statusPanelView: dashboard
|
||||
|
||||
# If true, jump to the Files panel after popping a stash
|
||||
switchToFilesAfterStashPop: true
|
||||
|
||||
# If true, jump to the Files panel after applying a stash
|
||||
switchToFilesAfterStashApply: true
|
||||
|
||||
# If true, when using the panel jump keys (default 1 through 5) and target panel is already active, go to next tab instead
|
||||
switchTabsWithPanelJumpKeys: false
|
||||
|
||||
# Config relating to git
|
||||
git:
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md
|
||||
paging:
|
||||
# Value of the --color arg in the git diff command. Some pagers want this to be set to 'always' and some want it set to 'never'
|
||||
colorArg: always
|
||||
|
||||
# e.g.
|
||||
# diff-so-fancy
|
||||
# delta --dark --paging=never
|
||||
# ydiff -p cat -s --wrap --width={{columnWidth}}
|
||||
pager: ""
|
||||
|
||||
useConfig: false
|
||||
|
||||
# e.g. 'difft --color=always'
|
||||
externalDiffCommand: ""
|
||||
|
||||
# Config relating to committing
|
||||
commit:
|
||||
# If true, pass '--signoff' flag when committing
|
||||
signOff: false
|
||||
|
||||
# Automatic WYSIWYG wrapping of the commit message as you type
|
||||
autoWrapCommitMessage: true
|
||||
|
||||
# If autoWrapCommitMessage is true, the width to wrap to
|
||||
autoWrapWidth: 72
|
||||
|
||||
# Config relating to merging
|
||||
merging:
|
||||
# If true, run merges in a subprocess so that if a commit message is required, Lazygit will not hang
|
||||
# Only applicable to unix users.
|
||||
manualCommit: false
|
||||
|
||||
# Extra args passed to , e.g. --no-ff
|
||||
args: ""
|
||||
|
||||
# The commit message to use for a squash merge commit. Can contain "{{selectedRef}}" and "{{currentBranch}}" placeholders.
|
||||
squashMergeMessage: Squash merge {{selectedRef}} into {{currentBranch}}
|
||||
|
||||
# list of branches that are considered 'main' branches, used when displaying commits
|
||||
mainBranches:
|
||||
- master
|
||||
- main
|
||||
|
||||
# Prefix to use when skipping hooks. E.g. if set to 'WIP', then pre-commit hooks will be skipped when the commit message starts with 'WIP'
|
||||
skipHookPrefix: WIP
|
||||
|
||||
# If true, periodically fetch from remote
|
||||
autoFetch: true
|
||||
|
||||
# If true, periodically refresh files and submodules
|
||||
autoRefresh: true
|
||||
|
||||
# If true, poll the repo periodically for external ref changes (commits,
|
||||
# branch updates, checkouts made outside lazygit) and refresh when one
|
||||
# is detected. Independent of autoRefresh, which only governs the files
|
||||
# panel.
|
||||
autoDetectExternalChanges: true
|
||||
|
||||
# If true, pass the --all arg to git fetch
|
||||
fetchAll: true
|
||||
|
||||
# If true, lazygit will automatically stage files that used to have merge
|
||||
# conflicts but no longer do; and it will also ask you if you want to
|
||||
# continue a merge or rebase if you've resolved all conflicts. If false, it
|
||||
# won't do either of these things.
|
||||
autoStageResolvedConflicts: true
|
||||
|
||||
# Command used when displaying the current branch git log in the main window
|
||||
branchLogCmd: git log --graph --color=always --abbrev-commit --decorate --date=relative --pretty=medium {{branchName}} --
|
||||
|
||||
# Command used to display git log of all branches in the main window.
|
||||
# Deprecated: Use allBranchesLogCmds instead.
|
||||
allBranchesLogCmd: git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium
|
||||
|
||||
# If true, do not spawn a separate process when using GPG
|
||||
overrideGpg: false
|
||||
|
||||
# If true, do not allow force pushes
|
||||
disableForcePushing: false
|
||||
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#predefined-branch-name-prefix
|
||||
branchPrefix: ""
|
||||
|
||||
# If true, parse emoji strings in commit messages e.g. render :rocket: as 🚀
|
||||
# (This should really be under 'gui', not 'git')
|
||||
parseEmoji: false
|
||||
|
||||
# Config for showing the log in the commits view
|
||||
log:
|
||||
# One of: 'date-order' | 'author-date-order' | 'topo-order' | 'default'
|
||||
# 'topo-order' makes it easier to read the git log graph, but commits may not
|
||||
# appear chronologically. See https://git-scm.com/docs/
|
||||
#
|
||||
# Deprecated: Configure this with Log menu -> Commit sort order (<c-l> in the commits window by default).
|
||||
order: topo-order
|
||||
|
||||
# This determines whether the git graph is rendered in the commits panel
|
||||
# One of 'always' | 'never' | 'when-maximised'
|
||||
#
|
||||
# Deprecated: Configure this with Log menu -> Show git graph (<c-l> in the commits window by default).
|
||||
showGraph: always
|
||||
|
||||
# displays the whole git graph by default in the commits view (equivalent to passing the --all argument to git log)
|
||||
showWholeGraph: false
|
||||
|
||||
# When copying commit hashes to the clipboard, truncate them to this
|
||||
# length. Set to 40 to disable truncation.
|
||||
truncateCopiedCommitHashesTo: 12
|
||||
|
||||
# Periodic update checks
|
||||
update:
|
||||
# One of: 'prompt' (default) | 'background' | 'never'
|
||||
method: prompt
|
||||
|
||||
# Period in days between update checks
|
||||
days: 14
|
||||
|
||||
# Background refreshes
|
||||
refresher:
|
||||
# File/submodule refresh interval in seconds.
|
||||
# Auto-refresh can be disabled via option 'git.autoRefresh'.
|
||||
refreshInterval: 10
|
||||
|
||||
# Re-fetch interval in seconds.
|
||||
# Auto-fetch can be disabled via option 'git.autoFetch'.
|
||||
fetchInterval: 60
|
||||
|
||||
# Interval in seconds at which lazygit polls for external ref changes
|
||||
# (commits, branch updates, checkouts made outside lazygit).
|
||||
# Detection can be disabled via option 'git.autoDetectExternalChanges'.
|
||||
externalChangeCheckInterval: 2
|
||||
|
||||
# If true, show a confirmation popup before quitting Lazygit
|
||||
confirmOnQuit: false
|
||||
|
||||
# If true, exit Lazygit when the user presses escape in a context where there is nothing to cancel/close
|
||||
quitOnTopLevelReturn: false
|
||||
|
||||
# Config relating to things outside of Lazygit like how files are opened, copying to clipboard, etc
|
||||
os:
|
||||
# Command for editing a file. Should contain "{{filename}}".
|
||||
edit: ""
|
||||
|
||||
# Command for editing a file at a given line number. Should contain
|
||||
# "{{filename}}", and may optionally contain "{{line}}".
|
||||
editAtLine: ""
|
||||
|
||||
# Same as EditAtLine, except that the command needs to wait until the
|
||||
# window is closed.
|
||||
editAtLineAndWait: ""
|
||||
|
||||
# Whether lazygit suspends until an edit process returns
|
||||
editInTerminal: false
|
||||
|
||||
# For opening a directory in an editor
|
||||
openDirInEditor: ""
|
||||
|
||||
# A built-in preset that sets all of the above settings. Supported presets
|
||||
# are defined in the getPreset function in editor_presets.go.
|
||||
editPreset: ""
|
||||
|
||||
# Command for opening a file, as if the file is double-clicked. Should
|
||||
# contain "{{filename}}", but doesn't support "{{line}}".
|
||||
open: ""
|
||||
|
||||
# Command for opening a link. Should contain "{{link}}".
|
||||
openLink: ""
|
||||
|
||||
# EditCommand is the command for editing a file.
|
||||
# Deprecated: use Edit instead. Note that semantics are different:
|
||||
# EditCommand is just the command itself, whereas Edit contains a
|
||||
# "{{filename}}" variable.
|
||||
editCommand: ""
|
||||
|
||||
# EditCommandTemplate is the command template for editing a file
|
||||
# Deprecated: use EditAtLine instead.
|
||||
editCommandTemplate: ""
|
||||
|
||||
# OpenCommand is the command for opening a file
|
||||
# Deprecated: use Open instead.
|
||||
openCommand: ""
|
||||
|
||||
# OpenLinkCommand is the command for opening a link
|
||||
# Deprecated: use OpenLink instead.
|
||||
openLinkCommand: ""
|
||||
|
||||
# CopyToClipboardCmd is the command for copying to clipboard.
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard
|
||||
copyToClipboardCmd: ""
|
||||
|
||||
# ReadFromClipboardCmd is the command for reading the clipboard.
|
||||
# See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-command-for-copying-to-and-pasting-from-clipboard
|
||||
readFromClipboardCmd: ""
|
||||
|
||||
# If true, don't display introductory popups upon opening Lazygit.
|
||||
disableStartupPopups: false
|
||||
|
||||
# What to do when opening Lazygit outside of a git repo.
|
||||
# - 'prompt': (default) ask whether to initialize a new repo or open in the most recent repo
|
||||
# - 'create': initialize a new repo
|
||||
# - 'skip': open most recent repo
|
||||
# - 'quit': exit Lazygit
|
||||
notARepository: prompt
|
||||
|
||||
# If true, display a confirmation when subprocess terminates. This allows you to view the output of the subprocess before returning to Lazygit.
|
||||
promptToReturnFromSubprocess: true
|
||||
|
||||
# Keybindings
|
||||
keybinding:
|
||||
universal:
|
||||
quit: q
|
||||
quit-alt1: <c-c>
|
||||
return: <esc>
|
||||
quitWithoutChangingDirectory: Q
|
||||
togglePanel: <tab>
|
||||
prevItem: <up>
|
||||
nextItem: <down>
|
||||
prevItem-alt: k
|
||||
nextItem-alt: j
|
||||
prevPage: ','
|
||||
nextPage: .
|
||||
scrollLeft: H
|
||||
scrollRight: L
|
||||
gotoTop: <
|
||||
gotoBottom: '>'
|
||||
toggleRangeSelect: v
|
||||
rangeSelectDown: <s-down>
|
||||
rangeSelectUp: <s-up>
|
||||
prevBlock: <left>
|
||||
nextBlock: <right>
|
||||
prevBlock-alt: h
|
||||
nextBlock-alt: l
|
||||
nextBlock-alt2: <tab>
|
||||
prevBlock-alt2: <backtab>
|
||||
jumpToBlock:
|
||||
- "1"
|
||||
- "2"
|
||||
- "3"
|
||||
- "4"
|
||||
- "5"
|
||||
nextMatch: "n"
|
||||
prevMatch: "N"
|
||||
startSearch: /
|
||||
optionMenu: <disabled>
|
||||
optionMenu-alt1: '?'
|
||||
select: <space>
|
||||
goInto: <enter>
|
||||
confirm: <enter>
|
||||
confirmInEditor: <a-enter>
|
||||
remove: d
|
||||
new: "n"
|
||||
edit: e
|
||||
openFile: o
|
||||
scrollUpMain: <pgup>
|
||||
scrollDownMain: <pgdown>
|
||||
scrollUpMain-alt1: K
|
||||
scrollDownMain-alt1: J
|
||||
scrollUpMain-alt2: <c-u>
|
||||
scrollDownMain-alt2: <c-d>
|
||||
executeShellCommand: ':'
|
||||
createRebaseOptionsMenu: m
|
||||
|
||||
# 'Files' appended for legacy reasons
|
||||
pushFiles: P
|
||||
|
||||
# 'Files' appended for legacy reasons
|
||||
pullFiles: p
|
||||
refresh: R
|
||||
createPatchOptionsMenu: <c-p>
|
||||
nextTab: ']'
|
||||
prevTab: '['
|
||||
nextScreenMode: +
|
||||
prevScreenMode: _
|
||||
undo: z
|
||||
redo: Z
|
||||
filteringMenu: <c-s>
|
||||
diffingMenu: W
|
||||
diffingMenu-alt: <c-e>
|
||||
copyToClipboard: <c-o>
|
||||
openRecentRepos: <c-r>
|
||||
submitEditorText: <enter>
|
||||
extrasMenu: '@'
|
||||
toggleWhitespaceInDiffView: <c-w>
|
||||
increaseContextInDiffView: '}'
|
||||
decreaseContextInDiffView: '{'
|
||||
increaseRenameSimilarityThreshold: )
|
||||
decreaseRenameSimilarityThreshold: (
|
||||
openDiffTool: <c-t>
|
||||
status:
|
||||
checkForUpdate: u
|
||||
recentRepos: <enter>
|
||||
allBranchesLogGraph: a
|
||||
files:
|
||||
commitChanges: c
|
||||
commitChangesWithoutHook: w
|
||||
amendLastCommit: A
|
||||
commitChangesWithEditor: C
|
||||
findBaseCommitForFixup: <c-f>
|
||||
confirmDiscard: x
|
||||
ignoreFile: i
|
||||
refreshFiles: r
|
||||
stashAllChanges: s
|
||||
viewStashOptions: S
|
||||
toggleStagedAll: a
|
||||
viewResetOptions: D
|
||||
fetch: f
|
||||
openMergeOptions: M
|
||||
openStatusFilter: <c-b>
|
||||
copyFileInfoToClipboard: "y"
|
||||
collapseAll: '-'
|
||||
expandAll: =
|
||||
branches:
|
||||
createPullRequest: o
|
||||
viewPullRequestOptions: O
|
||||
copyPullRequestURL: <c-y>
|
||||
checkoutBranchByName: c
|
||||
forceCheckoutBranch: F
|
||||
rebaseBranch: r
|
||||
renameBranch: R
|
||||
mergeIntoCurrentBranch: M
|
||||
viewGitFlowOptions: i
|
||||
fastForward: f
|
||||
createTag: T
|
||||
pushTag: P
|
||||
setUpstream: u
|
||||
fetchRemote: f
|
||||
sortOrder: s
|
||||
worktrees:
|
||||
viewWorktreeOptions: w
|
||||
commits:
|
||||
squashDown: s
|
||||
renameCommit: r
|
||||
renameCommitWithEditor: R
|
||||
viewResetOptions: g
|
||||
markCommitAsFixup: f
|
||||
createFixupCommit: F
|
||||
squashAboveCommits: S
|
||||
moveDownCommit: <c-j>
|
||||
moveUpCommit: <c-k>
|
||||
amendToCommit: A
|
||||
resetCommitAuthor: a
|
||||
pickCommit: p
|
||||
revertCommit: t
|
||||
cherryPickCopy: C
|
||||
pasteCommits: V
|
||||
markCommitAsBaseForRebase: B
|
||||
tagCommit: T
|
||||
checkoutCommit: <space>
|
||||
resetCherryPick: <c-R>
|
||||
copyCommitAttributeToClipboard: "y"
|
||||
openLogMenu: <c-l>
|
||||
openInBrowser: o
|
||||
viewBisectOptions: b
|
||||
startInteractiveRebase: i
|
||||
amendAttribute:
|
||||
resetAuthor: a
|
||||
setAuthor: A
|
||||
addCoAuthor: c
|
||||
stash:
|
||||
popStash: g
|
||||
renameStash: r
|
||||
commitFiles:
|
||||
checkoutCommitFile: c
|
||||
main:
|
||||
toggleSelectHunk: a
|
||||
pickBothHunks: b
|
||||
editSelectHunk: E
|
||||
submodules:
|
||||
init: i
|
||||
update: u
|
||||
bulkMenu: b
|
||||
commitMessage:
|
||||
commitMenu: <c-o>
|
||||
`)
|
||||
|
||||
func BenchmarkMigrationOnLargeConfiguration(b *testing.B) {
|
||||
for b.Loop() {
|
||||
changes := NewChangesSet()
|
||||
_, _, _ = computeMigratedConfig("path doesn't matter", largeConfiguration, changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllBranchesLogCmdMigrations(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
@@ -1218,6 +599,7 @@ func TestPagerMigration(t *testing.T) {
|
||||
expectedDidChange bool
|
||||
expectedChanges []string
|
||||
}{
|
||||
// Migrate 'paging' to 'pagers' array
|
||||
{
|
||||
name: "Incomplete Configuration Passes uneventfully",
|
||||
input: "git:",
|
||||
@@ -1226,68 +608,203 @@ func TestPagerMigration(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "No paging section",
|
||||
input: `git:
|
||||
autoFetch: true
|
||||
`,
|
||||
expected: `git:
|
||||
autoFetch: true
|
||||
`,
|
||||
expectedDidChange: false,
|
||||
expectedChanges: []string{},
|
||||
},
|
||||
{
|
||||
name: "Both paging and pagers exist",
|
||||
input: `git:
|
||||
paging:
|
||||
pager: delta --dark --paging=never
|
||||
pagers:
|
||||
- diff: diff-so-fancy
|
||||
`,
|
||||
expected: `git:
|
||||
paging:
|
||||
pager: delta --dark --paging=never
|
||||
pagers:
|
||||
- diff: diff-so-fancy
|
||||
`,
|
||||
input: "git:\n" +
|
||||
" autoFetch: true\n",
|
||||
expected: "git:\n" +
|
||||
" autoFetch: true\n",
|
||||
expectedDidChange: false,
|
||||
expectedChanges: []string{},
|
||||
},
|
||||
{
|
||||
name: "paging is not an object",
|
||||
input: `git:
|
||||
paging: 5
|
||||
`,
|
||||
expected: `git:
|
||||
paging: 5
|
||||
`,
|
||||
input: "git:\n" +
|
||||
" paging: 5\n",
|
||||
expected: "git:\n" +
|
||||
" paging: 5\n",
|
||||
expectedDidChange: false,
|
||||
expectedChanges: []string{},
|
||||
},
|
||||
{
|
||||
name: "paging is moved to pagers array (keeping the order)",
|
||||
input: `git:
|
||||
paging:
|
||||
pager: delta --dark --paging=never
|
||||
autoFetch: true
|
||||
`,
|
||||
expected: `git:
|
||||
pagers:
|
||||
- pager: delta --dark --paging=never
|
||||
autoFetch: true
|
||||
`,
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{"Moved git.paging object to git.pagers array"},
|
||||
name: "pagers is not an array",
|
||||
input: "git:\n" +
|
||||
" pagers: 5\n",
|
||||
expected: "git:\n" +
|
||||
" pagers: 5\n",
|
||||
expectedDidChange: false,
|
||||
expectedChanges: []string{},
|
||||
},
|
||||
{
|
||||
name: "paging is moved to pagers array even if empty",
|
||||
input: `git:
|
||||
paging: {}
|
||||
`,
|
||||
expected: `git:
|
||||
pagers: [{}]
|
||||
`,
|
||||
name: "paging and pagers coexist",
|
||||
input: "git:\n" +
|
||||
" paging:\n" +
|
||||
" pager: delta --dark --paging=never\n" +
|
||||
" pagers:\n" +
|
||||
" - pager: diff-so-fancy\n",
|
||||
expected: "git:\n" +
|
||||
" paging:\n" +
|
||||
" pager: delta --dark --paging=never\n" +
|
||||
" diffRenderers:\n" +
|
||||
" - command: diff-so-fancy\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{"Moved git.paging object to git.pagers array"},
|
||||
expectedChanges: []string{
|
||||
"Renamed git.pagers to git.diffRenderers",
|
||||
"Renamed 'pager' to 'command' in git pager",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "paging and diffRenderers coexist",
|
||||
input: "git:\n" +
|
||||
" paging:\n" +
|
||||
" pager: delta --dark --paging=never\n" +
|
||||
" diffRenderers:\n" +
|
||||
" - command: diff-so-fancy\n",
|
||||
expected: "git:\n" +
|
||||
" paging:\n" +
|
||||
" pager: delta --dark --paging=never\n" +
|
||||
" diffRenderers:\n" +
|
||||
" - command: diff-so-fancy\n",
|
||||
expectedDidChange: false,
|
||||
expectedChanges: []string{},
|
||||
},
|
||||
{
|
||||
name: "pagers and diffRenderers coexist",
|
||||
input: "git:\n" +
|
||||
" pagers:\n" +
|
||||
" - pager: delta --dark --paging=never\n" +
|
||||
" diffRenderers:\n" +
|
||||
" - command: diff-so-fancy\n",
|
||||
expected: "git:\n" +
|
||||
" pagers:\n" +
|
||||
" - pager: delta --dark --paging=never\n" +
|
||||
" diffRenderers:\n" +
|
||||
" - command: diff-so-fancy\n",
|
||||
expectedDidChange: false,
|
||||
expectedChanges: []string{},
|
||||
},
|
||||
{
|
||||
name: "paging is moved to diffRenderers array preserving fields and order",
|
||||
input: "git:\n" +
|
||||
" paging:\n" +
|
||||
" name: delta\n" +
|
||||
" colorArg: never\n" +
|
||||
" pager: delta --dark --paging=never\n" +
|
||||
" autoFetch: true\n",
|
||||
expected: "git:\n" +
|
||||
" diffRenderers:\n" +
|
||||
" - name: delta\n" +
|
||||
" colorArg: never\n" +
|
||||
" command: delta --dark --paging=never\n" +
|
||||
" autoFetch: true\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{
|
||||
"Moved git.paging object to git.pagers array",
|
||||
"Renamed git.pagers to git.diffRenderers",
|
||||
"Renamed 'pager' to 'command' in git pager",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "paging is moved to diffRenderers array even if empty",
|
||||
input: "git:\n" +
|
||||
" paging: {}\n",
|
||||
expected: "git:\n" +
|
||||
" diffRenderers:\n" +
|
||||
" - type: rawGit\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{
|
||||
"Moved git.paging object to git.pagers array",
|
||||
"Renamed git.pagers to git.diffRenderers",
|
||||
"Changed git pager without a command to 'type: rawGit'",
|
||||
},
|
||||
},
|
||||
|
||||
// Migrate 'pagers' array to 'diffRenderers' array
|
||||
{
|
||||
name: "empty pagers array is renamed",
|
||||
input: "git:\n" +
|
||||
" pagers: []\n",
|
||||
expected: "git:\n" +
|
||||
" diffRenderers: []\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{"Renamed git.pagers to git.diffRenderers"},
|
||||
},
|
||||
{
|
||||
name: "pagers array entries are adapted",
|
||||
input: "git:\n" +
|
||||
" pagers:\n" +
|
||||
" - name: delta\n" +
|
||||
" colorArg: never\n" +
|
||||
" pager: delta --dark --paging=never\n" +
|
||||
" - name: difft\n" +
|
||||
" colorArg: never\n" +
|
||||
" externalDiffCommand: difft --color=always\n" +
|
||||
" - name: git-config\n" +
|
||||
" colorArg: never\n" +
|
||||
" useExternalDiffGitConfig: TRUE\n" +
|
||||
" - name: git\n" +
|
||||
" colorArg: never\n" +
|
||||
" autoFetch: true\n",
|
||||
expected: "git:\n" +
|
||||
" diffRenderers:\n" +
|
||||
" - name: delta\n" +
|
||||
" colorArg: never\n" +
|
||||
" command: delta --dark --paging=never\n" +
|
||||
" - name: difft\n" +
|
||||
" colorArg: never\n" +
|
||||
" command: difft --color=always\n" +
|
||||
" type: extDiff\n" +
|
||||
" - name: git-config\n" +
|
||||
" colorArg: never\n" +
|
||||
" type: extDiff\n" +
|
||||
" - name: git\n" +
|
||||
" colorArg: never\n" +
|
||||
" type: rawGit\n" +
|
||||
" autoFetch: true\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{
|
||||
"Renamed git.pagers to git.diffRenderers",
|
||||
"Renamed 'pager' to 'command' in git pager",
|
||||
"Changed 'externalDiffCommand' to 'command' with 'type: extDiff' in git pager",
|
||||
"Changed 'useExternalDiffGitConfig: true' to 'type: extDiff' in git pager",
|
||||
"Changed git pager without a command to 'type: rawGit'",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zero-valued mechanism fields do not take precedence and are removed",
|
||||
input: "git:\n" +
|
||||
" pagers:\n" +
|
||||
" - pager: delta --dark --paging=never\n" +
|
||||
" externalDiffCommand: null\n" +
|
||||
" useExternalDiffGitConfig: false\n" +
|
||||
" - pager: \"\"\n" +
|
||||
" externalDiffCommand: difft --color=always\n" +
|
||||
" useExternalDiffGitConfig: false\n" +
|
||||
" - pager: null\n" +
|
||||
" externalDiffCommand: \"\"\n" +
|
||||
" useExternalDiffGitConfig: YES\n" +
|
||||
" - pager: \"\"\n" +
|
||||
" externalDiffCommand:\n" +
|
||||
" useExternalDiffGitConfig: false\n" +
|
||||
" - useExternalDiffGitConfig: null\n",
|
||||
expected: "git:\n" +
|
||||
" diffRenderers:\n" +
|
||||
" - command: delta --dark --paging=never\n" +
|
||||
" - command: difft --color=always\n" +
|
||||
" type: extDiff\n" +
|
||||
" - type: extDiff\n" +
|
||||
" - type: rawGit\n" +
|
||||
" - type: rawGit\n",
|
||||
expectedDidChange: true,
|
||||
expectedChanges: []string{
|
||||
"Renamed git.pagers to git.diffRenderers",
|
||||
"Renamed 'pager' to 'command' in git pager",
|
||||
"Removed empty 'externalDiffCommand' from git pager",
|
||||
"Removed 'useExternalDiffGitConfig: false' from git pager",
|
||||
"Changed 'externalDiffCommand' to 'command' with 'type: extDiff' in git pager",
|
||||
"Removed empty 'pager' from git pager",
|
||||
"Changed 'useExternalDiffGitConfig: true' to 'type: extDiff' in git pager",
|
||||
"Changed git pager without a command to 'type: rawGit'",
|
||||
"Removed empty 'useExternalDiffGitConfig' from git pager",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
type DiffRendererConfigManager struct {
|
||||
getUserConfig func() *UserConfig
|
||||
diffRendererIndex int
|
||||
}
|
||||
|
||||
type DiffRendererType int
|
||||
|
||||
const (
|
||||
DiffRendererType_StdinFilter DiffRendererType = iota
|
||||
DiffRendererType_ExtDiff
|
||||
DiffRendererType_RawGit
|
||||
)
|
||||
|
||||
func NewDiffRendererConfigManager(getUserConfig func() *UserConfig) *DiffRendererConfigManager {
|
||||
return &DiffRendererConfigManager{getUserConfig: getUserConfig}
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) currentDiffRendererConfig() *DiffRendererConfig {
|
||||
diffRenderers := self.getUserConfig().Git.DiffRenderers
|
||||
if len(diffRenderers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Guard against the diff renderer index being out of range, which can happen if the user
|
||||
// has removed diff renderers from their config file while lazygit is running.
|
||||
if self.diffRendererIndex >= len(diffRenderers) {
|
||||
self.diffRendererIndex = 0
|
||||
}
|
||||
|
||||
return &diffRenderers[self.diffRendererIndex]
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfig) getType() DiffRendererType {
|
||||
switch self.Type {
|
||||
case "stdinFilter", "":
|
||||
return DiffRendererType_StdinFilter
|
||||
case "extDiff":
|
||||
return DiffRendererType_ExtDiff
|
||||
case "rawGit":
|
||||
return DiffRendererType_RawGit
|
||||
}
|
||||
panic("invalid diff renderer type: " + self.Type)
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetDiffRendererType() DiffRendererType {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil {
|
||||
return DiffRendererType_RawGit
|
||||
}
|
||||
return currentDiffRendererConfig.getType()
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetStdinFilterCommand(width int) string {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_StdinFilter {
|
||||
return ""
|
||||
}
|
||||
|
||||
templateValues := map[string]string{
|
||||
"columnWidth": strconv.Itoa(width/2 - 6),
|
||||
}
|
||||
|
||||
commandTemplate := string(currentDiffRendererConfig.Command)
|
||||
return utils.ResolvePlaceholderString(commandTemplate, templateValues)
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetColorArg() string {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_StdinFilter {
|
||||
return "always"
|
||||
}
|
||||
|
||||
colorArg := currentDiffRendererConfig.ColorArg
|
||||
if colorArg == "" {
|
||||
return "always"
|
||||
}
|
||||
return colorArg
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetExternalDiffCommand(diffContext uint64) string {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_ExtDiff {
|
||||
return ""
|
||||
}
|
||||
|
||||
templateValues := map[string]string{
|
||||
"diffContext": strconv.Itoa(int(diffContext)),
|
||||
}
|
||||
|
||||
return utils.ResolvePlaceholderString(string(currentDiffRendererConfig.Command), templateValues)
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) GetRawGitArgs() []string {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil || currentDiffRendererConfig.getType() != DiffRendererType_RawGit {
|
||||
return nil
|
||||
}
|
||||
return currentDiffRendererConfig.Args
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) CycleDiffRenderers() {
|
||||
self.diffRendererIndex = (self.diffRendererIndex + 1) % len(self.getUserConfig().Git.DiffRenderers)
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) CycleDiffRenderersBackward() {
|
||||
n := len(self.getUserConfig().Git.DiffRenderers)
|
||||
self.diffRendererIndex = (self.diffRendererIndex - 1 + n) % n
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfigManager) CurrentDiffRendererIndex() (int, int) {
|
||||
return self.diffRendererIndex, len(self.getUserConfig().Git.DiffRenderers)
|
||||
}
|
||||
|
||||
// CurrentDiffRendererName returns a name for the current diff renderer, suitable for showing
|
||||
// to the user.
|
||||
func (self *DiffRendererConfigManager) CurrentDiffRendererName(tr *i18n.TranslationSet) string {
|
||||
currentDiffRendererConfig := self.currentDiffRendererConfig()
|
||||
if currentDiffRendererConfig == nil {
|
||||
return tr.DefaultDiffRendererName
|
||||
}
|
||||
|
||||
if name := currentDiffRendererConfig.displayName(); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
if currentDiffRendererConfig.getType() == DiffRendererType_ExtDiff && currentDiffRendererConfig.Command == "" {
|
||||
return tr.ExternalDiffDiffRendererName
|
||||
}
|
||||
|
||||
return tr.DefaultDiffRendererName
|
||||
}
|
||||
|
||||
func (self *DiffRendererConfig) displayName() string {
|
||||
if self.Name != "" {
|
||||
return self.Name
|
||||
}
|
||||
if self.getType() == DiffRendererType_RawGit && len(self.Args) > 0 {
|
||||
return self.Args[0]
|
||||
}
|
||||
return firstWord(string(self.Command))
|
||||
}
|
||||
|
||||
func firstWord(command string) string {
|
||||
fields := strings.Fields(command)
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fields[0]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/i18n"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCurrentDiffRendererName(t *testing.T) {
|
||||
tr := i18n.EnglishTranslationSet()
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
diffRendererConfig DiffRendererConfig
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "explicit name takes precedence over the command",
|
||||
diffRendererConfig: DiffRendererConfig{Name: "delta side-by-side", Command: "delta --side-by-side"},
|
||||
expected: "delta side-by-side",
|
||||
},
|
||||
{
|
||||
name: "derived from the first word of the stdinFilter command",
|
||||
diffRendererConfig: DiffRendererConfig{Command: "delta --side-by-side"},
|
||||
expected: "delta",
|
||||
},
|
||||
{
|
||||
name: "surrounding whitespace in the command is ignored",
|
||||
diffRendererConfig: DiffRendererConfig{Command: " diff-so-fancy "},
|
||||
expected: "diff-so-fancy",
|
||||
},
|
||||
{
|
||||
name: "derived from the first word of the extDiff command",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff", Command: "difft --color=always"},
|
||||
expected: "difft",
|
||||
},
|
||||
{
|
||||
name: "no name can be derived for external diff",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "extDiff"},
|
||||
expected: tr.ExternalDiffDiffRendererName,
|
||||
},
|
||||
{
|
||||
name: "derived from first argument of rawGit args",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "rawGit", Args: []string{"--color-words"}},
|
||||
expected: "--color-words",
|
||||
},
|
||||
{
|
||||
name: "no name can be derived for raw diff",
|
||||
diffRendererConfig: DiffRendererConfig{Type: "rawGit"},
|
||||
expected: tr.DefaultDiffRendererName,
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
userConfig := &UserConfig{}
|
||||
userConfig.Git.DiffRenderers = []DiffRendererConfig{s.diffRendererConfig}
|
||||
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
|
||||
|
||||
assert.Equal(t, s.expected, config.CurrentDiffRendererName(tr))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentDiffRendererNameWithoutDiffRenderers(t *testing.T) {
|
||||
config := NewDiffRendererConfigManager(func() *UserConfig { return &UserConfig{} })
|
||||
|
||||
tr := i18n.EnglishTranslationSet()
|
||||
assert.Equal(t, tr.DefaultDiffRendererName, config.CurrentDiffRendererName(tr))
|
||||
}
|
||||
|
||||
func TestCycleDiffRenderers(t *testing.T) {
|
||||
userConfig := &UserConfig{}
|
||||
userConfig.Git.DiffRenderers = []DiffRendererConfig{{Name: "a"}, {Name: "b"}, {Name: "c"}}
|
||||
config := NewDiffRendererConfigManager(func() *UserConfig { return userConfig })
|
||||
|
||||
currentIndex := func() int {
|
||||
index, _ := config.CurrentDiffRendererIndex()
|
||||
return index
|
||||
}
|
||||
|
||||
assert.Equal(t, 0, currentIndex())
|
||||
|
||||
config.CycleDiffRenderers()
|
||||
assert.Equal(t, 1, currentIndex())
|
||||
config.CycleDiffRenderers()
|
||||
assert.Equal(t, 2, currentIndex())
|
||||
config.CycleDiffRenderers()
|
||||
assert.Equal(t, 0, currentIndex(), "cycling forward past the last diff renderer wraps to the first")
|
||||
|
||||
config.CycleDiffRenderersBackward()
|
||||
assert.Equal(t, 2, currentIndex(), "cycling backward past the first diff renderer wraps to the last")
|
||||
config.CycleDiffRenderersBackward()
|
||||
assert.Equal(t, 1, currentIndex())
|
||||
}
|
||||
@@ -9,11 +9,12 @@ func NewDummyAppConfig() *AppConfig {
|
||||
userConfig := GetDefaultConfig()
|
||||
userConfig.Keybinding.MergeLegacyAltKeybindings()
|
||||
appConfig := &AppConfig{
|
||||
name: "lazygit",
|
||||
version: "unversioned",
|
||||
debug: false,
|
||||
userConfig: userConfig,
|
||||
appState: &AppState{},
|
||||
name: "lazygit",
|
||||
version: "unversioned",
|
||||
debug: false,
|
||||
userConfig: userConfig,
|
||||
appState: &AppState{},
|
||||
githubPullRequestCache: newGithubPullRequestCache(""),
|
||||
}
|
||||
_ = yaml.Unmarshal([]byte{}, appConfig.appState)
|
||||
return appConfig
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const githubPullRequestsCacheFileName = "github_pull_requests.json"
|
||||
|
||||
// CachedPullRequest stores the essential fields of a GitHub pull request.
|
||||
type CachedPullRequest struct {
|
||||
HeadRefName string `json:"headRefName"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
State string `json:"state"`
|
||||
ChecksState string `json:"checksState,omitempty"`
|
||||
Url string `json:"url"`
|
||||
HeadRepositoryOwner string `json:"headRepositoryOwner"`
|
||||
}
|
||||
|
||||
type githubPullRequestCache struct {
|
||||
mutex sync.Mutex
|
||||
path string
|
||||
pullRequestsByRepoPath map[string][]CachedPullRequest
|
||||
loadErr error
|
||||
}
|
||||
|
||||
func loadGithubPullRequestCache() *githubPullRequestCache {
|
||||
path, err := githubPullRequestCachePath()
|
||||
if err != nil {
|
||||
cache := newGithubPullRequestCache("")
|
||||
cache.loadErr = err
|
||||
return cache
|
||||
}
|
||||
|
||||
cache := newGithubPullRequestCache(path)
|
||||
cache.load()
|
||||
return cache
|
||||
}
|
||||
|
||||
func githubPullRequestCachePath() (string, error) {
|
||||
path, err := stateFilePath(stateFileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(path), githubPullRequestsCacheFileName), nil
|
||||
}
|
||||
|
||||
func newGithubPullRequestCache(path string) *githubPullRequestCache {
|
||||
return &githubPullRequestCache{
|
||||
path: path,
|
||||
pullRequestsByRepoPath: make(map[string][]CachedPullRequest),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *githubPullRequestCache) load() {
|
||||
if c.path == "" {
|
||||
return
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(c.path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
c.loadErr = fmt.Errorf("reading GitHub pull request cache: %w", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(content, &c.pullRequestsByRepoPath); err != nil {
|
||||
c.pullRequestsByRepoPath = make(map[string][]CachedPullRequest)
|
||||
c.loadErr = fmt.Errorf("parsing GitHub pull request cache: %w", err)
|
||||
} else if c.pullRequestsByRepoPath == nil {
|
||||
c.pullRequestsByRepoPath = make(map[string][]CachedPullRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *githubPullRequestCache) get(repoPath string) []CachedPullRequest {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
return append([]CachedPullRequest(nil), c.pullRequestsByRepoPath[repoPath]...)
|
||||
}
|
||||
|
||||
// takeLoadError returns the error, if any, that occurred while loading the
|
||||
// cache from disk, clearing it so that it is reported only once.
|
||||
func (c *githubPullRequestCache) takeLoadError() error {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
loadErr := c.loadErr
|
||||
c.loadErr = nil
|
||||
return loadErr
|
||||
}
|
||||
|
||||
func (c *githubPullRequestCache) save(repoPath string, pullRequests []CachedPullRequest) error {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
c.pullRequestsByRepoPath[repoPath] = append([]CachedPullRequest(nil), pullRequests...)
|
||||
if c.path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := json.MarshalIndent(c.pullRequestsByRepoPath, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content = append(content, '\n')
|
||||
|
||||
// Apparently when people have read-only permissions they prefer us to fail
|
||||
// silently, so don't propagate permission errors.
|
||||
if err := os.WriteFile(c.path, content, 0o644); err != nil && !os.IsPermission(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGithubPullRequestCachePath(t *testing.T) {
|
||||
stateDir := t.TempDir()
|
||||
t.Setenv("CONFIG_DIR", stateDir)
|
||||
|
||||
path, err := githubPullRequestCachePath()
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(stateDir, githubPullRequestsCacheFileName), path)
|
||||
}
|
||||
|
||||
func TestGithubPullRequestCache(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), githubPullRequestsCacheFileName)
|
||||
cache := newGithubPullRequestCache(path)
|
||||
repoOnePullRequests := []CachedPullRequest{{
|
||||
HeadRefName: "first-branch",
|
||||
Number: 1,
|
||||
Title: "First pull request",
|
||||
State: "OPEN",
|
||||
Url: "https://github.com/owner/repo/pull/1",
|
||||
HeadRepositoryOwner: "owner",
|
||||
}}
|
||||
repoTwoPullRequests := []CachedPullRequest{{
|
||||
HeadRefName: "second-branch",
|
||||
Number: 2,
|
||||
Title: "Second pull request",
|
||||
State: "MERGED",
|
||||
Url: "https://github.com/other/repo/pull/2",
|
||||
HeadRepositoryOwner: "other",
|
||||
}}
|
||||
|
||||
assert.NoError(t, cache.save("/repo/one", repoOnePullRequests))
|
||||
assert.NoError(t, cache.save("/repo/two", repoTwoPullRequests))
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, `{
|
||||
"/repo/one": [
|
||||
{
|
||||
"headRefName": "first-branch",
|
||||
"number": 1,
|
||||
"title": "First pull request",
|
||||
"state": "OPEN",
|
||||
"url": "https://github.com/owner/repo/pull/1",
|
||||
"headRepositoryOwner": "owner"
|
||||
}
|
||||
],
|
||||
"/repo/two": [
|
||||
{
|
||||
"headRefName": "second-branch",
|
||||
"number": 2,
|
||||
"title": "Second pull request",
|
||||
"state": "MERGED",
|
||||
"url": "https://github.com/other/repo/pull/2",
|
||||
"headRepositoryOwner": "other"
|
||||
}
|
||||
]
|
||||
}
|
||||
`, string(content))
|
||||
|
||||
reloadedCache := newGithubPullRequestCache(path)
|
||||
reloadedCache.load()
|
||||
assert.Equal(t, repoOnePullRequests, reloadedCache.get("/repo/one"))
|
||||
assert.Equal(t, repoTwoPullRequests, reloadedCache.get("/repo/two"))
|
||||
assert.NoError(t, reloadedCache.takeLoadError())
|
||||
}
|
||||
|
||||
func TestGithubPullRequestCacheIgnoresMalformedContent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), githubPullRequestsCacheFileName)
|
||||
assert.NoError(t, os.WriteFile(path, []byte("{"), 0o644))
|
||||
|
||||
cache := newGithubPullRequestCache(path)
|
||||
cache.load()
|
||||
|
||||
assert.ErrorContains(t, cache.takeLoadError(), "parsing GitHub pull request cache")
|
||||
assert.Empty(t, cache.get("/repo"))
|
||||
assert.NoError(t, cache.save("/repo", []CachedPullRequest{{Number: 1}}))
|
||||
|
||||
reloadedCache := newGithubPullRequestCache(path)
|
||||
reloadedCache.load()
|
||||
assert.Equal(t, []CachedPullRequest{{Number: 1}}, reloadedCache.get("/repo"))
|
||||
assert.NoError(t, reloadedCache.takeLoadError())
|
||||
}
|
||||
|
||||
func TestGithubPullRequestCacheDoesNotModifyAppState(t *testing.T) {
|
||||
stateDir := t.TempDir()
|
||||
t.Setenv("CONFIG_DIR", stateDir)
|
||||
statePath := filepath.Join(stateDir, stateFileName)
|
||||
stateContent := []byte("recentrepos:\n - /repo\n")
|
||||
assert.NoError(t, os.WriteFile(statePath, stateContent, 0o644))
|
||||
|
||||
cache := loadGithubPullRequestCache()
|
||||
assert.NoError(t, cache.save("/repo", []CachedPullRequest{{Number: 1}}))
|
||||
|
||||
actualStateContent, err := os.ReadFile(statePath)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, stateContent, actualStateContent)
|
||||
_, err = os.Stat(filepath.Join(stateDir, githubPullRequestsCacheFileName))
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestGithubPullRequestCacheSerializesConcurrentSaves(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), githubPullRequestsCacheFileName)
|
||||
cache := newGithubPullRequestCache(path)
|
||||
const repoCount = 20
|
||||
var waitGroup sync.WaitGroup
|
||||
errs := make(chan error, repoCount)
|
||||
|
||||
for index := range repoCount {
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
repoPath := fmt.Sprintf("/repo/%d", index)
|
||||
errs <- cache.save(repoPath, []CachedPullRequest{{Number: index}})
|
||||
}()
|
||||
}
|
||||
waitGroup.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
reloadedCache := newGithubPullRequestCache(path)
|
||||
reloadedCache.load()
|
||||
for index := range repoCount {
|
||||
repoPath := fmt.Sprintf("/repo/%d", index)
|
||||
assert.Equal(t, []CachedPullRequest{{Number: index}}, reloadedCache.get(repoPath))
|
||||
}
|
||||
assert.NoError(t, reloadedCache.takeLoadError())
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
type PagerConfig struct {
|
||||
getUserConfig func() *UserConfig
|
||||
pagerIndex int
|
||||
}
|
||||
|
||||
func NewPagerConfig(getUserConfig func() *UserConfig) *PagerConfig {
|
||||
return &PagerConfig{getUserConfig: getUserConfig}
|
||||
}
|
||||
|
||||
func (self *PagerConfig) currentPagerConfig() *PagingConfig {
|
||||
pagers := self.getUserConfig().Git.Pagers
|
||||
if len(pagers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Guard against the pager index being out of range, which can happen if the user
|
||||
// has removed pagers from their config file while lazygit is running.
|
||||
if self.pagerIndex >= len(pagers) {
|
||||
self.pagerIndex = 0
|
||||
}
|
||||
|
||||
return &pagers[self.pagerIndex]
|
||||
}
|
||||
|
||||
func (self *PagerConfig) GetPagerCommand(width int) string {
|
||||
currentPagerConfig := self.currentPagerConfig()
|
||||
if currentPagerConfig == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
templateValues := map[string]string{
|
||||
"columnWidth": strconv.Itoa(width/2 - 6),
|
||||
}
|
||||
|
||||
pagerTemplate := string(currentPagerConfig.Pager)
|
||||
return utils.ResolvePlaceholderString(pagerTemplate, templateValues)
|
||||
}
|
||||
|
||||
func (self *PagerConfig) GetColorArg() string {
|
||||
currentPagerConfig := self.currentPagerConfig()
|
||||
if currentPagerConfig == nil {
|
||||
return "always"
|
||||
}
|
||||
|
||||
colorArg := currentPagerConfig.ColorArg
|
||||
if colorArg == "" {
|
||||
return "always"
|
||||
}
|
||||
return colorArg
|
||||
}
|
||||
|
||||
func (self *PagerConfig) GetExternalDiffCommand() string {
|
||||
currentPagerConfig := self.currentPagerConfig()
|
||||
if currentPagerConfig == nil {
|
||||
return ""
|
||||
}
|
||||
return currentPagerConfig.ExternalDiffCommand
|
||||
}
|
||||
|
||||
func (self *PagerConfig) GetUseExternalDiffGitConfig() bool {
|
||||
currentPagerConfig := self.currentPagerConfig()
|
||||
if currentPagerConfig == nil {
|
||||
return false
|
||||
}
|
||||
return currentPagerConfig.UseExternalDiffGitConfig
|
||||
}
|
||||
|
||||
func (self *PagerConfig) CyclePagers() {
|
||||
self.pagerIndex = (self.pagerIndex + 1) % len(self.getUserConfig().Git.Pagers)
|
||||
}
|
||||
|
||||
func (self *PagerConfig) CyclePagersBackward() {
|
||||
n := len(self.getUserConfig().Git.Pagers)
|
||||
self.pagerIndex = (self.pagerIndex - 1 + n) % n
|
||||
}
|
||||
|
||||
func (self *PagerConfig) CurrentPagerIndex() (int, int) {
|
||||
return self.pagerIndex, len(self.getUserConfig().Git.Pagers)
|
||||
}
|
||||
|
||||
// CurrentPagerName returns a name for the current pager, suitable for showing
|
||||
// to the user. It returns an empty string if no name can be derived; callers
|
||||
// should substitute a localized fallback in that case.
|
||||
func (self *PagerConfig) CurrentPagerName() string {
|
||||
currentPagerConfig := self.currentPagerConfig()
|
||||
if currentPagerConfig == nil {
|
||||
return ""
|
||||
}
|
||||
return currentPagerConfig.displayName()
|
||||
}
|
||||
|
||||
// CurrentPagerUsesGitConfigDiff reports whether the current pager defers to
|
||||
// git's own external diff config. Such an entry has no name we can derive (the
|
||||
// actual command may even vary per file via .gitattributes), so callers show a
|
||||
// generic label rather than treating it like the default no-pager entry.
|
||||
func (self *PagerConfig) CurrentPagerUsesGitConfigDiff() bool {
|
||||
currentPagerConfig := self.currentPagerConfig()
|
||||
return currentPagerConfig != nil && currentPagerConfig.UseExternalDiffGitConfig
|
||||
}
|
||||
|
||||
func (self *PagingConfig) displayName() string {
|
||||
if self.Name != "" {
|
||||
return self.Name
|
||||
}
|
||||
if word := firstWord(string(self.Pager)); word != "" {
|
||||
return word
|
||||
}
|
||||
return firstWord(self.ExternalDiffCommand)
|
||||
}
|
||||
|
||||
func firstWord(command string) string {
|
||||
fields := strings.Fields(command)
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
return fields[0]
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCurrentPagerName(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
pager PagingConfig
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "explicit name takes precedence over the command",
|
||||
pager: PagingConfig{Name: "delta side-by-side", Pager: "delta --side-by-side"},
|
||||
expected: "delta side-by-side",
|
||||
},
|
||||
{
|
||||
name: "derived from the first word of the pager command",
|
||||
pager: PagingConfig{Pager: "delta --side-by-side"},
|
||||
expected: "delta",
|
||||
},
|
||||
{
|
||||
name: "surrounding whitespace in the command is ignored",
|
||||
pager: PagingConfig{Pager: " diff-so-fancy "},
|
||||
expected: "diff-so-fancy",
|
||||
},
|
||||
{
|
||||
name: "falls back to the external diff command when there is no pager",
|
||||
pager: PagingConfig{ExternalDiffCommand: "difft --color=always"},
|
||||
expected: "difft",
|
||||
},
|
||||
{
|
||||
name: "no name can be derived",
|
||||
pager: PagingConfig{UseExternalDiffGitConfig: true},
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
userConfig := &UserConfig{}
|
||||
userConfig.Git.Pagers = []PagingConfig{s.pager}
|
||||
config := NewPagerConfig(func() *UserConfig { return userConfig })
|
||||
|
||||
assert.Equal(t, s.expected, config.CurrentPagerName())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentPagerNameWithoutPagers(t *testing.T) {
|
||||
config := NewPagerConfig(func() *UserConfig { return &UserConfig{} })
|
||||
|
||||
assert.Equal(t, "", config.CurrentPagerName())
|
||||
}
|
||||
|
||||
func TestCyclePagers(t *testing.T) {
|
||||
userConfig := &UserConfig{}
|
||||
userConfig.Git.Pagers = []PagingConfig{{Name: "a"}, {Name: "b"}, {Name: "c"}}
|
||||
config := NewPagerConfig(func() *UserConfig { return userConfig })
|
||||
|
||||
currentIndex := func() int {
|
||||
index, _ := config.CurrentPagerIndex()
|
||||
return index
|
||||
}
|
||||
|
||||
assert.Equal(t, 0, currentIndex())
|
||||
|
||||
config.CyclePagers()
|
||||
assert.Equal(t, 1, currentIndex())
|
||||
config.CyclePagers()
|
||||
assert.Equal(t, 2, currentIndex())
|
||||
config.CyclePagers()
|
||||
assert.Equal(t, 0, currentIndex(), "cycling forward past the last pager wraps to the first")
|
||||
|
||||
config.CyclePagersBackward()
|
||||
assert.Equal(t, 2, currentIndex(), "cycling backward past the first pager wraps to the last")
|
||||
config.CyclePagersBackward()
|
||||
assert.Equal(t, 1, currentIndex())
|
||||
}
|
||||
+60
-55
@@ -87,7 +87,7 @@ type GuiConfig struct {
|
||||
// One of: 'margin' (default) | 'jump'
|
||||
ScrollOffBehavior string `yaml:"scrollOffBehavior"`
|
||||
// The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs.
|
||||
// Note that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command.
|
||||
// Note that when using a diff renderer, the renderer has its own tab width setting, so you need to pass it separately in the renderer command.
|
||||
TabWidth int `yaml:"tabWidth" jsonschema:"minimum=1"`
|
||||
// If true, capture mouse events.
|
||||
// When mouse events are captured, it's a little harder to select text: e.g. requiring you to hold the option key when on macOS.
|
||||
@@ -269,37 +269,39 @@ type SpinnerConfig struct {
|
||||
}
|
||||
|
||||
type GitConfig struct {
|
||||
// Array of pagers. Each entry has the following format:
|
||||
// [dev] The following documentation is duplicated from the PagingConfig struct below.
|
||||
// Array of diff renderers. Each entry has the following format:
|
||||
// [dev] The following documentation is duplicated from the DiffRendererConfig struct below.
|
||||
//
|
||||
// # A name for the pager, shown in the notification when cycling pagers.
|
||||
// # If not set, the name is derived from the first word of the pager
|
||||
// # command (or of the external diff command).
|
||||
// # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff'
|
||||
// # | 'rawGit'
|
||||
// type: "stdinFilter"
|
||||
//
|
||||
// # A name for the diff renderer, shown in the notification when cycling
|
||||
// # renderers. If not set, the name is derived from the first word of the
|
||||
// # renderer command.
|
||||
// name: ""
|
||||
//
|
||||
// # Value of the --color arg in the git diff command. Some pagers want
|
||||
// # this to be set to 'always' and some want it set to 'never'
|
||||
// # Value of the --color arg in the git diff command. Only used for type
|
||||
// # 'stdinFilter'. Some renderers want this to be set to 'always' and some
|
||||
// # want it set to 'never'.
|
||||
// colorArg: "always"
|
||||
//
|
||||
// # The command to use for rendering diffs. This is either a stdinFilter or
|
||||
// # an external diff command, depending on the type field; not applicable if
|
||||
// # the type is 'rawGit'.
|
||||
// # e.g.
|
||||
// # diff-so-fancy
|
||||
// # delta --dark --paging=never
|
||||
// # ydiff -p cat -s --wrap --width={{columnWidth}}
|
||||
// pager: ""
|
||||
// # ydiff -p cat
|
||||
// # difft --color=always
|
||||
// command: ""
|
||||
//
|
||||
// # e.g. 'difft --color=always'
|
||||
// externalDiffCommand: ""
|
||||
// # Extra arguments (array of strings) passed to the git command. Only
|
||||
// # applicable if the type is 'rawGit'.
|
||||
// args: []
|
||||
//
|
||||
// # If true, Lazygit will use git's `diff.external` config for paging.
|
||||
// # The advantage over `externalDiffCommand` is that this can be
|
||||
// # configured per file type in .gitattributes; see
|
||||
// # https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
|
||||
// useExternalDiffGitConfig: false
|
||||
//
|
||||
// 'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' are mutually exclusive; set at most one per entry.
|
||||
//
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md for more information.
|
||||
Pagers []PagingConfig `yaml:"pagers"`
|
||||
// See https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md for more information.
|
||||
DiffRenderers []DiffRendererConfig `yaml:"diffRenderers"`
|
||||
// Config relating to committing
|
||||
Commit CommitConfig `yaml:"commit"`
|
||||
// Config relating to merging
|
||||
@@ -358,31 +360,34 @@ type GitConfig struct {
|
||||
TruncateCopiedCommitHashesTo int `yaml:"truncateCopiedCommitHashesTo"`
|
||||
}
|
||||
|
||||
type PagerType string
|
||||
type DiffRendererCommandType string
|
||||
|
||||
func (PagerType) JSONSchemaExtend(schema *jsonschema.Schema) {
|
||||
func (DiffRendererCommandType) JSONSchemaExtend(schema *jsonschema.Schema) {
|
||||
schema.Examples = []any{
|
||||
"delta --dark --paging=never",
|
||||
"diff-so-fancy",
|
||||
"ydiff -p cat -s --wrap --width={{columnWidth}}",
|
||||
"ydiff -p cat",
|
||||
"difft --color=always",
|
||||
}
|
||||
}
|
||||
|
||||
// [dev] This documentation is duplicated in the GitConfig struct. If you make changes here, make them there too.
|
||||
type PagingConfig struct {
|
||||
// A name for the pager, shown in the notification when cycling pagers. If not set, the name is derived from the first word of the pager command (or of the external diff command).
|
||||
type DiffRendererConfig struct {
|
||||
// The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff' | 'rawGit'
|
||||
Type string `yaml:"type" jsonschema:"enum=stdinFilter,enum=extDiff,enum=rawGit"`
|
||||
// A name for the diff renderer, shown in the notification when cycling renderers. If not set, the name is derived from the first word of the renderer command.
|
||||
Name string `yaml:"name"`
|
||||
// Value of the --color arg in the git diff command. Some pagers want this to be set to 'always' and some want it set to 'never'
|
||||
// Value of the --color arg in the git diff command. Only used for type 'stdinFilter'. Some renderers want this to be set to 'always' and some want it set to 'never'.
|
||||
ColorArg string `yaml:"colorArg" jsonschema:"enum=always,enum=never"`
|
||||
// The command to use for rendering diffs. This is either a stdinFilter or an external diff command, depending on the type field; not applicable if the type is 'rawGit'.
|
||||
// e.g.
|
||||
// diff-so-fancy
|
||||
// delta --dark --paging=never
|
||||
// ydiff -p cat -s --wrap --width={{columnWidth}}
|
||||
Pager PagerType `yaml:"pager"`
|
||||
// e.g. 'difft --color=always'
|
||||
ExternalDiffCommand string `yaml:"externalDiffCommand"`
|
||||
// If true, Lazygit will use git's `diff.external` config for paging. The advantage over `externalDiffCommand` is that this can be configured per file type in .gitattributes; see https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.
|
||||
UseExternalDiffGitConfig bool `yaml:"useExternalDiffGitConfig"`
|
||||
// ydiff -p cat
|
||||
// difft --color=always
|
||||
Command DiffRendererCommandType `yaml:"command"`
|
||||
// Extra arguments (array of strings) passed to the git command. Only applicable if the type is 'rawGit'.
|
||||
Args []string `yaml:"args"`
|
||||
}
|
||||
|
||||
type CommitConfig struct {
|
||||
@@ -524,23 +529,23 @@ type KeybindingUniversalConfig struct {
|
||||
// Deprecated: add the key to `scrollUpMain` instead.
|
||||
ScrollUpMainAlt2 Keybinding `yaml:"scrollUpMain-alt2"`
|
||||
// Deprecated: add the key to `scrollDownMain` instead.
|
||||
ScrollDownMainAlt2 Keybinding `yaml:"scrollDownMain-alt2"`
|
||||
ExecuteShellCommand Keybinding `yaml:"executeShellCommand"`
|
||||
CreateRebaseOptionsMenu Keybinding `yaml:"createRebaseOptionsMenu"`
|
||||
Push Keybinding `yaml:"pushFiles"` // 'Files' appended for legacy reasons
|
||||
Pull Keybinding `yaml:"pullFiles"` // 'Files' appended for legacy reasons
|
||||
Refresh Keybinding `yaml:"refresh"`
|
||||
CreatePatchOptionsMenu Keybinding `yaml:"createPatchOptionsMenu"`
|
||||
NextTab Keybinding `yaml:"nextTab"`
|
||||
PrevTab Keybinding `yaml:"prevTab"`
|
||||
NextScreenMode Keybinding `yaml:"nextScreenMode"`
|
||||
PrevScreenMode Keybinding `yaml:"prevScreenMode"`
|
||||
CyclePagers Keybinding `yaml:"cyclePagers"`
|
||||
CyclePagersReverse Keybinding `yaml:"cyclePagersReverse"`
|
||||
Undo Keybinding `yaml:"undo"`
|
||||
Redo Keybinding `yaml:"redo"`
|
||||
FilteringMenu Keybinding `yaml:"filteringMenu"`
|
||||
DiffingMenu Keybinding `yaml:"diffingMenu"`
|
||||
ScrollDownMainAlt2 Keybinding `yaml:"scrollDownMain-alt2"`
|
||||
ExecuteShellCommand Keybinding `yaml:"executeShellCommand"`
|
||||
CreateRebaseOptionsMenu Keybinding `yaml:"createRebaseOptionsMenu"`
|
||||
Push Keybinding `yaml:"pushFiles"` // 'Files' appended for legacy reasons
|
||||
Pull Keybinding `yaml:"pullFiles"` // 'Files' appended for legacy reasons
|
||||
Refresh Keybinding `yaml:"refresh"`
|
||||
CreatePatchOptionsMenu Keybinding `yaml:"createPatchOptionsMenu"`
|
||||
NextTab Keybinding `yaml:"nextTab"`
|
||||
PrevTab Keybinding `yaml:"prevTab"`
|
||||
NextScreenMode Keybinding `yaml:"nextScreenMode"`
|
||||
PrevScreenMode Keybinding `yaml:"prevScreenMode"`
|
||||
CycleDiffRenderers Keybinding `yaml:"cycleDiffRenderers"`
|
||||
CycleDiffRenderersReverse Keybinding `yaml:"cycleDiffRenderersReverse"`
|
||||
Undo Keybinding `yaml:"undo"`
|
||||
Redo Keybinding `yaml:"redo"`
|
||||
FilteringMenu Keybinding `yaml:"filteringMenu"`
|
||||
DiffingMenu Keybinding `yaml:"diffingMenu"`
|
||||
// Deprecated: add the key to `diffingMenu` instead.
|
||||
DiffingMenuAlt Keybinding `yaml:"diffingMenu-alt"`
|
||||
CopyToClipboard Keybinding `yaml:"copyToClipboard"`
|
||||
@@ -922,8 +927,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
PortraitModeAutoMinHeight: 46,
|
||||
FilterMode: "substring",
|
||||
Spinner: SpinnerConfig{
|
||||
Frames: []string{"|", "/", "-", "\\"},
|
||||
Rate: 50,
|
||||
Frames: []string{"●∙∙", "∙●∙", "∙∙●", "∙●∙"},
|
||||
Rate: 180,
|
||||
},
|
||||
StatusPanelView: "dashboard",
|
||||
SwitchToFilesAfterStashPop: true,
|
||||
@@ -1055,8 +1060,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig {
|
||||
PrevTab: Keybinding{"["},
|
||||
NextScreenMode: Keybinding{"+"},
|
||||
PrevScreenMode: Keybinding{"_"},
|
||||
CyclePagers: Keybinding{"|"},
|
||||
CyclePagersReverse: Keybinding{"\\"},
|
||||
CycleDiffRenderers: Keybinding{"|"},
|
||||
CycleDiffRenderersReverse: Keybinding{"\\"},
|
||||
Undo: Keybinding{"z"},
|
||||
Redo: Keybinding{"Z"},
|
||||
FilteringMenu: Keybinding{"<ctrl+s>"},
|
||||
|
||||
@@ -46,7 +46,7 @@ func (config *UserConfig) Validate() error {
|
||||
[]string{"always", "never", "when-maximised"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePagers(config.Git.Pagers); err != nil {
|
||||
if err := validateDiffRenderers(config.Git.DiffRenderers); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateKeybindings(config.Keybinding); err != nil {
|
||||
@@ -110,25 +110,26 @@ func validateSpinner(spinner SpinnerConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePagers rejects pager entries that combine more than one diff
|
||||
// mechanism. A pager (GIT_PAGER) formats the diff that git produces, whereas
|
||||
// externalDiffCommand and useExternalDiffGitConfig change how git produces the
|
||||
// diff in the first place; piping one through the other almost always yields
|
||||
// garbled output, so we treat the three as mutually exclusive.
|
||||
func validatePagers(pagers []PagingConfig) error {
|
||||
for i, pager := range pagers {
|
||||
count := 0
|
||||
if pager.Pager != "" {
|
||||
count++
|
||||
}
|
||||
if pager.ExternalDiffCommand != "" {
|
||||
count++
|
||||
}
|
||||
if pager.UseExternalDiffGitConfig {
|
||||
count++
|
||||
}
|
||||
if count > 1 {
|
||||
return fmt.Errorf("git.pagers[%d]: at most one of 'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' may be set; they are mutually exclusive", i)
|
||||
func validateDiffRenderers(diffRenderers []DiffRendererConfig) error {
|
||||
for _, diffRenderer := range diffRenderers {
|
||||
switch diffRenderer.Type {
|
||||
case "stdinFilter", "":
|
||||
if diffRenderer.Command == "" {
|
||||
return errors.New("git.diffRenderers: 'command' must be specified for diff renderer type 'stdinFilter'.")
|
||||
}
|
||||
if len(diffRenderer.Args) > 0 {
|
||||
return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'stdinFilter'.")
|
||||
}
|
||||
case "extDiff":
|
||||
if len(diffRenderer.Args) > 0 {
|
||||
return errors.New("git.diffRenderers: 'args' cannot be used with diff renderer type 'extDiff'.")
|
||||
}
|
||||
case "rawGit":
|
||||
if diffRenderer.Command != "" {
|
||||
return errors.New("git.diffRenderers: 'command' cannot be used with diff renderer type 'rawGit'.")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("git.diffRenderers: unknown type '%s'. Allowed values: stdinFilter, extDiff, rawGit", diffRenderer.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -361,26 +361,30 @@ func TestUserConfigValidate_sidePanels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserConfigValidate_pagers(t *testing.T) {
|
||||
func TestUserConfigValidate_diffRenderers(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
pager PagingConfig
|
||||
valid bool
|
||||
name string
|
||||
diffRenderer DiffRendererConfig
|
||||
valid bool
|
||||
}{
|
||||
{name: "empty", pager: PagingConfig{}, valid: true},
|
||||
{name: "pager only", pager: PagingConfig{Pager: "delta"}, valid: true},
|
||||
{name: "external diff command only", pager: PagingConfig{ExternalDiffCommand: "difft"}, valid: true},
|
||||
{name: "git config external diff only", pager: PagingConfig{UseExternalDiffGitConfig: true}, valid: true},
|
||||
{name: "pager and external diff command", pager: PagingConfig{Pager: "delta", ExternalDiffCommand: "difft"}, valid: false},
|
||||
{name: "pager and git config external diff", pager: PagingConfig{Pager: "delta", UseExternalDiffGitConfig: true}, valid: false},
|
||||
{name: "both external diff mechanisms", pager: PagingConfig{ExternalDiffCommand: "difft", UseExternalDiffGitConfig: true}, valid: false},
|
||||
{name: "all three", pager: PagingConfig{Pager: "delta", ExternalDiffCommand: "difft", UseExternalDiffGitConfig: true}, valid: false},
|
||||
{name: "stdinFilter with type default", diffRenderer: DiffRendererConfig{Command: "delta"}, valid: true},
|
||||
{name: "stdinFilter with explicit type", diffRenderer: DiffRendererConfig{Type: "stdinFilter", Command: "delta"}, valid: true},
|
||||
{name: "stdinFilter with explicit type", diffRenderer: DiffRendererConfig{Type: "stdinFilter"}, valid: false},
|
||||
{name: "stdinFilter with type default without command", diffRenderer: DiffRendererConfig{}, valid: false},
|
||||
{name: "stdinFilter with args", diffRenderer: DiffRendererConfig{Type: "stdinFilter", Command: "delta", Args: []string{"-x"}}, valid: false},
|
||||
{name: "external diff", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft"}, valid: true},
|
||||
{name: "external diff without command", diffRenderer: DiffRendererConfig{Type: "extDiff"}, valid: true},
|
||||
{name: "external diff with args", diffRenderer: DiffRendererConfig{Type: "extDiff", Command: "difft", Args: []string{"-x"}}, valid: false},
|
||||
{name: "raw git", diffRenderer: DiffRendererConfig{Type: "rawGit"}, valid: true},
|
||||
{name: "raw git with args", diffRenderer: DiffRendererConfig{Type: "rawGit", Args: []string{"-x"}}, valid: true},
|
||||
{name: "raw git with command", diffRenderer: DiffRendererConfig{Type: "rawGit", Command: "delta"}, valid: false},
|
||||
{name: "unknown type", diffRenderer: DiffRendererConfig{Type: "unknown"}, valid: false},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
config := GetDefaultConfig()
|
||||
config.Git.Pagers = []PagingConfig{s.pager}
|
||||
config.Git.DiffRenderers = []DiffRendererConfig{s.diffRenderer}
|
||||
err := config.Validate()
|
||||
|
||||
if s.valid {
|
||||
|
||||
+16
-16
@@ -1,14 +1,14 @@
|
||||
package constants
|
||||
|
||||
type Docs struct {
|
||||
CustomPagers string
|
||||
CustomCommands string
|
||||
CustomKeybindings string
|
||||
Keybindings string
|
||||
Undoing string
|
||||
Config string
|
||||
Tutorial string
|
||||
CustomPatchDemo string
|
||||
CustomDiffRenderers string
|
||||
CustomCommands string
|
||||
CustomKeybindings string
|
||||
Keybindings string
|
||||
Undoing string
|
||||
Config string
|
||||
Tutorial string
|
||||
CustomPatchDemo string
|
||||
}
|
||||
|
||||
var Links = struct {
|
||||
@@ -25,13 +25,13 @@ var Links = struct {
|
||||
Discussions: "https://github.com/jesseduffield/lazygit/discussions",
|
||||
Releases: "https://github.com/jesseduffield/lazygit/releases",
|
||||
Docs: Docs{
|
||||
CustomPagers: "https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md",
|
||||
CustomKeybindings: "https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md",
|
||||
CustomCommands: "https://github.com/jesseduffield/lazygit/wiki/Custom-Commands-Compendium",
|
||||
Keybindings: "https://github.com/jesseduffield/lazygit/blob/%s/docs/keybindings",
|
||||
Undoing: "https://github.com/jesseduffield/lazygit/blob/master/docs/Undoing.md",
|
||||
Config: "https://github.com/jesseduffield/lazygit/blob/%s/docs/Config.md",
|
||||
Tutorial: "https://youtu.be/VDXvbHZYeKY",
|
||||
CustomPatchDemo: "https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches",
|
||||
CustomDiffRenderers: "https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md",
|
||||
CustomKeybindings: "https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md",
|
||||
CustomCommands: "https://github.com/jesseduffield/lazygit/wiki/Custom-Commands-Compendium",
|
||||
Keybindings: "https://github.com/jesseduffield/lazygit/blob/%s/docs/keybindings",
|
||||
Undoing: "https://github.com/jesseduffield/lazygit/blob/master/docs/Undoing.md",
|
||||
Config: "https://github.com/jesseduffield/lazygit/blob/%s/docs/Config.md",
|
||||
Tutorial: "https://youtu.be/VDXvbHZYeKY",
|
||||
CustomPatchDemo: "https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMouseReleaseDoesNotBreakDoubleClickDetection(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
g := newTestGui(t)
|
||||
view, _ := g.SetView("list", 0, 0, 20, 10, 0)
|
||||
doubleClicks := []bool{}
|
||||
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: "list",
|
||||
Key: MouseLeft,
|
||||
Handler: func(opts ViewMouseBindingOpts) error {
|
||||
doubleClicks = append(doubleClicks, opts.IsDoubleClick)
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
|
||||
for _, event := range []GocuiEvent{
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone)),
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonNone, tcell.ModNone)),
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(view.x0+1, view.y0+1, tcell.ButtonPrimary, tcell.ModNone)),
|
||||
} {
|
||||
assert.NoError(t, g.onKey(&event))
|
||||
}
|
||||
|
||||
assert.Equal(t, []bool{false, true}, doubleClicks)
|
||||
}
|
||||
+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
|
||||
}
|
||||
|
||||
+363
-32
@@ -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,8 +125,11 @@ 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
|
||||
@@ -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,28 @@ type Gui struct {
|
||||
|
||||
taskManager *TaskManager
|
||||
|
||||
lastHoverView *View
|
||||
// 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
|
||||
mouseCapture *View
|
||||
mouseGestureCanceled bool
|
||||
|
||||
// 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,13 +266,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)
|
||||
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),
|
||||
@@ -264,18 +295,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
|
||||
@@ -285,6 +361,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
|
||||
@@ -322,7 +403,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()
|
||||
@@ -518,6 +599,12 @@ func (g *Gui) DeleteView(name string) error {
|
||||
|
||||
for i, v := range g.views {
|
||||
if v.name == name {
|
||||
if g.mouseCapture == v {
|
||||
g.CancelMouseCapture()
|
||||
}
|
||||
if g.lastHoverView == v {
|
||||
g.lastHoverView = nil
|
||||
}
|
||||
g.views = append(g.views[:i], g.views[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
@@ -587,6 +674,24 @@ func (g *Gui) SetViewClickBinding(binding *ViewMouseBinding) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureMouse routes subsequent mouse events to view until the mouse button is
|
||||
// released or CancelMouseCapture is called.
|
||||
func (g *Gui) captureMouse(view *View) {
|
||||
g.mouseCapture = view
|
||||
g.mouseGestureCanceled = false
|
||||
}
|
||||
|
||||
func (g *Gui) releaseMouseCapture() {
|
||||
g.mouseCapture = nil
|
||||
}
|
||||
|
||||
// CancelMouseCapture releases capture and ignores the rest of the physical
|
||||
// gesture until the mouse button is released.
|
||||
func (g *Gui) CancelMouseCapture() {
|
||||
g.releaseMouseCapture()
|
||||
g.mouseGestureCanceled = true
|
||||
}
|
||||
|
||||
func (g *Gui) SetFocusHandler(handler func(bool) error) {
|
||||
g.focusHandler = handler
|
||||
}
|
||||
@@ -714,16 +819,112 @@ func (q *userEventQueue) dequeue() (userEvent, bool) {
|
||||
// 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) {
|
||||
task := g.NewTask()
|
||||
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.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
|
||||
// number of background tasks.
|
||||
// Always use this when you want to spawn a goroutine and you want lazygit to
|
||||
@@ -731,7 +932,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()
|
||||
@@ -795,6 +1007,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 {
|
||||
@@ -839,10 +1053,22 @@ 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
|
||||
@@ -856,7 +1082,8 @@ func (g *Gui) processEvent() error {
|
||||
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
|
||||
@@ -883,7 +1110,11 @@ 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
|
||||
}
|
||||
default:
|
||||
@@ -907,6 +1138,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)
|
||||
@@ -925,6 +1167,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()
|
||||
@@ -1236,6 +1496,11 @@ func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
|
||||
|
||||
// flush updates the gui, re-drawing frames and buffers.
|
||||
func (g *Gui) flush() error {
|
||||
// The screen must not be touched while suspended (see Suspend).
|
||||
if g.isSuspended() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pretty sure we don't need this, but keeping it here in case we get weird visual artifacts
|
||||
// g.clear(g.FgColor, g.BgColor)
|
||||
|
||||
@@ -1243,7 +1508,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
|
||||
@@ -1268,6 +1533,11 @@ func (g *Gui) flush() error {
|
||||
// actually-changed cells are emitted to the terminal.
|
||||
// Will also redraw any views that overlap tainted views
|
||||
func (g *Gui) flushContentOnly(views []*View) error {
|
||||
// The screen must not be touched while suspended (see Suspend).
|
||||
if g.isSuspended() {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, v := range viewsToRedrawContentOnly(views) {
|
||||
if err := g.draw(v); err != nil {
|
||||
return err
|
||||
@@ -1282,7 +1552,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
|
||||
}
|
||||
|
||||
@@ -1321,10 +1591,6 @@ func (g *Gui) ForceFlushViewsContentOnly(views []*View) error {
|
||||
|
||||
// draw manages the cursor and calls the draw function of a view.
|
||||
func (g *Gui) draw(v *View) error {
|
||||
if g.suspended {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 {
|
||||
return nil
|
||||
}
|
||||
@@ -1418,9 +1684,26 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
||||
|
||||
case eventMouse:
|
||||
mx, my := ev.MouseX, ev.MouseY
|
||||
v, err := g.VisibleViewByPosition(mx, my)
|
||||
if err != nil {
|
||||
break
|
||||
if g.mouseGestureCanceled {
|
||||
if ev.Key.KeyName() == MouseRelease {
|
||||
g.mouseGestureCanceled = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// While the mouse is captured, all mouse events go to the view that
|
||||
// was under the pointer when the button was pressed, even if the
|
||||
// pointer has since left it; this is what lets drag gestures keep
|
||||
// acting on the view they started in.
|
||||
v := g.mouseCapture
|
||||
if v == nil {
|
||||
var err error
|
||||
v, err = g.VisibleViewByPosition(mx, my)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if ev.Key.KeyName() == MouseRelease {
|
||||
g.releaseMouseCapture()
|
||||
}
|
||||
|
||||
// newCx and newCy are relative to the view port, i.e. to the visible area of the view
|
||||
@@ -1464,9 +1747,20 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
||||
break
|
||||
}
|
||||
}
|
||||
if ev.Key.KeyName() == MouseLeft && ev.Key.Mod()&ModMotion == 0 {
|
||||
g.captureMouse(v)
|
||||
}
|
||||
|
||||
if !IsMouseScrollKey(ev.Key.KeyName()) {
|
||||
v.SetCursor(newCx, newCy)
|
||||
if !IsMouseScrollKey(ev.Key.KeyName()) && ev.Key.KeyName() != MouseRelease {
|
||||
cursorX, cursorY := newCx, newCy
|
||||
// A captured drag can report positions outside the view; keep the
|
||||
// view cursor inside its bounds in that case. Handlers still get
|
||||
// the unclamped position through the binding opts.
|
||||
if g.mouseCapture != nil {
|
||||
cursorX = max(0, min(cursorX, v.InnerWidth()-1))
|
||||
cursorY = max(0, min(cursorY, v.InnerHeight()-1))
|
||||
}
|
||||
v.SetCursor(cursorX, cursorY)
|
||||
if v.Editable {
|
||||
v.TextArea.SetCursor2D(newX, newY)
|
||||
|
||||
@@ -1478,7 +1772,9 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
||||
}
|
||||
}
|
||||
|
||||
if v.Frame && my == v.y0 {
|
||||
// Only an actual click may activate tabs; a captured drag that
|
||||
// crosses the tab row must not switch tabs.
|
||||
if ev.Key.KeyName() == MouseLeft && ev.Key.Mod()&ModMotion == 0 && v.Frame && my == v.y0 {
|
||||
if len(v.Tabs) > 0 {
|
||||
tabIndex := v.GetClickedTabIndex(mx - v.x0)
|
||||
|
||||
@@ -1533,6 +1829,12 @@ func (g *Gui) recordClickInfo(x, y int, key KeyName, v *View) bool {
|
||||
g.lastClick = nil
|
||||
return false
|
||||
}
|
||||
// A release ends a gesture but is not a click of its own; it must leave
|
||||
// the click info of the press that started it alone, or no double click
|
||||
// could ever be detected.
|
||||
if key == MouseRelease {
|
||||
return false
|
||||
}
|
||||
|
||||
clickInfo := &clickInfo{
|
||||
x: x,
|
||||
@@ -1696,6 +1998,14 @@ func (g *Gui) onFocus(ev *GocuiEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// While g.suspended is true, nothing must be drawn to the screen: tcell
|
||||
// releases the screen's cell buffer when disengaging, and drawing to a
|
||||
// disengaged screen spins forever inside tcell while holding the screen lock,
|
||||
// which then blocks Resume (and with it all further input) forever. For the
|
||||
// flag to guarantee that, it must only ever be false while the screen is
|
||||
// engaged: Suspend sets it before disengaging, and Resume clears it only
|
||||
// after re-engaging.
|
||||
|
||||
func (g *Gui) Suspend() error {
|
||||
g.suspendedMutex.Lock()
|
||||
defer g.suspendedMutex.Unlock()
|
||||
@@ -1706,7 +2016,12 @@ func (g *Gui) Suspend() error {
|
||||
|
||||
g.suspended = true
|
||||
|
||||
return g.screen.Suspend()
|
||||
if err := g.screen.Suspend(); err != nil {
|
||||
g.suspended = false
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gui) Resume() error {
|
||||
@@ -1717,9 +2032,25 @@ func (g *Gui) Resume() error {
|
||||
return errors.New("Cannot resume because we are not suspended")
|
||||
}
|
||||
|
||||
if err := g.screen.Resume(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
g.suspended = false
|
||||
|
||||
return g.screen.Resume()
|
||||
// Schedule a redraw of the whole screen. Nothing else guarantees one:
|
||||
// flushes are skipped while suspended, and after re-engaging the screen
|
||||
// the terminal shows nothing until we draw again.
|
||||
go func() { g.gEvents <- GocuiEvent{Type: eventResize} }()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Gui) isSuspended() bool {
|
||||
g.suspendedMutex.Lock()
|
||||
defer g.suspendedMutex.Unlock()
|
||||
|
||||
return g.suspended
|
||||
}
|
||||
|
||||
// matchView returns if the keybinding matches the current view (and the view's context)
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMouseCaptureRoutesMotionAndReleaseOutsideView(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
view, err := g.SetView("captured", 10, 5, 30, 15, 0)
|
||||
if err != nil && !errors.Is(err, ErrUnknownView) {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
received := []ViewMouseBindingOpts{}
|
||||
for _, binding := range []*ViewMouseBinding{
|
||||
{
|
||||
ViewName: "captured",
|
||||
Key: MouseLeft,
|
||||
Modifier: ModMotion,
|
||||
Handler: func(opts ViewMouseBindingOpts) error {
|
||||
received = append(received, opts)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
ViewName: "captured",
|
||||
Key: MouseRelease,
|
||||
Handler: func(opts ViewMouseBindingOpts) error {
|
||||
assert.Nil(t, g.mouseCapture)
|
||||
received = append(received, opts)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
} {
|
||||
assert.NoError(t, g.SetViewClickBinding(binding))
|
||||
}
|
||||
|
||||
g.captureMouse(view)
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
MouseX: 0,
|
||||
MouseY: 0,
|
||||
Key: NewKey(MouseLeft, "", ModMotion),
|
||||
}))
|
||||
assert.Equal(t, ViewMouseBindingOpts{X: -11, Y: -6, Key: MouseLeft}, received[0])
|
||||
assert.Equal(t, 0, view.CursorX())
|
||||
assert.Equal(t, 0, view.CursorY())
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
MouseX: 79,
|
||||
MouseY: 23,
|
||||
Key: NewKeyName(MouseRelease),
|
||||
}))
|
||||
assert.Equal(t, ViewMouseBindingOpts{X: 68, Y: 17, Key: MouseRelease}, received[1])
|
||||
assert.Equal(t, 0, view.CursorX())
|
||||
assert.Equal(t, 0, view.CursorY())
|
||||
assert.Nil(t, g.mouseCapture)
|
||||
}
|
||||
|
||||
func TestPrimaryMouseDragStaysWithPressedView(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
left, _ := g.SetView("left", 0, 0, 20, 10, 0)
|
||||
_, _ = g.SetView("right", 21, 0, 41, 10, 0)
|
||||
|
||||
receivedBy := ""
|
||||
for _, viewName := range []string{"left", "right"} {
|
||||
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: viewName,
|
||||
Key: MouseLeft,
|
||||
Modifier: ModMotion,
|
||||
Handler: func(ViewMouseBindingOpts) error {
|
||||
receivedBy = viewName
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
MouseX: left.x0 + 1,
|
||||
MouseY: left.y0 + 1,
|
||||
Key: NewKeyName(MouseLeft),
|
||||
}))
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
MouseX: 22,
|
||||
MouseY: 1,
|
||||
Key: NewKey(MouseLeft, "", ModMotion),
|
||||
}))
|
||||
|
||||
assert.Equal(t, "left", receivedBy)
|
||||
}
|
||||
|
||||
func TestPrimaryMouseDragDoesNotActivateTabs(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
view, _ := g.SetView("tabs", 0, 0, 40, 10, 0)
|
||||
view.Tabs = []string{"first", "second"}
|
||||
|
||||
clickedTabs := []int{}
|
||||
assert.NoError(t, g.SetTabClickBinding("tabs", func(tabIndex int) error {
|
||||
clickedTabs = append(clickedTabs, tabIndex)
|
||||
return nil
|
||||
}))
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
MouseX: view.x0 + 1,
|
||||
MouseY: view.y0 + 1,
|
||||
Key: NewKeyName(MouseLeft),
|
||||
}))
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
MouseX: view.x0 + 3,
|
||||
MouseY: view.y0,
|
||||
Key: NewKey(MouseLeft, "", ModMotion),
|
||||
}))
|
||||
|
||||
assert.Empty(t, clickedTabs)
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
MouseX: view.x0 + 3,
|
||||
MouseY: view.y0,
|
||||
Key: NewKeyName(MouseRelease),
|
||||
}))
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
MouseX: view.x0 + 3,
|
||||
MouseY: view.y0,
|
||||
Key: NewKeyName(MouseLeft),
|
||||
}))
|
||||
assert.Equal(t, []int{0}, clickedTabs)
|
||||
}
|
||||
|
||||
func TestRejectedMouseReleaseClearsCapture(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
view, _ := g.SetView("captured", 0, 0, 20, 10, 0)
|
||||
g.captureMouse(view)
|
||||
g.ShouldHandleMouseEvent = func(*View, KeyName) bool { return false }
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
MouseX: view.x0 + 1,
|
||||
MouseY: view.y0 + 1,
|
||||
Key: NewKeyName(MouseRelease),
|
||||
}))
|
||||
|
||||
assert.Nil(t, g.mouseCapture)
|
||||
}
|
||||
|
||||
func TestDeleteViewClearsMouseState(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
view, _ := g.SetView("temporary", 0, 0, 20, 10, 0)
|
||||
g.captureMouse(view)
|
||||
g.lastHoverView = view
|
||||
|
||||
assert.NoError(t, g.DeleteView("temporary"))
|
||||
|
||||
assert.Nil(t, g.mouseCapture)
|
||||
assert.True(t, g.mouseGestureCanceled)
|
||||
assert.Nil(t, g.lastHoverView)
|
||||
}
|
||||
|
||||
func TestCancelMouseCaptureSuppressesRemainingGesture(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
left, _ := g.SetView("left", 0, 0, 20, 10, 0)
|
||||
_, _ = g.SetView("right", 21, 0, 41, 10, 0)
|
||||
receivedBy := ""
|
||||
for _, viewName := range []string{"left", "right"} {
|
||||
assert.NoError(t, g.SetViewClickBinding(&ViewMouseBinding{
|
||||
ViewName: viewName,
|
||||
Key: MouseLeft,
|
||||
Modifier: ModMotion,
|
||||
Handler: func(ViewMouseBindingOpts) error {
|
||||
receivedBy = viewName
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
g.captureMouse(left)
|
||||
g.CancelMouseCapture()
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
MouseX: 22,
|
||||
MouseY: 1,
|
||||
Key: NewKey(MouseLeft, "", ModMotion),
|
||||
}))
|
||||
assert.Empty(t, receivedBy)
|
||||
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
MouseX: 22,
|
||||
MouseY: 1,
|
||||
Key: NewKeyName(MouseRelease),
|
||||
}))
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
MouseX: 22,
|
||||
MouseY: 1,
|
||||
Key: NewKeyName(MouseLeft),
|
||||
}))
|
||||
assert.NoError(t, g.onKey(&GocuiEvent{
|
||||
Type: eventMouse,
|
||||
MouseX: 23,
|
||||
MouseY: 1,
|
||||
Key: NewKey(MouseLeft, "", ModMotion),
|
||||
}))
|
||||
|
||||
assert.Equal(t, "right", receivedBy)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// A flush while suspended must return without touching the screen: tcell
|
||||
// releases the screen's cell buffer when disengaging, and drawing to a
|
||||
// disengaged screen spins forever inside tcell while holding the screen lock,
|
||||
// blocking the resume triggered by fg (#5309). The flush runs in a goroutine
|
||||
// so that a regression fails the test instead of hanging the suite.
|
||||
func TestFlushIsNoOpWhileSuspended(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flush func(g *Gui) error
|
||||
}{
|
||||
{"flush", func(g *Gui) error { return g.flush() }},
|
||||
{"flushContentOnly", func(g *Gui) error { return g.flushContentOnly(g.views) }},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Deliberately not newTestGui: its cleanup closes the screen,
|
||||
// which would deadlock on the screen lock if a regression makes
|
||||
// the flush below spin.
|
||||
g, err := NewGui(NewGuiOpts{
|
||||
OutputMode: OutputNormal,
|
||||
Headless: true,
|
||||
Width: 80,
|
||||
Height: 24,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.NoError(t, g.Suspend())
|
||||
|
||||
flushReturned := make(chan error, 1)
|
||||
go func() { flushReturned <- tc.flush(g) }()
|
||||
|
||||
select {
|
||||
case err := <-flushReturned:
|
||||
assert.NoError(t, err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("flush touched the suspended screen and got stuck")
|
||||
}
|
||||
|
||||
assert.NoError(t, g.Resume())
|
||||
g.Close()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeSchedulesRedraw(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
|
||||
assert.NoError(t, g.Suspend())
|
||||
assert.NoError(t, g.Resume())
|
||||
|
||||
ev := GocuiEvent{Type: eventNone}
|
||||
select {
|
||||
case ev = <-g.gEvents:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
assert.Equal(t, eventResize, ev.Type,
|
||||
"resuming must schedule a redraw; without one the screen stays blank until the next event arrives")
|
||||
}
|
||||
+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))
|
||||
})
|
||||
}
|
||||
+48
-11
@@ -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.
|
||||
@@ -196,7 +202,6 @@ const (
|
||||
|
||||
var (
|
||||
lastMouseKey tcell.ButtonMask = tcell.ButtonNone
|
||||
lastMouseMod tcell.ModMask = tcell.ModNone
|
||||
dragState = NOT_DRAGGING
|
||||
lastX = 0
|
||||
lastY = 0
|
||||
@@ -208,6 +213,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 +236,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 +278,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 +296,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}
|
||||
@@ -344,9 +365,11 @@ func (g *Gui) pollEvent() GocuiEvent {
|
||||
|
||||
// process button events (not wheel events)
|
||||
button &= tcell.ButtonMask(0xff)
|
||||
newButtonPress := false
|
||||
buttonReleased := false
|
||||
if button != tcell.ButtonNone && lastMouseKey == tcell.ButtonNone {
|
||||
newButtonPress = true
|
||||
lastMouseKey = button
|
||||
lastMouseMod = tev.Modifiers()
|
||||
switch button {
|
||||
case tcell.ButtonPrimary:
|
||||
mouseKey = MouseLeft
|
||||
@@ -364,6 +387,7 @@ func (g *Gui) pollEvent() GocuiEvent {
|
||||
switch tev.Buttons() {
|
||||
case tcell.ButtonNone:
|
||||
if lastMouseKey != tcell.ButtonNone {
|
||||
buttonReleased = true
|
||||
switch lastMouseKey {
|
||||
case tcell.ButtonPrimary:
|
||||
dragState = NOT_DRAGGING
|
||||
@@ -371,14 +395,13 @@ func (g *Gui) pollEvent() GocuiEvent {
|
||||
case tcell.ButtonMiddle:
|
||||
default:
|
||||
}
|
||||
mouseMod = Modifier(lastMouseMod)
|
||||
lastMouseMod = tcell.ModNone
|
||||
mouseMod = ModNone
|
||||
lastMouseKey = tcell.ButtonNone
|
||||
}
|
||||
default:
|
||||
}
|
||||
|
||||
if !wheeling {
|
||||
if !wheeling && !buttonReleased {
|
||||
switch dragState {
|
||||
case NOT_DRAGGING:
|
||||
return GocuiEvent{
|
||||
@@ -388,9 +411,23 @@ func (g *Gui) pollEvent() GocuiEvent {
|
||||
}
|
||||
// if we haven't released the left mouse button and we've moved the cursor then we're dragging
|
||||
case MAYBE_DRAGGING:
|
||||
if x != lastX || y != lastY {
|
||||
dragState = DRAGGING
|
||||
if x == lastX && y == lastY {
|
||||
// Deliver the button press itself, but swallow held-button
|
||||
// motion events within the same cell: they carry no new
|
||||
// information, and if they fell through they would be
|
||||
// delivered with the default MouseRelease key.
|
||||
if !newButtonPress {
|
||||
return GocuiEvent{Type: eventNone}
|
||||
}
|
||||
break
|
||||
}
|
||||
// The first movement is already part of the drag; give it the
|
||||
// same key and modifier as the DRAGGING events below so it
|
||||
// reaches drag bindings instead of being delivered with the
|
||||
// default MouseRelease key.
|
||||
dragState = DRAGGING
|
||||
mouseMod = ModMotion
|
||||
mouseKey = MouseLeft
|
||||
case DRAGGING:
|
||||
mouseMod = ModMotion
|
||||
mouseKey = MouseLeft
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package gocui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFirstMouseMovementAfterPressIsDragEvent(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
|
||||
pressEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModNone))
|
||||
unchangedHeldEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModNone))
|
||||
dragEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModNone))
|
||||
|
||||
assert.Equal(t, eventMouse, pressEvent.Type)
|
||||
assert.Equal(t, MouseLeft, pressEvent.Key.KeyName())
|
||||
assert.Equal(t, ModNone, pressEvent.Key.Mod())
|
||||
assert.Equal(t, eventNone, unchangedHeldEvent.Type)
|
||||
assert.Equal(t, eventMouse, dragEvent.Type)
|
||||
assert.Equal(t, MouseLeft, dragEvent.Key.KeyName())
|
||||
assert.Equal(t, ModMotion, dragEvent.Key.Mod())
|
||||
}
|
||||
|
||||
func TestMouseReleaseAfterDragIsMouseEvent(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModNone))
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModNone))
|
||||
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModNone))
|
||||
|
||||
assert.Equal(t, eventMouse, releaseEvent.Type)
|
||||
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
|
||||
}
|
||||
|
||||
func TestMouseReleaseDoesNotKeepPressModifiers(t *testing.T) {
|
||||
t.Cleanup(resetMouseState)
|
||||
resetMouseState()
|
||||
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 2, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonPrimary, tcell.ModAlt))
|
||||
releaseEvent := gocuiEventFromTcellEvent(tcell.NewEventMouse(1, 3, tcell.ButtonNone, tcell.ModAlt))
|
||||
|
||||
assert.Equal(t, eventMouse, releaseEvent.Type)
|
||||
assert.Equal(t, MouseRelease, releaseEvent.Key.KeyName())
|
||||
assert.Equal(t, ModNone, releaseEvent.Key.Mod())
|
||||
}
|
||||
|
||||
func resetMouseState() {
|
||||
lastMouseKey = tcell.ButtonNone
|
||||
dragState = NOT_DRAGGING
|
||||
lastX = 0
|
||||
lastY = 0
|
||||
}
|
||||
+39
-34
@@ -87,6 +87,12 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) {
|
||||
result = append(result, cells[startOfLine:to]...)
|
||||
}
|
||||
|
||||
// Commit message trailers ("Signed-off-by:" and the like) must not be
|
||||
// auto-wrapped. They are only recognized in the last paragraph of the
|
||||
// message, so that a trailer-looking line in the message body isn't treated
|
||||
// as one; see startOfTrailerBlock.
|
||||
trailerBlockStart := startOfTrailerBlock(content)
|
||||
|
||||
for currentPos, c := range cells {
|
||||
if c.char == "\n" {
|
||||
appendCellsSinceLineStart(currentPos + 1)
|
||||
@@ -98,7 +104,9 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) {
|
||||
trailerMatcher.reset()
|
||||
} else {
|
||||
currentLineWidth += c.width
|
||||
if c.char == " " && !footNoteMatcher.isFootNote() && !trailerMatcher.isTrailer() {
|
||||
inTrailerBlock := c.contentIndex >= trailerBlockStart
|
||||
if c.char == " " && !footNoteMatcher.isFootNote() &&
|
||||
!(inTrailerBlock && trailerMatcher.isTrailer(content[c.contentIndex+len(c.char):])) {
|
||||
indexOfLastWhitespace = currentPos + 1
|
||||
} else if autoWrapWidth > 0 && currentLineWidth > autoWrapWidth && indexOfLastWhitespace >= 0 {
|
||||
wrapAt := indexOfLastWhitespace
|
||||
@@ -118,7 +126,9 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) {
|
||||
}
|
||||
|
||||
footNoteMatcher.addCharacter(c.char)
|
||||
trailerMatcher.addCharacter(c.char)
|
||||
if inTrailerBlock {
|
||||
trailerMatcher.addCharacter(c.char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +137,21 @@ func contentToCells(content string, autoWrapWidth int) ([]TextAreaCell, []int) {
|
||||
return result, softLineBreakIndices
|
||||
}
|
||||
|
||||
// startOfTrailerBlock returns the byte index into content at which the trailer
|
||||
// block begins, i.e. the start of the last paragraph (the run of lines at the
|
||||
// end of the message that is separated from the body by a blank line). Trailers
|
||||
// are only looked for from this index onwards, so that a trailer-looking line in
|
||||
// the middle of the message body isn't mistaken for a trailer. Trailing blank
|
||||
// lines are ignored, and a message that consists of a single paragraph is
|
||||
// treated as its own trailer block.
|
||||
func startOfTrailerBlock(content string) int {
|
||||
end := len(strings.TrimRight(content, "\n"))
|
||||
if blankLine := strings.LastIndex(content[:end], "\n\n"); blankLine >= 0 {
|
||||
return blankLine + len("\n\n")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var footNoteRe = regexp.MustCompile(`^\[\d+\]:\s*$`)
|
||||
|
||||
type footNoteMatcher struct {
|
||||
@@ -171,15 +196,11 @@ func (self *footNoteMatcher) reset() {
|
||||
self.didFailToMatch = false
|
||||
}
|
||||
|
||||
var supportedTrailers = []string{
|
||||
"Signed-off-by:",
|
||||
"Co-authored-by:",
|
||||
}
|
||||
|
||||
type trailerMatcher struct {
|
||||
lineStr strings.Builder
|
||||
didFailToMatch bool
|
||||
didMatch bool
|
||||
didFailToMatch bool
|
||||
didMatch bool
|
||||
keyContainsDash bool
|
||||
keyEndsWithColon bool
|
||||
}
|
||||
|
||||
func (self *trailerMatcher) addCharacter(chr string) {
|
||||
@@ -194,19 +215,11 @@ func (self *trailerMatcher) addCharacter(chr string) {
|
||||
return
|
||||
}
|
||||
|
||||
if self.lineStr.Len() == 0 {
|
||||
// If this is the first character, see if it could possibly match any supported trailer; if
|
||||
// not, we can fail early and stop tracking further characters for this line.
|
||||
if !anyOf(supportedTrailers, func(trailer string) bool { return trailer[0] == chr[0] }) {
|
||||
self.didFailToMatch = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
self.lineStr.WriteString(chr)
|
||||
self.keyContainsDash = self.keyContainsDash || chr == "-"
|
||||
self.keyEndsWithColon = chr == ":"
|
||||
}
|
||||
|
||||
func (self *trailerMatcher) isTrailer() bool {
|
||||
func (self *trailerMatcher) isTrailer(remainingContent string) bool {
|
||||
if self.didFailToMatch {
|
||||
return false
|
||||
}
|
||||
@@ -215,8 +228,9 @@ func (self *trailerMatcher) isTrailer() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
line := self.lineStr.String()
|
||||
if anyOf(supportedTrailers, func(trailer string) bool { return line == trailer }) {
|
||||
remainingContent = strings.TrimLeft(remainingContent, WHITESPACES)
|
||||
if self.keyEndsWithColon && (self.keyContainsDash ||
|
||||
strings.HasPrefix(remainingContent, "http://") || strings.HasPrefix(remainingContent, "https://")) {
|
||||
self.didMatch = true
|
||||
return true
|
||||
}
|
||||
@@ -226,19 +240,10 @@ func (self *trailerMatcher) isTrailer() bool {
|
||||
}
|
||||
|
||||
func (self *trailerMatcher) reset() {
|
||||
self.lineStr.Reset()
|
||||
self.didFailToMatch = false
|
||||
self.didMatch = false
|
||||
}
|
||||
|
||||
func anyOf(strings []string, predicate func(s string) bool) bool {
|
||||
for _, s := range strings {
|
||||
if predicate(s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
self.keyContainsDash = false
|
||||
self.keyEndsWithColon = false
|
||||
}
|
||||
|
||||
func (self *TextArea) updateCells() {
|
||||
|
||||
@@ -945,18 +945,63 @@ func Test_AutoWrapContent(t *testing.T) {
|
||||
expectedSoftLineBreaks: []int{16, 21},
|
||||
},
|
||||
{
|
||||
name: "don't break at space after trailer",
|
||||
content: "abc\nSigned-off-by: John Doe <john@doe.com>\nCo-authored-by: Jane Smith <jane@smith.com>\n",
|
||||
name: "don't break at space after trailer at beginning of message",
|
||||
content: "Signed-off-by: John Doe <john@doe.com>\nDepends-on: Some dependency with spaces\n",
|
||||
autoWrapWidth: 10,
|
||||
expectedWrappedContent: "abc\nSigned-off-by: John Doe <john@doe.com>\nCo-authored-by: Jane Smith <jane@smith.com>\n",
|
||||
expectedWrappedContent: "Signed-off-by: John Doe <john@doe.com>\nDepends-on: Some dependency with spaces\n",
|
||||
expectedSoftLineBreaks: []int{},
|
||||
},
|
||||
{
|
||||
name: "do break at space after trailer if there is no space after the colon",
|
||||
content: "abc\nSigned-off-by:John Doe <john@doe.com>\n",
|
||||
name: "don't break at space after trailer in a trailer block at the end of a message",
|
||||
content: "abc\n\nSigned-off-by: John Doe <john@doe.com>\nDepends-on: Some dependency with spaces\n",
|
||||
autoWrapWidth: 10,
|
||||
expectedWrappedContent: "abc\nSigned-off-by:John \nDoe \n<john@doe.com>\n",
|
||||
expectedSoftLineBreaks: []int{23, 27},
|
||||
expectedWrappedContent: "abc\n\nSigned-off-by: John Doe <john@doe.com>\nDepends-on: Some dependency with spaces\n",
|
||||
expectedSoftLineBreaks: []int{},
|
||||
},
|
||||
{
|
||||
name: "don't break at space after trailer with URL value",
|
||||
content: "abc\n\nBug: https://example.com/a/very/long/path\nIssue: http://example.com/a/very/long/path\n",
|
||||
autoWrapWidth: 10,
|
||||
expectedWrappedContent: "abc\n\nBug: https://example.com/a/very/long/path\nIssue: http://example.com/a/very/long/path\n",
|
||||
expectedSoftLineBreaks: []int{},
|
||||
},
|
||||
{
|
||||
name: "do break at space if trailer is not in a trailer block at the end",
|
||||
content: "abc\n\nSigned-off-by: John Doe <john@doe.com>\n\nMore text here\n",
|
||||
autoWrapWidth: 10,
|
||||
expectedWrappedContent: "abc\n\nSigned-off-by: \nJohn Doe \n<john@doe.com>\n\nMore text \nhere\n",
|
||||
expectedSoftLineBreaks: []int{20, 29, 55},
|
||||
},
|
||||
{
|
||||
// Each line in the trailer block is judged on its own, so a line
|
||||
// that isn't recognized as a trailer wraps without affecting the
|
||||
// real trailers around it.
|
||||
name: "keep a trailer next to a non-trailer line in the same block",
|
||||
content: "abc\n\nFixes: a long description that wraps\nSigned-off-by: John Doe <john@doe.com>\n",
|
||||
autoWrapWidth: 10,
|
||||
expectedWrappedContent: "abc\n\nFixes: a \nlong \ndescription \nthat wraps\nSigned-off-by: John Doe <john@doe.com>\n",
|
||||
expectedSoftLineBreaks: []int{14, 19, 31},
|
||||
},
|
||||
{
|
||||
name: "don't break at space after trailer when the block ends in a blank line",
|
||||
content: "abc\n\nSigned-off-by: John Doe <john@doe.com>\n\n",
|
||||
autoWrapWidth: 10,
|
||||
expectedWrappedContent: "abc\n\nSigned-off-by: John Doe <john@doe.com>\n\n",
|
||||
expectedSoftLineBreaks: []int{},
|
||||
},
|
||||
{
|
||||
name: "do break normal text after non-hyphenated key",
|
||||
content: "However: in this commit blah blah blah\n",
|
||||
autoWrapWidth: 10,
|
||||
expectedWrappedContent: "However: \nin this \ncommit \nblah blah \nblah\n",
|
||||
expectedSoftLineBreaks: []int{9, 17, 24, 34},
|
||||
},
|
||||
{
|
||||
name: "do break at space after trailer if there is no space after the colon",
|
||||
content: "abc\n\nSigned-off-by:John Doe <john@doe.com>\n",
|
||||
autoWrapWidth: 10,
|
||||
expectedWrappedContent: "abc\n\nSigned-off-by:John \nDoe \n<john@doe.com>\n",
|
||||
expectedSoftLineBreaks: []int{24, 28},
|
||||
},
|
||||
{
|
||||
name: "hard line breaks",
|
||||
|
||||
+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()
|
||||
}
|
||||
|
||||
+75
-26
@@ -6,7 +6,9 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
@@ -43,6 +45,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 +81,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 {
|
||||
@@ -101,23 +108,35 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() {
|
||||
self.gui.waitForIntro.Wait()
|
||||
|
||||
fetch := func(firstTimeOrRetriggered bool) error {
|
||||
// Do this on the UI thread so that we don't have to deal with synchronization around the
|
||||
// access of the repo state.
|
||||
self.gui.onUIThread(func() error {
|
||||
// There's a race here, where we might be recording the time stamp for a different repo
|
||||
// than where the fetch actually ran. It's not very likely though, and not harmful if it
|
||||
// does happen; guarding against it would be more effort than it's worth.
|
||||
// Capture what the fetch needs from the gui's per-repo state in a
|
||||
// single UI-thread hop: gui.git, gui.helpers and gui.State are all
|
||||
// replaced on a repo switch (which runs on the UI thread), so reading
|
||||
// them from this background goroutine would race the reassignment.
|
||||
// Capturing them together also ties the fetch, the post-fetch
|
||||
// refresh's generation baseline, and the recorded fetch time to the
|
||||
// same repo.
|
||||
var git *commands.GitCommand
|
||||
var appStatusHelper *helpers.AppStatusHelper
|
||||
var branchesHelper *helpers.BranchesHelper
|
||||
var fetchGeneration int
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
git = self.gui.git
|
||||
appStatusHelper = self.gui.helpers.AppStatus
|
||||
branchesHelper = self.gui.helpers.BranchesHelper
|
||||
fetchGeneration = self.gui.c.State().GetRepoGeneration()
|
||||
self.gui.State.LastBackgroundFetchTime = time.Now()
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if self.gui.UserConfig().Gui.ShowBottomLine || firstTimeOrRetriggered {
|
||||
return self.gui.helpers.AppStatus.WithWaitingStatusImpl(self.gui.Tr.FetchingStatus, func(gocui.Task) error {
|
||||
return self.backgroundFetch()
|
||||
return appStatusHelper.WithWaitingStatusImpl(self.gui.Tr.FetchingStatus, func(gocui.Task) error {
|
||||
return self.backgroundFetch(git, branchesHelper, fetchGeneration)
|
||||
}, nil)
|
||||
}
|
||||
|
||||
return self.backgroundFetch()
|
||||
return self.backgroundFetch(git, branchesHelper, fetchGeneration)
|
||||
}
|
||||
|
||||
// We want an immediate fetch at startup, and since goEvery starts by
|
||||
@@ -125,15 +144,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 +170,7 @@ func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() {
|
||||
self.goEvery(
|
||||
userConfig.Refresher.ExternalChangeCheckIntervalDuration(),
|
||||
self.gui.stopChan,
|
||||
nil,
|
||||
func(_ bool) error {
|
||||
self.checkForExternalChanges()
|
||||
return nil
|
||||
@@ -159,7 +179,20 @@ func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() {
|
||||
}
|
||||
|
||||
func (self *BackgroundRoutineMgr) checkForExternalChanges() {
|
||||
current, err := self.gui.git.Status.RefsSnapshot()
|
||||
// Capture the per-repo objects in a UI-thread hop, like the background
|
||||
// fetch does: gui.git and gui.helpers are replaced on a repo switch, so
|
||||
// reading them from this background goroutine would race the reassignment.
|
||||
var git *commands.GitCommand
|
||||
var refreshHelper *helpers.RefreshHelper
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
git = self.gui.git
|
||||
refreshHelper = self.gui.helpers.Refresh
|
||||
return nil
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
current, err := git.Status.RefsSnapshot()
|
||||
if err != nil {
|
||||
// Transient error (e.g. git process couldn't start). Don't update the
|
||||
// stored snapshot; we'll retry next tick.
|
||||
@@ -167,7 +200,7 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() {
|
||||
return
|
||||
}
|
||||
|
||||
if !self.gui.helpers.Refresh.RefsSnapshotChangedSince(current) {
|
||||
if !refreshHelper.RefsSnapshotChangedSince(current) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -184,13 +217,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 +231,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,17 +256,30 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru
|
||||
}
|
||||
}
|
||||
})
|
||||
return retrigger
|
||||
}
|
||||
|
||||
func (self *BackgroundRoutineMgr) backgroundFetch() (err error) {
|
||||
err = self.gui.git.Sync.FetchBackground()
|
||||
// The parameters are captured by the caller before the fetch starts, not read
|
||||
// here after it: the fetch is a network call during which the user may switch
|
||||
// repos, and the post-fetch refresh needs to be able to tell (see
|
||||
// PostFetchRefresh).
|
||||
func (self *BackgroundRoutineMgr) backgroundFetch(git *commands.GitCommand, branchesHelper *helpers.BranchesHelper, fetchGeneration int) error {
|
||||
err := git.Sync.FetchBackground()
|
||||
|
||||
return self.gui.helpers.BranchesHelper.PostFetchRefresh(err, true)
|
||||
return branchesHelper.PostFetchRefresh(err, true, fetchGeneration)
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -170,7 +186,7 @@ func (gui *Gui) getRandomTip() string {
|
||||
// links
|
||||
fmt.Sprintf(
|
||||
"If you want a git diff with syntax colouring, check out lazygit's integration with delta:\n%s",
|
||||
constants.Links.Docs.CustomPagers,
|
||||
constants.Links.Docs.CustomDiffRenderers,
|
||||
),
|
||||
fmt.Sprintf(
|
||||
"You can build your own custom menus and commands to run from within lazygit. For examples see:\n%s",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
@@ -9,6 +10,10 @@ import (
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
func formatListSectionHeader(label string) string {
|
||||
return fmt.Sprintf("─── %s", label)
|
||||
}
|
||||
|
||||
type NonModelItem struct {
|
||||
// Where in the model this should be inserted
|
||||
Index int
|
||||
@@ -32,34 +37,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 +118,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 +139,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))
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@ package context
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
@@ -17,6 +21,13 @@ type LocalCommitsContext struct {
|
||||
*LocalCommitsViewModel
|
||||
*ListContextTrait
|
||||
*SearchTrait
|
||||
|
||||
dropIndicator *commitDropIndicator
|
||||
}
|
||||
|
||||
type commitDropIndicator struct {
|
||||
insertionIndex int
|
||||
moving bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -26,6 +37,7 @@ var (
|
||||
)
|
||||
|
||||
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
dropIndicator := &commitDropIndicator{insertionIndex: -1}
|
||||
viewModel := NewLocalCommitsViewModel(
|
||||
func() []*models.Commit { return c.Model().Commits },
|
||||
c,
|
||||
@@ -71,7 +83,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
if c.Model().WorkingTreeStateAtLastCommitRefresh.Rebasing {
|
||||
result = append(result, &NonModelItem{
|
||||
Index: 0,
|
||||
Content: fmt.Sprintf("--- %s ---", c.Tr.PendingRebaseTodosSectionHeader),
|
||||
Content: formatListSectionHeader(c.Tr.PendingRebaseTodosSectionHeader),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,10 +102,19 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
c.Tr.PendingRevertsSectionHeader)
|
||||
result = append(result, &NonModelItem{
|
||||
Index: firstCherryPickOrRevertTodo,
|
||||
Content: fmt.Sprintf("--- %s ---", label),
|
||||
Content: formatListSectionHeader(label),
|
||||
})
|
||||
}
|
||||
|
||||
result = addCommitDropIndicator(
|
||||
result,
|
||||
dropIndicator,
|
||||
c.Tr.MoveCommitsHere,
|
||||
c.Tr.MovingCommitsHere,
|
||||
c.UserConfig().Gui.Spinner,
|
||||
time.Now(),
|
||||
)
|
||||
|
||||
_, firstRealCommit, found := lo.FindIndexOf(
|
||||
c.Model().Commits, func(c *models.Commit) bool {
|
||||
return !c.IsTODO()
|
||||
@@ -103,8 +124,17 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
}
|
||||
result = append(result, &NonModelItem{
|
||||
Index: firstRealCommit,
|
||||
Content: fmt.Sprintf("--- %s ---", c.Tr.CommitsSectionHeader),
|
||||
Content: formatListSectionHeader(c.Tr.CommitsSectionHeader),
|
||||
})
|
||||
} else {
|
||||
result = addCommitDropIndicator(
|
||||
result,
|
||||
dropIndicator,
|
||||
c.Tr.MoveCommitsHere,
|
||||
c.Tr.MovingCommitsHere,
|
||||
c.UserConfig().Gui.Spinner,
|
||||
time.Now(),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -113,6 +143,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
ctx := &LocalCommitsContext{
|
||||
LocalCommitsViewModel: viewModel,
|
||||
SearchTrait: NewSearchTrait(c),
|
||||
dropIndicator: dropIndicator,
|
||||
ListContextTrait: &ListContextTrait{
|
||||
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
|
||||
View: c.Views().Commits,
|
||||
@@ -137,12 +168,60 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
|
||||
return ctx
|
||||
}
|
||||
|
||||
func addCommitDropIndicator(
|
||||
items []*NonModelItem,
|
||||
indicator *commitDropIndicator,
|
||||
dropLabel string,
|
||||
movingLabel string,
|
||||
spinnerConfig config.SpinnerConfig,
|
||||
now time.Time,
|
||||
) []*NonModelItem {
|
||||
if indicator.insertionIndex < 0 {
|
||||
return items
|
||||
}
|
||||
label := dropLabel
|
||||
if indicator.moving {
|
||||
label = fmt.Sprintf("%s %s", movingLabel, presentation.Loader(now, spinnerConfig))
|
||||
}
|
||||
|
||||
insertAt := len(items)
|
||||
for i, item := range items {
|
||||
if item.Index > indicator.insertionIndex {
|
||||
insertAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return slices.Insert(items, insertAt, &NonModelItem{
|
||||
Index: indicator.insertionIndex,
|
||||
Content: style.FgCyan.SetBold().Sprintf("━━━━━━ %s ━━━━━━", label),
|
||||
Column: 6, // align with the commit subject
|
||||
})
|
||||
}
|
||||
|
||||
func (self *LocalCommitsContext) SetDropInsertionIndex(index int) {
|
||||
self.dropIndicator.insertionIndex = index
|
||||
self.dropIndicator.moving = false
|
||||
}
|
||||
|
||||
func (self *LocalCommitsContext) SetMovingCommitsInsertionIndex(index int) {
|
||||
self.dropIndicator.insertionIndex = index
|
||||
self.dropIndicator.moving = true
|
||||
}
|
||||
|
||||
func (self *LocalCommitsContext) ClearDropInsertionIndex() {
|
||||
self.dropIndicator.insertionIndex = -1
|
||||
self.dropIndicator.moving = false
|
||||
}
|
||||
|
||||
type LocalCommitsViewModel struct {
|
||||
*ListViewModel[*models.Commit]
|
||||
|
||||
// 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 +230,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 +300,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) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAddCommitDropIndicator(t *testing.T) {
|
||||
pendingHeader := &NonModelItem{Index: 0, Content: "pending"}
|
||||
commitsHeader := &NonModelItem{Index: 3, Content: "commits"}
|
||||
indicator := &commitDropIndicator{insertionIndex: 3}
|
||||
spinnerConfig := config.SpinnerConfig{Frames: []string{"one", "two"}, Rate: 100}
|
||||
|
||||
items := addCommitDropIndicator(
|
||||
[]*NonModelItem{pendingHeader}, indicator, "drop here", "moving commits here", spinnerConfig, time.UnixMilli(0),
|
||||
)
|
||||
items = append(items, commitsHeader)
|
||||
|
||||
assert.Equal(t, []*NonModelItem{
|
||||
pendingHeader,
|
||||
{
|
||||
Index: 3,
|
||||
Content: style.FgCyan.SetBold().Sprint("━━━━━━ drop here ━━━━━━"),
|
||||
Column: 6,
|
||||
},
|
||||
commitsHeader,
|
||||
}, items)
|
||||
assert.Equal(t, 6, modelIndexToViewIndex(4, items, 3))
|
||||
assert.Equal(t, 3, viewIndexToModelIndex(4, items, 4))
|
||||
}
|
||||
|
||||
func TestAddMovingCommitsIndicator(t *testing.T) {
|
||||
items := addCommitDropIndicator(
|
||||
nil,
|
||||
&commitDropIndicator{insertionIndex: 2, moving: true},
|
||||
"drop here",
|
||||
"moving commits here",
|
||||
config.SpinnerConfig{Frames: []string{"one", "two"}, Rate: 100},
|
||||
time.UnixMilli(100),
|
||||
)
|
||||
|
||||
assert.Equal(t, []*NonModelItem{
|
||||
{
|
||||
Index: 2,
|
||||
Content: style.FgCyan.SetBold().Sprint("━━━━━━ moving commits here two ━━━━━━"),
|
||||
Column: 6,
|
||||
},
|
||||
}, items)
|
||||
}
|
||||
@@ -198,7 +198,7 @@ func (self *MenuViewModel) GetNonModelItems() []*NonModelItem {
|
||||
result = append(result, &NonModelItem{
|
||||
Index: i,
|
||||
Column: 1,
|
||||
Content: style.FgGreen.SetBold().Sprintf("--- %s ---", menuItem.Section.Title),
|
||||
Content: style.FgGreen.SetBold().Sprint(formatListSectionHeader(menuItem.Section.Title)),
|
||||
})
|
||||
prevSection = menuItem.Section
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user