mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 15:46:26 -04:00
Merge branch 'master' into feat/reset-to-custom-ref
This commit is contained in:
commit
388fd918c8
2
.github/workflows/check-required-label.yml
vendored
2
.github/workflows/check-required-label.yml
vendored
|
|
@ -8,7 +8,7 @@ jobs:
|
|||
check-required-label:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5
|
||||
- uses: mheap/github-action-required-labels@23e10fde7e062233401931a0eece796cd9bf3177 # v5
|
||||
with:
|
||||
mode: exactly
|
||||
count: 1
|
||||
|
|
|
|||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
- name: Test code
|
||||
|
|
@ -93,7 +93,7 @@ jobs:
|
|||
path: ~/git-${{matrix.git-version}}
|
||||
key: ${{runner.os}}-git-${{matrix.git-version}}
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
- name: Print git version
|
||||
|
|
@ -130,7 +130,7 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
- name: Build linux binary
|
||||
|
|
@ -157,7 +157,7 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
- name: Check Vendor Directory
|
||||
|
|
@ -183,7 +183,7 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
- name: Check formatting
|
||||
|
|
@ -195,7 +195,7 @@ jobs:
|
|||
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9
|
||||
with:
|
||||
# If you change this, make sure to also update scripts/golangci-lint-shim.sh
|
||||
version: v2.4.0
|
||||
version: v2.12.2
|
||||
upload-coverage:
|
||||
# List all jobs that produce coverage files
|
||||
needs: [unit-tests, integration-tests]
|
||||
|
|
@ -206,7 +206,7 @@ jobs:
|
|||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
|
||||
|
|
|
|||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -160,7 +160,7 @@ jobs:
|
|||
git push origin "refs/tags/$NEW_TAG"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 1.25.x
|
||||
|
||||
|
|
|
|||
2
.github/workflows/sponsors.yml
vendored
2
.github/workflows/sponsors.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
uses: actions/checkout@v7
|
||||
|
||||
- name: Generate Sponsors 💖
|
||||
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1.6.0
|
||||
uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1
|
||||
with:
|
||||
token: ${{ secrets.SPONSORS_TOKEN }}
|
||||
file: "README.md"
|
||||
|
|
|
|||
|
|
@ -99,8 +99,6 @@ linters:
|
|||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- vendor/
|
||||
|
|
|
|||
50
AGENTS.md
50
AGENTS.md
|
|
@ -87,6 +87,10 @@ while still being meaningful and self-contained.
|
|||
- **Wrap message body to 72 characters**. The subject is allowed to go up to 80
|
||||
characters, or even a little more if needed to convey a good single-line
|
||||
summary; the body should be wrapped at 72 exactly, no more, no less.
|
||||
- **End every commit message with the `Co-authored-by:` trailer** naming the
|
||||
model that wrote it, exactly as your harness instructions spell it. Nothing
|
||||
in `just check` catches a missing one, so it has to be part of writing the
|
||||
message rather than something to notice afterwards.
|
||||
|
||||
## Iterate with `fixup!` commits
|
||||
|
||||
|
|
@ -105,6 +109,16 @@ separate, reviewable commit that the user decides when to fold in. A bare
|
|||
`--amend` rewrites the commit on the spot and skips that checkpoint. Don't
|
||||
treat "I'm only touching the tip commit" as an exception.
|
||||
|
||||
**When the tip is the wrong place for a fixup, insert it mid-branch.**
|
||||
Committing a fixup at the tip of the branch only works while the code it
|
||||
touches still looks the same there; once later commits have rewritten that
|
||||
code — or the target has since been split — the fixup won't apply, and
|
||||
rewriting the later commits to accommodate it defeats the point. Check out the
|
||||
target, make the change, `git commit --fixup=<target>`, then
|
||||
`git rebase --onto <the fixup> <target> <branch>` to replay the rest of the
|
||||
branch. The fixup stays a separate, reviewable commit; only its position
|
||||
changes.
|
||||
|
||||
If the changes don't map cleanly onto existing commits — say they cut
|
||||
across several of them, or restructure something at a different layer
|
||||
than any existing commit naturally owns — stop and ask the user how to
|
||||
|
|
@ -213,6 +227,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
|
||||
|
|
@ -255,7 +279,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
|
||||
|
||||
|
|
@ -376,12 +404,32 @@ Avoid phrasings like:
|
|||
- "cleaner than the previous approach"
|
||||
- "we used to ... but ..."
|
||||
- "after trying X, we found Y"
|
||||
- "X rather than Y", where Y is what the code did before the change
|
||||
|
||||
The iteration story is sometimes worth preserving — but it belongs in the
|
||||
commit message, which is the durable record of *why this change was made*. The
|
||||
code comment should make sense to someone who has never seen any prior version
|
||||
and is just trying to understand the file as it currently exists.
|
||||
|
||||
The tell is subtler than an explicit "we used to". A comment that justifies the
|
||||
code against an alternative — "run it on a worker rather than blocking the UI",
|
||||
"switch panels in `Then` rather than a moment earlier" — is history in disguise
|
||||
whenever that alternative is what the code did before the change. It reads as
|
||||
ordinary rationale, but the reader has no way to know the contrast is with a
|
||||
version that no longer exists.
|
||||
|
||||
So the check to apply is: would you have written this comment if you were
|
||||
writing the file from scratch, with no diff in mind? If not, the sentence
|
||||
belongs in the commit message.
|
||||
|
||||
## Don't justify routine call sites
|
||||
|
||||
If the codebase calls a helper in twenty places without explanation, your
|
||||
twenty-first call site doesn't need one either. A comment there says "something
|
||||
here is unusual"; when nothing is, it's noise — and it invites exactly the kind
|
||||
of before/after justification the section above warns about. Look at the
|
||||
neighboring call sites before writing one: if they're bare, match them.
|
||||
|
||||
## Don't present "live with the bug" as an option
|
||||
|
||||
When you're investigating a defect and laying out fix options for the user,
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
84
docs-master/Custom_DiffRenderers.md
Normal file
84
docs-master/Custom_DiffRenderers.md
Normal file
|
|
@ -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`), as an array of strings.
|
||||
|
||||
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,108 +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 --context={{diffContext}}
|
||||
```
|
||||
|
||||
The `colorArg` option is not used in this case. 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.
|
||||
|
||||
You can add whatever extra arguments you prefer for your difftool; for instance
|
||||
|
||||
```yaml
|
||||
git:
|
||||
pagers:
|
||||
- externalDiffCommand: difft --color=always --context={{diffContext}} --display=inline --syntax-highlight=off
|
||||
```
|
||||
|
||||
This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
|
||||
git diff --color-words --no-index --color=always --no-ext-diff --unified=$LAZYGIT_DIFF_CONTEXT "$2" "$5"
|
||||
```
|
||||
|
||||
And then use it in your git config like so:
|
||||
|
||||
```yaml
|
||||
git:
|
||||
pagers:
|
||||
- externalDiffCommand: LAZYGIT_DIFF_CONTEXT={{diffContext}} ~/bin/color-words.sh
|
||||
```
|
||||
|
||||
Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using
|
||||
|
||||
```yaml
|
||||
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. |
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
84
docs/Custom_DiffRenderers.md
Normal file
84
docs/Custom_DiffRenderers.md
Normal file
|
|
@ -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`), as an array of strings.
|
||||
|
||||
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,108 +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
|
||||
```
|
||||
|
||||
This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
|
||||
git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5"
|
||||
```
|
||||
|
||||
And then use it in your git config like so:
|
||||
|
||||
```yaml
|
||||
git:
|
||||
pagers:
|
||||
- externalDiffCommand: ~/bin/color-words.sh
|
||||
```
|
||||
|
||||
Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using
|
||||
|
||||
```yaml
|
||||
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. |
|
||||
|
|
|
|||
32
flake.lock
32
flake.lock
|
|
@ -7,7 +7,7 @@
|
|||
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
|
||||
"revCount": 69,
|
||||
"type": "tarball",
|
||||
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
|
||||
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz?rev=ff81ac966bb2cae68946d5ed5fc4994f96d0ffec&revCount=69"
|
||||
},
|
||||
"original": {
|
||||
"type": "tarball",
|
||||
|
|
@ -19,11 +19,11 @@
|
|||
"nixpkgs-lib": "nixpkgs-lib"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1759362264,
|
||||
"narHash": "sha256-wfG0S7pltlYyZTM+qqlhJ7GMw2fTF4mLKCIVhLii/4M=",
|
||||
"lastModified": 1785627969,
|
||||
"narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "flake-parts",
|
||||
"rev": "758cf7296bee11f1706a574c77d072b8a7baa881",
|
||||
"rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -34,11 +34,11 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1759831965,
|
||||
"narHash": "sha256-vgPm2xjOmKdZ0xKA6yLXPJpjOtQPHfaZDRtH+47XEBo=",
|
||||
"lastModified": 1785828668,
|
||||
"narHash": "sha256-8fsyqeO+mJqvIzeO4xIpgJe/f7MTbbVTEC6RT6WSXNs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c9b6fb798541223bbb396d287d16f43520250518",
|
||||
"rev": "e72e4f299401a3689d4b3d5fc6496b11db7064eb",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -50,11 +50,11 @@
|
|||
},
|
||||
"nixpkgs-lib": {
|
||||
"locked": {
|
||||
"lastModified": 1754788789,
|
||||
"narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=",
|
||||
"lastModified": 1785031560,
|
||||
"narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=",
|
||||
"owner": "nix-community",
|
||||
"repo": "nixpkgs.lib",
|
||||
"rev": "a73b9c743612e4244d865a2fdee11865283c04e6",
|
||||
"rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -65,11 +65,11 @@
|
|||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1754340878,
|
||||
"narHash": "sha256-lgmUyVQL9tSnvvIvBp7x1euhkkCho7n3TMzgjdvgPoU=",
|
||||
"lastModified": 1770107345,
|
||||
"narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "cab778239e705082fe97bb4990e0d24c50924c04",
|
||||
"rev": "4533d9293756b63904b7238acb84ac8fe4c8c2c4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -108,11 +108,11 @@
|
|||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1758728421,
|
||||
"narHash": "sha256-ySNJ008muQAds2JemiyrWYbwbG+V7S5wg3ZVKGHSFu8=",
|
||||
"lastModified": 1785360170,
|
||||
"narHash": "sha256-XE1lKgQ3eIO3E7zWryqcRsax+mYXod/5RHBn4YaR9YE=",
|
||||
"owner": "numtide",
|
||||
"repo": "treefmt-nix",
|
||||
"rev": "5eda4ee8121f97b218f7cc73f5172098d458f1d1",
|
||||
"rev": "d1187f8bc71fb8aab02395869ec3f5c1920f75c0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@
|
|||
# Development tools
|
||||
git
|
||||
gnumake
|
||||
just
|
||||
];
|
||||
|
||||
# Environment variables for development
|
||||
|
|
@ -108,8 +109,8 @@
|
|||
};
|
||||
|
||||
treefmt = {
|
||||
programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt-rfc-style.compiler;
|
||||
programs.nixfmt.package = pkgs.nixfmt-rfc-style;
|
||||
programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt.compiler;
|
||||
programs.nixfmt.package = pkgs.nixfmt;
|
||||
programs.gofmt.enable = true;
|
||||
};
|
||||
|
||||
|
|
|
|||
14
go.mod
14
go.mod
|
|
@ -5,6 +5,9 @@ go 1.25.0
|
|||
// This is necessary to ignore test files when executing gofumpt.
|
||||
ignore ./test
|
||||
|
||||
// Likewise for worktrees that are nested in the main tree.
|
||||
ignore ./.worktrees
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.2
|
||||
github.com/adrg/xdg v0.5.3
|
||||
|
|
@ -21,8 +24,8 @@ require (
|
|||
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5
|
||||
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0
|
||||
github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3
|
||||
github.com/kyokomi/emoji/v2 v2.2.13
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0
|
||||
github.com/kyokomi/emoji/v2 v2.2.14
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1
|
||||
github.com/mgutz/str v1.2.0
|
||||
github.com/mitchellh/go-ps v1.0.0
|
||||
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe
|
||||
|
|
@ -54,7 +57,6 @@ require (
|
|||
github.com/fatih/color v1.9.0 // indirect
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.5.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/hpcloud/tail v1.0.0 // indirect
|
||||
github.com/invopop/jsonschema v0.10.0 // indirect
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect
|
||||
|
|
@ -65,14 +67,14 @@ require (
|
|||
github.com/onsi/gomega v1.34.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
gopkg.in/fsnotify.v1 v1.4.7 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
mvdan.cc/gofumpt v0.11.0 // indirect
|
||||
)
|
||||
|
||||
tool mvdan.cc/gofumpt
|
||||
|
|
|
|||
32
go.sum
32
go.sum
|
|
@ -39,8 +39,8 @@ github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
|
|||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
|
||||
|
|
@ -73,10 +73,10 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
|
||||
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/kyokomi/emoji/v2 v2.2.14 h1:YOF6VL52613M0Qr9v4puJDD9QQPmyyjXedDDlrGzH80=
|
||||
github.com/kyokomi/emoji/v2 v2.2.14/go.mod h1:1AnYl9IgmJZXKd5m1PEijyyUw85SqYsuAr8lpU/s+9s=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
|
|
@ -104,8 +104,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=
|
||||
github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
|
||||
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||
|
|
@ -139,14 +139,14 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0
|
|||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
|
@ -181,8 +181,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
|
|||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
|
@ -196,5 +196,5 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD
|
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
|
||||
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
|
||||
mvdan.cc/gofumpt v0.11.0 h1:0H01XB95PnN2QgCSR9ELdZyTlJqNZ7181B0BTMh5VZc=
|
||||
mvdan.cc/gofumpt v0.11.0/go.mod h1:BeT5wCsOJt6J9zT2MZIOGszjUHzFkn1/l9g6xAzqsXo=
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ type errorMapping struct {
|
|||
func knownError(tr *i18n.TranslationSet, err error) (string, bool) {
|
||||
errorMessage := err.Error()
|
||||
|
||||
knownErrorMessages := []string{minGitVersionErrorMessage(tr)}
|
||||
knownErrorMessages := []string{minGitVersionErrorMessage(tr), tr.BareRepoNotSupported}
|
||||
|
||||
if lo.Contains(knownErrorMessages, errorMessage) {
|
||||
return errorMessage, true
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ func getHeader(binding *types.Binding, tr *i18n.TranslationSet) header {
|
|||
|
||||
func formatSections(tr *i18n.TranslationSet, bindingSections []*bindingSection) string {
|
||||
var content strings.Builder
|
||||
content.WriteString(fmt.Sprintf("# Lazygit %s\n", tr.Keybindings))
|
||||
fmt.Fprintf(&content, "# Lazygit %s\n", tr.Keybindings)
|
||||
|
||||
for _, section := range bindingSections {
|
||||
content.WriteString(formatTitle(section.title))
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/commands/patch"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/env"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
)
|
||||
|
||||
|
|
@ -60,18 +61,31 @@ 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 {
|
||||
return nil, errors.Errorf("Error getting repo paths: %v", err)
|
||||
}
|
||||
|
||||
// A bare repo has no worktree for us to work in. Callers that can offer the
|
||||
// user something better (app.setupRepo) check for this first; getting here
|
||||
// means nobody could, e.g. because --git-dir was pointed at a bare repo.
|
||||
if repoPaths.IsBareRepo() {
|
||||
return nil, errors.New(cmn.Tr.BareRepoNotSupported)
|
||||
}
|
||||
|
||||
err = os.Chdir(repoPaths.WorktreePath())
|
||||
if err != nil {
|
||||
return nil, utils.WrapError(err)
|
||||
}
|
||||
|
||||
// Everything we run through the command builder gets told where the repo is
|
||||
// by the builder itself, but subprocesses don't go through it: user-defined
|
||||
// custom commands, an editor, and the lazygit we re-enter as git's sequence
|
||||
// editor during a rebase. Put it in the process env for those.
|
||||
env.SetGitLocationEnvVars(repoPaths.GitLocationEnvVars())
|
||||
|
||||
// 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())
|
||||
|
|
@ -82,7 +96,7 @@ func NewGitCommand(
|
|||
osCommand,
|
||||
gitConfig,
|
||||
repoPaths,
|
||||
pagerConfig,
|
||||
diffRendererConfigManager,
|
||||
), nil
|
||||
}
|
||||
|
||||
|
|
@ -92,9 +106,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, repoPaths.WorktreePath())
|
||||
cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath(), repoPaths.GitLocationEnvVars())
|
||||
|
||||
// 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,
|
||||
|
|
@ -103,7 +117,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)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ type gitCmdObjBuilder struct {
|
|||
// 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
|
||||
|
||||
// The env vars every command we produce gets: the optional-locks one below,
|
||||
// plus the repo's git location if it has one (see
|
||||
// RepoPaths.GitLocationEnvVars). Those are in the process env too, but for
|
||||
// the same reason as repoDir we don't rely on that: the process env belongs
|
||||
// to whichever repo lazygit has since switched to.
|
||||
envVars []string
|
||||
}
|
||||
|
||||
var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{}
|
||||
|
|
@ -30,7 +37,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, repoDir string) *gitCmdObjBuilder {
|
||||
func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string, gitLocationEnvVars []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{
|
||||
|
|
@ -43,15 +50,16 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild
|
|||
return &gitCmdObjBuilder{
|
||||
innerBuilder: updatedBuilder,
|
||||
repoDir: repoDir,
|
||||
envVars: append([]string{defaultEnvVar}, gitLocationEnvVars...),
|
||||
}
|
||||
}
|
||||
|
||||
func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj {
|
||||
return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar).SetWd(self.repoDir)
|
||||
return self.innerBuilder.New(args).AddEnvVars(self.envVars...).SetWd(self.repoDir)
|
||||
}
|
||||
|
||||
func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj {
|
||||
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar).SetWd(self.repoDir)
|
||||
return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(self.envVars...).SetWd(self.repoDir)
|
||||
}
|
||||
|
||||
func (self *gitCmdObjBuilder) Quote(str string) string {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ func TestGitCmdObjBuilderDisablesOptionalLocksByDefault(t *testing.T) {
|
|||
utils.NewDummyLog(),
|
||||
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
|
||||
"/path/to/repo",
|
||||
nil,
|
||||
)
|
||||
|
||||
assert.Contains(t, builder.New([]string{"git", "status"}).GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0")
|
||||
|
|
@ -34,8 +35,27 @@ func TestGitCmdObjBuilderPinsCommandsToRepoDir(t *testing.T) {
|
|||
utils.NewDummyLog(),
|
||||
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
|
||||
"/path/to/repo",
|
||||
nil,
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// A repo whose git dir isn't in its worktree can't be found by running a
|
||||
// command there, so the builder has to tell every command where it is; see
|
||||
// RepoPaths.GitLocationEnvVars. The process env says the same thing, but only
|
||||
// for the repo lazygit is in right now, which isn't necessarily this one.
|
||||
func TestGitCmdObjBuilderPinsCommandsToGitLocation(t *testing.T) {
|
||||
builder := NewGitCmdObjBuilder(
|
||||
utils.NewDummyLog(),
|
||||
oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)),
|
||||
"/path/to/worktree",
|
||||
[]string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"},
|
||||
)
|
||||
|
||||
assert.Subset(t, builder.New([]string{"git", "status"}).GetEnvVars(),
|
||||
[]string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"})
|
||||
assert.Subset(t, builder.NewShell("git status", "").GetEnvVars(),
|
||||
[]string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(contextSize)
|
||||
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,23 +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 {
|
||||
contextSize := self.UserConfig().Git.DiffContextSize
|
||||
extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize)
|
||||
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", contextSize)).
|
||||
Arg(fmt.Sprintf("--color=%s", self.diffRendererConfigManager.GetColorArg())).
|
||||
Arg(diffArgs...).
|
||||
Dir(self.repoPaths.worktreePath).
|
||||
ToArgv(),
|
||||
|
|
@ -41,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
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ package git_commands
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type FileLoaderConfig interface {
|
||||
|
|
@ -88,27 +88,66 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
|
|||
files = append(files, file)
|
||||
}
|
||||
|
||||
// Go through the files to see if any of these files are actually worktrees
|
||||
// so that we can render them correctly
|
||||
worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath())
|
||||
for _, file := range files {
|
||||
for _, worktreePath := range worktreePaths {
|
||||
absFilePath, err := filepath.Abs(file.Path)
|
||||
if err != nil {
|
||||
self.Log.Error(err)
|
||||
continue
|
||||
}
|
||||
if absFilePath == worktreePath {
|
||||
file.IsWorktree = true
|
||||
// `git status` renders this worktree as a folder with a trailing slash but we'll represent it as a singular worktree
|
||||
// If we include the slash, it will be rendered as a folder with a null file inside.
|
||||
file.Path = strings.TrimSuffix(file.Path, "/")
|
||||
break
|
||||
}
|
||||
self.setConflictMarkerSizes(files)
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// Looks up how long the conflict markers in the conflicted files are. We ask
|
||||
// git for all of them at once, because spawning a process per file would be
|
||||
// painfully slow when hundreds of files are conflicted (especially on Windows).
|
||||
func (self *FileLoader) setConflictMarkerSizes(files []*models.File) {
|
||||
conflictedFiles := lo.Filter(files, func(file *models.File, _ int) bool {
|
||||
return file.HasInlineMergeConflicts
|
||||
})
|
||||
if len(conflictedFiles) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
paths := lo.Map(conflictedFiles, func(file *models.File, _ int) string {
|
||||
return file.Path
|
||||
})
|
||||
|
||||
markerSizes, err := self.getConflictMarkerSizes(paths)
|
||||
if err != nil {
|
||||
self.Log.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, file := range conflictedFiles {
|
||||
file.ConflictMarkerSize = markerSizes[file.Path]
|
||||
}
|
||||
}
|
||||
|
||||
func (self *FileLoader) getConflictMarkerSizes(paths []string) (map[string]int, error) {
|
||||
cmdArgs := NewGitCmd("check-attr").
|
||||
Arg("-z").
|
||||
Arg("--stdin").
|
||||
Arg("conflict-marker-size").
|
||||
ToArgv()
|
||||
|
||||
// -z makes git both read the paths and write its output NUL-separated, so
|
||||
// that paths containing newlines don't throw us off.
|
||||
output, _, err := self.cmd.New(cmdArgs).
|
||||
SetStdin(strings.Join(paths, "\x00")).
|
||||
DontLog().
|
||||
RunWithOutputs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
markerSizes := map[string]int{}
|
||||
fields := strings.Split(output, "\x00")
|
||||
// Each path yields a path/attribute/value triple; the value is either a
|
||||
// number or something like "unspecified", in which case we leave the marker
|
||||
// size at 0 to say that git's default applies.
|
||||
for i := 0; i+2 < len(fields); i += 3 {
|
||||
if markerSize, err := strconv.Atoi(fields[i+2]); err == nil && markerSize > 0 {
|
||||
markerSizes[fields[i]] = markerSize
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
return markerSizes, nil
|
||||
}
|
||||
|
||||
type FileDiff struct {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ func TestFileGetStatusFiles(t *testing.T) {
|
|||
ExpectGitArgs([]string{"diff", "--numstat", "-z", "HEAD"},
|
||||
"4\t1\tfile1.txt\x001\t0\tfile2.txt\x002\t2\tfile3.txt\x000\t2\tfile4.txt\x002\t2\tfile5.txt",
|
||||
nil,
|
||||
).
|
||||
ExpectGitArgs([]string{"check-attr", "-z", "--stdin", "conflict-marker-size"},
|
||||
"file5.txt\x00conflict-marker-size\x00unspecified\x00",
|
||||
nil,
|
||||
),
|
||||
showNumstatInFilesView: true,
|
||||
expectedFiles: []*models.File{
|
||||
|
|
@ -112,6 +116,58 @@ func TestFileGetStatusFiles(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "Conflicted files with a conflict-marker-size attribute",
|
||||
similarityThreshold: 50,
|
||||
runner: oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"},
|
||||
"UU file1.txt\x00UU file2.txt\x00UU file3.txt\x00 M file4.txt",
|
||||
nil,
|
||||
).
|
||||
ExpectGitArgs([]string{"check-attr", "-z", "--stdin", "conflict-marker-size"},
|
||||
"file1.txt\x00conflict-marker-size\x0032\x00"+
|
||||
"file2.txt\x00conflict-marker-size\x00unspecified\x00"+
|
||||
"file3.txt\x00conflict-marker-size\x00nonsense\x00",
|
||||
nil,
|
||||
),
|
||||
expectedFiles: []*models.File{
|
||||
{
|
||||
Path: "file1.txt",
|
||||
HasUnstagedChanges: true,
|
||||
Tracked: true,
|
||||
HasMergeConflicts: true,
|
||||
HasInlineMergeConflicts: true,
|
||||
ConflictMarkerSize: 32,
|
||||
DisplayString: "UU file1.txt",
|
||||
ShortStatus: "UU",
|
||||
},
|
||||
{
|
||||
Path: "file2.txt",
|
||||
HasUnstagedChanges: true,
|
||||
Tracked: true,
|
||||
HasMergeConflicts: true,
|
||||
HasInlineMergeConflicts: true,
|
||||
DisplayString: "UU file2.txt",
|
||||
ShortStatus: "UU",
|
||||
},
|
||||
{
|
||||
Path: "file3.txt",
|
||||
HasUnstagedChanges: true,
|
||||
Tracked: true,
|
||||
HasMergeConflicts: true,
|
||||
HasInlineMergeConflicts: true,
|
||||
DisplayString: "UU file3.txt",
|
||||
ShortStatus: "UU",
|
||||
},
|
||||
{
|
||||
Path: "file4.txt",
|
||||
HasUnstagedChanges: true,
|
||||
Tracked: true,
|
||||
DisplayString: " M file4.txt",
|
||||
ShortStatus: " M",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
testName: "File with new line char",
|
||||
similarityThreshold: 50,
|
||||
|
|
|
|||
|
|
@ -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,12 @@
|
|||
package git_commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/jesseduffield/lazygit/pkg/env"
|
||||
)
|
||||
|
||||
// OptionalLocksEnvVar is the name of the environment variable that tells git
|
||||
|
|
@ -16,6 +19,15 @@ import (
|
|||
// that opts back in is the foreground files refresh; see FileLoader.gitStatus.
|
||||
const OptionalLocksEnvVar = "GIT_OPTIONAL_LOCKS"
|
||||
|
||||
// forOtherRepo prepares a command that operates on a repo other than the one
|
||||
// we have open — a submodule, or another worktree. GIT_DIR and GIT_WORK_TREE
|
||||
// say where our repo is, and every command we run inherits them, so a command
|
||||
// pointed at a different repo would be resolved against ours instead: `git -C
|
||||
// <submodule> log` would silently log the superproject's commits.
|
||||
func forOtherRepo(cmdObj *oscommands.CmdObj) *oscommands.CmdObj {
|
||||
return cmdObj.RemoveEnvVar(env.GitDirEnvVar).RemoveEnvVar(env.GitWorkTreeEnvVar)
|
||||
}
|
||||
|
||||
// convenience struct for building git commands. Especially useful when
|
||||
// including conditional args
|
||||
type GitCommandBuilder struct {
|
||||
|
|
@ -111,6 +123,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...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -85,12 +87,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 +136,15 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
|
|||
number
|
||||
url
|
||||
isDraft
|
||||
headRef {
|
||||
target {
|
||||
... on Commit {
|
||||
statusCheckRollup {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
headRepositoryOwner {
|
||||
login
|
||||
}
|
||||
|
|
@ -138,9 +162,51 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
|
|||
return queryString, variables
|
||||
}
|
||||
|
||||
// GetAuthToken returns the token to authenticate against the given host with,
|
||||
// or an empty string if there is none.
|
||||
//
|
||||
// The token has to come from gh itself rather than from an in-process lookup
|
||||
// with go-gh: that reads gh's config file once per process and answers from
|
||||
// that snapshot ever after, whereas gh rewrites the file whenever the active
|
||||
// account changes, and keeps the active account's token either there or in the
|
||||
// system keyring. Under a long-running lazygit the snapshot therefore drifts
|
||||
// out of date, leaving us with a token for an account that is no longer active,
|
||||
// or with no token at all.
|
||||
func (self *GitHubCommands) GetAuthToken(host string) string {
|
||||
token, _ := auth.TokenForHost(host)
|
||||
return token
|
||||
ghExe := ghExecutable()
|
||||
if ghExe == "" {
|
||||
// Without gh installed, the environment variables and config file that
|
||||
// gh would have consulted are still worth a look.
|
||||
token, _ := auth.TokenFromEnvOrConfig(host)
|
||||
return token
|
||||
}
|
||||
|
||||
cmdArgs := []string{ghExe, "auth", "token", "--hostname", host}
|
||||
output, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs()
|
||||
if err != nil {
|
||||
// Not being logged in to this host is a normal state rather than
|
||||
// something to report; the runner logs gh's stderr for the rest.
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(output)
|
||||
}
|
||||
|
||||
// ghExecutable returns the path of the gh binary, or an empty string if it
|
||||
// isn't installed.
|
||||
func ghExecutable() string {
|
||||
if ghExe := os.Getenv("GH_PATH"); ghExe != "" {
|
||||
return ghExe
|
||||
}
|
||||
|
||||
// A gh found in the current directory rather than on PATH comes back as
|
||||
// exec.ErrDot, which we treat as not having found one at all.
|
||||
ghExe, err := exec.LookPath("gh")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return ghExe
|
||||
}
|
||||
|
||||
// FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo
|
||||
|
|
@ -231,9 +297,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
|
||||
}
|
||||
|
||||
|
|
@ -246,6 +315,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
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
package git_commands
|
||||
|
||||
import (
|
||||
ioFs "io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/env"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
type RepoPaths struct {
|
||||
|
|
@ -19,10 +18,12 @@ type RepoPaths struct {
|
|||
repoGitDirPath string
|
||||
repoName string
|
||||
isBareRepo bool
|
||||
gitLocationEnvVars []string
|
||||
}
|
||||
|
||||
// Path to the current worktree. If we're in the main worktree, this will
|
||||
// be the same as RepoPath()
|
||||
// be the same as RepoPath(). It is empty for a bare repo, which has no
|
||||
// worktree at all.
|
||||
func (self *RepoPaths) WorktreePath() string {
|
||||
return self.worktreePath
|
||||
}
|
||||
|
|
@ -53,10 +54,33 @@ func (self *RepoPaths) RepoName() string {
|
|||
return self.repoName
|
||||
}
|
||||
|
||||
// Whether we found no worktree, so that there is nothing for lazygit to show.
|
||||
// Note that this isn't quite git's core.bare: a repo that calls itself non-bare
|
||||
// but whose worktree we couldn't find counts as bare for us too. Concretely,
|
||||
// this is true when we're in
|
||||
//
|
||||
// - a genuinely bare repo;
|
||||
// - the git dir of a linked worktree (.git/worktrees/x), whose worktree is
|
||||
// recorded but not somewhere we look;
|
||||
// - a repo that keeps its worktree somewhere only GIT_WORK_TREE knows, such
|
||||
// as a vcsh-style dotfiles repo that hasn't been given core.worktree.
|
||||
//
|
||||
// The .git dir of an ordinary repo is not one of them: GetRepoPathsForDir
|
||||
// notices the worktree holding it and hands back that repo instead.
|
||||
func (self *RepoPaths) IsBareRepo() bool {
|
||||
return self.isBareRepo
|
||||
}
|
||||
|
||||
// The environment that tells git where this repo is, as "NAME=value" entries.
|
||||
// It is empty for the vast majority of repos, which git finds for itself by
|
||||
// looking for a .git in the directory a command runs in. It is only non-empty
|
||||
// when that doesn't work — when the git dir lives somewhere else entirely,
|
||||
// because of core.worktree or --work-tree — and then every command addressing
|
||||
// the repo has to carry it.
|
||||
func (self *RepoPaths) GitLocationEnvVars() []string {
|
||||
return self.gitLocationEnvVars
|
||||
}
|
||||
|
||||
// Returns the repo paths for a typical repo
|
||||
func MockRepoPaths(currentPath string) *RepoPaths {
|
||||
return &RepoPaths{
|
||||
|
|
@ -84,26 +108,76 @@ func GetRepoPathsForDir(
|
|||
dir string,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
) (*RepoPaths, error) {
|
||||
gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree")
|
||||
repoPaths, err := repoPathsForDir(dir, cmd)
|
||||
if err != nil || !repoPaths.IsBareRepo() {
|
||||
return repoPaths, err
|
||||
}
|
||||
|
||||
// We're in a git dir rather than in a working tree, which usually just means
|
||||
// somebody ran lazygit in the .git of an ordinary repo. git's convention is
|
||||
// that a git dir called .git belongs to the directory holding it, so look
|
||||
// there: if that is a working tree, it is the repo we were asked about, and
|
||||
// there's no reason to make the user go up a directory and try again.
|
||||
//
|
||||
// The git dirs that aren't called .git keep the paths we have. A linked
|
||||
// worktree's (.git/worktrees/x) and a submodule's (.git/modules/x) do have a
|
||||
// working tree, but only the directory holding a .git tells us where, so we
|
||||
// would be guessing. A bare repo's has none to find.
|
||||
if filepath.Base(repoPaths.WorktreeGitDirPath()) != ".git" {
|
||||
return repoPaths, nil
|
||||
}
|
||||
|
||||
pathsFromWorkTree, err := repoPathsForDir(filepath.Dir(repoPaths.WorktreeGitDirPath()), cmd)
|
||||
if err != nil || pathsFromWorkTree.IsBareRepo() {
|
||||
return repoPaths, nil
|
||||
}
|
||||
return pathsFromWorkTree, nil
|
||||
}
|
||||
|
||||
// repoPathsForDir asks git about the repo at dir, and reports a bare repo when
|
||||
// there is no working tree there. Unlike GetRepoPathsForDir it never looks
|
||||
// anywhere but dir, which is what keeps that one from going round in circles.
|
||||
func repoPathsForDir(
|
||||
dir string,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
) (*RepoPaths, error) {
|
||||
gitDirOutput, err := callGitRevParseWithDir(cmd, dir, "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// --show-toplevel is the only one of these that needs a work tree, and
|
||||
// git makes it fatal when there isn't one. So this may just mean we're in
|
||||
// a repo that has no work tree.
|
||||
return getBareRepoPathsForDir(dir, cmd, err)
|
||||
}
|
||||
|
||||
gitDirResults := strings.Split(utils.NormalizeLinefeeds(gitDirOutput), "\n")
|
||||
worktreePath := gitDirResults[0]
|
||||
worktreeGitDirPath := gitDirResults[1]
|
||||
repoGitDirPath := gitDirResults[2]
|
||||
isBareRepo := gitDirResults[3] == "true"
|
||||
|
||||
// If we're in a submodule, --show-superproject-working-tree will return
|
||||
// a value, meaning gitDirResults will be length 5. In that case
|
||||
// return the worktree path as the repoPath. Otherwise we're in a
|
||||
// normal repo or a worktree so return the parent of the git common
|
||||
// dir (repoGitDirPath)
|
||||
isSubmodule := len(gitDirResults) == 5
|
||||
// A worktree that has the repo's common git dir to itself is the repo's main
|
||||
// worktree, so it is the repoPath. That holds for a submodule as well: its
|
||||
// git dir lives under the superproject's .git/modules, but it is still the
|
||||
// submodule's own common dir.
|
||||
isMainWorktree := worktreeGitDirPath == repoGitDirPath
|
||||
|
||||
// If we're in a submodule, --show-superproject-working-tree will return a
|
||||
// value, meaning gitDirResults will be length 4. That only tells us anything
|
||||
// new for a linked worktree of a submodule, which isMainWorktree misses.
|
||||
isSubmodule := len(gitDirResults) == 4
|
||||
|
||||
// Otherwise we're in a linked worktree, and the repoPath is the repo's main
|
||||
// worktree. git won't tell us where that is: `git worktree list` reports it
|
||||
// as the common git dir with a trailing "/.git" removed, which is this same
|
||||
// derivation. So take the directory holding the common git dir. That is the
|
||||
// main worktree of an ordinary repo, and of a bare one it is the directory
|
||||
// its worktrees live in. It is not the main worktree of a repo that moved
|
||||
// that elsewhere with core.worktree; there we end up naming the git dir's
|
||||
// directory, which means that the repo name we display in the status panel
|
||||
// isn't correct, and we start looking for .lazygit.yml in the wrong place.
|
||||
// Both of those are not severe enough to justify the extra git call to get
|
||||
// the real main worktree, so we accept this for this rather niche use case.
|
||||
var repoPath string
|
||||
if isSubmodule {
|
||||
if isMainWorktree || isSubmodule {
|
||||
repoPath = worktreePath
|
||||
} else {
|
||||
repoPath = filepath.Dir(repoGitDirPath)
|
||||
|
|
@ -116,62 +190,113 @@ func GetRepoPathsForDir(
|
|||
repoPath: repoPath,
|
||||
repoGitDirPath: repoGitDirPath,
|
||||
repoName: repoName,
|
||||
isBareRepo: isBareRepo,
|
||||
isBareRepo: false,
|
||||
gitLocationEnvVars: gitLocationEnvVars(cmd, worktreePath, worktreeGitDirPath),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// gitLocationEnvVars works out whether git can find the repo by itself when a
|
||||
// command runs in its worktree, and if it can't, returns the environment that
|
||||
// tells git where it is. See RepoPaths.GitLocationEnvVars.
|
||||
func gitLocationEnvVars(
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
worktreePath string,
|
||||
worktreeGitDirPath string,
|
||||
) []string {
|
||||
// The ordinary repo, where the git dir sits in the worktree. Both paths are
|
||||
// git's own answers from the same invocation, so they are spelled alike and
|
||||
// comparing them is safe.
|
||||
if worktreeGitDirPath == filepath.Join(worktreePath, ".git") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// A linked worktree or a submodule instead has a .git file naming its git
|
||||
// dir, and git follows that just as happily. We could read the file, but the
|
||||
// path in it may well name the same directory differently than git did
|
||||
// above, so ask git to resolve it — from the worktree and nothing else.
|
||||
discoveredGitDirPath, err := callGitRevParseInOtherRepo(cmd, worktreePath, "--absolute-git-dir")
|
||||
if err == nil && discoveredGitDirPath == worktreeGitDirPath {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []string{
|
||||
env.GitDirEnvVar + "=" + worktreeGitDirPath,
|
||||
env.GitWorkTreeEnvVar + "=" + worktreePath,
|
||||
}
|
||||
}
|
||||
|
||||
// getBareRepoPathsForDir is the fallback for when we couldn't ask git for the
|
||||
// work tree. Everything but --show-toplevel works fine without one, so if the
|
||||
// remaining queries succeed we are in a bare repo, and we return what we know
|
||||
// about it with an empty worktreePath. If they fail too we simply aren't in a
|
||||
// repo, and the caller's original error says so better than ours would.
|
||||
func getBareRepoPathsForDir(
|
||||
dir string,
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
errWithWorktree error,
|
||||
) (*RepoPaths, error) {
|
||||
output, err := callGitRevParseWithDir(cmd, dir, "--absolute-git-dir", "--git-common-dir")
|
||||
if err != nil {
|
||||
return nil, errWithWorktree
|
||||
}
|
||||
|
||||
results := strings.Split(utils.NormalizeLinefeeds(output), "\n")
|
||||
repoGitDirPath := results[1]
|
||||
// A bare repo has no worktree, and so no repo path in the sense the caller
|
||||
// with a worktree means. It doesn't matter much what we say here, because
|
||||
// nobody reads it: whoever is handed a bare repo either offers to open a
|
||||
// recent one instead (app.setupRepo) or is turned away by NewGitCommand. The
|
||||
// directory holding the git dir is the nearest thing there is to a repo
|
||||
// path.
|
||||
repoPath := filepath.Dir(repoGitDirPath)
|
||||
|
||||
return &RepoPaths{
|
||||
worktreePath: "",
|
||||
worktreeGitDirPath: results[0],
|
||||
repoPath: repoPath,
|
||||
repoGitDirPath: repoGitDirPath,
|
||||
repoName: filepath.Base(repoPath),
|
||||
isBareRepo: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Asks git about the repo at dir. This is how we find our own repo, so it has
|
||||
// to be answered the way git itself would answer it there, GIT_DIR and
|
||||
// GIT_WORK_TREE included.
|
||||
func callGitRevParseWithDir(
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
dir string,
|
||||
gitRevArgs ...string,
|
||||
) (string, error) {
|
||||
return runGitRevParse(newGitRevParseCmd(cmd, dir, gitRevArgs...))
|
||||
}
|
||||
|
||||
// Asks git about a repo that isn't the one we have open; see forOtherRepo.
|
||||
func callGitRevParseInOtherRepo(
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
dir string,
|
||||
gitRevArgs ...string,
|
||||
) (string, error) {
|
||||
return runGitRevParse(forOtherRepo(newGitRevParseCmd(cmd, dir, gitRevArgs...)))
|
||||
}
|
||||
|
||||
func newGitRevParseCmd(
|
||||
cmd oscommands.ICmdObjBuilder,
|
||||
dir string,
|
||||
gitRevArgs ...string,
|
||||
) *oscommands.CmdObj {
|
||||
gitRevParse := NewGitCmd("rev-parse").Arg("--path-format=absolute").Arg(gitRevArgs...)
|
||||
if dir != "" {
|
||||
gitRevParse.Dir(dir)
|
||||
}
|
||||
|
||||
gitCmd := cmd.New(gitRevParse.ToArgv()).DontLog()
|
||||
return cmd.New(gitRevParse.ToArgv()).DontLog()
|
||||
}
|
||||
|
||||
func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) {
|
||||
res, err := gitCmd.RunWithOutput()
|
||||
if err != nil {
|
||||
return "", errors.Errorf("'%s' failed: %v", gitCmd.ToString(), err)
|
||||
}
|
||||
return strings.TrimSpace(res), nil
|
||||
}
|
||||
|
||||
// Returns the paths of linked worktrees
|
||||
func linkedWortkreePaths(fs afero.Fs, repoGitDirPath string) []string {
|
||||
result := []string{}
|
||||
// For each directory in this path we're going to cat the `gitdir` file and append its contents to our result
|
||||
// That file points us to the `.git` file in the worktree.
|
||||
worktreeGitDirsPath := filepath.Join(repoGitDirPath, "worktrees")
|
||||
|
||||
// ensure the directory exists
|
||||
_, err := fs.Stat(worktreeGitDirsPath)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
_ = afero.Walk(fs, worktreeGitDirsPath, func(currPath string, info ioFs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
gitDirPath := filepath.Join(currPath, "gitdir")
|
||||
gitDirBytes, err := afero.ReadFile(fs, gitDirPath)
|
||||
if err != nil {
|
||||
// ignoring error
|
||||
return nil
|
||||
}
|
||||
trimmedGitDir := strings.TrimSpace(string(gitDirBytes))
|
||||
// removing the .git part
|
||||
worktreeDir := filepath.Dir(trimmedGitDir)
|
||||
result = append(result, worktreeDir)
|
||||
return nil
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,8 +38,6 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
`C:\path\to\repo\.git`,
|
||||
// --git-common-dir
|
||||
`C:\path\to\repo\.git`,
|
||||
// --is-bare-repository
|
||||
"false",
|
||||
// --show-superproject-working-tree
|
||||
}, []string{
|
||||
// --show-toplevel
|
||||
|
|
@ -48,12 +46,10 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
"/path/to/repo/.git",
|
||||
// --git-common-dir
|
||||
"/path/to/repo/.git",
|
||||
// --is-bare-repository
|
||||
"false",
|
||||
// --show-superproject-working-tree
|
||||
})
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
|
||||
strings.Join(mockOutput, "\n"),
|
||||
nil)
|
||||
},
|
||||
|
|
@ -76,53 +72,147 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
Err: nil,
|
||||
},
|
||||
{
|
||||
// git refuses to answer --show-toplevel when there's no work tree, so
|
||||
// we have to ask a second time without it.
|
||||
Name: "bare repo",
|
||||
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
|
||||
// setup for main worktree
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
|
||||
"",
|
||||
errors.New("fatal: this operation must be run in a work tree"))
|
||||
|
||||
mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{
|
||||
// --show-toplevel
|
||||
`C:\path\to\repo`,
|
||||
// --git-dir
|
||||
`C:\path\to\bare_repo\bare.git`,
|
||||
`C:\path\to\project\bare.git`,
|
||||
// --git-common-dir
|
||||
`C:\path\to\bare_repo\bare.git`,
|
||||
// --is-bare-repository
|
||||
`true`,
|
||||
// --show-superproject-working-tree
|
||||
`C:\path\to\project\bare.git`,
|
||||
}, []string{
|
||||
// --show-toplevel
|
||||
"/path/to/repo",
|
||||
// --git-dir
|
||||
"/path/to/bare_repo/bare.git",
|
||||
"/path/to/project/bare.git",
|
||||
// --git-common-dir
|
||||
"/path/to/bare_repo/bare.git",
|
||||
// --is-bare-repository
|
||||
"true",
|
||||
// --show-superproject-working-tree
|
||||
"/path/to/project/bare.git",
|
||||
})
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
|
||||
append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"),
|
||||
strings.Join(mockOutput, "\n"),
|
||||
nil)
|
||||
},
|
||||
Path: "/path/to/repo",
|
||||
Path: "/path/to/project",
|
||||
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
|
||||
worktreePath: `C:\path\to\repo`,
|
||||
worktreeGitDirPath: `C:\path\to\bare_repo\bare.git`,
|
||||
repoPath: `C:\path\to\bare_repo`,
|
||||
repoGitDirPath: `C:\path\to\bare_repo\bare.git`,
|
||||
repoName: `bare_repo`,
|
||||
worktreePath: "",
|
||||
worktreeGitDirPath: `C:\path\to\project\bare.git`,
|
||||
repoPath: `C:\path\to\project`,
|
||||
repoGitDirPath: `C:\path\to\project\bare.git`,
|
||||
repoName: `project`,
|
||||
isBareRepo: true,
|
||||
}, &RepoPaths{
|
||||
worktreePath: "/path/to/repo",
|
||||
worktreeGitDirPath: "/path/to/bare_repo/bare.git",
|
||||
repoPath: "/path/to/bare_repo",
|
||||
repoGitDirPath: "/path/to/bare_repo/bare.git",
|
||||
repoName: "bare_repo",
|
||||
worktreePath: "",
|
||||
worktreeGitDirPath: "/path/to/project/bare.git",
|
||||
repoPath: "/path/to/project",
|
||||
repoGitDirPath: "/path/to/project/bare.git",
|
||||
repoName: "project",
|
||||
isBareRepo: true,
|
||||
}),
|
||||
Err: nil,
|
||||
},
|
||||
{
|
||||
// Standing in the .git dir of an ordinary repo: git refuses to name a
|
||||
// work tree, but the directory holding the .git is one, so we open the
|
||||
// repo from there.
|
||||
Name: "in a repo's .git dir",
|
||||
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
|
||||
gitDir := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\.git`, "/path/to/repo/.git")
|
||||
worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo`, "/path/to/repo")
|
||||
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
|
||||
"",
|
||||
errors.New("fatal: this operation must be run in a work tree"))
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"),
|
||||
strings.Join([]string{gitDir, gitDir}, "\n"),
|
||||
nil)
|
||||
|
||||
// asking again from the directory holding the .git
|
||||
runner.ExpectGitArgs(
|
||||
append(append([]string{"-C", worktree}, getRevParseArgs()...), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
|
||||
strings.Join([]string{worktree, gitDir, gitDir}, "\n"),
|
||||
nil)
|
||||
},
|
||||
Path: "/path/to/repo/.git",
|
||||
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
|
||||
worktreePath: `C:\path\to\repo`,
|
||||
worktreeGitDirPath: `C:\path\to\repo\.git`,
|
||||
repoPath: `C:\path\to\repo`,
|
||||
repoGitDirPath: `C:\path\to\repo\.git`,
|
||||
repoName: `repo`,
|
||||
isBareRepo: false,
|
||||
}, &RepoPaths{
|
||||
worktreePath: "/path/to/repo",
|
||||
worktreeGitDirPath: "/path/to/repo/.git",
|
||||
repoPath: "/path/to/repo",
|
||||
repoGitDirPath: "/path/to/repo/.git",
|
||||
repoName: "repo",
|
||||
isBareRepo: false,
|
||||
}),
|
||||
Err: nil,
|
||||
},
|
||||
{
|
||||
// A repo whose work tree lives somewhere else entirely, as set up by
|
||||
// core.worktree or by --work-tree. We're in the main worktree, but the
|
||||
// git dir is not inside it.
|
||||
Name: "repo with a separate work tree",
|
||||
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
|
||||
mockOutput := lo.Ternary(runtime.GOOS == "windows", []string{
|
||||
// --show-toplevel
|
||||
`C:\path\to\worktree`,
|
||||
// --git-dir
|
||||
`C:\path\to\repo\.git`,
|
||||
// --git-common-dir
|
||||
`C:\path\to\repo\.git`,
|
||||
// --show-superproject-working-tree
|
||||
}, []string{
|
||||
// --show-toplevel
|
||||
"/path/to/worktree",
|
||||
// --git-dir
|
||||
"/path/to/repo/.git",
|
||||
// --git-common-dir
|
||||
"/path/to/repo/.git",
|
||||
// --show-superproject-working-tree
|
||||
})
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
|
||||
strings.Join(mockOutput, "\n"),
|
||||
nil)
|
||||
|
||||
// asking git to find the repo from the work tree gets us nowhere,
|
||||
// because there is no .git there
|
||||
worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\worktree`, "/path/to/worktree")
|
||||
runner.ExpectGitArgs(
|
||||
append([]string{"-C", worktree}, append(getRevParseArgs(), "--absolute-git-dir")...),
|
||||
"",
|
||||
errors.New("fatal: not a git repository (or any of the parent directories): .git"))
|
||||
},
|
||||
Path: "/path/to/repo",
|
||||
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
|
||||
worktreePath: `C:\path\to\worktree`,
|
||||
worktreeGitDirPath: `C:\path\to\repo\.git`,
|
||||
repoPath: `C:\path\to\worktree`,
|
||||
repoGitDirPath: `C:\path\to\repo\.git`,
|
||||
repoName: `worktree`,
|
||||
isBareRepo: false,
|
||||
gitLocationEnvVars: []string{`GIT_DIR=C:\path\to\repo\.git`, `GIT_WORK_TREE=C:\path\to\worktree`},
|
||||
}, &RepoPaths{
|
||||
worktreePath: "/path/to/worktree",
|
||||
worktreeGitDirPath: "/path/to/repo/.git",
|
||||
repoPath: "/path/to/worktree",
|
||||
repoGitDirPath: "/path/to/repo/.git",
|
||||
repoName: "worktree",
|
||||
isBareRepo: false,
|
||||
gitLocationEnvVars: []string{"GIT_DIR=/path/to/repo/.git", "GIT_WORK_TREE=/path/to/worktree"},
|
||||
}),
|
||||
Err: nil,
|
||||
},
|
||||
{
|
||||
Name: "submodule",
|
||||
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
|
||||
|
|
@ -133,8 +223,6 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
`C:\path\to\repo\.git\modules\submodule1`,
|
||||
// --git-common-dir
|
||||
`C:\path\to\repo\.git\modules\submodule1`,
|
||||
// --is-bare-repository
|
||||
`false`,
|
||||
// --show-superproject-working-tree
|
||||
`C:\path\to\repo`,
|
||||
}, []string{
|
||||
|
|
@ -144,15 +232,22 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
"/path/to/repo/.git/modules/submodule1",
|
||||
// --git-common-dir
|
||||
"/path/to/repo/.git/modules/submodule1",
|
||||
// --is-bare-repository
|
||||
"false",
|
||||
// --show-superproject-working-tree
|
||||
"/path/to/repo",
|
||||
})
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
|
||||
strings.Join(mockOutput, "\n"),
|
||||
nil)
|
||||
|
||||
// git finds the submodule's git dir from its work tree, via the
|
||||
// .git file there
|
||||
worktree := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\submodule1`, "/path/to/repo/submodule1")
|
||||
gitDir := lo.Ternary(runtime.GOOS == "windows", `C:\path\to\repo\.git\modules\submodule1`, "/path/to/repo/.git/modules/submodule1")
|
||||
runner.ExpectGitArgs(
|
||||
append([]string{"-C", worktree}, append(getRevParseArgs(), "--absolute-git-dir")...),
|
||||
gitDir,
|
||||
nil)
|
||||
},
|
||||
Path: "/path/to/repo/submodule1",
|
||||
Expected: lo.Ternary(runtime.GOOS == "windows", &RepoPaths{
|
||||
|
|
@ -176,7 +271,12 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
Name: "git rev-parse returns an error",
|
||||
BeforeFunc: func(runner *oscommands.FakeCmdObjRunner, getRevParseArgs argFn) {
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--is-bare-repository", "--show-superproject-working-tree"),
|
||||
append(getRevParseArgs(), "--show-toplevel", "--absolute-git-dir", "--git-common-dir", "--show-superproject-working-tree"),
|
||||
"",
|
||||
errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git"))
|
||||
// we're not in a repo at all, so asking about a bare one fails too
|
||||
runner.ExpectGitArgs(
|
||||
append(getRevParseArgs(), "--absolute-git-dir", "--git-common-dir"),
|
||||
"",
|
||||
errors.New("fatal: invalid gitfile format: /path/to/repo/worktree2/.git"))
|
||||
},
|
||||
|
|
@ -184,7 +284,7 @@ func TestGetRepoPaths(t *testing.T) {
|
|||
Expected: nil,
|
||||
Err: func(getRevParseArgs argFn) error {
|
||||
args := strings.Join(getRevParseArgs(), " ")
|
||||
return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --is-bare-repository --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args)
|
||||
return fmt.Errorf("'git %v --show-toplevel --absolute-git-dir --git-common-dir --show-superproject-working-tree' failed: fatal: invalid gitfile format: /path/to/repo/worktree2/.git", args)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,21 +81,13 @@ func (self *StashCommands) Hash(index int) (string, error) {
|
|||
}
|
||||
|
||||
func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj {
|
||||
contextSize := self.UserConfig().Git.DiffContextSize
|
||||
extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize)
|
||||
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", contextSize)).
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string
|
|||
Config("log.showsignature=false").
|
||||
ToArgv()
|
||||
|
||||
summary, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
|
||||
summary, err := forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
|
||||
return strings.TrimSpace(summary), err
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +167,7 @@ func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string
|
|||
// caller then stages the submodule to record the resolution.
|
||||
func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) error {
|
||||
cmdArgs := NewGitCmd("checkout").Dir(path).Arg(sha).ToArgv()
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
|
||||
}
|
||||
|
||||
// ConflictSideLog returns a oneline log, run inside the submodule, of the commits
|
||||
|
|
@ -179,7 +179,7 @@ func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSi
|
|||
Arg("--oneline", "--color=always", otherSide+".."+side).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
|
||||
return forOtherRepo(self.cmd.New(cmdArgs)).DontLog().RunWithOutput()
|
||||
}
|
||||
|
||||
func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
|
||||
|
|
@ -195,20 +195,15 @@ func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error {
|
|||
Arg("--include-untracked").
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
|
||||
}
|
||||
|
||||
func (self *SubmoduleCommands) Reset(submodule *models.SubmoduleConfig) error {
|
||||
parentDir := ""
|
||||
if submodule.ParentModule != nil {
|
||||
parentDir = submodule.ParentModule.FullPath()
|
||||
}
|
||||
cmdArgs := NewGitCmd("submodule").
|
||||
Arg("update", "--init", "--force", "--", submodule.Path).
|
||||
DirIf(parentDir != "", parentDir).
|
||||
ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
return self.runInParentModule(submodule, self.cmd.New(cmdArgs))
|
||||
}
|
||||
|
||||
func (self *SubmoduleCommands) UpdateAll() error {
|
||||
|
|
@ -225,9 +220,16 @@ func (self *SubmoduleCommands) UpdateAll() error {
|
|||
// 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).
|
||||
//
|
||||
// That directory is relative, so it resolves against the process working
|
||||
// directory rather than against the repo directory the command builder
|
||||
// otherwise pins commands to. Only foreground commands the user issued end up
|
||||
// here, and lazygit won't switch repos while one of those is in flight, so the
|
||||
// two are the same directory; don't call this from background work, where they
|
||||
// need not be.
|
||||
func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error {
|
||||
if submodule.ParentModule != nil {
|
||||
cmdObj.SetWd(submodule.ParentModule.FullPath())
|
||||
forOtherRepo(cmdObj.SetWd(submodule.ParentModule.FullPath()))
|
||||
}
|
||||
return cmdObj.Run()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
package git_commands
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/oscommands"
|
||||
"github.com/jesseduffield/lazygit/pkg/env"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
@ -80,6 +83,27 @@ func TestSubmoduleCheckoutConflictCommit(t *testing.T) {
|
|||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
// A command that runs inside a submodule mustn't inherit the GIT_DIR and
|
||||
// GIT_WORK_TREE that say where the superproject is; git would answer it from
|
||||
// there instead, and the answer would look perfectly plausible.
|
||||
func TestSubmoduleCommandDoesntUseOurGitLocation(t *testing.T) {
|
||||
t.Setenv(env.GitDirEnvVar, "/path/to/repo/.git")
|
||||
t.Setenv(env.GitWorkTreeEnvVar, "/path/to/repo")
|
||||
|
||||
runner := oscommands.NewFakeRunner(t).
|
||||
ExpectFunc("has neither GIT_DIR nor GIT_WORK_TREE", func(cmdObj *oscommands.CmdObj) bool {
|
||||
return lo.NoneBy(cmdObj.GetEnvVars(), func(envVar string) bool {
|
||||
return strings.HasPrefix(envVar, env.GitDirEnvVar+"=") ||
|
||||
strings.HasPrefix(envVar, env.GitWorkTreeEnvVar+"=")
|
||||
})
|
||||
}, "bbbbbbb the subject\n", nil)
|
||||
instance := buildSubmoduleCommands(commonDeps{runner: runner})
|
||||
|
||||
_, err := instance.GetCommitSummary("mysub", "bbbbbbb")
|
||||
assert.NoError(t, err)
|
||||
runner.CheckForMissingCalls()
|
||||
}
|
||||
|
||||
func TestSubmoduleConflictSideLog(t *testing.T) {
|
||||
runner := oscommands.NewFakeRunner(t).
|
||||
ExpectGitArgs([]string{"-C", "mysub", "log", "--oneline", "--color=always", "ccccccc..bbbbbbb"}, "bbbbbbb left\n", nil)
|
||||
|
|
|
|||
|
|
@ -385,45 +385,31 @@ func (self *WorkingTreeCommands) Exclude(filename string) error {
|
|||
// WorktreeFileDiff returns the diff of a file
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiff(file *models.File, plain bool, cached bool) string {
|
||||
// for now we assume an error means the file was deleted
|
||||
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, nil).RunWithOutput()
|
||||
s, _ := self.WorktreeFileDiffCmdObj(file, plain, cached, file.Names()).RunWithOutput()
|
||||
return s
|
||||
}
|
||||
|
||||
// WorktreeFileDiffCmdObj returns a command object for diffing a file or directory
|
||||
// in the working tree. When pathOverrides is non-empty, those paths are used instead of
|
||||
// the node's path (used to diff only filtered/visible files within a directory).
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, pathOverrides []string) *oscommands.CmdObj {
|
||||
colorArg := self.pagerConfig.GetColorArg()
|
||||
// WorktreeFileDiffCmdObj returns a command object for diffing the given paths
|
||||
// in the working tree. node is the item they belong to; all it decides is
|
||||
// whether git has to compare against /dev/null, which is the case for a file
|
||||
// that isn't in the index yet.
|
||||
func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain bool, cached bool, paths []string) *oscommands.CmdObj {
|
||||
colorArg := self.diffRendererConfigManager.GetColorArg()
|
||||
if plain {
|
||||
colorArg = "never"
|
||||
}
|
||||
|
||||
contextSize := self.UserConfig().Git.DiffContextSize
|
||||
prevPath := node.GetPreviousPath()
|
||||
noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile()
|
||||
extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize)
|
||||
useExtDiff := extDiffCmd != "" && !plain
|
||||
useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain
|
||||
|
||||
paths := pathOverrides
|
||||
if len(paths) == 0 {
|
||||
paths = []string{node.GetPath()}
|
||||
}
|
||||
|
||||
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("--").
|
||||
ArgIf(noIndex, "/dev/null").
|
||||
Arg(paths...).
|
||||
ArgIf(prevPath != "", prevPath).
|
||||
Dir(self.repoPaths.worktreePath).
|
||||
ToArgv()
|
||||
|
||||
|
|
@ -443,29 +429,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(contextSize)
|
||||
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),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ func (self *WorktreeCommands) Delete(worktreePath string, force bool) error {
|
|||
func (self *WorktreeCommands) Detach(worktreePath string) error {
|
||||
cmdArgs := NewGitCmd("checkout").Arg("--detach").GitDir(filepath.Join(worktreePath, ".git")).ToArgv()
|
||||
|
||||
return self.cmd.New(cmdArgs).Run()
|
||||
return forOtherRepo(self.cmd.New(cmdArgs)).Run()
|
||||
}
|
||||
|
||||
func WorktreeForBranch(branch *models.Branch, worktrees []*models.Worktree) (*models.Worktree, bool) {
|
||||
|
|
|
|||
|
|
@ -22,9 +22,6 @@ func NewWorktreeLoader(gitCommon *GitCommon) *WorktreeLoader {
|
|||
}
|
||||
|
||||
func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
|
||||
currentRepoPath := self.repoPaths.RepoPath()
|
||||
worktreePath := self.repoPaths.WorktreePath()
|
||||
|
||||
cmdArgs := NewGitCmd("worktree").Arg("list", "--porcelain").ToArgv()
|
||||
worktreesOutput, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput()
|
||||
if err != nil {
|
||||
|
|
@ -54,17 +51,13 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
|
|||
|
||||
if strings.HasPrefix(splitLine, "worktree ") {
|
||||
path := strings.SplitN(splitLine, " ", 2)[1]
|
||||
isMain := path == currentRepoPath
|
||||
isCurrent := path == worktreePath
|
||||
isPathMissing := self.pathExists(path)
|
||||
|
||||
current = &models.Worktree{
|
||||
IsMain: isMain,
|
||||
IsCurrent: isCurrent,
|
||||
IsPathMissing: isPathMissing,
|
||||
IsPathMissing: self.pathExists(path),
|
||||
Path: path,
|
||||
// we defer populating GitDir until a loop below so that
|
||||
// we can parallelize the calls to git rev-parse
|
||||
// we can parallelize the calls to git rev-parse, and
|
||||
// IsMain/IsCurrent because they are derived from GitDir
|
||||
GitDir: "",
|
||||
}
|
||||
} else if strings.HasPrefix(splitLine, "HEAD ") {
|
||||
|
|
@ -84,7 +77,7 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
|
|||
if worktree.IsPathMissing {
|
||||
return
|
||||
}
|
||||
gitDir, err := callGitRevParseWithDir(self.cmd, worktree.Path, "--absolute-git-dir")
|
||||
gitDir, err := callGitRevParseInOtherRepo(self.cmd, worktree.Path, "--absolute-git-dir")
|
||||
if err != nil {
|
||||
self.Log.Warnf("Could not find git dir for worktree %s: %v", worktree.Path, err)
|
||||
return
|
||||
|
|
@ -95,6 +88,23 @@ func (self *WorktreeLoader) GetWorktrees() ([]*models.Worktree, error) {
|
|||
}
|
||||
wg.Wait()
|
||||
|
||||
// Identify the current and the main worktree by their git dir rather than by
|
||||
// their path: `git worktree list` reports the main worktree as the common
|
||||
// git dir with a trailing "/.git" removed, which is the working tree only
|
||||
// when the git dir sits inside it. In a submodule, a bare repo or a repo
|
||||
// using core.worktree it doesn't, and comparing paths then matches nothing.
|
||||
// A worktree whose directory is gone has no git dir to compare, so there we
|
||||
// have nothing better than its path.
|
||||
for _, worktree := range worktrees {
|
||||
if worktree.GitDir != "" {
|
||||
worktree.IsCurrent = worktree.GitDir == self.repoPaths.WorktreeGitDirPath()
|
||||
worktree.IsMain = worktree.GitDir == self.repoPaths.RepoGitDirPath()
|
||||
} else {
|
||||
worktree.IsCurrent = worktree.Path == self.repoPaths.WorktreePath()
|
||||
worktree.IsMain = worktree.Path == self.repoPaths.RepoPath()
|
||||
}
|
||||
}
|
||||
|
||||
names := getUniqueNamesFromPaths(lo.Map(worktrees, func(worktree *models.Worktree, _ int) string {
|
||||
return worktree.Path
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -23,8 +23,10 @@ func TestGetWorktrees(t *testing.T) {
|
|||
{
|
||||
testName: "Single worktree (main)",
|
||||
repoPaths: &RepoPaths{
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo",
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo",
|
||||
repoGitDirPath: "/path/to/repo/.git",
|
||||
worktreeGitDirPath: "/path/to/repo/.git",
|
||||
},
|
||||
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
|
||||
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
|
||||
|
|
@ -55,8 +57,10 @@ branch refs/heads/mybranch
|
|||
{
|
||||
testName: "Multiple worktrees (main + linked)",
|
||||
repoPaths: &RepoPaths{
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo",
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo",
|
||||
repoGitDirPath: "/path/to/repo/.git",
|
||||
worktreeGitDirPath: "/path/to/repo/.git",
|
||||
},
|
||||
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
|
||||
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
|
||||
|
|
@ -106,8 +110,10 @@ branch refs/heads/mybranch-worktree
|
|||
{
|
||||
testName: "Worktree missing path",
|
||||
repoPaths: &RepoPaths{
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo",
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo",
|
||||
repoGitDirPath: "/path/to/repo/.git",
|
||||
worktreeGitDirPath: "/path/to/repo/.git",
|
||||
},
|
||||
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
|
||||
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
|
||||
|
|
@ -136,8 +142,10 @@ branch refs/heads/missingbranch
|
|||
{
|
||||
testName: "In linked worktree",
|
||||
repoPaths: &RepoPaths{
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo-worktree",
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo-worktree",
|
||||
repoGitDirPath: "/path/to/repo/.git",
|
||||
worktreeGitDirPath: "/path/to/repo/.git/worktrees/repo-worktree",
|
||||
},
|
||||
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
|
||||
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
|
||||
|
|
@ -184,11 +192,51 @@ branch refs/heads/mybranch-worktree
|
|||
},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
testName: "In a submodule",
|
||||
repoPaths: &RepoPaths{
|
||||
repoPath: "/path/to/repo/mysubmodule",
|
||||
worktreePath: "/path/to/repo/mysubmodule",
|
||||
repoGitDirPath: "/path/to/repo/.git/modules/mysubmodule",
|
||||
worktreeGitDirPath: "/path/to/repo/.git/modules/mysubmodule",
|
||||
},
|
||||
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
|
||||
// A submodule's git dir doesn't live inside its working tree, and
|
||||
// `git worktree list` reports the git dir rather than the working
|
||||
// tree it belongs to.
|
||||
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
|
||||
`worktree /path/to/repo/.git/modules/mysubmodule
|
||||
HEAD d85cc9d281fa6ae1665c68365fc70e75e82a042d
|
||||
branch refs/heads/mybranch
|
||||
`,
|
||||
nil)
|
||||
|
||||
gitArgs := append(append([]string{"-C", "/path/to/repo/.git/modules/mysubmodule"}, getRevParseArgs()...), "--absolute-git-dir")
|
||||
runner.ExpectGitArgs(gitArgs, "/path/to/repo/.git/modules/mysubmodule", nil)
|
||||
|
||||
_ = fs.MkdirAll("/path/to/repo/.git/modules/mysubmodule", 0o755)
|
||||
},
|
||||
expectedWorktrees: []*models.Worktree{
|
||||
{
|
||||
IsMain: true,
|
||||
IsCurrent: true,
|
||||
Path: "/path/to/repo/.git/modules/mysubmodule",
|
||||
IsPathMissing: false,
|
||||
GitDir: "/path/to/repo/.git/modules/mysubmodule",
|
||||
Branch: "mybranch",
|
||||
Head: "d85cc9d281fa6ae1665c68365fc70e75e82a042d",
|
||||
Name: "mysubmodule",
|
||||
},
|
||||
},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
testName: "Detached HEAD worktree",
|
||||
repoPaths: &RepoPaths{
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo",
|
||||
repoPath: "/path/to/repo",
|
||||
worktreePath: "/path/to/repo",
|
||||
repoGitDirPath: "/path/to/repo/.git",
|
||||
worktreeGitDirPath: "/path/to/repo/.git",
|
||||
},
|
||||
before: func(runner *oscommands.FakeCmdObjRunner, fs afero.Fs, getRevParseArgs argFn) {
|
||||
runner.ExpectGitArgs([]string{"worktree", "list", "--porcelain"},
|
||||
|
|
|
|||
|
|
@ -18,10 +18,14 @@ type File struct {
|
|||
Deleted bool
|
||||
HasMergeConflicts bool
|
||||
HasInlineMergeConflicts bool
|
||||
DisplayString string
|
||||
ShortStatus string // e.g. 'AD', ' A', 'M ', '??'
|
||||
LinesDeleted int
|
||||
LinesAdded int
|
||||
// How long the conflict markers in this file are, taken from its
|
||||
// conflict-marker-size gitattribute; 0 if it doesn't have that attribute. We
|
||||
// only look this up for files that have inline merge conflicts.
|
||||
ConflictMarkerSize int
|
||||
DisplayString string
|
||||
ShortStatus string // e.g. 'AD', ' A', 'M ', '??'
|
||||
LinesDeleted int
|
||||
LinesAdded int
|
||||
|
||||
// If true, this must be a worktree folder
|
||||
IsWorktree bool
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
package oscommands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/samber/lo"
|
||||
|
|
@ -228,37 +228,47 @@ func (c *OSCommand) PipeCommands(cmdObjs ...*CmdObj) error {
|
|||
// keeping this here in case I adapt this code for some other purpose in the future
|
||||
// cmds[len(cmds)-1].Stdout = os.Stdout
|
||||
|
||||
finalErrors := []string{}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(len(cmds))
|
||||
|
||||
for _, cmd := range cmds {
|
||||
go utils.Safe(func() {
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
c.Log.Error(err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
c.Log.Error(err)
|
||||
}
|
||||
|
||||
if b, err := io.ReadAll(stderr); err == nil {
|
||||
if len(b) > 0 {
|
||||
finalErrors = append(finalErrors, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
c.Log.Error(err)
|
||||
}
|
||||
|
||||
wg.Done()
|
||||
})
|
||||
stderrs := make([]bytes.Buffer, len(cmds))
|
||||
for i := range cmds {
|
||||
cmds[i].Stderr = &stderrs[i]
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// Start every command before waiting for any of them: waiting for a command
|
||||
// closes our end of the pipe that feeds the next one, and a command that
|
||||
// hasn't been started by then would inherit a closed stdin.
|
||||
started := 0
|
||||
var startErr error
|
||||
for _, cmd := range cmds {
|
||||
if err := cmd.Start(); err != nil {
|
||||
startErr = err
|
||||
break
|
||||
}
|
||||
|
||||
started++
|
||||
}
|
||||
|
||||
finalErrors := []string{}
|
||||
|
||||
if startErr != nil {
|
||||
c.Log.Error(startErr)
|
||||
finalErrors = append(finalErrors, startErr.Error())
|
||||
|
||||
// Without the rest of the pipeline to drain them, the commands we did
|
||||
// start could block forever writing to a full pipe.
|
||||
for _, cmd := range cmds[:started] {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
for i, cmd := range cmds[:started] {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
c.Log.Error(err)
|
||||
}
|
||||
|
||||
if stderrs[i].Len() > 0 {
|
||||
finalErrors = append(finalErrors, stderrs[i].String())
|
||||
}
|
||||
}
|
||||
|
||||
if len(finalErrors) > 0 {
|
||||
return errors.New(strings.Join(finalErrors, "\n"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -64,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.
|
||||
//
|
||||
|
|
@ -71,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
|
||||
|
|
@ -83,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
|
||||
}
|
||||
|
|
@ -101,7 +204,8 @@ func (p *winPty) Close() error {
|
|||
// slave closes on child exit, but ConPTY keeps the pipe alive until we call
|
||||
// ClosePseudoConsole explicitly. Without doing that on child exit, the
|
||||
// scanner in pkg/tasks.NewCmdTask would block forever on the next read and
|
||||
// the post-content view never gets cleared (FlushStaleCells never fires).
|
||||
// the render would never reach its end of input, so the new content would
|
||||
// never be swapped in.
|
||||
func startWaiter(proc *os.Process, p *winPty) func() error {
|
||||
done := make(chan struct{})
|
||||
var waitErr error
|
||||
|
|
@ -123,6 +227,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
|
||||
|
|
@ -148,9 +298,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
|
||||
//
|
||||
// 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)
|
||||
if err = windows.CreatePseudoConsole(size, inRead, outWrite, 0, &hpc); err != nil {
|
||||
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)
|
||||
|
|
@ -160,9 +325,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 {
|
||||
|
|
@ -221,7 +417,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,
|
||||
|
|
@ -230,6 +426,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
|
||||
|
|
@ -245,6 +457,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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package oscommands
|
|||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
|
@ -23,3 +24,82 @@ func TestStartPtyWithZeroSize(t *testing.T) {
|
|||
_ = 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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ func (self *Hunk) lineCount() int {
|
|||
|
||||
// Returns all lines in the hunk, including the header line
|
||||
func (self *Hunk) allLines() []*PatchLine {
|
||||
lines := []*PatchLine{{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}}
|
||||
lines := make([]*PatchLine, 1, 1+len(self.bodyLines))
|
||||
lines[0] = &PatchLine{Content: self.formatHeaderLine(), Kind: HUNK_HEADER}
|
||||
lines = append(lines, self.bodyLines...)
|
||||
return lines
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
159
pkg/config/diff_renderer_config_manager.go
Normal file
159
pkg/config/diff_renderer_config_manager.go
Normal file
|
|
@ -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]
|
||||
}
|
||||
96
pkg/config/diff_renderer_config_manager_test.go
Normal file
96
pkg/config/diff_renderer_config_manager_test.go
Normal file
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -50,13 +50,13 @@ type editPreset struct {
|
|||
suspend func() bool
|
||||
}
|
||||
|
||||
func returnBool(a bool) func() bool { return (func() bool { return a }) }
|
||||
func returnBool(a bool) func() bool { return func() bool { return a } }
|
||||
|
||||
// IF YOU ADD A PRESET TO THIS FUNCTION YOU MUST UPDATE THE `Supported presets` SECTION OF docs/Config.md
|
||||
func getPreset(shell string, osConfig *OSConfig, guessDefaultEditor func() string) *editPreset {
|
||||
var nvimRemoteEditTemplate, nvimRemoteEditAtLineTemplate, nvimRemoteOpenDirInEditorTemplate string
|
||||
// By default fish doesn't have SHELL variable set, but it does have FISH_VERSION since Nov 2012.
|
||||
if (strings.HasSuffix(shell, "fish")) || (os.Getenv("FISH_VERSION") != "") {
|
||||
if strings.HasSuffix(shell, "fish") || (os.Getenv("FISH_VERSION") != "") {
|
||||
nvimRemoteEditTemplate = `begin; if test -z "$NVIM"; nvim -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; end; end`
|
||||
nvimRemoteEditAtLineTemplate = `begin; if test -z "$NVIM"; nvim +{{line}} -- {{filename}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{filename}}; nvim --server "$NVIM" --remote-send ":{{line}}<CR>"; end; end`
|
||||
nvimRemoteOpenDirInEditorTemplate = `begin; if test -z "$NVIM"; nvim -- {{dir}}; else; nvim --server "$NVIM" --remote-send "q"; nvim --server "$NVIM" --remote-tab {{dir}}; end; end`
|
||||
|
|
|
|||
124
pkg/config/github_pull_request_cache.go
Normal file
124
pkg/config/github_pull_request_cache.go
Normal file
|
|
@ -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
|
||||
}
|
||||
141
pkg/config/github_pull_request_cache_test.go
Normal file
141
pkg/config/github_pull_request_cache_test.go
Normal file
|
|
@ -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,131 +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(diffContext uint64) string {
|
||||
currentPagerConfig := self.currentPagerConfig()
|
||||
if currentPagerConfig == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
templateValues := map[string]string{
|
||||
"diffContext": strconv.Itoa(int(diffContext)),
|
||||
}
|
||||
|
||||
return utils.ResolvePlaceholderString(currentPagerConfig.ExternalDiffCommand, templateValues)
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
44
pkg/env/env.go
vendored
44
pkg/env/env.go
vendored
|
|
@ -2,27 +2,59 @@ package env
|
|||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This package encapsulates accessing/mutating the ENV of the program.
|
||||
|
||||
// The variables with which git can be told where a repo is, rather than having
|
||||
// it find out from the working directory.
|
||||
const (
|
||||
GitDirEnvVar = "GIT_DIR"
|
||||
GitWorkTreeEnvVar = "GIT_WORK_TREE"
|
||||
)
|
||||
|
||||
func GetGitDirEnv() string {
|
||||
return os.Getenv("GIT_DIR")
|
||||
return os.Getenv(GitDirEnvVar)
|
||||
}
|
||||
|
||||
func SetGitDirEnv(value string) {
|
||||
os.Setenv("GIT_DIR", value)
|
||||
os.Setenv(GitDirEnvVar, value)
|
||||
}
|
||||
|
||||
func GetWorkTreeEnv() string {
|
||||
return os.Getenv("GIT_WORK_TREE")
|
||||
return os.Getenv(GitWorkTreeEnvVar)
|
||||
}
|
||||
|
||||
func SetWorkTreeEnv(value string) {
|
||||
os.Setenv("GIT_WORK_TREE", value)
|
||||
os.Setenv(GitWorkTreeEnvVar, value)
|
||||
}
|
||||
|
||||
func UnsetGitLocationEnvVars() {
|
||||
_ = os.Unsetenv("GIT_DIR")
|
||||
_ = os.Unsetenv("GIT_WORK_TREE")
|
||||
_ = os.Unsetenv(GitDirEnvVar)
|
||||
_ = os.Unsetenv(GitWorkTreeEnvVar)
|
||||
}
|
||||
|
||||
// GetGitLocationEnvVars returns the location variables that are set, as
|
||||
// "NAME=value" entries.
|
||||
func GetGitLocationEnvVars() []string {
|
||||
envVars := []string{}
|
||||
for _, name := range []string{GitDirEnvVar, GitWorkTreeEnvVar} {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
envVars = append(envVars, name+"="+value)
|
||||
}
|
||||
}
|
||||
return envVars
|
||||
}
|
||||
|
||||
// SetGitLocationEnvVars sets the location variables from "NAME=value" entries,
|
||||
// clearing both first so that only what is given remains. Passing nothing is
|
||||
// how you say the repo is to be found from the working directory.
|
||||
func SetGitLocationEnvVars(envVars []string) {
|
||||
UnsetGitLocationEnvVars()
|
||||
for _, envVar := range envVars {
|
||||
if name, value, ok := strings.Cut(envVar, "="); ok {
|
||||
os.Setenv(name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
34
pkg/gocui/double_click_test.go
Normal file
34
pkg/gocui/double_click_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
158
pkg/gocui/gui.go
158
pkg/gocui/gui.go
|
|
@ -38,6 +38,11 @@ var (
|
|||
|
||||
// ErrKeybindingNotHandled is returned when a keybinding is not handled, so that the key can be dispatched further
|
||||
ErrKeybindingNotHandled = standardErrors.New("keybinding not handled")
|
||||
|
||||
// ErrLoopExited is returned by OnUIThreadAndWait when MainLoop has already
|
||||
// returned. Nothing dequeues user events after that, so the callback it was
|
||||
// asked to run on the main goroutine never will be.
|
||||
ErrLoopExited = standardErrors.New("main loop exited")
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -208,13 +213,20 @@ type Gui struct {
|
|||
// busy?" doesn't count itself.
|
||||
currentTask Task
|
||||
|
||||
lastHoverView *View
|
||||
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
|
||||
|
||||
// focused says whether the terminal we're running in has focus, as far as
|
||||
// its focus reports tell us (see IsFocused). Written by the event loop,
|
||||
// readable from anywhere, so it's atomic.
|
||||
focused atomic.Bool
|
||||
|
||||
// blockInputCount, when greater than zero, withholds keyboard input from
|
||||
// the handlers: key events are buffered into bufferedKeyEvents and replayed
|
||||
// once the count drops back to zero, while mouse clicks and hover are
|
||||
|
|
@ -299,6 +311,12 @@ func NewGui(opts NewGuiOpts) (*Gui, error) {
|
|||
// runs during startup, before we reach MainLoop.
|
||||
g.uiThreadID.Store(goid.Get())
|
||||
|
||||
// Assume we start out focused: a terminal that supports focus reports sends
|
||||
// one for the state it is already in when we turn reporting on in MainLoop,
|
||||
// and passing that on as a change would have the app react to a change that
|
||||
// never happened.
|
||||
g.focused.Store(true)
|
||||
|
||||
return g, nil
|
||||
}
|
||||
|
||||
|
|
@ -597,6 +615,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
|
||||
}
|
||||
|
|
@ -666,6 +690,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
|
||||
}
|
||||
|
|
@ -867,36 +909,50 @@ func (g *Gui) EndBlockingEvents() error {
|
|||
}
|
||||
|
||||
// OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the
|
||||
// caller until f has run, returning f's error. Use it to read UI-thread-owned
|
||||
// state (the model, contexts) from a worker without racing the UI thread.
|
||||
// caller until f has run. Use it to read UI-thread-owned state (the model,
|
||||
// contexts) from a worker without racing the UI thread.
|
||||
//
|
||||
// The error it returns is the wait's own, never f's: it reports that f was not
|
||||
// run at all, which happens when the main loop has exited (ErrLoopExited). f
|
||||
// doesn't report an error because what callers want on the UI thread — reading
|
||||
// and mutating state — doesn't fail.
|
||||
//
|
||||
// It must be called from a worker goroutine, never from the UI thread itself:
|
||||
// the UI thread would block waiting for a callback only it can run, which
|
||||
// deadlocks. Callers arrange this by construction (see the refresh helper's
|
||||
// RefreshFromWorker); a debug-only assertion there guards against getting it
|
||||
// wrong.
|
||||
func (g *Gui) OnUIThreadAndWait(f func() error) error {
|
||||
func (g *Gui) OnUIThreadAndWait(f func()) error {
|
||||
return g.onUIThreadAndWait(f, false)
|
||||
}
|
||||
|
||||
// Like OnUIThreadAndWait, but the enqueued work belongs to a background routine,
|
||||
// so it doesn't count towards the program being busy (see UpdateBackground).
|
||||
func (g *Gui) OnUIThreadAndWaitBackground(f func() error) error {
|
||||
func (g *Gui) OnUIThreadAndWaitBackground(f func()) error {
|
||||
return g.onUIThreadAndWait(f, true)
|
||||
}
|
||||
|
||||
func (g *Gui) onUIThreadAndWait(f func() error, background bool) error {
|
||||
func (g *Gui) onUIThreadAndWait(f func(), background bool) error {
|
||||
enqueue := g.Update
|
||||
if background {
|
||||
enqueue = g.UpdateBackground
|
||||
}
|
||||
|
||||
result := make(chan error, 1)
|
||||
ran := make(chan struct{})
|
||||
enqueue(func(*Gui) error {
|
||||
result <- f()
|
||||
f()
|
||||
close(ran)
|
||||
return nil
|
||||
})
|
||||
return <-result
|
||||
|
||||
select {
|
||||
case <-ran:
|
||||
return nil
|
||||
case <-g.loopExited:
|
||||
// The queue we just enqueued onto is no longer being served, so waiting
|
||||
// on `ran` here would mean waiting for the rest of the process's life.
|
||||
return ErrLoopExited
|
||||
}
|
||||
}
|
||||
|
||||
// Calls a function in a goroutine. Handles panics gracefully and tracks
|
||||
|
|
@ -1220,7 +1276,7 @@ func calcScrollbarRune(
|
|||
|
||||
func calcRealScrollbarStartEnd(v *View) (bool, int, int) {
|
||||
height := v.InnerHeight()
|
||||
fullHeight := v.ViewLinesHeight() - v.scrollMargin()
|
||||
fullHeight := v.scrollbarContentHeight() - v.scrollMargin()
|
||||
|
||||
if v.CanScrollPastBottom {
|
||||
fullHeight += height
|
||||
|
|
@ -1402,7 +1458,7 @@ func (g *Gui) drawTitle(v *View, fgColor, bgColor Attribute) error {
|
|||
currentBgColor = v.BgColor
|
||||
}
|
||||
|
||||
if i >= currentTabStart && i <= currentTabEnd {
|
||||
if i >= currentTabStart && i <= currentTabEnd && g.IsFocused() {
|
||||
currentFgColor = v.SelFgColor
|
||||
if v != g.currentView {
|
||||
currentFgColor &= ^AttrBold
|
||||
|
|
@ -1441,7 +1497,7 @@ func (g *Gui) drawSubtitle(v *View, fgColor, bgColor Attribute) error {
|
|||
|
||||
// drawListFooter draws the footer of a list view, showing something like '1 of 10'
|
||||
func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error {
|
||||
if len(v.lines) == 0 {
|
||||
if len(v.buf.lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1583,11 +1639,11 @@ func (g *Gui) draw(v *View) error {
|
|||
Screen.HideCursor()
|
||||
}
|
||||
|
||||
v.draw()
|
||||
v.draw(g.IsFocused())
|
||||
|
||||
if v.Frame {
|
||||
var fgColor, bgColor, frameColor Attribute
|
||||
if g.Highlight && v == g.currentView {
|
||||
if g.Highlight && v == g.currentView && g.IsFocused() {
|
||||
fgColor = g.SelFgColor
|
||||
bgColor = g.SelBgColor
|
||||
frameColor = g.SelFrameColor
|
||||
|
|
@ -1658,9 +1714,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
|
||||
|
|
@ -1674,13 +1747,13 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
|||
if newY < 0 {
|
||||
newY = 0
|
||||
newCy = -v.oy
|
||||
} else if newY >= len(v.lines) {
|
||||
newY = len(v.lines) - 1
|
||||
} else if newY >= len(v.buf.lines) {
|
||||
newY = len(v.buf.lines) - 1
|
||||
newCy = newY - v.oy
|
||||
}
|
||||
|
||||
visibleLineWidth := 0
|
||||
for _, c := range v.lines[newY].cells {
|
||||
for _, c := range v.buf.lines[newY].cells {
|
||||
visibleLineWidth += c.width
|
||||
}
|
||||
if visibleLineWidth < newX {
|
||||
|
|
@ -1690,10 +1763,8 @@ func (g *Gui) onKey(ev *GocuiEvent) error {
|
|||
}
|
||||
|
||||
if ev.Key.KeyName() == MouseLeft && (ev.Key.Mod()&ModMotion) == 0 && !v.Editable && g.openHyperlink != nil {
|
||||
if newY >= 0 && newY <= len(v.viewLines)-1 && newX >= 0 && newX <= len(v.viewLines[newY].line)-1 {
|
||||
if link := v.viewLines[newY].line[newX].hyperlink; link != "" {
|
||||
return g.openHyperlink(link, v.name)
|
||||
}
|
||||
if link := v.hyperlinkAt(newX, newY); link != "" {
|
||||
return g.openHyperlink(link, v.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1704,9 +1775,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)
|
||||
|
||||
|
|
@ -1718,7 +1800,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)
|
||||
|
||||
|
|
@ -1773,6 +1857,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,
|
||||
|
|
@ -1928,7 +2018,21 @@ func (g *Gui) execKeybinding(v *View, kb *keybinding) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// IsFocused reports whether the terminal we're running in has focus. Terminals
|
||||
// that don't report focus at all leave this true for good.
|
||||
func (g *Gui) IsFocused() bool {
|
||||
return g.focused.Load()
|
||||
}
|
||||
|
||||
func (g *Gui) onFocus(ev *GocuiEvent) error {
|
||||
// Terminals report their focus state when we turn focus reporting on, and
|
||||
// some report it again when their window is activated, so only pass on the
|
||||
// reports that actually change it.
|
||||
if ev.Focused == g.focused.Load() {
|
||||
return nil
|
||||
}
|
||||
g.focused.Store(ev.Focused)
|
||||
|
||||
if g.focusHandler != nil {
|
||||
return g.focusHandler(ev.Focused)
|
||||
}
|
||||
|
|
|
|||
216
pkg/gocui/mouse_capture_test.go
Normal file
216
pkg/gocui/mouse_capture_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -202,7 +202,6 @@ const (
|
|||
|
||||
var (
|
||||
lastMouseKey tcell.ButtonMask = tcell.ButtonNone
|
||||
lastMouseMod tcell.ModMask = tcell.ModNone
|
||||
dragState = NOT_DRAGGING
|
||||
lastX = 0
|
||||
lastY = 0
|
||||
|
|
@ -301,15 +300,15 @@ func (g *Gui) pollEvent() GocuiEvent {
|
|||
if g.playRecording {
|
||||
select {
|
||||
case ev := <-g.replayedEvents.Keys:
|
||||
tev = (ev).toTcellEvent()
|
||||
tev = ev.toTcellEvent()
|
||||
task = ev.task
|
||||
case ev := <-g.replayedEvents.Resizes:
|
||||
tev = (ev).toTcellEvent()
|
||||
tev = ev.toTcellEvent()
|
||||
case ev := <-g.replayedEvents.MouseEvents:
|
||||
tev = (ev).toTcellEvent()
|
||||
tev = ev.toTcellEvent()
|
||||
task = ev.task
|
||||
case ev := <-g.replayedEvents.FocusEvents:
|
||||
tev = (ev).toTcellEvent()
|
||||
tev = ev.toTcellEvent()
|
||||
task = ev.task
|
||||
}
|
||||
} else {
|
||||
|
|
@ -366,9 +365,11 @@ func gocuiEventFromTcellEvent(tev tcell.Event) 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
|
||||
|
|
@ -386,6 +387,7 @@ func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent {
|
|||
switch tev.Buttons() {
|
||||
case tcell.ButtonNone:
|
||||
if lastMouseKey != tcell.ButtonNone {
|
||||
buttonReleased = true
|
||||
switch lastMouseKey {
|
||||
case tcell.ButtonPrimary:
|
||||
dragState = NOT_DRAGGING
|
||||
|
|
@ -393,14 +395,13 @@ func gocuiEventFromTcellEvent(tev tcell.Event) 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{
|
||||
|
|
@ -410,9 +411,23 @@ func gocuiEventFromTcellEvent(tev tcell.Event) 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
|
||||
|
|
|
|||
57
pkg/gocui/tcell_driver_test.go
Normal file
57
pkg/gocui/tcell_driver_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
43
pkg/gocui/ui_thread_test.go
Normal file
43
pkg/gocui/ui_thread_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package gocui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// errStillWaiting stands in for the result of a wait that hasn't produced one.
|
||||
var errStillWaiting = errors.New("still waiting")
|
||||
|
||||
// resultOrTimeout reports what a wait returned, or errStillWaiting if it hasn't
|
||||
// returned by the time we give up on it.
|
||||
func resultOrTimeout(result chan error) error {
|
||||
select {
|
||||
case err := <-result:
|
||||
return err
|
||||
case <-time.After(time.Second):
|
||||
return errStillWaiting
|
||||
}
|
||||
}
|
||||
|
||||
// A worker waiting for the UI thread must not be left parked there once the
|
||||
// main loop has stopped: nothing will ever run its callback, and the shutdown
|
||||
// that follows blocks until such workers have finished (see
|
||||
// tasks.ViewBufferManager.Close).
|
||||
func TestOnUIThreadAndWaitGivesUpWhenTheLoopExits(t *testing.T) {
|
||||
g := newTestGui(t)
|
||||
|
||||
// Closing this is what MainLoop returning does. From here on nothing
|
||||
// dequeues user events, so the callback below is never going to run.
|
||||
close(g.loopExited)
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
result <- g.OnUIThreadAndWait(func() {})
|
||||
}()
|
||||
|
||||
err := resultOrTimeout(result)
|
||||
assert.ErrorIs(t, err, ErrLoopExited)
|
||||
}
|
||||
|
|
@ -25,17 +25,51 @@ const (
|
|||
RIGHT = 8 // view is overlapping at right edge
|
||||
)
|
||||
|
||||
// viewBuffer holds a view's content as cells, together with the cursor and
|
||||
// escape-sequence decoder state used to turn incoming bytes into those cells.
|
||||
// A view normally has a single buffer (the one it displays), but bundling this
|
||||
// state lets a re-render build a second, off-screen buffer and swap it in
|
||||
// atomically once the new content is ready, so no reader ever sees a
|
||||
// half-written buffer.
|
||||
type viewBuffer struct {
|
||||
// the view's content: one []cell per unwrapped line
|
||||
lines []lineType
|
||||
|
||||
// write cursor into lines
|
||||
wx, wy int
|
||||
|
||||
// decodes ESC sequences as bytes are written
|
||||
ei *escapeInterpreter
|
||||
|
||||
// If the last character written was a newline, we don't write it but instead
|
||||
// set pendingNewline to true. If more text is written, we write the newline
|
||||
// then. This avoids an extra blank line at the end of the view.
|
||||
pendingNewline bool
|
||||
}
|
||||
|
||||
// A View is a window. It maintains its own internal buffer and cursor
|
||||
// position.
|
||||
type View struct {
|
||||
name string
|
||||
x0, y0, x1, y1 int // left top right bottom
|
||||
ox, oy int // view offsets
|
||||
cx, cy int // cursor position
|
||||
rx, ry int // Read() offsets
|
||||
wx, wy int // Write() offsets
|
||||
lines []lineType // All the data
|
||||
x0, y0, x1, y1 int // left top right bottom
|
||||
ox, oy int // view offsets
|
||||
cx, cy int // cursor position
|
||||
rx, ry int // Read() offsets
|
||||
outMode OutputMode
|
||||
|
||||
// buf bundles the view's cell buffer and the cursor / escape-parser state
|
||||
// used to write into it (see the viewBuffer type). It is the buffer every
|
||||
// reader sees.
|
||||
buf *viewBuffer
|
||||
|
||||
// While non-nil, writes go here instead of buf, so an async re-render can
|
||||
// build its new content without disturbing what readers (draw, clicks,
|
||||
// scrolling, …) see. The task swaps it into buf once it has read enough to
|
||||
// paint (SwapInOffscreenRender), so the displayed content jumps straight
|
||||
// from the previous render to the new one with no half-written frame in
|
||||
// between. nil during normal (non-async) writes.
|
||||
offscreen *viewBuffer
|
||||
|
||||
// The y position of the first line of a range selection.
|
||||
// This is not relative to the view's origin: it is relative to the first line
|
||||
// of the view's content, so you can scroll the view and this value will remain
|
||||
|
|
@ -74,17 +108,20 @@ type View struct {
|
|||
// true and viewLines to nil
|
||||
viewLines []viewLine
|
||||
|
||||
// If the last character written was a newline, we don't write it but
|
||||
// instead set pendingNewline to true. If more text is written, we write the
|
||||
// newline then. This is to avoid having an extra blank at the end of the view.
|
||||
pendingNewline bool
|
||||
// While a re-render is loading new content (see offscreen), the displayed
|
||||
// buffer is only partially filled once we've swapped the off-screen render
|
||||
// in: the task keeps appending lines after the first paint, up to the count
|
||||
// needed for an accurate scrollbar. Sizing the scrollbar from that partial
|
||||
// view-line count would make the thumb shrink and snap back as the rest
|
||||
// streams in. So while a load is in progress we hold the scrollbar's height
|
||||
// at this value — the height the view had when the load began — and let it
|
||||
// grow only if the new content turns out taller. Zero means no load is in
|
||||
// progress and the scrollbar tracks the content directly.
|
||||
scrollbarHeightFloor int
|
||||
|
||||
// writeMutex protects locks the write process
|
||||
writeMutex sync.Mutex
|
||||
|
||||
// ei is used to decode ESC sequences on Write
|
||||
ei *escapeInterpreter
|
||||
|
||||
// Visible specifies whether the view is visible.
|
||||
Visible bool
|
||||
|
||||
|
|
@ -402,7 +439,7 @@ func (v *View) FocusPoint(cx int, cy int, scrollIntoView bool) {
|
|||
|
||||
if scrollIntoView {
|
||||
height := v.InnerHeight()
|
||||
v.oy = calculateNewOrigin(cy, v.oy, lineCount, height)
|
||||
v.SetOriginY(calculateNewOrigin(cy, v.oy, lineCount, height))
|
||||
}
|
||||
|
||||
v.cx = cx
|
||||
|
|
@ -461,7 +498,7 @@ type SearchPosition struct {
|
|||
}
|
||||
|
||||
type viewLine struct {
|
||||
linesX, linesY int // coordinates relative to v.lines
|
||||
linesX, linesY int // coordinates relative to v.buf.lines
|
||||
line []cell
|
||||
|
||||
// Colors used to extend the bg past this wrapped segment's content.
|
||||
|
|
@ -470,7 +507,7 @@ type viewLine struct {
|
|||
trailingFillAttributes *trailingFillAttributes
|
||||
}
|
||||
|
||||
// lineType is one of v.lines: the cells of a source line, plus optional
|
||||
// lineType is one of v.buf.lines: the cells of a source line, plus optional
|
||||
// trailingFillAttributes recording the colors used to extend the bg
|
||||
// past the line's content when the writer emitted '\x1b[K'.
|
||||
type lineType struct {
|
||||
|
|
@ -536,7 +573,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
|
|||
Editor: DefaultEditor,
|
||||
tainted: true,
|
||||
outMode: mode,
|
||||
ei: newEscapeInterpreter(mode),
|
||||
buf: &viewBuffer{ei: newEscapeInterpreter(mode)},
|
||||
searcher: &searcher{},
|
||||
TextArea: &TextArea{},
|
||||
rangeSelectStartY: -1,
|
||||
|
|
@ -547,7 +584,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
|
|||
v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault
|
||||
v.InactiveViewSelBgColor = ColorDefault
|
||||
v.TitleColor, v.FrameColor = ColorDefault, ColorDefault
|
||||
v.ei.screenColMax = v.InnerWidth()
|
||||
v.buf.ei.screenColMax = v.InnerWidth()
|
||||
return v
|
||||
}
|
||||
|
||||
|
|
@ -558,7 +595,7 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View {
|
|||
// content can consult this snapshot instead of reading the view's live
|
||||
// dimensions (which the UI thread mutates during layout).
|
||||
func (v *View) SetContentWidth(width int) {
|
||||
v.ei.screenColMax = width
|
||||
v.buf.ei.screenColMax = width
|
||||
}
|
||||
|
||||
// Dimensions returns the dimensions of the View
|
||||
|
|
@ -616,7 +653,7 @@ func (v *View) Name() string {
|
|||
// setCharacter sets a character (grapheme cluster) at the given point relative to the view. It applies
|
||||
// the specified colors, taking into account if the cell must be highlighted. Also, it checks if the
|
||||
// position is valid.
|
||||
func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute) {
|
||||
func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute, isWindowFocused bool) {
|
||||
maxX, maxY := v.Size()
|
||||
if x < 0 || x >= maxX || y < 0 || y >= maxY {
|
||||
return
|
||||
|
|
@ -642,7 +679,7 @@ func (v *View) setCharacter(x, y int, ch string, fgColor, bgColor Attribute) {
|
|||
fgColor += 8
|
||||
}
|
||||
fgColor = fgColor | AttrBold
|
||||
if v.HighlightInactive {
|
||||
if v.HighlightInactive || !isWindowFocused {
|
||||
bgColor = (bgColor & AttrStyleBits) | v.InactiveViewSelBgColor
|
||||
} else {
|
||||
bgColor = (bgColor & AttrStyleBits) | v.SelBgColor
|
||||
|
|
@ -707,15 +744,8 @@ func (v *View) CursorY() int {
|
|||
// implement Horizontal and Vertical scrolling with just incrementing
|
||||
// or decrementing ox and oy.
|
||||
func (v *View) SetOrigin(x, y int) {
|
||||
if x < 0 {
|
||||
x = 0
|
||||
}
|
||||
if y < 0 {
|
||||
y = 0
|
||||
}
|
||||
|
||||
v.ox = x
|
||||
v.oy = y
|
||||
v.SetOriginX(x)
|
||||
v.SetOriginY(y)
|
||||
}
|
||||
|
||||
func (v *View) SetOriginX(x int) {
|
||||
|
|
@ -755,16 +785,16 @@ func (v *View) SetWritePos(x, y int) {
|
|||
y = 0
|
||||
}
|
||||
|
||||
v.wx = x
|
||||
v.wy = y
|
||||
v.buf.wx = x
|
||||
v.buf.wy = y
|
||||
|
||||
// Changing the write position makes a pending newline obsolete
|
||||
v.pendingNewline = false
|
||||
v.buf.pendingNewline = false
|
||||
}
|
||||
|
||||
// WritePos returns the current write position of the view's internal buffer.
|
||||
func (v *View) WritePos() (x, y int) {
|
||||
return v.wx, v.wy
|
||||
return v.buf.wx, v.buf.wy
|
||||
}
|
||||
|
||||
// SetReadPos sets the read position of the view's internal buffer.
|
||||
|
|
@ -788,56 +818,56 @@ func (v *View) ReadPos() (x, y int) {
|
|||
}
|
||||
|
||||
// makeWriteable creates empty cells if required to make position (x, y) writeable.
|
||||
func (v *View) makeWriteable(x, y int) {
|
||||
func (b *viewBuffer) makeWriteable(x, y int) {
|
||||
// TODO: make this more efficient
|
||||
|
||||
// line `y` must be index-able (that's why `<=`)
|
||||
for len(v.lines) <= y {
|
||||
if cap(v.lines) > len(v.lines) {
|
||||
newLen := cap(v.lines)
|
||||
for len(b.lines) <= y {
|
||||
if cap(b.lines) > len(b.lines) {
|
||||
newLen := cap(b.lines)
|
||||
if newLen > y {
|
||||
newLen = y + 1
|
||||
}
|
||||
v.lines = v.lines[:newLen]
|
||||
b.lines = b.lines[:newLen]
|
||||
} else {
|
||||
v.lines = append(v.lines, lineType{})
|
||||
b.lines = append(b.lines, lineType{})
|
||||
}
|
||||
}
|
||||
// cell `x` need not be index-able (that's why `<`)
|
||||
// append should be used by `lines[y]` user if he wants to write beyond `x`
|
||||
for len(v.lines[y].cells) < x {
|
||||
if cap(v.lines[y].cells) > len(v.lines[y].cells) {
|
||||
newLen := cap(v.lines[y].cells)
|
||||
for len(b.lines[y].cells) < x {
|
||||
if cap(b.lines[y].cells) > len(b.lines[y].cells) {
|
||||
newLen := cap(b.lines[y].cells)
|
||||
if newLen > x {
|
||||
newLen = x
|
||||
}
|
||||
v.lines[y].cells = v.lines[y].cells[:newLen]
|
||||
b.lines[y].cells = b.lines[y].cells[:newLen]
|
||||
} else {
|
||||
v.lines[y].cells = append(v.lines[y].cells, cell{})
|
||||
b.lines[y].cells = append(b.lines[y].cells, cell{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeCells copies []cell to (v.wx, v.wy), and advances v.wx accordingly.
|
||||
// writeCells copies []cell to (b.wx, b.wy), and advances b.wx accordingly.
|
||||
// !!! caller MUST ensure that specified location (x, y) is writeable by calling makeWriteable
|
||||
func (v *View) writeCells(cells []cell) {
|
||||
func (b *viewBuffer) writeCells(cells []cell) {
|
||||
var newLen int
|
||||
// use maximum len available
|
||||
line := v.lines[v.wy].cells[:cap(v.lines[v.wy].cells)]
|
||||
maxCopy := len(line) - v.wx
|
||||
line := b.lines[b.wy].cells[:cap(b.lines[b.wy].cells)]
|
||||
maxCopy := len(line) - b.wx
|
||||
if maxCopy < len(cells) {
|
||||
copy(line[v.wx:], cells[:maxCopy])
|
||||
copy(line[b.wx:], cells[:maxCopy])
|
||||
line = append(line, cells[maxCopy:]...)
|
||||
newLen = len(line)
|
||||
} else { // maxCopy >= len(cells)
|
||||
copy(line[v.wx:], cells)
|
||||
newLen = v.wx + len(cells)
|
||||
if newLen < len(v.lines[v.wy].cells) {
|
||||
newLen = len(v.lines[v.wy].cells)
|
||||
copy(line[b.wx:], cells)
|
||||
newLen = b.wx + len(cells)
|
||||
if newLen < len(b.lines[b.wy].cells) {
|
||||
newLen = len(b.lines[b.wy].cells)
|
||||
}
|
||||
}
|
||||
v.lines[v.wy].cells = line[:newLen]
|
||||
v.wx += len(cells)
|
||||
b.lines[b.wy].cells = line[:newLen]
|
||||
b.wx += len(cells)
|
||||
}
|
||||
|
||||
// Write appends a byte slice into the view's internal buffer. Because
|
||||
|
|
@ -854,36 +884,54 @@ func (v *View) Write(p []byte) (n int, err error) {
|
|||
}
|
||||
|
||||
func (v *View) write(p []byte) {
|
||||
// An async re-render builds into the off-screen buffer (see View.offscreen)
|
||||
// until it swaps in; until then the displayed buffer, and so everything
|
||||
// readers see, is left untouched.
|
||||
if v.offscreen != nil {
|
||||
v.offscreen.write(v, p)
|
||||
return
|
||||
}
|
||||
|
||||
v.tainted = true
|
||||
// write only ever touches lines from v.wy onwards, so any cached wrapping
|
||||
// write only ever touches lines from v.buf.wy onwards, so any cached wrapping
|
||||
// below that stays valid.
|
||||
v.firstDirtyLine = min(v.firstDirtyLine, v.wy)
|
||||
v.firstDirtyLine = min(v.firstDirtyLine, v.buf.wy)
|
||||
v.clearHover()
|
||||
|
||||
v.buf.write(v, p)
|
||||
|
||||
v.updateSearchPositions()
|
||||
}
|
||||
|
||||
// write parses p into cells and appends them to the buffer at its write cursor.
|
||||
// It only touches the buffer; the View wrapper above handles display-side
|
||||
// effects (tainting, hover, search). v supplies render config (Editable, colors,
|
||||
// width, tab width, hyperlink auto-rendering).
|
||||
func (b *viewBuffer) write(v *View, p []byte) {
|
||||
// Fill with empty cells, if writing outside current view buffer
|
||||
v.makeWriteable(v.wx, v.wy)
|
||||
b.makeWriteable(b.wx, b.wy)
|
||||
|
||||
finishLine := func() {
|
||||
v.autoRenderHyperlinksInCurrentLine()
|
||||
b.autoRenderHyperlinksInCurrentLine(v)
|
||||
}
|
||||
|
||||
advanceToNextLine := func() {
|
||||
v.wx = 0
|
||||
v.wy++
|
||||
if v.wy >= len(v.lines) {
|
||||
v.lines = append(v.lines, lineType{})
|
||||
b.wx = 0
|
||||
b.wy++
|
||||
if b.wy >= len(b.lines) {
|
||||
b.lines = append(b.lines, lineType{})
|
||||
}
|
||||
}
|
||||
|
||||
if v.pendingNewline {
|
||||
if b.pendingNewline {
|
||||
advanceToNextLine()
|
||||
v.ei.notifyRowAdvance()
|
||||
v.pendingNewline = false
|
||||
b.ei.notifyRowAdvance()
|
||||
b.pendingNewline = false
|
||||
}
|
||||
|
||||
until := len(p)
|
||||
if !v.Editable && until > 0 && p[until-1] == '\n' {
|
||||
v.pendingNewline = true
|
||||
b.pendingNewline = true
|
||||
until--
|
||||
}
|
||||
|
||||
|
|
@ -899,26 +947,26 @@ func (v *View) write(p []byte) {
|
|||
case characterEquals(chr, '\n') || isCRLF(chr):
|
||||
finishLine()
|
||||
advanceToNextLine()
|
||||
v.ei.notifyRowAdvance()
|
||||
b.ei.notifyRowAdvance()
|
||||
case characterEquals(chr, '\r'):
|
||||
finishLine()
|
||||
v.wx = 0
|
||||
v.ei.notifyColumnReset()
|
||||
b.wx = 0
|
||||
b.ei.notifyColumnReset()
|
||||
default:
|
||||
truncateLine, cells := v.parseInput(chr, width, v.wx, v.wy)
|
||||
if cd, ok := v.ei.instruction.(cursorDown); ok {
|
||||
v.ei.instructionRead()
|
||||
truncateLine, cells := b.parseInput(v, chr, width, b.wx, b.wy)
|
||||
if cd, ok := b.ei.instruction.(cursorDown); ok {
|
||||
b.ei.instructionRead()
|
||||
for range cd.n {
|
||||
v.autoRenderHyperlinksInCurrentLine()
|
||||
b.autoRenderHyperlinksInCurrentLine(v)
|
||||
advanceToNextLine()
|
||||
}
|
||||
}
|
||||
if cells == nil {
|
||||
continue
|
||||
}
|
||||
v.writeCells(cells)
|
||||
b.writeCells(cells)
|
||||
if truncateLine {
|
||||
v.lines[v.wy].cells = v.lines[v.wy].cells[:v.wx]
|
||||
b.lines[b.wy].cells = b.lines[b.wy].cells[:b.wx]
|
||||
}
|
||||
// Soft-wrap tracking. truncateLine is true exactly when the
|
||||
// cells are from \x1b[K filling to end of line — ConPTY
|
||||
|
|
@ -929,18 +977,16 @@ func (v *View) write(p []byte) {
|
|||
for _, c := range cells {
|
||||
totalWidth += c.width
|
||||
}
|
||||
v.ei.notifyCellsWritten(totalWidth)
|
||||
b.ei.notifyCellsWritten(totalWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if v.pendingNewline {
|
||||
if b.pendingNewline {
|
||||
finishLine()
|
||||
} else {
|
||||
v.autoRenderHyperlinksInCurrentLine()
|
||||
b.autoRenderHyperlinksInCurrentLine(v)
|
||||
}
|
||||
|
||||
v.updateSearchPositions()
|
||||
}
|
||||
|
||||
// exported functions use the mutex. Non-exported functions are for internal use
|
||||
|
|
@ -983,12 +1029,12 @@ var lineEndCharacters = map[string]bool{
|
|||
")": true,
|
||||
}
|
||||
|
||||
func (v *View) autoRenderHyperlinksInCurrentLine() {
|
||||
func (b *viewBuffer) autoRenderHyperlinksInCurrentLine(v *View) {
|
||||
if !v.AutoRenderHyperLinks {
|
||||
return
|
||||
}
|
||||
|
||||
line := v.lines[v.wy].cells
|
||||
line := b.lines[b.wy].cells
|
||||
start := 0
|
||||
for {
|
||||
linkStart := findLinkStart(line[start:])
|
||||
|
|
@ -1005,7 +1051,7 @@ func (v *View) autoRenderHyperlinksInCurrentLine() {
|
|||
link.WriteString(line[linkEnd].chr)
|
||||
}
|
||||
for i := linkStart; i < linkEnd; i++ {
|
||||
v.lines[v.wy].cells[i].hyperlink = link.String()
|
||||
b.lines[b.wy].cells[i].hyperlink = link.String()
|
||||
}
|
||||
start = linkEnd
|
||||
}
|
||||
|
|
@ -1014,13 +1060,13 @@ func (v *View) autoRenderHyperlinksInCurrentLine() {
|
|||
// parseInput parses char by char the input written to the View. It returns nil
|
||||
// while processing ESC sequences. Otherwise, it returns a cell slice that
|
||||
// contains the processed data.
|
||||
func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
|
||||
func (b *viewBuffer) parseInput(v *View, ch []byte, width int, x int, _ int) (bool, []cell) {
|
||||
cells := []cell{}
|
||||
truncateLine := false
|
||||
|
||||
isEscape, err := v.ei.parseOne(ch)
|
||||
isEscape, err := b.ei.parseOne(ch)
|
||||
if err != nil {
|
||||
for _, chr := range v.ei.characters() {
|
||||
for _, chr := range b.ei.characters() {
|
||||
c := cell{
|
||||
fgColor: v.FgColor,
|
||||
bgColor: v.BgColor,
|
||||
|
|
@ -1029,28 +1075,28 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
|
|||
}
|
||||
cells = append(cells, c)
|
||||
}
|
||||
v.ei.reset()
|
||||
b.ei.reset()
|
||||
} else {
|
||||
repeatCount := 1
|
||||
if _, ok := v.ei.instruction.(eraseInLineFromCursor); ok {
|
||||
if _, ok := b.ei.instruction.(eraseInLineFromCursor); ok {
|
||||
// Discard any old content past the cursor and record the
|
||||
// fill colors so draw() paints the trailing area with them.
|
||||
// This extends the bg to the right edge in both the
|
||||
// content-fits and content-wraps cases — for the latter,
|
||||
// the metadata is what reaches every wrapped segment past
|
||||
// the last word.
|
||||
v.ei.instructionRead()
|
||||
b.ei.instructionRead()
|
||||
truncateLine = true
|
||||
v.lines[v.wy].trailingFillAttributes = &trailingFillAttributes{
|
||||
fg: v.ei.curFgColor,
|
||||
bg: v.ei.curBgColor,
|
||||
b.lines[b.wy].trailingFillAttributes = &trailingFillAttributes{
|
||||
fg: b.ei.curFgColor,
|
||||
bg: b.ei.curBgColor,
|
||||
}
|
||||
return truncateLine, []cell{}
|
||||
} else if cf, ok := v.ei.instruction.(cursorForward); ok {
|
||||
} else if cf, ok := b.ei.instruction.(cursorForward); ok {
|
||||
// emit `n` space cells under the parser-tracked SGR — used
|
||||
// to materialize ConPTY's compressed runs of spaces (which
|
||||
// it emits as ECH+CUF instead of literal whitespace).
|
||||
v.ei.instructionRead()
|
||||
b.ei.instructionRead()
|
||||
repeatCount = cf.n
|
||||
ch = []byte{' '}
|
||||
width = 1
|
||||
|
|
@ -1068,9 +1114,9 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) {
|
|||
repeatCount = tabWidth - (x % tabWidth)
|
||||
}
|
||||
c := cell{
|
||||
fgColor: v.ei.curFgColor,
|
||||
bgColor: v.ei.curBgColor,
|
||||
hyperlink: v.ei.hyperlink.String(),
|
||||
fgColor: b.ei.curFgColor,
|
||||
bgColor: b.ei.curBgColor,
|
||||
hyperlink: b.ei.hyperlink.String(),
|
||||
chr: string(ch),
|
||||
width: width,
|
||||
}
|
||||
|
|
@ -1098,9 +1144,9 @@ func (v *View) Read(p []byte) (n int, err error) {
|
|||
}
|
||||
v.readBuffer = nil
|
||||
}
|
||||
for v.ry < len(v.lines) {
|
||||
for v.rx < len(v.lines[v.ry].cells) {
|
||||
s := v.lines[v.ry].cells[v.rx].chr
|
||||
for v.ry < len(v.buf.lines) {
|
||||
for v.rx < len(v.buf.lines[v.ry].cells) {
|
||||
s := v.buf.lines[v.ry].cells[v.rx].chr
|
||||
count := len(s)
|
||||
copy(p[offset:], s)
|
||||
v.rx++
|
||||
|
|
@ -1122,8 +1168,17 @@ func (v *View) Read(p []byte) (n int, err error) {
|
|||
// only use this if the calling function has a lock on writeMutex
|
||||
func (v *View) clear() {
|
||||
v.rewind()
|
||||
v.lines = nil
|
||||
v.buf.lines = nil
|
||||
v.clearViewLines()
|
||||
// Abandon any in-progress off-screen render: a synchronous SetContent/Clear
|
||||
// is taking over the displayed buffer, so writes must go there, not into a
|
||||
// stale off-screen buffer left by a stopped task.
|
||||
v.offscreen = nil
|
||||
// Likewise release any held scrollbar height: the new content is defined
|
||||
// synchronously (e.g. a string render superseding a still-loading diff), so
|
||||
// there's no async growth left to smooth over and the scrollbar should track
|
||||
// the new content directly.
|
||||
v.scrollbarHeightFloor = 0
|
||||
}
|
||||
|
||||
// Clear empties the view's internal buffer.
|
||||
|
|
@ -1164,10 +1219,10 @@ func (v *View) CopyContent(from *View) {
|
|||
// This is a shallow clone -- the per-row cell data is immutable once written
|
||||
// and stays shared, so the cost is proportional to the number of rows, not
|
||||
// their contents.
|
||||
v.lines = slices.Clone(from.lines)
|
||||
v.buf.lines = slices.Clone(from.buf.lines)
|
||||
v.viewLines = slices.Clone(from.viewLines)
|
||||
v.ox = from.ox
|
||||
v.oy = from.oy
|
||||
v.SetOriginX(from.ox)
|
||||
v.SetOriginY(from.oy)
|
||||
v.cx = from.cx
|
||||
v.cy = from.cy
|
||||
}
|
||||
|
|
@ -1187,23 +1242,88 @@ func (v *View) Reset() {
|
|||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.rewind()
|
||||
v.lines = nil
|
||||
v.buf.lines = nil
|
||||
// As in clear(): abandon any in-progress off-screen render so writes after a
|
||||
// reset go to the displayed buffer.
|
||||
v.offscreen = nil
|
||||
}
|
||||
|
||||
// This is for when we've done a restart for the sake of avoiding a flicker and
|
||||
// we've reached the end of the new content to display: we need to clear the remaining
|
||||
// content from the previous round. We do this by setting v.viewLines to nil so that
|
||||
// we just render the new content from v.lines directly
|
||||
func (v *View) FlushStaleCells() {
|
||||
// BeginOffscreenRender starts building a re-render into an off-screen buffer.
|
||||
// Until SwapInOffscreenRender promotes it, writes go to that buffer and the
|
||||
// displayed buffer — what every reader sees — is left as it was. This is how an
|
||||
// async re-render avoids exposing a half-written buffer: it accumulates
|
||||
// off-screen and swaps in once it has read enough to paint.
|
||||
func (v *View) BeginOffscreenRender() {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.clearViewLines()
|
||||
ei := newEscapeInterpreter(v.outMode)
|
||||
// The screen width content is wrapped at is render configuration set by
|
||||
// SetContentWidth, not per-buffer state, so the off-screen buffer's parser
|
||||
// needs it too — otherwise it counts no soft wraps and cursor-positioning
|
||||
// escapes land on the wrong rows.
|
||||
ei.screenColMax = v.buf.ei.screenColMax
|
||||
v.offscreen = &viewBuffer{ei: ei}
|
||||
}
|
||||
|
||||
// SwapInOffscreenRender promotes the off-screen buffer (see BeginOffscreenRender)
|
||||
// to the displayed buffer in one step, so the view jumps straight from the
|
||||
// previous render to the new one with no half-written frame. Writes after this
|
||||
// append to the now-displayed buffer directly. It is a no-op if no off-screen
|
||||
// render is in progress, so it is safe to call more than once (e.g. again at EOF
|
||||
// after an earlier paint already swapped).
|
||||
func (v *View) SwapInOffscreenRender() {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if v.offscreen == nil {
|
||||
return
|
||||
}
|
||||
v.buf = v.offscreen
|
||||
v.offscreen = nil
|
||||
v.tainted = true
|
||||
v.clearHover()
|
||||
}
|
||||
|
||||
// FreezeScrollbarHeight records the view's current content height so the
|
||||
// scrollbar keeps that size while a re-render loads, instead of shrinking and
|
||||
// snapping back as the partially-loaded content streams in past the first paint
|
||||
// (see scrollbarHeightFloor). Call it when a load begins, while the view still
|
||||
// shows the previous render; UnfreezeScrollbarHeight clears it when the load
|
||||
// ends.
|
||||
func (v *View) FreezeScrollbarHeight() {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.refreshViewLinesIfNeeded()
|
||||
v.scrollbarHeightFloor = len(v.viewLines)
|
||||
}
|
||||
|
||||
// UnfreezeScrollbarHeight clears the height held by FreezeScrollbarHeight, so
|
||||
// the scrollbar tracks the view's content directly again. Call it when a load
|
||||
// ends.
|
||||
func (v *View) UnfreezeScrollbarHeight() {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.scrollbarHeightFloor = 0
|
||||
}
|
||||
|
||||
// scrollbarContentHeight is the view-line height the scrollbar is sized from.
|
||||
// While a re-render is loading it is held at the height the view had when the
|
||||
// load began (see FreezeScrollbarHeight), so the thumb doesn't shrink and jump
|
||||
// as partially-loaded content streams in.
|
||||
func (v *View) scrollbarContentHeight() int {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
v.refreshViewLinesIfNeeded()
|
||||
return max(len(v.viewLines), v.scrollbarHeightFloor)
|
||||
}
|
||||
|
||||
func (v *View) rewind() {
|
||||
v.ei.reset()
|
||||
v.ei.resetScreenCursor()
|
||||
v.buf.ei.reset()
|
||||
v.buf.ei.resetScreenCursor()
|
||||
|
||||
v.SetReadPos(0, 0)
|
||||
v.SetWritePos(0, 0)
|
||||
|
|
@ -1275,14 +1395,14 @@ func (v *View) updateSearchPositions() {
|
|||
for _, result := range v.searcher.modelSearchResults {
|
||||
// This code only works when v.Wrap is false.
|
||||
|
||||
if result.Y >= len(v.lines) {
|
||||
if result.Y >= len(v.buf.lines) {
|
||||
break
|
||||
}
|
||||
|
||||
// If a view line exists for this line index:
|
||||
if v.lines[result.Y].cells != nil {
|
||||
if v.buf.lines[result.Y].cells != nil {
|
||||
// search this view line for the search string
|
||||
positions := searchPositionsForLine(v.lines[result.Y].cells, result.Y)
|
||||
positions := searchPositionsForLine(v.buf.lines[result.Y].cells, result.Y)
|
||||
if len(positions) > 0 {
|
||||
// If we found any occurrences, add them
|
||||
v.searcher.searchPositions = append(v.searcher.searchPositions, positions...)
|
||||
|
|
@ -1319,7 +1439,7 @@ func (v *View) IsTainted() bool {
|
|||
}
|
||||
|
||||
// draw re-draws the view's contents.
|
||||
func (v *View) draw() {
|
||||
func (v *View) draw(isWindowFocused bool) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
|
|
@ -1335,14 +1455,14 @@ func (v *View) draw() {
|
|||
if maxX == 0 {
|
||||
return
|
||||
}
|
||||
v.ox = 0
|
||||
v.SetOriginX(0)
|
||||
}
|
||||
|
||||
v.refreshViewLinesIfNeeded()
|
||||
|
||||
visibleViewLinesHeight := v.viewLineLengthIgnoringTrailingBlankLines()
|
||||
if v.Autoscroll && visibleViewLinesHeight > maxY {
|
||||
v.oy = visibleViewLinesHeight - maxY
|
||||
v.SetOriginY(visibleViewLinesHeight - maxY)
|
||||
}
|
||||
|
||||
if len(v.viewLines) == 0 {
|
||||
|
|
@ -1409,7 +1529,7 @@ func (v *View) draw() {
|
|||
fgColor |= AttrUnderline
|
||||
}
|
||||
|
||||
v.setCharacter(x, y, c.chr, fgColor, bgColor)
|
||||
v.setCharacter(x, y, c.chr, fgColor, bgColor, isWindowFocused)
|
||||
|
||||
x += c.width
|
||||
cellIdx++
|
||||
|
|
@ -1429,7 +1549,7 @@ func (v *View) refreshViewLinesIfNeeded() {
|
|||
}
|
||||
|
||||
lineIdx := 0
|
||||
lines := v.lines
|
||||
lines := v.buf.lines
|
||||
for i := range lines {
|
||||
line := &lines[i]
|
||||
|
||||
|
|
@ -1475,6 +1595,13 @@ func (v *View) refreshViewLinesIfNeeded() {
|
|||
}
|
||||
|
||||
v.firstDirtyLine = len(lines)
|
||||
// Truncate any entries left over from a previous, longer render. An async
|
||||
// re-render builds its content off-screen and swaps it in whole (see
|
||||
// View.offscreen), so the buffer this rebuilds from is always a complete
|
||||
// render — there is no half-loaded shorter buffer whose tail we'd need to
|
||||
// keep showing to avoid a flicker, and a leftover tail would just be stale
|
||||
// lines mapping to the wrong buffer rows.
|
||||
v.viewLines = v.viewLines[:lineIdx]
|
||||
v.tainted = false
|
||||
}
|
||||
|
||||
|
|
@ -1553,8 +1680,8 @@ func (v *View) BufferLines() []string {
|
|||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
lines := make([]string, len(v.lines))
|
||||
for i, l := range v.lines {
|
||||
lines := make([]string, len(v.buf.lines))
|
||||
for i, l := range v.buf.lines {
|
||||
lines[i] = l.cells.String()
|
||||
}
|
||||
return lines
|
||||
|
|
@ -1566,7 +1693,7 @@ func (v *View) Buffer() string {
|
|||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
return linesToString(v.lines)
|
||||
return linesToString(v.buf.lines)
|
||||
}
|
||||
|
||||
// ViewBufferLines returns the lines in the view's internal
|
||||
|
|
@ -1586,7 +1713,7 @@ func (v *View) ViewBufferLines() []string {
|
|||
|
||||
// LinesHeight is the count of view lines (i.e. lines excluding wrapping)
|
||||
func (v *View) LinesHeight() int {
|
||||
return len(v.lines)
|
||||
return len(v.buf.lines)
|
||||
}
|
||||
|
||||
// ViewLinesHeight is the count of view lines (i.e. lines including wrapping)
|
||||
|
|
@ -1617,11 +1744,11 @@ func (v *View) Line(y int) (string, bool) {
|
|||
return "", false
|
||||
}
|
||||
|
||||
if y < 0 || y >= len(v.lines) {
|
||||
if y < 0 || y >= len(v.buf.lines) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return v.lines[y].cells.String(), true
|
||||
return v.buf.lines[y].cells.String(), true
|
||||
}
|
||||
|
||||
// Word returns a string with the word of the view's internal buffer
|
||||
|
|
@ -1632,11 +1759,11 @@ func (v *View) Word(x, y int) (string, bool) {
|
|||
return "", false
|
||||
}
|
||||
|
||||
if x < 0 || y < 0 || y >= len(v.lines) || x >= len(v.lines[y].cells) {
|
||||
if x < 0 || y < 0 || y >= len(v.buf.lines) || x >= len(v.buf.lines[y].cells) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
str := v.lines[y].cells.String()
|
||||
str := v.buf.lines[y].cells.String()
|
||||
|
||||
nl := strings.LastIndexFunc(str[:x], indexFunc)
|
||||
if nl == -1 {
|
||||
|
|
@ -1662,12 +1789,12 @@ func indexFunc(r rune) bool {
|
|||
// SetHighlight toggles highlighting of separate lines, for custom lists
|
||||
// or multiple selection in views.
|
||||
func (v *View) SetHighlight(y int, on bool) {
|
||||
if y < 0 || y >= len(v.lines) {
|
||||
if y < 0 || y >= len(v.buf.lines) {
|
||||
return
|
||||
}
|
||||
|
||||
cells := make([]cell, 0, len(v.lines[y].cells))
|
||||
for _, c := range v.lines[y].cells {
|
||||
cells := make([]cell, 0, len(v.buf.lines[y].cells))
|
||||
for _, c := range v.buf.lines[y].cells {
|
||||
if on {
|
||||
c.bgColor = v.SelBgColor
|
||||
c.fgColor = v.SelFgColor
|
||||
|
|
@ -1679,7 +1806,7 @@ func (v *View) SetHighlight(y int, on bool) {
|
|||
}
|
||||
v.tainted = true
|
||||
v.firstDirtyLine = min(v.firstDirtyLine, y)
|
||||
v.lines[y].cells = cells
|
||||
v.buf.lines[y].cells = cells
|
||||
v.clearHover()
|
||||
}
|
||||
|
||||
|
|
@ -1791,7 +1918,7 @@ func (v *View) SelectedLine() string {
|
|||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if len(v.lines) == 0 {
|
||||
if len(v.buf.lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
@ -1803,7 +1930,7 @@ func (v *View) SelectedLines() []string {
|
|||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if len(v.lines) == 0 {
|
||||
if len(v.buf.lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1818,7 +1945,7 @@ func (v *View) SelectedLines() []string {
|
|||
}
|
||||
|
||||
func (v *View) lineContentAtIdx(idx int) string {
|
||||
return v.lines[idx].cells.String()
|
||||
return v.buf.lines[idx].cells.String()
|
||||
}
|
||||
|
||||
func (v *View) SelectedPoint() (int, int) {
|
||||
|
|
@ -1891,8 +2018,8 @@ func (v *View) ClearTextArea() {
|
|||
|
||||
func (v *View) overwriteLines(y int, content string) {
|
||||
// break by newline, then for each line, write it, then add that erase command
|
||||
v.wx = 0
|
||||
v.wy = y
|
||||
v.buf.wx = 0
|
||||
v.buf.wy = y
|
||||
v.clearViewLines()
|
||||
|
||||
lines := strings.ReplaceAll(content, "\n", "\x1b[K\n")
|
||||
|
|
@ -1904,7 +2031,7 @@ func (v *View) overwriteLines(y int, content string) {
|
|||
v.writeString(lines)
|
||||
}
|
||||
|
||||
// only call this function if you don't care where v.wx and v.wy end up
|
||||
// only call this function if you don't care where v.buf.wx and v.buf.wy end up
|
||||
func (v *View) OverwriteLines(y int, content string) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
|
@ -1912,7 +2039,7 @@ func (v *View) OverwriteLines(y int, content string) {
|
|||
v.overwriteLines(y, content)
|
||||
}
|
||||
|
||||
// only call this function if you don't care where v.wx and v.wy end up
|
||||
// only call this function if you don't care where v.buf.wx and v.buf.wy end up
|
||||
func (v *View) OverwriteLinesAndClearEverythingElse(lineCount int, y int, content string) {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
|
@ -1922,19 +2049,19 @@ func (v *View) OverwriteLinesAndClearEverythingElse(lineCount int, y int, conten
|
|||
v.overwriteLines(y, content)
|
||||
|
||||
for i := range y {
|
||||
v.lines[i] = lineType{}
|
||||
v.buf.lines[i] = lineType{}
|
||||
}
|
||||
|
||||
for i := v.wy + 1; i < len(v.lines); i += 1 {
|
||||
v.lines[i] = lineType{}
|
||||
for i := v.buf.wy + 1; i < len(v.buf.lines); i += 1 {
|
||||
v.buf.lines[i] = lineType{}
|
||||
}
|
||||
}
|
||||
|
||||
func (v *View) setContentLineCount(lineCount int) {
|
||||
if lineCount > 0 {
|
||||
v.makeWriteable(0, lineCount-1)
|
||||
v.buf.makeWriteable(0, lineCount-1)
|
||||
}
|
||||
v.lines = v.lines[:lineCount]
|
||||
v.buf.lines = v.buf.lines[:lineCount]
|
||||
}
|
||||
|
||||
// If the current search result is no longer visible after a scroll up, select the last search
|
||||
|
|
@ -1989,7 +2116,7 @@ func (v *View) ScrollUp(amount int) {
|
|||
}
|
||||
|
||||
if amount != 0 {
|
||||
v.oy -= amount
|
||||
v.SetOriginY(v.oy - amount)
|
||||
v.cy += amount
|
||||
|
||||
v.clearHover()
|
||||
|
|
@ -2001,7 +2128,7 @@ func (v *View) ScrollUp(amount int) {
|
|||
func (v *View) ScrollDown(amount int) {
|
||||
adjustedAmount := v.adjustDownwardScrollAmount(amount)
|
||||
if adjustedAmount > 0 {
|
||||
v.oy += adjustedAmount
|
||||
v.SetOriginY(v.oy + adjustedAmount)
|
||||
v.cy -= adjustedAmount
|
||||
|
||||
v.clearHover()
|
||||
|
|
@ -2015,7 +2142,7 @@ func (v *View) ScrollLeft(amount int) {
|
|||
newOx = 0
|
||||
}
|
||||
if newOx != v.ox {
|
||||
v.ox = newOx
|
||||
v.SetOriginX(newOx)
|
||||
|
||||
v.clearHover()
|
||||
}
|
||||
|
|
@ -2023,7 +2150,7 @@ func (v *View) ScrollLeft(amount int) {
|
|||
|
||||
// not applying any limits to this
|
||||
func (v *View) ScrollRight(amount int) {
|
||||
v.ox += amount
|
||||
v.SetOriginX(v.ox + amount)
|
||||
|
||||
v.clearHover()
|
||||
}
|
||||
|
|
@ -2068,7 +2195,7 @@ func (v *View) scrollMargin() int {
|
|||
// Returns true if the view contains a line containing the given text with the given
|
||||
// foreground color
|
||||
func (v *View) ContainsColoredText(fgColor string, text string) bool {
|
||||
for _, line := range v.lines {
|
||||
for _, line := range v.buf.lines {
|
||||
if containsColoredTextInLine(fgColor, text, line.cells) {
|
||||
return true
|
||||
}
|
||||
|
|
@ -2105,6 +2232,9 @@ func (v *View) onMouseMove(x int, y int) {
|
|||
return
|
||||
}
|
||||
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
// newCx and newCy are relative to the view port, i.e. to the visible area of the view
|
||||
newCx := x - v.x0 - 1
|
||||
newCy := y - v.y0 - 1
|
||||
|
|
@ -2123,6 +2253,19 @@ func (v *View) onMouseMove(x int, y int) {
|
|||
}
|
||||
}
|
||||
|
||||
// hyperlinkAt returns the hyperlink at the given position of the view's
|
||||
// content, or an empty string if there is none.
|
||||
func (v *View) hyperlinkAt(x, y int) string {
|
||||
v.writeMutex.Lock()
|
||||
defer v.writeMutex.Unlock()
|
||||
|
||||
if y < 0 || y >= len(v.viewLines) || x < 0 || x >= len(v.viewLines[y].line) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return v.viewLines[y].line[x].hyperlink
|
||||
}
|
||||
|
||||
func (v *View) findHyperlinkAt(x, y int) *SearchPosition {
|
||||
linkStr := v.viewLines[y].line[x].hyperlink
|
||||
if linkStr == "" {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/gdamore/tcell/v3"
|
||||
"github.com/gdamore/tcell/v3/color"
|
||||
"github.com/rivo/uniseg"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
@ -101,15 +102,13 @@ func TestWriteString(t *testing.T) {
|
|||
for _, test := range tests {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal)
|
||||
for _, l := range test.existingLines {
|
||||
v.lines = append(v.lines, lineType{cells: stringToCells(l)})
|
||||
v.buf.lines = append(v.buf.lines, lineType{cells: stringToCells(l)})
|
||||
}
|
||||
for _, s := range test.stringsToWrite {
|
||||
v.writeString(s)
|
||||
}
|
||||
var resultingLines [][]string
|
||||
for _, l := range v.lines {
|
||||
resultingLines = append(resultingLines, cellsToStrings(l.cells))
|
||||
}
|
||||
resultingLines := lo.Map(v.buf.lines,
|
||||
func(l lineType, _ int) []string { return cellsToStrings(l.cells) })
|
||||
assert.Equal(t, test.expectedLines, resultingLines)
|
||||
}
|
||||
}
|
||||
|
|
@ -144,19 +143,115 @@ func TestAutoRenderingHyperlinks(t *testing.T) {
|
|||
|
||||
v.writeString("htt")
|
||||
// No hyperlinks are generated for incomplete URLs
|
||||
assert.Equal(t, "", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "", v.buf.lines[0].cells[0].hyperlink)
|
||||
// Writing more characters to the same line makes the link complete (even
|
||||
// though we didn't see a newline yet)
|
||||
v.writeString("ps://example.com")
|
||||
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
|
||||
|
||||
v.Clear()
|
||||
// Valid but incomplete URL
|
||||
v.writeString("https://exa")
|
||||
assert.Equal(t, "https://exa", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "https://exa", v.buf.lines[0].cells[0].hyperlink)
|
||||
// Writing more characters to the same fixes the link
|
||||
v.writeString("mple.com")
|
||||
assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink)
|
||||
assert.Equal(t, "https://example.com", v.buf.lines[0].cells[0].hyperlink)
|
||||
}
|
||||
|
||||
// An async re-render builds into an off-screen buffer and swaps it in once it
|
||||
// has enough to paint, so readers keep seeing the previous render — coherent and
|
||||
// consistent — until the new content appears in one step. See View.offscreen.
|
||||
func TestOffscreenRender(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 10, OutputNormal)
|
||||
|
||||
v.writeString("a\nb\nc")
|
||||
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
|
||||
|
||||
// Render new, longer content off-screen.
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString("w\nx\ny\nz")
|
||||
|
||||
// The displayed buffer is untouched: readers still see the previous render.
|
||||
assert.Equal(t, []string{"a", "b", "c"}, v.ViewBufferLines())
|
||||
|
||||
// Swapping in reveals the new content in one step.
|
||||
v.SwapInOffscreenRender()
|
||||
assert.Equal(t, []string{"w", "x", "y", "z"}, v.ViewBufferLines())
|
||||
|
||||
// A further write now appends to the displayed buffer directly.
|
||||
v.writeString("\nmore")
|
||||
assert.Equal(t, []string{"w", "x", "y", "z", "more"}, v.ViewBufferLines())
|
||||
}
|
||||
|
||||
// When a render produces fewer view lines than the previous one,
|
||||
// refreshViewLinesIfNeeded must truncate viewLines to the new content rather
|
||||
// than leaving the previous render's entries in the tail: with the off-screen
|
||||
// render there is no half-loaded buffer whose tail we'd want to keep showing,
|
||||
// and a leftover tail is just stale lines describing content that is gone.
|
||||
func TestViewLinesTruncatedByShorterRender(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 10, 10, OutputNormal) // InnerWidth is 9
|
||||
v.Wrap = true
|
||||
|
||||
// Two lines of 27 characters each wrap into 3 view lines apiece.
|
||||
v.writeString(strings.Repeat("a", 27) + "\n" + strings.Repeat("b", 27))
|
||||
assert.Equal(t, 6, v.ViewLinesHeight())
|
||||
|
||||
// Re-render with three short, unwrapped lines: only 3 view lines remain.
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString("aaa\nbbb\nccc")
|
||||
v.SwapInOffscreenRender()
|
||||
assert.Equal(t, 3, v.ViewLinesHeight())
|
||||
assert.Equal(t, []string{"aaa", "bbb", "ccc"}, v.ViewBufferLines())
|
||||
}
|
||||
|
||||
// While an async re-render loads, it swaps in only a partially-filled buffer at
|
||||
// its first paint and keeps appending lines afterwards. The scrollbar must keep
|
||||
// using the pre-load height until the load ends, so the thumb doesn't shrink and
|
||||
// snap back as the rest streams in. See View.scrollbarHeightFloor.
|
||||
func TestScrollbarHeightHeldWhileLoading(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 12, OutputNormal)
|
||||
|
||||
// Initial render: 100 lines, scrolled well down.
|
||||
v.writeString(strings.Repeat("x\n", 100))
|
||||
v.SetOrigin(0, 80)
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// A re-render begins while the previous render is still shown: hold the
|
||||
// scrollbar height at the current value.
|
||||
v.FreezeScrollbarHeight()
|
||||
|
||||
// The off-screen render swaps in only a screenful at its first paint.
|
||||
v.BeginOffscreenRender()
|
||||
v.writeString(strings.Repeat("y\n", 30))
|
||||
v.SwapInOffscreenRender()
|
||||
|
||||
// The displayed buffer is now short, but the scrollbar height stays held, so
|
||||
// the thumb keeps its position instead of jumping.
|
||||
assert.Equal(t, 30, v.ViewLinesHeight())
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// The rest of the content streams in.
|
||||
v.writeString(strings.Repeat("y\n", 70))
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// Once the load ends, the scrollbar tracks the real content directly again.
|
||||
v.UnfreezeScrollbarHeight()
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
}
|
||||
|
||||
// If a synchronous render (e.g. a string render) supersedes a still-loading diff
|
||||
// before it reaches its end, the held scrollbar height must be released, so the
|
||||
// scrollbar reflects the new content rather than the abandoned load's height.
|
||||
func TestScrollbarHeightReleasedWhenContentReplaced(t *testing.T) {
|
||||
v := NewView("name", 0, 0, 80, 12, OutputNormal)
|
||||
|
||||
v.writeString(strings.Repeat("x\n", 100))
|
||||
v.FreezeScrollbarHeight()
|
||||
assert.Equal(t, 100, v.scrollbarContentHeight())
|
||||
|
||||
// A synchronous render replaces the content before the (notional) load ends.
|
||||
v.SetContent("just a few\nshort lines\nhere")
|
||||
assert.Equal(t, 3, v.scrollbarContentHeight())
|
||||
}
|
||||
|
||||
func TestContainsColoredText(t *testing.T) {
|
||||
|
|
@ -233,7 +328,7 @@ func TestContainsColoredText(t *testing.T) {
|
|||
for j, cells := range test.lines {
|
||||
lines[j] = lineType{cells: cells}
|
||||
}
|
||||
v := &View{lines: lines}
|
||||
v := &View{buf: &viewBuffer{lines: lines}}
|
||||
assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i)
|
||||
}
|
||||
}
|
||||
|
|
@ -248,8 +343,8 @@ func TestWriteCursorPositionEscape(t *testing.T) {
|
|||
// "a", then "skip to row 3" (i.e. one blank row), then "b".
|
||||
v.writeString("a\r\n\x1b[3;1Hb\r\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
|
||||
|
|
@ -269,8 +364,8 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
|
|||
// ConPTY is on row 3 here; CUP to row 5 should skip exactly one row.
|
||||
v.writeString("c\x1b[5;1Hd\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
assert.Equal(t, [][]string{
|
||||
|
|
@ -282,6 +377,31 @@ func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) {
|
|||
}, got)
|
||||
}
|
||||
|
||||
func TestWriteCursorPositionEscapeInOffscreenRender(t *testing.T) {
|
||||
// Soft-wrap counting has to work in an off-screen render too: the content
|
||||
// width the parser counts wraps against is set by SetContentWidth before the
|
||||
// render starts, so the off-screen buffer's parser has to pick it up. If it
|
||||
// doesn't, no wraps are counted and the CUP below is evaluated against a
|
||||
// stale row, overshooting into an extra blank line.
|
||||
v := NewView("name", 0, 0, 30, 30, OutputNormal)
|
||||
v.SetContentWidth(5)
|
||||
|
||||
v.BeginOffscreenRender()
|
||||
// Seven characters soft-wrap once on a 5-column screen, putting ConPTY on
|
||||
// row 2; CUP to row 3 should then skip no rows at all.
|
||||
v.writeString("aaaaaaa\x1b[3;1Hb\n")
|
||||
v.SwapInOffscreenRender()
|
||||
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
assert.Equal(t, [][]string{
|
||||
{"a", "a", "a", "a", "a", "a", "a"},
|
||||
{"b"},
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestWriteCursorForwardEscape(t *testing.T) {
|
||||
// ConPTY compresses runs of default-colored spaces into ECH (\x1b[NX,
|
||||
// "clear N cells, cursor stationary") + CUF (\x1b[NC, "cursor forward
|
||||
|
|
@ -292,8 +412,8 @@ func TestWriteCursorForwardEscape(t *testing.T) {
|
|||
// "a" + ECH 5 + CUF 5 + "b" — visually "a b".
|
||||
v.writeString("a\x1b[5X\x1b[5Cb\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
|
||||
|
|
@ -312,8 +432,8 @@ func TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) {
|
|||
v.writeString("abcdefghij\n")
|
||||
v.writeString("\x1b[4;1Hxyz\n")
|
||||
|
||||
got := make([][]string, 0, len(v.lines))
|
||||
for _, l := range v.lines {
|
||||
got := make([][]string, 0, len(v.buf.lines))
|
||||
for _, l := range v.buf.lines {
|
||||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
assert.Equal(t, [][]string{
|
||||
|
|
@ -344,11 +464,7 @@ func cellsToString(cells []cell) string {
|
|||
}
|
||||
|
||||
func cellsToStrings(cells []cell) []string {
|
||||
s := []string{}
|
||||
for _, c := range cells {
|
||||
s = append(s, c.chr)
|
||||
}
|
||||
return s
|
||||
return lo.Map(cells, func(c cell, _ int) string { return c.chr })
|
||||
}
|
||||
|
||||
func TestLineWrap(t *testing.T) {
|
||||
|
|
@ -534,7 +650,7 @@ func TestNewlineTerminatedLineClearsTrailingBg(t *testing.T) {
|
|||
// renders with bg=red. The trailing area past "foo" must NOT extend
|
||||
// the red bg because '\n' marks the line as cleanly terminated.
|
||||
v.writeString("\x1b[7m\x1b[31mfoo\x1b[0m\n")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// First row: cells 1..3 are "foo" (render with red bg via reverse),
|
||||
// cells 4..10 are trailing and should be plain default.
|
||||
|
|
@ -560,7 +676,7 @@ func TestUnterminatedReverseLineDoesNotExtend(t *testing.T) {
|
|||
// Reverse + red fg, "foo", no termination. The trailing cells past
|
||||
// "foo" should be plain default, NOT a continuation of the red bg.
|
||||
v.writeString("\x1b[7m\x1b[31mfoo")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// Cells 4..10 are trailing and should be default with no reverse.
|
||||
for x := 4; x <= 10; x++ {
|
||||
|
|
@ -583,7 +699,7 @@ func TestShortFilledLineExtendsBgWithoutWrap(t *testing.T) {
|
|||
// \x1b[41m sets bg=red. "hi" fits within InnerWidth=10; \x1b[K should
|
||||
// fill the remaining 8 cells with red.
|
||||
v.writeString("\x1b[41mhi\x1b[K\x1b[0m\n")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// All ten cells at (1..10, 1) should have red bg.
|
||||
for x := 1; x <= 10; x++ {
|
||||
|
|
@ -611,7 +727,7 @@ func TestWrappedFilledLineExtendsBgToEdge(t *testing.T) {
|
|||
// segments — "aaa bbb" / "ccc ddd" / "eee". Each row's trailing area
|
||||
// must pick up the red fill from \x1b[K.
|
||||
v.writeString("\x1b[41m" + "aaa bbb ccc ddd eee" + "\x1b[0m\x1b[41m\x1b[K\x1b[0m\n")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// All three wrapped rows should have the red fill background across
|
||||
// the full InnerWidth, including the trailing cells past each row's
|
||||
|
|
@ -645,7 +761,7 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) {
|
|||
// last cell red) and segment 2 is "ccc" (green, last cell green).
|
||||
// \x1b[K records the green bg on the source line.
|
||||
v.writeString("\x1b[41maaa bbb\x1b[42m ccc\x1b[K\x1b[0m\n")
|
||||
v.draw()
|
||||
v.draw(true)
|
||||
|
||||
// Row 1's content ends with a red cell at x=7, so trailing columns
|
||||
// 8..10 should pick up red rather than the \x1b[K's green.
|
||||
|
|
|
|||
|
|
@ -119,13 +119,12 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() {
|
|||
var appStatusHelper *helpers.AppStatusHelper
|
||||
var branchesHelper *helpers.BranchesHelper
|
||||
var fetchGeneration int
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
git = self.gui.git
|
||||
appStatusHelper = self.gui.helpers.AppStatus
|
||||
branchesHelper = self.gui.helpers.BranchesHelper
|
||||
fetchGeneration = self.gui.c.State().GetRepoGeneration()
|
||||
self.gui.State.LastBackgroundFetchTime = time.Now()
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -184,10 +183,9 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() {
|
|||
// reading them from this background goroutine would race the reassignment.
|
||||
var git *commands.GitCommand
|
||||
var refreshHelper *helpers.RefreshHelper
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() error {
|
||||
if err := self.gui.g.OnUIThreadAndWaitBackground(func() {
|
||||
git = self.gui.git
|
||||
refreshHelper = self.gui.helpers.Refresh
|
||||
return nil
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue