diff --git a/.github/workflows/check-required-label.yml b/.github/workflows/check-required-label.yml index a7e1d97c7..eb681cc5c 100644 --- a/.github/workflows/check-required-label.yml +++ b/.github/workflows/check-required-label.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6d2fdf6d..f32d4804d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3cf63bbf..b7755cc2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml index 611ad553d..56466a07c 100644 --- a/.github/workflows/sponsors.yml +++ b/.github/workflows/sponsors.yml @@ -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" diff --git a/.golangci.yml b/.golangci.yml index 5ed7fb32d..e6a2f37ab 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -99,8 +99,6 @@ linters: generated: lax presets: - comments - - common-false-positives - - legacy - std-error-handling paths: - vendor/ diff --git a/AGENTS.md b/AGENTS.md index 2cafd9d50..d65a623d9 100644 --- a/AGENTS.md +++ b/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=`, then +`git rebase --onto ` 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, diff --git a/README.md b/README.md index 53c02478e..5d5e47e11 100644 --- a/README.md +++ b/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 diff --git a/docs-master/Config.md b/docs-master/Config.md index 1d101be18..857a4e359 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -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: diff --git a/docs-master/Custom_DiffRenderers.md b/docs-master/Custom_DiffRenderers.md new file mode 100644 index 000000000..509f42ebf --- /dev/null +++ b/docs-master/Custom_DiffRenderers.md @@ -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 +``` + +![](https://i.imgur.com/QJpQkF3.png) + +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 +``` + +![](https://i.imgur.com/rjH1TpT.png) + +## ydiff + +```yaml +gui: + sidePanelWidth: 0.2 # gives you more space to show things side-by-side +git: + diffRenderers: + - colorArg: never + command: ydiff -p cat +``` + +![](https://i.imgur.com/vaa8z0H.png) diff --git a/docs-master/Custom_Pagers.md b/docs-master/Custom_Pagers.md deleted file mode 100644 index 8bdcf164d..000000000 --- a/docs-master/Custom_Pagers.md +++ /dev/null @@ -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 -``` - -![](https://i.imgur.com/QJpQkF3.png) - -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 -``` - -![](https://i.imgur.com/rjH1TpT.png) - -## 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}} -``` - -![](https://i.imgur.com/vaa8z0H.png) - -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. diff --git a/docs-master/README.md b/docs-master/README.md index 1bc0bb6be..c586d9699 100644 --- a/docs-master/README.md +++ b/docs-master/README.md @@ -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) diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 4aa202740..3ec731bf2 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.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. | | `` `` | Cancel | | | `` ? `` | Open keybindings menu | | | `` `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 5b9c798a1..6a3d9b5c1 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -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. | | `` `` | キャンセル | | | `` ? `` | キーバインディングメニューを開く | | | `` `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index d1eb3afb1..a0e5d84dc 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -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. | | `` `` | 취소 | | | `` ? `` | 매뉴 열기 | | | `` `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 0eb61729b..7d95e72dd 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -9,31 +9,31 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Wissel naar een recente repo | | | `` , K, (fn+up/shift+k) `` | Scroll naar beneden vanaf hoofdpaneel | | | `` , J, (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.

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.

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.

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.

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. | | `` `` | 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. | | `` `` | Annuleren | | | `` ? `` | Open menu | | | `` `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W, `` | 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, `` | Afsluiten | | | `` `` | Pauzeer de applicatie | | -| `` `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.

The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. | +| `` `` | Witruimte weergeven in-/uitschakelen | Toggle whether or not whitespace changes are shown in the diff view.

The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. | | `` `` | 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.
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.
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 | | `` , `` | Verplaats commit 1 naar beneden | | | `` , `` | 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. | -| `` `` | 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. | +| `` `` | 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 | | -| `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | +| `` `` | 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 | `` , h `` | Selecteer de vorige hunk | | | `` , 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. | | `` `` | 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 | |-----|--------|-------------| | `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | +| `` `` | 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 | `` , h `` | Selecteer de vorige hunk | | | `` , 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. | | `` `` | Copy selected text to clipboard | | | `` `` | 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 | |-----|--------|-------------| | `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | +| `` `` | 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 |-----|--------|-------------| | `` `` | Copy tag to clipboard | | | `` `` | 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. | | `` `` | Open externe diff applicatie (git difftool) | | | `` 0 `` | Focus main view | | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 719a7e6c4..ba46f7c46 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -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. | | `` `` | Anuluj | | | `` ? `` | Otwórz menu przypisań klawiszy | | | `` `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 5fcba2688..3071613be 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -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. | | `` `` | Cancelar | | | `` ? `` | Abrir o menu de atalhos do teclado | | | `` `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 683135448..3d03f9ca6 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -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. | | `` `` | Отменить | | | `` ? `` | Открыть меню | | | `` `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index b4f1134ec..9819cb982 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -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. | | `` `` | 取消 | | | `` ? `` | 打开菜单 | | | `` `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index b50e25d35..1706a627e 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -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. | | `` `` | 取消 | | | `` ? `` | 開啟選單 | | | `` `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs/Config.md b/docs/Config.md index 1d101be18..857a4e359 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -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: diff --git a/docs/Custom_DiffRenderers.md b/docs/Custom_DiffRenderers.md new file mode 100644 index 000000000..509f42ebf --- /dev/null +++ b/docs/Custom_DiffRenderers.md @@ -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 +``` + +![](https://i.imgur.com/QJpQkF3.png) + +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 +``` + +![](https://i.imgur.com/rjH1TpT.png) + +## ydiff + +```yaml +gui: + sidePanelWidth: 0.2 # gives you more space to show things side-by-side +git: + diffRenderers: + - colorArg: never + command: ydiff -p cat +``` + +![](https://i.imgur.com/vaa8z0H.png) diff --git a/docs/Custom_Pagers.md b/docs/Custom_Pagers.md deleted file mode 100644 index f74005c19..000000000 --- a/docs/Custom_Pagers.md +++ /dev/null @@ -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 -``` - -![](https://i.imgur.com/QJpQkF3.png) - -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 -``` - -![](https://i.imgur.com/rjH1TpT.png) - -## 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}} -``` - -![](https://i.imgur.com/vaa8z0H.png) - -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. diff --git a/docs/README.md b/docs/README.md index 1bc0bb6be..c586d9699 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md index 4aa202740..3ec731bf2 100644 --- a/docs/keybindings/Keybindings_en.md +++ b/docs/keybindings/Keybindings_en.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. | | `` `` | Cancel | | | `` ? `` | Open keybindings menu | | | `` `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs/keybindings/Keybindings_ja.md b/docs/keybindings/Keybindings_ja.md index 5b9c798a1..6a3d9b5c1 100644 --- a/docs/keybindings/Keybindings_ja.md +++ b/docs/keybindings/Keybindings_ja.md @@ -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. | | `` `` | キャンセル | | | `` ? `` | キーバインディングメニューを開く | | | `` `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 | diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md index d1eb3afb1..a0e5d84dc 100644 --- a/docs/keybindings/Keybindings_ko.md +++ b/docs/keybindings/Keybindings_ko.md @@ -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. | | `` `` | 취소 | | | `` ? `` | 매뉴 열기 | | | `` `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md index 0eb61729b..7d95e72dd 100644 --- a/docs/keybindings/Keybindings_nl.md +++ b/docs/keybindings/Keybindings_nl.md @@ -9,31 +9,31 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Wissel naar een recente repo | | | `` , K, (fn+up/shift+k) `` | Scroll naar beneden vanaf hoofdpaneel | | | `` , J, (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.

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.

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.

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.

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. | | `` `` | 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. | | `` `` | Annuleren | | | `` ? `` | Open menu | | | `` `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W, `` | 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, `` | Afsluiten | | | `` `` | Pauzeer de applicatie | | -| `` `` | Toggle whitespace | Toggle whether or not whitespace changes are shown in the diff view.

The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. | +| `` `` | Witruimte weergeven in-/uitschakelen | Toggle whether or not whitespace changes are shown in the diff view.

The default can be changed in the config file with the key 'git.ignoreWhitespaceInDiffView'. | | `` `` | 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.
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.
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 | | `` , `` | Verplaats commit 1 naar beneden | | | `` , `` | 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. | -| `` `` | 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. | +| `` `` | 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 | | -| `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | +| `` `` | 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 | `` , h `` | Selecteer de vorige hunk | | | `` , 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. | | `` `` | 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 | |-----|--------|-------------| | `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | +| `` `` | 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 | `` , h `` | Selecteer de vorige hunk | | | `` , 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. | | `` `` | Copy selected text to clipboard | | | `` `` | 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 | |-----|--------|-------------| | `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Uitchecken | Checkout the selected commit as a detached HEAD. | +| `` `` | 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 |-----|--------|-------------| | `` `` | Copy tag to clipboard | | | `` `` | 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. | | `` `` | Open externe diff applicatie (git difftool) | | | `` 0 `` | Focus main view | | diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md index 719a7e6c4..ba46f7c46 100644 --- a/docs/keybindings/Keybindings_pl.md +++ b/docs/keybindings/Keybindings_pl.md @@ -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. | | `` `` | Anuluj | | | `` ? `` | Otwórz menu przypisań klawiszy | | | `` `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. | diff --git a/docs/keybindings/Keybindings_pt.md b/docs/keybindings/Keybindings_pt.md index 5fcba2688..3071613be 100644 --- a/docs/keybindings/Keybindings_pt.md +++ b/docs/keybindings/Keybindings_pt.md @@ -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. | | `` `` | Cancelar | | | `` ? `` | Abrir o menu de atalhos do teclado | | | `` `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs/keybindings/Keybindings_ru.md b/docs/keybindings/Keybindings_ru.md index 683135448..3d03f9ca6 100644 --- a/docs/keybindings/Keybindings_ru.md +++ b/docs/keybindings/Keybindings_ru.md @@ -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. | | `` `` | Отменить | | | `` ? `` | Открыть меню | | | `` `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs/keybindings/Keybindings_zh-CN.md b/docs/keybindings/Keybindings_zh-CN.md index b4f1134ec..9819cb982 100644 --- a/docs/keybindings/Keybindings_zh-CN.md +++ b/docs/keybindings/Keybindings_zh-CN.md @@ -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. | | `` `` | 取消 | | | `` ? `` | 打开菜单 | | | `` `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 | diff --git a/docs/keybindings/Keybindings_zh-TW.md b/docs/keybindings/Keybindings_zh-TW.md index b50e25d35..1706a627e 100644 --- a/docs/keybindings/Keybindings_zh-TW.md +++ b/docs/keybindings/Keybindings_zh-TW.md @@ -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. | | `` `` | 取消 | | | `` ? `` | 開啟選單 | | | `` `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/flake.lock b/flake.lock index 55f0c3139..672a2d56c 100644 --- a/flake.lock +++ b/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": { diff --git a/flake.nix b/flake.nix index b8069e9f7..fcc58a044 100644 --- a/flake.nix +++ b/flake.nix @@ -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; }; diff --git a/go.mod b/go.mod index 515bb8f1b..07e8ad43f 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 1b8cb7d66..430366e5b 100644 --- a/go.sum +++ b/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= diff --git a/pkg/app/daemon/daemon.go b/pkg/app/daemon/daemon.go index df0e4bb49..0b33bc12b 100644 --- a/pkg/app/daemon/daemon.go +++ b/pkg/app/daemon/daemon.go @@ -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()) }) } diff --git a/pkg/app/errors.go b/pkg/app/errors.go index 506fec276..ee24cff49 100644 --- a/pkg/app/errors.go +++ b/pkg/app/errors.go @@ -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 diff --git a/pkg/cheatsheet/generate.go b/pkg/cheatsheet/generate.go index a9cee6494..5c5a94530 100644 --- a/pkg/cheatsheet/generate.go +++ b/pkg/cheatsheet/generate.go @@ -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)) diff --git a/pkg/commands/git.go b/pkg/commands/git.go index ba6e5a033..69cddfa48 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -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) diff --git a/pkg/commands/git_cmd_obj_builder.go b/pkg/commands/git_cmd_obj_builder.go index 9c3cd50d4..d879019eb 100644 --- a/pkg/commands/git_cmd_obj_builder.go +++ b/pkg/commands/git_cmd_obj_builder.go @@ -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 { diff --git a/pkg/commands/git_cmd_obj_builder_test.go b/pkg/commands/git_cmd_obj_builder_test.go index 28e21501c..e969baa00 100644 --- a/pkg/commands/git_cmd_obj_builder_test.go +++ b/pkg/commands/git_cmd_obj_builder_test.go @@ -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"}) +} diff --git a/pkg/commands/git_commands/commit.go b/pkg/commands/git_commands/commit.go index 953717ec0..d067e9831 100644 --- a/pkg/commands/git_commands/commit.go +++ b/pkg/commands/git_commands/commit.go @@ -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). diff --git a/pkg/commands/git_commands/commit_test.go b/pkg/commands/git_commands/commit_test.go index 25966c06f..9b2ddecfb 100644 --- a/pkg/commands/git_commands/commit_test.go +++ b/pkg/commands/git_commands/commit_test.go @@ -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 diff --git a/pkg/commands/git_commands/common.go b/pkg/commands/git_commands/common.go index ba3b64d7f..4a8c85213 100644 --- a/pkg/commands/git_commands/common.go +++ b/pkg/commands/git_commands/common.go @@ -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, } } diff --git a/pkg/commands/git_commands/deps_test.go b/pkg/commands/git_commands/deps_test.go index 235f21716..91332cff6 100644 --- a/pkg/commands/git_commands/deps_test.go +++ b/pkg/commands/git_commands/deps_test.go @@ -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() }) diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go index f4ecb5f53..d532f1bbb 100644 --- a/pkg/commands/git_commands/diff.go +++ b/pkg/commands/git_commands/diff.go @@ -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 diff --git a/pkg/commands/git_commands/file.go b/pkg/commands/git_commands/file.go index 1b5f5b2dd..00f46e821 100644 --- a/pkg/commands/git_commands/file.go +++ b/pkg/commands/git_commands/file.go @@ -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 diff --git a/pkg/commands/git_commands/file_loader.go b/pkg/commands/git_commands/file_loader.go index 7e2bdf0f3..747572b38 100644 --- a/pkg/commands/git_commands/file_loader.go +++ b/pkg/commands/git_commands/file_loader.go @@ -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 { diff --git a/pkg/commands/git_commands/file_loader_test.go b/pkg/commands/git_commands/file_loader_test.go index ec1f502f1..23602ff9e 100644 --- a/pkg/commands/git_commands/file_loader_test.go +++ b/pkg/commands/git_commands/file_loader_test.go @@ -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, diff --git a/pkg/commands/git_commands/file_test.go b/pkg/commands/git_commands/file_test.go index 5dd3d133c..8f91f797c 100644 --- a/pkg/commands/git_commands/file_test.go +++ b/pkg/commands/git_commands/file_test.go @@ -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 { diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go index f37681223..f1a7c87b4 100644 --- a/pkg/commands/git_commands/git_command_builder.go +++ b/pkg/commands/git_commands/git_command_builder.go @@ -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 +// 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...) } diff --git a/pkg/commands/git_commands/github.go b/pkg/commands/git_commands/github.go index b74815301..2b0568685 100644 --- a/pkg/commands/git_commands/github.go +++ b/pkg/commands/git_commands/github.go @@ -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, diff --git a/pkg/commands/git_commands/github_test.go b/pkg/commands/git_commands/github_test.go index b332ba12a..ce068d750 100644 --- a/pkg/commands/git_commands/github_test.go +++ b/pkg/commands/git_commands/github_test.go @@ -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"}, }, diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index 97d48a1a0..74278b18d 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -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 diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index c64debfc5..0473f8f8e 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -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 -} diff --git a/pkg/commands/git_commands/repo_paths_test.go b/pkg/commands/git_commands/repo_paths_test.go index 29c40acee..c7b7705b4 100644 --- a/pkg/commands/git_commands/repo_paths_test.go +++ b/pkg/commands/git_commands/repo_paths_test.go @@ -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) }, }, } diff --git a/pkg/commands/git_commands/stash.go b/pkg/commands/git_commands/stash.go index 9bd960ed5..ea23c5141 100644 --- a/pkg/commands/git_commands/stash.go +++ b/pkg/commands/git_commands/stash.go @@ -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() diff --git a/pkg/commands/git_commands/stash_test.go b/pkg/commands/git_commands/stash_test.go index a942a4e98..8f5629c98 100644 --- a/pkg/commands/git_commands/stash_test.go +++ b/pkg/commands/git_commands/stash_test.go @@ -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", diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index d8c1208bc..7400f0514 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -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() } diff --git a/pkg/commands/git_commands/submodule_test.go b/pkg/commands/git_commands/submodule_test.go index 279c963df..d449d81de 100644 --- a/pkg/commands/git_commands/submodule_test.go +++ b/pkg/commands/git_commands/submodule_test.go @@ -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) diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index d296858f6..846b359b3 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -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). diff --git a/pkg/commands/git_commands/working_tree_test.go b/pkg/commands/git_commands/working_tree_test.go index 8af2b707d..5b87a1320 100644 --- a/pkg/commands/git_commands/working_tree_test.go +++ b/pkg/commands/git_commands/working_tree_test.go @@ -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), }, } diff --git a/pkg/commands/git_commands/worktree.go b/pkg/commands/git_commands/worktree.go index 986bb6d42..64748b878 100644 --- a/pkg/commands/git_commands/worktree.go +++ b/pkg/commands/git_commands/worktree.go @@ -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) { diff --git a/pkg/commands/git_commands/worktree_loader.go b/pkg/commands/git_commands/worktree_loader.go index 0e3615f13..f7577c870 100644 --- a/pkg/commands/git_commands/worktree_loader.go +++ b/pkg/commands/git_commands/worktree_loader.go @@ -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 })) diff --git a/pkg/commands/git_commands/worktree_loader_test.go b/pkg/commands/git_commands/worktree_loader_test.go index 1127540ca..c537c6be4 100644 --- a/pkg/commands/git_commands/worktree_loader_test.go +++ b/pkg/commands/git_commands/worktree_loader_test.go @@ -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"}, diff --git a/pkg/commands/models/file.go b/pkg/commands/models/file.go index e48696a4f..9eedfb1fc 100644 --- a/pkg/commands/models/file.go +++ b/pkg/commands/models/file.go @@ -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 diff --git a/pkg/commands/models/github.go b/pkg/commands/models/github.go index 6477c6ee6..da7bd79db 100644 --- a/pkg/commands/models/github.go +++ b/pkg/commands/models/github.go @@ -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"` } diff --git a/pkg/commands/oscommands/os.go b/pkg/commands/oscommands/os.go index f1ee86c4c..fed095a28 100644 --- a/pkg/commands/oscommands/os.go +++ b/pkg/commands/oscommands/os.go @@ -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")) diff --git a/pkg/commands/oscommands/pty_unix.go b/pkg/commands/oscommands/pty_unix.go index 6cf63cdff..cd3962a8e 100644 --- a/pkg/commands/oscommands/pty_unix.go +++ b/pkg/commands/oscommands/pty_unix.go @@ -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() {} diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go index eaa762ed6..ff707c519 100644 --- a/pkg/commands/oscommands/pty_windows.go +++ b/pkg/commands/oscommands/pty_windows.go @@ -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"), } diff --git a/pkg/commands/oscommands/pty_windows_test.go b/pkg/commands/oscommands/pty_windows_test.go index 0b4173561..e6581a85d 100644 --- a/pkg/commands/oscommands/pty_windows_test.go +++ b/pkg/commands/oscommands/pty_windows_test.go @@ -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") + } +} diff --git a/pkg/commands/patch/hunk.go b/pkg/commands/patch/hunk.go index 6d0177d05..568b312a7 100644 --- a/pkg/commands/patch/hunk.go +++ b/pkg/commands/patch/hunk.go @@ -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 } diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index 9b9db2c12..ade614f7d 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -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) { diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index be97c2acb..180f4b882 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -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 ( 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 ( 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: - return: - quitWithoutChangingDirectory: Q - togglePanel: - prevItem: - nextItem: - prevItem-alt: k - nextItem-alt: j - prevPage: ',' - nextPage: . - scrollLeft: H - scrollRight: L - gotoTop: < - gotoBottom: '>' - toggleRangeSelect: v - rangeSelectDown: - rangeSelectUp: - prevBlock: - nextBlock: - prevBlock-alt: h - nextBlock-alt: l - nextBlock-alt2: - prevBlock-alt2: - jumpToBlock: - - "1" - - "2" - - "3" - - "4" - - "5" - nextMatch: "n" - prevMatch: "N" - startSearch: / - optionMenu: - optionMenu-alt1: '?' - select: - goInto: - confirm: - confirmInEditor: - remove: d - new: "n" - edit: e - openFile: o - scrollUpMain: - scrollDownMain: - scrollUpMain-alt1: K - scrollDownMain-alt1: J - scrollUpMain-alt2: - scrollDownMain-alt2: - executeShellCommand: ':' - createRebaseOptionsMenu: m - - # 'Files' appended for legacy reasons - pushFiles: P - - # 'Files' appended for legacy reasons - pullFiles: p - refresh: R - createPatchOptionsMenu: - nextTab: ']' - prevTab: '[' - nextScreenMode: + - prevScreenMode: _ - undo: z - redo: Z - filteringMenu: - diffingMenu: W - diffingMenu-alt: - copyToClipboard: - openRecentRepos: - submitEditorText: - extrasMenu: '@' - toggleWhitespaceInDiffView: - increaseContextInDiffView: '}' - decreaseContextInDiffView: '{' - increaseRenameSimilarityThreshold: ) - decreaseRenameSimilarityThreshold: ( - openDiffTool: - status: - checkForUpdate: u - recentRepos: - allBranchesLogGraph: a - files: - commitChanges: c - commitChangesWithoutHook: w - amendLastCommit: A - commitChangesWithEditor: C - findBaseCommitForFixup: - confirmDiscard: x - ignoreFile: i - refreshFiles: r - stashAllChanges: s - viewStashOptions: S - toggleStagedAll: a - viewResetOptions: D - fetch: f - openMergeOptions: M - openStatusFilter: - copyFileInfoToClipboard: "y" - collapseAll: '-' - expandAll: = - branches: - createPullRequest: o - viewPullRequestOptions: O - copyPullRequestURL: - 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: - moveUpCommit: - amendToCommit: A - resetCommitAuthor: a - pickCommit: p - revertCommit: t - cherryPickCopy: C - pasteCommits: V - markCommitAsBaseForRebase: B - tagCommit: T - checkoutCommit: - resetCherryPick: - copyCommitAttributeToClipboard: "y" - openLogMenu: - 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: -`) - -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", + }, }, } diff --git a/pkg/config/diff_renderer_config_manager.go b/pkg/config/diff_renderer_config_manager.go new file mode 100644 index 000000000..b23933962 --- /dev/null +++ b/pkg/config/diff_renderer_config_manager.go @@ -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] +} diff --git a/pkg/config/diff_renderer_config_manager_test.go b/pkg/config/diff_renderer_config_manager_test.go new file mode 100644 index 000000000..98fd8f304 --- /dev/null +++ b/pkg/config/diff_renderer_config_manager_test.go @@ -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()) +} diff --git a/pkg/config/dummies.go b/pkg/config/dummies.go index 5bc349fa0..b872fac29 100644 --- a/pkg/config/dummies.go +++ b/pkg/config/dummies.go @@ -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 diff --git a/pkg/config/editor_presets.go b/pkg/config/editor_presets.go index 5fcde97c5..c101236ee 100644 --- a/pkg/config/editor_presets.go +++ b/pkg/config/editor_presets.go @@ -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}}"; 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` diff --git a/pkg/config/github_pull_request_cache.go b/pkg/config/github_pull_request_cache.go new file mode 100644 index 000000000..3ea8d0841 --- /dev/null +++ b/pkg/config/github_pull_request_cache.go @@ -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 +} diff --git a/pkg/config/github_pull_request_cache_test.go b/pkg/config/github_pull_request_cache_test.go new file mode 100644 index 000000000..aa10d1acb --- /dev/null +++ b/pkg/config/github_pull_request_cache_test.go @@ -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()) +} diff --git a/pkg/config/pager_config.go b/pkg/config/pager_config.go deleted file mode 100644 index 01f92f584..000000000 --- a/pkg/config/pager_config.go +++ /dev/null @@ -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] -} diff --git a/pkg/config/pager_config_test.go b/pkg/config/pager_config_test.go deleted file mode 100644 index 7267b9228..000000000 --- a/pkg/config/pager_config_test.go +++ /dev/null @@ -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()) -} diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 30ce0377d..9738186d9 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -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{""}, diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 9550e9160..81d2792ff 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -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 diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index a0c17636d..370818718 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -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 { diff --git a/pkg/constants/links.go b/pkg/constants/links.go index e9b06cba3..695b24d38 100644 --- a/pkg/constants/links.go +++ b/pkg/constants/links.go @@ -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", }, } diff --git a/pkg/env/env.go b/pkg/env/env.go index 1ade5b8c6..391fa6ac6 100644 --- a/pkg/env/env.go +++ b/pkg/env/env.go @@ -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) + } + } } diff --git a/pkg/gocui/double_click_test.go b/pkg/gocui/double_click_test.go new file mode 100644 index 000000000..b8d9f5f9c --- /dev/null +++ b/pkg/gocui/double_click_test.go @@ -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) +} diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 57818960c..b4cf107de 100644 --- a/pkg/gocui/gui.go +++ b/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) } diff --git a/pkg/gocui/mouse_capture_test.go b/pkg/gocui/mouse_capture_test.go new file mode 100644 index 000000000..eea1e3f9f --- /dev/null +++ b/pkg/gocui/mouse_capture_test.go @@ -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) +} diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index 885bcbabb..312d6d5a2 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -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 diff --git a/pkg/gocui/tcell_driver_test.go b/pkg/gocui/tcell_driver_test.go new file mode 100644 index 000000000..9038e73ce --- /dev/null +++ b/pkg/gocui/tcell_driver_test.go @@ -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 +} diff --git a/pkg/gocui/text_area.go b/pkg/gocui/text_area.go index 7aeb6220a..98a5af4da 100644 --- a/pkg/gocui/text_area.go +++ b/pkg/gocui/text_area.go @@ -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() { diff --git a/pkg/gocui/text_area_test.go b/pkg/gocui/text_area_test.go index f0bc2fca8..f66876d24 100644 --- a/pkg/gocui/text_area_test.go +++ b/pkg/gocui/text_area_test.go @@ -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 \nCo-authored-by: Jane Smith \n", + name: "don't break at space after trailer at beginning of message", + content: "Signed-off-by: John Doe \nDepends-on: Some dependency with spaces\n", autoWrapWidth: 10, - expectedWrappedContent: "abc\nSigned-off-by: John Doe \nCo-authored-by: Jane Smith \n", + expectedWrappedContent: "Signed-off-by: John Doe \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 \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 \nDepends-on: Some dependency with spaces\n", autoWrapWidth: 10, - expectedWrappedContent: "abc\nSigned-off-by:John \nDoe \n\n", - expectedSoftLineBreaks: []int{23, 27}, + expectedWrappedContent: "abc\n\nSigned-off-by: John Doe \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 \n\nMore text here\n", + autoWrapWidth: 10, + expectedWrappedContent: "abc\n\nSigned-off-by: \nJohn Doe \n\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 \n", + autoWrapWidth: 10, + expectedWrappedContent: "abc\n\nFixes: a \nlong \ndescription \nthat wraps\nSigned-off-by: John Doe \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 \n\n", + autoWrapWidth: 10, + expectedWrappedContent: "abc\n\nSigned-off-by: John Doe \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 \n", + autoWrapWidth: 10, + expectedWrappedContent: "abc\n\nSigned-off-by:John \nDoe \n\n", + expectedSoftLineBreaks: []int{24, 28}, }, { name: "hard line breaks", diff --git a/pkg/gocui/ui_thread_test.go b/pkg/gocui/ui_thread_test.go new file mode 100644 index 000000000..d76bfaf9c --- /dev/null +++ b/pkg/gocui/ui_thread_test.go @@ -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) +} diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index b106eb21f..dba71ab52 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -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 == "" { diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index f7e229f1c..2ee5eb4b8 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -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. diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 180b2d445..6a4c529a1 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -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 } diff --git a/pkg/gui/command_log_panel.go b/pkg/gui/command_log_panel.go index 6f7976c3e..e43f69999 100644 --- a/pkg/gui/command_log_panel.go +++ b/pkg/gui/command_log_panel.go @@ -186,7 +186,7 @@ func (gui *Gui) getRandomTip() string { // links fmt.Sprintf( "If you want a git diff with syntax colouring, check out lazygit's integration with delta:\n%s", - constants.Links.Docs.CustomPagers, + constants.Links.Docs.CustomDiffRenderers, ), fmt.Sprintf( "You can build your own custom menus and commands to run from within lazygit. For examples see:\n%s", diff --git a/pkg/gui/context/base_context.go b/pkg/gui/context/base_context.go index 7584b5a12..67b4654a6 100644 --- a/pkg/gui/context/base_context.go +++ b/pkg/gui/context/base_context.go @@ -119,7 +119,7 @@ func (self *BaseContext) GetKey() types.ContextKey { } func (self *BaseContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { - bindings := []*types.Binding{} + bindings := make([]*types.Binding, 0, len(self.keybindingsFns)) for i := range self.keybindingsFns { // the first binding in the bindings array takes precedence but we want the // last keybindingsFn to take precedence to we add them in reverse @@ -216,7 +216,7 @@ func (self *BaseContext) AddOnQuitFn(fn func()) { } func (self *BaseContext) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { - bindings := []*gocui.ViewMouseBinding{} + bindings := make([]*gocui.ViewMouseBinding, 0, len(self.mouseKeybindingsFns)) for i := range self.mouseKeybindingsFns { // the first binding in the bindings array takes precedence but we want the // last keybindingsFn to take precedence to we add them in reverse diff --git a/pkg/gui/context/list_context_trait.go b/pkg/gui/context/list_context_trait.go index 597fc99df..9ab24cf9b 100644 --- a/pkg/gui/context/list_context_trait.go +++ b/pkg/gui/context/list_context_trait.go @@ -89,7 +89,7 @@ func formatListFooter(selectedLineIdx int, length int) string { } func (self *ListContextTrait) HandleFocus(opts types.OnFocusOpts) { - self.FocusLine(opts.ScrollSelectionIntoView) + self.FocusLine(!opts.KeepScrollPosition) self.GetViewTrait().SetHighlight(self.list.Len() > 0) diff --git a/pkg/gui/context/list_renderer.go b/pkg/gui/context/list_renderer.go index b8d036778..59a128457 100644 --- a/pkg/gui/context/list_renderer.go +++ b/pkg/gui/context/list_renderer.go @@ -1,6 +1,7 @@ package context import ( + "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -9,6 +10,10 @@ import ( "golang.org/x/exp/slices" ) +func formatListSectionHeader(label string) string { + return fmt.Sprintf("─── %s", label) +} + type NonModelItem struct { // Where in the model this should be inserted Index int diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index a66c720c9..4a99259fd 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -3,13 +3,16 @@ package context import ( "fmt" "log" + "slices" "strings" "sync/atomic" "time" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/presentation" + "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) @@ -18,6 +21,13 @@ type LocalCommitsContext struct { *LocalCommitsViewModel *ListContextTrait *SearchTrait + + dropIndicator *commitDropIndicator +} + +type commitDropIndicator struct { + insertionIndex int + moving bool } var ( @@ -27,6 +37,7 @@ var ( ) func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { + dropIndicator := &commitDropIndicator{insertionIndex: -1} viewModel := NewLocalCommitsViewModel( func() []*models.Commit { return c.Model().Commits }, c, @@ -72,7 +83,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { if c.Model().WorkingTreeStateAtLastCommitRefresh.Rebasing { result = append(result, &NonModelItem{ Index: 0, - Content: fmt.Sprintf("--- %s ---", c.Tr.PendingRebaseTodosSectionHeader), + Content: formatListSectionHeader(c.Tr.PendingRebaseTodosSectionHeader), }) } @@ -91,10 +102,19 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { c.Tr.PendingRevertsSectionHeader) result = append(result, &NonModelItem{ Index: firstCherryPickOrRevertTodo, - Content: fmt.Sprintf("--- %s ---", label), + Content: formatListSectionHeader(label), }) } + result = addCommitDropIndicator( + result, + dropIndicator, + c.Tr.MoveCommitsHere, + c.Tr.MovingCommitsHere, + c.UserConfig().Gui.Spinner, + time.Now(), + ) + _, firstRealCommit, found := lo.FindIndexOf( c.Model().Commits, func(c *models.Commit) bool { return !c.IsTODO() @@ -104,8 +124,17 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { } result = append(result, &NonModelItem{ Index: firstRealCommit, - Content: fmt.Sprintf("--- %s ---", c.Tr.CommitsSectionHeader), + Content: formatListSectionHeader(c.Tr.CommitsSectionHeader), }) + } else { + result = addCommitDropIndicator( + result, + dropIndicator, + c.Tr.MoveCommitsHere, + c.Tr.MovingCommitsHere, + c.UserConfig().Gui.Spinner, + time.Now(), + ) } return result @@ -114,6 +143,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { ctx := &LocalCommitsContext{ LocalCommitsViewModel: viewModel, SearchTrait: NewSearchTrait(c), + dropIndicator: dropIndicator, ListContextTrait: &ListContextTrait{ Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{ View: c.Views().Commits, @@ -138,6 +168,52 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { return ctx } +func addCommitDropIndicator( + items []*NonModelItem, + indicator *commitDropIndicator, + dropLabel string, + movingLabel string, + spinnerConfig config.SpinnerConfig, + now time.Time, +) []*NonModelItem { + if indicator.insertionIndex < 0 { + return items + } + label := dropLabel + if indicator.moving { + label = fmt.Sprintf("%s %s", movingLabel, presentation.Loader(now, spinnerConfig)) + } + + insertAt := len(items) + for i, item := range items { + if item.Index > indicator.insertionIndex { + insertAt = i + break + } + } + + return slices.Insert(items, insertAt, &NonModelItem{ + Index: indicator.insertionIndex, + Content: style.FgCyan.SetBold().Sprintf("━━━━━━ %s ━━━━━━", label), + Column: 6, // align with the commit subject + }) +} + +func (self *LocalCommitsContext) SetDropInsertionIndex(index int) { + self.dropIndicator.insertionIndex = index + self.dropIndicator.moving = false +} + +func (self *LocalCommitsContext) SetMovingCommitsInsertionIndex(index int) { + self.dropIndicator.insertionIndex = index + self.dropIndicator.moving = true +} + +func (self *LocalCommitsContext) ClearDropInsertionIndex() { + self.dropIndicator.insertionIndex = -1 + self.dropIndicator.moving = false +} + type LocalCommitsViewModel struct { *ListViewModel[*models.Commit] @@ -248,7 +324,13 @@ func (self *LocalCommitsViewModel) GetCommits() []*models.Commit { } func shouldShowGraph(c *ContextCommon) bool { - if c.Modes().Filtering.Active() { + // Whether we can draw a graph is a property of the commit list we have + // loaded, not of the filtering mode: turning filtering on or off only + // reaches the screen when the reloaded list does, and until then the graph + // has to keep matching the list that is still on display. Drawing one for a + // filtered list is also ruinously slow, because none of the commits in it + // are connected to each other, so no pipe ever terminates. + if c.Model().CommitsWereFilteredAtLastRefresh { return false } diff --git a/pkg/gui/context/local_commits_context_test.go b/pkg/gui/context/local_commits_context_test.go new file mode 100644 index 000000000..f93af3a72 --- /dev/null +++ b/pkg/gui/context/local_commits_context_test.go @@ -0,0 +1,53 @@ +package context + +import ( + "testing" + "time" + + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/gui/style" + "github.com/stretchr/testify/assert" +) + +func TestAddCommitDropIndicator(t *testing.T) { + pendingHeader := &NonModelItem{Index: 0, Content: "pending"} + commitsHeader := &NonModelItem{Index: 3, Content: "commits"} + indicator := &commitDropIndicator{insertionIndex: 3} + spinnerConfig := config.SpinnerConfig{Frames: []string{"one", "two"}, Rate: 100} + + items := addCommitDropIndicator( + []*NonModelItem{pendingHeader}, indicator, "drop here", "moving commits here", spinnerConfig, time.UnixMilli(0), + ) + items = append(items, commitsHeader) + + assert.Equal(t, []*NonModelItem{ + pendingHeader, + { + Index: 3, + Content: style.FgCyan.SetBold().Sprint("━━━━━━ drop here ━━━━━━"), + Column: 6, + }, + commitsHeader, + }, items) + assert.Equal(t, 6, modelIndexToViewIndex(4, items, 3)) + assert.Equal(t, 3, viewIndexToModelIndex(4, items, 4)) +} + +func TestAddMovingCommitsIndicator(t *testing.T) { + items := addCommitDropIndicator( + nil, + &commitDropIndicator{insertionIndex: 2, moving: true}, + "drop here", + "moving commits here", + config.SpinnerConfig{Frames: []string{"one", "two"}, Rate: 100}, + time.UnixMilli(100), + ) + + assert.Equal(t, []*NonModelItem{ + { + Index: 2, + Content: style.FgCyan.SetBold().Sprint("━━━━━━ moving commits here two ━━━━━━"), + Column: 6, + }, + }, items) +} diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 9feef1e4c..8129aa420 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -198,7 +198,7 @@ func (self *MenuViewModel) GetNonModelItems() []*NonModelItem { result = append(result, &NonModelItem{ Index: i, Column: 1, - Content: style.FgGreen.SetBold().Sprintf("--- %s ---", menuItem.Section.Title), + Content: style.FgGreen.SetBold().Sprint(formatListSectionHeader(menuItem.Section.Title)), }) prevSection = menuItem.Section } diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index 4e05c9594..b0bcee30a 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -1,7 +1,6 @@ package context import ( - "fmt" "time" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -90,7 +89,7 @@ func NewSubCommitsContext( } result = append(result, &NonModelItem{ Index: upstreamIdx, - Content: fmt.Sprintf("--- %s ---", c.Tr.DivergenceSectionHeaderRemote), + Content: formatListSectionHeader(c.Tr.DivergenceSectionHeaderRemote), }) _, localIdx, found := lo.FindIndexOf( @@ -100,7 +99,7 @@ func NewSubCommitsContext( } result = append(result, &NonModelItem{ Index: localIdx, - Content: fmt.Sprintf("--- %s ---", c.Tr.DivergenceSectionHeaderLocal), + Content: formatListSectionHeader(c.Tr.DivergenceSectionHeaderLocal), }) } diff --git a/pkg/gui/controllers/basic_commits_controller.go b/pkg/gui/controllers/basic_commits_controller.go index 410335712..f698f6cf0 100644 --- a/pkg/gui/controllers/basic_commits_controller.go +++ b/pkg/gui/controllers/basic_commits_controller.go @@ -157,6 +157,18 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e } } + commitTagsItem := &types.MenuItem{ + Label: self.c.Tr.CommitTags, + OnPress: func() error { + return self.copyCommitTagsToClipboard(commit) + }, + Keys: menuKey('t'), + } + + if len(commit.Tags) == 0 { + commitTagsItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CommitHasNoTags} + } + items := []*types.MenuItem{ { Label: self.c.Tr.CommitHash, @@ -207,22 +219,9 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e }, Keys: menuKey('a'), }, + commitTagsItem, } - commitTagsItem := types.MenuItem{ - Label: self.c.Tr.CommitTags, - OnPress: func() error { - return self.copyCommitTagsToClipboard(commit) - }, - Keys: menuKey('t'), - } - - if len(commit.Tags) == 0 { - commitTagsItem.DisabledReason = &types.DisabledReason{Text: self.c.Tr.CommitHasNoTags} - } - - items = append(items, &commitTagsItem) - return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.Actions.CopyCommitAttributeToClipboard, Items: items, diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index a73ee3bc2..cfc46b503 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -5,15 +5,12 @@ import ( "fmt" "strings" - "github.com/gookit/color" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/presentation" - "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" - "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" @@ -214,13 +211,7 @@ func (self *BranchesController) GetOnRenderToMain() func() { pr, ok := self.c.Model().PullRequestsMap[branch.Name] if ok && presentation.ShouldShowPrForBranch(pr, branch.Name, self.c.UserConfig()) { - icon := lo.Ternary(icons.IsIconEnabled(), icons.IconForRemoteUrl(pr.Url)+" ", "") - ptyTask.Prefix = style.PrintHyperlink(fmt.Sprintf("%s%s %s %s\n", - icon, - coloredStateText(pr.State), - pr.Title, - style.FgCyan.Sprintf("#%d", pr.Number)), - pr.Url) + ptyTask.Prefix = presentation.FormatPullRequestHeader(pr, self.c.Tr) ptyTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n" } } @@ -236,37 +227,6 @@ func (self *BranchesController) GetOnRenderToMain() func() { } } -func stateText(state string) string { - var icon, label string - switch state { - case "OPEN": - icon, label = " ", "Open" - case "CLOSED": - icon, label = " ", "Closed" - case "MERGED": - icon, label = " ", "Merged" - case "DRAFT": - icon, label = " ", "Draft" - default: - return "" - } - if icons.IsIconEnabled() { - return icon + label - } - return label -} - -func coloredStateText(state string) string { - if icons.IsIconEnabled() { - return fmt.Sprintf("%s%s%s", - presentation.WithPrColor(state, "", false), - presentation.WithPrColor(state, color.RGB(0xFF, 0xFF, 0xFF, false).Sprint(stateText(state)), true), - presentation.WithPrColor(state, "", false)) - } - - return presentation.WithPrColor(state, stateText(state), false) -} - func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branch) error { upstream := lo.Ternary(selectedBranch.RemoteBranchStoredLocally(), selectedBranch.ShortUpstreamRefName(), diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 6c8f49e1f..4aa46a28c 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -339,7 +339,10 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN HandleConfirm: func() error { commits := self.c.Model().Commits selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx() - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RebasingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { var filePaths []string selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes) @@ -613,24 +616,9 @@ func (self *CommitFilesController) GetOnClickFocusedMainView() func(mainViewName } } -// pathsForDiff returns the file paths to use for a diff command. When a text -// filter is active and the node is a directory, only the visible (filtered) -// file paths are returned so the diff reflects what the user sees. func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) []string { - if !node.IsFile() && self.context().IsFiltering() { - var paths []string - _ = node.ForEachFile(func(file *models.CommitFile) error { - // For a rename we need to pass both paths so that git detects it as - // a rename rather than an unrelated delete and add. - paths = append(paths, file.Names()...) - return nil - }) - return paths - } - if file := node.GetFile(); file != nil { - return file.Names() - } - return []string{node.GetPath()} + return diffPathsForNode( + node.Raw(), self.context().GetRoot().Raw(), self.c.Model().CommitFiles, self.context().IsFiltering()) } // NOTE: these functions are identical to those in files_controller.go (except for types) and diff --git a/pkg/gui/controllers/diff_paths.go b/pkg/gui/controllers/diff_paths.go new file mode 100644 index 000000000..e9c12606f --- /dev/null +++ b/pkg/gui/controllers/diff_paths.go @@ -0,0 +1,132 @@ +package controllers + +import ( + "path" + "strings" + + "github.com/jesseduffield/lazygit/pkg/gui/filetree" + "github.com/samber/lo" +) + +// Both models.File and models.CommitFile satisfy this. Names returns the file's +// path, plus the path it was renamed from if it is a rename. +type fileWithNames[T any] interface { + *T + GetPath() string + GetPreviousPath() string + Names() []string +} + +// diffPathsForNode returns the paths to limit a diff command to for showing the +// changes of the given node. files are all the files that the diff contains, +// while root is the root of the tree the node belongs to, which holds only the +// files matching the text filter when there is one. +func diffPathsForNode[T any, PT fileWithNames[T]](node *filetree.Node[T], root *filetree.Node[T], files []*T, isFiltering bool) []string { + if file := node.GetFile(); file != nil { + return PT(file).Names() + } + + dir := node.GetPath() + + if isFiltering { + // Passing the directory would bring back the files that the filter hides, + // so we spell out the ones it leaves. + var paths []string + for _, file := range filesInDir[T, PT](filesInTree(root), dir) { + paths = append(paths, PT(file).Names()...) + } + return paths + } + + // The directory covers everything below it, but git only pairs up the two + // ends of a rename if both are in the pathspec, and one end can well be + // outside the directory. Without that end we would get an addition or a + // deletion where the diff has a rename. + var outsidePaths []string + for _, f := range filesInDir[T, PT](files, dir) { + file := PT(f) + if p := file.GetPath(); !isInDir(p, dir) { + outsidePaths = append(outsidePaths, p) + } + if p := file.GetPreviousPath(); p != "" && !isInDir(p, dir) { + outsidePaths = append(outsidePaths, p) + } + } + + return dropContainedPaths(append([]string{dir}, collapseToDirs[T, PT](outsidePaths, files, dir)...)) +} + +// dropContainedPaths removes the paths that another one of them contains, since +// a pathspec that matches a directory matches everything below it anyway. +func dropContainedPaths(paths []string) []string { + return lo.Filter(paths, func(p string, _ int) bool { + return !lo.SomeBy(paths, func(other string) bool { + return other != p && isInDir(p, other) + }) + }) +} + +// collapseToDirs replaces each of the given paths with the highest directory +// that can stand in for it, so that moving a whole directory elsewhere costs a +// single pathspec rather than one per file. There is a limit to how long a +// command line may get, and a commit can move a great many files at once. +func collapseToDirs[T any, PT fileWithNames[T]](paths []string, files []*T, dir string) []string { + if len(paths) == 0 { + return nil + } + + // A directory can stand in for the paths under it as long as everything it + // contains ends up in the diff anyway, which is to say as long as all of it + // is in the directory we are diffing too. + canStandIn := make(map[string]bool) + standsIn := func(candidate string) bool { + if result, ok := canStandIn[candidate]; ok { + return result + } + + result := lo.EveryBy(files, func(file *T) bool { + return !fileIsInDir[T, PT](file, candidate) || fileIsInDir[T, PT](file, dir) + }) + canStandIn[candidate] = result + return result + } + + return lo.Uniq(lo.Map(paths, func(p string, _ int) string { + // A directory that can't stand in for the path rules out its parents + // too, since they contain everything it contains. We stop short of the + // repository root: it would leave the command with nothing to say about + // the directory whose diff we are showing. + for candidate := path.Dir(p); candidate != "." && standsIn(candidate); candidate = path.Dir(candidate) { + p = candidate + } + return p + })) +} + +func filesInTree[T any](root *filetree.Node[T]) []*T { + files := []*T{} + _ = root.ForEachFile(func(file *T) error { + files = append(files, file) + return nil + }) + return files +} + +// filesInDir returns the files that the given directory contains, either at +// their current or at their previous path. +func filesInDir[T any, PT fileWithNames[T]](files []*T, dir string) []*T { + return lo.Filter(files, func(file *T, _ int) bool { + return fileIsInDir[T, PT](file, dir) + }) +} + +func fileIsInDir[T any, PT fileWithNames[T]](f *T, dir string) bool { + file := PT(f) + previousPath := file.GetPreviousPath() + return isInDir(file.GetPath(), dir) || (previousPath != "" && isInDir(previousPath, dir)) +} + +func isInDir(path string, dir string) bool { + // "." is the root item, which contains every file + return dir == "." || strings.HasPrefix(path, dir+"/") +} diff --git a/pkg/gui/controllers/diff_paths_test.go b/pkg/gui/controllers/diff_paths_test.go new file mode 100644 index 000000000..549ed4f31 --- /dev/null +++ b/pkg/gui/controllers/diff_paths_test.go @@ -0,0 +1,113 @@ +package controllers + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gui/filetree" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +func TestDiffPathsForNode(t *testing.T) { + files := []*models.CommitFile{ + {Path: "dir/file1", PreviousPath: "file1", ChangeStatus: "R"}, + {Path: "dir/file2-renamed", PreviousPath: "dir/file2", ChangeStatus: "R"}, + {Path: "dir/sub/file3", ChangeStatus: "M"}, + {Path: "file4", PreviousPath: "dir/sub/file4", ChangeStatus: "R"}, + {Path: "file5", ChangeStatus: "M"}, + } + + scenarios := []struct { + testName string + files []*models.CommitFile // defaults to the files above + selectedPath string + isFiltering bool + expectedPaths []string + }{ + { + testName: "file", + selectedPath: "dir/sub/file3", + expectedPaths: []string{"dir/sub/file3"}, + }, + { + testName: "renamed file", + selectedPath: "dir/file1", + expectedPaths: []string{"dir/file1", "file1"}, + }, + { + testName: "directory: pass the other end of each rename that crosses its boundary", + selectedPath: "dir", + // dir/file2-renamed was renamed within the directory, so both of its + // paths are covered by it already + expectedPaths: []string{"dir", "file1", "file4"}, + }, + { + testName: "directory without renames crossing its boundary", + selectedPath: "dir/sub", + expectedPaths: []string{"dir/sub", "file4"}, + }, + { + testName: "root", + selectedPath: ".", + expectedPaths: []string{"."}, + }, + { + testName: "a whole directory moved into the selected one collapses to that directory", + files: []*models.CommitFile{ + {Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"}, + {Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"}, + {Path: "dir/c", PreviousPath: "src/nested/c", ChangeStatus: "R"}, + {Path: "unrelated", ChangeStatus: "M"}, + }, + selectedPath: "dir", + expectedPaths: []string{"dir", "src"}, + }, + { + testName: "a directory that stands in for the selected one as well", + files: []*models.CommitFile{ + {Path: "a/b/c", PreviousPath: "a/c", ChangeStatus: "R"}, + {Path: "a/b/d", ChangeStatus: "M"}, + {Path: "unrelated", ChangeStatus: "M"}, + }, + selectedPath: "a/b", + expectedPaths: []string{"a"}, + }, + { + testName: "a directory with changes of its own doesn't collapse", + files: []*models.CommitFile{ + {Path: "dir/a", PreviousPath: "src/a", ChangeStatus: "R"}, + {Path: "dir/b", PreviousPath: "src/nested/b", ChangeStatus: "R"}, + {Path: "src/nested/c", ChangeStatus: "M"}, + }, + selectedPath: "dir", + // src/nested is left out of it, so that only src/a stays behind + expectedPaths: []string{"dir", "src/a", "src/nested/b"}, + }, + { + testName: "directory while filtering", + selectedPath: "dir", + isFiltering: true, + expectedPaths: []string{ + "dir/file1", "file1", + "dir/file2-renamed", "dir/file2", + "dir/sub/file3", + "file4", "dir/sub/file4", + }, + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + files := lo.Ternary(s.files != nil, s.files, files) + cmp := filetree.NodeSortComparator[models.CommitFile]("mixed", true) + root := filetree.BuildTreeFromCommitFiles(files, true, cmp) + node, found := lo.Find(root.Flatten(filetree.NewCollapsedPaths()), func(node *filetree.Node[models.CommitFile]) bool { + return node.GetPath() == s.selectedPath + }) + assert.True(t, found, "no node for path %s", s.selectedPath) + + assert.Equal(t, s.expectedPaths, diffPathsForNode(node, root, files, s.isFiltering)) + }) + } +} diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 567b0b6e5..e61e1409c 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -130,10 +130,11 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types OpensMenu: true, }, { - Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll), - Handler: self.toggleStagedAll, - Description: self.c.Tr.ToggleStagedAll, - Tooltip: self.c.Tr.ToggleStagedAllTooltip, + Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll), + Handler: self.toggleStagedAll, + GetDisabledReason: self.require(self.anyFilesDisplayed), + Description: self.c.Tr.ToggleStagedAll, + Tooltip: self.c.Tr.ToggleStagedAllTooltip, }, { Keys: opts.GetKeys(opts.Config.Universal.GoInto), @@ -328,7 +329,7 @@ func (self *FilesController) renderSubmoduleConflict(node *filetree.FileNode) { // (it was resolved in an editor), in which case the caller should fall back to // showing the file's diff. func (self *FilesController) renderInlineMergeConflict(node *filetree.FileNode) bool { - hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath()) + hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.File) if err != nil { return true } @@ -368,8 +369,8 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { split := self.c.UserConfig().Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges()) mainShowsStaged := !split && node.GetHasStagedChanges() - pathOverrides := self.pathOverridesForDiff(node) - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, pathOverrides) + paths := self.pathsForDiff(node) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged, paths) title := self.c.Tr.UnstagedChanges if mainShowsStaged { title = self.c.Tr.StagedChanges @@ -384,7 +385,7 @@ func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { } if split { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, paths) title := self.c.Tr.StagedChanges if mainShowsStaged { @@ -642,19 +643,9 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error { return nil } -// pathOverridesForDiff returns file paths to override the node's path in diff -// commands when a text filter is active and the node is a directory. This -// ensures the diff only shows filtered/visible files. -func (self *FilesController) pathOverridesForDiff(node *filetree.FileNode) []string { - if !node.IsFile() && self.context().IsFiltering() { - var paths []string - _ = node.ForEachFile(func(file *models.File) error { - paths = append(paths, file.Path) - return nil - }) - return paths - } - return nil +func (self *FilesController) pathsForDiff(node *filetree.FileNode) []string { + return diffPathsForNode( + node.Raw(), self.context().GetRoot().Raw(), self.c.Model().Files, self.context().IsFiltering()) } // unstageFilteredFiles unstages only the visible (filtered) files from the @@ -916,6 +907,17 @@ func (self *FilesController) openSubmoduleConflictMenu(file *models.File) error }) } +// The stage-all command acts on the file tree as it is displayed, so there has +// to be something in it. This is also the case before the first files refresh +// has come in, when there is no tree at all yet. +func (self *FilesController) anyFilesDisplayed() *types.DisabledReason { + if self.context().FileTreeViewModel.Len() == 0 { + return &types.DisabledReason{Text: self.c.Tr.NoChangedFiles} + } + + return nil +} + func (self *FilesController) toggleStagedAll() error { if err := self.toggleStagedAllWithLock(); err != nil { return err @@ -1264,7 +1266,7 @@ func (self *FilesController) switchToMerge() error { return nil } - return self.c.Helpers().MergeConflicts.SwitchToMerge(file.Path) + return self.c.Helpers().MergeConflicts.SwitchToMerge(file) } func (self *FilesController) createStashMenu() error { @@ -1508,13 +1510,20 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.StashChanges, HandleConfirm: func(stashComment string) error { - self.c.LogAction(action) + return self.c.WithWaitingStatusBlockingInput( + types.WaitingStatusOpts{Message: self.c.Tr.StashingStatus}, + func(gocui.Task) error { + self.c.LogAction(action) - if err := stashFunc(stashComment); err != nil { - return err - } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}}) - return nil + if err := stashFunc(stashComment); err != nil { + return err + } + self.c.RefreshFromWorker(types.RefreshOptions{ + BatchUIUpdates: true, + Scope: []types.RefreshableView{types.STASH, types.FILES}, + }) + return nil + }) }, AllowEmptyInput: true, }) diff --git a/pkg/gui/controllers/filtering_menu_action.go b/pkg/gui/controllers/filtering_menu_action.go index 7ae26c4ef..0bd7ca902 100644 --- a/pkg/gui/controllers/filtering_menu_action.go +++ b/pkg/gui/controllers/filtering_menu_action.go @@ -3,7 +3,6 @@ package controllers import ( "fmt" - "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -42,7 +41,7 @@ func (self *FilteringMenuAction) Call() error { menuItems = append(menuItems, &types.MenuItem{ Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, fileName), OnPress: func() error { - return self.setFilteringPath(fileName) + return self.c.Helpers().Mode.SetFilteringPath(fileName) }, Tooltip: tooltip, }) @@ -52,7 +51,7 @@ func (self *FilteringMenuAction) Call() error { menuItems = append(menuItems, &types.MenuItem{ Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, author), OnPress: func() error { - return self.setFilteringAuthor(author) + return self.c.Helpers().Mode.SetFilteringAuthor(author) }, Tooltip: tooltip, }) @@ -65,7 +64,7 @@ func (self *FilteringMenuAction) Call() error { FindSuggestionsFunc: self.c.Helpers().Suggestions.GetFilePathSuggestionsFunc(), Title: self.c.Tr.EnterFileName, HandleConfirm: func(response string) error { - return self.setFilteringPath(response) + return self.c.Helpers().Mode.SetFilteringPath(response) }, }) @@ -81,7 +80,7 @@ func (self *FilteringMenuAction) Call() error { FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), Title: self.c.Tr.EnterAuthor, HandleConfirm: func(response string) error { - return self.setFilteringAuthor(response) + return self.c.Helpers().Mode.SetFilteringAuthor(response) }, }) @@ -99,34 +98,3 @@ func (self *FilteringMenuAction) Call() error { return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.FilteringMenuTitle, Items: menuItems}) } - -func (self *FilteringMenuAction) setFilteringPath(path string) error { - self.c.Modes().Filtering.Reset() - self.c.Modes().Filtering.SetPath(path) - return self.setFiltering() -} - -func (self *FilteringMenuAction) setFilteringAuthor(author string) error { - self.c.Modes().Filtering.Reset() - self.c.Modes().Filtering.SetAuthor(author) - return self.setFiltering() -} - -func (self *FilteringMenuAction) setFiltering() error { - self.c.Modes().Filtering.SetSelectedCommitHash(self.c.Contexts().LocalCommits.GetSelectedCommitHash()) - - repoState := self.c.State().GetRepoState() - if repoState.GetScreenMode() == types.SCREEN_NORMAL { - repoState.SetScreenMode(types.SCREEN_HALF) - } - - self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) - - self.c.Refresh(types.RefreshOptions{Scope: helpers.ScopesToRefreshWhenFilteringModeChanges(), Then: func() error { - self.c.Contexts().LocalCommits.SetSelection(0) - self.c.Contexts().LocalCommits.HandleFocus(types.OnFocusOpts{}) - return nil - }}) - - return nil -} diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index 77ef29070..b50ec6d1d 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -62,18 +62,18 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type Description: self.c.Tr.PrevScreenMode, }, { - Keys: opts.GetKeys(opts.Config.Universal.CyclePagers), - Handler: opts.Guards.NoPopupPanel(self.cyclePagers), - GetDisabledReason: self.canCyclePagers, - Description: self.c.Tr.CyclePagers, - Tooltip: self.c.Tr.CyclePagersTooltip, + Keys: opts.GetKeys(opts.Config.Universal.CycleDiffRenderers), + Handler: opts.Guards.NoPopupPanel(self.cycleDiffRenderers), + GetDisabledReason: self.canCycleDiffRenderers, + Description: self.c.Tr.CycleDiffRenderers, + Tooltip: self.c.Tr.CycleDiffRenderersTooltip, }, { - Keys: opts.GetKeys(opts.Config.Universal.CyclePagersReverse), - Handler: opts.Guards.NoPopupPanel(self.cyclePagersBackward), - GetDisabledReason: self.canCyclePagers, - Description: self.c.Tr.CyclePagersReverse, - Tooltip: self.c.Tr.CyclePagersReverseTooltip, + Keys: opts.GetKeys(opts.Config.Universal.CycleDiffRenderersReverse), + Handler: opts.Guards.NoPopupPanel(self.cycleDiffRenderersBackward), + GetDisabledReason: self.canCycleDiffRenderers, + Description: self.c.Tr.CycleDiffRenderersReverse, + Tooltip: self.c.Tr.CycleDiffRenderersReverseTooltip, }, { Keys: opts.GetKeys(opts.Config.Universal.Return), @@ -170,21 +170,21 @@ func (self *GlobalController) prevScreenMode() error { return (&ScreenModeActions{c: self.c}).Prev() } -func (self *GlobalController) cyclePagers() error { - self.c.State().GetPagerConfig().CyclePagers() - self.onPagerChanged() +func (self *GlobalController) cycleDiffRenderers() error { + self.c.State().GetDiffRendererConfigManager().CycleDiffRenderers() + self.onDiffRenderersChanged() return nil } -func (self *GlobalController) cyclePagersBackward() error { - self.c.State().GetPagerConfig().CyclePagersBackward() - self.onPagerChanged() +func (self *GlobalController) cycleDiffRenderersBackward() error { + self.c.State().GetDiffRendererConfigManager().CycleDiffRenderersBackward() + self.onDiffRenderersChanged() return nil } -// onPagerChanged re-renders the main view so the newly selected pager takes -// effect, and shows a toast naming it. -func (self *GlobalController) onPagerChanged() { +// onDiffRenderersChanged re-renders the main view so the newly selected diff renderer +// takes effect, and shows a toast naming it. +func (self *GlobalController) onDiffRenderersChanged() { currentSide := self.c.Context().CurrentSide() currentKey := self.c.Context().Current().GetKey() if currentSide.GetKey() == currentKey || @@ -193,28 +193,21 @@ func (self *GlobalController) onPagerChanged() { currentSide.HandleRenderToMain() } - pagerConfig := self.c.State().GetPagerConfig() - current, total := pagerConfig.CurrentPagerIndex() - name := pagerConfig.CurrentPagerName() - if name == "" { - if pagerConfig.CurrentPagerUsesGitConfigDiff() { - name = self.c.Tr.ExternalDiffPagerName - } else { - name = self.c.Tr.DefaultPagerName - } - } - self.c.Toast(utils.ResolvePlaceholderString(self.c.Tr.SelectedPager, map[string]string{ + diffRendererConfigManager := self.c.State().GetDiffRendererConfigManager() + current, total := diffRendererConfigManager.CurrentDiffRendererIndex() + name := diffRendererConfigManager.CurrentDiffRendererName(self.c.Tr) + self.c.Toast(utils.ResolvePlaceholderString(self.c.Tr.SelectedDiffRenderers, map[string]string{ "name": name, "current": strconv.Itoa(current + 1), "total": strconv.Itoa(total), })) } -func (self *GlobalController) canCyclePagers() *types.DisabledReason { - _, total := self.c.State().GetPagerConfig().CurrentPagerIndex() +func (self *GlobalController) canCycleDiffRenderers() *types.DisabledReason { + _, total := self.c.State().GetDiffRendererConfigManager().CurrentDiffRendererIndex() if total <= 1 { return &types.DisabledReason{ - Text: self.c.Tr.CyclePagersDisabledReason, + Text: self.c.Tr.CycleDiffRenderersDisabledReason, } } return nil diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index d0bb03395..44de70546 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -85,30 +85,32 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui. // WithWaitingStatusBlockingInput is like WithWaitingStatus, but it also blocks // keyboard input for the whole duration of the operation: keys the user presses // while it runs are buffered and replayed against the post-operation state (see -// gocui.BeginBlockingEvents). Use it for operations that manipulate an -// in-progress rebase or otherwise rewrite commits, where a racing keypress -// would target the wrong commit or todo. +// gocui.BeginBlockingEvents). Use it for operations whose following keypress +// depends on the state they produce, e.g. ones that manipulate an in-progress +// rebase or otherwise rewrite commits, where a racing keypress would target the +// wrong commit or todo. // // Must be called on the UI thread: the block is begun synchronously here, before // the operation is dispatched to a worker, so no keypress can slip through in // between. -func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) { +func (self *AppStatusHelper) WithWaitingStatusBlockingInput(opts types.WaitingStatusOpts, f func(gocui.Task) error) { self.c.GocuiGui().BeginBlockingEvents() - // Hide the rebasing-mode indicator (and its reset button) while we drive the - // rebase ourselves; it reflects the transient on-disk state and would - // otherwise flash on for the duration of the operation. - self.modeHelper.SetSuppressRebasingMode(true) + if opts.HideWorkingTreeState { + self.modeHelper.SetSuppressWorkingTreeStateMode(true) + } self.c.OnWorker(func(task gocui.Task) error { // End the block and restore the mode indicator once the operation and its // refresh have applied their UI updates: OnUIThread queues this after the // refresh's model bounces and Then (which RefreshFromWorker has already // enqueued by the time f returns), so the replayed keys act on the - // refreshed state and any resulting rebase state shows correctly. + // refreshed state and any resulting working tree state shows correctly. defer self.c.OnUIThread(func() error { - self.modeHelper.SetSuppressRebasingMode(false) + if opts.HideWorkingTreeState { + self.modeHelper.SetSuppressWorkingTreeStateMode(false) + } return self.c.GocuiGui().EndBlockingEvents() }) - return self.WithWaitingStatusImpl(message, f, task) + return self.WithWaitingStatusImpl(opts.Message, f, task) }) } diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go index fc96b9d1b..f82f8843a 100644 --- a/pkg/gui/controllers/helpers/cherry_pick_helper.go +++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go @@ -85,7 +85,10 @@ func (self *CherryPickHelper) Paste() error { HandleConfirm: func() error { mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) cherryPickedCommits := self.getData().CherryPickedCommits - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CherryPickingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.CherryPickingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.CherryPick) if mustStash { diff --git a/pkg/gui/controllers/helpers/drag_autoscroller.go b/pkg/gui/controllers/helpers/drag_autoscroller.go new file mode 100644 index 000000000..7ec6a9c76 --- /dev/null +++ b/pkg/gui/controllers/helpers/drag_autoscroller.go @@ -0,0 +1,154 @@ +package helpers + +import ( + "time" + + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +const ( + dragAutoscrollInitialDelay = 300 * time.Millisecond + dragAutoscrollSlowInterval = 250 * time.Millisecond + dragAutoscrollFastInterval = 100 * time.Millisecond + dragAutoscrollVeryFastInterval = 50 * time.Millisecond +) + +// All state is UI-thread-owned. Timer goroutines only enqueue tick back onto +// the UI thread, where generation changes and scroll callbacks are serialized +// with mouse handlers and focus changes. +type DragAutoscroller struct { + c *HelperCommon + context types.Context + + canScroll func(direction int) bool + onScroll func(viewIndex int) bool + + // Incremented whenever the scroll direction changes or the autoscroller + // is canceled. A scheduled tick carries the generation it was created + // for, so stale ticks can be told apart from the one that is current. + generation uint64 + direction int + interval time.Duration + // Last known pointer position relative to the viewport; used by ticks to + // compute which line ends up under the pointer after scrolling. + pointerViewportY int +} + +func NewDragAutoscroller( + c *HelperCommon, + context types.Context, + canScroll func(direction int) bool, + onScroll func(viewIndex int) bool, +) *DragAutoscroller { + return &DragAutoscroller{ + c: c, + context: context, + canScroll: canScroll, + onScroll: onScroll, + } +} + +// Update is called with the pointer position of every drag event. Entering a +// scroll zone arms a timer (with an initial delay, so that merely passing +// through the zone doesn't scroll); once armed, scrolling continues on its +// own until the pointer leaves the zone, the drag ends, or a callback stops +// it. +func (self *DragAutoscroller) Update(pointerViewportY int) { + _, viewportHeight := self.context.GetViewTrait().ViewPortYBounds() + direction, interval := dragAutoscrollZone(viewportHeight, pointerViewportY) + if direction != 0 && self.canScroll != nil && !self.canScroll(direction) { + direction = 0 + interval = 0 + } + + self.pointerViewportY = pointerViewportY + generation, schedule := self.updateState(direction, interval) + if schedule { + self.schedule(generation, dragAutoscrollInitialDelay) + } +} + +func (self *DragAutoscroller) Direction() int { + return self.direction +} + +func (self *DragAutoscroller) updateState(direction int, interval time.Duration) (uint64, bool) { + if direction == self.direction { + self.interval = interval + return self.generation, false + } + + self.generation++ + self.direction = direction + self.interval = interval + return self.generation, direction != 0 +} + +func (self *DragAutoscroller) Cancel() { + self.generation++ + self.direction = 0 + self.interval = 0 +} + +func (self *DragAutoscroller) schedule(generation uint64, delay time.Duration) { + time.AfterFunc(delay, func() { + self.c.OnUIThreadBackground(func() error { + self.tick(generation) + return nil + }) + }) +} + +func (self *DragAutoscroller) tick(generation uint64) { + if generation != self.generation { + return + } + if self.direction == 0 || + self.canScroll != nil && !self.canScroll(self.direction) { + self.Cancel() + return + } + + view := self.context.GetViewTrait() + oldOriginY, _ := view.ViewPortYBounds() + if self.direction < 0 { + view.ScrollUp(1) + } else { + view.ScrollDown(1) + } + newOriginY, _ := view.ViewPortYBounds() + if newOriginY == oldOriginY { + self.Cancel() + return + } + + if !self.onScroll(newOriginY + self.pointerViewportY) { + self.Cancel() + return + } + + self.schedule(generation, self.interval) +} + +// dragAutoscrollZone returns the scroll direction and tick interval for a +// pointer position: anything beyond the view scrolls very fast, the outermost +// viewport row scrolls fast, the row just inside it scrolls slowly, and anything +// further inside doesn't scroll at all. +func dragAutoscrollZone(viewportHeight int, pointerViewportY int) (int, time.Duration) { + switch { + case pointerViewportY < 0: + return -1, dragAutoscrollVeryFastInterval + case pointerViewportY == 0: + return -1, dragAutoscrollFastInterval + case pointerViewportY == 1: + return -1, dragAutoscrollSlowInterval + case pointerViewportY > viewportHeight-1: + return 1, dragAutoscrollVeryFastInterval + case pointerViewportY == viewportHeight-1: + return 1, dragAutoscrollFastInterval + case pointerViewportY == viewportHeight-2: + return 1, dragAutoscrollSlowInterval + default: + return 0, 0 + } +} diff --git a/pkg/gui/controllers/helpers/drag_autoscroller_test.go b/pkg/gui/controllers/helpers/drag_autoscroller_test.go new file mode 100644 index 000000000..40faa789e --- /dev/null +++ b/pkg/gui/controllers/helpers/drag_autoscroller_test.go @@ -0,0 +1,62 @@ +package helpers + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDragAutoscrollZone(t *testing.T) { + testCases := []struct { + name string + pointerViewportY int + expectedDirection int + expectedInterval time.Duration + }{ + {name: "above view", pointerViewportY: -1, expectedDirection: -1, expectedInterval: dragAutoscrollVeryFastInterval}, + {name: "top outer row", pointerViewportY: 0, expectedDirection: -1, expectedInterval: dragAutoscrollFastInterval}, + {name: "top inner row", pointerViewportY: 1, expectedDirection: -1, expectedInterval: dragAutoscrollSlowInterval}, + {name: "middle", pointerViewportY: 5}, + {name: "bottom inner row", pointerViewportY: 8, expectedDirection: 1, expectedInterval: dragAutoscrollSlowInterval}, + {name: "bottom outer row", pointerViewportY: 9, expectedDirection: 1, expectedInterval: dragAutoscrollFastInterval}, + {name: "below view", pointerViewportY: 10, expectedDirection: 1, expectedInterval: dragAutoscrollVeryFastInterval}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + direction, interval := dragAutoscrollZone(10, testCase.pointerViewportY) + + assert.Equal(t, testCase.expectedDirection, direction) + assert.Equal(t, testCase.expectedInterval, interval) + }) + } +} + +func TestDragAutoscrollerDoesNotRestartWhenMovingToOuterEdge(t *testing.T) { + self := &DragAutoscroller{ + generation: 1, + direction: 1, + interval: dragAutoscrollSlowInterval, + } + + generation, schedule := self.updateState(1, dragAutoscrollFastInterval) + + assert.Equal(t, uint64(1), generation) + assert.False(t, schedule) + assert.Equal(t, dragAutoscrollFastInterval, self.interval) +} + +func TestStaleDragAutoscrollTickDoesNotCancelCurrentGeneration(t *testing.T) { + self := &DragAutoscroller{ + generation: 4, + direction: 1, + interval: dragAutoscrollFastInterval, + } + + self.tick(2) + + assert.Equal(t, uint64(4), self.generation) + assert.Equal(t, 1, self.direction) + assert.Equal(t, dragAutoscrollFastInterval, self.interval) +} diff --git a/pkg/gui/controllers/helpers/fixup_helper.go b/pkg/gui/controllers/helpers/fixup_helper.go index e8fa43f2d..e998c2ad1 100644 --- a/pkg/gui/controllers/helpers/fixup_helper.go +++ b/pkg/gui/controllers/helpers/fixup_helper.go @@ -141,7 +141,6 @@ func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error { } self.c.Contexts().LocalCommits.SetSelection(index) - self.c.Contexts().LocalCommits.FocusLine(true) self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) return nil }, diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 7c7ab3e9a..8f27efa08 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -188,9 +188,8 @@ func (self *MergeAndRebaseHelper) hasExecTodos(calledFromWorker bool) bool { } result := false - _ = self.c.GocuiGui().OnUIThreadAndWait(func() error { + _ = self.c.GocuiGui().OnUIThreadAndWait(func() { result = check() - return nil }) return result } diff --git a/pkg/gui/controllers/helpers/merge_conflicts_helper.go b/pkg/gui/controllers/helpers/merge_conflicts_helper.go index 34ae285f0..3928ecebf 100644 --- a/pkg/gui/controllers/helpers/merge_conflicts_helper.go +++ b/pkg/gui/controllers/helpers/merge_conflicts_helper.go @@ -1,6 +1,7 @@ package helpers import ( + "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -17,14 +18,14 @@ func NewMergeConflictsHelper( } } -func (self *MergeConflictsHelper) SetMergeState(path string) (bool, error) { +func (self *MergeConflictsHelper) SetMergeState(file *models.File) (bool, error) { self.context().GetMutex().Lock() defer self.context().GetMutex().Unlock() - return self.setMergeStateWithoutLock(path) + return self.setMergeStateWithoutLock(file.Path, file.ConflictMarkerSize) } -func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string) (bool, error) { +func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string, markerSize int) (bool, error) { content, err := self.c.Git().File.Cat(path) if err != nil { return false, err @@ -34,7 +35,7 @@ func (self *MergeConflictsHelper) setMergeStateWithoutLock(path string) (bool, e self.context().SetUserScrolling(false) } - self.context().GetState().SetContent(content, path) + self.context().GetState().SetContent(content, path, markerSize) return !self.context().GetState().NoConflicts(), nil } @@ -72,7 +73,8 @@ func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) { self.context().GetMutex().Lock() defer self.context().GetMutex().Unlock() - hasConflicts, err := self.setMergeStateWithoutLock(self.context().GetState().GetPath()) + state := self.context().GetState() + hasConflicts, err := self.setMergeStateWithoutLock(state.GetPath(), state.GetMarkerSize()) if err != nil { return false, err } @@ -84,9 +86,9 @@ func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) { return false, nil } -func (self *MergeConflictsHelper) SwitchToMerge(path string) error { - if self.context().GetState().GetPath() != path { - hasConflicts, err := self.SetMergeState(path) +func (self *MergeConflictsHelper) SwitchToMerge(file *models.File) error { + if self.context().GetState().GetPath() != file.Path { + hasConflicts, err := self.SetMergeState(file) if err != nil { return err } diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index 4947e42d1..68f7ea149 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" @@ -12,12 +13,12 @@ import ( type ModeHelper struct { c *HelperCommon - diffHelper *DiffHelper - patchBuildingHelper *PatchBuildingHelper - cherryPickHelper *CherryPickHelper - mergeAndRebaseHelper *MergeAndRebaseHelper - bisectHelper *BisectHelper - suppressRebasingMode bool + diffHelper *DiffHelper + patchBuildingHelper *PatchBuildingHelper + cherryPickHelper *CherryPickHelper + mergeAndRebaseHelper *MergeAndRebaseHelper + bisectHelper *BisectHelper + suppressWorkingTreeStateMode bool } func NewModeHelper( @@ -130,7 +131,7 @@ func (self *ModeHelper) Statuses() []ModeStatus { }, { IsActive: func() bool { - return !self.suppressRebasingMode && self.c.Git().Status.WorkingTreeState().Any() + return !self.suppressWorkingTreeStateMode && self.c.Git().Status.WorkingTreeState().Any() }, InfoLabel: func() string { workingTreeState := self.c.Git().Status.WorkingTreeState() @@ -182,16 +183,39 @@ func (self *ModeHelper) ExitFilterMode() error { return self.ClearFiltering() } +func (self *ModeHelper) SetFilteringPath(path string) error { + return self.setFiltering(func() { + self.c.Modes().Filtering.SetPath(path) + }) +} + +func (self *ModeHelper) SetFilteringAuthor(author string) error { + return self.setFiltering(func() { + self.c.Modes().Filtering.SetAuthor(author) + }) +} + +func (self *ModeHelper) setFiltering(setFilter func()) error { + return self.changeFiltering( + func() { + // Whatever we were filtering by before is replaced, not added to + self.c.Modes().Filtering.Reset() + setFilter() + self.c.Modes().Filtering.SetSelectedCommitHash( + self.c.Contexts().LocalCommits.GetSelectedCommitHash()) + }, + func() { + self.c.Contexts().LocalCommits.SetSelection(0) + }, + ) +} + func (self *ModeHelper) ClearFiltering() error { selectedCommitHash := self.c.Contexts().LocalCommits.GetSelectedCommitHash() - self.c.Modes().Filtering.Reset() - if self.c.State().GetRepoState().GetScreenMode() == types.SCREEN_HALF { - self.c.State().GetRepoState().SetScreenMode(types.SCREEN_NORMAL) - } - self.c.Refresh(types.RefreshOptions{ - Scope: ScopesToRefreshWhenFilteringModeChanges(), - Then: func() error { + return self.changeFiltering( + self.c.Modes().Filtering.Reset, + func() { // Find the commit that was last selected in filtering mode, and select it again after refreshing if !self.c.Contexts().LocalCommits.SelectCommitByHash(selectedCommitHash) { // If we couldn't find it (either because no commit was selected @@ -200,12 +224,57 @@ func (self *ModeHelper) ClearFiltering() error { // before we entered filtering self.c.Contexts().LocalCommits.SelectCommitByHash(self.c.Modes().Filtering.GetSelectedCommitHash()) } - - self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) - return nil }, + ) +} + +// changeFiltering applies a change to the filtering mode: setFilter mutates the +// mode, then the views whose contents depend on the filter are reloaded, and +// selectCommit puts the selection where it belongs in the reloaded commit list. +// +// Reloading the commit list can take seconds in a big repo, so it happens on a +// worker with a waiting status. Everything the user can see of the change waits +// for it: the screen mode, the focused panel and the reloaded lists all land in +// the same frame, from the refresh's Then, rather than framing an unfiltered +// list as if it were the filtered one. Until then the pre-change state stays on +// screen, and it stays consistent, because the only thing that has changed +// behind it is the filter that the reload is in the middle of applying. The one +// thing that can't wait is the mode indicator in the information panel: the +// filter has to be set before the reload can use it, so the indicator leads the +// lists by however long the reload takes. +// +// Input is blocked for the duration: the keys the user presses arrive after the +// change, which is where they meant them to go, and it keeps a second filter +// change from racing this one — they would both refresh with whichever filter +// happened to be set when their git commands ran. +func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) error { + setFilter() + + filtering := self.c.Modes().Filtering.Active() + message := lo.Ternary(filtering, self.c.Tr.ApplyingFilterStatus, self.c.Tr.RemovingFilterStatus) + + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{Message: message}, func(gocui.Task) error { + self.c.RefreshFromWorker(types.RefreshOptions{ + Scope: ScopesToRefreshWhenFilteringModeChanges(), + BatchUIUpdates: true, + Then: func() error { + repoState := self.c.State().GetRepoState() + if filtering { + if repoState.GetScreenMode() == types.SCREEN_NORMAL { + repoState.SetScreenMode(types.SCREEN_HALF) + } + self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + } else if repoState.GetScreenMode() == types.SCREEN_HALF { + repoState.SetScreenMode(types.SCREEN_NORMAL) + } + + selectCommit() + self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) + return nil + }, + }) + return nil }) - return nil } // Stashes really only need to be refreshed when filtering by path, not by author, but it's too much @@ -219,6 +288,6 @@ func ScopesToRefreshWhenFilteringModeChanges() []types.RefreshableView { } } -func (self *ModeHelper) SetSuppressRebasingMode(value bool) { - self.suppressRebasingMode = value +func (self *ModeHelper) SetSuppressWorkingTreeStateMode(value bool) { + self.suppressWorkingTreeStateMode = value } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 29784b5ff..941bd3b9c 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -111,6 +111,14 @@ type refreshEnv struct { // persist its refreshed stat cache. backgroundRoutine bool + // Whether the views this refresh updates must keep the scroll position they + // have. Focusing a list scrolls its selection into view, which is what a + // user action should do — but a refresh that no user action is behind must + // leave the viewport wherever the user last scrolled it to. That's the case + // for the unattended background routines, and for the refreshes that merely + // reload state (see RefreshOptions.DontBlockRepoSwitch). + keepScrollPosition bool + // the repo generation captured when the refresh started generation int @@ -220,13 +228,16 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // against the repo it started in, and the generation guard drops its // writes. env := refreshEnv{ - background: options.Background || options.DontBlockRepoSwitch, - backgroundRoutine: options.Background, + background: options.Background || options.DontBlockRepoSwitch, + backgroundRoutine: options.Background, + keepScrollPosition: options.Background || options.DontBlockRepoSwitch, } - self.captureOnUIThread(calledFromWorker, env.background, func() { + if !self.captureOnUIThread(calledFromWorker, env.background, func() { env.generation = self.c.State().GetRepoGeneration() env.git = self.c.Git() - }) + }) { + return + } if options.BatchUIUpdates { env.batch = &refreshBounceBatch{} } @@ -262,6 +273,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // - merge conflicts are part of what the files refresh produces // - pull requests are fetched for the tracking branches against the // remotes, so refresh both alongside to fetch against fresh data + // - commits and branches always go together: changing commits changes + // the branches' upstream/downstream counts, and changing branches + // (e.g. checking one out) changes the commits we show. This one comes + // last, so that it also covers the branches the rules above add. if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { scopeSet.Add(types.COMMITS, types.BRANCHES) } @@ -274,6 +289,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr if scopeSet.Includes(types.PULL_REQUESTS) { scopeSet.Add(types.BRANCHES, types.REMOTES) } + if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { + scopeSet.Add(types.COMMITS, types.BRANCHES) + } // Capture the refs snapshot now, before we start reading git's state // below, rather than after. This is important to guard against the race @@ -300,6 +318,23 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr }) } + // The branches view shows worktrees against branches, so a branches render + // that happens before the refreshed worktrees have landed in the model shows + // stale ones, and rendering again once they land makes the view flicker. + // Refresh the worktrees first, then, and let the branches refresh wait for + // them: waitForWorktrees returns once the worktrees model write is queued, + // so the branches write that follows is queued behind it and the view + // renders once, with both. + worktreesWg := sync.WaitGroup{} + waitForWorktrees := func() { worktreesWg.Wait() } + if scopeSet.Includes(types.WORKTREES) { + worktreesWg.Add(1) + refresh("worktrees", func() { + defer worktreesWg.Done() + self.refreshWorktrees(env, scopeSet.Includes(types.BRANCHES)) + }) + } + branchesAndRemotesWg := sync.WaitGroup{} // The pull-request fetch (below) needs the just-loaded branches and // remotes. Their model writes are bounced onto the UI thread, so the @@ -309,32 +344,49 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // branchesAndRemotesWg gives the fetch the happens-before to read them. var loadedBranches []*models.Branch var loadedRemotes []*models.Remote - includeWorktreesWithBranches := false - if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { - // whenever we change commits, we should update branches because the upstream/downstream - // counts can change. Whenever we change branches we should also change commits - // e.g. in the case of switching branches. - // Capture the commits, reflog and branches refresh inputs (model, - // contexts, modes) on the UI thread, before the git work is dispatched - // to a worker, so the workers compute from an immutable snapshot - // instead of reading state the UI thread concurrently mutates. + if scopeSet.Includes(types.COMMITS) { + // Capture the refresh's inputs (model, contexts, modes) on the UI + // thread, before the git work is dispatched to a worker, so the worker + // computes from an immutable snapshot instead of reading state the UI + // thread concurrently mutates. Every scope below does the same. var capturedCommits capturedCommitState - var capturedReflog capturedReflogState - var capturedBranches capturedBranchState - self.captureOnUIThread(calledFromWorker, env.background, func() { + if !self.captureOnUIThread(calledFromWorker, env.background, func() { capturedCommits = self.captureCommitsState() - capturedReflog = self.captureReflogState() - capturedBranches = self.captureBranchState() - }) + }) { + return + } refresh("commits and commit files", func() { self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env) }) + } else if scopeSet.Includes(types.REBASE_COMMITS) { + // the commits refresh above loads the rebase commits as well, so we only + // need this one when the rebase commits are all that was asked for + var rebaseHashPool *utils.StringPool + var rebaseCommits []*models.Commit + if !self.captureOnUIThread(calledFromWorker, env.background, func() { + rebaseHashPool, rebaseCommits = self.captureRebaseCommitState() + }) { + return + } + refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) }) + } + + if scopeSet.Includes(types.BRANCHES) { + // The reflog is refreshed here rather than in a scope of its own, + // because sorting the branches by recency needs it to be loaded first. + var capturedReflog capturedReflogState + var capturedBranches capturedBranchState + if !self.captureOnUIThread(calledFromWorker, env.background, func() { + capturedReflog = self.captureReflogState() + capturedBranches = self.captureBranchState() + }) { + return + } - includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { branchesAndRemotesWg.Add(1) refresh("reflog and branches", func() { - loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env) + loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, waitForWorktrees, options.BranchSelection, options.SelectTopReflogCommit, env) branchesAndRemotesWg.Done() }) } else { @@ -343,47 +395,44 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // Not a recency sort, so branches doesn't depend on the reflog // being fresh; it runs concurrently with the reflog refresh // below and uses the reflog we captured up front, as it always has. - loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, env) + loadedBranches = self.refreshBranches(capturedBranches, waitForWorktrees, options.BranchSelection, true, capturedReflog.reflogCommits, env) branchesAndRemotesWg.Done() }) refresh("reflog", func() { _, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit) }) } - } else if scopeSet.Includes(types.REBASE_COMMITS) { - // the above block handles rebase commits so we only need to call this one - // if we've asked specifically for rebase commits and not those other things - var rebaseHashPool *utils.StringPool - var rebaseCommits []*models.Commit - self.captureOnUIThread(calledFromWorker, env.background, func() { - rebaseHashPool, rebaseCommits = self.captureRebaseCommitState() - }) - refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) }) } if scopeSet.Includes(types.SUB_COMMITS) { var capturedSubCommits capturedSubCommitState - self.captureOnUIThread(calledFromWorker, env.background, func() { + if !self.captureOnUIThread(calledFromWorker, env.background, func() { capturedSubCommits = self.captureSubCommitState() - }) + }) { + return + } refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) }) } // reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) { var capturedCommitFiles capturedCommitFilesState - self.captureOnUIThread(calledFromWorker, env.background, func() { + if !self.captureOnUIThread(calledFromWorker, env.background, func() { capturedCommitFiles = self.captureCommitFilesState() - }) + }) { + return + } refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) }) } fileWg := sync.WaitGroup{} if scopeSet.Includes(types.FILES) { var capturedFiles capturedFilesState - self.captureOnUIThread(calledFromWorker, env.background, func() { + if !self.captureOnUIThread(calledFromWorker, env.background, func() { capturedFiles = self.captureFilesState() - }) + }) { + return + } fileWg.Add(1) refresh("files", func() { _ = self.refreshFilesAndSubmodules(capturedFiles, env) @@ -393,9 +442,11 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr if scopeSet.Includes(types.STASH) { var stashFilterPath string - self.captureOnUIThread(calledFromWorker, env.background, func() { + if !self.captureOnUIThread(calledFromWorker, env.background, func() { stashFilterPath = self.c.Modes().Filtering.GetPath() - }) + }) { + return + } refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) }) } @@ -408,9 +459,11 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // needs it to keep the remote-branches selection valid, and reading // the Remotes context off the UI thread races its render. var prevSelectedRemote *models.Remote - self.captureOnUIThread(calledFromWorker, env.background, func() { + if !self.captureOnUIThread(calledFromWorker, env.background, func() { prevSelectedRemote = self.c.Contexts().Remotes.GetSelected() - }) + }) { + return + } branchesAndRemotesWg.Add(1) refresh("remotes", func() { loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env) @@ -443,10 +496,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr }) } - if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches { - refresh("worktrees", func() { self.refreshWorktrees(env) }) - } - if scopeSet.Includes(types.STAGING) { refresh("staging", func() { fileWg.Wait() @@ -673,17 +722,19 @@ func (self *RefreshHelper) captureBranchState() capturedBranchState { } } -func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch { +func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: // Return the immediate (non-recency) load's branches; the recency-sorted // reload below runs on its own worker after we return. Both hold the same // set of branches, which is all the caller (the PR fetch) needs. - branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, env) + branches := self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, false, capturedReflog.reflogCommits, env) self.onWorker(env.background, func(_ gocui.Task) error { reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, false) - self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, env) + // The load above already waited for the worktrees, so this one has + // nothing left to wait for. + self.refreshBranches(capturedBranches, func() {}, types.SelectCheckedOutBranch, true, reflogCommits, env) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) @@ -692,7 +743,7 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo case types.COMPLETE: reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, selectTopReflogCommit) - return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, env) + return self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, true, reflogCommits, env) } return nil @@ -810,13 +861,16 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, self.onUIThreadUnlessRepoChanged(env, func() { var selectionRange *localCommitSelectionRange + var newConflictedCommitIdx *int if commitSelection == types.KeepCommitSelectionByHash { selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) + newConflictedCommitIdx = findNewConflictedCommit(self.c.Model().Commits, commits) } self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits + self.c.Model().CommitsWereFilteredAtLastRefresh = captured.filterPath != "" || captured.filterAuthor != "" self.RefreshAuthors(commits) self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState if checkedOutRef != nil { @@ -825,33 +879,23 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, self.c.Model().CheckedOutBranch = "" } - scrollSelectionIntoView := false switch commitSelection { case types.SelectHeadCommit: if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 { self.c.Contexts().LocalCommits.SetSelection(headCommitIdx) - scrollSelectionIntoView = true } case types.KeepCommitSelectionByHash: - if selectionRange != nil { - selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange) + if newConflictedCommitIdx != nil { + self.c.Contexts().LocalCommits.SetSelection(*newConflictedCommitIdx) + } else if selectionRange != nil { + selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(commits, selectionRange) if found { self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode) - scrollSelectionIntoView = didMove } } case types.KeepCommitSelectionIndex: // The caller set the selection index deliberately; leave it untouched. } - - if scrollSelectionIntoView { - // Enqueued from within this bounce so it runs after refreshView's - // render below (which was enqueued first), matching the previous - // ordering where FocusLine ran after the view was re-rendered. - self.onUIThreadUnlessRepoChanged(env, func() { - self.c.Contexts().LocalCommits.FocusLine(true) - }) - } }) self.refreshView(self.c.Contexts().LocalCommits, env) @@ -863,8 +907,6 @@ type localCommitSelectionRange struct { selectedIsTODO bool rangeStartHash string rangeStartIsTODO bool - selectedIdx int - rangeStartIdx int mode traits.RangeSelectMode } @@ -883,8 +925,6 @@ func captureLocalCommitSelectionRange( selectedIsTODO: commits[selectedIdx].IsTODO(), rangeStartHash: commits[rangeStartIdx].Hash(), rangeStartIsTODO: commits[rangeStartIdx].IsTODO(), - selectedIdx: selectedIdx, - rangeStartIdx: rangeStartIdx, mode: mode, } } @@ -892,17 +932,16 @@ func captureLocalCommitSelectionRange( func findLocalCommitSelectionRange( commits []*models.Commit, selectionRange *localCommitSelectionRange, -) (int, int, bool, bool) { +) (int, int, bool) { selectedIdx, foundSelected := findCommitByHashPreferringTODOStatus( commits, selectionRange.selectedHash, selectionRange.selectedIsTODO) rangeStartIdx, foundRangeStart := findCommitByHashPreferringTODOStatus( commits, selectionRange.rangeStartHash, selectionRange.rangeStartIsTODO) if !foundSelected || !foundRangeStart { - return 0, 0, false, false + return 0, 0, false } - didMove := selectedIdx != selectionRange.selectedIdx || rangeStartIdx != selectionRange.rangeStartIdx - return selectedIdx, rangeStartIdx, didMove, true + return selectedIdx, rangeStartIdx, true } // findCommitByHashPreferringTODOStatus finds the commit with the given hash. @@ -933,6 +972,24 @@ func hasRestorableCommitHash(commits []*models.Commit, idx int) bool { return idx >= 0 && idx < len(commits) && commits[idx].Hash() != "" } +// Returns the index of the conflicted commit in the new commits slice, if there is one and it has a +// different hash than the one before had (or there wasn't one before). Otherwise returns nil. +func findNewConflictedCommit(previousCommits []*models.Commit, commits []*models.Commit) *int { + previousConflictedCommit, _ := lo.Find(previousCommits, func(commit *models.Commit) bool { + return commit.Status == models.StatusConflicted + }) + + newConflictedCommit, idx, hasConflict := lo.FindIndexOf(commits, func(commit *models.Commit) bool { + return commit.Status == models.StatusConflicted + }) + + if hasConflict && (previousConflictedCommit == nil || previousConflictedCommit.Hash() != newConflictedCommit.Hash()) { + return &idx + } + + return nil +} + // capturedSubCommitState holds the sub-commits refresh's model/context/mode // inputs, gathered on the UI thread (see captureSubCommitState) before the git // work is dispatched to a worker. @@ -1073,7 +1130,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*mode // self.refreshStatus is called at the end of this because that's when we can // be sure there is a State.Model.Branches array to pick the current branch from -func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { +func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { loadSeq := self.branchLoadSeq.Add(1) branches, err := env.git.Loaders.BranchLoader.Load( @@ -1107,10 +1164,9 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh self.c.Log.Error(err) } - var worktrees []*models.Worktree - if refreshWorktrees { - worktrees = self.loadWorktrees(env) - } + // Render only once the refreshed worktrees are in the model; the branches + // view shows them against the branches (see performRefresh). + waitForWorktrees() self.onUIThreadUnlessRepoChanged(env, func() { // Drop this write if a branch load that started later has already applied @@ -1133,11 +1189,6 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh // the branches we just wrote, on the UI thread. self.rebuildPullRequestsMap() - if refreshWorktrees { - self.c.Model().Worktrees = worktrees - self.refreshView(self.c.Contexts().Worktrees, env) - } - // Setting the selection here, in the same bounce that writes the list, // keeps it on the UI thread and keeps the list and selection updating in // the same frame. @@ -1153,10 +1204,8 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh } } case types.SelectCheckedOutBranch: - // The checked-out branch is always at the top of the list. Setting - // the selection doesn't scroll the view, so also reset the origin. + // The checked-out branch is always at the top of the list. self.c.Contexts().Branches.SetSelectedLineIdx(0) - self.c.Contexts().Branches.GetView().SetOriginY(0) } // Need to re-render the commits view because the visualization of local @@ -1247,21 +1296,20 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) { // waiting for a callback that only it can run), and capturing inline also // guarantees the snapshot reflects the state at the moment Refresh was called, // before the calling handler regains control and can mutate it. -func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) { +// +// It returns false when fn didn't run because the app is shutting down, in +// which case the caller must abandon the refresh rather than compute from a +// snapshot that was never taken. +func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) bool { if !calledFromWorker { fn() - return + return true } - wrapped := func() error { - fn() - return nil - } if background { - _ = self.c.GocuiGui().OnUIThreadAndWaitBackground(wrapped) - } else { - _ = self.c.GocuiGui().OnUIThreadAndWait(wrapped) + return self.c.GocuiGui().OnUIThreadAndWaitBackground(fn) == nil } + return self.c.GocuiGui().OnUIThreadAndWait(fn) == nil } // capturedFilesState holds the files refresh's context/model inputs, gathered @@ -1304,7 +1352,8 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // process working directory, which may already point at another // repo if the user switched while this refresh was in flight. hasConflicts, err := mergeconflicts.FileHasConflictMarkers( - filepath.Join(env.git.RepoPaths.WorktreePath(), file.Path)) + filepath.Join(env.git.RepoPaths.WorktreePath(), file.Path), + file.ConflictMarkerSize) if err != nil { self.c.Log.Error(err) } else if !hasConflicts { @@ -1327,12 +1376,9 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re Background: env.backgroundRoutine, }) - conflictFileCount := 0 - for _, file := range files { - if file.HasMergeConflicts { - conflictFileCount++ - } - } + conflictedPaths := lo.FilterMap(files, func(file *models.File, _ int) (string, bool) { + return file.Path, file.HasMergeConflicts + }) repoState := self.c.State().GetRepoState() workingTreeState := env.git.Status.WorkingTreeState() @@ -1342,7 +1388,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re repoState.SetMergeOrRebaseStartedInLazygit(false) } - if workingTreeState.Any() && conflictFileCount == 0 { + if workingTreeState.Any() && len(conflictedPaths) == 0 { if prevConflictFileCount > 0 && repoState.GetMergeOrRebaseStartedInLazygit() { // The conflicts of an operation we started have just been resolved // (e.g. in the user's editor). Offer to continue it. We only do this @@ -1380,24 +1426,61 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re self.onUIThreadUnlessRepoChanged(env, func() { // only taking over the filter if it hasn't already been set by the user. - if conflictFileCount > 0 && prevConflictFileCount == 0 { + if len(conflictedPaths) > 0 && prevConflictFileCount == 0 { if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted) self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles } - } else if conflictFileCount == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted { - fileTreeViewModel.SetStatusFilter(filetree.DisplayAll) + } else if len(conflictedPaths) == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted { + fileTreeViewModel.SetStatusFilterPreservingSelection(filetree.DisplayAll) self.c.Contexts().Files.GetView().Subtitle = "" } + if fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted { + fileTreeViewModel.RememberConflictedPaths(conflictedPaths) + } + self.c.Model().Submodules = submoduleConfigs self.c.Model().Files = files + markWorktreeFiles(files, self.c.Model().Worktrees, env.git.RepoPaths.WorktreePath()) fileTreeViewModel.SetTree() }) return nil } +// markWorktreeFiles marks the files that are linked worktrees of this repo, so +// that the files view can render them as such. `git status` reports a worktree +// as an untracked directory, i.e. with a trailing slash, which we take off: +// keeping it would build a directory node with a nameless file inside it. +// +// It must run on the UI thread, as it works on the model. Both models it needs +// are written by refreshes of their own, so it is called after either of them +// lands; it reports whether it changed anything. +func markWorktreeFiles(files []*models.File, worktrees []*models.Worktree, worktreePath string) bool { + changed := false + + for _, file := range files { + absPath := filepath.Join(worktreePath, file.Path) + isWorktree := lo.SomeBy(worktrees, func(worktree *models.Worktree) bool { + return worktree.Path == absPath + }) + + if isWorktree != file.IsWorktree { + file.IsWorktree = isWorktree + changed = true + } + if isWorktree { + if trimmed := strings.TrimSuffix(file.Path, "/"); trimmed != file.Path { + file.Path = trimmed + changed = true + } + } + } + + return changed +} + // the reflogs panel is the only panel where we cache data, in that we only // load entries that have been created since we last ran the call. This means // we need to be more careful with how we use this, and to ensure we're emptying @@ -1447,11 +1530,9 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en self.c.Model().ReflogCommits = reflogCommits self.c.Model().FilteredReflogCommits = filteredReflogCommits // Setting the selection here, in the same bounce that writes the list, - // keeps it on the UI thread and atomic with the list update. Setting the - // selection doesn't scroll the view, so also reset the origin. + // keeps it on the UI thread and atomic with the list update. if selectTopEntry { self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0) - self.c.Contexts().ReflogCommits.GetView().SetOriginY(0) } }) @@ -1501,16 +1582,27 @@ func (self *RefreshHelper) loadWorktrees(env refreshEnv) []*models.Worktree { return worktrees } -func (self *RefreshHelper) refreshWorktrees(env refreshEnv) { +func (self *RefreshHelper) refreshWorktrees(env refreshEnv, branchesAreRefreshing bool) { worktrees := self.loadWorktrees(env) self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Worktrees = worktrees + + // A worktree inside our working tree is one of the files, so the files + // view has to be told about the ones we just loaded (see + // markWorktreeFiles). Rebuild the tree because a file's path can change. + if markWorktreeFiles(self.c.Model().Files, worktrees, env.git.RepoPaths.WorktreePath()) { + self.c.Contexts().Files.FileTreeViewModel.SetTree() + self.refreshView(self.c.Contexts().Files, env) + } }) - // need to refresh branches because the branches view shows worktrees against - // branches - self.refreshView(self.c.Contexts().Branches, env) + // The branches view shows worktrees against branches, so it needs to be + // rendered again as well. When the branches are being refreshed too, they + // render after waiting for the write above, so leave it to them. + if !branchesAreRefreshing { + self.refreshView(self.c.Contexts().Branches, env) + } self.refreshView(self.c.Contexts().Worktrees, env) } @@ -1578,7 +1670,11 @@ func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) { // the filtered list model is up to date for rendering. self.searchHelper.ReApplyFilter(context) - self.c.PostRefreshUpdate(context) + if env.keepScrollPosition { + self.c.PostRefreshUpdateKeepingScrollPosition(context) + } else { + self.c.PostRefreshUpdate(context) + } self.c.AfterLayout(func() error { // Re-applying the search must be done after re-rendering the view though, @@ -1754,7 +1850,10 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra // the branches and remotes as they are on the UI thread, after their // own refreshes' bounces have applied. self.rebuildPullRequestsMap() - self.c.PostRefreshUpdate(self.c.Contexts().Branches) + // This lands whenever the network call happens to return, and only + // changes how the branches are rendered, not which one is selected, so + // it has no business moving the viewport. + self.c.PostRefreshUpdateKeepingScrollPosition(self.c.Contexts().Branches) }) } @@ -1770,15 +1869,13 @@ func (self *RefreshHelper) savePullRequestsToCache(prs []*models.GithubPullReque Number: pr.Number, Title: pr.Title, State: pr.State, + ChecksState: pr.ChecksState, Url: pr.Url, HeadRepositoryOwner: pr.HeadRepositoryOwner.Login, } }) - appState := self.c.GetAppState() - if appState.GithubPullRequests == nil { - appState.GithubPullRequests = make(map[string][]config.CachedPullRequest) + if err := self.c.GetConfig().SaveCachedGithubPullRequests(repoPath, cached); err != nil { + self.c.Log.Warnf("error saving GitHub pull request cache: %v", err) } - appState.GithubPullRequests[repoPath] = cached - self.c.SaveAppStateAndLogError() } diff --git a/pkg/gui/controllers/helpers/refresh_helper_test.go b/pkg/gui/controllers/helpers/refresh_helper_test.go index 3a5f6ea82..5e58c56a3 100644 --- a/pkg/gui/controllers/helpers/refresh_helper_test.go +++ b/pkg/gui/controllers/helpers/refresh_helper_test.go @@ -1,6 +1,7 @@ package helpers import ( + "path/filepath" "testing" "github.com/jesseduffield/lazygit/pkg/commands/hosting_service" @@ -28,8 +29,6 @@ func TestCaptureLocalCommitSelectionRange(t *testing.T) { expected: &localCommitSelectionRange{ selectedHash: "b", rangeStartHash: "a", - selectedIdx: 1, - rangeStartIdx: 0, mode: traits.RangeSelectModeSticky, }, }, @@ -74,15 +73,12 @@ func TestFindLocalCommitSelectionRange(t *testing.T) { type expectation struct { selectedIdx int rangeStartIdx int - moved bool found bool } selectionRange := localCommitSelectionRange{ selectedHash: "b", rangeStartHash: "c", - selectedIdx: 1, - rangeStartIdx: 2, mode: traits.RangeSelectModeSticky, } @@ -97,7 +93,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) { expected: expectation{ selectedIdx: 2, rangeStartIdx: 3, - moved: true, found: true, }, }, @@ -126,7 +121,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) { expected: expectation{ selectedIdx: 2, rangeStartIdx: 3, - moved: true, found: true, }, }, @@ -139,7 +133,6 @@ func TestFindLocalCommitSelectionRange(t *testing.T) { expected: expectation{ selectedIdx: 0, rangeStartIdx: 1, - moved: true, found: true, }, }, @@ -147,11 +140,10 @@ func TestFindLocalCommitSelectionRange(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - selectedIdx, rangeStartIdx, moved, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange) + selectedIdx, rangeStartIdx, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange) actual := expectation{ selectedIdx: selectedIdx, rangeStartIdx: rangeStartIdx, - moved: moved, found: found, } @@ -160,6 +152,62 @@ func TestFindLocalCommitSelectionRange(t *testing.T) { } } +func TestFindNewConflictedCommit(t *testing.T) { + testCases := []struct { + name string + previousCommits []*models.Commit + commits []*models.Commit + expectedIdx *int + }{ + { + name: "finds a newly conflicted commit", + previousCommits: makeCommits("a", "b"), + commits: []*models.Commit{ + makeCommits("a")[0], + makeConflictedCommit("b"), + }, + expectedIdx: lo.ToPtr(1), + }, + { + name: "finds a different conflicted commit", + previousCommits: []*models.Commit{ + makeConflictedCommit("a"), + }, + commits: []*models.Commit{ + makeConflictedCommit("b"), + }, + expectedIdx: lo.ToPtr(0), + }, + { + name: "ignores the same conflicted commit", + previousCommits: []*models.Commit{ + makeConflictedCommit("a"), + }, + commits: []*models.Commit{ + makeConflictedCommit("a"), + }, + expectedIdx: nil, + }, + { + name: "reports not found when there is no conflict", + previousCommits: makeCommits("a"), + commits: makeCommits("a", "b"), + expectedIdx: nil, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + idx := findNewConflictedCommit(testCase.previousCommits, testCase.commits) + + assert.Equal(t, testCase.expectedIdx != nil, idx != nil) + if idx != nil { + assert.Equal(t, *testCase.expectedIdx, *idx) + } + }) + } +} + func TestGetGithubBaseRemote(t *testing.T) { cases := []struct { name string @@ -252,6 +300,46 @@ func TestGetAuthenticatedGithubRemotes(t *testing.T) { }, callsByHost) } +func TestMarkWorktreeFiles(t *testing.T) { + worktreePath := filepath.Join("/", "path", "to", "repo") + worktrees := []*models.Worktree{ + {Path: worktreePath}, + {Path: filepath.Join(worktreePath, "worktree1")}, + {Path: filepath.Join(worktreePath, "dir", "worktree2")}, + {Path: filepath.Join("/", "path", "to", "worktree3")}, + } + + t.Run("marks the files that are worktrees, and takes their slash off", func(t *testing.T) { + files := []*models.File{ + {Path: "file"}, + {Path: "worktree1/"}, + {Path: "dir/worktree2/"}, + {Path: "dir/"}, + } + + assert.True(t, markWorktreeFiles(files, worktrees, worktreePath)) + assert.Equal(t, []*models.File{ + {Path: "file"}, + {Path: "worktree1", IsWorktree: true}, + {Path: "dir/worktree2", IsWorktree: true}, + {Path: "dir/"}, + }, files) + }) + + t.Run("reports no change when there is nothing to mark", func(t *testing.T) { + files := []*models.File{{Path: "file"}, {Path: "dir/"}} + + assert.False(t, markWorktreeFiles(files, worktrees, worktreePath)) + }) + + t.Run("unmarks a file whose worktree is gone", func(t *testing.T) { + files := []*models.File{{Path: "worktree1", IsWorktree: true}} + + assert.True(t, markWorktreeFiles(files, nil, worktreePath)) + assert.Equal(t, []*models.File{{Path: "worktree1"}}, files) + }) +} + func makeGithubRemoteInfoList(names ...string) []githubRemoteInfo { return lo.Map(names, func(name string, _ int) githubRemoteInfo { return makeGithubRemoteInfo(name, name) @@ -288,3 +376,7 @@ func makeTodoCommit(action todo.TodoCommand) *models.Commit { func makeTodoCommitWithHash(hash string, action todo.TodoCommand) *models.Commit { return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Action: action}) } + +func makeConflictedCommit(hash string) *models.Commit { + return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Status: models.StatusConflicted}) +} diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index a61ad0013..d3b6bfe29 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -54,7 +54,10 @@ func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error if err != nil { return err } - self.c.State().GetRepoPathStack().Push(wd) + self.c.State().GetRepoPathStack().Push(types.RepoLocation{ + Path: wd, + GitLocationEnvVars: self.c.Git().RepoPaths.GitLocationEnvVars(), + }) return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) } @@ -164,7 +167,7 @@ func (self *ReposHelper) SwitchToParentRepo() error { if self.switchRefusedBecauseBusy() { return nil } - return self.switchTo(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) + return self.switchToLocation(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) } func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error { @@ -189,23 +192,41 @@ func (self *ReposHelper) switchRefusedBecauseBusy() bool { return false } -// switchTo switches lazygit to the repository (or worktree) at the given path. -// It runs synchronously on the UI thread: the switch swaps gui.State (in -// resetState) and reassigns gui.git and the process cwd, all of which the UI -// thread also reads, so doing it here rather than on a worker avoids racing -// those reads. The heavy data loading is still dispatched asynchronously by the -// refresh that onNewRepo kicks off. +// switchTo switches lazygit to the repository (or worktree) at the given path, +// which git is expected to find from that path alone. That's true of every repo +// we switch to without having been there before. func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.ContextKey) error { - env.UnsetGitLocationEnvVars() + return self.switchToLocation(types.RepoLocation{Path: path}, errMsg, contextKey) +} + +// switchToLocation switches lazygit to the repository (or worktree) at the +// given location. It runs synchronously on the UI thread: the switch swaps +// gui.State (in resetState) and reassigns gui.git and the process cwd, all of +// which the UI thread also reads, so doing it here rather than on a worker +// avoids racing those reads. The heavy data loading is still dispatched +// asynchronously by the refresh that onNewRepo kicks off. +// +// Everything from here on has to find the repo the way git does, from the +// directory we're about to change to, so the location's environment goes into +// the process env before we do. Usually that just clears whatever the repo +// we're leaving needed, but going back to a repo whose git dir isn't in its +// work tree (a dotfile repo opened with --git-dir/--work-tree, say) is the +// reason we remember the environment at all: nothing in the path leads to its +// git dir. On failure we put back what the repo we're staying in needs. +func (self *ReposHelper) switchToLocation(location types.RepoLocation, errMsg string, contextKey types.ContextKey) error { originalPath, err := os.Getwd() if err != nil { return nil } + originalGitLocationEnvVars := env.GetGitLocationEnvVars() - msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path}) + env.SetGitLocationEnvVars(location.GitLocationEnvVars) + + msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": location.Path}) self.c.LogCommand(msg, false) - if err := os.Chdir(path); err != nil { + if err := os.Chdir(location.Path); err != nil { + env.SetGitLocationEnvVars(originalGitLocationEnvVars) if os.IsNotExist(err) { return errors.New(errMsg) } @@ -213,6 +234,7 @@ func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.C } if err := commands.VerifyInGitRepo(self.c.OS()); err != nil { + env.SetGitLocationEnvVars(originalGitLocationEnvVars) if err := os.Chdir(originalPath); err != nil { return err } diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go index bc0c938f9..51f510792 100644 --- a/pkg/gui/controllers/helpers/search_helper.go +++ b/pkg/gui/controllers/helpers/search_helper.go @@ -225,7 +225,6 @@ func (self *SearchHelper) OnPromptContentChanged(searchString string) { switch context := state.Context.(type) { case types.IFilterableContext: context.SetSelection(0) - context.GetView().SetOriginY(0) context.SetFilter(searchString, self.c.UserConfig().Gui.UseFuzzySearch()) self.c.PostRefreshUpdate(context) case types.ISearchableContext: @@ -241,6 +240,9 @@ func (self *SearchHelper) ReApplyFilter(context types.Context) { state := self.searchState() if context == state.Context && self.c.Context().Current().GetKey() == self.c.Contexts().Search.GetKey() { filterableContext.SetSelection(0) + // This runs as part of a refresh, and a refresh that no user action + // is behind keeps the scroll position, which would leave the view + // scrolled somewhere the filtered list no longer has anything at. filterableContext.GetView().SetOriginY(0) } filterableContext.ReApplyFilter(self.c.UserConfig().Gui.UseFuzzySearch()) diff --git a/pkg/gui/controllers/helpers/sub_commits_helper.go b/pkg/gui/controllers/helpers/sub_commits_helper.go index 7bd928826..09f32d1a9 100644 --- a/pkg/gui/controllers/helpers/sub_commits_helper.go +++ b/pkg/gui/controllers/helpers/sub_commits_helper.go @@ -66,7 +66,6 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error { subCommitsContext.GetView().TitlePrefix = opts.Context.GetView().TitlePrefix self.c.PostRefreshUpdate(self.c.Contexts().SubCommits) - subCommitsContext.FocusLine(true) self.c.Context().Push(self.c.Contexts().SubCommits, types.OnFocusOpts{}) return nil diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index b2d45679b..c073e5141 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -2,6 +2,7 @@ package controllers import ( "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -16,18 +17,27 @@ func NewListControllerFactory(c *ControllerCommon) *ListControllerFactory { } func (self *ListControllerFactory) Create(context types.IListContext) *ListController { - return &ListController{ + controller := &ListController{ baseController: baseController{}, c: self.c, context: context, } + controller.dragAutoscroller = helpers.NewDragAutoscroller( + self.c.HelperCommon, + context, + func(int) bool { return context.GetList().IsSelectingRange() }, + controller.handleDragAutoscroll, + ) + return controller } type ListController struct { baseController c *ControllerCommon - context types.IListContext + context types.IListContext + dragAutoscroller *helpers.DragAutoscroller + draggingWithMouse bool } func (self *ListController) Context() types.Context { @@ -126,7 +136,7 @@ func (self *ListController) handleLineChangeAux(f func(int), change int) error { self.context.SetNeedRerenderVisibleLines() } - self.context.HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) + self.context.HandleFocus(types.OnFocusOpts{}) } else { // If the selection did not change (because, for example, we are at the top of the list and // press up), we still want to ensure that the selection is visible. This is useful after @@ -195,9 +205,10 @@ func (self *ListController) handlePageChange(delta int) error { // must tell it explicitly to rerender. self.context.SetNeedRerenderVisibleLines() - // Since we are maintaining the scroll position ourselves above, there's no point in passing - // ScrollSelectionIntoView=true here. - self.context.HandleFocus(types.OnFocusOpts{}) + // This function scrolls the view itself, keeping the selection at the edge of + // the viewport rather than in its middle, so the scroll position is ours to + // maintain, not the focus mechanism's. + self.context.HandleFocus(types.OnFocusOpts{KeepScrollPosition: true}) return nil } @@ -257,6 +268,51 @@ func (self *ListController) HandleClick(opts gocui.ViewMouseBindingOpts) error { return nil } +func (self *ListController) HandleDrag(opts gocui.ViewMouseBindingOpts) error { + self.draggingWithMouse = true + self.selectRangeThroughViewIndex(opts.Y) + originY, _ := self.context.GetViewTrait().ViewPortYBounds() + self.dragAutoscroller.Update(opts.Y - originY) + return nil +} + +func (self *ListController) selectRangeThroughViewIndex(viewIndex int) { + list := self.context.GetList() + newSelectedLineIdx := self.context.ViewIndexToModelIndex(viewIndex) + list.ExpandNonStickyRange(newSelectedLineIdx - list.GetSelectedLineIdx()) + + // The pointer can be outside the viewport, in which case so is the end of + // the range; the drag autoscroller takes care of following it, one line at a + // time, for as long as the pointer stays there. + self.context.HandleFocus(types.OnFocusOpts{KeepScrollPosition: true}) +} + +func (self *ListController) handleDragAutoscroll(viewIndex int) bool { + if !self.context.GetList().IsSelectingRange() { + return false + } + + self.context.SetNeedRerenderVisibleLines() + self.selectRangeThroughViewIndex(viewIndex) + return true +} + +func (self *ListController) handleDragRelease() error { + self.draggingWithMouse = false + self.dragAutoscroller.Cancel() + return nil +} + +func (self *ListController) GetOnFocusLost() func(types.OnFocusLostOpts) { + return func(types.OnFocusLostOpts) { + self.dragAutoscroller.Cancel() + if self.draggingWithMouse { + self.draggingWithMouse = false + self.c.GocuiGui().CancelMouseCapture() + } + } +} + func (self *ListController) pushContextIfNotFocused() error { if !self.isFocused() { self.c.Context().Push(self.context, types.OnFocusOpts{}) @@ -295,7 +351,7 @@ func (self *ListController) GetKeybindings(opts types.KeybindingsOpts) []*types. } func (self *ListController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { - return []*gocui.ViewMouseBinding{ + bindings := []*gocui.ViewMouseBinding{ { ViewName: self.context.GetViewName(), Key: gocui.MouseWheelUp, @@ -312,4 +368,22 @@ func (self *ListController) GetMouseKeybindings(opts types.KeybindingsOpts) []*g Handler: func(gocui.ViewMouseBindingOpts) error { return self.HandleScrollDown() }, }, } + + if self.context.RangeSelectEnabled() { + bindings = append(bindings, + &gocui.ViewMouseBinding{ + ViewName: self.context.GetViewName(), + Key: gocui.MouseLeft, + Modifier: gocui.ModMotion, + Handler: self.HandleDrag, + }, + &gocui.ViewMouseBinding{ + ViewName: self.context.GetViewName(), + Key: gocui.MouseRelease, + Handler: func(gocui.ViewMouseBindingOpts) error { return self.handleDragRelease() }, + }, + ) + } + + return bindings } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 708f8fc28..0a02e7398 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -2,12 +2,14 @@ package controllers import ( "strings" + "time" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -19,6 +21,10 @@ import ( // after selecting the 200th commit, we'll load in all the rest const COMMIT_THRESHOLD = 200 +// How long a commit move may take before the drop indicator switches to a +// "moving commits here" spinner; quick moves stay free of flicker. +const commitDragMovingIndicatorDelay = 200 * time.Millisecond + type ( PullFilesFn func() error ) @@ -28,7 +34,48 @@ type LocalCommitsController struct { *ListControllerTrait[*models.Commit] c *ControllerCommon - pullFiles PullFilesFn + pullFiles PullFilesFn + commitDrag *commitDragState + dragAutoscroller *helpers.DragAutoscroller + movingCommitsIndicatorStop chan struct{} +} + +// commitDragState tracks a mouse drag that moves the selected commits. It is +// created when the left button is pressed on the current selection, and lives +// until the button is released or the drag is canceled. +type commitDragState struct { + // Model index that was pressed; releasing without having moved collapses + // the selection to this commit, like a plain click would. + pressedIndex int + // Bounds of the selection at press time. + startIndex int + endIndex int + // Identifying information of the dragged commits, so that they can be + // found again on release even if the model was refreshed during the drag. + commitIdentities []commitDragIdentity + // Cursor and range-start position relative to startIndex, for restoring + // the selection after the move. + selectedOffset int + rangeStartOffset int + rangeSelectMode traits.RangeSelectMode + // Smallest and largest allowed insertion index. During a rebase this + // restricts the drag to the contiguous block of movable todos around the + // selection. + minInsertion int + maxInsertion int + // Current insertion index, or -1 if dropping wouldn't move anything + // (pointer over the dragged block itself). + insertionIndex int + // Whether any drag motion arrived since the press; distinguishes a drag + // from a plain click on the selection. + hasMoved bool +} + +type commitDragIdentity struct { + hash string + name string + action todo.TodoCommand + actionFlag string } var _ types.IController = &LocalCommitsController{} @@ -37,7 +84,7 @@ func NewLocalCommitsController( c *ControllerCommon, pullFiles PullFilesFn, ) *LocalCommitsController { - return &LocalCommitsController{ + controller := &LocalCommitsController{ baseController: baseController{}, c: c, pullFiles: pullFiles, @@ -48,12 +95,366 @@ func NewLocalCommitsController( c.Contexts().LocalCommits.GetSelectedItems, ), } + controller.dragAutoscroller = helpers.NewDragAutoscroller( + c.HelperCommon, + c.Contexts().LocalCommits, + controller.canCommitDragAutoscroll, + controller.handleCommitDragAutoscroll, + ) + return controller +} + +func (self *LocalCommitsController) GetMouseKeybindings(types.KeybindingsOpts) []*gocui.ViewMouseBinding { + viewName := self.context().GetViewName() + return []*gocui.ViewMouseBinding{ + { + ViewName: viewName, + FocusedView: viewName, + Key: gocui.MouseLeft, + Handler: self.handleCommitDragPress, + }, + { + ViewName: viewName, + FocusedView: viewName, + Key: gocui.MouseLeft, + Modifier: gocui.ModMotion, + Handler: self.handleCommitDrag, + }, + { + ViewName: viewName, + FocusedView: viewName, + Key: gocui.MouseRelease, + Handler: self.handleCommitDragRelease, + }, + } +} + +func (self *LocalCommitsController) handleCommitDragPress(opts gocui.ViewMouseBindingOpts) error { + context := self.context() + pressedIndex := context.ViewIndexToModelIndex(opts.Y) + startIndex, endIndex := context.GetSelectionRange() + selectedIndex, rangeStartIndex, rangeSelectMode := context.GetSelectionRangeAndMode() + selectedCommits, _, _ := context.GetSelectedItems() + // Only a single press on the current selection (of commits that may be + // moved) starts a drag; everything else falls through to the generic + // list click handling, i.e. selecting the pressed line, double-click + // actions, or dragging out a range selection. The view-index comparison + // rejects presses on section headers, which map to the model index of a + // nearby commit. + if opts.IsDoubleClick || + pressedIndex < startIndex || pressedIndex > endIndex || + context.ModelIndexToViewIndex(pressedIndex) != opts.Y || + self.midRebaseMoveCommandEnabled(selectedCommits, startIndex, endIndex) != nil { + return gocui.ErrKeybindingNotHandled + } + + minInsertion, maxInsertion := self.commitDragInsertionBounds(startIndex, endIndex) + self.commitDrag = &commitDragState{ + pressedIndex: pressedIndex, + startIndex: startIndex, + endIndex: endIndex, + commitIdentities: lo.Map(selectedCommits, func(commit *models.Commit, _ int) commitDragIdentity { + return commitDragIdentityForCommit(commit) + }), + selectedOffset: selectedIndex - startIndex, + rangeStartOffset: rangeStartIndex - startIndex, + rangeSelectMode: rangeSelectMode, + minInsertion: minInsertion, + maxInsertion: maxInsertion, + insertionIndex: -1, + } + self.restoreCommitDragHighlight() + return nil +} + +func (self *LocalCommitsController) commitDragInsertionBounds(startIndex int, endIndex int) (int, int) { + commits := self.c.Model().Commits + if !self.isRebasing() { + return 0, len(commits) + } + + minInsertion := startIndex + for minInsertion > 0 && commits[minInsertion-1].IsTODO() && commits[minInsertion-1].Status != models.StatusConflicted { + minInsertion-- + } + maxInsertion := endIndex + 1 + for maxInsertion < len(commits) && commits[maxInsertion].IsTODO() && commits[maxInsertion].Status != models.StatusConflicted { + maxInsertion++ + } + return minInsertion, maxInsertion +} + +func (self *LocalCommitsController) handleCommitDrag(opts gocui.ViewMouseBindingOpts) error { + if self.commitDrag == nil { + return gocui.ErrKeybindingNotHandled + } + + self.commitDrag.hasMoved = true + if self.updateCommitDragInsertion(opts.Y) { + self.c.PostRefreshUpdate(self.context()) + } + originY := self.context().GetView().OriginY() + self.dragAutoscroller.Update(opts.Y - originY) + self.restoreCommitDragHighlight() + return nil +} + +func (self *LocalCommitsController) updateCommitDragInsertion(viewIndex int) bool { + insertionIndex := self.commitDragInsertionIndex(viewIndex) + if insertionIndex >= self.commitDrag.startIndex && insertionIndex <= self.commitDrag.endIndex+1 { + insertionIndex = -1 + } + if insertionIndex == self.commitDrag.insertionIndex { + return false + } + + self.commitDrag.insertionIndex = insertionIndex + if insertionIndex < 0 { + self.context().ClearDropInsertionIndex() + } else { + self.context().SetDropInsertionIndex(insertionIndex) + } + return true +} + +// gocui moves the view cursor to the pointer position before invoking our +// handlers; move it back so that the dragged commits stay highlighted for the +// whole duration of the drag. +func (self *LocalCommitsController) restoreCommitDragHighlight() { + state := self.commitDrag + context := self.context() + view := context.GetView() + selectedIndex := state.startIndex + state.selectedOffset + rangeStartIndex := state.startIndex + state.rangeStartOffset + + view.SetCursorY(context.ModelIndexToViewIndex(selectedIndex) - view.OriginY()) + view.SetRangeSelectStart(context.ModelIndexToViewIndex(rangeStartIndex)) +} + +func (self *LocalCommitsController) commitDragInsertionIndex(viewIndex int) int { + context := self.context() + if viewIndex < 0 { + return self.commitDrag.minInsertion + } + if viewIndex >= context.TotalContentHeight() { + return self.commitDrag.maxInsertion + } + + // Rows above the dragged block insert before the pointed-at commit, rows + // below it insert after it, so that in both directions the line under + // the pointer is the one that makes way. + modelIndex := context.ViewIndexToModelIndex(viewIndex) + insertionIndex := modelIndex + if modelIndex > self.commitDrag.endIndex { + insertionIndex++ + } + return max(self.commitDrag.minInsertion, min(insertionIndex, self.commitDrag.maxInsertion)) +} + +func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindingOpts) error { + if self.commitDrag == nil { + return gocui.ErrKeybindingNotHandled + } + + state := self.commitDrag + self.dragAutoscroller.Cancel() + self.commitDrag = nil + + if !state.hasMoved { + self.context().ClearDropInsertionIndex() + self.context().SetSelection(state.pressedIndex) + self.c.PostRefreshUpdate(self.context()) + return nil + } + if state.insertionIndex < 0 { + self.context().ClearDropInsertionIndex() + self.c.PostRefreshUpdate(self.context()) + return nil + } + + offset := state.insertionIndex - state.startIndex + if state.insertionIndex > state.endIndex { + offset = state.insertionIndex - state.endIndex - 1 + } + selectedCommits, startIndex, endIndex, found := findCommitDragBlock( + self.context().GetItems(), state.commitIdentities, + ) + if !found { + self.context().ClearDropInsertionIndex() + self.c.PostRefreshUpdate(self.context()) + return nil + } + self.context().SetSelectionRangeAndMode( + startIndex+state.selectedOffset, + startIndex+state.rangeStartOffset, + state.rangeSelectMode, + ) + self.startMovingCommitsIndicator(state.insertionIndex) + if err := self.move(selectedCommits, startIndex, endIndex, offset, + func() error { self.stopMovingCommitsIndicator(); return nil }); err != nil { + self.stopMovingCommitsIndicator() + return err + } + return nil +} + +// startMovingCommitsIndicator keeps the drop indicator visible while the move +// is running, turning it into a spinner once the grace period elapses. The +// ticker goroutine only ever touches state from the UI thread, where the +// comparison against the current stop channel makes late callbacks harmless. +func (self *LocalCommitsController) startMovingCommitsIndicator(insertionIndex int) { + self.stopMovingCommitsIndicatorTicker() + stop := make(chan struct{}) + self.movingCommitsIndicatorStop = stop + go utils.Safe(func() { + graceTimer := time.NewTimer(commitDragMovingIndicatorDelay) + defer graceTimer.Stop() + select { + case <-graceTimer.C: + self.c.OnUIThreadContentOnlyBackground(func() error { + if self.movingCommitsIndicatorStop == stop { + self.context().SetMovingCommitsInsertionIndex(insertionIndex) + self.context().HandleRender() + } + return nil + }) + case <-stop: + return + } + + rate := time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate) + ticker := time.NewTicker(rate) + defer ticker.Stop() + for { + select { + case <-ticker.C: + self.c.OnUIThreadContentOnlyBackground(func() error { + if self.movingCommitsIndicatorStop == stop { + self.context().HandleRender() + } + return nil + }) + case <-stop: + return + } + } + }) +} + +func (self *LocalCommitsController) stopMovingCommitsIndicator() { + self.stopMovingCommitsIndicatorTicker() + self.context().ClearDropInsertionIndex() + self.c.PostRefreshUpdate(self.context()) +} + +func (self *LocalCommitsController) stopMovingCommitsIndicatorTicker() { + if self.movingCommitsIndicatorStop != nil { + close(self.movingCommitsIndicatorStop) + self.movingCommitsIndicatorStop = nil + } +} + +func commitDragIdentityForCommit(commit *models.Commit) commitDragIdentity { + return commitDragIdentity{ + hash: commit.Hash(), + name: commit.Name, + action: commit.Action, + actionFlag: commit.ActionFlag, + } +} + +// findCommitDragBlock locates the dragged commits in the (possibly refreshed) +// commit list by their identity rather than by the indices recorded at press +// time. If they no longer exist as a contiguous block, or more than one block +// matches, we give up rather than guess. +func findCommitDragBlock( + commits []*models.Commit, identities []commitDragIdentity, +) ([]*models.Commit, int, int, bool) { + matchStart := -1 + for startIndex := 0; startIndex+len(identities) <= len(commits); startIndex++ { + matches := true + for offset, identity := range identities { + if commitDragIdentityForCommit(commits[startIndex+offset]) != identity { + matches = false + break + } + } + if matches { + if matchStart >= 0 { + return nil, -1, -1, false + } + matchStart = startIndex + } + } + + if matchStart < 0 { + return nil, -1, -1, false + } + endIndex := matchStart + len(identities) - 1 + return commits[matchStart : endIndex+1], matchStart, endIndex, true +} + +func (self *LocalCommitsController) GetOnFocusLost() func(types.OnFocusLostOpts) { + return func(types.OnFocusLostOpts) { + if self.commitDrag == nil { + return + } + + self.cancelCommitDrag() + } +} + +func (self *LocalCommitsController) cancelCommitDrag() { + self.dragAutoscroller.Cancel() + self.commitDrag = nil + self.c.GocuiGui().CancelMouseCapture() + self.context().ClearDropInsertionIndex() + self.c.PostRefreshUpdate(self.context()) +} + +func (self *LocalCommitsController) handleCommitDragCancel() error { + if self.commitDrag == nil { + return gocui.ErrKeybindingNotHandled + } + + self.cancelCommitDrag() + return nil +} + +// Stop autoscrolling once the insertion point has reached the end of the +// allowed range in the scroll direction; e.g. during a rebase there is no +// point in scrolling on into the section of real commits. +func (self *LocalCommitsController) canCommitDragAutoscroll(direction int) bool { + state := self.commitDrag + if state == nil { + return false + } + if direction < 0 { + return state.insertionIndex != state.minInsertion + } + return state.insertionIndex != state.maxInsertion +} + +func (self *LocalCommitsController) handleCommitDragAutoscroll(viewIndex int) bool { + if self.commitDrag == nil { + return false + } + + self.updateCommitDragInsertion(viewIndex) + self.context().SetNeedRerenderVisibleLines() + self.context().HandleRender() + self.restoreCommitDragHighlight() + return self.canCommitDragAutoscroll(self.dragAutoscroller.Direction()) } func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { editCommitKey := opts.Config.Universal.Edit bindings := []*types.Binding{ + { + Keys: opts.GetKeys(opts.Config.Universal.Return), + Handler: self.handleCommitDragCancel, + }, { Keys: opts.GetKeys(opts.Config.Commits.SquashDown), Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.squashDown)), @@ -342,7 +743,10 @@ func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, HandleConfirm: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.SquashingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashCommitDown) return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx) }) @@ -366,7 +770,10 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star OnPress: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.FixingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommit) return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx) }) @@ -379,7 +786,10 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star OnPress: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.FixingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage) return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C") }) @@ -490,7 +900,10 @@ func (self *LocalCommitsController) handleReword(summary string, description str self.c.Tr.RewordingStatus, nil, nil) } - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RewordingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RewordingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description) if err != nil { return err @@ -576,7 +989,10 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start if !isMerge { self.selectRebaseResultCommit(startIdx) } - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.DroppingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.DroppingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DropCommit) if isMerge { return self.dropMergeCommit(commits, startIdx) @@ -601,7 +1017,10 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start commits := self.c.Model().Commits if !commits[endIdx].IsMerge() { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RebasingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "") return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{BatchUIUpdates: true}) @@ -623,7 +1042,10 @@ func (self *LocalCommitsController) quickStartInteractiveRebase() error { func (self *LocalCommitsController) startInteractiveRebaseWithEdit( commitsToEdit []*models.Commit, ) error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RebasingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.EditCommit) err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( @@ -734,12 +1156,22 @@ func (self *LocalCommitsController) isCherryPickingOrReverting() bool { } func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error { + return self.move(selectedCommits, startIdx, endIdx, 1, nil) +} + +func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, startIdx int, endIdx int) error { + return self.move(selectedCommits, startIdx, endIdx, -1, nil) +} + +func (self *LocalCommitsController) move( + selectedCommits []*models.Commit, startIdx int, endIdx int, offset int, onComplete func() error, +) error { if self.isRebasing() { - if err := self.c.Git().Rebase.MoveTodosDown(selectedCommits); err != nil { + if err := self.c.Git().Rebase.MoveTodos(selectedCommits, offset); err != nil { return err } - self.context().MoveSelection(1) - self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) + self.context().MoveSelection(offset) + self.context().HandleFocus(types.OnFocusOpts{}) // Block input until the refresh has landed: a quick second press must // read the moved todo from the refreshed model, not grab whatever the @@ -747,51 +1179,22 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s self.c.RefreshBlockingInput(types.RefreshOptions{ Scope: []types.RefreshableView{types.REBASE_COMMITS}, CommitSelection: types.KeepCommitSelectionIndex, + Then: onComplete, }) return nil } commits := self.c.Model().Commits - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error { - self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) - err := self.c.Git().Rebase.MoveCommitsDown(commits, startIdx, endIdx) - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( - err, types.RefreshOptions{ - BatchUIUpdates: true, - CommitSelection: types.KeepCommitSelectionIndex, - // Move the selection to follow the moved commit, in Then so it - // lands in the same frame as the refreshed commit list. - Then: func() error { - if err == nil { - self.context().MoveSelection(1) - self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - } - return nil - }, - }) - }) -} - -func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, startIdx int, endIdx int) error { - if self.isRebasing() { - if err := self.c.Git().Rebase.MoveTodosUp(selectedCommits); err != nil { - return err + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.MovingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { + if offset > 0 { + self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) + } else { + self.c.LogAction(self.c.Tr.Actions.MoveCommitUp) } - self.context().MoveSelection(-1) - self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - - // Block input for the same reason as in moveDown. - self.c.RefreshBlockingInput(types.RefreshOptions{ - Scope: []types.RefreshableView{types.REBASE_COMMITS}, - CommitSelection: types.KeepCommitSelectionIndex, - }) - return nil - } - - commits := self.c.Model().Commits - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error { - self.c.LogAction(self.c.Tr.Actions.MoveCommitUp) - err := self.c.Git().Rebase.MoveCommitsUp(commits, startIdx, endIdx) + err := self.c.Git().Rebase.MoveCommits(commits, startIdx, endIdx, offset) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{ BatchUIUpdates: true, @@ -800,8 +1203,11 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta // lands in the same frame as the refreshed commit list. Then: func() error { if err == nil { - self.context().MoveSelection(-1) - self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) + self.context().MoveSelection(offset) + self.context().HandleFocus(types.OnFocusOpts{}) + } + if onComplete != nil { + return onComplete() } return nil }, @@ -827,7 +1233,10 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error { selectedIdx := self.context().GetView().SelectedLineIdx() handleCommit = func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.AmendingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AmendCommit) err := self.c.Git().Rebase.AmendTo(commits, selectedIdx) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) @@ -889,7 +1298,10 @@ func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, en } func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.AmendingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor) if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil { return err @@ -905,7 +1317,10 @@ func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, e Title: self.c.Tr.SetAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.AmendingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor) if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil { return err @@ -925,7 +1340,10 @@ func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start, Title: self.c.Tr.AddCoAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.AmendingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor) if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil { return err @@ -959,7 +1377,10 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.RevertCommit) mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RevertingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RevertingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { if mustStash { if err := self.c.Git().Stash.Push(self.c.Tr.AutoStashForReverting); err != nil { return err @@ -1010,7 +1431,10 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err selectedIdx := self.context().GetSelectedLineIdx() commits := self.c.Model().Commits branches := self.c.Model().Branches - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.CreatingFixupCommitStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil { return err } @@ -1118,7 +1542,10 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc selectedIdx := self.context().GetSelectedLineIdx() commits := self.c.Model().Commits branches := self.c.Model().Branches - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.CreatingFixupCommitStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil { return err } @@ -1179,7 +1606,10 @@ func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, reba // up by that many rows to stay on the same commit. Compute the target as an // absolute index now, on the current list. targetIdx := self.context().GetSelectedLineIdx() - selectionOffset - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.SquashingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits) err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( diff --git a/pkg/gui/controllers/local_commits_controller_test.go b/pkg/gui/controllers/local_commits_controller_test.go index c5c5e7a5d..0f0a4d137 100644 --- a/pkg/gui/controllers/local_commits_controller_test.go +++ b/pkg/gui/controllers/local_commits_controller_test.go @@ -4,9 +4,47 @@ import ( "testing" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/stretchr/testify/assert" ) +func TestFindCommitDragBlock(t *testing.T) { + commit := func(hash string) *models.Commit { + return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash}) + } + identities := []commitDragIdentity{ + commitDragIdentityForCommit(commit("b")), + commitDragIdentityForCommit(commit("c")), + } + + t.Run("finds the original block after selection changes", func(t *testing.T) { + commits := []*models.Commit{commit("a"), commit("b"), commit("c"), commit("d")} + + actual, startIndex, endIndex, found := findCommitDragBlock(commits, identities) + + assert.True(t, found) + assert.Equal(t, commits[1:3], actual) + assert.Equal(t, 1, startIndex) + assert.Equal(t, 2, endIndex) + }) + + t.Run("rejects a block that is no longer contiguous", func(t *testing.T) { + _, _, _, found := findCommitDragBlock( + []*models.Commit{commit("a"), commit("b"), commit("d"), commit("c")}, identities, + ) + + assert.False(t, found) + }) + + t.Run("rejects an ambiguous block", func(t *testing.T) { + _, _, _, found := findCommitDragBlock( + []*models.Commit{commit("b"), commit("c"), commit("b"), commit("c")}, identities, + ) + + assert.False(t, found) + }) +} + func Test_countSquashableCommitsAbove(t *testing.T) { scenarios := []struct { name string diff --git a/pkg/gui/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go index f3e26e303..e1405463a 100644 --- a/pkg/gui/controllers/patch_building_controller.go +++ b/pkg/gui/controllers/patch_building_controller.go @@ -230,9 +230,8 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error { err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex) // Escape pops the patch-building context, so run it on the UI thread // before the refresh below. - _ = self.c.GocuiGui().OnUIThreadAndWait(func() error { + _ = self.c.GocuiGui().OnUIThreadAndWait(func() { self.c.Helpers().PatchBuilding.Escape() - return nil }) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{}) diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go index aa5fd54bb..70dfb8f4e 100644 --- a/pkg/gui/controllers/patch_explorer_controller.go +++ b/pkg/gui/controllers/patch_explorer_controller.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) @@ -19,18 +20,27 @@ func NewPatchExplorerControllerFactory(c *ControllerCommon) *PatchExplorerContro } func (self *PatchExplorerControllerFactory) Create(context types.IPatchExplorerContext) *PatchExplorerController { - return &PatchExplorerController{ + controller := &PatchExplorerController{ baseController: baseController{}, c: self.c, context: context, } + controller.dragAutoscroller = helpers.NewDragAutoscroller( + self.c.HelperCommon, + context, + controller.canDragAutoscroll, + controller.handleDragAutoscroll, + ) + return controller } type PatchExplorerController struct { baseController c *ControllerCommon - context types.IPatchExplorerContext + context types.IPatchExplorerContext + dragAutoscroller *helpers.DragAutoscroller + draggingWithMouse bool } func (self *PatchExplorerController) Context() types.Context { @@ -153,10 +163,74 @@ func (self *PatchExplorerController) GetMouseKeybindings(opts types.KeybindingsO ViewName: self.context.GetViewName(), Key: gocui.MouseLeft, Modifier: gocui.ModMotion, - Handler: func(gocui.ViewMouseBindingOpts) error { - return self.withRenderAndFocus(self.HandleMouseDrag)() - }, + Handler: self.handleMouseDrag, }, + { + ViewName: self.context.GetViewName(), + Key: gocui.MouseRelease, + Handler: func(gocui.ViewMouseBindingOpts) error { return self.handleDragRelease() }, + }, + } +} + +func (self *PatchExplorerController) handleMouseDrag(opts gocui.ViewMouseBindingOpts) error { + if err := self.withLock(func() error { + self.context.GetState().DragSelectLine(opts.Y) + self.renderDragSelection() + return nil + })(); err != nil { + return err + } + + self.draggingWithMouse = true + originY, _ := self.context.GetViewTrait().ViewPortYBounds() + self.dragAutoscroller.Update(opts.Y - originY) + return nil +} + +func (self *PatchExplorerController) canDragAutoscroll(int) bool { + state := self.context.GetState() + return state != nil && state.SelectingRange() +} + +func (self *PatchExplorerController) handleDragAutoscroll(viewIndex int) bool { + if !self.canDragAutoscroll(0) { + return false + } + + if err := self.withLock(func() error { + self.context.GetState().DragSelectLine(viewIndex) + self.renderDragSelection() + return nil + })(); err != nil { + return false + } + return true +} + +func (self *PatchExplorerController) renderDragSelection() { + view := self.context.GetView() + state := self.context.GetState() + originY := view.OriginY() + startIndex, _ := state.SelectedViewRange() + view.SetRangeSelectStart(startIndex) + view.SetCursorY(state.GetSelectedViewLineIdx() - originY) + self.context.Render() +} + +func (self *PatchExplorerController) handleDragRelease() error { + self.draggingWithMouse = false + self.dragAutoscroller.Cancel() + return nil +} + +func (self *PatchExplorerController) GetOnFocusLost() func(types.OnFocusLostOpts) { + return func(types.OnFocusLostOpts) { + self.dragAutoscroller.Cancel() + if self.draggingWithMouse { + self.draggingWithMouse = false + self.c.GocuiGui().CancelMouseCapture() + } } } @@ -266,12 +340,6 @@ func (self *PatchExplorerController) HandleMouseDown() error { return nil } -func (self *PatchExplorerController) HandleMouseDrag() error { - self.context.GetState().DragSelectLine(self.context.GetViewTrait().SelectedLineIdx()) - - return nil -} - func (self *PatchExplorerController) CopySelectedToClipboard() error { selected := self.context.GetState().PlainRenderSelected() diff --git a/pkg/gui/controllers/screen_mode_actions.go b/pkg/gui/controllers/screen_mode_actions.go index 887f34603..cb1b0616e 100644 --- a/pkg/gui/controllers/screen_mode_actions.go +++ b/pkg/gui/controllers/screen_mode_actions.go @@ -42,9 +42,10 @@ func (self *ScreenModeActions) rerenderViewsWithScreenModeDependentContent() { } } - // Rerender the main view; for views that display a diff this is necessary in case a custom - // pager depends on the width of the view. For other views it isn't needed, but we don't bother - // making a distinction here, as rerendering the main view unnecessarily is not a big deal. + // Rerender the main view; for views that display a diff this is necessary in case a custom diff + // renderer depends on the width of the view. For other views it isn't needed, but we don't + // bother making a distinction here, as rerendering the main view unnecessarily is not a big + // deal. self.c.Context().CurrentSide().HandleRenderToMain() } diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index d01fc8dbf..7730587f4 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -120,33 +121,29 @@ func (self *StashController) handleStashApply(stashEntry *models.StashEntry) err Title: self.c.Tr.StashApply, Prompt: self.c.Tr.SureApplyStashEntry, HandleConfirm: func() error { - self.c.LogAction(self.c.Tr.Actions.ApplyStash) - err := self.c.Git().Stash.Apply(stashEntry.Index) - self.postStashRefresh() - if err != nil { - return err - } - if self.c.UserConfig().Gui.SwitchToFilesAfterStashApply { - self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) - } - return nil + return self.c.WithWaitingStatusBlockingInput( + types.WaitingStatusOpts{Message: self.c.Tr.ApplyingStashStatus}, + func(gocui.Task) error { + self.c.LogAction(self.c.Tr.Actions.ApplyStash) + err := self.c.Git().Stash.Apply(stashEntry.Index) + self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashApply) + return err + }) }, }) } func (self *StashController) handleStashPop(stashEntry *models.StashEntry) error { pop := func() error { - self.c.LogAction(self.c.Tr.Actions.PopStash) - self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.PoppingStash, stashEntry.Hash), false) - err := self.c.Git().Stash.Pop(stashEntry.Index) - self.postStashRefresh() - if err != nil { - return err - } - if self.c.UserConfig().Gui.SwitchToFilesAfterStashPop { - self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) - } - return nil + return self.c.WithWaitingStatusBlockingInput( + types.WaitingStatusOpts{Message: self.c.Tr.PoppingStashStatus}, + func(gocui.Task) error { + self.c.LogAction(self.c.Tr.Actions.PopStash) + self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.PoppingStash, stashEntry.Hash), false) + err := self.c.Git().Stash.Pop(stashEntry.Index) + self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashPop) + return err + }) } if self.c.UserConfig().Gui.SkipStashWarning { @@ -175,31 +172,60 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) // iteration lets the workers race and an earlier, stale result can // land last. The indices are captured up front and we drop // highest-first, so the remaining lower indices stay valid without - // an intervening refresh. Block input until the refresh has - // landed, so that dropping the next entry in quick succession - // (confirming and pressing the key again right away) sees the - // refreshed list and not the stale, pre-drop indices. - defer self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) + // an intervening refresh. + var dropErr error for i := len(stashEntries) - 1; i >= 0; i-- { self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.DroppingStash, stashEntries[i].Hash), false) - if err := self.c.Git().Stash.Drop(stashEntries[i].Index); err != nil { - return err + if dropErr = self.c.Git().Stash.Drop(stashEntries[i].Index); dropErr != nil { + break } } - self.context().CollapseRangeSelectionToTop() - return nil + // Block input until the refresh has landed, so that dropping the + // next entry in quick succession (confirming and pressing the key + // again right away) sees the refreshed list and not the stale, + // pre-drop indices. + self.c.RefreshBlockingInput(types.RefreshOptions{ + Scope: []types.RefreshableView{types.STASH}, + Then: func() error { + // Collapse the range selection from here, so that it lands + // in the same frame as the shortened list. The refresh has + // painted the list by the time Then runs, so the new + // selection needs a focus update of its own. + if dropErr == nil { + self.context().CollapseRangeSelectionToTop() + self.context().HandleFocus(types.OnFocusOpts{}) + } + return nil + }, + }) + return dropErr }, }) return nil } -func (self *StashController) postStashRefresh() { - // Block input until the refresh has landed: popping shifts the indices of - // the remaining stash entries, and acting on the next entry in quick - // succession (confirming the popup and pressing the key again right away) - // must see the refreshed list, or it would target the wrong stash. - self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}}) +// postStashRefresh refreshes the panels that applying or popping a stash +// affects, moving the focus to the files panel if switchToFiles is set. +// +// Call it from the worker that ran the stash command, from inside a +// WithWaitingStatusBlockingInput: popping shifts the indices of the remaining +// stash entries, so acting on the next entry in quick succession (confirming +// the popup and pressing the key again right away) has to be held off until +// the refreshed list is in place, or it would target the wrong stash. +func (self *StashController) postStashRefresh(switchToFiles bool) { + self.c.RefreshFromWorker(types.RefreshOptions{ + BatchUIUpdates: true, + Scope: []types.RefreshableView{types.STASH, types.FILES}, + Then: func() error { + // Switch panels from here, so that the focus change lands in the + // same frame as the refreshed panel contents. + if switchToFiles { + self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) + } + return nil + }, + }) } func (self *StashController) handleNewBranchOffStashEntry(stashEntry *models.StashEntry) error { @@ -225,7 +251,6 @@ func (self *StashController) handleRenameStashEntry(stashEntry *models.StashEntr return err } self.context().SetSelection(0) // Select the renamed stash - self.context().FocusLine(true) // Renaming re-creates the stash at the top, shifting the other // entries' indices; block input so that a quick next action sees // the refreshed list rather than the stale indices. diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go index a2dd22ed3..82ca509ca 100644 --- a/pkg/gui/controllers/submodules_controller.go +++ b/pkg/gui/controllers/submodules_controller.go @@ -123,7 +123,7 @@ func (self *SubmodulesController) GetOnRenderToMain() func() { if file == nil { task = types.NewRenderStringTask(prefix) } else { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, nil) + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(file, false, !file.HasUnstagedChanges && file.HasStagedChanges, file.Names()) task = types.NewRunCommandTaskWithPrefix(cmdObj.GetCmd(), prefix) } } diff --git a/pkg/gui/filetree/file_node.go b/pkg/gui/filetree/file_node.go index 0836eaf02..04c98f1fa 100644 --- a/pkg/gui/filetree/file_node.go +++ b/pkg/gui/filetree/file_node.go @@ -51,7 +51,7 @@ func (self *FileNode) GetHasInlineMergeConflicts() bool { if !file.HasInlineMergeConflicts { return false } - hasConflicts, _ := mergeconflicts.FileHasConflictMarkers(file.Path) + hasConflicts, _ := mergeconflicts.FileHasConflictMarkers(file.Path, file.ConflictMarkerSize) return hasConflicts }) } diff --git a/pkg/gui/filetree/file_tree.go b/pkg/gui/filetree/file_tree.go index 2d3cec514..6c8eff72e 100644 --- a/pkg/gui/filetree/file_tree.go +++ b/pkg/gui/filetree/file_tree.go @@ -3,6 +3,7 @@ package filetree import ( "fmt" + "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -42,6 +43,7 @@ type IFileTree interface { FilterFiles(test func(*models.File) bool) []*models.File SetStatusFilter(filter FileTreeDisplayFilter) + RememberConflictedPaths(paths []string) ForceShowUntracked() bool Get(index int) *FileNode GetFile(path string) *models.File @@ -54,25 +56,31 @@ type IFileTree interface { } type FileTree struct { - getFiles func() []*models.File - tree *Node[models.File] - showTree bool - common *common.Common - filter FileTreeDisplayFilter - collapsedPaths *CollapsedPaths - textFilter string - useFuzzySearch bool + getFiles func() []*models.File + tree *Node[models.File] + showTree bool + common *common.Common + filter FileTreeDisplayFilter + // Paths of the files that had conflicts while the current filter has been + // active. The DisplayConflicted filter keeps showing them after their + // conflicts have been resolved, so that their diffs can be reviewed while + // the remaining files are still being worked on. + conflictedPaths *set.Set[string] + collapsedPaths *CollapsedPaths + textFilter string + useFuzzySearch bool } var _ IFileTree = &FileTree{} func NewFileTree(getFiles func() []*models.File, common *common.Common, showTree bool) *FileTree { return &FileTree{ - getFiles: getFiles, - common: common, - showTree: showTree, - filter: DisplayAll, - collapsedPaths: NewCollapsedPaths(), + getFiles: getFiles, + common: common, + showTree: showTree, + filter: DisplayAll, + conflictedPaths: set.New[string](), + collapsedPaths: NewCollapsedPaths(), } } @@ -100,7 +108,9 @@ func (self *FileTree) getFilesForDisplay() []*models.File { case DisplayUntracked: files = self.FilterFiles(func(file *models.File) bool { return !(file.Tracked || file.HasStagedChanges) }) case DisplayConflicted: - files = self.FilterFiles(func(file *models.File) bool { return file.HasMergeConflicts }) + files = self.FilterFiles(func(file *models.File) bool { + return file.HasMergeConflicts || self.conflictedPaths.Includes(file.Path) + }) default: panic(fmt.Sprintf("Unexpected files display filter: %d", self.filter)) } @@ -122,9 +132,16 @@ func (self *FileTree) FilterFiles(test func(*models.File) bool) []*models.File { func (self *FileTree) SetStatusFilter(filter FileTreeDisplayFilter) { self.filter = filter + self.conflictedPaths = set.New[string]() self.SetTree() } +// RememberConflictedPaths records which files have conflicts right now, so that +// the DisplayConflicted filter keeps showing them once they are resolved. +func (self *FileTree) RememberConflictedPaths(paths []string) { + self.conflictedPaths.Add(paths...) +} + func (self *FileTree) ToggleShowTree() { self.showTree = !self.showTree self.SetTree() diff --git a/pkg/gui/filetree/file_tree_test.go b/pkg/gui/filetree/file_tree_test.go index 1c7960a6e..3058e8db9 100644 --- a/pkg/gui/filetree/file_tree_test.go +++ b/pkg/gui/filetree/file_tree_test.go @@ -4,6 +4,7 @@ import ( "fmt" "testing" + "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/config" @@ -12,10 +13,11 @@ import ( func TestFilterAction(t *testing.T) { scenarios := []struct { - name string - filter FileTreeDisplayFilter - files []*models.File - expected []*models.File + name string + filter FileTreeDisplayFilter + conflictedPaths []string + files []*models.File + expected []*models.File }{ { name: "filter files with unstaged changes", @@ -84,11 +86,29 @@ func TestFilterAction(t *testing.T) { {Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true}, }, }, + { + name: "keep showing conflicted files whose conflicts have been resolved", + filter: DisplayConflicted, + conflictedPaths: []string{"dir2/dir2/file4", "file1"}, + files: []*models.File{ + {Path: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true}, + {Path: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true}, + {Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true}, + }, + expected: []*models.File{ + {Path: "dir2/dir2/file4", ShortStatus: "M ", HasStagedChanges: true}, + {Path: "file1", ShortStatus: "UU", HasMergeConflicts: true, HasInlineMergeConflicts: true}, + }, + }, } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { - mngr := &FileTree{getFiles: func() []*models.File { return s.files }, filter: s.filter} + mngr := &FileTree{ + getFiles: func() []*models.File { return s.files }, + filter: s.filter, + conflictedPaths: set.NewFromSlice(s.conflictedPaths), + } result := mngr.getFilesForDisplay() assert.EqualValues(t, s.expected, result) }) diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index aabbbce7f..a5971f592 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -167,6 +167,31 @@ func (self *FileTreeViewModel) SetStatusFilter(filter FileTreeDisplayFilter) { self.IListCursor.SetSelection(0) } +func (self *FileTreeViewModel) SetStatusFilterPreservingSelection(filter FileTreeDisplayFilter) { + self.preserveSelection(func() { + self.SetStatusFilter(filter) + }) +} + +func (self *FileTreeViewModel) preserveSelection(f func()) { + selectedNode := self.GetSelected() + var selectedPath string + if selectedNode != nil { + selectedPath = selectedNode.GetInternalPath() + } + + f() + + if selectedPath != "" { + self.ExpandToPath(selectedPath) + if idx, found := self.GetIndexForPath(selectedPath); found { + self.SetSelection(idx) + return + } + } + self.ClampSelection() +} + // If we're going from flat to tree we want to select the same file. // If we're going from tree to flat and we have a file selected we want to select that. // If instead we've selected a directory we need to select the first file in that directory. @@ -233,22 +258,9 @@ func (self *FileTreeViewModel) GetFilter() string { } func (self *FileTreeViewModel) ClearFilter() { - selectedNode := self.GetSelected() - var selectedPath string - if selectedNode != nil { - selectedPath = selectedNode.GetInternalPath() - } - - self.IFileTree.SetTextFilter("", false) - - if selectedPath != "" { - self.ExpandToPath(selectedPath) - if idx, found := self.GetIndexForPath(selectedPath); found { - self.SetSelection(idx) - return - } - } - self.ClampSelection() + self.preserveSelection(func() { + self.IFileTree.SetTextFilter("", false) + }) } func (self *FileTreeViewModel) ReApplyFilter(useFuzzySearch bool) { diff --git a/pkg/gui/filetree/file_tree_view_model_test.go b/pkg/gui/filetree/file_tree_view_model_test.go new file mode 100644 index 000000000..c14c91ea8 --- /dev/null +++ b/pkg/gui/filetree/file_tree_view_model_test.go @@ -0,0 +1,32 @@ +package filetree + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/common" + "github.com/stretchr/testify/assert" +) + +func TestSetStatusFilterPreservingSelection(t *testing.T) { + files := []*models.File{ + {Path: "file1"}, + {Path: "file2", HasMergeConflicts: true}, + {Path: "file3", HasMergeConflicts: true}, + } + viewModel := NewFileTreeViewModel( + func() []*models.File { return files }, + common.NewDummyCommon(), + false, + ) + viewModel.SetTree() + viewModel.SetStatusFilter(DisplayConflicted) + viewModel.SetSelection(viewModel.Len() - 2) + viewModel.ToggleStickyRange() + viewModel.MoveSelectedLine(1) + + viewModel.SetStatusFilterPreservingSelection(DisplayAll) + + assert.Equal(t, "file3", viewModel.GetSelectedPath()) + assert.False(t, viewModel.IsSelectingRange()) +} diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 912d46567..bde383caf 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -69,7 +69,7 @@ type Gui struct { // this is the state of the GUI for the current repo State *GuiRepoState - pagerConfig *config.PagerConfig + diffRendererConfig *config.DiffRendererConfigManager CustomCommandsClient *custom_commands.Client @@ -94,9 +94,9 @@ type Gui struct { Mutexes types.Mutexes - // when you enter into a submodule we'll append the superproject's path to this array - // so that you can return to the superproject - RepoPathStack *utils.StringStack + // when you enter into a submodule we'll append the superproject's location to + // this array so that you can return to the superproject + RepoPathStack *utils.Stack[types.RepoLocation] // this tells us whether our views have been initially set up ViewsSetup bool @@ -158,7 +158,7 @@ type StateAccessor struct { var _ types.IStateAccessor = new(StateAccessor) -func (self *StateAccessor) GetRepoPathStack() *utils.StringStack { +func (self *StateAccessor) GetRepoPathStack() *utils.Stack[types.RepoLocation] { return self.gui.RepoPathStack } @@ -178,8 +178,8 @@ func (self *StateAccessor) GetRepoGeneration() int { return int(self.gui.repoGeneration.Load()) } -func (self *StateAccessor) GetPagerConfig() *config.PagerConfig { - return self.gui.pagerConfig +func (self *StateAccessor) GetDiffRendererConfigManager() *config.DiffRendererConfigManager { + return self.gui.diffRendererConfig } func (self *StateAccessor) GetShowExtrasWindow() bool { @@ -340,17 +340,20 @@ func (gui *Gui) onSwitchToNewRepo(startArgs appTypes.StartArgs, contextKey types } func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.ContextKey) error { - var err error - gui.git, err = commands.NewGitCommand( + // Don't assign to gui.git until we know we have one: this also runs when + // switching repos, and leaving the field nil would take down the repo we + // were in before, which is where the error puts us back. + git, err := commands.NewGitCommand( gui.Common, gui.gitVersion, gui.os, git_config.NewStdCachedGitConfig(gui.Log), - gui.pagerConfig, + gui.diffRendererConfig, ) if err != nil { return err } + gui.git = git err = gui.Config.ReloadUserConfigForRepo(gui.getPerRepoConfigFiles()) if err != nil { @@ -666,7 +669,10 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { func (gui *Gui) loadCachedPullRequests() []*models.GithubPullRequest { repoPath := gui.git.RepoPaths.RepoPath() - cachedPRs := gui.c.GetAppState().GithubPullRequests[repoPath] + cachedPRs, err := gui.Config.GetCachedGithubPullRequests(repoPath) + if err != nil { + gui.Log.Warnf("error loading GitHub pull request cache: %v", err) + } return lo.Map(cachedPRs, func(cached config.CachedPullRequest, _ int) *models.GithubPullRequest { return &models.GithubPullRequest{ @@ -674,6 +680,7 @@ func (gui *Gui) loadCachedPullRequests() []*models.GithubPullRequest { Number: cached.Number, Title: cached.Title, State: cached.State, + ChecksState: cached.ChecksState, Url: cached.Url, HeadRepositoryOwner: models.GithubRepositoryOwner{ Login: cached.HeadRepositoryOwner, @@ -792,7 +799,7 @@ func NewGui( viewBufferManagerMap: map[string]*tasks.ViewBufferManager{}, viewPtmxMap: map[string]oscommands.Pty{}, showRecentRepos: showRecentRepos, - RepoPathStack: &utils.StringStack{}, + RepoPathStack: &utils.Stack[types.RepoLocation]{}, RepoStateMap: map[Repo]*GuiRepoState{}, GuiLog: []string{}, @@ -828,8 +835,8 @@ func NewGui( return nil }, func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatus(message, f) }, - func(message string, f func(gocui.Task) error) { - gui.helpers.AppStatus.WithWaitingStatusBlockingInput(message, f) + func(opts types.WaitingStatusOpts, f func(gocui.Task) error) { + gui.helpers.AppStatus.WithWaitingStatusBlockingInput(opts, f) }, func(message string, kind types.ToastKind) { gui.helpers.AppStatus.Toast(message, kind) }, func() string { return gui.Views.Prompt.TextArea.GetContent() }, @@ -859,7 +866,7 @@ func NewGui( gui.BackgroundRoutineMgr = &BackgroundRoutineMgr{gui: gui} gui.stateAccessor = &StateAccessor{gui: gui} - gui.pagerConfig = config.NewPagerConfig(func() *config.UserConfig { return gui.UserConfig() }) + gui.diffRendererConfig = config.NewDiffRendererConfigManager(func() *config.UserConfig { return gui.UserConfig() }) return gui, nil } @@ -1001,6 +1008,12 @@ func (gui *Gui) RunAndHandleError(startArgs appTypes.StartArgs) error { manager.Close() } + // The pty teardowns spawned by the manager closes above run on + // background goroutines that won't get to finish before the + // process exits; reap their process trees synchronously instead + // so that they don't outlive lazygit. + oscommands.TerminateLivePtys() + close(gui.stopChan) if errors.Is(err, gocui.ErrQuit) { diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index e7b14ba04..d92284ea5 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -39,7 +39,11 @@ func (self *guiCommon) RefreshFromWorker(opts types.RefreshOptions) { } func (self *guiCommon) PostRefreshUpdate(context types.Context) { - self.gui.postRefreshUpdate(context) + self.gui.postRefreshUpdate(context, false) +} + +func (self *guiCommon) PostRefreshUpdateKeepingScrollPosition(context types.Context) { + self.gui.postRefreshUpdate(context, true) } func (self *guiCommon) RunSubprocessAndRefresh(cmdObj *oscommands.CmdObj) error { diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 9e06f483b..b922f0705 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -53,26 +53,73 @@ func (self *GuiDriver) PressKeysRapidly(keyStrs ...string) { func (self *GuiDriver) Click(x, y int) { self.CheckAllToastsAcknowledged() + self.replayMouseEvent(x, y, tcell.ButtonPrimary) + self.replayMouseEvent(x, y, tcell.ButtonNone) +} + +func (self *GuiDriver) ClickAndHold(x, y int) { + self.CheckAllToastsAcknowledged() + self.replayMouseEvent(x, y, tcell.ButtonPrimary) +} + +// MouseMove reports the mouse at a new position with the left button still +// held down, i.e. a drag movement. (No test needs pointer motion without a +// button held, so that variant doesn't exist.) +func (self *GuiDriver) MouseMove(x, y int) { + self.replayMouseEvent(x, y, tcell.ButtonPrimary) +} + +func (self *GuiDriver) ScrollWheelDown(x, y int) { + self.replayMouseEvent(x, y, tcell.WheelDown) +} + +func (self *GuiDriver) MouseRelease(x, y int) { + self.replayMouseEvent(x, y, tcell.ButtonNone) +} + +func (self *GuiDriver) MouseReleaseWithoutWaiting(x, y int) { + self.replayMouseEventWithoutWaiting(x, y, tcell.ButtonNone) +} + +func (self *GuiDriver) WaitUntilIdle() { + self.waitTillIdle() +} + +func (self *GuiDriver) OnUIThreadAndWait(f func()) { + _ = self.gui.g.OnUIThreadAndWait(f) +} + +func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) { + self.replayMouseEventWithoutWaiting(x, y, buttons) + self.waitTillIdle() +} + +func (self *GuiDriver) replayMouseEventWithoutWaiting(x, y int, buttons tcell.ButtonMask) { self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( - tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0), + tcell.NewEventMouse(x, y, buttons, 0), 0, )) - self.waitTillIdle() - self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( - tcell.NewEventMouse(x, y, tcell.ButtonNone, 0), +} + +// replayFocusIn takes the focus away before handing it back, because that's the +// only way a terminal can report regaining it, and lazygit only reacts to focus +// reports that change the focus (see gocui.Gui.IsFocused). +func (self *GuiDriver) replayFocusIn() { + self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( + tcell.NewEventFocus(false), + 0, + )) + self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( + tcell.NewEventFocus(true), 0, )) - self.waitTillIdle() } // FocusIn simulates the terminal window regaining focus, which is how lazygit // learns to reload changed config files. Tests use it to exercise the live // config-reload path. func (self *GuiDriver) FocusIn() { - self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( - tcell.NewEventFocus(true), - 0, - )) + self.replayFocusIn() self.waitTillIdle() } @@ -80,10 +127,7 @@ func (self *GuiDriver) FocusIn() { func (self *GuiDriver) FocusInAndClick(x, y int) { self.CheckAllToastsAcknowledged() - self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( - tcell.NewEventFocus(true), - 0, - )) + self.replayFocusIn() self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0), 0, @@ -96,6 +140,16 @@ func (self *GuiDriver) FocusInAndClick(x, y int) { self.waitTillIdle() } +// RefreshInBackground performs the refresh that the background routines perform +// on a timer (see BackgroundRoutineMgr). Tests drive it directly rather than +// turning those routines on, so that they neither wait for a timer nor depend on +// one firing at a particular moment. +func (self *GuiDriver) RefreshInBackground() { + self.gui.c.RefreshFromWorker(types.RefreshOptions{Background: true}) + + self.waitTillIdle() +} + func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() { self.gui.onUIThread(func() error { self.gui.State.SetMergeOrRebaseStartedInLazygit(true) diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 22d76f01b..c6ac2533e 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -295,8 +295,9 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin }, } - mouseKeybindings := []*gocui.ViewMouseBinding{} - for _, c := range gui.State.Contexts.Flatten() { + contexts := gui.State.Contexts.Flatten() + mouseKeybindings := make([]*gocui.ViewMouseBinding, 0, len(contexts)) + for _, c := range contexts { viewName := c.GetViewName() for _, binding := range c.GetKeybindings(opts) { // TODO: move all mouse keybindings into the mouse keybindings approach below diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index bcdc0edfc..67e695f2b 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -88,7 +88,13 @@ func (gui *Gui) layout(g *gocui.Gui) error { if !view.CanScrollPastBottom { maxOriginY -= newHeight - 1 } - if oldOriginY := view.OriginY(); oldOriginY > maxOriginY { + // Don't scroll up while the view's content is still being loaded: its + // height only reflects what has been read so far, so clamping to it now + // would yank the view to the top even though more content is on the way + // (e.g. when re-rendering a diff the user was scrolled into). + manager := gui.getViewBufferManagerForView(view) + stillLoading := manager != nil && manager.IsLoading() + if oldOriginY := view.OriginY(); oldOriginY > maxOriginY && !stillLoading { view.ScrollUp(oldOriginY - maxOriginY) // the view might not have scrolled actually (if it was at the limit // already), so we need to check if it did diff --git a/pkg/gui/main_panels.go b/pkg/gui/main_panels.go index 03b7469d2..a0efcb14c 100644 --- a/pkg/gui/main_panels.go +++ b/pkg/gui/main_panels.go @@ -107,16 +107,6 @@ func (gui *Gui) allMainContextPairs() []types.MainContextPair { } func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) { - // need to reset scroll positions of all other main views - for _, pair := range gui.allMainContextPairs() { - if pair.Main != opts.Pair.Main { - pair.Main.GetView().SetOrigin(0, 0) - } - if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary { - pair.Secondary.GetView().SetOrigin(0, 0) - } - } - gui.moveMainContextPairToTop(opts.Pair) if opts.Main != nil { @@ -129,6 +119,20 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) { opts.Pair.Secondary.GetView().Clear() } + // Reset the scroll positions of all the other main views. We do this after + // moving this pair to the top (which copies the previously-shown view's + // content into the now-visible one to avoid a blank frame): resetting first + // would zero that source view's scroll before it gets copied, forcing the + // placeholder to the top instead of leaving it where the screen already was. + for _, pair := range gui.allMainContextPairs() { + if pair.Main != opts.Pair.Main { + pair.Main.GetView().SetOrigin(0, 0) + } + if pair.Secondary != nil && pair.Secondary != opts.Pair.Secondary { + pair.Secondary.GetView().SetOrigin(0, 0) + } + } + gui.splitMainPanel(opts.Secondary != nil) } diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index 23016b9a5..0ddefdbee 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -72,8 +72,6 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error { gui.State.Contexts.Menu.SetOnCancel(opts.OnCancel) gui.State.Contexts.Menu.SetSelection(0) - gui.Views.Menu.SetOriginY(0) - gui.Views.Menu.Title = opts.Title gui.Views.Menu.FgColor = theme.GocuiDefaultTextColor diff --git a/pkg/gui/mergeconflicts/find_conflicts.go b/pkg/gui/mergeconflicts/find_conflicts.go index 5fe45624e..e57c16635 100644 --- a/pkg/gui/mergeconflicts/find_conflicts.go +++ b/pkg/gui/mergeconflicts/find_conflicts.go @@ -2,7 +2,6 @@ package mergeconflicts import ( "bufio" - "bytes" "io" "os" "strings" @@ -22,7 +21,23 @@ const ( NOT_A_MARKER ) -func findConflicts(content string) []*mergeConflict { +// The number of characters a conflict marker consists of, unless the file's +// conflict-marker-size gitattribute says otherwise. +const defaultConflictMarkerSize = 7 + +// The marker size that everything in here takes is the conflict-marker-size +// gitattribute of the file being examined, which is 0 for a file that doesn't +// have that attribute. Git falls back to its default size in that case, so we +// do the same. +func effectiveMarkerSize(markerSize int) int { + if markerSize < 1 { + return defaultConflictMarkerSize + } + + return markerSize +} + +func findConflicts(content string, markerSize int) []*mergeConflict { conflicts := make([]*mergeConflict, 0) if content == "" { @@ -31,7 +46,7 @@ func findConflicts(content string) []*mergeConflict { var newConflict *mergeConflict for i, line := range utils.SplitLines(content) { - switch determineLineType(line) { + switch determineLineType(line, markerSize) { case START: newConflict = &mergeConflict{start: i, ancestor: -1} case ANCESTOR: @@ -57,35 +72,59 @@ func findConflicts(content string) []*mergeConflict { return conflicts } -var ( - CONFLICT_START = "<<<<<<< " - CONFLICT_END = ">>>>>>> " - CONFLICT_START_BYTES = []byte(CONFLICT_START) - CONFLICT_END_BYTES = []byte(CONFLICT_END) -) +func determineLineType(line string, markerSize int) LineType { + markerSize = effectiveMarkerSize(markerSize) -func determineLineType(line string) LineType { // TODO: find out whether we ever actually get this prefix trimmedLine := strings.TrimPrefix(line, "++") switch { - case strings.HasPrefix(trimmedLine, CONFLICT_START): + case isConflictMarker(trimmedLine, '<', markerSize): return START - case strings.HasPrefix(trimmedLine, "||||||| "): + case isConflictMarker(trimmedLine, '|', markerSize): return ANCESTOR - case trimmedLine == "=======": + case isTargetMarker(trimmedLine, markerSize): return TARGET - case strings.HasPrefix(trimmedLine, CONFLICT_END): + case isConflictMarker(trimmedLine, '>', markerSize): return END default: return NOT_A_MARKER } } +// Tells us whether the line begins with markerSize repetitions of markerChar. +func hasMarkerPrefix[T string | []byte](line T, markerChar byte, markerSize int) bool { + if len(line) < markerSize { + return false + } + + for i := range markerSize { + if line[i] != markerChar { + return false + } + } + + return true +} + +// A start, ancestor or end marker is followed by a space and a label, e.g. +// "<<<<<<< HEAD". The label can be missing though, in which case git doesn't +// write the space either; `git checkout -m` with the diff3 conflict style does +// that for the ancestor marker, for example. +func isConflictMarker[T string | []byte](line T, markerChar byte, markerSize int) bool { + return hasMarkerPrefix(line, markerChar, markerSize) && + (len(line) == markerSize || line[markerSize] == ' ') +} + +// The marker separating the two sides of a conflict never has a label after it. +func isTargetMarker(line string, markerSize int) bool { + return hasMarkerPrefix(line, '=', markerSize) && len(line) == markerSize +} + // tells us whether a file actually has inline merge conflicts. We need to run this // because git will continue showing a status of 'UU' even after the conflicts have // been resolved in the user's editor -func FileHasConflictMarkers(path string) (bool, error) { +func FileHasConflictMarkers(path string, markerSize int) (bool, error) { file, err := os.Open(path) if err != nil { return false, err @@ -93,22 +132,20 @@ func FileHasConflictMarkers(path string) (bool, error) { defer file.Close() - return fileHasConflictMarkersAux(file) + return fileHasConflictMarkersAux(file, markerSize) } // Efficiently scans through a file looking for merge conflict markers. Returns true if it does -func fileHasConflictMarkersAux(file io.Reader) (bool, error) { +func fileHasConflictMarkersAux(file io.Reader, markerSize int) (bool, error) { + markerSize = effectiveMarkerSize(markerSize) + scanner := bufio.NewScanner(file) scanner.Split(utils.ScanLinesAndTruncateWhenLongerThanBuffer(bufio.MaxScanTokenSize)) for scanner.Scan() { line := scanner.Bytes() // only searching for start/end markers because the others are more ambiguous - if bytes.HasPrefix(line, CONFLICT_START_BYTES) { - return true, nil - } - - if bytes.HasPrefix(line, CONFLICT_END_BYTES) { + if isConflictMarker(line, '<', markerSize) || isConflictMarker(line, '>', markerSize) { return true, nil } } diff --git a/pkg/gui/mergeconflicts/find_conflicts_test.go b/pkg/gui/mergeconflicts/find_conflicts_test.go index c763aa51f..28839126f 100644 --- a/pkg/gui/mergeconflicts/find_conflicts_test.go +++ b/pkg/gui/mergeconflicts/find_conflicts_test.go @@ -8,9 +8,12 @@ import ( ) func TestDetermineLineType(t *testing.T) { + // A markerSize of 0 means the file has no conflict-marker-size gitattribute, + // so git's default size applies. type scenario struct { - line string - expected LineType + line string + markerSize int + expected LineType } scenarios := []scenario{ @@ -54,17 +57,75 @@ func TestDetermineLineType(t *testing.T) { line: "||||||| adf33b9", expected: ANCESTOR, }, + { + line: "<<<<<<<<", + expected: NOT_A_MARKER, + }, + // Markers without a label + { + line: "<<<<<<<", + expected: START, + }, + { + line: "|||||||", + expected: ANCESTOR, + }, + { + line: ">>>>>>>", + expected: END, + }, + { + line: strings.Repeat("<", 32) + " HEAD", + markerSize: 32, + expected: START, + }, + { + line: strings.Repeat("|", 32) + " adf33b9", + markerSize: 32, + expected: ANCESTOR, + }, + { + line: strings.Repeat("=", 32), + markerSize: 32, + expected: TARGET, + }, + { + line: strings.Repeat(">", 32) + " blah", + markerSize: 32, + expected: END, + }, + // A file gets a bigger marker size precisely because its regular content + // tends to contain marker-looking lines, so lines with the default size + // must not be mistaken for markers + { + line: "<<<<<<< HEAD", + markerSize: 32, + expected: NOT_A_MARKER, + }, + { + line: "=======", + markerSize: 32, + expected: NOT_A_MARKER, + }, + { + line: strings.Repeat("=", 33), + markerSize: 32, + expected: NOT_A_MARKER, + }, } for _, s := range scenarios { - assert.EqualValues(t, s.expected, determineLineType(s.line)) + assert.EqualValues(t, s.expected, determineLineType(s.line, s.markerSize), s.line) } } func TestFindConflictsAux(t *testing.T) { + // A markerSize of 0 means the file has no conflict-marker-size gitattribute, + // so git's default size applies. type scenario struct { - content string - expected bool + content string + markerSize int + expected bool } scenarios := []scenario{ @@ -88,16 +149,36 @@ func TestFindConflictsAux(t *testing.T) { content: " <<<<<<< ", expected: false, }, + { + content: ">>>>>>>", + expected: true, + }, { content: "a\nb\nc\n<<<<<<< ", expected: true, }, + { + content: "a\nb\nc\n" + strings.Repeat("<", 32) + " HEAD", + markerSize: 32, + expected: true, + }, + { + content: "a\nb\nc\n" + strings.Repeat(">", 32) + " blah", + markerSize: 32, + expected: true, + }, + // Marker-looking lines of the default size are the file's regular content + { + content: "a\nb\nc\n<<<<<<< HEAD\n=======\n>>>>>>> blah", + markerSize: 32, + expected: false, + }, } for _, s := range scenarios { reader := strings.NewReader(s.content) - result, err := fileHasConflictMarkersAux(reader) + result, err := fileHasConflictMarkersAux(reader, s.markerSize) assert.NoError(t, err) - assert.EqualValues(t, s.expected, result) + assert.EqualValues(t, s.expected, result, s.content) } } diff --git a/pkg/gui/mergeconflicts/state.go b/pkg/gui/mergeconflicts/state.go index 047241353..d38e0c754 100644 --- a/pkg/gui/mergeconflicts/state.go +++ b/pkg/gui/mergeconflicts/state.go @@ -12,6 +12,9 @@ type State struct { // path of the file with the conflicts path string + // the file's conflict-marker-size gitattribute, or 0 if it doesn't have one + markerSize int + // This is a stack of the file content. It is used to undo changes. // The last item is the current file content. contents []string @@ -74,12 +77,13 @@ func (s *State) currentConflict() *mergeConflict { } // this is for starting a new merge conflict session -func (s *State) SetContent(content string, path string) { - if content == s.GetContent() && path == s.path { +func (s *State) SetContent(content string, path string, markerSize int) { + if content == s.GetContent() && path == s.path && markerSize == s.markerSize { return } s.path = path + s.markerSize = markerSize s.contents = []string{} s.PushContent(content) } @@ -88,7 +92,7 @@ func (s *State) SetContent(content string, path string) { // state func (s *State) PushContent(content string) { s.contents = append(s.contents, content) - s.setConflicts(findConflicts(content)) + s.setConflicts(findConflicts(content, s.markerSize)) } func (s *State) GetContent() string { @@ -103,6 +107,10 @@ func (s *State) GetPath() string { return s.path } +func (s *State) GetMarkerSize() int { + return s.markerSize +} + func (s *State) Undo() bool { if len(s.contents) <= 1 { return false @@ -112,7 +120,7 @@ func (s *State) Undo() bool { newContent := s.GetContent() // We could be storing the old conflicts and selected index on a stack too. - s.setConflicts(findConflicts(newContent)) + s.setConflicts(findConflicts(newContent, s.markerSize)) return true } @@ -147,6 +155,7 @@ func (s *State) AllConflictsResolved() bool { func (s *State) Reset() { s.contents = []string{} s.path = "" + s.markerSize = 0 } // we're not resetting selectedIndex here because the user typically would want diff --git a/pkg/gui/mergeconflicts/state_test.go b/pkg/gui/mergeconflicts/state_test.go index 7a9ee8c26..06f8fa6bb 100644 --- a/pkg/gui/mergeconflicts/state_test.go +++ b/pkg/gui/mergeconflicts/state_test.go @@ -116,7 +116,7 @@ baz for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { - assert.EqualValues(t, s.expected, findConflicts(s.content)) + assert.EqualValues(t, s.expected, findConflicts(s.content, defaultConflictMarkerSize)) }) } } diff --git a/pkg/gui/patch_exploring/state.go b/pkg/gui/patch_exploring/state.go index 5f1a29e61..7222fef3b 100644 --- a/pkg/gui/patch_exploring/state.go +++ b/pkg/gui/patch_exploring/state.go @@ -79,7 +79,7 @@ func NewState(diff string, selectedLineIdx int, view *gocui.View, oldState *Stat // if we have clicked from the outside to focus the main view we'll pass in a non-negative line index so that we can instantly select that line if selectedLineIdx >= 0 { // Clamp to the number of wrapped view lines; index might be out of - // bounds if a custom pager is being used which produces more lines + // bounds if a custom diff renderer is being used which produces more lines selectedLineIdx = min(selectedLineIdx, len(viewLineIndices)-1) selectMode = RANGE diff --git a/pkg/gui/popup/popup_handler.go b/pkg/gui/popup/popup_handler.go index 7c15c56ea..3b305a25d 100644 --- a/pkg/gui/popup/popup_handler.go +++ b/pkg/gui/popup/popup_handler.go @@ -19,7 +19,7 @@ type PopupHandler struct { currentContextFn func() types.Context createMenuFn func(types.CreateMenuOptions) error withWaitingStatusFn func(message string, f func(gocui.Task) error) - withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error) + withWaitingStatusBlockingInputFn func(opts types.WaitingStatusOpts, f func(gocui.Task) error) toastFn func(message string, kind types.ToastKind) getPromptInputFn func() string inDemo func() bool @@ -35,7 +35,7 @@ func NewPopupHandler( currentContextFn func() types.Context, createMenuFn func(types.CreateMenuOptions) error, withWaitingStatusFn func(message string, f func(gocui.Task) error), - withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error), + withWaitingStatusBlockingInputFn func(opts types.WaitingStatusOpts, f func(gocui.Task) error), toastFn func(message string, kind types.ToastKind), getPromptInputFn func() string, inDemo func() bool, @@ -76,8 +76,8 @@ func (self *PopupHandler) WithWaitingStatus(message string, f func(gocui.Task) e return nil } -func (self *PopupHandler) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error { - self.withWaitingStatusBlockingInputFn(message, f) +func (self *PopupHandler) WithWaitingStatusBlockingInput(opts types.WaitingStatusOpts, f func(gocui.Task) error) error { + self.withWaitingStatusBlockingInputFn(opts, f) return nil } diff --git a/pkg/gui/presentation/branches.go b/pkg/gui/presentation/branches.go index 2e8ab0106..f58f34dc6 100644 --- a/pkg/gui/presentation/branches.go +++ b/pkg/gui/presentation/branches.go @@ -150,6 +150,12 @@ func getBranchDisplayStrings( prIcon = "●" } coloredPrIcon = WithPrColor(pr.State, prIcon, false) + if pr.State == "OPEN" { + icon, _, textStyle := checksStatePresentation(pr.ChecksState, tr) + if icon != "" { + coloredPrIcon = textStyle.Sprint(icon) + } + } } res = append(res, coloredPrIcon) @@ -287,6 +293,79 @@ func WithPrColor(state string, text string, isBg bool) string { } } +func FormatPullRequestHeader(pr *models.GithubPullRequest, tr *i18n.TranslationSet) string { + icon := lo.Ternary(icons.IsIconEnabled(), icons.IconForRemoteUrl(pr.Url)+" ", "") + stateText := coloredPullRequestStateText(pr.State) + checksStateText := coloredChecksStateText(pr.ChecksState, tr) + numberText := style.FgCyan.Sprintf("#%d", pr.Number) + + // The checks status links to the checks tab, so it needs to be its own + // hyperlink separate from the rest of the header. + parts := []string{style.PrintHyperlink(icon+stateText, pr.Url)} + if checksStateText != "" { + parts = append(parts, style.PrintHyperlink(checksStateText, strings.TrimSuffix(pr.Url, "/")+"/checks")) + } + parts = append(parts, style.PrintHyperlink(fmt.Sprintf("%s %s\n", pr.Title, numberText), pr.Url)) + + return strings.Join(parts, " ") +} + +func pullRequestStateText(state string) string { + var icon, label string + switch state { + case "OPEN": + icon, label = " ", "Open" + case "CLOSED": + icon, label = " ", "Closed" + case "MERGED": + icon, label = " ", "Merged" + case "DRAFT": + icon, label = " ", "Draft" + default: + return "" + } + if icons.IsIconEnabled() { + return icon + label + } + return label +} + +func coloredPullRequestStateText(state string) string { + if icons.IsIconEnabled() { + return fmt.Sprintf("%s%s%s", + WithPrColor(state, "", false), + WithPrColor(state, color.RGB(0xFF, 0xFF, 0xFF, false).Sprint(pullRequestStateText(state)), true), + WithPrColor(state, "", false)) + } + + return WithPrColor(state, pullRequestStateText(state), false) +} + +func checksStatePresentation(state string, tr *i18n.TranslationSet) (string, string, style.TextStyle) { + switch state { + case "SUCCESS": + return "✓", tr.PullRequestChecksPassing, style.FgGreen + case "PENDING": + return "●", tr.PullRequestChecksPending, style.FgYellow + case "FAILURE": + return "✗", tr.PullRequestChecksFailing, style.FgRed + case "ERROR": + return "!", tr.PullRequestChecksError, style.FgRed + case "EXPECTED": + return "○", tr.PullRequestChecksExpected, style.FgDefault + default: + return "", "", style.Nothing + } +} + +func coloredChecksStateText(state string, tr *i18n.TranslationSet) string { + icon, text, textStyle := checksStatePresentation(state, tr) + if text != "" { + return textStyle.Sprintf("%s %s", icon, text) + } + return "" +} + func ShouldShowPrForBranch(pr *models.GithubPullRequest, branchName string, userConfig *config.UserConfig) bool { if !lo.Contains(userConfig.Git.MainBranches, branchName) { return true diff --git a/pkg/gui/presentation/branches_test.go b/pkg/gui/presentation/branches_test.go index b2c19a9ea..3d83ca0ba 100644 --- a/pkg/gui/presentation/branches_test.go +++ b/pkg/gui/presentation/branches_test.go @@ -10,7 +10,9 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" + "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/samber/lo" "github.com/stretchr/testify/assert" "github.com/xo/terminfo" @@ -22,6 +24,84 @@ func makeAtomic(v int32) *atomic.Int32 { return &result } +func TestFormatPullRequestHeader(t *testing.T) { + oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone) + defer color.ForceSetColorLevel(oldColorLevel) + icons.SetNerdFontsVersion("") + + pr := &models.GithubPullRequest{ + Title: "Improve checks", + Number: 5871, + State: "OPEN", + ChecksState: "SUCCESS", + Url: "https://github.com/jesseduffield/lazygit/pull/5871", + } + numberText := style.FgCyan.Sprint("#5871") + tr := i18n.EnglishTranslationSet() + + t.Run("links checks separately from the rest of the header", func(t *testing.T) { + actual := FormatPullRequestHeader(pr, tr) + + expected := style.PrintHyperlink("Open", pr.Url) + + " " + + style.PrintHyperlink("✓ Passing", pr.Url+"/checks") + + " " + + style.PrintHyperlink("Improve checks "+numberText+"\n", pr.Url) + assert.Equal(t, expected, actual) + }) + + t.Run("leaves the separator unlinked when checks are unavailable", func(t *testing.T) { + prWithoutChecks := *pr + prWithoutChecks.ChecksState = "" + + actual := FormatPullRequestHeader(&prWithoutChecks, tr) + + expected := style.PrintHyperlink("Open", pr.Url) + + " " + + style.PrintHyperlink("Improve checks "+numberText+"\n", pr.Url) + assert.Equal(t, expected, actual) + }) + + t.Run("avoids a double slash in the checks URL", func(t *testing.T) { + prWithTrailingSlash := *pr + prWithTrailingSlash.Url += "/" + + actual := FormatPullRequestHeader(&prWithTrailingSlash, tr) + + assert.Contains(t, actual, "https://github.com/jesseduffield/lazygit/pull/5871/checks") + assert.NotContains(t, actual, "pull/5871//checks") + }) +} + +func TestChecksStatePresentation(t *testing.T) { + tr := i18n.EnglishTranslationSet() + testCases := []struct { + name string + state string + expectedIcon string + expectedText string + expectedStyle style.TextStyle + }{ + {name: "success", state: "SUCCESS", expectedIcon: "✓", expectedText: "Passing", expectedStyle: style.FgGreen}, + {name: "pending", state: "PENDING", expectedIcon: "●", expectedText: "Pending", expectedStyle: style.FgYellow}, + {name: "failure", state: "FAILURE", expectedIcon: "✗", expectedText: "Failing", expectedStyle: style.FgRed}, + {name: "error", state: "ERROR", expectedIcon: "!", expectedText: "Error", expectedStyle: style.FgRed}, + {name: "expected", state: "EXPECTED", expectedIcon: "○", expectedText: "Expected", expectedStyle: style.FgDefault}, + {name: "empty", state: "", expectedIcon: "", expectedText: "", expectedStyle: style.Nothing}, + {name: "unknown", state: "FUTURE_STATE", expectedIcon: "", expectedText: "", expectedStyle: style.Nothing}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + icon, text, textStyle := checksStatePresentation(testCase.state, tr) + + assert.Equal(t, testCase.expectedIcon, icon) + assert.Equal(t, testCase.expectedText, text) + assert.Equal(t, testCase.expectedStyle, textStyle) + }) + } +} + func Test_getBranchDisplayStrings(t *testing.T) { scenarios := []struct { branch *models.Branch @@ -162,7 +242,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", - expected: []string{"1m", "", "branch_name Pushing |"}, + expected: []string{"1m", "", "branch_name Pushing ●∙∙"}, }, { branch: &models.Branch{ @@ -282,7 +362,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", - expected: []string{"1m", "", "branc… Pushing |"}, + expected: []string{"1m", "", "bra… Pushing ●∙∙"}, }, { branch: &models.Branch{Name: "abc", Recency: "1m"}, @@ -292,7 +372,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", - expected: []string{"1m", "", "abc Pushing |"}, + expected: []string{"1m", "", "abc Pushing ●∙∙"}, }, { branch: &models.Branch{Name: "ab", Recency: "1m"}, @@ -302,7 +382,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", - expected: []string{"1m", "", "ab Pushing |"}, + expected: []string{"1m", "", "ab Pushing ●∙∙"}, }, { branch: &models.Branch{Name: "a", Recency: "1m"}, @@ -312,7 +392,7 @@ func Test_getBranchDisplayStrings(t *testing.T) { useIcons: false, checkedOutByWorktree: false, showDivergenceCfg: "none", - expected: []string{"1m", "", "a Pushing |"}, + expected: []string{"1m", "", "a Pushing ●∙∙"}, }, { branch: &models.Branch{ diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index e10ea8ec7..fb7ba352e 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -5,9 +5,12 @@ import ( "io" "os" "os/exec" + "path/filepath" + "runtime" "strings" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/tasks" "github.com/jesseduffield/lazygit/pkg/utils" @@ -46,44 +49,49 @@ type ptyCmd struct { wait func() error } -func (p ptyCmd) Wait() error { return p.wait() } -func (p ptyCmd) String() string { return p.cmd.String() } -func (p ptyCmd) GetProcess() *os.Process { return p.process } +func (p ptyCmd) Wait() error { return p.wait() } +func (p ptyCmd) String() string { return p.cmd.String() } +func (p ptyCmd) Terminate() error { return oscommands.TerminateProcessGracefully(p.process) } // Some commands need to output for a terminal to active certain behaviour. -// For example, git won't invoke the GIT_PAGER env var unless it thinks it's +// For example, git won't invoke the GIT_PAGER env var unless it thinks it's // talking to a terminal. We typically write cmd outputs straight to a view, // which is just an io.Reader. the pty package lets us wrap a command in a // pseudo-terminal meaning we'll get the behaviour we want from the underlying // command. func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { width := view.InnerWidth() - diffContext := gui.UserConfig().Git.DiffContextSize - // LAZYGIT_COLUMNS is documented in docs/Custom_Pagers.md for pager - // scripts that can't query the terminal width directly. We set it on - // every platform so those scripts remain portable. + // Set LAZYGIT_COLUMNS for diff renderer scripts that can't query the terminal width directly. cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", width)) - pager := gui.stateAccessor.GetPagerConfig().GetPagerCommand(width) - externalDiffCommand := gui.stateAccessor.GetPagerConfig().GetExternalDiffCommand(diffContext) - useExtDiffGitConfig := gui.stateAccessor.GetPagerConfig().GetUseExternalDiffGitConfig() - - if pager == "" && externalDiffCommand == "" && !useExtDiffGitConfig { - // If we're not using a custom pager nor external diff command, then we don't need to use a pty + if gui.stateAccessor.GetDiffRendererConfigManager().GetDiffRendererType() == config.DiffRendererType_RawGit { + // If we're not using a custom diff renderer, then we don't need to use a pty return gui.newCmdTask(view, cmd, prefix) } + cmd.Args = withPtyGitConfig(cmd.Args, runtime.GOOS) + + // Mark the view as loading synchronously now, before the layout pass: the + // actual task is created in afterLayout (below), which runs after layout, so + // without this the next layout pass would clamp the scroll position to the + // not-yet-loaded content. + gui.getManager(view).StartLoading() + // Hold the scrollbar at its current height while the re-render loads, so the + // thumb doesn't shrink and snap back when the first partial paint swaps in + // (see the matching call in newCmdTask). + view.FreezeScrollbarHeight() + // Run the pty after layout so that it gets the correct size gui.afterLayout(func() error { - // Need to get the width and the pager again because the layout might have + // Need to get the width and the pager command again because the layout might have // changed the size of the view width = view.InnerWidth() - pager := gui.stateAccessor.GetPagerConfig().GetPagerCommand(width) + pager := gui.stateAccessor.GetDiffRendererConfigManager().GetStdinFilterCommand(width) cmdStr := strings.Join(cmd.Args, " ") - // This communicates to pagers that we're in a very simple + // This communicates to diff renderers that we're in a very simple // terminal that they should not expect to have much capabilities. // Moving the cursor, clearing the screen, or querying for colors are among such "advanced" capabilities. // Context: https://github.com/jesseduffield/lazygit/issues/3419 @@ -102,7 +110,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error var p oscommands.Pty var fallbackPipe io.ReadCloser start := func() (tasks.Cmd, io.Reader) { - // The pty (and pager) wrap to this width; apply it here, on the + // The pty (and diff renderer) wrap to this width; apply it here, on the // task's goroutine once the previous task has stopped, so it doesn't // race that task's writes (see View.SetContentWidth). view.SetContentWidth(width) @@ -110,7 +118,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error sp, err := oscommands.StartPty(cmd, cols, rows) if err != nil { gui.c.Log.Error(err) - // Fall back to running the command without a pty: the pager is + // Fall back to running the command without a pty: the diff renderer is // lost, but the command's output still renders. execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log) fallbackPipe = pipe @@ -145,6 +153,43 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error return nil } +// withPtyGitConfig returns args with extra git configuration for commands +// that render into a pty. On Windows, such a command is terminated at an +// arbitrary point of its execution when its task stops: tearing down the +// pseudoconsole delivers CTRL_CLOSE_EVENT, which git leaves to the default +// handler, which just calls ExitProcess. git's automatic index refresh +// (diff.autoRefreshIndex, on by default) takes index.lock at the end of a +// diff against the worktree to write back refreshed stat information — +// GIT_OPTIONAL_LOCKS does not cover this lock — and a termination landing +// in that window leaves a stale index.lock behind that the next git command +// chokes on. So don't let pty-rendered commands refresh the index; +// lazygit's foreground `git status` refreshes, which never run in a pty, +// keep the stat cache fresh instead. +// +// On Unix a stopped pty child gets SIGTERM, and git's signal handlers remove +// its lock files, so the refresh can stay enabled there and keep healing +// stale stat info. +func withPtyGitConfig(args []string, goos string) []string { + if goos != "windows" { + return args + } + // Most pty commands are direct git invocations, but the user-configured + // ones can be arbitrary command lines (e.g. a branchLogCmd wrapping git + // in `sh -c`), and injecting git flags into those would corrupt them. + // Only direct git invocations get the config; that loses nothing, since + // the wrapped commands are log commands, which never take the index + // lock. (For direct invocations other than worktree diffs the config is + // simply a no-op.) + base := strings.TrimSuffix(strings.ToLower(filepath.Base(args[0])), ".exe") + if base != "git" { + return args + } + result := make([]string, 0, len(args)+2) + result = append(result, args[0]) + result = append(result, "-c", "diff.autoRefreshIndex=false") + return append(result, args[1:]...) +} + func removeExistingTermEnvVars(env []string) []string { return lo.Filter(env, func(envVar string, _ int) bool { return !isTermEnvVar(envVar) diff --git a/pkg/gui/pty_test.go b/pkg/gui/pty_test.go new file mode 100644 index 000000000..8d7f0e2ca --- /dev/null +++ b/pkg/gui/pty_test.go @@ -0,0 +1,29 @@ +package gui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWithPtyGitConfig(t *testing.T) { + args := []string{"git", "-C", "/repo", "diff", "--color=always"} + + assert.Equal(t, + []string{"git", "-c", "diff.autoRefreshIndex=false", "-C", "/repo", "diff", "--color=always"}, + withPtyGitConfig(args, "windows")) + + assert.Equal(t, args, withPtyGitConfig(args, "linux")) + assert.Equal(t, args, withPtyGitConfig(args, "darwin")) + + // A user-configured command that wraps git in a shell must not have git + // flags injected into it. + shellArgs := []string{"sh", "-c", "git log --graph {{branchName}} -- | sed -e s/x/y/"} + assert.Equal(t, shellArgs, withPtyGitConfig(shellArgs, "windows")) + + // The guard recognizes git regardless of case and extension. + exeArgs := []string{"GIT.EXE", "diff"} + assert.Equal(t, + []string{"GIT.EXE", "-c", "diff.autoRefreshIndex=false", "diff"}, + withPtyGitConfig(exeArgs, "windows")) +} diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 3dce93874..5e5295639 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -18,6 +18,15 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error ).Debug("RunCommand") manager := gui.getManager(view) + // Mark the view as loading synchronously (before the task's goroutine runs + // and before the next layout pass) so the layout doesn't clamp the scroll + // position to the not-yet-loaded content. + manager.StartLoading() + // Hold the scrollbar at the height the view has now (the previous render), + // while it still shows that render: once the re-render swaps in its first + // partial paint the displayed buffer is briefly short, and we don't want the + // thumb to shrink and snap back as the rest loads. + view.FreezeScrollbarHeight() // Snapshot the view width here, on the UI thread, so the task goroutine // doesn't read the view's live dimensions while it streams output. It's @@ -80,9 +89,8 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error { manager := gui.getManager(view) f := func(tasks.TaskOpts) error { - return gui.g.OnUIThreadAndWaitBackground(func() error { + return gui.g.OnUIThreadAndWaitBackground(func() { gui.c.SetViewContent(view, str) - return nil }) } @@ -97,10 +105,9 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in manager := gui.getManager(view) f := func(tasks.TaskOpts) error { - return gui.g.OnUIThreadAndWaitBackground(func() error { + return gui.g.OnUIThreadAndWaitBackground(func() { gui.c.SetViewContent(view, str) view.SetOrigin(originX, originY) - return nil }) } @@ -115,10 +122,9 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e manager := gui.getManager(view) f := func(tasks.TaskOpts) error { - return gui.g.OnUIThreadAndWaitBackground(func() error { + return gui.g.OnUIThreadAndWaitBackground(func() { gui.c.ResetViewOrigin(view) gui.c.SetViewContent(view, str) - return nil }) } @@ -136,12 +142,10 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { gui.Log, view, func() { - // we could clear here, but that actually has the effect of causing a flicker - // where the view may contain no content momentarily as the gui refreshes. - // Instead, we're rewinding the write pointer so that we will just start - // overwriting the existing content from the top down. Once we've reached - // the end of the content do display, we call view.FlushStaleCells() to - // clear out the remaining content from the previous render. + // Called before showing the "loading..." indicator: clear the + // displayed buffer so only "loading..." is shown. The actual content + // is rendered off-screen (beginRender below) and swapped in, so it + // never overwrites the displayed buffer incrementally. view.Reset() }, func() { @@ -153,6 +157,11 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { gui.renderContentOnly() }, func() { + // The content is fully loaded now, so let the scrollbar track it + // directly again (it was held at the previous render's height while + // loading, see FreezeScrollbarHeight). + view.UnfreezeScrollbarHeight() + // Need to check if the content of the view is well past the origin. linesHeight := view.ViewLinesHeight() _, originY := view.Origin() @@ -161,12 +170,12 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { view.SetOrigin(0, newOriginY) } - - view.FlushStaleCells() }, func() { view.SetOrigin(0, 0) }, + view.BeginOffscreenRender, + view.SwapInOffscreenRender, func() gocui.Task { // A background task: rendering content into a view is display // work, not lazygit driving a git operation, so it must not diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 87bd9ef50..ff73b91f6 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -48,8 +48,13 @@ type IGuiCommon interface { RefreshFromWorker(RefreshOptions) // we call this when we've changed something in the view model but not the actual model, // e.g. expanding or collapsing a folder in a file view. Calling 'Refresh' in this - // case would be overkill, although refresh will internally call 'PostRefreshUpdate' + // case would be overkill, although refresh will internally call 'PostRefreshUpdate'. + // It re-focuses the context's selection, which scrolls it into view. PostRefreshUpdate(Context) + // Like PostRefreshUpdate, but leaves the view scrolled where it is. For + // refreshes that no user action is behind: those must not move the viewport + // away from wherever the user last put it. + PostRefreshUpdateKeepingScrollPosition(Context) // renders string to a view without resetting its origin SetViewContent(view *gocui.View, content string) @@ -171,7 +176,7 @@ type IPopupHandler interface { // Shows a popup prompting the user for input. Prompt(opts PromptOpts) WithWaitingStatus(message string, f func(gocui.Task) error) error - WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error + WithWaitingStatusBlockingInput(opts WaitingStatusOpts, f func(gocui.Task) error) error Menu(opts CreateMenuOptions) error Toast(message string) ErrorToast(message string) @@ -179,6 +184,20 @@ type IPopupHandler interface { GetPromptInput() string } +type WaitingStatusOpts struct { + // The message shown alongside the spinner while the operation runs. + Message string + + // When set, the working tree state mode (the yellow + // "Rebasing"/"Merging"/"Cherry-picking"/"Reverting" indicator, along with + // its abort button) stays hidden until the operation is done. Set it for + // operations that drive such a state themselves: the state they leave on + // disk while they run is transient, so surfacing it would flash the + // indicator on and offer to abort a sequence that lazygit is in the middle + // of running. + HideWorkingTreeState bool +} + type ToastKind int const ( @@ -349,6 +368,7 @@ type Model struct { BisectInfo *git_commands.BisectInfo WorkingTreeStateAtLastCommitRefresh models.WorkingTreeState + CommitsWereFilteredAtLastRefresh bool RemoteBranches []*models.RemoteBranch Tags []*models.Tag @@ -388,10 +408,19 @@ type HasUrn interface { URN() string } +// RepoLocation is everything it takes to open a repo again: the directory to +// change to, plus the environment telling git where the repo is for the repos +// git can't find from that directory (see RepoPaths.GitLocationEnvVars), which +// is empty for all the others. +type RepoLocation struct { + Path string + GitLocationEnvVars []string +} + type IStateAccessor interface { - GetRepoPathStack() *utils.StringStack + GetRepoPathStack() *utils.Stack[RepoLocation] GetRepoState() IRepoStateAccessor - GetPagerConfig() *config.PagerConfig + GetDiffRendererConfigManager() *config.DiffRendererConfigManager // tells us whether we're currently updating lazygit GetUpdating() bool SetUpdating(bool) diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 416b39b95..35662d86c 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -227,9 +227,13 @@ type IViewTrait interface { } type OnFocusOpts struct { - ClickedWindowName string - ClickedViewLineIdx int - ScrollSelectionIntoView bool + ClickedWindowName string + ClickedViewLineIdx int + + // Focusing a list context scrolls its selection into view. Set this to leave + // the view's scroll position alone instead; only for callers that maintain + // it themselves, e.g. by keeping the selection at the edge of the viewport. + KeepScrollPosition bool } type OnFocusLostOpts struct { diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index d139984fa..e9ad48aab 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -132,7 +132,7 @@ func (gui *Gui) renderContentOnly() { // postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed // if the context's view is set to another context we do nothing. // if the context's view is the current view we trigger a focus; re-selecting the current item. -func (gui *Gui) postRefreshUpdate(c types.Context) { +func (gui *Gui) postRefreshUpdate(c types.Context, keepScrollPosition bool) { t := time.Now() defer func() { gui.Log.Infof("postRefreshUpdate for %s took %s", c.GetKey(), time.Since(t)) @@ -141,14 +141,14 @@ func (gui *Gui) postRefreshUpdate(c types.Context) { c.HandleRender() if gui.currentViewName() == c.GetViewName() { - c.HandleFocus(types.OnFocusOpts{}) + c.HandleFocus(types.OnFocusOpts{KeepScrollPosition: keepScrollPosition}) } else { // The FocusLine call is included in the HandleFocus method which we // call for focused views above; but we need to call it here for // non-focused views to ensure that an inactive selection is painted // correctly, and that integration tests see the up to date selection // state. - c.FocusLine(false) + c.FocusLine(!keepScrollPosition) currentCtx := gui.State.ContextMgr.Current() if currentCtx.GetKey() == context.NORMAL_MAIN_CONTEXT_KEY || currentCtx.GetKey() == context.NORMAL_SECONDARY_CONTEXT_KEY { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index cd226f433..ea27c6c2d 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -367,12 +367,19 @@ type TranslationSet struct { FwdNoLocalUpstream string FwdCommitsToPush string PullRequestNoUpstream string + PullRequestChecksPassing string + PullRequestChecksPending string + PullRequestChecksFailing string + PullRequestChecksError string + PullRequestChecksExpected string ErrorOccurred string ConflictLabel string PendingRebaseTodosSectionHeader string PendingCherryPicksSectionHeader string PendingRevertsSectionHeader string CommitsSectionHeader string + MoveCommitsHere string + MovingCommitsHere string YouDied string RewordNotSupported string ChangingThisActionIsNotAllowed string @@ -434,6 +441,11 @@ type TranslationSet struct { ResettingStatus string CreatingFixupCommitStatus string MovingCommitsToNewBranchStatus string + ApplyingFilterStatus string + RemovingFilterStatus string + StashingStatus string + ApplyingStashStatus string + PoppingStashStatus string CommitFiles string SubCommitsDynamicTitle string CommitFilesDynamicTitle string @@ -452,6 +464,7 @@ type TranslationSet struct { DisabledForGPG string CreateRepo string BareRepo string + BareRepoNotSupported string InitialBranch string NoRecentRepositories string IncorrectNotARepository string @@ -612,14 +625,14 @@ type TranslationSet struct { ViewResetToUpstreamOptions string NextScreenMode string PrevScreenMode string - CyclePagers string - CyclePagersTooltip string - CyclePagersReverse string - CyclePagersReverseTooltip string - CyclePagersDisabledReason string - SelectedPager string - DefaultPagerName string - ExternalDiffPagerName string + CycleDiffRenderers string + CycleDiffRenderersTooltip string + CycleDiffRenderersReverse string + CycleDiffRenderersReverseTooltip string + CycleDiffRenderersDisabledReason string + SelectedDiffRenderers string + DefaultDiffRendererName string + ExternalDiffDiffRendererName string StartSearch string StartFilter string SelectRemoteRepository string @@ -1519,12 +1532,19 @@ func EnglishTranslationSet() *TranslationSet { FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally", FwdCommitsToPush: "Cannot fast-forward a branch with commits to push", PullRequestNoUpstream: "Cannot open a pull request for a branch with no upstream", + PullRequestChecksPassing: "Passing", + PullRequestChecksPending: "Pending", + PullRequestChecksFailing: "Failing", + PullRequestChecksError: "Error", + PullRequestChecksExpected: "Expected", ErrorOccurred: "An error occurred! Please create an issue at", ConflictLabel: "CONFLICT", PendingRebaseTodosSectionHeader: "Pending rebase todos", PendingCherryPicksSectionHeader: "Pending cherry-picks", PendingRevertsSectionHeader: "Pending reverts", CommitsSectionHeader: "Commits", + MoveCommitsHere: "drop here", + MovingCommitsHere: "moving commits here", YouDied: "YOU DIED!", RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported", ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed", @@ -1586,6 +1606,11 @@ func EnglishTranslationSet() *TranslationSet { ResettingStatus: "Resetting", CreatingFixupCommitStatus: "Creating fixup commit", MovingCommitsToNewBranchStatus: "Moving commits to new branch", + ApplyingFilterStatus: "Applying filter", + RemovingFilterStatus: "Removing filter", + StashingStatus: "Stashing", + ApplyingStashStatus: "Applying stash", + PoppingStashStatus: "Popping stash", CommitFiles: "Commit files", SubCommitsDynamicTitle: "Commits (%s)", CommitFilesDynamicTitle: "Diff files (%s)", @@ -1603,7 +1628,8 @@ func EnglishTranslationSet() *TranslationSet { DiscardFileChangesPromptResetPatch: "Are you sure you want to discard changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\n\nNote: This will reset the active custom patch!", DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.", CreateRepo: "Not in a git repository. Create a new git repository? (y/N): ", - BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not yet support bare repos. Open most recent repo? (y/n) ", + BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not support bare repos. Open most recent repo? (y/n) ", + BareRepoNotSupported: "Lazygit does not support bare repos.", InitialBranch: "Branch name? (leave empty for git's default): ", NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.", IncorrectNotARepository: "The value of 'notARepository' is incorrect. It should be one of 'prompt', 'create', 'skip', or 'quit'.", @@ -1767,14 +1793,14 @@ func EnglishTranslationSet() *TranslationSet { ViewResetToUpstreamOptions: "View upstream reset options", NextScreenMode: "Next screen mode (normal/half/fullscreen)", PrevScreenMode: "Prev screen mode", - CyclePagers: "Cycle pagers", - CyclePagersTooltip: "Choose the next pager in the list of configured pagers.", - CyclePagersReverse: "Cycle pagers (reverse)", - CyclePagersReverseTooltip: "Choose the previous pager in the list of configured pagers.", - CyclePagersDisabledReason: "No other pagers configured", - SelectedPager: "Pager: {{.name}} ({{.current}} of {{.total}})", - DefaultPagerName: "(default)", - ExternalDiffPagerName: "(external diff)", + CycleDiffRenderers: "Cycle diff renderers", + CycleDiffRenderersTooltip: "Choose the next renderer in the list of configured diff renderers.", + CycleDiffRenderersReverse: "Cycle diff renderers (reverse)", + CycleDiffRenderersReverseTooltip: "Choose the previous renderer in the list of configured diff renderers.", + CycleDiffRenderersDisabledReason: "No other diff renderers configured", + SelectedDiffRenderers: "Diff renderer: {{.name}} ({{.current}} of {{.total}})", + DefaultDiffRendererName: "(default)", + ExternalDiffDiffRendererName: "(external diff)", StartSearch: "Search the current view by text", StartFilter: "Filter the current view by text", SelectRemoteRepository: "Select base repository for pull requests", @@ -2319,7 +2345,7 @@ keybinding: suspendApp: redo: -- The 'git.paging.useConfig' option has been removed. If you were relying on it to configure your pager, you'll have to explicitly set the pager again using the 'git.paging.pager' option. +- The 'git.paging.useConfig' option has been removed. If you were relying on it to configure your pager, you'll have to explicitly set the command again using the 'git.diffRenderers.*.command' option. `, "0.62.0": `- The default keybinding for submitting a commit from the commit description editor has changed from alt-enter to command-enter on Mac, or ctrl-enter on Linux and Windows; these are the same bindings that are used in many multi-line edit field situations, e.g. in GitHub comments. Unfortunately these are not supported by all terminals; see https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md#terminal-compatibility for more on that. If you want to revert this change, you can do so by adding the following to your config: diff --git a/pkg/i18n/translations/nl.json b/pkg/i18n/translations/nl.json index e6369d85a..4ebdcde84 100644 --- a/pkg/i18n/translations/nl.json +++ b/pkg/i18n/translations/nl.json @@ -69,6 +69,10 @@ "FilterLabelUntrackedFiles": "(alleen niet-getrackt)", "FilterLabelConflictingFiles": "(alleen conflicten)", "MergeConflictsTitle": "Merge conflicten", + "MergeConflictDescription_DD": "Conflict: deze file is verplaatst of hernoemd in zowel de current changes als de incoming changes, maar naar verschillende plekken. Ik weet niet welke, maar ze zouden allebei ook weergegeven moeten worden als conflicten (gemarkeerd met respectievelijk 'AU' en 'UA'). De meest waarschijnlijke oplossing is om deze file zelf te verwijderen, één van de twee nieuwe locaties te houden, en de andere ook te verwijderen.", + "MergeConflictDescription_AU": "Conflict: een file is verplaatst of hernoemd naar deze plek in de current changes, maar dezelfde file is naar een andere plek verplaatst of hernoemd in de incoming changes. Die andere locatie zou ook zichtbaar moeten zijn als een conflict (gemarkeerd met 'UA'), alsmede de originele locatie van de file (gemarkeerd met 'DD').", + "MergeConflictDescription_UA": "Conflict: een file is verplaatst of hernoemd naar deze plek in de incoming changes, maar dezelfde file is naar een andere plek verplaatst of hernoemd in de current changes. Die andere locatie zou ook zichtbaar moeten zijn als een conflict (gemarkeerd met 'AU'), alsmede de originele locatie van de file (gemarkeerd met 'DD').", + "MergeConflictDescription_DU": "Conflict: deze file is verwijderd in de current changes, en gewijzigd in de incoming changes.\n\nDe meest waarschijnlijke oplossing is om dit bestand te verwijderen nadat de incoming changes wijzigingen handmatig zijn toegepast op een andere plaats in de code.", "MergeConflictDescription_UD": "Conflict: dit bestand is gewijzigd in de current changes en verwijderd in incoming changes.\n\nDe meest waarschijnlijke oplossing is om dit bestand te verwijderen nadat de current changes wijzigingen handmatig zijn toegepast op een andere plaats in de code.", "MergeConflictIncomingDiff": "Inkomende wijziging:", "MergeConflictCurrentDiff": "Huidige wijzigingen:", @@ -118,18 +122,28 @@ "CloseCancel": "Sluiten", "Confirm": "Bevestig", "Quit": "Afsluiten", + "CannotSquashOrFixupFirstCommit": "Er is geen commit hieronder om in te squashen", "Fixup": "Fixup", "SureSquashThisCommit": "Weet je zeker dat je deze commit wil samenvoegen met de commit hieronder?", "Squash": "Squash", "PickCommitTooltip": "Kies commit (wanneer midden in rebase)", + "Pick": "Pick", + "Edit": "Edit", + "Revert": "Revert", + "RevertCommitTooltip": "Maak een revert commit voor de geselecteerde commit, die de wijzigingen in deze commit terugdraait.", "Reword": "Hernoem commit", + "CommitRewordTooltip": "Herschrijf de commit message van de geselecteerde commit.", "DropCommit": "Verwijder commit", "MoveDownCommit": "Verplaats commit 1 naar beneden", "MoveUpCommit": "Verplaats commit 1 naar boven", "CannotMoveAnyFurther": "Kan niet verder verplaatsen", "CannotMoveMergeCommit": "Kan een merge commit niet verplaatsen", + "EditCommit": "Bewerken (start interactieve rebase)", "EditCommitTooltip": "Wijzig commit", "AmendCommitTooltip": "Wijzig commit met staged veranderingen", + "ResetAuthor": "Reset auteur", + "ResetAuthorTooltip": "Wijzig de commit auteur naar de huidige gebruiker. Dit vernieuwt ook de auteur timestamp", + "SetAuthor": "Auteur instellen", "AddCoAuthor": "Voeg co-auteur toe", "RewordCommitEditor": "Hernoem commit met editor", "NoCommitsThisBranch": "Geen commits in deze branch", @@ -138,6 +152,7 @@ "Undo": "Ongedaan maken", "UndoReflog": "Ongedaan maken (via reflog) (experimenteel)", "RedoReflog": "Redo (via reflog) (experimenteel)", + "RedoTooltip": "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.", "DiscardAllTooltip": "Verwijder zowel gestagede als niet-gestagede wijzigingen in '{{.path}}'.", "DiscardUnstagedTooltip": "Verwijder niet-gestagede wijzigingen in '{{.path}}'.", "DiscardUnstagedDisabled": "De geselecteerde items hebben geen mix van gestagede en niet-gestagede wijzigingen.", @@ -162,12 +177,12 @@ "CheckForUpdate": "Check voor updates", "CheckingForUpdates": "Zoeken naar updates...", "UpdateAvailableTitle": "Update beschikbaar!", + "UpdateAvailable": "Download en installeer versie {{.newVersion}}?", "FailedToRetrieveLatestVersionErr": "Ophalen versie-informatie mislukt", "OnLatestVersionErr": "Je hebt al de laatste versie", "MajorVersionErr": "Nieuwe versie ({{.newVersion}}) is niet backwards compatibele vergeleken met de huidige versie ({{.currentVersion}})", "CouldNotFindBinaryErr": "Kon geen binary vinden op {{.url}}", "ConfirmQuitDuringUpdate": "Er is een update bezig. Weet je zeker dat je wilt afsluiten?", - "IntroPopupMessage": "\nBedankt voor het gebruik van lazygit! Je bent een kanjer. Deze vier dingen wil ik met je delen:\n\n 1) Als je meer wilt weten over lazygit's features, kijk dan deze video:\n https://youtu.be/CPLdltN7wgE\n\n 2) Lees de laatste release notes hier:\n https://github.com/jesseduffield/lazygit/releases\n\n 3) Als je git gebruikt, dan ben je een programmeur! Met jouw hulp kunnen we\n lazygit beter maken, dus overweeg mee te helpen met coden op\n https://github.com/jesseduffield/lazygit\n Of geef het repo een ster om te laten zien dat je het leuk vindt!\n\n 4) Als lazygit je leven makkelijker heeft gemaakt kan je \"dank je wel\" zeggen door\n op de donatie knop rechtsonder te drukken. Doneren geeft geen recht op voorrang bij ondersteuning\n maar wordt wel zeer gewaardeerd.\n\nDruk op {{confirmationKey}} om te beginnen.\n", "GitconfigParseErr": "Gogit kon je gitconfig bestand niet goed parsen door de aanwezigheid van losstaande '\\' tekens. Het weghalen van deze tekens zou het probleem moeten oplossen. ", "EditFile": "Verander bestand", "EditFileTooltip": "Open bestand in externe editor.", @@ -183,11 +198,16 @@ "UnsupportedGitService": "Niet-ondersteunde git-service", "CopyPullRequestURL": "Kopieer de URL van het pull-verzoek naar het klembord", "NoBranchOnRemote": "Deze branch bestaat niet op de remote. U moet het eerst naar de remote pushen.", + "Fetch": "Fetch", "ExpandAll": "Vouw alle bestanden uit", "ExpandAllTooltip": "Vouw alle mappen in de bestandsstructuur uit", "FileEnter": "Stage individuele hunks/lijnen", "StageSelectionTooltip": "Toggle lijnen staged / unstaged", "DiscardSelection": "Verwijdert change (git reset)", + "ToggleSelectHunk": "Wissel tussen hunk selectie aan of uit", + "SelectHunk": "Selecteer hunks", + "SelectLineByLine": "Selecteer regel-voor-regel", + "ToggleSelectHunkTooltip": "Wissel tussen regel-voor-regel of hunk selectie modus.", "ToggleSelectionForPatch": "Voeg toe/verwijder lijn(en) in patch", "ToggleStagingView": "Ga naar een ander paneel", "ReturnToFilesPanel": "Ga terug naar het bestanden paneel", @@ -254,6 +274,7 @@ "ScrollUpMainWindow": "Scroll naar beneden vanaf hoofdpaneel", "ScrollDownMainWindow": "Scroll naar beneden vanaf hoofdpaneel", "SuspendApp": "Pauzeer de applicatie", + "CannotSuspendApp": "Applicatie pauzeren wordt niet ondersteund op Windows", "AmendCommitTitle": "Commit wijzigen", "AmendCommitPrompt": "Weet je zeker dat je deze commit wil wijzigen met de vorige staged bestanden?", "AmendCommitWithConflictsContinue": "Nee, doorgaan met rebase", @@ -279,6 +300,7 @@ "DiscardOldFileChangeTooltip": "Uitsluit deze commit zijn veranderingen aan dit bestand", "DiscardFileChangesTitle": "Uitsluit bestand zijn veranderingen", "CreateRepo": "Niet in een git repository. Maak een nieuwe git repository? (y/N): ", + "AutoStashTitle": "Autostash?", "AutoStashPrompt": "Je moet je veranderingen stashen en poppen om ze over te brengen. Dit automatisch doen? (enter/esc)", "Discard": "Bekijk 'veranderingen ongedaan maken' opties", "Cancel": "Annuleren", @@ -294,6 +316,7 @@ "CreateFixupCommit": "Creëer fixup commit", "CreateFixupCommitTooltip": "Creëer fixup commit", "SquashAboveCommitsTooltip": "Squash bovenstaande commits", + "ExecuteShellCommand": "Voer shellcommando uit", "CommitChangesWithoutHook": "Commit veranderingen zonder pre-commit hook", "ResetTo": "Reset naar", "PressEnterToReturn": "Press om terug te gaan naar lazygit", @@ -329,10 +352,30 @@ "DivergenceSectionHeaderRemote": "Remote", "SetUpstreamTitle": "Stel in als upstream branch", "EditRemoteTooltip": "Wijzig remote", + "TagCommitTooltip": "Maak een nieuwe tag die naar de geselecteerde commit wijst. Je wordt gevraagd om een tag naam en optionele omschrijving.", "TagNameTitle": "Tag naam:", + "TagMessageTitle": "Tag omschrijving", + "LightweightTag": "Lichtgewicht tag", + "AnnotatedTag": "Geannoteerde tag", + "DeleteTagTitle": "Verwijder tag '{{.tagName}}'?", + "DeleteLocalTag": "Verwijder lokale tag", + "DeleteRemoteTag": "Verwijder remote tag", + "DeleteLocalAndRemoteTag": "Verwijder locale en remote tag", + "SelectRemoteTagUpstream": "Remote van waar de tag '{{.tagName}}' verwijderd moet worden:", + "DeleteRemoteTagPrompt": "Weet je zeker dat je de remote tag '{{.tagName}}' wilt verwijderen uit '{{.upstream}}'?", + "DeleteLocalAndRemoteTagPrompt": "Weet je zeker dat je {{.tagName}} zowel lokaal als in {{.upstream}}' wilt verwijderen?", + "RemoteTagDeletedMessage": "Remote tag verwijderd", "PushTagTitle": "Remote om tag '{{.tagName}}' te pushen naar:", + "PushTag": "Tag pushen", + "PushTagTooltip": "Push de geselecteerde tag naar een remote. Je krijgt de optie een remote te selecteren.", "NewTag": "Creëer tag", + "NewTagTooltip": "Maak een nieuwe tag die naar de huidige commit wijst. Je wordt gevraagd om een tag naam en optionele beschrijving.", + "CreatingTag": "Tag wordt gemaakt", + "ForceTag": "Forceer Tag", + "ForceTagPrompt": "De tag '{{.tagName}}' bestaat al. Druk op {{.cancelKey}} om te annuleren, of op {{.confirmKey}} om te overschrijven.", "FetchRemoteTooltip": "Fetch remote", + "CheckoutCommitTooltip": "Check de geselecteerde branch uit als een detached HEAD.", + "NoBranchesFoundAtCommitTooltip": "Geen branches gevonden bij de geselecteerde commit.", "GitFlowOptions": "Laat git-flow opties zien", "NotAGitFlowBranch": "Dit lijkt geen git flow branch te zijn", "NewBranchNamePrompt": "Noem een nieuwe branch naam", @@ -376,6 +419,8 @@ "DiffingMenuTitle": "Diffen", "SwapDiff": "Keer diff richting om", "ViewDiffingOptions": "Open diff menu", + "OpenCommandLogMenu": "Commandolog opties weergeven", + "OpenCommandLogMenuTooltip": "Bekijk commandolog opties, bijv. commandolog tonen/verbergen en focus.", "ShowingGitDiff": "Laat output zien voor:", "CopyBranchNameToClipboard": "Kopieer branch name naar klembord", "CopyPathToClipboard": "Kopieer de bestandsnaam naar het klembord", @@ -386,31 +431,93 @@ "BranchNotFoundPrompt": "Branch niet gevonden. Creëer een nieuwe branch genaamd", "CreateNewBranchFromCommit": "Creëer nieuwe branch van commit", "ViewCommits": "Bekijk commits", + "RunningCustomCommandStatus": "Aangepast commando uitvoeren", "EnterSubmoduleTooltip": "Enter submodule", "CopySubmoduleNameToClipboard": "Kopieer submodule naam naar klembord", "NewSubmodule": "Voeg nieuwe submodule toe", "InitSubmoduleTooltip": "Initialiseer submodule", "ViewBulkSubmoduleOptions": "Bekijk bulk submodule opties", "NavigationTitle": "Lijstpaneel navigatie", + "ExtrasTitle": "Commandolog", "PullRequestURLCopiedToClipboard": "Pull-aanvraag-URL gekopieerd naar klembord", "CommitMessageCopiedToClipboard": "Commit message gekopieerd naar klembord", + "PatchCopiedToClipboard": "Patch gekopieerd naar klembord", + "MessageCopiedToClipboard": "Bericht gekopieerd naar klembord", "CopiedToClipboard": "gekopieerd naar klembord", + "ErrRepositoryMovedOrDeleted": "Kan repo niet vinden. Misschien is het verplaatst of verwijderd ¯\\_(ツ)_/¯", + "ErrWorktreeMovedOrRemoved": "Kan worktree niet vinden. Misschien is deze verplaatst of verwijderd ¯\\_(ツ)_/¯", + "CommandLog": "Commandolog", + "ToggleShowCommandLog": "Toon/verberg commando log", + "FocusCommandLog": "Focus commandolog", + "CommandLogHeader": "Je kunt dit paneel verbergen/focussen door op '%s'\n te drukken\n", + "RandomTip": "Willekeurige tip", + "ToggleWhitespaceInDiffView": "Witruimte weergeven in-/uitschakelen", "CreatePullRequestOptions": "Bekijk opties voor pull-aanvraag", + "DefaultBranch": "Standaard branch", + "SelectBranch": "Selecteer branch", + "SelectTargetRemote": "Selecteer target remote", + "NoValidRemoteName": "Een remote met naam '%s' bestaat niet", "CreatePullRequest": "Maak een pull-request", + "SelectConfigFile": "Selecteer configuratiefile", + "NoConfigFileFoundErr": "Configuratiefile niet gevonden", + "GitOutput": "Git output:", + "GitCommandFailed": "Git commando mislukt. Controleer commandolog voor details (open met %s)", + "OpenLogMenu": "Log opties weergeven", + "SortAlphabetical": "Alfabetisch", "ConfirmRevertCommit": "Weet u zeker dat u {{.selectedCommit}} ongedaan wilt maken?", + "SwitchToWorktree": "Overschakelen naar worktree", + "RemoveWorktree": "Worktree verwijderen", + "RemoveWorktreeTitle": "Worktree verwijderen", + "RemoveWorktreeMenuTitle": "Verwijder worktree '{{.worktreeName}}'?", + "RemoveWorktreeAndDeleteBranch": "Worktree en branch verwijderen", + "RemoveWorktreeAndDeleteBothBranches": "Worktree, lokale branch en remote branch verwijderen", + "WorktreeNotCheckedOutOnBranch": "Deze worktree kan niet worden uitgecheckt op een branch", + "WorktreesTitle": "Worktrees", + "WorktreeTitle": "Worktree", + "RemovingWorktree": "Worktree wordt verwijderd", + "AddingWorktree": "Worktree wordt toegevoegd", + "CantDeleteCurrentWorktree": "Je kan de huidige worktree niet verwijderen!", + "AlreadyInWorktree": "Je bent al in de geselecteerde worktree", + "CantDeleteMainWorktree": "Je kan de hoofdworktree niet verwijderen!", + "NoWorktreesThisRepo": "Geen worktrees", + "MissingWorktree": "(ontbreekt)", + "WorktreeLocationPromptCheckout": "Worktree voor branch '{{.branchName}}':", + "LcWorktree": "worktree", + "Name": "Naam", + "Branch": "Branch", + "Path": "Pad", + "MarkedBaseCommitStatus": "Gemarkeerd als basiscommit voor rebase", + "MarkAsBaseCommit": "Markeer als basiscommit voor rebase", + "MarkAsBaseCommitTooltip": "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.", + "CancelMarkedBaseCommit": "Annuleer de gemarkeerde basiscommit", + "MarkedCommitMarker": "↑↑↑ Hier wordt de rebase gedaan ↑↑↑", + "FailedToOpenURL": "Fout bij het openen van URL %s\n\nError: %v", + "InvalidLazygitEditURL": "Ongeldige lazygit-edit URL-formaat: %s", + "NoCopiedCommits": "Geen gekopieerde commits", + "DisabledMenuItemPrefix": "Uitgeschakeld: ", + "QuickStartInteractiveRebase": "Start interactieve rebase", "ToggleRangeSelect": "Toggle drag selecteer", + "CustomCommands": "Aangepaste commando's", + "NoApplicableCommandsInThisContext": "(Geen toepasselijke commando's in deze context)", "Actions": { "CopyCommitAuthorToClipboard": "Kopieer commit auteur naar klembord", "CopyCommitAttributeToClipboard": "Kopieer naar klembord", "CopyCommitTagsToClipboard": "Kopieer commit tags naar klembord", "CopyPatchToClipboard": "Kopieer patch naar klembord", + "CustomCommand": "Aangepast commando", "Commit": "Commit", "Push": "Push", "Pull": "Pull", "OpenFile": "Open bestand", "CopyToClipboard": "Kopieer naar klembord", "CopySelectedTextToClipboard": "Kopieer geselecteerde tekst naar klembord", + "DeleteRemoteBranch": "Verwijder remote branch", + "SetBranchUpstream": "Stel upstream branch in", "AddRemote": "Voeg remote toe", + "RemoveRemote": "Verwijder remote", + "UpdateRemote": "Update remote", + "ApplyPatch": "Pas patch toe", + "Stash": "Stash", "RemoveSubmodule": "Verwijder submodule", "ResetSubmodule": "Reset submodule", "AddSubmodule": "Voeg submodule toe", @@ -441,7 +548,10 @@ "PoppingStash": "Pop stash %s", "DeletingBranch": "Verwijder branch '{{.branchName}}' (was {{.hash}})" }, - "BreakingChangesByVersion": {}, + "BreakingChangesMessage": "Je bent aan het updaten naar een nieuwe versie van lazygit waar incompatibele wijzigingen in zitten. Bekijk de onderstaande notities en update je configuratie indien nodig.\nVoor meer informatie, zie de volledige release notes op .", + "BreakingChangesByVersion": { + "0.41.0": "- Als je op 'g' drukt om het git reset menu te openen, is de 'mixed' optie nu de eerste en standaard optie, in plaats van 'soft'. Dit is om dat 'mixed' de meest gebruikte optie is.\n- Het commit message paneel doet nu automatisch aan zinsafbreking (voegt een nieuwe regel toe als de kantlijn bereikt is). Dit kan je aanpassen in de config met:\n\ngit:\n commit:\n autoWrapCommitMessage: true\n autoWrapWidth: 72\n\n- De 'v' knop was al gebruik om in de stagingweergave een range te selecteren, maar nu kun je die ook gebruiken om een range te selecteren vanuit andere weergaven. Jammergenoeg conflicteert dit met de 'v' keybinding voor het plakken van commits (cherry-pick), dus wordt dat nu gedaan met 'shift+V' en voor de consistentie, gaat kopieren met 'shift-C' in plaats van 'c'. Let op dat de 'v' keybinding niet de enige manier is om een range selectie te starten: je kan ook shift+pijltje omhoog/naar beneden gebruiken. Dus als je de cherry-pick keybindings volgens het oude gedrag wilt configureren, zet dan het volgende in je config:\n\nkeybinding:\n universal:\n toggleRangeSelect: \n commits:\n cherryPickCopy: 'c'\n pasteCommits: 'v'\n\n- Squashen van fixups met 'shift-S' opent nu een menu, met de standaard optie om alle fixup commits in de branch te squashen. Het originele gedrag, waarbij alleen de commits boven de geselecteerde commit werden gesquast is nog steeds te kiezen als tweede optie in dat menu.\n- Push/pull/fetch activiteitsstatus wordt nu weergegeven bij de branch en niet meer in een popup. Hierdoor kan je meerdere branches tegelijkertijd fetchen en de status hiervan bekijken.\n- De git log graph in de commit weergave is nu standaard altijd zichtbaar (voorheen was het alleen zichtbaar in gemaximaliseerde weergave). Als je dit te druk vindt, kan je het terug veranderen via ctrl+L -> 'Geef git graph weer' -> 'Wanneer gemaximaliseerd'\n- Op de spatiebalk drukken wanneer een remote branch geselecteerd is gaf eerst een dialoogvenster voor het invullen van een naam voor de nieuwe locale branch behorend bij de checkout van deze remote branch. In plaats daarvan wordt de remote branch nu meteen uitgecheckt, met de keuze voor een nieuwe locale branch met dezelfde naam, of een detatched head. Het oude gedrag is nog steeds beschikbaar via de 'n' keybinding.\n- Fliteren (bijv. als je op '/' drukt) is standaard minder fuzzy; alleen stukken van woorden of substrings worden nu gematched. Zoeken op meerdere substrings kan door ze te scheiden met spaties. Als je het oude gedrag wilt, stel dan dit in in je config:\n\ngui:\n filterMode: 'fuzzy'\n" + }, "ViewMergeConflictOptions": "Bekijk merge conflict opties", "ViewMergeConflictOptionsTooltip": "Bekijk opties voor het oplossen van mergeconflicten.", "NoFilesWithMergeConflicts": "Er zijn geen files met mergeconflicten.", diff --git a/pkg/i18n/translations/pt.json b/pkg/i18n/translations/pt.json index 640ce2d65..499e45e8b 100644 --- a/pkg/i18n/translations/pt.json +++ b/pkg/i18n/translations/pt.json @@ -226,7 +226,6 @@ "UpdateFailedErr": "Falha na atualização: {{.errMessage}}", "ConfirmQuitDuringUpdateTitle": "Atualmente atualizando", "ConfirmQuitDuringUpdate": "Uma atualização está em andamento. Tem certeza que deseja sair?", - "IntroPopupMessage": "\nObrigado por usar o lazygit! Sério, você é demais. Três coisas que queremos compartilhar com você:\n\n 1) Se quiser aprender sobre os recursos do lazygit, assista a este vídeo:\n https://youtu.be/CPLdltN7wgE\n\n 2) Não deixe de ler as últimas notas de lançamento em:\n https://github. um/jesseduffield/lazygit/releases\n\n 3) Se você estiver usando um git, isso o torna um programador! Com a sua ajuda, podemos tornar o\n lazygit melhor, então considere se tornar um contribuidor e se junte à diversão no\n https://github.com/jesseduffield/lazygit\n Ou apenas favoritar o repositório para espalhar o amor. \n\n4) Se o lazygit fez a sua vida mais fácil, você pode agradecer ao clicar no\n botão de doação no canto inferior direito. Doações não garantem prioridade no suporte, mas são muito bem vindas. \n\nPressione {{confirmationKey}} para continuar.\n", "NonReloadableConfigWarningTitle": "Configuração alterada", "NonReloadableConfigWarning": "As seguintes configurações foram alteradas, mas a mudança não tem efeito imediatamente. Encerre e reinicie o lazygit para que as mudanças tenham efeito:\n\n{{configs}}", "GitconfigParseErr": "Gogit falhou ao analisar seu arquivo gitconfig devido à presença de caracteres '\\' não citados. Removendo-os deve corrigir o problema.", diff --git a/pkg/i18n/translations/zh-CN.json b/pkg/i18n/translations/zh-CN.json index 6799fd0ae..3ae7d4e65 100644 --- a/pkg/i18n/translations/zh-CN.json +++ b/pkg/i18n/translations/zh-CN.json @@ -239,7 +239,6 @@ "UpdateFailedErr": "更新失败: {{.errMessage}}", "ConfirmQuitDuringUpdateTitle": "当前正在更新中...", "ConfirmQuitDuringUpdate": "当前正在更新中,您确定要退出吗?", - "IntroPopupMessage": "\n感谢使用 lazygit!您真是太棒了。有三件事想与您分享:\n\n 1) 如果您想了解 lazygit 的功能,请观看此视频:\n https://youtu.be/CPLdltN7wgE\n\n 2) 请务必阅读最新的发布说明:\n https://github.com/jesseduffield/lazygit/releases\n\n 3) 如果您在使用 git,那您就是程序员!在您的帮助下,我们可以让\n lazygit 变得更好,所以考虑成为贡献者,加入我们的乐趣吧:\n https://github.com/jesseduffield/lazygit\n 或者仅仅给仓库点个星,分享这份喜爱!\n\n 4) 如果 lazygit 让您的生活更轻松,您可以通过点击\n 右下角的捐赠按钮来表达感谢。捐赠不会获得优先支持,\n 但我们非常感激。\n\n按 {{confirmationKey}} 键开始。\n", "NonReloadableConfigWarningTitle": "配置已更改", "NonReloadableConfigWarning": "以下配置设置已更改,但更改不会立即生效。请退出并重新启动lazygit以使更改生效:\n\n{{configs}}", "GitconfigParseErr": "由于存在未加引号的'\\'字符,因此 Gogit 无法解析您的 gitconfig 文件。删除它们应该可以解决问题。", @@ -591,9 +590,6 @@ "ViewResetToUpstreamOptions": "查看上游重置选项", "NextScreenMode": "下一屏模式(正常/半屏/全屏)", "PrevScreenMode": "上一屏模式", - "CyclePagers": "切换分页器", - "CyclePagersTooltip": "从已配置的分页器列表中选择下一个分页器", - "CyclePagersDisabledReason": "未配置其他分页器", "StartSearch": "开始搜索", "StartFilter": "通过文本过滤当前视图", "SelectRemoteRepository": "为拉取请求选择基础仓库", @@ -1060,7 +1056,6 @@ "0.50.0": "- 拉取后,如果主分支落后于其上游分支,现在会自动前推。这对于自动保持主分支或 master 分支最新很有用。如果不希望这样,可以通过在配置中设置以下内容来禁用它:\n\ngit:\n autoForwardBranches: none\n\n相反,如果希望功能分支也这样做,可以将其设置为 'allBranches'。", "0.51.0": "- 自定义命令的 'subprocess'、'stream' 和 'showOutput' 字段已被替换为单个 'output' 字段。这应该是透明的,如果您在配置文件中使用了这些字段,它们应该已自动更新。但有一个显著变化:'stream' 字段过去意味着命令输出将流式传输到命令日志,并且命令将在伪终端 (pty) 中运行。我们将其转换为 'output: log',这意味着命令输出将流式传输到命令日志,但不使用 pty,假设这是大多数人想要的。如果您确实希望在 pty 中运行命令,可以将其更改为 'output: logWithPty'。", "0.54.0": "- 本地和远程分支的默认排序顺序已更改:过去本地分支是 'recency'(基于 reflog),远程分支是 'alphabetical'。这两者都已更改为 'date'(即提交者日期)。如果您更喜欢旧的默认设置,可以通过以下配置恢复:\n\ngit:\n localBranchSortOrder: recency\n remoteBranchSortOrder: alphabetical\n\n- 暂存区和自定义补丁构建视图中的默认选择模式已更改为块模式。在大多数情况下,这是更有用的模式,因为它通常可以节省大量按键。如果想切换回旧的行模式默认设置,可以通过在配置中添加以下内容来实现:\n\ngui:\n useHunkModeInStagingView: false\n", - "0.55.0": "- 原先绑定到 ctrl-z 的 'redo' 命令,现在改为绑定到 shift-Z。这是因为 ctrl-z 现在用于挂起应用程序;在 Linux 世界中,这是该功能的常用键绑定。如果你想恢复此更改,可以在配置中添加以下内容:\n\nkeybinding:\n universal:\n suspendApp: \n redo: \n\n- 'git.paging.useConfig' 选项已被移除。如果你之前依赖它来配置你的分页器,现在必须使用 'git.paging.pager' 选项重新明确设置分页器。", "0.62.0": "从提交描述编辑器提交变更的默认快捷键已从 Mac 上的 alt-enter 改为 command-enter,在 Linux 和 Windows 上改为 ctrl-enter;这些和很多多行编辑框里用的快捷键一样,比如 GitHub 评论里用的那种。很遗憾,并非所有终端都支持这些快捷键;更多说明见:https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md#terminal-compatibility。如果你想恢复这个改动,可以在配置里加上:\n\nkeybinding:\n universal:\n confirmInEditor: \n" }, "ViewMergeConflictOptions": "查看合并冲突选项", diff --git a/pkg/integration/components/assertion_helper.go b/pkg/integration/components/assertion_helper.go index 0529e8bec..0a1d97b8c 100644 --- a/pkg/integration/components/assertion_helper.go +++ b/pkg/integration/components/assertion_helper.go @@ -1,9 +1,13 @@ package components import ( + "time" + integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" ) +const eventuallyTimeout = 2 * time.Second + type assertionHelper struct { gui integrationTypes.GuiDriver } @@ -24,6 +28,21 @@ func (self *assertionHelper) assertWithRetries(test func() (bool, string)) { } } +func (self *assertionHelper) assertEventually(test func() (bool, string)) { + deadline := time.Now().Add(eventuallyTimeout) + for { + ok, message := test() + if ok { + return + } + if time.Now().After(deadline) { + self.fail(message) + return + } + time.Sleep(10 * time.Millisecond) + } +} + func (self *assertionHelper) fail(message string) { self.gui.Fail(message) } diff --git a/pkg/integration/components/env.go b/pkg/integration/components/env.go index 6306a88ba..39152092e 100644 --- a/pkg/integration/components/env.go +++ b/pkg/integration/components/env.go @@ -3,6 +3,8 @@ package components import ( "fmt" "os" + + "github.com/samber/lo" ) const ( @@ -43,11 +45,9 @@ var hostEnvironmentAllowlist = [...]string{ // Returns a copy of the environment filtered by // hostEnvironmentAllowlist func allowedHostEnvironment() []string { - env := []string{} - for _, envVar := range hostEnvironmentAllowlist { - env = append(env, fmt.Sprintf("%s=%s", envVar, os.Getenv(envVar))) - } - return env + return lo.Map(hostEnvironmentAllowlist[:], func(envVar string, _ int) string { + return fmt.Sprintf("%s=%s", envVar, os.Getenv(envVar)) + }) } func NewTestEnvironment(rootDir string) []string { diff --git a/pkg/integration/components/runner.go b/pkg/integration/components/runner.go index 78cb5439f..098f3f2e9 100644 --- a/pkg/integration/components/runner.go +++ b/pkg/integration/components/runner.go @@ -1,6 +1,7 @@ package components import ( + "errors" "fmt" "os" "os/exec" @@ -159,9 +160,7 @@ func prepareTestDir( return "", err } - workingDir := createFixture(test, paths, rootDir) - - return workingDir, nil + return createFixture(test, paths, rootDir) } func buildLazygit(testArgs RunTestArgs) error { @@ -182,22 +181,41 @@ func buildLazygit(testArgs RunTestArgs) error { return osCommand.Cmd.New(args).Run() } +// A failing setup step panics with this so that the remaining steps, which +// would only produce follow-on failures, are skipped. +type fixtureFailure string + // Sets up the fixture for test and returns the working directory to invoke // lazygit in. -func createFixture(test *IntegrationTest, paths Paths, rootDir string) string { +func createFixture(test *IntegrationTest, paths Paths, rootDir string) (workingDir string, err error) { + // Tests run as parallel subtests, and a panic escaping one of them takes + // down the whole test binary, discarding every other test's result along + // with it. Report a broken fixture as this test's error instead. + defer func() { + panicValue := recover() + if panicValue == nil { + return + } + failure, ok := panicValue.(fixtureFailure) + if !ok { + panic(panicValue) + } + err = errors.New(string(failure)) + }() + env := NewTestEnvironment(rootDir) env = append(env, fmt.Sprintf("%s=%s", PWD, paths.ActualRepo())) shell := NewShell( paths.ActualRepo(), env, - func(errorMsg string) { panic(errorMsg) }, + func(errorMsg string) { panic(fixtureFailure(errorMsg)) }, ) shell.Init() test.SetupRepo(shell) - return shell.dir + return shell.dir, nil } func testPath(rootdir string) string { @@ -238,14 +256,15 @@ func getLazygitCommand( return nil, err } - cmdArgs := []string{tempLazygitPath(), "-debug", "--use-config-dir=" + paths.Config()} - resolvedExtraArgs := lo.Map(test.ExtraCmdArgs(), func(arg string, _ int) string { return utils.ResolvePlaceholderString(arg, map[string]string{ "actualPath": paths.Actual(), "actualRepoPath": paths.ActualRepo(), }) }) + + cmdArgs := make([]string, 0, 3+len(resolvedExtraArgs)) + cmdArgs = append(cmdArgs, tempLazygitPath(), "-debug", "--use-config-dir="+paths.Config()) cmdArgs = append(cmdArgs, resolvedExtraArgs...) // Use a limited environment for test isolation, including pass through diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 19219707a..afd55a845 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -13,6 +13,8 @@ type TestDriver struct { gui integrationTypes.GuiDriver keys config.KeybindingConfig inputDelay int + mouseX int + mouseY int *assertionHelper shell *Shell } @@ -58,6 +60,36 @@ func (self *TestDriver) click(x, y int) { self.Wait(self.inputDelay) } +func (self *TestDriver) clickAndHold(x, y int) { + self.SetCaption(fmt.Sprintf("Clicking and holding %d, %d", x, y)) + self.mouseX, self.mouseY = x, y + self.gui.ClickAndHold(x, y) + self.Wait(self.inputDelay) +} + +func (self *TestDriver) mouseMove(x, y int) { + self.SetCaption(fmt.Sprintf("Moving mouse to %d, %d", x, y)) + self.mouseX, self.mouseY = x, y + self.gui.MouseMove(x, y) + self.Wait(self.inputDelay) +} + +func (self *TestDriver) repeatMouseMove() { + self.mouseMove(self.mouseX, self.mouseY) +} + +func (self *TestDriver) scrollWheelDown(x, y int) { + self.SetCaption(fmt.Sprintf("Scrolling down at %d, %d", x, y)) + self.gui.ScrollWheelDown(x, y) + self.Wait(self.inputDelay) +} + +func (self *TestDriver) mouseRelease() { + self.SetCaption(fmt.Sprintf("Releasing mouse at %d, %d", self.mouseX, self.mouseY)) + self.gui.MouseRelease(self.mouseX, self.mouseY) + self.Wait(self.inputDelay) +} + // Should only be used in specific cases where you're doing something weird! // E.g. invoking a global keybinding from within a popup. // You probably shouldn't use this function, and should instead go through a view like t.Views().Commit().Focus().Press(...) @@ -110,6 +142,15 @@ func (self *TestDriver) Log(message string) { self.gui.LogUI(message) } +// RefreshInBackground performs the refresh that lazygit's background routines +// perform on a timer, e.g. to pick up changes made by RunCommand. Tests use this +// rather than turning those routines on and waiting for them. +func (self *TestDriver) RefreshInBackground() { + self.SetCaption("Refreshing in the background") + self.gui.RefreshInBackground() + self.Wait(self.inputDelay) +} + // allows the user to run shell commands during the test to emulate background activity func (self *TestDriver) Shell() *Shell { return self.shell diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index 7196779eb..bda63e3fb 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -1,8 +1,11 @@ package components import ( + "os" + "path/filepath" "testing" + lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" @@ -19,9 +22,12 @@ type coordinate struct { } type fakeGuiDriver struct { - failureMessage string - pressedKeys []string - clickedCoordinates []coordinate + failureMessage string + pressedKeys []string + clickedCoordinates []coordinate + heldCoordinates []coordinate + movedCoordinates []coordinate + releasedCoordinates []coordinate } var _ integrationTypes.GuiDriver = &fakeGuiDriver{} @@ -38,6 +44,28 @@ func (self *fakeGuiDriver) Click(x, y int) { self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) } +func (self *fakeGuiDriver) ClickAndHold(x, y int) { + self.heldCoordinates = append(self.heldCoordinates, coordinate{x: x, y: y}) +} + +func (self *fakeGuiDriver) MouseMove(x, y int) { + self.movedCoordinates = append(self.movedCoordinates, coordinate{x: x, y: y}) +} + +func (self *fakeGuiDriver) MouseRelease(x, y int) { + self.releasedCoordinates = append(self.releasedCoordinates, coordinate{x: x, y: y}) +} + +func (self *fakeGuiDriver) ScrollWheelDown(x, y int) { +} + +func (self *fakeGuiDriver) RefreshInBackground() { +} + +func (self *fakeGuiDriver) OnUIThreadAndWait(f func()) { + f() +} + func (self *fakeGuiDriver) FocusIn() { } @@ -123,15 +151,43 @@ func TestSuccess(t *testing.T) { t.press("b") t.click(0, 1) t.click(2, 3) + t.clickAndHold(0, 1) + t.mouseMove(2, 3) + t.repeatMouseMove() + t.mouseRelease() }, }) driver := &fakeGuiDriver{} test.Run(driver) assert.EqualValues(t, []string{"a", "b"}, driver.pressedKeys) assert.EqualValues(t, []coordinate{{0, 1}, {2, 3}}, driver.clickedCoordinates) + assert.EqualValues(t, []coordinate{{0, 1}}, driver.heldCoordinates) + assert.EqualValues(t, []coordinate{{2, 3}, {2, 3}}, driver.movedCoordinates) + assert.EqualValues(t, []coordinate{{2, 3}}, driver.releasedCoordinates) assert.Equal(t, "", driver.failureMessage) } +func TestFailingFixture(t *testing.T) { + test := NewIntegrationTest(NewIntegrationTestArgs{ + Description: unitTestDescription, + SetupRepo: func(shell *Shell) { + shell.RunCommand([]string{"git", "checkout", "no-such-branch"}) + shell.CreateFile("reached.txt", "") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) {}, + }) + + paths := NewPaths(t.TempDir()) + assert.NoError(t, os.MkdirAll(paths.ActualRepo(), 0o777)) + + workingDir, err := createFixture(test, paths, lazycoreUtils.GetLazyRootDirectory()) + + assert.ErrorContains(t, err, "git checkout no-such-branch") + assert.Empty(t, workingDir) + // the steps following the failing one are skipped + assert.NoFileExists(t, filepath.Join(paths.ActualRepo(), "reached.txt")) +} + func TestGitVersionRestriction(t *testing.T) { scenarios := []struct { testName string diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 2cfaba338..23e3502a1 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -343,6 +343,55 @@ func (self *ViewDriver) SelectedLineIdx(expected int) *ViewDriver { return self } +func (self *ViewDriver) SelectedLineIdxAtLeast(expected int) *ViewDriver { + self.t.assertEventually(func() (bool, string) { + var actual int + self.t.gui.OnUIThreadAndWait(func() { + actual = self.getView().SelectedLineIdx() + }) + return actual >= expected, fmt.Sprintf("%s: Expected selected line index to be at least %d, got %d", self.context, expected, actual) + }) + + return self +} + +// asserts on the scroll position of the view, i.e. the index of the line that +// is shown at the top of the view. +func (self *ViewDriver) OriginY(expected int) *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + actual := self.getView().OriginY() + return expected == actual, fmt.Sprintf("%s: Expected origin Y to be %d, got %d", self.context, expected, actual) + }) + + return self +} + +// asserts that the selected line is inside the visible area of the view +func (self *ViewDriver) SelectedLineIsVisible() *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + view := self.getView() + firstVisible, lastVisible := view.OriginY(), view.OriginY()+view.InnerHeight()-1 + actual := view.SelectedLineIdx() + return actual >= firstVisible && actual <= lastVisible, + fmt.Sprintf("%s: Expected the selected line (%d) to be visible, but only lines %d to %d are", + self.context, actual, firstVisible, lastVisible) + }) + + return self +} + +func (self *ViewDriver) OriginYAtLeast(expected int) *ViewDriver { + self.t.assertEventually(func() (bool, string) { + var actual int + self.t.gui.OnUIThreadAndWait(func() { + actual = self.getView().OriginY() + }) + return actual >= expected, fmt.Sprintf("%s: Expected origin Y to be at least %d, got %d", self.context, expected, actual) + }) + + return self +} + // focus the view (assumes the view is a side-view) func (self *ViewDriver) Focus() *ViewDriver { viewName := self.getView().Name() @@ -483,6 +532,51 @@ func (self *ViewDriver) FocusInAndClick(x, y int) *ViewDriver { return self } +func (self *ViewDriver) MouseMoveToView(target *ViewDriver, x, y int) *ViewDriver { + offsetX, offsetY, _, _ := target.getView().Dimensions() + self.t.mouseMove(offsetX+1+x, offsetY+1+y) + return self +} + +func (self *ViewDriver) Drag(fromX, fromY, toX, toY int) *ViewDriver { + return self.ClickAndHold(fromX, fromY).MouseMove(toX, toY).MouseRelease() +} + +func (self *ViewDriver) ClickAndHold(x, y int) *ViewDriver { + offsetX, offsetY, _, _ := self.getView().Dimensions() + self.t.clickAndHold(offsetX+1+x, offsetY+1+y) + return self +} + +func (self *ViewDriver) MouseMove(x, y int) *ViewDriver { + offsetX, offsetY, _, _ := self.getView().Dimensions() + self.t.mouseMove(offsetX+1+x, offsetY+1+y) + return self +} + +func (self *ViewDriver) MouseMoveToBottom(x int) *ViewDriver { + return self.MouseMove(x, self.getView().InnerHeight()-1) +} + +// scrolls the view down by one notch of the mouse wheel, i.e. by +// gui.scrollHeight lines. This moves the scroll position without moving the +// selection. +func (self *ViewDriver) ScrollWheelDown() *ViewDriver { + offsetX, offsetY, _, _ := self.getView().Dimensions() + self.t.scrollWheelDown(offsetX+1, offsetY+1) + return self +} + +func (self *ViewDriver) RepeatMouseMove() *ViewDriver { + self.t.repeatMouseMove() + return self +} + +func (self *ViewDriver) MouseRelease() *ViewDriver { + self.t.mouseRelease() + return self +} + // i.e. pressing down arrow func (self *ViewDriver) SelectNextItem() *ViewDriver { return self.PressFast(self.t.keys.Universal.NextItem) diff --git a/pkg/integration/tests/branch/rebase_and_drop.go b/pkg/integration/tests/branch/rebase_and_drop.go index ff17d6417..bbb8abf00 100644 --- a/pkg/integration/tests/branch/rebase_and_drop.go +++ b/pkg/integration/tests/branch/rebase_and_drop.go @@ -53,23 +53,23 @@ var RebaseAndDrop = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). TopLines( - Contains("--- Pending rebase todos ---"), - MatchesRegexp(`pick.*to keep`).IsSelected(), + Contains("─── Pending rebase todos"), + MatchesRegexp(`pick.*to keep`), MatchesRegexp(`pick.*to remove`), - MatchesRegexp(`pick.*CONFLICT.*first change`), - Contains("--- Commits ---"), + MatchesRegexp(`pick.*CONFLICT.*first change`).IsSelected(), + Contains("─── Commits"), MatchesRegexp("second-change-branch unrelated change"), MatchesRegexp("second change"), MatchesRegexp("original"), ). - SelectNextItem(). + NavigateToLine(Contains("to remove")). Press(keys.Universal.Remove). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp(`pick.*to keep`), MatchesRegexp(`drop.*to remove`).IsSelected(), MatchesRegexp(`pick.*CONFLICT.*first change`), - Contains("--- Commits ---"), + Contains("─── Commits"), MatchesRegexp("second-change-branch unrelated change"), MatchesRegexp("second change"), MatchesRegexp("original"), diff --git a/pkg/integration/tests/branch/rebase_conflicts_fix_build_errors_with_out_of_date_submodule.go b/pkg/integration/tests/branch/rebase_conflicts_fix_build_errors_with_out_of_date_submodule.go index 1b95fd316..29db1a212 100644 --- a/pkg/integration/tests/branch/rebase_conflicts_fix_build_errors_with_out_of_date_submodule.go +++ b/pkg/integration/tests/branch/rebase_conflicts_fix_build_errors_with_out_of_date_submodule.go @@ -75,8 +75,8 @@ var RebaseConflictsFixBuildErrorsWithOutOfDateSubmodule = NewIntegrationTest(New t.Views().Files(). Lines( - Equals("▼ /").IsSelected(), - Equals(" MM file"), + Equals("▼ /"), + Equals(" MM file").IsSelected(), Equals(" M submodule (submodule)"), Equals(" ?? untracked-file"), ) @@ -90,8 +90,8 @@ var RebaseConflictsFixBuildErrorsWithOutOfDateSubmodule = NewIntegrationTest(New t.Views().Files(). Lines( - Equals("▼ /").IsSelected(), - Equals(" M submodule (submodule)"), + Equals("▼ /"), + Equals(" M submodule (submodule)").IsSelected(), Equals(" ?? untracked-file"), ) diff --git a/pkg/integration/tests/branch/show_divergence_from_base_branch.go b/pkg/integration/tests/branch/show_divergence_from_base_branch.go index 2903b7837..1ff9edca0 100644 --- a/pkg/integration/tests/branch/show_divergence_from_base_branch.go +++ b/pkg/integration/tests/branch/show_divergence_from_base_branch.go @@ -37,9 +37,9 @@ var ShowDivergenceFromBaseBranch = NewIntegrationTest(NewIntegrationTestArgs{ IsFocused(). Title(Contains("Commits (feature <-> master)")). Lines( - DoesNotContainAnyOf("↓", "↑").Contains("--- Remote ---"), + DoesNotContainAnyOf("↓", "↑").Contains("─── Remote"), Contains("↓").Contains("master 3"), - DoesNotContainAnyOf("↓", "↑").Contains("--- Local ---"), + DoesNotContainAnyOf("↓", "↑").Contains("─── Local"), Contains("↑").Contains("feature 2"), Contains("↑").Contains("feature 1"), ) diff --git a/pkg/integration/tests/branch/show_divergence_from_upstream.go b/pkg/integration/tests/branch/show_divergence_from_upstream.go index 8aff21ca9..91e068cef 100644 --- a/pkg/integration/tests/branch/show_divergence_from_upstream.go +++ b/pkg/integration/tests/branch/show_divergence_from_upstream.go @@ -44,10 +44,10 @@ var ShowDivergenceFromUpstream = NewIntegrationTest(NewIntegrationTestArgs{ IsFocused(). Title(Contains("Commits (master <-> origin/master)")). Lines( - DoesNotContainAnyOf("↓", "↑").Contains("--- Remote ---"), + DoesNotContainAnyOf("↓", "↑").Contains("─── Remote"), Contains("↓").Contains("three"), Contains("↓").Contains("two"), - DoesNotContainAnyOf("↓", "↑").Contains("--- Local ---"), + DoesNotContainAnyOf("↓", "↑").Contains("─── Local"), Contains("↑").Contains("four"), ) }, diff --git a/pkg/integration/tests/branch/show_divergence_from_upstream_no_divergence.go b/pkg/integration/tests/branch/show_divergence_from_upstream_no_divergence.go index 5b446fcb8..ced13ed20 100644 --- a/pkg/integration/tests/branch/show_divergence_from_upstream_no_divergence.go +++ b/pkg/integration/tests/branch/show_divergence_from_upstream_no_divergence.go @@ -27,8 +27,8 @@ var ShowDivergenceFromUpstreamNoDivergence = NewIntegrationTest(NewIntegrationTe IsFocused(). Title(Contains("Commits (master <-> origin/master)")). Lines( - Contains("--- Remote ---"), - Contains("--- Local ---"), + Contains("─── Remote"), + Contains("─── Local"), ) }, }) diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_conflicts.go b/pkg/integration/tests/cherry_pick/cherry_pick_conflicts.go index 7468f921c..abdfcae82 100644 --- a/pkg/integration/tests/cherry_pick/cherry_pick_conflicts.go +++ b/pkg/integration/tests/cherry_pick/cherry_pick_conflicts.go @@ -79,10 +79,9 @@ var CherryPickConflicts = NewIntegrationTest(NewIntegrationTestArgs{ Focus(). TopLines( Contains("second-change-branch unrelated change"), - Contains("second change"), - Contains("first change").IsSelected(), + Contains("second change").IsSelected(), + Contains("first change"), ). - SelectPreviousItem(). Tap(func() { // because we picked 'Second change' when resolving the conflict, // we now see this commit as having replaced First Change with Second Change, diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_during_rebase.go b/pkg/integration/tests/cherry_pick/cherry_pick_during_rebase.go index a14dbe7c9..e46626360 100644 --- a/pkg/integration/tests/cherry_pick/cherry_pick_during_rebase.go +++ b/pkg/integration/tests/cherry_pick/cherry_pick_during_rebase.go @@ -60,9 +60,9 @@ var CherryPickDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ SelectNextItem(). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick CI two"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(" CI one").IsSelected(), Contains(" CI base"), ). @@ -77,9 +77,9 @@ var CherryPickDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Information().Content(DoesNotContain("commit copied")) }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick CI two"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(" CI three"), Contains(" CI one").IsSelected(), Contains(" CI base"), diff --git a/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_amend.go b/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_amend.go index acc2f389c..8ef3368d6 100644 --- a/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_amend.go +++ b/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_amend.go @@ -29,10 +29,10 @@ var AmendWhenThereAreConflictsAndAmend = NewIntegrationTest(NewIntegrationTestAr t.Views().Commits(). Focus(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit three"), - Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"), - Contains("--- Commits ---"), + Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch").IsSelected(), + Contains("─── Commits"), Contains("commit two"), Contains("file1 changed in master"), Contains("base commit"), diff --git a/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_cancel.go b/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_cancel.go index f7f5ec2e1..fe7c67ddf 100644 --- a/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_cancel.go +++ b/pkg/integration/tests/commit/amend_when_there_are_conflicts_and_cancel.go @@ -33,10 +33,10 @@ var AmendWhenThereAreConflictsAndCancel = NewIntegrationTest(NewIntegrationTestA t.Views().Commits(). Focus(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit three"), - Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"), - Contains("--- Commits ---"), + Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch").IsSelected(), + Contains("─── Commits"), Contains("commit two"), Contains("file1 changed in master"), Contains("base commit"), diff --git a/pkg/integration/tests/commit/directory_diff_with_renamed_files.go b/pkg/integration/tests/commit/directory_diff_with_renamed_files.go new file mode 100644 index 000000000..f849056f7 --- /dev/null +++ b/pkg/integration/tests/commit/directory_diff_with_renamed_files.go @@ -0,0 +1,90 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Selecting a directory in the commit files panel shows the renames of files that were moved into or out of it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateDir("dir") + shell.CreateDir("dir/nested") + shell.CreateFileAndAdd("file1", "file1 content\n") + shell.CreateFileAndAdd("dir/file2", "file2 content\n") + shell.CreateFileAndAdd("dir/nested/file3", "file3 content\n") + shell.Commit("initial commit") + shell.RenameFileInGit("file1", "dir/file1") + shell.RenameFileInGit("dir/file2", "dir/file2-renamed") + shell.RenameFileInGit("dir/nested/file3", "file3") + shell.Commit("move files") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("move files").IsSelected(), + Contains("initial commit"), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Equals(" ▼ dir"), + Equals(" R file1 → file1"), + Equals(" R file2 → file2-renamed"), + Equals(" R dir/nested/file3 → file3"), + ) + + t.Views().Main().ContainsLines( + Equals("diff --git a/file1 b/dir/file1"), + Equals("similarity index 100%"), + Equals("rename from file1"), + Equals("rename to dir/file1"), + Equals("diff --git a/dir/file2 b/dir/file2-renamed"), + Equals("similarity index 100%"), + Equals("rename from dir/file2"), + Equals("rename to dir/file2-renamed"), + Equals("diff --git a/dir/nested/file3 b/file3"), + Equals("similarity index 100%"), + Equals("rename from dir/nested/file3"), + Equals("rename to file3"), + ) + + t.Views().CommitFiles(). + SelectNextItem(). + SelectedLine(Equals(" ▼ dir")) + + t.Views().Main(). + ContainsLines( + Equals("diff --git a/file1 b/dir/file1"), + Equals("similarity index 100%"), + Equals("rename from file1"), + Equals("rename to dir/file1"), + Equals("diff --git a/dir/file2 b/dir/file2-renamed"), + Equals("similarity index 100%"), + Equals("rename from dir/file2"), + Equals("rename to dir/file2-renamed"), + Equals("diff --git a/dir/nested/file3 b/file3"), + Equals("similarity index 100%"), + Equals("rename from dir/nested/file3"), + Equals("rename to file3"), + ) + + t.Views().CommitFiles(). + SelectNextItem(). + SelectedLine(Equals(" R file1 → file1")) + + t.Views().Main().ContainsLines( + Equals("diff --git a/file1 b/dir/file1"), + Equals("similarity index 100%"), + Equals("rename from file1"), + Equals("rename to dir/file1"), + ) + }, +}) diff --git a/pkg/integration/tests/commit/revert_with_conflict_multiple_commits.go b/pkg/integration/tests/commit/revert_with_conflict_multiple_commits.go index cbbd8accd..5ad60aad5 100644 --- a/pkg/integration/tests/commit/revert_with_conflict_multiple_commits.go +++ b/pkg/integration/tests/commit/revert_with_conflict_multiple_commits.go @@ -44,10 +44,10 @@ var RevertWithConflictMultipleCommits = NewIntegrationTest(NewIntegrationTestArg Confirm() }). Lines( - Contains("--- Pending reverts ---"), + Contains("─── Pending reverts"), Contains("revert").Contains("CI unrelated change"), - Contains("revert").Contains("CI <-- CONFLICT --- add first line"), - Contains("--- Commits ---"), + Contains("revert").Contains("CI <-- CONFLICT --- add first line").IsSelected(), + Contains("─── Commits"), Contains("CI ○ add second line"), Contains("CI ○ add first line"), Contains("CI ○ unrelated change"), diff --git a/pkg/integration/tests/commit/revert_with_conflict_single_commit.go b/pkg/integration/tests/commit/revert_with_conflict_single_commit.go index 1a0669b10..374b40338 100644 --- a/pkg/integration/tests/commit/revert_with_conflict_single_commit.go +++ b/pkg/integration/tests/commit/revert_with_conflict_single_commit.go @@ -39,9 +39,9 @@ var RevertWithConflictSingleCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending reverts ---"), - Contains("revert").Contains("CI <-- CONFLICT --- add first line"), - Contains("--- Commits ---"), + Contains("─── Pending reverts"), + Contains("revert").Contains("CI <-- CONFLICT --- add first line").IsSelected(), + Contains("─── Commits"), Contains("CI ○ add second line"), Contains("CI ○ add first line"), Contains("CI ○ add empty file"), diff --git a/pkg/integration/tests/commit/shared.go b/pkg/integration/tests/commit/shared.go index 918197aaf..c1a66c13e 100644 --- a/pkg/integration/tests/commit/shared.go +++ b/pkg/integration/tests/commit/shared.go @@ -43,10 +43,10 @@ func doTheRebaseForAmendTests(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit three"), - Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch"), - Contains("--- Commits ---"), + Contains("pick").Contains("<-- CONFLICT --- file1 changed in branch").IsSelected(), + Contains("─── Commits"), Contains("commit two"), Contains("file1 changed in master"), Contains("base commit"), diff --git a/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go b/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go new file mode 100644 index 000000000..52931c192 --- /dev/null +++ b/pkg/integration/tests/conflicts/conflict_marker_size_not_auto_staged.go @@ -0,0 +1,43 @@ +package conflicts + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +var ConflictMarkerSizeNotAutoStaged = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Doesn't auto-stage an unresolved file whose conflict-marker-size gitattribute makes its markers longer than usual", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shared.SetCustomConflictMarkerSize(shell) + shared.CreateMergeConflictFile(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Common().PretendMergeOrRebaseStartedInLazygit() + + t.Views().Files(). + IsFocused(). + Lines( + Contains("UU file").IsSelected(), + ). + // Each refresh checks whether the conflicts are still there + Press(keys.Universal.Refresh). + // They are, so the file doesn't get staged and we don't get asked to + // continue the merge + Lines( + Contains("UU file").IsSelected(), + ). + // Once they really are resolved, we do + Tap(func() { + t.Shell().UpdateFile("file", "resolved content") + }). + Press(keys.Universal.Refresh). + Tap(func() { + t.Common().ContinueOnConflictsResolved("merge") + }). + IsEmpty() + }, +}) diff --git a/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go b/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go new file mode 100644 index 000000000..13a647931 --- /dev/null +++ b/pkg/integration/tests/conflicts/conflict_marker_size_resolve.go @@ -0,0 +1,40 @@ +package conflicts + +import ( + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" + "github.com/jesseduffield/lazygit/pkg/integration/tests/shared" +) + +var ConflictMarkerSizeResolve = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Resolves a conflict in a file whose conflict-marker-size gitattribute makes its markers longer than usual", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shared.SetCustomConflictMarkerSize(shell) + shared.CreateMergeConflictFileMultiple(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + startMarker := strings.Repeat("<", shared.CustomConflictMarkerSize) + + t.Views().Files(). + IsFocused(). + Lines( + Contains("UU file").IsSelected(), + ). + PressEnter() + + t.Views().MergeConflicts(). + IsFocused(). + SelectedLines( + Contains(startMarker+" HEAD"), + Contains("First Change"), + Contains(strings.Repeat("=", shared.CustomConflictMarkerSize)), + ). + PressPrimaryAction(). + Content(DoesNotContain(startMarker + " HEAD\nFirst Change")) + }, +}) diff --git a/pkg/integration/tests/conflicts/resolve_multiple_files.go b/pkg/integration/tests/conflicts/resolve_multiple_files.go index 5a8f9447e..66d0f8ae4 100644 --- a/pkg/integration/tests/conflicts/resolve_multiple_files.go +++ b/pkg/integration/tests/conflicts/resolve_multiple_files.go @@ -7,7 +7,7 @@ import ( ) var ResolveMultipleFiles = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Ensures that upon resolving conflicts for one file, the next file is selected", + Description: "Ensures that a file whose conflicts have been resolved keeps being shown while other files still have conflicts", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, @@ -34,25 +34,40 @@ var ResolveMultipleFiles = NewIntegrationTest(NewIntegrationTestArgs{ Contains("First Change"), Contains("======="), ). + SelectNextItem(). PressPrimaryAction() + // The resolved file is still shown, and stays selected so that its diff + // can be reviewed t.Views().Files(). IsFocused(). Lines( - Equals("UU file2").IsSelected(), + Equals("▼ /"), + Equals(" M file1").IsSelected(), + Equals(" UU file2"), ). + SelectNextItem(). PressEnter() // coincidentally these files have the same conflict t.Views().MergeConflicts(). IsFocused(). SelectedLines( - Contains("<<<<<<< HEAD"), - Contains("First Change"), Contains("======="), + Contains("Second Change"), + Contains(">>>>>>>"), ). PressPrimaryAction() + // Now that all conflicts are resolved, the filter is turned off again + t.Views().Files(). + Lines( + Equals("▼ /"), + Equals(" M file1"), + Equals(" M file2").IsSelected(), + Equals(" A file3"), + ) + t.Common().ContinueOnConflictsResolved("merge") }, }) diff --git a/pkg/integration/tests/diff/cycle_diff_renderers.go b/pkg/integration/tests/diff/cycle_diff_renderers.go new file mode 100644 index 000000000..4cf4ebfb5 --- /dev/null +++ b/pkg/integration/tests/diff/cycle_diff_renderers.go @@ -0,0 +1,50 @@ +package diff + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CycleDiffRenderers = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Cycle forwards and backwards through configured diff renderers", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Git.DiffRenderers = []config.DiffRendererConfig{ + // an explicit name overrides the derived one + {Name: "custom name", Command: "cat"}, + // no name, so it's derived from the first word of the command + {Command: "cat -n"}, + // rawGit derives it from the first argument if any + {Type: "rawGit", Args: []string{"--color-words"}}, + // neither name nor command, so it falls back to the default label + {Type: "rawGit"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(1) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.CycleDiffRenderers) + t.ExpectToast(Equals("Diff renderer: cat (2 of 4)")) + + t.Views().Commits().Press(keys.Universal.CycleDiffRenderers) + t.ExpectToast(Equals("Diff renderer: --color-words (3 of 4)")) + + t.Views().Commits().Press(keys.Universal.CycleDiffRenderers) + t.ExpectToast(Equals("Diff renderer: (default) (4 of 4)")) + + // cycling forward past the last diff renderer wraps around to the first + t.Views().Commits().Press(keys.Universal.CycleDiffRenderers) + t.ExpectToast(Equals("Diff renderer: custom name (1 of 4)")) + + // cycling backward past the first diff renderer wraps around to the last + t.Views().Commits().Press(keys.Universal.CycleDiffRenderersReverse) + t.ExpectToast(Equals("Diff renderer: (default) (4 of 4)")) + + t.Views().Commits().Press(keys.Universal.CycleDiffRenderersReverse) + t.ExpectToast(Equals("Diff renderer: --color-words (3 of 4)")) + }, +}) diff --git a/pkg/integration/tests/diff/cycle_pagers.go b/pkg/integration/tests/diff/cycle_pagers.go deleted file mode 100644 index 2f2da9a5b..000000000 --- a/pkg/integration/tests/diff/cycle_pagers.go +++ /dev/null @@ -1,45 +0,0 @@ -package diff - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -var CyclePagers = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Cycle forwards and backwards through configured pagers", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(cfg *config.AppConfig) { - cfg.GetUserConfig().Git.Pagers = []config.PagingConfig{ - // an explicit name overrides the derived one - {Name: "custom name", Pager: "cat"}, - // no name, so it's derived from the first word of the command - {Pager: "cat -n"}, - // neither name nor command, so it falls back to the default label - {}, - } - }, - SetupRepo: func(shell *Shell) { - shell.CreateNCommits(1) - }, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Commits(). - Focus(). - Press(keys.Universal.CyclePagers) - t.ExpectToast(Equals("Pager: cat (2 of 3)")) - - t.Views().Commits().Press(keys.Universal.CyclePagers) - t.ExpectToast(Equals("Pager: (default) (3 of 3)")) - - // cycling forward past the last pager wraps around to the first - t.Views().Commits().Press(keys.Universal.CyclePagers) - t.ExpectToast(Equals("Pager: custom name (1 of 3)")) - - // cycling backward past the first pager wraps around to the last - t.Views().Commits().Press(keys.Universal.CyclePagersReverse) - t.ExpectToast(Equals("Pager: (default) (3 of 3)")) - - t.Views().Commits().Press(keys.Universal.CyclePagersReverse) - t.ExpectToast(Equals("Pager: cat (2 of 3)")) - }, -}) diff --git a/pkg/integration/tests/file/directory_diff_with_renamed_files.go b/pkg/integration/tests/file/directory_diff_with_renamed_files.go new file mode 100644 index 000000000..18906bf03 --- /dev/null +++ b/pkg/integration/tests/file/directory_diff_with_renamed_files.go @@ -0,0 +1,86 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DirectoryDiffWithRenamedFiles = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Selecting a directory in the files panel shows the renames of files that were moved into or out of it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateDir("dir") + shell.CreateDir("dir/nested") + shell.CreateFileAndAdd("file1", "file1 content\n") + shell.CreateFileAndAdd("dir/file2", "file2 content\n") + shell.CreateFileAndAdd("dir/nested/file3", "file3 content\n") + shell.Commit("initial commit") + shell.RenameFileInGit("file1", "dir/file1") + shell.RenameFileInGit("dir/file2", "dir/file2-renamed") + shell.RenameFileInGit("dir/nested/file3", "file3") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Equals(" ▼ dir"), + Equals(" R file1 → file1"), + Equals(" R file2 → file2-renamed"), + Equals(" R dir/nested/file3 → file3"), + ) + + t.Views().Main().ContainsLines( + Equals("diff --git a/file1 b/dir/file1"), + Equals("similarity index 100%"), + Equals("rename from file1"), + Equals("rename to dir/file1"), + Equals("diff --git a/dir/file2 b/dir/file2-renamed"), + Equals("similarity index 100%"), + Equals("rename from dir/file2"), + Equals("rename to dir/file2-renamed"), + Equals("diff --git a/dir/nested/file3 b/file3"), + Equals("similarity index 100%"), + Equals("rename from dir/nested/file3"), + Equals("rename to file3"), + ) + + t.Views().Files(). + SelectNextItem(). + SelectedLine(Equals(" ▼ dir")) + + t.Views().Main(). + ContainsLines( + Equals("diff --git a/file1 b/dir/file1"), + Equals("similarity index 100%"), + Equals("rename from file1"), + Equals("rename to dir/file1"), + Equals("diff --git a/dir/file2 b/dir/file2-renamed"), + Equals("similarity index 100%"), + Equals("rename from dir/file2"), + Equals("rename to dir/file2-renamed"), + Equals("diff --git a/dir/nested/file3 b/file3"), + Equals("similarity index 100%"), + Equals("rename from dir/nested/file3"), + Equals("rename to file3"), + ) + + // The same applies when a filter reduces the directory to a single file + t.Views().Files(). + FilterOrSearch("file1"). + Lines( + Equals("▼ dir").IsSelected(), + Equals(" R file1 → file1"), + ) + + t.Views().Main(). + ContainsLines( + Equals("diff --git a/file1 b/dir/file1"), + Equals("similarity index 100%"), + Equals("rename from file1"), + Equals("rename to dir/file1"), + ) + }, +}) diff --git a/pkg/integration/tests/file/discard_various_changes_range_select.go b/pkg/integration/tests/file/discard_various_changes_range_select.go index 16ecedd04..2199f1278 100644 --- a/pkg/integration/tests/file/discard_various_changes_range_select.go +++ b/pkg/integration/tests/file/discard_various_changes_range_select.go @@ -46,12 +46,12 @@ var DiscardVariousChangesRangeSelect = NewIntegrationTest(NewIntegrationTestArgs Cancel() }). Lines( - Equals("▼ /").IsSelected(), + Equals("▼ /"), Equals(" AM added-changed.txt"), Equals(" MD change-delete.txt"), Equals(" D delete-change.txt"), Equals(" D deleted-staged.txt"), - Equals(" D deleted.txt"), + Equals(" D deleted.txt").IsSelected(), Equals(" MM double-modded.txt"), Equals(" M modded-staged.txt"), Equals(" M modded.txt"), @@ -59,6 +59,7 @@ var DiscardVariousChangesRangeSelect = NewIntegrationTest(NewIntegrationTestArgs Equals(" ?? new.txt"), Equals(" R renamed.txt → renamed2.txt"), ). + NavigateToLine(Equals("▼ /")). Press(keys.Universal.ToggleRangeSelect). NavigateToLine(Contains("renamed.txt")). Press(keys.Universal.Remove). diff --git a/pkg/integration/tests/file/stage_all_without_changed_files.go b/pkg/integration/tests/file/stage_all_without_changed_files.go new file mode 100644 index 000000000..bae54dffe --- /dev/null +++ b/pkg/integration/tests/file/stage_all_without_changed_files.go @@ -0,0 +1,25 @@ +package file + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageAllWithoutChangedFiles = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Pressing the stage-all key when there are no changed files says that there are none", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + IsEmpty(). + Press(keys.Files.ToggleStagedAll). + Tap(func() { + t.ExpectToast(Contains("No changed files")) + }) + }, +}) diff --git a/pkg/integration/tests/filter_and_search/filter_menu.go b/pkg/integration/tests/filter_and_search/filter_menu.go index e5b6b216e..9b6eed8a1 100644 --- a/pkg/integration/tests/filter_and_search/filter_menu.go +++ b/pkg/integration/tests/filter_and_search/filter_menu.go @@ -26,7 +26,7 @@ var FilterMenu = NewIntegrationTest(NewIntegrationTestArgs{ Filter("Ignore"). Lines( // menu has filtered down to the one item that matches the filter - Contains(`--- Local ---`), + Contains(`─── Local`), Contains(`Ignore`).IsSelected(), ). Confirm() diff --git a/pkg/integration/tests/filter_and_search/filter_menu_by_keybinding.go b/pkg/integration/tests/filter_and_search/filter_menu_by_keybinding.go index 957ca5c6a..54105b77e 100644 --- a/pkg/integration/tests/filter_and_search/filter_menu_by_keybinding.go +++ b/pkg/integration/tests/filter_and_search/filter_menu_by_keybinding.go @@ -21,7 +21,7 @@ var FilterMenuByKeybinding = NewIntegrationTest(NewIntegrationTestArgs{ Filter("@_"). Lines( // menu has filtered down to the one item that matches the filter - Contains("--- Global ---"), + Contains("─── Global"), Contains("_ Prev screen mode").IsSelected(), ). Confirm() diff --git a/pkg/integration/tests/filter_and_search/filter_menu_cancel_filter_with_escape.go b/pkg/integration/tests/filter_and_search/filter_menu_cancel_filter_with_escape.go index daf55fd0d..2ed81926e 100644 --- a/pkg/integration/tests/filter_and_search/filter_menu_cancel_filter_with_escape.go +++ b/pkg/integration/tests/filter_and_search/filter_menu_cancel_filter_with_escape.go @@ -20,7 +20,7 @@ var FilterMenuCancelFilterWithEscape = NewIntegrationTest(NewIntegrationTestArgs Filter("Ignore"). Lines( // menu has filtered down to the one item that matches the filter - Contains(`--- Local ---`), + Contains(`─── Local`), Contains(`Ignore`).IsSelected(), ) diff --git a/pkg/integration/tests/filter_and_search/filter_menu_with_no_keybindings.go b/pkg/integration/tests/filter_and_search/filter_menu_with_no_keybindings.go index 522ee7689..66718e822 100644 --- a/pkg/integration/tests/filter_and_search/filter_menu_with_no_keybindings.go +++ b/pkg/integration/tests/filter_and_search/filter_menu_with_no_keybindings.go @@ -25,7 +25,7 @@ var FilterMenuWithNoKeybindings = NewIntegrationTest(NewIntegrationTestArgs{ Lines( // menu has filtered down to the one item that matches the // filter, and it doesn't have a keybinding - Equals("--- Global ---"), + Equals("─── Global"), Equals("Toggle whitespace").IsSelected(), ) }, diff --git a/pkg/integration/tests/interactive_rebase/advanced_interactive_rebase.go b/pkg/integration/tests/interactive_rebase/advanced_interactive_rebase.go index 1162a20c7..4936fc38e 100644 --- a/pkg/integration/tests/interactive_rebase/advanced_interactive_rebase.go +++ b/pkg/integration/tests/interactive_rebase/advanced_interactive_rebase.go @@ -45,25 +45,25 @@ var AdvancedInteractiveRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains(TOP_COMMIT), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(BASE_COMMIT), ). NavigateToLine(Contains(TOP_COMMIT)). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains(TOP_COMMIT).Contains("edit"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(BASE_COMMIT), ). Tap(func() { t.Common().ContinueRebase() }). Lines( - Contains("--- Pending rebase todos ---"), - Contains("--- Commits ---"), + Contains("─── Pending rebase todos"), + Contains("─── Commits"), Contains(TOP_COMMIT), Contains(BASE_COMMIT), ) diff --git a/pkg/integration/tests/interactive_rebase/amend_commit_with_conflict.go b/pkg/integration/tests/interactive_rebase/amend_commit_with_conflict.go index ef01739dc..ade48d389 100644 --- a/pkg/integration/tests/interactive_rebase/amend_commit_with_conflict.go +++ b/pkg/integration/tests/interactive_rebase/amend_commit_with_conflict.go @@ -34,10 +34,10 @@ var AmendCommitWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ t.Common().AcknowledgeConflicts() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("three"), - Contains("fixup").Contains("<-- CONFLICT --- fixup! two"), - Contains("--- Commits ---"), + Contains("fixup").Contains("<-- CONFLICT --- fixup! two").IsSelected(), + Contains("─── Commits"), Contains("two"), Contains("one"), ) @@ -68,9 +68,9 @@ var AmendCommitWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), - Contains("<-- CONFLICT --- three"), - Contains("--- Commits ---"), + Contains("─── Pending rebase todos"), + Contains("<-- CONFLICT --- three").IsSelected(), + Contains("─── Commits"), Contains("two"), Contains("one"), ) diff --git a/pkg/integration/tests/interactive_rebase/amend_head_commit_during_rebase.go b/pkg/integration/tests/interactive_rebase/amend_head_commit_during_rebase.go index 0ca3ffaaa..9663a23e5 100644 --- a/pkg/integration/tests/interactive_rebase/amend_head_commit_during_rebase.go +++ b/pkg/integration/tests/interactive_rebase/amend_head_commit_during_rebase.go @@ -24,9 +24,9 @@ var AmendHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02").IsSelected(), Contains("commit-01"), ) @@ -50,9 +50,9 @@ var AmendHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02").IsSelected(), Contains("commit-01"), ) diff --git a/pkg/integration/tests/interactive_rebase/amend_non_head_commit_during_rebase.go b/pkg/integration/tests/interactive_rebase/amend_non_head_commit_during_rebase.go index 3e4df1404..740407dd5 100644 --- a/pkg/integration/tests/interactive_rebase/amend_non_head_commit_during_rebase.go +++ b/pkg/integration/tests/interactive_rebase/amend_non_head_commit_during_rebase.go @@ -24,9 +24,9 @@ var AmendNonHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02"), Contains("commit-01"), ) diff --git a/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go b/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go index 86b2ed950..1ae68666d 100644 --- a/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go +++ b/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go @@ -26,14 +26,14 @@ var DeleteUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-06"), Contains("pick").Contains("CI commit-05"), Contains("pick").Contains("CI commit-04"), Contains("update-ref").Contains("branch1"), Contains("pick").Contains("CI commit-03"), Contains("pick").Contains("CI commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ commit-01"), ). NavigateToLine(Contains("update-ref")). @@ -45,13 +45,13 @@ var DeleteUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-06"), Contains("pick").Contains("CI commit-05"), Contains("pick").Contains("CI commit-04"), Contains("pick").Contains("CI commit-03").IsSelected(), Contains("pick").Contains("CI commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ commit-01"), ). NavigateToLine(Contains("commit-02")). diff --git a/pkg/integration/tests/interactive_rebase/dont_show_branch_heads_for_todo_items.go b/pkg/integration/tests/interactive_rebase/dont_show_branch_heads_for_todo_items.go index 1b8674593..da6f9dd85 100644 --- a/pkg/integration/tests/interactive_rebase/dont_show_branch_heads_for_todo_items.go +++ b/pkg/integration/tests/interactive_rebase/dont_show_branch_heads_for_todo_items.go @@ -41,14 +41,14 @@ var DontShowBranchHeadsForTodoItems = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-04")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-09"), Contains("pick").Contains("CI commit-08"), Contains("pick").Contains("CI commit-07"), Contains("update-ref").Contains("branch2"), Contains("pick").Contains("CI commit-06"), // no star on this entry, even though branch2 points to it Contains("pick").Contains("CI commit-05"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI commit-04"), Contains("CI commit-03"), Contains("CI * commit-02"), // this star is fine though diff --git a/pkg/integration/tests/interactive_rebase/drag_keeps_selection_highlighted.go b/pkg/integration/tests/interactive_rebase/drag_keeps_selection_highlighted.go new file mode 100644 index 000000000..32ef54272 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drag_keeps_selection_highlighted.go @@ -0,0 +1,35 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragKeepsSelectionHighlighted = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Keep the original commit range highlighted while dragging sideways", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(5) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.RangeSelectDown). + Press(keys.Universal.RangeSelectDown). + ClickAndHold(1, 1). + SelectedLines( + Contains("commit-05"), + Contains("commit-04"), + Contains("commit-03"), + ). + MouseMove(10, 1). + SelectedLines( + Contains("commit-05"), + Contains("commit-04"), + Contains("commit-03"), + ). + MouseRelease() + }, +}) diff --git a/pkg/integration/tests/interactive_rebase/drag_to_reorder.go b/pkg/integration/tests/interactive_rebase/drag_to_reorder.go new file mode 100644 index 000000000..4d2f883a0 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drag_to_reorder.go @@ -0,0 +1,107 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragToReorder = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Drag a selected commit range multiple rows in one operation", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(5) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.RangeSelectDown). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ). + ClickAndHold(1, 1). + MouseMove(1, 3). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("drop here"), + Contains("commit-01"), + ). + PressEscape(). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ). + MouseMove(1, 4). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ). + MouseRelease(). + ClickAndHold(1, 1). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ). + MouseMove(1, 3). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("drop here"), + Contains("commit-01"), + ). + SelectNextItem(). + SelectedLines( + Contains("commit-03"), + ). + MouseRelease(). + TopLines( + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-01"), + ). + ClickAndHold(1, 2). + MouseMove(1, 0). + TopLines( + Contains("drop here"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-01"), + ). + MouseRelease(). + TopLines( + Contains("commit-05").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ). + ClickAndHold(1, 1). + MouseRelease(). + SelectedLines( + Contains("commit-04"), + ) + }, +}) diff --git a/pkg/integration/tests/interactive_rebase/drag_to_reorder_in_rebase.go b/pkg/integration/tests/interactive_rebase/drag_to_reorder_in_rebase.go new file mode 100644 index 000000000..1d27a1635 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drag_to_reorder_in_rebase.go @@ -0,0 +1,72 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragToReorderInRebase = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Drag rebase todos without allowing real commits to move", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(5) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + NavigateToLine(Contains("commit-01")). + Press(keys.Universal.Edit). + Lines( + Contains("─── Pending rebase todos"), + Contains("commit-05"), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), + Contains("─── Commits"), + Contains("commit-01").IsSelected(), + ). + NavigateToLine(Contains("commit-05")). + ClickAndHold(1, 1). + MouseMove(1, 6). + Lines( + Contains("─── Pending rebase todos"), + Contains("commit-05").IsSelected(), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), + Contains("drop here"), + Contains("─── Commits"), + Contains("commit-01"), + ). + MouseRelease(). + Lines( + Contains("─── Pending rebase todos"), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-05").IsSelected(), + Contains("─── Commits"), + Contains("commit-01"), + ). + NavigateToLine(Contains("commit-01")). + ClickAndHold(1, 6). + MouseMove(1, 4). + SelectedLines( + Contains("commit-05"), + Contains("─── Commits"), + Contains("commit-01"), + ). + MouseRelease(). + Lines( + Contains("─── Pending rebase todos"), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-05").IsSelected(), + Contains("─── Commits").IsSelected(), + Contains("commit-01").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/interactive_rebase/drag_to_reorder_with_autoscroll.go b/pkg/integration/tests/interactive_rebase/drag_to_reorder_with_autoscroll.go new file mode 100644 index 000000000..d8086e0a3 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drag_to_reorder_with_autoscroll.go @@ -0,0 +1,35 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragToReorderWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Keep scrolling commits while a dragged commit is held at the panel edge", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(40) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + TopLines( + Contains("commit-40").IsSelected(), + ). + ClickAndHold(1, 0). + MouseMoveToBottom(1). + OriginYAtLeast(3). + MouseRelease(). + SelectedLines( + Contains("commit-40"), + ). + SelectedLineIdxAtLeast(3). + GotoTop(). + TopLines( + Contains("commit-39").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go b/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go index 9fb450afe..90208c95d 100644 --- a/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go +++ b/pkg/integration/tests/interactive_rebase/drop_todo_commit_with_update_ref.go @@ -39,14 +39,14 @@ var DropTodoCommitWithUpdateRef = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-07"), Contains("pick").Contains("CI commit-06"), Contains("pick").Contains("CI commit-05"), Contains("update-ref").Contains("branch1").DoesNotContain("*"), Contains("pick").Contains("CI commit-04"), Contains("pick").Contains("CI commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI commit-02").IsSelected(), Contains("CI commit-01"), ). diff --git a/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go b/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go index 3045a5088..c340c83f3 100644 --- a/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go +++ b/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go @@ -25,9 +25,9 @@ var EditAndAutoAmend = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02").IsSelected(), Contains("commit-01"), ) diff --git a/pkg/integration/tests/interactive_rebase/edit_first_commit.go b/pkg/integration/tests/interactive_rebase/edit_first_commit.go index 2ba657370..8eca4e772 100644 --- a/pkg/integration/tests/interactive_rebase/edit_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/edit_first_commit.go @@ -24,9 +24,9 @@ var EditFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01").IsSelected(), ). Tap(func() { diff --git a/pkg/integration/tests/interactive_rebase/edit_last_commit_of_stacked_branch.go b/pkg/integration/tests/interactive_rebase/edit_last_commit_of_stacked_branch.go index 7db9bb262..87cdf4be1 100644 --- a/pkg/integration/tests/interactive_rebase/edit_last_commit_of_stacked_branch.go +++ b/pkg/integration/tests/interactive_rebase/edit_last_commit_of_stacked_branch.go @@ -37,11 +37,11 @@ var EditLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-03")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-05"), Contains("pick").Contains("CI commit-04"), Contains("update-ref").Contains("branch1"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI * commit-03").IsSelected(), Contains("CI commit-02"), Contains("CI commit-01"), diff --git a/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go b/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go index 00f77594e..bedad3ec9 100644 --- a/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go +++ b/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go @@ -23,8 +23,8 @@ var EditNonTodoCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), - Contains("--- Commits ---"), + Contains("─── Pending rebase todos"), + Contains("─── Commits"), Contains("commit-02"), Contains("commit-01"), ). diff --git a/pkg/integration/tests/interactive_rebase/edit_range_select_down_to_merge_outside_rebase.go b/pkg/integration/tests/interactive_rebase/edit_range_select_down_to_merge_outside_rebase.go index a57ab8acd..e5de3ab38 100644 --- a/pkg/integration/tests/interactive_rebase/edit_range_select_down_to_merge_outside_rebase.go +++ b/pkg/integration/tests/interactive_rebase/edit_range_select_down_to_merge_outside_rebase.go @@ -27,10 +27,10 @@ var EditRangeSelectDownToMergeOutsideRebase = NewIntegrationTest(NewIntegrationT Press(keys.Universal.RangeSelectDown). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("edit CI commit-02").IsSelected(), Contains("edit CI commit-01").IsSelected(), - Contains("--- Commits ---").IsSelected(), + Contains("─── Commits").IsSelected(), Contains(" CI ◎─╮ Merge branch 'second-change-branch' into first-change-branch").IsSelected(), Contains(" CI │ ○ * second-change-branch unrelated change"), Contains(" CI │ ○ second change"), diff --git a/pkg/integration/tests/interactive_rebase/edit_range_select_outside_rebase.go b/pkg/integration/tests/interactive_rebase/edit_range_select_outside_rebase.go index f39ab638c..f38703fc2 100644 --- a/pkg/integration/tests/interactive_rebase/edit_range_select_outside_rebase.go +++ b/pkg/integration/tests/interactive_rebase/edit_range_select_outside_rebase.go @@ -37,13 +37,13 @@ var EditRangeSelectOutsideRebase = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("merge CI Merge branch 'second-change-branch' into first-change-branch").IsSelected(), Contains("edit CI first change").IsSelected(), Contains("edit CI * second-change-branch unrelated change").IsSelected(), Contains("edit CI second change").IsSelected(), Contains("edit CI * original").IsSelected(), - Contains("--- Commits ---").IsSelected(), + Contains("─── Commits").IsSelected(), Contains(" CI ○ three").IsSelected(), Contains(" CI ○ two"), Contains(" CI ○ one"), diff --git a/pkg/integration/tests/interactive_rebase/edit_the_confl_commit.go b/pkg/integration/tests/interactive_rebase/edit_the_confl_commit.go index 5e03acdd5..b395e4747 100644 --- a/pkg/integration/tests/interactive_rebase/edit_the_confl_commit.go +++ b/pkg/integration/tests/interactive_rebase/edit_the_confl_commit.go @@ -32,10 +32,10 @@ var EditTheConflCommit = NewIntegrationTest(NewIntegrationTestArgs{ }). Focus(). Lines( - Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit two"), + Contains("─── Pending rebase todos"), + Contains("pick").Contains("commit two").IsSelected(), Contains("pick").Contains("<-- CONFLICT --- commit three"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit one"), ). NavigateToLine(Contains("<-- CONFLICT --- commit three")). diff --git a/pkg/integration/tests/interactive_rebase/fixup_keep_message_rebase.go b/pkg/integration/tests/interactive_rebase/fixup_keep_message_rebase.go index 595968c17..e040905fe 100644 --- a/pkg/integration/tests/interactive_rebase/fixup_keep_message_rebase.go +++ b/pkg/integration/tests/interactive_rebase/fixup_keep_message_rebase.go @@ -28,20 +28,20 @@ var FixupKeepMessageRebase = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("First Commit")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick CI Third Commit"), Contains("pick CI Second Commit"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("First Commit").IsSelected(), ). // Mark second commit as fixup NavigateToLine(Contains("Second Commit")). Press(keys.Commits.MarkCommitAsFixup). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick CI Third Commit"), Contains("fixup CI Second Commit").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("First Commit"), ). // Now set the -C flag using the SetFixupMessage keybinding @@ -53,10 +53,10 @@ var FixupKeepMessageRebase = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick CI Third Commit"), Contains("fixup -C CI Second Commit").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("First Commit"), ). // Continue the rebase diff --git a/pkg/integration/tests/interactive_rebase/interactive_rebase_of_copied_branch.go b/pkg/integration/tests/interactive_rebase/interactive_rebase_of_copied_branch.go index de0bb28d0..d0053a5c7 100644 --- a/pkg/integration/tests/interactive_rebase/interactive_rebase_of_copied_branch.go +++ b/pkg/integration/tests/interactive_rebase/interactive_rebase_of_copied_branch.go @@ -32,11 +32,11 @@ var InteractiveRebaseOfCopiedBranch = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), // No update-ref todo for branch1 here, even though command-line git would have added it Contains("pick").Contains("CI commit-03"), Contains("pick").Contains("CI commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI commit-01"), ) }, diff --git a/pkg/integration/tests/interactive_rebase/interactive_rebase_with_conflict_for_edit_command.go b/pkg/integration/tests/interactive_rebase/interactive_rebase_with_conflict_for_edit_command.go index 5b341e61c..6771080d2 100644 --- a/pkg/integration/tests/interactive_rebase/interactive_rebase_with_conflict_for_edit_command.go +++ b/pkg/integration/tests/interactive_rebase/interactive_rebase_with_conflict_for_edit_command.go @@ -52,9 +52,9 @@ var InteractiveRebaseWithConflictForEditCommand = NewIntegrationTest(NewIntegrat t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("edit").Contains("<-- CONFLICT --- this will conflict").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-03"), Contains("commit-02"), Contains("commit-01"), diff --git a/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go b/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go index 95dab3056..e5ce2387e 100644 --- a/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go +++ b/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go @@ -24,13 +24,13 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ // Start a rebase Press(keys.Universal.Edit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("pick").Contains("commit-07"), Contains("pick").Contains("commit-06"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05").IsSelected(), Contains("commit-04"), ). @@ -38,73 +38,73 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ // perform various actions on a range of commits Press(keys.Universal.RangeSelectUp). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("pick").Contains("commit-07").IsSelected(), Contains("pick").Contains("commit-06").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Commits.MarkCommitAsFixup). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("fixup").Contains("commit-07").IsSelected(), Contains("fixup").Contains("commit-06").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Commits.PickCommit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("pick").Contains("commit-07").IsSelected(), Contains("pick").Contains("commit-06").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Universal.Edit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("edit").Contains("commit-07").IsSelected(), Contains("edit").Contains("commit-06").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Commits.SquashDown). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("squash").Contains("commit-07").IsSelected(), Contains("squash").Contains("commit-06").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Commits.MoveDownCommit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), Contains("squash").Contains("commit-07").IsSelected(), Contains("squash").Contains("commit-06").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). @@ -113,37 +113,37 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ }). Press(keys.Commits.MoveUpCommit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("squash").Contains("commit-07").IsSelected(), Contains("squash").Contains("commit-06").IsSelected(), Contains("pick").Contains("commit-08"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Commits.MoveUpCommit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-10"), Contains("squash").Contains("commit-07").IsSelected(), Contains("squash").Contains("commit-06").IsSelected(), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). Press(keys.Commits.MoveUpCommit). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("squash").Contains("commit-07").IsSelected(), Contains("squash").Contains("commit-06").IsSelected(), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). @@ -152,13 +152,13 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectToast(Contains("Disabled: Cannot move any further")) }). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("squash").Contains("commit-07").IsSelected(), Contains("squash").Contains("commit-06").IsSelected(), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-05"), Contains("commit-04"), ). @@ -167,13 +167,13 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-08")). Press(keys.Universal.RangeSelectDown). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("squash").Contains("commit-07"), Contains("squash").Contains("commit-06"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08").IsSelected(), - Contains("--- Commits ---").IsSelected(), + Contains("─── Commits").IsSelected(), Contains("commit-05").IsSelected(), Contains("commit-04"), ). @@ -182,13 +182,13 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectToast(Contains("Disabled: When rebasing, this action only works on a selection of TODO commits.")) }). TopLines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("squash").Contains("commit-07"), Contains("squash").Contains("commit-06"), Contains("pick").Contains("commit-10"), Contains("pick").Contains("commit-09"), Contains("pick").Contains("commit-08").IsSelected(), - Contains("--- Commits ---").IsSelected(), + Contains("─── Commits").IsSelected(), Contains("commit-05").IsSelected(), Contains("commit-04"), ). diff --git a/pkg/integration/tests/interactive_rebase/move_in_rebase.go b/pkg/integration/tests/interactive_rebase/move_in_rebase.go index 9138839b6..5e518c1a2 100644 --- a/pkg/integration/tests/interactive_rebase/move_in_rebase.go +++ b/pkg/integration/tests/interactive_rebase/move_in_rebase.go @@ -25,30 +25,30 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-03"), Contains("commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01").IsSelected(), ). SelectPreviousItem(). Press(keys.Commits.MoveUpCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-02").IsSelected(), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). Press(keys.Commits.MoveUpCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-02").IsSelected(), Contains("commit-04"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). // assert we can't move past the top @@ -57,29 +57,29 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-02").IsSelected(), Contains("commit-04"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-02").IsSelected(), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-03"), Contains("commit-02").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). // assert we can't move past the bottom @@ -88,21 +88,21 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-03"), Contains("commit-02").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). // move it back up one so that we land in a different order than we started with Press(keys.Commits.MoveUpCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-02").IsSelected(), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01"), ). Tap(func() { diff --git a/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go b/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go index 16e58e18e..1c1eead5d 100644 --- a/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go +++ b/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go @@ -38,11 +38,11 @@ var MoveTodoDownWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-04"), Contains("commit-03"), Contains("commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-01").IsSelected(), ). NavigateToLine(Contains("commit-04")). diff --git a/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go b/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go index c730fd995..cd715fa24 100644 --- a/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go +++ b/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go @@ -26,28 +26,28 @@ var MoveUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-06"), Contains("pick").Contains("CI commit-05"), Contains("pick").Contains("CI commit-04"), Contains("update-ref").Contains("branch1"), Contains("pick").Contains("CI commit-03"), Contains("pick").Contains("CI commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ commit-01"), ). NavigateToLine(Contains("update-ref")). Press(keys.Commits.MoveUpCommit). Press(keys.Commits.MoveUpCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-06"), Contains("update-ref").Contains("branch1"), Contains("pick").Contains("CI commit-05"), Contains("pick").Contains("CI commit-04"), Contains("pick").Contains("CI commit-03"), Contains("pick").Contains("CI commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ commit-01"), ). Tap(func() { diff --git a/pkg/integration/tests/interactive_rebase/pick_rescheduled.go b/pkg/integration/tests/interactive_rebase/pick_rescheduled.go index af948b7cd..0822eee14 100644 --- a/pkg/integration/tests/interactive_rebase/pick_rescheduled.go +++ b/pkg/integration/tests/interactive_rebase/pick_rescheduled.go @@ -26,10 +26,10 @@ var PickRescheduled = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("one")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("three"), Contains("pick").Contains("two"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("one").IsSelected(), ). Tap(func() { @@ -41,9 +41,9 @@ var PickRescheduled = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("three"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("two"), Contains("one"), ) diff --git a/pkg/integration/tests/interactive_rebase/quick_start.go b/pkg/integration/tests/interactive_rebase/quick_start.go index 07baa0616..da7020b20 100644 --- a/pkg/integration/tests/interactive_rebase/quick_start.go +++ b/pkg/integration/tests/interactive_rebase/quick_start.go @@ -73,10 +73,10 @@ var QuickStart = NewIntegrationTest(NewIntegrationTestArgs{ // Verify quick start picks the last commit on the main branch Press(keys.Commits.StartInteractiveRebase). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("feature-branch two").IsSelected(), Contains("feature-branch one"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("last main commit"), Contains("initial commit"), ). @@ -106,9 +106,9 @@ var QuickStart = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Commits.StartInteractiveRebase). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("branch-with-merge three").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("Merge branch 'branch-to-merge'"), Contains("branch-to-merge two"), Contains("branch-to-merge one"), diff --git a/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go b/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go index 4d045c8fc..7189e0d37 100644 --- a/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go +++ b/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go @@ -39,7 +39,7 @@ var QuickStartKeepSelection = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-02")). Press(keys.Commits.StartInteractiveRebase). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-07"), Contains("pick").Contains("CI commit-06"), Contains("pick").Contains("CI commit-05"), @@ -47,7 +47,7 @@ var QuickStartKeepSelection = NewIntegrationTest(NewIntegrationTestArgs{ Contains("pick").Contains("CI commit-04"), Contains("pick").Contains("CI commit-03"), Contains("CI commit-02").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI commit-01"), ) }, diff --git a/pkg/integration/tests/interactive_rebase/quick_start_keep_selection_range.go b/pkg/integration/tests/interactive_rebase/quick_start_keep_selection_range.go index 4d25a883c..1bc7758f8 100644 --- a/pkg/integration/tests/interactive_rebase/quick_start_keep_selection_range.go +++ b/pkg/integration/tests/interactive_rebase/quick_start_keep_selection_range.go @@ -43,7 +43,7 @@ var QuickStartKeepSelectionRange = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Commits.StartInteractiveRebase). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("CI commit-07"), Contains("CI commit-06"), Contains("update-ref").Contains("branch2"), @@ -52,7 +52,7 @@ var QuickStartKeepSelectionRange = NewIntegrationTest(NewIntegrationTestArgs{ Contains("update-ref").Contains("branch1").IsSelected(), Contains("CI commit-03").IsSelected(), Contains("CI commit-02").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI commit-01"), ) }, diff --git a/pkg/integration/tests/interactive_rebase/rebase.go b/pkg/integration/tests/interactive_rebase/rebase.go index e1940ff7a..d829abf9a 100644 --- a/pkg/integration/tests/interactive_rebase/rebase.go +++ b/pkg/integration/tests/interactive_rebase/rebase.go @@ -34,60 +34,60 @@ var Rebase = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("first commit to edit")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp("pick.*commit to fixup"), MatchesRegexp("pick.*commit to drop"), MatchesRegexp("pick.*second commit to edit"), MatchesRegexp("pick.*commit to squash"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("first commit to edit").IsSelected(), Contains("initial commit"), ). SelectPreviousItem(). Press(keys.Commits.SquashDown). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp("pick.*commit to fixup"), MatchesRegexp("pick.*commit to drop"), MatchesRegexp("pick.*second commit to edit"), MatchesRegexp("squash.*commit to squash").IsSelected(), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("first commit to edit"), Contains("initial commit"), ). SelectPreviousItem(). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp("pick.*commit to fixup"), MatchesRegexp("pick.*commit to drop"), MatchesRegexp("edit.*second commit to edit").IsSelected(), MatchesRegexp("squash.*commit to squash"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("first commit to edit"), Contains("initial commit"), ). SelectPreviousItem(). Press(keys.Universal.Remove). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp("pick.*commit to fixup"), MatchesRegexp("drop.*commit to drop").IsSelected(), MatchesRegexp("edit.*second commit to edit"), MatchesRegexp("squash.*commit to squash"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("first commit to edit"), Contains("initial commit"), ). SelectPreviousItem(). Press(keys.Commits.MarkCommitAsFixup). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp("fixup.*commit to fixup").IsSelected(), MatchesRegexp("drop.*commit to drop"), MatchesRegexp("edit.*second commit to edit"), MatchesRegexp("squash.*commit to squash"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("first commit to edit"), Contains("initial commit"), ). @@ -95,10 +95,10 @@ var Rebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Common().ContinueRebase() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), MatchesRegexp("fixup.*commit to fixup").IsSelected(), MatchesRegexp("drop.*commit to drop"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("second commit to edit"), MatchesRegexp("first commit to edit"), Contains("initial commit"), diff --git a/pkg/integration/tests/interactive_rebase/revert_during_rebase_when_stopped_on_edit.go b/pkg/integration/tests/interactive_rebase/revert_during_rebase_when_stopped_on_edit.go index 44a39200b..3024e39da 100644 --- a/pkg/integration/tests/interactive_rebase/revert_during_rebase_when_stopped_on_edit.go +++ b/pkg/integration/tests/interactive_rebase/revert_during_rebase_when_stopped_on_edit.go @@ -30,9 +30,9 @@ var RevertDuringRebaseWhenStoppedOnEdit = NewIntegrationTest(NewIntegrationTestA NavigateToLine(Contains("commit-03")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-04"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-03").IsSelected(), Contains("commit-02"), Contains("commit-01"), @@ -49,9 +49,9 @@ var RevertDuringRebaseWhenStoppedOnEdit = NewIntegrationTest(NewIntegrationTestA Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("commit-04"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(`Revert "commit-01"`), Contains(`Revert "commit-02"`), Contains("commit-03"), diff --git a/pkg/integration/tests/interactive_rebase/revert_multiple_commits_in_interactive_rebase.go b/pkg/integration/tests/interactive_rebase/revert_multiple_commits_in_interactive_rebase.go index 2cb0c05b7..630edc823 100644 --- a/pkg/integration/tests/interactive_rebase/revert_multiple_commits_in_interactive_rebase.go +++ b/pkg/integration/tests/interactive_rebase/revert_multiple_commits_in_interactive_rebase.go @@ -50,13 +50,13 @@ var RevertMultipleCommitsInInteractiveRebase = NewIntegrationTest(NewIntegration Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("CI unrelated change 3"), Contains("CI unrelated change 2"), - Contains("--- Pending reverts ---"), + Contains("─── Pending reverts"), Contains("revert").Contains("CI unrelated change 1"), - Contains("revert").Contains("CI <-- CONFLICT --- add first line"), - Contains("--- Commits ---"), + Contains("revert").Contains("CI <-- CONFLICT --- add first line").IsSelected(), + Contains("─── Commits"), Contains("CI ○ add second line"), Contains("CI ○ add first line"), Contains("CI ○ unrelated change 1"), @@ -83,10 +83,10 @@ var RevertMultipleCommitsInInteractiveRebase = NewIntegrationTest(NewIntegration t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI unrelated change 3"), Contains("pick").Contains("CI unrelated change 2"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(`CI ○ Revert "unrelated change 1"`), Contains(`CI ○ Revert "add first line"`), Contains("CI ○ add second line"), diff --git a/pkg/integration/tests/interactive_rebase/revert_single_commit_in_interactive_rebase.go b/pkg/integration/tests/interactive_rebase/revert_single_commit_in_interactive_rebase.go index 2b0ee24b2..d4ba4312d 100644 --- a/pkg/integration/tests/interactive_rebase/revert_single_commit_in_interactive_rebase.go +++ b/pkg/integration/tests/interactive_rebase/revert_single_commit_in_interactive_rebase.go @@ -45,14 +45,14 @@ var RevertSingleCommitInInteractiveRebase = NewIntegrationTest(NewIntegrationTes Cancel() // stay in commits panel }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("CI unrelated change 2"), Contains("CI unrelated change 1"), - Contains("--- Pending reverts ---"), - Contains("revert").Contains("CI <-- CONFLICT --- add first line"), - Contains("--- Commits ---"), + Contains("─── Pending reverts"), + Contains("revert").Contains("CI <-- CONFLICT --- add first line").IsSelected(), + Contains("─── Commits"), Contains("CI ○ add second line"), - Contains("CI ○ add first line").IsSelected(), + Contains("CI ○ add first line"), Contains("CI ○ add empty file"), ). Press(keys.Commits.MoveDownCommit). @@ -84,10 +84,10 @@ var RevertSingleCommitInInteractiveRebase = NewIntegrationTest(NewIntegrationTes t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI unrelated change 2"), Contains("pick").Contains("CI unrelated change 1"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains(`CI ○ Revert "add first line"`), Contains("CI ○ add second line"), Contains("CI ○ add first line"), diff --git a/pkg/integration/tests/interactive_rebase/reword_commit_with_editor_and_fail.go b/pkg/integration/tests/interactive_rebase/reword_commit_with_editor_and_fail.go index b8cf20ae8..8a58be2f8 100644 --- a/pkg/integration/tests/interactive_rebase/reword_commit_with_editor_and_fail.go +++ b/pkg/integration/tests/interactive_rebase/reword_commit_with_editor_and_fail.go @@ -33,9 +33,9 @@ var RewordCommitWithEditorAndFail = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02").IsSelected(), Contains("commit-01"), ) diff --git a/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit.go b/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit.go index bd58ab083..ef34b1844 100644 --- a/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit.go +++ b/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit.go @@ -25,9 +25,9 @@ var RewordYouAreHereCommit = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02").IsSelected(), Contains("commit-01"), ). @@ -41,9 +41,9 @@ var RewordYouAreHereCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("renamed 02").IsSelected(), Contains("commit-01"), ) diff --git a/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit_with_editor.go b/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit_with_editor.go index 5406ed02b..a4a19f42f 100644 --- a/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit_with_editor.go +++ b/pkg/integration/tests/interactive_rebase/reword_you_are_here_commit_with_editor.go @@ -27,9 +27,9 @@ var RewordYouAreHereCommitWithEditor = NewIntegrationTest(NewIntegrationTestArgs NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit-02").IsSelected(), Contains("commit-01"), ). @@ -41,9 +41,9 @@ var RewordYouAreHereCommitWithEditor = NewIntegrationTest(NewIntegrationTestArgs Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("renamed 02").IsSelected(), Contains("commit-01"), ) diff --git a/pkg/integration/tests/interactive_rebase/shared.go b/pkg/integration/tests/interactive_rebase/shared.go index ea6626fd6..522f425c1 100644 --- a/pkg/integration/tests/interactive_rebase/shared.go +++ b/pkg/integration/tests/interactive_rebase/shared.go @@ -4,15 +4,26 @@ import ( . "github.com/jesseduffield/lazygit/pkg/integration/components" ) -func handleConflictsFromSwap(t *TestDriver, expectedCommand string) { +func handleConflictsFromSwap(t *TestDriver, expectedCommand string, selectConflict bool) { t.Common().AcknowledgeConflicts() + // If the conflict comes from directly moving a commit, we want to keep the moved commit + // selected, so selectConflict is false. In other cases (e.g. a conflict after "continue + // rebase") we want to select the conflict commit. + commitTwoMatcher := Contains("pick").Contains("commit two") + conflictMatcher := Contains(expectedCommand).Contains("<-- CONFLICT --- commit three") + if selectConflict { + conflictMatcher.IsSelected() + } else { + commitTwoMatcher.IsSelected() + } + t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit two"), - Contains(expectedCommand).Contains("<-- CONFLICT --- commit three"), - Contains("--- Commits ---"), + Contains("─── Pending rebase todos"), + commitTwoMatcher, + conflictMatcher, + Contains("─── Commits"), Contains("commit one"), ) diff --git a/pkg/integration/tests/interactive_rebase/show_exec_todos.go b/pkg/integration/tests/interactive_rebase/show_exec_todos.go index fad0e44e8..3873f3ab4 100644 --- a/pkg/integration/tests/interactive_rebase/show_exec_todos.go +++ b/pkg/integration/tests/interactive_rebase/show_exec_todos.go @@ -31,10 +31,10 @@ var ShowExecTodos = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectPopup().Alert().Title(Equals("Error")).Content(Contains("Rebasing (2/4)Executing: false")).Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("exec").Contains("false"), Contains("pick").Contains("CI commit-03"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI ○ commit-02"), Contains("CI ○ commit-01"), ). @@ -43,8 +43,8 @@ var ShowExecTodos = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectPopup().Alert().Title(Equals("Error")).Content(Contains("exit status 1")).Confirm() }). Lines( - Contains("--- Pending rebase todos ---"), - Contains("--- Commits ---"), + Contains("─── Pending rebase todos"), + Contains("─── Commits"), Contains("CI ○ commit-03"), Contains("CI ○ commit-02"), Contains("CI ○ commit-01"), diff --git a/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict.go b/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict.go index f6653f9b0..693c37a9b 100644 --- a/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict.go +++ b/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict.go @@ -29,25 +29,25 @@ var SwapInRebaseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit one")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit three"), Contains("commit two"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit one").IsSelected(), ). SelectPreviousItem(). Press(keys.Commits.MoveUpCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit two").IsSelected(), Contains("commit three"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit one"), ). Tap(func() { t.Common().ContinueRebase() }) - handleConflictsFromSwap(t, "pick") + handleConflictsFromSwap(t, "pick", true) }, }) diff --git a/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict_and_edit.go b/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict_and_edit.go index f5beb4374..7ee710ebe 100644 --- a/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict_and_edit.go +++ b/pkg/integration/tests/interactive_rebase/swap_in_rebase_with_conflict_and_edit.go @@ -29,19 +29,19 @@ var SwapInRebaseWithConflictAndEdit = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit one")). Press(keys.Universal.Edit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit three"), Contains("commit two"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit one").IsSelected(), ). NavigateToLine(Contains("commit two")). Press(keys.Commits.MoveUpCommit). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("commit two").IsSelected(), Contains("commit three"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("commit one"), ). NavigateToLine(Contains("commit three")). @@ -51,6 +51,6 @@ var SwapInRebaseWithConflictAndEdit = NewIntegrationTest(NewIntegrationTestArgs{ t.Common().ContinueRebase() }) - handleConflictsFromSwap(t, "edit") + handleConflictsFromSwap(t, "edit", true) }, }) diff --git a/pkg/integration/tests/interactive_rebase/swap_with_conflict.go b/pkg/integration/tests/interactive_rebase/swap_with_conflict.go index 1ea71356e..5f91d9f04 100644 --- a/pkg/integration/tests/interactive_rebase/swap_with_conflict.go +++ b/pkg/integration/tests/interactive_rebase/swap_with_conflict.go @@ -28,6 +28,6 @@ var SwapWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Commits.MoveDownCommit) - handleConflictsFromSwap(t, "pick") + handleConflictsFromSwap(t, "pick", false) }, }) diff --git a/pkg/integration/tests/interactive_rebase/view_files_of_todo_entries.go b/pkg/integration/tests/interactive_rebase/view_files_of_todo_entries.go index 3746633c7..607f94eed 100644 --- a/pkg/integration/tests/interactive_rebase/view_files_of_todo_entries.go +++ b/pkg/integration/tests/interactive_rebase/view_files_of_todo_entries.go @@ -28,11 +28,11 @@ var ViewFilesOfTodoEntries = NewIntegrationTest(NewIntegrationTestArgs{ Focus(). Press(keys.Commits.StartInteractiveRebase). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("CI commit-03").IsSelected(), Contains("update-ref").Contains("branch1"), Contains("pick").Contains("CI commit-02"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("CI commit-01"), ). Press(keys.Universal.GoInto) diff --git a/pkg/integration/tests/misc/start_in_git_dir.go b/pkg/integration/tests/misc/start_in_git_dir.go new file mode 100644 index 000000000..5fffcc2a9 --- /dev/null +++ b/pkg/integration/tests/misc/start_in_git_dir.go @@ -0,0 +1,34 @@ +package misc + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StartInGitDir = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Start lazygit in a repo's .git dir, and have it open the repo", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("blah", "original content\n") + shell.Commit("initial commit") + shell.UpdateFile("blah", "updated content\n") + + // this is where lazygit will start + shell.Chdir(".git") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Lines( + Contains("initial commit"), + ) + + // we're in the work tree the .git belongs to, not in the .git itself + t.Views().Files(). + IsFocused(). + Lines( + Contains(" M blah"), + ) + }, +}) diff --git a/pkg/integration/tests/patch_building/apply_in_reverse_with_conflict.go b/pkg/integration/tests/patch_building/apply_in_reverse_with_conflict.go index 1a09cea7a..d9f99a703 100644 --- a/pkg/integration/tests/patch_building/apply_in_reverse_with_conflict.go +++ b/pkg/integration/tests/patch_building/apply_in_reverse_with_conflict.go @@ -83,11 +83,10 @@ var ApplyInReverseWithConflict = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files(). Focus(). Lines( - Equals("▼ /").IsSelected(), - Equals(" M file1"), + Equals("▼ /"), + Equals(" M file1").IsSelected(), Equals(" M file2"), - ). - SelectNextItem() + ) t.Views().Main(). ContainsLines( diff --git a/pkg/integration/tests/patch_building/move_to_earlier_commit_from_added_file.go b/pkg/integration/tests/patch_building/move_to_earlier_commit_from_added_file.go index a619128fa..7f0d3584f 100644 --- a/pkg/integration/tests/patch_building/move_to_earlier_commit_from_added_file.go +++ b/pkg/integration/tests/patch_building/move_to_earlier_commit_from_added_file.go @@ -76,10 +76,11 @@ var MoveToEarlierCommitFromAddedFile = NewIntegrationTest(NewIntegrationTestArgs t.Views().Commits(). Focus(). Lines( - Contains("commit to move from"), - Contains("destination commit").IsSelected(), + Contains("commit to move from").IsSelected(), + Contains("destination commit"), Contains("first commit"), ). + NavigateToLine(Contains("destination commit")). PressEnter() t.Views().CommitFiles(). diff --git a/pkg/integration/tests/shared/conflicts.go b/pkg/integration/tests/shared/conflicts.go index b84c8c7ad..c8319acf4 100644 --- a/pkg/integration/tests/shared/conflicts.go +++ b/pkg/integration/tests/shared/conflicts.go @@ -1,6 +1,8 @@ package shared import ( + "fmt" + . "github.com/jesseduffield/lazygit/pkg/integration/components" ) @@ -28,6 +30,20 @@ Second Change File ` +// A conflict-marker-size that isn't git's default of 7. It's set for file types +// whose regular content tends to contain marker-looking lines, e.g. +// documentation about merging, or test scripts. +const CustomConflictMarkerSize = 32 + +// Makes git write conflict markers of CustomConflictMarkerSize characters into +// the file that the setups below create conflicts in. Call this before one of +// them. +var SetCustomConflictMarkerSize = func(shell *Shell) { + shell.CreateFileAndAdd(".gitattributes", + fmt.Sprintf("file conflict-marker-size=%d\n", CustomConflictMarkerSize)). + Commit("set a custom conflict marker size") +} + // prepares us for a rebase/merge that has conflicts var MergeConflictsSetup = func(shell *Shell) { shell. diff --git a/pkg/integration/tests/submodule/enter.go b/pkg/integration/tests/submodule/enter.go index b768ed40e..67df35276 100644 --- a/pkg/integration/tests/submodule/enter.go +++ b/pkg/integration/tests/submodule/enter.go @@ -29,7 +29,7 @@ var Enter = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Status().Content(Contains("repo")) } assertInSubmodule := func() { - t.Views().Status().Content(Contains("my_submodule_path(my_submodule_name)")) + t.Views().Status().Content(Contains("my_submodule_path")) } assertInParentRepo() diff --git a/pkg/integration/tests/submodule/enter_from_dotfile_bare_repo.go b/pkg/integration/tests/submodule/enter_from_dotfile_bare_repo.go new file mode 100644 index 000000000..e5537c5f9 --- /dev/null +++ b/pkg/integration/tests/submodule/enter_from_dotfile_bare_repo.go @@ -0,0 +1,72 @@ +package submodule + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// Entering a submodule and escaping back out again, in a repo that git can only +// find because we were told where it is (--git-dir/--work-tree). Entering the +// submodule has to leave that behind, since it says where the superproject is, +// so coming back out has to bring it along again. + +var EnterFromDotfileBareRepo = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Enter a submodule of a dotfile bare repo and escape back out again", + ExtraCmdArgs: []string{"--git-dir={{.actualPath}}/.bare", "--work-tree={{.actualPath}}/repo"}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + // we're going to have a directory structure like this: + // project + // - .bare (the git dir) + // - repo (the work tree, with no .git of its own) + // - my_submodule_name (the submodule's remote) + // + // The work tree is called 'repo' because that's the directory that all + // lazygit tests start in + + // make a repo for the submodule to be cloned from, using the .git dir + // that every test starts with + shell.EmptyCommit("initial submodule commit") + shell.Clone("my_submodule_name") + + // now turn the test repo into a dotfile-style bare repo + shell.DeleteFile(".git") + shell.RunCommand([]string{"git", "init", "--bare", "../.bare"}) + gitInBareRepo := []string{"git", "--git-dir=../.bare", "--work-tree=."} + shell.RunCommand(append(gitInBareRepo, "checkout", "-b", "mybranch")) + shell.CreateFile("blah", "blah\n") + shell.RunCommand(append(gitInBareRepo, "add", "blah")) + shell.RunCommand(append(gitInBareRepo, "commit", "-m", "initial commit")) + shell.RunCommand(append(gitInBareRepo, "-c", "protocol.file.allow=always", "submodule", + "add", "--name", "my_submodule_name", "../my_submodule_name", "my_submodule_path")) + shell.RunCommand(append(gitInBareRepo, "commit", "-m", "add submodule")) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + assertInParentRepo := func() { + t.Views().Status().Content(Contains("repo")) + t.Views().Commits().Lines( + Contains("add submodule"), + Contains("initial commit"), + ) + } + + assertInParentRepo() + + t.Views().Submodules().Focus(). + Lines( + Contains("my_submodule_name").IsSelected(), + ). + PressEnter() + + t.Views().Status().Content(Contains("my_submodule_path")) + t.Views().Commits().Lines( + Contains("initial submodule commit"), + ) + + t.Views().Files().IsFocused().PressEscape() + + assertInParentRepo() + t.Views().Submodules().IsFocused() + }, +}) diff --git a/pkg/integration/tests/submodule/enter_nested.go b/pkg/integration/tests/submodule/enter_nested.go index 24cdf5261..1fc96425c 100644 --- a/pkg/integration/tests/submodule/enter_nested.go +++ b/pkg/integration/tests/submodule/enter_nested.go @@ -37,7 +37,7 @@ var EnterNested = NewIntegrationTest(NewIntegrationTestArgs{ // enter the nested submodule PressEnter() - t.Views().Status().Content(Contains("innerSubPath(innerSubName)")) + t.Views().Status().Content(Contains("innerSubPath")) t.Views().Commits().ContainsLines( Contains("initial inner commit"), ) diff --git a/pkg/integration/tests/submodule/reset.go b/pkg/integration/tests/submodule/reset.go index d671066a1..6c23bbd85 100644 --- a/pkg/integration/tests/submodule/reset.go +++ b/pkg/integration/tests/submodule/reset.go @@ -31,7 +31,7 @@ var Reset = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Status().Content(Contains("repo")) } assertInSubmodule := func() { - t.Views().Status().Content(Contains("my_submodule_path(my_submodule_name)")) + t.Views().Status().Content(Contains("my_submodule_path")) } assertInParentRepo() diff --git a/pkg/integration/tests/sync/pull_rebase_interactive_conflict.go b/pkg/integration/tests/sync/pull_rebase_interactive_conflict.go index 2bb39e14f..2e08688df 100644 --- a/pkg/integration/tests/sync/pull_rebase_interactive_conflict.go +++ b/pkg/integration/tests/sync/pull_rebase_interactive_conflict.go @@ -48,10 +48,10 @@ var PullRebaseInteractiveConflict = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("pick").Contains("five"), - Contains("pick").Contains("CONFLICT").Contains("four"), - Contains("--- Commits ---"), + Contains("pick").Contains("CONFLICT").Contains("four").IsSelected(), + Contains("─── Commits"), Contains("three"), Contains("two"), Contains("one"), @@ -83,13 +83,12 @@ var PullRebaseInteractiveConflict = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("five").IsSelected(), - Contains("four"), + Contains("five"), + Contains("four").IsSelected(), Contains("three"), Contains("two"), Contains("one"), - ). - SelectNextItem() + ) t.Views().Main(). Content( diff --git a/pkg/integration/tests/sync/pull_rebase_interactive_conflict_drop.go b/pkg/integration/tests/sync/pull_rebase_interactive_conflict_drop.go index ad7a4806f..38b63608e 100644 --- a/pkg/integration/tests/sync/pull_rebase_interactive_conflict_drop.go +++ b/pkg/integration/tests/sync/pull_rebase_interactive_conflict_drop.go @@ -49,20 +49,21 @@ var PullRebaseInteractiveConflictDrop = NewIntegrationTest(NewIntegrationTestArg t.Views().Commits(). Focus(). Lines( - Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("five").IsSelected(), - Contains("pick").Contains("CONFLICT").Contains("four"), - Contains("--- Commits ---"), + Contains("─── Pending rebase todos"), + Contains("pick").Contains("five"), + Contains("pick").Contains("CONFLICT").Contains("four").IsSelected(), + Contains("─── Commits"), Contains("three"), Contains("two"), Contains("one"), ). + NavigateToLine(Contains("five")). Press(keys.Universal.Remove). Lines( - Contains("--- Pending rebase todos ---"), + Contains("─── Pending rebase todos"), Contains("drop").Contains("five").IsSelected(), Contains("pick").Contains("CONFLICT").Contains("four"), - Contains("--- Commits ---"), + Contains("─── Commits"), Contains("three"), Contains("two"), Contains("one"), diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 77e54e265..0124de268 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -126,6 +126,7 @@ var tests = []*components.IntegrationTest{ commit.CreateAmendCommit, commit.CreateFixupCommitInBranchStack, commit.CreateTag, + commit.DirectoryDiffWithRenamedFiles, commit.DisableCopyCommitMessageBody, commit.DiscardOldFileChanges, commit.DiscardRenamedFile, @@ -165,6 +166,8 @@ var tests = []*components.IntegrationTest{ config.NegativeRefspec, config.RemoteNamedStar, config.SidePanelsInPerRepoConfig, + conflicts.ConflictMarkerSizeNotAutoStaged, + conflicts.ConflictMarkerSizeResolve, conflicts.ContinuePromptDismissedWhenResolvedExternally, conflicts.Filter, conflicts.MergeFileBoth, @@ -218,7 +221,7 @@ var tests = []*components.IntegrationTest{ demo.Undo, demo.WorktreeCreateFromBranches, diff.CopyToClipboard, - diff.CyclePagers, + diff.CycleDiffRenderers, diff.Diff, diff.DiffAndApplyPatch, diff.DiffCommits, @@ -229,6 +232,7 @@ var tests = []*components.IntegrationTest{ file.CollapseExpand, file.CopyMenu, file.DirWithUntrackedFile, + file.DirectoryDiffWithRenamedFiles, file.DiscardAllDirChanges, file.DiscardAllDirChangesWhenFiltering, file.DiscardRangeSelect, @@ -246,6 +250,7 @@ var tests = []*components.IntegrationTest{ file.RenameSimilarityThresholdChange, file.RenamedFiles, file.RenamedFilesNoRootItem, + file.StageAllWithoutChangedFiles, file.StageChildrenRangeSelect, file.StageDeletedRangeSelect, file.StageRangeSelect, @@ -290,6 +295,10 @@ var tests = []*components.IntegrationTest{ interactive_rebase.AmendNonHeadCommitDuringRebase, interactive_rebase.DeleteUpdateRefTodo, interactive_rebase.DontShowBranchHeadsForTodoItems, + interactive_rebase.DragKeepsSelectionHighlighted, + interactive_rebase.DragToReorder, + interactive_rebase.DragToReorderInRebase, + interactive_rebase.DragToReorderWithAutoscroll, interactive_rebase.DropCommitInCopiedBranchWithUpdateRef, interactive_rebase.DropMergeCommit, interactive_rebase.DropTodoCommitWithUpdateRef, @@ -349,6 +358,7 @@ var tests = []*components.IntegrationTest{ misc.DirenvUnloadsOnBlockedEnvrc, misc.InitialOpen, misc.RecentReposOnLaunch, + misc.StartInGitDir, patch_building.Apply, patch_building.ApplyInReverse, patch_building.ApplyInReverseWithConflict, @@ -437,6 +447,7 @@ var tests = []*components.IntegrationTest{ status.LogCmdStatusPanelAllBranchesLog, submodule.Add, submodule.Enter, + submodule.EnterFromDotfileBareRepo, submodule.EnterNested, submodule.Remove, submodule.RemoveNested, @@ -488,19 +499,28 @@ var tests = []*components.IntegrationTest{ tag.Reset, tag.ResetToDuplicateNamedBranch, ui.Accordion, + ui.BackgroundRefreshKeepsScrollPosition, ui.BranchesNotFirstTab, ui.CommitsNotFirstTab, ui.DisableSwitchTabWithPanelJumpKeys, + ui.DragBeyondViewport, ui.EmptyMenu, + ui.FilteringScrollsSelectionIntoView, + ui.FindBaseCommitForFixupScrollsIntoView, ui.HideSidePanel, ui.KeybindingSuggestionsDontCrashOnDisabledBindings, ui.KeybindingSuggestionsWhenSwitchingRepos, + ui.MenuScrollPositionIsReset, ui.ModeSpecificKeybindingSuggestions, + ui.MoveCommitScrollsSelectionIntoView, ui.OpenLinkFailure, + ui.PageUpAndDown, ui.PromoteTabToSidePanel, ui.RangeSelect, + ui.RangeSelectWithAutoscroll, ui.ReloadSidePanels, ui.ReorderSidePanels, + ui.SubCommitsScrollPositionIsReset, ui.SwitchTabFromMenu, ui.SwitchTabWithPanelJumpKeys, undo.UndoCheckoutAndDrop, @@ -536,6 +556,8 @@ var tests = []*components.IntegrationTest{ worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch, worktree.RemoveWorktreeFromBranch, worktree.ResetWindowTabs, + worktree.SeparateWorkTreeConfig, worktree.SymlinkIntoRepoSubdir, worktree.WorktreeInRepo, + worktree.WorktreeInsideRepo, } diff --git a/pkg/integration/tests/ui/background_refresh_keeps_scroll_position.go b/pkg/integration/tests/ui/background_refresh_keeps_scroll_position.go new file mode 100644 index 000000000..e2cf6ee1e --- /dev/null +++ b/pkg/integration/tests/ui/background_refresh_keeps_scroll_position.go @@ -0,0 +1,41 @@ +package ui + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var BackgroundRefreshKeepsScrollPosition = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A background refresh doesn't scroll the selection back into view", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial commit") + for i := range 20 { + shell.CreateFile(fmt.Sprintf("file%02d", i), "") + } + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Focus(). + SelectNextItem(). + SelectedLine(Contains("file00")). + // Scroll the selection out of view with the mouse wheel + ScrollWheelDown(). + ScrollWheelDown(). + OriginY(4). + Tap(func() { + t.Shell().CreateFile("aaa", "") + t.RefreshInBackground() + }). + // The new file sorts before the selected one, so the selection has + // moved down a line; the view must stay where the user left it though + SelectedLineIdx(2). + OriginY(4) + }, +}) diff --git a/pkg/integration/tests/ui/drag_beyond_viewport.go b/pkg/integration/tests/ui/drag_beyond_viewport.go new file mode 100644 index 000000000..f756a783c --- /dev/null +++ b/pkg/integration/tests/ui/drag_beyond_viewport.go @@ -0,0 +1,37 @@ +package ui + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DragBeyondViewport = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Dragging a range selection beyond the bottom of the panel doesn't scroll the view", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial commit") + for i := range 20 { + shell.CreateFile(fmt.Sprintf("file%02d", i), "") + } + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Focus(). + OriginY(0). + // The pointer ends up below the panel, so the range extends to a line + // that isn't visible. Scrolling there is the drag autoscroller's job, + // which scrolls line by line for as long as the pointer stays there; + // the drag itself must leave the scroll position alone. + ClickAndHold(1, 1). + MouseMove(1, 8). + MouseRelease(). + SelectedLineIdx(8). + OriginY(0) + }, +}) diff --git a/pkg/integration/tests/ui/filtering_scrolls_selection_into_view.go b/pkg/integration/tests/ui/filtering_scrolls_selection_into_view.go new file mode 100644 index 000000000..3f1ab4d84 --- /dev/null +++ b/pkg/integration/tests/ui/filtering_scrolls_selection_into_view.go @@ -0,0 +1,62 @@ +package ui + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FilteringScrollsSelectionIntoView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Entering and leaving filtering mode scrolls the selected commit into view", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + for i := range 40 { + file := "otherFile" + if i%2 == 0 { + file = "filterFile" + } + shell.UpdateFileAndAdd(file, fmt.Sprintf("content %02d", i)) + shell.Commit(fmt.Sprintf("commit %02d", i)) + } + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit 00")). + OriginYAtLeast(1). + Press(keys.Universal.FilteringMenu) + + t.ExpectPopup().Menu(). + Title(Equals("Filtering")). + Select(Contains("Enter path to filter by")). + Confirm() + t.ExpectPopup().Prompt(). + Title(Equals("Enter path:")). + Type("filterFile"). + Confirm() + + // The filtered list has nothing to do with the one that was showing, so + // its scroll position doesn't either: we start at the top again + t.Views().Commits(). + IsFocused(). + SelectedLine(Contains("commit 38")). + SelectedLineIdx(0). + OriginY(0). + Press(keys.Universal.GotoBottom). + SelectedLine(Contains("commit 00")). + PressEscape() + + // Leaving filtering mode keeps the commit selected, at its position in + // the full list, which needs scrolling to again + t.Views().Commits(). + IsFocused(). + SelectedLine(Contains("commit 00")). + SelectedLineIsVisible() + }, +}) diff --git a/pkg/integration/tests/ui/find_base_commit_for_fixup_scrolls_into_view.go b/pkg/integration/tests/ui/find_base_commit_for_fixup_scrolls_into_view.go new file mode 100644 index 000000000..b0c63dcf5 --- /dev/null +++ b/pkg/integration/tests/ui/find_base_commit_for_fixup_scrolls_into_view.go @@ -0,0 +1,35 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FindBaseCommitForFixupScrollsIntoView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Finding the base commit for a fixup scrolls it into view", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch"). + EmptyCommit("1st commit"). + CreateFileAndAdd("file1", "line 1\nline 2\nline 3\n"). + Commit("base commit"). + CreateNCommits(40). + UpdateFile("file1", "line 1\nline 2 changed\nline 3\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Focus(). + Press(keys.Files.FindBaseCommitForFixup) + + // The base commit is at the very bottom of the list, far below the + // visible area + t.Views().Commits(). + IsFocused(). + SelectedLine(Contains("base commit")). + SelectedLineIsVisible() + }, +}) diff --git a/pkg/integration/tests/ui/menu_scroll_position_is_reset.go b/pkg/integration/tests/ui/menu_scroll_position_is_reset.go new file mode 100644 index 000000000..448e0b995 --- /dev/null +++ b/pkg/integration/tests/ui/menu_scroll_position_is_reset.go @@ -0,0 +1,39 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var MenuScrollPositionIsReset = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A menu that is opened after a scrolled down one starts at the top again", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFile("myfile", "myfile") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Press(keys.Universal.OptionMenu) + + t.Views().Menu(). + IsFocused(). + // The first line is a section header, so the first item is at index 1 + SelectedLineIdx(1). + OriginY(0). + Press(keys.Universal.GotoBottom). + OriginYAtLeast(1). + PressEscape() + + t.Views().Files(). + IsFocused(). + Press(keys.Universal.OptionMenu) + + t.Views().Menu(). + IsFocused(). + SelectedLineIdx(1). + OriginY(0) + }, +}) diff --git a/pkg/integration/tests/ui/move_commit_scrolls_selection_into_view.go b/pkg/integration/tests/ui/move_commit_scrolls_selection_into_view.go new file mode 100644 index 000000000..a665b548d --- /dev/null +++ b/pkg/integration/tests/ui/move_commit_scrolls_selection_into_view.go @@ -0,0 +1,31 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var MoveCommitScrollsSelectionIntoView = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Moving a commit down scrolls it into view if it isn't visible", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(40) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + SelectedLine(Contains("commit-40")). + // Scroll the selected commit out of view with the mouse wheel + ScrollWheelDown(). + ScrollWheelDown(). + OriginY(4). + Press(keys.Commits.MoveDownCommit). + SelectedLine(Contains("commit-40")). + SelectedLineIdx(1). + SelectedLineIsVisible() + }, +}) diff --git a/pkg/integration/tests/ui/page_up_and_down.go b/pkg/integration/tests/ui/page_up_and_down.go new file mode 100644 index 000000000..603edfd92 --- /dev/null +++ b/pkg/integration/tests/ui/page_up_and_down.go @@ -0,0 +1,47 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +const ( + // The height of the commits panel in this test's window, in lines. + commitsPanelHeight = 5 + // Paging keeps one line of overlap between the old and the new page. + pageDelta = commitsPanelHeight - 1 +) + +var PageUpAndDown = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Paging down and up keeps the selection at the edge of the viewport", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(40) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + SelectedLineIdx(0). + OriginY(0). + Press(keys.Universal.NextPage). + // The selection moves to the bottom of the viewport; nothing scrolls yet + SelectedLineIdx(commitsPanelHeight - 1). + OriginY(0). + Press(keys.Universal.NextPage). + // Now the view scrolls by a page, and the selection stays at the bottom + SelectedLineIdx(commitsPanelHeight - 1 + pageDelta). + OriginY(pageDelta). + Press(keys.Universal.PrevPage). + // The selection moves to the top of the viewport; nothing scrolls + SelectedLineIdx(pageDelta). + OriginY(pageDelta). + Press(keys.Universal.PrevPage). + // And back a page, with the selection staying at the top + SelectedLineIdx(0). + OriginY(0) + }, +}) diff --git a/pkg/integration/tests/ui/range_select.go b/pkg/integration/tests/ui/range_select.go index b021ea65d..4c5d8420a 100644 --- a/pkg/integration/tests/ui/range_select.go +++ b/pkg/integration/tests/ui/range_select.go @@ -33,6 +33,7 @@ var RangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Skip: false, SetupConfig: func(config *config.AppConfig) { config.GetUserConfig().Gui.UseHunkModeInStagingView = false + config.GetUserConfig().Gui.ExpandFocusedSidePanel = true }, SetupRepo: func(shell *Shell) { // We're testing the commits view as our representative list context, @@ -51,6 +52,7 @@ var RangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ } shell.CreateFileAndAdd("file1", "staged\n") shell.UpdateFile("file1", fileContent) + shell.NewBranch("branch1").NewBranch("branch2") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { assertRangeSelectBehaviour := func(v *ViewDriver, focusOtherView func(), lineIdxOfFirstItem int) { @@ -179,5 +181,46 @@ var RangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ PressEnter() assertRangeSelectBehaviour(t.Views().Staging().IsFocused(), func() { t.Views().Staging().PressTab() }, 6) + + t.Views().Branches().Focus() + t.Views().Branches(). + SelectedLines( + Contains("branch2"), + ) + t.Views().Commits(). + ClickAndHold(1, 3). + MouseMoveToView(t.Views().Branches(), 1, 2). + SelectedLines( + Contains("line 1"), + Contains("line 2"), + Contains("line 3"), + Contains("line 4"), + ). + Tap(func() { + t.Views().Branches().SelectedLines( + Contains("branch2"), + ) + }). + MouseRelease() + + t.Views().Branches().Focus() + t.Views().Commits(). + ClickAndHold(1, 0). + SelectedLines( + Contains("line 1"), + ). + RepeatMouseMove(). + SelectedLines( + Contains("line 1"), + ). + MouseMove(1, 3). + SelectedLines( + Contains("line 1"), + Contains("line 2"), + Contains("line 3"), + Contains("line 4"), + ). + MouseRelease(). + Click(1, 0) }, }) diff --git a/pkg/integration/tests/ui/range_select_with_autoscroll.go b/pkg/integration/tests/ui/range_select_with_autoscroll.go new file mode 100644 index 000000000..94a4fb4a5 --- /dev/null +++ b/pkg/integration/tests/ui/range_select_with_autoscroll.go @@ -0,0 +1,47 @@ +package ui + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RangeSelectWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Keep scrolling while creating a range selection at the panel edge", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInStagingView = false + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(40) + fileContent := "base\n" + shell.CreateFileAndAdd("file1", fileContent) + for i := 1; i <= 40; i++ { + fileContent += fmt.Sprintf("line %d\n", i) + } + shell.UpdateFile("file1", fileContent) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches().Focus() + t.Views().Commits(). + ClickAndHold(1, 0). + MouseMoveToBottom(1). + OriginYAtLeast(3). + SelectedLineIdxAtLeast(3). + MouseRelease() + + t.Views().Files(). + Focus(). + PressEnter() + t.Views().Staging(). + ClickAndHold(1, 6). + MouseMoveToBottom(1). + OriginYAtLeast(3). + SelectedLineIdxAtLeast(9). + MouseRelease() + }, +}) diff --git a/pkg/integration/tests/ui/sub_commits_scroll_position_is_reset.go b/pkg/integration/tests/ui/sub_commits_scroll_position_is_reset.go new file mode 100644 index 000000000..1de8acb70 --- /dev/null +++ b/pkg/integration/tests/ui/sub_commits_scroll_position_is_reset.go @@ -0,0 +1,39 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SubCommitsScrollPositionIsReset = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Viewing the commits of a branch again after scrolling down starts at the top again", + ExtraCmdArgs: []string{}, + Skip: false, + Width: 120, + Height: 30, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(40) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + OriginY(0). + Press(keys.Universal.GotoBottom). + OriginYAtLeast(1). + PressEscape() + + t.Views().Branches(). + IsFocused(). + PressEnter() + + t.Views().SubCommits(). + IsFocused(). + SelectedLineIdx(0). + OriginY(0) + }, +}) diff --git a/pkg/integration/tests/worktree/separate_work_tree_config.go b/pkg/integration/tests/worktree/separate_work_tree_config.go new file mode 100644 index 000000000..e6c036b20 --- /dev/null +++ b/pkg/integration/tests/worktree/separate_work_tree_config.go @@ -0,0 +1,70 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// This case is like bare_repo_worktree_config.go, except that lazygit isn't +// told where the git dir is: it is started in the directory containing it, and +// finds it the way git does. The work tree is somewhere else entirely, so git +// can't find its way back from there, and every command we run has to be told +// where the repo is. + +var SeparateWorkTreeConfig = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Open lazygit in the git dir of a repo whose work tree is elsewhere, and add a file and commit", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + // we're going to have a directory structure like this: + // project + // - repo (holds the .git dir, and nothing else; lazygit starts here) + // - worktree (holds the files) + // + // 'repo' is the repository/directory that all lazygit tests start in + + shell.CreateFileAndAdd("blah", "original content\n") + shell.Commit("initial commit") + + // point the repo at a work tree outside of it (core.worktree is + // relative to the .git dir), and fill that work tree from HEAD + shell.CreateDir("../worktree") + shell.SetConfig("core.worktree", "../../worktree") + shell.RunCommand([]string{"git", "reset", "--hard"}) + + // the copy of the file we committed from is not in the work tree, so + // git no longer knows anything about it + shell.DeleteFile("blah") + + shell.UpdateFile("../worktree/blah", "updated content\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Lines( + Contains("initial commit"), + ) + + t.Views().Files(). + IsFocused(). + Lines( + Contains(" M blah"), // shows as modified + ). + PressPrimaryAction(). + Press(keys.Files.CommitChanges) + + t.ExpectPopup().CommitMessagePanel(). + Title(Equals("Commit summary")). + Type("Add blah"). + Confirm() + + t.Views().Files(). + IsEmpty() + + t.Views().Commits(). + Lines( + Contains("Add blah"), + Contains("initial commit"), + ) + }, +}) diff --git a/pkg/integration/tests/worktree/worktree_inside_repo.go b/pkg/integration/tests/worktree/worktree_inside_repo.go new file mode 100644 index 000000000..bae2ff8f1 --- /dev/null +++ b/pkg/integration/tests/worktree/worktree_inside_repo.go @@ -0,0 +1,28 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var WorktreeInsideRepo = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A worktree that lives inside the repo's working tree is shown as a single item in the files panel", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.NerdFontsVersion = "3" + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.AddWorktree("mybranch", "nested-worktree", "newbranch") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? 󰌹 nested-worktree").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index ea76c45be..db9068ad9 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -28,6 +28,15 @@ type GuiDriver interface { // user typing faster than lazygit processes the input. PressKeysRapidly(...string) Click(int, int) + ClickAndHold(int, int) + MouseMove(int, int) + MouseRelease(int, int) + ScrollWheelDown(int, int) + // Perform the refresh that a background routine would perform on a timer + RefreshInBackground() + // Can be used to avoid data races with the UI thread in the uncommon cases that + // the test driver needs to assert state while the gui is not idle. + OnUIThreadAndWait(func()) // Simulate the terminal window regaining focus (which triggers a reload of // changed config files) FocusIn() diff --git a/pkg/jsonschema/generate.go b/pkg/jsonschema/generate.go index dc5045025..cf7596761 100644 --- a/pkg/jsonschema/generate.go +++ b/pkg/jsonschema/generate.go @@ -144,7 +144,7 @@ func setDefaultVals(rootSchema, schema *jsonschema.Schema, defaults any) { t := reflect.TypeOf(defaults) v := reflect.ValueOf(defaults) - if t.Kind() == reflect.Ptr || t.Kind() == reflect.Interface { + if t.Kind() == reflect.Pointer || t.Kind() == reflect.Interface { t = t.Elem() v = v.Elem() } @@ -202,7 +202,7 @@ func isZeroValue(v any) bool { switch rv.Kind() { case reflect.Slice, reflect.Map: return rv.Len() == 0 - case reflect.Ptr, reflect.Interface: + case reflect.Pointer, reflect.Interface: return rv.IsNil() case reflect.Struct: for i := range rv.NumField() { diff --git a/pkg/logs/logs.go b/pkg/logs/logs.go index 7ec1b91b4..40fc207c5 100644 --- a/pkg/logs/logs.go +++ b/pkg/logs/logs.go @@ -4,6 +4,8 @@ import ( "io" "log" "os" + "sync" + "time" "github.com/sirupsen/logrus" ) @@ -34,6 +36,11 @@ func NewProductionLogger() *logrus.Entry { return formatted(logger) } +// Separates one run's log entries from the previous run's. Only the first +// logger of a run writes it: with LAZYGIT_LOG_PATH set there are two of them +// for the same file, the global one and the app's. +var runSeparator sync.Once + func NewDevelopmentLogger(logPath string) *logrus.Entry { logger := logrus.New() logger.SetLevel(getLogLevel()) @@ -42,6 +49,9 @@ func NewDevelopmentLogger(logPath string) *logrus.Entry { if err != nil { log.Fatalf("Unable to log to log file: %v", err) } + runSeparator.Do(func() { + _, _ = file.WriteString("\n") + }) logger.SetOutput(file) return formatted(logger) } @@ -49,7 +59,7 @@ func NewDevelopmentLogger(logPath string) *logrus.Entry { func formatted(log *logrus.Logger) *logrus.Entry { // highly recommended: tail -f development.log | humanlog // https://github.com/aybabtme/humanlog - log.Formatter = &logrus.JSONFormatter{} + log.Formatter = &logrus.JSONFormatter{TimestampFormat: time.RFC3339Nano} return log.WithFields(logrus.Fields{}) } diff --git a/pkg/logs/tail/tail.go b/pkg/logs/tail/tail.go index b21bc21e4..1cc5ef05e 100644 --- a/pkg/logs/tail/tail.go +++ b/pkg/logs/tail/tail.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "os" + "time" "github.com/aybabtme/humanlog" ) @@ -15,6 +16,7 @@ func TailLogs(logFilePath string) { opts := humanlog.DefaultOptions opts.Truncates = false + opts.TimeFormat = time.StampMilli _, err := os.Stat(logFilePath) if err != nil { diff --git a/pkg/snake/snake.go b/pkg/snake/snake.go index 62fc0ddfd..7dd2e079c 100644 --- a/pkg/snake/snake.go +++ b/pkg/snake/snake.go @@ -20,7 +20,7 @@ type Game struct { exit chan (struct{}) // channel for specifying the direction the player wants the snake to go in - setNewDir chan (Direction) + setNewDir chan Direction // allows logging for debugging logger func(string) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 3a964c838..17cfabb5f 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -6,6 +6,7 @@ import ( "io" "os" "os/exec" + "strconv" "sync" "sync/atomic" "time" @@ -24,7 +25,9 @@ import ( type Cmd interface { Wait() error String() string - GetProcess() *os.Process + // Terminate makes the process stop early, as gracefully as the platform + // allows. It doesn't wait for the process to exit. + Terminate() error } // ExecCmd adapts *exec.Cmd to Cmd. @@ -32,8 +35,11 @@ type ExecCmd struct { *exec.Cmd } -func (c ExecCmd) GetProcess() *os.Process { - return c.Process +// Terminate sends SIGTERM on Unix. On Windows it does nothing, so a stopped +// command keeps running until it next writes to its (by then closed) output +// pipe. +func (c ExecCmd) Terminate() error { + return oscommands.TerminateProcessGracefully(c.Process) } // This file revolves around running commands that will be output to the main panel @@ -57,22 +63,59 @@ type ViewBufferManager struct { writer io.Writer waitingMutex deadlock.Mutex - taskIDMutex deadlock.Mutex - Log *logrus.Entry - newTaskID int + // Guards newTaskID and taskKey, which identify the most recently requested + // task. Both are written on the goroutine NewTask spawns, and taskKey is + // read from the UI thread (GetTaskKey), so neither may be touched without + // holding this. + taskIDMutex deadlock.Mutex + Log *logrus.Entry + newTaskID int // The channel by which the currently-running task is told to read more // lines (e.g. as the user scrolls). Held in an atomic because it's swapped // out as tasks come and go while ReadLines/ReadToEnd read it from the UI // thread; nil when no task is running. readLines atomic.Pointer[chan LinesToRead] taskKey string - onNewKey func() + + // Resets the view's scroll position to the top. A render whose content is + // different from what the view last showed (a different command key) calls + // this — but at its *first paint*, not when the task starts: the off-screen + // render leaves the previous content displayed until the swap, so resetting + // the origin up front would scroll that still-displayed content to the top + // before the new content replaces it. See newContentPending. + resetOrigin func() + + // Whether the content the running task is rendering differs from what the + // view is currently showing (i.e. the command key changed). Two things key + // off it: the loading indicator only takes the view over when it is set, + // since there is no point clearing content we are about to render + // identically; and the first paint that reveals the content resets the + // scroll to the top and clears it. + // + // It deliberately outlives the task that set it: a task can be stopped and + // replaced before it ever paints — a background refresh landing just after + // the user clicked a different item, say — and the replacement, which + // renders the same content and so sets nothing of its own, still has to do + // what that task was owed. + newContentPending atomic.Bool + + // Whether a command task is currently reading content into the view. While + // this is true the content is still growing, so callers (e.g. the layout) + // must not clamp the view's scroll position to the amount loaded so far. + loading atomic.Bool // beforeStart is the function that is called before starting a new task beforeStart func() refreshView func() onEndOfInput func() + // beginRender starts an off-screen render: the new content is built without + // disturbing what's displayed. swapInRender then promotes it to the display + // in one step. Together they keep the view showing the previous render until + // the new one has read enough to paint, instead of revealing it line by line. + beginRender func() + swapInRender func() + // see docs/dev/Busy.md // A gocui task is not the same thing as the tasks defined in this file. // A gocui task simply represents the fact that lazygit is busy doing something, @@ -83,7 +126,7 @@ type ViewBufferManager struct { // of the view happen through this, so that the view is only ever touched on // the UI thread (where it is also laid out and drawn), never on the task's // own goroutine. - onUIThread func(f func() error) error + onUIThread func(f func()) error // if the user flicks through a heap of items, with each one // spawning a process to render something to the main view, @@ -111,6 +154,9 @@ type LinesToRead struct { } func (self *ViewBufferManager) GetTaskKey() string { + self.taskIDMutex.Lock() + defer self.taskIDMutex.Unlock() + return self.taskKey } @@ -120,9 +166,11 @@ func NewViewBufferManager( beforeStart func(), refreshView func(), onEndOfInput func(), - onNewKey func(), + resetOrigin func(), + beginRender func(), + swapInRender func(), newGocuiTask func() gocui.Task, - onUIThread func(f func() error) error, + onUIThread func(f func()) error, ) *ViewBufferManager { return &ViewBufferManager{ Log: log, @@ -130,7 +178,9 @@ func NewViewBufferManager( beforeStart: beforeStart, refreshView: refreshView, onEndOfInput: onEndOfInput, - onNewKey: onNewKey, + resetOrigin: resetOrigin, + beginRender: beginRender, + swapInRender: swapInRender, newGocuiTask: newGocuiTask, onUIThread: onUIThread, } @@ -149,6 +199,21 @@ func (self *ViewBufferManager) ReadLines(totalLines int) { } } +// IsLoading reports whether a command task is currently reading content into the +// view, meaning the content is still growing. +func (self *ViewBufferManager) IsLoading() bool { + return self.loading.Load() +} + +// StartLoading marks the view as loading content. It must be called +// synchronously when a command/pty task is started, before the task's goroutine +// runs, so that a layout pass happening in between doesn't clamp the scroll +// position to the not-yet-loaded content. It is cleared when the task reaches +// the end of its input. +func (self *ViewBufferManager) StartLoading() { + self.loading.Store(true) +} + func (self *ViewBufferManager) ReadToEnd(then func()) { if ch := self.readLines.Load(); ch != nil { readLines := *ch @@ -213,10 +278,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix // when flicking through several very long diffs when diff.algorithm = histogram is // being used, in which case multiple git processes continue to calculate expensive // diffs in the background even though they have been stopped already. - // - // Unfortunately this will do nothing on Windows, so Windows users will have to live - // with the higher CPU usage. - if err := oscommands.TerminateProcessGracefully(cmd.GetProcess()); err != nil { + if err := cmd.Terminate(); err != nil { self.Log.Errorf("error when trying to terminate cmd task: %v; Command: %v", err, cmd.String()) } @@ -270,8 +332,21 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix return case <-ticker.C: loadingMutex.Lock() - if !loaded { + // Only take the view over to say "loading..." when the content coming + // is different from what's on screen. A re-render of the same content + // leaves the view showing exactly what it should already, so clearing + // it for the message and then rendering the same thing back is a + // visible flicker for nothing — and a slow re-render of unchanged + // content is common (a background refresh over a repo with submodules + // that have uncommitted changes, say). The pending flag isn't consumed + // here; the first paint still owes the scroll reset. + if !loaded && self.newContentPending.Load() { self.beforeStart() + // beforeStart cleared the previous content to show "loading...", so + // put the view back at the top for it (beforeStart doesn't touch the + // origin). The origin is view state the UI thread reads while laying + // out, so write it there. + _ = self.onUIThread(self.resetOrigin) _, _ = self.writer.Write([]byte("loading...")) self.refreshView() } @@ -296,8 +371,9 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix // closed the selects below could still service a ready data channel // instead of bailing. Check stop explicitly first to give it priority: // a task that's been stopped (it's being replaced by a newer one) must - // not touch the view here — beforeStart clears it and the prefix gets - // written, clobbering what the incoming task is about to render. + // not touch the view here — it would start an off-screen render and + // write the prefix into it, clobbering what the incoming task is about + // to render. stopped := func() bool { select { case <-opts.Stop: @@ -312,6 +388,36 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix // this to work out how many more lines, if any, we still need to read. linesRead := 0 + // The first paint swaps the off-screen render in to reveal the new + // content, and settles the scroll position in the same step — so the new + // content first appears already where it belongs, and no draw can land + // between the two and show it at the previous render's scroll. It happens + // once, either when we've read far enough (below) or at end of input for + // content shorter than that. Callers run it on the UI thread: it writes + // the view's origin. + painted := false + firstPaint := func() { + if painted { + return + } + painted = true + self.swapInRender() + if self.newContentPending.Swap(false) { + self.resetOrigin() + } + } + + // Set LAZYGIT_SLOW_RENDER= to sleep that long after each + // line is written to the view, stretching async loads out so the frames + // of a re-render become visible. Useful for debugging scroll/flicker + // behaviour; has no effect when the variable is unset. + var slowRenderPerLine time.Duration + if v := os.Getenv("LAZYGIT_SLOW_RENDER"); v != "" { + if ms, err := strconv.Atoi(v); err == nil { + slowRenderPerLine = time.Duration(ms) * time.Millisecond + } + } + outer: for { if stopped() { @@ -343,7 +449,10 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix loadingMutex.Lock() if !loaded { - self.beforeStart() + // Build the new content off-screen, leaving the previous render + // displayed until we swap in below; this is what keeps an async + // re-render from showing a half-loaded buffer. + self.beginRender() if prefix != "" { writeToView([]byte(prefix)) } @@ -352,26 +461,68 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix loadingMutex.Unlock() if !ok { - // if we're here then there's nothing left to scan from the source - // so we're at the EOF and can flush the stale content. - // onEndOfInput reads the view's dimensions (to decide - // whether to scroll) and sets the origin, both of which - // are UI-thread-only, so run it there. - _ = self.onUIThread(func() error { + // lineChan is closed. At a genuine end of input we swap in what we + // read and finalize. But lineChan is also closed when this task has + // been stopped to make way for a newer one: stopping closes + // opts.Stop, and the scanner goroutine then closes lineChan, so the + // select above can land here instead of on the opts.Stop case. A + // stopped task is being replaced and must leave the view to the + // incoming task — swapping in its half-read buffer, clamping the + // origin, or clearing `loading` would all corrupt what that task is + // about to render. So bail out here, the same as the explicit stop + // case above. + select { + case <-opts.Stop: + callThen() + break outer + default: + } + // Genuine end of input: do the first paint now if it hasn't happened + // yet (the content was shorter than a screenful, so we never reached + // the point below), and flush the stale content. onEndOfInput reads + // the view's dimensions (to decide whether to scroll) and sets the + // origin, both of which are UI-thread-only, so run it there — as is + // firstPaint, which also writes the origin. + _ = self.onUIThread(func() { + firstPaint() self.onEndOfInput() - return nil }) + // The content is fully loaded now, so it's safe again for the + // layout to clamp the scroll position to it. We deliberately + // don't clear this when stopped (rather than EOF'd), because that + // means a newer task is taking over and is still loading. + self.loading.Store(false) callThen() + // Any read requests that were queued while we were reading are + // now trivially satisfied, since we've read everything. Fire + // their callbacks instead of dropping them when we break out of + // the loop below (and nil out readLines). + drain: + for { + select { + case queued := <-readLines: + if queued.Then != nil { + queued.Then() + } + default: + break drain + } + } break outer } writeToView(append(line, '\n')) lineWrittenChan <- struct{}{} linesRead++ + if slowRenderPerLine > 0 { + time.Sleep(slowRenderPerLine) + } + if linesRead == linesToRead.InitialRefreshAfter { - // We have read enough lines to fill the view, so do a first refresh - // here to show what we have. Continue reading and refresh again at - // the end to make sure the scrollbar has the right size. + // We have read enough lines to fill the view, so do the first paint + // and refresh to show it. Continue reading and refresh again at the + // end to make sure the scrollbar has the right size. + _ = self.onUIThread(firstPaint) refreshViewIfStale() } } @@ -490,23 +641,19 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error return } - resetOrigin := self.GetTaskKey() != key && self.onNewKey != nil + // Note we don't reset the origin here even when the command key changed: + // that's deferred to the first paint that reveals the new content (see + // newContentPending), so the previous content — left displayed until the + // swap — doesn't visibly jump to the top before the new content appears. + // Read taskKey directly: we already hold the mutex that guards it, and + // GetTaskKey would take it again. + if self.taskKey != key && self.resetOrigin != nil { + self.newContentPending.Store(true) + } self.taskKey = key self.taskIDMutex.Unlock() - if resetOrigin { - // onNewKey resets the view's scroll origin, which is view state the - // UI thread reads while laying out and drawing, so do it there. This - // must happen after releasing taskIDMutex: it blocks until the UI - // thread runs it, and a NewTask call on the UI thread takes - // taskIDMutex, so holding it here would deadlock. - _ = self.onUIThread(func() error { - self.onNewKey() - return nil - }) - } - self.waitingMutex.Lock() // Re-check staleness after acquiring waitingMutex: a newer task diff --git a/pkg/tasks/tasks_test.go b/pkg/tasks/tasks_test.go index 2cea139e8..b15b48ee4 100644 --- a/pkg/tasks/tasks_test.go +++ b/pkg/tasks/tasks_test.go @@ -7,11 +7,13 @@ import ( "reflect" "strings" "sync" + "sync/atomic" "testing" "time" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stretchr/testify/assert" ) func getCounter() (func(), func() int) { @@ -24,7 +26,9 @@ func TestNewCmdTaskInstantStop(t *testing.T) { beforeStart, getBeforeStartCallCount := getCounter() refreshView, getRefreshViewCallCount := getCounter() onEndOfInput, getOnEndOfInputCallCount := getCounter() - onNewKey, getOnNewKeyCallCount := getCounter() + resetOrigin, getResetOriginCallCount := getCounter() + beginRender, getBeginRenderCallCount := getCounter() + swapInRender, getSwapInRenderCallCount := getCounter() onDone, getOnDoneCallCount := getCounter() task := gocui.NewFakeTask() newTask := func() gocui.Task { @@ -37,10 +41,12 @@ func TestNewCmdTaskInstantStop(t *testing.T) { beforeStart, refreshView, onEndOfInput, - onNewKey, + resetOrigin, + beginRender, + swapInRender, newTask, // no UI thread in the test; run the view mutations inline - func(f func() error) error { return f() }, + func(f func()) error { f(); return nil }, ) stop := make(chan struct{}) @@ -66,7 +72,9 @@ func TestNewCmdTaskInstantStop(t *testing.T) { {0, getBeforeStartCallCount(), "beforeStart"}, {1, getRefreshViewCallCount(), "refreshView"}, {0, getOnEndOfInputCallCount(), "onEndOfInput"}, - {0, getOnNewKeyCallCount(), "onNewKey"}, + {0, getResetOriginCallCount(), "resetOrigin"}, + {0, getBeginRenderCallCount(), "beginRender"}, + {0, getSwapInRenderCallCount(), "swapInRender"}, {1, getOnDoneCallCount(), "onDone"}, } for _, expectation := range callCountExpectations { @@ -91,7 +99,9 @@ func TestNewCmdTask(t *testing.T) { beforeStart, getBeforeStartCallCount := getCounter() refreshView, getRefreshViewCallCount := getCounter() onEndOfInput, getOnEndOfInputCallCount := getCounter() - onNewKey, getOnNewKeyCallCount := getCounter() + resetOrigin, getResetOriginCallCount := getCounter() + beginRender, getBeginRenderCallCount := getCounter() + swapInRender, getSwapInRenderCallCount := getCounter() onDone, getOnDoneCallCount := getCounter() task := gocui.NewFakeTask() newTask := func() gocui.Task { @@ -104,10 +114,12 @@ func TestNewCmdTask(t *testing.T) { beforeStart, refreshView, onEndOfInput, - onNewKey, + resetOrigin, + beginRender, + swapInRender, newTask, // no UI thread in the test; run the view mutations inline - func(f func() error) error { return f() }, + func(f func()) error { f(); return nil }, ) stop := make(chan struct{}) @@ -134,10 +146,12 @@ func TestNewCmdTask(t *testing.T) { actual int name string }{ - {1, getBeforeStartCallCount(), "beforeStart"}, + {0, getBeforeStartCallCount(), "beforeStart"}, {1, getRefreshViewCallCount(), "refreshView"}, {1, getOnEndOfInputCallCount(), "onEndOfInput"}, - {0, getOnNewKeyCallCount(), "onNewKey"}, + {0, getResetOriginCallCount(), "resetOrigin"}, + {1, getBeginRenderCallCount(), "beginRender"}, + {1, getSwapInRenderCallCount(), "swapInRender"}, {1, getOnDoneCallCount(), "onDone"}, } for _, expectation := range callCountExpectations { @@ -174,6 +188,206 @@ func (d *BlankLineReader) Read(p []byte) (n int, err error) { return 1, nil } +// A dummy reader that yields the given number of blank lines and then blocks +// until unblock is closed, at which point it reports EOF. This lets a test hold +// a task in its "still loading" state for as long as it needs to. +type BlockingLineReader struct { + linesToYield int + linesYielded int + reachedEnd bool + blocked chan struct{} + unblock chan struct{} +} + +func (d *BlockingLineReader) Read(p []byte) (n int, err error) { + if d.linesYielded == d.linesToYield { + if !d.reachedEnd { + d.reachedEnd = true + close(d.blocked) + } + <-d.unblock + return 0, io.EOF + } + + d.linesYielded++ + p[0] = '\n' + return 1, nil +} + +func TestNewCmdTaskQueuedReadAtEndOfInput(t *testing.T) { + writer := bytes.NewBuffer(nil) + task := gocui.NewFakeTask() + + manager := NewViewBufferManager( + utils.NewDummyLog(), + writer, + func() {}, + func() {}, + func() {}, + func() {}, + func() {}, + func() {}, + func() gocui.Task { return task }, + // no UI thread in the test; run the view mutations inline + func(f func()) error { f(); return nil }, + ) + + reader := BlockingLineReader{ + linesToYield: 5, + blocked: make(chan struct{}), + unblock: make(chan struct{}), + } + start := func() (Cmd, io.Reader) { + // not actually starting this because it's not necessary + return ExecCmd{Cmd: exec.Command("blah")}, &reader + } + + // The initial request asks for far more lines than the reader has, so the + // task reaches EOF while that request is still the one being served. + fn := manager.NewCmdTask(start, "", LinesToRead{100, -1, nil}, func() {}) + + thenCalled := false + wg := sync.WaitGroup{} + wg.Go(func() { + _ = fn(TaskOpts{Stop: make(chan struct{}), InitialContentLoaded: func() { task.Done() }}) + }) + + <-reader.blocked + manager.ReadToEnd(func() { thenCalled = true }) + // ReadToEnd queues its request from a goroutine; wait for it to land so that + // it is definitely outstanding by the time we let the task reach EOF. + for len(*manager.readLines.Load()) == 0 { + time.Sleep(time.Millisecond) + } + close(reader.unblock) + + wg.Wait() + + assert.True(t, thenCalled) +} + +// A task rendering content the view wasn't already showing resets the scroll +// position to the top, at its first paint. If it is stopped and replaced before +// it ever paints — a background refresh landing just after the user clicked a +// different item, say — the replacement renders the same content and so decides +// on no reset of its own; it has to perform the one the stopped task was owed, +// or the view keeps the scroll position of the content it showed before. +func TestResetOriginSurvivesTaskReplacement(t *testing.T) { + resetOrigin, getResetOriginCallCount := getCounter() + + manager := NewViewBufferManager( + utils.NewDummyLog(), + bytes.NewBuffer(nil), + func() {}, + func() {}, + func() {}, + resetOrigin, + func() {}, + func() {}, + func() gocui.Task { return gocui.NewFakeTask() }, + // no UI thread in the test; run the view mutations inline + func(f func()) error { f(); return nil }, + ) + + startTask := func(key string, reader io.Reader, onDone func()) { + start := func() (Cmd, io.Reader) { + // not actually starting this because it's not necessary + return ExecCmd{Cmd: exec.Command("blah")}, reader + } + // The first-paint point is far beyond what any of these readers yield, so + // only reaching EOF paints. + _ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, onDone), key) + } + runTaskToCompletion := func(key string) { + done := make(chan struct{}) + startTask(key, &BlankLineReader{totalLinesToYield: 3}, func() { close(done) }) + <-done + } + + // A render of content the view wasn't showing resets the scroll position. + runTaskToCompletion("cmd1") + assert.Equal(t, 1, getResetOriginCallCount()) + + // Different content again, but this task stalls before it can paint. + stalled := BlockingLineReader{ + linesToYield: 3, + blocked: make(chan struct{}), + unblock: make(chan struct{}), + } + defer close(stalled.unblock) + startTask("cmd2", &stalled, nil) + <-stalled.blocked + + // The replacement shows the same content as the stalled task, so it has no + // reset of its own to do — but it must still do that task's. + runTaskToCompletion("cmd2") + assert.Equal(t, 2, getResetOriginCallCount()) +} + +// A render that takes long enough to start takes the view over to say +// "loading...", which means blanking whatever it was showing. That is only worth +// doing when the content coming is different from what's on screen: re-rendering +// the same content (a background refresh, say) would otherwise blank the view and +// paint the same thing back, a visible flicker for nothing. +func TestLoadingIndicatorOnlyTakesOverForNewContent(t *testing.T) { + var beforeStartCount atomic.Int32 + + manager := NewViewBufferManager( + utils.NewDummyLog(), + io.Discard, + func() { beforeStartCount.Add(1) }, + func() {}, + func() {}, + func() {}, + func() {}, + func() {}, + func() gocui.Task { return gocui.NewFakeTask() }, + // no UI thread in the test; run the view mutations inline + func(f func()) error { f(); return nil }, + ) + + startTask := func(key string, reader io.Reader, onDone func()) { + start := func() (Cmd, io.Reader) { + // not actually starting this because it's not necessary + return ExecCmd{Cmd: exec.Command("blah")}, reader + } + _ = manager.NewTask(manager.NewCmdTask(start, "", LinesToRead{100, 50, nil}, onDone), key) + } + // Starts a task whose command produces nothing at all, so that it is still + // waiting for its first line when the loading indicator falls due. Returns + // the reader so the caller can let it finish. + startStalledTask := func(key string) *BlockingLineReader { + reader := &BlockingLineReader{ + blocked: make(chan struct{}), + unblock: make(chan struct{}), + } + startTask(key, reader, nil) + <-reader.blocked + return reader + } + + // Get some content on screen first: the indicator is only due when a render + // is slow, and this one isn't. + done := make(chan struct{}) + startTask("cmd1", &BlankLineReader{totalLinesToYield: 3}, func() { close(done) }) + <-done + assert.EqualValues(t, 0, beforeStartCount.Load()) + + // A slow re-render of that same content must leave the view alone however + // long it takes. The indicator is due 200ms in, so give it well past that. + sameContent := startStalledTask("cmd1") + defer close(sameContent.unblock) + time.Sleep(500 * time.Millisecond) + assert.EqualValues(t, 0, beforeStartCount.Load()) + + // Different content, though, is worth taking the view over for. + newContent := startStalledTask("cmd2") + defer close(newContent.unblock) + assert.Eventually(t, + func() bool { return beforeStartCount.Load() == 1 }, + 2*time.Second, 10*time.Millisecond) +} + func TestNewCmdTaskRefresh(t *testing.T) { type scenario struct { name string @@ -240,9 +454,11 @@ func TestNewCmdTaskRefresh(t *testing.T) { refreshView, func() {}, func() {}, + func() {}, + func() {}, newTask, // no UI thread in the test; run the view mutations inline - func(f func() error) error { return f() }, + func(f func()) error { f(); return nil }, ) stop := make(chan struct{}) diff --git a/pkg/utils/rebase_todo.go b/pkg/utils/rebase_todo.go index fe04cbc60..3a3577f80 100644 --- a/pkg/utils/rebase_todo.go +++ b/pkg/utils/rebase_todo.go @@ -144,27 +144,40 @@ func deleteTodos(todos []todo.Todo, todosToDelete []Todo) ([]todo.Todo, error) { } func MoveTodosDown(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error { + return MoveTodos(fileName, todosToMove, isInRebase, 1, commentChar) +} + +func MoveTodosUp(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error { + return MoveTodos(fileName, todosToMove, isInRebase, -1, commentChar) +} + +func MoveTodos(fileName string, todosToMove []Todo, isInRebase bool, offset int, commentChar byte) error { todos, err := ReadRebaseTodoFile(fileName, commentChar) if err != nil { return err } - rearrangedTodos, err := moveTodosDown(todos, todosToMove, isInRebase) + rearrangedTodos, err := moveTodos(todos, todosToMove, isInRebase, offset) if err != nil { return err } return WriteRebaseTodoFile(fileName, rearrangedTodos, commentChar) } -func MoveTodosUp(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error { - todos, err := ReadRebaseTodoFile(fileName, commentChar) - if err != nil { - return err +func moveTodos(todos []todo.Todo, todosToMove []Todo, isInRebase bool, offset int) ([]todo.Todo, error) { + moveOneRow := moveTodosUp + if offset > 0 { + moveOneRow = moveTodosDown } - rearrangedTodos, err := moveTodosUp(todos, todosToMove, isInRebase) - if err != nil { - return err + + for range max(offset, -offset) { + var err error + todos, err = moveOneRow(todos, slices.Clone(todosToMove), isInRebase) + if err != nil { + return nil, err + } } - return WriteRebaseTodoFile(fileName, rearrangedTodos, commentChar) + + return todos, nil } func moveTodoDown(todos []todo.Todo, todoToMove Todo, isInRebase bool) ([]todo.Todo, error) { diff --git a/pkg/utils/rebase_todo_test.go b/pkg/utils/rebase_todo_test.go index 9daf7db01..a9ac1bba5 100644 --- a/pkg/utils/rebase_todo_test.go +++ b/pkg/utils/rebase_todo_test.go @@ -3,12 +3,55 @@ package utils import ( "errors" "fmt" + "slices" "testing" "github.com/stefanhaller/git-todo-parser/todo" "github.com/stretchr/testify/assert" ) +func TestMoveTodos(t *testing.T) { + todos := []todo.Todo{ + {Command: todo.Pick, Commit: "a"}, + {Command: todo.Pick, Commit: "b"}, + {Command: todo.Label, Label: "hidden"}, + {Command: todo.Pick, Commit: "c"}, + {Command: todo.Pick, Commit: "d"}, + {Command: todo.Pick, Commit: "e"}, + {Command: todo.Pick, Commit: "f"}, + } + + t.Run("moves a range up multiple rendered rows", func(t *testing.T) { + actual, err := moveTodos(slices.Clone(todos), []Todo{{Hash: "d"}, {Hash: "c"}}, false, -2) + + assert.NoError(t, err) + assert.Equal(t, []todo.Todo{ + {Command: todo.Pick, Commit: "a"}, + {Command: todo.Pick, Commit: "b"}, + {Command: todo.Label, Label: "hidden"}, + {Command: todo.Pick, Commit: "e"}, + {Command: todo.Pick, Commit: "f"}, + {Command: todo.Pick, Commit: "c"}, + {Command: todo.Pick, Commit: "d"}, + }, actual) + }) + + t.Run("moves a range down multiple rendered rows", func(t *testing.T) { + actual, err := moveTodos(slices.Clone(todos), []Todo{{Hash: "e"}, {Hash: "d"}}, false, 2) + + assert.NoError(t, err) + assert.Equal(t, []todo.Todo{ + {Command: todo.Pick, Commit: "a"}, + {Command: todo.Pick, Commit: "d"}, + {Command: todo.Pick, Commit: "e"}, + {Command: todo.Pick, Commit: "b"}, + {Command: todo.Label, Label: "hidden"}, + {Command: todo.Pick, Commit: "c"}, + {Command: todo.Pick, Commit: "f"}, + }, actual) + }) +} + func TestRebaseCommands_moveTodoDown(t *testing.T) { type scenario struct { testName string diff --git a/pkg/utils/stack.go b/pkg/utils/stack.go new file mode 100644 index 000000000..9cac1f563 --- /dev/null +++ b/pkg/utils/stack.go @@ -0,0 +1,28 @@ +package utils + +type Stack[T any] struct { + stack []T +} + +func (self *Stack[T]) Push(item T) { + self.stack = append(self.stack, item) +} + +func (self *Stack[T]) Pop() T { + if len(self.stack) == 0 { + var zero T + return zero + } + n := len(self.stack) - 1 + last := self.stack[n] + self.stack = self.stack[:n] + return last +} + +func (self *Stack[T]) IsEmpty() bool { + return len(self.stack) == 0 +} + +func (self *Stack[T]) Clear() { + self.stack = nil +} diff --git a/pkg/utils/string_stack.go b/pkg/utils/string_stack.go deleted file mode 100644 index c2d18c70c..000000000 --- a/pkg/utils/string_stack.go +++ /dev/null @@ -1,27 +0,0 @@ -package utils - -type StringStack struct { - stack []string -} - -func (self *StringStack) Push(s string) { - self.stack = append(self.stack, s) -} - -func (self *StringStack) Pop() string { - if len(self.stack) == 0 { - return "" - } - n := len(self.stack) - 1 - last := self.stack[n] - self.stack = self.stack[:n] - return last -} - -func (self *StringStack) IsEmpty() bool { - return len(self.stack) == 0 -} - -func (self *StringStack) Clear() { - self.stack = []string{} -} diff --git a/pkg/utils/yaml_utils/yaml_utils.go b/pkg/utils/yaml_utils/yaml_utils.go index 251d4dc01..c7a72515b 100644 --- a/pkg/utils/yaml_utils/yaml_utils.go +++ b/pkg/utils/yaml_utils/yaml_utils.go @@ -32,6 +32,23 @@ func RemoveKey(node *yaml.Node, key string) (*yaml.Node, *yaml.Node) { return nil, nil } +// Adds a string field to the given object. Caution: doesn't check for duplicate +// keys, that's the caller's responsibility +func AddStringKey(mappingNode *yaml.Node, key string, value string) { + keyNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: key, + } + valueNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: value, + } + + mappingNode.Content = append(mappingNode.Content, keyNode, valueNode) +} + // Walks a yaml document from the root node to the specified path, and then applies the transformation to that node. // If the requested path is not defined in the document, no changes are made to the document. func TransformNode(rootNode *yaml.Node, path []string, transform func(node *yaml.Node) error) error { diff --git a/schema-master/config.json b/schema-master/config.json index 82dbebb0b..45a2b9efe 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -314,14 +314,58 @@ "type": "object", "description": "Custom icons for filenames and file extensions\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color" }, - "GitConfig": { + "DiffRendererConfig": { "properties": { - "pagers": { + "type": { + "type": "string", + "enum": [ + "stdinFilter", + "extDiff", + "rawGit" + ], + "description": "The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff' | 'rawGit'" + }, + "name": { + "type": "string", + "description": "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." + }, + "colorArg": { + "type": "string", + "enum": [ + "always", + "never" + ], + "description": "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'." + }, + "command": { + "type": "string", + "description": "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'.\ne.g.\ndiff-so-fancy\ndelta --dark --paging=never\nydiff -p cat\ndifft --color=always", + "examples": [ + "delta --dark --paging=never", + "diff-so-fancy", + "ydiff -p cat", + "difft --color=always" + ] + }, + "args": { "items": { - "$ref": "#/$defs/PagingConfig" + "type": "string" }, "type": "array", - "description": "Array of pagers. Each entry has the following format:\n\n # A name for the pager, shown in the notification when cycling pagers.\n # If not set, the name is derived from the first word of the pager\n # command (or of the external diff command).\n name: \"\"\n\n # Value of the --color arg in the git diff command. Some pagers want\n # this to be set to 'always' and some want it set to 'never'\n colorArg: \"always\"\n\n # e.g.\n # diff-so-fancy\n # delta --dark --paging=never\n # ydiff -p cat -s --wrap --width={{columnWidth}}\n pager: \"\"\n\n # e.g. 'difft --color=always'\n externalDiffCommand: \"\"\n\n # If true, Lazygit will use git's `diff.external` config for paging.\n # The advantage over `externalDiffCommand` is that this can be\n # configured per file type in .gitattributes; see\n # https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.\n useExternalDiffGitConfig: false\n\n'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' are mutually exclusive; set at most one per entry.\n\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md for more information." + "description": "Extra arguments (array of strings) passed to the git command. Only applicable if the type is 'rawGit'." + } + }, + "additionalProperties": false, + "type": "object" + }, + "GitConfig": { + "properties": { + "diffRenderers": { + "items": { + "$ref": "#/$defs/DiffRendererConfig" + }, + "type": "array", + "description": "Array of diff renderers. Each entry has the following format:\n\n # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff'\n # | 'rawGit'\n type: \"stdinFilter\"\n\n # A name for the diff renderer, shown in the notification when cycling\n # renderers. If not set, the name is derived from the first word of the\n # renderer command.\n name: \"\"\n\n # Value of the --color arg in the git diff command. Only used for type\n # 'stdinFilter'. Some renderers want this to be set to 'always' and some\n # want it set to 'never'.\n colorArg: \"always\"\n\n # The command to use for rendering diffs. This is either a stdinFilter or\n # an external diff command, depending on the type field; not applicable if\n # the type is 'rawGit'.\n # e.g.\n # diff-so-fancy\n # delta --dark --paging=never\n # ydiff -p cat\n # difft --color=always\n command: \"\"\n\n # Extra arguments (array of strings) passed to the git command. Only\n # applicable if the type is 'rawGit'.\n args: []\n\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md for more information." }, "commit": { "$ref": "#/$defs/CommitConfig", @@ -535,7 +579,7 @@ "tabWidth": { "type": "integer", "minimum": 1, - "description": "The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs.\nNote that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command.", + "description": "The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs.\nNote 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.", "default": 4 }, "mouseEvents": { @@ -3163,7 +3207,7 @@ ], "default": "_" }, - "cyclePagers": { + "cycleDiffRenderers": { "oneOf": [ { "type": "string" @@ -3177,7 +3221,7 @@ ], "default": "|" }, - "cyclePagersReverse": { + "cycleDiffRenderersReverse": { "oneOf": [ { "type": "string" @@ -3544,41 +3588,6 @@ "type": "object", "description": "Config relating to things outside of Lazygit like how files are opened, copying to clipboard, etc" }, - "PagingConfig": { - "properties": { - "name": { - "type": "string", - "description": "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)." - }, - "colorArg": { - "type": "string", - "enum": [ - "always", - "never" - ], - "description": "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'" - }, - "pager": { - "type": "string", - "description": "e.g.\ndiff-so-fancy\ndelta --dark --paging=never\nydiff -p cat -s --wrap --width={{columnWidth}}", - "examples": [ - "delta --dark --paging=never", - "diff-so-fancy", - "ydiff -p cat -s --wrap --width={{columnWidth}}" - ] - }, - "externalDiffCommand": { - "type": "string", - "description": "e.g. 'difft --color=always'" - }, - "useExternalDiffGitConfig": { - "type": "boolean", - "description": "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." - } - }, - "additionalProperties": false, - "type": "object" - }, "RefresherConfig": { "properties": { "refreshInterval": { @@ -3631,17 +3640,17 @@ "type": "array", "description": "The frames of the spinner animation.", "default": [ - "|", - "/", - "-", - "\\" + "●∙∙", + "∙●∙", + "∙∙●", + "∙●∙" ] }, "rate": { "type": "integer", "minimum": 1, "description": "The \"speed\" of the spinner in milliseconds.", - "default": 50 + "default": 180 } }, "additionalProperties": false, diff --git a/schema/config.json b/schema/config.json index 82dbebb0b..45a2b9efe 100644 --- a/schema/config.json +++ b/schema/config.json @@ -314,14 +314,58 @@ "type": "object", "description": "Custom icons for filenames and file extensions\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-files-icon--color" }, - "GitConfig": { + "DiffRendererConfig": { "properties": { - "pagers": { + "type": { + "type": "string", + "enum": [ + "stdinFilter", + "extDiff", + "rawGit" + ], + "description": "The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff' | 'rawGit'" + }, + "name": { + "type": "string", + "description": "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." + }, + "colorArg": { + "type": "string", + "enum": [ + "always", + "never" + ], + "description": "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'." + }, + "command": { + "type": "string", + "description": "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'.\ne.g.\ndiff-so-fancy\ndelta --dark --paging=never\nydiff -p cat\ndifft --color=always", + "examples": [ + "delta --dark --paging=never", + "diff-so-fancy", + "ydiff -p cat", + "difft --color=always" + ] + }, + "args": { "items": { - "$ref": "#/$defs/PagingConfig" + "type": "string" }, "type": "array", - "description": "Array of pagers. Each entry has the following format:\n\n # A name for the pager, shown in the notification when cycling pagers.\n # If not set, the name is derived from the first word of the pager\n # command (or of the external diff command).\n name: \"\"\n\n # Value of the --color arg in the git diff command. Some pagers want\n # this to be set to 'always' and some want it set to 'never'\n colorArg: \"always\"\n\n # e.g.\n # diff-so-fancy\n # delta --dark --paging=never\n # ydiff -p cat -s --wrap --width={{columnWidth}}\n pager: \"\"\n\n # e.g. 'difft --color=always'\n externalDiffCommand: \"\"\n\n # If true, Lazygit will use git's `diff.external` config for paging.\n # The advantage over `externalDiffCommand` is that this can be\n # configured per file type in .gitattributes; see\n # https://git-scm.com/docs/gitattributes#_defining_an_external_diff_driver.\n useExternalDiffGitConfig: false\n\n'pager', 'externalDiffCommand', and 'useExternalDiffGitConfig' are mutually exclusive; set at most one per entry.\n\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md for more information." + "description": "Extra arguments (array of strings) passed to the git command. Only applicable if the type is 'rawGit'." + } + }, + "additionalProperties": false, + "type": "object" + }, + "GitConfig": { + "properties": { + "diffRenderers": { + "items": { + "$ref": "#/$defs/DiffRendererConfig" + }, + "type": "array", + "description": "Array of diff renderers. Each entry has the following format:\n\n # The type of diff renderer. One of: 'stdinFilter' (default) | 'extDiff'\n # | 'rawGit'\n type: \"stdinFilter\"\n\n # A name for the diff renderer, shown in the notification when cycling\n # renderers. If not set, the name is derived from the first word of the\n # renderer command.\n name: \"\"\n\n # Value of the --color arg in the git diff command. Only used for type\n # 'stdinFilter'. Some renderers want this to be set to 'always' and some\n # want it set to 'never'.\n colorArg: \"always\"\n\n # The command to use for rendering diffs. This is either a stdinFilter or\n # an external diff command, depending on the type field; not applicable if\n # the type is 'rawGit'.\n # e.g.\n # diff-so-fancy\n # delta --dark --paging=never\n # ydiff -p cat\n # difft --color=always\n command: \"\"\n\n # Extra arguments (array of strings) passed to the git command. Only\n # applicable if the type is 'rawGit'.\n args: []\n\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_DiffRenderers.md for more information." }, "commit": { "$ref": "#/$defs/CommitConfig", @@ -535,7 +579,7 @@ "tabWidth": { "type": "integer", "minimum": 1, - "description": "The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs.\nNote that when using a pager, the pager has its own tab width setting, so you need to pass it separately in the pager command.", + "description": "The number of spaces per tab; used for everything that's shown in the main view, but probably mostly relevant for diffs.\nNote 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.", "default": 4 }, "mouseEvents": { @@ -3163,7 +3207,7 @@ ], "default": "_" }, - "cyclePagers": { + "cycleDiffRenderers": { "oneOf": [ { "type": "string" @@ -3177,7 +3221,7 @@ ], "default": "|" }, - "cyclePagersReverse": { + "cycleDiffRenderersReverse": { "oneOf": [ { "type": "string" @@ -3544,41 +3588,6 @@ "type": "object", "description": "Config relating to things outside of Lazygit like how files are opened, copying to clipboard, etc" }, - "PagingConfig": { - "properties": { - "name": { - "type": "string", - "description": "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)." - }, - "colorArg": { - "type": "string", - "enum": [ - "always", - "never" - ], - "description": "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'" - }, - "pager": { - "type": "string", - "description": "e.g.\ndiff-so-fancy\ndelta --dark --paging=never\nydiff -p cat -s --wrap --width={{columnWidth}}", - "examples": [ - "delta --dark --paging=never", - "diff-so-fancy", - "ydiff -p cat -s --wrap --width={{columnWidth}}" - ] - }, - "externalDiffCommand": { - "type": "string", - "description": "e.g. 'difft --color=always'" - }, - "useExternalDiffGitConfig": { - "type": "boolean", - "description": "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." - } - }, - "additionalProperties": false, - "type": "object" - }, "RefresherConfig": { "properties": { "refreshInterval": { @@ -3631,17 +3640,17 @@ "type": "array", "description": "The frames of the spinner animation.", "default": [ - "|", - "/", - "-", - "\\" + "●∙∙", + "∙●∙", + "∙∙●", + "∙●∙" ] }, "rate": { "type": "integer", "minimum": 1, "description": "The \"speed\" of the spinner in milliseconds.", - "default": 50 + "default": 180 } }, "additionalProperties": false, diff --git a/scripts/golangci-lint-shim.sh b/scripts/golangci-lint-shim.sh index a85ccc4d7..6cb3e007c 100755 --- a/scripts/golangci-lint-shim.sh +++ b/scripts/golangci-lint-shim.sh @@ -3,6 +3,6 @@ set -e # Must be kept in sync with the version in .github/workflows/ci.yml -version="v2.4.0" +version="v2.12.2" go run "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$version" "$@" diff --git a/scripts/run_integration_tests.sh b/scripts/run_integration_tests.sh index 2bf010f19..0c659ebfa 100755 --- a/scripts/run_integration_tests.sh +++ b/scripts/run_integration_tests.sh @@ -2,16 +2,6 @@ echo "Running integration tests with $(git --version)" -# This is ugly, but older versions of git don't support the GIT_CONFIG_GLOBAL -# env var; the only way to run tests for these old versions is to copy our test -# config file to the actual global location. Move an existing file out of the -# way so that we can restore it at the end. -if test -f ~/.gitconfig; then - mv ~/.gitconfig ~/.gitconfig.lazygit.bak -fi - -cp test/global_git_config ~/.gitconfig - # if the LAZYGIT_GOCOVERDIR env var is set, we'll capture code coverage data if [ -n "$LAZYGIT_GOCOVERDIR" ]; then # Go expects us to either be running the test binary directly or running `go test`, but because @@ -33,10 +23,6 @@ else EXITCODE=$? fi -if test -f ~/.gitconfig.lazygit.bak; then - mv ~/.gitconfig.lazygit.bak ~/.gitconfig -fi - # If per-test timings were collected (LAZYGIT_TEST_TIMING points at the file the # harness appends to), print them sorted by slowest first so they show up in the # CI log. diff --git a/test/global_git_config b/test/global_git_config index f4f47c003..b83ea57b4 100644 --- a/test/global_git_config +++ b/test/global_git_config @@ -8,3 +8,12 @@ allow = always [commit] gpgSign = false +[maintenance] + # Every `git commit` forks `git maintenance run --auto --detach`. Since git + # 2.54 that repacks as soon as two objects share the objects/17 fanout + # directory, which happens readily in a fixture repo, and `git repack -d` + # prunes loose objects while the next fixture command -- or lazygit itself -- + # is still working in the same repo. That surfaces as + # "error: invalid object for 'file09.txt'" / "Error building trees". + # Tests must never race a background repack. + auto = false diff --git a/vendor/github.com/google/go-cmp/LICENSE b/vendor/github.com/google/go-cmp/LICENSE deleted file mode 100644 index 32017f8fa..000000000 --- a/vendor/github.com/google/go-cmp/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2017 The Go Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/google/go-cmp/cmp/compare.go b/vendor/github.com/google/go-cmp/cmp/compare.go deleted file mode 100644 index 0f5b8a48c..000000000 --- a/vendor/github.com/google/go-cmp/cmp/compare.go +++ /dev/null @@ -1,671 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// Package cmp determines equality of values. -// -// This package is intended to be a more powerful and safer alternative to -// [reflect.DeepEqual] for comparing whether two values are semantically equal. -// It is intended to only be used in tests, as performance is not a goal and -// it may panic if it cannot compare the values. Its propensity towards -// panicking means that its unsuitable for production environments where a -// spurious panic may be fatal. -// -// The primary features of cmp are: -// -// - When the default behavior of equality does not suit the test's needs, -// custom equality functions can override the equality operation. -// For example, an equality function may report floats as equal so long as -// they are within some tolerance of each other. -// -// - Types with an Equal method (e.g., [time.Time.Equal]) may use that method -// to determine equality. This allows package authors to determine -// the equality operation for the types that they define. -// -// - If no custom equality functions are used and no Equal method is defined, -// equality is determined by recursively comparing the primitive kinds on -// both values, much like [reflect.DeepEqual]. Unlike [reflect.DeepEqual], -// unexported fields are not compared by default; they result in panics -// unless suppressed by using an [Ignore] option -// (see [github.com/google/go-cmp/cmp/cmpopts.IgnoreUnexported]) -// or explicitly compared using the [Exporter] option. -package cmp - -import ( - "fmt" - "reflect" - "strings" - - "github.com/google/go-cmp/cmp/internal/diff" - "github.com/google/go-cmp/cmp/internal/function" - "github.com/google/go-cmp/cmp/internal/value" -) - -// TODO(≥go1.18): Use any instead of interface{}. - -// Equal reports whether x and y are equal by recursively applying the -// following rules in the given order to x and y and all of their sub-values: -// -// - Let S be the set of all [Ignore], [Transformer], and [Comparer] options that -// remain after applying all path filters, value filters, and type filters. -// If at least one [Ignore] exists in S, then the comparison is ignored. -// If the number of [Transformer] and [Comparer] options in S is non-zero, -// then Equal panics because it is ambiguous which option to use. -// If S contains a single [Transformer], then use that to transform -// the current values and recursively call Equal on the output values. -// If S contains a single [Comparer], then use that to compare the current values. -// Otherwise, evaluation proceeds to the next rule. -// -// - If the values have an Equal method of the form "(T) Equal(T) bool" or -// "(T) Equal(I) bool" where T is assignable to I, then use the result of -// x.Equal(y) even if x or y is nil. Otherwise, no such method exists and -// evaluation proceeds to the next rule. -// -// - Lastly, try to compare x and y based on their basic kinds. -// Simple kinds like booleans, integers, floats, complex numbers, strings, -// and channels are compared using the equivalent of the == operator in Go. -// Functions are only equal if they are both nil, otherwise they are unequal. -// -// Structs are equal if recursively calling Equal on all fields report equal. -// If a struct contains unexported fields, Equal panics unless an [Ignore] option -// (e.g., [github.com/google/go-cmp/cmp/cmpopts.IgnoreUnexported]) ignores that field -// or the [Exporter] option explicitly permits comparing the unexported field. -// -// Slices are equal if they are both nil or both non-nil, where recursively -// calling Equal on all non-ignored slice or array elements report equal. -// Empty non-nil slices and nil slices are not equal; to equate empty slices, -// consider using [github.com/google/go-cmp/cmp/cmpopts.EquateEmpty]. -// -// Maps are equal if they are both nil or both non-nil, where recursively -// calling Equal on all non-ignored map entries report equal. -// Map keys are equal according to the == operator. -// To use custom comparisons for map keys, consider using -// [github.com/google/go-cmp/cmp/cmpopts.SortMaps]. -// Empty non-nil maps and nil maps are not equal; to equate empty maps, -// consider using [github.com/google/go-cmp/cmp/cmpopts.EquateEmpty]. -// -// Pointers and interfaces are equal if they are both nil or both non-nil, -// where they have the same underlying concrete type and recursively -// calling Equal on the underlying values reports equal. -// -// Before recursing into a pointer, slice element, or map, the current path -// is checked to detect whether the address has already been visited. -// If there is a cycle, then the pointed at values are considered equal -// only if both addresses were previously visited in the same path step. -func Equal(x, y interface{}, opts ...Option) bool { - s := newState(opts) - s.compareAny(rootStep(x, y)) - return s.result.Equal() -} - -// Diff returns a human-readable report of the differences between two values: -// y - x. It returns an empty string if and only if Equal returns true for the -// same input values and options. -// -// The output is displayed as a literal in pseudo-Go syntax. -// At the start of each line, a "-" prefix indicates an element removed from x, -// a "+" prefix to indicates an element added from y, and the lack of a prefix -// indicates an element common to both x and y. If possible, the output -// uses fmt.Stringer.String or error.Error methods to produce more humanly -// readable outputs. In such cases, the string is prefixed with either an -// 's' or 'e' character, respectively, to indicate that the method was called. -// -// Do not depend on this output being stable. If you need the ability to -// programmatically interpret the difference, consider using a custom Reporter. -func Diff(x, y interface{}, opts ...Option) string { - s := newState(opts) - - // Optimization: If there are no other reporters, we can optimize for the - // common case where the result is equal (and thus no reported difference). - // This avoids the expensive construction of a difference tree. - if len(s.reporters) == 0 { - s.compareAny(rootStep(x, y)) - if s.result.Equal() { - return "" - } - s.result = diff.Result{} // Reset results - } - - r := new(defaultReporter) - s.reporters = append(s.reporters, reporter{r}) - s.compareAny(rootStep(x, y)) - d := r.String() - if (d == "") != s.result.Equal() { - panic("inconsistent difference and equality results") - } - return d -} - -// rootStep constructs the first path step. If x and y have differing types, -// then they are stored within an empty interface type. -func rootStep(x, y interface{}) PathStep { - vx := reflect.ValueOf(x) - vy := reflect.ValueOf(y) - - // If the inputs are different types, auto-wrap them in an empty interface - // so that they have the same parent type. - var t reflect.Type - if !vx.IsValid() || !vy.IsValid() || vx.Type() != vy.Type() { - t = anyType - if vx.IsValid() { - vvx := reflect.New(t).Elem() - vvx.Set(vx) - vx = vvx - } - if vy.IsValid() { - vvy := reflect.New(t).Elem() - vvy.Set(vy) - vy = vvy - } - } else { - t = vx.Type() - } - - return &pathStep{t, vx, vy} -} - -type state struct { - // These fields represent the "comparison state". - // Calling statelessCompare must not result in observable changes to these. - result diff.Result // The current result of comparison - curPath Path // The current path in the value tree - curPtrs pointerPath // The current set of visited pointers - reporters []reporter // Optional reporters - - // recChecker checks for infinite cycles applying the same set of - // transformers upon the output of itself. - recChecker recChecker - - // dynChecker triggers pseudo-random checks for option correctness. - // It is safe for statelessCompare to mutate this value. - dynChecker dynChecker - - // These fields, once set by processOption, will not change. - exporters []exporter // List of exporters for structs with unexported fields - opts Options // List of all fundamental and filter options -} - -func newState(opts []Option) *state { - // Always ensure a validator option exists to validate the inputs. - s := &state{opts: Options{validator{}}} - s.curPtrs.Init() - s.processOption(Options(opts)) - return s -} - -func (s *state) processOption(opt Option) { - switch opt := opt.(type) { - case nil: - case Options: - for _, o := range opt { - s.processOption(o) - } - case coreOption: - type filtered interface { - isFiltered() bool - } - if fopt, ok := opt.(filtered); ok && !fopt.isFiltered() { - panic(fmt.Sprintf("cannot use an unfiltered option: %v", opt)) - } - s.opts = append(s.opts, opt) - case exporter: - s.exporters = append(s.exporters, opt) - case reporter: - s.reporters = append(s.reporters, opt) - default: - panic(fmt.Sprintf("unknown option %T", opt)) - } -} - -// statelessCompare compares two values and returns the result. -// This function is stateless in that it does not alter the current result, -// or output to any registered reporters. -func (s *state) statelessCompare(step PathStep) diff.Result { - // We do not save and restore curPath and curPtrs because all of the - // compareX methods should properly push and pop from them. - // It is an implementation bug if the contents of the paths differ from - // when calling this function to when returning from it. - - oldResult, oldReporters := s.result, s.reporters - s.result = diff.Result{} // Reset result - s.reporters = nil // Remove reporters to avoid spurious printouts - s.compareAny(step) - res := s.result - s.result, s.reporters = oldResult, oldReporters - return res -} - -func (s *state) compareAny(step PathStep) { - // Update the path stack. - s.curPath.push(step) - defer s.curPath.pop() - for _, r := range s.reporters { - r.PushStep(step) - defer r.PopStep() - } - s.recChecker.Check(s.curPath) - - // Cycle-detection for slice elements (see NOTE in compareSlice). - t := step.Type() - vx, vy := step.Values() - if si, ok := step.(SliceIndex); ok && si.isSlice && vx.IsValid() && vy.IsValid() { - px, py := vx.Addr(), vy.Addr() - if eq, visited := s.curPtrs.Push(px, py); visited { - s.report(eq, reportByCycle) - return - } - defer s.curPtrs.Pop(px, py) - } - - // Rule 1: Check whether an option applies on this node in the value tree. - if s.tryOptions(t, vx, vy) { - return - } - - // Rule 2: Check whether the type has a valid Equal method. - if s.tryMethod(t, vx, vy) { - return - } - - // Rule 3: Compare based on the underlying kind. - switch t.Kind() { - case reflect.Bool: - s.report(vx.Bool() == vy.Bool(), 0) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - s.report(vx.Int() == vy.Int(), 0) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: - s.report(vx.Uint() == vy.Uint(), 0) - case reflect.Float32, reflect.Float64: - s.report(vx.Float() == vy.Float(), 0) - case reflect.Complex64, reflect.Complex128: - s.report(vx.Complex() == vy.Complex(), 0) - case reflect.String: - s.report(vx.String() == vy.String(), 0) - case reflect.Chan, reflect.UnsafePointer: - s.report(vx.Pointer() == vy.Pointer(), 0) - case reflect.Func: - s.report(vx.IsNil() && vy.IsNil(), 0) - case reflect.Struct: - s.compareStruct(t, vx, vy) - case reflect.Slice, reflect.Array: - s.compareSlice(t, vx, vy) - case reflect.Map: - s.compareMap(t, vx, vy) - case reflect.Ptr: - s.comparePtr(t, vx, vy) - case reflect.Interface: - s.compareInterface(t, vx, vy) - default: - panic(fmt.Sprintf("%v kind not handled", t.Kind())) - } -} - -func (s *state) tryOptions(t reflect.Type, vx, vy reflect.Value) bool { - // Evaluate all filters and apply the remaining options. - if opt := s.opts.filter(s, t, vx, vy); opt != nil { - opt.apply(s, vx, vy) - return true - } - return false -} - -func (s *state) tryMethod(t reflect.Type, vx, vy reflect.Value) bool { - // Check if this type even has an Equal method. - m, ok := t.MethodByName("Equal") - if !ok || !function.IsType(m.Type, function.EqualAssignable) { - return false - } - - eq := s.callTTBFunc(m.Func, vx, vy) - s.report(eq, reportByMethod) - return true -} - -func (s *state) callTRFunc(f, v reflect.Value, step Transform) reflect.Value { - if !s.dynChecker.Next() { - return f.Call([]reflect.Value{v})[0] - } - - // Run the function twice and ensure that we get the same results back. - // We run in goroutines so that the race detector (if enabled) can detect - // unsafe mutations to the input. - c := make(chan reflect.Value) - go detectRaces(c, f, v) - got := <-c - want := f.Call([]reflect.Value{v})[0] - if step.vx, step.vy = got, want; !s.statelessCompare(step).Equal() { - // To avoid false-positives with non-reflexive equality operations, - // we sanity check whether a value is equal to itself. - if step.vx, step.vy = want, want; !s.statelessCompare(step).Equal() { - return want - } - panic(fmt.Sprintf("non-deterministic function detected: %s", function.NameOf(f))) - } - return want -} - -func (s *state) callTTBFunc(f, x, y reflect.Value) bool { - if !s.dynChecker.Next() { - return f.Call([]reflect.Value{x, y})[0].Bool() - } - - // Swapping the input arguments is sufficient to check that - // f is symmetric and deterministic. - // We run in goroutines so that the race detector (if enabled) can detect - // unsafe mutations to the input. - c := make(chan reflect.Value) - go detectRaces(c, f, y, x) - got := <-c - want := f.Call([]reflect.Value{x, y})[0].Bool() - if !got.IsValid() || got.Bool() != want { - panic(fmt.Sprintf("non-deterministic or non-symmetric function detected: %s", function.NameOf(f))) - } - return want -} - -func detectRaces(c chan<- reflect.Value, f reflect.Value, vs ...reflect.Value) { - var ret reflect.Value - defer func() { - recover() // Ignore panics, let the other call to f panic instead - c <- ret - }() - ret = f.Call(vs)[0] -} - -func (s *state) compareStruct(t reflect.Type, vx, vy reflect.Value) { - var addr bool - var vax, vay reflect.Value // Addressable versions of vx and vy - - var mayForce, mayForceInit bool - step := StructField{&structField{}} - for i := 0; i < t.NumField(); i++ { - step.typ = t.Field(i).Type - step.vx = vx.Field(i) - step.vy = vy.Field(i) - step.name = t.Field(i).Name - step.idx = i - step.unexported = !isExported(step.name) - if step.unexported { - if step.name == "_" { - continue - } - // Defer checking of unexported fields until later to give an - // Ignore a chance to ignore the field. - if !vax.IsValid() || !vay.IsValid() { - // For retrieveUnexportedField to work, the parent struct must - // be addressable. Create a new copy of the values if - // necessary to make them addressable. - addr = vx.CanAddr() || vy.CanAddr() - vax = makeAddressable(vx) - vay = makeAddressable(vy) - } - if !mayForceInit { - for _, xf := range s.exporters { - mayForce = mayForce || xf(t) - } - mayForceInit = true - } - step.mayForce = mayForce - step.paddr = addr - step.pvx = vax - step.pvy = vay - step.field = t.Field(i) - } - s.compareAny(step) - } -} - -func (s *state) compareSlice(t reflect.Type, vx, vy reflect.Value) { - isSlice := t.Kind() == reflect.Slice - if isSlice && (vx.IsNil() || vy.IsNil()) { - s.report(vx.IsNil() && vy.IsNil(), 0) - return - } - - // NOTE: It is incorrect to call curPtrs.Push on the slice header pointer - // since slices represents a list of pointers, rather than a single pointer. - // The pointer checking logic must be handled on a per-element basis - // in compareAny. - // - // A slice header (see reflect.SliceHeader) in Go is a tuple of a starting - // pointer P, a length N, and a capacity C. Supposing each slice element has - // a memory size of M, then the slice is equivalent to the list of pointers: - // [P+i*M for i in range(N)] - // - // For example, v[:0] and v[:1] are slices with the same starting pointer, - // but they are clearly different values. Using the slice pointer alone - // violates the assumption that equal pointers implies equal values. - - step := SliceIndex{&sliceIndex{pathStep: pathStep{typ: t.Elem()}, isSlice: isSlice}} - withIndexes := func(ix, iy int) SliceIndex { - if ix >= 0 { - step.vx, step.xkey = vx.Index(ix), ix - } else { - step.vx, step.xkey = reflect.Value{}, -1 - } - if iy >= 0 { - step.vy, step.ykey = vy.Index(iy), iy - } else { - step.vy, step.ykey = reflect.Value{}, -1 - } - return step - } - - // Ignore options are able to ignore missing elements in a slice. - // However, detecting these reliably requires an optimal differencing - // algorithm, for which diff.Difference is not. - // - // Instead, we first iterate through both slices to detect which elements - // would be ignored if standing alone. The index of non-discarded elements - // are stored in a separate slice, which diffing is then performed on. - var indexesX, indexesY []int - var ignoredX, ignoredY []bool - for ix := 0; ix < vx.Len(); ix++ { - ignored := s.statelessCompare(withIndexes(ix, -1)).NumDiff == 0 - if !ignored { - indexesX = append(indexesX, ix) - } - ignoredX = append(ignoredX, ignored) - } - for iy := 0; iy < vy.Len(); iy++ { - ignored := s.statelessCompare(withIndexes(-1, iy)).NumDiff == 0 - if !ignored { - indexesY = append(indexesY, iy) - } - ignoredY = append(ignoredY, ignored) - } - - // Compute an edit-script for slices vx and vy (excluding ignored elements). - edits := diff.Difference(len(indexesX), len(indexesY), func(ix, iy int) diff.Result { - return s.statelessCompare(withIndexes(indexesX[ix], indexesY[iy])) - }) - - // Replay the ignore-scripts and the edit-script. - var ix, iy int - for ix < vx.Len() || iy < vy.Len() { - var e diff.EditType - switch { - case ix < len(ignoredX) && ignoredX[ix]: - e = diff.UniqueX - case iy < len(ignoredY) && ignoredY[iy]: - e = diff.UniqueY - default: - e, edits = edits[0], edits[1:] - } - switch e { - case diff.UniqueX: - s.compareAny(withIndexes(ix, -1)) - ix++ - case diff.UniqueY: - s.compareAny(withIndexes(-1, iy)) - iy++ - default: - s.compareAny(withIndexes(ix, iy)) - ix++ - iy++ - } - } -} - -func (s *state) compareMap(t reflect.Type, vx, vy reflect.Value) { - if vx.IsNil() || vy.IsNil() { - s.report(vx.IsNil() && vy.IsNil(), 0) - return - } - - // Cycle-detection for maps. - if eq, visited := s.curPtrs.Push(vx, vy); visited { - s.report(eq, reportByCycle) - return - } - defer s.curPtrs.Pop(vx, vy) - - // We combine and sort the two map keys so that we can perform the - // comparisons in a deterministic order. - step := MapIndex{&mapIndex{pathStep: pathStep{typ: t.Elem()}}} - for _, k := range value.SortKeys(append(vx.MapKeys(), vy.MapKeys()...)) { - step.vx = vx.MapIndex(k) - step.vy = vy.MapIndex(k) - step.key = k - if !step.vx.IsValid() && !step.vy.IsValid() { - // It is possible for both vx and vy to be invalid if the - // key contained a NaN value in it. - // - // Even with the ability to retrieve NaN keys in Go 1.12, - // there still isn't a sensible way to compare the values since - // a NaN key may map to multiple unordered values. - // The most reasonable way to compare NaNs would be to compare the - // set of values. However, this is impossible to do efficiently - // since set equality is provably an O(n^2) operation given only - // an Equal function. If we had a Less function or Hash function, - // this could be done in O(n*log(n)) or O(n), respectively. - // - // Rather than adding complex logic to deal with NaNs, make it - // the user's responsibility to compare such obscure maps. - const help = "consider providing a Comparer to compare the map" - panic(fmt.Sprintf("%#v has map key with NaNs\n%s", s.curPath, help)) - } - s.compareAny(step) - } -} - -func (s *state) comparePtr(t reflect.Type, vx, vy reflect.Value) { - if vx.IsNil() || vy.IsNil() { - s.report(vx.IsNil() && vy.IsNil(), 0) - return - } - - // Cycle-detection for pointers. - if eq, visited := s.curPtrs.Push(vx, vy); visited { - s.report(eq, reportByCycle) - return - } - defer s.curPtrs.Pop(vx, vy) - - vx, vy = vx.Elem(), vy.Elem() - s.compareAny(Indirect{&indirect{pathStep{t.Elem(), vx, vy}}}) -} - -func (s *state) compareInterface(t reflect.Type, vx, vy reflect.Value) { - if vx.IsNil() || vy.IsNil() { - s.report(vx.IsNil() && vy.IsNil(), 0) - return - } - vx, vy = vx.Elem(), vy.Elem() - if vx.Type() != vy.Type() { - s.report(false, 0) - return - } - s.compareAny(TypeAssertion{&typeAssertion{pathStep{vx.Type(), vx, vy}}}) -} - -func (s *state) report(eq bool, rf resultFlags) { - if rf&reportByIgnore == 0 { - if eq { - s.result.NumSame++ - rf |= reportEqual - } else { - s.result.NumDiff++ - rf |= reportUnequal - } - } - for _, r := range s.reporters { - r.Report(Result{flags: rf}) - } -} - -// recChecker tracks the state needed to periodically perform checks that -// user provided transformers are not stuck in an infinitely recursive cycle. -type recChecker struct{ next int } - -// Check scans the Path for any recursive transformers and panics when any -// recursive transformers are detected. Note that the presence of a -// recursive Transformer does not necessarily imply an infinite cycle. -// As such, this check only activates after some minimal number of path steps. -func (rc *recChecker) Check(p Path) { - const minLen = 1 << 16 - if rc.next == 0 { - rc.next = minLen - } - if len(p) < rc.next { - return - } - rc.next <<= 1 - - // Check whether the same transformer has appeared at least twice. - var ss []string - m := map[Option]int{} - for _, ps := range p { - if t, ok := ps.(Transform); ok { - t := t.Option() - if m[t] == 1 { // Transformer was used exactly once before - tf := t.(*transformer).fnc.Type() - ss = append(ss, fmt.Sprintf("%v: %v => %v", t, tf.In(0), tf.Out(0))) - } - m[t]++ - } - } - if len(ss) > 0 { - const warning = "recursive set of Transformers detected" - const help = "consider using cmpopts.AcyclicTransformer" - set := strings.Join(ss, "\n\t") - panic(fmt.Sprintf("%s:\n\t%s\n%s", warning, set, help)) - } -} - -// dynChecker tracks the state needed to periodically perform checks that -// user provided functions are symmetric and deterministic. -// The zero value is safe for immediate use. -type dynChecker struct{ curr, next int } - -// Next increments the state and reports whether a check should be performed. -// -// Checks occur every Nth function call, where N is a triangular number: -// -// 0 1 3 6 10 15 21 28 36 45 55 66 78 91 105 120 136 153 171 190 ... -// -// See https://en.wikipedia.org/wiki/Triangular_number -// -// This sequence ensures that the cost of checks drops significantly as -// the number of functions calls grows larger. -func (dc *dynChecker) Next() bool { - ok := dc.curr == dc.next - if ok { - dc.curr = 0 - dc.next++ - } - dc.curr++ - return ok -} - -// makeAddressable returns a value that is always addressable. -// It returns the input verbatim if it is already addressable, -// otherwise it creates a new value and returns an addressable copy. -func makeAddressable(v reflect.Value) reflect.Value { - if v.CanAddr() { - return v - } - vc := reflect.New(v.Type()).Elem() - vc.Set(v) - return vc -} diff --git a/vendor/github.com/google/go-cmp/cmp/export.go b/vendor/github.com/google/go-cmp/cmp/export.go deleted file mode 100644 index 29f82fe6b..000000000 --- a/vendor/github.com/google/go-cmp/cmp/export.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "reflect" - "unsafe" -) - -// retrieveUnexportedField uses unsafe to forcibly retrieve any field from -// a struct such that the value has read-write permissions. -// -// The parent struct, v, must be addressable, while f must be a StructField -// describing the field to retrieve. If addr is false, -// then the returned value will be shallowed copied to be non-addressable. -func retrieveUnexportedField(v reflect.Value, f reflect.StructField, addr bool) reflect.Value { - ve := reflect.NewAt(f.Type, unsafe.Pointer(uintptr(unsafe.Pointer(v.UnsafeAddr()))+f.Offset)).Elem() - if !addr { - // A field is addressable if and only if the struct is addressable. - // If the original parent value was not addressable, shallow copy the - // value to make it non-addressable to avoid leaking an implementation - // detail of how forcibly exporting a field works. - if ve.Kind() == reflect.Interface && ve.IsNil() { - return reflect.Zero(f.Type) - } - return reflect.ValueOf(ve.Interface()).Convert(f.Type) - } - return ve -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go b/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go deleted file mode 100644 index 36062a604..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -//go:build !cmp_debug -// +build !cmp_debug - -package diff - -var debug debugger - -type debugger struct{} - -func (debugger) Begin(_, _ int, f EqualFunc, _, _ *EditScript) EqualFunc { - return f -} -func (debugger) Update() {} -func (debugger) Finish() {} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go b/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go deleted file mode 100644 index a3b97a1ad..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -//go:build cmp_debug -// +build cmp_debug - -package diff - -import ( - "fmt" - "strings" - "sync" - "time" -) - -// The algorithm can be seen running in real-time by enabling debugging: -// go test -tags=cmp_debug -v -// -// Example output: -// === RUN TestDifference/#34 -// ┌───────────────────────────────┐ -// │ \ · · · · · · · · · · · · · · │ -// │ · # · · · · · · · · · · · · · │ -// │ · \ · · · · · · · · · · · · · │ -// │ · · \ · · · · · · · · · · · · │ -// │ · · · X # · · · · · · · · · · │ -// │ · · · # \ · · · · · · · · · · │ -// │ · · · · · # # · · · · · · · · │ -// │ · · · · · # \ · · · · · · · · │ -// │ · · · · · · · \ · · · · · · · │ -// │ · · · · · · · · \ · · · · · · │ -// │ · · · · · · · · · \ · · · · · │ -// │ · · · · · · · · · · \ · · # · │ -// │ · · · · · · · · · · · \ # # · │ -// │ · · · · · · · · · · · # # # · │ -// │ · · · · · · · · · · # # # # · │ -// │ · · · · · · · · · # # # # # · │ -// │ · · · · · · · · · · · · · · \ │ -// └───────────────────────────────┘ -// [.Y..M.XY......YXYXY.|] -// -// The grid represents the edit-graph where the horizontal axis represents -// list X and the vertical axis represents list Y. The start of the two lists -// is the top-left, while the ends are the bottom-right. The '·' represents -// an unexplored node in the graph. The '\' indicates that the two symbols -// from list X and Y are equal. The 'X' indicates that two symbols are similar -// (but not exactly equal) to each other. The '#' indicates that the two symbols -// are different (and not similar). The algorithm traverses this graph trying to -// make the paths starting in the top-left and the bottom-right connect. -// -// The series of '.', 'X', 'Y', and 'M' characters at the bottom represents -// the currently established path from the forward and reverse searches, -// separated by a '|' character. - -const ( - updateDelay = 100 * time.Millisecond - finishDelay = 500 * time.Millisecond - ansiTerminal = true // ANSI escape codes used to move terminal cursor -) - -var debug debugger - -type debugger struct { - sync.Mutex - p1, p2 EditScript - fwdPath, revPath *EditScript - grid []byte - lines int -} - -func (dbg *debugger) Begin(nx, ny int, f EqualFunc, p1, p2 *EditScript) EqualFunc { - dbg.Lock() - dbg.fwdPath, dbg.revPath = p1, p2 - top := "┌─" + strings.Repeat("──", nx) + "┐\n" - row := "│ " + strings.Repeat("· ", nx) + "│\n" - btm := "└─" + strings.Repeat("──", nx) + "┘\n" - dbg.grid = []byte(top + strings.Repeat(row, ny) + btm) - dbg.lines = strings.Count(dbg.String(), "\n") - fmt.Print(dbg) - - // Wrap the EqualFunc so that we can intercept each result. - return func(ix, iy int) (r Result) { - cell := dbg.grid[len(top)+iy*len(row):][len("│ ")+len("· ")*ix:][:len("·")] - for i := range cell { - cell[i] = 0 // Zero out the multiple bytes of UTF-8 middle-dot - } - switch r = f(ix, iy); { - case r.Equal(): - cell[0] = '\\' - case r.Similar(): - cell[0] = 'X' - default: - cell[0] = '#' - } - return - } -} - -func (dbg *debugger) Update() { - dbg.print(updateDelay) -} - -func (dbg *debugger) Finish() { - dbg.print(finishDelay) - dbg.Unlock() -} - -func (dbg *debugger) String() string { - dbg.p1, dbg.p2 = *dbg.fwdPath, dbg.p2[:0] - for i := len(*dbg.revPath) - 1; i >= 0; i-- { - dbg.p2 = append(dbg.p2, (*dbg.revPath)[i]) - } - return fmt.Sprintf("%s[%v|%v]\n\n", dbg.grid, dbg.p1, dbg.p2) -} - -func (dbg *debugger) print(d time.Duration) { - if ansiTerminal { - fmt.Printf("\x1b[%dA", dbg.lines) // Reset terminal cursor - } - fmt.Print(dbg) - time.Sleep(d) -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go b/vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go deleted file mode 100644 index a248e5436..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go +++ /dev/null @@ -1,402 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// Package diff implements an algorithm for producing edit-scripts. -// The edit-script is a sequence of operations needed to transform one list -// of symbols into another (or vice-versa). The edits allowed are insertions, -// deletions, and modifications. The summation of all edits is called the -// Levenshtein distance as this problem is well-known in computer science. -// -// This package prioritizes performance over accuracy. That is, the run time -// is more important than obtaining a minimal Levenshtein distance. -package diff - -import ( - "math/rand" - "time" - - "github.com/google/go-cmp/cmp/internal/flags" -) - -// EditType represents a single operation within an edit-script. -type EditType uint8 - -const ( - // Identity indicates that a symbol pair is identical in both list X and Y. - Identity EditType = iota - // UniqueX indicates that a symbol only exists in X and not Y. - UniqueX - // UniqueY indicates that a symbol only exists in Y and not X. - UniqueY - // Modified indicates that a symbol pair is a modification of each other. - Modified -) - -// EditScript represents the series of differences between two lists. -type EditScript []EditType - -// String returns a human-readable string representing the edit-script where -// Identity, UniqueX, UniqueY, and Modified are represented by the -// '.', 'X', 'Y', and 'M' characters, respectively. -func (es EditScript) String() string { - b := make([]byte, len(es)) - for i, e := range es { - switch e { - case Identity: - b[i] = '.' - case UniqueX: - b[i] = 'X' - case UniqueY: - b[i] = 'Y' - case Modified: - b[i] = 'M' - default: - panic("invalid edit-type") - } - } - return string(b) -} - -// stats returns a histogram of the number of each type of edit operation. -func (es EditScript) stats() (s struct{ NI, NX, NY, NM int }) { - for _, e := range es { - switch e { - case Identity: - s.NI++ - case UniqueX: - s.NX++ - case UniqueY: - s.NY++ - case Modified: - s.NM++ - default: - panic("invalid edit-type") - } - } - return -} - -// Dist is the Levenshtein distance and is guaranteed to be 0 if and only if -// lists X and Y are equal. -func (es EditScript) Dist() int { return len(es) - es.stats().NI } - -// LenX is the length of the X list. -func (es EditScript) LenX() int { return len(es) - es.stats().NY } - -// LenY is the length of the Y list. -func (es EditScript) LenY() int { return len(es) - es.stats().NX } - -// EqualFunc reports whether the symbols at indexes ix and iy are equal. -// When called by Difference, the index is guaranteed to be within nx and ny. -type EqualFunc func(ix int, iy int) Result - -// Result is the result of comparison. -// NumSame is the number of sub-elements that are equal. -// NumDiff is the number of sub-elements that are not equal. -type Result struct{ NumSame, NumDiff int } - -// BoolResult returns a Result that is either Equal or not Equal. -func BoolResult(b bool) Result { - if b { - return Result{NumSame: 1} // Equal, Similar - } else { - return Result{NumDiff: 2} // Not Equal, not Similar - } -} - -// Equal indicates whether the symbols are equal. Two symbols are equal -// if and only if NumDiff == 0. If Equal, then they are also Similar. -func (r Result) Equal() bool { return r.NumDiff == 0 } - -// Similar indicates whether two symbols are similar and may be represented -// by using the Modified type. As a special case, we consider binary comparisons -// (i.e., those that return Result{1, 0} or Result{0, 1}) to be similar. -// -// The exact ratio of NumSame to NumDiff to determine similarity may change. -func (r Result) Similar() bool { - // Use NumSame+1 to offset NumSame so that binary comparisons are similar. - return r.NumSame+1 >= r.NumDiff -} - -var randBool = rand.New(rand.NewSource(time.Now().Unix())).Intn(2) == 0 - -// Difference reports whether two lists of lengths nx and ny are equal -// given the definition of equality provided as f. -// -// This function returns an edit-script, which is a sequence of operations -// needed to convert one list into the other. The following invariants for -// the edit-script are maintained: -// - eq == (es.Dist()==0) -// - nx == es.LenX() -// - ny == es.LenY() -// -// This algorithm is not guaranteed to be an optimal solution (i.e., one that -// produces an edit-script with a minimal Levenshtein distance). This algorithm -// favors performance over optimality. The exact output is not guaranteed to -// be stable and may change over time. -func Difference(nx, ny int, f EqualFunc) (es EditScript) { - // This algorithm is based on traversing what is known as an "edit-graph". - // See Figure 1 from "An O(ND) Difference Algorithm and Its Variations" - // by Eugene W. Myers. Since D can be as large as N itself, this is - // effectively O(N^2). Unlike the algorithm from that paper, we are not - // interested in the optimal path, but at least some "decent" path. - // - // For example, let X and Y be lists of symbols: - // X = [A B C A B B A] - // Y = [C B A B A C] - // - // The edit-graph can be drawn as the following: - // A B C A B B A - // ┌─────────────┐ - // C │_|_|\|_|_|_|_│ 0 - // B │_|\|_|_|\|\|_│ 1 - // A │\|_|_|\|_|_|\│ 2 - // B │_|\|_|_|\|\|_│ 3 - // A │\|_|_|\|_|_|\│ 4 - // C │ | |\| | | | │ 5 - // └─────────────┘ 6 - // 0 1 2 3 4 5 6 7 - // - // List X is written along the horizontal axis, while list Y is written - // along the vertical axis. At any point on this grid, if the symbol in - // list X matches the corresponding symbol in list Y, then a '\' is drawn. - // The goal of any minimal edit-script algorithm is to find a path from the - // top-left corner to the bottom-right corner, while traveling through the - // fewest horizontal or vertical edges. - // A horizontal edge is equivalent to inserting a symbol from list X. - // A vertical edge is equivalent to inserting a symbol from list Y. - // A diagonal edge is equivalent to a matching symbol between both X and Y. - - // Invariants: - // - 0 ≤ fwdPath.X ≤ (fwdFrontier.X, revFrontier.X) ≤ revPath.X ≤ nx - // - 0 ≤ fwdPath.Y ≤ (fwdFrontier.Y, revFrontier.Y) ≤ revPath.Y ≤ ny - // - // In general: - // - fwdFrontier.X < revFrontier.X - // - fwdFrontier.Y < revFrontier.Y - // - // Unless, it is time for the algorithm to terminate. - fwdPath := path{+1, point{0, 0}, make(EditScript, 0, (nx+ny)/2)} - revPath := path{-1, point{nx, ny}, make(EditScript, 0)} - fwdFrontier := fwdPath.point // Forward search frontier - revFrontier := revPath.point // Reverse search frontier - - // Search budget bounds the cost of searching for better paths. - // The longest sequence of non-matching symbols that can be tolerated is - // approximately the square-root of the search budget. - searchBudget := 4 * (nx + ny) // O(n) - - // Running the tests with the "cmp_debug" build tag prints a visualization - // of the algorithm running in real-time. This is educational for - // understanding how the algorithm works. See debug_enable.go. - f = debug.Begin(nx, ny, f, &fwdPath.es, &revPath.es) - - // The algorithm below is a greedy, meet-in-the-middle algorithm for - // computing sub-optimal edit-scripts between two lists. - // - // The algorithm is approximately as follows: - // - Searching for differences switches back-and-forth between - // a search that starts at the beginning (the top-left corner), and - // a search that starts at the end (the bottom-right corner). - // The goal of the search is connect with the search - // from the opposite corner. - // - As we search, we build a path in a greedy manner, - // where the first match seen is added to the path (this is sub-optimal, - // but provides a decent result in practice). When matches are found, - // we try the next pair of symbols in the lists and follow all matches - // as far as possible. - // - When searching for matches, we search along a diagonal going through - // through the "frontier" point. If no matches are found, - // we advance the frontier towards the opposite corner. - // - This algorithm terminates when either the X coordinates or the - // Y coordinates of the forward and reverse frontier points ever intersect. - - // This algorithm is correct even if searching only in the forward direction - // or in the reverse direction. We do both because it is commonly observed - // that two lists commonly differ because elements were added to the front - // or end of the other list. - // - // Non-deterministically start with either the forward or reverse direction - // to introduce some deliberate instability so that we have the flexibility - // to change this algorithm in the future. - if flags.Deterministic || randBool { - goto forwardSearch - } else { - goto reverseSearch - } - -forwardSearch: - { - // Forward search from the beginning. - if fwdFrontier.X >= revFrontier.X || fwdFrontier.Y >= revFrontier.Y || searchBudget == 0 { - goto finishSearch - } - for stop1, stop2, i := false, false, 0; !(stop1 && stop2) && searchBudget > 0; i++ { - // Search in a diagonal pattern for a match. - z := zigzag(i) - p := point{fwdFrontier.X + z, fwdFrontier.Y - z} - switch { - case p.X >= revPath.X || p.Y < fwdPath.Y: - stop1 = true // Hit top-right corner - case p.Y >= revPath.Y || p.X < fwdPath.X: - stop2 = true // Hit bottom-left corner - case f(p.X, p.Y).Equal(): - // Match found, so connect the path to this point. - fwdPath.connect(p, f) - fwdPath.append(Identity) - // Follow sequence of matches as far as possible. - for fwdPath.X < revPath.X && fwdPath.Y < revPath.Y { - if !f(fwdPath.X, fwdPath.Y).Equal() { - break - } - fwdPath.append(Identity) - } - fwdFrontier = fwdPath.point - stop1, stop2 = true, true - default: - searchBudget-- // Match not found - } - debug.Update() - } - // Advance the frontier towards reverse point. - if revPath.X-fwdFrontier.X >= revPath.Y-fwdFrontier.Y { - fwdFrontier.X++ - } else { - fwdFrontier.Y++ - } - goto reverseSearch - } - -reverseSearch: - { - // Reverse search from the end. - if fwdFrontier.X >= revFrontier.X || fwdFrontier.Y >= revFrontier.Y || searchBudget == 0 { - goto finishSearch - } - for stop1, stop2, i := false, false, 0; !(stop1 && stop2) && searchBudget > 0; i++ { - // Search in a diagonal pattern for a match. - z := zigzag(i) - p := point{revFrontier.X - z, revFrontier.Y + z} - switch { - case fwdPath.X >= p.X || revPath.Y < p.Y: - stop1 = true // Hit bottom-left corner - case fwdPath.Y >= p.Y || revPath.X < p.X: - stop2 = true // Hit top-right corner - case f(p.X-1, p.Y-1).Equal(): - // Match found, so connect the path to this point. - revPath.connect(p, f) - revPath.append(Identity) - // Follow sequence of matches as far as possible. - for fwdPath.X < revPath.X && fwdPath.Y < revPath.Y { - if !f(revPath.X-1, revPath.Y-1).Equal() { - break - } - revPath.append(Identity) - } - revFrontier = revPath.point - stop1, stop2 = true, true - default: - searchBudget-- // Match not found - } - debug.Update() - } - // Advance the frontier towards forward point. - if revFrontier.X-fwdPath.X >= revFrontier.Y-fwdPath.Y { - revFrontier.X-- - } else { - revFrontier.Y-- - } - goto forwardSearch - } - -finishSearch: - // Join the forward and reverse paths and then append the reverse path. - fwdPath.connect(revPath.point, f) - for i := len(revPath.es) - 1; i >= 0; i-- { - t := revPath.es[i] - revPath.es = revPath.es[:i] - fwdPath.append(t) - } - debug.Finish() - return fwdPath.es -} - -type path struct { - dir int // +1 if forward, -1 if reverse - point // Leading point of the EditScript path - es EditScript -} - -// connect appends any necessary Identity, Modified, UniqueX, or UniqueY types -// to the edit-script to connect p.point to dst. -func (p *path) connect(dst point, f EqualFunc) { - if p.dir > 0 { - // Connect in forward direction. - for dst.X > p.X && dst.Y > p.Y { - switch r := f(p.X, p.Y); { - case r.Equal(): - p.append(Identity) - case r.Similar(): - p.append(Modified) - case dst.X-p.X >= dst.Y-p.Y: - p.append(UniqueX) - default: - p.append(UniqueY) - } - } - for dst.X > p.X { - p.append(UniqueX) - } - for dst.Y > p.Y { - p.append(UniqueY) - } - } else { - // Connect in reverse direction. - for p.X > dst.X && p.Y > dst.Y { - switch r := f(p.X-1, p.Y-1); { - case r.Equal(): - p.append(Identity) - case r.Similar(): - p.append(Modified) - case p.Y-dst.Y >= p.X-dst.X: - p.append(UniqueY) - default: - p.append(UniqueX) - } - } - for p.X > dst.X { - p.append(UniqueX) - } - for p.Y > dst.Y { - p.append(UniqueY) - } - } -} - -func (p *path) append(t EditType) { - p.es = append(p.es, t) - switch t { - case Identity, Modified: - p.add(p.dir, p.dir) - case UniqueX: - p.add(p.dir, 0) - case UniqueY: - p.add(0, p.dir) - } - debug.Update() -} - -type point struct{ X, Y int } - -func (p *point) add(dx, dy int) { p.X += dx; p.Y += dy } - -// zigzag maps a consecutive sequence of integers to a zig-zag sequence. -// -// [0 1 2 3 4 5 ...] => [0 -1 +1 -2 +2 ...] -func zigzag(x int) int { - if x&1 != 0 { - x = ^x - } - return x >> 1 -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go b/vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go deleted file mode 100644 index d8e459c9b..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright 2019, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package flags - -// Deterministic controls whether the output of Diff should be deterministic. -// This is only used for testing. -var Deterministic bool diff --git a/vendor/github.com/google/go-cmp/cmp/internal/function/func.go b/vendor/github.com/google/go-cmp/cmp/internal/function/func.go deleted file mode 100644 index def01a6be..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/function/func.go +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// Package function provides functionality for identifying function types. -package function - -import ( - "reflect" - "regexp" - "runtime" - "strings" -) - -type funcType int - -const ( - _ funcType = iota - - tbFunc // func(T) bool - ttbFunc // func(T, T) bool - ttiFunc // func(T, T) int - trbFunc // func(T, R) bool - tibFunc // func(T, I) bool - trFunc // func(T) R - - Equal = ttbFunc // func(T, T) bool - EqualAssignable = tibFunc // func(T, I) bool; encapsulates func(T, T) bool - Transformer = trFunc // func(T) R - ValueFilter = ttbFunc // func(T, T) bool - Less = ttbFunc // func(T, T) bool - Compare = ttiFunc // func(T, T) int - ValuePredicate = tbFunc // func(T) bool - KeyValuePredicate = trbFunc // func(T, R) bool -) - -var boolType = reflect.TypeOf(true) -var intType = reflect.TypeOf(0) - -// IsType reports whether the reflect.Type is of the specified function type. -func IsType(t reflect.Type, ft funcType) bool { - if t == nil || t.Kind() != reflect.Func || t.IsVariadic() { - return false - } - ni, no := t.NumIn(), t.NumOut() - switch ft { - case tbFunc: // func(T) bool - if ni == 1 && no == 1 && t.Out(0) == boolType { - return true - } - case ttbFunc: // func(T, T) bool - if ni == 2 && no == 1 && t.In(0) == t.In(1) && t.Out(0) == boolType { - return true - } - case ttiFunc: // func(T, T) int - if ni == 2 && no == 1 && t.In(0) == t.In(1) && t.Out(0) == intType { - return true - } - case trbFunc: // func(T, R) bool - if ni == 2 && no == 1 && t.Out(0) == boolType { - return true - } - case tibFunc: // func(T, I) bool - if ni == 2 && no == 1 && t.In(0).AssignableTo(t.In(1)) && t.Out(0) == boolType { - return true - } - case trFunc: // func(T) R - if ni == 1 && no == 1 { - return true - } - } - return false -} - -var lastIdentRx = regexp.MustCompile(`[_\p{L}][_\p{L}\p{N}]*$`) - -// NameOf returns the name of the function value. -func NameOf(v reflect.Value) string { - fnc := runtime.FuncForPC(v.Pointer()) - if fnc == nil { - return "" - } - fullName := fnc.Name() // e.g., "long/path/name/mypkg.(*MyType).(long/path/name/mypkg.myMethod)-fm" - - // Method closures have a "-fm" suffix. - fullName = strings.TrimSuffix(fullName, "-fm") - - var name string - for len(fullName) > 0 { - inParen := strings.HasSuffix(fullName, ")") - fullName = strings.TrimSuffix(fullName, ")") - - s := lastIdentRx.FindString(fullName) - if s == "" { - break - } - name = s + "." + name - fullName = strings.TrimSuffix(fullName, s) - - if i := strings.LastIndexByte(fullName, '('); inParen && i >= 0 { - fullName = fullName[:i] - } - fullName = strings.TrimSuffix(fullName, ".") - } - return strings.TrimSuffix(name, ".") -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/name.go b/vendor/github.com/google/go-cmp/cmp/internal/value/name.go deleted file mode 100644 index 7b498bb2c..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/value/name.go +++ /dev/null @@ -1,164 +0,0 @@ -// Copyright 2020, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package value - -import ( - "reflect" - "strconv" -) - -var anyType = reflect.TypeOf((*interface{})(nil)).Elem() - -// TypeString is nearly identical to reflect.Type.String, -// but has an additional option to specify that full type names be used. -func TypeString(t reflect.Type, qualified bool) string { - return string(appendTypeName(nil, t, qualified, false)) -} - -func appendTypeName(b []byte, t reflect.Type, qualified, elideFunc bool) []byte { - // BUG: Go reflection provides no way to disambiguate two named types - // of the same name and within the same package, - // but declared within the namespace of different functions. - - // Use the "any" alias instead of "interface{}" for better readability. - if t == anyType { - return append(b, "any"...) - } - - // Named type. - if t.Name() != "" { - if qualified && t.PkgPath() != "" { - b = append(b, '"') - b = append(b, t.PkgPath()...) - b = append(b, '"') - b = append(b, '.') - b = append(b, t.Name()...) - } else { - b = append(b, t.String()...) - } - return b - } - - // Unnamed type. - switch k := t.Kind(); k { - case reflect.Bool, reflect.String, reflect.UnsafePointer, - reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, - reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128: - b = append(b, k.String()...) - case reflect.Chan: - if t.ChanDir() == reflect.RecvDir { - b = append(b, "<-"...) - } - b = append(b, "chan"...) - if t.ChanDir() == reflect.SendDir { - b = append(b, "<-"...) - } - b = append(b, ' ') - b = appendTypeName(b, t.Elem(), qualified, false) - case reflect.Func: - if !elideFunc { - b = append(b, "func"...) - } - b = append(b, '(') - for i := 0; i < t.NumIn(); i++ { - if i > 0 { - b = append(b, ", "...) - } - if i == t.NumIn()-1 && t.IsVariadic() { - b = append(b, "..."...) - b = appendTypeName(b, t.In(i).Elem(), qualified, false) - } else { - b = appendTypeName(b, t.In(i), qualified, false) - } - } - b = append(b, ')') - switch t.NumOut() { - case 0: - // Do nothing - case 1: - b = append(b, ' ') - b = appendTypeName(b, t.Out(0), qualified, false) - default: - b = append(b, " ("...) - for i := 0; i < t.NumOut(); i++ { - if i > 0 { - b = append(b, ", "...) - } - b = appendTypeName(b, t.Out(i), qualified, false) - } - b = append(b, ')') - } - case reflect.Struct: - b = append(b, "struct{ "...) - for i := 0; i < t.NumField(); i++ { - if i > 0 { - b = append(b, "; "...) - } - sf := t.Field(i) - if !sf.Anonymous { - if qualified && sf.PkgPath != "" { - b = append(b, '"') - b = append(b, sf.PkgPath...) - b = append(b, '"') - b = append(b, '.') - } - b = append(b, sf.Name...) - b = append(b, ' ') - } - b = appendTypeName(b, sf.Type, qualified, false) - if sf.Tag != "" { - b = append(b, ' ') - b = strconv.AppendQuote(b, string(sf.Tag)) - } - } - if b[len(b)-1] == ' ' { - b = b[:len(b)-1] - } else { - b = append(b, ' ') - } - b = append(b, '}') - case reflect.Slice, reflect.Array: - b = append(b, '[') - if k == reflect.Array { - b = strconv.AppendUint(b, uint64(t.Len()), 10) - } - b = append(b, ']') - b = appendTypeName(b, t.Elem(), qualified, false) - case reflect.Map: - b = append(b, "map["...) - b = appendTypeName(b, t.Key(), qualified, false) - b = append(b, ']') - b = appendTypeName(b, t.Elem(), qualified, false) - case reflect.Ptr: - b = append(b, '*') - b = appendTypeName(b, t.Elem(), qualified, false) - case reflect.Interface: - b = append(b, "interface{ "...) - for i := 0; i < t.NumMethod(); i++ { - if i > 0 { - b = append(b, "; "...) - } - m := t.Method(i) - if qualified && m.PkgPath != "" { - b = append(b, '"') - b = append(b, m.PkgPath...) - b = append(b, '"') - b = append(b, '.') - } - b = append(b, m.Name...) - b = appendTypeName(b, m.Type, qualified, true) - } - if b[len(b)-1] == ' ' { - b = b[:len(b)-1] - } else { - b = append(b, ' ') - } - b = append(b, '}') - default: - panic("invalid kind: " + k.String()) - } - return b -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer.go b/vendor/github.com/google/go-cmp/cmp/internal/value/pointer.go deleted file mode 100644 index e5dfff69a..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer.go +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2018, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package value - -import ( - "reflect" - "unsafe" -) - -// Pointer is an opaque typed pointer and is guaranteed to be comparable. -type Pointer struct { - p unsafe.Pointer - t reflect.Type -} - -// PointerOf returns a Pointer from v, which must be a -// reflect.Ptr, reflect.Slice, or reflect.Map. -func PointerOf(v reflect.Value) Pointer { - // The proper representation of a pointer is unsafe.Pointer, - // which is necessary if the GC ever uses a moving collector. - return Pointer{unsafe.Pointer(v.Pointer()), v.Type()} -} - -// IsNil reports whether the pointer is nil. -func (p Pointer) IsNil() bool { - return p.p == nil -} - -// Uintptr returns the pointer as a uintptr. -func (p Pointer) Uintptr() uintptr { - return uintptr(p.p) -} diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/sort.go b/vendor/github.com/google/go-cmp/cmp/internal/value/sort.go deleted file mode 100644 index 98533b036..000000000 --- a/vendor/github.com/google/go-cmp/cmp/internal/value/sort.go +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package value - -import ( - "fmt" - "math" - "reflect" - "sort" -) - -// SortKeys sorts a list of map keys, deduplicating keys if necessary. -// The type of each value must be comparable. -func SortKeys(vs []reflect.Value) []reflect.Value { - if len(vs) == 0 { - return vs - } - - // Sort the map keys. - sort.SliceStable(vs, func(i, j int) bool { return isLess(vs[i], vs[j]) }) - - // Deduplicate keys (fails for NaNs). - vs2 := vs[:1] - for _, v := range vs[1:] { - if isLess(vs2[len(vs2)-1], v) { - vs2 = append(vs2, v) - } - } - return vs2 -} - -// isLess is a generic function for sorting arbitrary map keys. -// The inputs must be of the same type and must be comparable. -func isLess(x, y reflect.Value) bool { - switch x.Type().Kind() { - case reflect.Bool: - return !x.Bool() && y.Bool() - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return x.Int() < y.Int() - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: - return x.Uint() < y.Uint() - case reflect.Float32, reflect.Float64: - // NOTE: This does not sort -0 as less than +0 - // since Go maps treat -0 and +0 as equal keys. - fx, fy := x.Float(), y.Float() - return fx < fy || math.IsNaN(fx) && !math.IsNaN(fy) - case reflect.Complex64, reflect.Complex128: - cx, cy := x.Complex(), y.Complex() - rx, ix, ry, iy := real(cx), imag(cx), real(cy), imag(cy) - if rx == ry || (math.IsNaN(rx) && math.IsNaN(ry)) { - return ix < iy || math.IsNaN(ix) && !math.IsNaN(iy) - } - return rx < ry || math.IsNaN(rx) && !math.IsNaN(ry) - case reflect.Ptr, reflect.UnsafePointer, reflect.Chan: - return x.Pointer() < y.Pointer() - case reflect.String: - return x.String() < y.String() - case reflect.Array: - for i := 0; i < x.Len(); i++ { - if isLess(x.Index(i), y.Index(i)) { - return true - } - if isLess(y.Index(i), x.Index(i)) { - return false - } - } - return false - case reflect.Struct: - for i := 0; i < x.NumField(); i++ { - if isLess(x.Field(i), y.Field(i)) { - return true - } - if isLess(y.Field(i), x.Field(i)) { - return false - } - } - return false - case reflect.Interface: - vx, vy := x.Elem(), y.Elem() - if !vx.IsValid() || !vy.IsValid() { - return !vx.IsValid() && vy.IsValid() - } - tx, ty := vx.Type(), vy.Type() - if tx == ty { - return isLess(x.Elem(), y.Elem()) - } - if tx.Kind() != ty.Kind() { - return vx.Kind() < vy.Kind() - } - if tx.String() != ty.String() { - return tx.String() < ty.String() - } - if tx.PkgPath() != ty.PkgPath() { - return tx.PkgPath() < ty.PkgPath() - } - // This can happen in rare situations, so we fallback to just comparing - // the unique pointer for a reflect.Type. This guarantees deterministic - // ordering within a program, but it is obviously not stable. - return reflect.ValueOf(vx.Type()).Pointer() < reflect.ValueOf(vy.Type()).Pointer() - default: - // Must be Func, Map, or Slice; which are not comparable. - panic(fmt.Sprintf("%T is not comparable", x.Type())) - } -} diff --git a/vendor/github.com/google/go-cmp/cmp/options.go b/vendor/github.com/google/go-cmp/cmp/options.go deleted file mode 100644 index ba3fce81f..000000000 --- a/vendor/github.com/google/go-cmp/cmp/options.go +++ /dev/null @@ -1,562 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "fmt" - "reflect" - "regexp" - "strings" - - "github.com/google/go-cmp/cmp/internal/function" -) - -// Option configures for specific behavior of [Equal] and [Diff]. In particular, -// the fundamental Option functions ([Ignore], [Transformer], and [Comparer]), -// configure how equality is determined. -// -// The fundamental options may be composed with filters ([FilterPath] and -// [FilterValues]) to control the scope over which they are applied. -// -// The [github.com/google/go-cmp/cmp/cmpopts] package provides helper functions -// for creating options that may be used with [Equal] and [Diff]. -type Option interface { - // filter applies all filters and returns the option that remains. - // Each option may only read s.curPath and call s.callTTBFunc. - // - // An Options is returned only if multiple comparers or transformers - // can apply simultaneously and will only contain values of those types - // or sub-Options containing values of those types. - filter(s *state, t reflect.Type, vx, vy reflect.Value) applicableOption -} - -// applicableOption represents the following types: -// -// Fundamental: ignore | validator | *comparer | *transformer -// Grouping: Options -type applicableOption interface { - Option - - // apply executes the option, which may mutate s or panic. - apply(s *state, vx, vy reflect.Value) -} - -// coreOption represents the following types: -// -// Fundamental: ignore | validator | *comparer | *transformer -// Filters: *pathFilter | *valuesFilter -type coreOption interface { - Option - isCore() -} - -type core struct{} - -func (core) isCore() {} - -// Options is a list of [Option] values that also satisfies the [Option] interface. -// Helper comparison packages may return an Options value when packing multiple -// [Option] values into a single [Option]. When this package processes an Options, -// it will be implicitly expanded into a flat list. -// -// Applying a filter on an Options is equivalent to applying that same filter -// on all individual options held within. -type Options []Option - -func (opts Options) filter(s *state, t reflect.Type, vx, vy reflect.Value) (out applicableOption) { - for _, opt := range opts { - switch opt := opt.filter(s, t, vx, vy); opt.(type) { - case ignore: - return ignore{} // Only ignore can short-circuit evaluation - case validator: - out = validator{} // Takes precedence over comparer or transformer - case *comparer, *transformer, Options: - switch out.(type) { - case nil: - out = opt - case validator: - // Keep validator - case *comparer, *transformer, Options: - out = Options{out, opt} // Conflicting comparers or transformers - } - } - } - return out -} - -func (opts Options) apply(s *state, _, _ reflect.Value) { - const warning = "ambiguous set of applicable options" - const help = "consider using filters to ensure at most one Comparer or Transformer may apply" - var ss []string - for _, opt := range flattenOptions(nil, opts) { - ss = append(ss, fmt.Sprint(opt)) - } - set := strings.Join(ss, "\n\t") - panic(fmt.Sprintf("%s at %#v:\n\t%s\n%s", warning, s.curPath, set, help)) -} - -func (opts Options) String() string { - var ss []string - for _, opt := range opts { - ss = append(ss, fmt.Sprint(opt)) - } - return fmt.Sprintf("Options{%s}", strings.Join(ss, ", ")) -} - -// FilterPath returns a new [Option] where opt is only evaluated if filter f -// returns true for the current [Path] in the value tree. -// -// This filter is called even if a slice element or map entry is missing and -// provides an opportunity to ignore such cases. The filter function must be -// symmetric such that the filter result is identical regardless of whether the -// missing value is from x or y. -// -// The option passed in may be an [Ignore], [Transformer], [Comparer], [Options], or -// a previously filtered [Option]. -func FilterPath(f func(Path) bool, opt Option) Option { - if f == nil { - panic("invalid path filter function") - } - if opt := normalizeOption(opt); opt != nil { - return &pathFilter{fnc: f, opt: opt} - } - return nil -} - -type pathFilter struct { - core - fnc func(Path) bool - opt Option -} - -func (f pathFilter) filter(s *state, t reflect.Type, vx, vy reflect.Value) applicableOption { - if f.fnc(s.curPath) { - return f.opt.filter(s, t, vx, vy) - } - return nil -} - -func (f pathFilter) String() string { - return fmt.Sprintf("FilterPath(%s, %v)", function.NameOf(reflect.ValueOf(f.fnc)), f.opt) -} - -// FilterValues returns a new [Option] where opt is only evaluated if filter f, -// which is a function of the form "func(T, T) bool", returns true for the -// current pair of values being compared. If either value is invalid or -// the type of the values is not assignable to T, then this filter implicitly -// returns false. -// -// The filter function must be -// symmetric (i.e., agnostic to the order of the inputs) and -// deterministic (i.e., produces the same result when given the same inputs). -// If T is an interface, it is possible that f is called with two values with -// different concrete types that both implement T. -// -// The option passed in may be an [Ignore], [Transformer], [Comparer], [Options], or -// a previously filtered [Option]. -func FilterValues(f interface{}, opt Option) Option { - v := reflect.ValueOf(f) - if !function.IsType(v.Type(), function.ValueFilter) || v.IsNil() { - panic(fmt.Sprintf("invalid values filter function: %T", f)) - } - if opt := normalizeOption(opt); opt != nil { - vf := &valuesFilter{fnc: v, opt: opt} - if ti := v.Type().In(0); ti.Kind() != reflect.Interface || ti.NumMethod() > 0 { - vf.typ = ti - } - return vf - } - return nil -} - -type valuesFilter struct { - core - typ reflect.Type // T - fnc reflect.Value // func(T, T) bool - opt Option -} - -func (f valuesFilter) filter(s *state, t reflect.Type, vx, vy reflect.Value) applicableOption { - if !vx.IsValid() || !vx.CanInterface() || !vy.IsValid() || !vy.CanInterface() { - return nil - } - if (f.typ == nil || t.AssignableTo(f.typ)) && s.callTTBFunc(f.fnc, vx, vy) { - return f.opt.filter(s, t, vx, vy) - } - return nil -} - -func (f valuesFilter) String() string { - return fmt.Sprintf("FilterValues(%s, %v)", function.NameOf(f.fnc), f.opt) -} - -// Ignore is an [Option] that causes all comparisons to be ignored. -// This value is intended to be combined with [FilterPath] or [FilterValues]. -// It is an error to pass an unfiltered Ignore option to [Equal]. -func Ignore() Option { return ignore{} } - -type ignore struct{ core } - -func (ignore) isFiltered() bool { return false } -func (ignore) filter(_ *state, _ reflect.Type, _, _ reflect.Value) applicableOption { return ignore{} } -func (ignore) apply(s *state, _, _ reflect.Value) { s.report(true, reportByIgnore) } -func (ignore) String() string { return "Ignore()" } - -// validator is a sentinel Option type to indicate that some options could not -// be evaluated due to unexported fields, missing slice elements, or -// missing map entries. Both values are validator only for unexported fields. -type validator struct{ core } - -func (validator) filter(_ *state, _ reflect.Type, vx, vy reflect.Value) applicableOption { - if !vx.IsValid() || !vy.IsValid() { - return validator{} - } - if !vx.CanInterface() || !vy.CanInterface() { - return validator{} - } - return nil -} -func (validator) apply(s *state, vx, vy reflect.Value) { - // Implies missing slice element or map entry. - if !vx.IsValid() || !vy.IsValid() { - s.report(vx.IsValid() == vy.IsValid(), 0) - return - } - - // Unable to Interface implies unexported field without visibility access. - if !vx.CanInterface() || !vy.CanInterface() { - help := "consider using a custom Comparer; if you control the implementation of type, you can also consider using an Exporter, AllowUnexported, or cmpopts.IgnoreUnexported" - var name string - if t := s.curPath.Index(-2).Type(); t.Name() != "" { - // Named type with unexported fields. - name = fmt.Sprintf("%q.%v", t.PkgPath(), t.Name()) // e.g., "path/to/package".MyType - isProtoMessage := func(t reflect.Type) bool { - m, ok := reflect.PointerTo(t).MethodByName("ProtoReflect") - return ok && m.Type.NumIn() == 1 && m.Type.NumOut() == 1 && - m.Type.Out(0).PkgPath() == "google.golang.org/protobuf/reflect/protoreflect" && - m.Type.Out(0).Name() == "Message" - } - if isProtoMessage(t) { - help = `consider using "google.golang.org/protobuf/testing/protocmp".Transform to compare proto.Message types` - } else if _, ok := reflect.New(t).Interface().(error); ok { - help = "consider using cmpopts.EquateErrors to compare error values" - } else if t.Comparable() { - help = "consider using cmpopts.EquateComparable to compare comparable Go types" - } - } else { - // Unnamed type with unexported fields. Derive PkgPath from field. - var pkgPath string - for i := 0; i < t.NumField() && pkgPath == ""; i++ { - pkgPath = t.Field(i).PkgPath - } - name = fmt.Sprintf("%q.(%v)", pkgPath, t.String()) // e.g., "path/to/package".(struct { a int }) - } - panic(fmt.Sprintf("cannot handle unexported field at %#v:\n\t%v\n%s", s.curPath, name, help)) - } - - panic("not reachable") -} - -// identRx represents a valid identifier according to the Go specification. -const identRx = `[_\p{L}][_\p{L}\p{N}]*` - -var identsRx = regexp.MustCompile(`^` + identRx + `(\.` + identRx + `)*$`) - -// Transformer returns an [Option] that applies a transformation function that -// converts values of a certain type into that of another. -// -// The transformer f must be a function "func(T) R" that converts values of -// type T to those of type R and is implicitly filtered to input values -// assignable to T. The transformer must not mutate T in any way. -// -// To help prevent some cases of infinite recursive cycles applying the -// same transform to the output of itself (e.g., in the case where the -// input and output types are the same), an implicit filter is added such that -// a transformer is applicable only if that exact transformer is not already -// in the tail of the [Path] since the last non-[Transform] step. -// For situations where the implicit filter is still insufficient, -// consider using [github.com/google/go-cmp/cmp/cmpopts.AcyclicTransformer], -// which adds a filter to prevent the transformer from -// being recursively applied upon itself. -// -// The name is a user provided label that is used as the [Transform.Name] in the -// transformation [PathStep] (and eventually shown in the [Diff] output). -// The name must be a valid identifier or qualified identifier in Go syntax. -// If empty, an arbitrary name is used. -func Transformer(name string, f interface{}) Option { - v := reflect.ValueOf(f) - if !function.IsType(v.Type(), function.Transformer) || v.IsNil() { - panic(fmt.Sprintf("invalid transformer function: %T", f)) - } - if name == "" { - name = function.NameOf(v) - if !identsRx.MatchString(name) { - name = "λ" // Lambda-symbol as placeholder name - } - } else if !identsRx.MatchString(name) { - panic(fmt.Sprintf("invalid name: %q", name)) - } - tr := &transformer{name: name, fnc: reflect.ValueOf(f)} - if ti := v.Type().In(0); ti.Kind() != reflect.Interface || ti.NumMethod() > 0 { - tr.typ = ti - } - return tr -} - -type transformer struct { - core - name string - typ reflect.Type // T - fnc reflect.Value // func(T) R -} - -func (tr *transformer) isFiltered() bool { return tr.typ != nil } - -func (tr *transformer) filter(s *state, t reflect.Type, _, _ reflect.Value) applicableOption { - for i := len(s.curPath) - 1; i >= 0; i-- { - if t, ok := s.curPath[i].(Transform); !ok { - break // Hit most recent non-Transform step - } else if tr == t.trans { - return nil // Cannot directly use same Transform - } - } - if tr.typ == nil || t.AssignableTo(tr.typ) { - return tr - } - return nil -} - -func (tr *transformer) apply(s *state, vx, vy reflect.Value) { - step := Transform{&transform{pathStep{typ: tr.fnc.Type().Out(0)}, tr}} - vvx := s.callTRFunc(tr.fnc, vx, step) - vvy := s.callTRFunc(tr.fnc, vy, step) - step.vx, step.vy = vvx, vvy - s.compareAny(step) -} - -func (tr transformer) String() string { - return fmt.Sprintf("Transformer(%s, %s)", tr.name, function.NameOf(tr.fnc)) -} - -// Comparer returns an [Option] that determines whether two values are equal -// to each other. -// -// The comparer f must be a function "func(T, T) bool" and is implicitly -// filtered to input values assignable to T. If T is an interface, it is -// possible that f is called with two values of different concrete types that -// both implement T. -// -// The equality function must be: -// - Symmetric: equal(x, y) == equal(y, x) -// - Deterministic: equal(x, y) == equal(x, y) -// - Pure: equal(x, y) does not modify x or y -func Comparer(f interface{}) Option { - v := reflect.ValueOf(f) - if !function.IsType(v.Type(), function.Equal) || v.IsNil() { - panic(fmt.Sprintf("invalid comparer function: %T", f)) - } - cm := &comparer{fnc: v} - if ti := v.Type().In(0); ti.Kind() != reflect.Interface || ti.NumMethod() > 0 { - cm.typ = ti - } - return cm -} - -type comparer struct { - core - typ reflect.Type // T - fnc reflect.Value // func(T, T) bool -} - -func (cm *comparer) isFiltered() bool { return cm.typ != nil } - -func (cm *comparer) filter(_ *state, t reflect.Type, _, _ reflect.Value) applicableOption { - if cm.typ == nil || t.AssignableTo(cm.typ) { - return cm - } - return nil -} - -func (cm *comparer) apply(s *state, vx, vy reflect.Value) { - eq := s.callTTBFunc(cm.fnc, vx, vy) - s.report(eq, reportByFunc) -} - -func (cm comparer) String() string { - return fmt.Sprintf("Comparer(%s)", function.NameOf(cm.fnc)) -} - -// Exporter returns an [Option] that specifies whether [Equal] is allowed to -// introspect into the unexported fields of certain struct types. -// -// Users of this option must understand that comparing on unexported fields -// from external packages is not safe since changes in the internal -// implementation of some external package may cause the result of [Equal] -// to unexpectedly change. However, it may be valid to use this option on types -// defined in an internal package where the semantic meaning of an unexported -// field is in the control of the user. -// -// In many cases, a custom [Comparer] should be used instead that defines -// equality as a function of the public API of a type rather than the underlying -// unexported implementation. -// -// For example, the [reflect.Type] documentation defines equality to be determined -// by the == operator on the interface (essentially performing a shallow pointer -// comparison) and most attempts to compare *[regexp.Regexp] types are interested -// in only checking that the regular expression strings are equal. -// Both of these are accomplished using [Comparer] options: -// -// Comparer(func(x, y reflect.Type) bool { return x == y }) -// Comparer(func(x, y *regexp.Regexp) bool { return x.String() == y.String() }) -// -// In other cases, the [github.com/google/go-cmp/cmp/cmpopts.IgnoreUnexported] -// option can be used to ignore all unexported fields on specified struct types. -func Exporter(f func(reflect.Type) bool) Option { - return exporter(f) -} - -type exporter func(reflect.Type) bool - -func (exporter) filter(_ *state, _ reflect.Type, _, _ reflect.Value) applicableOption { - panic("not implemented") -} - -// AllowUnexported returns an [Option] that allows [Equal] to forcibly introspect -// unexported fields of the specified struct types. -// -// See [Exporter] for the proper use of this option. -func AllowUnexported(types ...interface{}) Option { - m := make(map[reflect.Type]bool) - for _, typ := range types { - t := reflect.TypeOf(typ) - if t.Kind() != reflect.Struct { - panic(fmt.Sprintf("invalid struct type: %T", typ)) - } - m[t] = true - } - return exporter(func(t reflect.Type) bool { return m[t] }) -} - -// Result represents the comparison result for a single node and -// is provided by cmp when calling Report (see [Reporter]). -type Result struct { - _ [0]func() // Make Result incomparable - flags resultFlags -} - -// Equal reports whether the node was determined to be equal or not. -// As a special case, ignored nodes are considered equal. -func (r Result) Equal() bool { - return r.flags&(reportEqual|reportByIgnore) != 0 -} - -// ByIgnore reports whether the node is equal because it was ignored. -// This never reports true if [Result.Equal] reports false. -func (r Result) ByIgnore() bool { - return r.flags&reportByIgnore != 0 -} - -// ByMethod reports whether the Equal method determined equality. -func (r Result) ByMethod() bool { - return r.flags&reportByMethod != 0 -} - -// ByFunc reports whether a [Comparer] function determined equality. -func (r Result) ByFunc() bool { - return r.flags&reportByFunc != 0 -} - -// ByCycle reports whether a reference cycle was detected. -func (r Result) ByCycle() bool { - return r.flags&reportByCycle != 0 -} - -type resultFlags uint - -const ( - _ resultFlags = (1 << iota) / 2 - - reportEqual - reportUnequal - reportByIgnore - reportByMethod - reportByFunc - reportByCycle -) - -// Reporter is an [Option] that can be passed to [Equal]. When [Equal] traverses -// the value trees, it calls PushStep as it descends into each node in the -// tree and PopStep as it ascend out of the node. The leaves of the tree are -// either compared (determined to be equal or not equal) or ignored and reported -// as such by calling the Report method. -func Reporter(r interface { - // PushStep is called when a tree-traversal operation is performed. - // The PathStep itself is only valid until the step is popped. - // The PathStep.Values are valid for the duration of the entire traversal - // and must not be mutated. - // - // Equal always calls PushStep at the start to provide an operation-less - // PathStep used to report the root values. - // - // Within a slice, the exact set of inserted, removed, or modified elements - // is unspecified and may change in future implementations. - // The entries of a map are iterated through in an unspecified order. - PushStep(PathStep) - - // Report is called exactly once on leaf nodes to report whether the - // comparison identified the node as equal, unequal, or ignored. - // A leaf node is one that is immediately preceded by and followed by - // a pair of PushStep and PopStep calls. - Report(Result) - - // PopStep ascends back up the value tree. - // There is always a matching pop call for every push call. - PopStep() -}) Option { - return reporter{r} -} - -type reporter struct{ reporterIface } -type reporterIface interface { - PushStep(PathStep) - Report(Result) - PopStep() -} - -func (reporter) filter(_ *state, _ reflect.Type, _, _ reflect.Value) applicableOption { - panic("not implemented") -} - -// normalizeOption normalizes the input options such that all Options groups -// are flattened and groups with a single element are reduced to that element. -// Only coreOptions and Options containing coreOptions are allowed. -func normalizeOption(src Option) Option { - switch opts := flattenOptions(nil, Options{src}); len(opts) { - case 0: - return nil - case 1: - return opts[0] - default: - return opts - } -} - -// flattenOptions copies all options in src to dst as a flat list. -// Only coreOptions and Options containing coreOptions are allowed. -func flattenOptions(dst, src Options) Options { - for _, opt := range src { - switch opt := opt.(type) { - case nil: - continue - case Options: - dst = flattenOptions(dst, opt) - case coreOption: - dst = append(dst, opt) - default: - panic(fmt.Sprintf("invalid option type: %T", opt)) - } - } - return dst -} diff --git a/vendor/github.com/google/go-cmp/cmp/path.go b/vendor/github.com/google/go-cmp/cmp/path.go deleted file mode 100644 index c3c145642..000000000 --- a/vendor/github.com/google/go-cmp/cmp/path.go +++ /dev/null @@ -1,390 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "fmt" - "reflect" - "strings" - "unicode" - "unicode/utf8" - - "github.com/google/go-cmp/cmp/internal/value" -) - -// Path is a list of [PathStep] describing the sequence of operations to get -// from some root type to the current position in the value tree. -// The first Path element is always an operation-less [PathStep] that exists -// simply to identify the initial type. -// -// When traversing structs with embedded structs, the embedded struct will -// always be accessed as a field before traversing the fields of the -// embedded struct themselves. That is, an exported field from the -// embedded struct will never be accessed directly from the parent struct. -type Path []PathStep - -// PathStep is a union-type for specific operations to traverse -// a value's tree structure. Users of this package never need to implement -// these types as values of this type will be returned by this package. -// -// Implementations of this interface: -// - [StructField] -// - [SliceIndex] -// - [MapIndex] -// - [Indirect] -// - [TypeAssertion] -// - [Transform] -type PathStep interface { - String() string - - // Type is the resulting type after performing the path step. - Type() reflect.Type - - // Values is the resulting values after performing the path step. - // The type of each valid value is guaranteed to be identical to Type. - // - // In some cases, one or both may be invalid or have restrictions: - // - For StructField, both are not interface-able if the current field - // is unexported and the struct type is not explicitly permitted by - // an Exporter to traverse unexported fields. - // - For SliceIndex, one may be invalid if an element is missing from - // either the x or y slice. - // - For MapIndex, one may be invalid if an entry is missing from - // either the x or y map. - // - // The provided values must not be mutated. - Values() (vx, vy reflect.Value) -} - -var ( - _ PathStep = StructField{} - _ PathStep = SliceIndex{} - _ PathStep = MapIndex{} - _ PathStep = Indirect{} - _ PathStep = TypeAssertion{} - _ PathStep = Transform{} -) - -func (pa *Path) push(s PathStep) { - *pa = append(*pa, s) -} - -func (pa *Path) pop() { - *pa = (*pa)[:len(*pa)-1] -} - -// Last returns the last [PathStep] in the Path. -// If the path is empty, this returns a non-nil [PathStep] -// that reports a nil [PathStep.Type]. -func (pa Path) Last() PathStep { - return pa.Index(-1) -} - -// Index returns the ith step in the Path and supports negative indexing. -// A negative index starts counting from the tail of the Path such that -1 -// refers to the last step, -2 refers to the second-to-last step, and so on. -// If index is invalid, this returns a non-nil [PathStep] -// that reports a nil [PathStep.Type]. -func (pa Path) Index(i int) PathStep { - if i < 0 { - i = len(pa) + i - } - if i < 0 || i >= len(pa) { - return pathStep{} - } - return pa[i] -} - -// String returns the simplified path to a node. -// The simplified path only contains struct field accesses. -// -// For example: -// -// MyMap.MySlices.MyField -func (pa Path) String() string { - var ss []string - for _, s := range pa { - if _, ok := s.(StructField); ok { - ss = append(ss, s.String()) - } - } - return strings.TrimPrefix(strings.Join(ss, ""), ".") -} - -// GoString returns the path to a specific node using Go syntax. -// -// For example: -// -// (*root.MyMap["key"].(*mypkg.MyStruct).MySlices)[2][3].MyField -func (pa Path) GoString() string { - var ssPre, ssPost []string - var numIndirect int - for i, s := range pa { - var nextStep PathStep - if i+1 < len(pa) { - nextStep = pa[i+1] - } - switch s := s.(type) { - case Indirect: - numIndirect++ - pPre, pPost := "(", ")" - switch nextStep.(type) { - case Indirect: - continue // Next step is indirection, so let them batch up - case StructField: - numIndirect-- // Automatic indirection on struct fields - case nil: - pPre, pPost = "", "" // Last step; no need for parenthesis - } - if numIndirect > 0 { - ssPre = append(ssPre, pPre+strings.Repeat("*", numIndirect)) - ssPost = append(ssPost, pPost) - } - numIndirect = 0 - continue - case Transform: - ssPre = append(ssPre, s.trans.name+"(") - ssPost = append(ssPost, ")") - continue - } - ssPost = append(ssPost, s.String()) - } - for i, j := 0, len(ssPre)-1; i < j; i, j = i+1, j-1 { - ssPre[i], ssPre[j] = ssPre[j], ssPre[i] - } - return strings.Join(ssPre, "") + strings.Join(ssPost, "") -} - -type pathStep struct { - typ reflect.Type - vx, vy reflect.Value -} - -func (ps pathStep) Type() reflect.Type { return ps.typ } -func (ps pathStep) Values() (vx, vy reflect.Value) { return ps.vx, ps.vy } -func (ps pathStep) String() string { - if ps.typ == nil { - return "" - } - s := value.TypeString(ps.typ, false) - if s == "" || strings.ContainsAny(s, "{}\n") { - return "root" // Type too simple or complex to print - } - return fmt.Sprintf("{%s}", s) -} - -// StructField is a [PathStep] that represents a struct field access -// on a field called [StructField.Name]. -type StructField struct{ *structField } -type structField struct { - pathStep - name string - idx int - - // These fields are used for forcibly accessing an unexported field. - // pvx, pvy, and field are only valid if unexported is true. - unexported bool - mayForce bool // Forcibly allow visibility - paddr bool // Was parent addressable? - pvx, pvy reflect.Value // Parent values (always addressable) - field reflect.StructField // Field information -} - -func (sf StructField) Type() reflect.Type { return sf.typ } -func (sf StructField) Values() (vx, vy reflect.Value) { - if !sf.unexported { - return sf.vx, sf.vy // CanInterface reports true - } - - // Forcibly obtain read-write access to an unexported struct field. - if sf.mayForce { - vx = retrieveUnexportedField(sf.pvx, sf.field, sf.paddr) - vy = retrieveUnexportedField(sf.pvy, sf.field, sf.paddr) - return vx, vy // CanInterface reports true - } - return sf.vx, sf.vy // CanInterface reports false -} -func (sf StructField) String() string { return fmt.Sprintf(".%s", sf.name) } - -// Name is the field name. -func (sf StructField) Name() string { return sf.name } - -// Index is the index of the field in the parent struct type. -// See [reflect.Type.Field]. -func (sf StructField) Index() int { return sf.idx } - -// SliceIndex is a [PathStep] that represents an index operation on -// a slice or array at some index [SliceIndex.Key]. -type SliceIndex struct{ *sliceIndex } -type sliceIndex struct { - pathStep - xkey, ykey int - isSlice bool // False for reflect.Array -} - -func (si SliceIndex) Type() reflect.Type { return si.typ } -func (si SliceIndex) Values() (vx, vy reflect.Value) { return si.vx, si.vy } -func (si SliceIndex) String() string { - switch { - case si.xkey == si.ykey: - return fmt.Sprintf("[%d]", si.xkey) - case si.ykey == -1: - // [5->?] means "I don't know where X[5] went" - return fmt.Sprintf("[%d->?]", si.xkey) - case si.xkey == -1: - // [?->3] means "I don't know where Y[3] came from" - return fmt.Sprintf("[?->%d]", si.ykey) - default: - // [5->3] means "X[5] moved to Y[3]" - return fmt.Sprintf("[%d->%d]", si.xkey, si.ykey) - } -} - -// Key is the index key; it may return -1 if in a split state -func (si SliceIndex) Key() int { - if si.xkey != si.ykey { - return -1 - } - return si.xkey -} - -// SplitKeys are the indexes for indexing into slices in the -// x and y values, respectively. These indexes may differ due to the -// insertion or removal of an element in one of the slices, causing -// all of the indexes to be shifted. If an index is -1, then that -// indicates that the element does not exist in the associated slice. -// -// [SliceIndex.Key] is guaranteed to return -1 if and only if the indexes -// returned by SplitKeys are not the same. SplitKeys will never return -1 for -// both indexes. -func (si SliceIndex) SplitKeys() (ix, iy int) { return si.xkey, si.ykey } - -// MapIndex is a [PathStep] that represents an index operation on a map at some index Key. -type MapIndex struct{ *mapIndex } -type mapIndex struct { - pathStep - key reflect.Value -} - -func (mi MapIndex) Type() reflect.Type { return mi.typ } -func (mi MapIndex) Values() (vx, vy reflect.Value) { return mi.vx, mi.vy } -func (mi MapIndex) String() string { return fmt.Sprintf("[%#v]", mi.key) } - -// Key is the value of the map key. -func (mi MapIndex) Key() reflect.Value { return mi.key } - -// Indirect is a [PathStep] that represents pointer indirection on the parent type. -type Indirect struct{ *indirect } -type indirect struct { - pathStep -} - -func (in Indirect) Type() reflect.Type { return in.typ } -func (in Indirect) Values() (vx, vy reflect.Value) { return in.vx, in.vy } -func (in Indirect) String() string { return "*" } - -// TypeAssertion is a [PathStep] that represents a type assertion on an interface. -type TypeAssertion struct{ *typeAssertion } -type typeAssertion struct { - pathStep -} - -func (ta TypeAssertion) Type() reflect.Type { return ta.typ } -func (ta TypeAssertion) Values() (vx, vy reflect.Value) { return ta.vx, ta.vy } -func (ta TypeAssertion) String() string { return fmt.Sprintf(".(%v)", value.TypeString(ta.typ, false)) } - -// Transform is a [PathStep] that represents a transformation -// from the parent type to the current type. -type Transform struct{ *transform } -type transform struct { - pathStep - trans *transformer -} - -func (tf Transform) Type() reflect.Type { return tf.typ } -func (tf Transform) Values() (vx, vy reflect.Value) { return tf.vx, tf.vy } -func (tf Transform) String() string { return fmt.Sprintf("%s()", tf.trans.name) } - -// Name is the name of the [Transformer]. -func (tf Transform) Name() string { return tf.trans.name } - -// Func is the function pointer to the transformer function. -func (tf Transform) Func() reflect.Value { return tf.trans.fnc } - -// Option returns the originally constructed [Transformer] option. -// The == operator can be used to detect the exact option used. -func (tf Transform) Option() Option { return tf.trans } - -// pointerPath represents a dual-stack of pointers encountered when -// recursively traversing the x and y values. This data structure supports -// detection of cycles and determining whether the cycles are equal. -// In Go, cycles can occur via pointers, slices, and maps. -// -// The pointerPath uses a map to represent a stack; where descension into a -// pointer pushes the address onto the stack, and ascension from a pointer -// pops the address from the stack. Thus, when traversing into a pointer from -// reflect.Ptr, reflect.Slice element, or reflect.Map, we can detect cycles -// by checking whether the pointer has already been visited. The cycle detection -// uses a separate stack for the x and y values. -// -// If a cycle is detected we need to determine whether the two pointers -// should be considered equal. The definition of equality chosen by Equal -// requires two graphs to have the same structure. To determine this, both the -// x and y values must have a cycle where the previous pointers were also -// encountered together as a pair. -// -// Semantically, this is equivalent to augmenting Indirect, SliceIndex, and -// MapIndex with pointer information for the x and y values. -// Suppose px and py are two pointers to compare, we then search the -// Path for whether px was ever encountered in the Path history of x, and -// similarly so with py. If either side has a cycle, the comparison is only -// equal if both px and py have a cycle resulting from the same PathStep. -// -// Using a map as a stack is more performant as we can perform cycle detection -// in O(1) instead of O(N) where N is len(Path). -type pointerPath struct { - // mx is keyed by x pointers, where the value is the associated y pointer. - mx map[value.Pointer]value.Pointer - // my is keyed by y pointers, where the value is the associated x pointer. - my map[value.Pointer]value.Pointer -} - -func (p *pointerPath) Init() { - p.mx = make(map[value.Pointer]value.Pointer) - p.my = make(map[value.Pointer]value.Pointer) -} - -// Push indicates intent to descend into pointers vx and vy where -// visited reports whether either has been seen before. If visited before, -// equal reports whether both pointers were encountered together. -// Pop must be called if and only if the pointers were never visited. -// -// The pointers vx and vy must be a reflect.Ptr, reflect.Slice, or reflect.Map -// and be non-nil. -func (p pointerPath) Push(vx, vy reflect.Value) (equal, visited bool) { - px := value.PointerOf(vx) - py := value.PointerOf(vy) - _, ok1 := p.mx[px] - _, ok2 := p.my[py] - if ok1 || ok2 { - equal = p.mx[px] == py && p.my[py] == px // Pointers paired together - return equal, true - } - p.mx[px] = py - p.my[py] = px - return false, false -} - -// Pop ascends from pointers vx and vy. -func (p pointerPath) Pop(vx, vy reflect.Value) { - delete(p.mx, value.PointerOf(vx)) - delete(p.my, value.PointerOf(vy)) -} - -// isExported reports whether the identifier is exported. -func isExported(id string) bool { - r, _ := utf8.DecodeRuneInString(id) - return unicode.IsUpper(r) -} diff --git a/vendor/github.com/google/go-cmp/cmp/report.go b/vendor/github.com/google/go-cmp/cmp/report.go deleted file mode 100644 index f43cd12eb..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2017, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -// defaultReporter implements the reporter interface. -// -// As Equal serially calls the PushStep, Report, and PopStep methods, the -// defaultReporter constructs a tree-based representation of the compared value -// and the result of each comparison (see valueNode). -// -// When the String method is called, the FormatDiff method transforms the -// valueNode tree into a textNode tree, which is a tree-based representation -// of the textual output (see textNode). -// -// Lastly, the textNode.String method produces the final report as a string. -type defaultReporter struct { - root *valueNode - curr *valueNode -} - -func (r *defaultReporter) PushStep(ps PathStep) { - r.curr = r.curr.PushStep(ps) - if r.root == nil { - r.root = r.curr - } -} -func (r *defaultReporter) Report(rs Result) { - r.curr.Report(rs) -} -func (r *defaultReporter) PopStep() { - r.curr = r.curr.PopStep() -} - -// String provides a full report of the differences detected as a structured -// literal in pseudo-Go syntax. String may only be called after the entire tree -// has been traversed. -func (r *defaultReporter) String() string { - assert(r.root != nil && r.curr == nil) - if r.root.NumDiff == 0 { - return "" - } - ptrs := new(pointerReferences) - text := formatOptions{}.FormatDiff(r.root, ptrs) - resolveReferences(text) - return text.String() -} - -func assert(ok bool) { - if !ok { - panic("assertion failure") - } -} diff --git a/vendor/github.com/google/go-cmp/cmp/report_compare.go b/vendor/github.com/google/go-cmp/cmp/report_compare.go deleted file mode 100644 index 2050bf6b4..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report_compare.go +++ /dev/null @@ -1,433 +0,0 @@ -// Copyright 2019, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "fmt" - "reflect" -) - -// numContextRecords is the number of surrounding equal records to print. -const numContextRecords = 2 - -type diffMode byte - -const ( - diffUnknown diffMode = 0 - diffIdentical diffMode = ' ' - diffRemoved diffMode = '-' - diffInserted diffMode = '+' -) - -type typeMode int - -const ( - // emitType always prints the type. - emitType typeMode = iota - // elideType never prints the type. - elideType - // autoType prints the type only for composite kinds - // (i.e., structs, slices, arrays, and maps). - autoType -) - -type formatOptions struct { - // DiffMode controls the output mode of FormatDiff. - // - // If diffUnknown, then produce a diff of the x and y values. - // If diffIdentical, then emit values as if they were equal. - // If diffRemoved, then only emit x values (ignoring y values). - // If diffInserted, then only emit y values (ignoring x values). - DiffMode diffMode - - // TypeMode controls whether to print the type for the current node. - // - // As a general rule of thumb, we always print the type of the next node - // after an interface, and always elide the type of the next node after - // a slice or map node. - TypeMode typeMode - - // formatValueOptions are options specific to printing reflect.Values. - formatValueOptions -} - -func (opts formatOptions) WithDiffMode(d diffMode) formatOptions { - opts.DiffMode = d - return opts -} -func (opts formatOptions) WithTypeMode(t typeMode) formatOptions { - opts.TypeMode = t - return opts -} -func (opts formatOptions) WithVerbosity(level int) formatOptions { - opts.VerbosityLevel = level - opts.LimitVerbosity = true - return opts -} -func (opts formatOptions) verbosity() uint { - switch { - case opts.VerbosityLevel < 0: - return 0 - case opts.VerbosityLevel > 16: - return 16 // some reasonable maximum to avoid shift overflow - default: - return uint(opts.VerbosityLevel) - } -} - -const maxVerbosityPreset = 6 - -// verbosityPreset modifies the verbosity settings given an index -// between 0 and maxVerbosityPreset, inclusive. -func verbosityPreset(opts formatOptions, i int) formatOptions { - opts.VerbosityLevel = int(opts.verbosity()) + 2*i - if i > 0 { - opts.AvoidStringer = true - } - if i >= maxVerbosityPreset { - opts.PrintAddresses = true - opts.QualifiedNames = true - } - return opts -} - -// FormatDiff converts a valueNode tree into a textNode tree, where the later -// is a textual representation of the differences detected in the former. -func (opts formatOptions) FormatDiff(v *valueNode, ptrs *pointerReferences) (out textNode) { - if opts.DiffMode == diffIdentical { - opts = opts.WithVerbosity(1) - } else if opts.verbosity() < 3 { - opts = opts.WithVerbosity(3) - } - - // Check whether we have specialized formatting for this node. - // This is not necessary, but helpful for producing more readable outputs. - if opts.CanFormatDiffSlice(v) { - return opts.FormatDiffSlice(v) - } - - var parentKind reflect.Kind - if v.parent != nil && v.parent.TransformerName == "" { - parentKind = v.parent.Type.Kind() - } - - // For leaf nodes, format the value based on the reflect.Values alone. - // As a special case, treat equal []byte as a leaf nodes. - isBytes := v.Type.Kind() == reflect.Slice && v.Type.Elem() == byteType - isEqualBytes := isBytes && v.NumDiff+v.NumIgnored+v.NumTransformed == 0 - if v.MaxDepth == 0 || isEqualBytes { - switch opts.DiffMode { - case diffUnknown, diffIdentical: - // Format Equal. - if v.NumDiff == 0 { - outx := opts.FormatValue(v.ValueX, parentKind, ptrs) - outy := opts.FormatValue(v.ValueY, parentKind, ptrs) - if v.NumIgnored > 0 && v.NumSame == 0 { - return textEllipsis - } else if outx.Len() < outy.Len() { - return outx - } else { - return outy - } - } - - // Format unequal. - assert(opts.DiffMode == diffUnknown) - var list textList - outx := opts.WithTypeMode(elideType).FormatValue(v.ValueX, parentKind, ptrs) - outy := opts.WithTypeMode(elideType).FormatValue(v.ValueY, parentKind, ptrs) - for i := 0; i <= maxVerbosityPreset && outx != nil && outy != nil && outx.Equal(outy); i++ { - opts2 := verbosityPreset(opts, i).WithTypeMode(elideType) - outx = opts2.FormatValue(v.ValueX, parentKind, ptrs) - outy = opts2.FormatValue(v.ValueY, parentKind, ptrs) - } - if outx != nil { - list = append(list, textRecord{Diff: '-', Value: outx}) - } - if outy != nil { - list = append(list, textRecord{Diff: '+', Value: outy}) - } - return opts.WithTypeMode(emitType).FormatType(v.Type, list) - case diffRemoved: - return opts.FormatValue(v.ValueX, parentKind, ptrs) - case diffInserted: - return opts.FormatValue(v.ValueY, parentKind, ptrs) - default: - panic("invalid diff mode") - } - } - - // Register slice element to support cycle detection. - if parentKind == reflect.Slice { - ptrRefs := ptrs.PushPair(v.ValueX, v.ValueY, opts.DiffMode, true) - defer ptrs.Pop() - defer func() { out = wrapTrunkReferences(ptrRefs, out) }() - } - - // Descend into the child value node. - if v.TransformerName != "" { - out := opts.WithTypeMode(emitType).FormatDiff(v.Value, ptrs) - out = &textWrap{Prefix: "Inverse(" + v.TransformerName + ", ", Value: out, Suffix: ")"} - return opts.FormatType(v.Type, out) - } else { - switch k := v.Type.Kind(); k { - case reflect.Struct, reflect.Array, reflect.Slice: - out = opts.formatDiffList(v.Records, k, ptrs) - out = opts.FormatType(v.Type, out) - case reflect.Map: - // Register map to support cycle detection. - ptrRefs := ptrs.PushPair(v.ValueX, v.ValueY, opts.DiffMode, false) - defer ptrs.Pop() - - out = opts.formatDiffList(v.Records, k, ptrs) - out = wrapTrunkReferences(ptrRefs, out) - out = opts.FormatType(v.Type, out) - case reflect.Ptr: - // Register pointer to support cycle detection. - ptrRefs := ptrs.PushPair(v.ValueX, v.ValueY, opts.DiffMode, false) - defer ptrs.Pop() - - out = opts.FormatDiff(v.Value, ptrs) - out = wrapTrunkReferences(ptrRefs, out) - out = &textWrap{Prefix: "&", Value: out} - case reflect.Interface: - out = opts.WithTypeMode(emitType).FormatDiff(v.Value, ptrs) - default: - panic(fmt.Sprintf("%v cannot have children", k)) - } - return out - } -} - -func (opts formatOptions) formatDiffList(recs []reportRecord, k reflect.Kind, ptrs *pointerReferences) textNode { - // Derive record name based on the data structure kind. - var name string - var formatKey func(reflect.Value) string - switch k { - case reflect.Struct: - name = "field" - opts = opts.WithTypeMode(autoType) - formatKey = func(v reflect.Value) string { return v.String() } - case reflect.Slice, reflect.Array: - name = "element" - opts = opts.WithTypeMode(elideType) - formatKey = func(reflect.Value) string { return "" } - case reflect.Map: - name = "entry" - opts = opts.WithTypeMode(elideType) - formatKey = func(v reflect.Value) string { return formatMapKey(v, false, ptrs) } - } - - maxLen := -1 - if opts.LimitVerbosity { - if opts.DiffMode == diffIdentical { - maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc... - } else { - maxLen = (1 << opts.verbosity()) << 1 // 2, 4, 8, 16, 32, 64, etc... - } - opts.VerbosityLevel-- - } - - // Handle unification. - switch opts.DiffMode { - case diffIdentical, diffRemoved, diffInserted: - var list textList - var deferredEllipsis bool // Add final "..." to indicate records were dropped - for _, r := range recs { - if len(list) == maxLen { - deferredEllipsis = true - break - } - - // Elide struct fields that are zero value. - if k == reflect.Struct { - var isZero bool - switch opts.DiffMode { - case diffIdentical: - isZero = r.Value.ValueX.IsZero() || r.Value.ValueY.IsZero() - case diffRemoved: - isZero = r.Value.ValueX.IsZero() - case diffInserted: - isZero = r.Value.ValueY.IsZero() - } - if isZero { - continue - } - } - // Elide ignored nodes. - if r.Value.NumIgnored > 0 && r.Value.NumSame+r.Value.NumDiff == 0 { - deferredEllipsis = !(k == reflect.Slice || k == reflect.Array) - if !deferredEllipsis { - list.AppendEllipsis(diffStats{}) - } - continue - } - if out := opts.FormatDiff(r.Value, ptrs); out != nil { - list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) - } - } - if deferredEllipsis { - list.AppendEllipsis(diffStats{}) - } - return &textWrap{Prefix: "{", Value: list, Suffix: "}"} - case diffUnknown: - default: - panic("invalid diff mode") - } - - // Handle differencing. - var numDiffs int - var list textList - var keys []reflect.Value // invariant: len(list) == len(keys) - groups := coalesceAdjacentRecords(name, recs) - maxGroup := diffStats{Name: name} - for i, ds := range groups { - if maxLen >= 0 && numDiffs >= maxLen { - maxGroup = maxGroup.Append(ds) - continue - } - - // Handle equal records. - if ds.NumDiff() == 0 { - // Compute the number of leading and trailing records to print. - var numLo, numHi int - numEqual := ds.NumIgnored + ds.NumIdentical - for numLo < numContextRecords && numLo+numHi < numEqual && i != 0 { - if r := recs[numLo].Value; r.NumIgnored > 0 && r.NumSame+r.NumDiff == 0 { - break - } - numLo++ - } - for numHi < numContextRecords && numLo+numHi < numEqual && i != len(groups)-1 { - if r := recs[numEqual-numHi-1].Value; r.NumIgnored > 0 && r.NumSame+r.NumDiff == 0 { - break - } - numHi++ - } - if numEqual-(numLo+numHi) == 1 && ds.NumIgnored == 0 { - numHi++ // Avoid pointless coalescing of a single equal record - } - - // Format the equal values. - for _, r := range recs[:numLo] { - out := opts.WithDiffMode(diffIdentical).FormatDiff(r.Value, ptrs) - list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) - keys = append(keys, r.Key) - } - if numEqual > numLo+numHi { - ds.NumIdentical -= numLo + numHi - list.AppendEllipsis(ds) - for len(keys) < len(list) { - keys = append(keys, reflect.Value{}) - } - } - for _, r := range recs[numEqual-numHi : numEqual] { - out := opts.WithDiffMode(diffIdentical).FormatDiff(r.Value, ptrs) - list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) - keys = append(keys, r.Key) - } - recs = recs[numEqual:] - continue - } - - // Handle unequal records. - for _, r := range recs[:ds.NumDiff()] { - switch { - case opts.CanFormatDiffSlice(r.Value): - out := opts.FormatDiffSlice(r.Value) - list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) - keys = append(keys, r.Key) - case r.Value.NumChildren == r.Value.MaxDepth: - outx := opts.WithDiffMode(diffRemoved).FormatDiff(r.Value, ptrs) - outy := opts.WithDiffMode(diffInserted).FormatDiff(r.Value, ptrs) - for i := 0; i <= maxVerbosityPreset && outx != nil && outy != nil && outx.Equal(outy); i++ { - opts2 := verbosityPreset(opts, i) - outx = opts2.WithDiffMode(diffRemoved).FormatDiff(r.Value, ptrs) - outy = opts2.WithDiffMode(diffInserted).FormatDiff(r.Value, ptrs) - } - if outx != nil { - list = append(list, textRecord{Diff: diffRemoved, Key: formatKey(r.Key), Value: outx}) - keys = append(keys, r.Key) - } - if outy != nil { - list = append(list, textRecord{Diff: diffInserted, Key: formatKey(r.Key), Value: outy}) - keys = append(keys, r.Key) - } - default: - out := opts.FormatDiff(r.Value, ptrs) - list = append(list, textRecord{Key: formatKey(r.Key), Value: out}) - keys = append(keys, r.Key) - } - } - recs = recs[ds.NumDiff():] - numDiffs += ds.NumDiff() - } - if maxGroup.IsZero() { - assert(len(recs) == 0) - } else { - list.AppendEllipsis(maxGroup) - for len(keys) < len(list) { - keys = append(keys, reflect.Value{}) - } - } - assert(len(list) == len(keys)) - - // For maps, the default formatting logic uses fmt.Stringer which may - // produce ambiguous output. Avoid calling String to disambiguate. - if k == reflect.Map { - var ambiguous bool - seenKeys := map[string]reflect.Value{} - for i, currKey := range keys { - if currKey.IsValid() { - strKey := list[i].Key - prevKey, seen := seenKeys[strKey] - if seen && prevKey.CanInterface() && currKey.CanInterface() { - ambiguous = prevKey.Interface() != currKey.Interface() - if ambiguous { - break - } - } - seenKeys[strKey] = currKey - } - } - if ambiguous { - for i, k := range keys { - if k.IsValid() { - list[i].Key = formatMapKey(k, true, ptrs) - } - } - } - } - - return &textWrap{Prefix: "{", Value: list, Suffix: "}"} -} - -// coalesceAdjacentRecords coalesces the list of records into groups of -// adjacent equal, or unequal counts. -func coalesceAdjacentRecords(name string, recs []reportRecord) (groups []diffStats) { - var prevCase int // Arbitrary index into which case last occurred - lastStats := func(i int) *diffStats { - if prevCase != i { - groups = append(groups, diffStats{Name: name}) - prevCase = i - } - return &groups[len(groups)-1] - } - for _, r := range recs { - switch rv := r.Value; { - case rv.NumIgnored > 0 && rv.NumSame+rv.NumDiff == 0: - lastStats(1).NumIgnored++ - case rv.NumDiff == 0: - lastStats(1).NumIdentical++ - case rv.NumDiff > 0 && !rv.ValueY.IsValid(): - lastStats(2).NumRemoved++ - case rv.NumDiff > 0 && !rv.ValueX.IsValid(): - lastStats(2).NumInserted++ - default: - lastStats(2).NumModified++ - } - } - return groups -} diff --git a/vendor/github.com/google/go-cmp/cmp/report_references.go b/vendor/github.com/google/go-cmp/cmp/report_references.go deleted file mode 100644 index be31b33a9..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report_references.go +++ /dev/null @@ -1,264 +0,0 @@ -// Copyright 2020, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "fmt" - "reflect" - "strings" - - "github.com/google/go-cmp/cmp/internal/flags" - "github.com/google/go-cmp/cmp/internal/value" -) - -const ( - pointerDelimPrefix = "⟪" - pointerDelimSuffix = "⟫" -) - -// formatPointer prints the address of the pointer. -func formatPointer(p value.Pointer, withDelims bool) string { - v := p.Uintptr() - if flags.Deterministic { - v = 0xdeadf00f // Only used for stable testing purposes - } - if withDelims { - return pointerDelimPrefix + formatHex(uint64(v)) + pointerDelimSuffix - } - return formatHex(uint64(v)) -} - -// pointerReferences is a stack of pointers visited so far. -type pointerReferences [][2]value.Pointer - -func (ps *pointerReferences) PushPair(vx, vy reflect.Value, d diffMode, deref bool) (pp [2]value.Pointer) { - if deref && vx.IsValid() { - vx = vx.Addr() - } - if deref && vy.IsValid() { - vy = vy.Addr() - } - switch d { - case diffUnknown, diffIdentical: - pp = [2]value.Pointer{value.PointerOf(vx), value.PointerOf(vy)} - case diffRemoved: - pp = [2]value.Pointer{value.PointerOf(vx), value.Pointer{}} - case diffInserted: - pp = [2]value.Pointer{value.Pointer{}, value.PointerOf(vy)} - } - *ps = append(*ps, pp) - return pp -} - -func (ps *pointerReferences) Push(v reflect.Value) (p value.Pointer, seen bool) { - p = value.PointerOf(v) - for _, pp := range *ps { - if p == pp[0] || p == pp[1] { - return p, true - } - } - *ps = append(*ps, [2]value.Pointer{p, p}) - return p, false -} - -func (ps *pointerReferences) Pop() { - *ps = (*ps)[:len(*ps)-1] -} - -// trunkReferences is metadata for a textNode indicating that the sub-tree -// represents the value for either pointer in a pair of references. -type trunkReferences struct{ pp [2]value.Pointer } - -// trunkReference is metadata for a textNode indicating that the sub-tree -// represents the value for the given pointer reference. -type trunkReference struct{ p value.Pointer } - -// leafReference is metadata for a textNode indicating that the value is -// truncated as it refers to another part of the tree (i.e., a trunk). -type leafReference struct{ p value.Pointer } - -func wrapTrunkReferences(pp [2]value.Pointer, s textNode) textNode { - switch { - case pp[0].IsNil(): - return &textWrap{Value: s, Metadata: trunkReference{pp[1]}} - case pp[1].IsNil(): - return &textWrap{Value: s, Metadata: trunkReference{pp[0]}} - case pp[0] == pp[1]: - return &textWrap{Value: s, Metadata: trunkReference{pp[0]}} - default: - return &textWrap{Value: s, Metadata: trunkReferences{pp}} - } -} -func wrapTrunkReference(p value.Pointer, printAddress bool, s textNode) textNode { - var prefix string - if printAddress { - prefix = formatPointer(p, true) - } - return &textWrap{Prefix: prefix, Value: s, Metadata: trunkReference{p}} -} -func makeLeafReference(p value.Pointer, printAddress bool) textNode { - out := &textWrap{Prefix: "(", Value: textEllipsis, Suffix: ")"} - var prefix string - if printAddress { - prefix = formatPointer(p, true) - } - return &textWrap{Prefix: prefix, Value: out, Metadata: leafReference{p}} -} - -// resolveReferences walks the textNode tree searching for any leaf reference -// metadata and resolves each against the corresponding trunk references. -// Since pointer addresses in memory are not particularly readable to the user, -// it replaces each pointer value with an arbitrary and unique reference ID. -func resolveReferences(s textNode) { - var walkNodes func(textNode, func(textNode)) - walkNodes = func(s textNode, f func(textNode)) { - f(s) - switch s := s.(type) { - case *textWrap: - walkNodes(s.Value, f) - case textList: - for _, r := range s { - walkNodes(r.Value, f) - } - } - } - - // Collect all trunks and leaves with reference metadata. - var trunks, leaves []*textWrap - walkNodes(s, func(s textNode) { - if s, ok := s.(*textWrap); ok { - switch s.Metadata.(type) { - case leafReference: - leaves = append(leaves, s) - case trunkReference, trunkReferences: - trunks = append(trunks, s) - } - } - }) - - // No leaf references to resolve. - if len(leaves) == 0 { - return - } - - // Collect the set of all leaf references to resolve. - leafPtrs := make(map[value.Pointer]bool) - for _, leaf := range leaves { - leafPtrs[leaf.Metadata.(leafReference).p] = true - } - - // Collect the set of trunk pointers that are always paired together. - // This allows us to assign a single ID to both pointers for brevity. - // If a pointer in a pair ever occurs by itself or as a different pair, - // then the pair is broken. - pairedTrunkPtrs := make(map[value.Pointer]value.Pointer) - unpair := func(p value.Pointer) { - if !pairedTrunkPtrs[p].IsNil() { - pairedTrunkPtrs[pairedTrunkPtrs[p]] = value.Pointer{} // invalidate other half - } - pairedTrunkPtrs[p] = value.Pointer{} // invalidate this half - } - for _, trunk := range trunks { - switch p := trunk.Metadata.(type) { - case trunkReference: - unpair(p.p) // standalone pointer cannot be part of a pair - case trunkReferences: - p0, ok0 := pairedTrunkPtrs[p.pp[0]] - p1, ok1 := pairedTrunkPtrs[p.pp[1]] - switch { - case !ok0 && !ok1: - // Register the newly seen pair. - pairedTrunkPtrs[p.pp[0]] = p.pp[1] - pairedTrunkPtrs[p.pp[1]] = p.pp[0] - case ok0 && ok1 && p0 == p.pp[1] && p1 == p.pp[0]: - // Exact pair already seen; do nothing. - default: - // Pair conflicts with some other pair; break all pairs. - unpair(p.pp[0]) - unpair(p.pp[1]) - } - } - } - - // Correlate each pointer referenced by leaves to a unique identifier, - // and print the IDs for each trunk that matches those pointers. - var nextID uint - ptrIDs := make(map[value.Pointer]uint) - newID := func() uint { - id := nextID - nextID++ - return id - } - for _, trunk := range trunks { - switch p := trunk.Metadata.(type) { - case trunkReference: - if print := leafPtrs[p.p]; print { - id, ok := ptrIDs[p.p] - if !ok { - id = newID() - ptrIDs[p.p] = id - } - trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id)) - } - case trunkReferences: - print0 := leafPtrs[p.pp[0]] - print1 := leafPtrs[p.pp[1]] - if print0 || print1 { - id0, ok0 := ptrIDs[p.pp[0]] - id1, ok1 := ptrIDs[p.pp[1]] - isPair := pairedTrunkPtrs[p.pp[0]] == p.pp[1] && pairedTrunkPtrs[p.pp[1]] == p.pp[0] - if isPair { - var id uint - assert(ok0 == ok1) // must be seen together or not at all - if ok0 { - assert(id0 == id1) // must have the same ID - id = id0 - } else { - id = newID() - ptrIDs[p.pp[0]] = id - ptrIDs[p.pp[1]] = id - } - trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id)) - } else { - if print0 && !ok0 { - id0 = newID() - ptrIDs[p.pp[0]] = id0 - } - if print1 && !ok1 { - id1 = newID() - ptrIDs[p.pp[1]] = id1 - } - switch { - case print0 && print1: - trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id0)+","+formatReference(id1)) - case print0: - trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id0)) - case print1: - trunk.Prefix = updateReferencePrefix(trunk.Prefix, formatReference(id1)) - } - } - } - } - } - - // Update all leaf references with the unique identifier. - for _, leaf := range leaves { - if id, ok := ptrIDs[leaf.Metadata.(leafReference).p]; ok { - leaf.Prefix = updateReferencePrefix(leaf.Prefix, formatReference(id)) - } - } -} - -func formatReference(id uint) string { - return fmt.Sprintf("ref#%d", id) -} - -func updateReferencePrefix(prefix, ref string) string { - if prefix == "" { - return pointerDelimPrefix + ref + pointerDelimSuffix - } - suffix := strings.TrimPrefix(prefix, pointerDelimPrefix) - return pointerDelimPrefix + ref + ": " + suffix -} diff --git a/vendor/github.com/google/go-cmp/cmp/report_reflect.go b/vendor/github.com/google/go-cmp/cmp/report_reflect.go deleted file mode 100644 index e39f42284..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report_reflect.go +++ /dev/null @@ -1,414 +0,0 @@ -// Copyright 2019, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "bytes" - "fmt" - "reflect" - "strconv" - "strings" - "unicode" - "unicode/utf8" - - "github.com/google/go-cmp/cmp/internal/value" -) - -var ( - anyType = reflect.TypeOf((*interface{})(nil)).Elem() - stringType = reflect.TypeOf((*string)(nil)).Elem() - bytesType = reflect.TypeOf((*[]byte)(nil)).Elem() - byteType = reflect.TypeOf((*byte)(nil)).Elem() -) - -type formatValueOptions struct { - // AvoidStringer controls whether to avoid calling custom stringer - // methods like error.Error or fmt.Stringer.String. - AvoidStringer bool - - // PrintAddresses controls whether to print the address of all pointers, - // slice elements, and maps. - PrintAddresses bool - - // QualifiedNames controls whether FormatType uses the fully qualified name - // (including the full package path as opposed to just the package name). - QualifiedNames bool - - // VerbosityLevel controls the amount of output to produce. - // A higher value produces more output. A value of zero or lower produces - // no output (represented using an ellipsis). - // If LimitVerbosity is false, then the level is treated as infinite. - VerbosityLevel int - - // LimitVerbosity specifies that formatting should respect VerbosityLevel. - LimitVerbosity bool -} - -// FormatType prints the type as if it were wrapping s. -// This may return s as-is depending on the current type and TypeMode mode. -func (opts formatOptions) FormatType(t reflect.Type, s textNode) textNode { - // Check whether to emit the type or not. - switch opts.TypeMode { - case autoType: - switch t.Kind() { - case reflect.Struct, reflect.Slice, reflect.Array, reflect.Map: - if s.Equal(textNil) { - return s - } - default: - return s - } - if opts.DiffMode == diffIdentical { - return s // elide type for identical nodes - } - case elideType: - return s - } - - // Determine the type label, applying special handling for unnamed types. - typeName := value.TypeString(t, opts.QualifiedNames) - if t.Name() == "" { - // According to Go grammar, certain type literals contain symbols that - // do not strongly bind to the next lexicographical token (e.g., *T). - switch t.Kind() { - case reflect.Chan, reflect.Func, reflect.Ptr: - typeName = "(" + typeName + ")" - } - } - return &textWrap{Prefix: typeName, Value: wrapParens(s)} -} - -// wrapParens wraps s with a set of parenthesis, but avoids it if the -// wrapped node itself is already surrounded by a pair of parenthesis or braces. -// It handles unwrapping one level of pointer-reference nodes. -func wrapParens(s textNode) textNode { - var refNode *textWrap - if s2, ok := s.(*textWrap); ok { - // Unwrap a single pointer reference node. - switch s2.Metadata.(type) { - case leafReference, trunkReference, trunkReferences: - refNode = s2 - if s3, ok := refNode.Value.(*textWrap); ok { - s2 = s3 - } - } - - // Already has delimiters that make parenthesis unnecessary. - hasParens := strings.HasPrefix(s2.Prefix, "(") && strings.HasSuffix(s2.Suffix, ")") - hasBraces := strings.HasPrefix(s2.Prefix, "{") && strings.HasSuffix(s2.Suffix, "}") - if hasParens || hasBraces { - return s - } - } - if refNode != nil { - refNode.Value = &textWrap{Prefix: "(", Value: refNode.Value, Suffix: ")"} - return s - } - return &textWrap{Prefix: "(", Value: s, Suffix: ")"} -} - -// FormatValue prints the reflect.Value, taking extra care to avoid descending -// into pointers already in ptrs. As pointers are visited, ptrs is also updated. -func (opts formatOptions) FormatValue(v reflect.Value, parentKind reflect.Kind, ptrs *pointerReferences) (out textNode) { - if !v.IsValid() { - return nil - } - t := v.Type() - - // Check slice element for cycles. - if parentKind == reflect.Slice { - ptrRef, visited := ptrs.Push(v.Addr()) - if visited { - return makeLeafReference(ptrRef, false) - } - defer ptrs.Pop() - defer func() { out = wrapTrunkReference(ptrRef, false, out) }() - } - - // Check whether there is an Error or String method to call. - if !opts.AvoidStringer && v.CanInterface() { - // Avoid calling Error or String methods on nil receivers since many - // implementations crash when doing so. - if (t.Kind() != reflect.Ptr && t.Kind() != reflect.Interface) || !v.IsNil() { - var prefix, strVal string - func() { - // Swallow and ignore any panics from String or Error. - defer func() { recover() }() - switch v := v.Interface().(type) { - case error: - strVal = v.Error() - prefix = "e" - case fmt.Stringer: - strVal = v.String() - prefix = "s" - } - }() - if prefix != "" { - return opts.formatString(prefix, strVal) - } - } - } - - // Check whether to explicitly wrap the result with the type. - var skipType bool - defer func() { - if !skipType { - out = opts.FormatType(t, out) - } - }() - - switch t.Kind() { - case reflect.Bool: - return textLine(fmt.Sprint(v.Bool())) - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return textLine(fmt.Sprint(v.Int())) - case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return textLine(fmt.Sprint(v.Uint())) - case reflect.Uint8: - if parentKind == reflect.Slice || parentKind == reflect.Array { - return textLine(formatHex(v.Uint())) - } - return textLine(fmt.Sprint(v.Uint())) - case reflect.Uintptr: - return textLine(formatHex(v.Uint())) - case reflect.Float32, reflect.Float64: - return textLine(fmt.Sprint(v.Float())) - case reflect.Complex64, reflect.Complex128: - return textLine(fmt.Sprint(v.Complex())) - case reflect.String: - return opts.formatString("", v.String()) - case reflect.UnsafePointer, reflect.Chan, reflect.Func: - return textLine(formatPointer(value.PointerOf(v), true)) - case reflect.Struct: - var list textList - v := makeAddressable(v) // needed for retrieveUnexportedField - maxLen := v.NumField() - if opts.LimitVerbosity { - maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc... - opts.VerbosityLevel-- - } - for i := 0; i < v.NumField(); i++ { - vv := v.Field(i) - if vv.IsZero() { - continue // Elide fields with zero values - } - if len(list) == maxLen { - list.AppendEllipsis(diffStats{}) - break - } - sf := t.Field(i) - if !isExported(sf.Name) { - vv = retrieveUnexportedField(v, sf, true) - } - s := opts.WithTypeMode(autoType).FormatValue(vv, t.Kind(), ptrs) - list = append(list, textRecord{Key: sf.Name, Value: s}) - } - return &textWrap{Prefix: "{", Value: list, Suffix: "}"} - case reflect.Slice: - if v.IsNil() { - return textNil - } - - // Check whether this is a []byte of text data. - if t.Elem() == byteType { - b := v.Bytes() - isPrintSpace := func(r rune) bool { return unicode.IsPrint(r) || unicode.IsSpace(r) } - if len(b) > 0 && utf8.Valid(b) && len(bytes.TrimFunc(b, isPrintSpace)) == 0 { - out = opts.formatString("", string(b)) - skipType = true - return opts.FormatType(t, out) - } - } - - fallthrough - case reflect.Array: - maxLen := v.Len() - if opts.LimitVerbosity { - maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc... - opts.VerbosityLevel-- - } - var list textList - for i := 0; i < v.Len(); i++ { - if len(list) == maxLen { - list.AppendEllipsis(diffStats{}) - break - } - s := opts.WithTypeMode(elideType).FormatValue(v.Index(i), t.Kind(), ptrs) - list = append(list, textRecord{Value: s}) - } - - out = &textWrap{Prefix: "{", Value: list, Suffix: "}"} - if t.Kind() == reflect.Slice && opts.PrintAddresses { - header := fmt.Sprintf("ptr:%v, len:%d, cap:%d", formatPointer(value.PointerOf(v), false), v.Len(), v.Cap()) - out = &textWrap{Prefix: pointerDelimPrefix + header + pointerDelimSuffix, Value: out} - } - return out - case reflect.Map: - if v.IsNil() { - return textNil - } - - // Check pointer for cycles. - ptrRef, visited := ptrs.Push(v) - if visited { - return makeLeafReference(ptrRef, opts.PrintAddresses) - } - defer ptrs.Pop() - - maxLen := v.Len() - if opts.LimitVerbosity { - maxLen = ((1 << opts.verbosity()) >> 1) << 2 // 0, 4, 8, 16, 32, etc... - opts.VerbosityLevel-- - } - var list textList - for _, k := range value.SortKeys(v.MapKeys()) { - if len(list) == maxLen { - list.AppendEllipsis(diffStats{}) - break - } - sk := formatMapKey(k, false, ptrs) - sv := opts.WithTypeMode(elideType).FormatValue(v.MapIndex(k), t.Kind(), ptrs) - list = append(list, textRecord{Key: sk, Value: sv}) - } - - out = &textWrap{Prefix: "{", Value: list, Suffix: "}"} - out = wrapTrunkReference(ptrRef, opts.PrintAddresses, out) - return out - case reflect.Ptr: - if v.IsNil() { - return textNil - } - - // Check pointer for cycles. - ptrRef, visited := ptrs.Push(v) - if visited { - out = makeLeafReference(ptrRef, opts.PrintAddresses) - return &textWrap{Prefix: "&", Value: out} - } - defer ptrs.Pop() - - // Skip the name only if this is an unnamed pointer type. - // Otherwise taking the address of a value does not reproduce - // the named pointer type. - if v.Type().Name() == "" { - skipType = true // Let the underlying value print the type instead - } - out = opts.FormatValue(v.Elem(), t.Kind(), ptrs) - out = wrapTrunkReference(ptrRef, opts.PrintAddresses, out) - out = &textWrap{Prefix: "&", Value: out} - return out - case reflect.Interface: - if v.IsNil() { - return textNil - } - // Interfaces accept different concrete types, - // so configure the underlying value to explicitly print the type. - return opts.WithTypeMode(emitType).FormatValue(v.Elem(), t.Kind(), ptrs) - default: - panic(fmt.Sprintf("%v kind not handled", v.Kind())) - } -} - -func (opts formatOptions) formatString(prefix, s string) textNode { - maxLen := len(s) - maxLines := strings.Count(s, "\n") + 1 - if opts.LimitVerbosity { - maxLen = (1 << opts.verbosity()) << 5 // 32, 64, 128, 256, etc... - maxLines = (1 << opts.verbosity()) << 2 // 4, 8, 16, 32, 64, etc... - } - - // For multiline strings, use the triple-quote syntax, - // but only use it when printing removed or inserted nodes since - // we only want the extra verbosity for those cases. - lines := strings.Split(strings.TrimSuffix(s, "\n"), "\n") - isTripleQuoted := len(lines) >= 4 && (opts.DiffMode == '-' || opts.DiffMode == '+') - for i := 0; i < len(lines) && isTripleQuoted; i++ { - lines[i] = strings.TrimPrefix(strings.TrimSuffix(lines[i], "\r"), "\r") // trim leading/trailing carriage returns for legacy Windows endline support - isPrintable := func(r rune) bool { - return unicode.IsPrint(r) || r == '\t' // specially treat tab as printable - } - line := lines[i] - isTripleQuoted = !strings.HasPrefix(strings.TrimPrefix(line, prefix), `"""`) && !strings.HasPrefix(line, "...") && strings.TrimFunc(line, isPrintable) == "" && len(line) <= maxLen - } - if isTripleQuoted { - var list textList - list = append(list, textRecord{Diff: opts.DiffMode, Value: textLine(prefix + `"""`), ElideComma: true}) - for i, line := range lines { - if numElided := len(lines) - i; i == maxLines-1 && numElided > 1 { - comment := commentString(fmt.Sprintf("%d elided lines", numElided)) - list = append(list, textRecord{Diff: opts.DiffMode, Value: textEllipsis, ElideComma: true, Comment: comment}) - break - } - list = append(list, textRecord{Diff: opts.DiffMode, Value: textLine(line), ElideComma: true}) - } - list = append(list, textRecord{Diff: opts.DiffMode, Value: textLine(prefix + `"""`), ElideComma: true}) - return &textWrap{Prefix: "(", Value: list, Suffix: ")"} - } - - // Format the string as a single-line quoted string. - if len(s) > maxLen+len(textEllipsis) { - return textLine(prefix + formatString(s[:maxLen]) + string(textEllipsis)) - } - return textLine(prefix + formatString(s)) -} - -// formatMapKey formats v as if it were a map key. -// The result is guaranteed to be a single line. -func formatMapKey(v reflect.Value, disambiguate bool, ptrs *pointerReferences) string { - var opts formatOptions - opts.DiffMode = diffIdentical - opts.TypeMode = elideType - opts.PrintAddresses = disambiguate - opts.AvoidStringer = disambiguate - opts.QualifiedNames = disambiguate - opts.VerbosityLevel = maxVerbosityPreset - opts.LimitVerbosity = true - s := opts.FormatValue(v, reflect.Map, ptrs).String() - return strings.TrimSpace(s) -} - -// formatString prints s as a double-quoted or backtick-quoted string. -func formatString(s string) string { - // Use quoted string if it the same length as a raw string literal. - // Otherwise, attempt to use the raw string form. - qs := strconv.Quote(s) - if len(qs) == 1+len(s)+1 { - return qs - } - - // Disallow newlines to ensure output is a single line. - // Only allow printable runes for readability purposes. - rawInvalid := func(r rune) bool { - return r == '`' || r == '\n' || !(unicode.IsPrint(r) || r == '\t') - } - if utf8.ValidString(s) && strings.IndexFunc(s, rawInvalid) < 0 { - return "`" + s + "`" - } - return qs -} - -// formatHex prints u as a hexadecimal integer in Go notation. -func formatHex(u uint64) string { - var f string - switch { - case u <= 0xff: - f = "0x%02x" - case u <= 0xffff: - f = "0x%04x" - case u <= 0xffffff: - f = "0x%06x" - case u <= 0xffffffff: - f = "0x%08x" - case u <= 0xffffffffff: - f = "0x%010x" - case u <= 0xffffffffffff: - f = "0x%012x" - case u <= 0xffffffffffffff: - f = "0x%014x" - case u <= 0xffffffffffffffff: - f = "0x%016x" - } - return fmt.Sprintf(f, u) -} diff --git a/vendor/github.com/google/go-cmp/cmp/report_slices.go b/vendor/github.com/google/go-cmp/cmp/report_slices.go deleted file mode 100644 index 23e444f62..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report_slices.go +++ /dev/null @@ -1,614 +0,0 @@ -// Copyright 2019, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "bytes" - "fmt" - "math" - "reflect" - "strconv" - "strings" - "unicode" - "unicode/utf8" - - "github.com/google/go-cmp/cmp/internal/diff" -) - -// CanFormatDiffSlice reports whether we support custom formatting for nodes -// that are slices of primitive kinds or strings. -func (opts formatOptions) CanFormatDiffSlice(v *valueNode) bool { - switch { - case opts.DiffMode != diffUnknown: - return false // Must be formatting in diff mode - case v.NumDiff == 0: - return false // No differences detected - case !v.ValueX.IsValid() || !v.ValueY.IsValid(): - return false // Both values must be valid - case v.NumIgnored > 0: - return false // Some ignore option was used - case v.NumTransformed > 0: - return false // Some transform option was used - case v.NumCompared > 1: - return false // More than one comparison was used - case v.NumCompared == 1 && v.Type.Name() != "": - // The need for cmp to check applicability of options on every element - // in a slice is a significant performance detriment for large []byte. - // The workaround is to specify Comparer(bytes.Equal), - // which enables cmp to compare []byte more efficiently. - // If they differ, we still want to provide batched diffing. - // The logic disallows named types since they tend to have their own - // String method, with nicer formatting than what this provides. - return false - } - - // Check whether this is an interface with the same concrete types. - t := v.Type - vx, vy := v.ValueX, v.ValueY - if t.Kind() == reflect.Interface && !vx.IsNil() && !vy.IsNil() && vx.Elem().Type() == vy.Elem().Type() { - vx, vy = vx.Elem(), vy.Elem() - t = vx.Type() - } - - // Check whether we provide specialized diffing for this type. - switch t.Kind() { - case reflect.String: - case reflect.Array, reflect.Slice: - // Only slices of primitive types have specialized handling. - switch t.Elem().Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, - reflect.Bool, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128: - default: - return false - } - - // Both slice values have to be non-empty. - if t.Kind() == reflect.Slice && (vx.Len() == 0 || vy.Len() == 0) { - return false - } - - // If a sufficient number of elements already differ, - // use specialized formatting even if length requirement is not met. - if v.NumDiff > v.NumSame { - return true - } - default: - return false - } - - // Use specialized string diffing for longer slices or strings. - const minLength = 32 - return vx.Len() >= minLength && vy.Len() >= minLength -} - -// FormatDiffSlice prints a diff for the slices (or strings) represented by v. -// This provides custom-tailored logic to make printing of differences in -// textual strings and slices of primitive kinds more readable. -func (opts formatOptions) FormatDiffSlice(v *valueNode) textNode { - assert(opts.DiffMode == diffUnknown) - t, vx, vy := v.Type, v.ValueX, v.ValueY - if t.Kind() == reflect.Interface { - vx, vy = vx.Elem(), vy.Elem() - t = vx.Type() - opts = opts.WithTypeMode(emitType) - } - - // Auto-detect the type of the data. - var sx, sy string - var ssx, ssy []string - var isString, isMostlyText, isPureLinedText, isBinary bool - switch { - case t.Kind() == reflect.String: - sx, sy = vx.String(), vy.String() - isString = true - case t.Kind() == reflect.Slice && t.Elem() == byteType: - sx, sy = string(vx.Bytes()), string(vy.Bytes()) - isString = true - case t.Kind() == reflect.Array: - // Arrays need to be addressable for slice operations to work. - vx2, vy2 := reflect.New(t).Elem(), reflect.New(t).Elem() - vx2.Set(vx) - vy2.Set(vy) - vx, vy = vx2, vy2 - } - if isString { - var numTotalRunes, numValidRunes, numLines, lastLineIdx, maxLineLen int - for i, r := range sx + sy { - numTotalRunes++ - if (unicode.IsPrint(r) || unicode.IsSpace(r)) && r != utf8.RuneError { - numValidRunes++ - } - if r == '\n' { - if maxLineLen < i-lastLineIdx { - maxLineLen = i - lastLineIdx - } - lastLineIdx = i + 1 - numLines++ - } - } - isPureText := numValidRunes == numTotalRunes - isMostlyText = float64(numValidRunes) > math.Floor(0.90*float64(numTotalRunes)) - isPureLinedText = isPureText && numLines >= 4 && maxLineLen <= 1024 - isBinary = !isMostlyText - - // Avoid diffing by lines if it produces a significantly more complex - // edit script than diffing by bytes. - if isPureLinedText { - ssx = strings.Split(sx, "\n") - ssy = strings.Split(sy, "\n") - esLines := diff.Difference(len(ssx), len(ssy), func(ix, iy int) diff.Result { - return diff.BoolResult(ssx[ix] == ssy[iy]) - }) - esBytes := diff.Difference(len(sx), len(sy), func(ix, iy int) diff.Result { - return diff.BoolResult(sx[ix] == sy[iy]) - }) - efficiencyLines := float64(esLines.Dist()) / float64(len(esLines)) - efficiencyBytes := float64(esBytes.Dist()) / float64(len(esBytes)) - quotedLength := len(strconv.Quote(sx + sy)) - unquotedLength := len(sx) + len(sy) - escapeExpansionRatio := float64(quotedLength) / float64(unquotedLength) - isPureLinedText = efficiencyLines < 4*efficiencyBytes || escapeExpansionRatio > 1.1 - } - } - - // Format the string into printable records. - var list textList - var delim string - switch { - // If the text appears to be multi-lined text, - // then perform differencing across individual lines. - case isPureLinedText: - list = opts.formatDiffSlice( - reflect.ValueOf(ssx), reflect.ValueOf(ssy), 1, "line", - func(v reflect.Value, d diffMode) textRecord { - s := formatString(v.Index(0).String()) - return textRecord{Diff: d, Value: textLine(s)} - }, - ) - delim = "\n" - - // If possible, use a custom triple-quote (""") syntax for printing - // differences in a string literal. This format is more readable, - // but has edge-cases where differences are visually indistinguishable. - // This format is avoided under the following conditions: - // - A line starts with `"""` - // - A line starts with "..." - // - A line contains non-printable characters - // - Adjacent different lines differ only by whitespace - // - // For example: - // - // """ - // ... // 3 identical lines - // foo - // bar - // - baz - // + BAZ - // """ - isTripleQuoted := true - prevRemoveLines := map[string]bool{} - prevInsertLines := map[string]bool{} - var list2 textList - list2 = append(list2, textRecord{Value: textLine(`"""`), ElideComma: true}) - for _, r := range list { - if !r.Value.Equal(textEllipsis) { - line, _ := strconv.Unquote(string(r.Value.(textLine))) - line = strings.TrimPrefix(strings.TrimSuffix(line, "\r"), "\r") // trim leading/trailing carriage returns for legacy Windows endline support - normLine := strings.Map(func(r rune) rune { - if unicode.IsSpace(r) { - return -1 // drop whitespace to avoid visually indistinguishable output - } - return r - }, line) - isPrintable := func(r rune) bool { - return unicode.IsPrint(r) || r == '\t' // specially treat tab as printable - } - isTripleQuoted = !strings.HasPrefix(line, `"""`) && !strings.HasPrefix(line, "...") && strings.TrimFunc(line, isPrintable) == "" - switch r.Diff { - case diffRemoved: - isTripleQuoted = isTripleQuoted && !prevInsertLines[normLine] - prevRemoveLines[normLine] = true - case diffInserted: - isTripleQuoted = isTripleQuoted && !prevRemoveLines[normLine] - prevInsertLines[normLine] = true - } - if !isTripleQuoted { - break - } - r.Value = textLine(line) - r.ElideComma = true - } - if !(r.Diff == diffRemoved || r.Diff == diffInserted) { // start a new non-adjacent difference group - prevRemoveLines = map[string]bool{} - prevInsertLines = map[string]bool{} - } - list2 = append(list2, r) - } - if r := list2[len(list2)-1]; r.Diff == diffIdentical && len(r.Value.(textLine)) == 0 { - list2 = list2[:len(list2)-1] // elide single empty line at the end - } - list2 = append(list2, textRecord{Value: textLine(`"""`), ElideComma: true}) - if isTripleQuoted { - var out textNode = &textWrap{Prefix: "(", Value: list2, Suffix: ")"} - switch t.Kind() { - case reflect.String: - if t != stringType { - out = opts.FormatType(t, out) - } - case reflect.Slice: - // Always emit type for slices since the triple-quote syntax - // looks like a string (not a slice). - opts = opts.WithTypeMode(emitType) - out = opts.FormatType(t, out) - } - return out - } - - // If the text appears to be single-lined text, - // then perform differencing in approximately fixed-sized chunks. - // The output is printed as quoted strings. - case isMostlyText: - list = opts.formatDiffSlice( - reflect.ValueOf(sx), reflect.ValueOf(sy), 64, "byte", - func(v reflect.Value, d diffMode) textRecord { - s := formatString(v.String()) - return textRecord{Diff: d, Value: textLine(s)} - }, - ) - - // If the text appears to be binary data, - // then perform differencing in approximately fixed-sized chunks. - // The output is inspired by hexdump. - case isBinary: - list = opts.formatDiffSlice( - reflect.ValueOf(sx), reflect.ValueOf(sy), 16, "byte", - func(v reflect.Value, d diffMode) textRecord { - var ss []string - for i := 0; i < v.Len(); i++ { - ss = append(ss, formatHex(v.Index(i).Uint())) - } - s := strings.Join(ss, ", ") - comment := commentString(fmt.Sprintf("%c|%v|", d, formatASCII(v.String()))) - return textRecord{Diff: d, Value: textLine(s), Comment: comment} - }, - ) - - // For all other slices of primitive types, - // then perform differencing in approximately fixed-sized chunks. - // The size of each chunk depends on the width of the element kind. - default: - var chunkSize int - if t.Elem().Kind() == reflect.Bool { - chunkSize = 16 - } else { - switch t.Elem().Bits() { - case 8: - chunkSize = 16 - case 16: - chunkSize = 12 - case 32: - chunkSize = 8 - default: - chunkSize = 8 - } - } - list = opts.formatDiffSlice( - vx, vy, chunkSize, t.Elem().Kind().String(), - func(v reflect.Value, d diffMode) textRecord { - var ss []string - for i := 0; i < v.Len(); i++ { - switch t.Elem().Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - ss = append(ss, fmt.Sprint(v.Index(i).Int())) - case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: - ss = append(ss, fmt.Sprint(v.Index(i).Uint())) - case reflect.Uint8, reflect.Uintptr: - ss = append(ss, formatHex(v.Index(i).Uint())) - case reflect.Bool, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128: - ss = append(ss, fmt.Sprint(v.Index(i).Interface())) - } - } - s := strings.Join(ss, ", ") - return textRecord{Diff: d, Value: textLine(s)} - }, - ) - } - - // Wrap the output with appropriate type information. - var out textNode = &textWrap{Prefix: "{", Value: list, Suffix: "}"} - if !isMostlyText { - // The "{...}" byte-sequence literal is not valid Go syntax for strings. - // Emit the type for extra clarity (e.g. "string{...}"). - if t.Kind() == reflect.String { - opts = opts.WithTypeMode(emitType) - } - return opts.FormatType(t, out) - } - switch t.Kind() { - case reflect.String: - out = &textWrap{Prefix: "strings.Join(", Value: out, Suffix: fmt.Sprintf(", %q)", delim)} - if t != stringType { - out = opts.FormatType(t, out) - } - case reflect.Slice: - out = &textWrap{Prefix: "bytes.Join(", Value: out, Suffix: fmt.Sprintf(", %q)", delim)} - if t != bytesType { - out = opts.FormatType(t, out) - } - } - return out -} - -// formatASCII formats s as an ASCII string. -// This is useful for printing binary strings in a semi-legible way. -func formatASCII(s string) string { - b := bytes.Repeat([]byte{'.'}, len(s)) - for i := 0; i < len(s); i++ { - if ' ' <= s[i] && s[i] <= '~' { - b[i] = s[i] - } - } - return string(b) -} - -func (opts formatOptions) formatDiffSlice( - vx, vy reflect.Value, chunkSize int, name string, - makeRec func(reflect.Value, diffMode) textRecord, -) (list textList) { - eq := func(ix, iy int) bool { - return vx.Index(ix).Interface() == vy.Index(iy).Interface() - } - es := diff.Difference(vx.Len(), vy.Len(), func(ix, iy int) diff.Result { - return diff.BoolResult(eq(ix, iy)) - }) - - appendChunks := func(v reflect.Value, d diffMode) int { - n0 := v.Len() - for v.Len() > 0 { - n := chunkSize - if n > v.Len() { - n = v.Len() - } - list = append(list, makeRec(v.Slice(0, n), d)) - v = v.Slice(n, v.Len()) - } - return n0 - v.Len() - } - - var numDiffs int - maxLen := -1 - if opts.LimitVerbosity { - maxLen = (1 << opts.verbosity()) << 2 // 4, 8, 16, 32, 64, etc... - opts.VerbosityLevel-- - } - - groups := coalesceAdjacentEdits(name, es) - groups = coalesceInterveningIdentical(groups, chunkSize/4) - groups = cleanupSurroundingIdentical(groups, eq) - maxGroup := diffStats{Name: name} - for i, ds := range groups { - if maxLen >= 0 && numDiffs >= maxLen { - maxGroup = maxGroup.Append(ds) - continue - } - - // Print equal. - if ds.NumDiff() == 0 { - // Compute the number of leading and trailing equal bytes to print. - var numLo, numHi int - numEqual := ds.NumIgnored + ds.NumIdentical - for numLo < chunkSize*numContextRecords && numLo+numHi < numEqual && i != 0 { - numLo++ - } - for numHi < chunkSize*numContextRecords && numLo+numHi < numEqual && i != len(groups)-1 { - numHi++ - } - if numEqual-(numLo+numHi) <= chunkSize && ds.NumIgnored == 0 { - numHi = numEqual - numLo // Avoid pointless coalescing of single equal row - } - - // Print the equal bytes. - appendChunks(vx.Slice(0, numLo), diffIdentical) - if numEqual > numLo+numHi { - ds.NumIdentical -= numLo + numHi - list.AppendEllipsis(ds) - } - appendChunks(vx.Slice(numEqual-numHi, numEqual), diffIdentical) - vx = vx.Slice(numEqual, vx.Len()) - vy = vy.Slice(numEqual, vy.Len()) - continue - } - - // Print unequal. - len0 := len(list) - nx := appendChunks(vx.Slice(0, ds.NumIdentical+ds.NumRemoved+ds.NumModified), diffRemoved) - vx = vx.Slice(nx, vx.Len()) - ny := appendChunks(vy.Slice(0, ds.NumIdentical+ds.NumInserted+ds.NumModified), diffInserted) - vy = vy.Slice(ny, vy.Len()) - numDiffs += len(list) - len0 - } - if maxGroup.IsZero() { - assert(vx.Len() == 0 && vy.Len() == 0) - } else { - list.AppendEllipsis(maxGroup) - } - return list -} - -// coalesceAdjacentEdits coalesces the list of edits into groups of adjacent -// equal or unequal counts. -// -// Example: -// -// Input: "..XXY...Y" -// Output: [ -// {NumIdentical: 2}, -// {NumRemoved: 2, NumInserted 1}, -// {NumIdentical: 3}, -// {NumInserted: 1}, -// ] -func coalesceAdjacentEdits(name string, es diff.EditScript) (groups []diffStats) { - var prevMode byte - lastStats := func(mode byte) *diffStats { - if prevMode != mode { - groups = append(groups, diffStats{Name: name}) - prevMode = mode - } - return &groups[len(groups)-1] - } - for _, e := range es { - switch e { - case diff.Identity: - lastStats('=').NumIdentical++ - case diff.UniqueX: - lastStats('!').NumRemoved++ - case diff.UniqueY: - lastStats('!').NumInserted++ - case diff.Modified: - lastStats('!').NumModified++ - } - } - return groups -} - -// coalesceInterveningIdentical coalesces sufficiently short (<= windowSize) -// equal groups into adjacent unequal groups that currently result in a -// dual inserted/removed printout. This acts as a high-pass filter to smooth -// out high-frequency changes within the windowSize. -// -// Example: -// -// WindowSize: 16, -// Input: [ -// {NumIdentical: 61}, // group 0 -// {NumRemoved: 3, NumInserted: 1}, // group 1 -// {NumIdentical: 6}, // ├── coalesce -// {NumInserted: 2}, // ├── coalesce -// {NumIdentical: 1}, // ├── coalesce -// {NumRemoved: 9}, // └── coalesce -// {NumIdentical: 64}, // group 2 -// {NumRemoved: 3, NumInserted: 1}, // group 3 -// {NumIdentical: 6}, // ├── coalesce -// {NumInserted: 2}, // ├── coalesce -// {NumIdentical: 1}, // ├── coalesce -// {NumRemoved: 7}, // ├── coalesce -// {NumIdentical: 1}, // ├── coalesce -// {NumRemoved: 2}, // └── coalesce -// {NumIdentical: 63}, // group 4 -// ] -// Output: [ -// {NumIdentical: 61}, -// {NumIdentical: 7, NumRemoved: 12, NumInserted: 3}, -// {NumIdentical: 64}, -// {NumIdentical: 8, NumRemoved: 12, NumInserted: 3}, -// {NumIdentical: 63}, -// ] -func coalesceInterveningIdentical(groups []diffStats, windowSize int) []diffStats { - groups, groupsOrig := groups[:0], groups - for i, ds := range groupsOrig { - if len(groups) >= 2 && ds.NumDiff() > 0 { - prev := &groups[len(groups)-2] // Unequal group - curr := &groups[len(groups)-1] // Equal group - next := &groupsOrig[i] // Unequal group - hadX, hadY := prev.NumRemoved > 0, prev.NumInserted > 0 - hasX, hasY := next.NumRemoved > 0, next.NumInserted > 0 - if ((hadX || hasX) && (hadY || hasY)) && curr.NumIdentical <= windowSize { - *prev = prev.Append(*curr).Append(*next) - groups = groups[:len(groups)-1] // Truncate off equal group - continue - } - } - groups = append(groups, ds) - } - return groups -} - -// cleanupSurroundingIdentical scans through all unequal groups, and -// moves any leading sequence of equal elements to the preceding equal group and -// moves and trailing sequence of equal elements to the succeeding equal group. -// -// This is necessary since coalesceInterveningIdentical may coalesce edit groups -// together such that leading/trailing spans of equal elements becomes possible. -// Note that this can occur even with an optimal diffing algorithm. -// -// Example: -// -// Input: [ -// {NumIdentical: 61}, -// {NumIdentical: 1 , NumRemoved: 11, NumInserted: 2}, // assume 3 leading identical elements -// {NumIdentical: 67}, -// {NumIdentical: 7, NumRemoved: 12, NumInserted: 3}, // assume 10 trailing identical elements -// {NumIdentical: 54}, -// ] -// Output: [ -// {NumIdentical: 64}, // incremented by 3 -// {NumRemoved: 9}, -// {NumIdentical: 67}, -// {NumRemoved: 9}, -// {NumIdentical: 64}, // incremented by 10 -// ] -func cleanupSurroundingIdentical(groups []diffStats, eq func(i, j int) bool) []diffStats { - var ix, iy int // indexes into sequence x and y - for i, ds := range groups { - // Handle equal group. - if ds.NumDiff() == 0 { - ix += ds.NumIdentical - iy += ds.NumIdentical - continue - } - - // Handle unequal group. - nx := ds.NumIdentical + ds.NumRemoved + ds.NumModified - ny := ds.NumIdentical + ds.NumInserted + ds.NumModified - var numLeadingIdentical, numTrailingIdentical int - for j := 0; j < nx && j < ny && eq(ix+j, iy+j); j++ { - numLeadingIdentical++ - } - for j := 0; j < nx && j < ny && eq(ix+nx-1-j, iy+ny-1-j); j++ { - numTrailingIdentical++ - } - if numIdentical := numLeadingIdentical + numTrailingIdentical; numIdentical > 0 { - if numLeadingIdentical > 0 { - // Remove leading identical span from this group and - // insert it into the preceding group. - if i-1 >= 0 { - groups[i-1].NumIdentical += numLeadingIdentical - } else { - // No preceding group exists, so prepend a new group, - // but do so after we finish iterating over all groups. - defer func() { - groups = append([]diffStats{{Name: groups[0].Name, NumIdentical: numLeadingIdentical}}, groups...) - }() - } - // Increment indexes since the preceding group would have handled this. - ix += numLeadingIdentical - iy += numLeadingIdentical - } - if numTrailingIdentical > 0 { - // Remove trailing identical span from this group and - // insert it into the succeeding group. - if i+1 < len(groups) { - groups[i+1].NumIdentical += numTrailingIdentical - } else { - // No succeeding group exists, so append a new group, - // but do so after we finish iterating over all groups. - defer func() { - groups = append(groups, diffStats{Name: groups[len(groups)-1].Name, NumIdentical: numTrailingIdentical}) - }() - } - // Do not increment indexes since the succeeding group will handle this. - } - - // Update this group since some identical elements were removed. - nx -= numIdentical - ny -= numIdentical - groups[i] = diffStats{Name: ds.Name, NumRemoved: nx, NumInserted: ny} - } - ix += nx - iy += ny - } - return groups -} diff --git a/vendor/github.com/google/go-cmp/cmp/report_text.go b/vendor/github.com/google/go-cmp/cmp/report_text.go deleted file mode 100644 index 388fcf571..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report_text.go +++ /dev/null @@ -1,432 +0,0 @@ -// Copyright 2019, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import ( - "bytes" - "fmt" - "math/rand" - "strings" - "time" - "unicode/utf8" - - "github.com/google/go-cmp/cmp/internal/flags" -) - -var randBool = rand.New(rand.NewSource(time.Now().Unix())).Intn(2) == 0 - -const maxColumnLength = 80 - -type indentMode int - -func (n indentMode) appendIndent(b []byte, d diffMode) []byte { - // The output of Diff is documented as being unstable to provide future - // flexibility in changing the output for more humanly readable reports. - // This logic intentionally introduces instability to the exact output - // so that users can detect accidental reliance on stability early on, - // rather than much later when an actual change to the format occurs. - if flags.Deterministic || randBool { - // Use regular spaces (U+0020). - switch d { - case diffUnknown, diffIdentical: - b = append(b, " "...) - case diffRemoved: - b = append(b, "- "...) - case diffInserted: - b = append(b, "+ "...) - } - } else { - // Use non-breaking spaces (U+00a0). - switch d { - case diffUnknown, diffIdentical: - b = append(b, "  "...) - case diffRemoved: - b = append(b, "- "...) - case diffInserted: - b = append(b, "+ "...) - } - } - return repeatCount(n).appendChar(b, '\t') -} - -type repeatCount int - -func (n repeatCount) appendChar(b []byte, c byte) []byte { - for ; n > 0; n-- { - b = append(b, c) - } - return b -} - -// textNode is a simplified tree-based representation of structured text. -// Possible node types are textWrap, textList, or textLine. -type textNode interface { - // Len reports the length in bytes of a single-line version of the tree. - // Nested textRecord.Diff and textRecord.Comment fields are ignored. - Len() int - // Equal reports whether the two trees are structurally identical. - // Nested textRecord.Diff and textRecord.Comment fields are compared. - Equal(textNode) bool - // String returns the string representation of the text tree. - // It is not guaranteed that len(x.String()) == x.Len(), - // nor that x.String() == y.String() implies that x.Equal(y). - String() string - - // formatCompactTo formats the contents of the tree as a single-line string - // to the provided buffer. Any nested textRecord.Diff and textRecord.Comment - // fields are ignored. - // - // However, not all nodes in the tree should be collapsed as a single-line. - // If a node can be collapsed as a single-line, it is replaced by a textLine - // node. Since the top-level node cannot replace itself, this also returns - // the current node itself. - // - // This does not mutate the receiver. - formatCompactTo([]byte, diffMode) ([]byte, textNode) - // formatExpandedTo formats the contents of the tree as a multi-line string - // to the provided buffer. In order for column alignment to operate well, - // formatCompactTo must be called before calling formatExpandedTo. - formatExpandedTo([]byte, diffMode, indentMode) []byte -} - -// textWrap is a wrapper that concatenates a prefix and/or a suffix -// to the underlying node. -type textWrap struct { - Prefix string // e.g., "bytes.Buffer{" - Value textNode // textWrap | textList | textLine - Suffix string // e.g., "}" - Metadata interface{} // arbitrary metadata; has no effect on formatting -} - -func (s *textWrap) Len() int { - return len(s.Prefix) + s.Value.Len() + len(s.Suffix) -} -func (s1 *textWrap) Equal(s2 textNode) bool { - if s2, ok := s2.(*textWrap); ok { - return s1.Prefix == s2.Prefix && s1.Value.Equal(s2.Value) && s1.Suffix == s2.Suffix - } - return false -} -func (s *textWrap) String() string { - var d diffMode - var n indentMode - _, s2 := s.formatCompactTo(nil, d) - b := n.appendIndent(nil, d) // Leading indent - b = s2.formatExpandedTo(b, d, n) // Main body - b = append(b, '\n') // Trailing newline - return string(b) -} -func (s *textWrap) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) { - n0 := len(b) // Original buffer length - b = append(b, s.Prefix...) - b, s.Value = s.Value.formatCompactTo(b, d) - b = append(b, s.Suffix...) - if _, ok := s.Value.(textLine); ok { - return b, textLine(b[n0:]) - } - return b, s -} -func (s *textWrap) formatExpandedTo(b []byte, d diffMode, n indentMode) []byte { - b = append(b, s.Prefix...) - b = s.Value.formatExpandedTo(b, d, n) - b = append(b, s.Suffix...) - return b -} - -// textList is a comma-separated list of textWrap or textLine nodes. -// The list may be formatted as multi-lines or single-line at the discretion -// of the textList.formatCompactTo method. -type textList []textRecord -type textRecord struct { - Diff diffMode // e.g., 0 or '-' or '+' - Key string // e.g., "MyField" - Value textNode // textWrap | textLine - ElideComma bool // avoid trailing comma - Comment fmt.Stringer // e.g., "6 identical fields" -} - -// AppendEllipsis appends a new ellipsis node to the list if none already -// exists at the end. If cs is non-zero it coalesces the statistics with the -// previous diffStats. -func (s *textList) AppendEllipsis(ds diffStats) { - hasStats := !ds.IsZero() - if len(*s) == 0 || !(*s)[len(*s)-1].Value.Equal(textEllipsis) { - if hasStats { - *s = append(*s, textRecord{Value: textEllipsis, ElideComma: true, Comment: ds}) - } else { - *s = append(*s, textRecord{Value: textEllipsis, ElideComma: true}) - } - return - } - if hasStats { - (*s)[len(*s)-1].Comment = (*s)[len(*s)-1].Comment.(diffStats).Append(ds) - } -} - -func (s textList) Len() (n int) { - for i, r := range s { - n += len(r.Key) - if r.Key != "" { - n += len(": ") - } - n += r.Value.Len() - if i < len(s)-1 { - n += len(", ") - } - } - return n -} - -func (s1 textList) Equal(s2 textNode) bool { - if s2, ok := s2.(textList); ok { - if len(s1) != len(s2) { - return false - } - for i := range s1 { - r1, r2 := s1[i], s2[i] - if !(r1.Diff == r2.Diff && r1.Key == r2.Key && r1.Value.Equal(r2.Value) && r1.Comment == r2.Comment) { - return false - } - } - return true - } - return false -} - -func (s textList) String() string { - return (&textWrap{Prefix: "{", Value: s, Suffix: "}"}).String() -} - -func (s textList) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) { - s = append(textList(nil), s...) // Avoid mutating original - - // Determine whether we can collapse this list as a single line. - n0 := len(b) // Original buffer length - var multiLine bool - for i, r := range s { - if r.Diff == diffInserted || r.Diff == diffRemoved { - multiLine = true - } - b = append(b, r.Key...) - if r.Key != "" { - b = append(b, ": "...) - } - b, s[i].Value = r.Value.formatCompactTo(b, d|r.Diff) - if _, ok := s[i].Value.(textLine); !ok { - multiLine = true - } - if r.Comment != nil { - multiLine = true - } - if i < len(s)-1 { - b = append(b, ", "...) - } - } - // Force multi-lined output when printing a removed/inserted node that - // is sufficiently long. - if (d == diffInserted || d == diffRemoved) && len(b[n0:]) > maxColumnLength { - multiLine = true - } - if !multiLine { - return b, textLine(b[n0:]) - } - return b, s -} - -func (s textList) formatExpandedTo(b []byte, d diffMode, n indentMode) []byte { - alignKeyLens := s.alignLens( - func(r textRecord) bool { - _, isLine := r.Value.(textLine) - return r.Key == "" || !isLine - }, - func(r textRecord) int { return utf8.RuneCountInString(r.Key) }, - ) - alignValueLens := s.alignLens( - func(r textRecord) bool { - _, isLine := r.Value.(textLine) - return !isLine || r.Value.Equal(textEllipsis) || r.Comment == nil - }, - func(r textRecord) int { return utf8.RuneCount(r.Value.(textLine)) }, - ) - - // Format lists of simple lists in a batched form. - // If the list is sequence of only textLine values, - // then batch multiple values on a single line. - var isSimple bool - for _, r := range s { - _, isLine := r.Value.(textLine) - isSimple = r.Diff == 0 && r.Key == "" && isLine && r.Comment == nil - if !isSimple { - break - } - } - if isSimple { - n++ - var batch []byte - emitBatch := func() { - if len(batch) > 0 { - b = n.appendIndent(append(b, '\n'), d) - b = append(b, bytes.TrimRight(batch, " ")...) - batch = batch[:0] - } - } - for _, r := range s { - line := r.Value.(textLine) - if len(batch)+len(line)+len(", ") > maxColumnLength { - emitBatch() - } - batch = append(batch, line...) - batch = append(batch, ", "...) - } - emitBatch() - n-- - return n.appendIndent(append(b, '\n'), d) - } - - // Format the list as a multi-lined output. - n++ - for i, r := range s { - b = n.appendIndent(append(b, '\n'), d|r.Diff) - if r.Key != "" { - b = append(b, r.Key+": "...) - } - b = alignKeyLens[i].appendChar(b, ' ') - - b = r.Value.formatExpandedTo(b, d|r.Diff, n) - if !r.ElideComma { - b = append(b, ',') - } - b = alignValueLens[i].appendChar(b, ' ') - - if r.Comment != nil { - b = append(b, " // "+r.Comment.String()...) - } - } - n-- - - return n.appendIndent(append(b, '\n'), d) -} - -func (s textList) alignLens( - skipFunc func(textRecord) bool, - lenFunc func(textRecord) int, -) []repeatCount { - var startIdx, endIdx, maxLen int - lens := make([]repeatCount, len(s)) - for i, r := range s { - if skipFunc(r) { - for j := startIdx; j < endIdx && j < len(s); j++ { - lens[j] = repeatCount(maxLen - lenFunc(s[j])) - } - startIdx, endIdx, maxLen = i+1, i+1, 0 - } else { - if maxLen < lenFunc(r) { - maxLen = lenFunc(r) - } - endIdx = i + 1 - } - } - for j := startIdx; j < endIdx && j < len(s); j++ { - lens[j] = repeatCount(maxLen - lenFunc(s[j])) - } - return lens -} - -// textLine is a single-line segment of text and is always a leaf node -// in the textNode tree. -type textLine []byte - -var ( - textNil = textLine("nil") - textEllipsis = textLine("...") -) - -func (s textLine) Len() int { - return len(s) -} -func (s1 textLine) Equal(s2 textNode) bool { - if s2, ok := s2.(textLine); ok { - return bytes.Equal([]byte(s1), []byte(s2)) - } - return false -} -func (s textLine) String() string { - return string(s) -} -func (s textLine) formatCompactTo(b []byte, d diffMode) ([]byte, textNode) { - return append(b, s...), s -} -func (s textLine) formatExpandedTo(b []byte, _ diffMode, _ indentMode) []byte { - return append(b, s...) -} - -type diffStats struct { - Name string - NumIgnored int - NumIdentical int - NumRemoved int - NumInserted int - NumModified int -} - -func (s diffStats) IsZero() bool { - s.Name = "" - return s == diffStats{} -} - -func (s diffStats) NumDiff() int { - return s.NumRemoved + s.NumInserted + s.NumModified -} - -func (s diffStats) Append(ds diffStats) diffStats { - assert(s.Name == ds.Name) - s.NumIgnored += ds.NumIgnored - s.NumIdentical += ds.NumIdentical - s.NumRemoved += ds.NumRemoved - s.NumInserted += ds.NumInserted - s.NumModified += ds.NumModified - return s -} - -// String prints a humanly-readable summary of coalesced records. -// -// Example: -// -// diffStats{Name: "Field", NumIgnored: 5}.String() => "5 ignored fields" -func (s diffStats) String() string { - var ss []string - var sum int - labels := [...]string{"ignored", "identical", "removed", "inserted", "modified"} - counts := [...]int{s.NumIgnored, s.NumIdentical, s.NumRemoved, s.NumInserted, s.NumModified} - for i, n := range counts { - if n > 0 { - ss = append(ss, fmt.Sprintf("%d %v", n, labels[i])) - } - sum += n - } - - // Pluralize the name (adjusting for some obscure English grammar rules). - name := s.Name - if sum > 1 { - name += "s" - if strings.HasSuffix(name, "ys") { - name = name[:len(name)-2] + "ies" // e.g., "entrys" => "entries" - } - } - - // Format the list according to English grammar (with Oxford comma). - switch n := len(ss); n { - case 0: - return "" - case 1, 2: - return strings.Join(ss, " and ") + " " + name - default: - return strings.Join(ss[:n-1], ", ") + ", and " + ss[n-1] + " " + name - } -} - -type commentString string - -func (s commentString) String() string { return string(s) } diff --git a/vendor/github.com/google/go-cmp/cmp/report_value.go b/vendor/github.com/google/go-cmp/cmp/report_value.go deleted file mode 100644 index 668d470fd..000000000 --- a/vendor/github.com/google/go-cmp/cmp/report_value.go +++ /dev/null @@ -1,121 +0,0 @@ -// Copyright 2019, The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package cmp - -import "reflect" - -// valueNode represents a single node within a report, which is a -// structured representation of the value tree, containing information -// regarding which nodes are equal or not. -type valueNode struct { - parent *valueNode - - Type reflect.Type - ValueX reflect.Value - ValueY reflect.Value - - // NumSame is the number of leaf nodes that are equal. - // All descendants are equal only if NumDiff is 0. - NumSame int - // NumDiff is the number of leaf nodes that are not equal. - NumDiff int - // NumIgnored is the number of leaf nodes that are ignored. - NumIgnored int - // NumCompared is the number of leaf nodes that were compared - // using an Equal method or Comparer function. - NumCompared int - // NumTransformed is the number of non-leaf nodes that were transformed. - NumTransformed int - // NumChildren is the number of transitive descendants of this node. - // This counts from zero; thus, leaf nodes have no descendants. - NumChildren int - // MaxDepth is the maximum depth of the tree. This counts from zero; - // thus, leaf nodes have a depth of zero. - MaxDepth int - - // Records is a list of struct fields, slice elements, or map entries. - Records []reportRecord // If populated, implies Value is not populated - - // Value is the result of a transformation, pointer indirect, of - // type assertion. - Value *valueNode // If populated, implies Records is not populated - - // TransformerName is the name of the transformer. - TransformerName string // If non-empty, implies Value is populated -} -type reportRecord struct { - Key reflect.Value // Invalid for slice element - Value *valueNode -} - -func (parent *valueNode) PushStep(ps PathStep) (child *valueNode) { - vx, vy := ps.Values() - child = &valueNode{parent: parent, Type: ps.Type(), ValueX: vx, ValueY: vy} - switch s := ps.(type) { - case StructField: - assert(parent.Value == nil) - parent.Records = append(parent.Records, reportRecord{Key: reflect.ValueOf(s.Name()), Value: child}) - case SliceIndex: - assert(parent.Value == nil) - parent.Records = append(parent.Records, reportRecord{Value: child}) - case MapIndex: - assert(parent.Value == nil) - parent.Records = append(parent.Records, reportRecord{Key: s.Key(), Value: child}) - case Indirect: - assert(parent.Value == nil && parent.Records == nil) - parent.Value = child - case TypeAssertion: - assert(parent.Value == nil && parent.Records == nil) - parent.Value = child - case Transform: - assert(parent.Value == nil && parent.Records == nil) - parent.Value = child - parent.TransformerName = s.Name() - parent.NumTransformed++ - default: - assert(parent == nil) // Must be the root step - } - return child -} - -func (r *valueNode) Report(rs Result) { - assert(r.MaxDepth == 0) // May only be called on leaf nodes - - if rs.ByIgnore() { - r.NumIgnored++ - } else { - if rs.Equal() { - r.NumSame++ - } else { - r.NumDiff++ - } - } - assert(r.NumSame+r.NumDiff+r.NumIgnored == 1) - - if rs.ByMethod() { - r.NumCompared++ - } - if rs.ByFunc() { - r.NumCompared++ - } - assert(r.NumCompared <= 1) -} - -func (child *valueNode) PopStep() (parent *valueNode) { - if child.parent == nil { - return nil - } - parent = child.parent - parent.NumSame += child.NumSame - parent.NumDiff += child.NumDiff - parent.NumIgnored += child.NumIgnored - parent.NumCompared += child.NumCompared - parent.NumTransformed += child.NumTransformed - parent.NumChildren += child.NumChildren + 1 - if parent.MaxDepth < child.MaxDepth+1 { - parent.MaxDepth = child.MaxDepth + 1 - } - return parent -} diff --git a/vendor/github.com/kyokomi/emoji/v2/emoji_codemap.go b/vendor/github.com/kyokomi/emoji/v2/emoji_codemap.go index a6e039e4b..017a5c478 100644 --- a/vendor/github.com/kyokomi/emoji/v2/emoji_codemap.go +++ b/vendor/github.com/kyokomi/emoji/v2/emoji_codemap.go @@ -62,6 +62,7 @@ func emojiCode() map[string]string { ":Leo:": "\u264c", ":Libra:": "\u264e", ":Mrs._Claus:": "\U0001f936", + ":Mx_Claus:": "\U0001f9d1\u200d\U0001f384", ":NEW_button:": "\U0001f195", ":NG_button:": "\U0001f196", ":OK_button:": "\U0001f197", @@ -220,6 +221,7 @@ func emojiCode() map[string]string { ":bald_man:": "\U0001f468\u200d\U0001f9b2", ":bald_person:": "\U0001f9d1\u200d\U0001f9b2", ":bald_woman:": "\U0001f469\u200d\U0001f9b2", + ":ballet_dancer:": "\U0001f9d1\u200d\U0001fa70", ":ballet_shoes:": "\U0001fa70", ":balloon:": "\U0001f388", ":ballot_box:": "\U0001f5f3", @@ -764,6 +766,7 @@ func emojiCode() map[string]string { ":disappointed_face:": "\U0001f61e", ":disappointed_relieved:": "\U0001f625", ":disguised_face:": "\U0001f978", + ":distorted_face:": "\U0001faea", ":divide:": "\u2797", ":dividers:": "\U0001f5c2", ":diving_mask:": "\U0001f93f", @@ -890,6 +893,7 @@ func emojiCode() map[string]string { ":face_savoring_food:": "\U0001f60b", ":face_screaming_in_fear:": "\U0001f631", ":face_vomiting:": "\U0001f92e", + ":face_with_bags_under_eyes:": "\U0001fae9", ":face_with_cowboy_hat:": "\U0001f920", ":face_with_crossed-out_eyes:": "\U0001f635", ":face_with_diagonal_mouth:": "\U0001fae4", @@ -1018,12 +1022,14 @@ func emojiCode() map[string]string { ":ferry:": "\u26f4\ufe0f", ":field_hockey:": "\U0001f3d1", ":field_hockey_stick_and_ball:": "\U0001f3d1", + ":fight_cloud:": "\U0001faef", ":fiji:": "\U0001f1eb\U0001f1ef", ":file_cabinet:": "\U0001f5c4\ufe0f", ":file_folder:": "\U0001f4c1", ":film_frames:": "\U0001f39e\ufe0f", ":film_projector:": "\U0001f4fd\ufe0f", ":film_strip:": "\U0001f39e\ufe0f", + ":fingerprint:": "\U0001fac6", ":fingers_crossed:": "\U0001f91e", ":fingers_crossed_tone1:": "\U0001f91e\U0001f3fb", ":fingers_crossed_tone2:": "\U0001f91e\U0001f3fc", @@ -1251,6 +1257,7 @@ func emojiCode() map[string]string { ":flag-rs:": "\U0001f1f7\U0001f1f8", ":flag-rw:": "\U0001f1f7\U0001f1fc", ":flag-sa:": "\U0001f1f8\U0001f1e6", + ":flag-sark:": "\U0001f1e8\U0001f1f6", ":flag-sb:": "\U0001f1f8\U0001f1e7", ":flag-sc:": "\U0001f1f8\U0001f1e8", ":flag-scotland:": "\U0001f3f4\U000e0067\U000e0062\U000e0073\U000e0063\U000e0074\U000e007f", @@ -1504,6 +1511,7 @@ func emojiCode() map[string]string { ":flag_Réunion:": "\U0001f1f7\U0001f1ea", ":flag_Samoa:": "\U0001f1fc\U0001f1f8", ":flag_San_Marino:": "\U0001f1f8\U0001f1f2", + ":flag_Sark:": "\U0001f1e8\U0001f1f6", ":flag_Saudi_Arabia:": "\U0001f1f8\U0001f1e6", ":flag_Scotland:": "\U0001f3f4\U000e0067\U000e0062\U000e0073\U000e0063\U000e0074\U000e007f", ":flag_Senegal:": "\U0001f1f8\U0001f1f3", @@ -1995,6 +2003,7 @@ func emojiCode() map[string]string { ":haircut:": "\U0001f487\u200d\u2640\ufe0f", ":haircut_man:": "\U0001f487\u200d\u2642\ufe0f", ":haircut_woman:": "\U0001f487\u200d\u2640\ufe0f", + ":hairy_creature:": "\U0001fac8", ":haiti:": "\U0001f1ed\U0001f1f9", ":hamburger:": "\U0001f354", ":hammer:": "\U0001f528", @@ -2012,43 +2021,44 @@ func emojiCode() map[string]string { ":hand_splayed_tone5:": "\U0001f590\U0001f3ff", ":hand_with_fingers_splayed:": "\U0001f590", ":hand_with_index_finger_and_thumb_crossed:": "\U0001faf0", - ":handbag:": "\U0001f45c", - ":handball:": "\U0001f93e", - ":handball_person:": "\U0001f93e", - ":handshake:": "\U0001f91d", - ":hankey:": "\U0001f4a9", - ":hash:": "#\ufe0f\u20e3", - ":hatched_chick:": "\U0001f425", - ":hatching_chick:": "\U0001f423", - ":head_bandage:": "\U0001f915", - ":head_shaking_horizontally:": "\U0001f642\u200d\u2194\ufe0f", - ":head_shaking_vertically:": "\U0001f642\u200d\u2195\ufe0f", - ":headphone:": "\U0001f3a7", - ":headphones:": "\U0001f3a7", - ":headstone:": "\U0001faa6", - ":health_worker:": "\U0001f9d1\u200d\u2695\ufe0f", - ":hear-no-evil_monkey:": "\U0001f649", - ":hear_no_evil:": "\U0001f649", - ":heard_mcdonald_islands:": "\U0001f1ed\U0001f1f2", - ":heart:": "\u2764\ufe0f", - ":heart_decoration:": "\U0001f49f", - ":heart_exclamation:": "\u2763", - ":heart_eyes:": "\U0001f60d", - ":heart_eyes_cat:": "\U0001f63b", - ":heart_hands:": "\U0001faf6", - ":heart_on_fire:": "\u2764\ufe0f\u200d\U0001f525", - ":heart_suit:": "\u2665", - ":heart_with_arrow:": "\U0001f498", - ":heart_with_ribbon:": "\U0001f49d", - ":heartbeat:": "\U0001f493", - ":heartpulse:": "\U0001f497", - ":hearts:": "\u2665\ufe0f", - ":heavy_check_mark:": "\u2714\ufe0f", - ":heavy_division_sign:": "\u2797", - ":heavy_dollar_sign:": "\U0001f4b2", - ":heavy_equals_sign:": "\U0001f7f0", - ":heavy_exclamation_mark:": "\u2757", - ":heavy_heart_exclamation:": "\u2763\ufe0f", + ":handbag:": "\U0001f45c", + ":handball:": "\U0001f93e", + ":handball_person:": "\U0001f93e", + ":handshake:": "\U0001f91d", + ":hankey:": "\U0001f4a9", + ":harp:": "\U0001fa89", + ":hash:": "#\ufe0f\u20e3", + ":hatched_chick:": "\U0001f425", + ":hatching_chick:": "\U0001f423", + ":head_bandage:": "\U0001f915", + ":head_shaking_horizontally:": "\U0001f642\u200d\u2194\ufe0f", + ":head_shaking_vertically:": "\U0001f642\u200d\u2195\ufe0f", + ":headphone:": "\U0001f3a7", + ":headphones:": "\U0001f3a7", + ":headstone:": "\U0001faa6", + ":health_worker:": "\U0001f9d1\u200d\u2695\ufe0f", + ":hear-no-evil_monkey:": "\U0001f649", + ":hear_no_evil:": "\U0001f649", + ":heard_mcdonald_islands:": "\U0001f1ed\U0001f1f2", + ":heart:": "\u2764\ufe0f", + ":heart_decoration:": "\U0001f49f", + ":heart_exclamation:": "\u2763", + ":heart_eyes:": "\U0001f60d", + ":heart_eyes_cat:": "\U0001f63b", + ":heart_hands:": "\U0001faf6", + ":heart_on_fire:": "\u2764\ufe0f\u200d\U0001f525", + ":heart_suit:": "\u2665", + ":heart_with_arrow:": "\U0001f498", + ":heart_with_ribbon:": "\U0001f49d", + ":heartbeat:": "\U0001f493", + ":heartpulse:": "\U0001f497", + ":hearts:": "\u2665\ufe0f", + ":heavy_check_mark:": "\u2714\ufe0f", + ":heavy_division_sign:": "\u2797", + ":heavy_dollar_sign:": "\U0001f4b2", + ":heavy_equals_sign:": "\U0001f7f0", + ":heavy_exclamation_mark:": "\u2757", + ":heavy_heart_exclamation:": "\u2763\ufe0f", ":heavy_heart_exclamation_mark_ornament:": "\u2763\ufe0f", ":heavy_minus_sign:": "\u2796", ":heavy_multiplication_x:": "\u2716\ufe0f", @@ -2237,6 +2247,7 @@ func emojiCode() map[string]string { ":ladder:": "\U0001fa9c", ":lady_beetle:": "\U0001f41e", ":ladybug:": "\U0001f41e", + ":landslide:": "\U0001f6d8", ":lantern:": "\U0001f3ee", ":laos:": "\U0001f1f1\U0001f1e6", ":laptop:": "\U0001f4bb", @@ -2263,6 +2274,7 @@ func emojiCode() map[string]string { ":latvia:": "\U0001f1f1\U0001f1fb", ":laughing:": "\U0001f606", ":leaf_fluttering_in_wind:": "\U0001f343", + ":leafless_tree:": "\U0001fabe", ":leafy_green:": "\U0001f96c", ":leaves:": "\U0001f343", ":lebanon:": "\U0001f1f1\U0001f1e7", @@ -3146,6 +3158,7 @@ func emojiCode() map[string]string { ":orange_heart:": "\U0001f9e1", ":orange_square:": "\U0001f7e7", ":orangutan:": "\U0001f9a7", + ":orca:": "\U0001facd", ":orthodox_cross:": "\u2626\ufe0f", ":otter:": "\U0001f9a6", ":outbox_tray:": "\U0001f4e4", @@ -3702,6 +3715,7 @@ func emojiCode() map[string]string { ":rolling_on_the_floor_laughing:": "\U0001f923", ":romania:": "\U0001f1f7\U0001f1f4", ":rooster:": "\U0001f413", + ":root_vegetable:": "\U0001fadc", ":rose:": "\U0001f339", ":rosette:": "\U0001f3f5\ufe0f", ":rotating_light:": "\U0001f6a8", @@ -3810,6 +3824,7 @@ func emojiCode() map[string]string { ":shopping_trolley:": "\U0001f6d2", ":shortcake:": "\U0001f370", ":shorts:": "\U0001fa73", + ":shovel:": "\U0001fa8f", ":shower:": "\U0001f6bf", ":shrimp:": "\U0001f990", ":shrug:": "\U0001f937", @@ -3938,6 +3953,7 @@ func emojiCode() map[string]string { ":spiral_note_pad:": "\U0001f5d2\ufe0f", ":spiral_notepad:": "\U0001f5d2", ":spiral_shell:": "\U0001f41a", + ":splatter:": "\U0001fadf", ":spock-hand:": "\U0001f596", ":sponge:": "\U0001f9fd", ":spoon:": "\U0001f944", @@ -4129,6 +4145,7 @@ func emojiCode() map[string]string { ":tram_car:": "\U0001f68b", ":transgender_flag:": "\U0001f3f3\ufe0f\u200d\u26a7\ufe0f", ":transgender_symbol:": "\u26a7\ufe0f", + ":treasure_chest:": "\U0001fa8e", ":triangular_flag:": "\U0001f6a9", ":triangular_flag_on_post:": "\U0001f6a9", ":triangular_ruler:": "\U0001f4d0", @@ -4139,6 +4156,7 @@ func emojiCode() map[string]string { ":triumph:": "\U0001f624", ":troll:": "\U0001f9cc", ":trolleybus:": "\U0001f68e", + ":trombone:": "\U0001fa8a", ":trophy:": "\U0001f3c6", ":tropical_drink:": "\U0001f379", ":tropical_fish:": "\U0001f420", @@ -4881,6 +4899,7 @@ func emojiRevCode() map[string][]string { "\U0001f1e8\U0001f1f3": {":cn:", ":flag_cn:", ":flag_China:"}, "\U0001f1e8\U0001f1f4": {":flag-co:", ":flag_co:", ":colombia:", ":flag_Colombia:"}, "\U0001f1e8\U0001f1f5": {":flag-cp:", ":flag_cp:", ":clipperton_island:", ":flag_Clipperton_Island:"}, + "\U0001f1e8\U0001f1f6": {":flag-sark:", ":flag_Sark:"}, "\U0001f1e8\U0001f1f7": {":flag-cr:", ":flag_cr:", ":costa_rica:", ":flag_Costa_Rica:"}, "\U0001f1e8\U0001f1fa": {":cuba:", ":flag-cu:", ":flag_cu:", ":flag_Cuba:"}, "\U0001f1e8\U0001f1fb": {":flag-cv:", ":flag_cv:", ":cape_verde:", ":flag_Cape_Verde:"}, @@ -5246,7 +5265,7 @@ func emojiRevCode() map[string][]string { "\U0001f381": {":gift:", ":wrapped_gift:"}, "\U0001f382": {":birthday:", ":birthday_cake:"}, "\U0001f383": {":jack-o-lantern:", ":jack_o_lantern:"}, - "\U0001f384": {":Christmas_tree:", ":christmas_tree:"}, + "\U0001f384": {":christmas_tree:", ":Christmas_tree:"}, "\U0001f385": {":santa:", ":Santa_Claus:"}, "\U0001f385\U0001f3fb": {":santa_tone1:"}, "\U0001f385\U0001f3fc": {":santa_tone2:"}, @@ -5465,7 +5484,7 @@ func emojiRevCode() map[string][]string { "\U0001f3ec": {":department_store:"}, "\U0001f3ed": {":factory:"}, "\U0001f3ee": {":lantern:", ":izakaya_lantern:", ":red_paper_lantern:"}, - "\U0001f3ef": {":Japanese_castle:", ":japanese_castle:"}, + "\U0001f3ef": {":japanese_castle:", ":Japanese_castle:"}, "\U0001f3f0": {":castle:", ":european_castle:"}, "\U0001f3f3": {":flag_white:", ":white_flag:"}, "\U0001f3f3\ufe0f": {":waving_white_flag:"}, @@ -5608,7 +5627,7 @@ func emojiRevCode() map[string][]string { "\U0001f44b\U0001f3fd": {":wave_tone3:"}, "\U0001f44b\U0001f3fe": {":wave_tone4:"}, "\U0001f44b\U0001f3ff": {":wave_tone5:"}, - "\U0001f44c": {":OK_hand:", ":ok_hand:"}, + "\U0001f44c": {":ok_hand:", ":OK_hand:"}, "\U0001f44c\U0001f3fb": {":ok_hand_tone1:"}, "\U0001f44c\U0001f3fc": {":ok_hand_tone2:"}, "\U0001f44c\U0001f3fd": {":ok_hand_tone3:"}, @@ -6169,7 +6188,7 @@ func emojiRevCode() map[string][]string { "\U0001f4a1": {":bulb:", ":light_bulb:"}, "\U0001f4a2": {":anger:", ":anger_symbol:"}, "\U0001f4a3": {":bomb:"}, - "\U0001f4a4": {":ZZZ:", ":zzz:"}, + "\U0001f4a4": {":zzz:", ":ZZZ:"}, "\U0001f4a5": {":boom:", ":collision:"}, "\U0001f4a6": {":sweat_drops:", ":sweat_droplets:"}, "\U0001f4a7": {":droplet:"}, @@ -6466,8 +6485,8 @@ func emojiRevCode() map[string][]string { "\U0001f5fa": {":map:"}, "\U0001f5fa\ufe0f": {":world_map:"}, "\U0001f5fb": {":mount_fuji:"}, - "\U0001f5fc": {":Tokyo_tower:", ":tokyo_tower:"}, - "\U0001f5fd": {":Statue_of_Liberty:", ":statue_of_liberty:"}, + "\U0001f5fc": {":tokyo_tower:", ":Tokyo_tower:"}, + "\U0001f5fd": {":statue_of_liberty:", ":Statue_of_Liberty:"}, "\U0001f5fe": {":japan:", ":map_of_Japan:"}, "\U0001f5ff": {":moai:", ":moyai:"}, "\U0001f600": {":grinning:", ":grinning_face:"}, @@ -6544,7 +6563,7 @@ func emojiRevCode() map[string][]string { "\U0001f642\u200d\u2195\ufe0f": {":head_shaking_vertically:"}, "\U0001f643": {":upside_down:", ":upside-down_face:", ":upside_down_face:"}, "\U0001f644": {":roll_eyes:", ":rolling_eyes:", ":face_with_rolling_eyes:"}, - "\U0001f645": {":person_gesturing_NO:", ":person_gesturing_no:"}, + "\U0001f645": {":person_gesturing_no:", ":person_gesturing_NO:"}, "\U0001f645\U0001f3fb": {":person_gesturing_no_tone1:"}, "\U0001f645\U0001f3fb\u200d\u2640\ufe0f": {":woman_gesturing_no_tone1:"}, "\U0001f645\U0001f3fb\u200d\u2642\ufe0f": {":man_gesturing_no_tone1:"}, @@ -6560,9 +6579,9 @@ func emojiRevCode() map[string][]string { "\U0001f645\U0001f3ff": {":person_gesturing_no_tone5:"}, "\U0001f645\U0001f3ff\u200d\u2640\ufe0f": {":woman_gesturing_no_tone5:"}, "\U0001f645\U0001f3ff\u200d\u2642\ufe0f": {":man_gesturing_no_tone5:"}, - "\U0001f645\u200d\u2640\ufe0f": {":no_good:", ":ng_woman:", ":no_good_woman:", ":woman-gesturing-no:", ":woman_gesturing_NO:", ":woman_gesturing_no:"}, - "\U0001f645\u200d\u2642\ufe0f": {":ng_man:", ":no_good_man:", ":man-gesturing-no:", ":man_gesturing_NO:", ":man_gesturing_no:"}, - "\U0001f646": {":ok_person:", ":person_gesturing_OK:", ":person_gesturing_ok:"}, + "\U0001f645\u200d\u2640\ufe0f": {":no_good:", ":ng_woman:", ":no_good_woman:", ":woman-gesturing-no:", ":woman_gesturing_no:", ":woman_gesturing_NO:"}, + "\U0001f645\u200d\u2642\ufe0f": {":ng_man:", ":no_good_man:", ":man-gesturing-no:", ":man_gesturing_no:", ":man_gesturing_NO:"}, + "\U0001f646": {":ok_person:", ":person_gesturing_ok:", ":person_gesturing_OK:"}, "\U0001f646\U0001f3fb": {":person_gesturing_ok_tone1:"}, "\U0001f646\U0001f3fb\u200d\u2640\ufe0f": {":woman_gesturing_ok_tone1:"}, "\U0001f646\U0001f3fb\u200d\u2642\ufe0f": {":man_gesturing_ok_tone1:"}, @@ -6578,8 +6597,8 @@ func emojiRevCode() map[string][]string { "\U0001f646\U0001f3ff": {":person_gesturing_ok_tone5:"}, "\U0001f646\U0001f3ff\u200d\u2640\ufe0f": {":woman_gesturing_ok_tone5:"}, "\U0001f646\U0001f3ff\u200d\u2642\ufe0f": {":man_gesturing_ok_tone5:"}, - "\U0001f646\u200d\u2640\ufe0f": {":ok_woman:", ":woman-gesturing-ok:", ":woman_gesturing_OK:", ":woman_gesturing_ok:"}, - "\U0001f646\u200d\u2642\ufe0f": {":ok_man:", ":man-gesturing-ok:", ":man_gesturing_OK:", ":man_gesturing_ok:"}, + "\U0001f646\u200d\u2640\ufe0f": {":ok_woman:", ":woman-gesturing-ok:", ":woman_gesturing_ok:", ":woman_gesturing_OK:"}, + "\U0001f646\u200d\u2642\ufe0f": {":ok_man:", ":man-gesturing-ok:", ":man_gesturing_ok:", ":man_gesturing_OK:"}, "\U0001f647": {":bow:", ":person_bowing:"}, "\U0001f647\U0001f3fb": {":person_bowing_tone1:"}, "\U0001f647\U0001f3fb\u200d\u2640\ufe0f": {":woman_bowing_tone1:"}, @@ -6831,6 +6850,7 @@ func emojiRevCode() map[string][]string { "\U0001f6d5": {":hindu_temple:"}, "\U0001f6d6": {":hut:"}, "\U0001f6d7": {":elevator:"}, + "\U0001f6d8": {":landslide:"}, "\U0001f6dc": {":wireless:"}, "\U0001f6dd": {":playground_slide:"}, "\U0001f6de": {":wheel:"}, @@ -7186,7 +7206,7 @@ func emojiRevCode() map[string][]string { "\U0001f993": {":zebra:", ":zebra_face:"}, "\U0001f994": {":hedgehog:"}, "\U0001f995": {":sauropod:"}, - "\U0001f996": {":T-Rex:", ":t-rex:", ":t_rex:"}, + "\U0001f996": {":t-rex:", ":T-Rex:", ":t_rex:"}, "\U0001f997": {":cricket:"}, "\U0001f998": {":kangaroo:"}, "\U0001f999": {":llama:"}, @@ -7267,7 +7287,7 @@ func emojiRevCode() map[string][]string { "\U0001f9d1\u200d\U0001f33e": {":farmer:"}, "\U0001f9d1\u200d\U0001f373": {":cook:"}, "\U0001f9d1\u200d\U0001f37c": {":person_feeding_baby:"}, - "\U0001f9d1\u200d\U0001f384": {":mx_claus:"}, + "\U0001f9d1\u200d\U0001f384": {":mx_claus:", ":Mx_Claus:"}, "\U0001f9d1\u200d\U0001f393": {":student:"}, "\U0001f9d1\u200d\U0001f3a4": {":singer:"}, "\U0001f9d1\u200d\U0001f3a8": {":artist:"}, @@ -7294,6 +7314,7 @@ func emojiRevCode() map[string][]string { "\U0001f9d1\u200d\U0001f9d1\u200d\U0001f9d2\u200d\U0001f9d2": {":family_adult_adult_child_child:"}, "\U0001f9d1\u200d\U0001f9d2": {":family_adult_child:"}, "\U0001f9d1\u200d\U0001f9d2\u200d\U0001f9d2": {":family_adult_child_child:"}, + "\U0001f9d1\u200d\U0001fa70": {":ballet_dancer:"}, "\U0001f9d1\u200d\u2695\ufe0f": {":health_worker:"}, "\U0001f9d1\u200d\u2696\ufe0f": {":judge:"}, "\U0001f9d1\u200d\u2708\ufe0f": {":pilot:"}, @@ -7520,6 +7541,10 @@ func emojiRevCode() map[string][]string { "\U0001fa86": {":nesting_dolls:"}, "\U0001fa87": {":maracas:"}, "\U0001fa88": {":flute:"}, + "\U0001fa89": {":harp:"}, + "\U0001fa8a": {":trombone:"}, + "\U0001fa8e": {":treasure_chest:"}, + "\U0001fa8f": {":shovel:"}, "\U0001fa90": {":ringed_planet:"}, "\U0001fa91": {":chair:"}, "\U0001fa92": {":razor:"}, @@ -7566,6 +7591,7 @@ func emojiRevCode() map[string][]string { "\U0001fabb": {":hyacinth:"}, "\U0001fabc": {":jellyfish:"}, "\U0001fabd": {":wing:"}, + "\U0001fabe": {":leafless_tree:"}, "\U0001fabf": {":goose:"}, "\U0001fac0": {":anatomical_heart:"}, "\U0001fac1": {":lungs:"}, @@ -7573,6 +7599,9 @@ func emojiRevCode() map[string][]string { "\U0001fac3": {":pregnant_man:"}, "\U0001fac4": {":pregnant_person:"}, "\U0001fac5": {":person_with_crown:"}, + "\U0001fac6": {":fingerprint:"}, + "\U0001fac8": {":hairy_creature:"}, + "\U0001facd": {":orca:"}, "\U0001face": {":moose:"}, "\U0001facf": {":donkey:"}, "\U0001fad0": {":blueberries:"}, @@ -7587,6 +7616,8 @@ func emojiRevCode() map[string][]string { "\U0001fad9": {":jar:"}, "\U0001fada": {":ginger_root:"}, "\U0001fadb": {":pea_pod:"}, + "\U0001fadc": {":root_vegetable:"}, + "\U0001fadf": {":splatter:"}, "\U0001fae0": {":melting_face:"}, "\U0001fae1": {":saluting_face:"}, "\U0001fae2": {":face_with_open_eyes_and_hand_over_mouth:"}, @@ -7596,6 +7627,9 @@ func emojiRevCode() map[string][]string { "\U0001fae6": {":biting_lip:"}, "\U0001fae7": {":bubbles:"}, "\U0001fae8": {":shaking_face:"}, + "\U0001fae9": {":face_with_bags_under_eyes:"}, + "\U0001faea": {":distorted_face:"}, + "\U0001faef": {":fight_cloud:"}, "\U0001faf0": {":hand_with_index_finger_and_thumb_crossed:"}, "\U0001faf1": {":rightwards_hand:"}, "\U0001faf2": {":leftwards_hand:"}, @@ -7709,18 +7743,18 @@ func emojiRevCode() map[string][]string { "\u263a\ufe0f": {":relaxed:"}, "\u2640\ufe0f": {":female_sign:"}, "\u2642\ufe0f": {":male_sign:"}, - "\u2648": {":Aries:", ":aries:"}, - "\u2649": {":Taurus:", ":taurus:"}, - "\u264a": {":Gemini:", ":gemini:"}, - "\u264b": {":Cancer:", ":cancer:"}, - "\u264c": {":Leo:", ":leo:"}, - "\u264d": {":Virgo:", ":virgo:"}, - "\u264e": {":Libra:", ":libra:"}, + "\u2648": {":aries:", ":Aries:"}, + "\u2649": {":taurus:", ":Taurus:"}, + "\u264a": {":gemini:", ":Gemini:"}, + "\u264b": {":cancer:", ":Cancer:"}, + "\u264c": {":leo:", ":Leo:"}, + "\u264d": {":virgo:", ":Virgo:"}, + "\u264e": {":libra:", ":Libra:"}, "\u264f": {":Scorpio:", ":scorpius:"}, - "\u2650": {":Sagittarius:", ":sagittarius:"}, - "\u2651": {":Capricorn:", ":capricorn:"}, - "\u2652": {":Aquarius:", ":aquarius:"}, - "\u2653": {":Pisces:", ":pisces:"}, + "\u2650": {":sagittarius:", ":Sagittarius:"}, + "\u2651": {":capricorn:", ":Capricorn:"}, + "\u2652": {":aquarius:", ":Aquarius:"}, + "\u2653": {":pisces:", ":Pisces:"}, "\u265f\ufe0f": {":chess_pawn:"}, "\u2660": {":spade_suit:"}, "\u2660\ufe0f": {":spades:"}, @@ -7763,7 +7797,7 @@ func emojiRevCode() map[string][]string { "\u26c5": {":partly_sunny:", ":sun_behind_cloud:"}, "\u26c8": {":thunder_cloud_rain:", ":cloud_with_lightning_and_rain:"}, "\u26c8\ufe0f": {":thunder_cloud_and_rain:"}, - "\u26ce": {":Ophiuchus:", ":ophiuchus:"}, + "\u26ce": {":ophiuchus:", ":Ophiuchus:"}, "\u26cf\ufe0f": {":pick:"}, "\u26d1": {":helmet_with_cross:", ":rescue_worker’s_helmet:"}, "\u26d1\ufe0f": {":rescue_worker_helmet:", ":helmet_with_white_cross:"}, diff --git a/vendor/github.com/lucasb-eyer/go-colorful/CHANGELOG.md b/vendor/github.com/lucasb-eyer/go-colorful/CHANGELOG.md index 3879f14aa..f349b3357 100644 --- a/vendor/github.com/lucasb-eyer/go-colorful/CHANGELOG.md +++ b/vendor/github.com/lucasb-eyer/go-colorful/CHANGELOG.md @@ -8,6 +8,10 @@ but only releases after v1.0.3 properly adhere to it. ## [Unreleased] +## [1.4.1] - 2026-08-02 +### Fixed +- Corrected `D50ToD65` to use the CSS Color 4 matrix inverse of `D65ToD50` (#85). + ## [1.4.0] - 2026-03-28 ### Added - Constructors, decomposers, and blend functions for the CSS Color Level 4 wide-gamut RGB color spaces `DisplayP3`, `A98Rgb`, `ProPhotoRgb`, and `Rec2020` (#81) diff --git a/vendor/github.com/lucasb-eyer/go-colorful/widegamut.go b/vendor/github.com/lucasb-eyer/go-colorful/widegamut.go index 6805a2b96..63c3e878e 100644 --- a/vendor/github.com/lucasb-eyer/go-colorful/widegamut.go +++ b/vendor/github.com/lucasb-eyer/go-colorful/widegamut.go @@ -10,9 +10,9 @@ import "math" // Bradford chromatic adaptation between D50 and D65 illuminants. func D50ToD65(x, y, z float64) (xo, yo, zo float64) { - xo = 0.9555766*x - 0.0230393*y + 0.0631636*z - yo = -0.0282895*x + 1.0099416*y + 0.0210077*z - zo = 0.0122982*x - 0.0204830*y + 1.3299098*z + xo = 0.9554734527042182*x - 0.023098536874261423*y + 0.06325964552894382*z + yo = -0.028369706963208136*x + 1.0099954580058226*y + 0.021041398966943008*z + zo = 0.012314001688319899*x - 0.020507696433477912*y + 1.3303659366080753*z return } diff --git a/vendor/golang.org/x/mod/modfile/read.go b/vendor/golang.org/x/mod/modfile/read.go index 5b528c718..9e35e1ac5 100644 --- a/vendor/golang.org/x/mod/modfile/read.go +++ b/vendor/golang.org/x/mod/modfile/read.go @@ -924,7 +924,7 @@ var ( moduleStr = []byte("module") ) -// ModulePath returns the module path from the gomod file text. +// ModulePath returns the module path from the go.mod file text. // If it cannot find a module path, it returns an empty string. // It is tolerant of unrelated problems in the go.mod file. func ModulePath(mod []byte) string { diff --git a/vendor/golang.org/x/mod/modfile/rule.go b/vendor/golang.org/x/mod/modfile/rule.go index 9ab203b56..20ba825d2 100644 --- a/vendor/golang.org/x/mod/modfile/rule.go +++ b/vendor/golang.org/x/mod/modfile/rule.go @@ -1477,7 +1477,7 @@ func (f *File) setRequireSeparateIndirect(req []*Require, simplify bool) { // Delete requirements we don't want anymore. // Update versions and indirect comments on requirements we want to keep. // If a requirement is in last{Direct,Indirect}Block with the wrong - // indirect marking after this, or if the requirement is in an single + // indirect marking after this, or if the requirement is in a single // uncommented mixed block (oneFlatUncommentedBlock), move it to the // correct block. // @@ -1537,7 +1537,7 @@ func (f *File) DropRequire(path string) error { return nil } -// AddExclude adds a exclude statement to the mod file. Errors if the provided +// AddExclude adds an exclude statement to the mod file. Errors if the provided // version is not a canonical version string func (f *File) AddExclude(path, vers string) error { if err := checkCanonicalVersion(path, vers); err != nil { @@ -1708,7 +1708,7 @@ func (f *File) AddIgnore(path string) error { return nil } -// DropIgnore removes a ignore directive with the given path. +// DropIgnore removes an ignore directive with the given path. // It does nothing if no such ignore directive exists. func (f *File) DropIgnore(path string) error { for _, t := range f.Ignore { diff --git a/vendor/modules.txt b/vendor/modules.txt index 005d79a73..09881cc11 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -61,13 +61,6 @@ github.com/go-errors/errors # github.com/go-logfmt/logfmt v0.5.0 ## explicit; go 1.13 github.com/go-logfmt/logfmt -# github.com/google/go-cmp v0.7.0 -## explicit; go 1.21 -github.com/google/go-cmp/cmp -github.com/google/go-cmp/cmp/internal/diff -github.com/google/go-cmp/cmp/internal/flags -github.com/google/go-cmp/cmp/internal/function -github.com/google/go-cmp/cmp/internal/value # github.com/gookit/color v1.6.1 ## explicit; go 1.18 github.com/gookit/color @@ -96,10 +89,10 @@ github.com/karimkhaleel/jsonschema # github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 ## explicit github.com/kr/logfmt -# github.com/kyokomi/emoji/v2 v2.2.13 -## explicit; go 1.14 +# github.com/kyokomi/emoji/v2 v2.2.14 +## explicit; go 1.21 github.com/kyokomi/emoji/v2 -# github.com/lucasb-eyer/go-colorful v1.4.0 +# github.com/lucasb-eyer/go-colorful v1.4.1 ## explicit; go 1.12 github.com/lucasb-eyer/go-colorful # github.com/mailru/easyjson v0.7.7 @@ -175,7 +168,7 @@ github.com/xo/terminfo ## explicit; go 1.20 golang.org/x/exp/constraints golang.org/x/exp/slices -# golang.org/x/mod v0.37.0 +# golang.org/x/mod v0.38.0 ## explicit; go 1.25.0 golang.org/x/mod/internal/lazyregexp golang.org/x/mod/modfile @@ -206,7 +199,7 @@ golang.org/x/text/language golang.org/x/text/runes golang.org/x/text/transform golang.org/x/text/unicode/norm -# golang.org/x/tools v0.47.0 +# golang.org/x/tools v0.48.0 ## explicit; go 1.25.0 golang.org/x/tools/go/ast/astutil # gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c @@ -221,8 +214,8 @@ gopkg.in/ozeidan/fuzzy-patricia.v3/patricia # gopkg.in/yaml.v3 v3.0.1 ## explicit gopkg.in/yaml.v3 -# mvdan.cc/gofumpt v0.9.2 -## explicit; go 1.24.0 +# mvdan.cc/gofumpt v0.11.0 +## explicit; go 1.25.0 mvdan.cc/gofumpt mvdan.cc/gofumpt/format mvdan.cc/gofumpt/internal/govendor/diff diff --git a/vendor/mvdan.cc/gofumpt/CHANGELOG.md b/vendor/mvdan.cc/gofumpt/CHANGELOG.md index f3a384077..1168ddc63 100644 --- a/vendor/mvdan.cc/gofumpt/CHANGELOG.md +++ b/vendor/mvdan.cc/gofumpt/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## [v0.11.0] - 2026-07-27 + +Like v0.10.0, this release is based on Go 1.26's gofmt, and requires Go 1.25 or later. + +The multi-line function call rule introduced in v0.10.0 proved controversial, +so it is now the extra rule `balance_calls`, disabled by default. +It is also narrowed to only place the closing parenthesis on its own line +when the opening parenthesis ends a line. See #74. + +Avoid crashing when compiled with tinygo for Wasm, which lacks recover support, +by detecting commented-out code without the parser's bailout panic. See #230. + +Produce stable output in a single pass when a lone var declaration is adjacent +to a single-element var group, which previously required a second run. See #355. + +Keep the parentheses around an expression which begins with a composite literal +of the form `T{...}`, such as `(s{}.Foo())`, as they are required when the +expression starts an `if`, `for`, or `switch` clause. See #356. + +## [v0.10.0] - 2026-05-04 + +This release is based on Go 1.26's gofmt, and requires Go 1.25 or later. + +A new rule is introduced to drop unnecessary parentheses around expressions +where the inner expression is unambiguous on its own, such as `f((3))`. +Parentheses are kept where they are useful, such as on binary expressions. See #44. + +A new rule is introduced to require multi-line function calls to match +the opening and closing parenthesis in terms of the use of newlines. See #74. + +The `-extra` flag now accepts a comma-separated list of rule names to enable +individual extra rules, rather than enabling all of them at once. See #339. + +The following changes are included as well: + +* Avoid crashing on `go.mod` files without a `module` directive - #350 +* Avoid failing when an ignored directory cannot be read - #351 +* Avoid prefixing more kinds of commented-out Go code with spaces - #230 +* Avoid prefixing a shebang comment with a space - #237 +* Narrow the newlines on assignments rule to ignore complex cases - #354 +* Fix three bugs which caused a second gofumpt run to make changes - #132, #345 + ## [v0.9.1] - 2025-09-07 This is a bugfix release to address a regression in detecting @@ -187,6 +229,8 @@ those building programs with gofumpt. Finally, this release adds the `-version` flag, to print the tool's own version. The flag will work for "master" builds too. +[v0.11.0]: https://github.com/mvdan/gofumpt/releases/tag/v0.11.0 +[v0.10.0]: https://github.com/mvdan/gofumpt/releases/tag/v0.10.0 [v0.9.0]: https://github.com/mvdan/gofumpt/releases/tag/v0.9.0 [v0.8.0]: https://github.com/mvdan/gofumpt/releases/tag/v0.8.0 [v0.7.0]: https://github.com/mvdan/gofumpt/releases/tag/v0.7.0 diff --git a/vendor/mvdan.cc/gofumpt/README.md b/vendor/mvdan.cc/gofumpt/README.md index f391ef969..609cf65bf 100644 --- a/vendor/mvdan.cc/gofumpt/README.md +++ b/vendor/mvdan.cc/gofumpt/README.md @@ -7,7 +7,7 @@ Enforce a stricter format than `gofmt`, while being backwards compatible. That is, `gofumpt` is happy with a subset of the formats that `gofmt` is happy with. -The tool is a fork of `gofmt` as of Go 1.25.0, and requires Go 1.24 or later. +The tool is a fork of `gofmt` as of Go 1.26.0, and requires Go 1.25 or later. It can be used as a drop-in replacement to format your Go code, and running `gofmt` after `gofumpt` should produce no changes. For example: @@ -15,7 +15,7 @@ For example: gofumpt -l -w . Some of the Go source files in this repository belong to the Go project. -The project includes copies of `go/printer` and `go/doc/comment` as of Go 1.25.0 +The project includes copies of `go/printer` and `go/doc/comment` as of Go 1.26.0 to ensure consistent formatting independent of what Go version is being used. The [added formatting rules](#Added-rules) are implemented in the `format` package. @@ -31,7 +31,7 @@ and the `-s` flag is hidden as it is always enabled. ### Added rules -**No empty lines following an assignment operator** +**No newline after a simple assignment's operator**
Example @@ -438,6 +438,27 @@ type ZeroFields struct {
+**Definitely useless parentheses should be removed** + +
Example + +```go +type C chan (int) + +var _ = f((3)) +``` + +```go +type C chan int + +var _ = f(3) +``` + +Parentheses around binary or unary expressions, as well as around types +which require them (such as `chan (<-chan T)`), are kept as is. + +
+ ### Extra rules behind `-extra` **Adjacent parameters with the same type should be grouped together** @@ -472,6 +493,28 @@ func Foo() (err error) { +**Multi-line function calls with the opening parenthesis at the end of a line +should place the closing parenthesis at the start of a line** + +
Example + +```go +result := compute( + a, + b, + c) +``` + +```go +result := compute( + a, + b, + c, +) +``` + +
+ ### Installation `gofumpt` is a replacement for `gofmt`, so you can simply `go install` it as @@ -627,6 +670,18 @@ well might be proposed for `gofmt` itself. The tool is also compatible with `gofmt` and is aimed to be stable, so you can rely on it for your code as long as you pin a version of it. +### Updating with `go/format` and `cmd/gofmt` + +`internal/govendor` contains frozen copies of `go/format` and its dependencies +at a specific Go version, so that installing a specific version of `gofumpt` +results in exactly the same formatting behavior regardless of the Go version. + +As this tool is a fork of `cmd/gofmt`, the `gofmt.go`, `internal.go`, +`format/rewrite.go`, and `format/simplify.go` are inherited from upstream. +These include some modifications where necessary, and are updated manually. +Note that two live under the `format` package as we want to expose +syntax simplification via the Go API. + ### Frequently Asked Questions > Why attempt to replace `gofmt` instead of building on top of it? diff --git a/vendor/mvdan.cc/gofumpt/format/format.go b/vendor/mvdan.cc/gofumpt/format/format.go index 879969da9..438a73b53 100644 --- a/vendor/mvdan.cc/gofumpt/format/format.go +++ b/vendor/mvdan.cc/gofumpt/format/format.go @@ -23,7 +23,6 @@ import ( "unicode" "unicode/utf8" - "github.com/google/go-cmp/cmp" "golang.org/x/tools/go/ast/astutil" "mvdan.cc/gofumpt/internal/govendor/go/format" @@ -57,11 +56,81 @@ type Options struct { // is formatted as if it weren't inside a module. ModulePath string - // ExtraRules enables extra formatting rules, such as grouping function + // ExtraRules enables all extra formatting rules, such as grouping function // parameters with repeated types together. + // + // Deprecated: use [Options.Extra] instead. ExtraRules bool + + // Extra allows enabling extra formatting rules which are disabled by default. + Extra Extra } +// Extra is the set of extra formatting rules which are available. +// +// As the formatter evolves, we might add or remove boolean fields here. +// Go API users who wish to avoid build errors in such cases +// can use the string API in [Extra.Set]. +type Extra struct { + // TODO: should we have "All" to turn them all on, + // akin to how the CLI has -extra=true for historical reasons? + // I lean against it, as it should be a conscious choice to turn on + // each of these extra rules, and we should be able to add more rules + // without fear of causing unexpected changes for users. + + // GroupParams groups function parameters with repeated types. + GroupParams bool + + // ClotheReturns clothes naked returns in functions with named results. + ClotheReturns bool + + // BalanceCalls places a multi-line call's closing parenthesis on its + // own line when the opening parenthesis ends a line. + BalanceCalls bool +} + +func (e *Extra) String() string { + var active []string + if e.GroupParams { + active = append(active, "group_params") + } + if e.ClotheReturns { + active = append(active, "clothe_returns") + } + if e.BalanceCalls { + active = append(active, "balance_calls") + } + return strings.Join(active, ",") +} + +func (e *Extra) Set(v string) error { + if v == "true" { + e.GroupParams = true + e.ClotheReturns = true + e.BalanceCalls = true + return nil + } + *e = Extra{} + if v == "false" { + return nil + } + for s := range strings.SplitSeq(v, ",") { + switch s { + case "group_params": + e.GroupParams = true + case "clothe_returns": + e.ClotheReturns = true + case "balance_calls": + e.BalanceCalls = true + default: + return fmt.Errorf("unknown rule: %q", s) + } + } + return nil +} + +func (e *Extra) IsBoolFlag() bool { return true } + // Source formats src in gofumpt's format, assuming that src holds a valid Go // source file. func Source(src []byte, opts Options) ([]byte, error) { @@ -91,6 +160,10 @@ func Source(src []byte, opts Options) ([]byte, error) { func File(fset *token.FileSet, file *ast.File, opts Options) { simplify(file) + if opts.ExtraRules { + opts.Extra.Set("true") // enable all the extra rules + } + if opts.LangVersion == "" { opts.LangVersion = "go1" } else { @@ -258,6 +331,37 @@ func (f *fumpter) removeLinesBetween(from, to token.Pos) { f.removeLines(f.Line(from)+1, f.Line(to)) } +// removeParens unwraps a single-spec var group like "var (\n\tx = 1\n)" into a +// lone "var x = 1". It only acts on such groups without a doc comment. +func (f *fumpter) removeParens(node *ast.GenDecl) { + if node.Tok != token.VAR || len(node.Specs) != 1 || + !node.Lparen.IsValid() || node.Doc != nil { + return + } + specPos := node.Specs[0].Pos() + specEnd := node.Specs[0].End() + + if len(f.commentsBetween(node.TokPos, specPos)) > 0 { + // If the single spec has a comment on the line above, + // the comment must go before the entire declaration now. + node.TokPos = specPos + } else { + f.removeLines(f.Line(node.TokPos), f.Line(specPos)) + } + if len(f.commentsBetween(specEnd, node.Rparen)) > 0 { + // Leave one newline to not force a comment on the next line to + // become an inline comment. + f.removeLines(f.Line(specEnd)+1, f.Line(node.Rparen)) + } else { + f.removeLines(f.Line(specEnd), f.Line(node.Rparen)) + } + + // Remove the parentheses. go/printer will automatically + // get rid of the newlines. + node.Lparen = token.NoPos + node.Rparen = token.NoPos +} + func (f *fumpter) Position(p token.Pos) token.Position { return f.file.PositionFor(p, false) } @@ -337,11 +441,68 @@ var rxCommentDirective = regexp.MustCompile( `|sys(?:nb)?\b` + `)`) +// rxShebangComment matches a shebang like `//usr/bin/env go run`. +var rxShebangComment = regexp.MustCompile(`^//[^ /].*\bbin/`) + +// commentGroupLooksLikeCode reports whether the lines of a //-style comment +// group parse as Go statements with at least one non-trivial statement. +// A bare identifier path or label is treated as trivial, since prose like +// "// foo" or "// TODO: bar" parses but is not commented-out code. +func commentGroupLooksLikeCode(group *ast.CommentGroup) bool { + src := "package p\nfunc _() {\n" + group.Text() + "}\n" + // AllErrors avoids the parser's panic/recover bailout on too many errors, + // which crashes under tinygo's Wasm target as it lacks recover support. + file, err := parser.ParseFile(token.NewFileSet(), "", src, parser.SkipObjectResolution|parser.AllErrors) + if err != nil { + return false + } + fn, _ := file.Decls[0].(*ast.FuncDecl) + if fn == nil || fn.Body == nil { + return false + } + for _, stmt := range fn.Body.List { + if !isTrivialStmt(stmt) { + return true + } + } + return false +} + +func isTrivialStmt(stmt ast.Stmt) bool { + switch s := stmt.(type) { + case *ast.ExprStmt: + return isIdentPath(s.X) + case *ast.LabeledStmt: + return isTrivialStmt(s.Stmt) + case *ast.EmptyStmt: + return true + } + return false +} + +func isIdentPath(expr ast.Expr) bool { + switch e := expr.(type) { + case *ast.Ident: + return true + case *ast.SelectorExpr: + return isIdentPath(e.X) + } + return false +} + func (f *fumpter) applyPre(c *astutil.Cursor) { f.splitLongLine(c) switch node := c.Node().(type) { case *ast.File: + // Unwrap single-spec var groups before the joining below, + // so an adjacent var line and var group merge in one pass. + for _, decl := range node.Decls { + if decl, ok := decl.(*ast.GenDecl); ok { + f.removeParens(decl) + } + } + // Join contiguous lone var/const/import lines. // Abort if there are empty lines in between, // including a leading comment if it's a directive. @@ -354,6 +515,7 @@ func (f *fumpter) applyPre(c *astutil.Cursor) { continue } lastPos := start.Pos() + merged := false contLoop: for i++; i < len(node.Decls); { cont, ok := node.Decls[i].(*ast.GenDecl) @@ -377,17 +539,25 @@ func (f *fumpter) applyPre(c *astutil.Cursor) { } start.Specs = append(start.Specs, cont.Specs...) + merged = true + end := cont.End() if c := f.inlineComment(cont.End()); c != nil { // don't move an inline comment outside - start.Rparen = c.End() - } else { - // so the code below treats the joined - // decl group as multi-line - start.Rparen = cont.End() + end = c.End() } + // Point Rparen at the last content character, like a real + // ')', so start.End() stays on the content's final line and + // the empty-line separator below is idempotent in one pass. + start.Rparen = end - 1 lastPos = cont.Pos() i++ } + // Re-sort imports in the new group so the output is idempotent. + // Set Lparen so ast.SortImports doesn't skip the merged decl. + if merged && start.Tok == token.IMPORT { + start.Lparen = start.TokPos + token.Pos(len("import")) + ast.SortImports(f.fset, f.astFile) + } } node.Decls = newDecls @@ -399,15 +569,30 @@ func (f *fumpter) applyPre(c *astutil.Cursor) { var lastEnd token.Pos for _, decl := range node.Decls { pos := decl.Pos() - comments := f.commentsBetween(lastEnd, pos) - if len(comments) > 0 { - pos = comments[0].Pos() + // Trailing inline comments on lastEnd's line belong to the + // previous decl and extend its effective end. + effectiveEnd := lastEnd + lastEndLine := f.Line(lastEnd) + for _, cg := range f.commentsBetween(lastEnd, pos) { + if f.Line(cg.Pos()) != lastEndLine { + pos = cg.Pos() + break + } + effectiveEnd = cg.End() } // Note that we want End-1, as End is the character after the node. multi := f.Line(pos) < f.Line(decl.End()-1) - if multi && lastMulti && f.Line(lastEnd)+1 == f.Line(pos) { - f.addNewline(lastEnd) + // A func declaration which fits on a single source line may + // still be printed across multiple lines: go/printer's funcBody + // breaks the body onto its own lines once header+body exceeds + // 100 bytes. Approximate that with the source byte length. + if fn, _ := decl.(*ast.FuncDecl); fn != nil && !multi && fn.Body != nil && + f.Offset(fn.End())-f.Offset(fn.Pos()) > 100 { + multi = true + } + if multi && lastMulti && f.Line(effectiveEnd)+1 == f.Line(pos) { + f.addNewline(effectiveEnd) } lastMulti = multi @@ -418,6 +603,10 @@ func (f *fumpter) applyPre(c *astutil.Cursor) { groupLoop: for _, group := range node.Comments { for _, comment := range group.List { + // Leave shebang lines like `//usr/bin/env go run` alone. + if f.Line(comment.Slash) == 1 && rxShebangComment.MatchString(comment.Text) { + continue groupLoop + } if comment.Text == "//gofumpt:diagnose" || strings.HasPrefix(comment.Text, "//gofumpt:diagnose ") { slc := []string{ "//gofumpt:diagnose", @@ -427,8 +616,8 @@ func (f *fumpter) applyPre(c *astutil.Cursor) { "-lang=" + f.LangVersion, "-modpath=" + f.ModulePath, } - if f.ExtraRules { - slc = append(slc, "-extra") + if s := f.Extra.String(); s != "" { + slc = append(slc, "-extra="+s) } comment.Text = strings.Join(slc, " ") } @@ -447,6 +636,9 @@ func (f *fumpter) applyPre(c *astutil.Cursor) { continue groupLoop } } + if commentGroupLooksLikeCode(group) { + continue groupLoop + } // If none of the comment group's lines look like a // directive or code, add spaces, if needed. for _, comment := range group.List { @@ -488,31 +680,7 @@ func (f *fumpter) applyPre(c *astutil.Cursor) { // Single var declarations shouldn't use parentheses, unless // there's a comment on the grouped declaration. - if node.Tok == token.VAR && len(node.Specs) == 1 && - node.Lparen.IsValid() && node.Doc == nil { - specPos := node.Specs[0].Pos() - specEnd := node.Specs[0].End() - - if len(f.commentsBetween(node.TokPos, specPos)) > 0 { - // If the single spec has a comment on the line above, - // the comment must go before the entire declaration now. - node.TokPos = specPos - } else { - f.removeLines(f.Line(node.TokPos), f.Line(specPos)) - } - if len(f.commentsBetween(specEnd, node.Rparen)) > 0 { - // Leave one newline to not force a comment on the next line to - // become an inline comment. - f.removeLines(f.Line(specEnd)+1, f.Line(node.Rparen)) - } else { - f.removeLines(f.Line(specEnd), f.Line(node.Rparen)) - } - - // Remove the parentheses. go/printer will automatically - // get rid of the newlines. - node.Lparen = token.NoPos - node.Rparen = token.NoPos - } + f.removeParens(node) case *ast.InterfaceType: if len(node.Methods.List) > 0 { @@ -682,8 +850,7 @@ func (f *fumpter) applyPre(c *astutil.Cursor) { f.removeLinesBetween(bodyEnd, node.End()) } - // Merging adjacent fields (e.g. parameters) is disabled by default. - if !f.ExtraRules { + if !f.Extra.GroupParams { break } switch c.Parent().(type) { @@ -694,6 +861,14 @@ func (f *fumpter) applyPre(c *astutil.Cursor) { // Do not merge adjacent fields in structs. } + case *ast.ParenExpr: + // Unwrap any chain of redundant inner parens first, + // since astutil.Apply does not walk replacement nodes. + node.X = ast.Unparen(node.X) + if f.canRemoveParens(node) { + c.Replace(node.X) + } + case *ast.BasicLit: // Octal number literals were introduced in Go 1.13. if goversion.Compare(f.LangVersion, "go1.13") >= 0 { @@ -704,15 +879,21 @@ func (f *fumpter) applyPre(c *astutil.Cursor) { } case *ast.AssignStmt: - // Only remove lines between the assignment token and the first right-hand side expression - f.removeLines(f.Line(node.TokPos), f.Line(node.Rhs[0].Pos())) + // Only remove lines between the assignment token and the right-hand side + // for simple single-value assignments. Skip multi-value assignments and + // binary expressions like long string concatenations, where a line break + // after the assignment token can improve readability. + if len(node.Rhs) == 1 { + if _, ok := node.Rhs[0].(*ast.BinaryExpr); !ok { + f.removeLines(f.Line(node.TokPos), f.Line(node.Rhs[0].Pos())) + } + } case *ast.ReturnStmt: if len(node.Results) > 0 { break } - // Clothing naked returns is disabled by default. - if !f.ExtraRules { + if !f.Extra.ClotheReturns { break } results := f.parentFuncTypes[len(f.parentFuncTypes)-1].Results @@ -822,6 +1003,33 @@ func (f *fumpter) applyPost(c *astutil.Cursor) { f.addNewline(elem1.End()) } } + + // In a multi-line call, if the opening parenthesis is at the end of a + // line, the closing parenthesis should be at the start of a line. + // See https://github.com/mvdan/gofumpt/issues/74. + case *ast.CallExpr: + if !f.Extra.BalanceCalls { + break + } + if len(node.Args) == 0 { + break + } + openLine := f.Line(node.Lparen) + closeLine := f.Line(node.Rparen) + if openLine == closeLine { + break + } + firstLine := f.Line(node.Args[0].Pos()) + lastEnd := node.Args[len(node.Args)-1].End() + if comment := f.inlineComment(lastEnd); comment != nil { + lastEnd = comment.End() + } + lastLine := f.Line(lastEnd) + openAtEOL := openLine != firstLine + closeAtBOL := closeLine != lastLine + if openAtEOL && !closeAtBOL { + f.addNewline(node.Rparen) + } } } @@ -908,6 +1116,46 @@ func (f *fumpter) splitLongLine(c *astutil.Cursor) { } } +// canRemoveParens reports whether the parentheses around node are definitely +// useless and can be safely removed without changing intent. +func (f *fumpter) canRemoveParens(node *ast.ParenExpr) bool { + // Don't drop parens which contain comments, + // as the printer may not place them well without the parens. + if len(f.commentsBetween(node.Lparen, node.Rparen)) > 0 { + return false + } + return !keepParens(node.X, true) +} + +// keepParens reports whether the parentheses directly around expr should be +// kept: around binary, unary, and type expressions for readability and for +// conversions like `(<-chan T)(v)`, but only when outermost; and around an +// expression whose leftmost operand is a composite literal, whose brace would +// otherwise open an if, for, or switch body. +func keepParens(expr ast.Expr, outermost bool) bool { + switch expr := expr.(type) { + case *ast.CompositeLit: + return true + case *ast.CallExpr: + return keepParens(expr.Fun, false) + case *ast.SelectorExpr: + return keepParens(expr.X, false) + case *ast.IndexExpr: + return keepParens(expr.X, false) + case *ast.IndexListExpr: + return keepParens(expr.X, false) + case *ast.SliceExpr: + return keepParens(expr.X, false) + case *ast.TypeAssertExpr: + return keepParens(expr.X, false) + case *ast.BinaryExpr, *ast.UnaryExpr, *ast.StarExpr, + *ast.ChanType, *ast.ArrayType, *ast.MapType, + *ast.FuncType, *ast.InterfaceType, *ast.StructType: + return outermost + } + return false +} + func isComposite(node ast.Node) *ast.CompositeLit { switch node := node.(type) { case *ast.CompositeLit: @@ -1093,16 +1341,28 @@ func (f *fumpter) shouldMergeAdjacentFields(f1, f2 *ast.Field) bool { // Only merge if the types that the syntax nodes represent are equal, // e.g. two *ast.Ident nodes "int" are equal, but the two *ast.Ident nodes - // "string" and "bool" are not. Hence we use go-cmp to do deep comparisons - // while ignoring position information, as it is irrelevant. + // "string" and "bool" are not. We use reflection to quickly discard most cases. + // + // We use an empty [token.FileSet] so that positions are ignored when printing, + // and two syntax nodes with different uses of newlines end up the same. // // Note that we could in theory use go/types here, but in practice gofumpt // needs to be fast, hence it shouldn't rely on expensive typechecking. - opt := cmp.Comparer(func(x, y token.Pos) bool { return true }) - return cmp.Equal(f1.Type, f2.Type, opt) + if reflect.TypeOf(f1.Type) != reflect.TypeOf(f2.Type) { + return false + } + emptyFset := token.NewFileSet() + var b1, b2 bytes.Buffer + if err := format.Node(&b1, emptyFset, f1.Type); err != nil { + return false + } + if err := format.Node(&b2, emptyFset, f2.Type); err != nil { + return false + } + return bytes.Equal(b1.Bytes(), b2.Bytes()) } -var posType = reflect.TypeOf(token.NoPos) +var posType = reflect.TypeFor[token.Pos]() // setPos recursively sets all position fields in the node v to pos. func setPos(v reflect.Value, pos token.Pos) { diff --git a/vendor/mvdan.cc/gofumpt/format/rewrite.go b/vendor/mvdan.cc/gofumpt/format/rewrite.go index ec7a2e5db..47ff5ee7b 100644 --- a/vendor/mvdan.cc/gofumpt/format/rewrite.go +++ b/vendor/mvdan.cc/gofumpt/format/rewrite.go @@ -2,6 +2,13 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +// NOTE(gofumpt): the original cmd/gofmt/rewrite.go is mostly stripped here. +// gofumpt drops the -r flag (use `gofmt -r` instead), so the rewrite engine +// (initRewrite, parseExpr, rewriteFile, apply, set, subst) and its +// reflect-helpers (objectPtrNil, scopePtrNil, scopePtrType) are gone. Only +// match/isWildcard remain because simplify.go still uses them to compare +// AST literals when omitting redundant types in composite literals. + package format import ( @@ -14,10 +21,10 @@ import ( // Values/types for special cases. var ( - identType = reflect.TypeOf((*ast.Ident)(nil)) - objectPtrType = reflect.TypeOf((*ast.Object)(nil)) - positionType = reflect.TypeOf(token.NoPos) - callExprType = reflect.TypeOf((*ast.CallExpr)(nil)) + identType = reflect.TypeFor[*ast.Ident]() + objectPtrType = reflect.TypeFor[*ast.Object]() + positionType = reflect.TypeFor[token.Pos]() + callExprType = reflect.TypeFor[*ast.CallExpr]() ) func isWildcard(s string) bool { diff --git a/vendor/mvdan.cc/gofumpt/format/simplify.go b/vendor/mvdan.cc/gofumpt/format/simplify.go index 117646464..363f8d059 100644 --- a/vendor/mvdan.cc/gofumpt/format/simplify.go +++ b/vendor/mvdan.cc/gofumpt/format/simplify.go @@ -2,6 +2,11 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +// NOTE(gofumpt): moved into the format package (from package main) so that +// syntax simplification is exported via the Go API. gofumpt always simplifies, +// so the -s flag was dropped; see also the removal of the -r rewrite logic in +// rewrite.go, which left this file as the sole user of match/isWildcard. + package format import ( diff --git a/vendor/mvdan.cc/gofumpt/gofmt.go b/vendor/mvdan.cc/gofumpt/gofmt.go index 5c922ffbd..e6cd81d90 100644 --- a/vendor/mvdan.cc/gofumpt/gofmt.go +++ b/vendor/mvdan.cc/gofumpt/gofmt.go @@ -7,7 +7,6 @@ package main import ( "bytes" "context" - "errors" "flag" "fmt" "go/ast" @@ -16,23 +15,33 @@ import ( "go/token" "io" "io/fs" + "math/rand" "os" "path/filepath" "regexp" "runtime" "runtime/pprof" + "strconv" "strings" "sync" + // NOTE(gofumpt): x/mod/modfile is used to read each file's go.mod for the + // default -lang and -modpath, and to honor `ignore` directives. "golang.org/x/mod/modfile" "golang.org/x/sync/semaphore" + // NOTE(gofumpt): the format package exposes gofumpt's added rules and + // simplification as a public Go API. diff and go/printer are vendored + // copies frozen at a specific Go version, so gofumpt's output is + // reproducible regardless of the user's Go toolchain. gformat "mvdan.cc/gofumpt/format" "mvdan.cc/gofumpt/internal/govendor/diff" "mvdan.cc/gofumpt/internal/govendor/go/printer" gversion "mvdan.cc/gofumpt/internal/version" ) +// NOTE(gofumpt): regenerate the vendored Go source under internal/govendor, +// then re-format it with the freshly built gofumpt binary. //go:generate go run gen_govendor.go //go:generate go run . -w internal/govendor @@ -46,17 +55,36 @@ var ( // debugging cpuprofile = flag.String("cpuprofile", "", "") - // gofumpt's own flags + // NOTE(gofumpt): gofumpt's own flags. + // -lang sets the target Go language version for version-gated rules + // (e.g. octal literal syntax requires go1.13); defaulted from go.mod. + // -modpath sets the current module path so import grouping can treat + // imports sharing that prefix as third-party; defaulted from go.mod. + // -extra opts in to non-default rules like group_params. + // -version prints the gofumpt build version (set via -ldflags=main.version=). langVersion = flag.String("lang", "", "") modulePath = flag.String("modpath", "", "") - extraRules = flag.Bool("extra", false, "") + extraRules gformat.Extra showVersion = flag.Bool("version", false, "") - // DEPRECATED + // NOTE(gofumpt): -r and -s are kept only to print a friendly error. + // -r was dropped in favor of `gofmt -r`; -s is always on (gofumpt always + // simplifies). rewriteRule = flag.String("r", "", "") simplifyAST = flag.Bool("s", false, "") + + // errors + // NOTE(gofumpt): sentinel used to drive exit code 1 when -d found + // formatting differences. Upstream gofmt does not change its exit code + // on -d; gofumpt's -d acts like `diff` so CI checks can rely on the + // nonzero exit. See reporter.Report below. + errFormattingDiffers = fmt.Errorf("formatting differs from gofumpt's") ) +func init() { flag.Var(&extraRules, "extra", "") } + +// NOTE(gofumpt): set via -ldflags=main.version=... at release time so that +// `gofumpt -version` reports a meaningful string for prebuilt binaries. var version = "" // Keep these in sync with go/format/format.go. @@ -81,10 +109,22 @@ const ( // so this limit may be approximate. var fdSem = make(chan bool, 200) -var ( - fileSet = token.NewFileSet() // per process FileSet - parserMode parser.Mode -) +// NOTE(gofumpt): upstream gofmt declares `rewrite` here for the -r flag; we +// dropped that. +var parserMode parser.Mode + +// newFileSet returns a fresh token.FileSet for parsing a single file. +// +// NOTE(gofumpt): we reserve base 1 with a dummy ten-byte file so that +// token.NoPos+1 cannot be a valid position in any real file added later. +// Some of gofumpt's added rules construct positions via token.NoPos+1; +// without this guard, tests starting from an empty FileSet would silently +// map NoPos+1 to a valid offset and hide bugs like #166. +func newFileSet() *token.FileSet { + fset := token.NewFileSet() + fset.AddFile("gofumpt_base.go", 1, 10) + return fset +} func usage() { fmt.Fprintf(os.Stderr, `usage: gofumpt [flags] [path ...] @@ -94,7 +134,7 @@ func usage() { -e report all errors (not just the first 10 on different lines) -l list files whose formatting differs from gofumpt's -w write result to (source) file instead of stdout - -extra enable extra rules which should be vetted by a human + -extra enable extra rules, e.g. -extra=group_params,clothe_returns -lang str target Go version in the form "go1.X" (default from go.mod) -modpath str Go module path containing the source file (default from go.mod) @@ -102,16 +142,27 @@ func usage() { } func initParserMode() { + // NOTE(gofumpt): always SkipObjectResolution. Upstream only sets it when + // -r is unused (object resolution is needed for the rewrite engine), but + // gofumpt has no -r flag, so we can always skip it for speed. parserMode = parser.ParseComments | parser.SkipObjectResolution if *allErrors { parserMode |= parser.AllErrors } } +// NOTE(gofumpt): split out from upstream's isGoFile. Upstream combined the +// name check with `!f.IsDir()`, but gofumpt's WalkDir callback already +// distinguishes directories, and explicit non-.go arguments are formatted too, +// so the name-only test is needed independently. func isGoFilename(name string) bool { return !strings.HasPrefix(name, ".") && strings.HasSuffix(name, ".go") } +// NOTE(gofumpt): generated-file detection. gofumpt's added rules are not +// applied to generated Go files unless they are passed explicitly on the +// command line; this avoids churning machine-written code that humans don't +// edit. See processFile below for the `explicit || !isGenerated(file)` gate. var rxCodeGenerated = regexp.MustCompile(`^// Code generated .* DO NOT EDIT\.$`) func isGenerated(file *ast.File) bool { @@ -255,10 +306,9 @@ func (r *reporter) Report(err error) { panic("Report with nil error") } st := r.getState() - switch err.(type) { - case printedDiff: + if err == errFormattingDiffers { st.exitCode = 1 - default: + } else { scanner.PrintError(st.err, err) st.exitCode = 2 } @@ -268,25 +318,23 @@ func (r *reporter) ExitCode() int { return r.getState().exitCode } -type printedDiff struct{} - -func (printedDiff) Error() string { return "printed a diff, exiting with status code 1" } - // If info == nil, we are formatting stdin instead of a file. // If in == nil, the source is the contents of the file with the given filename. +// +// NOTE(gofumpt): the `explicit` parameter (added vs upstream) tracks whether +// this file was named directly on the command line. Explicit files always get +// the gofumpt rules applied (even generated files); walked files do not when +// they look generated. It also forces non-.go explicit args to be formatted. func processFile(filename string, info fs.FileInfo, in io.Reader, r *reporter, explicit bool) error { src, err := readFile(filename, info, in) if err != nil { return err } - fileSet := token.NewFileSet() - fragmentOk := false - if info == nil { - // If we are formatting stdin, we accept a program fragment in lieu of a - // complete source file. - fragmentOk = true - } + fileSet := newFileSet() + // If we are formatting stdin, we accept a program fragment in lieu of a + // complete source file. + fragmentOk := info == nil file, sourceAdj, indentAdj, err := parse(fileSet, filename, src, fragmentOk) if err != nil { return err @@ -294,7 +342,12 @@ func processFile(filename string, info fs.FileInfo, in io.Reader, r *reporter, e ast.SortImports(fileSet, file) - // Apply gofumpt's changes before we print the code in gofumpt's format. + // NOTE(gofumpt): from here until the call to format() below is the + // gofumpt-specific work upstream gofmt does not do: resolve -lang and + // -modpath defaults from the file's containing go.mod, then run + // gformat.File to apply the added rules (and simplification, which + // gofumpt always runs in lieu of the dropped -s flag). Apply gofumpt's + // changes before we print the code in gofumpt's format. // If either -lang or -modpath aren't set, fetch them from go.mod. lang := *langVersion @@ -314,8 +367,8 @@ func processFile(filename string, info fs.FileInfo, in io.Reader, r *reporter, e lang = "go" + mod.file.Go.Version } } - if modpath == "" { - modpath = mod.file.Module.Mod.Path + if m := mod.file.Module; m != nil && modpath == "" { + modpath = m.Mod.Path } } } @@ -327,7 +380,7 @@ func processFile(filename string, info fs.FileInfo, in io.Reader, r *reporter, e gformat.File(fileSet, file, gformat.Options{ LangVersion: lang, ModulePath: modpath, - ExtraRules: *extraRules, + Extra: extraRules, }) } @@ -345,21 +398,9 @@ func processFile(filename string, info fs.FileInfo, in io.Reader, r *reporter, e if info == nil { panic("-w should not have been allowed with stdin") } - // make a temporary backup before overwriting original + perm := info.Mode().Perm() - bakname, err := backupFile(filename+".", src, perm) - if err != nil { - return err - } - fdSem <- true - err = os.WriteFile(filename, res, perm) - <-fdSem - if err != nil { - os.Rename(bakname, filename) - return err - } - err = os.Remove(bakname) - if err != nil { + if err := writeFile(filename, src, res, perm, info.Size()); err != nil { return err } } @@ -367,7 +408,7 @@ func processFile(filename string, info fs.FileInfo, in io.Reader, r *reporter, e newName := filepath.ToSlash(filename) oldName := newName + ".orig" r.Write(diff.Diff(oldName, src, newName, res)) - return printedDiff{} + return errFormattingDiffers } } @@ -459,13 +500,12 @@ func main() { } func gofmtMain(s *sequencer) { - // Ensure our parsed files never start with base 1, - // to ensure that using token.NoPos+1 will panic. - fileSet.AddFile("gofumpt_base.go", 1, 10) - flag.Usage = usage flag.Parse() + // NOTE(gofumpt): friendly handling of the dropped -s and -r flags so users + // migrating from gofmt get a clear message rather than "flag provided but + // not defined". -s is always on; -r is delegated to `gofmt -r`. if *simplifyAST { fmt.Fprintf(os.Stderr, "warning: -s is deprecated as it is always enabled\n") } @@ -474,7 +514,9 @@ func gofmtMain(s *sequencer) { os.Exit(2) } - // Print the gofumpt version if the user asks for it. + // NOTE(gofumpt): print the gofumpt version if the user asks for it. + // -version dumps the build version and any embedded build-info fields + // (see internal/version), useful for bug reports and `//gofumpt:diagnose`. if *showVersion { fmt.Println(gversion.String(version)) return @@ -510,6 +552,14 @@ func gofmtMain(s *sequencer) { return } + // NOTE(gofumpt): the argument-walking loop below is rewritten vs upstream. + // Upstream branched on os.Stat (file vs dir); gofumpt always uses + // filepath.WalkDir and tracks `explicit := path == arg` so that: + // - explicit non-.go and explicit generated files are still formatted; + // - vendor/testdata directories and go.mod `ignore` entries are skipped + // during walks but honored when named directly (so `gofumpt -w vendor` + // still works); + // - the explicit bit propagates into processFile to gate gofumpt rules. for _, arg := range args { // Walk each given argument as a directory tree. // If the argument is not a directory, it's always formatted as a Go file. @@ -544,6 +594,10 @@ func gofmtMain(s *sequencer) { } } +// NOTE(gofumpt): everything from here to the end of the file is gofumpt-only. +// shouldIgnore implements skipping `vendor` and `testdata` directories during +// walks, plus honoring Go 1.25's `ignore` directives in go.mod. These are +// skipped during recursive walks but still formatted when named explicitly. func shouldIgnore(path string) bool { switch filepath.Base(path) { case "vendor", "testdata": @@ -594,6 +648,13 @@ func matchIgnore(ignore, relPath string) bool { return strings.HasSuffix(relPath, ignore) } +// NOTE(gofumpt): module loading is gofumpt-only. The go.mod is consulted for +// the default -lang (Go language version, used by version-gated rules), the +// default -modpath (so imports sharing the module prefix are grouped as +// third-party), and the `ignore` directives consumed by shouldIgnore above. +// Results are cached per directory; loadModule walks up to find an enclosing +// go.mod just like the go command would. +// // A nil entry means the directory is not part of a Go module, // or a go.mod file was found but it's invalid. // A non-nil entry means this directory, or a parent, is in a valid Go module. @@ -614,7 +675,10 @@ func loadModule(dir string) *cachedModule { fdSem <- true data, err := os.ReadFile(path) <-fdSem - if errors.Is(err, fs.ErrNotExist) { + if err != nil { + // If the file is missing, or we can't read this directory at all + // (e.g. permission denied on a directory listed in `ignore`), keep + // walking up to find an enclosing go.mod. parent := filepath.Dir(dir) if parent == "." { panic("loadModule was not given an absolute path?") @@ -624,9 +688,6 @@ func loadModule(dir string) *cachedModule { } return loadModule(parent) // try the parent directory } - if err != nil { - return nil // some other file reading error - } file, err := modfile.Parse(filepath.Join(dir, "go.mod"), data, nil) if err != nil { return nil // invalid go.mod file @@ -663,32 +724,111 @@ func fileWeight(path string, info fs.FileInfo) int64 { return info.Size() } -const chmodSupported = runtime.GOOS != "windows" +// writeFile updates a file with the new formatted data. +func writeFile(filename string, orig, formatted []byte, perm fs.FileMode, size int64) error { + // Make a temporary backup file before rewriting the original file. + bakname, err := backupFile(filename, orig, perm) + if err != nil { + return err + } + + fdSem <- true + defer func() { <-fdSem }() + + fout, err := os.OpenFile(filename, os.O_WRONLY, perm) + if err != nil { + // We couldn't even open the file, so it should + // not have changed. + os.Remove(bakname) + return err + } + defer fout.Close() // for error paths + + restoreFail := func(err error) { + fmt.Fprintf(os.Stderr, "gofumpt: %s: error restoring file to original: %v; backup in %s\n", filename, err, bakname) + } + + n, err := fout.Write(formatted) + if err == nil && int64(n) < size { + err = fout.Truncate(int64(n)) + } + + if err != nil { + // Rewriting the file failed. + + if n == 0 { + // Original file unchanged. + os.Remove(bakname) + return err + } + + // Try to restore the original contents. + + no, erro := fout.WriteAt(orig, 0) + if erro != nil { + // That failed too. + restoreFail(erro) + return err + } + + if no < n { + // Original file is shorter. Truncate. + if erro = fout.Truncate(int64(no)); erro != nil { + restoreFail(erro) + return err + } + } + + if erro := fout.Close(); erro != nil { + restoreFail(erro) + return err + } + + // Original contents restored. + os.Remove(bakname) + return err + } + + if err := fout.Close(); err != nil { + restoreFail(err) + return err + } + + // File updated. + os.Remove(bakname) + return nil +} // backupFile writes data to a new file named filename with permissions perm, -// with randomly chosen such that the file name is unique. backupFile returns // the chosen file name. func backupFile(filename string, data []byte, perm fs.FileMode) (string, error) { fdSem <- true defer func() { <-fdSem }() - // create backup file - f, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)) - if err != nil { - return "", err + nextRandom := func() string { + return strconv.Itoa(rand.Int()) } - bakname := f.Name() - if chmodSupported { - err = f.Chmod(perm) - if err != nil { - f.Close() - os.Remove(bakname) - return bakname, err + + dir, base := filepath.Split(filename) + var ( + bakname string + f *os.File + ) + for { + bakname = filepath.Join(dir, base+"."+nextRandom()) + var err error + f, err = os.OpenFile(bakname, os.O_RDWR|os.O_CREATE|os.O_EXCL, perm) + if err == nil { + break + } + if !os.IsExist(err) { + return "", err } } // write data to backup file - _, err = f.Write(data) + _, err := f.Write(data) if err1 := f.Close(); err == nil { err = err1 } diff --git a/vendor/mvdan.cc/gofumpt/internal.go b/vendor/mvdan.cc/gofumpt/internal.go index 2f7e51420..3c9f56037 100644 --- a/vendor/mvdan.cc/gofumpt/internal.go +++ b/vendor/mvdan.cc/gofumpt/internal.go @@ -16,6 +16,9 @@ import ( "go/token" "strings" + // NOTE(gofumpt): use a vendored copy of go/printer (and go/doc/comment) + // frozen at a specific Go version. This way installing a given gofumpt + // release produces the same output regardless of the user's Go toolchain. "mvdan.cc/gofumpt/internal/govendor/go/printer" ) diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/format/internal.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/format/internal.go index 383655f16..df0358714 100644 --- a/vendor/mvdan.cc/gofumpt/internal/govendor/go/format/internal.go +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/format/internal.go @@ -33,7 +33,7 @@ func parse(fset *token.FileSet, filename string, src []byte, fragmentOk bool) ( // package line and source fragments are ok, fall through to // try as a source fragment. Stop and return on any other error. if err == nil || !fragmentOk || !strings.Contains(err.Error(), "expected 'package'") { - return file, sourceAdj, indentAdj, err + return } // If this is a declaration list, make it a source file @@ -49,13 +49,13 @@ func parse(fset *token.FileSet, filename string, src []byte, fragmentOk bool) ( src = src[indent+len("package p\n"):] return bytes.TrimSpace(src) } - return file, sourceAdj, indentAdj, err + return } // If the error is that the source file didn't begin with a // declaration, fall through to try as a statement list. // Stop and return on any other error. if !strings.Contains(err.Error(), "expected declaration") { - return file, sourceAdj, indentAdj, err + return } // If this is a statement list, make it a source file @@ -86,7 +86,7 @@ func parse(fset *token.FileSet, filename string, src []byte, fragmentOk bool) ( } // Succeeded, or out of options. - return file, sourceAdj, indentAdj, err + return } // format formats the given package file originally obtained from src diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/nodes.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/nodes.go index df3b7250e..c7d2b0f14 100644 --- a/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/nodes.go +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/nodes.go @@ -57,7 +57,7 @@ func (p *printer) linebreak(line, min int, ws whiteSpace, newSection bool) (nbre p.print(newline) } } - return nbreaks + return } // setComment sets g as the next comment if g != nil and if node comments @@ -465,7 +465,7 @@ func identListSize(list []*ast.Ident, maxSize int) (size int) { break } } - return size + return } func (p *printer) isOneLineFieldList(list []*ast.Field) bool { @@ -693,7 +693,7 @@ func walkBinary(e *ast.BinaryExpr) (has4, has5 bool, maxProblem int) { maxProblem = max(maxProblem, 4) } } - return has4, has5, maxProblem + return } func cutoff(e *ast.BinaryExpr, depth int) int { @@ -1818,14 +1818,14 @@ func (p *printer) nodeSize(n ast.Node, maxSize int) (size int) { cfg := Config{Mode: RawFormat} var counter sizeCounter if err := cfg.fprint(&counter, p.fset, n, p.nodeSizes); err != nil { - return size + return } if counter.size <= maxSize && !counter.hasNewline { // n fits in a single line size = counter.size p.nodeSizes[n] = size } - return size + return } // numLines returns the number of lines spanned by node n in the original source. @@ -1959,7 +1959,7 @@ func declToken(decl ast.Decl) (tok token.Token) { case *ast.FuncDecl: tok = token.FUNC } - return tok + return } func (p *printer) declList(list []ast.Decl) { diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/printer.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/printer.go index 00713309b..a6c74c729 100644 --- a/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/printer.go +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/printer.go @@ -715,7 +715,7 @@ func (p *printer) writeCommentSuffix(needsLinebreak bool) (wroteNewline, dropped wroteNewline = true } - return wroteNewline, droppedFF + return } // containsLinebreak reports whether the whitespace buffer contains any line breaks. @@ -809,7 +809,7 @@ func (p *printer) intersperseComments(next token.Position, tok token.Token) (wro // no comment was written - we should never reach here since // intersperseComments should not be called in that case p.internalError("intersperseComments called without pending comments") - return wroteNewline, droppedFF + return } // writeWhitespace writes the first n whitespace entries. @@ -878,7 +878,7 @@ func mayCombine(prev token.Token, next byte) (b bool) { case token.AND: b = next == '&' || next == '^' // && or &^ } - return b + return } func (p *printer) setPos(pos token.Pos) { @@ -1041,7 +1041,7 @@ func (p *printer) flush(next token.Position, tok token.Token) (wroteNewline, dro // otherwise, write any leftover whitespace p.writeWhitespace(len(p.wsbuf)) } - return wroteNewline, droppedFF + return } // getDoc returns the ast.CommentGroup associated with n, if any. @@ -1269,7 +1269,7 @@ func (p *trimmer) Write(data []byte) (n int, err error) { panic("unreachable") } if err != nil { - return n, err + return } } n = len(data) @@ -1280,7 +1280,7 @@ func (p *trimmer) Write(data []byte) (n int, err error) { p.resetSpace() } - return n, err + return } // ---------------------------------------------------------------------------- @@ -1361,7 +1361,7 @@ func (cfg *Config) fprint(output io.Writer, fset *token.FileSet, node any, nodeS p := newPrinter(cfg, fset, nodeSizes) defer p.free() if err = p.printNode(node); err != nil { - return err + return } // print outstanding comments p.impliedSemi = false // EOF acts like a newline @@ -1397,7 +1397,7 @@ func (cfg *Config) fprint(output io.Writer, fset *token.FileSet, node any, nodeS // write printer result via tabwriter/trimmer to output if _, err = output.Write(p.output); err != nil { - return err + return } // flush tabwriter, if any @@ -1405,7 +1405,7 @@ func (cfg *Config) fprint(output io.Writer, fset *token.FileSet, node any, nodeS err = tw.Flush() } - return err + return } // A CommentedNode bundles an AST node and corresponding comments.