From 52ffc5465ac248943ee2c20832ca1fc7eaedc828 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 11 May 2026 16:01:49 +0200 Subject: [PATCH 001/384] Remove the invitation to submit PRs from the issue template --- .github/ISSUE_TEMPLATE/feature_request.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index b47bbf68d..dfe4e61c2 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -22,8 +22,4 @@ Add any other context or screenshots about the feature request here. You may be able to add your desired feature with a custom command. Check out the examples here: https://github.com/jesseduffield/lazygit/wiki/Custom-Commands-Compendium If a custom command does what you want but you still want to see the feature built-in to lazygit, feel free to paste the custom command into the issue to help us better understand the functionality you want. - -We also encourage you to put up a PR yourself! Who cares if you've never written Go before, neither did any of the existing contributors before their first lazygit PR! Check out the PR tutorial here: https://www.youtube.com/watch?v=kNavnhzZHtk&ab_channel=JesseDuffield - -Also check out the contributing guide here: https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md --> From 16e6d0828192acf552ecc3b1f6ead95db8336007 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 09:23:25 +0200 Subject: [PATCH 002/384] Add script to preview release notes --- scripts/preview_release_notes.sh | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100755 scripts/preview_release_notes.sh diff --git a/scripts/preview_release_notes.sh b/scripts/preview_release_notes.sh new file mode 100755 index 000000000..4c58514e4 --- /dev/null +++ b/scripts/preview_release_notes.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +# Preview the release notes that would be generated if we were to create a +# release now. + +gh api -X POST /repos/jesseduffield/lazygit/releases/generate-notes \ + -f tag_name=v0.99.0 \ + -f target_commitish=master \ + -q .body | code - From 49410fc95394d1a97a90c00611f995a04e980acf Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 13 May 2026 09:54:47 +0200 Subject: [PATCH 003/384] Some additions to AGENTS.md --- AGENTS.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 476d6e03a..1d7688b6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,13 +106,54 @@ the buggy one, so the file compiles and the test passes against unfixed code. In the fix commit, remove the comment markers and delete the `ACTUAL` line. Don't explain the pattern in commit messages. +The fix commit must be _exactly_ "delete the markers and delete the `ACTUAL` +line" — no other edits. That means `EXPECTED` and `ACTUAL` have to be drop-in +replacements for each other at the same syntactic position. If you can't write +them that way (e.g. one is `.IsEmpty()` and the other is `.Lines(...)`), +restructure the surrounding code until you can — usually by putting the +comment block between two adjacent chained calls, so both forms are just the +next method in the chain: + +```go +t.Views().Files(). + Focus(). + /* EXPECTED: + IsEmpty() + ACTUAL: */ + Lines( + Equals("D file03.txt"), + ) +``` + +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. +## Integration test conventions + +Don't bind views to local variables. Always chain method calls directly from +`t.Views().()`. Patterns like `filesView := t.Views().Files().Focus()` +followed by `filesView.Lines(...)` are not how tests in this repo are written; +keep the call site fluent. + ## Use stretchr/testify for assertions Prefer `assert.Equal` (and friends) over hand-rolled `if` checks. The failure messages are more useful and the intent is clearer at a glance. +## Don't present "live with the bug" as an option + +When you're investigating a defect and laying out fix options for the user, +"accept the race / leave it as-is / document it and move on" is not one of +them. A known race condition, data corruption, or correctness violation is a +bug that needs a real fix, not a tradeoff. Even if the failure rate is low, +even if the window is tiny, even if no current code path appears to hit it — +present actual fixes. If a real fix is genuinely out of reach (e.g. it +requires API changes you can't make), say so plainly; don't dress "no fix" +up as a viable option in a numbered list alongside real ones. + ## Don't search outside the working tree Never run `find` (or similar) from `/` or other paths outside the project. All From 7d1d90ae4d319f8f1bb54f99e46c2bae20ff66e8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 11:54:18 +0200 Subject: [PATCH 004/384] Preserve empty Worktrees slice when worktree list fails to load If `git worktree list` fails, we want the Worktrees model to fall back to an empty slice so callers iterating over it stay correct. The error branch was setting it to `[]`, but the line below unconditionally overwrote it with the nil `worktrees` value from the failed call. Use an else branch so the empty-slice fallback actually sticks. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 2922b4403..d27b38feb 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -724,9 +724,9 @@ func (self *RefreshHelper) loadWorktrees() { if err != nil { self.c.Log.Error(err) self.c.Model().Worktrees = []*models.Worktree{} + } else { + self.c.Model().Worktrees = worktrees } - - self.c.Model().Worktrees = worktrees } func (self *RefreshHelper) refreshWorktrees() { From 77652863c4c152fce1d2556db15c5ce3b9702bdf Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 11:54:45 +0200 Subject: [PATCH 005/384] Refresh pull requests after a manual fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background fetch path already includes PULL_REQUESTS in its post-fetch refresh scope, but the manual fetch from the files view doesn't. As far as I can tell that's an oversight from when PULL_REQUESTS was added — there's no reason the two paths should differ. Align them so both refresh PRs after fetching. This also sets up the next commit to extract a shared helper for the post-fetch refresh. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gui/controllers/files_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index e75a5ee3f..1513a324e 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1348,7 +1348,7 @@ func (self *FilesController) fetch() error { return errors.New(self.c.Tr.PassUnameWrong) } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.SYNC}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS}, Mode: types.SYNC}) if err == nil { err = self.c.Helpers().BranchesHelper.AutoForwardBranches() From f032ee8b0f36747b4edfeab178e82e2a4d32c643 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 11:57:02 +0200 Subject: [PATCH 006/384] Extract BranchesHelper.PostFetchRefresh to unify the two fetch paths The post-fetch logic was duplicated in `backgroundFetch` and the manual fetch handler: refresh a fixed set of views, then auto-forward branches if the fetch succeeded. The two had already drifted on the refresh scope; folding them into a single helper makes the duplication go away and prevents it from drifting again. Pass the fetch error through so we preserve the previous behaviour of refreshing unconditionally but only auto-forwarding on success. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gui/background.go | 8 +------- pkg/gui/controllers/files_controller.go | 8 +------- pkg/gui/controllers/helpers/branches_helper.go | 13 +++++++++++++ 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index cedc6a78c..8795b49aa 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -155,13 +155,7 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru func (self *BackgroundRoutineMgr) backgroundFetch() (err error) { err = self.gui.git.Sync.FetchBackground() - self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS}, Mode: types.SYNC}) - - if err == nil { - err = self.gui.helpers.BranchesHelper.AutoForwardBranches() - } - - return err + return self.gui.helpers.BranchesHelper.PostFetchRefresh(err) } func (self *BackgroundRoutineMgr) triggerImmediateFetch() { diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 1513a324e..c8d50d54d 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1348,13 +1348,7 @@ func (self *FilesController) fetch() error { return errors.New(self.c.Tr.PassUnameWrong) } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS}, Mode: types.SYNC}) - - if err == nil { - err = self.c.Helpers().BranchesHelper.AutoForwardBranches() - } - - return err + return self.c.Helpers().BranchesHelper.PostFetchRefresh(err) }) } diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index c3f4242bf..a53bf2181 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -285,6 +285,19 @@ func (self *BranchesHelper) deleteRemoteBranches(remoteBranches []*models.Remote return nil } +func (self *BranchesHelper) PostFetchRefresh(fetchErr error) error { + self.c.Refresh(types.RefreshOptions{ + Scope: []types.RefreshableView{ + types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS, + }, + Mode: types.SYNC, + }) + if fetchErr != nil { + return fetchErr + } + return self.AutoForwardBranches() +} + func (self *BranchesHelper) AutoForwardBranches() error { if self.c.UserConfig().Git.AutoForwardBranches == "none" { return nil From 532cce4873737a5d32c68d48a83bab098b124cb4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 12:30:36 +0200 Subject: [PATCH 007/384] Add test for auto-forwarding a branch checked out in another worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the linked worktree's branch is changed externally — by another shell, by another tool, or by git running outside lazygit — lazygit's worktrees model goes stale. The next post-fetch auto-forward then doesn't realise the branch is now checked out elsewhere, and advances its ref behind the worktree's back. The worktree's HEAD then resolves to a commit its index/working tree haven't been updated to, and the user sees that diff as the inverse of what was fetched — files appearing as pending changes that they didn't make. The test sets up a linked worktree initially on a side branch, then externally checks out master in it before pressing fetch. Two EXPECTED/ACTUAL pairs capture the symptoms: the branches view shows master as `✓` rather than `↓1`, and switching to the linked worktree shows master's would-be incoming file as a pending deletion against HEAD. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...d_branches_worktree_added_after_startup.go | 68 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 69 insertions(+) create mode 100644 pkg/integration/tests/sync/fetch_and_auto_forward_branches_worktree_added_after_startup.go diff --git a/pkg/integration/tests/sync/fetch_and_auto_forward_branches_worktree_added_after_startup.go b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_worktree_added_after_startup.go new file mode 100644 index 000000000..61cb0fd9f --- /dev/null +++ b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_worktree_added_after_startup.go @@ -0,0 +1,68 @@ +package sync + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var FetchAndAutoForwardBranchesWorktreeAddedAfterStartup = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Auto-forward skips a main branch that was externally checked out in a linked worktree after lazygit started", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Git.AutoForwardBranches = "onlyMainBranches" + config.GetUserConfig().Git.LocalBranchSortOrder = "alphabetical" + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(3) + shell.NewBranch("feature") + shell.NewBranch("wt-branch") + shell.CloneIntoRemote("origin") + shell.SetBranchUpstream("master", "origin/master") + shell.SetBranchUpstream("feature", "origin/feature") + shell.Checkout("master") + shell.HardReset("HEAD^") + shell.Checkout("feature") + shell.AddWorktreeCheckout("wt-branch", "../linked-worktree") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Lines( + Contains("feature").IsSelected(), + Contains("master ↓1").DoesNotContain("↑"), + Contains("wt-branch (worktree linked-worktree)"), + ) + + // Switch the linked worktree to master externally. + t.Shell().RunCommand([]string{"git", "-C", "../linked-worktree", "checkout", "master"}) + + t.Views().Files(). + IsFocused(). + Press(keys.Files.Fetch) + + t.Views().Branches(). + Lines( + Contains("feature").IsSelected(), + /* EXPECTED: + Contains("master (worktree linked-worktree) ↓1"), + Contains("wt-branch").DoesNotContain("worktree"), + ACTUAL: */ + Contains("master ✓"), + Contains("wt-branch (worktree linked-worktree)"), + ) + + t.Views().Worktrees(). + Focus(). + NavigateToLine(Contains("linked-worktree")). + PressPrimaryAction() + + t.Views().Files(). + Focus(). + /* EXPECTED: + IsEmpty() + ACTUAL: */ + Lines( + Equals("D file03.txt"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 4b96c4d42..fdccb08bf 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -428,6 +428,7 @@ var tests = []*components.IntegrationTest{ sync.FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree, sync.FetchAndAutoForwardBranchesNone, sync.FetchAndAutoForwardBranchesOnlyMainBranches, + sync.FetchAndAutoForwardBranchesWorktreeAddedAfterStartup, sync.FetchPrune, sync.FetchWhenSortedByDate, sync.ForcePush, From 4f6cdedb1e5ea8c13d0ccbf57d772a387ef5728f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 21 May 2026 12:31:09 +0200 Subject: [PATCH 008/384] Refresh worktrees before auto-forwarding branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AutoForwardBranches relies on the worktree model to skip any branch that's currently checked out in another worktree (so we don't update its ref behind the worktree's back). The post-fetch refresh wasn't including the worktrees scope, so any external change to the worktree list between lazygit's startup and the fetch — a `git worktree add`, a `git checkout` in a linked worktree, a branch rename — left the in-memory model stale and the skip check returned false negatives. Add WORKTREES to the post-fetch refresh scope when auto-forwarding is enabled. We gate on the config so users with auto-forward disabled don't pay for an extra `git worktree list` plus per-worktree rev-parse on every fetch tick. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gui/controllers/helpers/branches_helper.go | 14 ++++++++------ ...orward_branches_worktree_added_after_startup.go | 9 --------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index a53bf2181..8af447f79 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -286,12 +286,14 @@ func (self *BranchesHelper) deleteRemoteBranches(remoteBranches []*models.Remote } func (self *BranchesHelper) PostFetchRefresh(fetchErr error) error { - self.c.Refresh(types.RefreshOptions{ - Scope: []types.RefreshableView{ - types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS, - }, - Mode: types.SYNC, - }) + scope := []types.RefreshableView{ + types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS, + } + // AutoForwardBranches needs a fresh worktree model to skip branches that are checked out elsewhere. + if self.c.UserConfig().Git.AutoForwardBranches != "none" { + scope = append(scope, types.WORKTREES) + } + self.c.Refresh(types.RefreshOptions{Scope: scope, Mode: types.SYNC}) if fetchErr != nil { return fetchErr } diff --git a/pkg/integration/tests/sync/fetch_and_auto_forward_branches_worktree_added_after_startup.go b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_worktree_added_after_startup.go index 61cb0fd9f..bee14276a 100644 --- a/pkg/integration/tests/sync/fetch_and_auto_forward_branches_worktree_added_after_startup.go +++ b/pkg/integration/tests/sync/fetch_and_auto_forward_branches_worktree_added_after_startup.go @@ -43,12 +43,8 @@ var FetchAndAutoForwardBranchesWorktreeAddedAfterStartup = NewIntegrationTest(Ne t.Views().Branches(). Lines( Contains("feature").IsSelected(), - /* EXPECTED: Contains("master (worktree linked-worktree) ↓1"), Contains("wt-branch").DoesNotContain("worktree"), - ACTUAL: */ - Contains("master ✓"), - Contains("wt-branch (worktree linked-worktree)"), ) t.Views().Worktrees(). @@ -58,11 +54,6 @@ var FetchAndAutoForwardBranchesWorktreeAddedAfterStartup = NewIntegrationTest(Ne t.Views().Files(). Focus(). - /* EXPECTED: IsEmpty() - ACTUAL: */ - Lines( - Equals("D file03.txt"), - ) }, }) From 880064b9870e6b494fb06d6fb95c8559d61f6f39 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 25 May 2026 15:16:25 +0200 Subject: [PATCH 009/384] Use the isolated test env for shell commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell.RunShellCommand was passing os.Environ() to its child, while its sibling runCommandWithOutputAndEnv has used the minimal NewTestEnvironment since late 2023 when env isolation was introduced; the sh path was just missed. This matters when integration tests run from inside a `git rebase -x` exec in a linked worktree: git sets GIT_DIR=
/.git/worktrees/ for the exec, and it leaks all the way down through bash, just, go test, and the test process, into every git invocation RunShellCommand spawns. cmd.Dir becomes irrelevant — git resolves GIT_DIR over cwd-based discovery, with the work-tree taken from the gitdir file (i.e. the worktree root). So `git checkout -b conflict` in a test fixture creates the branch on the real worktree and switches its HEAD, hijacking the in-progress rebase and trashing the working tree. (In the main worktree git doesn't set GIT_DIR for rebase exec, which is why the bug was only visible from linked worktrees.) Using self.env also incidentally restores GIT_CONFIG_GLOBAL for shell commands, so commits made via RunShellCommand are now authored by the test config's CI identity rather than whatever the host's ~/.gitconfig resolves to. --- pkg/integration/components/shell.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/integration/components/shell.go b/pkg/integration/components/shell.go index 2e5fa01ce..70b12146a 100644 --- a/pkg/integration/components/shell.go +++ b/pkg/integration/components/shell.go @@ -77,7 +77,7 @@ func (self *Shell) RunShellCommand(cmdStr string) *Shell { } cmd := exec.Command(shell, shellArg, cmdStr) - cmd.Env = os.Environ() + cmd.Env = self.env cmd.Dir = self.dir output, err := cmd.CombinedOutput() From 5bd91977ee39c7274200a52c5b494398748306fe Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 25 May 2026 12:49:54 +0200 Subject: [PATCH 010/384] Add script for checking all commits in a branch --- scripts/check_commit.sh | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100755 scripts/check_commit.sh diff --git a/scripts/check_commit.sh b/scripts/check_commit.sh new file mode 100755 index 000000000..9cc5e5c7b --- /dev/null +++ b/scripts/check_commit.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +# Run some tests on the current commit, similar to what CI does; useful for +# checking every commit in a branch with `git rebase -x scripts/check_commit.sh master`. + +set -e + +git diff --quiet || { + echo "Error: there are unstaged changes. Please stage or stash them before running this script." + exit 1 +} + +just test +just lint +just generate +git diff --quiet || { + echo "Error: auto-generated files not up to date." + exit 1 +} From 5a363578b4bd2fc35c86a51d9316872d75cec0a5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 17 May 2026 15:37:48 +0200 Subject: [PATCH 011/384] Remove unused text KeybindingsLegend Should have been removed in 74a6ea85c82. --- pkg/i18n/english.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 31b18dca0..ea8e42da4 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -614,7 +614,6 @@ type TranslationSet struct { SelectRemoteRepository string FetchingPullRequests string Keybindings string - KeybindingsLegend string KeybindingsMenuSectionLocal string KeybindingsMenuSectionGlobal string KeybindingsMenuSectionNavigation string From 3c279614bf26c34c32f3cb418474249723c4ce4c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 12 May 2026 09:25:19 +0200 Subject: [PATCH 012/384] Change SetKeybinding to not return an error It always returned nil. --- pkg/gocui/gui.go | 3 +- pkg/gui/keybindings.go | 8 ++-- pkg/integration/clients/tui.go | 74 +++++++++++----------------------- 3 files changed, 28 insertions(+), 57 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 645329d6b..579dfe21d 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -546,10 +546,9 @@ func (g *Gui) CurrentView() *View { // SetKeybinding creates a new keybinding. If viewname equals to "" // (empty string) then the keybinding will apply to all views. key must // be a rune or a Key. -func (g *Gui) SetKeybinding(viewname string, key Key, handler func(*Gui, *View) error) error { +func (g *Gui) SetKeybinding(viewname string, key Key, handler func(*Gui, *View) error) { kb := newKeybinding(viewname, key, handler) g.keybindings = append(g.keybindings, kb) - return nil } // DeleteKeybindings deletes all keybindings of view. diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 082d99ecc..11a3fbc6a 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -418,9 +418,7 @@ func (gui *Gui) resetKeybindings() error { bindings, mouseBindings := gui.GetInitialKeybindingsWithCustomCommands() for _, binding := range bindings { - if err := gui.SetKeybinding(binding); err != nil { - return err - } + gui.SetKeybinding(binding) } for _, binding := range mouseBindings { @@ -445,12 +443,12 @@ func (gui *Gui) resetKeybindings() error { return nil } -func (gui *Gui) SetKeybinding(binding *types.Binding) error { +func (gui *Gui) SetKeybinding(binding *types.Binding) { handler := func(g *gocui.Gui, v *gocui.View) error { return gui.callKeybindingHandler(binding) } - return gui.g.SetKeybinding(binding.ViewName, binding.Key, handler) + gui.g.SetKeybinding(binding.ViewName, binding.Key, handler) } func (gui *Gui) SetMouseKeybinding(binding *gocui.ViewMouseBinding) error { diff --git a/pkg/integration/clients/tui.go b/pkg/integration/clients/tui.go index c4f8f94ab..0f07b5b19 100644 --- a/pkg/integration/clients/tui.go +++ b/pkg/integration/clients/tui.go @@ -43,7 +43,7 @@ func RunTUI(raceDetector bool) { g.SetManagerFunc(app.layout) - if err := g.SetKeybinding("list", gocui.NewKeyName(gocui.KeyArrowUp), func(*gocui.Gui, *gocui.View) error { + g.SetKeybinding("list", gocui.NewKeyName(gocui.KeyArrowUp), func(*gocui.Gui, *gocui.View) error { if app.itemIdx > 0 { app.itemIdx-- } @@ -53,11 +53,9 @@ func RunTUI(raceDetector bool) { } listView.FocusPoint(0, app.itemIdx, true) return nil - }); err != nil { - log.Panicln(err) - } + }) - if err := g.SetKeybinding("list", gocui.NewKeyName(gocui.KeyArrowDown), func(*gocui.Gui, *gocui.View) error { + g.SetKeybinding("list", gocui.NewKeyName(gocui.KeyArrowDown), func(*gocui.Gui, *gocui.View) error { if app.itemIdx < len(app.filteredTests)-1 { app.itemIdx++ } @@ -68,19 +66,13 @@ func RunTUI(raceDetector bool) { } listView.FocusPoint(0, app.itemIdx, true) return nil - }); err != nil { - log.Panicln(err) - } + }) - if err := g.SetKeybinding("list", gocui.NewKeyStrMod("c", gocui.ModCtrl), quit); err != nil { - log.Panicln(err) - } + g.SetKeybinding("list", gocui.NewKeyStrMod("c", gocui.ModCtrl), quit) - if err := g.SetKeybinding("list", gocui.NewKeyRune('q'), quit); err != nil { - log.Panicln(err) - } + g.SetKeybinding("list", gocui.NewKeyRune('q'), quit) - if err := g.SetKeybinding("list", gocui.NewKeyRune('s'), func(*gocui.Gui, *gocui.View) error { + g.SetKeybinding("list", gocui.NewKeyRune('s'), func(*gocui.Gui, *gocui.View) error { currentTest := app.getCurrentTest() if currentTest == nil { return nil @@ -89,11 +81,9 @@ func RunTUI(raceDetector bool) { suspendAndRunTest(currentTest, true, false, raceDetector, 0) return nil - }); err != nil { - log.Panicln(err) - } + }) - if err := g.SetKeybinding("list", gocui.NewKeyName(gocui.KeyEnter), func(*gocui.Gui, *gocui.View) error { + g.SetKeybinding("list", gocui.NewKeyName(gocui.KeyEnter), func(*gocui.Gui, *gocui.View) error { currentTest := app.getCurrentTest() if currentTest == nil { return nil @@ -102,11 +92,9 @@ func RunTUI(raceDetector bool) { suspendAndRunTest(currentTest, false, false, raceDetector, 0) return nil - }); err != nil { - log.Panicln(err) - } + }) - if err := g.SetKeybinding("list", gocui.NewKeyRune('t'), func(*gocui.Gui, *gocui.View) error { + g.SetKeybinding("list", gocui.NewKeyRune('t'), func(*gocui.Gui, *gocui.View) error { currentTest := app.getCurrentTest() if currentTest == nil { return nil @@ -115,11 +103,9 @@ func RunTUI(raceDetector bool) { suspendAndRunTest(currentTest, false, false, raceDetector, SLOW_INPUT_DELAY) return nil - }); err != nil { - log.Panicln(err) - } + }) - if err := g.SetKeybinding("list", gocui.NewKeyRune('d'), func(*gocui.Gui, *gocui.View) error { + g.SetKeybinding("list", gocui.NewKeyRune('d'), func(*gocui.Gui, *gocui.View) error { currentTest := app.getCurrentTest() if currentTest == nil { return nil @@ -128,11 +114,9 @@ func RunTUI(raceDetector bool) { suspendAndRunTest(currentTest, false, true, raceDetector, 0) return nil - }); err != nil { - log.Panicln(err) - } + }) - if err := g.SetKeybinding("list", gocui.NewKeyRune('o'), func(*gocui.Gui, *gocui.View) error { + g.SetKeybinding("list", gocui.NewKeyRune('o'), func(*gocui.Gui, *gocui.View) error { currentTest := app.getCurrentTest() if currentTest == nil { return nil @@ -144,11 +128,9 @@ func RunTUI(raceDetector bool) { } return nil - }); err != nil { - log.Panicln(err) - } + }) - if err := g.SetKeybinding("list", gocui.NewKeyRune('O'), func(*gocui.Gui, *gocui.View) error { + g.SetKeybinding("list", gocui.NewKeyRune('O'), func(*gocui.Gui, *gocui.View) error { currentTest := app.getCurrentTest() if currentTest == nil { return nil @@ -160,11 +142,9 @@ func RunTUI(raceDetector bool) { } return nil - }); err != nil { - log.Panicln(err) - } + }) - if err := g.SetKeybinding("list", gocui.NewKeyRune('/'), func(*gocui.Gui, *gocui.View) error { + g.SetKeybinding("list", gocui.NewKeyRune('/'), func(*gocui.Gui, *gocui.View) error { app.filtering = true if _, err := g.SetCurrentView("editor"); err != nil { return err @@ -176,12 +156,10 @@ func RunTUI(raceDetector bool) { editorView.Clear() return nil - }); err != nil { - log.Panicln(err) - } + }) // not using the editor yet, but will use it to help filter the list - if err := g.SetKeybinding("editor", gocui.NewKeyName(gocui.KeyEsc), func(*gocui.Gui, *gocui.View) error { + g.SetKeybinding("editor", gocui.NewKeyName(gocui.KeyEsc), func(*gocui.Gui, *gocui.View) error { app.filtering = false if _, err := g.SetCurrentView("list"); err != nil { return err @@ -194,11 +172,9 @@ func RunTUI(raceDetector bool) { app.editorView.Reset() return nil - }); err != nil { - log.Panicln(err) - } + }) - if err := g.SetKeybinding("editor", gocui.NewKeyName(gocui.KeyEnter), func(*gocui.Gui, *gocui.View) error { + g.SetKeybinding("editor", gocui.NewKeyName(gocui.KeyEnter), func(*gocui.Gui, *gocui.View) error { app.filtering = false if _, err := g.SetCurrentView("list"); err != nil { @@ -208,9 +184,7 @@ func RunTUI(raceDetector bool) { app.renderTests() return nil - }); err != nil { - log.Panicln(err) - } + }) err = g.MainLoop() g.Close() From 12cfb9be1ffd5ae79ed8feae5ca3a8c17a4e122f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 11 May 2026 09:20:22 +0200 Subject: [PATCH 013/384] Remove OptionMenuAlt1 For legacy reasons, OptionMenu was set to ``, and OptionMenuAlt1 to `?`. This doesn't make a lot of sense any more; get rid of OptionMenuAlt1 and bind OptionMenu to `?` by default. This is a breaking change for users who rebound OptionMenuAlt1 in their config, but it doesn't strike me as very likely, and it's easy enough to fix. --- docs-master/Config.md | 3 +-- pkg/config/user_config.go | 4 +--- pkg/gui/controllers/global_controller.go | 17 +++++------------ .../tests/ui/switch_tab_from_menu.go | 2 +- schema-master/config.json | 4 ---- 5 files changed, 8 insertions(+), 22 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 05d3b6d20..b24329548 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -643,8 +643,7 @@ keybinding: # on Mac forwardDeleteWord: - optionMenu: - optionMenu-alt1: '?' + optionMenu: '?' select: goInto: confirm: diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index ddb0c0876..3d59782e5 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -459,7 +459,6 @@ type KeybindingUniversalConfig struct { BackspaceWord string `yaml:"backspaceWord"` // on Mac ForwardDeleteWord string `yaml:"forwardDeleteWord"` // on Mac OptionMenu string `yaml:"optionMenu"` - OptionMenuAlt1 string `yaml:"optionMenu-alt1"` Select string `yaml:"select"` GoInto string `yaml:"goInto"` Confirm string `yaml:"confirm"` @@ -941,8 +940,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { MoveWordRight: platformKeyBinding(platform, map[string]string{"darwin": ""}, ""), BackspaceWord: platformKeyBinding(platform, map[string]string{"darwin": ""}, ""), ForwardDeleteWord: platformKeyBinding(platform, map[string]string{"darwin": ""}, ""), - OptionMenu: "", - OptionMenuAlt1: "?", + OptionMenu: "?", Select: "", GoInto: "", Confirm: "", diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index d7f18fcb4..f8f981525 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -75,21 +75,14 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type DisplayOnScreen: true, }, { - ViewName: "", - Key: opts.GetKey(opts.Config.Universal.OptionMenu), - Handler: self.createOptionsMenu, - OpensMenu: true, - }, - { - ViewName: "", - Key: opts.GetKey(opts.Config.Universal.OptionMenuAlt1), - // we have the description on the alt key and not the main key for legacy reasons - // (the original main key was 'x' but we've reassigned that to other purposes) + ViewName: "", + Key: opts.GetKey(opts.Config.Universal.OptionMenu), Description: self.c.Tr.OpenKeybindingsMenu, - Handler: self.createOptionsMenu, ShortDescription: self.c.Tr.Keybindings, - DisplayOnScreen: true, + Handler: self.createOptionsMenu, GetDisabledReason: self.optionsMenuDisabledReason, + OpensMenu: true, + DisplayOnScreen: true, }, { ViewName: "", diff --git a/pkg/integration/tests/ui/switch_tab_from_menu.go b/pkg/integration/tests/ui/switch_tab_from_menu.go index 61bd991ab..fbcdbd954 100644 --- a/pkg/integration/tests/ui/switch_tab_from_menu.go +++ b/pkg/integration/tests/ui/switch_tab_from_menu.go @@ -14,7 +14,7 @@ var SwitchTabFromMenu = NewIntegrationTest(NewIntegrationTestArgs{ }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files().IsFocused(). - Press(keys.Universal.OptionMenuAlt1) + Press(keys.Universal.OptionMenu) t.ExpectPopup().Menu().Title(Equals("Keybindings")). Select(Contains("Next tab")). diff --git a/schema-master/config.json b/schema-master/config.json index 549b80877..d6ad8116d 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -1431,10 +1431,6 @@ "default": "\u003cctrl+delete\u003e" }, "optionMenu": { - "type": "string", - "default": "\u003cdisabled\u003e" - }, - "optionMenu-alt1": { "type": "string", "default": "?" }, From 22a508fdba53f7fd9a8d0a450d1fedcc586e329f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 10 May 2026 16:53:33 +0200 Subject: [PATCH 014/384] Add menuKey helper to reduce noise on menu item literals Constructing a menu item key from a literal character requires gocui.NewKeyRune('r'), which is a bit noisy. Add a private menuKey helper in both the controllers and helpers packages so the common case in either reads as menuKey('r'). Duplicating the one-liner is cheaper than a cross-package import dependency and avoids forcing every controller file to qualify the call. The reason for doing this now is that we are going to change MenuItem.Key to a slice of keys later in the branch, which means we'd have to add `[]gocui.Key{` at each call site, making them even more noisy. With the menuKey helper we can just change its signature and leave all clients unchanged. --- .../controllers/basic_commits_controller.go | 15 ++++--- pkg/gui/controllers/bisect_controller.go | 17 ++++---- pkg/gui/controllers/branches_controller.go | 16 ++++---- .../controllers/commits_files_controller.go | 12 +++--- .../custom_patch_options_menu_action.go | 18 ++++----- pkg/gui/controllers/files_controller.go | 40 +++++++++---------- pkg/gui/controllers/git_flow_controller.go | 9 ++--- pkg/gui/controllers/helpers/commits_helper.go | 6 +-- pkg/gui/controllers/helpers/menu_key.go | 11 +++++ .../helpers/merge_and_rebase_helper.go | 26 ++++++------ pkg/gui/controllers/helpers/refs_helper.go | 18 ++++----- .../helpers/working_tree_helper.go | 9 ++--- .../controllers/local_commits_controller.go | 18 ++++----- pkg/gui/controllers/menu_key.go | 11 +++++ pkg/gui/controllers/submodules_controller.go | 8 ++-- pkg/gui/controllers/tags_controller.go | 6 +-- .../controllers/workspace_reset_controller.go | 14 +++---- 17 files changed, 136 insertions(+), 118 deletions(-) create mode 100644 pkg/gui/controllers/helpers/menu_key.go create mode 100644 pkg/gui/controllers/menu_key.go diff --git a/pkg/gui/controllers/basic_commits_controller.go b/pkg/gui/controllers/basic_commits_controller.go index f425addb5..60605cf44 100644 --- a/pkg/gui/controllers/basic_commits_controller.go +++ b/pkg/gui/controllers/basic_commits_controller.go @@ -6,7 +6,6 @@ import ( "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/traits" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" @@ -164,14 +163,14 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e OnPress: func() error { return self.copyCommitSubjectToClipboard(commit) }, - Key: gocui.NewKeyRune('s'), + Key: menuKey('s'), }, { Label: self.c.Tr.CommitMessage, OnPress: func() error { return self.copyCommitMessageToClipboard(commit) }, - Key: gocui.NewKeyRune('m'), + Key: menuKey('m'), }, { Label: self.c.Tr.CommitMessageBody, @@ -179,28 +178,28 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e OnPress: func() error { return self.copyCommitMessageBodyToClipboard(commitMessageBody) }, - Key: gocui.NewKeyRune('b'), + Key: menuKey('b'), }, { Label: self.c.Tr.CommitURL, OnPress: func() error { return self.copyCommitURLToClipboard(commit) }, - Key: gocui.NewKeyRune('u'), + Key: menuKey('u'), }, { Label: self.c.Tr.CommitDiff, OnPress: func() error { return self.copyCommitDiffToClipboard(commit) }, - Key: gocui.NewKeyRune('d'), + Key: menuKey('d'), }, { Label: self.c.Tr.CommitAuthor, OnPress: func() error { return self.copyAuthorToClipboard(commit) }, - Key: gocui.NewKeyRune('a'), + Key: menuKey('a'), }, } @@ -209,7 +208,7 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e OnPress: func() error { return self.copyCommitTagsToClipboard(commit) }, - Key: gocui.NewKeyRune('t'), + Key: menuKey('t'), } if len(commit.Tags) == 0 { diff --git a/pkg/gui/controllers/bisect_controller.go b/pkg/gui/controllers/bisect_controller.go index 15c37b88a..d86d34f93 100644 --- a/pkg/gui/controllers/bisect_controller.go +++ b/pkg/gui/controllers/bisect_controller.go @@ -6,7 +6,6 @@ import ( "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/types" "github.com/jesseduffield/lazygit/pkg/utils" @@ -102,7 +101,7 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c return self.afterMark(selectCurrentAfter, waitToReselect) }, DisabledReason: singleItemIfNotBisecting, - Key: gocui.NewKeyRune('b'), + Key: menuKey('b'), }, { Label: fmt.Sprintf(self.c.Tr.Bisect.Mark, shortHashToMark, info.OldTerm()), @@ -115,7 +114,7 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c return self.afterMark(selectCurrentAfter, waitToReselect) }, DisabledReason: singleItemIfNotBisecting, - Key: gocui.NewKeyRune('g'), + Key: menuKey('g'), }, { Label: fmt.Sprintf(self.c.Tr.Bisect.SkipCurrent, shortHashToMark), @@ -128,7 +127,7 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c return self.afterMark(selectCurrentAfter, waitToReselect) }, DisabledReason: singleItemIfNotBisecting, - Key: gocui.NewKeyRune('s'), + Key: menuKey('s'), }, } if info.GetCurrentHash() != "" && info.GetCurrentHash() != commit.Hash() { @@ -143,7 +142,7 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c return self.afterMark(selectCurrentAfter, waitToReselect) }, DisabledReason: self.require(self.singleItemSelected())(), - Key: gocui.NewKeyRune('S'), + Key: menuKey('S'), })) } menuItems = append(menuItems, lo.ToPtr(types.MenuItem{ @@ -151,7 +150,7 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c OnPress: func() error { return self.c.Helpers().Bisect.Reset() }, - Key: gocui.NewKeyRune('r'), + Key: menuKey('r'), })) return self.c.Menu(types.CreateMenuOptions{ @@ -180,7 +179,7 @@ func (self *BisectController) openStartBisectMenu(info *git_commands.BisectInfo, return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: gocui.NewKeyRune('b'), + Key: menuKey('b'), }, { Label: fmt.Sprintf(self.c.Tr.Bisect.MarkStart, commit.ShortHash(), info.OldTerm()), @@ -198,7 +197,7 @@ func (self *BisectController) openStartBisectMenu(info *git_commands.BisectInfo, return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: gocui.NewKeyRune('g'), + Key: menuKey('g'), }, { Label: self.c.Tr.Bisect.ChooseTerms, @@ -223,7 +222,7 @@ func (self *BisectController) openStartBisectMenu(info *git_commands.BisectInfo, }) return nil }, - Key: gocui.NewKeyRune('t'), + Key: menuKey('t'), }, }, }) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 268e94134..9fc595952 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -300,7 +300,7 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc ) viewDivergenceFromBaseBranchItem := &types.MenuItem{ LabelColumns: []string{label}, - Key: gocui.NewKeyRune('b'), + Key: menuKey('b'), OnPress: func() error { branch := self.context().GetSelected() if branch == nil { @@ -333,7 +333,7 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc }) return nil }, - Key: gocui.NewKeyRune('u'), + Key: menuKey('u'), } setUpstreamItem := &types.MenuItem{ @@ -358,7 +358,7 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc return nil }) }, - Key: gocui.NewKeyRune('s'), + Key: menuKey('s'), } upstreamResetOptions := utils.ResolvePlaceholderString( @@ -391,7 +391,7 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc return nil }, Tooltip: upstreamResetTooltip, - Key: gocui.NewKeyRune('g'), + Key: menuKey('g'), } upstreamRebaseItem := &types.MenuItem{ @@ -404,7 +404,7 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc return nil }, Tooltip: upstreamRebaseTooltip, - Key: gocui.NewKeyRune('r'), + Key: menuKey('r'), } if !selectedBranch.IsTrackingRemote() { @@ -624,7 +624,7 @@ func (self *BranchesController) delete(branches []*models.Branch) error { localDeleteItem := &types.MenuItem{ Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteLocalBranches, self.c.Tr.DeleteLocalBranch), - Key: gocui.NewKeyRune('c'), + Key: menuKey('c'), OnPress: func() error { return self.localDelete(branches) }, @@ -635,7 +635,7 @@ func (self *BranchesController) delete(branches []*models.Branch) error { remoteDeleteItem := &types.MenuItem{ Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteRemoteBranches, self.c.Tr.DeleteRemoteBranch), - Key: gocui.NewKeyRune('r'), + Key: menuKey('r'), OnPress: func() error { return self.remoteDelete(branches) }, @@ -648,7 +648,7 @@ func (self *BranchesController) delete(branches []*models.Branch) error { deleteBothItem := &types.MenuItem{ Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteLocalAndRemoteBranches, self.c.Tr.DeleteLocalAndRemoteBranch), - Key: gocui.NewKeyRune('b'), + Key: menuKey('b'), OnPress: func() error { return self.localAndRemoteDelete(branches) }, diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index b12819c39..3d5527293 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -230,7 +230,7 @@ func (self *CommitFilesController) openCopyMenu() error { return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: gocui.NewKeyRune('n'), + Key: menuKey('n'), } copyRelativePathItem := &types.MenuItem{ Label: self.c.Tr.CopyRelativeFilePath, @@ -242,7 +242,7 @@ func (self *CommitFilesController) openCopyMenu() error { return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: gocui.NewKeyRune('p'), + Key: menuKey('p'), } copyAbsolutePathItem := &types.MenuItem{ Label: self.c.Tr.CopyAbsoluteFilePath, @@ -258,7 +258,7 @@ func (self *CommitFilesController) openCopyMenu() error { return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: gocui.NewKeyRune('P'), + Key: menuKey('P'), } copyFileDiffItem := &types.MenuItem{ Label: self.c.Tr.CopySelectedDiff, @@ -266,7 +266,7 @@ func (self *CommitFilesController) openCopyMenu() error { return self.copyDiffToClipboard(node.GetPath(), self.c.Tr.FileDiffCopiedToast) }, DisabledReason: self.require(self.singleItemSelected())(), - Key: gocui.NewKeyRune('s'), + Key: menuKey('s'), } copyAllDiff := &types.MenuItem{ Label: self.c.Tr.CopyAllFilesDiff, @@ -274,7 +274,7 @@ func (self *CommitFilesController) openCopyMenu() error { return self.copyDiffToClipboard(".", self.c.Tr.AllFilesDiffCopiedToast) }, DisabledReason: self.require(self.itemsSelected())(), - Key: gocui.NewKeyRune('a'), + Key: menuKey('a'), } copyFileContentItem := &types.MenuItem{ Label: self.c.Tr.CopyFileContent, @@ -295,7 +295,7 @@ func (self *CommitFilesController) openCopyMenu() error { } return nil }))(), - Key: gocui.NewKeyRune('c'), + Key: menuKey('c'), } return self.c.Menu(types.CreateMenuOptions{ diff --git a/pkg/gui/controllers/custom_patch_options_menu_action.go b/pkg/gui/controllers/custom_patch_options_menu_action.go index 9ef990f79..979e048c0 100644 --- a/pkg/gui/controllers/custom_patch_options_menu_action.go +++ b/pkg/gui/controllers/custom_patch_options_menu_action.go @@ -31,19 +31,19 @@ func (self *CustomPatchOptionsMenuAction) Call() error { Label: self.c.Tr.ResetPatch, Tooltip: self.c.Tr.ResetPatchTooltip, OnPress: self.c.Helpers().PatchBuilding.Reset, - Key: gocui.NewKeyRune('c'), + Key: menuKey('c'), }, { Label: self.c.Tr.ApplyPatch, Tooltip: self.c.Tr.ApplyPatchTooltip, OnPress: func() error { return self.handleApplyPatch(false) }, - Key: gocui.NewKeyRune('a'), + Key: menuKey('a'), }, { Label: self.c.Tr.ApplyPatchInReverse, Tooltip: self.c.Tr.ApplyPatchInReverseTooltip, OnPress: func() error { return self.handleApplyPatch(true) }, - Key: gocui.NewKeyRune('r'), + Key: menuKey('r'), }, } @@ -53,25 +53,25 @@ func (self *CustomPatchOptionsMenuAction) Call() error { Label: fmt.Sprintf(self.c.Tr.RemovePatchFromOriginalCommit, utils.ShortHash(self.c.Git().Patch.PatchBuilder.To)), Tooltip: self.c.Tr.RemovePatchFromOriginalCommitTooltip, OnPress: self.handleDeletePatchFromCommit, - Key: gocui.NewKeyRune('d'), + Key: menuKey('d'), }, { Label: self.c.Tr.MovePatchOutIntoIndex, Tooltip: self.c.Tr.MovePatchOutIntoIndexTooltip, OnPress: self.handleMovePatchIntoWorkingTree, - Key: gocui.NewKeyRune('i'), + Key: menuKey('i'), }, { Label: self.c.Tr.MovePatchIntoNewCommit, Tooltip: self.c.Tr.MovePatchIntoNewCommitTooltip, OnPress: self.handlePullPatchIntoNewCommit, - Key: gocui.NewKeyRune('n'), + Key: menuKey('n'), }, { Label: self.c.Tr.MovePatchIntoNewCommitBefore, Tooltip: self.c.Tr.MovePatchIntoNewCommitBeforeTooltip, OnPress: self.handlePullPatchIntoNewCommitBefore, - Key: gocui.NewKeyRune('N'), + Key: menuKey('N'), }, }...) @@ -93,7 +93,7 @@ func (self *CustomPatchOptionsMenuAction) Call() error { Label: fmt.Sprintf(self.c.Tr.MovePatchToSelectedCommit, selectedCommit.Hash()), Tooltip: self.c.Tr.MovePatchToSelectedCommitTooltip, OnPress: self.handleMovePatchToSelectedCommit, - Key: gocui.NewKeyRune('m'), + Key: menuKey('m'), DisabledReason: disabledReason, }, }, menuItems[1:]..., @@ -107,7 +107,7 @@ func (self *CustomPatchOptionsMenuAction) Call() error { { Label: self.c.Tr.CopyPatchToClipboard, OnPress: func() error { return self.copyPatchToClipboard() }, - Key: gocui.NewKeyRune('y'), + Key: menuKey('y'), }, }...) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index c8d50d54d..5fc1540d2 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -671,14 +671,14 @@ func (self *FilesController) handleNonInlineConflict(file *models.File) error { OnPress: func() error { return handle(self.c.Git().WorkingTree.StageFile, self.c.Tr.Actions.ResolveConflictByKeepingFile) }, - Key: gocui.NewKeyRune('k'), + Key: menuKey('k'), } deleteItem := &types.MenuItem{ Label: self.c.Tr.MergeConflictDeleteFile, OnPress: func() error { return handle(self.c.Git().WorkingTree.RemoveConflictedFile, self.c.Tr.Actions.ResolveConflictByDeletingFile) }, - Key: gocui.NewKeyRune('d'), + Key: menuKey('d'), } items := []*types.MenuItem{} switch file.ShortStatus { @@ -856,7 +856,7 @@ func (self *FilesController) ignoreOrExcludeMenu(node *filetree.FileNode) error } return nil }, - Key: gocui.NewKeyRune('i'), + Key: menuKey('i'), }, { LabelColumns: []string{self.c.Tr.ExcludeFile}, @@ -866,7 +866,7 @@ func (self *FilesController) ignoreOrExcludeMenu(node *filetree.FileNode) error } return nil }, - Key: gocui.NewKeyRune('e'), + Key: menuKey('e'), }, }, }) @@ -950,7 +950,7 @@ func (self *FilesController) handleStatusFilterPressed() error { OnPress: func() error { return self.setStatusFiltering(filetree.DisplayStaged) }, - Key: gocui.NewKeyRune('s'), + Key: menuKey('s'), Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayStaged), }, { @@ -958,7 +958,7 @@ func (self *FilesController) handleStatusFilterPressed() error { OnPress: func() error { return self.setStatusFiltering(filetree.DisplayUnstaged) }, - Key: gocui.NewKeyRune('u'), + Key: menuKey('u'), Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayUnstaged), }, { @@ -966,7 +966,7 @@ func (self *FilesController) handleStatusFilterPressed() error { OnPress: func() error { return self.setStatusFiltering(filetree.DisplayTracked) }, - Key: gocui.NewKeyRune('t'), + Key: menuKey('t'), Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayTracked), }, { @@ -974,7 +974,7 @@ func (self *FilesController) handleStatusFilterPressed() error { OnPress: func() error { return self.setStatusFiltering(filetree.DisplayUntracked) }, - Key: gocui.NewKeyRune('T'), + Key: menuKey('T'), Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayUntracked), }, { @@ -982,7 +982,7 @@ func (self *FilesController) handleStatusFilterPressed() error { OnPress: func() error { return self.setStatusFiltering(filetree.DisplayAll) }, - Key: gocui.NewKeyRune('r'), + Key: menuKey('r'), Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayAll), }, }, @@ -1092,7 +1092,7 @@ func (self *FilesController) createStashMenu() error { } return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashAllChanges) }, - Key: gocui.NewKeyRune('a'), + Key: menuKey('a'), }, { Label: self.c.Tr.StashAllChangesKeepIndex, @@ -1103,14 +1103,14 @@ func (self *FilesController) createStashMenu() error { // if there are no staged files it behaves the same as Stash.Save return self.handleStashSave(self.c.Git().Stash.StashAndKeepIndex, self.c.Tr.Actions.StashAllChangesKeepIndex) }, - Key: gocui.NewKeyRune('i'), + Key: menuKey('i'), }, { Label: self.c.Tr.StashIncludeUntrackedChanges, OnPress: func() error { return self.handleStashSave(self.c.Git().Stash.StashIncludeUntrackedChanges, self.c.Tr.Actions.StashIncludeUntrackedChanges) }, - Key: gocui.NewKeyRune('U'), + Key: menuKey('U'), }, { Label: self.c.Tr.StashStagedChanges, @@ -1121,7 +1121,7 @@ func (self *FilesController) createStashMenu() error { } return self.handleStashSave(self.c.Git().Stash.SaveStagedChanges, self.c.Tr.Actions.StashStagedChanges) }, - Key: gocui.NewKeyRune('s'), + Key: menuKey('s'), }, { Label: self.c.Tr.StashUnstagedChanges, @@ -1135,7 +1135,7 @@ func (self *FilesController) createStashMenu() error { // ordinary stash return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashUnstagedChanges) }, - Key: gocui.NewKeyRune('u'), + Key: menuKey('u'), }, }, }) @@ -1182,7 +1182,7 @@ func (self *FilesController) openCopyMenu() error { return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: gocui.NewKeyRune('n'), + Key: menuKey('n'), } copyRelativePathItem := &types.MenuItem{ Label: self.c.Tr.CopyRelativeFilePath, @@ -1194,7 +1194,7 @@ func (self *FilesController) openCopyMenu() error { return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: gocui.NewKeyRune('p'), + Key: menuKey('p'), } copyAbsolutePathItem := &types.MenuItem{ Label: self.c.Tr.CopyAbsoluteFilePath, @@ -1210,7 +1210,7 @@ func (self *FilesController) openCopyMenu() error { return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: gocui.NewKeyRune('P'), + Key: menuKey('P'), } copyFileDiffItem := &types.MenuItem{ Label: self.c.Tr.CopySelectedDiff, @@ -1236,7 +1236,7 @@ func (self *FilesController) openCopyMenu() error { return nil }, ))(), - Key: gocui.NewKeyRune('s'), + Key: menuKey('s'), } copyAllDiff := &types.MenuItem{ Label: self.c.Tr.CopyAllFilesDiff, @@ -1261,7 +1261,7 @@ func (self *FilesController) openCopyMenu() error { return nil }, )(), - Key: gocui.NewKeyRune('a'), + Key: menuKey('a'), } return self.c.Menu(types.CreateMenuOptions{ @@ -1528,7 +1528,7 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) return nil }, - Key: gocui.NewKeyRune('u'), + Key: menuKey('u'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.DiscardUnstagedTooltip, map[string]string{ diff --git a/pkg/gui/controllers/git_flow_controller.go b/pkg/gui/controllers/git_flow_controller.go index 2fcb4e5f5..6e6bec95d 100644 --- a/pkg/gui/controllers/git_flow_controller.go +++ b/pkg/gui/controllers/git_flow_controller.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/jesseduffield/lazygit/pkg/commands/models" - "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) @@ -83,22 +82,22 @@ func (self *GitFlowController) handleCreateGitFlowMenu(branch *models.Branch) er { Label: "start feature", OnPress: startHandler("feature"), - Key: gocui.NewKeyRune('f'), + Key: menuKey('f'), }, { Label: "start hotfix", OnPress: startHandler("hotfix"), - Key: gocui.NewKeyRune('h'), + Key: menuKey('h'), }, { Label: "start bugfix", OnPress: startHandler("bugfix"), - Key: gocui.NewKeyRune('b'), + Key: menuKey('b'), }, { Label: "start release", OnPress: startHandler("release"), - Key: gocui.NewKeyRune('r'), + Key: menuKey('r'), }, }, }) diff --git a/pkg/gui/controllers/helpers/commits_helper.go b/pkg/gui/controllers/helpers/commits_helper.go index 810861401..c9b21250a 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -228,7 +228,7 @@ func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.S OnPress: func() error { return self.SwitchToEditor() }, - Key: gocui.NewKeyRune('e'), + Key: menuKey('e'), DisabledReason: disabledReasonForOpenInEditor, }, { @@ -236,14 +236,14 @@ func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.S OnPress: func() error { return self.addCoAuthor(suggestionFunc) }, - Key: gocui.NewKeyRune('c'), + Key: menuKey('c'), }, { Label: self.c.Tr.PasteCommitMessageFromClipboard, OnPress: func() error { return self.pasteCommitMessageFromClipboard() }, - Key: gocui.NewKeyRune('p'), + Key: menuKey('p'), }, } return self.c.Menu(types.CreateMenuOptions{ diff --git a/pkg/gui/controllers/helpers/menu_key.go b/pkg/gui/controllers/helpers/menu_key.go new file mode 100644 index 000000000..7b43a66fc --- /dev/null +++ b/pkg/gui/controllers/helpers/menu_key.go @@ -0,0 +1,11 @@ +package helpers + +import "github.com/jesseduffield/lazygit/pkg/gocui" + +// menuKey is a shorthand for constructing a key value for a menu item from a single rune literal, +// avoiding the noise of `gocui.NewKeyRune('a')` at every call site. There is an intentionally +// identical helper in the controllers package so that callers in either package can use the +// unqualified form. +func menuKey(r rune) gocui.Key { + return gocui.NewKeyRune(r) +} diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 986f61b16..ae042b8cb 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -43,13 +43,13 @@ func (self *MergeAndRebaseHelper) CreateRebaseOptionsMenu() error { } options := []optionAndKey{ - {option: REBASE_OPTION_CONTINUE, key: gocui.NewKeyRune('c')}, - {option: REBASE_OPTION_ABORT, key: gocui.NewKeyRune('a')}, + {option: REBASE_OPTION_CONTINUE, key: menuKey('c')}, + {option: REBASE_OPTION_ABORT, key: menuKey('a')}, } if self.c.Git().Status.WorkingTreeState().CanSkip() { options = append(options, optionAndKey{ - option: REBASE_OPTION_SKIP, key: gocui.NewKeyRune('s'), + option: REBASE_OPTION_SKIP, key: menuKey('s'), }) } @@ -198,7 +198,7 @@ func (self *MergeAndRebaseHelper) PromptForConflictHandling() error { OnPress: func() error { return self.genericMergeCommand(REBASE_OPTION_ABORT) }, - Key: gocui.NewKeyRune('a'), + Key: menuKey('a'), }, }, HideCancel: true, @@ -284,7 +284,7 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { Label: utils.ResolvePlaceholderString(self.c.Tr.SimpleRebase, map[string]string{"ref": ref}, ), - Key: gocui.NewKeyRune('s'), + Key: menuKey('s'), DisabledReason: disabledReason, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RebaseBranch) @@ -308,7 +308,7 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { Label: utils.ResolvePlaceholderString(self.c.Tr.InteractiveRebase, map[string]string{"ref": ref}, ), - Key: gocui.NewKeyRune('i'), + Key: menuKey('i'), DisabledReason: disabledReason, Tooltip: self.c.Tr.InteractiveRebaseTooltip, OnPress: func() error { @@ -334,7 +334,7 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { Label: utils.ResolvePlaceholderString(self.c.Tr.RebaseOntoBaseBranch, map[string]string{"baseBranch": ShortBranchName(baseBranch)}, ), - Key: gocui.NewKeyRune('b'), + Key: menuKey('b'), DisabledReason: baseBranchDisabledReason, Tooltip: self.c.Tr.RebaseOntoBaseBranchTooltip, OnPress: func() error { @@ -392,7 +392,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e firstRegularMergeItem = &types.MenuItem{ Label: self.c.Tr.RegularMergeFastForward, OnPress: self.RegularMerge(refName, git_commands.MERGE_VARIANT_REGULAR), - Key: gocui.NewKeyRune('m'), + Key: menuKey('m'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.RegularMergeFastForwardTooltip, map[string]string{ @@ -406,7 +406,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e secondRegularMergeItem = &types.MenuItem{ Label: self.c.Tr.RegularMergeNonFastForward, OnPress: self.RegularMerge(refName, git_commands.MERGE_VARIANT_NON_FAST_FORWARD), - Key: gocui.NewKeyRune('n'), + Key: menuKey('n'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.RegularMergeNonFastForwardTooltip, map[string]string{ @@ -419,7 +419,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e firstRegularMergeItem = &types.MenuItem{ Label: self.c.Tr.RegularMergeNonFastForward, OnPress: self.RegularMerge(refName, git_commands.MERGE_VARIANT_REGULAR), - Key: gocui.NewKeyRune('m'), + Key: menuKey('m'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.RegularMergeNonFastForwardTooltip, map[string]string{ @@ -432,7 +432,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e secondRegularMergeItem = &types.MenuItem{ Label: self.c.Tr.RegularMergeFastForward, OnPress: self.RegularMerge(refName, git_commands.MERGE_VARIANT_FAST_FORWARD), - Key: gocui.NewKeyRune('f'), + Key: menuKey('f'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.RegularMergeFastForwardTooltip, map[string]string{ @@ -464,7 +464,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e { Label: self.c.Tr.SquashMergeUncommitted, OnPress: self.SquashMergeUncommitted(refName), - Key: gocui.NewKeyRune('s'), + Key: menuKey('s'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.SquashMergeUncommittedTooltip, map[string]string{ @@ -475,7 +475,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e { Label: self.c.Tr.SquashMergeCommitted, OnPress: self.SquashMergeCommitted(refName, checkedOutBranchName), - Key: gocui.NewKeyRune('S'), + Key: menuKey('S'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.SquashMergeCommittedTooltip, map[string]string{ diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 43c8c93c6..6771bf301 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -222,9 +222,9 @@ func (self *RefsHelper) CreateSortOrderMenu(sortOptionsOrder []string, menuPromp sortOrder string } availableSortOptions := map[string]sortMenuOption{ - "recency": {label: self.c.Tr.SortByRecency, description: self.c.Tr.SortBasedOnReflog, key: gocui.NewKeyRune('r')}, - "alphabetical": {label: self.c.Tr.SortAlphabetical, description: "--sort=refname", key: gocui.NewKeyRune('a')}, - "date": {label: self.c.Tr.SortByDate, description: "--sort=-committerdate", key: gocui.NewKeyRune('d')}, + "recency": {label: self.c.Tr.SortByRecency, description: self.c.Tr.SortBasedOnReflog, key: menuKey('r')}, + "alphabetical": {label: self.c.Tr.SortAlphabetical, description: "--sort=refname", key: menuKey('a')}, + "date": {label: self.c.Tr.SortByDate, description: "--sort=-committerdate", key: menuKey('d')}, } sortOptions := make([]sortMenuOption, 0, len(sortOptionsOrder)) for _, key := range sortOptionsOrder { @@ -265,9 +265,9 @@ func (self *RefsHelper) CreateGitResetMenu(name string, ref string) error { } strengths := []strengthWithKey{ // not i18'ing because it's git terminology - {strength: "mixed", label: "Mixed reset", key: gocui.NewKeyRune('m'), tooltip: self.c.Tr.ResetMixedTooltip}, - {strength: "soft", label: "Soft reset", key: gocui.NewKeyRune('s'), tooltip: self.c.Tr.ResetSoftTooltip}, - {strength: "hard", label: "Hard reset", key: gocui.NewKeyRune('h'), tooltip: self.c.Tr.ResetHardTooltip}, + {strength: "mixed", label: "Mixed reset", key: menuKey('m'), tooltip: self.c.Tr.ResetMixedTooltip}, + {strength: "soft", label: "Soft reset", key: menuKey('s'), tooltip: self.c.Tr.ResetSoftTooltip}, + {strength: "hard", label: "Hard reset", key: menuKey('h'), tooltip: self.c.Tr.ResetHardTooltip}, } menuItems := lo.Map(strengths, func(row strengthWithKey, _ int) *types.MenuItem { @@ -312,7 +312,7 @@ func (self *RefsHelper) CreateCheckoutMenu(commit *models.Commit) error { self.c.LogAction(self.c.Tr.Actions.CheckoutCommit) return self.CheckoutRef(hash, types.CheckoutRefOptions{}) }, - Key: gocui.NewKeyRune('d'), + Key: menuKey('d'), }, } @@ -320,7 +320,7 @@ func (self *RefsHelper) CreateCheckoutMenu(commit *models.Commit) error { menuItems = append(menuItems, lo.Map(branches, func(branch *models.Branch, index int) *types.MenuItem { var key gocui.Key if index < 9 { - key = gocui.NewKeyRune(rune(index + 1 + '0')) // Convert 1-based index to key + key = menuKey(rune(index + 1 + '0')) // Convert 1-based index to key } return &types.MenuItem{ LabelColumns: []string{fmt.Sprintf(self.c.Tr.Actions.CheckoutBranchAtCommit, branch.Name)}, @@ -336,7 +336,7 @@ func (self *RefsHelper) CreateCheckoutMenu(commit *models.Commit) error { LabelColumns: []string{self.c.Tr.Actions.CheckoutBranch}, OnPress: func() error { return nil }, DisabledReason: &types.DisabledReason{Text: self.c.Tr.NoBranchesFoundAtCommitTooltip}, - Key: gocui.NewKeyRune('1'), + Key: menuKey('1'), }) } diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 28aeb56f6..dba2341a7 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -10,7 +10,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "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/context" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -383,7 +382,7 @@ func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []strin OnPress: func() error { return onMergeStrategySelected("--ours") }, - Key: gocui.NewKeyRune('c'), + Key: menuKey('c'), }, { LabelColumns: []string{ @@ -393,7 +392,7 @@ func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []strin OnPress: func() error { return onMergeStrategySelected("--theirs") }, - Key: gocui.NewKeyRune('i'), + Key: menuKey('i'), }, { LabelColumns: []string{ @@ -403,7 +402,7 @@ func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []strin OnPress: func() error { return onMergeStrategySelected("--union") }, - Key: gocui.NewKeyRune('b'), + Key: menuKey('b'), }, { LabelColumns: []string{ @@ -411,7 +410,7 @@ func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []strin cmdColor.Sprint("git mergetool"), }, OnPress: self.OpenMergeTool, - Key: gocui.NewKeyRune('m'), + Key: menuKey('m'), }, }, }) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 31c746750..0683147b9 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -361,7 +361,7 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star Items: []*types.MenuItem{ { Label: self.c.Tr.Fixup, - Key: gocui.NewKeyRune('f'), + Key: menuKey('f'), OnPress: func() error { return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommit) @@ -372,7 +372,7 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star }, { Label: self.c.Tr.FixupKeepMessage, - Key: gocui.NewKeyRune('c'), + Key: menuKey('c'), OnPress: func() error { return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage) @@ -403,7 +403,7 @@ func (self *LocalCommitsController) setFixupMessage(commit *models.Commit) error Items: []*types.MenuItem{ { Label: self.c.Tr.FixupDiscardMessage, - Key: gocui.NewKeyRune('f'), + Key: menuKey('f'), OnPress: func() error { return self.updateTodosWithFlag(todo.Fixup, []*models.Commit{commit}, "") }, @@ -411,7 +411,7 @@ func (self *LocalCommitsController) setFixupMessage(commit *models.Commit) error }, { Label: self.c.Tr.FixupKeepMessage, - Key: gocui.NewKeyRune('c'), + Key: menuKey('c'), OnPress: func() error { return self.updateTodosWithFlag(todo.Fixup, []*models.Commit{commit}, "-C") }, @@ -1000,7 +1000,7 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err Items: []*types.MenuItem{ { Label: self.c.Tr.FixupMenu_Fixup, - Key: gocui.NewKeyRune('f'), + Key: menuKey('f'), OnPress: func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit) @@ -1024,7 +1024,7 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err }, { Label: self.c.Tr.FixupMenu_AmendWithChanges, - Key: gocui.NewKeyRune('a'), + Key: menuKey('a'), OnPress: func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { return self.createAmendCommit(commit, true) @@ -1035,7 +1035,7 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err }, { Label: self.c.Tr.FixupMenu_AmendWithoutChanges, - Key: gocui.NewKeyRune('r'), + Key: menuKey('r'), OnPress: func() error { return self.createAmendCommit(commit, false) }, Tooltip: self.c.Tr.FixupMenu_AmendWithoutChangesTooltip, }, @@ -1134,14 +1134,14 @@ func (self *LocalCommitsController) squashFixupCommits() error { Label: self.c.Tr.SquashCommitsInCurrentBranch, OnPress: self.squashAllFixupsInCurrentBranch, DisabledReason: self.canFindCommitForSquashFixupsInCurrentBranch(), - Key: gocui.NewKeyRune('b'), + Key: menuKey('b'), Tooltip: self.c.Tr.SquashCommitsInCurrentBranchTooltip, }, { Label: self.c.Tr.SquashCommitsAboveSelectedCommit, OnPress: self.withItem(self.squashAllFixupsAboveSelectedCommit), DisabledReason: self.singleItemSelected()(), - Key: gocui.NewKeyRune('a'), + Key: menuKey('a'), Tooltip: self.c.Tr.SquashCommitsAboveSelectedTooltip, }, }, diff --git a/pkg/gui/controllers/menu_key.go b/pkg/gui/controllers/menu_key.go new file mode 100644 index 000000000..afafe1c0b --- /dev/null +++ b/pkg/gui/controllers/menu_key.go @@ -0,0 +1,11 @@ +package controllers + +import "github.com/jesseduffield/lazygit/pkg/gocui" + +// menuKey is a shorthand for constructing a key value for a menu item from a single rune literal, +// avoiding the noise of `gocui.NewKeyRune('a')` at every call site. There is an intentionally +// identical helper in the helpers package so that callers in either package can use the unqualified +// form. +func menuKey(r rune) gocui.Key { + return gocui.NewKeyRune(r) +} diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go index f6e12cfe4..0a4904b2e 100644 --- a/pkg/gui/controllers/submodules_controller.go +++ b/pkg/gui/controllers/submodules_controller.go @@ -233,7 +233,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return nil }) }, - Key: gocui.NewKeyRune('i'), + Key: menuKey('i'), }, { LabelColumns: []string{self.c.Tr.BulkUpdateSubmodules, style.FgYellow.Sprint(self.c.Git().Submodule.BulkUpdateCmdObj().ToString())}, @@ -248,7 +248,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return nil }) }, - Key: gocui.NewKeyRune('u'), + Key: menuKey('u'), }, { LabelColumns: []string{self.c.Tr.BulkUpdateRecursiveSubmodules, style.FgYellow.Sprint(self.c.Git().Submodule.BulkUpdateRecursivelyCmdObj().ToString())}, @@ -263,7 +263,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return nil }) }, - Key: gocui.NewKeyRune('r'), + Key: menuKey('r'), }, { LabelColumns: []string{self.c.Tr.BulkDeinitSubmodules, style.FgRed.Sprint(self.c.Git().Submodule.BulkDeinitCmdObj().ToString())}, @@ -278,7 +278,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return nil }) }, - Key: gocui.NewKeyRune('d'), + Key: menuKey('d'), }, }, }) diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go index 1e274c077..352ec05e6 100644 --- a/pkg/gui/controllers/tags_controller.go +++ b/pkg/gui/controllers/tags_controller.go @@ -282,14 +282,14 @@ func (self *TagsController) delete(tag *models.Tag) error { menuItems := []*types.MenuItem{ { Label: self.c.Tr.DeleteLocalTag, - Key: gocui.NewKeyRune('c'), + Key: menuKey('c'), OnPress: func() error { return self.localDelete(tag) }, }, { Label: self.c.Tr.DeleteRemoteTag, - Key: gocui.NewKeyRune('r'), + Key: menuKey('r'), OpensMenu: true, OnPress: func() error { return self.remoteDelete(tag) @@ -297,7 +297,7 @@ func (self *TagsController) delete(tag *models.Tag) error { }, { Label: self.c.Tr.DeleteLocalAndRemoteTag, - Key: gocui.NewKeyRune('b'), + Key: menuKey('b'), OpensMenu: true, OnPress: func() error { return self.localAndRemoteDelete(tag) diff --git a/pkg/gui/controllers/workspace_reset_controller.go b/pkg/gui/controllers/workspace_reset_controller.go index 48ccfd298..c6b68fb96 100644 --- a/pkg/gui/controllers/workspace_reset_controller.go +++ b/pkg/gui/controllers/workspace_reset_controller.go @@ -53,7 +53,7 @@ func (self *FilesController) createResetMenu() error { }) return nil }, - Key: gocui.NewKeyRune('x'), + Key: menuKey('x'), Tooltip: self.c.Tr.NukeDescription, }, { @@ -72,7 +72,7 @@ func (self *FilesController) createResetMenu() error { ) return nil }, - Key: gocui.NewKeyRune('u'), + Key: menuKey('u'), }, { LabelColumns: []string{ @@ -90,7 +90,7 @@ func (self *FilesController) createResetMenu() error { ) return nil }, - Key: gocui.NewKeyRune('c'), + Key: menuKey('c'), }, { LabelColumns: []string{ @@ -115,7 +115,7 @@ func (self *FilesController) createResetMenu() error { ) return nil }, - Key: gocui.NewKeyRune('S'), + Key: menuKey('S'), }, { LabelColumns: []string{ @@ -133,7 +133,7 @@ func (self *FilesController) createResetMenu() error { ) return nil }, - Key: gocui.NewKeyRune('s'), + Key: menuKey('s'), }, { LabelColumns: []string{ @@ -151,7 +151,7 @@ func (self *FilesController) createResetMenu() error { ) return nil }, - Key: gocui.NewKeyRune('m'), + Key: menuKey('m'), }, { LabelColumns: []string{ @@ -176,7 +176,7 @@ func (self *FilesController) createResetMenu() error { }, }) }, - Key: gocui.NewKeyRune('h'), + Key: menuKey('h'), }, } From 3d18ee8f91c7b33a66a5fc964e46837e13a7c957 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 3 May 2026 22:44:53 +0200 Subject: [PATCH 015/384] Use a slice of keys for each binding This is a pure refactor in preparation for letting users configure multiple alternate bindings for a single command. Every Binding still has exactly one key, so nothing changes visibly: the cheatsheet, the on-screen options bar, and the keybindings menu all render identically. When a Binding ends up with multiple keys, the on-screen options bar will show only the first (to avoid clutter); the cheatsheet will show all of them (in a later commit). For now both paths take Key[0]. MenuItem.Key is changed in the same way, it also has a slice of keys now. In this commit we keep the name `Key` in Binding, KeybindingOpts and MenuItem, instead of renaming them to `Keys` right away, in order to keep the diff a bit more readable. We'll do the rename separately in the next commit. --- pkg/cheatsheet/generate.go | 6 +- pkg/cheatsheet/generate_test.go | 58 +++++++++---------- pkg/config/keynames.go | 8 +++ pkg/gui/context/menu_context.go | 13 +++-- pkg/gui/controllers/helpers/menu_key.go | 10 ++-- .../helpers/merge_and_rebase_helper.go | 2 +- pkg/gui/controllers/helpers/refs_helper.go | 6 +- pkg/gui/controllers/menu_key.go | 10 ++-- pkg/gui/controllers/prompt_controller.go | 2 +- .../controllers/search_prompt_controller.go | 2 +- pkg/gui/extras_panel.go | 4 +- pkg/gui/keybindings.go | 26 +++++---- pkg/gui/menu_panel.go | 6 +- pkg/gui/options_map.go | 6 +- pkg/gui/services/custom_commands/client.go | 7 ++- .../custom_commands/handler_creator.go | 2 +- .../custom_commands/keybinding_creator.go | 3 +- pkg/gui/types/common.go | 7 ++- pkg/gui/types/context.go | 2 +- pkg/gui/types/keybindings.go | 2 +- 20 files changed, 101 insertions(+), 81 deletions(-) diff --git a/pkg/cheatsheet/generate.go b/pkg/cheatsheet/generate.go index 3b5c490b8..22bc0c505 100644 --- a/pkg/cheatsheet/generate.go +++ b/pkg/cheatsheet/generate.go @@ -145,7 +145,7 @@ func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*b return false } - return (binding.Description != "" || binding.Alternative != "") && binding.Key.IsSet() + return (binding.Description != "" || binding.Alternative != "") && len(binding.Key) > 0 }) bindingsByHeader := lo.GroupBy(bindingsToDisplay, func(binding *types.Binding) header { @@ -156,7 +156,7 @@ func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*b bindingsByHeader, func(header header, hBindings []*types.Binding) headerWithBindings { uniqBindings := lo.UniqBy(hBindings, func(binding *types.Binding) string { - return binding.Description + config.LabelForKey(binding.Key) + return binding.Description + config.LabelForKey(binding.Key[0]) }) return headerWithBindings{ @@ -214,7 +214,7 @@ func formatTitle(title string) string { } func formatBinding(binding *types.Binding) string { - action := config.LabelForKey(binding.Key) + action := config.LabelForKey(binding.Key[0]) description := binding.Description if binding.Alternative != "" { action += fmt.Sprintf(" (%s)", binding.Alternative) diff --git a/pkg/cheatsheet/generate_test.go b/pkg/cheatsheet/generate_test.go index bae2ec498..3ff066244 100644 --- a/pkg/cheatsheet/generate_test.go +++ b/pkg/cheatsheet/generate_test.go @@ -28,7 +28,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, }, expected: []*bindingSection{ @@ -38,7 +38,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -50,7 +50,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "", Description: "quit", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, }, expected: []*bindingSection{ @@ -60,7 +60,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "", Description: "quit", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -72,17 +72,17 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "unstage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "submodules", Description: "drop submodule", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, }, expected: []*bindingSection{ @@ -92,12 +92,12 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "unstage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -107,7 +107,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "submodules", Description: "drop submodule", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -119,23 +119,23 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "unstage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "scroll", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, { ViewName: "commits", Description: "revert commit", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, }, expected: []*bindingSection{ @@ -145,7 +145,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "scroll", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, }, @@ -156,7 +156,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "commits", Description: "revert commit", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -166,12 +166,12 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "unstage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -183,34 +183,34 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "unstage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "scroll", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, { ViewName: "commits", Description: "revert commit", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "commits", Description: "scroll", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, { ViewName: "commits", Description: "page up", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, }, @@ -221,13 +221,13 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "scroll", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, { ViewName: "commits", Description: "page up", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, }, @@ -238,7 +238,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "commits", Description: "revert commit", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -248,12 +248,12 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "unstage file", - Key: gocui.NewKeyRune('a'), + Key: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, diff --git a/pkg/config/keynames.go b/pkg/config/keynames.go index 943a2f37a..7f361dec8 100644 --- a/pkg/config/keynames.go +++ b/pkg/config/keynames.go @@ -205,3 +205,11 @@ func GetValidatedKeyBindingKey(label string) gocui.Key { return key } + +func GetValidatedKeyBindingKeys(label string) []gocui.Key { + k := GetValidatedKeyBindingKey(label) + if !k.IsSet() { + return nil + } + return []gocui.Key{k} +} diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index e9fece612..03b8d51c4 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" @@ -73,7 +74,11 @@ func NewMenuViewModel(c *ContextCommon) *MenuViewModel { func() []*types.MenuItem { return self.menuItems }, func(item *types.MenuItem) []string { if filterKeybindings { - return []string{config.LabelForKey(item.Key)} + // Allow searching all configured keybindings of each item, even though only the + // first one is shown in the menu. + return lo.Map(item.Key, func(k gocui.Key, _ int) string { + return config.LabelForKey(k) + }) } return item.LabelColumns @@ -138,8 +143,8 @@ func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string { } keyLabel := "" - if item.Key.IsSet() { - keyLabel = style.FgCyan.Sprint(config.LabelForKey(item.Key)) + if len(item.Key) > 0 { + keyLabel = style.FgCyan.Sprint(config.LabelForKey(item.Key[0])) } checkMark := "" @@ -205,7 +210,7 @@ func (self *MenuViewModel) GetNonModelItems() []*NonModelItem { func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { basicBindings := self.ListContextTrait.GetKeybindings(opts) menuItemsWithKeys := lo.Filter(self.menuItems, func(item *types.MenuItem, _ int) bool { - return item.Key.IsSet() + return len(item.Key) > 0 }) menuItemBindings := lo.Map(menuItemsWithKeys, func(item *types.MenuItem, _ int) *types.Binding { diff --git a/pkg/gui/controllers/helpers/menu_key.go b/pkg/gui/controllers/helpers/menu_key.go index 7b43a66fc..d4271fa5b 100644 --- a/pkg/gui/controllers/helpers/menu_key.go +++ b/pkg/gui/controllers/helpers/menu_key.go @@ -3,9 +3,9 @@ package helpers import "github.com/jesseduffield/lazygit/pkg/gocui" // menuKey is a shorthand for constructing a key value for a menu item from a single rune literal, -// avoiding the noise of `gocui.NewKeyRune('a')` at every call site. There is an intentionally -// identical helper in the controllers package so that callers in either package can use the -// unqualified form. -func menuKey(r rune) gocui.Key { - return gocui.NewKeyRune(r) +// avoiding the noise of `[]gocui.Key{gocui.NewKeyRune('a')}` at every call site. There is an +// intentionally identical helper in the controllers package so that callers in either package can +// use the unqualified form. +func menuKey(r rune) []gocui.Key { + return []gocui.Key{gocui.NewKeyRune(r)} } diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index ae042b8cb..48c342446 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -39,7 +39,7 @@ const ( func (self *MergeAndRebaseHelper) CreateRebaseOptionsMenu() error { type optionAndKey struct { option string - key gocui.Key + key []gocui.Key } options := []optionAndKey{ diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 6771bf301..ae560941f 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -216,7 +216,7 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string func (self *RefsHelper) CreateSortOrderMenu(sortOptionsOrder []string, menuPrompt string, onSelected func(sortOrder string) error, currentValue string) error { type sortMenuOption struct { - key gocui.Key + key []gocui.Key label string description string sortOrder string @@ -260,7 +260,7 @@ func (self *RefsHelper) CreateGitResetMenu(name string, ref string) error { type strengthWithKey struct { strength string label string - key gocui.Key + key []gocui.Key tooltip string } strengths := []strengthWithKey{ @@ -318,7 +318,7 @@ func (self *RefsHelper) CreateCheckoutMenu(commit *models.Commit) error { if len(branches) > 0 { menuItems = append(menuItems, lo.Map(branches, func(branch *models.Branch, index int) *types.MenuItem { - var key gocui.Key + var key []gocui.Key if index < 9 { key = menuKey(rune(index + 1 + '0')) // Convert 1-based index to key } diff --git a/pkg/gui/controllers/menu_key.go b/pkg/gui/controllers/menu_key.go index afafe1c0b..95993801b 100644 --- a/pkg/gui/controllers/menu_key.go +++ b/pkg/gui/controllers/menu_key.go @@ -3,9 +3,9 @@ package controllers import "github.com/jesseduffield/lazygit/pkg/gocui" // menuKey is a shorthand for constructing a key value for a menu item from a single rune literal, -// avoiding the noise of `gocui.NewKeyRune('a')` at every call site. There is an intentionally -// identical helper in the helpers package so that callers in either package can use the unqualified -// form. -func menuKey(r rune) gocui.Key { - return gocui.NewKeyRune(r) +// avoiding the noise of `[]gocui.Key{gocui.NewKeyRune('a')}` at every call site. There is an +// intentionally identical helper in the helpers package so that callers in either package can use +// the unqualified form. +func menuKey(r rune) []gocui.Key { + return []gocui.Key{gocui.NewKeyRune(r)} } diff --git a/pkg/gui/controllers/prompt_controller.go b/pkg/gui/controllers/prompt_controller.go index 1ff40a0ef..9a1fd63c9 100644 --- a/pkg/gui/controllers/prompt_controller.go +++ b/pkg/gui/controllers/prompt_controller.go @@ -27,7 +27,7 @@ func NewPromptController( func (self *PromptController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: gocui.NewKeyName(gocui.KeyEnter), + Key: []gocui.Key{gocui.NewKeyName(gocui.KeyEnter)}, Handler: func() error { return self.context().State.OnConfirm() }, Description: self.c.Tr.Confirm, DisplayOnScreen: true, diff --git a/pkg/gui/controllers/search_prompt_controller.go b/pkg/gui/controllers/search_prompt_controller.go index d5c2f5c3c..b3b9918e6 100644 --- a/pkg/gui/controllers/search_prompt_controller.go +++ b/pkg/gui/controllers/search_prompt_controller.go @@ -24,7 +24,7 @@ func NewSearchPromptController( func (self *SearchPromptController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { - Key: gocui.NewKeyName(gocui.KeyEnter), + Key: []gocui.Key{gocui.NewKeyName(gocui.KeyEnter)}, Handler: self.confirm, }, { diff --git a/pkg/gui/extras_panel.go b/pkg/gui/extras_panel.go index 03e82345a..468d8f290 100644 --- a/pkg/gui/extras_panel.go +++ b/pkg/gui/extras_panel.go @@ -15,7 +15,7 @@ func (gui *Gui) handleCreateExtrasMenuPanel() error { Items: []*types.MenuItem{ { Label: gui.c.Tr.ToggleShowCommandLog, - Key: gocui.NewKeyRune('t'), + Key: []gocui.Key{gocui.NewKeyRune('t')}, OnPress: func() error { currentContext := gui.c.Context().CurrentStatic() if gui.c.State().GetShowExtrasWindow() && currentContext.GetKey() == context.COMMAND_LOG_CONTEXT_KEY { @@ -30,7 +30,7 @@ func (gui *Gui) handleCreateExtrasMenuPanel() error { }, { Label: gui.c.Tr.FocusCommandLog, - Key: gocui.NewKeyRune('f'), + Key: []gocui.Key{gocui.NewKeyRune('f')}, OnPress: gui.handleFocusCommandLog, }, }, diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 11a3fbc6a..038ae5d49 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -69,7 +69,7 @@ func (gui *Gui) keybindingOpts() types.KeybindingsOpts { } return types.KeybindingsOpts{ - GetKey: config.GetValidatedKeyBindingKey, + GetKey: config.GetValidatedKeyBindingKeys, Config: keybindingConfig, Guards: guards, } @@ -176,7 +176,7 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin }, { ViewName: "information", - Key: gocui.NewKeyName(gocui.MouseLeft), + Key: []gocui.Key{gocui.NewKeyName(gocui.MouseLeft)}, Handler: gui.handleInfoClick, }, { @@ -196,26 +196,26 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin }, { ViewName: "main", - Key: gocui.NewKeyName(gocui.MouseWheelDown), + Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelDown)}, Handler: gui.scrollDownMain, Description: gui.c.Tr.ScrollDown, Alternative: "fn+up", }, { ViewName: "main", - Key: gocui.NewKeyName(gocui.MouseWheelUp), + Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelUp)}, Handler: gui.scrollUpMain, Description: gui.c.Tr.ScrollUp, Alternative: "fn+down", }, { ViewName: "secondary", - Key: gocui.NewKeyName(gocui.MouseWheelDown), + Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelDown)}, Handler: gui.scrollDownSecondary, }, { ViewName: "secondary", - Key: gocui.NewKeyName(gocui.MouseWheelUp), + Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelUp)}, Handler: gui.scrollUpSecondary, }, { @@ -240,12 +240,12 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin }, { ViewName: "confirmation", - Key: gocui.NewKeyName(gocui.MouseWheelUp), + Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelUp)}, Handler: gui.scrollUpConfirmationPanel, }, { ViewName: "confirmation", - Key: gocui.NewKeyName(gocui.MouseWheelDown), + Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelDown)}, Handler: gui.scrollDownConfirmationPanel, }, { @@ -287,12 +287,12 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin }, { ViewName: "extras", - Key: gocui.NewKeyName(gocui.MouseWheelUp), + Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelUp)}, Handler: gui.scrollUpExtra, }, { ViewName: "extras", - Key: gocui.NewKeyName(gocui.MouseWheelDown), + Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelDown)}, Handler: gui.scrollDownExtra, }, { @@ -352,7 +352,7 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin { ViewName: "extras", Tag: "navigation", - Key: gocui.NewKeyName(gocui.MouseLeft), + Key: []gocui.Key{gocui.NewKeyName(gocui.MouseLeft)}, Handler: gui.handleFocusCommandLog, }, } @@ -448,7 +448,9 @@ func (gui *Gui) SetKeybinding(binding *types.Binding) { return gui.callKeybindingHandler(binding) } - gui.g.SetKeybinding(binding.ViewName, binding.Key, handler) + for _, key := range binding.Key { + gui.g.SetKeybinding(binding.ViewName, key, handler) + } } func (gui *Gui) SetMouseKeybinding(binding *gocui.ViewMouseBinding) error { diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index 248da40fb..20079700c 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -46,8 +46,10 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error { maxColumnSize = max(maxColumnSize, len(item.LabelColumns)) // Remove all item keybindings that are the same as one of the essential bindings - if !opts.KeepConflictingKeybindings && lo.Contains(essentialKeys, item.Key) { - item.Key = gocui.Key{} + if !opts.KeepConflictingKeybindings { + item.Key = lo.Filter(item.Key, func(k gocui.Key, _ int) bool { + return !lo.Contains(essentialKeys, k) + }) } } diff --git a/pkg/gui/options_map.go b/pkg/gui/options_map.go index c6360e27c..2771a6c25 100644 --- a/pkg/gui/options_map.go +++ b/pkg/gui/options_map.go @@ -41,12 +41,12 @@ func (self *OptionsMapMgr) renderContextOptionsMap() { globalBindings := self.c.Contexts().Global.GetKeybindings(self.c.KeybindingsOpts()) currentContextKeys := set.NewFromSlice( - lo.Map(currentContextBindings, func(binding *types.Binding, _ int) gocui.Key { + lo.FlatMap(currentContextBindings, func(binding *types.Binding, _ int) []gocui.Key { return binding.Key })) allBindings := append(currentContextBindings, lo.Filter(globalBindings, func(b *types.Binding, _ int) bool { - return !currentContextKeys.Includes(b.Key) + return len(b.Key) == 0 || !currentContextKeys.Includes(b.Key[0]) })...) bindingsToDisplay := lo.Filter(allBindings, func(binding *types.Binding, _ int) bool { @@ -60,7 +60,7 @@ func (self *OptionsMapMgr) renderContextOptionsMap() { } return bindingInfo{ - key: config.LabelForKey(binding.Key), + key: config.LabelForKey(binding.Key[0]), description: binding.GetShortDescription(), style: displayStyle, } diff --git a/pkg/gui/services/custom_commands/client.go b/pkg/gui/services/custom_commands/client.go index 4b927cf68..6d30ab310 100644 --- a/pkg/gui/services/custom_commands/client.go +++ b/pkg/gui/services/custom_commands/client.go @@ -2,6 +2,7 @@ package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" @@ -45,7 +46,7 @@ func (self *Client) GetCustomCommandKeybindings() ([]*types.Binding, error) { } bindings = append(bindings, &types.Binding{ ViewName: "", // custom commands menus are global; we filter the commands inside by context - Key: config.GetValidatedKeyBindingKey(customCommand.Key), + Key: []gocui.Key{config.GetValidatedKeyBindingKey(customCommand.Key)}, Handler: handler, Description: getCustomCommandsMenuDescription(customCommand, self.c.Tr), OpensMenu: true, @@ -72,7 +73,7 @@ func (self *Client) showCustomCommandsMenu(customCommand config.CustomCommand) e } menuItems = append(menuItems, &types.MenuItem{ Label: subCommand.GetDescription(), - Key: config.GetValidatedKeyBindingKey(subCommand.Key), + Key: []gocui.Key{config.GetValidatedKeyBindingKey(subCommand.Key)}, OnPress: handler, OpensMenu: true, }) @@ -92,7 +93,7 @@ func (self *Client) showCustomCommandsMenu(customCommand config.CustomCommand) e menuItems = append(menuItems, &types.MenuItem{ Label: subCommand.GetDescription(), - Key: config.GetValidatedKeyBindingKey(subCommand.Key), + Key: []gocui.Key{config.GetValidatedKeyBindingKey(subCommand.Key)}, OnPress: self.handlerCreator.call(subCommand), }) } diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index 412fc7de6..60a4555d8 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -232,7 +232,7 @@ func (self *HandlerCreator) menuPrompt(prompt *config.CustomCommandPrompt, wrapp OnPress: func() error { return wrappedF(option.Value) }, - Key: config.GetValidatedKeyBindingKey(option.Key), + Key: []gocui.Key{config.GetValidatedKeyBindingKey(option.Key)}, } }) diff --git a/pkg/gui/services/custom_commands/keybinding_creator.go b/pkg/gui/services/custom_commands/keybinding_creator.go index 644e6af45..52456323a 100644 --- a/pkg/gui/services/custom_commands/keybinding_creator.go +++ b/pkg/gui/services/custom_commands/keybinding_creator.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/jesseduffield/lazygit/pkg/config" + "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/types" @@ -35,7 +36,7 @@ func (self *KeybindingCreator) call(customCommand config.CustomCommand, handler return lo.Map(viewNames, func(viewName string, _ int) *types.Binding { return &types.Binding{ ViewName: viewName, - Key: config.GetValidatedKeyBindingKey(customCommand.Key), + Key: []gocui.Key{config.GetValidatedKeyBindingKey(customCommand.Key)}, Handler: handler, Description: customCommand.GetDescription(), } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 5856bb5e0..475cafeab 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -259,9 +259,10 @@ type MenuItem struct { // Only applies when Label is used OpensMenu bool - // If Key is defined it allows the user to press the key to invoke the menu - // item, as opposed to having to navigate to it - Key gocui.Key + // If Key is non-empty, the user can press any of these keys to invoke the + // menu item, as opposed to having to navigate to it. Only the first key is + // shown in the menu; the alternates are matched silently. + Key []gocui.Key // A widget to show in front of the menu item. Supported widget types are // checkboxes and radio buttons, diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 35201c6cf..69d76ea78 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -239,7 +239,7 @@ type OnFocusLostOpts struct { type ContextKey string type KeybindingsOpts struct { - GetKey func(key string) gocui.Key + GetKey func(key string) []gocui.Key Config config.KeybindingConfig Guards KeybindingGuards } diff --git a/pkg/gui/types/keybindings.go b/pkg/gui/types/keybindings.go index 63079ccc7..3b3e512d8 100644 --- a/pkg/gui/types/keybindings.go +++ b/pkg/gui/types/keybindings.go @@ -11,7 +11,7 @@ import ( type Binding struct { ViewName string Handler func() error - Key gocui.Key + Key []gocui.Key Description string // DescriptionFunc is used instead of Description if non-nil, and is useful for dynamic // descriptions that change depending on context. Important: this must not be an expensive call. From 26366641c0bf13c7ad4124728c2c1eaa728db226 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 10 May 2026 17:09:20 +0200 Subject: [PATCH 016/384] Rename Key to Keys in Binding, KeybindingsOpts, and MenuItem This is a straight rename with no other code changes. Doing it in a separate commit to keep the diff of the previous one somewhat readable. --- pkg/cheatsheet/generate.go | 6 +- pkg/cheatsheet/generate_test.go | 58 +++++----- pkg/gui/context/menu_context.go | 10 +- .../controllers/basic_commits_controller.go | 34 +++--- pkg/gui/controllers/bisect_controller.go | 18 +-- pkg/gui/controllers/branches_controller.go | 56 ++++----- .../commit_description_controller.go | 10 +- .../controllers/commit_message_controller.go | 12 +- .../controllers/commits_files_controller.go | 36 +++--- .../controllers/confirmation_controller.go | 6 +- .../controllers/context_lines_controller.go | 4 +- .../custom_patch_options_menu_action.go | 18 +-- pkg/gui/controllers/files_controller.go | 92 +++++++-------- pkg/gui/controllers/filter_controller.go | 2 +- pkg/gui/controllers/git_flow_controller.go | 10 +- pkg/gui/controllers/global_controller.go | 34 +++--- pkg/gui/controllers/helpers/commits_helper.go | 6 +- .../helpers/merge_and_rebase_helper.go | 30 ++--- pkg/gui/controllers/helpers/refs_helper.go | 30 ++--- .../helpers/working_tree_helper.go | 8 +- .../jump_to_side_window_controller.go | 2 +- pkg/gui/controllers/list_controller.go | 30 ++--- .../controllers/local_commits_controller.go | 68 +++++------ pkg/gui/controllers/main_view_controller.go | 6 +- pkg/gui/controllers/menu_controller.go | 6 +- .../controllers/merge_conflicts_controller.go | 34 +++--- pkg/gui/controllers/options_menu_action.go | 2 +- .../controllers/patch_building_controller.go | 10 +- .../controllers/patch_explorer_controller.go | 42 +++---- pkg/gui/controllers/prompt_controller.go | 6 +- .../controllers/remote_branches_controller.go | 18 +-- pkg/gui/controllers/remotes_controller.go | 12 +- .../rename_similarity_threshold_controller.go | 4 +- pkg/gui/controllers/search_controller.go | 2 +- .../controllers/search_prompt_controller.go | 8 +- pkg/gui/controllers/side_window_controller.go | 12 +- pkg/gui/controllers/snake_controller.go | 10 +- pkg/gui/controllers/staging_controller.go | 22 ++-- pkg/gui/controllers/stash_controller.go | 10 +- pkg/gui/controllers/status_controller.go | 12 +- pkg/gui/controllers/submodules_controller.go | 24 ++-- pkg/gui/controllers/suggestions_controller.go | 10 +- .../switch_to_diff_files_controller.go | 2 +- .../switch_to_focused_main_view_controller.go | 2 +- .../switch_to_sub_commits_controller.go | 2 +- pkg/gui/controllers/sync_controller.go | 4 +- pkg/gui/controllers/tags_controller.go | 18 +-- pkg/gui/controllers/undo_controller.go | 4 +- .../controllers/view_selection_controller.go | 20 ++-- .../controllers/workspace_reset_controller.go | 14 +-- .../worktree_options_controller.go | 2 +- pkg/gui/controllers/worktrees_controller.go | 10 +- pkg/gui/extras_panel.go | 4 +- pkg/gui/keybindings.go | 108 +++++++++--------- pkg/gui/menu_panel.go | 2 +- pkg/gui/options_map.go | 6 +- pkg/gui/services/custom_commands/client.go | 6 +- .../custom_commands/handler_creator.go | 2 +- .../custom_commands/keybinding_creator.go | 2 +- pkg/gui/types/common.go | 4 +- pkg/gui/types/context.go | 6 +- pkg/gui/types/keybindings.go | 2 +- 62 files changed, 525 insertions(+), 525 deletions(-) diff --git a/pkg/cheatsheet/generate.go b/pkg/cheatsheet/generate.go index 22bc0c505..bd2eff624 100644 --- a/pkg/cheatsheet/generate.go +++ b/pkg/cheatsheet/generate.go @@ -145,7 +145,7 @@ func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*b return false } - return (binding.Description != "" || binding.Alternative != "") && len(binding.Key) > 0 + return (binding.Description != "" || binding.Alternative != "") && len(binding.Keys) > 0 }) bindingsByHeader := lo.GroupBy(bindingsToDisplay, func(binding *types.Binding) header { @@ -156,7 +156,7 @@ func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*b bindingsByHeader, func(header header, hBindings []*types.Binding) headerWithBindings { uniqBindings := lo.UniqBy(hBindings, func(binding *types.Binding) string { - return binding.Description + config.LabelForKey(binding.Key[0]) + return binding.Description + config.LabelForKey(binding.Keys[0]) }) return headerWithBindings{ @@ -214,7 +214,7 @@ func formatTitle(title string) string { } func formatBinding(binding *types.Binding) string { - action := config.LabelForKey(binding.Key[0]) + action := config.LabelForKey(binding.Keys[0]) description := binding.Description if binding.Alternative != "" { action += fmt.Sprintf(" (%s)", binding.Alternative) diff --git a/pkg/cheatsheet/generate_test.go b/pkg/cheatsheet/generate_test.go index 3ff066244..373fcaa35 100644 --- a/pkg/cheatsheet/generate_test.go +++ b/pkg/cheatsheet/generate_test.go @@ -28,7 +28,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, }, expected: []*bindingSection{ @@ -38,7 +38,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -50,7 +50,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "", Description: "quit", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, }, expected: []*bindingSection{ @@ -60,7 +60,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "", Description: "quit", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -72,17 +72,17 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "unstage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "submodules", Description: "drop submodule", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, }, expected: []*bindingSection{ @@ -92,12 +92,12 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "unstage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -107,7 +107,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "submodules", Description: "drop submodule", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -119,23 +119,23 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "unstage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "scroll", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, { ViewName: "commits", Description: "revert commit", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, }, expected: []*bindingSection{ @@ -145,7 +145,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "scroll", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, }, @@ -156,7 +156,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "commits", Description: "revert commit", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -166,12 +166,12 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "unstage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -183,34 +183,34 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "unstage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "scroll", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, { ViewName: "commits", Description: "revert commit", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "commits", Description: "scroll", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, { ViewName: "commits", Description: "page up", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, }, @@ -221,13 +221,13 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "scroll", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, { ViewName: "commits", Description: "page up", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, Tag: "navigation", }, }, @@ -238,7 +238,7 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "commits", Description: "revert commit", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, @@ -248,12 +248,12 @@ func TestGetBindingSections(t *testing.T) { { ViewName: "files", Description: "stage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, { ViewName: "files", Description: "unstage file", - Key: []gocui.Key{gocui.NewKeyRune('a')}, + Keys: []gocui.Key{gocui.NewKeyRune('a')}, }, }, }, diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 03b8d51c4..9feef1e4c 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -76,7 +76,7 @@ func NewMenuViewModel(c *ContextCommon) *MenuViewModel { if filterKeybindings { // Allow searching all configured keybindings of each item, even though only the // first one is shown in the menu. - return lo.Map(item.Key, func(k gocui.Key, _ int) string { + return lo.Map(item.Keys, func(k gocui.Key, _ int) string { return config.LabelForKey(k) }) } @@ -143,8 +143,8 @@ func (self *MenuViewModel) GetDisplayStrings(_ int, _ int) [][]string { } keyLabel := "" - if len(item.Key) > 0 { - keyLabel = style.FgCyan.Sprint(config.LabelForKey(item.Key[0])) + if len(item.Keys) > 0 { + keyLabel = style.FgCyan.Sprint(config.LabelForKey(item.Keys[0])) } checkMark := "" @@ -210,12 +210,12 @@ func (self *MenuViewModel) GetNonModelItems() []*NonModelItem { func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { basicBindings := self.ListContextTrait.GetKeybindings(opts) menuItemsWithKeys := lo.Filter(self.menuItems, func(item *types.MenuItem, _ int) bool { - return len(item.Key) > 0 + return len(item.Keys) > 0 }) menuItemBindings := lo.Map(menuItemsWithKeys, func(item *types.MenuItem, _ int) *types.Binding { return &types.Binding{ - Key: item.Key, + Keys: item.Keys, Handler: func() error { return self.OnMenuPress(item) }, } }) diff --git a/pkg/gui/controllers/basic_commits_controller.go b/pkg/gui/controllers/basic_commits_controller.go index 60605cf44..3e019bf18 100644 --- a/pkg/gui/controllers/basic_commits_controller.go +++ b/pkg/gui/controllers/basic_commits_controller.go @@ -51,7 +51,7 @@ func NewBasicCommitsController(c *ControllerCommon, context ContainsCommits) *Ba func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Commits.CheckoutCommit), + Keys: opts.GetKeys(opts.Config.Commits.CheckoutCommit), Handler: self.withItem(self.checkout), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Checkout, @@ -59,7 +59,7 @@ func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Commits.CopyCommitAttributeToClipboard), + Keys: opts.GetKeys(opts.Config.Commits.CopyCommitAttributeToClipboard), Handler: self.withItem(self.copyCommitAttribute), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CopyCommitAttributeToClipboard, @@ -67,13 +67,13 @@ func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Commits.OpenInBrowser), + Keys: opts.GetKeys(opts.Config.Commits.OpenInBrowser), Handler: self.withItem(self.openInBrowser), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenCommitInBrowser, }, { - Key: opts.GetKey(opts.Config.Universal.New), + Keys: opts.GetKeys(opts.Config.Universal.New), Handler: self.withItem(self.newBranch), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CreateNewBranchFromCommit, @@ -83,14 +83,14 @@ func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ // panel. But I find it important that this ends up next to "New Branch", and I couldn't // find another way to achieve this. It's not such a big deal to have it in subcommits and // reflog too, I'd say. - Key: opts.GetKey(opts.Config.Branches.MoveCommitsToNewBranch), + Keys: opts.GetKeys(opts.Config.Branches.MoveCommitsToNewBranch), Handler: self.c.Helpers().Refs.MoveCommitsToNewBranch, GetDisabledReason: self.c.Helpers().Refs.CanMoveCommitsToNewBranch, Description: self.c.Tr.MoveCommitsToNewBranch, Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip, }, { - Key: opts.GetKey(opts.Config.Commits.ViewResetOptions), + Keys: opts.GetKeys(opts.Config.Commits.ViewResetOptions), Handler: self.withItem(self.createResetMenu), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.ViewResetOptions, @@ -99,7 +99,7 @@ func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Commits.CherryPickCopy), + Keys: opts.GetKeys(opts.Config.Commits.CherryPickCopy), Handler: self.withItem(self.copyRange), GetDisabledReason: self.require(self.itemRangeSelected(self.canCopyCommits)), Description: self.c.Tr.CherryPickCopy, @@ -112,18 +112,18 @@ func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Commits.ResetCherryPick), + Keys: opts.GetKeys(opts.Config.Commits.ResetCherryPick), Handler: self.c.Helpers().CherryPick.Reset, Description: self.c.Tr.ResetCherryPick, }, { - Key: opts.GetKey(opts.Config.Universal.OpenDiffTool), + Keys: opts.GetKeys(opts.Config.Universal.OpenDiffTool), Handler: self.withItem(self.openDiffTool), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenDiffTool, }, { - Key: opts.GetKey(opts.Config.Commits.SelectCommitsOfCurrentBranch), + Keys: opts.GetKeys(opts.Config.Commits.SelectCommitsOfCurrentBranch), Handler: self.selectCommitsOfCurrentBranch, GetDisabledReason: self.require(self.canSelectCommitsOfCurrentBranch), Description: self.c.Tr.SelectCommitsOfCurrentBranch, @@ -163,14 +163,14 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e OnPress: func() error { return self.copyCommitSubjectToClipboard(commit) }, - Key: menuKey('s'), + Keys: menuKey('s'), }, { Label: self.c.Tr.CommitMessage, OnPress: func() error { return self.copyCommitMessageToClipboard(commit) }, - Key: menuKey('m'), + Keys: menuKey('m'), }, { Label: self.c.Tr.CommitMessageBody, @@ -178,28 +178,28 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e OnPress: func() error { return self.copyCommitMessageBodyToClipboard(commitMessageBody) }, - Key: menuKey('b'), + Keys: menuKey('b'), }, { Label: self.c.Tr.CommitURL, OnPress: func() error { return self.copyCommitURLToClipboard(commit) }, - Key: menuKey('u'), + Keys: menuKey('u'), }, { Label: self.c.Tr.CommitDiff, OnPress: func() error { return self.copyCommitDiffToClipboard(commit) }, - Key: menuKey('d'), + Keys: menuKey('d'), }, { Label: self.c.Tr.CommitAuthor, OnPress: func() error { return self.copyAuthorToClipboard(commit) }, - Key: menuKey('a'), + Keys: menuKey('a'), }, } @@ -208,7 +208,7 @@ func (self *BasicCommitsController) copyCommitAttribute(commit *models.Commit) e OnPress: func() error { return self.copyCommitTagsToClipboard(commit) }, - Key: menuKey('t'), + Keys: menuKey('t'), } if len(commit.Tags) == 0 { diff --git a/pkg/gui/controllers/bisect_controller.go b/pkg/gui/controllers/bisect_controller.go index d86d34f93..1066237c1 100644 --- a/pkg/gui/controllers/bisect_controller.go +++ b/pkg/gui/controllers/bisect_controller.go @@ -38,7 +38,7 @@ func NewBisectController( func (self *BisectController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Commits.ViewBisectOptions), + Keys: opts.GetKeys(opts.Config.Commits.ViewBisectOptions), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.openMenu)), Description: self.c.Tr.ViewBisectOptions, OpensMenu: true, @@ -101,7 +101,7 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c return self.afterMark(selectCurrentAfter, waitToReselect) }, DisabledReason: singleItemIfNotBisecting, - Key: menuKey('b'), + Keys: menuKey('b'), }, { Label: fmt.Sprintf(self.c.Tr.Bisect.Mark, shortHashToMark, info.OldTerm()), @@ -114,7 +114,7 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c return self.afterMark(selectCurrentAfter, waitToReselect) }, DisabledReason: singleItemIfNotBisecting, - Key: menuKey('g'), + Keys: menuKey('g'), }, { Label: fmt.Sprintf(self.c.Tr.Bisect.SkipCurrent, shortHashToMark), @@ -127,7 +127,7 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c return self.afterMark(selectCurrentAfter, waitToReselect) }, DisabledReason: singleItemIfNotBisecting, - Key: menuKey('s'), + Keys: menuKey('s'), }, } if info.GetCurrentHash() != "" && info.GetCurrentHash() != commit.Hash() { @@ -142,7 +142,7 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c return self.afterMark(selectCurrentAfter, waitToReselect) }, DisabledReason: self.require(self.singleItemSelected())(), - Key: menuKey('S'), + Keys: menuKey('S'), })) } menuItems = append(menuItems, lo.ToPtr(types.MenuItem{ @@ -150,7 +150,7 @@ func (self *BisectController) openMidBisectMenu(info *git_commands.BisectInfo, c OnPress: func() error { return self.c.Helpers().Bisect.Reset() }, - Key: menuKey('r'), + Keys: menuKey('r'), })) return self.c.Menu(types.CreateMenuOptions{ @@ -179,7 +179,7 @@ func (self *BisectController) openStartBisectMenu(info *git_commands.BisectInfo, return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: menuKey('b'), + Keys: menuKey('b'), }, { Label: fmt.Sprintf(self.c.Tr.Bisect.MarkStart, commit.ShortHash(), info.OldTerm()), @@ -197,7 +197,7 @@ func (self *BisectController) openStartBisectMenu(info *git_commands.BisectInfo, return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: menuKey('g'), + Keys: menuKey('g'), }, { Label: self.c.Tr.Bisect.ChooseTerms, @@ -222,7 +222,7 @@ func (self *BisectController) openStartBisectMenu(info *git_commands.BisectInfo, }) return nil }, - Key: menuKey('t'), + Keys: menuKey('t'), }, }, }) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 9fc595952..24ef84d54 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -45,7 +45,7 @@ func NewBranchesController( func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.Select), + Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withItem(self.press), GetDisabledReason: self.require( self.singleItemSelected(), @@ -56,64 +56,64 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.New), + Keys: opts.GetKeys(opts.Config.Universal.New), Handler: self.withItem(self.newBranch), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.NewBranch, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Branches.MoveCommitsToNewBranch), + Keys: opts.GetKeys(opts.Config.Branches.MoveCommitsToNewBranch), Handler: self.c.Helpers().Refs.MoveCommitsToNewBranch, GetDisabledReason: self.c.Helpers().Refs.CanMoveCommitsToNewBranch, Description: self.c.Tr.MoveCommitsToNewBranch, Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip, }, { - Key: opts.GetKey(opts.Config.Branches.CreatePullRequest), + Keys: opts.GetKeys(opts.Config.Branches.CreatePullRequest), Handler: self.withItem(self.handleCreatePullRequest), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CreatePullRequest, }, { - Key: opts.GetKey(opts.Config.Branches.ViewPullRequestOptions), + Keys: opts.GetKeys(opts.Config.Branches.ViewPullRequestOptions), Handler: self.withItem(self.handleCreatePullRequestMenu), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CreatePullRequestOptions, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Branches.OpenPullRequestInBrowser), + Keys: opts.GetKeys(opts.Config.Branches.OpenPullRequestInBrowser), Handler: self.withItem(self.openPRInBrowser), GetDisabledReason: self.require(self.singleItemSelected(self.branchHasPR)), Description: self.c.Tr.OpenPullRequestInBrowser, }, { - Key: opts.GetKey(opts.Config.Branches.CopyPullRequestURL), + Keys: opts.GetKeys(opts.Config.Branches.CopyPullRequestURL), Handler: self.copyPullRequestURL, GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CopyPullRequestURL, }, { - Key: opts.GetKey(opts.Config.Branches.CheckoutBranchByName), + Keys: opts.GetKeys(opts.Config.Branches.CheckoutBranchByName), Handler: self.checkoutByName, Description: self.c.Tr.CheckoutByName, Tooltip: self.c.Tr.CheckoutByNameTooltip, }, { - Key: opts.GetKey(opts.Config.Branches.CheckoutPreviousBranch), + Keys: opts.GetKeys(opts.Config.Branches.CheckoutPreviousBranch), Handler: self.checkoutPreviousBranch, Description: self.c.Tr.CheckoutPreviousBranch, }, { - Key: opts.GetKey(opts.Config.Branches.ForceCheckoutBranch), + Keys: opts.GetKeys(opts.Config.Branches.ForceCheckoutBranch), Handler: self.forceCheckout, GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.ForceCheckout, Tooltip: self.c.Tr.ForceCheckoutTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.Remove), + Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItems(self.delete), GetDisabledReason: self.require(self.itemRangeSelected(self.branchesAreReal)), Description: self.c.Tr.Delete, @@ -122,7 +122,7 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Branches.RebaseBranch), + Keys: opts.GetKeys(opts.Config.Branches.RebaseBranch), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.rebase)), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.RebaseBranch, @@ -131,7 +131,7 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Branches.MergeIntoCurrentBranch), + Keys: opts.GetKeys(opts.Config.Branches.MergeIntoCurrentBranch), Handler: opts.Guards.OutsideFilterMode(self.merge), GetDisabledReason: self.require(self.singleItemSelected(self.notMergingIntoYourself)), Description: self.c.Tr.Merge, @@ -140,26 +140,26 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Branches.FastForward), + Keys: opts.GetKeys(opts.Config.Branches.FastForward), Handler: self.withItem(self.fastForward), GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)), Description: self.c.Tr.FastForward, Tooltip: self.c.Tr.FastForwardTooltip, }, { - Key: opts.GetKey(opts.Config.Branches.CreateTag), + Keys: opts.GetKeys(opts.Config.Branches.CreateTag), Handler: self.withItem(self.createTag), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.NewTag, }, { - Key: opts.GetKey(opts.Config.Branches.SortOrder), + Keys: opts.GetKeys(opts.Config.Branches.SortOrder), Handler: self.createSortMenu, Description: self.c.Tr.SortOrder, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Commits.ViewResetOptions), + Keys: opts.GetKeys(opts.Config.Commits.ViewResetOptions), Handler: self.withItem(self.createResetMenu), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.ViewResetOptions, @@ -167,13 +167,13 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Branches.RenameBranch), + Keys: opts.GetKeys(opts.Config.Branches.RenameBranch), Handler: self.withItem(self.rename), GetDisabledReason: self.require(self.singleItemSelected(self.branchIsReal)), Description: self.c.Tr.RenameBranch, }, { - Key: opts.GetKey(opts.Config.Branches.SetUpstream), + Keys: opts.GetKeys(opts.Config.Branches.SetUpstream), Handler: self.withItem(self.viewUpstreamOptions), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.ViewBranchUpstreamOptions, @@ -183,7 +183,7 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.OpenDiffTool), + Keys: opts.GetKeys(opts.Config.Universal.OpenDiffTool), Handler: self.withItem(func(selectedBranch *models.Branch) error { return self.c.Helpers().Diff.OpenDiffToolForRef(selectedBranch) }), @@ -300,7 +300,7 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc ) viewDivergenceFromBaseBranchItem := &types.MenuItem{ LabelColumns: []string{label}, - Key: menuKey('b'), + Keys: menuKey('b'), OnPress: func() error { branch := self.context().GetSelected() if branch == nil { @@ -333,7 +333,7 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc }) return nil }, - Key: menuKey('u'), + Keys: menuKey('u'), } setUpstreamItem := &types.MenuItem{ @@ -358,7 +358,7 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc return nil }) }, - Key: menuKey('s'), + Keys: menuKey('s'), } upstreamResetOptions := utils.ResolvePlaceholderString( @@ -391,7 +391,7 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc return nil }, Tooltip: upstreamResetTooltip, - Key: menuKey('g'), + Keys: menuKey('g'), } upstreamRebaseItem := &types.MenuItem{ @@ -404,7 +404,7 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc return nil }, Tooltip: upstreamRebaseTooltip, - Key: menuKey('r'), + Keys: menuKey('r'), } if !selectedBranch.IsTrackingRemote() { @@ -624,7 +624,7 @@ func (self *BranchesController) delete(branches []*models.Branch) error { localDeleteItem := &types.MenuItem{ Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteLocalBranches, self.c.Tr.DeleteLocalBranch), - Key: menuKey('c'), + Keys: menuKey('c'), OnPress: func() error { return self.localDelete(branches) }, @@ -635,7 +635,7 @@ func (self *BranchesController) delete(branches []*models.Branch) error { remoteDeleteItem := &types.MenuItem{ Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteRemoteBranches, self.c.Tr.DeleteRemoteBranch), - Key: menuKey('r'), + Keys: menuKey('r'), OnPress: func() error { return self.remoteDelete(branches) }, @@ -648,7 +648,7 @@ func (self *BranchesController) delete(branches []*models.Branch) error { deleteBothItem := &types.MenuItem{ Label: lo.Ternary(len(branches) > 1, self.c.Tr.DeleteLocalAndRemoteBranches, self.c.Tr.DeleteLocalAndRemoteBranch), - Key: menuKey('b'), + Keys: menuKey('b'), OnPress: func() error { return self.localAndRemoteDelete(branches) }, diff --git a/pkg/gui/controllers/commit_description_controller.go b/pkg/gui/controllers/commit_description_controller.go index 63f6876e5..0e5102e3e 100644 --- a/pkg/gui/controllers/commit_description_controller.go +++ b/pkg/gui/controllers/commit_description_controller.go @@ -25,23 +25,23 @@ func NewCommitDescriptionController( func (self *CommitDescriptionController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.TogglePanel), + Keys: opts.GetKeys(opts.Config.Universal.TogglePanel), Handler: self.handleTogglePanel, }, { - Key: opts.GetKey(opts.Config.Universal.Return), + Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: self.close, }, { - Key: opts.GetKey(opts.Config.Universal.ConfirmInEditor), + Keys: opts.GetKeys(opts.Config.Universal.ConfirmInEditor), Handler: self.confirm, }, { - Key: opts.GetKey(opts.Config.Universal.ConfirmInEditorAlt), + Keys: opts.GetKeys(opts.Config.Universal.ConfirmInEditorAlt), Handler: self.confirm, }, { - Key: opts.GetKey(opts.Config.CommitMessage.CommitMenu), + Keys: opts.GetKeys(opts.Config.CommitMessage.CommitMenu), Handler: self.openCommitMenu, }, } diff --git a/pkg/gui/controllers/commit_message_controller.go b/pkg/gui/controllers/commit_message_controller.go index e1561690c..4743598f6 100644 --- a/pkg/gui/controllers/commit_message_controller.go +++ b/pkg/gui/controllers/commit_message_controller.go @@ -29,29 +29,29 @@ func NewCommitMessageController( func (self *CommitMessageController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.SubmitEditorText), + Keys: opts.GetKeys(opts.Config.Universal.SubmitEditorText), Handler: self.confirm, Description: self.c.Tr.Confirm, }, { - Key: opts.GetKey(opts.Config.Universal.Return), + Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: self.close, Description: self.c.Tr.Close, }, { - Key: opts.GetKey(opts.Config.Universal.PrevItem), + Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePreviousCommit, }, { - Key: opts.GetKey(opts.Config.Universal.NextItem), + Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextCommit, }, { - Key: opts.GetKey(opts.Config.Universal.TogglePanel), + Keys: opts.GetKeys(opts.Config.Universal.TogglePanel), Handler: self.handleTogglePanel, }, { - Key: opts.GetKey(opts.Config.CommitMessage.CommitMenu), + Keys: opts.GetKeys(opts.Config.CommitMessage.CommitMenu), Handler: self.openCommitMenu, }, } diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 3d5527293..eed9d02b9 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -45,13 +45,13 @@ func NewCommitFilesController( func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Files.CopyFileInfoToClipboard), + Keys: opts.GetKeys(opts.Config.Files.CopyFileInfoToClipboard), Handler: self.openCopyMenu, Description: self.c.Tr.CopyToClipboardMenu, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.CommitFiles.CheckoutCommitFile), + Keys: opts.GetKeys(opts.Config.CommitFiles.CheckoutCommitFile), Handler: self.withItem(self.checkout), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Checkout, @@ -59,7 +59,7 @@ func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) [] DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Remove), + Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItems(self.discard), GetDisabledReason: self.require(self.itemsSelected(self.canDiscardFileChanges)), Description: self.c.Tr.Discard, @@ -67,14 +67,14 @@ func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) [] DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.OpenFile), + Keys: opts.GetKeys(opts.Config.Universal.OpenFile), Handler: self.withItem(self.open), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenFile, Tooltip: self.c.Tr.OpenFileTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.Edit), + Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: self.withItems(self.edit), GetDisabledReason: self.require(self.itemsSelected(self.canEditFiles)), Description: self.c.Tr.Edit, @@ -82,13 +82,13 @@ func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) [] DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.OpenDiffTool), + Keys: opts.GetKeys(opts.Config.Universal.OpenDiffTool), Handler: self.withItem(self.openDiffTool), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenDiffTool, }, { - Key: opts.GetKey(opts.Config.Universal.Select), + Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withItems(self.toggleForPatch), GetDisabledReason: self.require(self.itemsSelected()), Description: self.c.Tr.ToggleAddToPatch, @@ -98,7 +98,7 @@ func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) [] DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Files.ToggleStagedAll), + Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll), Handler: self.withItem(self.toggleAllForPatch), Description: self.c.Tr.ToggleAllInPatch, Tooltip: utils.ResolvePlaceholderString(self.c.Tr.ToggleAllInPatchTooltip, @@ -106,27 +106,27 @@ func (self *CommitFilesController) GetKeybindings(opts types.KeybindingsOpts) [] ), }, { - Key: opts.GetKey(opts.Config.Universal.GoInto), + Keys: opts.GetKeys(opts.Config.Universal.GoInto), Handler: self.withItem(self.enter), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.EnterCommitFile, Tooltip: self.c.Tr.EnterCommitFileTooltip, }, { - Key: opts.GetKey(opts.Config.Files.ToggleTreeView), + Keys: opts.GetKeys(opts.Config.Files.ToggleTreeView), Handler: self.toggleTreeView, Description: self.c.Tr.ToggleTreeView, Tooltip: self.c.Tr.ToggleTreeViewTooltip, }, { - Key: opts.GetKey(opts.Config.Files.CollapseAll), + Keys: opts.GetKeys(opts.Config.Files.CollapseAll), Handler: self.collapseAll, Description: self.c.Tr.CollapseAll, Tooltip: self.c.Tr.CollapseAllTooltip, GetDisabledReason: self.require(self.isInTreeMode), }, { - Key: opts.GetKey(opts.Config.Files.ExpandAll), + Keys: opts.GetKeys(opts.Config.Files.ExpandAll), Handler: self.expandAll, Description: self.c.Tr.ExpandAll, Tooltip: self.c.Tr.ExpandAllTooltip, @@ -230,7 +230,7 @@ func (self *CommitFilesController) openCopyMenu() error { return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: menuKey('n'), + Keys: menuKey('n'), } copyRelativePathItem := &types.MenuItem{ Label: self.c.Tr.CopyRelativeFilePath, @@ -242,7 +242,7 @@ func (self *CommitFilesController) openCopyMenu() error { return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: menuKey('p'), + Keys: menuKey('p'), } copyAbsolutePathItem := &types.MenuItem{ Label: self.c.Tr.CopyAbsoluteFilePath, @@ -258,7 +258,7 @@ func (self *CommitFilesController) openCopyMenu() error { return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: menuKey('P'), + Keys: menuKey('P'), } copyFileDiffItem := &types.MenuItem{ Label: self.c.Tr.CopySelectedDiff, @@ -266,7 +266,7 @@ func (self *CommitFilesController) openCopyMenu() error { return self.copyDiffToClipboard(node.GetPath(), self.c.Tr.FileDiffCopiedToast) }, DisabledReason: self.require(self.singleItemSelected())(), - Key: menuKey('s'), + Keys: menuKey('s'), } copyAllDiff := &types.MenuItem{ Label: self.c.Tr.CopyAllFilesDiff, @@ -274,7 +274,7 @@ func (self *CommitFilesController) openCopyMenu() error { return self.copyDiffToClipboard(".", self.c.Tr.AllFilesDiffCopiedToast) }, DisabledReason: self.require(self.itemsSelected())(), - Key: menuKey('a'), + Keys: menuKey('a'), } copyFileContentItem := &types.MenuItem{ Label: self.c.Tr.CopyFileContent, @@ -295,7 +295,7 @@ func (self *CommitFilesController) openCopyMenu() error { } return nil }))(), - Key: menuKey('c'), + Keys: menuKey('c'), } return self.c.Menu(types.CreateMenuOptions{ diff --git a/pkg/gui/controllers/confirmation_controller.go b/pkg/gui/controllers/confirmation_controller.go index 206818f40..990b2b09e 100644 --- a/pkg/gui/controllers/confirmation_controller.go +++ b/pkg/gui/controllers/confirmation_controller.go @@ -24,19 +24,19 @@ func NewConfirmationController( func (self *ConfirmationController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.Confirm), + Keys: opts.GetKeys(opts.Config.Universal.Confirm), Handler: func() error { return self.context().State.OnConfirm() }, Description: self.c.Tr.Confirm, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Return), + Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: func() error { return self.context().State.OnClose() }, Description: self.c.Tr.CloseCancel, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), + Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), Handler: self.handleCopyToClipboard, Description: self.c.Tr.CopyToClipboardMenu, DisplayOnScreen: true, diff --git a/pkg/gui/controllers/context_lines_controller.go b/pkg/gui/controllers/context_lines_controller.go index a1ce9f518..022364c07 100644 --- a/pkg/gui/controllers/context_lines_controller.go +++ b/pkg/gui/controllers/context_lines_controller.go @@ -30,13 +30,13 @@ func NewContextLinesController( func (self *ContextLinesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.IncreaseContextInDiffView), + Keys: opts.GetKeys(opts.Config.Universal.IncreaseContextInDiffView), Handler: self.Increase, Description: self.c.Tr.IncreaseContextInDiffView, Tooltip: self.c.Tr.IncreaseContextInDiffViewTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.DecreaseContextInDiffView), + Keys: opts.GetKeys(opts.Config.Universal.DecreaseContextInDiffView), Handler: self.Decrease, Description: self.c.Tr.DecreaseContextInDiffView, Tooltip: self.c.Tr.DecreaseContextInDiffViewTooltip, diff --git a/pkg/gui/controllers/custom_patch_options_menu_action.go b/pkg/gui/controllers/custom_patch_options_menu_action.go index 979e048c0..cabba4739 100644 --- a/pkg/gui/controllers/custom_patch_options_menu_action.go +++ b/pkg/gui/controllers/custom_patch_options_menu_action.go @@ -31,19 +31,19 @@ func (self *CustomPatchOptionsMenuAction) Call() error { Label: self.c.Tr.ResetPatch, Tooltip: self.c.Tr.ResetPatchTooltip, OnPress: self.c.Helpers().PatchBuilding.Reset, - Key: menuKey('c'), + Keys: menuKey('c'), }, { Label: self.c.Tr.ApplyPatch, Tooltip: self.c.Tr.ApplyPatchTooltip, OnPress: func() error { return self.handleApplyPatch(false) }, - Key: menuKey('a'), + Keys: menuKey('a'), }, { Label: self.c.Tr.ApplyPatchInReverse, Tooltip: self.c.Tr.ApplyPatchInReverseTooltip, OnPress: func() error { return self.handleApplyPatch(true) }, - Key: menuKey('r'), + Keys: menuKey('r'), }, } @@ -53,25 +53,25 @@ func (self *CustomPatchOptionsMenuAction) Call() error { Label: fmt.Sprintf(self.c.Tr.RemovePatchFromOriginalCommit, utils.ShortHash(self.c.Git().Patch.PatchBuilder.To)), Tooltip: self.c.Tr.RemovePatchFromOriginalCommitTooltip, OnPress: self.handleDeletePatchFromCommit, - Key: menuKey('d'), + Keys: menuKey('d'), }, { Label: self.c.Tr.MovePatchOutIntoIndex, Tooltip: self.c.Tr.MovePatchOutIntoIndexTooltip, OnPress: self.handleMovePatchIntoWorkingTree, - Key: menuKey('i'), + Keys: menuKey('i'), }, { Label: self.c.Tr.MovePatchIntoNewCommit, Tooltip: self.c.Tr.MovePatchIntoNewCommitTooltip, OnPress: self.handlePullPatchIntoNewCommit, - Key: menuKey('n'), + Keys: menuKey('n'), }, { Label: self.c.Tr.MovePatchIntoNewCommitBefore, Tooltip: self.c.Tr.MovePatchIntoNewCommitBeforeTooltip, OnPress: self.handlePullPatchIntoNewCommitBefore, - Key: menuKey('N'), + Keys: menuKey('N'), }, }...) @@ -93,7 +93,7 @@ func (self *CustomPatchOptionsMenuAction) Call() error { Label: fmt.Sprintf(self.c.Tr.MovePatchToSelectedCommit, selectedCommit.Hash()), Tooltip: self.c.Tr.MovePatchToSelectedCommitTooltip, OnPress: self.handleMovePatchToSelectedCommit, - Key: menuKey('m'), + Keys: menuKey('m'), DisabledReason: disabledReason, }, }, menuItems[1:]..., @@ -107,7 +107,7 @@ func (self *CustomPatchOptionsMenuAction) Call() error { { Label: self.c.Tr.CopyPatchToClipboard, OnPress: func() error { return self.copyPatchToClipboard() }, - Key: menuKey('y'), + Keys: menuKey('y'), }, }...) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 5fc1540d2..8ea4425e6 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -42,7 +42,7 @@ func NewFilesController( func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.Select), + Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withItems(self.press), GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected())), Description: self.c.Tr.Stage, @@ -50,46 +50,46 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Files.OpenStatusFilter), + Keys: opts.GetKeys(opts.Config.Files.OpenStatusFilter), Handler: self.handleStatusFilterPressed, Description: self.c.Tr.FileFilter, }, { - Key: opts.GetKey(opts.Config.Files.CopyFileInfoToClipboard), + Keys: opts.GetKeys(opts.Config.Files.CopyFileInfoToClipboard), Handler: self.openCopyMenu, Description: self.c.Tr.CopyToClipboardMenu, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Files.CommitChanges), + Keys: opts.GetKeys(opts.Config.Files.CommitChanges), Handler: self.c.Helpers().WorkingTree.HandleCommitPress, Description: self.c.Tr.Commit, Tooltip: self.c.Tr.CommitTooltip, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Files.CommitChangesWithoutHook), + Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithoutHook), Handler: self.c.Helpers().WorkingTree.HandleWIPCommitPress, Description: self.c.Tr.CommitChangesWithoutHook, }, { - Key: opts.GetKey(opts.Config.Files.AmendLastCommit), + Keys: opts.GetKeys(opts.Config.Files.AmendLastCommit), Handler: self.handleAmendCommitPress, Description: self.c.Tr.AmendLastCommit, }, { - Key: opts.GetKey(opts.Config.Files.CommitChangesWithEditor), + Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithEditor), Handler: self.c.Helpers().WorkingTree.HandleCommitEditorPress, Description: self.c.Tr.CommitChangesWithEditor, }, { - Key: opts.GetKey(opts.Config.Files.FindBaseCommitForFixup), + Keys: opts.GetKeys(opts.Config.Files.FindBaseCommitForFixup), Handler: self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress, Description: self.c.Tr.FindBaseCommitForFixup, Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.Edit), + Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: self.withItems(self.edit), GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canEditFiles))), Description: self.c.Tr.Edit, @@ -97,53 +97,53 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.OpenFile), + Keys: opts.GetKeys(opts.Config.Universal.OpenFile), Handler: self.Open, GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenFile, Tooltip: self.c.Tr.OpenFileTooltip, }, { - Key: opts.GetKey(opts.Config.Files.IgnoreFile), + Keys: opts.GetKeys(opts.Config.Files.IgnoreFile), Handler: self.withItem(self.ignoreOrExcludeMenu), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Actions.IgnoreExcludeFile, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Files.RefreshFiles), + Keys: opts.GetKeys(opts.Config.Files.RefreshFiles), Handler: self.refresh, Description: self.c.Tr.RefreshFiles, }, { - Key: opts.GetKey(opts.Config.Files.StashAllChanges), + Keys: opts.GetKeys(opts.Config.Files.StashAllChanges), Handler: self.stash, Description: self.c.Tr.Stash, Tooltip: self.c.Tr.StashTooltip, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Files.ViewStashOptions), + Keys: opts.GetKeys(opts.Config.Files.ViewStashOptions), Handler: self.createStashMenu, Description: self.c.Tr.ViewStashOptions, Tooltip: self.c.Tr.ViewStashOptionsTooltip, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Files.ToggleStagedAll), + Keys: opts.GetKeys(opts.Config.Files.ToggleStagedAll), Handler: self.toggleStagedAll, Description: self.c.Tr.ToggleStagedAll, Tooltip: self.c.Tr.ToggleStagedAllTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.GoInto), + Keys: opts.GetKeys(opts.Config.Universal.GoInto), Handler: self.enter, GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.FileEnter, Tooltip: self.c.Tr.FileEnterTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.Remove), + Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItems(self.remove), GetDisabledReason: self.withFileTreeViewModelMutex(self.require(self.itemsSelected(self.canRemove))), Description: self.c.Tr.Discard, @@ -152,13 +152,13 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Commits.ViewResetOptions), + Keys: opts.GetKeys(opts.Config.Commits.ViewResetOptions), Handler: self.createResetToUpstreamMenu, Description: self.c.Tr.ViewResetToUpstreamOptions, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Files.ViewResetOptions), + Keys: opts.GetKeys(opts.Config.Files.ViewResetOptions), Handler: self.createResetMenu, Description: self.c.Tr.Reset, Tooltip: self.c.Tr.FileResetOptionsTooltip, @@ -166,19 +166,19 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Files.ToggleTreeView), + Keys: opts.GetKeys(opts.Config.Files.ToggleTreeView), Handler: self.toggleTreeView, Description: self.c.Tr.ToggleTreeView, Tooltip: self.c.Tr.ToggleTreeViewTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.OpenDiffTool), + Keys: opts.GetKeys(opts.Config.Universal.OpenDiffTool), Handler: self.withItem(self.openDiffTool), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenDiffTool, }, { - Key: opts.GetKey(opts.Config.Files.OpenMergeOptions), + Keys: opts.GetKeys(opts.Config.Files.OpenMergeOptions), Handler: self.withItems(self.openMergeConflictMenu), Description: self.c.Tr.ViewMergeConflictOptions, Tooltip: self.c.Tr.ViewMergeConflictOptionsTooltip, @@ -187,20 +187,20 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Files.Fetch), + Keys: opts.GetKeys(opts.Config.Files.Fetch), Handler: self.fetch, Description: self.c.Tr.Fetch, Tooltip: self.c.Tr.FetchTooltip, }, { - Key: opts.GetKey(opts.Config.Files.CollapseAll), + Keys: opts.GetKeys(opts.Config.Files.CollapseAll), Handler: self.collapseAll, Description: self.c.Tr.CollapseAll, Tooltip: self.c.Tr.CollapseAllTooltip, GetDisabledReason: self.require(self.isInTreeMode), }, { - Key: opts.GetKey(opts.Config.Files.ExpandAll), + Keys: opts.GetKeys(opts.Config.Files.ExpandAll), Handler: self.expandAll, Description: self.c.Tr.ExpandAll, Tooltip: self.c.Tr.ExpandAllTooltip, @@ -671,14 +671,14 @@ func (self *FilesController) handleNonInlineConflict(file *models.File) error { OnPress: func() error { return handle(self.c.Git().WorkingTree.StageFile, self.c.Tr.Actions.ResolveConflictByKeepingFile) }, - Key: menuKey('k'), + Keys: menuKey('k'), } deleteItem := &types.MenuItem{ Label: self.c.Tr.MergeConflictDeleteFile, OnPress: func() error { return handle(self.c.Git().WorkingTree.RemoveConflictedFile, self.c.Tr.Actions.ResolveConflictByDeletingFile) }, - Key: menuKey('d'), + Keys: menuKey('d'), } items := []*types.MenuItem{} switch file.ShortStatus { @@ -856,7 +856,7 @@ func (self *FilesController) ignoreOrExcludeMenu(node *filetree.FileNode) error } return nil }, - Key: menuKey('i'), + Keys: menuKey('i'), }, { LabelColumns: []string{self.c.Tr.ExcludeFile}, @@ -866,7 +866,7 @@ func (self *FilesController) ignoreOrExcludeMenu(node *filetree.FileNode) error } return nil }, - Key: menuKey('e'), + Keys: menuKey('e'), }, }, }) @@ -950,7 +950,7 @@ func (self *FilesController) handleStatusFilterPressed() error { OnPress: func() error { return self.setStatusFiltering(filetree.DisplayStaged) }, - Key: menuKey('s'), + Keys: menuKey('s'), Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayStaged), }, { @@ -958,7 +958,7 @@ func (self *FilesController) handleStatusFilterPressed() error { OnPress: func() error { return self.setStatusFiltering(filetree.DisplayUnstaged) }, - Key: menuKey('u'), + Keys: menuKey('u'), Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayUnstaged), }, { @@ -966,7 +966,7 @@ func (self *FilesController) handleStatusFilterPressed() error { OnPress: func() error { return self.setStatusFiltering(filetree.DisplayTracked) }, - Key: menuKey('t'), + Keys: menuKey('t'), Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayTracked), }, { @@ -974,7 +974,7 @@ func (self *FilesController) handleStatusFilterPressed() error { OnPress: func() error { return self.setStatusFiltering(filetree.DisplayUntracked) }, - Key: menuKey('T'), + Keys: menuKey('T'), Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayUntracked), }, { @@ -982,7 +982,7 @@ func (self *FilesController) handleStatusFilterPressed() error { OnPress: func() error { return self.setStatusFiltering(filetree.DisplayAll) }, - Key: menuKey('r'), + Keys: menuKey('r'), Widget: types.MakeMenuRadioButton(currentFilter == filetree.DisplayAll), }, }, @@ -1092,7 +1092,7 @@ func (self *FilesController) createStashMenu() error { } return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashAllChanges) }, - Key: menuKey('a'), + Keys: menuKey('a'), }, { Label: self.c.Tr.StashAllChangesKeepIndex, @@ -1103,14 +1103,14 @@ func (self *FilesController) createStashMenu() error { // if there are no staged files it behaves the same as Stash.Save return self.handleStashSave(self.c.Git().Stash.StashAndKeepIndex, self.c.Tr.Actions.StashAllChangesKeepIndex) }, - Key: menuKey('i'), + Keys: menuKey('i'), }, { Label: self.c.Tr.StashIncludeUntrackedChanges, OnPress: func() error { return self.handleStashSave(self.c.Git().Stash.StashIncludeUntrackedChanges, self.c.Tr.Actions.StashIncludeUntrackedChanges) }, - Key: menuKey('U'), + Keys: menuKey('U'), }, { Label: self.c.Tr.StashStagedChanges, @@ -1121,7 +1121,7 @@ func (self *FilesController) createStashMenu() error { } return self.handleStashSave(self.c.Git().Stash.SaveStagedChanges, self.c.Tr.Actions.StashStagedChanges) }, - Key: menuKey('s'), + Keys: menuKey('s'), }, { Label: self.c.Tr.StashUnstagedChanges, @@ -1135,7 +1135,7 @@ func (self *FilesController) createStashMenu() error { // ordinary stash return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashUnstagedChanges) }, - Key: menuKey('u'), + Keys: menuKey('u'), }, }, }) @@ -1182,7 +1182,7 @@ func (self *FilesController) openCopyMenu() error { return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: menuKey('n'), + Keys: menuKey('n'), } copyRelativePathItem := &types.MenuItem{ Label: self.c.Tr.CopyRelativeFilePath, @@ -1194,7 +1194,7 @@ func (self *FilesController) openCopyMenu() error { return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: menuKey('p'), + Keys: menuKey('p'), } copyAbsolutePathItem := &types.MenuItem{ Label: self.c.Tr.CopyAbsoluteFilePath, @@ -1210,7 +1210,7 @@ func (self *FilesController) openCopyMenu() error { return nil }, DisabledReason: self.require(self.singleItemSelected())(), - Key: menuKey('P'), + Keys: menuKey('P'), } copyFileDiffItem := &types.MenuItem{ Label: self.c.Tr.CopySelectedDiff, @@ -1236,7 +1236,7 @@ func (self *FilesController) openCopyMenu() error { return nil }, ))(), - Key: menuKey('s'), + Keys: menuKey('s'), } copyAllDiff := &types.MenuItem{ Label: self.c.Tr.CopyAllFilesDiff, @@ -1261,7 +1261,7 @@ func (self *FilesController) openCopyMenu() error { return nil }, )(), - Key: menuKey('a'), + Keys: menuKey('a'), } return self.c.Menu(types.CreateMenuOptions{ @@ -1502,7 +1502,7 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) return nil }, - Key: self.c.KeybindingsOpts().GetKey(self.c.UserConfig().Keybinding.Files.ConfirmDiscard), + Keys: self.c.KeybindingsOpts().GetKeys(self.c.UserConfig().Keybinding.Files.ConfirmDiscard), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.DiscardAllTooltip, map[string]string{ @@ -1528,7 +1528,7 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) return nil }, - Key: menuKey('u'), + Keys: menuKey('u'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.DiscardUnstagedTooltip, map[string]string{ diff --git a/pkg/gui/controllers/filter_controller.go b/pkg/gui/controllers/filter_controller.go index 8b049b26c..358fb8ed5 100644 --- a/pkg/gui/controllers/filter_controller.go +++ b/pkg/gui/controllers/filter_controller.go @@ -36,7 +36,7 @@ func (self *FilterController) Context() types.Context { func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.StartSearch), + Keys: opts.GetKeys(opts.Config.Universal.StartSearch), Handler: self.OpenFilterPrompt, Description: self.c.Tr.StartFilter, }, diff --git a/pkg/gui/controllers/git_flow_controller.go b/pkg/gui/controllers/git_flow_controller.go index 6e6bec95d..9e892e97f 100644 --- a/pkg/gui/controllers/git_flow_controller.go +++ b/pkg/gui/controllers/git_flow_controller.go @@ -35,7 +35,7 @@ func NewGitFlowController( func (self *GitFlowController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Branches.ViewGitFlowOptions), + Keys: opts.GetKeys(opts.Config.Branches.ViewGitFlowOptions), Handler: self.withItem(self.handleCreateGitFlowMenu), Description: self.c.Tr.GitFlowOptions, OpensMenu: true, @@ -82,22 +82,22 @@ func (self *GitFlowController) handleCreateGitFlowMenu(branch *models.Branch) er { Label: "start feature", OnPress: startHandler("feature"), - Key: menuKey('f'), + Keys: menuKey('f'), }, { Label: "start hotfix", OnPress: startHandler("hotfix"), - Key: menuKey('h'), + Keys: menuKey('h'), }, { Label: "start bugfix", OnPress: startHandler("bugfix"), - Key: menuKey('b'), + Keys: menuKey('b'), }, { Label: "start release", OnPress: startHandler("release"), - Key: menuKey('r'), + Keys: menuKey('r'), }, }, }) diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index f8f981525..5528e10e7 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -23,20 +23,20 @@ func NewGlobalController( func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.ExecuteShellCommand), + Keys: opts.GetKeys(opts.Config.Universal.ExecuteShellCommand), Handler: self.shellCommand, Description: self.c.Tr.ExecuteShellCommand, Tooltip: self.c.Tr.ExecuteShellCommandTooltip, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Universal.CreatePatchOptionsMenu), + Keys: opts.GetKeys(opts.Config.Universal.CreatePatchOptionsMenu), Handler: self.createCustomPatchOptionsMenu, Description: self.c.Tr.ViewPatchOptions, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Universal.CreateRebaseOptionsMenu), + Keys: opts.GetKeys(opts.Config.Universal.CreateRebaseOptionsMenu), Handler: opts.Guards.NoPopupPanel(self.c.Helpers().MergeAndRebase.CreateRebaseOptionsMenu), Description: self.c.Tr.ViewMergeRebaseOptions, Tooltip: self.c.Tr.ViewMergeRebaseOptionsTooltip, @@ -44,30 +44,30 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type GetDisabledReason: self.canShowRebaseOptions, }, { - Key: opts.GetKey(opts.Config.Universal.Refresh), + Keys: opts.GetKeys(opts.Config.Universal.Refresh), Handler: opts.Guards.NoPopupPanel(self.refresh), Description: self.c.Tr.Refresh, Tooltip: self.c.Tr.RefreshTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.NextScreenMode), + Keys: opts.GetKeys(opts.Config.Universal.NextScreenMode), Handler: opts.Guards.NoPopupPanel(self.nextScreenMode), Description: self.c.Tr.NextScreenMode, }, { - Key: opts.GetKey(opts.Config.Universal.PrevScreenMode), + Keys: opts.GetKeys(opts.Config.Universal.PrevScreenMode), Handler: opts.Guards.NoPopupPanel(self.prevScreenMode), Description: self.c.Tr.PrevScreenMode, }, { - Key: opts.GetKey(opts.Config.Universal.CyclePagers), + 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, }, { - Key: opts.GetKey(opts.Config.Universal.Return), + Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: self.escape, Description: self.c.Tr.Cancel, DescriptionFunc: self.escapeDescription, @@ -76,7 +76,7 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type }, { ViewName: "", - Key: opts.GetKey(opts.Config.Universal.OptionMenu), + Keys: opts.GetKeys(opts.Config.Universal.OptionMenu), Description: self.c.Tr.OpenKeybindingsMenu, ShortDescription: self.c.Tr.Keybindings, Handler: self.createOptionsMenu, @@ -86,41 +86,41 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type }, { ViewName: "", - Key: opts.GetKey(opts.Config.Universal.FilteringMenu), + Keys: opts.GetKeys(opts.Config.Universal.FilteringMenu), Handler: opts.Guards.NoPopupPanel(self.createFilteringMenu), Description: self.c.Tr.OpenFilteringMenu, Tooltip: self.c.Tr.OpenFilteringMenuTooltip, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Universal.DiffingMenu), + Keys: opts.GetKeys(opts.Config.Universal.DiffingMenu), Handler: opts.Guards.NoPopupPanel(self.createDiffingMenu), Description: self.c.Tr.ViewDiffingOptions, Tooltip: self.c.Tr.ViewDiffingOptionsTooltip, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Universal.DiffingMenuAlt), + Keys: opts.GetKeys(opts.Config.Universal.DiffingMenuAlt), Handler: opts.Guards.NoPopupPanel(self.createDiffingMenu), Description: self.c.Tr.ViewDiffingOptions, Tooltip: self.c.Tr.ViewDiffingOptionsTooltip, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Universal.Quit), + Keys: opts.GetKeys(opts.Config.Universal.Quit), Description: self.c.Tr.Quit, Handler: self.quit, }, { - Key: opts.GetKey(opts.Config.Universal.QuitAlt1), + Keys: opts.GetKeys(opts.Config.Universal.QuitAlt1), Handler: self.quit, }, { - Key: opts.GetKey(opts.Config.Universal.QuitWithoutChangingDirectory), + Keys: opts.GetKeys(opts.Config.Universal.QuitWithoutChangingDirectory), Handler: self.quitWithoutChangingDirectory, }, { - Key: opts.GetKey(opts.Config.Universal.SuspendApp), + Keys: opts.GetKeys(opts.Config.Universal.SuspendApp), Handler: self.c.Helpers().SuspendResume.SuspendApp, Description: self.c.Tr.SuspendApp, GetDisabledReason: func() *types.DisabledReason { @@ -133,7 +133,7 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type }, }, { - Key: opts.GetKey(opts.Config.Universal.ToggleWhitespaceInDiffView), + Keys: opts.GetKeys(opts.Config.Universal.ToggleWhitespaceInDiffView), Handler: self.toggleWhitespace, Description: self.c.Tr.ToggleWhitespaceInDiffView, Tooltip: self.c.Tr.ToggleWhitespaceInDiffViewTooltip, diff --git a/pkg/gui/controllers/helpers/commits_helper.go b/pkg/gui/controllers/helpers/commits_helper.go index c9b21250a..5e50ba7b2 100644 --- a/pkg/gui/controllers/helpers/commits_helper.go +++ b/pkg/gui/controllers/helpers/commits_helper.go @@ -228,7 +228,7 @@ func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.S OnPress: func() error { return self.SwitchToEditor() }, - Key: menuKey('e'), + Keys: menuKey('e'), DisabledReason: disabledReasonForOpenInEditor, }, { @@ -236,14 +236,14 @@ func (self *CommitsHelper) OpenCommitMenu(suggestionFunc func(string) []*types.S OnPress: func() error { return self.addCoAuthor(suggestionFunc) }, - Key: menuKey('c'), + Keys: menuKey('c'), }, { Label: self.c.Tr.PasteCommitMessageFromClipboard, OnPress: func() error { return self.pasteCommitMessageFromClipboard() }, - Key: menuKey('p'), + Keys: menuKey('p'), }, } return self.c.Menu(types.CreateMenuOptions{ diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 48c342446..cd141c697 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -39,17 +39,17 @@ const ( func (self *MergeAndRebaseHelper) CreateRebaseOptionsMenu() error { type optionAndKey struct { option string - key []gocui.Key + keys []gocui.Key } options := []optionAndKey{ - {option: REBASE_OPTION_CONTINUE, key: menuKey('c')}, - {option: REBASE_OPTION_ABORT, key: menuKey('a')}, + {option: REBASE_OPTION_CONTINUE, keys: menuKey('c')}, + {option: REBASE_OPTION_ABORT, keys: menuKey('a')}, } if self.c.Git().Status.WorkingTreeState().CanSkip() { options = append(options, optionAndKey{ - option: REBASE_OPTION_SKIP, key: menuKey('s'), + option: REBASE_OPTION_SKIP, keys: menuKey('s'), }) } @@ -59,7 +59,7 @@ func (self *MergeAndRebaseHelper) CreateRebaseOptionsMenu() error { OnPress: func() error { return self.genericMergeCommand(row.option) }, - Key: row.key, + Keys: row.keys, } }) @@ -198,7 +198,7 @@ func (self *MergeAndRebaseHelper) PromptForConflictHandling() error { OnPress: func() error { return self.genericMergeCommand(REBASE_OPTION_ABORT) }, - Key: menuKey('a'), + Keys: menuKey('a'), }, }, HideCancel: true, @@ -284,7 +284,7 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { Label: utils.ResolvePlaceholderString(self.c.Tr.SimpleRebase, map[string]string{"ref": ref}, ), - Key: menuKey('s'), + Keys: menuKey('s'), DisabledReason: disabledReason, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RebaseBranch) @@ -308,7 +308,7 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { Label: utils.ResolvePlaceholderString(self.c.Tr.InteractiveRebase, map[string]string{"ref": ref}, ), - Key: menuKey('i'), + Keys: menuKey('i'), DisabledReason: disabledReason, Tooltip: self.c.Tr.InteractiveRebaseTooltip, OnPress: func() error { @@ -334,7 +334,7 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { Label: utils.ResolvePlaceholderString(self.c.Tr.RebaseOntoBaseBranch, map[string]string{"baseBranch": ShortBranchName(baseBranch)}, ), - Key: menuKey('b'), + Keys: menuKey('b'), DisabledReason: baseBranchDisabledReason, Tooltip: self.c.Tr.RebaseOntoBaseBranchTooltip, OnPress: func() error { @@ -392,7 +392,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e firstRegularMergeItem = &types.MenuItem{ Label: self.c.Tr.RegularMergeFastForward, OnPress: self.RegularMerge(refName, git_commands.MERGE_VARIANT_REGULAR), - Key: menuKey('m'), + Keys: menuKey('m'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.RegularMergeFastForwardTooltip, map[string]string{ @@ -406,7 +406,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e secondRegularMergeItem = &types.MenuItem{ Label: self.c.Tr.RegularMergeNonFastForward, OnPress: self.RegularMerge(refName, git_commands.MERGE_VARIANT_NON_FAST_FORWARD), - Key: menuKey('n'), + Keys: menuKey('n'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.RegularMergeNonFastForwardTooltip, map[string]string{ @@ -419,7 +419,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e firstRegularMergeItem = &types.MenuItem{ Label: self.c.Tr.RegularMergeNonFastForward, OnPress: self.RegularMerge(refName, git_commands.MERGE_VARIANT_REGULAR), - Key: menuKey('m'), + Keys: menuKey('m'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.RegularMergeNonFastForwardTooltip, map[string]string{ @@ -432,7 +432,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e secondRegularMergeItem = &types.MenuItem{ Label: self.c.Tr.RegularMergeFastForward, OnPress: self.RegularMerge(refName, git_commands.MERGE_VARIANT_FAST_FORWARD), - Key: menuKey('f'), + Keys: menuKey('f'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.RegularMergeFastForwardTooltip, map[string]string{ @@ -464,7 +464,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e { Label: self.c.Tr.SquashMergeUncommitted, OnPress: self.SquashMergeUncommitted(refName), - Key: menuKey('s'), + Keys: menuKey('s'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.SquashMergeUncommittedTooltip, map[string]string{ @@ -475,7 +475,7 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e { Label: self.c.Tr.SquashMergeCommitted, OnPress: self.SquashMergeCommitted(refName, checkedOutBranchName), - Key: menuKey('S'), + Keys: menuKey('S'), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.SquashMergeCommittedTooltip, map[string]string{ diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index ae560941f..a3db043ef 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -216,15 +216,15 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string func (self *RefsHelper) CreateSortOrderMenu(sortOptionsOrder []string, menuPrompt string, onSelected func(sortOrder string) error, currentValue string) error { type sortMenuOption struct { - key []gocui.Key + keys []gocui.Key label string description string sortOrder string } availableSortOptions := map[string]sortMenuOption{ - "recency": {label: self.c.Tr.SortByRecency, description: self.c.Tr.SortBasedOnReflog, key: menuKey('r')}, - "alphabetical": {label: self.c.Tr.SortAlphabetical, description: "--sort=refname", key: menuKey('a')}, - "date": {label: self.c.Tr.SortByDate, description: "--sort=-committerdate", key: menuKey('d')}, + "recency": {label: self.c.Tr.SortByRecency, description: self.c.Tr.SortBasedOnReflog, keys: menuKey('r')}, + "alphabetical": {label: self.c.Tr.SortAlphabetical, description: "--sort=refname", keys: menuKey('a')}, + "date": {label: self.c.Tr.SortByDate, description: "--sort=-committerdate", keys: menuKey('d')}, } sortOptions := make([]sortMenuOption, 0, len(sortOptionsOrder)) for _, key := range sortOptionsOrder { @@ -245,7 +245,7 @@ func (self *RefsHelper) CreateSortOrderMenu(sortOptionsOrder []string, menuPromp OnPress: func() error { return onSelected(opt.sortOrder) }, - Key: opt.key, + Keys: opt.keys, Widget: types.MakeMenuRadioButton(opt.sortOrder == currentValue), } }) @@ -260,14 +260,14 @@ func (self *RefsHelper) CreateGitResetMenu(name string, ref string) error { type strengthWithKey struct { strength string label string - key []gocui.Key + keys []gocui.Key tooltip string } strengths := []strengthWithKey{ // not i18'ing because it's git terminology - {strength: "mixed", label: "Mixed reset", key: menuKey('m'), tooltip: self.c.Tr.ResetMixedTooltip}, - {strength: "soft", label: "Soft reset", key: menuKey('s'), tooltip: self.c.Tr.ResetSoftTooltip}, - {strength: "hard", label: "Hard reset", key: menuKey('h'), tooltip: self.c.Tr.ResetHardTooltip}, + {strength: "mixed", label: "Mixed reset", keys: menuKey('m'), tooltip: self.c.Tr.ResetMixedTooltip}, + {strength: "soft", label: "Soft reset", keys: menuKey('s'), tooltip: self.c.Tr.ResetSoftTooltip}, + {strength: "hard", label: "Hard reset", keys: menuKey('h'), tooltip: self.c.Tr.ResetHardTooltip}, } menuItems := lo.Map(strengths, func(row strengthWithKey, _ int) *types.MenuItem { @@ -287,7 +287,7 @@ func (self *RefsHelper) CreateGitResetMenu(name string, ref string) error { }, }) }, - Key: row.key, + Keys: row.keys, Tooltip: row.tooltip, } }) @@ -312,15 +312,15 @@ func (self *RefsHelper) CreateCheckoutMenu(commit *models.Commit) error { self.c.LogAction(self.c.Tr.Actions.CheckoutCommit) return self.CheckoutRef(hash, types.CheckoutRefOptions{}) }, - Key: menuKey('d'), + Keys: menuKey('d'), }, } if len(branches) > 0 { menuItems = append(menuItems, lo.Map(branches, func(branch *models.Branch, index int) *types.MenuItem { - var key []gocui.Key + var keys []gocui.Key if index < 9 { - key = menuKey(rune(index + 1 + '0')) // Convert 1-based index to key + keys = menuKey(rune(index + 1 + '0')) // Convert 1-based index to key } return &types.MenuItem{ LabelColumns: []string{fmt.Sprintf(self.c.Tr.Actions.CheckoutBranchAtCommit, branch.Name)}, @@ -328,7 +328,7 @@ func (self *RefsHelper) CreateCheckoutMenu(commit *models.Commit) error { self.c.LogAction(self.c.Tr.Actions.CheckoutBranch) return self.CheckoutRef(branch.RefName(), types.CheckoutRefOptions{}) }, - Key: key, + Keys: keys, } })...) } else { @@ -336,7 +336,7 @@ func (self *RefsHelper) CreateCheckoutMenu(commit *models.Commit) error { LabelColumns: []string{self.c.Tr.Actions.CheckoutBranch}, OnPress: func() error { return nil }, DisabledReason: &types.DisabledReason{Text: self.c.Tr.NoBranchesFoundAtCommitTooltip}, - Key: menuKey('1'), + Keys: menuKey('1'), }) } diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index dba2341a7..3ad2c54cf 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -382,7 +382,7 @@ func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []strin OnPress: func() error { return onMergeStrategySelected("--ours") }, - Key: menuKey('c'), + Keys: menuKey('c'), }, { LabelColumns: []string{ @@ -392,7 +392,7 @@ func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []strin OnPress: func() error { return onMergeStrategySelected("--theirs") }, - Key: menuKey('i'), + Keys: menuKey('i'), }, { LabelColumns: []string{ @@ -402,7 +402,7 @@ func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []strin OnPress: func() error { return onMergeStrategySelected("--union") }, - Key: menuKey('b'), + Keys: menuKey('b'), }, { LabelColumns: []string{ @@ -410,7 +410,7 @@ func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []strin cmdColor.Sprint("git mergetool"), }, OnPress: self.OpenMergeTool, - Key: menuKey('m'), + Keys: menuKey('m'), }, }, }) diff --git a/pkg/gui/controllers/jump_to_side_window_controller.go b/pkg/gui/controllers/jump_to_side_window_controller.go index 6a08e3758..2ea8ac762 100644 --- a/pkg/gui/controllers/jump_to_side_window_controller.go +++ b/pkg/gui/controllers/jump_to_side_window_controller.go @@ -39,7 +39,7 @@ func (self *JumpToSideWindowController) GetKeybindings(opts types.KeybindingsOpt return &types.Binding{ ViewName: "", // by default the keys are 1, 2, 3, etc - Key: opts.GetKey(opts.Config.Universal.JumpToBlock[index]), + Keys: opts.GetKeys(opts.Config.Universal.JumpToBlock[index]), Handler: opts.Guards.NoPopupPanel(self.goToSideWindow(window)), } }) diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index dba08552c..854e14ffa 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -271,26 +271,26 @@ func (self *ListController) isFocused() bool { func (self *ListController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), Handler: self.HandlePrevLine}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItem), Handler: self.HandlePrevLine}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItemAlt), Handler: self.HandleNextLine}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItem), Handler: self.HandleNextLine}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevPage), Handler: self.HandlePrevPage, Description: self.c.Tr.PrevPage}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextPage), Handler: self.HandleNextPage, Description: self.c.Tr.NextPage}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoTop), Handler: self.HandleGotoTop, Description: self.c.Tr.GotoTop, Alternative: ""}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottom), Handler: self.HandleGotoBottom, Description: self.c.Tr.GotoBottom, Alternative: ""}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoTopAlt), Handler: self.HandleGotoTop}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottomAlt), Handler: self.HandleGotoBottom}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollLeft), Handler: self.HandleScrollLeft}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollRight), Handler: self.HandleScrollRight}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItemAlt), Handler: self.HandlePrevLine}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.HandlePrevLine}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItemAlt), Handler: self.HandleNextLine}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.HandleNextLine}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.HandlePrevPage, Description: self.c.Tr.PrevPage}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.HandleNextPage, Description: self.c.Tr.NextPage}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.HandleGotoTop, Description: self.c.Tr.GotoTop, Alternative: ""}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.HandleGotoBottom, Description: self.c.Tr.GotoBottom, Alternative: ""}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTopAlt), Handler: self.HandleGotoTop}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottomAlt), Handler: self.HandleGotoBottom}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.ScrollLeft), Handler: self.HandleScrollLeft}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.ScrollRight), Handler: self.HandleScrollRight}, } if self.context.RangeSelectEnabled() { bindings = append(bindings, []*types.Binding{ - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ToggleRangeSelect), Handler: self.HandleToggleRangeSelect, Description: self.c.Tr.ToggleRangeSelect}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.RangeSelectDown), Handler: self.HandleRangeSelectDown, Description: self.c.Tr.RangeSelectDown}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.RangeSelectUp), Handler: self.HandleRangeSelectUp, Description: self.c.Tr.RangeSelectUp}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect), Handler: self.HandleToggleRangeSelect, Description: self.c.Tr.ToggleRangeSelect}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown), Handler: self.HandleRangeSelectDown, Description: self.c.Tr.RangeSelectDown}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.RangeSelectUp), Handler: self.HandleRangeSelectUp, Description: self.c.Tr.RangeSelectUp}, }..., ) } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 0683147b9..b0cad6586 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -56,7 +56,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Commits.SquashDown), + Keys: opts.GetKeys(opts.Config.Commits.SquashDown), Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.squashDown)), GetDisabledReason: self.require( self.itemRangeSelected( @@ -69,7 +69,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Commits.MarkCommitAsFixup), + Keys: opts.GetKeys(opts.Config.Commits.MarkCommitAsFixup), Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.fixup)), GetDisabledReason: self.require( self.itemRangeSelected( @@ -82,7 +82,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Commits.SetFixupMessage), + Keys: opts.GetKeys(opts.Config.Commits.SetFixupMessage), Handler: self.withItem(self.setFixupMessage), GetDisabledReason: self.require( self.singleItemSelected(self.canSetFixupMessage), @@ -91,7 +91,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ Tooltip: self.c.Tr.SetFixupMessageTooltip, }, { - Key: opts.GetKey(opts.Config.Commits.RenameCommit), + Keys: opts.GetKeys(opts.Config.Commits.RenameCommit), Handler: self.withItem(self.reword), GetDisabledReason: self.require( self.singleItemSelected(self.rewordEnabled), @@ -102,7 +102,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Commits.RenameCommitWithEditor), + Keys: opts.GetKeys(opts.Config.Commits.RenameCommitWithEditor), Handler: self.withItem(self.rewordEditor), GetDisabledReason: self.require( self.singleItemSelected(self.rewordEnabled), @@ -110,7 +110,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ Description: self.c.Tr.RewordCommitEditor, }, { - Key: opts.GetKey(opts.Config.Universal.Remove), + Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItemsRange(self.drop), GetDisabledReason: self.require( self.itemRangeSelected( @@ -122,7 +122,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ DisplayOnScreen: true, }, { - Key: opts.GetKey(editCommitKey), + Keys: opts.GetKeys(editCommitKey), Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.edit)), GetDisabledReason: self.require( self.itemRangeSelected(self.midRebaseCommandEnabled), @@ -136,7 +136,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ // The user-facing description here is 'Start interactive rebase' but internally // we're calling it 'quick-start interactive rebase' to differentiate it from // when you manually select the base commit. - Key: opts.GetKey(opts.Config.Commits.StartInteractiveRebase), + Keys: opts.GetKeys(opts.Config.Commits.StartInteractiveRebase), Handler: opts.Guards.OutsideFilterMode(self.quickStartInteractiveRebase), GetDisabledReason: self.require(self.notMidRebase(self.c.Tr.AlreadyRebasing), self.canFindCommitForQuickStart), Description: self.c.Tr.QuickStartInteractiveRebase, @@ -145,7 +145,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }), }, { - Key: opts.GetKey(opts.Config.Commits.PickCommit), + Keys: opts.GetKeys(opts.Config.Commits.PickCommit), Handler: opts.Guards.OutsideFilterMode(self.withItems(self.pick)), GetDisabledReason: self.require( self.itemRangeSelected(self.pickEnabled), @@ -154,7 +154,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ Tooltip: self.c.Tr.PickCommitTooltip, }, { - Key: opts.GetKey(opts.Config.Commits.CreateFixupCommit), + Keys: opts.GetKeys(opts.Config.Commits.CreateFixupCommit), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.createFixupCommit)), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.CreateFixupCommit, @@ -166,7 +166,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ ), }, { - Key: opts.GetKey(opts.Config.Commits.SquashAboveCommits), + Keys: opts.GetKeys(opts.Config.Commits.SquashAboveCommits), Handler: opts.Guards.OutsideFilterMode(self.squashFixupCommits), GetDisabledReason: self.require( self.notMidRebase(self.c.Tr.AlreadyRebasing), @@ -176,7 +176,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Commits.MoveDownCommit), + Keys: opts.GetKeys(opts.Config.Commits.MoveDownCommit), Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.moveDown)), GetDisabledReason: self.require(self.itemRangeSelected( self.midRebaseMoveCommandEnabled, @@ -185,7 +185,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ Description: self.c.Tr.MoveDownCommit, }, { - Key: opts.GetKey(opts.Config.Commits.MoveUpCommit), + Keys: opts.GetKeys(opts.Config.Commits.MoveUpCommit), Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.moveUp)), GetDisabledReason: self.require(self.itemRangeSelected( self.midRebaseMoveCommandEnabled, @@ -194,14 +194,14 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ Description: self.c.Tr.MoveUpCommit, }, { - Key: opts.GetKey(opts.Config.Commits.PasteCommits), + Keys: opts.GetKeys(opts.Config.Commits.PasteCommits), Handler: opts.Guards.OutsideFilterMode(self.paste), GetDisabledReason: self.require(self.canPaste), Description: self.c.Tr.PasteCommits, DisplayStyle: &style.FgCyan, }, { - Key: opts.GetKey(opts.Config.Commits.MarkCommitAsBaseForRebase), + Keys: opts.GetKeys(opts.Config.Commits.MarkCommitAsBaseForRebase), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.markAsBaseCommit)), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.MarkAsBaseCommit, @@ -210,13 +210,13 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ // overriding this navigation keybinding because we might need to load // more commits on demand { - Key: opts.GetKey(opts.Config.Universal.StartSearch), + Keys: opts.GetKeys(opts.Config.Universal.StartSearch), Handler: self.openSearch, Description: self.c.Tr.StartSearch, Tag: "navigation", }, { - Key: opts.GetKey(opts.Config.Commits.AmendToCommit), + Keys: opts.GetKeys(opts.Config.Commits.AmendToCommit), Handler: self.withItem(self.amendTo), GetDisabledReason: self.require(self.singleItemSelected(self.canAmend)), Description: self.c.Tr.Amend, @@ -224,7 +224,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Commits.ResetCommitAuthor), + Keys: opts.GetKeys(opts.Config.Commits.ResetCommitAuthor), Handler: self.withItemsRange(self.amendAttribute), GetDisabledReason: self.require(self.itemRangeSelected(self.canAmendRange)), Description: self.c.Tr.AmendCommitAttribute, @@ -232,28 +232,28 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Commits.RevertCommit), + Keys: opts.GetKeys(opts.Config.Commits.RevertCommit), Handler: self.withItemsRange(self.revert), GetDisabledReason: self.require(self.itemRangeSelected()), Description: self.c.Tr.Revert, Tooltip: self.c.Tr.RevertCommitTooltip, }, { - Key: opts.GetKey(opts.Config.Commits.CreateTag), + Keys: opts.GetKeys(opts.Config.Commits.CreateTag), Handler: self.withItem(self.createTag), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.TagCommit, Tooltip: self.c.Tr.TagCommitTooltip, }, { - Key: opts.GetKey(opts.Config.Commits.OpenLogMenu), + Keys: opts.GetKeys(opts.Config.Commits.OpenLogMenu), Handler: self.handleOpenLogMenu, Description: self.c.Tr.OpenLogMenu, Tooltip: self.c.Tr.OpenLogMenuTooltip, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Commits.OpenPullRequestInBrowser), + Keys: opts.GetKeys(opts.Config.Commits.OpenPullRequestInBrowser), Handler: self.openPRInBrowser, GetDisabledReason: self.checkedOutBranchHasPR, Description: self.c.Tr.OpenPullRequestInBrowser, @@ -361,7 +361,7 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star Items: []*types.MenuItem{ { Label: self.c.Tr.Fixup, - Key: menuKey('f'), + Keys: menuKey('f'), OnPress: func() error { return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommit) @@ -372,7 +372,7 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star }, { Label: self.c.Tr.FixupKeepMessage, - Key: menuKey('c'), + Keys: menuKey('c'), OnPress: func() error { return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage) @@ -403,7 +403,7 @@ func (self *LocalCommitsController) setFixupMessage(commit *models.Commit) error Items: []*types.MenuItem{ { Label: self.c.Tr.FixupDiscardMessage, - Key: menuKey('f'), + Keys: menuKey('f'), OnPress: func() error { return self.updateTodosWithFlag(todo.Fixup, []*models.Commit{commit}, "") }, @@ -411,7 +411,7 @@ func (self *LocalCommitsController) setFixupMessage(commit *models.Commit) error }, { Label: self.c.Tr.FixupKeepMessage, - Key: menuKey('c'), + Keys: menuKey('c'), OnPress: func() error { return self.updateTodosWithFlag(todo.Fixup, []*models.Commit{commit}, "-C") }, @@ -864,19 +864,19 @@ func (self *LocalCommitsController) amendAttribute(commits []*models.Commit, sta { Label: self.c.Tr.ResetAuthor, OnPress: func() error { return self.resetAuthor(start, end) }, - Key: opts.GetKey(opts.Config.AmendAttribute.ResetAuthor), + Keys: opts.GetKeys(opts.Config.AmendAttribute.ResetAuthor), Tooltip: self.c.Tr.ResetAuthorTooltip, }, { Label: self.c.Tr.SetAuthor, OnPress: func() error { return self.setAuthor(start, end) }, - Key: opts.GetKey(opts.Config.AmendAttribute.SetAuthor), + Keys: opts.GetKeys(opts.Config.AmendAttribute.SetAuthor), Tooltip: self.c.Tr.SetAuthorTooltip, }, { Label: self.c.Tr.AddCoAuthor, OnPress: func() error { return self.addCoAuthor(start, end) }, - Key: opts.GetKey(opts.Config.AmendAttribute.AddCoAuthor), + Keys: opts.GetKeys(opts.Config.AmendAttribute.AddCoAuthor), Tooltip: self.c.Tr.AddCoAuthorTooltip, }, }, @@ -1000,7 +1000,7 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err Items: []*types.MenuItem{ { Label: self.c.Tr.FixupMenu_Fixup, - Key: menuKey('f'), + Keys: menuKey('f'), OnPress: func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit) @@ -1024,7 +1024,7 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err }, { Label: self.c.Tr.FixupMenu_AmendWithChanges, - Key: menuKey('a'), + Keys: menuKey('a'), OnPress: func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { return self.createAmendCommit(commit, true) @@ -1035,7 +1035,7 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err }, { Label: self.c.Tr.FixupMenu_AmendWithoutChanges, - Key: menuKey('r'), + Keys: menuKey('r'), OnPress: func() error { return self.createAmendCommit(commit, false) }, Tooltip: self.c.Tr.FixupMenu_AmendWithoutChangesTooltip, }, @@ -1134,14 +1134,14 @@ func (self *LocalCommitsController) squashFixupCommits() error { Label: self.c.Tr.SquashCommitsInCurrentBranch, OnPress: self.squashAllFixupsInCurrentBranch, DisabledReason: self.canFindCommitForSquashFixupsInCurrentBranch(), - Key: menuKey('b'), + Keys: menuKey('b'), Tooltip: self.c.Tr.SquashCommitsInCurrentBranchTooltip, }, { Label: self.c.Tr.SquashCommitsAboveSelectedCommit, OnPress: self.withItem(self.squashAllFixupsAboveSelectedCommit), DisabledReason: self.singleItemSelected()(), - Key: menuKey('a'), + Keys: menuKey('a'), Tooltip: self.c.Tr.SquashCommitsAboveSelectedTooltip, }, }, diff --git a/pkg/gui/controllers/main_view_controller.go b/pkg/gui/controllers/main_view_controller.go index 58d88485a..6eb6c86e3 100644 --- a/pkg/gui/controllers/main_view_controller.go +++ b/pkg/gui/controllers/main_view_controller.go @@ -32,21 +32,21 @@ func NewMainViewController( func (self *MainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.TogglePanel), + Keys: opts.GetKeys(opts.Config.Universal.TogglePanel), Handler: self.togglePanel, Description: self.c.Tr.ToggleStagingView, Tooltip: self.c.Tr.ToggleStagingViewTooltip, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Return), + Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: self.escape, Description: self.c.Tr.ExitFocusedMainView, DisplayOnScreen: true, }, { // overriding this because we want to read all of the task's output before we start searching - Key: opts.GetKey(opts.Config.Universal.StartSearch), + Keys: opts.GetKeys(opts.Config.Universal.StartSearch), Handler: self.openSearch, Description: self.c.Tr.StartSearch, Tag: "navigation", diff --git a/pkg/gui/controllers/menu_controller.go b/pkg/gui/controllers/menu_controller.go index a2c77e457..283c2bbdf 100644 --- a/pkg/gui/controllers/menu_controller.go +++ b/pkg/gui/controllers/menu_controller.go @@ -33,19 +33,19 @@ func NewMenuController( func (self *MenuController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.Select), + Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withItem(self.press), GetDisabledReason: self.require(self.singleItemSelected()), }, { - Key: opts.GetKey(opts.Config.Universal.ConfirmMenu), + Keys: opts.GetKeys(opts.Config.Universal.ConfirmMenu), Handler: self.withItem(self.press), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Execute, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Return), + Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: self.close, Description: self.c.Tr.CloseCancel, DisplayOnScreen: true, diff --git a/pkg/gui/controllers/merge_conflicts_controller.go b/pkg/gui/controllers/merge_conflicts_controller.go index 1de192928..a539e710f 100644 --- a/pkg/gui/controllers/merge_conflicts_controller.go +++ b/pkg/gui/controllers/merge_conflicts_controller.go @@ -28,91 +28,91 @@ func NewMergeConflictsController( func (self *MergeConflictsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.Select), + Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withRenderAndFocus(self.HandlePickHunk), Description: self.c.Tr.PickHunk, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Main.PickBothHunks), + Keys: opts.GetKeys(opts.Config.Main.PickBothHunks), Handler: self.withRenderAndFocus(self.HandlePickAllHunks), Description: self.c.Tr.PickAllHunks, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.PrevItem), + Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.withRenderAndFocus(self.PrevConflictHunk), Description: self.c.Tr.SelectPrevHunk, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.NextItem), + Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.withRenderAndFocus(self.NextConflictHunk), Description: self.c.Tr.SelectNextHunk, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.PrevBlock), + Keys: opts.GetKeys(opts.Config.Universal.PrevBlock), Handler: self.withRenderAndFocus(self.PrevConflict), Description: self.c.Tr.PrevConflict, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.NextBlock), + Keys: opts.GetKeys(opts.Config.Universal.NextBlock), Handler: self.withRenderAndFocus(self.NextConflict), Description: self.c.Tr.NextConflict, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Undo), + Keys: opts.GetKeys(opts.Config.Universal.Undo), Handler: self.withRenderAndFocus(self.HandleUndo), Description: self.c.Tr.Undo, Tooltip: self.c.Tr.UndoMergeResolveTooltip, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Edit), + Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: self.HandleEditFile, Description: self.c.Tr.EditFile, Tooltip: self.c.Tr.EditFileTooltip, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.OpenFile), + Keys: opts.GetKeys(opts.Config.Universal.OpenFile), Handler: self.HandleOpenFile, Description: self.c.Tr.OpenFile, Tooltip: self.c.Tr.OpenFileTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.PrevBlockAlt), + Keys: opts.GetKeys(opts.Config.Universal.PrevBlockAlt), Handler: self.withRenderAndFocus(self.PrevConflict), }, { - Key: opts.GetKey(opts.Config.Universal.NextBlockAlt), + Keys: opts.GetKeys(opts.Config.Universal.NextBlockAlt), Handler: self.withRenderAndFocus(self.NextConflict), }, { - Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), + Keys: opts.GetKeys(opts.Config.Universal.PrevItemAlt), Handler: self.withRenderAndFocus(self.PrevConflictHunk), }, { - Key: opts.GetKey(opts.Config.Universal.NextItemAlt), + Keys: opts.GetKeys(opts.Config.Universal.NextItemAlt), Handler: self.withRenderAndFocus(self.NextConflictHunk), }, { - Key: opts.GetKey(opts.Config.Universal.ScrollLeft), + Keys: opts.GetKeys(opts.Config.Universal.ScrollLeft), Handler: self.withRenderAndFocus(self.HandleScrollLeft), Description: self.c.Tr.ScrollLeft, Tag: "navigation", }, { - Key: opts.GetKey(opts.Config.Universal.ScrollRight), + Keys: opts.GetKeys(opts.Config.Universal.ScrollRight), Handler: self.withRenderAndFocus(self.HandleScrollRight), Description: self.c.Tr.ScrollRight, Tag: "navigation", }, { - Key: opts.GetKey(opts.Config.Files.OpenMergeOptions), + Keys: opts.GetKeys(opts.Config.Files.OpenMergeOptions), Handler: self.openMergeConflictMenu, Description: self.c.Tr.ViewMergeConflictOptions, Tooltip: self.c.Tr.ViewMergeConflictOptionsTooltip, @@ -120,7 +120,7 @@ func (self *MergeConflictsController) GetKeybindings(opts types.KeybindingsOpts) DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Return), + Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: self.Escape, Description: self.c.Tr.ReturnToFilesPanel, }, diff --git a/pkg/gui/controllers/options_menu_action.go b/pkg/gui/controllers/options_menu_action.go index e711f9df6..e49c02386 100644 --- a/pkg/gui/controllers/options_menu_action.go +++ b/pkg/gui/controllers/options_menu_action.go @@ -33,7 +33,7 @@ func (self *OptionsMenuAction) Call() error { return self.c.IGuiCommon.CallKeybindingHandler(binding) }, - Key: binding.Key, + Keys: binding.Keys, Tooltip: binding.Tooltip, DisabledReason: disabledReason, Section: section, diff --git a/pkg/gui/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go index 3557a3879..dd8c89fff 100644 --- a/pkg/gui/controllers/patch_building_controller.go +++ b/pkg/gui/controllers/patch_building_controller.go @@ -27,25 +27,25 @@ func NewPatchBuildingController( func (self *PatchBuildingController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.OpenFile), + Keys: opts.GetKeys(opts.Config.Universal.OpenFile), Handler: self.OpenFile, Description: self.c.Tr.OpenFile, Tooltip: self.c.Tr.OpenFileTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.Edit), + Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: self.EditFile, Description: self.c.Tr.EditFile, Tooltip: self.c.Tr.EditFileTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.Select), + Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.ToggleSelectionAndRefresh, Description: self.c.Tr.ToggleSelectionForPatch, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Remove), + Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.discardSelection, GetDisabledReason: self.getDisabledReasonForDiscard, Description: self.c.Tr.RemoveSelectionFromPatch, @@ -53,7 +53,7 @@ func (self *PatchBuildingController) GetKeybindings(opts types.KeybindingsOpts) DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Return), + Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: self.Escape, Description: self.c.Tr.ExitCustomPatchBuilder, DescriptionFunc: self.EscapeDescription, diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go index 9c0ef078f..14bce304e 100644 --- a/pkg/gui/controllers/patch_explorer_controller.go +++ b/pkg/gui/controllers/patch_explorer_controller.go @@ -41,61 +41,61 @@ func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts) return []*types.Binding{ { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), + Keys: opts.GetKeys(opts.Config.Universal.PrevItemAlt), Handler: self.withRenderAndFocus(self.HandlePrevLine), }, { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.PrevItem), + Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.withRenderAndFocus(self.HandlePrevLine), }, { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.NextItemAlt), + Keys: opts.GetKeys(opts.Config.Universal.NextItemAlt), Handler: self.withRenderAndFocus(self.HandleNextLine), }, { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.NextItem), + Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.withRenderAndFocus(self.HandleNextLine), }, { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.RangeSelectUp), + Keys: opts.GetKeys(opts.Config.Universal.RangeSelectUp), Handler: self.withRenderAndFocus(self.HandlePrevLineRange), Description: self.c.Tr.RangeSelectUp, }, { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.RangeSelectDown), + Keys: opts.GetKeys(opts.Config.Universal.RangeSelectDown), Handler: self.withRenderAndFocus(self.HandleNextLineRange), Description: self.c.Tr.RangeSelectDown, }, { - Key: opts.GetKey(opts.Config.Universal.PrevBlock), + Keys: opts.GetKeys(opts.Config.Universal.PrevBlock), Handler: self.withRenderAndFocus(self.HandlePrevHunk), Description: self.c.Tr.PrevHunk, }, { - Key: opts.GetKey(opts.Config.Universal.PrevBlockAlt), + Keys: opts.GetKeys(opts.Config.Universal.PrevBlockAlt), Handler: self.withRenderAndFocus(self.HandlePrevHunk), }, { - Key: opts.GetKey(opts.Config.Universal.NextBlock), + Keys: opts.GetKeys(opts.Config.Universal.NextBlock), Handler: self.withRenderAndFocus(self.HandleNextHunk), Description: self.c.Tr.NextHunk, }, { - Key: opts.GetKey(opts.Config.Universal.NextBlockAlt), + Keys: opts.GetKeys(opts.Config.Universal.NextBlockAlt), Handler: self.withRenderAndFocus(self.HandleNextHunk), }, { - Key: opts.GetKey(opts.Config.Universal.ToggleRangeSelect), + Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect), Handler: self.withRenderAndFocus(self.HandleToggleSelectRange), Description: self.c.Tr.ToggleRangeSelect, }, { - Key: opts.GetKey(opts.Config.Main.ToggleSelectHunk), + Keys: opts.GetKeys(opts.Config.Main.ToggleSelectHunk), Handler: self.withRenderAndFocus(self.HandleToggleSelectHunk), Description: self.c.Tr.ToggleSelectHunk, DescriptionFunc: func() string { @@ -109,50 +109,50 @@ func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts) }, { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.PrevPage), + Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.withRenderAndFocus(self.HandlePrevPage), Description: self.c.Tr.PrevPage, }, { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.NextPage), + Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.withRenderAndFocus(self.HandleNextPage), Description: self.c.Tr.NextPage, }, { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.GotoTop), + Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.withRenderAndFocus(self.HandleGotoTop), Description: self.c.Tr.GotoTop, }, { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.GotoBottom), + Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Description: self.c.Tr.GotoBottom, Handler: self.withRenderAndFocus(self.HandleGotoBottom), }, { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.GotoTopAlt), + Keys: opts.GetKeys(opts.Config.Universal.GotoTopAlt), Handler: self.withRenderAndFocus(self.HandleGotoTop), }, { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.GotoBottomAlt), + Keys: opts.GetKeys(opts.Config.Universal.GotoBottomAlt), Handler: self.withRenderAndFocus(self.HandleGotoBottom), }, { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.ScrollLeft), + Keys: opts.GetKeys(opts.Config.Universal.ScrollLeft), Handler: self.withRenderAndFocus(self.HandleScrollLeft), }, { Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.ScrollRight), + Keys: opts.GetKeys(opts.Config.Universal.ScrollRight), Handler: self.withRenderAndFocus(self.HandleScrollRight), }, { - Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), + Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), Handler: self.withLock(self.CopySelectedToClipboard), Description: self.c.Tr.CopySelectedTextToClipboard, }, diff --git a/pkg/gui/controllers/prompt_controller.go b/pkg/gui/controllers/prompt_controller.go index 9a1fd63c9..abcf73ef5 100644 --- a/pkg/gui/controllers/prompt_controller.go +++ b/pkg/gui/controllers/prompt_controller.go @@ -27,19 +27,19 @@ func NewPromptController( func (self *PromptController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: []gocui.Key{gocui.NewKeyName(gocui.KeyEnter)}, + Keys: []gocui.Key{gocui.NewKeyName(gocui.KeyEnter)}, Handler: func() error { return self.context().State.OnConfirm() }, Description: self.c.Tr.Confirm, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Return), + Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: func() error { return self.context().State.OnClose() }, Description: self.c.Tr.CloseCancel, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.TogglePanel), + Keys: opts.GetKeys(opts.Config.Universal.TogglePanel), Handler: func() error { if len(self.c.Contexts().Suggestions.State.Suggestions) > 0 { self.switchToSuggestions() diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go index 3a0350477..3a49c0114 100644 --- a/pkg/gui/controllers/remote_branches_controller.go +++ b/pkg/gui/controllers/remote_branches_controller.go @@ -35,7 +35,7 @@ func NewRemoteBranchesController( func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.Select), + Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withItem(self.checkoutBranch), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Checkout, @@ -43,13 +43,13 @@ func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts) DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.New), + Keys: opts.GetKeys(opts.Config.Universal.New), Handler: self.withItem(self.newLocalBranch), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.NewBranch, }, { - Key: opts.GetKey(opts.Config.Branches.MergeIntoCurrentBranch), + Keys: opts.GetKeys(opts.Config.Branches.MergeIntoCurrentBranch), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.merge)), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Merge, @@ -57,7 +57,7 @@ func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts) DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Branches.RebaseBranch), + Keys: opts.GetKeys(opts.Config.Branches.RebaseBranch), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.rebase)), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.RebaseBranch, @@ -65,7 +65,7 @@ func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts) DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Remove), + Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItems(self.delete), GetDisabledReason: self.require(self.itemRangeSelected()), Description: self.c.Tr.Delete, @@ -73,7 +73,7 @@ func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts) DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Branches.SetUpstream), + Keys: opts.GetKeys(opts.Config.Branches.SetUpstream), Handler: self.withItem(self.setAsUpstream), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.SetAsUpstream, @@ -81,13 +81,13 @@ func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts) DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Branches.SortOrder), + Keys: opts.GetKeys(opts.Config.Branches.SortOrder), Handler: self.createSortMenu, Description: self.c.Tr.SortOrder, OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Commits.ViewResetOptions), + Keys: opts.GetKeys(opts.Config.Commits.ViewResetOptions), Handler: self.withItem(self.createResetMenu), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.ViewResetOptions, @@ -95,7 +95,7 @@ func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts) OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Universal.OpenDiffTool), + Keys: opts.GetKeys(opts.Config.Universal.OpenDiffTool), Handler: self.withItem(func(selectedBranch *models.RemoteBranch) error { return self.c.Helpers().Diff.OpenDiffToolForRef(selectedBranch) }), diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index c07626649..b2e30f231 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -45,20 +45,20 @@ func NewRemotesController( func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.GoInto), + Keys: opts.GetKeys(opts.Config.Universal.GoInto), Handler: self.withItem(self.enter), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.ViewBranches, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.New), + Keys: opts.GetKeys(opts.Config.Universal.New), Handler: self.add, Description: self.c.Tr.NewRemote, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Remove), + Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItem(self.remove), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Remove, @@ -66,7 +66,7 @@ func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*typ DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Edit), + Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: self.withItem(self.edit), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Edit, @@ -74,7 +74,7 @@ func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*typ DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Branches.FetchRemote), + Keys: opts.GetKeys(opts.Config.Branches.FetchRemote), Handler: self.withItem(self.fetch), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Fetch, @@ -82,7 +82,7 @@ func (self *RemotesController) GetKeybindings(opts types.KeybindingsOpts) []*typ DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Branches.AddForkRemote), + Keys: opts.GetKeys(opts.Config.Branches.AddForkRemote), Handler: self.addFork, GetDisabledReason: self.hasOriginRemote(), Description: self.c.Tr.AddForkRemote, diff --git a/pkg/gui/controllers/rename_similarity_threshold_controller.go b/pkg/gui/controllers/rename_similarity_threshold_controller.go index 2d5f52bc0..78b8bb7f4 100644 --- a/pkg/gui/controllers/rename_similarity_threshold_controller.go +++ b/pkg/gui/controllers/rename_similarity_threshold_controller.go @@ -28,13 +28,13 @@ func NewRenameSimilarityThresholdController( func (self *RenameSimilarityThresholdController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.IncreaseRenameSimilarityThreshold), + Keys: opts.GetKeys(opts.Config.Universal.IncreaseRenameSimilarityThreshold), Handler: self.Increase, Description: self.c.Tr.IncreaseRenameSimilarityThreshold, Tooltip: self.c.Tr.IncreaseRenameSimilarityThresholdTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.DecreaseRenameSimilarityThreshold), + Keys: opts.GetKeys(opts.Config.Universal.DecreaseRenameSimilarityThreshold), Handler: self.Decrease, Description: self.c.Tr.DecreaseRenameSimilarityThreshold, Tooltip: self.c.Tr.DecreaseRenameSimilarityThresholdTooltip, diff --git a/pkg/gui/controllers/search_controller.go b/pkg/gui/controllers/search_controller.go index 395784d10..f1d5efe2a 100644 --- a/pkg/gui/controllers/search_controller.go +++ b/pkg/gui/controllers/search_controller.go @@ -36,7 +36,7 @@ func (self *SearchController) Context() types.Context { func (self *SearchController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.StartSearch), + Keys: opts.GetKeys(opts.Config.Universal.StartSearch), Handler: self.OpenSearchPrompt, Description: self.c.Tr.StartSearch, }, diff --git a/pkg/gui/controllers/search_prompt_controller.go b/pkg/gui/controllers/search_prompt_controller.go index b3b9918e6..1ce02abf0 100644 --- a/pkg/gui/controllers/search_prompt_controller.go +++ b/pkg/gui/controllers/search_prompt_controller.go @@ -24,19 +24,19 @@ func NewSearchPromptController( func (self *SearchPromptController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { - Key: []gocui.Key{gocui.NewKeyName(gocui.KeyEnter)}, + Keys: []gocui.Key{gocui.NewKeyName(gocui.KeyEnter)}, Handler: self.confirm, }, { - Key: opts.GetKey(opts.Config.Universal.Return), + Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: self.cancel, }, { - Key: opts.GetKey(opts.Config.Universal.PrevItem), + Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.prevHistory, }, { - Key: opts.GetKey(opts.Config.Universal.NextItem), + Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.nextHistory, }, } diff --git a/pkg/gui/controllers/side_window_controller.go b/pkg/gui/controllers/side_window_controller.go index 94706eb8f..f09a5bbdb 100644 --- a/pkg/gui/controllers/side_window_controller.go +++ b/pkg/gui/controllers/side_window_controller.go @@ -35,12 +35,12 @@ func NewSideWindowController( func (self *SideWindowController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ - {Key: opts.GetKey(opts.Config.Universal.PrevBlock), Handler: self.previousSideWindow}, - {Key: opts.GetKey(opts.Config.Universal.NextBlock), Handler: self.nextSideWindow}, - {Key: opts.GetKey(opts.Config.Universal.PrevBlockAlt), Handler: self.previousSideWindow}, - {Key: opts.GetKey(opts.Config.Universal.NextBlockAlt), Handler: self.nextSideWindow}, - {Key: opts.GetKey(opts.Config.Universal.PrevBlockAlt2), Handler: self.previousSideWindow}, - {Key: opts.GetKey(opts.Config.Universal.NextBlockAlt2), Handler: self.nextSideWindow}, + {Keys: opts.GetKeys(opts.Config.Universal.PrevBlock), Handler: self.previousSideWindow}, + {Keys: opts.GetKeys(opts.Config.Universal.NextBlock), Handler: self.nextSideWindow}, + {Keys: opts.GetKeys(opts.Config.Universal.PrevBlockAlt), Handler: self.previousSideWindow}, + {Keys: opts.GetKeys(opts.Config.Universal.NextBlockAlt), Handler: self.nextSideWindow}, + {Keys: opts.GetKeys(opts.Config.Universal.PrevBlockAlt2), Handler: self.previousSideWindow}, + {Keys: opts.GetKeys(opts.Config.Universal.NextBlockAlt2), Handler: self.nextSideWindow}, } } diff --git a/pkg/gui/controllers/snake_controller.go b/pkg/gui/controllers/snake_controller.go index a2a2030b7..b059ce787 100644 --- a/pkg/gui/controllers/snake_controller.go +++ b/pkg/gui/controllers/snake_controller.go @@ -24,23 +24,23 @@ func NewSnakeController( func (self *SnakeController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.NextItem), + Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.SetDirection(snake.Down), }, { - Key: opts.GetKey(opts.Config.Universal.PrevItem), + Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.SetDirection(snake.Up), }, { - Key: opts.GetKey(opts.Config.Universal.PrevBlock), + Keys: opts.GetKeys(opts.Config.Universal.PrevBlock), Handler: self.SetDirection(snake.Left), }, { - Key: opts.GetKey(opts.Config.Universal.NextBlock), + Keys: opts.GetKeys(opts.Config.Universal.NextBlock), Handler: self.SetDirection(snake.Right), }, { - Key: opts.GetKey(opts.Config.Universal.Return), + Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: self.Escape, }, } diff --git a/pkg/gui/controllers/staging_controller.go b/pkg/gui/controllers/staging_controller.go index 75042f46a..8d876acda 100644 --- a/pkg/gui/controllers/staging_controller.go +++ b/pkg/gui/controllers/staging_controller.go @@ -41,69 +41,69 @@ func NewStagingController( func (self *StagingController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.Select), + Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.ToggleStaged, Description: self.c.Tr.Stage, Tooltip: self.c.Tr.StageSelectionTooltip, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Remove), + Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.DiscardSelection, Description: self.c.Tr.DiscardSelection, Tooltip: self.c.Tr.DiscardSelectionTooltip, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.OpenFile), + Keys: opts.GetKeys(opts.Config.Universal.OpenFile), Handler: self.OpenFile, Description: self.c.Tr.OpenFile, Tooltip: self.c.Tr.OpenFileTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.Edit), + Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: self.EditFile, Description: self.c.Tr.EditFile, Tooltip: self.c.Tr.EditFileTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.Return), + Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: self.Escape, Description: self.c.Tr.ReturnToFilesPanel, DescriptionFunc: self.EscapeDescription, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.TogglePanel), + Keys: opts.GetKeys(opts.Config.Universal.TogglePanel), Handler: self.TogglePanel, Description: self.c.Tr.ToggleStagingView, Tooltip: self.c.Tr.ToggleStagingViewTooltip, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Main.EditSelectHunk), + Keys: opts.GetKeys(opts.Config.Main.EditSelectHunk), Handler: self.EditHunkAndRefresh, Description: self.c.Tr.EditHunk, Tooltip: self.c.Tr.EditHunkTooltip, }, { - Key: opts.GetKey(opts.Config.Files.CommitChanges), + Keys: opts.GetKeys(opts.Config.Files.CommitChanges), Handler: self.c.Helpers().WorkingTree.HandleCommitPress, Description: self.c.Tr.Commit, Tooltip: self.c.Tr.CommitTooltip, }, { - Key: opts.GetKey(opts.Config.Files.CommitChangesWithoutHook), + Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithoutHook), Handler: self.c.Helpers().WorkingTree.HandleWIPCommitPress, Description: self.c.Tr.CommitChangesWithoutHook, }, { - Key: opts.GetKey(opts.Config.Files.CommitChangesWithEditor), + Keys: opts.GetKeys(opts.Config.Files.CommitChangesWithEditor), Handler: self.c.Helpers().WorkingTree.HandleCommitEditorPress, Description: self.c.Tr.CommitChangesWithEditor, }, { - Key: opts.GetKey(opts.Config.Files.FindBaseCommitForFixup), + Keys: opts.GetKeys(opts.Config.Files.FindBaseCommitForFixup), Handler: self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress, Description: self.c.Tr.FindBaseCommitForFixup, Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip, diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 20dc2826e..49abedd9f 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -36,7 +36,7 @@ func NewStashController( func (self *StashController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.Select), + Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withItem(self.handleStashApply), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Apply, @@ -44,7 +44,7 @@ func (self *StashController) GetKeybindings(opts types.KeybindingsOpts) []*types DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Stash.PopStash), + Keys: opts.GetKeys(opts.Config.Stash.PopStash), Handler: self.withItem(self.handleStashPop), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Pop, @@ -52,7 +52,7 @@ func (self *StashController) GetKeybindings(opts types.KeybindingsOpts) []*types DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Remove), + Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItems(self.handleStashDrop), GetDisabledReason: self.require(self.itemRangeSelected()), Description: self.c.Tr.Drop, @@ -60,14 +60,14 @@ func (self *StashController) GetKeybindings(opts types.KeybindingsOpts) []*types DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.New), + Keys: opts.GetKeys(opts.Config.Universal.New), Handler: self.withItem(self.handleNewBranchOffStashEntry), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.NewBranch, Tooltip: self.c.Tr.NewBranchFromStashTooltip, }, { - Key: opts.GetKey(opts.Config.Stash.RenameStash), + Keys: opts.GetKeys(opts.Config.Stash.RenameStash), Handler: self.withItem(self.handleRenameStashEntry), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.RenameStash, diff --git a/pkg/gui/controllers/status_controller.go b/pkg/gui/controllers/status_controller.go index a3e08e384..f29ee97f0 100644 --- a/pkg/gui/controllers/status_controller.go +++ b/pkg/gui/controllers/status_controller.go @@ -34,37 +34,37 @@ func NewStatusController( func (self *StatusController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.OpenFile), + Keys: opts.GetKeys(opts.Config.Universal.OpenFile), Handler: self.openConfig, Description: self.c.Tr.OpenConfig, Tooltip: self.c.Tr.OpenFileTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.Edit), + Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: self.editConfig, Description: self.c.Tr.EditConfig, Tooltip: self.c.Tr.EditFileTooltip, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Status.CheckForUpdate), + Keys: opts.GetKeys(opts.Config.Status.CheckForUpdate), Handler: self.handleCheckForUpdate, Description: self.c.Tr.CheckForUpdate, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Status.RecentRepos), + Keys: opts.GetKeys(opts.Config.Status.RecentRepos), Handler: self.c.Helpers().Repos.CreateRecentReposMenu, Description: self.c.Tr.SwitchRepo, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Status.AllBranchesLogGraph), + Keys: opts.GetKeys(opts.Config.Status.AllBranchesLogGraph), Handler: func() error { self.switchToOrRotateAllBranchesLogs(); return nil }, Description: self.c.Tr.AllBranchesLogGraph, }, { - Key: opts.GetKey(opts.Config.Status.AllBranchesLogGraphReverse), + Keys: opts.GetKeys(opts.Config.Status.AllBranchesLogGraphReverse), Handler: func() error { self.switchToOrRotateAllBranchesLogsBackward(); return nil }, Description: self.c.Tr.AllBranchesLogGraphReverse, }, diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go index 0a4904b2e..7d6be1e82 100644 --- a/pkg/gui/controllers/submodules_controller.go +++ b/pkg/gui/controllers/submodules_controller.go @@ -39,7 +39,7 @@ func NewSubmodulesController( func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.GoInto), + Keys: opts.GetKeys(opts.Config.Universal.GoInto), Handler: self.withItem(self.enter), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Enter, @@ -48,12 +48,12 @@ func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []* DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Select), + Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withItem(self.enter), GetDisabledReason: self.require(self.singleItemSelected()), }, { - Key: opts.GetKey(opts.Config.Universal.Remove), + Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItem(self.remove), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Remove, @@ -61,7 +61,7 @@ func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []* DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Submodules.Update), + Keys: opts.GetKeys(opts.Config.Submodules.Update), Handler: self.withItem(self.update), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Update, @@ -69,26 +69,26 @@ func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []* DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.New), + Keys: opts.GetKeys(opts.Config.Universal.New), Handler: self.add, Description: self.c.Tr.NewSubmodule, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Edit), + Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: self.withItem(self.editURL), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.EditSubmoduleUrl, }, { - Key: opts.GetKey(opts.Config.Submodules.Init), + Keys: opts.GetKeys(opts.Config.Submodules.Init), Handler: self.withItem(self.init), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Initialize, Tooltip: self.c.Tr.InitSubmoduleTooltip, }, { - Key: opts.GetKey(opts.Config.Submodules.BulkMenu), + Keys: opts.GetKeys(opts.Config.Submodules.BulkMenu), Handler: self.openBulkActionsMenu, Description: self.c.Tr.ViewBulkSubmoduleOptions, OpensMenu: true, @@ -233,7 +233,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return nil }) }, - Key: menuKey('i'), + Keys: menuKey('i'), }, { LabelColumns: []string{self.c.Tr.BulkUpdateSubmodules, style.FgYellow.Sprint(self.c.Git().Submodule.BulkUpdateCmdObj().ToString())}, @@ -248,7 +248,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return nil }) }, - Key: menuKey('u'), + Keys: menuKey('u'), }, { LabelColumns: []string{self.c.Tr.BulkUpdateRecursiveSubmodules, style.FgYellow.Sprint(self.c.Git().Submodule.BulkUpdateRecursivelyCmdObj().ToString())}, @@ -263,7 +263,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return nil }) }, - Key: menuKey('r'), + Keys: menuKey('r'), }, { LabelColumns: []string{self.c.Tr.BulkDeinitSubmodules, style.FgRed.Sprint(self.c.Git().Submodule.BulkDeinitCmdObj().ToString())}, @@ -278,7 +278,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return nil }) }, - Key: menuKey('d'), + Keys: menuKey('d'), }, }, }) diff --git a/pkg/gui/controllers/suggestions_controller.go b/pkg/gui/controllers/suggestions_controller.go index e5de7619b..0553050e5 100644 --- a/pkg/gui/controllers/suggestions_controller.go +++ b/pkg/gui/controllers/suggestions_controller.go @@ -32,26 +32,26 @@ func NewSuggestionsController( func (self *SuggestionsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.ConfirmSuggestion), + Keys: opts.GetKeys(opts.Config.Universal.ConfirmSuggestion), Handler: func() error { return self.context().State.OnConfirm() }, GetDisabledReason: self.require(self.singleItemSelected()), }, { - Key: opts.GetKey(opts.Config.Universal.Return), + Keys: opts.GetKeys(opts.Config.Universal.Return), Handler: func() error { return self.context().State.OnClose() }, }, { - Key: opts.GetKey(opts.Config.Universal.TogglePanel), + Keys: opts.GetKeys(opts.Config.Universal.TogglePanel), Handler: self.switchToPrompt, }, { - Key: opts.GetKey(opts.Config.Universal.Remove), + Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: func() error { return self.context().State.OnDeleteSuggestion() }, }, { - Key: opts.GetKey(opts.Config.Universal.Edit), + Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: func() error { if self.context().State.AllowEditSuggestion { if selectedItem := self.c.Contexts().Suggestions.GetSelected(); selectedItem != nil { diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index 94c3c5712..c2ff4d674 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -40,7 +40,7 @@ func NewSwitchToDiffFilesController( func (self *SwitchToDiffFilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.GoInto), + Keys: opts.GetKeys(opts.Config.Universal.GoInto), Handler: self.enter, GetDisabledReason: self.canEnter, Description: self.c.Tr.ViewItemFiles, diff --git a/pkg/gui/controllers/switch_to_focused_main_view_controller.go b/pkg/gui/controllers/switch_to_focused_main_view_controller.go index 5161a5c83..5606a0bab 100644 --- a/pkg/gui/controllers/switch_to_focused_main_view_controller.go +++ b/pkg/gui/controllers/switch_to_focused_main_view_controller.go @@ -29,7 +29,7 @@ func NewSwitchToFocusedMainViewController( func (self *SwitchToFocusedMainViewController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.FocusMainView), + Keys: opts.GetKeys(opts.Config.Universal.FocusMainView), Handler: self.handleFocusMainView, Description: self.c.Tr.FocusMainView, Tag: "global", diff --git a/pkg/gui/controllers/switch_to_sub_commits_controller.go b/pkg/gui/controllers/switch_to_sub_commits_controller.go index 8bee8d7e6..4d723d061 100644 --- a/pkg/gui/controllers/switch_to_sub_commits_controller.go +++ b/pkg/gui/controllers/switch_to_sub_commits_controller.go @@ -47,7 +47,7 @@ func (self *SwitchToSubCommitsController) GetKeybindings(opts types.KeybindingsO { Handler: self.viewCommits, GetDisabledReason: self.require(self.singleItemSelected()), - Key: opts.GetKey(opts.Config.Universal.GoInto), + Keys: opts.GetKeys(opts.Config.Universal.GoInto), Description: self.c.Tr.ViewCommits, }, } diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index 00178b1bb..4e66e2708 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -32,14 +32,14 @@ func NewSyncController( func (self *SyncController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.Push), + Keys: opts.GetKeys(opts.Config.Universal.Push), Handler: opts.Guards.NoPopupPanel(self.HandlePush), GetDisabledReason: self.getDisabledReasonForPushOrPull, Description: self.c.Tr.Push, Tooltip: self.c.Tr.PushTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.Pull), + Keys: opts.GetKeys(opts.Config.Universal.Pull), Handler: opts.Guards.NoPopupPanel(self.HandlePull), GetDisabledReason: self.getDisabledReasonForPushOrPull, Description: self.c.Tr.Pull, diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go index 352ec05e6..a10f9a374 100644 --- a/pkg/gui/controllers/tags_controller.go +++ b/pkg/gui/controllers/tags_controller.go @@ -39,7 +39,7 @@ func NewTagsController( func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.Select), + Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withItem(self.checkout), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Checkout, @@ -47,14 +47,14 @@ func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types. DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.New), + Keys: opts.GetKeys(opts.Config.Universal.New), Handler: self.create, Description: self.c.Tr.NewTag, Tooltip: self.c.Tr.NewTagTooltip, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Remove), + Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItem(self.delete), Description: self.c.Tr.Delete, GetDisabledReason: self.require(self.singleItemSelected()), @@ -63,7 +63,7 @@ func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types. DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Branches.PushTag), + Keys: opts.GetKeys(opts.Config.Branches.PushTag), Handler: self.withItem(self.push), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.PushTag, @@ -71,7 +71,7 @@ func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types. DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Commits.ViewResetOptions), + Keys: opts.GetKeys(opts.Config.Commits.ViewResetOptions), Handler: self.withItem(self.createResetMenu), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Reset, @@ -80,7 +80,7 @@ func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types. OpensMenu: true, }, { - Key: opts.GetKey(opts.Config.Universal.OpenDiffTool), + Keys: opts.GetKeys(opts.Config.Universal.OpenDiffTool), Handler: self.withItem(func(selectedTag *models.Tag) error { return self.c.Helpers().Diff.OpenDiffToolForRef(selectedTag) }), @@ -282,14 +282,14 @@ func (self *TagsController) delete(tag *models.Tag) error { menuItems := []*types.MenuItem{ { Label: self.c.Tr.DeleteLocalTag, - Key: menuKey('c'), + Keys: menuKey('c'), OnPress: func() error { return self.localDelete(tag) }, }, { Label: self.c.Tr.DeleteRemoteTag, - Key: menuKey('r'), + Keys: menuKey('r'), OpensMenu: true, OnPress: func() error { return self.remoteDelete(tag) @@ -297,7 +297,7 @@ func (self *TagsController) delete(tag *models.Tag) error { }, { Label: self.c.Tr.DeleteLocalAndRemoteTag, - Key: menuKey('b'), + Keys: menuKey('b'), OpensMenu: true, OnPress: func() error { return self.localAndRemoteDelete(tag) diff --git a/pkg/gui/controllers/undo_controller.go b/pkg/gui/controllers/undo_controller.go index 02e27ddce..775e871a4 100644 --- a/pkg/gui/controllers/undo_controller.go +++ b/pkg/gui/controllers/undo_controller.go @@ -53,13 +53,13 @@ type reflogAction struct { func (self *UndoController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.Undo), + Keys: opts.GetKeys(opts.Config.Universal.Undo), Handler: self.reflogUndo, Description: self.c.Tr.UndoReflog, Tooltip: self.c.Tr.UndoTooltip, }, { - Key: opts.GetKey(opts.Config.Universal.Redo), + Keys: opts.GetKeys(opts.Config.Universal.Redo), Handler: self.reflogRedo, Description: self.c.Tr.RedoReflog, Tooltip: self.c.Tr.RedoTooltip, diff --git a/pkg/gui/controllers/view_selection_controller.go b/pkg/gui/controllers/view_selection_controller.go index 0d69c5109..710fd37cb 100644 --- a/pkg/gui/controllers/view_selection_controller.go +++ b/pkg/gui/controllers/view_selection_controller.go @@ -36,16 +36,16 @@ func (self *ViewSelectionController) Context() types.Context { func (self *ViewSelectionController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), Handler: self.handlePrevLine}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItem), Handler: self.handleNextLine}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextItemAlt), Handler: self.handleNextLine}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop, Alternative: ""}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom, Alternative: ""}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoTopAlt), Handler: self.handleGotoTop}, - {Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottomAlt), Handler: self.handleGotoBottom}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItemAlt), Handler: self.handlePrevLine}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItemAlt), Handler: self.handleNextLine}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop, Alternative: ""}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom, Alternative: ""}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTopAlt), Handler: self.handleGotoTop}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottomAlt), Handler: self.handleGotoBottom}, } } diff --git a/pkg/gui/controllers/workspace_reset_controller.go b/pkg/gui/controllers/workspace_reset_controller.go index c6b68fb96..9a9005254 100644 --- a/pkg/gui/controllers/workspace_reset_controller.go +++ b/pkg/gui/controllers/workspace_reset_controller.go @@ -53,7 +53,7 @@ func (self *FilesController) createResetMenu() error { }) return nil }, - Key: menuKey('x'), + Keys: menuKey('x'), Tooltip: self.c.Tr.NukeDescription, }, { @@ -72,7 +72,7 @@ func (self *FilesController) createResetMenu() error { ) return nil }, - Key: menuKey('u'), + Keys: menuKey('u'), }, { LabelColumns: []string{ @@ -90,7 +90,7 @@ func (self *FilesController) createResetMenu() error { ) return nil }, - Key: menuKey('c'), + Keys: menuKey('c'), }, { LabelColumns: []string{ @@ -115,7 +115,7 @@ func (self *FilesController) createResetMenu() error { ) return nil }, - Key: menuKey('S'), + Keys: menuKey('S'), }, { LabelColumns: []string{ @@ -133,7 +133,7 @@ func (self *FilesController) createResetMenu() error { ) return nil }, - Key: menuKey('s'), + Keys: menuKey('s'), }, { LabelColumns: []string{ @@ -151,7 +151,7 @@ func (self *FilesController) createResetMenu() error { ) return nil }, - Key: menuKey('m'), + Keys: menuKey('m'), }, { LabelColumns: []string{ @@ -176,7 +176,7 @@ func (self *FilesController) createResetMenu() error { }, }) }, - Key: menuKey('h'), + Keys: menuKey('h'), }, } diff --git a/pkg/gui/controllers/worktree_options_controller.go b/pkg/gui/controllers/worktree_options_controller.go index 0cdf4d008..b1123e2a8 100644 --- a/pkg/gui/controllers/worktree_options_controller.go +++ b/pkg/gui/controllers/worktree_options_controller.go @@ -36,7 +36,7 @@ func NewWorktreeOptionsController(c *ControllerCommon, context CanViewWorktreeOp func (self *WorktreeOptionsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Worktrees.ViewWorktreeOptions), + Keys: opts.GetKeys(opts.Config.Worktrees.ViewWorktreeOptions), Handler: self.withItem(self.viewWorktreeOptions), Description: self.c.Tr.ViewWorktreeOptions, OpensMenu: true, diff --git a/pkg/gui/controllers/worktrees_controller.go b/pkg/gui/controllers/worktrees_controller.go index 0e34ec59f..5128ad716 100644 --- a/pkg/gui/controllers/worktrees_controller.go +++ b/pkg/gui/controllers/worktrees_controller.go @@ -39,13 +39,13 @@ func NewWorktreesController( func (self *WorktreesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ { - Key: opts.GetKey(opts.Config.Universal.New), + Keys: opts.GetKeys(opts.Config.Universal.New), Handler: self.add, Description: self.c.Tr.NewWorktree, DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.Select), + Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withItem(self.enter), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Switch, @@ -53,18 +53,18 @@ func (self *WorktreesController) GetKeybindings(opts types.KeybindingsOpts) []*t DisplayOnScreen: true, }, { - Key: opts.GetKey(opts.Config.Universal.GoInto), + Keys: opts.GetKeys(opts.Config.Universal.GoInto), Handler: self.withItem(self.enter), GetDisabledReason: self.require(self.singleItemSelected()), }, { - Key: opts.GetKey(opts.Config.Universal.OpenFile), + Keys: opts.GetKeys(opts.Config.Universal.OpenFile), Handler: self.withItem(self.open), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenInEditor, }, { - Key: opts.GetKey(opts.Config.Universal.Remove), + Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItem(self.remove), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Remove, diff --git a/pkg/gui/extras_panel.go b/pkg/gui/extras_panel.go index 468d8f290..980c31d45 100644 --- a/pkg/gui/extras_panel.go +++ b/pkg/gui/extras_panel.go @@ -15,7 +15,7 @@ func (gui *Gui) handleCreateExtrasMenuPanel() error { Items: []*types.MenuItem{ { Label: gui.c.Tr.ToggleShowCommandLog, - Key: []gocui.Key{gocui.NewKeyRune('t')}, + Keys: []gocui.Key{gocui.NewKeyRune('t')}, OnPress: func() error { currentContext := gui.c.Context().CurrentStatic() if gui.c.State().GetShowExtrasWindow() && currentContext.GetKey() == context.COMMAND_LOG_CONTEXT_KEY { @@ -30,7 +30,7 @@ func (gui *Gui) handleCreateExtrasMenuPanel() error { }, { Label: gui.c.Tr.FocusCommandLog, - Key: []gocui.Key{gocui.NewKeyRune('f')}, + Keys: []gocui.Key{gocui.NewKeyRune('f')}, OnPress: gui.handleFocusCommandLog, }, }, diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 038ae5d49..7bab622b7 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -69,9 +69,9 @@ func (gui *Gui) keybindingOpts() types.KeybindingsOpts { } return types.KeybindingsOpts{ - GetKey: config.GetValidatedKeyBindingKeys, - Config: keybindingConfig, - Guards: guards, + GetKeys: config.GetValidatedKeyBindingKeys, + Config: keybindingConfig, + Guards: guards, } } @@ -81,114 +81,114 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin bindings := []*types.Binding{ { ViewName: "", - Key: opts.GetKey(opts.Config.Universal.OpenRecentRepos), + Keys: opts.GetKeys(opts.Config.Universal.OpenRecentRepos), Handler: opts.Guards.NoPopupPanel(gui.helpers.Repos.CreateRecentReposMenu), Description: gui.c.Tr.SwitchRepo, }, { ViewName: "", - Key: opts.GetKey(opts.Config.Universal.ScrollUpMain), + Keys: opts.GetKeys(opts.Config.Universal.ScrollUpMain), Handler: gui.scrollUpMain, Alternative: "fn+up/shift+k", Description: gui.c.Tr.ScrollUpMainWindow, }, { ViewName: "", - Key: opts.GetKey(opts.Config.Universal.ScrollDownMain), + Keys: opts.GetKeys(opts.Config.Universal.ScrollDownMain), Handler: gui.scrollDownMain, Alternative: "fn+down/shift+j", Description: gui.c.Tr.ScrollDownMainWindow, }, { ViewName: "", - Key: opts.GetKey(opts.Config.Universal.ScrollUpMainAlt1), + Keys: opts.GetKeys(opts.Config.Universal.ScrollUpMainAlt1), Handler: gui.scrollUpMain, }, { ViewName: "", - Key: opts.GetKey(opts.Config.Universal.ScrollDownMainAlt1), + Keys: opts.GetKeys(opts.Config.Universal.ScrollDownMainAlt1), Handler: gui.scrollDownMain, }, { ViewName: "", - Key: opts.GetKey(opts.Config.Universal.ScrollUpMainAlt2), + Keys: opts.GetKeys(opts.Config.Universal.ScrollUpMainAlt2), Handler: gui.scrollUpMain, }, { ViewName: "", - Key: opts.GetKey(opts.Config.Universal.ScrollDownMainAlt2), + Keys: opts.GetKeys(opts.Config.Universal.ScrollDownMainAlt2), Handler: gui.scrollDownMain, }, { ViewName: "files", - Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), + Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), Handler: gui.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: gui.getCopySelectedSideContextItemToClipboardDisabledReason, Description: gui.c.Tr.CopyPathToClipboard, }, { ViewName: "localBranches", - Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), + Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), Handler: gui.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: gui.getCopySelectedSideContextItemToClipboardDisabledReason, Description: gui.c.Tr.CopyBranchNameToClipboard, }, { ViewName: "remoteBranches", - Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), + Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), Handler: gui.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: gui.getCopySelectedSideContextItemToClipboardDisabledReason, Description: gui.c.Tr.CopyBranchNameToClipboard, }, { ViewName: "tags", - Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), + Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), Handler: gui.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: gui.getCopySelectedSideContextItemToClipboardDisabledReason, Description: gui.c.Tr.CopyTagToClipboard, }, { ViewName: "commits", - Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), + Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), Handler: gui.handleCopySelectedSideContextItemCommitHashToClipboard, GetDisabledReason: gui.getCopySelectedSideContextItemToClipboardDisabledReason, Description: gui.c.Tr.CopyCommitHashToClipboard, }, { ViewName: "commits", - Key: opts.GetKey(opts.Config.Commits.ResetCherryPick), + Keys: opts.GetKeys(opts.Config.Commits.ResetCherryPick), Handler: gui.helpers.CherryPick.Reset, Description: gui.c.Tr.ResetCherryPick, }, { ViewName: "reflogCommits", - Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), + Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), Handler: gui.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: gui.getCopySelectedSideContextItemToClipboardDisabledReason, Description: gui.c.Tr.CopyCommitHashToClipboard, }, { ViewName: "subCommits", - Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), + Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), Handler: gui.handleCopySelectedSideContextItemCommitHashToClipboard, GetDisabledReason: gui.getCopySelectedSideContextItemToClipboardDisabledReason, Description: gui.c.Tr.CopyCommitHashToClipboard, }, { ViewName: "information", - Key: []gocui.Key{gocui.NewKeyName(gocui.MouseLeft)}, + Keys: []gocui.Key{gocui.NewKeyName(gocui.MouseLeft)}, Handler: gui.handleInfoClick, }, { ViewName: "commitFiles", - Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), + Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), Handler: gui.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: gui.getCopySelectedSideContextItemToClipboardDisabledReason, Description: gui.c.Tr.CopyPathToClipboard, }, { ViewName: "", - Key: opts.GetKey(opts.Config.Universal.ExtrasMenu), + Keys: opts.GetKeys(opts.Config.Universal.ExtrasMenu), Handler: opts.Guards.NoPopupPanel(gui.handleCreateExtrasMenuPanel), Description: gui.c.Tr.OpenCommandLogMenu, Tooltip: gui.c.Tr.OpenCommandLogMenuTooltip, @@ -196,163 +196,163 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin }, { ViewName: "main", - Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelDown)}, + Keys: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelDown)}, Handler: gui.scrollDownMain, Description: gui.c.Tr.ScrollDown, Alternative: "fn+up", }, { ViewName: "main", - Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelUp)}, + Keys: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelUp)}, Handler: gui.scrollUpMain, Description: gui.c.Tr.ScrollUp, Alternative: "fn+down", }, { ViewName: "secondary", - Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelDown)}, + Keys: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelDown)}, Handler: gui.scrollDownSecondary, }, { ViewName: "secondary", - Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelUp)}, + Keys: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelUp)}, Handler: gui.scrollUpSecondary, }, { ViewName: "confirmation", - Key: opts.GetKey(opts.Config.Universal.PrevItem), + Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: gui.scrollUpConfirmationPanel, }, { ViewName: "confirmation", - Key: opts.GetKey(opts.Config.Universal.NextItem), + Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: gui.scrollDownConfirmationPanel, }, { ViewName: "confirmation", - Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), + Keys: opts.GetKeys(opts.Config.Universal.PrevItemAlt), Handler: gui.scrollUpConfirmationPanel, }, { ViewName: "confirmation", - Key: opts.GetKey(opts.Config.Universal.NextItemAlt), + Keys: opts.GetKeys(opts.Config.Universal.NextItemAlt), Handler: gui.scrollDownConfirmationPanel, }, { ViewName: "confirmation", - Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelUp)}, + Keys: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelUp)}, Handler: gui.scrollUpConfirmationPanel, }, { ViewName: "confirmation", - Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelDown)}, + Keys: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelDown)}, Handler: gui.scrollDownConfirmationPanel, }, { ViewName: "confirmation", - Key: opts.GetKey(opts.Config.Universal.NextPage), + Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: gui.pageDownConfirmationPanel, }, { ViewName: "confirmation", - Key: opts.GetKey(opts.Config.Universal.PrevPage), + Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: gui.pageUpConfirmationPanel, }, { ViewName: "confirmation", - Key: opts.GetKey(opts.Config.Universal.GotoTop), + Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: gui.goToConfirmationPanelTop, }, { ViewName: "confirmation", - Key: opts.GetKey(opts.Config.Universal.GotoTopAlt), + Keys: opts.GetKeys(opts.Config.Universal.GotoTopAlt), Handler: gui.goToConfirmationPanelTop, }, { ViewName: "confirmation", - Key: opts.GetKey(opts.Config.Universal.GotoBottom), + Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: gui.goToConfirmationPanelBottom, }, { ViewName: "confirmation", - Key: opts.GetKey(opts.Config.Universal.GotoBottomAlt), + Keys: opts.GetKeys(opts.Config.Universal.GotoBottomAlt), Handler: gui.goToConfirmationPanelBottom, }, { ViewName: "submodules", - Key: opts.GetKey(opts.Config.Universal.CopyToClipboard), + Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), Handler: gui.handleCopySelectedSideContextItemToClipboard, GetDisabledReason: gui.getCopySelectedSideContextItemToClipboardDisabledReason, Description: gui.c.Tr.CopySubmoduleNameToClipboard, }, { ViewName: "extras", - Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelUp)}, + Keys: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelUp)}, Handler: gui.scrollUpExtra, }, { ViewName: "extras", - Key: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelDown)}, + Keys: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelDown)}, Handler: gui.scrollDownExtra, }, { ViewName: "extras", Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.PrevItemAlt), + Keys: opts.GetKeys(opts.Config.Universal.PrevItemAlt), Handler: gui.scrollUpExtra, }, { ViewName: "extras", Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.PrevItem), + Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: gui.scrollUpExtra, }, { ViewName: "extras", Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.NextItem), + Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: gui.scrollDownExtra, }, { ViewName: "extras", Tag: "navigation", - Key: opts.GetKey(opts.Config.Universal.NextItemAlt), + Keys: opts.GetKeys(opts.Config.Universal.NextItemAlt), Handler: gui.scrollDownExtra, }, { ViewName: "extras", - Key: opts.GetKey(opts.Config.Universal.NextPage), + Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: gui.pageDownExtrasPanel, }, { ViewName: "extras", - Key: opts.GetKey(opts.Config.Universal.PrevPage), + Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: gui.pageUpExtrasPanel, }, { ViewName: "extras", - Key: opts.GetKey(opts.Config.Universal.GotoTop), + Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: gui.goToExtrasPanelTop, }, { ViewName: "extras", - Key: opts.GetKey(opts.Config.Universal.GotoTopAlt), + Keys: opts.GetKeys(opts.Config.Universal.GotoTopAlt), Handler: gui.goToExtrasPanelTop, }, { ViewName: "extras", - Key: opts.GetKey(opts.Config.Universal.GotoBottom), + Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: gui.goToExtrasPanelBottom, }, { ViewName: "extras", - Key: opts.GetKey(opts.Config.Universal.GotoBottomAlt), + Keys: opts.GetKeys(opts.Config.Universal.GotoBottomAlt), Handler: gui.goToExtrasPanelBottom, }, { ViewName: "extras", Tag: "navigation", - Key: []gocui.Key{gocui.NewKeyName(gocui.MouseLeft)}, + Keys: []gocui.Key{gocui.NewKeyName(gocui.MouseLeft)}, Handler: gui.handleFocusCommandLog, }, } @@ -372,14 +372,14 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin bindings = append(bindings, []*types.Binding{ { ViewName: "", - Key: opts.GetKey(opts.Config.Universal.NextTab), + Keys: opts.GetKeys(opts.Config.Universal.NextTab), Handler: opts.Guards.NoPopupPanel(gui.handleNextTab), Description: gui.c.Tr.NextTab, Tag: "navigation", }, { ViewName: "", - Key: opts.GetKey(opts.Config.Universal.PrevTab), + Keys: opts.GetKeys(opts.Config.Universal.PrevTab), Handler: opts.Guards.NoPopupPanel(gui.handlePrevTab), Description: gui.c.Tr.PrevTab, Tag: "navigation", @@ -448,7 +448,7 @@ func (gui *Gui) SetKeybinding(binding *types.Binding) { return gui.callKeybindingHandler(binding) } - for _, key := range binding.Key { + for _, key := range binding.Keys { gui.g.SetKeybinding(binding.ViewName, key, handler) } } diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index 20079700c..e43ac1d29 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -47,7 +47,7 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error { // Remove all item keybindings that are the same as one of the essential bindings if !opts.KeepConflictingKeybindings { - item.Key = lo.Filter(item.Key, func(k gocui.Key, _ int) bool { + item.Keys = lo.Filter(item.Keys, func(k gocui.Key, _ int) bool { return !lo.Contains(essentialKeys, k) }) } diff --git a/pkg/gui/options_map.go b/pkg/gui/options_map.go index 2771a6c25..c0d0fcfff 100644 --- a/pkg/gui/options_map.go +++ b/pkg/gui/options_map.go @@ -42,11 +42,11 @@ func (self *OptionsMapMgr) renderContextOptionsMap() { currentContextKeys := set.NewFromSlice( lo.FlatMap(currentContextBindings, func(binding *types.Binding, _ int) []gocui.Key { - return binding.Key + return binding.Keys })) allBindings := append(currentContextBindings, lo.Filter(globalBindings, func(b *types.Binding, _ int) bool { - return len(b.Key) == 0 || !currentContextKeys.Includes(b.Key[0]) + return len(b.Keys) == 0 || !currentContextKeys.Includes(b.Keys[0]) })...) bindingsToDisplay := lo.Filter(allBindings, func(binding *types.Binding, _ int) bool { @@ -60,7 +60,7 @@ func (self *OptionsMapMgr) renderContextOptionsMap() { } return bindingInfo{ - key: config.LabelForKey(binding.Key[0]), + key: config.LabelForKey(binding.Keys[0]), description: binding.GetShortDescription(), style: displayStyle, } diff --git a/pkg/gui/services/custom_commands/client.go b/pkg/gui/services/custom_commands/client.go index 6d30ab310..0884e0cce 100644 --- a/pkg/gui/services/custom_commands/client.go +++ b/pkg/gui/services/custom_commands/client.go @@ -46,7 +46,7 @@ func (self *Client) GetCustomCommandKeybindings() ([]*types.Binding, error) { } bindings = append(bindings, &types.Binding{ ViewName: "", // custom commands menus are global; we filter the commands inside by context - Key: []gocui.Key{config.GetValidatedKeyBindingKey(customCommand.Key)}, + Keys: []gocui.Key{config.GetValidatedKeyBindingKey(customCommand.Key)}, Handler: handler, Description: getCustomCommandsMenuDescription(customCommand, self.c.Tr), OpensMenu: true, @@ -73,7 +73,7 @@ func (self *Client) showCustomCommandsMenu(customCommand config.CustomCommand) e } menuItems = append(menuItems, &types.MenuItem{ Label: subCommand.GetDescription(), - Key: []gocui.Key{config.GetValidatedKeyBindingKey(subCommand.Key)}, + Keys: []gocui.Key{config.GetValidatedKeyBindingKey(subCommand.Key)}, OnPress: handler, OpensMenu: true, }) @@ -93,7 +93,7 @@ func (self *Client) showCustomCommandsMenu(customCommand config.CustomCommand) e menuItems = append(menuItems, &types.MenuItem{ Label: subCommand.GetDescription(), - Key: []gocui.Key{config.GetValidatedKeyBindingKey(subCommand.Key)}, + Keys: []gocui.Key{config.GetValidatedKeyBindingKey(subCommand.Key)}, OnPress: self.handlerCreator.call(subCommand), }) } diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index 60a4555d8..5f3fd27a0 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -232,7 +232,7 @@ func (self *HandlerCreator) menuPrompt(prompt *config.CustomCommandPrompt, wrapp OnPress: func() error { return wrappedF(option.Value) }, - Key: []gocui.Key{config.GetValidatedKeyBindingKey(option.Key)}, + Keys: []gocui.Key{config.GetValidatedKeyBindingKey(option.Key)}, } }) diff --git a/pkg/gui/services/custom_commands/keybinding_creator.go b/pkg/gui/services/custom_commands/keybinding_creator.go index 52456323a..ee0e01fa4 100644 --- a/pkg/gui/services/custom_commands/keybinding_creator.go +++ b/pkg/gui/services/custom_commands/keybinding_creator.go @@ -36,7 +36,7 @@ func (self *KeybindingCreator) call(customCommand config.CustomCommand, handler return lo.Map(viewNames, func(viewName string, _ int) *types.Binding { return &types.Binding{ ViewName: viewName, - Key: []gocui.Key{config.GetValidatedKeyBindingKey(customCommand.Key)}, + Keys: []gocui.Key{config.GetValidatedKeyBindingKey(customCommand.Key)}, Handler: handler, Description: customCommand.GetDescription(), } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 475cafeab..92f004634 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -259,10 +259,10 @@ type MenuItem struct { // Only applies when Label is used OpensMenu bool - // If Key is non-empty, the user can press any of these keys to invoke the + // If Keys is non-empty, the user can press any of these keys to invoke the // menu item, as opposed to having to navigate to it. Only the first key is // shown in the menu; the alternates are matched silently. - Key []gocui.Key + Keys []gocui.Key // A widget to show in front of the menu item. Supported widget types are // checkboxes and radio buttons, diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 69d76ea78..dcf4b9a1a 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -239,9 +239,9 @@ type OnFocusLostOpts struct { type ContextKey string type KeybindingsOpts struct { - GetKey func(key string) []gocui.Key - Config config.KeybindingConfig - Guards KeybindingGuards + GetKeys func(key string) []gocui.Key + Config config.KeybindingConfig + Guards KeybindingGuards } type ( diff --git a/pkg/gui/types/keybindings.go b/pkg/gui/types/keybindings.go index 3b3e512d8..337162d76 100644 --- a/pkg/gui/types/keybindings.go +++ b/pkg/gui/types/keybindings.go @@ -11,7 +11,7 @@ import ( type Binding struct { ViewName string Handler func() error - Key []gocui.Key + Keys []gocui.Key Description string // DescriptionFunc is used instead of Description if non-nil, and is useful for dynamic // descriptions that change depending on context. Important: this must not be an expensive call. From f08a49fe52556d15c35b43ca1407ff8f8f237b06 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 9 May 2026 20:22:43 +0200 Subject: [PATCH 017/384] Render every key for a binding in the cheatsheet The cheatsheet has been showing only the first key of each binding since Binding.Key became Binding.Keys; collapse the list back into a single comma-separated cell so users can see all the alternates at a glance once bindings start carrying more than one key. --- pkg/cheatsheet/generate.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg/cheatsheet/generate.go b/pkg/cheatsheet/generate.go index bd2eff624..a9cee6494 100644 --- a/pkg/cheatsheet/generate.go +++ b/pkg/cheatsheet/generate.go @@ -22,6 +22,7 @@ import ( "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/app" "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/samber/lo" @@ -156,7 +157,7 @@ func getBindingSections(bindings []*types.Binding, tr *i18n.TranslationSet) []*b bindingsByHeader, func(header header, hBindings []*types.Binding) headerWithBindings { uniqBindings := lo.UniqBy(hBindings, func(binding *types.Binding) string { - return binding.Description + config.LabelForKey(binding.Keys[0]) + return binding.Description + keyLabels(binding.Keys) }) return headerWithBindings{ @@ -213,8 +214,14 @@ func formatTitle(title string) string { return fmt.Sprintf("\n## %s\n\n", title) } +func keyLabels(keys []gocui.Key) string { + return strings.Join(lo.Map(keys, func(k gocui.Key, _ int) string { + return config.LabelForKey(k) + }), ", ") +} + func formatBinding(binding *types.Binding) string { - action := config.LabelForKey(binding.Keys[0]) + action := keyLabels(binding.Keys) description := binding.Description if binding.Alternative != "" { action += fmt.Sprintf(" (%s)", binding.Alternative) From 06b8d5a1e469456d4be91c7864fef52ebab29d00 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 4 May 2026 07:21:42 +0200 Subject: [PATCH 018/384] Add Keybinding type that accepts a string or a sequence of strings Each user-configurable keybinding is currently a single string in the YAML config. To let users assign alternate keys to a command, introduce a Keybinding type that decodes from either a scalar (the existing single-key form, kept for backward compatibility and for a simpler config file) or a sequence of strings. Marshalling collapses single-element slices back to a scalar so configs and generated docs round-trip cleanly. JSONSchema describes the type as a oneOf union so editors validate either form; subsequent commits will inline the union into the generated schema and start using Keybinding as the field type. --- pkg/config/keybinding.go | 79 ++++++++++++++++++ pkg/config/keybinding_test.go | 151 ++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 pkg/config/keybinding.go create mode 100644 pkg/config/keybinding_test.go diff --git a/pkg/config/keybinding.go b/pkg/config/keybinding.go new file mode 100644 index 000000000..bf605d44f --- /dev/null +++ b/pkg/config/keybinding.go @@ -0,0 +1,79 @@ +package config + +import ( + "encoding/json" + "fmt" + + "github.com/karimkhaleel/jsonschema" + "github.com/samber/lo" + "gopkg.in/yaml.v3" +) + +// Keybinding represents the value of a single keybinding entry in the user's +// config. It's a slice of key strings to allow alternates, but for backward +// compatibility (and because most bindings only have one key) it can be +// written in YAML/JSON as either a single scalar string or as a sequence of +// strings. +type Keybinding []string + +func (k *Keybinding) UnmarshalYAML(node *yaml.Node) error { + var ss []string + switch node.Kind { + case yaml.ScalarNode: + var s string + if err := node.Decode(&s); err != nil { + return err + } + ss = []string{s} + case yaml.SequenceNode: + if err := node.Decode(&ss); err != nil { + return err + } + default: + return fmt.Errorf("expected a string or a sequence of strings for keybinding, got %v", node.Tag) + } + // Drop empty and entries so clients never have to special-case + // them: an empty Keybinding means "no key bound", a non-empty one is + // guaranteed to contain only real keys. + *k = lo.Filter(ss, func(s string, _ int) bool { + return s != "" && s != "" + }) + return nil +} + +func (k Keybinding) MarshalYAML() (any, error) { + if len(k) == 1 { + return k[0], nil + } + // Render multi-key bindings in flow style (`[a, b]`) rather than the default + // block style, which is more compact and reads better in the generated docs. + node := &yaml.Node{ + Kind: yaml.SequenceNode, + Style: yaml.FlowStyle, + } + for _, s := range k { + node.Content = append(node.Content, &yaml.Node{ + Kind: yaml.ScalarNode, + Value: s, + }) + } + return node, nil +} + +func (k Keybinding) MarshalJSON() ([]byte, error) { + if len(k) == 1 { + return json.Marshal(k[0]) + } + return json.Marshal([]string(k)) +} + +// JSONSchema lets the schema generator describe this type as a union of a +// string and an array of strings instead of just an array. +func (Keybinding) JSONSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + OneOf: []*jsonschema.Schema{ + {Type: "string"}, + {Type: "array", Items: &jsonschema.Schema{Type: "string"}}, + }, + } +} diff --git a/pkg/config/keybinding_test.go b/pkg/config/keybinding_test.go new file mode 100644 index 000000000..d3406412c --- /dev/null +++ b/pkg/config/keybinding_test.go @@ -0,0 +1,151 @@ +package config + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +func TestKeybindingUnmarshalYAML(t *testing.T) { + scenarios := []struct { + name string + input string + expected Keybinding + wantErr bool + }{ + { + name: "scalar string", + input: `q`, + expected: Keybinding{"q"}, + }, + { + name: "scalar with special characters", + input: ``, + expected: Keybinding{""}, + }, + { + name: "sequence with one element", + input: `[q]`, + expected: Keybinding{"q"}, + }, + { + name: "sequence with multiple elements", + input: `["q", ""]`, + expected: Keybinding{"q", ""}, + }, + { + name: "empty sequence", + input: `[]`, + expected: Keybinding{}, + }, + { + name: "scalar decodes to empty", + input: ``, + expected: Keybinding{}, + }, + { + name: "scalar empty string decodes to empty", + input: `""`, + expected: Keybinding{}, + }, + { + name: " entries are filtered out of a sequence", + input: `["q", "", ""]`, + expected: Keybinding{"q", ""}, + }, + { + name: "mapping is rejected", + input: `{key: q}`, + wantErr: true, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + var k Keybinding + err := yaml.Unmarshal([]byte(s.input), &k) + if s.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, s.expected, k) + }) + } +} + +func TestKeybindingMarshalYAML(t *testing.T) { + scenarios := []struct { + name string + input Keybinding + expected string + }{ + { + name: "single key emits a scalar", + input: Keybinding{"q"}, + expected: "q\n", + }, + { + name: "multiple keys emit a flow sequence", + input: Keybinding{"q", ""}, + expected: "[q, ]\n", + }, + { + name: "empty keybinding emits an empty sequence", + input: Keybinding{}, + expected: "[]\n", + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + out, err := yaml.Marshal(s.input) + assert.NoError(t, err) + assert.Equal(t, s.expected, string(out)) + }) + } +} + +func TestKeybindingMarshalJSON(t *testing.T) { + scenarios := []struct { + name string + input Keybinding + expected string + }{ + { + name: "single key emits a string", + input: Keybinding{"q"}, + expected: `"q"`, + }, + { + name: "multiple keys emit an array", + input: Keybinding{"q", "esc"}, + expected: `["q","esc"]`, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + out, err := json.Marshal(s.input) + assert.NoError(t, err) + assert.Equal(t, s.expected, string(out)) + }) + } +} + +func TestKeybindingYAMLRoundTrip(t *testing.T) { + scenarios := []Keybinding{ + {"q"}, + {"q", ""}, + {"", "", ""}, + } + for _, original := range scenarios { + out, err := yaml.Marshal(original) + assert.NoError(t, err) + var decoded Keybinding + assert.NoError(t, yaml.Unmarshal(out, &decoded)) + assert.Equal(t, original, decoded) + } +} From 5748d82073a126c2b8b5c13705e3f41b0e1527c7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 10 May 2026 08:22:44 +0200 Subject: [PATCH 019/384] Convert keybinding fields to Keybinding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now every keybinding config field was a plain string. That meant a user couldn't ask for two keys to invoke a command — the config silently accepted only one form. Convert every string-typed field across all 13 KeybindingXxxConfig structs to Keybinding so the union type extends to every command. Defaults wrap their single-key value in Keybinding{...} so the generated Config.md still renders one scalar key per binding. The alt fields keep their separate Binding registrations for now: this commit does not yet introduce the merge mechanism that folds them into the main field — that comes in a follow-up. Consumers previously calling opts.GetKeys on a string field now call opts.GetKeys on the Keybinding, or take .String() / Keys[0] where a single value is needed. Adds a Keybinding.String helper for rendering, schema-generator work that inlines the Keybinding union into each consuming property, and a unit test covering the user-facing scalar/sequence YAML forms for quit. --- docs-master/Config.md | 5 +- docs-master/Custom_Command_Keybindings.md | 4 +- docs-master/keybindings/Custom_Keybindings.md | 2 + pkg/config/keybinding.go | 7 + pkg/config/keybinding_test.go | 31 + pkg/config/keynames.go | 11 +- pkg/config/user_config.go | 653 +++--- pkg/config/user_config_validation_test.go | 2 +- pkg/gocui/edit.go | 18 +- pkg/gocui/gui.go | 20 +- pkg/gui/context/commit_message_context.go | 4 +- .../controllers/basic_commits_controller.go | 4 +- .../commit_description_controller.go | 19 +- .../controllers/commit_message_controller.go | 5 +- pkg/gui/controllers/helpers/tags_helper.go | 4 +- .../jump_to_side_window_controller.go | 3 +- .../controllers/local_commits_controller.go | 6 +- pkg/gui/controllers/submodules_controller.go | 2 +- pkg/gui/controllers/sync_controller.go | 4 +- pkg/gui/gui.go | 16 +- pkg/gui/menu_panel.go | 8 +- pkg/gui/options_map.go | 8 +- pkg/gui/types/context.go | 2 +- pkg/gui/views.go | 2 +- .../commit_description_panel_driver.go | 2 +- .../components/commit_message_panel_driver.go | 2 +- pkg/integration/components/prompt_driver.go | 12 +- pkg/integration/components/test_driver.go | 4 +- pkg/integration/components/view_driver.go | 13 +- pkg/integration/tests/commit/search.go | 14 +- .../custom_commands_in_per_repo_config.go | 6 +- .../access_commit_properties.go | 2 +- .../tests/custom_commands/basic_command.go | 2 +- .../custom_commands/check_for_conflicts.go | 2 +- .../conditional_prompt_false_string.go | 2 +- .../conditional_prompt_false_value.go | 2 +- .../custom_commands/conditional_prompts.go | 8 +- .../custom_commands_submenu.go | 6 +- ...mmands_submenu_with_special_keybindings.go | 10 +- .../tests/custom_commands/form_prompts.go | 2 +- .../tests/custom_commands/global_context.go | 6 +- .../custom_commands/menu_from_command.go | 2 +- .../menu_from_commands_output.go | 2 +- .../custom_commands/menu_prompt_with_keys.go | 4 +- .../custom_commands/multiple_contexts.go | 6 +- .../tests/custom_commands/multiple_prompts.go | 2 +- .../tests/custom_commands/run_command.go | 2 +- .../tests/custom_commands/selected_commit.go | 14 +- .../custom_commands/selected_commit_range.go | 4 +- .../tests/custom_commands/selected_path.go | 4 +- .../custom_commands/selected_submodule.go | 6 +- .../custom_commands/show_output_in_panel.go | 4 +- .../custom_commands/suggestions_command.go | 2 +- .../custom_commands/suggestions_preset.go | 2 +- pkg/integration/tests/demo/custom_command.go | 2 +- .../filter_menu_with_no_keybindings.go | 4 +- .../interactive_rebase/show_exec_todos.go | 2 +- .../tests/misc/disabled_keybindings.go | 26 - pkg/integration/tests/submodule/enter.go | 2 +- pkg/integration/tests/submodule/reset.go | 2 +- pkg/integration/tests/test_list.go | 1 - ...disable_switch_tab_with_panel_jump_keys.go | 4 +- .../ui/switch_tab_with_panel_jump_keys.go | 10 +- .../tests/worktree/custom_command.go | 2 +- pkg/jsonschema/generate.go | 52 + schema-master/config.json | 1948 +++++++++++++++-- 66 files changed, 2370 insertions(+), 674 deletions(-) delete mode 100644 pkg/integration/tests/misc/disabled_keybindings.go diff --git a/docs-master/Config.md b/docs-master/Config.md index b24329548..4e9a4617a 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -591,7 +591,10 @@ notARepository: prompt # view the output of the subprocess before returning to Lazygit. promptToReturnFromSubprocess: true -# Keybindings +# Keybindings. +# Each binding can be a single key or a list of keys; see +# https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md +# for the syntax. keybinding: universal: quit: q diff --git a/docs-master/Custom_Command_Keybindings.md b/docs-master/Custom_Command_Keybindings.md index c8036ea41..55e14d5f1 100644 --- a/docs-master/Custom_Command_Keybindings.md +++ b/docs-master/Custom_Command_Keybindings.md @@ -50,7 +50,7 @@ Custom command keybindings will appear alongside inbuilt keybindings when you vi For a given custom command, here are the allowed fields: | _field_ | _description_ | required | |-----------------|----------------------|-| -| key | The key to trigger the command. Use a single letter or one of the values from [here](https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md). Custom commands without a key specified can be triggered by selecting them from the keybindings (`?`) menu | no | +| key | The key to trigger the command. Use a single key or list of keys, as described in [Custom_Keybindings.md](https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md). Custom commands without a key specified can be triggered by selecting them from the keybindings (`?`) menu | no | | command | The command to run (using Go template syntax for placeholder values) | yes | | context | The context in which to listen for the key (see [below](#contexts)) | yes | | prompts | A list of prompts that will request user input before running the final command | no | @@ -193,7 +193,7 @@ The permitted option fields are: | name | The first part of the label | no | | description | The second part of the label | no | | value | the value that will be used in the command | yes | -| key | Keybinding to invoke this menu option without needing to navigate to it. Can be a single letter or one of the values from [here](https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md) | no | +| key | Keybinding to invoke this menu option without needing to navigate to it. Use a single key or list of keys, as described in [Custom_Keybindings.md](https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md) | no | If an option has no name the value will be displayed to the user in place of the name, so you're allowed to only include the value like so: diff --git a/docs-master/keybindings/Custom_Keybindings.md b/docs-master/keybindings/Custom_Keybindings.md index ad24070a3..998aae14c 100644 --- a/docs-master/keybindings/Custom_Keybindings.md +++ b/docs-master/keybindings/Custom_Keybindings.md @@ -7,6 +7,8 @@ A keybinding is one of: - A special key name in angle brackets, e.g. ``, ``, ``. - A key with modifiers in angle brackets, e.g. ``, ``. - The literal string `` to disable a binding. +- A list of any of the above, to bind multiple keys to the same action: + `quit: [q, ]`. ### Modifiers diff --git a/pkg/config/keybinding.go b/pkg/config/keybinding.go index bf605d44f..f552707e5 100644 --- a/pkg/config/keybinding.go +++ b/pkg/config/keybinding.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" "fmt" + "strings" "github.com/karimkhaleel/jsonschema" "github.com/samber/lo" @@ -67,6 +68,12 @@ func (k Keybinding) MarshalJSON() ([]byte, error) { return json.Marshal([]string(k)) } +// String renders the keybinding as a human-readable label, joining +// alternates with " or " for use in help text. +func (k Keybinding) String() string { + return strings.Join(k, " or ") +} + // JSONSchema lets the schema generator describe this type as a union of a // string and an array of strings instead of just an array. func (Keybinding) JSONSchema() *jsonschema.Schema { diff --git a/pkg/config/keybinding_test.go b/pkg/config/keybinding_test.go index d3406412c..9bf3ed442 100644 --- a/pkg/config/keybinding_test.go +++ b/pkg/config/keybinding_test.go @@ -149,3 +149,34 @@ func TestKeybindingYAMLRoundTrip(t *testing.T) { assert.Equal(t, original, decoded) } } + +func TestKeybindingConfigYAMLAcceptsBothForms(t *testing.T) { + scenarios := []struct { + name string + yaml string + expected Keybinding + }{ + { + name: "scalar form", + yaml: "quit: q\n", + expected: Keybinding{"q"}, + }, + { + name: "sequence form", + yaml: "quit: [q, ]\n", + expected: Keybinding{"q", ""}, + }, + { + name: "block sequence form", + yaml: "quit:\n - q\n - \n", + expected: Keybinding{"q", ""}, + }, + } + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + var cfg KeybindingUniversalConfig + assert.NoError(t, yaml.Unmarshal([]byte(s.yaml), &cfg)) + assert.Equal(t, s.expected, cfg.Quit) + }) + } +} diff --git a/pkg/config/keynames.go b/pkg/config/keynames.go index 7f361dec8..da5b8dbae 100644 --- a/pkg/config/keynames.go +++ b/pkg/config/keynames.go @@ -206,10 +206,9 @@ func GetValidatedKeyBindingKey(label string) gocui.Key { return key } -func GetValidatedKeyBindingKeys(label string) []gocui.Key { - k := GetValidatedKeyBindingKey(label) - if !k.IsSet() { - return nil - } - return []gocui.Key{k} +func GetValidatedKeyBindingKeys(labels Keybinding) []gocui.Key { + return lo.FilterMap(labels, func(label string, _ int) (gocui.Key, bool) { + k := GetValidatedKeyBindingKey(label) + return k, k.IsSet() + }) } diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 3d59782e5..bbd568e8d 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -36,7 +36,8 @@ type UserConfig struct { NotARepository string `yaml:"notARepository" jsonschema:"enum=prompt,enum=create,enum=skip,enum=quit"` // If true, display a confirmation when subprocess terminates. This allows you to view the output of the subprocess before returning to Lazygit. PromptToReturnFromSubprocess bool `yaml:"promptToReturnFromSubprocess"` - // Keybindings + // Keybindings. + // Each binding can be a single key or a list of keys; see https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md for the syntax. Keybinding KeybindingConfig `yaml:"keybinding"` } @@ -422,202 +423,202 @@ type KeybindingConfig struct { // damn looks like we have some inconsistencies here with -alt and -alt1 type KeybindingUniversalConfig struct { - Quit string `yaml:"quit"` - QuitAlt1 string `yaml:"quit-alt1"` - SuspendApp string `yaml:"suspendApp"` - Return string `yaml:"return"` - QuitWithoutChangingDirectory string `yaml:"quitWithoutChangingDirectory"` - TogglePanel string `yaml:"togglePanel"` - PrevItem string `yaml:"prevItem"` - NextItem string `yaml:"nextItem"` - PrevItemAlt string `yaml:"prevItem-alt"` - NextItemAlt string `yaml:"nextItem-alt"` - PrevPage string `yaml:"prevPage"` - NextPage string `yaml:"nextPage"` - ScrollLeft string `yaml:"scrollLeft"` - ScrollRight string `yaml:"scrollRight"` - GotoTop string `yaml:"gotoTop"` - GotoBottom string `yaml:"gotoBottom"` - GotoTopAlt string `yaml:"gotoTop-alt"` - GotoBottomAlt string `yaml:"gotoBottom-alt"` - ToggleRangeSelect string `yaml:"toggleRangeSelect"` - RangeSelectDown string `yaml:"rangeSelectDown"` - RangeSelectUp string `yaml:"rangeSelectUp"` - PrevBlock string `yaml:"prevBlock"` - NextBlock string `yaml:"nextBlock"` - PrevBlockAlt string `yaml:"prevBlock-alt"` - NextBlockAlt string `yaml:"nextBlock-alt"` - NextBlockAlt2 string `yaml:"nextBlock-alt2"` - PrevBlockAlt2 string `yaml:"prevBlock-alt2"` - JumpToBlock []string `yaml:"jumpToBlock"` - FocusMainView string `yaml:"focusMainView"` - NextMatch string `yaml:"nextMatch"` - PrevMatch string `yaml:"prevMatch"` - StartSearch string `yaml:"startSearch"` - MoveWordLeft string `yaml:"moveWordLeft"` // on Mac - MoveWordRight string `yaml:"moveWordRight"` // on Mac - BackspaceWord string `yaml:"backspaceWord"` // on Mac - ForwardDeleteWord string `yaml:"forwardDeleteWord"` // on Mac - OptionMenu string `yaml:"optionMenu"` - Select string `yaml:"select"` - GoInto string `yaml:"goInto"` - Confirm string `yaml:"confirm"` - ConfirmMenu string `yaml:"confirmMenu"` - ConfirmSuggestion string `yaml:"confirmSuggestion"` - ConfirmInEditor string `yaml:"confirmInEditor"` // on Mac - ConfirmInEditorAlt string `yaml:"confirmInEditor-alt"` - Remove string `yaml:"remove"` - New string `yaml:"new"` - Edit string `yaml:"edit"` - OpenFile string `yaml:"openFile"` - ScrollUpMain string `yaml:"scrollUpMain"` - ScrollDownMain string `yaml:"scrollDownMain"` - ScrollUpMainAlt1 string `yaml:"scrollUpMain-alt1"` - ScrollDownMainAlt1 string `yaml:"scrollDownMain-alt1"` - ScrollUpMainAlt2 string `yaml:"scrollUpMain-alt2"` - ScrollDownMainAlt2 string `yaml:"scrollDownMain-alt2"` - ExecuteShellCommand string `yaml:"executeShellCommand"` - CreateRebaseOptionsMenu string `yaml:"createRebaseOptionsMenu"` - Push string `yaml:"pushFiles"` // 'Files' appended for legacy reasons - Pull string `yaml:"pullFiles"` // 'Files' appended for legacy reasons - Refresh string `yaml:"refresh"` - CreatePatchOptionsMenu string `yaml:"createPatchOptionsMenu"` - NextTab string `yaml:"nextTab"` - PrevTab string `yaml:"prevTab"` - NextScreenMode string `yaml:"nextScreenMode"` - PrevScreenMode string `yaml:"prevScreenMode"` - CyclePagers string `yaml:"cyclePagers"` - Undo string `yaml:"undo"` - Redo string `yaml:"redo"` - FilteringMenu string `yaml:"filteringMenu"` - DiffingMenu string `yaml:"diffingMenu"` - DiffingMenuAlt string `yaml:"diffingMenu-alt"` - CopyToClipboard string `yaml:"copyToClipboard"` - OpenRecentRepos string `yaml:"openRecentRepos"` - SubmitEditorText string `yaml:"submitEditorText"` - ExtrasMenu string `yaml:"extrasMenu"` - ToggleWhitespaceInDiffView string `yaml:"toggleWhitespaceInDiffView"` - IncreaseContextInDiffView string `yaml:"increaseContextInDiffView"` - DecreaseContextInDiffView string `yaml:"decreaseContextInDiffView"` - IncreaseRenameSimilarityThreshold string `yaml:"increaseRenameSimilarityThreshold"` - DecreaseRenameSimilarityThreshold string `yaml:"decreaseRenameSimilarityThreshold"` - OpenDiffTool string `yaml:"openDiffTool"` + Quit Keybinding `yaml:"quit"` + QuitAlt1 Keybinding `yaml:"quit-alt1"` + SuspendApp Keybinding `yaml:"suspendApp"` + Return Keybinding `yaml:"return"` + QuitWithoutChangingDirectory Keybinding `yaml:"quitWithoutChangingDirectory"` + TogglePanel Keybinding `yaml:"togglePanel"` + PrevItem Keybinding `yaml:"prevItem"` + NextItem Keybinding `yaml:"nextItem"` + PrevItemAlt Keybinding `yaml:"prevItem-alt"` + NextItemAlt Keybinding `yaml:"nextItem-alt"` + PrevPage Keybinding `yaml:"prevPage"` + NextPage Keybinding `yaml:"nextPage"` + ScrollLeft Keybinding `yaml:"scrollLeft"` + ScrollRight Keybinding `yaml:"scrollRight"` + GotoTop Keybinding `yaml:"gotoTop"` + GotoBottom Keybinding `yaml:"gotoBottom"` + GotoTopAlt Keybinding `yaml:"gotoTop-alt"` + GotoBottomAlt Keybinding `yaml:"gotoBottom-alt"` + ToggleRangeSelect Keybinding `yaml:"toggleRangeSelect"` + RangeSelectDown Keybinding `yaml:"rangeSelectDown"` + RangeSelectUp Keybinding `yaml:"rangeSelectUp"` + PrevBlock Keybinding `yaml:"prevBlock"` + NextBlock Keybinding `yaml:"nextBlock"` + PrevBlockAlt Keybinding `yaml:"prevBlock-alt"` + NextBlockAlt Keybinding `yaml:"nextBlock-alt"` + NextBlockAlt2 Keybinding `yaml:"nextBlock-alt2"` + PrevBlockAlt2 Keybinding `yaml:"prevBlock-alt2"` + JumpToBlock []string `yaml:"jumpToBlock"` + FocusMainView Keybinding `yaml:"focusMainView"` + NextMatch Keybinding `yaml:"nextMatch"` + PrevMatch Keybinding `yaml:"prevMatch"` + StartSearch Keybinding `yaml:"startSearch"` + MoveWordLeft Keybinding `yaml:"moveWordLeft"` // on Mac + MoveWordRight Keybinding `yaml:"moveWordRight"` // on Mac + BackspaceWord Keybinding `yaml:"backspaceWord"` // on Mac + ForwardDeleteWord Keybinding `yaml:"forwardDeleteWord"` // on Mac + OptionMenu Keybinding `yaml:"optionMenu"` + Select Keybinding `yaml:"select"` + GoInto Keybinding `yaml:"goInto"` + Confirm Keybinding `yaml:"confirm"` + ConfirmMenu Keybinding `yaml:"confirmMenu"` + ConfirmSuggestion Keybinding `yaml:"confirmSuggestion"` + ConfirmInEditor Keybinding `yaml:"confirmInEditor"` // on Mac + ConfirmInEditorAlt Keybinding `yaml:"confirmInEditor-alt"` + Remove Keybinding `yaml:"remove"` + New Keybinding `yaml:"new"` + Edit Keybinding `yaml:"edit"` + OpenFile Keybinding `yaml:"openFile"` + ScrollUpMain Keybinding `yaml:"scrollUpMain"` + ScrollDownMain Keybinding `yaml:"scrollDownMain"` + ScrollUpMainAlt1 Keybinding `yaml:"scrollUpMain-alt1"` + ScrollDownMainAlt1 Keybinding `yaml:"scrollDownMain-alt1"` + ScrollUpMainAlt2 Keybinding `yaml:"scrollUpMain-alt2"` + 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"` + Undo Keybinding `yaml:"undo"` + Redo Keybinding `yaml:"redo"` + FilteringMenu Keybinding `yaml:"filteringMenu"` + DiffingMenu Keybinding `yaml:"diffingMenu"` + DiffingMenuAlt Keybinding `yaml:"diffingMenu-alt"` + CopyToClipboard Keybinding `yaml:"copyToClipboard"` + OpenRecentRepos Keybinding `yaml:"openRecentRepos"` + SubmitEditorText Keybinding `yaml:"submitEditorText"` + ExtrasMenu Keybinding `yaml:"extrasMenu"` + ToggleWhitespaceInDiffView Keybinding `yaml:"toggleWhitespaceInDiffView"` + IncreaseContextInDiffView Keybinding `yaml:"increaseContextInDiffView"` + DecreaseContextInDiffView Keybinding `yaml:"decreaseContextInDiffView"` + IncreaseRenameSimilarityThreshold Keybinding `yaml:"increaseRenameSimilarityThreshold"` + DecreaseRenameSimilarityThreshold Keybinding `yaml:"decreaseRenameSimilarityThreshold"` + OpenDiffTool Keybinding `yaml:"openDiffTool"` } type KeybindingStatusConfig struct { - CheckForUpdate string `yaml:"checkForUpdate"` - RecentRepos string `yaml:"recentRepos"` - AllBranchesLogGraph string `yaml:"allBranchesLogGraph"` - AllBranchesLogGraphReverse string `yaml:"allBranchesLogGraphReverse"` + CheckForUpdate Keybinding `yaml:"checkForUpdate"` + RecentRepos Keybinding `yaml:"recentRepos"` + AllBranchesLogGraph Keybinding `yaml:"allBranchesLogGraph"` + AllBranchesLogGraphReverse Keybinding `yaml:"allBranchesLogGraphReverse"` } type KeybindingFilesConfig struct { - CommitChanges string `yaml:"commitChanges"` - CommitChangesWithoutHook string `yaml:"commitChangesWithoutHook"` - AmendLastCommit string `yaml:"amendLastCommit"` - CommitChangesWithEditor string `yaml:"commitChangesWithEditor"` - FindBaseCommitForFixup string `yaml:"findBaseCommitForFixup"` - ConfirmDiscard string `yaml:"confirmDiscard"` - IgnoreFile string `yaml:"ignoreFile"` - RefreshFiles string `yaml:"refreshFiles"` - StashAllChanges string `yaml:"stashAllChanges"` - ViewStashOptions string `yaml:"viewStashOptions"` - ToggleStagedAll string `yaml:"toggleStagedAll"` - ViewResetOptions string `yaml:"viewResetOptions"` - Fetch string `yaml:"fetch"` - ToggleTreeView string `yaml:"toggleTreeView"` - OpenMergeOptions string `yaml:"openMergeOptions"` - OpenStatusFilter string `yaml:"openStatusFilter"` - CopyFileInfoToClipboard string `yaml:"copyFileInfoToClipboard"` - CollapseAll string `yaml:"collapseAll"` - ExpandAll string `yaml:"expandAll"` + CommitChanges Keybinding `yaml:"commitChanges"` + CommitChangesWithoutHook Keybinding `yaml:"commitChangesWithoutHook"` + AmendLastCommit Keybinding `yaml:"amendLastCommit"` + CommitChangesWithEditor Keybinding `yaml:"commitChangesWithEditor"` + FindBaseCommitForFixup Keybinding `yaml:"findBaseCommitForFixup"` + ConfirmDiscard Keybinding `yaml:"confirmDiscard"` + IgnoreFile Keybinding `yaml:"ignoreFile"` + RefreshFiles Keybinding `yaml:"refreshFiles"` + StashAllChanges Keybinding `yaml:"stashAllChanges"` + ViewStashOptions Keybinding `yaml:"viewStashOptions"` + ToggleStagedAll Keybinding `yaml:"toggleStagedAll"` + ViewResetOptions Keybinding `yaml:"viewResetOptions"` + Fetch Keybinding `yaml:"fetch"` + ToggleTreeView Keybinding `yaml:"toggleTreeView"` + OpenMergeOptions Keybinding `yaml:"openMergeOptions"` + OpenStatusFilter Keybinding `yaml:"openStatusFilter"` + CopyFileInfoToClipboard Keybinding `yaml:"copyFileInfoToClipboard"` + CollapseAll Keybinding `yaml:"collapseAll"` + ExpandAll Keybinding `yaml:"expandAll"` } type KeybindingBranchesConfig struct { - CreatePullRequest string `yaml:"createPullRequest"` - ViewPullRequestOptions string `yaml:"viewPullRequestOptions"` - OpenPullRequestInBrowser string `yaml:"openPullRequestInBrowser"` - CopyPullRequestURL string `yaml:"copyPullRequestURL"` - CheckoutBranchByName string `yaml:"checkoutBranchByName"` - ForceCheckoutBranch string `yaml:"forceCheckoutBranch"` - CheckoutPreviousBranch string `yaml:"checkoutPreviousBranch"` - RebaseBranch string `yaml:"rebaseBranch"` - RenameBranch string `yaml:"renameBranch"` - MergeIntoCurrentBranch string `yaml:"mergeIntoCurrentBranch"` - MoveCommitsToNewBranch string `yaml:"moveCommitsToNewBranch"` - ViewGitFlowOptions string `yaml:"viewGitFlowOptions"` - FastForward string `yaml:"fastForward"` - CreateTag string `yaml:"createTag"` - PushTag string `yaml:"pushTag"` - SetUpstream string `yaml:"setUpstream"` - FetchRemote string `yaml:"fetchRemote"` - AddForkRemote string `yaml:"addForkRemote"` - SortOrder string `yaml:"sortOrder"` + CreatePullRequest Keybinding `yaml:"createPullRequest"` + ViewPullRequestOptions Keybinding `yaml:"viewPullRequestOptions"` + OpenPullRequestInBrowser Keybinding `yaml:"openPullRequestInBrowser"` + CopyPullRequestURL Keybinding `yaml:"copyPullRequestURL"` + CheckoutBranchByName Keybinding `yaml:"checkoutBranchByName"` + ForceCheckoutBranch Keybinding `yaml:"forceCheckoutBranch"` + CheckoutPreviousBranch Keybinding `yaml:"checkoutPreviousBranch"` + RebaseBranch Keybinding `yaml:"rebaseBranch"` + RenameBranch Keybinding `yaml:"renameBranch"` + MergeIntoCurrentBranch Keybinding `yaml:"mergeIntoCurrentBranch"` + MoveCommitsToNewBranch Keybinding `yaml:"moveCommitsToNewBranch"` + ViewGitFlowOptions Keybinding `yaml:"viewGitFlowOptions"` + FastForward Keybinding `yaml:"fastForward"` + CreateTag Keybinding `yaml:"createTag"` + PushTag Keybinding `yaml:"pushTag"` + SetUpstream Keybinding `yaml:"setUpstream"` + FetchRemote Keybinding `yaml:"fetchRemote"` + AddForkRemote Keybinding `yaml:"addForkRemote"` + SortOrder Keybinding `yaml:"sortOrder"` } type KeybindingWorktreesConfig struct { - ViewWorktreeOptions string `yaml:"viewWorktreeOptions"` + ViewWorktreeOptions Keybinding `yaml:"viewWorktreeOptions"` } type KeybindingCommitsConfig struct { - SquashDown string `yaml:"squashDown"` - RenameCommit string `yaml:"renameCommit"` - RenameCommitWithEditor string `yaml:"renameCommitWithEditor"` - ViewResetOptions string `yaml:"viewResetOptions"` - MarkCommitAsFixup string `yaml:"markCommitAsFixup"` - SetFixupMessage string `yaml:"setFixupMessage"` - CreateFixupCommit string `yaml:"createFixupCommit"` - SquashAboveCommits string `yaml:"squashAboveCommits"` - MoveDownCommit string `yaml:"moveDownCommit"` - MoveUpCommit string `yaml:"moveUpCommit"` - AmendToCommit string `yaml:"amendToCommit"` - ResetCommitAuthor string `yaml:"resetCommitAuthor"` - PickCommit string `yaml:"pickCommit"` - RevertCommit string `yaml:"revertCommit"` - CherryPickCopy string `yaml:"cherryPickCopy"` - PasteCommits string `yaml:"pasteCommits"` - MarkCommitAsBaseForRebase string `yaml:"markCommitAsBaseForRebase"` - CreateTag string `yaml:"tagCommit"` - CheckoutCommit string `yaml:"checkoutCommit"` - ResetCherryPick string `yaml:"resetCherryPick"` - CopyCommitAttributeToClipboard string `yaml:"copyCommitAttributeToClipboard"` - OpenLogMenu string `yaml:"openLogMenu"` - OpenInBrowser string `yaml:"openInBrowser"` - OpenPullRequestInBrowser string `yaml:"openPullRequestInBrowser"` - ViewBisectOptions string `yaml:"viewBisectOptions"` - StartInteractiveRebase string `yaml:"startInteractiveRebase"` - SelectCommitsOfCurrentBranch string `yaml:"selectCommitsOfCurrentBranch"` + SquashDown Keybinding `yaml:"squashDown"` + RenameCommit Keybinding `yaml:"renameCommit"` + RenameCommitWithEditor Keybinding `yaml:"renameCommitWithEditor"` + ViewResetOptions Keybinding `yaml:"viewResetOptions"` + MarkCommitAsFixup Keybinding `yaml:"markCommitAsFixup"` + SetFixupMessage Keybinding `yaml:"setFixupMessage"` + CreateFixupCommit Keybinding `yaml:"createFixupCommit"` + SquashAboveCommits Keybinding `yaml:"squashAboveCommits"` + MoveDownCommit Keybinding `yaml:"moveDownCommit"` + MoveUpCommit Keybinding `yaml:"moveUpCommit"` + AmendToCommit Keybinding `yaml:"amendToCommit"` + ResetCommitAuthor Keybinding `yaml:"resetCommitAuthor"` + PickCommit Keybinding `yaml:"pickCommit"` + RevertCommit Keybinding `yaml:"revertCommit"` + CherryPickCopy Keybinding `yaml:"cherryPickCopy"` + PasteCommits Keybinding `yaml:"pasteCommits"` + MarkCommitAsBaseForRebase Keybinding `yaml:"markCommitAsBaseForRebase"` + CreateTag Keybinding `yaml:"tagCommit"` + CheckoutCommit Keybinding `yaml:"checkoutCommit"` + ResetCherryPick Keybinding `yaml:"resetCherryPick"` + CopyCommitAttributeToClipboard Keybinding `yaml:"copyCommitAttributeToClipboard"` + OpenLogMenu Keybinding `yaml:"openLogMenu"` + OpenInBrowser Keybinding `yaml:"openInBrowser"` + OpenPullRequestInBrowser Keybinding `yaml:"openPullRequestInBrowser"` + ViewBisectOptions Keybinding `yaml:"viewBisectOptions"` + StartInteractiveRebase Keybinding `yaml:"startInteractiveRebase"` + SelectCommitsOfCurrentBranch Keybinding `yaml:"selectCommitsOfCurrentBranch"` } type KeybindingAmendAttributeConfig struct { - ResetAuthor string `yaml:"resetAuthor"` - SetAuthor string `yaml:"setAuthor"` - AddCoAuthor string `yaml:"addCoAuthor"` + ResetAuthor Keybinding `yaml:"resetAuthor"` + SetAuthor Keybinding `yaml:"setAuthor"` + AddCoAuthor Keybinding `yaml:"addCoAuthor"` } type KeybindingStashConfig struct { - PopStash string `yaml:"popStash"` - RenameStash string `yaml:"renameStash"` + PopStash Keybinding `yaml:"popStash"` + RenameStash Keybinding `yaml:"renameStash"` } type KeybindingCommitFilesConfig struct { - CheckoutCommitFile string `yaml:"checkoutCommitFile"` + CheckoutCommitFile Keybinding `yaml:"checkoutCommitFile"` } type KeybindingMainConfig struct { - ToggleSelectHunk string `yaml:"toggleSelectHunk"` - PickBothHunks string `yaml:"pickBothHunks"` - EditSelectHunk string `yaml:"editSelectHunk"` + ToggleSelectHunk Keybinding `yaml:"toggleSelectHunk"` + PickBothHunks Keybinding `yaml:"pickBothHunks"` + EditSelectHunk Keybinding `yaml:"editSelectHunk"` } type KeybindingSubmodulesConfig struct { - Init string `yaml:"init"` - Update string `yaml:"update"` - BulkMenu string `yaml:"bulkMenu"` + Init Keybinding `yaml:"init"` + Update Keybinding `yaml:"update"` + BulkMenu Keybinding `yaml:"bulkMenu"` } type KeybindingCommitMessageConfig struct { - CommitMenu string `yaml:"commitMenu"` + CommitMenu Keybinding `yaml:"commitMenu"` } // OSConfig contains config on the level of the os @@ -904,191 +905,191 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { PromptToReturnFromSubprocess: true, Keybinding: KeybindingConfig{ Universal: KeybindingUniversalConfig{ - Quit: "q", - QuitAlt1: "", - SuspendApp: "", - Return: "", - QuitWithoutChangingDirectory: "Q", - TogglePanel: "", - PrevItem: "", - NextItem: "", - PrevItemAlt: "k", - NextItemAlt: "j", - PrevPage: ",", - NextPage: ".", - ScrollLeft: "H", - ScrollRight: "L", - GotoTop: "<", - GotoBottom: ">", - GotoTopAlt: "", - GotoBottomAlt: "", - ToggleRangeSelect: "v", - RangeSelectDown: "", - RangeSelectUp: "", - PrevBlock: "", - NextBlock: "", - PrevBlockAlt: "h", - NextBlockAlt: "l", - PrevBlockAlt2: "", - NextBlockAlt2: "", + Quit: Keybinding{"q"}, + QuitAlt1: Keybinding{""}, + SuspendApp: Keybinding{""}, + Return: Keybinding{""}, + QuitWithoutChangingDirectory: Keybinding{"Q"}, + TogglePanel: Keybinding{""}, + PrevItem: Keybinding{""}, + NextItem: Keybinding{""}, + PrevItemAlt: Keybinding{"k"}, + NextItemAlt: Keybinding{"j"}, + PrevPage: Keybinding{","}, + NextPage: Keybinding{"."}, + ScrollLeft: Keybinding{"H"}, + ScrollRight: Keybinding{"L"}, + GotoTop: Keybinding{"<"}, + GotoBottom: Keybinding{">"}, + GotoTopAlt: Keybinding{""}, + GotoBottomAlt: Keybinding{""}, + ToggleRangeSelect: Keybinding{"v"}, + RangeSelectDown: Keybinding{""}, + RangeSelectUp: Keybinding{""}, + PrevBlock: Keybinding{""}, + NextBlock: Keybinding{""}, + PrevBlockAlt: Keybinding{"h"}, + NextBlockAlt: Keybinding{"l"}, + PrevBlockAlt2: Keybinding{""}, + NextBlockAlt2: Keybinding{""}, JumpToBlock: []string{"1", "2", "3", "4", "5"}, - FocusMainView: "0", - NextMatch: "n", - PrevMatch: "N", - StartSearch: "/", - MoveWordLeft: platformKeyBinding(platform, map[string]string{"darwin": ""}, ""), - MoveWordRight: platformKeyBinding(platform, map[string]string{"darwin": ""}, ""), - BackspaceWord: platformKeyBinding(platform, map[string]string{"darwin": ""}, ""), - ForwardDeleteWord: platformKeyBinding(platform, map[string]string{"darwin": ""}, ""), - OptionMenu: "?", - Select: "", - GoInto: "", - Confirm: "", - ConfirmMenu: "", - ConfirmSuggestion: "", - ConfirmInEditor: platformKeyBinding(platform, map[string]string{"darwin": ""}, ""), - ConfirmInEditorAlt: "", - Remove: "d", - New: "n", - Edit: "e", - OpenFile: "o", - OpenRecentRepos: "", - ScrollUpMain: "", - ScrollDownMain: "", - ScrollUpMainAlt1: "K", - ScrollDownMainAlt1: "J", - ScrollUpMainAlt2: "", - ScrollDownMainAlt2: "", - ExecuteShellCommand: ":", - CreateRebaseOptionsMenu: "m", - Push: "P", - Pull: "p", - Refresh: "R", - CreatePatchOptionsMenu: "", - NextTab: "]", - PrevTab: "[", - NextScreenMode: "+", - PrevScreenMode: "_", - CyclePagers: "|", - Undo: "z", - Redo: "Z", - FilteringMenu: "", - DiffingMenu: "W", - DiffingMenuAlt: "", - CopyToClipboard: "", - SubmitEditorText: "", - ExtrasMenu: "@", - ToggleWhitespaceInDiffView: "", - IncreaseContextInDiffView: "}", - DecreaseContextInDiffView: "{", - IncreaseRenameSimilarityThreshold: ")", - DecreaseRenameSimilarityThreshold: "(", - OpenDiffTool: "", + FocusMainView: Keybinding{"0"}, + NextMatch: Keybinding{"n"}, + PrevMatch: Keybinding{"N"}, + StartSearch: Keybinding{"/"}, + MoveWordLeft: Keybinding{platformKeyBinding(platform, map[string]string{"darwin": ""}, "")}, + MoveWordRight: Keybinding{platformKeyBinding(platform, map[string]string{"darwin": ""}, "")}, + BackspaceWord: Keybinding{platformKeyBinding(platform, map[string]string{"darwin": ""}, "")}, + ForwardDeleteWord: Keybinding{platformKeyBinding(platform, map[string]string{"darwin": ""}, "")}, + OptionMenu: Keybinding{"?"}, + Select: Keybinding{""}, + GoInto: Keybinding{""}, + Confirm: Keybinding{""}, + ConfirmMenu: Keybinding{""}, + ConfirmSuggestion: Keybinding{""}, + ConfirmInEditor: Keybinding{platformKeyBinding(platform, map[string]string{"darwin": ""}, "")}, + ConfirmInEditorAlt: Keybinding{""}, + Remove: Keybinding{"d"}, + New: Keybinding{"n"}, + Edit: Keybinding{"e"}, + OpenFile: Keybinding{"o"}, + OpenRecentRepos: Keybinding{""}, + ScrollUpMain: Keybinding{""}, + ScrollDownMain: Keybinding{""}, + ScrollUpMainAlt1: Keybinding{"K"}, + ScrollDownMainAlt1: Keybinding{"J"}, + ScrollUpMainAlt2: Keybinding{""}, + ScrollDownMainAlt2: Keybinding{""}, + ExecuteShellCommand: Keybinding{":"}, + CreateRebaseOptionsMenu: Keybinding{"m"}, + Push: Keybinding{"P"}, + Pull: Keybinding{"p"}, + Refresh: Keybinding{"R"}, + CreatePatchOptionsMenu: Keybinding{""}, + NextTab: Keybinding{"]"}, + PrevTab: Keybinding{"["}, + NextScreenMode: Keybinding{"+"}, + PrevScreenMode: Keybinding{"_"}, + CyclePagers: Keybinding{"|"}, + Undo: Keybinding{"z"}, + Redo: Keybinding{"Z"}, + FilteringMenu: Keybinding{""}, + DiffingMenu: Keybinding{"W"}, + DiffingMenuAlt: Keybinding{""}, + CopyToClipboard: Keybinding{""}, + SubmitEditorText: Keybinding{""}, + ExtrasMenu: Keybinding{"@"}, + ToggleWhitespaceInDiffView: Keybinding{""}, + IncreaseContextInDiffView: Keybinding{"}"}, + DecreaseContextInDiffView: Keybinding{"{"}, + IncreaseRenameSimilarityThreshold: Keybinding{")"}, + DecreaseRenameSimilarityThreshold: Keybinding{"("}, + OpenDiffTool: Keybinding{""}, }, Status: KeybindingStatusConfig{ - CheckForUpdate: "u", - RecentRepos: "", - AllBranchesLogGraph: "a", - AllBranchesLogGraphReverse: "A", + CheckForUpdate: Keybinding{"u"}, + RecentRepos: Keybinding{""}, + AllBranchesLogGraph: Keybinding{"a"}, + AllBranchesLogGraphReverse: Keybinding{"A"}, }, Files: KeybindingFilesConfig{ - CommitChanges: "c", - CommitChangesWithoutHook: "w", - AmendLastCommit: "A", - CommitChangesWithEditor: "C", - FindBaseCommitForFixup: "", - IgnoreFile: "i", - RefreshFiles: "r", - StashAllChanges: "s", - ViewStashOptions: "S", - ToggleStagedAll: "a", - ViewResetOptions: "D", - Fetch: "f", - ToggleTreeView: "`", - OpenMergeOptions: "M", - OpenStatusFilter: "", - ConfirmDiscard: "x", - CopyFileInfoToClipboard: "y", - CollapseAll: "-", - ExpandAll: "=", + CommitChanges: Keybinding{"c"}, + CommitChangesWithoutHook: Keybinding{"w"}, + AmendLastCommit: Keybinding{"A"}, + CommitChangesWithEditor: Keybinding{"C"}, + FindBaseCommitForFixup: Keybinding{""}, + IgnoreFile: Keybinding{"i"}, + RefreshFiles: Keybinding{"r"}, + StashAllChanges: Keybinding{"s"}, + ViewStashOptions: Keybinding{"S"}, + ToggleStagedAll: Keybinding{"a"}, + ViewResetOptions: Keybinding{"D"}, + Fetch: Keybinding{"f"}, + ToggleTreeView: Keybinding{"`"}, + OpenMergeOptions: Keybinding{"M"}, + OpenStatusFilter: Keybinding{""}, + ConfirmDiscard: Keybinding{"x"}, + CopyFileInfoToClipboard: Keybinding{"y"}, + CollapseAll: Keybinding{"-"}, + ExpandAll: Keybinding{"="}, }, Branches: KeybindingBranchesConfig{ - CopyPullRequestURL: "", - CreatePullRequest: "o", - ViewPullRequestOptions: "O", - OpenPullRequestInBrowser: "G", - CheckoutBranchByName: "c", - ForceCheckoutBranch: "F", - CheckoutPreviousBranch: "-", - RebaseBranch: "r", - RenameBranch: "R", - MergeIntoCurrentBranch: "M", - MoveCommitsToNewBranch: "N", - ViewGitFlowOptions: "i", - FastForward: "f", - CreateTag: "T", - PushTag: "P", - SetUpstream: "u", - FetchRemote: "f", - AddForkRemote: "F", - SortOrder: "s", + CopyPullRequestURL: Keybinding{""}, + CreatePullRequest: Keybinding{"o"}, + ViewPullRequestOptions: Keybinding{"O"}, + OpenPullRequestInBrowser: Keybinding{"G"}, + CheckoutBranchByName: Keybinding{"c"}, + ForceCheckoutBranch: Keybinding{"F"}, + CheckoutPreviousBranch: Keybinding{"-"}, + RebaseBranch: Keybinding{"r"}, + RenameBranch: Keybinding{"R"}, + MergeIntoCurrentBranch: Keybinding{"M"}, + MoveCommitsToNewBranch: Keybinding{"N"}, + ViewGitFlowOptions: Keybinding{"i"}, + FastForward: Keybinding{"f"}, + CreateTag: Keybinding{"T"}, + PushTag: Keybinding{"P"}, + SetUpstream: Keybinding{"u"}, + FetchRemote: Keybinding{"f"}, + AddForkRemote: Keybinding{"F"}, + SortOrder: Keybinding{"s"}, }, Worktrees: KeybindingWorktreesConfig{ - ViewWorktreeOptions: "w", + ViewWorktreeOptions: Keybinding{"w"}, }, Commits: KeybindingCommitsConfig{ - SquashDown: "s", - RenameCommit: "r", - RenameCommitWithEditor: "R", - ViewResetOptions: "g", - MarkCommitAsFixup: "f", - SetFixupMessage: "c", - CreateFixupCommit: "F", - SquashAboveCommits: "S", - MoveDownCommit: "", - MoveUpCommit: "", - AmendToCommit: "A", - ResetCommitAuthor: "a", - PickCommit: "p", - RevertCommit: "t", - CherryPickCopy: "C", - PasteCommits: "V", - MarkCommitAsBaseForRebase: "B", - CreateTag: "T", - CheckoutCommit: "", - ResetCherryPick: "", - CopyCommitAttributeToClipboard: "y", - OpenLogMenu: "", - OpenInBrowser: "o", - OpenPullRequestInBrowser: "G", - ViewBisectOptions: "b", - StartInteractiveRebase: "i", - SelectCommitsOfCurrentBranch: "*", + SquashDown: Keybinding{"s"}, + RenameCommit: Keybinding{"r"}, + RenameCommitWithEditor: Keybinding{"R"}, + ViewResetOptions: Keybinding{"g"}, + MarkCommitAsFixup: Keybinding{"f"}, + SetFixupMessage: Keybinding{"c"}, + CreateFixupCommit: Keybinding{"F"}, + SquashAboveCommits: Keybinding{"S"}, + MoveDownCommit: Keybinding{""}, + MoveUpCommit: Keybinding{""}, + AmendToCommit: Keybinding{"A"}, + ResetCommitAuthor: Keybinding{"a"}, + PickCommit: Keybinding{"p"}, + RevertCommit: Keybinding{"t"}, + CherryPickCopy: Keybinding{"C"}, + PasteCommits: Keybinding{"V"}, + MarkCommitAsBaseForRebase: Keybinding{"B"}, + CreateTag: Keybinding{"T"}, + CheckoutCommit: Keybinding{""}, + ResetCherryPick: Keybinding{""}, + CopyCommitAttributeToClipboard: Keybinding{"y"}, + OpenLogMenu: Keybinding{""}, + OpenInBrowser: Keybinding{"o"}, + OpenPullRequestInBrowser: Keybinding{"G"}, + ViewBisectOptions: Keybinding{"b"}, + StartInteractiveRebase: Keybinding{"i"}, + SelectCommitsOfCurrentBranch: Keybinding{"*"}, }, AmendAttribute: KeybindingAmendAttributeConfig{ - ResetAuthor: "a", - SetAuthor: "A", - AddCoAuthor: "c", + ResetAuthor: Keybinding{"a"}, + SetAuthor: Keybinding{"A"}, + AddCoAuthor: Keybinding{"c"}, }, Stash: KeybindingStashConfig{ - PopStash: "g", - RenameStash: "r", + PopStash: Keybinding{"g"}, + RenameStash: Keybinding{"r"}, }, CommitFiles: KeybindingCommitFilesConfig{ - CheckoutCommitFile: "c", + CheckoutCommitFile: Keybinding{"c"}, }, Main: KeybindingMainConfig{ - ToggleSelectHunk: "a", - PickBothHunks: "b", - EditSelectHunk: "E", + ToggleSelectHunk: Keybinding{"a"}, + PickBothHunks: Keybinding{"b"}, + EditSelectHunk: Keybinding{"E"}, }, Submodules: KeybindingSubmodulesConfig{ - Init: "i", - Update: "u", - BulkMenu: "b", + Init: Keybinding{"i"}, + Update: Keybinding{"u"}, + BulkMenu: Keybinding{"b"}, }, CommitMessage: KeybindingCommitMessageConfig{ - CommitMenu: "", + CommitMenu: Keybinding{""}, }, }, } diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index a2841684d..6474ac3b0 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -114,7 +114,7 @@ func TestUserConfigValidate_enums(t *testing.T) { { name: "Keybindings", setup: func(config *UserConfig, value string) { - config.Keybinding.Universal.Quit = value + config.Keybinding.Universal.Quit = Keybinding{value} }, testCases: []testCase{ {value: "", valid: true}, diff --git a/pkg/gocui/edit.go b/pkg/gocui/edit.go index 76823364e..649379fb6 100644 --- a/pkg/gocui/edit.go +++ b/pkg/gocui/edit.go @@ -4,6 +4,8 @@ package gocui +import "github.com/samber/lo" + // Editor interface must be satisfied by gocui editors. type Editor interface { Edit(v *View, key Key) bool @@ -23,19 +25,19 @@ func (f EditorFunc) Edit(v *View, key Key) bool { var DefaultEditor Editor = EditorFunc(SimpleEditor) var ( - moveWordLeftKeybinding = NewKey(KeyArrowLeft, "", ModCtrl) - moveWordRightKeybinding = NewKey(KeyArrowRight, "", ModCtrl) - backspaceWordKeybinding = NewKey(KeyBackspace, "", ModCtrl) - forwardDeleteWordKeybinding = NewKey(KeyDelete, "", ModCtrl) + moveWordLeftKeybinding = []Key{NewKey(KeyArrowLeft, "", ModCtrl)} + moveWordRightKeybinding = []Key{NewKey(KeyArrowRight, "", ModCtrl)} + backspaceWordKeybinding = []Key{NewKey(KeyBackspace, "", ModCtrl)} + forwardDeleteWordKeybinding = []Key{NewKey(KeyDelete, "", ModCtrl)} ) // SimpleEditor is used as the default gocui editor. func SimpleEditor(v *View, key Key) bool { switch { - case key.Equals(backspaceWordKeybinding), + case lo.SomeBy(backspaceWordKeybinding, func(k Key) bool { return key.Equals(k) }), key.Equals(NewKeyStrMod("w", ModCtrl)): v.TextArea.BackSpaceWord() - case key.Equals(forwardDeleteWordKeybinding), + case lo.SomeBy(forwardDeleteWordKeybinding, func(k Key) bool { return key.Equals(k) }), key.Equals(NewKeyStrMod("d", ModAlt)): v.TextArea.ForwardDeleteWord() case key.Equals(NewKeyName(KeyBackspace)), @@ -49,13 +51,13 @@ func SimpleEditor(v *View, key Key) bool { case key.Equals(NewKeyName(KeyArrowUp)): v.TextArea.MoveCursorUp() case key.Equals(NewKeyStrMod("b", ModAlt)), - key.Equals(moveWordLeftKeybinding): + lo.SomeBy(moveWordLeftKeybinding, func(k Key) bool { return key.Equals(k) }): v.TextArea.MoveLeftWord() case key.Equals(NewKeyName(KeyArrowLeft)), key.Equals(NewKeyStrMod("b", ModCtrl)): v.TextArea.MoveCursorLeft() case key.Equals(NewKeyStrMod("f", ModAlt)), - key.Equals(moveWordRightKeybinding): + lo.SomeBy(moveWordRightKeybinding, func(k Key) bool { return key.Equals(k) }): v.TextArea.MoveRightWord() case key.Equals(NewKeyName(KeyArrowRight)), key.Equals(NewKeyStrMod("f", ModCtrl)): diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 579dfe21d..7b691f6d7 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -178,9 +178,9 @@ type Gui struct { OnSearchEscape func() error - SearchEscapeKey Key - NextSearchMatchKey Key - PrevSearchMatchKey Key + SearchEscapeKeys []Key + NextSearchMatchKeys []Key + PrevSearchMatchKeys []Key ErrorHandler func(error) error @@ -256,9 +256,9 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { g.SupportOverlaps = opts.SupportOverlaps // default keys for when searching strings in a view - g.SearchEscapeKey = NewKeyName(KeyEsc) - g.NextSearchMatchKey = NewKeyRune('n') - g.PrevSearchMatchKey = NewKeyRune('N') + g.SearchEscapeKeys = []Key{NewKeyName(KeyEsc)} + g.NextSearchMatchKeys = []Key{NewKeyRune('n')} + g.PrevSearchMatchKeys = []Key{NewKeyRune('N')} g.playRecording = opts.PlayRecording @@ -1525,11 +1525,11 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error { // if we're searching, and we've hit n/N/Esc, we ignore the default keybinding if v != nil && v.IsSearching() { - if ev.Key.Equals(g.NextSearchMatchKey) { + if lo.SomeBy(g.NextSearchMatchKeys, func(k Key) bool { return ev.Key.Equals(k) }) { return v.gotoNextMatch() - } else if ev.Key.Equals(g.PrevSearchMatchKey) { + } else if lo.SomeBy(g.PrevSearchMatchKeys, func(k Key) bool { return ev.Key.Equals(k) }) { return v.gotoPreviousMatch() - } else if ev.Key.Equals(g.SearchEscapeKey) { + } else if lo.SomeBy(g.SearchEscapeKeys, func(k Key) bool { return ev.Key.Equals(k) }) { v.searcher.clearSearch() if g.OnSearchEscape != nil { if err := g.OnSearchEscape(); err != nil { @@ -1669,7 +1669,7 @@ func (g *Gui) Snapshot() string { return builder.String() } -func (g *Gui) SetEditKeybindings(moveWordLeft, moveWordRight, backspaceWord, forwardDeleteWord Key) { +func (g *Gui) SetEditKeybindings(moveWordLeft, moveWordRight, backspaceWord, forwardDeleteWord []Key) { moveWordLeftKeybinding = moveWordLeft moveWordRightKeybinding = moveWordRight backspaceWordKeybinding = backspaceWord diff --git a/pkg/gui/context/commit_message_context.go b/pkg/gui/context/commit_message_context.go index d14dc609b..7db3a085b 100644 --- a/pkg/gui/context/commit_message_context.go +++ b/pkg/gui/context/commit_message_context.go @@ -166,8 +166,8 @@ func (self *CommitMessageContext) SetPanelState( self.c.Views().CommitDescription.Subtitle = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionSubTitle, map[string]string{ - "togglePanelKeyBinding": self.c.UserConfig().Keybinding.Universal.TogglePanel, - "commitMenuKeybinding": self.c.UserConfig().Keybinding.CommitMessage.CommitMenu, + "togglePanelKeyBinding": self.c.UserConfig().Keybinding.Universal.TogglePanel.String(), + "commitMenuKeybinding": self.c.UserConfig().Keybinding.CommitMessage.CommitMenu.String(), }) self.c.Views().CommitDescription.Visible = true diff --git a/pkg/gui/controllers/basic_commits_controller.go b/pkg/gui/controllers/basic_commits_controller.go index 3e019bf18..2ddb5055e 100644 --- a/pkg/gui/controllers/basic_commits_controller.go +++ b/pkg/gui/controllers/basic_commits_controller.go @@ -105,8 +105,8 @@ func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ Description: self.c.Tr.CherryPickCopy, Tooltip: utils.ResolvePlaceholderString(self.c.Tr.CherryPickCopyTooltip, map[string]string{ - "paste": opts.Config.Commits.PasteCommits, - "escape": opts.Config.Universal.Return, + "paste": opts.Config.Commits.PasteCommits.String(), + "escape": opts.Config.Universal.Return.String(), }, ), DisplayOnScreen: true, diff --git a/pkg/gui/controllers/commit_description_controller.go b/pkg/gui/controllers/commit_description_controller.go index 0e5102e3e..755fddb56 100644 --- a/pkg/gui/controllers/commit_description_controller.go +++ b/pkg/gui/controllers/commit_description_controller.go @@ -4,6 +4,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" ) type CommitDescriptionController struct { @@ -67,22 +68,24 @@ func (self *CommitDescriptionController) GetMouseKeybindings(opts types.Keybindi func (self *CommitDescriptionController) GetOnFocus() func(types.OnFocusOpts) { return func(types.OnFocusOpts) { footer := "" - if self.c.UserConfig().Keybinding.Universal.ConfirmInEditor != "" || self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt != "" { - if self.c.UserConfig().Keybinding.Universal.ConfirmInEditor == "" { + mainDisabled := len(self.c.UserConfig().Keybinding.Universal.ConfirmInEditor) > 0 + altDisabled := len(self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt) > 0 + if !mainDisabled || !altDisabled { + if mainDisabled { footer = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionFooter, map[string]string{ - "confirmInEditorKeybinding": self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt, + "confirmInEditorKeybinding": self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt.String(), }) - } else if self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt == "" { + } else if altDisabled { footer = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionFooter, map[string]string{ - "confirmInEditorKeybinding": self.c.UserConfig().Keybinding.Universal.ConfirmInEditor, + "confirmInEditorKeybinding": self.c.UserConfig().Keybinding.Universal.ConfirmInEditor.String(), }) } else { footer = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionFooterTwoBindings, map[string]string{ - "confirmInEditorKeybinding1": self.c.UserConfig().Keybinding.Universal.ConfirmInEditor, - "confirmInEditorKeybinding2": self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt, + "confirmInEditorKeybinding1": self.c.UserConfig().Keybinding.Universal.ConfirmInEditor.String(), + "confirmInEditorKeybinding2": self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt.String(), }) } } @@ -112,7 +115,7 @@ func (self *CommitDescriptionController) handleTogglePanel() error { // ctrl key or fn key, which is unlikely to occur in pasted text. And if // they mapped some *other* command to "", then we're totally out of // luck. - if self.c.GocuiGui().IsPasting && self.c.UserConfig().Keybinding.Universal.TogglePanel == "" { + if self.c.GocuiGui().IsPasting && lo.Contains(self.c.UserConfig().Keybinding.Universal.TogglePanel, "") { // Handling tabs in pasted commit messages is not optimal, but hopefully // good enough for now. We simply insert 4 spaces without worrying about // column alignment. This works well enough for leading indentation, diff --git a/pkg/gui/controllers/commit_message_controller.go b/pkg/gui/controllers/commit_message_controller.go index 4743598f6..098f42d30 100644 --- a/pkg/gui/controllers/commit_message_controller.go +++ b/pkg/gui/controllers/commit_message_controller.go @@ -8,6 +8,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" ) type CommitMessageController struct { @@ -123,7 +124,7 @@ func (self *CommitMessageController) handleTogglePanel() error { // ctrl key or fn key, which is unlikely to occur in pasted text. And if // they mapped some *other* command to "", then we're totally out of // luck. - if self.c.GocuiGui().IsPasting && self.c.UserConfig().Keybinding.Universal.TogglePanel == "" { + if self.c.GocuiGui().IsPasting && lo.Contains(self.c.UserConfig().Keybinding.Universal.TogglePanel, "") { // It is unlikely that a pasted commit message contains a tab in the // subject line, so it shouldn't matter too much how we handle it. // Simply insert 4 spaces instead; all that matters is that we don't @@ -183,7 +184,7 @@ func (self *CommitMessageController) confirm() error { // to some ctrl key or fn key, which is unlikely to occur in pasted text. // And if they mapped some *other* command to "", then we're totally // out of luck. - if self.c.GocuiGui().IsPasting && self.c.UserConfig().Keybinding.Universal.SubmitEditorText == "" { + if self.c.GocuiGui().IsPasting && lo.Contains(self.c.UserConfig().Keybinding.Universal.SubmitEditorText, "") { return self.switchToCommitDescription() } diff --git a/pkg/gui/controllers/helpers/tags_helper.go b/pkg/gui/controllers/helpers/tags_helper.go index 6a7e47219..650e4162c 100644 --- a/pkg/gui/controllers/helpers/tags_helper.go +++ b/pkg/gui/controllers/helpers/tags_helper.go @@ -28,8 +28,8 @@ func (self *TagsHelper) OpenCreateTagPrompt(ref string, onCreate func()) error { self.c.Tr.ForceTagPrompt, map[string]string{ "tagName": tagName, - "cancelKey": self.c.UserConfig().Keybinding.Universal.Return, - "confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm, + "cancelKey": self.c.UserConfig().Keybinding.Universal.Return.String(), + "confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm.String(), }, ) force := self.c.Git().Tag.HasTag(tagName) diff --git a/pkg/gui/controllers/jump_to_side_window_controller.go b/pkg/gui/controllers/jump_to_side_window_controller.go index 2ea8ac762..31228e3a6 100644 --- a/pkg/gui/controllers/jump_to_side_window_controller.go +++ b/pkg/gui/controllers/jump_to_side_window_controller.go @@ -3,6 +3,7 @@ package controllers import ( "log" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) @@ -39,7 +40,7 @@ func (self *JumpToSideWindowController) GetKeybindings(opts types.KeybindingsOpt return &types.Binding{ ViewName: "", // by default the keys are 1, 2, 3, etc - Keys: opts.GetKeys(opts.Config.Universal.JumpToBlock[index]), + Keys: opts.GetKeys(config.Keybinding{opts.Config.Universal.JumpToBlock[index]}), Handler: opts.Guards.NoPopupPanel(self.goToSideWindow(window)), } }) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index b0cad6586..4ab436bbc 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -141,7 +141,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ GetDisabledReason: self.require(self.notMidRebase(self.c.Tr.AlreadyRebasing), self.canFindCommitForQuickStart), Description: self.c.Tr.QuickStartInteractiveRebase, Tooltip: utils.ResolvePlaceholderString(self.c.Tr.QuickStartInteractiveRebaseTooltip, map[string]string{ - "editKey": editCommitKey, + "editKey": editCommitKey.String(), }), }, { @@ -161,7 +161,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ Tooltip: utils.ResolvePlaceholderString( self.c.Tr.CreateFixupCommitTooltip, map[string]string{ - "squashAbove": opts.Config.Commits.SquashAboveCommits, + "squashAbove": opts.Config.Commits.SquashAboveCommits.String(), }, ), }, @@ -679,7 +679,7 @@ func (self *LocalCommitsController) findCommitForQuickStartInteractiveRebase() ( if !ok || index == 0 { errorMsg := utils.ResolvePlaceholderString(self.c.Tr.CannotQuickStartInteractiveRebase, map[string]string{ - "editKey": self.c.UserConfig().Keybinding.Universal.Edit, + "editKey": self.c.UserConfig().Keybinding.Universal.Edit.String(), }) return nil, errors.New(errorMsg) diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go index 7d6be1e82..97b7ff3dd 100644 --- a/pkg/gui/controllers/submodules_controller.go +++ b/pkg/gui/controllers/submodules_controller.go @@ -44,7 +44,7 @@ func (self *SubmodulesController) GetKeybindings(opts types.KeybindingsOpts) []* GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Enter, Tooltip: utils.ResolvePlaceholderString(self.c.Tr.EnterSubmoduleTooltip, - map[string]string{"escape": opts.Config.Universal.Return}), + map[string]string{"escape": opts.Config.Universal.Return.String()}), DisplayOnScreen: true, }, { diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index 4e66e2708..649b53338 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -256,8 +256,8 @@ func (self *SyncController) forcePushPrompt() string { return utils.ResolvePlaceholderString( self.c.Tr.ForcePushPrompt, map[string]string{ - "cancelKey": self.c.UserConfig().Keybinding.Universal.Return, - "confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm, + "cancelKey": self.c.UserConfig().Keybinding.Universal.Return.String(), + "confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm.String(), }, ) } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 458e33804..e2881cca1 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -472,15 +472,15 @@ func (gui *Gui) onUserConfigLoaded() error { gui.setColorScheme() gui.configureViewProperties() - gui.g.SearchEscapeKey = config.GetValidatedKeyBindingKey(userConfig.Keybinding.Universal.Return) - gui.g.NextSearchMatchKey = config.GetValidatedKeyBindingKey(userConfig.Keybinding.Universal.NextMatch) - gui.g.PrevSearchMatchKey = config.GetValidatedKeyBindingKey(userConfig.Keybinding.Universal.PrevMatch) + gui.g.SearchEscapeKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.Return) + gui.g.NextSearchMatchKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.NextMatch) + gui.g.PrevSearchMatchKeys = config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.PrevMatch) gui.g.SetEditKeybindings( - config.GetValidatedKeyBindingKey(userConfig.Keybinding.Universal.MoveWordLeft), - config.GetValidatedKeyBindingKey(userConfig.Keybinding.Universal.MoveWordRight), - config.GetValidatedKeyBindingKey(userConfig.Keybinding.Universal.BackspaceWord), - config.GetValidatedKeyBindingKey(userConfig.Keybinding.Universal.ForwardDeleteWord), + config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.MoveWordLeft), + config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.MoveWordRight), + config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.BackspaceWord), + config.GetValidatedKeyBindingKeys(userConfig.Keybinding.Universal.ForwardDeleteWord), ) gui.g.ShowListFooter = userConfig.Gui.ShowListFooter @@ -1089,7 +1089,7 @@ func (gui *Gui) showIntroPopupMessage() { introMessage := utils.ResolvePlaceholderString( gui.c.Tr.IntroPopupMessage, map[string]string{ - "confirmationKey": gui.c.UserConfig().Keybinding.Universal.Confirm, + "confirmationKey": gui.c.UserConfig().Keybinding.Universal.Confirm.String(), }, ) diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index e43ac1d29..336f7601b 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -28,10 +28,10 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error { maxColumnSize := 1 essentialKeys := []gocui.Key{ - config.GetValidatedKeyBindingKey(gui.c.UserConfig().Keybinding.Universal.ConfirmMenu), - config.GetValidatedKeyBindingKey(gui.c.UserConfig().Keybinding.Universal.Return), - config.GetValidatedKeyBindingKey(gui.c.UserConfig().Keybinding.Universal.PrevItem), - config.GetValidatedKeyBindingKey(gui.c.UserConfig().Keybinding.Universal.NextItem), + config.GetValidatedKeyBindingKeys(gui.c.UserConfig().Keybinding.Universal.ConfirmMenu)[0], + config.GetValidatedKeyBindingKeys(gui.c.UserConfig().Keybinding.Universal.Return)[0], + config.GetValidatedKeyBindingKeys(gui.c.UserConfig().Keybinding.Universal.PrevItem)[0], + config.GetValidatedKeyBindingKeys(gui.c.UserConfig().Keybinding.Universal.NextItem)[0], } for _, item := range opts.Items { diff --git a/pkg/gui/options_map.go b/pkg/gui/options_map.go index c0d0fcfff..890d2f2de 100644 --- a/pkg/gui/options_map.go +++ b/pkg/gui/options_map.go @@ -70,7 +70,7 @@ func (self *OptionsMapMgr) renderContextOptionsMap() { if currentContext.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY { if self.c.Modes().CherryPicking.Active() { optionsMap = utils.Prepend(optionsMap, bindingInfo{ - key: self.c.KeybindingsOpts().Config.Commits.PasteCommits, + key: self.c.KeybindingsOpts().Config.Commits.PasteCommits.String(), description: self.c.Tr.PasteCommits, style: style.FgCyan, }) @@ -78,7 +78,7 @@ func (self *OptionsMapMgr) renderContextOptionsMap() { if self.c.Model().BisectInfo.Started() { optionsMap = utils.Prepend(optionsMap, bindingInfo{ - key: self.c.KeybindingsOpts().Config.Commits.ViewBisectOptions, + key: self.c.KeybindingsOpts().Config.Commits.ViewBisectOptions.String(), description: self.c.Tr.ViewBisectOptions, style: style.FgGreen, }) @@ -88,7 +88,7 @@ func (self *OptionsMapMgr) renderContextOptionsMap() { // Mode-specific global keybindings if state := self.c.Model().WorkingTreeStateAtLastCommitRefresh; state.Any() { optionsMap = utils.Prepend(optionsMap, bindingInfo{ - key: self.c.KeybindingsOpts().Config.Universal.CreateRebaseOptionsMenu, + key: self.c.KeybindingsOpts().Config.Universal.CreateRebaseOptionsMenu.String(), description: state.OptionsMapTitle(self.c.Tr), style: style.FgYellow, }) @@ -96,7 +96,7 @@ func (self *OptionsMapMgr) renderContextOptionsMap() { if self.c.Git().Patch.PatchBuilder.Active() { optionsMap = utils.Prepend(optionsMap, bindingInfo{ - key: self.c.KeybindingsOpts().Config.Universal.CreatePatchOptionsMenu, + key: self.c.KeybindingsOpts().Config.Universal.CreatePatchOptionsMenu.String(), description: self.c.Tr.ViewPatchOptions, style: style.FgYellow, }) diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index dcf4b9a1a..416b39b95 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -239,7 +239,7 @@ type OnFocusLostOpts struct { type ContextKey string type KeybindingsOpts struct { - GetKeys func(key string) []gocui.Key + GetKeys func(keys config.Keybinding) []gocui.Key Config config.KeybindingConfig Guards KeybindingGuards } diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 093257998..297da143f 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -236,7 +236,7 @@ func (gui *Gui) configureViewProperties() { gui.Views.Stash.TitlePrefix = jumpLabels[4] - gui.Views.Main.TitlePrefix = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView) + gui.Views.Main.TitlePrefix = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView[0]) } else { gui.Views.Status.TitlePrefix = "" diff --git a/pkg/integration/components/commit_description_panel_driver.go b/pkg/integration/components/commit_description_panel_driver.go index caf6d1b8a..6281e756b 100644 --- a/pkg/integration/components/commit_description_panel_driver.go +++ b/pkg/integration/components/commit_description_panel_driver.go @@ -42,7 +42,7 @@ func (self *CommitDescriptionPanelDriver) GoToBeginning() *CommitDescriptionPane } func (self *CommitDescriptionPanelDriver) AddCoAuthor(author string) *CommitDescriptionPanelDriver { - self.t.press(self.t.keys.CommitMessage.CommitMenu) + self.t.press(self.t.keys.CommitMessage.CommitMenu[0]) self.t.ExpectPopup().Menu().Title(Equals("Commit Menu")). Select(Contains("Add co-author")). Confirm() diff --git a/pkg/integration/components/commit_message_panel_driver.go b/pkg/integration/components/commit_message_panel_driver.go index 047cc59b1..f24950f13 100644 --- a/pkg/integration/components/commit_message_panel_driver.go +++ b/pkg/integration/components/commit_message_panel_driver.go @@ -73,6 +73,6 @@ func (self *CommitMessagePanelDriver) SelectNextMessage() *CommitMessagePanelDri } func (self *CommitMessagePanelDriver) OpenCommitMenu() *CommitMessagePanelDriver { - self.t.press(self.t.keys.CommitMessage.CommitMenu) + self.t.press(self.t.keys.CommitMessage.CommitMenu[0]) return self } diff --git a/pkg/integration/components/prompt_driver.go b/pkg/integration/components/prompt_driver.go index 2c29dd7c4..1000af007 100644 --- a/pkg/integration/components/prompt_driver.go +++ b/pkg/integration/components/prompt_driver.go @@ -68,7 +68,7 @@ func (self *PromptDriver) SuggestionTopLines(matchers ...*TextMatcher) *PromptDr } func (self *PromptDriver) ConfirmFirstSuggestion() { - self.t.press(self.t.keys.Universal.TogglePanel) + self.t.press(self.t.keys.Universal.TogglePanel[0]) self.t.Views().Suggestions(). IsFocused(). SelectedLineIdx(0). @@ -76,7 +76,7 @@ func (self *PromptDriver) ConfirmFirstSuggestion() { } func (self *PromptDriver) ConfirmSuggestion(matcher *TextMatcher) { - self.t.press(self.t.keys.Universal.TogglePanel) + self.t.press(self.t.keys.Universal.TogglePanel[0]) self.t.Views().Suggestions(). IsFocused(). NavigateToLine(matcher). @@ -84,19 +84,19 @@ func (self *PromptDriver) ConfirmSuggestion(matcher *TextMatcher) { } func (self *PromptDriver) DeleteSuggestion(matcher *TextMatcher) *PromptDriver { - self.t.press(self.t.keys.Universal.TogglePanel) + self.t.press(self.t.keys.Universal.TogglePanel[0]) self.t.Views().Suggestions(). IsFocused(). NavigateToLine(matcher) - self.t.press(self.t.keys.Universal.Remove) + self.t.press(self.t.keys.Universal.Remove[0]) return self } func (self *PromptDriver) EditSuggestion(matcher *TextMatcher) *PromptDriver { - self.t.press(self.t.keys.Universal.TogglePanel) + self.t.press(self.t.keys.Universal.TogglePanel[0]) self.t.Views().Suggestions(). IsFocused(). NavigateToLine(matcher) - self.t.press(self.t.keys.Universal.Edit) + self.t.press(self.t.keys.Universal.Edit[0]) return self } diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index a1775239c..8294f3b46 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -52,8 +52,8 @@ func (self *TestDriver) click(x, y int) { // 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(...) -func (self *TestDriver) GlobalPress(keyStr string) { - self.press(keyStr) +func (self *TestDriver) GlobalPress(key config.Keybinding) { + self.press(key[0]) } func (self *TestDriver) typeContent(content string) { diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index ee7c5b68a..8b1650c32 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/samber/lo" ) @@ -376,11 +377,11 @@ func (self *ViewDriver) Focus() *ViewDriver { currentViewTabIndex := lo.IndexOf(window.viewNames, currentViewName) if tabIndex > currentViewTabIndex { for range tabIndex - currentViewTabIndex { - self.t.press(self.t.keys.Universal.NextTab) + self.t.press(self.t.keys.Universal.NextTab[0]) } } else if tabIndex < currentViewTabIndex { for range currentViewTabIndex - tabIndex { - self.t.press(self.t.keys.Universal.PrevTab) + self.t.press(self.t.keys.Universal.PrevTab[0]) } } @@ -407,10 +408,10 @@ func (self *ViewDriver) IsFocused() *ViewDriver { return self } -func (self *ViewDriver) Press(keyStr string) *ViewDriver { +func (self *ViewDriver) Press(key config.Keybinding) *ViewDriver { self.IsFocused() - self.t.press(keyStr) + self.t.press(key[0]) return self } @@ -423,10 +424,10 @@ func (self *ViewDriver) Delay() *ViewDriver { // for use when typing or navigating, because in demos we want that to happen // faster -func (self *ViewDriver) PressFast(keyStr string) *ViewDriver { +func (self *ViewDriver) PressFast(key config.Keybinding) *ViewDriver { self.IsFocused() - self.t.pressFast(keyStr) + self.t.pressFast(key[0]) return self } diff --git a/pkg/integration/tests/commit/search.go b/pkg/integration/tests/commit/search.go index 5439a1b32..25f6bb233 100644 --- a/pkg/integration/tests/commit/search.go +++ b/pkg/integration/tests/commit/search.go @@ -58,7 +58,7 @@ var Search = NewIntegrationTest(NewIntegrationTestArgs{ Contains("two").IsSelected(), Contains("one"), ). - Press("n"). + Press(config.Keybinding{"n"}). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'o' (3 of 3)")) }). @@ -68,7 +68,7 @@ var Search = NewIntegrationTest(NewIntegrationTestArgs{ Contains("two"), Contains("one").IsSelected(), ). - Press("n"). + Press(config.Keybinding{"n"}). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'o' (1 of 3)")) }). @@ -78,7 +78,7 @@ var Search = NewIntegrationTest(NewIntegrationTestArgs{ Contains("two"), Contains("one"), ). - Press("n"). + Press(config.Keybinding{"n"}). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'o' (2 of 3)")) }). @@ -88,7 +88,7 @@ var Search = NewIntegrationTest(NewIntegrationTestArgs{ Contains("two").IsSelected(), Contains("one"), ). - Press("N"). + Press(config.Keybinding{"N"}). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'o' (1 of 3)")) }). @@ -98,7 +98,7 @@ var Search = NewIntegrationTest(NewIntegrationTestArgs{ Contains("two"), Contains("one"), ). - Press("N"). + Press(config.Keybinding{"N"}). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'o' (3 of 3)")) }). @@ -112,7 +112,7 @@ var Search = NewIntegrationTest(NewIntegrationTestArgs{ Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'o' (1 of 3)")) }). - Press("N"). + Press(config.Keybinding{"N"}). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 'o' (1 of 3)")) }). @@ -146,7 +146,7 @@ var Search = NewIntegrationTest(NewIntegrationTestArgs{ Contains("two"), Contains("one"), ). - Press("n"). + Press(config.Keybinding{"n"}). Tap(func() { t.Views().Search().IsVisible().Content(Contains("matches for 't' (1 of 2)")) }). diff --git a/pkg/integration/tests/config/custom_commands_in_per_repo_config.go b/pkg/integration/tests/config/custom_commands_in_per_repo_config.go index 81f8724aa..4a66cd43c 100644 --- a/pkg/integration/tests/config/custom_commands_in_per_repo_config.go +++ b/pkg/integration/tests/config/custom_commands_in_per_repo_config.go @@ -48,13 +48,13 @@ customCommands: ).Confirm() t.Views().Status().Content(Contains("other → master")) - t.GlobalPress("X") + t.GlobalPress(config.Keybinding{"X"}) t.FileSystem().FileContent("../other/file.txt", Equals("global X")) - t.GlobalPress("Y") + t.GlobalPress(config.Keybinding{"Y"}) t.FileSystem().FileContent("../other/file.txt", Equals("local Y")) - t.GlobalPress("Z") + t.GlobalPress(config.Keybinding{"Z"}) t.FileSystem().FileContent("../other/file.txt", Equals("local Z")) }, }) diff --git a/pkg/integration/tests/custom_commands/access_commit_properties.go b/pkg/integration/tests/custom_commands/access_commit_properties.go index 22d1d0631..a1ba84a27 100644 --- a/pkg/integration/tests/custom_commands/access_commit_properties.go +++ b/pkg/integration/tests/custom_commands/access_commit_properties.go @@ -29,7 +29,7 @@ var AccessCommitProperties = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("my change").IsSelected(), ). - Press("X") + Press(config.Keybinding{"X"}) hash := t.Git().GetCommitHash("HEAD") t.FileSystem().FileContent("file.txt", Equals(fmt.Sprintf("my change\n%s\n%s", hash, hash))) diff --git a/pkg/integration/tests/custom_commands/basic_command.go b/pkg/integration/tests/custom_commands/basic_command.go index 10a9058b7..b50acc792 100644 --- a/pkg/integration/tests/custom_commands/basic_command.go +++ b/pkg/integration/tests/custom_commands/basic_command.go @@ -25,7 +25,7 @@ var BasicCommand = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files(). IsEmpty(). IsFocused(). - Press("a"). + Press(config.Keybinding{"a"}). Lines( Contains("myfile"), ) diff --git a/pkg/integration/tests/custom_commands/check_for_conflicts.go b/pkg/integration/tests/custom_commands/check_for_conflicts.go index d3376b456..70f528a6d 100644 --- a/pkg/integration/tests/custom_commands/check_for_conflicts.go +++ b/pkg/integration/tests/custom_commands/check_for_conflicts.go @@ -35,7 +35,7 @@ var CheckForConflicts = NewIntegrationTest(NewIntegrationTestArgs{ Contains("second-change-branch"), ). NavigateToLine(Contains("second-change-branch")). - Press("m") + Press(config.Keybinding{"m"}) t.Common().AcknowledgeConflicts() }, diff --git a/pkg/integration/tests/custom_commands/conditional_prompt_false_string.go b/pkg/integration/tests/custom_commands/conditional_prompt_false_string.go index a60db1036..d2c6beadb 100644 --- a/pkg/integration/tests/custom_commands/conditional_prompt_false_string.go +++ b/pkg/integration/tests/custom_commands/conditional_prompt_false_string.go @@ -55,7 +55,7 @@ var ConditionalPromptFalseString = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). - Press("a") + Press(config.Keybinding{"a"}) t.ExpectPopup().Menu().Title(Equals("Pick one")).Select(Contains("foo")).Confirm() diff --git a/pkg/integration/tests/custom_commands/conditional_prompt_false_value.go b/pkg/integration/tests/custom_commands/conditional_prompt_false_value.go index 44378ce22..4a0326669 100644 --- a/pkg/integration/tests/custom_commands/conditional_prompt_false_value.go +++ b/pkg/integration/tests/custom_commands/conditional_prompt_false_value.go @@ -37,7 +37,7 @@ var ConditionalPromptFalseValue = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). - Press("a") + Press(config.Keybinding{"a"}) t.ExpectPopup().Prompt().Title(Equals("Enter a word")).Type("false").Confirm() diff --git a/pkg/integration/tests/custom_commands/conditional_prompts.go b/pkg/integration/tests/custom_commands/conditional_prompts.go index 36aab67df..4f1f97531 100644 --- a/pkg/integration/tests/custom_commands/conditional_prompts.go +++ b/pkg/integration/tests/custom_commands/conditional_prompts.go @@ -52,12 +52,12 @@ var ConditionalPrompts = NewIntegrationTest(NewIntegrationTestArgs{ // Test 1: Select "first" via key — conditional prompt should be skipped t.Views().Files(). IsFocused(). - Press("a") + Press(config.Keybinding{"a"}) t.ExpectPopup().Menu(). Title(Equals("Choose an option")) - t.Views().Menu().Press("1") + t.Views().Menu().Press(config.Keybinding{"1"}) // Detail prompt should be skipped, file should be created directly t.Views().Files(). @@ -75,12 +75,12 @@ var ConditionalPrompts = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files(). IsEmpty(). IsFocused(). - Press("a") + Press(config.Keybinding{"a"}) t.ExpectPopup().Menu(). Title(Equals("Choose an option")) - t.Views().Menu().Press("H") + t.Views().Menu().Press(config.Keybinding{"H"}) // Detail prompt should appear because Choice == "SECOND" t.ExpectPopup().Prompt().Title(Equals("Enter detail for second option")).Type("extra").Confirm() diff --git a/pkg/integration/tests/custom_commands/custom_commands_submenu.go b/pkg/integration/tests/custom_commands/custom_commands_submenu.go index a8d13cf73..93c670449 100644 --- a/pkg/integration/tests/custom_commands/custom_commands_submenu.go +++ b/pkg/integration/tests/custom_commands/custom_commands_submenu.go @@ -39,7 +39,7 @@ var CustomCommandsSubmenu = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files(). Focus(). IsEmpty(). - Press("x"). + Press(config.Keybinding{"x"}). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("My Custom Commands")). @@ -55,7 +55,7 @@ var CustomCommandsSubmenu = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). - Press("x"). + Press(config.Keybinding{"x"}). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("My Custom Commands")). @@ -63,7 +63,7 @@ var CustomCommandsSubmenu = NewIntegrationTest(NewIntegrationTestArgs{ Contains("1 touch myfile-global"), Contains("3 touch myfile-commits"), ) - t.GlobalPress("3") + t.GlobalPress(config.Keybinding{"3"}) }) t.Views().Files(). diff --git a/pkg/integration/tests/custom_commands/custom_commands_submenu_with_special_keybindings.go b/pkg/integration/tests/custom_commands/custom_commands_submenu_with_special_keybindings.go index a1f62aff6..29638ee92 100644 --- a/pkg/integration/tests/custom_commands/custom_commands_submenu_with_special_keybindings.go +++ b/pkg/integration/tests/custom_commands/custom_commands_submenu_with_special_keybindings.go @@ -43,13 +43,13 @@ var CustomCommandsSubmenuWithSpecialKeybindings = NewIntegrationTest(NewIntegrat }, }, } - cfg.GetUserConfig().Keybinding.Universal.ConfirmMenu = "y" + cfg.GetUserConfig().Keybinding.Universal.ConfirmMenu = config.Keybinding{"y"} }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). Focus(). IsEmpty(). - Press("x"). + Press(config.Keybinding{"x"}). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("My Custom Commands")). @@ -59,14 +59,14 @@ var CustomCommandsSubmenuWithSpecialKeybindings = NewIntegrationTest(NewIntegrat Contains(" echo y"), Contains(" echo down"), ) - t.GlobalPress("j") + t.GlobalPress(config.Keybinding{"j"}) t.ExpectPopup().Alert().Title(Equals("echo j")).Content(Equals("j")).Confirm() }). - Press("x"). + Press(config.Keybinding{"x"}). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("My Custom Commands")) - t.GlobalPress("H") + t.GlobalPress(config.Keybinding{"H"}) t.ExpectPopup().Alert().Title(Equals("echo H")).Content(Equals("H")).Confirm() }) }, diff --git a/pkg/integration/tests/custom_commands/form_prompts.go b/pkg/integration/tests/custom_commands/form_prompts.go index ccb2339de..3e051cb8e 100644 --- a/pkg/integration/tests/custom_commands/form_prompts.go +++ b/pkg/integration/tests/custom_commands/form_prompts.go @@ -59,7 +59,7 @@ var FormPrompts = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files(). IsEmpty(). IsFocused(). - Press("a") + Press(config.Keybinding{"a"}) t.ExpectPopup().Prompt().Title(Equals("Enter a file name")).Type("my file").Confirm() diff --git a/pkg/integration/tests/custom_commands/global_context.go b/pkg/integration/tests/custom_commands/global_context.go index 82ef53010..274d6a7ba 100644 --- a/pkg/integration/tests/custom_commands/global_context.go +++ b/pkg/integration/tests/custom_commands/global_context.go @@ -25,7 +25,7 @@ var GlobalContext = NewIntegrationTest(NewIntegrationTestArgs{ // commits t.Views().Commits(). Focus(). - Press("X") + Press(config.Keybinding{"X"}) t.Views().Files(). Focus(). @@ -37,7 +37,7 @@ var GlobalContext = NewIntegrationTest(NewIntegrationTestArgs{ // branches t.Views().Branches(). Focus(). - Press("X") + Press(config.Keybinding{"X"}) t.Views().Files(). Focus(). @@ -49,7 +49,7 @@ var GlobalContext = NewIntegrationTest(NewIntegrationTestArgs{ // files t.Views().Files(). Focus(). - Press("X") + Press(config.Keybinding{"X"}) t.Views().Files(). Focus(). diff --git a/pkg/integration/tests/custom_commands/menu_from_command.go b/pkg/integration/tests/custom_commands/menu_from_command.go index 10b8192ba..ae62c9a5c 100644 --- a/pkg/integration/tests/custom_commands/menu_from_command.go +++ b/pkg/integration/tests/custom_commands/menu_from_command.go @@ -48,7 +48,7 @@ var MenuFromCommand = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Branches(). Focus(). - Press("a") + Press(config.Keybinding{"a"}) t.ExpectPopup().Menu().Title(Equals("Choose commit message")).Select(Contains("bar")).Confirm() diff --git a/pkg/integration/tests/custom_commands/menu_from_commands_output.go b/pkg/integration/tests/custom_commands/menu_from_commands_output.go index 591daa5af..7820bc515 100644 --- a/pkg/integration/tests/custom_commands/menu_from_commands_output.go +++ b/pkg/integration/tests/custom_commands/menu_from_commands_output.go @@ -46,7 +46,7 @@ var MenuFromCommandsOutput = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Branches(). Focus(). - Press("a") + Press(config.Keybinding{"a"}) t.ExpectPopup().Prompt(). Title(Equals("Which git command do you want to run?")). diff --git a/pkg/integration/tests/custom_commands/menu_prompt_with_keys.go b/pkg/integration/tests/custom_commands/menu_prompt_with_keys.go index f7f733d9c..d29a049f9 100644 --- a/pkg/integration/tests/custom_commands/menu_prompt_with_keys.go +++ b/pkg/integration/tests/custom_commands/menu_prompt_with_keys.go @@ -51,14 +51,14 @@ var MenuPromptWithKeys = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Files(). IsFocused(). - Press("a") + Press(config.Keybinding{"a"}) t.ExpectPopup().Menu(). Title(Equals("Choose an option")) // 'H' is normally a navigation key (ScrollLeft), so this tests that menu item // keybindings have proper precedence over non-essential navigation keys - t.Views().Menu().Press("H") + t.Views().Menu().Press(config.Keybinding{"H"}) t.FileSystem().FileContent("result.txt", Equals("SECOND\n")) }, diff --git a/pkg/integration/tests/custom_commands/multiple_contexts.go b/pkg/integration/tests/custom_commands/multiple_contexts.go index 61d46775b..b53242edc 100644 --- a/pkg/integration/tests/custom_commands/multiple_contexts.go +++ b/pkg/integration/tests/custom_commands/multiple_contexts.go @@ -25,7 +25,7 @@ var MultipleContexts = NewIntegrationTest(NewIntegrationTestArgs{ // commits t.Views().Commits(). Focus(). - Press("X") + Press(config.Keybinding{"X"}) t.Views().Files(). Focus(). @@ -37,7 +37,7 @@ var MultipleContexts = NewIntegrationTest(NewIntegrationTestArgs{ // branches t.Views().Branches(). Focus(). - Press("X") + Press(config.Keybinding{"X"}) t.Views().Files(). Focus(). @@ -46,7 +46,7 @@ var MultipleContexts = NewIntegrationTest(NewIntegrationTestArgs{ // files t.Views().ReflogCommits(). Focus(). - Press("X") + Press(config.Keybinding{"X"}) t.Views().Files(). Focus(). diff --git a/pkg/integration/tests/custom_commands/multiple_prompts.go b/pkg/integration/tests/custom_commands/multiple_prompts.go index b40aa77f2..a450f7aee 100644 --- a/pkg/integration/tests/custom_commands/multiple_prompts.go +++ b/pkg/integration/tests/custom_commands/multiple_prompts.go @@ -57,7 +57,7 @@ var MultiplePrompts = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files(). IsEmpty(). IsFocused(). - Press("a") + Press(config.Keybinding{"a"}) t.ExpectPopup().Prompt().Title(Equals("Enter a file name")).Type("myfile").Confirm() diff --git a/pkg/integration/tests/custom_commands/run_command.go b/pkg/integration/tests/custom_commands/run_command.go index 107b9e285..ca9abb658 100644 --- a/pkg/integration/tests/custom_commands/run_command.go +++ b/pkg/integration/tests/custom_commands/run_command.go @@ -32,7 +32,7 @@ var RunCommand = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Branches(). Focus(). - Press("a") + Press(config.Keybinding{"a"}) t.ExpectPopup().Prompt(). Title(Equals("Enter a branch name")). diff --git a/pkg/integration/tests/custom_commands/selected_commit.go b/pkg/integration/tests/custom_commands/selected_commit.go index 6288634f1..49595afd0 100644 --- a/pkg/integration/tests/custom_commands/selected_commit.go +++ b/pkg/integration/tests/custom_commands/selected_commit.go @@ -34,34 +34,34 @@ var SelectedCommit = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit 03")) // SubCommits - t.GlobalPress("X") + t.GlobalPress(config.Keybinding{"X"}) t.FileSystem().FileContent("file.txt", Equals("commit 03")) t.Views().SubCommits().PressEnter() - t.GlobalPress("X") + t.GlobalPress(config.Keybinding{"X"}) t.FileSystem().FileContent("file.txt", Equals("commit 03")) // ReflogCommits t.Views().ReflogCommits().Focus() - t.GlobalPress("X") + t.GlobalPress(config.Keybinding{"X"}) t.FileSystem().FileContent("file.txt", Equals("commit: commit 02")) t.Views().ReflogCommits().PressEnter() - t.GlobalPress("X") + t.GlobalPress(config.Keybinding{"X"}) t.FileSystem().FileContent("file.txt", Equals("commit: commit 02")) // LocalCommits t.Views().Commits().Focus() - t.GlobalPress("X") + t.GlobalPress(config.Keybinding{"X"}) t.FileSystem().FileContent("file.txt", Equals("commit 01")) t.Views().Commits().PressEnter() - t.GlobalPress("X") + t.GlobalPress(config.Keybinding{"X"}) t.FileSystem().FileContent("file.txt", Equals("commit 01")) // None of these t.Views().Files().Focus() - t.GlobalPress("X") + t.GlobalPress(config.Keybinding{"X"}) t.FileSystem().FileContent("file.txt", Equals("commit 01")) }, }) diff --git a/pkg/integration/tests/custom_commands/selected_commit_range.go b/pkg/integration/tests/custom_commands/selected_commit_range.go index 1a4b3087c..f335d91d9 100644 --- a/pkg/integration/tests/custom_commands/selected_commit_range.go +++ b/pkg/integration/tests/custom_commands/selected_commit_range.go @@ -29,13 +29,13 @@ var SelectedCommitRange = NewIntegrationTest(NewIntegrationTestArgs{ Contains("commit 01"), ) - t.GlobalPress("X") + t.GlobalPress(config.Keybinding{"X"}) t.FileSystem().FileContent("file.txt", Equals("commit 03\n")) t.Views().Commits().Focus(). Press(keys.Universal.RangeSelectDown) - t.GlobalPress("X") + t.GlobalPress(config.Keybinding{"X"}) t.FileSystem().FileContent("file.txt", Equals("commit 03\ncommit 02\n")) }, }) diff --git a/pkg/integration/tests/custom_commands/selected_path.go b/pkg/integration/tests/custom_commands/selected_path.go index 9dc63ed43..c6c680335 100644 --- a/pkg/integration/tests/custom_commands/selected_path.go +++ b/pkg/integration/tests/custom_commands/selected_path.go @@ -29,7 +29,7 @@ var SelectedPath = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files(). Focus(). NavigateToLine(Contains("file2")) - t.GlobalPress("X") + t.GlobalPress(config.Keybinding{"X"}) t.FileSystem().FileContent("file.txt", Equals("folder2/file2")) t.Views().Commits(). @@ -38,7 +38,7 @@ var SelectedPath = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().CommitFiles(). IsFocused(). NavigateToLine(Contains("file1")) - t.GlobalPress("X") + t.GlobalPress(config.Keybinding{"X"}) t.FileSystem().FileContent("file.txt", Equals("folder1/file1")) }, }) diff --git a/pkg/integration/tests/custom_commands/selected_submodule.go b/pkg/integration/tests/custom_commands/selected_submodule.go index c4640017d..d03af03dd 100644 --- a/pkg/integration/tests/custom_commands/selected_submodule.go +++ b/pkg/integration/tests/custom_commands/selected_submodule.go @@ -40,13 +40,13 @@ var SelectedSubmodule = NewIntegrationTest(NewIntegrationTestArgs{ Contains("submodule").IsSelected(), ) - t.Views().Submodules().Press("X") + t.Views().Submodules().Press(config.Keybinding{"X"}) t.FileSystem().FileContent("file.txt", Equals("path/submodule")) - t.Views().Submodules().Press("U") + t.Views().Submodules().Press(config.Keybinding{"U"}) t.FileSystem().FileContent("file.txt", Equals("../submodule")) - t.Views().Submodules().Press("N") + t.Views().Submodules().Press(config.Keybinding{"N"}) t.FileSystem().FileContent("file.txt", Equals("submodule")) }, }) diff --git a/pkg/integration/tests/custom_commands/show_output_in_panel.go b/pkg/integration/tests/custom_commands/show_output_in_panel.go index 9fcab1be3..95765cb5f 100644 --- a/pkg/integration/tests/custom_commands/show_output_in_panel.go +++ b/pkg/integration/tests/custom_commands/show_output_in_panel.go @@ -37,7 +37,7 @@ var ShowOutputInPanel = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("my change").IsSelected(), ). - Press("X") + Press(config.Keybinding{"X"}) t.ExpectPopup().Alert(). // Uses cmd string as title if no outputTitle is provided @@ -46,7 +46,7 @@ var ShowOutputInPanel = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() t.Views().Commits(). - Press("Y") + Press(config.Keybinding{"Y"}) hash := t.Git().GetCommitHash("HEAD") t.ExpectPopup().Alert(). diff --git a/pkg/integration/tests/custom_commands/suggestions_command.go b/pkg/integration/tests/custom_commands/suggestions_command.go index 51bbc2c65..831ecddda 100644 --- a/pkg/integration/tests/custom_commands/suggestions_command.go +++ b/pkg/integration/tests/custom_commands/suggestions_command.go @@ -49,7 +49,7 @@ var SuggestionsCommand = NewIntegrationTest(NewIntegrationTestArgs{ Contains("branch-three"), Contains("branch-two"), ). - Press("a") + Press(config.Keybinding{"a"}) t.ExpectPopup().Prompt(). Title(Equals("Enter a branch name")). diff --git a/pkg/integration/tests/custom_commands/suggestions_preset.go b/pkg/integration/tests/custom_commands/suggestions_preset.go index 891ebf725..285d88e01 100644 --- a/pkg/integration/tests/custom_commands/suggestions_preset.go +++ b/pkg/integration/tests/custom_commands/suggestions_preset.go @@ -49,7 +49,7 @@ var SuggestionsPreset = NewIntegrationTest(NewIntegrationTestArgs{ Contains("branch-three"), Contains("branch-two"), ). - Press("a") + Press(config.Keybinding{"a"}) t.ExpectPopup().Prompt(). Title(Equals("Enter a branch name")). diff --git a/pkg/integration/tests/demo/custom_command.go b/pkg/integration/tests/demo/custom_command.go index a65c2c073..f14a19b5f 100644 --- a/pkg/integration/tests/demo/custom_command.go +++ b/pkg/integration/tests/demo/custom_command.go @@ -63,7 +63,7 @@ var CustomCommand = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Branches(). Focus(). Wait(500). - Press("a"). + Press(config.Keybinding{"a"}). Tap(func() { t.Wait(500) 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 1d9ef589f..522ee7689 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 @@ -9,8 +9,8 @@ var FilterMenuWithNoKeybindings = NewIntegrationTest(NewIntegrationTestArgs{ Description: "Filtering the keybindings menu so that only entries without keybinding are left", ExtraCmdArgs: []string{}, Skip: false, - SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Keybinding.Universal.ToggleWhitespaceInDiffView = "" + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Keybinding.Universal.ToggleWhitespaceInDiffView = nil }, SetupRepo: func(shell *Shell) { }, diff --git a/pkg/integration/tests/interactive_rebase/show_exec_todos.go b/pkg/integration/tests/interactive_rebase/show_exec_todos.go index 74d3830f6..959139154 100644 --- a/pkg/integration/tests/interactive_rebase/show_exec_todos.go +++ b/pkg/integration/tests/interactive_rebase/show_exec_todos.go @@ -26,7 +26,7 @@ var ShowExecTodos = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). - Press("X"). + Press(config.Keybinding{"X"}). Tap(func() { t.ExpectPopup().Alert().Title(Equals("Error")).Content(Contains("Rebasing (2/4)Executing: false")).Confirm() }). diff --git a/pkg/integration/tests/misc/disabled_keybindings.go b/pkg/integration/tests/misc/disabled_keybindings.go deleted file mode 100644 index 7ab1ba42a..000000000 --- a/pkg/integration/tests/misc/disabled_keybindings.go +++ /dev/null @@ -1,26 +0,0 @@ -package misc - -import ( - "github.com/jesseduffield/lazygit/pkg/config" - . "github.com/jesseduffield/lazygit/pkg/integration/components" -) - -var DisabledKeybindings = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Confirms you can disable keybindings by setting them to ", - ExtraCmdArgs: []string{}, - Skip: false, - SetupConfig: func(config *config.AppConfig) { - config.GetUserConfig().Keybinding.Universal.PrevItem = "" - config.GetUserConfig().Keybinding.Universal.NextItem = "" - config.GetUserConfig().Keybinding.Universal.NextTab = "" - config.GetUserConfig().Keybinding.Universal.PrevTab = "" - }, - SetupRepo: func(shell *Shell) {}, - Run: func(t *TestDriver, keys config.KeybindingConfig) { - t.Views().Files(). - IsFocused(). - Press("") - - t.Views().Worktrees().IsFocused() - }, -}) diff --git a/pkg/integration/tests/submodule/enter.go b/pkg/integration/tests/submodule/enter.go index 588ae2049..a56dd8910 100644 --- a/pkg/integration/tests/submodule/enter.go +++ b/pkg/integration/tests/submodule/enter.go @@ -44,7 +44,7 @@ var Enter = NewIntegrationTest(NewIntegrationTestArgs{ assertInSubmodule() t.Views().Files().IsFocused(). - Press("e"). + Press(config.Keybinding{"e"}). Tap(func() { t.Views().Commits().Content(Contains("empty commit")) }). diff --git a/pkg/integration/tests/submodule/reset.go b/pkg/integration/tests/submodule/reset.go index 5cd6d58aa..5e7cde7f0 100644 --- a/pkg/integration/tests/submodule/reset.go +++ b/pkg/integration/tests/submodule/reset.go @@ -46,7 +46,7 @@ var Reset = NewIntegrationTest(NewIntegrationTestArgs{ assertInSubmodule() t.Views().Files().IsFocused(). - Press("e"). + Press(config.Keybinding{"e"}). Tap(func() { t.Views().Commits().Content(Contains("empty commit")) t.Views().Files().Content(Contains("my_file")) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index fdccb08bf..aea37515b 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -334,7 +334,6 @@ var tests = []*components.IntegrationTest{ misc.ConfirmOnQuit, misc.CopyConfirmationMessageToClipboard, misc.CopyToClipboard, - misc.DisabledKeybindings, misc.InitialOpen, misc.RecentReposOnLaunch, patch_building.Apply, diff --git a/pkg/integration/tests/ui/disable_switch_tab_with_panel_jump_keys.go b/pkg/integration/tests/ui/disable_switch_tab_with_panel_jump_keys.go index fb1ba5aba..4ca3d1475 100644 --- a/pkg/integration/tests/ui/disable_switch_tab_with_panel_jump_keys.go +++ b/pkg/integration/tests/ui/disable_switch_tab_with_panel_jump_keys.go @@ -15,9 +15,9 @@ var DisableSwitchTabWithPanelJumpKeys = NewIntegrationTest(NewIntegrationTestArg }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Status().Focus(). - Press(keys.Universal.JumpToBlock[1]) + Press(config.Keybinding{keys.Universal.JumpToBlock[1]}) t.Views().Files().IsFocused(). - Press(keys.Universal.JumpToBlock[1]) + Press(config.Keybinding{keys.Universal.JumpToBlock[1]}) // Despite jumping to an already focused panel, // the tab should not change from the base files view diff --git a/pkg/integration/tests/ui/switch_tab_with_panel_jump_keys.go b/pkg/integration/tests/ui/switch_tab_with_panel_jump_keys.go index 4411cb3c6..25676a7ed 100644 --- a/pkg/integration/tests/ui/switch_tab_with_panel_jump_keys.go +++ b/pkg/integration/tests/ui/switch_tab_with_panel_jump_keys.go @@ -16,19 +16,19 @@ var SwitchTabWithPanelJumpKeys = NewIntegrationTest(NewIntegrationTestArgs{ }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Worktrees().Focus(). - Press(keys.Universal.JumpToBlock[2]) + Press(config.Keybinding{keys.Universal.JumpToBlock[2]}) t.Views().Branches().IsFocused(). - Press(keys.Universal.JumpToBlock[2]) + Press(config.Keybinding{keys.Universal.JumpToBlock[2]}) t.Views().Remotes().IsFocused(). - Press(keys.Universal.JumpToBlock[2]) + Press(config.Keybinding{keys.Universal.JumpToBlock[2]}) t.Views().Tags().IsFocused(). - Press(keys.Universal.JumpToBlock[2]) + Press(config.Keybinding{keys.Universal.JumpToBlock[2]}) t.Views().Branches().IsFocused(). - Press(keys.Universal.JumpToBlock[1]) + Press(config.Keybinding{keys.Universal.JumpToBlock[1]}) // When jumping to a panel from a different one, keep its current tab: t.Views().Worktrees().IsFocused() diff --git a/pkg/integration/tests/worktree/custom_command.go b/pkg/integration/tests/worktree/custom_command.go index 00c3c4c06..a3cddd36a 100644 --- a/pkg/integration/tests/worktree/custom_command.go +++ b/pkg/integration/tests/worktree/custom_command.go @@ -32,7 +32,7 @@ var CustomCommand = NewIntegrationTest(NewIntegrationTestArgs{ Contains("linked-worktree"), ). NavigateToLine(Contains("linked-worktree")). - Press("d"). + Press(config.Keybinding{"d"}). Lines( Contains("(main worktree)"), ) diff --git a/pkg/jsonschema/generate.go b/pkg/jsonschema/generate.go index 5e7267e3e..414756b50 100644 --- a/pkg/jsonschema/generate.go +++ b/pkg/jsonschema/generate.go @@ -58,6 +58,7 @@ func customReflect(v *config.UserConfig) *jsonschema.Schema { } filterOutDevComments(r) schema := r.Reflect(v) + inlineKeybindingRefs(schema) defaultConfig := config.GetDefaultConfig() userConfigSchema := schema.Definitions["UserConfig"] @@ -77,6 +78,57 @@ func customReflect(v *config.UserConfig) *jsonschema.Schema { return schema } +// inlineKeybindingRefs replaces every `$ref: #/$defs/Keybinding` in the +// schema with the inlined oneOf union, then drops the Keybinding definition. +// +// The schema generator stores types that implement JSONSchema() as shared +// definitions and uses $ref to point at them. That works for most types +// (where every reference logically points at the same data), but for +// Keybinding fields each property carries its own description and default, +// and writing those onto the shared definition would clobber siblings. +// Inlining sidesteps the issue. +func inlineKeybindingRefs(schema *jsonschema.Schema) { + const ref = "#/$defs/Keybinding" + keybindingDef, ok := schema.Definitions["Keybinding"] + if !ok { + return + } + inline := func(s *jsonschema.Schema) { + desc := s.Description + *s = *keybindingDef + s.Description = desc + } + var visit func(s *jsonschema.Schema) + visit = func(s *jsonschema.Schema) { + if s == nil { + return + } + if s.Properties != nil { + for pair := s.Properties.Oldest(); pair != nil; pair = pair.Next() { + if pair.Value.Ref == ref { + inline(pair.Value) + } else { + visit(pair.Value) + } + } + } + if s.Items != nil { + if s.Items.Ref == ref { + inline(s.Items) + } else { + visit(s.Items) + } + } + if s.AdditionalProperties != nil { + visit(s.AdditionalProperties) + } + } + for _, def := range schema.Definitions { + visit(def) + } + delete(schema.Definitions, "Keybinding") +} + func filterOutDevComments(r *jsonschema.Reflector) { for k, v := range r.CommentMap { commentLines := strings.Split(v, "\n") diff --git a/schema-master/config.json b/schema-master/config.json index d6ad8116d..f2ba2ad46 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -843,15 +843,45 @@ "KeybindingAmendAttributeConfig": { "properties": { "resetAuthor": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "a" }, "setAuthor": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "A" }, "addCoAuthor": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "c" } }, @@ -861,79 +891,269 @@ "KeybindingBranchesConfig": { "properties": { "createPullRequest": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "o" }, "viewPullRequestOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "O" }, "openPullRequestInBrowser": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "G" }, "copyPullRequestURL": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+y\u003e" }, "checkoutBranchByName": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "c" }, "forceCheckoutBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "F" }, "checkoutPreviousBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "-" }, "rebaseBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "r" }, "renameBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "R" }, "mergeIntoCurrentBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "M" }, "moveCommitsToNewBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "N" }, "viewGitFlowOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "i" }, "fastForward": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "f" }, "createTag": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "T" }, "pushTag": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "P" }, "setUpstream": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "u" }, "fetchRemote": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "f" }, "addForkRemote": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "F" }, "sortOrder": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "s" } }, @@ -943,7 +1163,17 @@ "KeybindingCommitFilesConfig": { "properties": { "checkoutCommitFile": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "c" } }, @@ -953,7 +1183,17 @@ "KeybindingCommitMessageConfig": { "properties": { "commitMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+o\u003e" } }, @@ -963,111 +1203,381 @@ "KeybindingCommitsConfig": { "properties": { "squashDown": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "s" }, "renameCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "r" }, "renameCommitWithEditor": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "R" }, "viewResetOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "g" }, "markCommitAsFixup": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "f" }, "setFixupMessage": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "c" }, "createFixupCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "F" }, "squashAboveCommits": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "S" }, "moveDownCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+j\u003e" }, "moveUpCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+k\u003e" }, "amendToCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "A" }, "resetCommitAuthor": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "a" }, "pickCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "p" }, "revertCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "t" }, "cherryPickCopy": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "C" }, "pasteCommits": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "V" }, "markCommitAsBaseForRebase": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "B" }, "tagCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "T" }, "checkoutCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cspace\u003e" }, "resetCherryPick": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+r\u003e" }, "copyCommitAttributeToClipboard": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "y" }, "openLogMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+l\u003e" }, "openInBrowser": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "o" }, "openPullRequestInBrowser": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "G" }, "viewBisectOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "b" }, "startInteractiveRebase": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "i" }, "selectCommitsOfCurrentBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "*" } }, @@ -1115,84 +1625,274 @@ }, "additionalProperties": false, "type": "object", - "description": "Keybindings" + "description": "Keybindings.\nEach binding can be a single key or a list of keys; see https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md for the syntax." }, "KeybindingFilesConfig": { "properties": { "commitChanges": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "c" }, "commitChangesWithoutHook": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "w" }, "amendLastCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "A" }, "commitChangesWithEditor": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "C" }, "findBaseCommitForFixup": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+f\u003e" }, "confirmDiscard": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "x" }, "ignoreFile": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "i" }, "refreshFiles": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "r" }, "stashAllChanges": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "s" }, "viewStashOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "S" }, "toggleStagedAll": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "a" }, "viewResetOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "D" }, "fetch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "f" }, "toggleTreeView": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "`" }, "openMergeOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "M" }, "openStatusFilter": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+b\u003e" }, "copyFileInfoToClipboard": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "y" }, "collapseAll": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "-" }, "expandAll": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "=" } }, @@ -1202,15 +1902,45 @@ "KeybindingMainConfig": { "properties": { "toggleSelectHunk": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "a" }, "pickBothHunks": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "b" }, "editSelectHunk": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "E" } }, @@ -1220,11 +1950,31 @@ "KeybindingStashConfig": { "properties": { "popStash": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "g" }, "renameStash": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "r" } }, @@ -1234,19 +1984,59 @@ "KeybindingStatusConfig": { "properties": { "checkForUpdate": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "u" }, "recentRepos": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003center\u003e" }, "allBranchesLogGraph": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "a" }, "allBranchesLogGraphReverse": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "A" } }, @@ -1256,15 +2046,45 @@ "KeybindingSubmodulesConfig": { "properties": { "init": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "i" }, "update": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "u" }, "bulkMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "b" } }, @@ -1274,111 +2094,381 @@ "KeybindingUniversalConfig": { "properties": { "quit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "q" }, "quit-alt1": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+c\u003e" }, "suspendApp": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+z\u003e" }, "return": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cesc\u003e" }, "quitWithoutChangingDirectory": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "Q" }, "togglePanel": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003ctab\u003e" }, "prevItem": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cup\u003e" }, "nextItem": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cdown\u003e" }, "prevItem-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "k" }, "nextItem-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "j" }, "prevPage": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "," }, "nextPage": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "." }, "scrollLeft": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "H" }, "scrollRight": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "L" }, "gotoTop": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003c" }, "gotoBottom": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003e" }, "gotoTop-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003chome\u003e" }, "gotoBottom-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cend\u003e" }, "toggleRangeSelect": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "v" }, "rangeSelectDown": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cshift+down\u003e" }, "rangeSelectUp": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cshift+up\u003e" }, "prevBlock": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cleft\u003e" }, "nextBlock": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cright\u003e" }, "prevBlock-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "h" }, "nextBlock-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "l" }, "nextBlock-alt2": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003ctab\u003e" }, "prevBlock-alt2": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cbacktab\u003e" }, "jumpToBlock": { @@ -1395,218 +2485,738 @@ ] }, "focusMainView": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "0" }, "nextMatch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "n" }, "prevMatch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "N" }, "startSearch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "/" }, "moveWordLeft": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "description": "\u003calt+left\u003e on Mac", "default": "\u003cctrl+left\u003e" }, "moveWordRight": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "description": "\u003calt+right\u003e on Mac", "default": "\u003cctrl+right\u003e" }, "backspaceWord": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "description": "\u003calt+backspace\u003e on Mac", "default": "\u003cctrl+backspace\u003e" }, "forwardDeleteWord": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "description": "\u003calt+delete\u003e on Mac", "default": "\u003cctrl+delete\u003e" }, "optionMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "?" }, "select": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cspace\u003e" }, "goInto": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003center\u003e" }, "confirm": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003center\u003e" }, "confirmMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003center\u003e" }, "confirmSuggestion": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003center\u003e" }, "confirmInEditor": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "description": "\u003cmeta+enter\u003e on Mac", "default": "\u003cctrl+enter\u003e" }, "confirmInEditor-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+s\u003e" }, "remove": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "d" }, "new": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "n" }, "edit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "e" }, "openFile": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "o" }, "scrollUpMain": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cpgup\u003e" }, "scrollDownMain": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cpgdown\u003e" }, "scrollUpMain-alt1": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "K" }, "scrollDownMain-alt1": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "J" }, "scrollUpMain-alt2": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+u\u003e" }, "scrollDownMain-alt2": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+d\u003e" }, "executeShellCommand": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": ":" }, "createRebaseOptionsMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "m" }, "pushFiles": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "description": "'Files' appended for legacy reasons", "default": "P" }, "pullFiles": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "description": "'Files' appended for legacy reasons", "default": "p" }, "refresh": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "R" }, "createPatchOptionsMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+p\u003e" }, "nextTab": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "]" }, "prevTab": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "[" }, "nextScreenMode": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "+" }, "prevScreenMode": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "_" }, "cyclePagers": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "|" }, "undo": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "z" }, "redo": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "Z" }, "filteringMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+s\u003e" }, "diffingMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "W" }, "diffingMenu-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+e\u003e" }, "copyToClipboard": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+o\u003e" }, "openRecentRepos": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+r\u003e" }, "submitEditorText": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003center\u003e" }, "extrasMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "@" }, "toggleWhitespaceInDiffView": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+w\u003e" }, "increaseContextInDiffView": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "}" }, "decreaseContextInDiffView": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "{" }, "increaseRenameSimilarityThreshold": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": ")" }, "decreaseRenameSimilarityThreshold": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "(" }, "openDiffTool": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cctrl+t\u003e" } }, @@ -1616,7 +3226,17 @@ "KeybindingWorktreesConfig": { "properties": { "viewWorktreeOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "w" } }, @@ -2062,7 +3682,7 @@ }, "keybinding": { "$ref": "#/$defs/KeybindingConfig", - "description": "Keybindings" + "description": "Keybindings.\nEach binding can be a single key or a list of keys; see https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md for the syntax." } }, "additionalProperties": false, From 3ecca88bd8a809444dbe4895fa80f9d50f2fc287 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 4 May 2026 09:08:29 +0200 Subject: [PATCH 020/384] Convert JumpToBlock to a list of multi-key bindings JumpToBlock is special: each of its 5 elements is the binding for one side window (status / files / branches / commits / stash), not an alternate for a single command. Change the field from []string to []Keybinding so each window slot can have alternates of its own. The schema becomes "an array of 5 keybindings, each itself a string or array of strings", which falls out cleanly from how the Keybinding type inlines into the generated schema. Existing configs (a flat array of 5 strings) keep validating because each element is unmarshalled through Keybinding's scalar-or-sequence decoder. --- pkg/config/keybinding_test.go | 15 ++ pkg/config/user_config.go | 162 +++++++++--------- pkg/config/user_config_validation_test.go | 6 +- .../jump_to_side_window_controller.go | 3 +- pkg/gui/views.go | 11 +- pkg/integration/components/view_driver.go | 2 +- ...disable_switch_tab_with_panel_jump_keys.go | 4 +- .../ui/switch_tab_with_panel_jump_keys.go | 10 +- schema-master/config.json | 12 +- 9 files changed, 127 insertions(+), 98 deletions(-) diff --git a/pkg/config/keybinding_test.go b/pkg/config/keybinding_test.go index 9bf3ed442..a0bff786c 100644 --- a/pkg/config/keybinding_test.go +++ b/pkg/config/keybinding_test.go @@ -180,3 +180,18 @@ func TestKeybindingConfigYAMLAcceptsBothForms(t *testing.T) { }) } } + +func TestJumpToBlockYAMLAcceptsMixedForms(t *testing.T) { + yamlInput := ` +jumpToBlock: + - "1" + - ["2", "@"] + - "3" + - "4" + - "5" +` + var cfg KeybindingUniversalConfig + assert.NoError(t, yaml.Unmarshal([]byte(yamlInput), &cfg)) + expected := []Keybinding{{"1"}, {"2", "@"}, {"3"}, {"4"}, {"5"}} + assert.Equal(t, expected, cfg.JumpToBlock) +} diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index bbd568e8d..dfeced3c0 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -423,86 +423,86 @@ type KeybindingConfig struct { // damn looks like we have some inconsistencies here with -alt and -alt1 type KeybindingUniversalConfig struct { - Quit Keybinding `yaml:"quit"` - QuitAlt1 Keybinding `yaml:"quit-alt1"` - SuspendApp Keybinding `yaml:"suspendApp"` - Return Keybinding `yaml:"return"` - QuitWithoutChangingDirectory Keybinding `yaml:"quitWithoutChangingDirectory"` - TogglePanel Keybinding `yaml:"togglePanel"` - PrevItem Keybinding `yaml:"prevItem"` - NextItem Keybinding `yaml:"nextItem"` - PrevItemAlt Keybinding `yaml:"prevItem-alt"` - NextItemAlt Keybinding `yaml:"nextItem-alt"` - PrevPage Keybinding `yaml:"prevPage"` - NextPage Keybinding `yaml:"nextPage"` - ScrollLeft Keybinding `yaml:"scrollLeft"` - ScrollRight Keybinding `yaml:"scrollRight"` - GotoTop Keybinding `yaml:"gotoTop"` - GotoBottom Keybinding `yaml:"gotoBottom"` - GotoTopAlt Keybinding `yaml:"gotoTop-alt"` - GotoBottomAlt Keybinding `yaml:"gotoBottom-alt"` - ToggleRangeSelect Keybinding `yaml:"toggleRangeSelect"` - RangeSelectDown Keybinding `yaml:"rangeSelectDown"` - RangeSelectUp Keybinding `yaml:"rangeSelectUp"` - PrevBlock Keybinding `yaml:"prevBlock"` - NextBlock Keybinding `yaml:"nextBlock"` - PrevBlockAlt Keybinding `yaml:"prevBlock-alt"` - NextBlockAlt Keybinding `yaml:"nextBlock-alt"` - NextBlockAlt2 Keybinding `yaml:"nextBlock-alt2"` - PrevBlockAlt2 Keybinding `yaml:"prevBlock-alt2"` - JumpToBlock []string `yaml:"jumpToBlock"` - FocusMainView Keybinding `yaml:"focusMainView"` - NextMatch Keybinding `yaml:"nextMatch"` - PrevMatch Keybinding `yaml:"prevMatch"` - StartSearch Keybinding `yaml:"startSearch"` - MoveWordLeft Keybinding `yaml:"moveWordLeft"` // on Mac - MoveWordRight Keybinding `yaml:"moveWordRight"` // on Mac - BackspaceWord Keybinding `yaml:"backspaceWord"` // on Mac - ForwardDeleteWord Keybinding `yaml:"forwardDeleteWord"` // on Mac - OptionMenu Keybinding `yaml:"optionMenu"` - Select Keybinding `yaml:"select"` - GoInto Keybinding `yaml:"goInto"` - Confirm Keybinding `yaml:"confirm"` - ConfirmMenu Keybinding `yaml:"confirmMenu"` - ConfirmSuggestion Keybinding `yaml:"confirmSuggestion"` - ConfirmInEditor Keybinding `yaml:"confirmInEditor"` // on Mac - ConfirmInEditorAlt Keybinding `yaml:"confirmInEditor-alt"` - Remove Keybinding `yaml:"remove"` - New Keybinding `yaml:"new"` - Edit Keybinding `yaml:"edit"` - OpenFile Keybinding `yaml:"openFile"` - ScrollUpMain Keybinding `yaml:"scrollUpMain"` - ScrollDownMain Keybinding `yaml:"scrollDownMain"` - ScrollUpMainAlt1 Keybinding `yaml:"scrollUpMain-alt1"` - ScrollDownMainAlt1 Keybinding `yaml:"scrollDownMain-alt1"` - ScrollUpMainAlt2 Keybinding `yaml:"scrollUpMain-alt2"` - 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"` - Undo Keybinding `yaml:"undo"` - Redo Keybinding `yaml:"redo"` - FilteringMenu Keybinding `yaml:"filteringMenu"` - DiffingMenu Keybinding `yaml:"diffingMenu"` - DiffingMenuAlt Keybinding `yaml:"diffingMenu-alt"` - CopyToClipboard Keybinding `yaml:"copyToClipboard"` - OpenRecentRepos Keybinding `yaml:"openRecentRepos"` - SubmitEditorText Keybinding `yaml:"submitEditorText"` - ExtrasMenu Keybinding `yaml:"extrasMenu"` - ToggleWhitespaceInDiffView Keybinding `yaml:"toggleWhitespaceInDiffView"` - IncreaseContextInDiffView Keybinding `yaml:"increaseContextInDiffView"` - DecreaseContextInDiffView Keybinding `yaml:"decreaseContextInDiffView"` - IncreaseRenameSimilarityThreshold Keybinding `yaml:"increaseRenameSimilarityThreshold"` - DecreaseRenameSimilarityThreshold Keybinding `yaml:"decreaseRenameSimilarityThreshold"` - OpenDiffTool Keybinding `yaml:"openDiffTool"` + Quit Keybinding `yaml:"quit"` + QuitAlt1 Keybinding `yaml:"quit-alt1"` + SuspendApp Keybinding `yaml:"suspendApp"` + Return Keybinding `yaml:"return"` + QuitWithoutChangingDirectory Keybinding `yaml:"quitWithoutChangingDirectory"` + TogglePanel Keybinding `yaml:"togglePanel"` + PrevItem Keybinding `yaml:"prevItem"` + NextItem Keybinding `yaml:"nextItem"` + PrevItemAlt Keybinding `yaml:"prevItem-alt"` + NextItemAlt Keybinding `yaml:"nextItem-alt"` + PrevPage Keybinding `yaml:"prevPage"` + NextPage Keybinding `yaml:"nextPage"` + ScrollLeft Keybinding `yaml:"scrollLeft"` + ScrollRight Keybinding `yaml:"scrollRight"` + GotoTop Keybinding `yaml:"gotoTop"` + GotoBottom Keybinding `yaml:"gotoBottom"` + GotoTopAlt Keybinding `yaml:"gotoTop-alt"` + GotoBottomAlt Keybinding `yaml:"gotoBottom-alt"` + ToggleRangeSelect Keybinding `yaml:"toggleRangeSelect"` + RangeSelectDown Keybinding `yaml:"rangeSelectDown"` + RangeSelectUp Keybinding `yaml:"rangeSelectUp"` + PrevBlock Keybinding `yaml:"prevBlock"` + NextBlock Keybinding `yaml:"nextBlock"` + PrevBlockAlt Keybinding `yaml:"prevBlock-alt"` + NextBlockAlt Keybinding `yaml:"nextBlock-alt"` + NextBlockAlt2 Keybinding `yaml:"nextBlock-alt2"` + PrevBlockAlt2 Keybinding `yaml:"prevBlock-alt2"` + JumpToBlock []Keybinding `yaml:"jumpToBlock"` + FocusMainView Keybinding `yaml:"focusMainView"` + NextMatch Keybinding `yaml:"nextMatch"` + PrevMatch Keybinding `yaml:"prevMatch"` + StartSearch Keybinding `yaml:"startSearch"` + MoveWordLeft Keybinding `yaml:"moveWordLeft"` // on Mac + MoveWordRight Keybinding `yaml:"moveWordRight"` // on Mac + BackspaceWord Keybinding `yaml:"backspaceWord"` // on Mac + ForwardDeleteWord Keybinding `yaml:"forwardDeleteWord"` // on Mac + OptionMenu Keybinding `yaml:"optionMenu"` + Select Keybinding `yaml:"select"` + GoInto Keybinding `yaml:"goInto"` + Confirm Keybinding `yaml:"confirm"` + ConfirmMenu Keybinding `yaml:"confirmMenu"` + ConfirmSuggestion Keybinding `yaml:"confirmSuggestion"` + ConfirmInEditor Keybinding `yaml:"confirmInEditor"` // on Mac + ConfirmInEditorAlt Keybinding `yaml:"confirmInEditor-alt"` + Remove Keybinding `yaml:"remove"` + New Keybinding `yaml:"new"` + Edit Keybinding `yaml:"edit"` + OpenFile Keybinding `yaml:"openFile"` + ScrollUpMain Keybinding `yaml:"scrollUpMain"` + ScrollDownMain Keybinding `yaml:"scrollDownMain"` + ScrollUpMainAlt1 Keybinding `yaml:"scrollUpMain-alt1"` + ScrollDownMainAlt1 Keybinding `yaml:"scrollDownMain-alt1"` + ScrollUpMainAlt2 Keybinding `yaml:"scrollUpMain-alt2"` + 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"` + Undo Keybinding `yaml:"undo"` + Redo Keybinding `yaml:"redo"` + FilteringMenu Keybinding `yaml:"filteringMenu"` + DiffingMenu Keybinding `yaml:"diffingMenu"` + DiffingMenuAlt Keybinding `yaml:"diffingMenu-alt"` + CopyToClipboard Keybinding `yaml:"copyToClipboard"` + OpenRecentRepos Keybinding `yaml:"openRecentRepos"` + SubmitEditorText Keybinding `yaml:"submitEditorText"` + ExtrasMenu Keybinding `yaml:"extrasMenu"` + ToggleWhitespaceInDiffView Keybinding `yaml:"toggleWhitespaceInDiffView"` + IncreaseContextInDiffView Keybinding `yaml:"increaseContextInDiffView"` + DecreaseContextInDiffView Keybinding `yaml:"decreaseContextInDiffView"` + IncreaseRenameSimilarityThreshold Keybinding `yaml:"increaseRenameSimilarityThreshold"` + DecreaseRenameSimilarityThreshold Keybinding `yaml:"decreaseRenameSimilarityThreshold"` + OpenDiffTool Keybinding `yaml:"openDiffTool"` } type KeybindingStatusConfig struct { @@ -932,7 +932,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { NextBlockAlt: Keybinding{"l"}, PrevBlockAlt2: Keybinding{""}, NextBlockAlt2: Keybinding{""}, - JumpToBlock: []string{"1", "2", "3", "4", "5"}, + JumpToBlock: []Keybinding{{"1"}, {"2"}, {"3"}, {"4"}, {"5"}}, FocusMainView: Keybinding{"0"}, NextMatch: Keybinding{"n"}, PrevMatch: Keybinding{"N"}, diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index 6474ac3b0..ec79c9c3e 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/samber/lo" "github.com/stretchr/testify/assert" ) @@ -127,7 +128,10 @@ func TestUserConfigValidate_enums(t *testing.T) { { name: "JumpToBlock keybinding", setup: func(config *UserConfig, value string) { - config.Keybinding.Universal.JumpToBlock = strings.Split(value, ",") + labels := strings.Split(value, ",") + config.Keybinding.Universal.JumpToBlock = lo.Map(labels, func(label string, _ int) Keybinding { + return Keybinding{label} + }) }, testCases: []testCase{ {value: "", valid: false}, diff --git a/pkg/gui/controllers/jump_to_side_window_controller.go b/pkg/gui/controllers/jump_to_side_window_controller.go index 31228e3a6..2ea8ac762 100644 --- a/pkg/gui/controllers/jump_to_side_window_controller.go +++ b/pkg/gui/controllers/jump_to_side_window_controller.go @@ -3,7 +3,6 @@ package controllers import ( "log" - "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) @@ -40,7 +39,7 @@ func (self *JumpToSideWindowController) GetKeybindings(opts types.KeybindingsOpt return &types.Binding{ ViewName: "", // by default the keys are 1, 2, 3, etc - Keys: opts.GetKeys(config.Keybinding{opts.Config.Universal.JumpToBlock[index]}), + Keys: opts.GetKeys(opts.Config.Universal.JumpToBlock[index]), Handler: opts.Guards.NoPopupPanel(self.goToSideWindow(window)), } }) diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 297da143f..ecfc0ddcd 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/theme" @@ -210,14 +211,14 @@ func (gui *Gui) configureViewProperties() { gui.Views.CommitDescription.TextArea.AutoWrapWidth = gui.c.UserConfig().Git.Commit.AutoWrapWidth if gui.c.UserConfig().Gui.ShowPanelJumps { - keyToTitlePrefix := func(key string) string { - if key == "" { + keyToTitlePrefix := func(binding config.Keybinding) string { + if len(binding) == 0 { return "" } - return fmt.Sprintf("[%s]", key) + return fmt.Sprintf("[%s]", binding[0]) } jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock - jumpLabels := lo.Map(jumpBindings, func(binding string, _ int) string { + jumpLabels := lo.Map(jumpBindings, func(binding config.Keybinding, _ int) string { return keyToTitlePrefix(binding) }) @@ -236,7 +237,7 @@ func (gui *Gui) configureViewProperties() { gui.Views.Stash.TitlePrefix = jumpLabels[4] - gui.Views.Main.TitlePrefix = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView[0]) + gui.Views.Main.TitlePrefix = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView) } else { gui.Views.Status.TitlePrefix = "" diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 8b1650c32..e9e5fbbc7 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -363,7 +363,7 @@ func (self *ViewDriver) Focus() *ViewDriver { if lo.Contains(window.viewNames, viewName) { tabIndex := lo.IndexOf(window.viewNames, viewName) // jump to the desired window - self.t.press(self.t.keys.Universal.JumpToBlock[windowIndex]) + self.t.press(self.t.keys.Universal.JumpToBlock[windowIndex][0]) // assert we're in the window before continuing self.t.assertWithRetries(func() (bool, string) { diff --git a/pkg/integration/tests/ui/disable_switch_tab_with_panel_jump_keys.go b/pkg/integration/tests/ui/disable_switch_tab_with_panel_jump_keys.go index 4ca3d1475..fb1ba5aba 100644 --- a/pkg/integration/tests/ui/disable_switch_tab_with_panel_jump_keys.go +++ b/pkg/integration/tests/ui/disable_switch_tab_with_panel_jump_keys.go @@ -15,9 +15,9 @@ var DisableSwitchTabWithPanelJumpKeys = NewIntegrationTest(NewIntegrationTestArg }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Status().Focus(). - Press(config.Keybinding{keys.Universal.JumpToBlock[1]}) + Press(keys.Universal.JumpToBlock[1]) t.Views().Files().IsFocused(). - Press(config.Keybinding{keys.Universal.JumpToBlock[1]}) + Press(keys.Universal.JumpToBlock[1]) // Despite jumping to an already focused panel, // the tab should not change from the base files view diff --git a/pkg/integration/tests/ui/switch_tab_with_panel_jump_keys.go b/pkg/integration/tests/ui/switch_tab_with_panel_jump_keys.go index 25676a7ed..4411cb3c6 100644 --- a/pkg/integration/tests/ui/switch_tab_with_panel_jump_keys.go +++ b/pkg/integration/tests/ui/switch_tab_with_panel_jump_keys.go @@ -16,19 +16,19 @@ var SwitchTabWithPanelJumpKeys = NewIntegrationTest(NewIntegrationTestArgs{ }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Worktrees().Focus(). - Press(config.Keybinding{keys.Universal.JumpToBlock[2]}) + Press(keys.Universal.JumpToBlock[2]) t.Views().Branches().IsFocused(). - Press(config.Keybinding{keys.Universal.JumpToBlock[2]}) + Press(keys.Universal.JumpToBlock[2]) t.Views().Remotes().IsFocused(). - Press(config.Keybinding{keys.Universal.JumpToBlock[2]}) + Press(keys.Universal.JumpToBlock[2]) t.Views().Tags().IsFocused(). - Press(config.Keybinding{keys.Universal.JumpToBlock[2]}) + Press(keys.Universal.JumpToBlock[2]) t.Views().Branches().IsFocused(). - Press(config.Keybinding{keys.Universal.JumpToBlock[1]}) + Press(keys.Universal.JumpToBlock[1]) // When jumping to a panel from a different one, keep its current tab: t.Views().Worktrees().IsFocused() diff --git a/schema-master/config.json b/schema-master/config.json index f2ba2ad46..495acc1d2 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -2473,7 +2473,17 @@ }, "jumpToBlock": { "items": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] }, "type": "array", "default": [ From fbcf562e296d43854fb0c7ab3f0e213dfb8ac859 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 10 May 2026 17:31:52 +0200 Subject: [PATCH 021/384] Convert custom command Key fields to Keybinding CustomCommand.Key and CustomCommandMenuOption.Key are user-configured keybindings just like the built-in ones. Converting them to the Keybinding type lets a user assign multiple keys to the same custom command, e.g. `key: [a, b]`, the same way they would for any other keybinding. The validator iterates over the elements rather than checking a single string, the binding registration goes through GetValidatedKeyBindingKeys to register every alternate, and the existing error messages use .String() so a multi-key binding renders sensibly. CustomCommandPrompt.Key (a form field name, not a keybinding) stays a plain string. --- pkg/config/user_config.go | 8 +++--- pkg/config/user_config_validation.go | 20 +++++++------ pkg/config/user_config_validation_test.go | 22 +++++++-------- pkg/gui/services/custom_commands/client.go | 7 ++--- .../custom_commands/handler_creator.go | 2 +- .../custom_commands/keybinding_creator.go | 7 ++--- .../custom_commands_in_per_repo_config.go | 4 +-- .../access_commit_properties.go | 2 +- .../tests/custom_commands/basic_command.go | 2 +- .../custom_commands/check_for_conflicts.go | 2 +- .../conditional_prompt_false_string.go | 2 +- .../conditional_prompt_false_value.go | 2 +- .../custom_commands/conditional_prompts.go | 6 ++-- .../custom_commands_submenu.go | 8 +++--- ...mmands_submenu_with_special_keybindings.go | 10 +++---- .../tests/custom_commands/form_prompts.go | 2 +- .../tests/custom_commands/global_context.go | 2 +- .../custom_commands/menu_from_command.go | 2 +- .../menu_from_commands_output.go | 2 +- .../custom_commands/menu_prompt_with_keys.go | 8 +++--- .../custom_commands/multiple_contexts.go | 2 +- .../tests/custom_commands/multiple_prompts.go | 2 +- .../tests/custom_commands/run_command.go | 2 +- .../tests/custom_commands/selected_commit.go | 2 +- .../custom_commands/selected_commit_range.go | 2 +- .../tests/custom_commands/selected_path.go | 2 +- .../custom_commands/selected_submodule.go | 6 ++-- .../custom_commands/show_output_in_panel.go | 4 +-- .../custom_commands/suggestions_command.go | 2 +- .../custom_commands/suggestions_preset.go | 2 +- pkg/integration/tests/demo/custom_command.go | 2 +- .../interactive_rebase/show_exec_todos.go | 2 +- pkg/integration/tests/submodule/enter.go | 2 +- pkg/integration/tests/submodule/reset.go | 2 +- .../tests/worktree/custom_command.go | 2 +- schema-master/config.json | 28 ++++++++++++++++--- 36 files changed, 103 insertions(+), 81 deletions(-) diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index dfeced3c0..6461ece75 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -667,8 +667,8 @@ type CustomCommandAfterHook struct { } type CustomCommand struct { - // The key to trigger the command. Use a single letter or one of the values from https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md - Key string `yaml:"key"` + // The key to trigger the command. Use a single letter or one of the values from https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md. To bind several alternates to the same command, use a sequence (e.g. `[a, b]`). + Key Keybinding `yaml:"key"` // Instead of defining a single custom command, create a menu of custom commands. Useful for grouping related commands together under a single keybinding, and for keeping them out of the global keybindings menu. // When using this, all other fields except Key and Description are ignored and must be empty. CommandMenu []CustomCommand `yaml:"commandMenu"` @@ -753,8 +753,8 @@ type CustomCommandMenuOption struct { Description string `yaml:"description"` // The value that will be used in the command Value string `yaml:"value" jsonschema:"example=feature,minLength=1"` - // Keybinding to invoke this menu option without needing to navigate to it - Key string `yaml:"key"` + // Keybinding to invoke this menu option without needing to navigate to it. Accepts either a single key or a sequence of alternates. + Key Keybinding `yaml:"key"` } type CustomIconsConfig struct { diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 7eeab32dd..163fc61c4 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -126,10 +126,12 @@ func validateKeybindings(keybindingConfig KeybindingConfig) error { return nil } -func validateCustomCommandKey(key string) error { - if !isValidKeybindingKey(key) { - return fmt.Errorf("Unrecognized key '%s' for custom command. For permitted values see %s", - key, constants.Links.Docs.CustomKeybindings) +func validateCustomCommandKey(key Keybinding) error { + for _, k := range key { + if !isValidKeybindingKey(k) { + return fmt.Errorf("Unrecognized key '%s' for custom command. For permitted values see %s", + k, constants.Links.Docs.CustomKeybindings) + } } return nil } @@ -150,7 +152,7 @@ func validateCustomCommands(customCommands []CustomCommand) error { customCommand.After != nil { commandRef := "" if len(customCommand.Key) > 0 { - commandRef = fmt.Sprintf(" with key '%s'", customCommand.Key) + commandRef = fmt.Sprintf(" with key '%s'", customCommand.Key.String()) } return fmt.Errorf("Error with custom command%s: it is not allowed to use both commandMenu and any of the other fields except key and description.", commandRef) } @@ -176,9 +178,11 @@ func validateCustomCommands(customCommands []CustomCommand) error { func validateCustomCommandPrompt(prompt CustomCommandPrompt) error { for _, option := range prompt.Options { - if !isValidKeybindingKey(option.Key) { - return fmt.Errorf("Unrecognized key '%s' for custom command prompt option. For permitted values see %s", - option.Key, constants.Links.Docs.CustomKeybindings) + for _, k := range option.Key { + if !isValidKeybindingKey(k) { + return fmt.Errorf("Unrecognized key '%s' for custom command prompt option. For permitted values see %s", + k, constants.Links.Docs.CustomKeybindings) + } } } diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index ec79c9c3e..bb2d2580f 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -146,7 +146,7 @@ func TestUserConfigValidate_enums(t *testing.T) { setup: func(config *UserConfig, value string) { config.CustomCommands = []CustomCommand{ { - Key: value, + Key: Keybinding{value}, Command: "echo 'hello'", }, } @@ -164,10 +164,10 @@ func TestUserConfigValidate_enums(t *testing.T) { setup: func(config *UserConfig, value string) { config.CustomCommands = []CustomCommand{ { - Key: "X", + Key: Keybinding{"X"}, Description: "My Custom Commands", CommandMenu: []CustomCommand{ - {Key: value, Command: "echo 'hello'", Context: "global"}, + {Key: Keybinding{value}, Command: "echo 'hello'", Context: "global"}, }, }, } @@ -185,12 +185,12 @@ func TestUserConfigValidate_enums(t *testing.T) { setup: func(config *UserConfig, value string) { config.CustomCommands = []CustomCommand{ { - Key: "X", + Key: Keybinding{"X"}, Description: "My Custom Commands", Prompts: []CustomCommandPrompt{ { Options: []CustomCommandMenuOption{ - {Key: value}, + {Key: Keybinding{value}}, }, }, }, @@ -229,10 +229,10 @@ func TestUserConfigValidate_enums(t *testing.T) { setup: func(config *UserConfig, _ string) { config.CustomCommands = []CustomCommand{ { - Key: "X", + Key: Keybinding{"X"}, Description: "My Custom Commands", CommandMenu: []CustomCommand{ - {Key: "1", Command: "echo 'hello'", Context: "global"}, + {Key: Keybinding{"1"}, Command: "echo 'hello'", Context: "global"}, }, }, } @@ -246,10 +246,10 @@ func TestUserConfigValidate_enums(t *testing.T) { setup: func(config *UserConfig, _ string) { config.CustomCommands = []CustomCommand{ { - Key: "X", + Key: Keybinding{"X"}, Context: "global", // context is not allowed for submenus CommandMenu: []CustomCommand{ - {Key: "1", Command: "echo 'hello'", Context: "global"}, + {Key: Keybinding{"1"}, Command: "echo 'hello'", Context: "global"}, }, }, } @@ -263,10 +263,10 @@ func TestUserConfigValidate_enums(t *testing.T) { setup: func(config *UserConfig, _ string) { config.CustomCommands = []CustomCommand{ { - Key: "X", + Key: Keybinding{"X"}, LoadingText: "loading", // other properties are not allowed for submenus (using loadingText as an example) CommandMenu: []CustomCommand{ - {Key: "1", Command: "echo 'hello'", Context: "global"}, + {Key: Keybinding{"1"}, Command: "echo 'hello'", Context: "global"}, }, }, } diff --git a/pkg/gui/services/custom_commands/client.go b/pkg/gui/services/custom_commands/client.go index 0884e0cce..3f16b8ba8 100644 --- a/pkg/gui/services/custom_commands/client.go +++ b/pkg/gui/services/custom_commands/client.go @@ -2,7 +2,6 @@ package custom_commands import ( "github.com/jesseduffield/lazygit/pkg/config" - "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/i18n" @@ -46,7 +45,7 @@ func (self *Client) GetCustomCommandKeybindings() ([]*types.Binding, error) { } bindings = append(bindings, &types.Binding{ ViewName: "", // custom commands menus are global; we filter the commands inside by context - Keys: []gocui.Key{config.GetValidatedKeyBindingKey(customCommand.Key)}, + Keys: config.GetValidatedKeyBindingKeys(customCommand.Key), Handler: handler, Description: getCustomCommandsMenuDescription(customCommand, self.c.Tr), OpensMenu: true, @@ -73,7 +72,7 @@ func (self *Client) showCustomCommandsMenu(customCommand config.CustomCommand) e } menuItems = append(menuItems, &types.MenuItem{ Label: subCommand.GetDescription(), - Keys: []gocui.Key{config.GetValidatedKeyBindingKey(subCommand.Key)}, + Keys: config.GetValidatedKeyBindingKeys(subCommand.Key), OnPress: handler, OpensMenu: true, }) @@ -93,7 +92,7 @@ func (self *Client) showCustomCommandsMenu(customCommand config.CustomCommand) e menuItems = append(menuItems, &types.MenuItem{ Label: subCommand.GetDescription(), - Keys: []gocui.Key{config.GetValidatedKeyBindingKey(subCommand.Key)}, + Keys: config.GetValidatedKeyBindingKeys(subCommand.Key), OnPress: self.handlerCreator.call(subCommand), }) } diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index 5f3fd27a0..19694c481 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -232,7 +232,7 @@ func (self *HandlerCreator) menuPrompt(prompt *config.CustomCommandPrompt, wrapp OnPress: func() error { return wrappedF(option.Value) }, - Keys: []gocui.Key{config.GetValidatedKeyBindingKey(option.Key)}, + Keys: config.GetValidatedKeyBindingKeys(option.Key), } }) diff --git a/pkg/gui/services/custom_commands/keybinding_creator.go b/pkg/gui/services/custom_commands/keybinding_creator.go index ee0e01fa4..f35fc8fd4 100644 --- a/pkg/gui/services/custom_commands/keybinding_creator.go +++ b/pkg/gui/services/custom_commands/keybinding_creator.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/jesseduffield/lazygit/pkg/config" - "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/types" @@ -36,7 +35,7 @@ func (self *KeybindingCreator) call(customCommand config.CustomCommand, handler return lo.Map(viewNames, func(viewName string, _ int) *types.Binding { return &types.Binding{ ViewName: viewName, - Keys: []gocui.Key{config.GetValidatedKeyBindingKey(customCommand.Key)}, + Keys: config.GetValidatedKeyBindingKeys(customCommand.Key), Handler: handler, Description: customCommand.GetDescription(), } @@ -81,9 +80,9 @@ func formatUnknownContextError(customCommand config.CustomCommand) error { return string(key) }) - return fmt.Errorf("Error when setting custom command keybindings: unknown context: %s. Key: %s, Command: %s.\nPermitted contexts: %s", customCommand.Context, customCommand.Key, customCommand.Command, strings.Join(allContextKeyStrings, ", ")) + return fmt.Errorf("Error when setting custom command keybindings: unknown context: %s. Key: %s, Command: %s.\nPermitted contexts: %s", customCommand.Context, customCommand.Key.String(), customCommand.Command, strings.Join(allContextKeyStrings, ", ")) } func formatContextNotProvidedError(customCommand config.CustomCommand) error { - return fmt.Errorf("Error parsing custom command keybindings: context not provided (use context: 'global' for the global context). Key: %s, Command: %s", customCommand.Key, customCommand.Command) + return fmt.Errorf("Error parsing custom command keybindings: context not provided (use context: 'global' for the global context). Key: %s, Command: %s", customCommand.Key.String(), customCommand.Command) } diff --git a/pkg/integration/tests/config/custom_commands_in_per_repo_config.go b/pkg/integration/tests/config/custom_commands_in_per_repo_config.go index 4a66cd43c..929c7eae9 100644 --- a/pkg/integration/tests/config/custom_commands_in_per_repo_config.go +++ b/pkg/integration/tests/config/custom_commands_in_per_repo_config.go @@ -17,12 +17,12 @@ var CustomCommandsInPerRepoConfig = NewIntegrationTest(NewIntegrationTestArgs{ cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "X", + Key: config.Keybinding{"X"}, Context: "global", Command: "printf 'global X' > file.txt", }, { - Key: "Y", + Key: config.Keybinding{"Y"}, Context: "global", Command: "printf 'global Y' > file.txt", }, diff --git a/pkg/integration/tests/custom_commands/access_commit_properties.go b/pkg/integration/tests/custom_commands/access_commit_properties.go index a1ba84a27..0823b1033 100644 --- a/pkg/integration/tests/custom_commands/access_commit_properties.go +++ b/pkg/integration/tests/custom_commands/access_commit_properties.go @@ -17,7 +17,7 @@ var AccessCommitProperties = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "X", + Key: config.Keybinding{"X"}, Context: "commits", Command: "printf '%s\n%s\n%s' '{{ .SelectedLocalCommit.Name }}' '{{ .SelectedLocalCommit.Hash }}' '{{ .SelectedLocalCommit.Sha }}' > file.txt", }, diff --git a/pkg/integration/tests/custom_commands/basic_command.go b/pkg/integration/tests/custom_commands/basic_command.go index b50acc792..f5ae90a96 100644 --- a/pkg/integration/tests/custom_commands/basic_command.go +++ b/pkg/integration/tests/custom_commands/basic_command.go @@ -15,7 +15,7 @@ var BasicCommand = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "a", + Key: config.Keybinding{"a"}, Context: "files", Command: "touch myfile", }, diff --git a/pkg/integration/tests/custom_commands/check_for_conflicts.go b/pkg/integration/tests/custom_commands/check_for_conflicts.go index 70f528a6d..5d7924c90 100644 --- a/pkg/integration/tests/custom_commands/check_for_conflicts.go +++ b/pkg/integration/tests/custom_commands/check_for_conflicts.go @@ -16,7 +16,7 @@ var CheckForConflicts = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "m", + Key: config.Keybinding{"m"}, Context: "localBranches", Command: "git merge {{ .SelectedLocalBranch.Name | quote }}", After: &config.CustomCommandAfterHook{ diff --git a/pkg/integration/tests/custom_commands/conditional_prompt_false_string.go b/pkg/integration/tests/custom_commands/conditional_prompt_false_string.go index d2c6beadb..e8a64ea0d 100644 --- a/pkg/integration/tests/custom_commands/conditional_prompt_false_string.go +++ b/pkg/integration/tests/custom_commands/conditional_prompt_false_string.go @@ -15,7 +15,7 @@ var ConditionalPromptFalseString = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "a", + Key: config.Keybinding{"a"}, Context: "files", Command: `echo "{{.Form.Choice}}" > result.txt`, Prompts: []config.CustomCommandPrompt{ diff --git a/pkg/integration/tests/custom_commands/conditional_prompt_false_value.go b/pkg/integration/tests/custom_commands/conditional_prompt_false_value.go index 4a0326669..15379c7b4 100644 --- a/pkg/integration/tests/custom_commands/conditional_prompt_false_value.go +++ b/pkg/integration/tests/custom_commands/conditional_prompt_false_value.go @@ -15,7 +15,7 @@ var ConditionalPromptFalseValue = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "a", + Key: config.Keybinding{"a"}, Context: "files", Command: `echo "{{.Form.Word}} {{.Form.Extra}}" > result.txt`, Prompts: []config.CustomCommandPrompt{ diff --git a/pkg/integration/tests/custom_commands/conditional_prompts.go b/pkg/integration/tests/custom_commands/conditional_prompts.go index 4f1f97531..ef743282a 100644 --- a/pkg/integration/tests/custom_commands/conditional_prompts.go +++ b/pkg/integration/tests/custom_commands/conditional_prompts.go @@ -15,7 +15,7 @@ var ConditionalPrompts = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "a", + Key: config.Keybinding{"a"}, Context: "files", Command: `echo "{{.Form.Choice}}{{if .Form.Detail}} {{.Form.Detail}}{{end}}" > result.txt`, Prompts: []config.CustomCommandPrompt{ @@ -28,13 +28,13 @@ var ConditionalPrompts = NewIntegrationTest(NewIntegrationTestArgs{ Name: "first", Description: "First option", Value: "FIRST", - Key: "1", + Key: config.Keybinding{"1"}, }, { Name: "second", Description: "Second option", Value: "SECOND", - Key: "H", + Key: config.Keybinding{"H"}, }, }, }, diff --git a/pkg/integration/tests/custom_commands/custom_commands_submenu.go b/pkg/integration/tests/custom_commands/custom_commands_submenu.go index 93c670449..18dd51d83 100644 --- a/pkg/integration/tests/custom_commands/custom_commands_submenu.go +++ b/pkg/integration/tests/custom_commands/custom_commands_submenu.go @@ -13,21 +13,21 @@ var CustomCommandsSubmenu = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "x", + Key: config.Keybinding{"x"}, Description: "My Custom Commands", CommandMenu: []config.CustomCommand{ { - Key: "1", + Key: config.Keybinding{"1"}, Context: "global", Command: "touch myfile-global", }, { - Key: "2", + Key: config.Keybinding{"2"}, Context: "files", Command: "touch myfile-files", }, { - Key: "3", + Key: config.Keybinding{"3"}, Context: "commits", Command: "touch myfile-commits", }, diff --git a/pkg/integration/tests/custom_commands/custom_commands_submenu_with_special_keybindings.go b/pkg/integration/tests/custom_commands/custom_commands_submenu_with_special_keybindings.go index 29638ee92..e79e05991 100644 --- a/pkg/integration/tests/custom_commands/custom_commands_submenu_with_special_keybindings.go +++ b/pkg/integration/tests/custom_commands/custom_commands_submenu_with_special_keybindings.go @@ -13,29 +13,29 @@ var CustomCommandsSubmenuWithSpecialKeybindings = NewIntegrationTest(NewIntegrat SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "x", + Key: config.Keybinding{"x"}, Description: "My Custom Commands", CommandMenu: []config.CustomCommand{ { - Key: "j", + Key: config.Keybinding{"j"}, Context: "global", Command: "echo j", Output: "popup", }, { - Key: "H", + Key: config.Keybinding{"H"}, Context: "global", Command: "echo H", Output: "popup", }, { - Key: "y", + Key: config.Keybinding{"y"}, Context: "global", Command: "echo y", Output: "popup", }, { - Key: "", + Key: config.Keybinding{""}, Context: "global", Command: "echo down", Output: "popup", diff --git a/pkg/integration/tests/custom_commands/form_prompts.go b/pkg/integration/tests/custom_commands/form_prompts.go index 3e051cb8e..43246b872 100644 --- a/pkg/integration/tests/custom_commands/form_prompts.go +++ b/pkg/integration/tests/custom_commands/form_prompts.go @@ -15,7 +15,7 @@ var FormPrompts = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "a", + Key: config.Keybinding{"a"}, Context: "files", Command: `echo {{.Form.FileContent | quote}} > {{.Form.FileName | quote}}`, Prompts: []config.CustomCommandPrompt{ diff --git a/pkg/integration/tests/custom_commands/global_context.go b/pkg/integration/tests/custom_commands/global_context.go index 274d6a7ba..2c4cd464f 100644 --- a/pkg/integration/tests/custom_commands/global_context.go +++ b/pkg/integration/tests/custom_commands/global_context.go @@ -15,7 +15,7 @@ var GlobalContext = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "X", + Key: config.Keybinding{"X"}, Context: "global", Command: "touch myfile", }, diff --git a/pkg/integration/tests/custom_commands/menu_from_command.go b/pkg/integration/tests/custom_commands/menu_from_command.go index ae62c9a5c..51122aa6f 100644 --- a/pkg/integration/tests/custom_commands/menu_from_command.go +++ b/pkg/integration/tests/custom_commands/menu_from_command.go @@ -21,7 +21,7 @@ var MenuFromCommand = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "a", + Key: config.Keybinding{"a"}, Context: "localBranches", Command: `echo "{{index .PromptResponses 0}} {{index .PromptResponses 1}} {{ .SelectedLocalBranch.Name }}" > output.txt`, Prompts: []config.CustomCommandPrompt{ diff --git a/pkg/integration/tests/custom_commands/menu_from_commands_output.go b/pkg/integration/tests/custom_commands/menu_from_commands_output.go index 7820bc515..51444ad52 100644 --- a/pkg/integration/tests/custom_commands/menu_from_commands_output.go +++ b/pkg/integration/tests/custom_commands/menu_from_commands_output.go @@ -20,7 +20,7 @@ var MenuFromCommandsOutput = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "a", + Key: config.Keybinding{"a"}, Context: "localBranches", Command: "git checkout {{ index .PromptResponses 1 }}", Prompts: []config.CustomCommandPrompt{ diff --git a/pkg/integration/tests/custom_commands/menu_prompt_with_keys.go b/pkg/integration/tests/custom_commands/menu_prompt_with_keys.go index d29a049f9..4a217f924 100644 --- a/pkg/integration/tests/custom_commands/menu_prompt_with_keys.go +++ b/pkg/integration/tests/custom_commands/menu_prompt_with_keys.go @@ -15,7 +15,7 @@ var MenuPromptWithKeys = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "a", + Key: config.Keybinding{"a"}, Context: "files", Command: `echo {{.Form.Choice | quote}} > result.txt`, Prompts: []config.CustomCommandPrompt{ @@ -28,19 +28,19 @@ var MenuPromptWithKeys = NewIntegrationTest(NewIntegrationTestArgs{ Name: "first", Description: "First option", Value: "FIRST", - Key: "1", + Key: config.Keybinding{"1"}, }, { Name: "second", Description: "Second option", Value: "SECOND", - Key: "H", + Key: config.Keybinding{"H"}, }, { Name: "third", Description: "Third option", Value: "THIRD", - Key: "3", + Key: config.Keybinding{"3"}, }, }, }, diff --git a/pkg/integration/tests/custom_commands/multiple_contexts.go b/pkg/integration/tests/custom_commands/multiple_contexts.go index b53242edc..3c7e07e84 100644 --- a/pkg/integration/tests/custom_commands/multiple_contexts.go +++ b/pkg/integration/tests/custom_commands/multiple_contexts.go @@ -15,7 +15,7 @@ var MultipleContexts = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "X", + Key: config.Keybinding{"X"}, Context: "commits, reflogCommits", Command: "touch myfile", }, diff --git a/pkg/integration/tests/custom_commands/multiple_prompts.go b/pkg/integration/tests/custom_commands/multiple_prompts.go index a450f7aee..8651a330a 100644 --- a/pkg/integration/tests/custom_commands/multiple_prompts.go +++ b/pkg/integration/tests/custom_commands/multiple_prompts.go @@ -15,7 +15,7 @@ var MultiplePrompts = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "a", + Key: config.Keybinding{"a"}, Context: "files", Command: `echo "{{index .PromptResponses 1}}" > {{index .PromptResponses 0}}`, Prompts: []config.CustomCommandPrompt{ diff --git a/pkg/integration/tests/custom_commands/run_command.go b/pkg/integration/tests/custom_commands/run_command.go index ca9abb658..afff59590 100644 --- a/pkg/integration/tests/custom_commands/run_command.go +++ b/pkg/integration/tests/custom_commands/run_command.go @@ -15,7 +15,7 @@ var RunCommand = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "a", + Key: config.Keybinding{"a"}, Context: "localBranches", Command: `git checkout {{.Form.Branch}}`, Prompts: []config.CustomCommandPrompt{ diff --git a/pkg/integration/tests/custom_commands/selected_commit.go b/pkg/integration/tests/custom_commands/selected_commit.go index 49595afd0..0265759a8 100644 --- a/pkg/integration/tests/custom_commands/selected_commit.go +++ b/pkg/integration/tests/custom_commands/selected_commit.go @@ -15,7 +15,7 @@ var SelectedCommit = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "X", + Key: config.Keybinding{"X"}, Context: "global", Command: "printf '%s' '{{ .SelectedCommit.Name }}' > file.txt", }, diff --git a/pkg/integration/tests/custom_commands/selected_commit_range.go b/pkg/integration/tests/custom_commands/selected_commit_range.go index f335d91d9..6ef1305aa 100644 --- a/pkg/integration/tests/custom_commands/selected_commit_range.go +++ b/pkg/integration/tests/custom_commands/selected_commit_range.go @@ -15,7 +15,7 @@ var SelectedCommitRange = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "X", + Key: config.Keybinding{"X"}, Context: "global", Command: `git log --format="%s" {{.SelectedCommitRange.From}}^..{{.SelectedCommitRange.To}} > file.txt`, }, diff --git a/pkg/integration/tests/custom_commands/selected_path.go b/pkg/integration/tests/custom_commands/selected_path.go index c6c680335..cad479e4a 100644 --- a/pkg/integration/tests/custom_commands/selected_path.go +++ b/pkg/integration/tests/custom_commands/selected_path.go @@ -19,7 +19,7 @@ var SelectedPath = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "X", + Key: config.Keybinding{"X"}, Context: "global", Command: "printf '%s' '{{ .SelectedPath }}' > file.txt", }, diff --git a/pkg/integration/tests/custom_commands/selected_submodule.go b/pkg/integration/tests/custom_commands/selected_submodule.go index d03af03dd..0671b4b83 100644 --- a/pkg/integration/tests/custom_commands/selected_submodule.go +++ b/pkg/integration/tests/custom_commands/selected_submodule.go @@ -17,17 +17,17 @@ var SelectedSubmodule = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "X", + Key: config.Keybinding{"X"}, Context: "submodules", Command: "printf '%s' '{{ .SelectedSubmodule.Path }}' > file.txt", }, { - Key: "U", + Key: config.Keybinding{"U"}, Context: "submodules", Command: "printf '%s' '{{ .SelectedSubmodule.Url }}' > file.txt", }, { - Key: "N", + Key: config.Keybinding{"N"}, Context: "submodules", Command: "printf '%s' '{{ .SelectedSubmodule.Name }}' > file.txt", }, diff --git a/pkg/integration/tests/custom_commands/show_output_in_panel.go b/pkg/integration/tests/custom_commands/show_output_in_panel.go index 95765cb5f..4654b9b51 100644 --- a/pkg/integration/tests/custom_commands/show_output_in_panel.go +++ b/pkg/integration/tests/custom_commands/show_output_in_panel.go @@ -17,13 +17,13 @@ var ShowOutputInPanel = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "X", + Key: config.Keybinding{"X"}, Context: "commits", Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'", Output: "popup", }, { - Key: "Y", + Key: config.Keybinding{"Y"}, Context: "commits", Command: "printf '%s' '{{ .SelectedLocalCommit.Name }}'", Output: "popup", diff --git a/pkg/integration/tests/custom_commands/suggestions_command.go b/pkg/integration/tests/custom_commands/suggestions_command.go index 831ecddda..e31d6dd8b 100644 --- a/pkg/integration/tests/custom_commands/suggestions_command.go +++ b/pkg/integration/tests/custom_commands/suggestions_command.go @@ -22,7 +22,7 @@ var SuggestionsCommand = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "a", + Key: config.Keybinding{"a"}, Context: "localBranches", Command: `git checkout {{.Form.Branch}}`, Prompts: []config.CustomCommandPrompt{ diff --git a/pkg/integration/tests/custom_commands/suggestions_preset.go b/pkg/integration/tests/custom_commands/suggestions_preset.go index 285d88e01..ebd9ee5da 100644 --- a/pkg/integration/tests/custom_commands/suggestions_preset.go +++ b/pkg/integration/tests/custom_commands/suggestions_preset.go @@ -22,7 +22,7 @@ var SuggestionsPreset = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "a", + Key: config.Keybinding{"a"}, Context: "localBranches", Command: `git checkout {{.Form.Branch}}`, Prompts: []config.CustomCommandPrompt{ diff --git a/pkg/integration/tests/demo/custom_command.go b/pkg/integration/tests/demo/custom_command.go index f14a19b5f..486b3488a 100644 --- a/pkg/integration/tests/demo/custom_command.go +++ b/pkg/integration/tests/demo/custom_command.go @@ -28,7 +28,7 @@ var CustomCommand = NewIntegrationTest(NewIntegrationTestArgs{ cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "a", + Key: config.Keybinding{"a"}, Context: "localBranches", Command: `git checkout {{.Form.Branch}}`, Prompts: []config.CustomCommandPrompt{ diff --git a/pkg/integration/tests/interactive_rebase/show_exec_todos.go b/pkg/integration/tests/interactive_rebase/show_exec_todos.go index 959139154..948bfb7d8 100644 --- a/pkg/integration/tests/interactive_rebase/show_exec_todos.go +++ b/pkg/integration/tests/interactive_rebase/show_exec_todos.go @@ -12,7 +12,7 @@ var ShowExecTodos = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "X", + Key: config.Keybinding{"X"}, Context: "commits", Command: "git -c core.editor=: rebase -i -x false HEAD^^", }, diff --git a/pkg/integration/tests/submodule/enter.go b/pkg/integration/tests/submodule/enter.go index a56dd8910..b768ed40e 100644 --- a/pkg/integration/tests/submodule/enter.go +++ b/pkg/integration/tests/submodule/enter.go @@ -12,7 +12,7 @@ var Enter = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "e", + Key: config.Keybinding{"e"}, Context: "files", Command: "git commit --allow-empty -m \"empty commit\"", }, diff --git a/pkg/integration/tests/submodule/reset.go b/pkg/integration/tests/submodule/reset.go index 5e7cde7f0..d671066a1 100644 --- a/pkg/integration/tests/submodule/reset.go +++ b/pkg/integration/tests/submodule/reset.go @@ -12,7 +12,7 @@ var Reset = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "e", + Key: config.Keybinding{"e"}, Context: "files", Command: "git commit --allow-empty -m \"empty commit\" && echo \"my_file content\" > my_file", }, diff --git a/pkg/integration/tests/worktree/custom_command.go b/pkg/integration/tests/worktree/custom_command.go index a3cddd36a..e00c162c3 100644 --- a/pkg/integration/tests/worktree/custom_command.go +++ b/pkg/integration/tests/worktree/custom_command.go @@ -12,7 +12,7 @@ var CustomCommand = NewIntegrationTest(NewIntegrationTestArgs{ SetupConfig: func(cfg *config.AppConfig) { cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ { - Key: "d", + Key: config.Keybinding{"d"}, Context: "worktrees", Command: "git worktree remove {{ .SelectedWorktree.Path | quote }}", }, diff --git a/schema-master/config.json b/schema-master/config.json index 495acc1d2..1f6e9e7d8 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -60,8 +60,18 @@ "CustomCommand": { "properties": { "key": { - "type": "string", - "description": "The key to trigger the command. Use a single letter or one of the values from https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "The key to trigger the command. Use a single letter or one of the values from https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md. To bind several alternates to the same command, use a sequence (e.g. `[a, b]`)." }, "commandMenu": { "items": { @@ -165,8 +175,18 @@ ] }, "key": { - "type": "string", - "description": "Keybinding to invoke this menu option without needing to navigate to it" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Keybinding to invoke this menu option without needing to navigate to it. Accepts either a single key or a sequence of alternates." } }, "additionalProperties": false, From 022d24cb79589f3a6933c1a0b2103c87c21731ca Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 4 May 2026 09:03:15 +0200 Subject: [PATCH 022/384] Fold legacy quit-alt1 into the multi-key quit binding Now that quit accepts multiple keys, the historical quit-alt1 field is redundant: existing configs that set it should keep working without the user having to migrate, but the lazygit code shouldn't have to register the alt binding separately. Add a merge step that runs after the user config is loaded (and from NewDummyAppConfig, which the cheatsheet generator and integration tests go through) folding the alt value into the main key list. Mark QuitAlt1 deprecated so it disappears from the generated Config.md example, while staying in the JSON schema with a description so editors can still steer users toward the new form. Note that instead of marking the alt config as deprecated, we could have added a migrator that changes users' config files and gets rid of the alt config for good. I decided not to do that, because this would render the config file invalid for older versions of lazygit, which would then refuse to start; and that's annoying when bisecting bugs. We'll keep the deprecated configs in the code for a year or so, and then add the migrator. The next commit will fold the remaining ~15 -alt-style fields the same way; the helper is shaped to keep that mechanical. --- docs-master/Config.md | 3 +- docs-master/keybindings/Keybindings_en.md | 2 +- docs-master/keybindings/Keybindings_ja.md | 2 +- docs-master/keybindings/Keybindings_ko.md | 2 +- docs-master/keybindings/Keybindings_nl.md | 2 +- docs-master/keybindings/Keybindings_pl.md | 2 +- docs-master/keybindings/Keybindings_pt.md | 2 +- docs-master/keybindings/Keybindings_ru.md | 2 +- docs-master/keybindings/Keybindings_zh-CN.md | 2 +- docs-master/keybindings/Keybindings_zh-TW.md | 2 +- pkg/config/app_config.go | 1 + pkg/config/dummies.go | 4 +- pkg/config/keybinding.go | 6 +++ pkg/config/keybinding_test.go | 53 ++++++++++++++++++++ pkg/config/user_config.go | 11 +++- pkg/gui/controllers/global_controller.go | 4 -- pkg/jsonschema/generate.go | 1 + schema-master/config.json | 6 ++- 18 files changed, 89 insertions(+), 18 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 4e9a4617a..dd0a7dd11 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -597,8 +597,7 @@ promptToReturnFromSubprocess: true # for the syntax. keybinding: universal: - quit: q - quit-alt1: + quit: [q, ] suspendApp: return: quitWithoutChangingDirectory: Q diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 74dbcc97d..8191c6b8b 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -28,7 +28,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` q `` | Quit | | +| `` q, `` | Quit | | | `` `` | Suspend the application | | | `` `` | 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'. | | `` z `` | Undo | 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. | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index f06f1a0bf..0d51ebe7f 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -28,7 +28,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 | | `` W `` | 差分オプションを表示 | 2つのrefの差分に関連するオプションを表示します(例:選択したrefとの差分表示、差分を取るrefの入力、差分方向の反転など)。 | | `` `` | 差分オプションを表示 | 2つのrefの差分に関連するオプションを表示します(例:選択したrefとの差分表示、差分を取るrefの入力、差分方向の反転など)。 | -| `` q `` | 終了 | | +| `` q, `` | 終了 | | | `` `` | Suspend the application | | | `` `` | 空白表示の切り替え | 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'. | | `` z `` | 元に戻す | 最後のgitコマンドを元に戻すために実行するgitコマンドを決定するためにreflogが使用されます。これにはワーキングツリーへの変更は含まれません。コミットのみが考慮されます。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 2b96d0aa8..d183f82de 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -28,7 +28,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W `` | Diff 메뉴 열기 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` `` | Diff 메뉴 열기 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` q `` | 종료 | | +| `` q, `` | 종료 | | | `` `` | Suspend the application | | | `` `` | 공백문자를 Diff 뷰에서 표시 여부 전환 | 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'. | | `` z `` | 되돌리기 (reflog) (실험적) | 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. | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index f048e2a2c..8b01ad9da 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -28,7 +28,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 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. | | `` `` | 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 `` | Quit | | +| `` q, `` | Quit | | | `` `` | Suspend the application | | | `` `` | 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'. | | `` 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. | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index fcde7bfb4..36d3c58a2 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -28,7 +28,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. | | `` W `` | Pokaż opcje różnicowania | Pokaż opcje dotyczące różnicowania dwóch refów, np. różnicowanie względem wybranego refa, wprowadzanie refa do różnicowania i odwracanie kierunku różnic. | | `` `` | Pokaż opcje różnicowania | Pokaż opcje dotyczące różnicowania dwóch refów, np. różnicowanie względem wybranego refa, wprowadzanie refa do różnicowania i odwracanie kierunku różnic. | -| `` q `` | Wyjdź | | +| `` q, `` | Wyjdź | | | `` `` | Suspend the application | | | `` `` | Przełącz białe znaki | 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'. | | `` z `` | Cofnij | Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby cofnąć ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity. | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 1aacc5722..17835d1a4 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -28,7 +28,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` q `` | Sair | | +| `` q, `` | Sair | | | `` `` | Suspender a aplicação | | | `` `` | 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'. | | `` z `` | Desfazer | O reflog será usado para determinar qual comando git para executar para desfazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração. | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 0dcb6753e..538a05ef0 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -28,7 +28,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W `` | Открыть меню сравнении | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` `` | Открыть меню сравнении | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` q `` | Выйти | | +| `` q, `` | Выйти | | | `` `` | Suspend the application | | | `` `` | Переключить отображение изменении пробелов в просмотрщике сравнении | 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'. | | `` z `` | Отменить (через reflog) (экспериментальный) | Журнал ссылок (reflog) будет использоваться для определения того, какую команду git запустить, чтобы отменить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты. | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index d78572f1b..b144b08cd 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -28,7 +28,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 | | `` W `` | 打开 diff 菜单 | 查看与比较两个引用相关的选项,例如与选定的 ref 进行比较,输入要比较的 ref,然后反转比较方向。 | | `` `` | 打开 diff 菜单 | 查看与比较两个引用相关的选项,例如与选定的 ref 进行比较,输入要比较的 ref,然后反转比较方向。 | -| `` q `` | 退出 | | +| `` q, `` | 退出 | | | `` `` | 挂起应用程序 | | | `` `` | 切换是否在差异视图中显示空白字符差异 | 切换是否在差异视图中显示空白字符更改。

默认值可在配置文件中通过键 'git.ignoreWhitespaceInDiffView' 更改。 | | `` z `` | 撤销 | Reflog将用于确定运行哪个git命令来撤消最后一个git命令。这并不包括对工作树的更改,只考虑提交。 | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 70294eab9..5e7892bd9 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -28,7 +28,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. | | `` W `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` q `` | 結束 | | +| `` q, `` | 結束 | | | `` `` | Suspend the application | | | `` `` | 切換是否在差異檢視中顯示空格變更 | 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'. | | `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 | diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index 27ee38c0b..e7267158a 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -203,6 +203,7 @@ func loadUserConfig(configFiles []*ConfigFile, base *UserConfig, isGuiInitialize } } + base.Keybinding.MergeLegacyAltKeybindings() return base, nil } diff --git a/pkg/config/dummies.go b/pkg/config/dummies.go index 06c8755a6..5bc349fa0 100644 --- a/pkg/config/dummies.go +++ b/pkg/config/dummies.go @@ -6,11 +6,13 @@ import ( // NewDummyAppConfig creates a new dummy AppConfig for testing func NewDummyAppConfig() *AppConfig { + userConfig := GetDefaultConfig() + userConfig.Keybinding.MergeLegacyAltKeybindings() appConfig := &AppConfig{ name: "lazygit", version: "unversioned", debug: false, - userConfig: GetDefaultConfig(), + userConfig: userConfig, appState: &AppState{}, } _ = yaml.Unmarshal([]byte{}, appConfig.appState) diff --git a/pkg/config/keybinding.go b/pkg/config/keybinding.go index f552707e5..905bc2bd7 100644 --- a/pkg/config/keybinding.go +++ b/pkg/config/keybinding.go @@ -84,3 +84,9 @@ func (Keybinding) JSONSchema() *jsonschema.Schema { }, } } + +// mergeLegacyAlt folds a deprecated `*Alt*` field into the corresponding +// multi-key main field. +func mergeLegacyAlt(main *Keybinding, alt Keybinding) { + *main = lo.Union(*main, alt) +} diff --git a/pkg/config/keybinding_test.go b/pkg/config/keybinding_test.go index a0bff786c..be9f15acb 100644 --- a/pkg/config/keybinding_test.go +++ b/pkg/config/keybinding_test.go @@ -135,6 +135,59 @@ func TestKeybindingMarshalJSON(t *testing.T) { } } +func TestMergeLegacyAltKeybindings(t *testing.T) { + scenarios := []struct { + name string + quit Keybinding + quitAlt1 Keybinding + expected Keybinding + }{ + { + name: "alt is folded into main", + quit: Keybinding{"q"}, + quitAlt1: Keybinding{""}, + expected: Keybinding{"q", ""}, + }, + { + name: "alt is not appended if already present", + quit: Keybinding{"q", ""}, + quitAlt1: Keybinding{""}, + expected: Keybinding{"q", ""}, + }, + { + name: "empty alt is ignored", + quit: Keybinding{"q"}, + quitAlt1: nil, + expected: Keybinding{"q"}, + }, + { + name: "user-supplied multi-key main is preserved", + quit: Keybinding{"q", ""}, + quitAlt1: Keybinding{""}, + expected: Keybinding{"q", "", ""}, + }, + { + name: "multi-key alt is folded element by element", + quit: Keybinding{"q"}, + quitAlt1: Keybinding{"", ""}, + expected: Keybinding{"q", "", ""}, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + cfg := KeybindingConfig{ + Universal: KeybindingUniversalConfig{ + Quit: s.quit, + QuitAlt1: s.quitAlt1, + }, + } + cfg.MergeLegacyAltKeybindings() + assert.Equal(t, s.expected, cfg.Universal.Quit) + }) + } +} + func TestKeybindingYAMLRoundTrip(t *testing.T) { scenarios := []Keybinding{ {"q"}, diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 6461ece75..a5525804d 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -423,7 +423,8 @@ type KeybindingConfig struct { // damn looks like we have some inconsistencies here with -alt and -alt1 type KeybindingUniversalConfig struct { - Quit Keybinding `yaml:"quit"` + Quit Keybinding `yaml:"quit"` + // Deprecated: add the key to `quit` instead. QuitAlt1 Keybinding `yaml:"quit-alt1"` SuspendApp Keybinding `yaml:"suspendApp"` Return Keybinding `yaml:"return"` @@ -769,6 +770,14 @@ type IconProperties struct { Color string `yaml:"color"` } +// MergeLegacyAltKeybindings folds deprecated `*Alt*` fields into their +// corresponding multi-key main field. New code should treat the main field +// as the single source of truth; the alt fields will be removed in a future +// release. +func (c *KeybindingConfig) MergeLegacyAltKeybindings() { + mergeLegacyAlt(&c.Universal.Quit, c.Universal.QuitAlt1) +} + func GetDefaultConfig() *UserConfig { // This is only for tests; we don't want to use the test runner's host platform in that case, // but always use the fallback bindings diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index 5528e10e7..8e5013a55 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -111,10 +111,6 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type Description: self.c.Tr.Quit, Handler: self.quit, }, - { - Keys: opts.GetKeys(opts.Config.Universal.QuitAlt1), - Handler: self.quit, - }, { Keys: opts.GetKeys(opts.Config.Universal.QuitWithoutChangingDirectory), Handler: self.quitWithoutChangingDirectory, diff --git a/pkg/jsonschema/generate.go b/pkg/jsonschema/generate.go index 414756b50..dc5045025 100644 --- a/pkg/jsonschema/generate.go +++ b/pkg/jsonschema/generate.go @@ -60,6 +60,7 @@ func customReflect(v *config.UserConfig) *jsonschema.Schema { schema := r.Reflect(v) inlineKeybindingRefs(schema) defaultConfig := config.GetDefaultConfig() + defaultConfig.Keybinding.MergeLegacyAltKeybindings() userConfigSchema := schema.Definitions["UserConfig"] defaultValue := reflect.ValueOf(defaultConfig).Elem() diff --git a/schema-master/config.json b/schema-master/config.json index 1f6e9e7d8..a33e20f2d 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -2125,7 +2125,10 @@ "type": "array" } ], - "default": "q" + "default": [ + "q", + "\u003cctrl+c\u003e" + ] }, "quit-alt1": { "oneOf": [ @@ -2139,6 +2142,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `quit` instead.", "default": "\u003cctrl+c\u003e" }, "suspendApp": { From 2ba401909d16be289a625a9c3992a2fae50967f8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 25 May 2026 12:10:31 +0200 Subject: [PATCH 023/384] Use a dedicated keybinding for hunk navigation in the main view Previously the patch_explorer and merge_conflicts controllers reused Universal.PrevBlock/NextBlock for moving between hunks (or conflicts) in the main view, sharing keys with the global side-window cycle. The two operations are conceptually distinct: cycling side windows is a global navigation gesture, while next/prev hunk acts on the diff in the main view. Tying them together also blocks adding / as side- window-cycle keys, because already means "toggle panel" in the staging view. Add Main.PrevHunk/NextHunk to the existing KeybindingMainConfig (which already groups bindings for the main view across staging, patch building, and merge conflicts) and switch both controllers to it. The defaults match the active key set those controllers had before (//h/l), so the user-visible behavior is unchanged. --- docs-master/Config.md | 2 ++ docs-master/keybindings/Keybindings_en.md | 12 +++---- docs-master/keybindings/Keybindings_ja.md | 12 +++---- docs-master/keybindings/Keybindings_ko.md | 12 +++---- docs-master/keybindings/Keybindings_nl.md | 12 +++---- docs-master/keybindings/Keybindings_pl.md | 12 +++---- docs-master/keybindings/Keybindings_pt.md | 12 +++---- docs-master/keybindings/Keybindings_ru.md | 12 +++---- docs-master/keybindings/Keybindings_zh-CN.md | 12 +++---- docs-master/keybindings/Keybindings_zh-TW.md | 12 +++---- pkg/config/user_config.go | 4 +++ .../controllers/merge_conflicts_controller.go | 12 ++----- .../controllers/patch_explorer_controller.go | 12 ++----- schema-master/config.json | 34 +++++++++++++++++++ 14 files changed, 98 insertions(+), 74 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index dd0a7dd11..90ddde045 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -780,6 +780,8 @@ keybinding: commitFiles: checkoutCommitFile: c main: + prevHunk: [, h] + nextHunk: [, l] toggleSelectHunk: a pickBothHunks: b editSelectHunk: E diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 8191c6b8b..fafa7312d 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -207,8 +207,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` b `` | Pick all hunks | | | `` `` | Previous hunk | | | `` `` | Next hunk | | -| `` `` | Previous conflict | | -| `` `` | Next conflict | | +| `` , h `` | Previous conflict | | +| `` , l `` | Next conflict | | | `` z `` | Undo | Undo last merge conflict resolution. | | `` e `` | Edit file | Open file in external editor. | | `` o `` | Open file | Open file in default application. | @@ -229,8 +229,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Go to previous hunk | | -| `` `` | Go to next hunk | | +| `` , h `` | Go to previous hunk | | +| `` , l `` | Go to next hunk | | | `` v `` | Toggle range select | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` `` | Copy selected text to clipboard | | @@ -245,8 +245,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Go to previous hunk | | -| `` `` | Go to next hunk | | +| `` , h `` | Go to previous hunk | | +| `` , l `` | Go to next hunk | | | `` v `` | Toggle range select | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` `` | Copy selected text to clipboard | | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 0d51ebe7f..4b5bfc9f3 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -248,8 +248,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | 前のハンクに移動 | | -| `` `` | 次のハンクに移動 | | +| `` , h `` | 前のハンクに移動 | | +| `` , l `` | 次のハンクに移動 | | | `` v `` | 範囲選択を切り替え | | | `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | | `` `` | 選択したテキストをクリップボードにコピー | | @@ -270,8 +270,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | 前のハンクに移動 | | -| `` `` | 次のハンクに移動 | | +| `` , h `` | 前のハンクに移動 | | +| `` , l `` | 次のハンクに移動 | | | `` v `` | 範囲選択を切り替え | | | `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | | `` `` | 選択したテキストをクリップボードにコピー | | @@ -290,8 +290,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` b `` | すべてのハンクを選択 | | | `` `` | 前のハンク | | | `` `` | 次のハンク | | -| `` `` | 前のコンフリクト | | -| `` `` | 次のコンフリクト | | +| `` , h `` | 前のコンフリクト | | +| `` , l `` | 次のコンフリクト | | | `` z `` | 元に戻す | 最後のマージコンフリクト解決を元に戻します。 | | `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 | | `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index d183f82de..157984a9c 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -146,8 +146,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` b `` | Pick all hunks | | | `` `` | 이전 hunk를 선택 | | | `` `` | 다음 hunk를 선택 | | -| `` `` | 이전 충돌을 선택 | | -| `` `` | 다음 충돌을 선택 | | +| `` , h `` | 이전 충돌을 선택 | | +| `` , l `` | 다음 충돌을 선택 | | | `` z `` | 되돌리기 | Undo last merge conflict resolution. | | `` e `` | 파일 편집 | Open file in external editor. | | `` o `` | 파일 닫기 | Open file in default application. | @@ -168,8 +168,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | 이전 hunk를 선택 | | -| `` `` | 다음 hunk를 선택 | | +| `` , h `` | 이전 hunk를 선택 | | +| `` , l `` | 다음 hunk를 선택 | | | `` v `` | 드래그 선택 전환 | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` `` | 선택한 텍스트를 클립보드에 복사 | | @@ -184,8 +184,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | 이전 hunk를 선택 | | -| `` `` | 다음 hunk를 선택 | | +| `` , h `` | 이전 hunk를 선택 | | +| `` , l `` | 다음 hunk를 선택 | | | `` v `` | 드래그 선택 전환 | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` `` | 선택한 텍스트를 클립보드에 복사 | | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 8b01ad9da..ca3449cf9 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -215,8 +215,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` b `` | Kies beide stukken | | | `` `` | Selecteer bovenste hunk | | | `` `` | Selecteer onderste hunk | | -| `` `` | Selecteer voorgaand conflict | | -| `` `` | Selecteer volgende conflict | | +| `` , h `` | Selecteer voorgaand conflict | | +| `` , l `` | Selecteer volgende conflict | | | `` z `` | Ongedaan maken | Undo last merge conflict resolution. | | `` e `` | Verander bestand | Open file in external editor. | | `` o `` | Open bestand | Open file in default application. | @@ -237,8 +237,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Selecteer de vorige hunk | | -| `` `` | Selecteer de volgende hunk | | +| `` , 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. | | `` `` | Copy selected text to clipboard | | @@ -312,8 +312,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Selecteer de vorige hunk | | -| `` `` | Selecteer de volgende hunk | | +| `` , 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. | | `` `` | Copy selected text to clipboard | | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 36d3c58a2..69e60b78d 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -115,8 +115,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Idź do poprzedniego fragmentu | | -| `` `` | Idź do następnego fragmentu | | +| `` , h `` | Idź do poprzedniego fragmentu | | +| `` , l `` | Idź do następnego fragmentu | | | `` v `` | Przełącz zaznaczenie zakresu | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` `` | Kopiuj zaznaczony tekst do schowka | | @@ -191,8 +191,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` b `` | Wybierz wszystkie fragmenty | | | `` `` | Poprzedni fragment | | | `` `` | Następny fragment | | -| `` `` | Poprzedni konflikt | | -| `` `` | Następny konflikt | | +| `` , h `` | Poprzedni konflikt | | +| `` , l `` | Następny konflikt | | | `` z `` | Cofnij | Cofnij ostatnie rozwiązanie konfliktu scalania. | | `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | @@ -203,8 +203,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Idź do poprzedniego fragmentu | | -| `` `` | Idź do następnego fragmentu | | +| `` , h `` | Idź do poprzedniego fragmentu | | +| `` , l `` | Idź do następnego fragmentu | | | `` v `` | Przełącz zaznaczenie zakresu | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` `` | Kopiuj zaznaczony tekst do schowka | | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 17835d1a4..0152530f0 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -241,8 +241,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Ir para o local anterior | | -| `` `` | Ir para o próximo trecho | | +| `` , h `` | Ir para o local anterior | | +| `` , l `` | Ir para o próximo trecho | | | `` v `` | Toggle range select | | | `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. | | `` `` | Copiar texto selecionado para área de transferência | | @@ -275,8 +275,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` b `` | Pegar todos os pedaços | | | `` `` | Trecho anterior | | | `` `` | Próximo trecho | | -| `` `` | Conflito anterior | | -| `` `` | Próximo conflito | | +| `` , h `` | Conflito anterior | | +| `` , l `` | Próximo conflito | | | `` z `` | Desfazer | Desfazer resolução de conflitos de última mesclagem. | | `` e `` | Editar arquivo | Abrir arquivo no editor externo. | | `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | @@ -287,8 +287,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Ir para o local anterior | | -| `` `` | Ir para o próximo trecho | | +| `` , h `` | Ir para o local anterior | | +| `` , l `` | Ir para o próximo trecho | | | `` v `` | Toggle range select | | | `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. | | `` `` | Copiar texto selecionado para área de transferência | | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 538a05ef0..697912e5a 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -80,8 +80,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Выбрать предыдущую часть | | -| `` `` | Выбрать следующую часть | | +| `` , h `` | Выбрать предыдущую часть | | +| `` , l `` | Выбрать следующую часть | | | `` v `` | Переключить выборку перетаскивания | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` `` | Скопировать выделенный текст в буфер обмена | | @@ -116,8 +116,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` b `` | Выбрать все части | | | `` `` | Выбрать предыдущую часть | | | `` `` | Выбрать следующую часть | | -| `` `` | Выбрать предыдущий конфликт | | -| `` `` | Выбрать следующий конфликт | | +| `` , h `` | Выбрать предыдущий конфликт | | +| `` , l `` | Выбрать следующий конфликт | | | `` z `` | Отменить | Undo last merge conflict resolution. | | `` e `` | Редактировать файл | Open file in external editor. | | `` o `` | Открыть файл | Open file in default application. | @@ -128,8 +128,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Выбрать предыдущую часть | | -| `` `` | Выбрать следующую часть | | +| `` , h `` | Выбрать предыдущую часть | | +| `` , l `` | Выбрать следующую часть | | | `` v `` | Переключить выборку перетаскивания | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` `` | Скопировать выделенный текст в буфер обмена | | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index b144b08cd..fcd78596a 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -252,8 +252,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | 选择上一个区块 | | -| `` `` | 选择下一个区块 | | +| `` , h `` | 选择上一个区块 | | +| `` , l `` | 选择下一个区块 | | | `` v `` | 切换拖动选择 | | | `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | | `` `` | 复制选中文本到剪贴板 | | @@ -296,8 +296,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` b `` | 选中所有区块 | | | `` `` | 选择顶部块 | | | `` `` | 选择底部块 | | -| `` `` | 选择上一个冲突 | | -| `` `` | 选择下一个冲突 | | +| `` , h `` | 选择上一个冲突 | | +| `` , l `` | 选择下一个冲突 | | | `` z `` | 撤销 | 撤消上次合并冲突解决 | | `` e `` | 编辑文件 | 使用外部编辑器打开文件 | | `` o `` | 打开文件 | 使用默认程序打开该文件 | @@ -308,8 +308,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | 选择上一个区块 | | -| `` `` | 选择下一个区块 | | +| `` , h `` | 选择上一个区块 | | +| `` , l `` | 选择下一个区块 | | | `` v `` | 切换拖动选择 | | | `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | | `` `` | 复制选中文本到剪贴板 | | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 5e7892bd9..2c2456e4a 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -62,8 +62,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | 選擇上一段 | | -| `` `` | 選擇下一段 | | +| `` , h `` | 選擇上一段 | | +| `` , l `` | 選擇下一段 | | | `` v `` | 切換拖曳選擇 | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` `` | 複製所選文本至剪貼簿 | | @@ -92,8 +92,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` b `` | 挑選所有程式碼片段 | | | `` `` | 選擇上一段 | | | `` `` | 選擇下一段 | | -| `` `` | 選擇上一個衝突 | | -| `` `` | 選擇下一個衝突 | | +| `` , h `` | 選擇上一個衝突 | | +| `` , l `` | 選擇下一個衝突 | | | `` z `` | 復原 | Undo last merge conflict resolution. | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 | @@ -104,8 +104,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | 選擇上一段 | | -| `` `` | 選擇下一段 | | +| `` , h `` | 選擇上一段 | | +| `` , l `` | 選擇下一段 | | | `` v `` | 切換拖曳選擇 | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` `` | 複製所選文本至剪貼簿 | | diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index a5525804d..6956d4366 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -607,6 +607,8 @@ type KeybindingCommitFilesConfig struct { } type KeybindingMainConfig struct { + PrevHunk Keybinding `yaml:"prevHunk"` + NextHunk Keybinding `yaml:"nextHunk"` ToggleSelectHunk Keybinding `yaml:"toggleSelectHunk"` PickBothHunks Keybinding `yaml:"pickBothHunks"` EditSelectHunk Keybinding `yaml:"editSelectHunk"` @@ -1088,6 +1090,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { CheckoutCommitFile: Keybinding{"c"}, }, Main: KeybindingMainConfig{ + PrevHunk: Keybinding{"", "h"}, + NextHunk: Keybinding{"", "l"}, ToggleSelectHunk: Keybinding{"a"}, PickBothHunks: Keybinding{"b"}, EditSelectHunk: Keybinding{"E"}, diff --git a/pkg/gui/controllers/merge_conflicts_controller.go b/pkg/gui/controllers/merge_conflicts_controller.go index a539e710f..322d1cd39 100644 --- a/pkg/gui/controllers/merge_conflicts_controller.go +++ b/pkg/gui/controllers/merge_conflicts_controller.go @@ -52,13 +52,13 @@ func (self *MergeConflictsController) GetKeybindings(opts types.KeybindingsOpts) DisplayOnScreen: true, }, { - Keys: opts.GetKeys(opts.Config.Universal.PrevBlock), + Keys: opts.GetKeys(opts.Config.Main.PrevHunk), Handler: self.withRenderAndFocus(self.PrevConflict), Description: self.c.Tr.PrevConflict, DisplayOnScreen: true, }, { - Keys: opts.GetKeys(opts.Config.Universal.NextBlock), + Keys: opts.GetKeys(opts.Config.Main.NextHunk), Handler: self.withRenderAndFocus(self.NextConflict), Description: self.c.Tr.NextConflict, DisplayOnScreen: true, @@ -83,14 +83,6 @@ func (self *MergeConflictsController) GetKeybindings(opts types.KeybindingsOpts) Description: self.c.Tr.OpenFile, Tooltip: self.c.Tr.OpenFileTooltip, }, - { - Keys: opts.GetKeys(opts.Config.Universal.PrevBlockAlt), - Handler: self.withRenderAndFocus(self.PrevConflict), - }, - { - Keys: opts.GetKeys(opts.Config.Universal.NextBlockAlt), - Handler: self.withRenderAndFocus(self.NextConflict), - }, { Keys: opts.GetKeys(opts.Config.Universal.PrevItemAlt), Handler: self.withRenderAndFocus(self.PrevConflictHunk), diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go index 14bce304e..6f7e766af 100644 --- a/pkg/gui/controllers/patch_explorer_controller.go +++ b/pkg/gui/controllers/patch_explorer_controller.go @@ -72,23 +72,15 @@ func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts) Description: self.c.Tr.RangeSelectDown, }, { - Keys: opts.GetKeys(opts.Config.Universal.PrevBlock), + Keys: opts.GetKeys(opts.Config.Main.PrevHunk), Handler: self.withRenderAndFocus(self.HandlePrevHunk), Description: self.c.Tr.PrevHunk, }, { - Keys: opts.GetKeys(opts.Config.Universal.PrevBlockAlt), - Handler: self.withRenderAndFocus(self.HandlePrevHunk), - }, - { - Keys: opts.GetKeys(opts.Config.Universal.NextBlock), + Keys: opts.GetKeys(opts.Config.Main.NextHunk), Handler: self.withRenderAndFocus(self.HandleNextHunk), Description: self.c.Tr.NextHunk, }, - { - Keys: opts.GetKeys(opts.Config.Universal.NextBlockAlt), - Handler: self.withRenderAndFocus(self.HandleNextHunk), - }, { Keys: opts.GetKeys(opts.Config.Universal.ToggleRangeSelect), Handler: self.withRenderAndFocus(self.HandleToggleSelectRange), diff --git a/schema-master/config.json b/schema-master/config.json index a33e20f2d..b879ba5c5 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -1921,6 +1921,40 @@ }, "KeybindingMainConfig": { "properties": { + "prevHunk": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003cleft\u003e", + "h" + ] + }, + "nextHunk": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003cright\u003e", + "l" + ] + }, "toggleSelectHunk": { "oneOf": [ { From 3a3625d85580789fac3b770f6883e0f8035c6247 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 10 May 2026 09:09:57 +0200 Subject: [PATCH 024/384] Fold remaining alt bindings into their main fields Convert the remaining *Alt/*Alt[12] sibling fields (PrevItem/NextItem, GotoTop/GotoBottom, PrevBlock/NextBlock, ScrollUpMain/ScrollDownMain, OptionMenu, ConfirmInEditor, DiffingMenu) so the merge mechanism folds their values into the corresponding main multi-key binding at config load. The redundant alt-only Binding registrations across the various controllers and the global keybindings file are gone: the merged main field already carries every key, so the for-loop in SetKeybinding registers them all. --- docs-master/Config.md | 34 +--- docs-master/keybindings/Keybindings_en.md | 15 +- docs-master/keybindings/Keybindings_ja.md | 15 +- docs-master/keybindings/Keybindings_ko.md | 15 +- docs-master/keybindings/Keybindings_nl.md | 15 +- docs-master/keybindings/Keybindings_pl.md | 15 +- docs-master/keybindings/Keybindings_pt.md | 15 +- docs-master/keybindings/Keybindings_ru.md | 15 +- docs-master/keybindings/Keybindings_zh-CN.md | 15 +- docs-master/keybindings/Keybindings_zh-TW.md | 15 +- pkg/config/user_config.go | 186 ++++++++++-------- pkg/gui/command_log_panel.go | 5 +- .../commit_description_controller.go | 30 +-- pkg/gui/controllers/global_controller.go | 7 - pkg/gui/controllers/list_controller.go | 8 +- .../controllers/merge_conflicts_controller.go | 8 - .../controllers/patch_explorer_controller.go | 20 -- pkg/gui/controllers/side_window_controller.go | 4 - .../controllers/view_selection_controller.go | 8 +- pkg/gui/keybindings.go | 62 ------ pkg/gui/menu_panel.go | 4 + pkg/i18n/english.go | 2 - schema-master/config.json | 68 ++++++- 23 files changed, 254 insertions(+), 327 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 90ddde045..882baec63 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -602,27 +602,19 @@ keybinding: return: quitWithoutChangingDirectory: Q togglePanel: - prevItem: - nextItem: - prevItem-alt: k - nextItem-alt: j + prevItem: [, k] + nextItem: [, j] prevPage: ',' nextPage: . scrollLeft: H scrollRight: L - gotoTop: < - gotoBottom: '>' - gotoTop-alt: - gotoBottom-alt: + gotoTop: [<, ] + gotoBottom: ['>', ] toggleRangeSelect: v rangeSelectDown: rangeSelectUp: - prevBlock: - nextBlock: - prevBlock-alt: h - nextBlock-alt: l - nextBlock-alt2: - prevBlock-alt2: + prevBlock: [, h, ] + nextBlock: [, l, ] jumpToBlock: - "1" - "2" @@ -653,18 +645,13 @@ keybinding: confirmSuggestion: # on Mac - confirmInEditor: - confirmInEditor-alt: + confirmInEditor: [, ] remove: d new: "n" edit: e openFile: o - scrollUpMain: - scrollDownMain: - scrollUpMain-alt1: K - scrollDownMain-alt1: J - scrollUpMain-alt2: - scrollDownMain-alt2: + scrollUpMain: [, K, ] + scrollDownMain: [, J, ] executeShellCommand: ':' createRebaseOptionsMenu: m @@ -683,8 +670,7 @@ keybinding: undo: z redo: Z filteringMenu: - diffingMenu: W - diffingMenu-alt: + diffingMenu: [W, ] copyToClipboard: openRecentRepos: submitEditorText: diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index fafa7312d..71ecdae5b 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -7,8 +7,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Switch to a recent repo | | -| `` (fn+up/shift+k) `` | Scroll up main window | | -| `` (fn+down/shift+j) `` | Scroll down main window | | +| `` , K, (fn+up/shift+k) `` | Scroll up main window | | +| `` , J, (fn+down/shift+j) `` | Scroll down main window | | | `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | @@ -26,8 +26,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Cancel | | | `` ? `` | Open keybindings menu | | | `` `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. | -| `` W `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | +| `` W, `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q, `` | Quit | | | `` `` | Suspend the application | | | `` `` | 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'. | @@ -40,8 +39,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` , `` | Previous page | | | `` . `` | Next page | | -| `` < () `` | Scroll to top | | -| `` > () `` | Scroll to bottom | | +| `` <, `` | Scroll to top | | +| `` >, `` | Scroll to bottom | | | `` v `` | Toggle range select | | | `` `` | Range select down | | | `` `` | Range select up | | @@ -205,8 +204,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Pick hunk | | | `` b `` | Pick all hunks | | -| `` `` | Previous hunk | | -| `` `` | Next hunk | | +| `` , k `` | Previous hunk | | +| `` , j `` | Next hunk | | | `` , h `` | Previous conflict | | | `` , l `` | Next conflict | | | `` z `` | Undo | Undo last merge conflict resolution. | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 4b5bfc9f3..5a958f80e 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -7,8 +7,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | 最近のリポジトリをチェックアウト | | -| `` (fn+up/shift+k) `` | メインウィンドウを上にスクロール | | -| `` (fn+down/shift+j) `` | メインウィンドウを下にスクロール | | +| `` , K, (fn+up/shift+k) `` | メインウィンドウを上にスクロール | | +| `` , J, (fn+down/shift+j) `` | メインウィンドウを下にスクロール | | | `` @ `` | コマンドログオプションを表示 | コマンドログのオプションを表示します(例:コマンドログの表示/非表示、コマンドログへのフォーカスなど)。 | | `` P `` | プッシュ | 現在のブランチを対応するアップストリームブランチにプッシュします。アップストリームが設定されていない場合、アップストリームブランチの設定を求められます。 | | `` p `` | プル | 現在のブランチのリモートから変更をプルします。アップストリームが設定されていない場合、アップストリームブランチの設定を求められます。 | @@ -26,8 +26,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | キャンセル | | | `` ? `` | キーバインディングメニューを開く | | | `` `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 | -| `` W `` | 差分オプションを表示 | 2つのrefの差分に関連するオプションを表示します(例:選択したrefとの差分表示、差分を取るrefの入力、差分方向の反転など)。 | -| `` `` | 差分オプションを表示 | 2つのrefの差分に関連するオプションを表示します(例:選択したrefとの差分表示、差分を取るrefの入力、差分方向の反転など)。 | +| `` W, `` | 差分オプションを表示 | 2つのrefの差分に関連するオプションを表示します(例:選択したrefとの差分表示、差分を取るrefの入力、差分方向の反転など)。 | | `` q, `` | 終了 | | | `` `` | Suspend the application | | | `` `` | 空白表示の切り替え | 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'. | @@ -40,8 +39,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` , `` | 前のページ | | | `` . `` | 次のページ | | -| `` < () `` | 先頭にスクロール | | -| `` > () `` | 末尾にスクロール | | +| `` <, `` | 先頭にスクロール | | +| `` >, `` | 末尾にスクロール | | | `` v `` | 範囲選択を切り替え | | | `` `` | 範囲選択を下に | | | `` `` | 範囲選択を上に | | @@ -288,8 +287,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | ハンクを選択 | | | `` b `` | すべてのハンクを選択 | | -| `` `` | 前のハンク | | -| `` `` | 次のハンク | | +| `` , k `` | 前のハンク | | +| `` , j `` | 次のハンク | | | `` , h `` | 前のコンフリクト | | | `` , l `` | 次のコンフリクト | | | `` z `` | 元に戻す | 最後のマージコンフリクト解決を元に戻します。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 157984a9c..d6121452b 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -7,8 +7,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | 최근에 사용한 저장소로 전환 | | -| `` (fn+up/shift+k) `` | 메인 패널을 위로 스크롤 | | -| `` (fn+down/shift+j) `` | 메인 패널을 아래로로 스크롤 | | +| `` , K, (fn+up/shift+k) `` | 메인 패널을 위로 스크롤 | | +| `` , J, (fn+down/shift+j) `` | 메인 패널을 아래로로 스크롤 | | | `` @ `` | 명령어 로그 메뉴 열기 | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` p `` | 업데이트 | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | @@ -26,8 +26,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 취소 | | | `` ? `` | 매뉴 열기 | | | `` `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. | -| `` W `` | Diff 메뉴 열기 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` `` | Diff 메뉴 열기 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | +| `` W, `` | Diff 메뉴 열기 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q, `` | 종료 | | | `` `` | Suspend the application | | | `` `` | 공백문자를 Diff 뷰에서 표시 여부 전환 | 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'. | @@ -40,8 +39,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` , `` | 이전 페이지 | | | `` . `` | 다음 페이지 | | -| `` < () `` | 맨 위로 스크롤 | | -| `` > () `` | 맨 아래로 스크롤 | | +| `` <, `` | 맨 위로 스크롤 | | +| `` >, `` | 맨 아래로 스크롤 | | | `` v `` | 드래그 선택 전환 | | | `` `` | Range select down | | | `` `` | Range select up | | @@ -144,8 +143,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Pick hunk | | | `` b `` | Pick all hunks | | -| `` `` | 이전 hunk를 선택 | | -| `` `` | 다음 hunk를 선택 | | +| `` , k `` | 이전 hunk를 선택 | | +| `` , j `` | 다음 hunk를 선택 | | | `` , h `` | 이전 충돌을 선택 | | | `` , l `` | 다음 충돌을 선택 | | | `` z `` | 되돌리기 | Undo last merge conflict resolution. | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index ca3449cf9..23a08aae4 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -7,8 +7,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Wissel naar een recente repo | | -| `` (fn+up/shift+k) `` | Scroll naar beneden vanaf hoofdpaneel | | -| `` (fn+down/shift+j) `` | Scroll naar beneden vanaf hoofdpaneel | | +| `` , 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. | | `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | @@ -26,8 +26,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 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. | -| `` `` | 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. | +| `` 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, `` | Quit | | | `` `` | Suspend the application | | | `` `` | 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'. | @@ -40,8 +39,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` , `` | Vorige pagina | | | `` . `` | Volgende pagina | | -| `` < () `` | Scroll naar boven | | -| `` > () `` | Scroll naar beneden | | +| `` <, `` | Scroll naar boven | | +| `` >, `` | Scroll naar beneden | | | `` v `` | Toggle drag selecteer | | | `` `` | Range select down | | | `` `` | Range select up | | @@ -213,8 +212,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Kies stuk | | | `` b `` | Kies beide stukken | | -| `` `` | Selecteer bovenste hunk | | -| `` `` | Selecteer onderste hunk | | +| `` , k `` | Selecteer bovenste hunk | | +| `` , j `` | Selecteer onderste hunk | | | `` , h `` | Selecteer voorgaand conflict | | | `` , l `` | Selecteer volgende conflict | | | `` z `` | Ongedaan maken | Undo last merge conflict resolution. | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 69e60b78d..b233fd6d4 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -7,8 +7,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Przełącz na ostatnie repozytorium | | -| `` (fn+up/shift+k) `` | Przewiń główne okno w górę | | -| `` (fn+down/shift+j) `` | Przewiń główne okno w dół | | +| `` , K, (fn+up/shift+k) `` | Przewiń główne okno w górę | | +| `` , J, (fn+down/shift+j) `` | Przewiń główne okno w dół | | | `` @ `` | Pokaż opcje dziennika poleceń | Pokaż opcje dla dziennika poleceń, np. pokazywanie/ukrywanie dziennika poleceń i skupienie na dzienniku poleceń. | | `` P `` | Wypchnij | Wypchnij bieżącą gałąź do jej gałęzi nadrzędnej. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej. | | `` p `` | Pociągnij | Pociągnij zmiany z zdalnego dla bieżącej gałęzi. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej. | @@ -26,8 +26,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 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. | -| `` W `` | Pokaż opcje różnicowania | Pokaż opcje dotyczące różnicowania dwóch refów, np. różnicowanie względem wybranego refa, wprowadzanie refa do różnicowania i odwracanie kierunku różnic. | -| `` `` | Pokaż opcje różnicowania | Pokaż opcje dotyczące różnicowania dwóch refów, np. różnicowanie względem wybranego refa, wprowadzanie refa do różnicowania i odwracanie kierunku różnic. | +| `` W, `` | Pokaż opcje różnicowania | Pokaż opcje dotyczące różnicowania dwóch refów, np. różnicowanie względem wybranego refa, wprowadzanie refa do różnicowania i odwracanie kierunku różnic. | | `` q, `` | Wyjdź | | | `` `` | Suspend the application | | | `` `` | Przełącz białe znaki | 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'. | @@ -40,8 +39,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` , `` | Poprzednia strona | | | `` . `` | Następna strona | | -| `` < () `` | Przewiń do góry | | -| `` > () `` | Przewiń do dołu | | +| `` <, `` | Przewiń do góry | | +| `` >, `` | Przewiń do dołu | | | `` v `` | Przełącz zaznaczenie zakresu | | | `` `` | Zaznacz zakres w dół | | | `` `` | Zaznacz zakres w górę | | @@ -189,8 +188,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Wybierz fragment | | | `` b `` | Wybierz wszystkie fragmenty | | -| `` `` | Poprzedni fragment | | -| `` `` | Następny fragment | | +| `` , k `` | Poprzedni fragment | | +| `` , j `` | Następny fragment | | | `` , h `` | Poprzedni konflikt | | | `` , l `` | Następny konflikt | | | `` z `` | Cofnij | Cofnij ostatnie rozwiązanie konfliktu scalania. | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 0152530f0..22d5cbb55 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -7,8 +7,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Mudar para um repositório recente | | -| `` (fn+up/shift+k) `` | Rolar janela principal para cima | | -| `` (fn+down/shift+j) `` | Rolar a janela principal para baixo | | +| `` , K, (fn+up/shift+k) `` | Rolar janela principal para cima | | +| `` , J, (fn+down/shift+j) `` | Rolar a janela principal para baixo | | | `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | Empurre (Push) | Faça push do branch atual para o seu branch upstream. Se nenhum upstream estiver configurado, você será solicitado a configurar um branch a montante. | | `` p `` | Puxar (Pull) | Puxe alterações do controle remoto para o ramo atual. Se nenhum upstream estiver configurado, será solicitado configurar um ramo a montante. | @@ -26,8 +26,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 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. | -| `` W `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | +| `` W, `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q, `` | Sair | | | `` `` | Suspender a aplicação | | | `` `` | 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'. | @@ -40,8 +39,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` , `` | Aba anterior | | | `` . `` | Próxima aba | | -| `` < () `` | Voltar ao topo | | -| `` > () `` | Ir para o final | | +| `` <, `` | Voltar ao topo | | +| `` >, `` | Ir para o final | | | `` v `` | Toggle range select | | | `` `` | Range select down | | | `` `` | Range select up | | @@ -273,8 +272,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Escolha o local | | | `` b `` | Pegar todos os pedaços | | -| `` `` | Trecho anterior | | -| `` `` | Próximo trecho | | +| `` , k `` | Trecho anterior | | +| `` , j `` | Próximo trecho | | | `` , h `` | Conflito anterior | | | `` , l `` | Próximo conflito | | | `` z `` | Desfazer | Desfazer resolução de conflitos de última mesclagem. | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 697912e5a..3b81d2433 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -7,8 +7,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Переключиться на последний репозиторий | | -| `` (fn+up/shift+k) `` | Прокрутить вверх главную панель | | -| `` (fn+down/shift+j) `` | Прокрутить вниз главную панель | | +| `` , K, (fn+up/shift+k) `` | Прокрутить вверх главную панель | | +| `` , J, (fn+down/shift+j) `` | Прокрутить вниз главную панель | | | `` @ `` | Открыть меню журнала команд | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` p `` | Получить и слить изменения | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | @@ -26,8 +26,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Отменить | | | `` ? `` | Открыть меню | | | `` `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. | -| `` W `` | Открыть меню сравнении | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` `` | Открыть меню сравнении | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | +| `` W, `` | Открыть меню сравнении | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q, `` | Выйти | | | `` `` | Suspend the application | | | `` `` | Переключить отображение изменении пробелов в просмотрщике сравнении | 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'. | @@ -40,8 +39,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` , `` | Предыдущая страница | | | `` . `` | Следующая страница | | -| `` < () `` | Пролистать наверх | | -| `` > () `` | Прокрутить вниз | | +| `` <, `` | Пролистать наверх | | +| `` >, `` | Прокрутить вниз | | | `` v `` | Переключить выборку перетаскивания | | | `` `` | Range select down | | | `` `` | Range select up | | @@ -114,8 +113,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Выбрать эту часть | | | `` b `` | Выбрать все части | | -| `` `` | Выбрать предыдущую часть | | -| `` `` | Выбрать следующую часть | | +| `` , k `` | Выбрать предыдущую часть | | +| `` , j `` | Выбрать следующую часть | | | `` , h `` | Выбрать предыдущий конфликт | | | `` , l `` | Выбрать следующий конфликт | | | `` z `` | Отменить | Undo last merge conflict resolution. | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index fcd78596a..76e378695 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -7,8 +7,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | 切换到最近的仓库 | | -| `` (fn+up/shift+k) `` | 向上滚动主面板 | | -| `` (fn+down/shift+j) `` | 向下滚动主面板 | | +| `` , K, (fn+up/shift+k) `` | 向上滚动主面板 | | +| `` , J, (fn+down/shift+j) `` | 向下滚动主面板 | | | `` @ `` | 打开命令日志菜单 | 查看命令日志的选项,例如显示/隐藏命令日志以及聚焦命令日志 | | `` P `` | 推送 | 推送当前分支到它的上游。如果上游未配置,您可以在弹窗中配置上游分支。 | | `` p `` | 拉取 | 从当前分支的远程分支获取改动。如果上游未配置,您可以在弹窗中配置上游分支。 | @@ -26,8 +26,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 取消 | | | `` ? `` | 打开菜单 | | | `` `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 | -| `` W `` | 打开 diff 菜单 | 查看与比较两个引用相关的选项,例如与选定的 ref 进行比较,输入要比较的 ref,然后反转比较方向。 | -| `` `` | 打开 diff 菜单 | 查看与比较两个引用相关的选项,例如与选定的 ref 进行比较,输入要比较的 ref,然后反转比较方向。 | +| `` W, `` | 打开 diff 菜单 | 查看与比较两个引用相关的选项,例如与选定的 ref 进行比较,输入要比较的 ref,然后反转比较方向。 | | `` q, `` | 退出 | | | `` `` | 挂起应用程序 | | | `` `` | 切换是否在差异视图中显示空白字符差异 | 切换是否在差异视图中显示空白字符更改。

默认值可在配置文件中通过键 'git.ignoreWhitespaceInDiffView' 更改。 | @@ -40,8 +39,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` , `` | 上一页 | | | `` . `` | 下一页 | | -| `` < () `` | 滚动到顶部 | | -| `` > () `` | 滚动到底部 | | +| `` <, `` | 滚动到顶部 | | +| `` >, `` | 滚动到底部 | | | `` v `` | 切换拖动选择 | | | `` `` | 向下扩展选择范围 | | | `` `` | 向上扩展选择范围 | | @@ -294,8 +293,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | 选中区块 | | | `` b `` | 选中所有区块 | | -| `` `` | 选择顶部块 | | -| `` `` | 选择底部块 | | +| `` , k `` | 选择顶部块 | | +| `` , j `` | 选择底部块 | | | `` , h `` | 选择上一个冲突 | | | `` , l `` | 选择下一个冲突 | | | `` z `` | 撤销 | 撤消上次合并冲突解决 | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 2c2456e4a..6ebfa5c68 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -7,8 +7,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | 切換到最近使用的版本庫 | | -| `` (fn+up/shift+k) `` | 向上捲動主面板 | | -| `` (fn+down/shift+j) `` | 向下捲動主面板 | | +| `` , K, (fn+up/shift+k) `` | 向上捲動主面板 | | +| `` , J, (fn+down/shift+j) `` | 向下捲動主面板 | | | `` @ `` | 開啟命令記錄選單 | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | 推送 | 推送到遠端。如果沒有設定遠端,會開啟設定視窗。 | | `` p `` | 拉取 | 從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。 | @@ -26,8 +26,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 取消 | | | `` ? `` | 開啟選單 | | | `` `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. | -| `` W `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | +| `` W, `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | | `` q, `` | 結束 | | | `` `` | Suspend the application | | | `` `` | 切換是否在差異檢視中顯示空格變更 | 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'. | @@ -40,8 +39,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` , `` | 上一頁 | | | `` . `` | 下一頁 | | -| `` < () `` | 捲動到頂部 | | -| `` > () `` | 捲動到底部 | | +| `` <, `` | 捲動到頂部 | | +| `` >, `` | 捲動到底部 | | | `` v `` | 切換拖曳選擇 | | | `` `` | Range select down | | | `` `` | Range select up | | @@ -90,8 +89,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | 挑選程式碼片段 | | | `` b `` | 挑選所有程式碼片段 | | -| `` `` | 選擇上一段 | | -| `` `` | 選擇下一段 | | +| `` , k `` | 選擇上一段 | | +| `` , j `` | 選擇下一段 | | | `` , h `` | 選擇上一個衝突 | | | `` , l `` | 選擇下一個衝突 | | | `` z `` | 復原 | Undo last merge conflict resolution. | diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 6956d4366..0b2dcac0c 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -425,85 +425,99 @@ type KeybindingConfig struct { type KeybindingUniversalConfig struct { Quit Keybinding `yaml:"quit"` // Deprecated: add the key to `quit` instead. - QuitAlt1 Keybinding `yaml:"quit-alt1"` - SuspendApp Keybinding `yaml:"suspendApp"` - Return Keybinding `yaml:"return"` - QuitWithoutChangingDirectory Keybinding `yaml:"quitWithoutChangingDirectory"` - TogglePanel Keybinding `yaml:"togglePanel"` - PrevItem Keybinding `yaml:"prevItem"` - NextItem Keybinding `yaml:"nextItem"` - PrevItemAlt Keybinding `yaml:"prevItem-alt"` - NextItemAlt Keybinding `yaml:"nextItem-alt"` - PrevPage Keybinding `yaml:"prevPage"` - NextPage Keybinding `yaml:"nextPage"` - ScrollLeft Keybinding `yaml:"scrollLeft"` - ScrollRight Keybinding `yaml:"scrollRight"` - GotoTop Keybinding `yaml:"gotoTop"` - GotoBottom Keybinding `yaml:"gotoBottom"` - GotoTopAlt Keybinding `yaml:"gotoTop-alt"` - GotoBottomAlt Keybinding `yaml:"gotoBottom-alt"` - ToggleRangeSelect Keybinding `yaml:"toggleRangeSelect"` - RangeSelectDown Keybinding `yaml:"rangeSelectDown"` - RangeSelectUp Keybinding `yaml:"rangeSelectUp"` - PrevBlock Keybinding `yaml:"prevBlock"` - NextBlock Keybinding `yaml:"nextBlock"` - PrevBlockAlt Keybinding `yaml:"prevBlock-alt"` - NextBlockAlt Keybinding `yaml:"nextBlock-alt"` - NextBlockAlt2 Keybinding `yaml:"nextBlock-alt2"` - PrevBlockAlt2 Keybinding `yaml:"prevBlock-alt2"` - JumpToBlock []Keybinding `yaml:"jumpToBlock"` - FocusMainView Keybinding `yaml:"focusMainView"` - NextMatch Keybinding `yaml:"nextMatch"` - PrevMatch Keybinding `yaml:"prevMatch"` - StartSearch Keybinding `yaml:"startSearch"` - MoveWordLeft Keybinding `yaml:"moveWordLeft"` // on Mac - MoveWordRight Keybinding `yaml:"moveWordRight"` // on Mac - BackspaceWord Keybinding `yaml:"backspaceWord"` // on Mac - ForwardDeleteWord Keybinding `yaml:"forwardDeleteWord"` // on Mac - OptionMenu Keybinding `yaml:"optionMenu"` - Select Keybinding `yaml:"select"` - GoInto Keybinding `yaml:"goInto"` - Confirm Keybinding `yaml:"confirm"` - ConfirmMenu Keybinding `yaml:"confirmMenu"` - ConfirmSuggestion Keybinding `yaml:"confirmSuggestion"` - ConfirmInEditor Keybinding `yaml:"confirmInEditor"` // on Mac - ConfirmInEditorAlt Keybinding `yaml:"confirmInEditor-alt"` - Remove Keybinding `yaml:"remove"` - New Keybinding `yaml:"new"` - Edit Keybinding `yaml:"edit"` - OpenFile Keybinding `yaml:"openFile"` - ScrollUpMain Keybinding `yaml:"scrollUpMain"` - ScrollDownMain Keybinding `yaml:"scrollDownMain"` - ScrollUpMainAlt1 Keybinding `yaml:"scrollUpMain-alt1"` - ScrollDownMainAlt1 Keybinding `yaml:"scrollDownMain-alt1"` - ScrollUpMainAlt2 Keybinding `yaml:"scrollUpMain-alt2"` - 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"` - Undo Keybinding `yaml:"undo"` - Redo Keybinding `yaml:"redo"` - FilteringMenu Keybinding `yaml:"filteringMenu"` - DiffingMenu Keybinding `yaml:"diffingMenu"` - DiffingMenuAlt Keybinding `yaml:"diffingMenu-alt"` - CopyToClipboard Keybinding `yaml:"copyToClipboard"` - OpenRecentRepos Keybinding `yaml:"openRecentRepos"` - SubmitEditorText Keybinding `yaml:"submitEditorText"` - ExtrasMenu Keybinding `yaml:"extrasMenu"` - ToggleWhitespaceInDiffView Keybinding `yaml:"toggleWhitespaceInDiffView"` - IncreaseContextInDiffView Keybinding `yaml:"increaseContextInDiffView"` - DecreaseContextInDiffView Keybinding `yaml:"decreaseContextInDiffView"` - IncreaseRenameSimilarityThreshold Keybinding `yaml:"increaseRenameSimilarityThreshold"` - DecreaseRenameSimilarityThreshold Keybinding `yaml:"decreaseRenameSimilarityThreshold"` - OpenDiffTool Keybinding `yaml:"openDiffTool"` + QuitAlt1 Keybinding `yaml:"quit-alt1"` + SuspendApp Keybinding `yaml:"suspendApp"` + Return Keybinding `yaml:"return"` + QuitWithoutChangingDirectory Keybinding `yaml:"quitWithoutChangingDirectory"` + TogglePanel Keybinding `yaml:"togglePanel"` + PrevItem Keybinding `yaml:"prevItem"` + NextItem Keybinding `yaml:"nextItem"` + // Deprecated: add the key to `prevItem` instead. + PrevItemAlt Keybinding `yaml:"prevItem-alt"` + // Deprecated: add the key to `nextItem` instead. + NextItemAlt Keybinding `yaml:"nextItem-alt"` + PrevPage Keybinding `yaml:"prevPage"` + NextPage Keybinding `yaml:"nextPage"` + ScrollLeft Keybinding `yaml:"scrollLeft"` + ScrollRight Keybinding `yaml:"scrollRight"` + GotoTop Keybinding `yaml:"gotoTop"` + GotoBottom Keybinding `yaml:"gotoBottom"` + // Deprecated: add the key to `gotoTop` instead. + GotoTopAlt Keybinding `yaml:"gotoTop-alt"` + // Deprecated: add the key to `gotoBottom` instead. + GotoBottomAlt Keybinding `yaml:"gotoBottom-alt"` + ToggleRangeSelect Keybinding `yaml:"toggleRangeSelect"` + RangeSelectDown Keybinding `yaml:"rangeSelectDown"` + RangeSelectUp Keybinding `yaml:"rangeSelectUp"` + PrevBlock Keybinding `yaml:"prevBlock"` + NextBlock Keybinding `yaml:"nextBlock"` + // Deprecated: add the key to `prevBlock` instead. + PrevBlockAlt Keybinding `yaml:"prevBlock-alt"` + // Deprecated: add the key to `nextBlock` instead. + NextBlockAlt Keybinding `yaml:"nextBlock-alt"` + // Deprecated: add the key to `nextBlock` instead. + NextBlockAlt2 Keybinding `yaml:"nextBlock-alt2"` + // Deprecated: add the key to `prevBlock` instead. + PrevBlockAlt2 Keybinding `yaml:"prevBlock-alt2"` + JumpToBlock []Keybinding `yaml:"jumpToBlock"` + FocusMainView Keybinding `yaml:"focusMainView"` + NextMatch Keybinding `yaml:"nextMatch"` + PrevMatch Keybinding `yaml:"prevMatch"` + StartSearch Keybinding `yaml:"startSearch"` + MoveWordLeft Keybinding `yaml:"moveWordLeft"` // on Mac + MoveWordRight Keybinding `yaml:"moveWordRight"` // on Mac + BackspaceWord Keybinding `yaml:"backspaceWord"` // on Mac + ForwardDeleteWord Keybinding `yaml:"forwardDeleteWord"` // on Mac + OptionMenu Keybinding `yaml:"optionMenu"` + Select Keybinding `yaml:"select"` + GoInto Keybinding `yaml:"goInto"` + Confirm Keybinding `yaml:"confirm"` + ConfirmMenu Keybinding `yaml:"confirmMenu"` + ConfirmSuggestion Keybinding `yaml:"confirmSuggestion"` + ConfirmInEditor Keybinding `yaml:"confirmInEditor"` // on Mac + // Deprecated: add the key to `confirmInEditor` instead. + ConfirmInEditorAlt Keybinding `yaml:"confirmInEditor-alt"` + Remove Keybinding `yaml:"remove"` + New Keybinding `yaml:"new"` + Edit Keybinding `yaml:"edit"` + OpenFile Keybinding `yaml:"openFile"` + ScrollUpMain Keybinding `yaml:"scrollUpMain"` + ScrollDownMain Keybinding `yaml:"scrollDownMain"` + // Deprecated: add the key to `scrollUpMain` instead. + ScrollUpMainAlt1 Keybinding `yaml:"scrollUpMain-alt1"` + // Deprecated: add the key to `scrollDownMain` instead. + ScrollDownMainAlt1 Keybinding `yaml:"scrollDownMain-alt1"` + // 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"` + 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"` + OpenRecentRepos Keybinding `yaml:"openRecentRepos"` + SubmitEditorText Keybinding `yaml:"submitEditorText"` + ExtrasMenu Keybinding `yaml:"extrasMenu"` + ToggleWhitespaceInDiffView Keybinding `yaml:"toggleWhitespaceInDiffView"` + IncreaseContextInDiffView Keybinding `yaml:"increaseContextInDiffView"` + DecreaseContextInDiffView Keybinding `yaml:"decreaseContextInDiffView"` + IncreaseRenameSimilarityThreshold Keybinding `yaml:"increaseRenameSimilarityThreshold"` + DecreaseRenameSimilarityThreshold Keybinding `yaml:"decreaseRenameSimilarityThreshold"` + OpenDiffTool Keybinding `yaml:"openDiffTool"` } type KeybindingStatusConfig struct { @@ -778,6 +792,20 @@ type IconProperties struct { // release. func (c *KeybindingConfig) MergeLegacyAltKeybindings() { mergeLegacyAlt(&c.Universal.Quit, c.Universal.QuitAlt1) + mergeLegacyAlt(&c.Universal.PrevItem, c.Universal.PrevItemAlt) + mergeLegacyAlt(&c.Universal.NextItem, c.Universal.NextItemAlt) + mergeLegacyAlt(&c.Universal.GotoTop, c.Universal.GotoTopAlt) + mergeLegacyAlt(&c.Universal.GotoBottom, c.Universal.GotoBottomAlt) + mergeLegacyAlt(&c.Universal.PrevBlock, c.Universal.PrevBlockAlt) + mergeLegacyAlt(&c.Universal.NextBlock, c.Universal.NextBlockAlt) + mergeLegacyAlt(&c.Universal.PrevBlock, c.Universal.PrevBlockAlt2) + mergeLegacyAlt(&c.Universal.NextBlock, c.Universal.NextBlockAlt2) + mergeLegacyAlt(&c.Universal.ConfirmInEditor, c.Universal.ConfirmInEditorAlt) + mergeLegacyAlt(&c.Universal.ScrollUpMain, c.Universal.ScrollUpMainAlt1) + mergeLegacyAlt(&c.Universal.ScrollUpMain, c.Universal.ScrollUpMainAlt2) + mergeLegacyAlt(&c.Universal.ScrollDownMain, c.Universal.ScrollDownMainAlt1) + mergeLegacyAlt(&c.Universal.ScrollDownMain, c.Universal.ScrollDownMainAlt2) + mergeLegacyAlt(&c.Universal.DiffingMenu, c.Universal.DiffingMenuAlt) } func GetDefaultConfig() *UserConfig { diff --git a/pkg/gui/command_log_panel.go b/pkg/gui/command_log_panel.go index 3381f371a..8f2e06b98 100644 --- a/pkg/gui/command_log_panel.go +++ b/pkg/gui/command_log_panel.go @@ -136,9 +136,8 @@ func (gui *Gui) getRandomTip() string { config.Universal.NextPage, ), fmt.Sprintf( - "You can jump to the top/bottom of a panel using '%s (or %s)' and '%s (or %s)'", - config.Universal.GotoTop, config.Universal.GotoTopAlt, - config.Universal.GotoBottom, config.Universal.GotoBottomAlt, + "You can jump to the top/bottom of a panel using '%s' and '%s'", + config.Universal.GotoTop, config.Universal.GotoBottom, ), fmt.Sprintf( "To collapse/expand a directory, press '%s'", diff --git a/pkg/gui/controllers/commit_description_controller.go b/pkg/gui/controllers/commit_description_controller.go index 755fddb56..41ec54103 100644 --- a/pkg/gui/controllers/commit_description_controller.go +++ b/pkg/gui/controllers/commit_description_controller.go @@ -37,10 +37,6 @@ func (self *CommitDescriptionController) GetKeybindings(opts types.KeybindingsOp Keys: opts.GetKeys(opts.Config.Universal.ConfirmInEditor), Handler: self.confirm, }, - { - Keys: opts.GetKeys(opts.Config.Universal.ConfirmInEditorAlt), - Handler: self.confirm, - }, { Keys: opts.GetKeys(opts.Config.CommitMessage.CommitMenu), Handler: self.openCommitMenu, @@ -68,26 +64,12 @@ func (self *CommitDescriptionController) GetMouseKeybindings(opts types.Keybindi func (self *CommitDescriptionController) GetOnFocus() func(types.OnFocusOpts) { return func(types.OnFocusOpts) { footer := "" - mainDisabled := len(self.c.UserConfig().Keybinding.Universal.ConfirmInEditor) > 0 - altDisabled := len(self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt) > 0 - if !mainDisabled || !altDisabled { - if mainDisabled { - footer = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionFooter, - map[string]string{ - "confirmInEditorKeybinding": self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt.String(), - }) - } else if altDisabled { - footer = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionFooter, - map[string]string{ - "confirmInEditorKeybinding": self.c.UserConfig().Keybinding.Universal.ConfirmInEditor.String(), - }) - } else { - footer = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionFooterTwoBindings, - map[string]string{ - "confirmInEditorKeybinding1": self.c.UserConfig().Keybinding.Universal.ConfirmInEditor.String(), - "confirmInEditorKeybinding2": self.c.UserConfig().Keybinding.Universal.ConfirmInEditorAlt.String(), - }) - } + keys := self.c.UserConfig().Keybinding.Universal.ConfirmInEditor + if len(keys) > 0 { + footer = utils.ResolvePlaceholderString(self.c.Tr.CommitDescriptionFooter, + map[string]string{ + "confirmInEditorKeybinding": keys.String(), + }) } self.c.Views().CommitDescription.Footer = footer } diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index 8e5013a55..1043ee88f 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -99,13 +99,6 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type Tooltip: self.c.Tr.ViewDiffingOptionsTooltip, OpensMenu: true, }, - { - Keys: opts.GetKeys(opts.Config.Universal.DiffingMenuAlt), - Handler: opts.Guards.NoPopupPanel(self.createDiffingMenu), - Description: self.c.Tr.ViewDiffingOptions, - Tooltip: self.c.Tr.ViewDiffingOptionsTooltip, - OpensMenu: true, - }, { Keys: opts.GetKeys(opts.Config.Universal.Quit), Description: self.c.Tr.Quit, diff --git a/pkg/gui/controllers/list_controller.go b/pkg/gui/controllers/list_controller.go index 854e14ffa..b2d45679b 100644 --- a/pkg/gui/controllers/list_controller.go +++ b/pkg/gui/controllers/list_controller.go @@ -271,16 +271,12 @@ func (self *ListController) isFocused() bool { func (self *ListController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItemAlt), Handler: self.HandlePrevLine}, {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.HandlePrevLine}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItemAlt), Handler: self.HandleNextLine}, {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.HandleNextLine}, {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.HandlePrevPage, Description: self.c.Tr.PrevPage}, {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.HandleNextPage, Description: self.c.Tr.NextPage}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.HandleGotoTop, Description: self.c.Tr.GotoTop, Alternative: ""}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.HandleGotoBottom, Description: self.c.Tr.GotoBottom, Alternative: ""}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTopAlt), Handler: self.HandleGotoTop}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottomAlt), Handler: self.HandleGotoBottom}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.HandleGotoTop, Description: self.c.Tr.GotoTop}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.HandleGotoBottom, Description: self.c.Tr.GotoBottom}, {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.ScrollLeft), Handler: self.HandleScrollLeft}, {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.ScrollRight), Handler: self.HandleScrollRight}, } diff --git a/pkg/gui/controllers/merge_conflicts_controller.go b/pkg/gui/controllers/merge_conflicts_controller.go index 322d1cd39..e1e3f8e2c 100644 --- a/pkg/gui/controllers/merge_conflicts_controller.go +++ b/pkg/gui/controllers/merge_conflicts_controller.go @@ -83,14 +83,6 @@ func (self *MergeConflictsController) GetKeybindings(opts types.KeybindingsOpts) Description: self.c.Tr.OpenFile, Tooltip: self.c.Tr.OpenFileTooltip, }, - { - Keys: opts.GetKeys(opts.Config.Universal.PrevItemAlt), - Handler: self.withRenderAndFocus(self.PrevConflictHunk), - }, - { - Keys: opts.GetKeys(opts.Config.Universal.NextItemAlt), - Handler: self.withRenderAndFocus(self.NextConflictHunk), - }, { Keys: opts.GetKeys(opts.Config.Universal.ScrollLeft), Handler: self.withRenderAndFocus(self.HandleScrollLeft), diff --git a/pkg/gui/controllers/patch_explorer_controller.go b/pkg/gui/controllers/patch_explorer_controller.go index 6f7e766af..aa5fd54bb 100644 --- a/pkg/gui/controllers/patch_explorer_controller.go +++ b/pkg/gui/controllers/patch_explorer_controller.go @@ -39,21 +39,11 @@ func (self *PatchExplorerController) Context() types.Context { func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.PrevItemAlt), - Handler: self.withRenderAndFocus(self.HandlePrevLine), - }, { Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.withRenderAndFocus(self.HandlePrevLine), }, - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.NextItemAlt), - Handler: self.withRenderAndFocus(self.HandleNextLine), - }, { Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), @@ -123,16 +113,6 @@ func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts) Description: self.c.Tr.GotoBottom, Handler: self.withRenderAndFocus(self.HandleGotoBottom), }, - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.GotoTopAlt), - Handler: self.withRenderAndFocus(self.HandleGotoTop), - }, - { - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.GotoBottomAlt), - Handler: self.withRenderAndFocus(self.HandleGotoBottom), - }, { Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.ScrollLeft), diff --git a/pkg/gui/controllers/side_window_controller.go b/pkg/gui/controllers/side_window_controller.go index f09a5bbdb..67fc9f946 100644 --- a/pkg/gui/controllers/side_window_controller.go +++ b/pkg/gui/controllers/side_window_controller.go @@ -37,10 +37,6 @@ func (self *SideWindowController) GetKeybindings(opts types.KeybindingsOpts) []* return []*types.Binding{ {Keys: opts.GetKeys(opts.Config.Universal.PrevBlock), Handler: self.previousSideWindow}, {Keys: opts.GetKeys(opts.Config.Universal.NextBlock), Handler: self.nextSideWindow}, - {Keys: opts.GetKeys(opts.Config.Universal.PrevBlockAlt), Handler: self.previousSideWindow}, - {Keys: opts.GetKeys(opts.Config.Universal.NextBlockAlt), Handler: self.nextSideWindow}, - {Keys: opts.GetKeys(opts.Config.Universal.PrevBlockAlt2), Handler: self.previousSideWindow}, - {Keys: opts.GetKeys(opts.Config.Universal.NextBlockAlt2), Handler: self.nextSideWindow}, } } diff --git a/pkg/gui/controllers/view_selection_controller.go b/pkg/gui/controllers/view_selection_controller.go index 710fd37cb..31cbd3695 100644 --- a/pkg/gui/controllers/view_selection_controller.go +++ b/pkg/gui/controllers/view_selection_controller.go @@ -37,15 +37,11 @@ func (self *ViewSelectionController) Context() types.Context { func (self *ViewSelectionController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItem), Handler: self.handlePrevLine}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevItemAlt), Handler: self.handlePrevLine}, {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: self.handleNextLine}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextItemAlt), Handler: self.handleNextLine}, {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.PrevPage), Handler: self.handlePrevPage, Description: self.c.Tr.PrevPage}, {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.NextPage), Handler: self.handleNextPage, Description: self.c.Tr.NextPage}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop, Alternative: ""}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom, Alternative: ""}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTopAlt), Handler: self.handleGotoTop}, - {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottomAlt), Handler: self.handleGotoBottom}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: self.handleGotoTop, Description: self.c.Tr.GotoTop}, + {Tag: "navigation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: self.handleGotoBottom, Description: self.c.Tr.GotoBottom}, } } diff --git a/pkg/gui/keybindings.go b/pkg/gui/keybindings.go index 7bab622b7..22d76f01b 100644 --- a/pkg/gui/keybindings.go +++ b/pkg/gui/keybindings.go @@ -99,26 +99,6 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin Alternative: "fn+down/shift+j", Description: gui.c.Tr.ScrollDownMainWindow, }, - { - ViewName: "", - Keys: opts.GetKeys(opts.Config.Universal.ScrollUpMainAlt1), - Handler: gui.scrollUpMain, - }, - { - ViewName: "", - Keys: opts.GetKeys(opts.Config.Universal.ScrollDownMainAlt1), - Handler: gui.scrollDownMain, - }, - { - ViewName: "", - Keys: opts.GetKeys(opts.Config.Universal.ScrollUpMainAlt2), - Handler: gui.scrollUpMain, - }, - { - ViewName: "", - Keys: opts.GetKeys(opts.Config.Universal.ScrollDownMainAlt2), - Handler: gui.scrollDownMain, - }, { ViewName: "files", Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), @@ -228,16 +208,6 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: gui.scrollDownConfirmationPanel, }, - { - ViewName: "confirmation", - Keys: opts.GetKeys(opts.Config.Universal.PrevItemAlt), - Handler: gui.scrollUpConfirmationPanel, - }, - { - ViewName: "confirmation", - Keys: opts.GetKeys(opts.Config.Universal.NextItemAlt), - Handler: gui.scrollDownConfirmationPanel, - }, { ViewName: "confirmation", Keys: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelUp)}, @@ -263,21 +233,11 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: gui.goToConfirmationPanelTop, }, - { - ViewName: "confirmation", - Keys: opts.GetKeys(opts.Config.Universal.GotoTopAlt), - Handler: gui.goToConfirmationPanelTop, - }, { ViewName: "confirmation", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: gui.goToConfirmationPanelBottom, }, - { - ViewName: "confirmation", - Keys: opts.GetKeys(opts.Config.Universal.GotoBottomAlt), - Handler: gui.goToConfirmationPanelBottom, - }, { ViewName: "submodules", Keys: opts.GetKeys(opts.Config.Universal.CopyToClipboard), @@ -295,12 +255,6 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin Keys: []gocui.Key{gocui.NewKeyName(gocui.MouseWheelDown)}, Handler: gui.scrollDownExtra, }, - { - ViewName: "extras", - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.PrevItemAlt), - Handler: gui.scrollUpExtra, - }, { ViewName: "extras", Tag: "navigation", @@ -313,12 +267,6 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin Keys: opts.GetKeys(opts.Config.Universal.NextItem), Handler: gui.scrollDownExtra, }, - { - ViewName: "extras", - Tag: "navigation", - Keys: opts.GetKeys(opts.Config.Universal.NextItemAlt), - Handler: gui.scrollDownExtra, - }, { ViewName: "extras", Keys: opts.GetKeys(opts.Config.Universal.NextPage), @@ -334,21 +282,11 @@ func (gui *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBin Keys: opts.GetKeys(opts.Config.Universal.GotoTop), Handler: gui.goToExtrasPanelTop, }, - { - ViewName: "extras", - Keys: opts.GetKeys(opts.Config.Universal.GotoTopAlt), - Handler: gui.goToExtrasPanelTop, - }, { ViewName: "extras", Keys: opts.GetKeys(opts.Config.Universal.GotoBottom), Handler: gui.goToExtrasPanelBottom, }, - { - ViewName: "extras", - Keys: opts.GetKeys(opts.Config.Universal.GotoBottomAlt), - Handler: gui.goToExtrasPanelBottom, - }, { ViewName: "extras", Tag: "navigation", diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index 336f7601b..23016b9a5 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -27,6 +27,10 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error { maxColumnSize := 1 + // Only the primary key of each navigation binding is reserved as + // essential; alternates (e.g. the historical j/k that lived under + // `*Alt` fields) stay available to be reused by menu items, which + // take precedence over the inherited list bindings. essentialKeys := []gocui.Key{ config.GetValidatedKeyBindingKeys(gui.c.UserConfig().Keybinding.Universal.ConfirmMenu)[0], config.GetValidatedKeyBindingKeys(gui.c.UserConfig().Keybinding.Universal.Return)[0], diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index ea8e42da4..769248375 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -337,7 +337,6 @@ type TranslationSet struct { CommitDescriptionTitle string CommitDescriptionSubTitle string CommitDescriptionFooter string - CommitDescriptionFooterTwoBindings string CommitHooksDisabledSubTitle string LocalBranchesTitle string SearchTitle string @@ -1456,7 +1455,6 @@ func EnglishTranslationSet() *TranslationSet { CommitDescriptionTitle: "Commit description", CommitDescriptionSubTitle: "Press {{.togglePanelKeyBinding}} to toggle focus, {{.commitMenuKeybinding}} to open menu", CommitDescriptionFooter: "Press {{.confirmInEditorKeybinding}} to submit", - CommitDescriptionFooterTwoBindings: "Press {{.confirmInEditorKeybinding1}} or {{.confirmInEditorKeybinding2}} to submit", CommitHooksDisabledSubTitle: "(hooks disabled)", LocalBranchesTitle: "Local branches", SearchTitle: "Search", diff --git a/schema-master/config.json b/schema-master/config.json index b879ba5c5..84fd2220e 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -2247,7 +2247,10 @@ "type": "array" } ], - "default": "\u003cup\u003e" + "default": [ + "\u003cup\u003e", + "k" + ] }, "nextItem": { "oneOf": [ @@ -2261,7 +2264,10 @@ "type": "array" } ], - "default": "\u003cdown\u003e" + "default": [ + "\u003cdown\u003e", + "j" + ] }, "prevItem-alt": { "oneOf": [ @@ -2275,6 +2281,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `prevItem` instead.", "default": "k" }, "nextItem-alt": { @@ -2289,6 +2296,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `nextItem` instead.", "default": "j" }, "prevPage": { @@ -2359,7 +2367,10 @@ "type": "array" } ], - "default": "\u003c" + "default": [ + "\u003c", + "\u003chome\u003e" + ] }, "gotoBottom": { "oneOf": [ @@ -2373,7 +2384,10 @@ "type": "array" } ], - "default": "\u003e" + "default": [ + "\u003e", + "\u003cend\u003e" + ] }, "gotoTop-alt": { "oneOf": [ @@ -2387,6 +2401,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `gotoTop` instead.", "default": "\u003chome\u003e" }, "gotoBottom-alt": { @@ -2401,6 +2416,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `gotoBottom` instead.", "default": "\u003cend\u003e" }, "toggleRangeSelect": { @@ -2457,7 +2473,11 @@ "type": "array" } ], - "default": "\u003cleft\u003e" + "default": [ + "\u003cleft\u003e", + "h", + "\u003cbacktab\u003e" + ] }, "nextBlock": { "oneOf": [ @@ -2471,7 +2491,11 @@ "type": "array" } ], - "default": "\u003cright\u003e" + "default": [ + "\u003cright\u003e", + "l", + "\u003ctab\u003e" + ] }, "prevBlock-alt": { "oneOf": [ @@ -2485,6 +2509,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `prevBlock` instead.", "default": "h" }, "nextBlock-alt": { @@ -2499,6 +2524,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `nextBlock` instead.", "default": "l" }, "nextBlock-alt2": { @@ -2513,6 +2539,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `nextBlock` instead.", "default": "\u003ctab\u003e" }, "prevBlock-alt2": { @@ -2527,6 +2554,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `prevBlock` instead.", "default": "\u003cbacktab\u003e" }, "jumpToBlock": { @@ -2765,7 +2793,10 @@ } ], "description": "\u003cmeta+enter\u003e on Mac", - "default": "\u003cctrl+enter\u003e" + "default": [ + "\u003cctrl+enter\u003e", + "\u003cctrl+s\u003e" + ] }, "confirmInEditor-alt": { "oneOf": [ @@ -2779,6 +2810,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `confirmInEditor` instead.", "default": "\u003cctrl+s\u003e" }, "remove": { @@ -2849,7 +2881,11 @@ "type": "array" } ], - "default": "\u003cpgup\u003e" + "default": [ + "\u003cpgup\u003e", + "K", + "\u003cctrl+u\u003e" + ] }, "scrollDownMain": { "oneOf": [ @@ -2863,7 +2899,11 @@ "type": "array" } ], - "default": "\u003cpgdown\u003e" + "default": [ + "\u003cpgdown\u003e", + "J", + "\u003cctrl+d\u003e" + ] }, "scrollUpMain-alt1": { "oneOf": [ @@ -2877,6 +2917,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `scrollUpMain` instead.", "default": "K" }, "scrollDownMain-alt1": { @@ -2891,6 +2932,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `scrollDownMain` instead.", "default": "J" }, "scrollUpMain-alt2": { @@ -2905,6 +2947,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `scrollUpMain` instead.", "default": "\u003cctrl+u\u003e" }, "scrollDownMain-alt2": { @@ -2919,6 +2962,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `scrollDownMain` instead.", "default": "\u003cctrl+d\u003e" }, "executeShellCommand": { @@ -3131,7 +3175,10 @@ "type": "array" } ], - "default": "W" + "default": [ + "W", + "\u003cctrl+e\u003e" + ] }, "diffingMenu-alt": { "oneOf": [ @@ -3145,6 +3192,7 @@ "type": "array" } ], + "description": "Deprecated: add the key to `diffingMenu` instead.", "default": "\u003cctrl+e\u003e" }, "copyToClipboard": { From d0d58233fffe9ff18621664b53a0e05ee604df9e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 17 May 2026 15:40:13 +0200 Subject: [PATCH 025/384] If a menu entry has multiple keybindings, list them in a tooltip We append them with a blank line to an existing tooltip if the item already has one, or create a new tooltip if not. --- pkg/gui/controllers/options_menu_action.go | 14 +++++++++++++- pkg/i18n/english.go | 2 ++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/options_menu_action.go b/pkg/gui/controllers/options_menu_action.go index e49c02386..c92fdd589 100644 --- a/pkg/gui/controllers/options_menu_action.go +++ b/pkg/gui/controllers/options_menu_action.go @@ -1,6 +1,10 @@ package controllers import ( + "strings" + + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" @@ -23,6 +27,14 @@ func (self *OptionsMenuAction) Call() error { if binding.GetDisabledReason != nil { disabledReason = binding.GetDisabledReason() } + tooltip := binding.Tooltip + if len(binding.Keys) > 1 { + if tooltip != "" { + tooltip += "\n\n" + } + keyLabels := lo.Map(binding.Keys, func(k gocui.Key, _ int) string { return config.LabelForKey(k) }) + tooltip += self.c.Tr.KeybindingsTooltip + strings.Join(keyLabels, ", ") + } return &types.MenuItem{ OpensMenu: binding.OpensMenu, Label: binding.GetDescription(), @@ -34,7 +46,7 @@ func (self *OptionsMenuAction) Call() error { return self.c.IGuiCommon.CallKeybindingHandler(binding) }, Keys: binding.Keys, - Tooltip: binding.Tooltip, + Tooltip: tooltip, DisabledReason: disabledReason, Section: section, } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 769248375..ee5f4ceec 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -616,6 +616,7 @@ type TranslationSet struct { KeybindingsMenuSectionLocal string KeybindingsMenuSectionGlobal string KeybindingsMenuSectionNavigation string + KeybindingsTooltip string RenameBranch string Upstream string BranchUpstreamOptionsTitle string @@ -1475,6 +1476,7 @@ func EnglishTranslationSet() *TranslationSet { KeybindingsMenuSectionLocal: "Local", KeybindingsMenuSectionGlobal: "Global", KeybindingsMenuSectionNavigation: "Navigation", + KeybindingsTooltip: "Keybindings: ", RebasingTitle: "Rebase '{{.checkedOutBranch}}'", RebasingFromBaseCommitTitle: "Rebase '{{.checkedOutBranch}}' from marked base", SimpleRebase: "Simple rebase onto '{{.ref}}'", From 25379950679f4d59f8efe44ef9c870bb80d3f08b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 25 May 2026 19:15:54 +0200 Subject: [PATCH 026/384] Add ``/`` as alternate keybindings for moving commits up/down I like these because they are the same as moving a line of code up or down in Visual Studio Code. --- docs-master/Config.md | 4 ++-- docs-master/keybindings/Keybindings_en.md | 4 ++-- docs-master/keybindings/Keybindings_ja.md | 4 ++-- docs-master/keybindings/Keybindings_ko.md | 4 ++-- docs-master/keybindings/Keybindings_nl.md | 4 ++-- docs-master/keybindings/Keybindings_pl.md | 4 ++-- docs-master/keybindings/Keybindings_pt.md | 4 ++-- docs-master/keybindings/Keybindings_ru.md | 4 ++-- docs-master/keybindings/Keybindings_zh-CN.md | 4 ++-- docs-master/keybindings/Keybindings_zh-TW.md | 4 ++-- pkg/config/user_config.go | 4 ++-- schema-master/config.json | 10 ++++++++-- 12 files changed, 30 insertions(+), 24 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 882baec63..9f7921821 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -737,8 +737,8 @@ keybinding: setFixupMessage: c createFixupCommit: F squashAboveCommits: S - moveDownCommit: - moveUpCommit: + moveDownCommit: [, ] + moveUpCommit: [, ] amendToCommit: A resetCommitAuthor: a pickCommit: p diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 71ecdae5b..d63058d82 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -95,8 +95,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` p `` | Pick | Mark the selected commit to be picked (when mid-rebase). This means that the commit will be retained upon continuing the rebase. | | `` F `` | Create fixup commit | Create 'fixup!' commit for the selected commit. Later on, you can press `S` on this same commit to apply all above fixup commits. | | `` S `` | Apply fixup commits | Squash all 'fixup!' commits, either above the selected commit, or all in current branch (autosquash). | -| `` `` | Move commit down one | | -| `` `` | Move commit up one | | +| `` , `` | Move commit down one | | +| `` , `` | Move commit up one | | | `` V `` | Paste (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. | | `` A `` | Amend | Amend commit with staged changes. If the selected commit is the HEAD commit, this will perform `git commit --amend`. Otherwise the commit will be amended via a rebase. | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 5a958f80e..d9b87d747 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -75,8 +75,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` p `` | ピック | 選択したコミットをピックするようにマークします(リベース中)。これは、リベースを続行すると、コミットが保持されることを意味します。 | | `` F `` | fixupコミットを作成 | 選択したコミットに対する「fixup!」コミットを作成します。fixupコミットは、選択したコミットの修正用コミットです。後で、同じコミットで `S` を押すと、上記のすべてのfixupコミットが適用されます。 | | `` S `` | fixupコミットを適用 | すべての「fixup!」コミットを、選択したコミットの上部または現在のブランチ内のすべてをスカッシュします(autosquash)。 | -| `` `` | コミットを1つ下に移動 | | -| `` `` | コミットを1つ上に移動 | | +| `` , `` | コミットを1つ下に移動 | | +| `` , `` | コミットを1つ上に移動 | | | `` V `` | ペースト(チェリーピック) | | | `` B `` | リベース用のベースコミットとしてマーク | 次のリベース用のベースコミットを選択します。ブランチにリベースするとき、ベースコミットより上のコミットのみが持ち込まれます。これは `git rebase --onto` コマンドを使用します。 | | `` A `` | 修正 | ステージされた変更でコミットを修正します。選択したコミットがHEADコミットの場合、これは `git commit --amend` を実行します。それ以外の場合、コミットはリベースを通じて修正されます。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index d6121452b..089543c5f 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -307,8 +307,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` p `` | Pick | Pick commit (when mid-rebase) | | `` F `` | Create fixup commit | Create fixup commit for this commit | | `` S `` | Apply fixup commits | Squash all 'fixup!' commits above selected commit (autosquash) | -| `` `` | 커밋을 1개 아래로 이동 | | -| `` `` | 커밋을 1개 위로 이동 | | +| `` , `` | 커밋을 1개 아래로 이동 | | +| `` , `` | 커밋을 1개 위로 이동 | | | `` V `` | 커밋을 붙여넣기 (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. | | `` A `` | Amend | Amend commit with staged changes | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 23a08aae4..1715c597e 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -167,8 +167,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` 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 | | +| `` , `` | 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. | | `` A `` | Amend | Wijzig commit met staged veranderingen | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index b233fd6d4..b754d7311 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -68,8 +68,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` p `` | Wybierz | Oznacz wybrany commit do wybrania (podczas rebazowania). Oznacza to, że commit zostanie zachowany po kontynuacji rebazowania. | | `` F `` | Utwórz commit fixup | Utwórz commit 'fixup!' dla wybranego commita. Później możesz nacisnąć `S` na tym samym commicie, aby zastosować wszystkie powyższe commity fixup. | | `` S `` | Zastosuj commity fixup | Scal wszystkie commity 'fixup!', albo powyżej wybranego commita, albo wszystkie w bieżącej gałęzi (autosquash). | -| `` `` | Przesuń commit w dół | | -| `` `` | Przesuń commit w górę | | +| `` , `` | Przesuń commit w dół | | +| `` , `` | Przesuń commit w górę | | | `` V `` | Wklej (cherry-pick) | | | `` B `` | Oznacz jako bazowy commit dla rebase | Wybierz bazowy commit dla następnego rebase. Kiedy robisz rebase na branch, tylko commity powyżej bazowego commita zostaną przeniesione. Używa to polecenia `git rebase --onto`. | | `` A `` | Popraw | Popraw commit ze zmianami zatwierdzonymi. Jeśli wybrany commit jest commit HEAD, to wykona `git commit --amend`. W przeciwnym razie commit zostanie poprawiony za pomocą rebazowania. | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 22d5cbb55..c19619191 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -171,8 +171,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` p `` | Escolher | Marque o commit selecionado para ser escolhido (quando meados da base). Isso significa que o commit será mantido ao continuar o rebase. | | `` F `` | Criar commit de correção | Crie o commit 'correção!' para o commit selecionado. Mais tarde, você pode pressionar `S` neste mesmo commit para aplicar todas os commits de correção acima. | | `` S `` | Aplicar commits de correções | Aplicar Squash all 'correção!', seja acima do commit selecionado, ou tudo no branch atual (autosquash). | -| `` `` | Mover commit um para baixo | | -| `` `` | Mover o commit um para cima | | +| `` , `` | Mover commit um para baixo | | +| `` , `` | Mover o commit um para cima | | | `` V `` | Colar (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. | | `` A `` | Modificar | Alterar o commit com mudanças em sted. Se o commit selecionado for o commit HEAD, ele executará o `git commit --amend`. Caso contrário, o compromisso será alterado por meio de uma base de apoio. | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 3b81d2433..c802678b3 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -177,8 +177,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` p `` | Pick | Выбрать коммит (в середине перебазирования) | | `` F `` | Создать fixup коммит | Создать fixup коммит для этого коммита | | `` S `` | Apply fixup commits | Объединить все 'fixup!' коммиты выше в выбранный коммит (автосохранение) | -| `` `` | Переместить коммит вниз на один | | -| `` `` | Переместить коммит вверх на один | | +| `` , `` | Переместить коммит вниз на один | | +| `` , `` | Переместить коммит вверх на один | | | `` V `` | Вставить отобранные коммиты (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. | | `` A `` | Amend | Править последний коммит с проиндексированными изменениями | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 76e378695..0d0cbbab6 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -132,8 +132,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` p `` | 拣选(Pick) | 标记选中的提交为 picked(变基过程中)。这意味该提交将在后续的变基中保留。 | | `` F `` | 为此提交创建修正 | 创建修正提交 | | `` S `` | 应用该修复提交 | 压缩所选提交之上或当前分支的所有 “fixup!” 提交(自动压缩)。 | -| `` `` | 下移提交 | | -| `` `` | 上移提交 | | +| `` , `` | 下移提交 | | +| `` , `` | 上移提交 | | | `` V `` | 粘贴提交(拣选) | | | `` B `` | 标记一个主提交用于变基 | 选择下一次变基的主提交。当您变基到一个分支时,只有高于主提交的提交才会被引入。这使用“git rebase --onto”命令。 | | `` A `` | 修补(Amend) | 用已暂存的变更来修补提交 | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 6ebfa5c68..d6526b5b2 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -191,8 +191,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` p `` | 挑選 | 挑選提交 (於變基過程中) | | `` F `` | 建立修復提交 | 為此提交建立修復提交 | | `` S `` | 壓縮上方所有「fixup」提交(自動壓縮) | 是否壓縮上方 {{.commit}} 所有「fixup」提交? | -| `` `` | 向下移動提交 | | -| `` `` | 向上移動提交 | | +| `` , `` | 向下移動提交 | | +| `` , `` | 向上移動提交 | | | `` V `` | 貼上提交 (揀選) | | | `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 | | `` A `` | 修改 | 使用已預存的更改修正提交 | diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 0b2dcac0c..cac87ec91 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -1085,8 +1085,8 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { SetFixupMessage: Keybinding{"c"}, CreateFixupCommit: Keybinding{"F"}, SquashAboveCommits: Keybinding{"S"}, - MoveDownCommit: Keybinding{""}, - MoveUpCommit: Keybinding{""}, + MoveDownCommit: Keybinding{"", ""}, + MoveUpCommit: Keybinding{"", ""}, AmendToCommit: Keybinding{"A"}, ResetCommitAuthor: Keybinding{"a"}, PickCommit: Keybinding{"p"}, diff --git a/schema-master/config.json b/schema-master/config.json index 84fd2220e..2e968ba8f 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -1346,7 +1346,10 @@ "type": "array" } ], - "default": "\u003cctrl+j\u003e" + "default": [ + "\u003cctrl+j\u003e", + "\u003calt-down\u003e" + ] }, "moveUpCommit": { "oneOf": [ @@ -1360,7 +1363,10 @@ "type": "array" } ], - "default": "\u003cctrl+k\u003e" + "default": [ + "\u003cctrl+k\u003e", + "\u003calt-up\u003e" + ] }, "amendToCommit": { "oneOf": [ From 137831630aff924fb5fae5a1bab1d3c669d2d867 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 26 May 2026 07:54:29 +0200 Subject: [PATCH 027/384] Do less work to update the main view when cycling pagers The call to HandleFocus worked fine too, but it was doing too much; all we really need here is rerender the main view. --- pkg/gui/controllers/global_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index 1043ee88f..c472d1b7b 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -158,7 +158,7 @@ func (self *GlobalController) prevScreenMode() error { func (self *GlobalController) cyclePagers() error { self.c.State().GetPagerConfig().CyclePagers() if self.c.Context().CurrentSide().GetKey() == self.c.Context().Current().GetKey() { - self.c.Context().CurrentSide().HandleFocus(types.OnFocusOpts{}) + self.c.Context().CurrentSide().HandleRenderToMain() } current, total := self.c.State().GetPagerConfig().CurrentPagerIndex() From e6a8415162121d4b54cd1ec584ba8ed7f2cadf58 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 26 May 2026 07:57:32 +0200 Subject: [PATCH 028/384] Refresh main view when cycling pagers with main view focused The previous logic only re-rendered the main view when the side panel itself was focused. When the user pressed `0` to focus the main view, the Normal/NormalSecondary context becomes Current and the equality check failed, so cycling pagers had no visible effect. Mirror the pattern from postRefreshUpdate: when the main view is focused, call HandleRenderToMain on the side panel below it on the stack (which CurrentSide already returns). --- pkg/gui/controllers/global_controller.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index c472d1b7b..d2ca28c60 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -3,6 +3,7 @@ package controllers import ( "fmt" + "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -157,8 +158,12 @@ func (self *GlobalController) prevScreenMode() error { func (self *GlobalController) cyclePagers() error { self.c.State().GetPagerConfig().CyclePagers() - if self.c.Context().CurrentSide().GetKey() == self.c.Context().Current().GetKey() { - self.c.Context().CurrentSide().HandleRenderToMain() + currentSide := self.c.Context().CurrentSide() + currentKey := self.c.Context().Current().GetKey() + if currentSide.GetKey() == currentKey || + currentKey == context.NORMAL_MAIN_CONTEXT_KEY || + currentKey == context.NORMAL_SECONDARY_CONTEXT_KEY { + currentSide.HandleRenderToMain() } current, total := self.c.State().GetPagerConfig().CurrentPagerIndex() From af041092d53caa111fbaadde54c632d0b7833f86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 07:39:34 +0000 Subject: [PATCH 029/384] Bump github.com/gookit/color from 1.6.0 to 1.6.1 Bumps [github.com/gookit/color](https://github.com/gookit/color) from 1.6.0 to 1.6.1. - [Release notes](https://github.com/gookit/color/releases) - [Commits](https://github.com/gookit/color/compare/v1.6.0...v1.6.1) --- updated-dependencies: - dependency-name: github.com/gookit/color dependency-version: 1.6.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- vendor/github.com/gookit/color/.gitignore | 1 + vendor/github.com/gookit/color/color.go | 9 +++++++-- vendor/github.com/gookit/color/convert.go | 6 +++--- vendor/github.com/gookit/color/detect_windows.go | 9 +++------ vendor/modules.txt | 2 +- 7 files changed, 18 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 615155332..1a64c445f 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/creack/pty v1.1.24 github.com/gdamore/tcell/v3 v3.3.0 github.com/go-errors/errors v1.5.1 - github.com/gookit/color v1.6.0 + github.com/gookit/color v1.6.1 github.com/integrii/flaggy v1.8.0 github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 diff --git a/go.sum b/go.sum index 8ac21030f..0fa338e30 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,8 @@ 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= github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= -github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= -github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= +github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU= +github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/integrii/flaggy v1.8.0 h1:tC1qWwg4fhF2Qdaj+MpPK04cxlOSq0+HoMZqAW6Arao= diff --git a/vendor/github.com/gookit/color/.gitignore b/vendor/github.com/gookit/color/.gitignore index 4ce2d4456..e726419b9 100644 --- a/vendor/github.com/gookit/color/.gitignore +++ b/vendor/github.com/gookit/color/.gitignore @@ -19,3 +19,4 @@ .DS_Store app demo +.xenv.toml \ No newline at end of file diff --git a/vendor/github.com/gookit/color/color.go b/vendor/github.com/gookit/color/color.go index 8c204fe9a..a51bde09d 100644 --- a/vendor/github.com/gookit/color/color.go +++ b/vendor/github.com/gookit/color/color.go @@ -227,8 +227,13 @@ func RenderString(code string, str string) string { return ClearCode(str) } - // return fmt.Sprintf(FullColorTpl, code, str) - return StartSet + code + "m" + str + ResetSet + open := StartSet + code + "m" + // If the string contains reset sequences, re-apply our color after each + // reset so that nested colored args don't break the outer color. + if strings.Contains(str, ResetSet) { + str = strings.ReplaceAll(str, ResetSet, ResetSet+open) + } + return open + str + ResetSet } // ClearCode clear color codes. diff --git a/vendor/github.com/gookit/color/convert.go b/vendor/github.com/gookit/color/convert.go index cc1f31cf0..21344e70a 100644 --- a/vendor/github.com/gookit/color/convert.go +++ b/vendor/github.com/gookit/color/convert.go @@ -767,15 +767,15 @@ func RgbStrToHsl(rgbStr string) []float64 { } r, e1 := strconv.ParseInt(strings.TrimSpace(rgbVals[0]), 10, 0) - if e1 != nil { + if e1 != nil || r < 0 || r > 255 { return nil } g, e2 := strconv.ParseInt(strings.TrimSpace(rgbVals[1]), 10, 0) - if e2 != nil { + if e2 != nil || g < 0 || g > 255 { return nil } b, e3 := strconv.ParseInt(strings.TrimSpace(rgbVals[2]), 10, 0) - if e3 != nil { + if e3 != nil || b < 0 || b > 255 { return nil } return RgbToHsl(uint8(r), uint8(g), uint8(b)) diff --git a/vendor/github.com/gookit/color/detect_windows.go b/vendor/github.com/gookit/color/detect_windows.go index 7c331be7c..16b58b9c0 100644 --- a/vendor/github.com/gookit/color/detect_windows.go +++ b/vendor/github.com/gookit/color/detect_windows.go @@ -30,11 +30,8 @@ var ( ) func init() { - // if support color 16+, don't need to enable VTP - if colorLevel > Level16 { - return - } - if !Enable { // disable color + // needVTP=false OR Enable=false: Don't need to enable virtual process + if !needVTP || !Enable { return } @@ -146,7 +143,7 @@ func detectSpecialTermColor(termVal string) (tl Level, needVTP bool) { } // Windows 10 build 14931 is the first release that supports 16m/TrueColor - debugf("support True Color on windows version is >= build 14931") + debugf("support True Color on windows version >= 14931, needVTP=true") return LevelRgb, true } diff --git a/vendor/modules.txt b/vendor/modules.txt index c44cb0e50..4adeb40f0 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -63,7 +63,7 @@ github.com/go-errors/errors github.com/go-logfmt/logfmt # github.com/google/go-cmp v0.7.0 ## explicit; go 1.21 -# github.com/gookit/color v1.6.0 +# github.com/gookit/color v1.6.1 ## explicit; go 1.18 github.com/gookit/color # github.com/hpcloud/tail v1.0.0 From 9ae76d7eec2800957c37e78bc00be4c3ab503135 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 26 May 2026 09:44:40 +0200 Subject: [PATCH 030/384] Fix undo shortcut in Undoing.md We fixed this recently in the Readme (see 7a3bae4de1f7), but forgot to update this. --- docs-master/Undoing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-master/Undoing.md b/docs-master/Undoing.md index 0a4c2f381..032573258 100644 --- a/docs-master/Undoing.md +++ b/docs-master/Undoing.md @@ -1,6 +1,6 @@ # Undo/Redo in lazygit -You can undo the last action by pressing 'z' and redo with `ctrl+z`. Here we drop a couple of commits and then undo the actions. +You can undo the last action by pressing 'z' and redo with 'Z' (shift+z). Here we drop a couple of commits and then undo the actions. Undo uses the reflog which is specific to commits and branches so we can't undo changes to the working tree or stash. ![undo](../../assets/demo/undo-compressed.gif) From 62b38ff78ae7970fcb3f02bc315c4e8125945f09 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 07:51:24 +0000 Subject: [PATCH 031/384] Bump github.com/sahilm/fuzzy from 0.1.1 to 0.1.2 Bumps [github.com/sahilm/fuzzy](https://github.com/sahilm/fuzzy) from 0.1.1 to 0.1.2. - [Release notes](https://github.com/sahilm/fuzzy/releases) - [Commits](https://github.com/sahilm/fuzzy/compare/v0.1.1...v0.1.2) --- updated-dependencies: - dependency-name: github.com/sahilm/fuzzy dependency-version: 0.1.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 3 +- go.sum | 4 +- vendor/github.com/sahilm/fuzzy/.travis.yml | 8 --- vendor/github.com/sahilm/fuzzy/Gopkg.lock | 20 ------- vendor/github.com/sahilm/fuzzy/Gopkg.toml | 4 -- vendor/github.com/sahilm/fuzzy/Makefile | 66 +++++++++++----------- vendor/github.com/sahilm/fuzzy/fuzzy.go | 27 ++++++--- vendor/modules.txt | 6 +- 8 files changed, 57 insertions(+), 81 deletions(-) delete mode 100644 vendor/github.com/sahilm/fuzzy/.travis.yml delete mode 100644 vendor/github.com/sahilm/fuzzy/Gopkg.lock delete mode 100644 vendor/github.com/sahilm/fuzzy/Gopkg.toml diff --git a/go.mod b/go.mod index 1a64c445f..d2d645ae8 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/mgutz/str v1.2.0 github.com/mitchellh/go-ps v1.0.0 github.com/rivo/uniseg v0.4.7 - github.com/sahilm/fuzzy v0.1.1 + github.com/sahilm/fuzzy v0.1.2 github.com/samber/lo v1.53.0 github.com/sanity-io/litter v1.5.8 github.com/sasha-s/go-deadlock v0.3.9 @@ -58,7 +58,6 @@ require ( github.com/invopop/jsonschema v0.10.0 // indirect github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 0fa338e30..792d90f62 100644 --- a/go.sum +++ b/go.sum @@ -107,8 +107,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sahilm/fuzzy v0.1.2 h1:kdSkz23lx1meNjEl+SLJULeSbjTI4Dn14K/YxdGrIww= +github.com/sahilm/fuzzy v0.1.2/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= diff --git a/vendor/github.com/sahilm/fuzzy/.travis.yml b/vendor/github.com/sahilm/fuzzy/.travis.yml deleted file mode 100644 index f77acde75..000000000 --- a/vendor/github.com/sahilm/fuzzy/.travis.yml +++ /dev/null @@ -1,8 +0,0 @@ -arch: - - amd64 - - ppc64le -language: go -go: - - 1.x -script: - - make diff --git a/vendor/github.com/sahilm/fuzzy/Gopkg.lock b/vendor/github.com/sahilm/fuzzy/Gopkg.lock deleted file mode 100644 index 6e3a7fe54..000000000 --- a/vendor/github.com/sahilm/fuzzy/Gopkg.lock +++ /dev/null @@ -1,20 +0,0 @@ -# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. - - -[[projects]] - branch = "master" - digest = "1:ee97ec8a00b2424570c1ce53d7b410e96fbd4c241b29df134276ff6aa3750335" - name = "github.com/kylelemons/godebug" - packages = [ - "diff", - "pretty", - ] - pruneopts = "" - revision = "d65d576e9348f5982d7f6d83682b694e731a45c6" - -[solve-meta] - analyzer-name = "dep" - analyzer-version = 1 - input-imports = ["github.com/kylelemons/godebug/pretty"] - solver-name = "gps-cdcl" - solver-version = 1 diff --git a/vendor/github.com/sahilm/fuzzy/Gopkg.toml b/vendor/github.com/sahilm/fuzzy/Gopkg.toml deleted file mode 100644 index 8f96b112e..000000000 --- a/vendor/github.com/sahilm/fuzzy/Gopkg.toml +++ /dev/null @@ -1,4 +0,0 @@ -# Test dependency -[[constraint]] - branch = "master" - name = "github.com/kylelemons/godebug" diff --git a/vendor/github.com/sahilm/fuzzy/Makefile b/vendor/github.com/sahilm/fuzzy/Makefile index 7fa2be4ec..8d150cdd9 100644 --- a/vendor/github.com/sahilm/fuzzy/Makefile +++ b/vendor/github.com/sahilm/fuzzy/Makefile @@ -1,14 +1,10 @@ .PHONY: all all: setup lint test +PKGS := $(shell go list ./... | grep -v /vendor) .PHONY: test test: setup - go test -bench ./... - -.PHONY: cover -cover: setup - mkdir -p coverage - gocov test ./... | gocov-html > coverage/coverage.html + go test $(PKGS) sources = $(shell find . -name '*.go' -not -path './vendor/*') .PHONY: goimports @@ -17,41 +13,47 @@ goimports: setup .PHONY: lint lint: setup - gometalinter ./... --enable=goimports --disable=gocyclo --vendor -t + $(BIN_DIR)/golangci-lint run + +COVERAGE := $(CURDIR)/coverage +COVER_PROFILE :=$(COVERAGE)/cover.out +TMP_COVER_PROFILE :=$(COVERAGE)/cover.tmp +.PHONY: cover +cover: setup + rm -rf $(COVERAGE) + mkdir -p $(COVERAGE) + echo "mode: set" > $(COVER_PROFILE) + for pkg in $(PKGS); do \ + go test -v -coverprofile=$(TMP_COVER_PROFILE) $$pkg; \ + if [ -f $(TMP_COVER_PROFILE) ]; then \ + grep -v 'mode: set' $(TMP_COVER_PROFILE) >> $(COVER_PROFILE); \ + rm $(TMP_COVER_PROFILE); \ + fi; \ + done + go tool cover -html=$(COVER_PROFILE) -o $(COVERAGE)/index.html + +.PHONY: ci +ci: setup lint test .PHONY: install install: setup - go install + go install $(PKGS) +.PHONY: build +build: setup + go build $(PKGS) + +GOPATH ?= $(HOME)/go BIN_DIR := $(GOPATH)/bin GOIMPORTS := $(BIN_DIR)/goimports -GOMETALINTER := $(BIN_DIR)/gometalinter -DEP := $(BIN_DIR)/dep -GOCOV := $(BIN_DIR)/gocov -GOCOV_HTML := $(BIN_DIR)/gocov-html +GOLANG_CI_LINT := $(BIN_DIR)/golangci-lint $(GOIMPORTS): go get -u golang.org/x/tools/cmd/goimports -$(GOMETALINTER): - go get -u github.com/alecthomas/gometalinter - gometalinter --install &> /dev/null +$(GOLANG_CI_LINT): + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(BIN_DIR) v2.12.2 -$(GOCOV): - go get -u github.com/axw/gocov/gocov +tools: $(GOIMPORTS) $(GOLANG_CI_LINT) -$(GOCOV_HTML): - go get -u gopkg.in/matm/v1/gocov-html - -$(DEP): - go get -u github.com/golang/dep/cmd/dep - -tools: $(GOIMPORTS) $(GOMETALINTER) $(GOCOV) $(GOCOV_HTML) $(DEP) - -vendor: $(DEP) - dep ensure - -setup: tools vendor - -updatedeps: - dep ensure -update +setup: tools diff --git a/vendor/github.com/sahilm/fuzzy/fuzzy.go b/vendor/github.com/sahilm/fuzzy/fuzzy.go index 5125821fd..54eb98fb2 100644 --- a/vendor/github.com/sahilm/fuzzy/fuzzy.go +++ b/vendor/github.com/sahilm/fuzzy/fuzzy.go @@ -7,6 +7,7 @@ package fuzzy import ( "sort" + "strings" "unicode" "unicode/utf8" ) @@ -39,7 +40,7 @@ type Matches []Match func (a Matches) Len() int { return len(a) } func (a Matches) Swap(i, j int) { a[i], a[j] = a[j], a[i] } -func (a Matches) Less(i, j int) bool { return a[i].Score >= a[j].Score } +func (a Matches) Less(i, j int) bool { return a[i].Score > a[j].Score } // Source represents an abstract source of a list of strings. Source must be iterable type such as a slice. // The source will be iterated over till Len() with String(i) being called for each element where i is the @@ -114,7 +115,15 @@ func FindFromNoSort(pattern string, data Source) Matches { var matchedIndexes []int for i := 0; i < data.Len(); i++ { var match Match - match.Str = data.String(i) + matchStr := data.String(i) + match.Str = matchStr + // Limit matching to the first NUL rune, if any. We could maybe replace it + // with whitespace, but this way doesn't allocate so much, and the presence + // of NULs is most often an error by the library user. + cleanMatchStr := matchStr + if nullI := strings.IndexRune(matchStr, 0); nullI > -1 { + cleanMatchStr = cleanMatchStr[:nullI] + } match.Index = i if matchedIndexes != nil { match.MatchedIndexes = matchedIndexes @@ -128,10 +137,10 @@ func FindFromNoSort(pattern string, data Source) Matches { currAdjacentMatchBonus := 0 var last rune var lastIndex int - nextc, nextSize := utf8.DecodeRuneInString(data.String(i)) + nextc, nextSize := utf8.DecodeRuneInString(cleanMatchStr) var candidate rune var candidateSize int - for j := 0; j < len(data.String(i)); j += candidateSize { + for j := 0; j < len(cleanMatchStr); j += candidateSize { candidate, candidateSize = nextc, nextSize if equalFold(candidate, runes[patternIndex]) { score = 0 @@ -161,11 +170,11 @@ func FindFromNoSort(pattern string, data Source) Matches { if patternIndex < len(runes)-1 { nextp = runes[patternIndex+1] } - if j+candidateSize < len(data.String(i)) { - if data.String(i)[j+candidateSize] < utf8.RuneSelf { // Fast path for ASCII - nextc, nextSize = rune(data.String(i)[j+candidateSize]), 1 + if j+candidateSize < len(cleanMatchStr) { + if cleanMatchStr[j+candidateSize] < utf8.RuneSelf { // Fast path for ASCII + nextc, nextSize = rune(cleanMatchStr[j+candidateSize]), 1 } else { - nextc, nextSize = utf8.DecodeRuneInString(data.String(i)[j+candidateSize:]) + nextc, nextSize = utf8.DecodeRuneInString(cleanMatchStr[j+candidateSize:]) } } else { nextc, nextSize = 0, 0 @@ -192,7 +201,7 @@ func FindFromNoSort(pattern string, data Source) Matches { last = candidate } // apply penalty for each unmatched character - penalty := len(match.MatchedIndexes) - len(data.String(i)) + penalty := len(match.MatchedIndexes) - len(cleanMatchStr) match.Score += penalty if len(match.MatchedIndexes) == len(runes) { matches = append(matches, match) diff --git a/vendor/modules.txt b/vendor/modules.txt index 4adeb40f0..e60827c69 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -93,8 +93,6 @@ github.com/karimkhaleel/jsonschema github.com/kr/logfmt # github.com/kr/pretty v0.3.1 ## explicit; go 1.12 -# github.com/kylelemons/godebug v1.1.0 -## explicit; go 1.11 # github.com/kyokomi/emoji/v2 v2.2.13 ## explicit; go 1.14 github.com/kyokomi/emoji/v2 @@ -132,8 +130,8 @@ github.com/pmezard/go-difflib/difflib github.com/rivo/uniseg # github.com/rogpeppe/go-internal v1.14.1 ## explicit; go 1.23 -# github.com/sahilm/fuzzy v0.1.1 -## explicit +# github.com/sahilm/fuzzy v0.1.2 +## explicit; go 1.24.5 github.com/sahilm/fuzzy # github.com/samber/lo v1.53.0 ## explicit; go 1.18 From dfcd195fb8dcd12a2ce83d55c87df838d16ec1ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 07:55:25 +0000 Subject: [PATCH 032/384] Bump golang.org/x/sys from 0.43.0 to 0.45.0 Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.43.0 to 0.45.0. - [Commits](https://github.com/golang/sys/compare/v0.43.0...v0.45.0) --- updated-dependencies: - dependency-name: golang.org/x/sys dependency-version: 0.44.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 +- .../golang.org/x/sys/unix/affinity_linux.go | 128 +- vendor/golang.org/x/sys/unix/mkall.sh | 2 +- vendor/golang.org/x/sys/unix/mkerrors.sh | 3 + vendor/golang.org/x/sys/unix/readv_unix.go | 103 ++ .../golang.org/x/sys/unix/syscall_darwin.go | 89 -- vendor/golang.org/x/sys/unix/syscall_linux.go | 114 +- .../x/sys/unix/syscall_linux_arm.go | 3 + .../x/sys/unix/syscall_linux_arm64.go | 3 + .../x/sys/unix/syscall_linux_loong64.go | 3 + .../x/sys/unix/syscall_linux_riscv64.go | 3 + .../golang.org/x/sys/unix/syscall_openbsd.go | 4 + vendor/golang.org/x/sys/unix/zerrors_linux.go | 61 +- .../x/sys/unix/zerrors_linux_386.go | 7 +- .../x/sys/unix/zerrors_linux_amd64.go | 7 +- .../x/sys/unix/zerrors_linux_arm.go | 7 +- .../x/sys/unix/zerrors_linux_arm64.go | 7 +- .../x/sys/unix/zerrors_linux_loong64.go | 7 +- .../x/sys/unix/zerrors_linux_mips.go | 7 +- .../x/sys/unix/zerrors_linux_mips64.go | 7 +- .../x/sys/unix/zerrors_linux_mips64le.go | 7 +- .../x/sys/unix/zerrors_linux_mipsle.go | 7 +- .../x/sys/unix/zerrors_linux_ppc.go | 7 +- .../x/sys/unix/zerrors_linux_ppc64.go | 7 +- .../x/sys/unix/zerrors_linux_ppc64le.go | 7 +- .../x/sys/unix/zerrors_linux_riscv64.go | 1114 +++++++++-------- .../x/sys/unix/zerrors_linux_s390x.go | 7 +- .../x/sys/unix/zerrors_linux_sparc64.go | 7 +- .../golang.org/x/sys/unix/zsyscall_linux.go | 12 +- .../x/sys/unix/zsyscall_openbsd_386.go | 84 ++ .../x/sys/unix/zsyscall_openbsd_386.s | 20 + .../x/sys/unix/zsyscall_openbsd_amd64.go | 84 ++ .../x/sys/unix/zsyscall_openbsd_amd64.s | 20 + .../x/sys/unix/zsyscall_openbsd_arm.go | 84 ++ .../x/sys/unix/zsyscall_openbsd_arm.s | 20 + .../x/sys/unix/zsyscall_openbsd_arm64.go | 84 ++ .../x/sys/unix/zsyscall_openbsd_arm64.s | 20 + .../x/sys/unix/zsyscall_openbsd_mips64.go | 84 ++ .../x/sys/unix/zsyscall_openbsd_mips64.s | 20 + .../x/sys/unix/zsyscall_openbsd_ppc64.go | 84 ++ .../x/sys/unix/zsyscall_openbsd_ppc64.s | 24 + .../x/sys/unix/zsyscall_openbsd_riscv64.go | 84 ++ .../x/sys/unix/zsyscall_openbsd_riscv64.s | 20 + .../x/sys/unix/zsysnum_linux_386.go | 4 + .../x/sys/unix/zsysnum_linux_amd64.go | 5 + .../x/sys/unix/zsysnum_linux_arm.go | 4 + .../x/sys/unix/zsysnum_linux_arm64.go | 4 + .../x/sys/unix/zsysnum_linux_loong64.go | 5 + .../x/sys/unix/zsysnum_linux_mips.go | 4 + .../x/sys/unix/zsysnum_linux_mips64.go | 4 + .../x/sys/unix/zsysnum_linux_mips64le.go | 4 + .../x/sys/unix/zsysnum_linux_mipsle.go | 4 + .../x/sys/unix/zsysnum_linux_ppc.go | 4 + .../x/sys/unix/zsysnum_linux_ppc64.go | 4 + .../x/sys/unix/zsysnum_linux_ppc64le.go | 4 + .../x/sys/unix/zsysnum_linux_riscv64.go | 4 + .../x/sys/unix/zsysnum_linux_s390x.go | 4 + .../x/sys/unix/zsysnum_linux_sparc64.go | 5 + vendor/golang.org/x/sys/unix/ztypes_linux.go | 47 +- .../golang.org/x/sys/unix/ztypes_linux_386.go | 8 + .../x/sys/unix/ztypes_linux_amd64.go | 8 + .../golang.org/x/sys/unix/ztypes_linux_arm.go | 8 + .../x/sys/unix/ztypes_linux_arm64.go | 8 + .../x/sys/unix/ztypes_linux_loong64.go | 8 + .../x/sys/unix/ztypes_linux_mips.go | 8 + .../x/sys/unix/ztypes_linux_mips64.go | 8 + .../x/sys/unix/ztypes_linux_mips64le.go | 8 + .../x/sys/unix/ztypes_linux_mipsle.go | 8 + .../golang.org/x/sys/unix/ztypes_linux_ppc.go | 8 + .../x/sys/unix/ztypes_linux_ppc64.go | 8 + .../x/sys/unix/ztypes_linux_ppc64le.go | 8 + .../x/sys/unix/ztypes_linux_riscv64.go | 8 + .../x/sys/unix/ztypes_linux_s390x.go | 8 + .../x/sys/unix/ztypes_linux_sparc64.go | 8 + .../x/sys/windows/syscall_windows.go | 16 +- .../golang.org/x/sys/windows/types_windows.go | 33 +- .../x/sys/windows/zsyscall_windows.go | 71 ++ vendor/modules.txt | 2 +- 79 files changed, 2033 insertions(+), 797 deletions(-) create mode 100644 vendor/golang.org/x/sys/unix/readv_unix.go diff --git a/go.mod b/go.mod index d2d645ae8..b8732b004 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.43.0 + golang.org/x/sys v0.45.0 gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 792d90f62..f41968456 100644 --- a/go.sum +++ b/go.sum @@ -162,8 +162,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/vendor/golang.org/x/sys/unix/affinity_linux.go b/vendor/golang.org/x/sys/unix/affinity_linux.go index 3ea470387..acd6257fa 100644 --- a/vendor/golang.org/x/sys/unix/affinity_linux.go +++ b/vendor/golang.org/x/sys/unix/affinity_linux.go @@ -13,11 +13,19 @@ import ( const cpuSetSize = _CPU_SETSIZE / _NCPUBITS -// CPUSet represents a CPU affinity mask. +// CPUSet represents a bit mask of CPUs, to be used with [SchedGetaffinity], [SchedSetaffinity], +// and [SetMemPolicy]. +// +// Note this type can only represent CPU IDs 0 through 1023. +// Use [CPUSetDynamic]/[NewCPUSet] instead to avoid this limit. type CPUSet [cpuSetSize]cpuMask -func schedAffinity(trap uintptr, pid int, set *CPUSet) error { - _, _, e := RawSyscall(trap, uintptr(pid), uintptr(unsafe.Sizeof(*set)), uintptr(unsafe.Pointer(set))) +// CPUSetDynamic represents a bit mask of CPUs, to be used with [SchedGetaffinityDynamic], +// [SchedSetaffinityDynamic], and [SetMemPolicyDynamic]. Use [NewCPUSet] to allocate. +type CPUSetDynamic []cpuMask + +func schedAffinity(trap uintptr, pid int, size uintptr, ptr unsafe.Pointer) error { + _, _, e := RawSyscall(trap, uintptr(pid), uintptr(size), uintptr(ptr)) if e != 0 { return errnoErr(e) } @@ -27,13 +35,13 @@ func schedAffinity(trap uintptr, pid int, set *CPUSet) error { // SchedGetaffinity gets the CPU affinity mask of the thread specified by pid. // If pid is 0 the calling thread is used. func SchedGetaffinity(pid int, set *CPUSet) error { - return schedAffinity(SYS_SCHED_GETAFFINITY, pid, set) + return schedAffinity(SYS_SCHED_GETAFFINITY, pid, unsafe.Sizeof(*set), unsafe.Pointer(set)) } // SchedSetaffinity sets the CPU affinity mask of the thread specified by pid. // If pid is 0 the calling thread is used. func SchedSetaffinity(pid int, set *CPUSet) error { - return schedAffinity(SYS_SCHED_SETAFFINITY, pid, set) + return schedAffinity(SYS_SCHED_SETAFFINITY, pid, unsafe.Sizeof(*set), unsafe.Pointer(set)) } // Zero clears the set s, so that it contains no CPUs. @@ -45,9 +53,7 @@ func (s *CPUSet) Zero() { // will silently ignore any invalid CPU bits in [CPUSet] so this is an // efficient way of resetting the CPU affinity of a process. func (s *CPUSet) Fill() { - for i := range s { - s[i] = ^cpuMask(0) - } + cpuMaskFill(s[:]) } func cpuBitsIndex(cpu int) int { @@ -58,24 +64,27 @@ func cpuBitsMask(cpu int) cpuMask { return cpuMask(1 << (uint(cpu) % _NCPUBITS)) } -// Set adds cpu to the set s. -func (s *CPUSet) Set(cpu int) { +func cpuMaskFill(s []cpuMask) { + for i := range s { + s[i] = ^cpuMask(0) + } +} + +func cpuMaskSet(s []cpuMask, cpu int) { i := cpuBitsIndex(cpu) if i < len(s) { s[i] |= cpuBitsMask(cpu) } } -// Clear removes cpu from the set s. -func (s *CPUSet) Clear(cpu int) { +func cpuMaskClear(s []cpuMask, cpu int) { i := cpuBitsIndex(cpu) if i < len(s) { s[i] &^= cpuBitsMask(cpu) } } -// IsSet reports whether cpu is in the set s. -func (s *CPUSet) IsSet(cpu int) bool { +func cpuMaskIsSet(s []cpuMask, cpu int) bool { i := cpuBitsIndex(cpu) if i < len(s) { return s[i]&cpuBitsMask(cpu) != 0 @@ -83,11 +92,98 @@ func (s *CPUSet) IsSet(cpu int) bool { return false } -// Count returns the number of CPUs in the set s. -func (s *CPUSet) Count() int { +func cpuMaskCount(s []cpuMask) int { c := 0 for _, b := range s { c += bits.OnesCount64(uint64(b)) } return c } + +// Set adds cpu to the set s. If cpu is out of bounds for s, no action is taken. +func (s *CPUSet) Set(cpu int) { + cpuMaskSet(s[:], cpu) +} + +// Clear removes cpu from the set s. If cpu is out of bounds for s, no action is taken. +func (s *CPUSet) Clear(cpu int) { + cpuMaskClear(s[:], cpu) +} + +// IsSet reports whether cpu is in the set s. +func (s *CPUSet) IsSet(cpu int) bool { + return cpuMaskIsSet(s[:], cpu) +} + +// Count returns the number of CPUs in the set s. +func (s *CPUSet) Count() int { + return cpuMaskCount(s[:]) +} + +// NewCPUSet creates a CPU affinity mask capable of representing CPU IDs +// up to maxCPU (exclusive). +func NewCPUSet(maxCPU int) CPUSetDynamic { + numMasks := (maxCPU + _NCPUBITS - 1) / _NCPUBITS + if numMasks == 0 { + numMasks = 1 + } + return make(CPUSetDynamic, numMasks) +} + +// Zero clears the set s, so that it contains no CPUs. +func (s CPUSetDynamic) Zero() { + clear(s) +} + +// Fill adds all possible CPU bits to the set s. On Linux, [SchedSetaffinityDynamic] +// will silently ignore any invalid CPU bits in [CPUSetDynamic] so this is an +// efficient way of resetting the CPU affinity of a process. +func (s CPUSetDynamic) Fill() { + cpuMaskFill(s) +} + +// Set adds cpu to the set s. If cpu is out of bounds for s, no action is taken. +func (s CPUSetDynamic) Set(cpu int) { + cpuMaskSet(s, cpu) +} + +// Clear removes cpu from the set s. If cpu is out of bounds for s, no action is taken. +func (s CPUSetDynamic) Clear(cpu int) { + cpuMaskClear(s, cpu) +} + +// IsSet reports whether cpu is in the set s. +func (s CPUSetDynamic) IsSet(cpu int) bool { + return cpuMaskIsSet(s, cpu) +} + +// Count returns the number of CPUs in the set s. +func (s CPUSetDynamic) Count() int { + return cpuMaskCount(s) +} + +func (s CPUSetDynamic) size() uintptr { + return uintptr(len(s)) * unsafe.Sizeof(cpuMask(0)) +} + +func (s CPUSetDynamic) pointer() unsafe.Pointer { + if len(s) == 0 { + return nil + } + return unsafe.Pointer(&s[0]) +} + +// SchedGetaffinityDynamic gets the CPU affinity mask of the thread specified by pid. +// If pid is 0 the calling thread is used. +// +// If the set is smaller than the size of the affinity mask used by the kernel, +// [EINVAL] is returned. +func SchedGetaffinityDynamic(pid int, set CPUSetDynamic) error { + return schedAffinity(SYS_SCHED_GETAFFINITY, pid, set.size(), set.pointer()) +} + +// SchedSetaffinityDynamic sets the CPU affinity mask of the thread specified by pid. +// If pid is 0 the calling thread is used. +func SchedSetaffinityDynamic(pid int, set CPUSetDynamic) error { + return schedAffinity(SYS_SCHED_SETAFFINITY, pid, set.size(), set.pointer()) +} diff --git a/vendor/golang.org/x/sys/unix/mkall.sh b/vendor/golang.org/x/sys/unix/mkall.sh index d0ed61191..f6ddee1ae 100644 --- a/vendor/golang.org/x/sys/unix/mkall.sh +++ b/vendor/golang.org/x/sys/unix/mkall.sh @@ -51,7 +51,7 @@ if [[ "$GOOS" = "linux" ]]; then # Files generated through docker (use $cmd so you can Ctl-C the build or run) set -e $cmd docker build --tag generate:$GOOS $GOOS - $cmd docker run --interactive --tty --volume $(cd -- "$(dirname -- "$0")/.." && pwd):/build generate:$GOOS + $cmd docker run --rm --interactive --tty --volume $(cd -- "$(dirname -- "$0")/.." && pwd):/build generate:$GOOS exit fi diff --git a/vendor/golang.org/x/sys/unix/mkerrors.sh b/vendor/golang.org/x/sys/unix/mkerrors.sh index fd39be4ef..fa74cfe9e 100644 --- a/vendor/golang.org/x/sys/unix/mkerrors.sh +++ b/vendor/golang.org/x/sys/unix/mkerrors.sh @@ -354,6 +354,9 @@ struct ltchars { // Renamed in v6.16, commit c6d732c38f93 ("net: ethtool: remove duplicate defines for family info") #define ETHTOOL_FAMILY_NAME ETHTOOL_GENL_NAME #define ETHTOOL_FAMILY_VERSION ETHTOOL_GENL_VERSION + +// Removed in v6.17, commit 760e6f7befba ("futex: Remove support for IMMUTABLE") +#define PR_FUTEX_HASH_GET_IMMUTABLE 3 ' includes_NetBSD=' diff --git a/vendor/golang.org/x/sys/unix/readv_unix.go b/vendor/golang.org/x/sys/unix/readv_unix.go new file mode 100644 index 000000000..38a2be937 --- /dev/null +++ b/vendor/golang.org/x/sys/unix/readv_unix.go @@ -0,0 +1,103 @@ +// Copyright 2026 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 darwin || linux || openbsd + +package unix + +import "unsafe" + +// minIovec is the size of the small initial allocation used by +// Readv, Writev, etc. +// +// This small allocation gets stack allocated, which lets the +// common use case of len(iovs) <= minIovec avoid more expensive +// heap allocations. +const minIovec = 8 + +// appendBytes converts bs to Iovecs and appends them to vecs. +func appendBytes(vecs []Iovec, bs [][]byte) []Iovec { + for _, b := range bs { + var v Iovec + v.SetLen(len(b)) + if len(b) > 0 { + v.Base = &b[0] + } else { + v.Base = (*byte)(unsafe.Pointer(&_zero)) + } + vecs = append(vecs, v) + } + return vecs +} + +// writevRaceDetect tells the race detector that the program +// has read the first n bytes stored in iovecs. +func writevRaceDetect(iovecs []Iovec, n int) { + if !raceenabled { + return + } + for i := 0; n > 0 && i < len(iovecs); i++ { + m := min(int(iovecs[i].Len), n) + n -= m + if m > 0 { + raceReadRange(unsafe.Pointer(iovecs[i].Base), m) + } + } +} + +// readvRaceDetect tells the race detector that the program +// has written to the first n bytes stored in iovecs. +func readvRaceDetect(iovecs []Iovec, n int, err error) { + if !raceenabled { + return + } + for i := 0; n > 0 && i < len(iovecs); i++ { + m := min(int(iovecs[i].Len), n) + n -= m + if m > 0 { + raceWriteRange(unsafe.Pointer(iovecs[i].Base), m) + } + } + if err == nil { + raceAcquire(unsafe.Pointer(&ioSync)) + } +} + +func Readv(fd int, iovs [][]byte) (n int, err error) { + iovecs := make([]Iovec, 0, minIovec) + iovecs = appendBytes(iovecs, iovs) + n, err = readv(fd, iovecs) + readvRaceDetect(iovecs, n, err) + return n, err +} + +func Preadv(fd int, iovs [][]byte, offset int64) (n int, err error) { + iovecs := make([]Iovec, 0, minIovec) + iovecs = appendBytes(iovecs, iovs) + n, err = preadv(fd, iovecs, offset) + readvRaceDetect(iovecs, n, err) + return n, err +} + +func Writev(fd int, iovs [][]byte) (n int, err error) { + iovecs := make([]Iovec, 0, minIovec) + iovecs = appendBytes(iovecs, iovs) + if raceenabled { + raceReleaseMerge(unsafe.Pointer(&ioSync)) + } + n, err = writev(fd, iovecs) + writevRaceDetect(iovecs, n) + return n, err +} + +func Pwritev(fd int, iovs [][]byte, offset int64) (n int, err error) { + iovecs := make([]Iovec, 0, minIovec) + iovecs = appendBytes(iovecs, iovs) + if raceenabled { + raceReleaseMerge(unsafe.Pointer(&ioSync)) + } + n, err = pwritev(fd, iovecs, offset) + writevRaceDetect(iovecs, n) + return n, err +} diff --git a/vendor/golang.org/x/sys/unix/syscall_darwin.go b/vendor/golang.org/x/sys/unix/syscall_darwin.go index 7838ca5db..38590ca81 100644 --- a/vendor/golang.org/x/sys/unix/syscall_darwin.go +++ b/vendor/golang.org/x/sys/unix/syscall_darwin.go @@ -602,95 +602,6 @@ func Connectx(fd int, srcIf uint32, srcAddr, dstAddr Sockaddr, associd SaeAssocI return } -const minIovec = 8 - -func Readv(fd int, iovs [][]byte) (n int, err error) { - iovecs := make([]Iovec, 0, minIovec) - iovecs = appendBytes(iovecs, iovs) - n, err = readv(fd, iovecs) - readvRacedetect(iovecs, n, err) - return n, err -} - -func Preadv(fd int, iovs [][]byte, offset int64) (n int, err error) { - iovecs := make([]Iovec, 0, minIovec) - iovecs = appendBytes(iovecs, iovs) - n, err = preadv(fd, iovecs, offset) - readvRacedetect(iovecs, n, err) - return n, err -} - -func Writev(fd int, iovs [][]byte) (n int, err error) { - iovecs := make([]Iovec, 0, minIovec) - iovecs = appendBytes(iovecs, iovs) - if raceenabled { - raceReleaseMerge(unsafe.Pointer(&ioSync)) - } - n, err = writev(fd, iovecs) - writevRacedetect(iovecs, n) - return n, err -} - -func Pwritev(fd int, iovs [][]byte, offset int64) (n int, err error) { - iovecs := make([]Iovec, 0, minIovec) - iovecs = appendBytes(iovecs, iovs) - if raceenabled { - raceReleaseMerge(unsafe.Pointer(&ioSync)) - } - n, err = pwritev(fd, iovecs, offset) - writevRacedetect(iovecs, n) - return n, err -} - -func appendBytes(vecs []Iovec, bs [][]byte) []Iovec { - for _, b := range bs { - var v Iovec - v.SetLen(len(b)) - if len(b) > 0 { - v.Base = &b[0] - } else { - v.Base = (*byte)(unsafe.Pointer(&_zero)) - } - vecs = append(vecs, v) - } - return vecs -} - -func writevRacedetect(iovecs []Iovec, n int) { - if !raceenabled { - return - } - for i := 0; n > 0 && i < len(iovecs); i++ { - m := int(iovecs[i].Len) - if m > n { - m = n - } - n -= m - if m > 0 { - raceReadRange(unsafe.Pointer(iovecs[i].Base), m) - } - } -} - -func readvRacedetect(iovecs []Iovec, n int, err error) { - if !raceenabled { - return - } - for i := 0; n > 0 && i < len(iovecs); i++ { - m := int(iovecs[i].Len) - if m > n { - m = n - } - n -= m - if m > 0 { - raceWriteRange(unsafe.Pointer(iovecs[i].Base), m) - } - } - if err == nil { - raceAcquire(unsafe.Pointer(&ioSync)) - } -} - //sys connectx(fd int, endpoints *SaEndpoints, associd SaeAssocID, flags uint32, iov []Iovec, n *uintptr, connid *SaeConnID) (err error) //sys sendfile(infd int, outfd int, offset int64, len *int64, hdtr unsafe.Pointer, flags int) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux.go b/vendor/golang.org/x/sys/unix/syscall_linux.go index 06c0eea6f..ce4d7ab1e 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux.go @@ -2150,33 +2150,10 @@ func Signalfd(fd int, sigmask *Sigset_t, flags int) (newfd int, err error) { //sys exitThread(code int) (err error) = SYS_EXIT //sys readv(fd int, iovs []Iovec) (n int, err error) = SYS_READV //sys writev(fd int, iovs []Iovec) (n int, err error) = SYS_WRITEV -//sys preadv(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr) (n int, err error) = SYS_PREADV -//sys pwritev(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr) (n int, err error) = SYS_PWRITEV -//sys preadv2(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr, flags int) (n int, err error) = SYS_PREADV2 -//sys pwritev2(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr, flags int) (n int, err error) = SYS_PWRITEV2 - -// minIovec is the size of the small initial allocation used by -// Readv, Writev, etc. -// -// This small allocation gets stack allocated, which lets the -// common use case of len(iovs) <= minIovs avoid more expensive -// heap allocations. -const minIovec = 8 - -// appendBytes converts bs to Iovecs and appends them to vecs. -func appendBytes(vecs []Iovec, bs [][]byte) []Iovec { - for _, b := range bs { - var v Iovec - v.SetLen(len(b)) - if len(b) > 0 { - v.Base = &b[0] - } else { - v.Base = (*byte)(unsafe.Pointer(&_zero)) - } - vecs = append(vecs, v) - } - return vecs -} +//sys preadvSyscall(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr) (n int, err error) = SYS_PREADV +//sys pwritevSyscall(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr) (n int, err error) = SYS_PWRITEV +//sys preadv2Syscall(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr, flags int) (n int, err error) = SYS_PREADV2 +//sys pwritev2Syscall(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr, flags int) (n int, err error) = SYS_PWRITEV2 // offs2lohi splits offs into its low and high order bits. func offs2lohi(offs int64) (lo, hi uintptr) { @@ -2184,69 +2161,23 @@ func offs2lohi(offs int64) (lo, hi uintptr) { return uintptr(offs), uintptr(uint64(offs) >> (longBits - 1) >> 1) // two shifts to avoid false positive in vet } -func Readv(fd int, iovs [][]byte) (n int, err error) { - iovecs := make([]Iovec, 0, minIovec) - iovecs = appendBytes(iovecs, iovs) - n, err = readv(fd, iovecs) - readvRacedetect(iovecs, n, err) - return n, err -} - -func Preadv(fd int, iovs [][]byte, offset int64) (n int, err error) { - iovecs := make([]Iovec, 0, minIovec) - iovecs = appendBytes(iovecs, iovs) +func preadv(fd int, iovecs []Iovec, offset int64) (n int, err error) { lo, hi := offs2lohi(offset) - n, err = preadv(fd, iovecs, lo, hi) - readvRacedetect(iovecs, n, err) - return n, err + return preadvSyscall(fd, iovecs, lo, hi) } func Preadv2(fd int, iovs [][]byte, offset int64, flags int) (n int, err error) { iovecs := make([]Iovec, 0, minIovec) iovecs = appendBytes(iovecs, iovs) lo, hi := offs2lohi(offset) - n, err = preadv2(fd, iovecs, lo, hi, flags) - readvRacedetect(iovecs, n, err) + n, err = preadv2Syscall(fd, iovecs, lo, hi, flags) + readvRaceDetect(iovecs, n, err) return n, err } -func readvRacedetect(iovecs []Iovec, n int, err error) { - if !raceenabled { - return - } - for i := 0; n > 0 && i < len(iovecs); i++ { - m := min(int(iovecs[i].Len), n) - n -= m - if m > 0 { - raceWriteRange(unsafe.Pointer(iovecs[i].Base), m) - } - } - if err == nil { - raceAcquire(unsafe.Pointer(&ioSync)) - } -} - -func Writev(fd int, iovs [][]byte) (n int, err error) { - iovecs := make([]Iovec, 0, minIovec) - iovecs = appendBytes(iovecs, iovs) - if raceenabled { - raceReleaseMerge(unsafe.Pointer(&ioSync)) - } - n, err = writev(fd, iovecs) - writevRacedetect(iovecs, n) - return n, err -} - -func Pwritev(fd int, iovs [][]byte, offset int64) (n int, err error) { - iovecs := make([]Iovec, 0, minIovec) - iovecs = appendBytes(iovecs, iovs) - if raceenabled { - raceReleaseMerge(unsafe.Pointer(&ioSync)) - } +func pwritev(fd int, iovecs []Iovec, offset int64) (n int, err error) { lo, hi := offs2lohi(offset) - n, err = pwritev(fd, iovecs, lo, hi) - writevRacedetect(iovecs, n) - return n, err + return pwritevSyscall(fd, iovecs, lo, hi) } func Pwritev2(fd int, iovs [][]byte, offset int64, flags int) (n int, err error) { @@ -2256,24 +2187,11 @@ func Pwritev2(fd int, iovs [][]byte, offset int64, flags int) (n int, err error) raceReleaseMerge(unsafe.Pointer(&ioSync)) } lo, hi := offs2lohi(offset) - n, err = pwritev2(fd, iovecs, lo, hi, flags) - writevRacedetect(iovecs, n) + n, err = pwritev2Syscall(fd, iovecs, lo, hi, flags) + writevRaceDetect(iovecs, n) return n, err } -func writevRacedetect(iovecs []Iovec, n int) { - if !raceenabled { - return - } - for i := 0; n > 0 && i < len(iovecs); i++ { - m := min(int(iovecs[i].Len), n) - n -= m - if m > 0 { - raceReadRange(unsafe.Pointer(iovecs[i].Base), m) - } - } -} - // mmap varies by architecture; see syscall_linux_*.go. //sys munmap(addr uintptr, length uintptr) (err error) //sys mremap(oldaddr uintptr, oldlength uintptr, newlength uintptr, flags int, newaddr uintptr) (xaddr uintptr, err error) @@ -2644,8 +2562,12 @@ func SchedGetAttr(pid int, flags uint) (*SchedAttr, error) { //sys Cachestat(fd uint, crange *CachestatRange, cstat *Cachestat_t, flags uint) (err error) //sys Mseal(b []byte, flags uint) (err error) -//sys setMemPolicy(mode int, mask *CPUSet, size int) (err error) = SYS_SET_MEMPOLICY +//sys setMemPolicy(mode int, mask unsafe.Pointer, size uintptr) (err error) = SYS_SET_MEMPOLICY func SetMemPolicy(mode int, mask *CPUSet) error { - return setMemPolicy(mode, mask, _CPU_SETSIZE) + return setMemPolicy(mode, unsafe.Pointer(mask), _CPU_SETSIZE) +} + +func SetMemPolicyDynamic(mode int, mask CPUSetDynamic) error { + return setMemPolicy(mode, mask.pointer(), mask.size()) } diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_arm.go b/vendor/golang.org/x/sys/unix/syscall_linux_arm.go index cd2dd797f..ecf92bfa2 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_arm.go @@ -82,6 +82,9 @@ func Time(t *Time_t) (Time_t, error) { } func Utime(path string, buf *Utimbuf) error { + if buf == nil { + return Utimes(path, nil) + } tv := []Timeval{ {Sec: buf.Actime}, {Sec: buf.Modtime}, diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go b/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go index 745e5c7e6..173738077 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go @@ -113,6 +113,9 @@ func Time(t *Time_t) (Time_t, error) { } func Utime(path string, buf *Utimbuf) error { + if buf == nil { + return Utimes(path, nil) + } tv := []Timeval{ {Sec: buf.Actime}, {Sec: buf.Modtime}, diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go b/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go index dd2262a40..a3fd1d0b8 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go @@ -150,6 +150,9 @@ func Time(t *Time_t) (Time_t, error) { } func Utime(path string, buf *Utimbuf) error { + if buf == nil { + return Utimes(path, nil) + } tv := []Timeval{ {Sec: buf.Actime}, {Sec: buf.Modtime}, diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go b/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go index 8cf3670bd..fc5543c5f 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go @@ -112,6 +112,9 @@ func Time(t *Time_t) (Time_t, error) { } func Utime(path string, buf *Utimbuf) error { + if buf == nil { + return Utimes(path, nil) + } tv := []Timeval{ {Sec: buf.Actime}, {Sec: buf.Modtime}, diff --git a/vendor/golang.org/x/sys/unix/syscall_openbsd.go b/vendor/golang.org/x/sys/unix/syscall_openbsd.go index b86ded549..7b0ef8e12 100644 --- a/vendor/golang.org/x/sys/unix/syscall_openbsd.go +++ b/vendor/golang.org/x/sys/unix/syscall_openbsd.go @@ -300,6 +300,10 @@ func Uname(uname *Utsname) error { //sys Pathconf(path string, name int) (val int, err error) //sys pread(fd int, p []byte, offset int64) (n int, err error) //sys pwrite(fd int, p []byte, offset int64) (n int, err error) +//sys readv(fd int, iovecs []Iovec) (n int, err error) +//sys writev(fd int, iovecs []Iovec) (n int, err error) +//sys preadv(fd int, iovecs []Iovec, offset int64) (n int, err error) +//sys pwritev(fd int, iovecs []Iovec, offset int64) (n int, err error) //sys read(fd int, p []byte) (n int, err error) //sys Readlink(path string, buf []byte) (n int, err error) //sys Readlinkat(dirfd int, path string, buf []byte) (n int, err error) diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux.go b/vendor/golang.org/x/sys/unix/zerrors_linux.go index 120a7b35d..9d72a6b73 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux.go @@ -353,8 +353,10 @@ const ( AUDIT_MAC_IPSEC_EVENT = 0x587 AUDIT_MAC_MAP_ADD = 0x581 AUDIT_MAC_MAP_DEL = 0x582 + AUDIT_MAC_OBJ_CONTEXTS = 0x592 AUDIT_MAC_POLICY_LOAD = 0x57b AUDIT_MAC_STATUS = 0x57c + AUDIT_MAC_TASK_CONTEXTS = 0x591 AUDIT_MAC_UNLBL_ALLOW = 0x57e AUDIT_MAC_UNLBL_STCADD = 0x588 AUDIT_MAC_UNLBL_STCDEL = 0x589 @@ -591,8 +593,13 @@ const ( CAN_CTRLMODE_LOOPBACK = 0x1 CAN_CTRLMODE_ONE_SHOT = 0x8 CAN_CTRLMODE_PRESUME_ACK = 0x40 + CAN_CTRLMODE_RESTRICTED = 0x800 CAN_CTRLMODE_TDC_AUTO = 0x200 CAN_CTRLMODE_TDC_MANUAL = 0x400 + CAN_CTRLMODE_XL = 0x1000 + CAN_CTRLMODE_XL_TDC_AUTO = 0x2000 + CAN_CTRLMODE_XL_TDC_MANUAL = 0x4000 + CAN_CTRLMODE_XL_TMS = 0x8000 CAN_EFF_FLAG = 0x80000000 CAN_EFF_ID_BITS = 0x1d CAN_EFF_MASK = 0x1fffffff @@ -800,6 +807,8 @@ const ( DEVLINK_PORT_FN_CAP_IPSEC_PACKET = 0x8 DEVLINK_PORT_FN_CAP_MIGRATABLE = 0x2 DEVLINK_PORT_FN_CAP_ROCE = 0x1 + DEVLINK_RATE_TCS_MAX = 0x8 + DEVLINK_RATE_TC_INDEX_MAX = 0x7 DEVLINK_SB_THRESHOLD_TO_ALPHA_MAX = 0x14 DEVLINK_SUPPORTED_FLASH_OVERWRITE_SECTIONS = 0x3 DEVMEM_MAGIC = 0x454d444d @@ -1186,6 +1195,7 @@ const ( ETH_P_MPLS_UC = 0x8847 ETH_P_MRP = 0x88e3 ETH_P_MVRP = 0x88f5 + ETH_P_MXLGSW = 0x88c3 ETH_P_NCSI = 0x88f8 ETH_P_NSH = 0x894f ETH_P_PAE = 0x888e @@ -1218,6 +1228,7 @@ const ( ETH_P_WCCP = 0x883e ETH_P_X25 = 0x805 ETH_P_XDSA = 0xf8 + ETH_P_YT921X = 0x9988 ET_CORE = 0x4 ET_DYN = 0x3 ET_EXEC = 0x2 @@ -1258,6 +1269,7 @@ const ( FALLOC_FL_NO_HIDE_STALE = 0x4 FALLOC_FL_PUNCH_HOLE = 0x2 FALLOC_FL_UNSHARE_RANGE = 0x40 + FALLOC_FL_WRITE_ZEROES = 0x80 FALLOC_FL_ZERO_RANGE = 0x10 FANOTIFY_METADATA_VERSION = 0x3 FAN_ACCESS = 0x1 @@ -1477,6 +1489,7 @@ const ( GRND_INSECURE = 0x4 GRND_NONBLOCK = 0x1 GRND_RANDOM = 0x2 + GUEST_MEMFD_MAGIC = 0x474d454d HDIO_DRIVE_CMD = 0x31f HDIO_DRIVE_CMD_AEB = 0x31e HDIO_DRIVE_CMD_HDR_SIZE = 0x4 @@ -1517,6 +1530,7 @@ const ( HDIO_SET_XFER = 0x306 HDIO_TRISTATE_HWIF = 0x31b HDIO_UNREGISTER_HWIF = 0x32a + HIDIOCTL_LAST = 0xd HID_MAX_DESCRIPTOR_SIZE = 0x1000 HOSTFS_SUPER_MAGIC = 0xc0ffee HPFS_SUPER_MAGIC = 0xf995e849 @@ -1809,6 +1823,8 @@ const ( KEXEC_ARCH_X86_64 = 0x3e0000 KEXEC_CRASH_HOTPLUG_SUPPORT = 0x8 KEXEC_FILE_DEBUG = 0x8 + KEXEC_FILE_FORCE_DTB = 0x20 + KEXEC_FILE_NO_CMA = 0x10 KEXEC_FILE_NO_INITRAMFS = 0x4 KEXEC_FILE_ON_CRASH = 0x2 KEXEC_FILE_UNLOAD = 0x1 @@ -1905,6 +1921,7 @@ const ( LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON = 0x2 LANDLOCK_RESTRICT_SELF_LOG_SAME_EXEC_OFF = 0x1 LANDLOCK_RESTRICT_SELF_LOG_SUBDOMAINS_OFF = 0x4 + LANDLOCK_RESTRICT_SELF_TSYNC = 0x8 LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET = 0x1 LANDLOCK_SCOPE_SIGNAL = 0x2 LINUX_REBOOT_CMD_CAD_OFF = 0x0 @@ -2412,6 +2429,7 @@ const ( NN_PRXFPREG = "LINUX" NN_RISCV_CSR = "LINUX" NN_RISCV_TAGGED_ADDR_CTRL = "LINUX" + NN_RISCV_USER_CFI = "LINUX" NN_RISCV_VECTOR = "LINUX" NN_S390_CTRS = "LINUX" NN_S390_GS_BC = "LINUX" @@ -2493,6 +2511,7 @@ const ( NT_PRXFPREG = 0x46e62b7f NT_RISCV_CSR = 0x900 NT_RISCV_TAGGED_ADDR_CTRL = 0x902 + NT_RISCV_USER_CFI = 0x903 NT_RISCV_VECTOR = 0x901 NT_S390_CTRS = 0x304 NT_S390_GS_BC = 0x30c @@ -2515,6 +2534,7 @@ const ( NT_X86_SHSTK = 0x204 NT_X86_XSAVE_LAYOUT = 0x205 NT_X86_XSTATE = 0x202 + NULL_FS_MAGIC = 0x4e554c4c OCFS2_SUPER_MAGIC = 0x7461636f OCRNL = 0x8 OFDEL = 0x80 @@ -2594,6 +2614,7 @@ const ( PERF_ATTR_SIZE_VER6 = 0x78 PERF_ATTR_SIZE_VER7 = 0x80 PERF_ATTR_SIZE_VER8 = 0x88 + PERF_ATTR_SIZE_VER9 = 0x90 PERF_AUX_FLAG_COLLISION = 0x8 PERF_AUX_FLAG_CORESIGHT_FORMAT_CORESIGHT = 0x0 PERF_AUX_FLAG_CORESIGHT_FORMAT_RAW = 0x100 @@ -2629,6 +2650,7 @@ const ( PERF_MEM_LVLNUM_ANY_CACHE = 0xb PERF_MEM_LVLNUM_CXL = 0x9 PERF_MEM_LVLNUM_IO = 0xa + PERF_MEM_LVLNUM_L0 = 0x7 PERF_MEM_LVLNUM_L1 = 0x1 PERF_MEM_LVLNUM_L2 = 0x2 PERF_MEM_LVLNUM_L2_MHB = 0x5 @@ -2662,6 +2684,23 @@ const ( PERF_MEM_OP_PFETCH = 0x8 PERF_MEM_OP_SHIFT = 0x0 PERF_MEM_OP_STORE = 0x4 + PERF_MEM_REGION_L_NON_SHARE = 0x3 + PERF_MEM_REGION_L_SHARE = 0x2 + PERF_MEM_REGION_MEM0 = 0x8 + PERF_MEM_REGION_MEM1 = 0x9 + PERF_MEM_REGION_MEM2 = 0xa + PERF_MEM_REGION_MEM3 = 0xb + PERF_MEM_REGION_MEM4 = 0xc + PERF_MEM_REGION_MEM5 = 0xd + PERF_MEM_REGION_MEM6 = 0xe + PERF_MEM_REGION_MEM7 = 0xf + PERF_MEM_REGION_MMIO = 0x7 + PERF_MEM_REGION_NA = 0x0 + PERF_MEM_REGION_O_IO = 0x4 + PERF_MEM_REGION_O_NON_SHARE = 0x6 + PERF_MEM_REGION_O_SHARE = 0x5 + PERF_MEM_REGION_RSVD = 0x1 + PERF_MEM_REGION_SHIFT = 0x2e PERF_MEM_REMOTE_REMOTE = 0x1 PERF_MEM_REMOTE_SHIFT = 0x25 PERF_MEM_SNOOPX_FWD = 0x1 @@ -2776,6 +2815,10 @@ const ( PR_CAP_AMBIENT_IS_SET = 0x1 PR_CAP_AMBIENT_LOWER = 0x3 PR_CAP_AMBIENT_RAISE = 0x2 + PR_CFI_BRANCH_LANDING_PADS = 0x0 + PR_CFI_DISABLE = 0x2 + PR_CFI_ENABLE = 0x1 + PR_CFI_LOCK = 0x4 PR_ENDIAN_BIG = 0x0 PR_ENDIAN_LITTLE = 0x1 PR_ENDIAN_PPC_LITTLE = 0x2 @@ -2798,6 +2841,7 @@ const ( PR_FUTEX_HASH_GET_SLOTS = 0x2 PR_FUTEX_HASH_SET_SLOTS = 0x1 PR_GET_AUXV = 0x41555856 + PR_GET_CFI = 0x50 PR_GET_CHILD_SUBREAPER = 0x25 PR_GET_DUMPABLE = 0x3 PR_GET_ENDIAN = 0x13 @@ -2834,6 +2878,7 @@ const ( PR_MDWE_REFUSE_EXEC_GAIN = 0x1 PR_MPX_DISABLE_MANAGEMENT = 0x2c PR_MPX_ENABLE_MANAGEMENT = 0x2b + PR_MTE_STORE_ONLY = 0x80000 PR_MTE_TAG_MASK = 0x7fff8 PR_MTE_TAG_SHIFT = 0x3 PR_MTE_TCF_ASYNC = 0x4 @@ -2877,6 +2922,10 @@ const ( PR_RISCV_V_VSTATE_CTRL_NEXT_MASK = 0xc PR_RISCV_V_VSTATE_CTRL_OFF = 0x1 PR_RISCV_V_VSTATE_CTRL_ON = 0x2 + PR_RSEQ_SLICE_EXTENSION = 0x4f + PR_RSEQ_SLICE_EXTENSION_GET = 0x1 + PR_RSEQ_SLICE_EXTENSION_SET = 0x2 + PR_RSEQ_SLICE_EXT_ENABLE = 0x1 PR_SCHED_CORE = 0x3e PR_SCHED_CORE_CREATE = 0x1 PR_SCHED_CORE_GET = 0x0 @@ -2886,6 +2935,7 @@ const ( PR_SCHED_CORE_SCOPE_THREAD_GROUP = 0x1 PR_SCHED_CORE_SHARE_FROM = 0x3 PR_SCHED_CORE_SHARE_TO = 0x2 + PR_SET_CFI = 0x51 PR_SET_CHILD_SUBREAPER = 0x24 PR_SET_DUMPABLE = 0x4 PR_SET_ENDIAN = 0x14 @@ -2951,11 +3001,14 @@ const ( PR_SVE_SET_VL_ONEXEC = 0x40000 PR_SVE_VL_INHERIT = 0x20000 PR_SVE_VL_LEN_MASK = 0xffff + PR_SYS_DISPATCH_EXCLUSIVE_ON = 0x1 + PR_SYS_DISPATCH_INCLUSIVE_ON = 0x2 PR_SYS_DISPATCH_OFF = 0x0 PR_SYS_DISPATCH_ON = 0x1 PR_TAGGED_ADDR_ENABLE = 0x1 PR_TASK_PERF_EVENTS_DISABLE = 0x1f PR_TASK_PERF_EVENTS_ENABLE = 0x20 + PR_THP_DISABLE_EXCEPT_ADVISED = 0x2 PR_TIMER_CREATE_RESTORE_IDS = 0x4d PR_TIMER_CREATE_RESTORE_IDS_GET = 0x2 PR_TIMER_CREATE_RESTORE_IDS_OFF = 0x0 @@ -2987,8 +3040,10 @@ const ( PTP_STRICT_FLAGS = 0x8 PTP_SYS_OFFSET_EXTENDED = 0xc4c03d09 PTP_SYS_OFFSET_EXTENDED2 = 0xc4c03d12 + PTP_SYS_OFFSET_EXTENDED_CYCLES = 0xc4c03d16 PTP_SYS_OFFSET_PRECISE = 0xc0403d08 PTP_SYS_OFFSET_PRECISE2 = 0xc0403d11 + PTP_SYS_OFFSET_PRECISE_CYCLES = 0xc0403d15 PTRACE_ATTACH = 0x10 PTRACE_CONT = 0x7 PTRACE_DETACH = 0x11 @@ -3330,8 +3385,9 @@ const ( RWF_DSYNC = 0x2 RWF_HIPRI = 0x1 RWF_NOAPPEND = 0x20 + RWF_NOSIGNAL = 0x100 RWF_NOWAIT = 0x8 - RWF_SUPPORTED = 0xff + RWF_SUPPORTED = 0x1ff RWF_SYNC = 0x4 RWF_WRITE_LIFE_NOT_SET = 0x0 SCHED_BATCH = 0x3 @@ -3714,7 +3770,7 @@ const ( TASKSTATS_GENL_NAME = "TASKSTATS" TASKSTATS_GENL_VERSION = 0x1 TASKSTATS_TYPE_MAX = 0x6 - TASKSTATS_VERSION = 0x10 + TASKSTATS_VERSION = 0x11 TCIFLUSH = 0x0 TCIOFF = 0x2 TCIOFLUSH = 0x2 @@ -4052,6 +4108,7 @@ const ( XDP_FLAGS_REPLACE = 0x10 XDP_FLAGS_SKB_MODE = 0x2 XDP_FLAGS_UPDATE_IF_NOEXIST = 0x1 + XDP_MAX_TX_SKB_BUDGET = 0x9 XDP_MMAP_OFFSETS = 0x1 XDP_OPTIONS = 0x8 XDP_OPTIONS_ZEROCOPY = 0x1 diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_386.go b/vendor/golang.org/x/sys/unix/zerrors_linux_386.go index 97a61fc5b..c0a8ea1de 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_386.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_386.go @@ -159,6 +159,7 @@ const ( NFDBITS = 0x20 NLDLY = 0x100 NOFLSH = 0x80 + NS_GET_ID = 0x8008b70d NS_GET_MNTNS_ID = 0x8008b705 NS_GET_NSTYPE = 0xb703 NS_GET_OWNER_UID = 0xb704 @@ -305,6 +306,7 @@ const ( RTC_WKALM_SET = 0x4028700f SCM_DEVMEM_DMABUF = 0x4f SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 SCM_TIMESTAMPING = 0x25 SCM_TIMESTAMPING_OPT_STATS = 0x36 SCM_TIMESTAMPING_PKTINFO = 0x3a @@ -352,6 +354,7 @@ const ( SO_ERROR = 0x4 SO_INCOMING_CPU = 0x31 SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 SO_KEEPALIVE = 0x9 SO_LINGER = 0xd SO_LOCK_FILTER = 0x2c @@ -596,6 +599,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x59) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x7a) + EFSBADCRC = syscall.Errno(0x4a) + EFSCORRUPTED = syscall.Errno(0x75) EHOSTDOWN = syscall.Errno(0x70) EHOSTUNREACH = syscall.Errno(0x71) EHWPOISON = syscall.Errno(0x85) @@ -819,7 +824,7 @@ var errorList = [...]struct { {114, "EALREADY", "operation already in progress"}, {115, "EINPROGRESS", "operation now in progress"}, {116, "ESTALE", "stale file handle"}, - {117, "EUCLEAN", "structure needs cleaning"}, + {117, "EFSCORRUPTED", "structure needs cleaning"}, {118, "ENOTNAM", "not a XENIX named type file"}, {119, "ENAVAIL", "no XENIX semaphores available"}, {120, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_amd64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_amd64.go index a0d6d498c..ff927c830 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_amd64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_amd64.go @@ -159,6 +159,7 @@ const ( NFDBITS = 0x40 NLDLY = 0x100 NOFLSH = 0x80 + NS_GET_ID = 0x8008b70d NS_GET_MNTNS_ID = 0x8008b705 NS_GET_NSTYPE = 0xb703 NS_GET_OWNER_UID = 0xb704 @@ -306,6 +307,7 @@ const ( RTC_WKALM_SET = 0x4028700f SCM_DEVMEM_DMABUF = 0x4f SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 SCM_TIMESTAMPING = 0x25 SCM_TIMESTAMPING_OPT_STATS = 0x36 SCM_TIMESTAMPING_PKTINFO = 0x3a @@ -353,6 +355,7 @@ const ( SO_ERROR = 0x4 SO_INCOMING_CPU = 0x31 SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 SO_KEEPALIVE = 0x9 SO_LINGER = 0xd SO_LOCK_FILTER = 0x2c @@ -596,6 +599,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x59) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x7a) + EFSBADCRC = syscall.Errno(0x4a) + EFSCORRUPTED = syscall.Errno(0x75) EHOSTDOWN = syscall.Errno(0x70) EHOSTUNREACH = syscall.Errno(0x71) EHWPOISON = syscall.Errno(0x85) @@ -819,7 +824,7 @@ var errorList = [...]struct { {114, "EALREADY", "operation already in progress"}, {115, "EINPROGRESS", "operation now in progress"}, {116, "ESTALE", "stale file handle"}, - {117, "EUCLEAN", "structure needs cleaning"}, + {117, "EFSCORRUPTED", "structure needs cleaning"}, {118, "ENOTNAM", "not a XENIX named type file"}, {119, "ENAVAIL", "no XENIX semaphores available"}, {120, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_arm.go b/vendor/golang.org/x/sys/unix/zerrors_linux_arm.go index dd9c903f9..55294eda5 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_arm.go @@ -156,6 +156,7 @@ const ( NFDBITS = 0x20 NLDLY = 0x100 NOFLSH = 0x80 + NS_GET_ID = 0x8008b70d NS_GET_MNTNS_ID = 0x8008b705 NS_GET_NSTYPE = 0xb703 NS_GET_OWNER_UID = 0xb704 @@ -311,6 +312,7 @@ const ( RTC_WKALM_SET = 0x4028700f SCM_DEVMEM_DMABUF = 0x4f SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 SCM_TIMESTAMPING = 0x25 SCM_TIMESTAMPING_OPT_STATS = 0x36 SCM_TIMESTAMPING_PKTINFO = 0x3a @@ -358,6 +360,7 @@ const ( SO_ERROR = 0x4 SO_INCOMING_CPU = 0x31 SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 SO_KEEPALIVE = 0x9 SO_LINGER = 0xd SO_LOCK_FILTER = 0x2c @@ -601,6 +604,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x59) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x7a) + EFSBADCRC = syscall.Errno(0x4a) + EFSCORRUPTED = syscall.Errno(0x75) EHOSTDOWN = syscall.Errno(0x70) EHOSTUNREACH = syscall.Errno(0x71) EHWPOISON = syscall.Errno(0x85) @@ -824,7 +829,7 @@ var errorList = [...]struct { {114, "EALREADY", "operation already in progress"}, {115, "EINPROGRESS", "operation now in progress"}, {116, "ESTALE", "stale file handle"}, - {117, "EUCLEAN", "structure needs cleaning"}, + {117, "EFSCORRUPTED", "structure needs cleaning"}, {118, "ENOTNAM", "not a XENIX named type file"}, {119, "ENAVAIL", "no XENIX semaphores available"}, {120, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_arm64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_arm64.go index 384c61ca3..5dac54c35 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_arm64.go @@ -161,6 +161,7 @@ const ( NFDBITS = 0x40 NLDLY = 0x100 NOFLSH = 0x80 + NS_GET_ID = 0x8008b70d NS_GET_MNTNS_ID = 0x8008b705 NS_GET_NSTYPE = 0xb703 NS_GET_OWNER_UID = 0xb704 @@ -304,6 +305,7 @@ const ( RTC_WKALM_SET = 0x4028700f SCM_DEVMEM_DMABUF = 0x4f SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 SCM_TIMESTAMPING = 0x25 SCM_TIMESTAMPING_OPT_STATS = 0x36 SCM_TIMESTAMPING_PKTINFO = 0x3a @@ -351,6 +353,7 @@ const ( SO_ERROR = 0x4 SO_INCOMING_CPU = 0x31 SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 SO_KEEPALIVE = 0x9 SO_LINGER = 0xd SO_LOCK_FILTER = 0x2c @@ -598,6 +601,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x59) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x7a) + EFSBADCRC = syscall.Errno(0x4a) + EFSCORRUPTED = syscall.Errno(0x75) EHOSTDOWN = syscall.Errno(0x70) EHOSTUNREACH = syscall.Errno(0x71) EHWPOISON = syscall.Errno(0x85) @@ -821,7 +826,7 @@ var errorList = [...]struct { {114, "EALREADY", "operation already in progress"}, {115, "EINPROGRESS", "operation now in progress"}, {116, "ESTALE", "stale file handle"}, - {117, "EUCLEAN", "structure needs cleaning"}, + {117, "EFSCORRUPTED", "structure needs cleaning"}, {118, "ENOTNAM", "not a XENIX named type file"}, {119, "ENAVAIL", "no XENIX semaphores available"}, {120, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_loong64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_loong64.go index 6384c9831..46ac1fcb2 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_loong64.go @@ -160,6 +160,7 @@ const ( NFDBITS = 0x40 NLDLY = 0x100 NOFLSH = 0x80 + NS_GET_ID = 0x8008b70d NS_GET_MNTNS_ID = 0x8008b705 NS_GET_NSTYPE = 0xb703 NS_GET_OWNER_UID = 0xb704 @@ -298,6 +299,7 @@ const ( RTC_WKALM_SET = 0x4028700f SCM_DEVMEM_DMABUF = 0x4f SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 SCM_TIMESTAMPING = 0x25 SCM_TIMESTAMPING_OPT_STATS = 0x36 SCM_TIMESTAMPING_PKTINFO = 0x3a @@ -345,6 +347,7 @@ const ( SO_ERROR = 0x4 SO_INCOMING_CPU = 0x31 SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 SO_KEEPALIVE = 0x9 SO_LINGER = 0xd SO_LOCK_FILTER = 0x2c @@ -588,6 +591,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x59) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x7a) + EFSBADCRC = syscall.Errno(0x4a) + EFSCORRUPTED = syscall.Errno(0x75) EHOSTDOWN = syscall.Errno(0x70) EHOSTUNREACH = syscall.Errno(0x71) EHWPOISON = syscall.Errno(0x85) @@ -811,7 +816,7 @@ var errorList = [...]struct { {114, "EALREADY", "operation already in progress"}, {115, "EINPROGRESS", "operation now in progress"}, {116, "ESTALE", "stale file handle"}, - {117, "EUCLEAN", "structure needs cleaning"}, + {117, "EFSCORRUPTED", "structure needs cleaning"}, {118, "ENOTNAM", "not a XENIX named type file"}, {119, "ENAVAIL", "no XENIX semaphores available"}, {120, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_mips.go b/vendor/golang.org/x/sys/unix/zerrors_linux_mips.go index 553c1c6f1..b55483e8a 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_mips.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_mips.go @@ -156,6 +156,7 @@ const ( NFDBITS = 0x20 NLDLY = 0x100 NOFLSH = 0x80 + NS_GET_ID = 0x4008b70d NS_GET_MNTNS_ID = 0x4008b705 NS_GET_NSTYPE = 0x2000b703 NS_GET_OWNER_UID = 0x2000b704 @@ -304,6 +305,7 @@ const ( RTC_WKALM_SET = 0x8028700f SCM_DEVMEM_DMABUF = 0x4f SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 SCM_TIMESTAMPING = 0x25 SCM_TIMESTAMPING_OPT_STATS = 0x36 SCM_TIMESTAMPING_PKTINFO = 0x3a @@ -351,6 +353,7 @@ const ( SO_ERROR = 0x1007 SO_INCOMING_CPU = 0x31 SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 SO_KEEPALIVE = 0x8 SO_LINGER = 0x80 SO_LOCK_FILTER = 0x2c @@ -597,6 +600,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x60) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x46d) + EFSBADCRC = syscall.Errno(0x4d) + EFSCORRUPTED = syscall.Errno(0x87) EHOSTDOWN = syscall.Errno(0x93) EHOSTUNREACH = syscall.Errno(0x94) EHWPOISON = syscall.Errno(0xa8) @@ -814,7 +819,7 @@ var errorList = [...]struct { {132, "ENOBUFS", "no buffer space available"}, {133, "EISCONN", "transport endpoint is already connected"}, {134, "ENOTCONN", "transport endpoint is not connected"}, - {135, "EUCLEAN", "structure needs cleaning"}, + {135, "EFSCORRUPTED", "structure needs cleaning"}, {137, "ENOTNAM", "not a XENIX named type file"}, {138, "ENAVAIL", "no XENIX semaphores available"}, {139, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_mips64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_mips64.go index b3339f209..71890c98a 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_mips64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_mips64.go @@ -156,6 +156,7 @@ const ( NFDBITS = 0x40 NLDLY = 0x100 NOFLSH = 0x80 + NS_GET_ID = 0x4008b70d NS_GET_MNTNS_ID = 0x4008b705 NS_GET_NSTYPE = 0x2000b703 NS_GET_OWNER_UID = 0x2000b704 @@ -304,6 +305,7 @@ const ( RTC_WKALM_SET = 0x8028700f SCM_DEVMEM_DMABUF = 0x4f SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 SCM_TIMESTAMPING = 0x25 SCM_TIMESTAMPING_OPT_STATS = 0x36 SCM_TIMESTAMPING_PKTINFO = 0x3a @@ -351,6 +353,7 @@ const ( SO_ERROR = 0x1007 SO_INCOMING_CPU = 0x31 SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 SO_KEEPALIVE = 0x8 SO_LINGER = 0x80 SO_LOCK_FILTER = 0x2c @@ -597,6 +600,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x60) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x46d) + EFSBADCRC = syscall.Errno(0x4d) + EFSCORRUPTED = syscall.Errno(0x87) EHOSTDOWN = syscall.Errno(0x93) EHOSTUNREACH = syscall.Errno(0x94) EHWPOISON = syscall.Errno(0xa8) @@ -814,7 +819,7 @@ var errorList = [...]struct { {132, "ENOBUFS", "no buffer space available"}, {133, "EISCONN", "transport endpoint is already connected"}, {134, "ENOTCONN", "transport endpoint is not connected"}, - {135, "EUCLEAN", "structure needs cleaning"}, + {135, "EFSCORRUPTED", "structure needs cleaning"}, {137, "ENOTNAM", "not a XENIX named type file"}, {138, "ENAVAIL", "no XENIX semaphores available"}, {139, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_mips64le.go b/vendor/golang.org/x/sys/unix/zerrors_linux_mips64le.go index 177091d2b..a78b6cc14 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_mips64le.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_mips64le.go @@ -156,6 +156,7 @@ const ( NFDBITS = 0x40 NLDLY = 0x100 NOFLSH = 0x80 + NS_GET_ID = 0x4008b70d NS_GET_MNTNS_ID = 0x4008b705 NS_GET_NSTYPE = 0x2000b703 NS_GET_OWNER_UID = 0x2000b704 @@ -304,6 +305,7 @@ const ( RTC_WKALM_SET = 0x8028700f SCM_DEVMEM_DMABUF = 0x4f SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 SCM_TIMESTAMPING = 0x25 SCM_TIMESTAMPING_OPT_STATS = 0x36 SCM_TIMESTAMPING_PKTINFO = 0x3a @@ -351,6 +353,7 @@ const ( SO_ERROR = 0x1007 SO_INCOMING_CPU = 0x31 SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 SO_KEEPALIVE = 0x8 SO_LINGER = 0x80 SO_LOCK_FILTER = 0x2c @@ -597,6 +600,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x60) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x46d) + EFSBADCRC = syscall.Errno(0x4d) + EFSCORRUPTED = syscall.Errno(0x87) EHOSTDOWN = syscall.Errno(0x93) EHOSTUNREACH = syscall.Errno(0x94) EHWPOISON = syscall.Errno(0xa8) @@ -814,7 +819,7 @@ var errorList = [...]struct { {132, "ENOBUFS", "no buffer space available"}, {133, "EISCONN", "transport endpoint is already connected"}, {134, "ENOTCONN", "transport endpoint is not connected"}, - {135, "EUCLEAN", "structure needs cleaning"}, + {135, "EFSCORRUPTED", "structure needs cleaning"}, {137, "ENOTNAM", "not a XENIX named type file"}, {138, "ENAVAIL", "no XENIX semaphores available"}, {139, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_mipsle.go b/vendor/golang.org/x/sys/unix/zerrors_linux_mipsle.go index c5abf156d..d0e38ca73 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_mipsle.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_mipsle.go @@ -156,6 +156,7 @@ const ( NFDBITS = 0x20 NLDLY = 0x100 NOFLSH = 0x80 + NS_GET_ID = 0x4008b70d NS_GET_MNTNS_ID = 0x4008b705 NS_GET_NSTYPE = 0x2000b703 NS_GET_OWNER_UID = 0x2000b704 @@ -304,6 +305,7 @@ const ( RTC_WKALM_SET = 0x8028700f SCM_DEVMEM_DMABUF = 0x4f SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 SCM_TIMESTAMPING = 0x25 SCM_TIMESTAMPING_OPT_STATS = 0x36 SCM_TIMESTAMPING_PKTINFO = 0x3a @@ -351,6 +353,7 @@ const ( SO_ERROR = 0x1007 SO_INCOMING_CPU = 0x31 SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 SO_KEEPALIVE = 0x8 SO_LINGER = 0x80 SO_LOCK_FILTER = 0x2c @@ -597,6 +600,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x60) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x46d) + EFSBADCRC = syscall.Errno(0x4d) + EFSCORRUPTED = syscall.Errno(0x87) EHOSTDOWN = syscall.Errno(0x93) EHOSTUNREACH = syscall.Errno(0x94) EHWPOISON = syscall.Errno(0xa8) @@ -814,7 +819,7 @@ var errorList = [...]struct { {132, "ENOBUFS", "no buffer space available"}, {133, "EISCONN", "transport endpoint is already connected"}, {134, "ENOTCONN", "transport endpoint is not connected"}, - {135, "EUCLEAN", "structure needs cleaning"}, + {135, "EFSCORRUPTED", "structure needs cleaning"}, {137, "ENOTNAM", "not a XENIX named type file"}, {138, "ENAVAIL", "no XENIX semaphores available"}, {139, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_ppc.go b/vendor/golang.org/x/sys/unix/zerrors_linux_ppc.go index f1f3fadf5..c883e14c7 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_ppc.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_ppc.go @@ -158,6 +158,7 @@ const ( NL3 = 0x300 NLDLY = 0x300 NOFLSH = 0x80000000 + NS_GET_ID = 0x4008b70d NS_GET_MNTNS_ID = 0x4008b705 NS_GET_NSTYPE = 0x2000b703 NS_GET_OWNER_UID = 0x2000b704 @@ -359,6 +360,7 @@ const ( RTC_WKALM_SET = 0x8028700f SCM_DEVMEM_DMABUF = 0x4f SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 SCM_TIMESTAMPING = 0x25 SCM_TIMESTAMPING_OPT_STATS = 0x36 SCM_TIMESTAMPING_PKTINFO = 0x3a @@ -406,6 +408,7 @@ const ( SO_ERROR = 0x4 SO_INCOMING_CPU = 0x31 SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 SO_KEEPALIVE = 0x9 SO_LINGER = 0xd SO_LOCK_FILTER = 0x2c @@ -653,6 +656,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x59) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x7a) + EFSBADCRC = syscall.Errno(0x4a) + EFSCORRUPTED = syscall.Errno(0x75) EHOSTDOWN = syscall.Errno(0x70) EHOSTUNREACH = syscall.Errno(0x71) EHWPOISON = syscall.Errno(0x85) @@ -877,7 +882,7 @@ var errorList = [...]struct { {114, "EALREADY", "operation already in progress"}, {115, "EINPROGRESS", "operation now in progress"}, {116, "ESTALE", "stale file handle"}, - {117, "EUCLEAN", "structure needs cleaning"}, + {117, "EFSCORRUPTED", "structure needs cleaning"}, {118, "ENOTNAM", "not a XENIX named type file"}, {119, "ENAVAIL", "no XENIX semaphores available"}, {120, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64.go index 203ad9c54..1834273d4 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64.go @@ -158,6 +158,7 @@ const ( NL3 = 0x300 NLDLY = 0x300 NOFLSH = 0x80000000 + NS_GET_ID = 0x4008b70d NS_GET_MNTNS_ID = 0x4008b705 NS_GET_NSTYPE = 0x2000b703 NS_GET_OWNER_UID = 0x2000b704 @@ -363,6 +364,7 @@ const ( RTC_WKALM_SET = 0x8028700f SCM_DEVMEM_DMABUF = 0x4f SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 SCM_TIMESTAMPING = 0x25 SCM_TIMESTAMPING_OPT_STATS = 0x36 SCM_TIMESTAMPING_PKTINFO = 0x3a @@ -410,6 +412,7 @@ const ( SO_ERROR = 0x4 SO_INCOMING_CPU = 0x31 SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 SO_KEEPALIVE = 0x9 SO_LINGER = 0xd SO_LOCK_FILTER = 0x2c @@ -657,6 +660,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x59) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x7a) + EFSBADCRC = syscall.Errno(0x4a) + EFSCORRUPTED = syscall.Errno(0x75) EHOSTDOWN = syscall.Errno(0x70) EHOSTUNREACH = syscall.Errno(0x71) EHWPOISON = syscall.Errno(0x85) @@ -881,7 +886,7 @@ var errorList = [...]struct { {114, "EALREADY", "operation already in progress"}, {115, "EINPROGRESS", "operation now in progress"}, {116, "ESTALE", "stale file handle"}, - {117, "EUCLEAN", "structure needs cleaning"}, + {117, "EFSCORRUPTED", "structure needs cleaning"}, {118, "ENOTNAM", "not a XENIX named type file"}, {119, "ENAVAIL", "no XENIX semaphores available"}, {120, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64le.go b/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64le.go index 4b9abcb21..39945dd9a 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64le.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_ppc64le.go @@ -158,6 +158,7 @@ const ( NL3 = 0x300 NLDLY = 0x300 NOFLSH = 0x80000000 + NS_GET_ID = 0x4008b70d NS_GET_MNTNS_ID = 0x4008b705 NS_GET_NSTYPE = 0x2000b703 NS_GET_OWNER_UID = 0x2000b704 @@ -363,6 +364,7 @@ const ( RTC_WKALM_SET = 0x8028700f SCM_DEVMEM_DMABUF = 0x4f SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 SCM_TIMESTAMPING = 0x25 SCM_TIMESTAMPING_OPT_STATS = 0x36 SCM_TIMESTAMPING_PKTINFO = 0x3a @@ -410,6 +412,7 @@ const ( SO_ERROR = 0x4 SO_INCOMING_CPU = 0x31 SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 SO_KEEPALIVE = 0x9 SO_LINGER = 0xd SO_LOCK_FILTER = 0x2c @@ -657,6 +660,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x59) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x7a) + EFSBADCRC = syscall.Errno(0x4a) + EFSCORRUPTED = syscall.Errno(0x75) EHOSTDOWN = syscall.Errno(0x70) EHOSTUNREACH = syscall.Errno(0x71) EHWPOISON = syscall.Errno(0x85) @@ -881,7 +886,7 @@ var errorList = [...]struct { {114, "EALREADY", "operation already in progress"}, {115, "EINPROGRESS", "operation now in progress"}, {116, "ESTALE", "stale file handle"}, - {117, "EUCLEAN", "structure needs cleaning"}, + {117, "EFSCORRUPTED", "structure needs cleaning"}, {118, "ENOTNAM", "not a XENIX named type file"}, {119, "ENAVAIL", "no XENIX semaphores available"}, {120, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_riscv64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_riscv64.go index f87983037..bc0f37241 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_riscv64.go @@ -11,553 +11,569 @@ package unix import "syscall" const ( - B1000000 = 0x1008 - B115200 = 0x1002 - B1152000 = 0x1009 - B1500000 = 0x100a - B2000000 = 0x100b - B230400 = 0x1003 - B2500000 = 0x100c - B3000000 = 0x100d - B3500000 = 0x100e - B4000000 = 0x100f - B460800 = 0x1004 - B500000 = 0x1005 - B57600 = 0x1001 - B576000 = 0x1006 - B921600 = 0x1007 - BLKALIGNOFF = 0x127a - BLKBSZGET = 0x80081270 - BLKBSZSET = 0x40081271 - BLKDISCARD = 0x1277 - BLKDISCARDZEROES = 0x127c - BLKFLSBUF = 0x1261 - BLKFRAGET = 0x1265 - BLKFRASET = 0x1264 - BLKGETDISKSEQ = 0x80081280 - BLKGETSIZE = 0x1260 - BLKGETSIZE64 = 0x80081272 - BLKIOMIN = 0x1278 - BLKIOOPT = 0x1279 - BLKPBSZGET = 0x127b - BLKRAGET = 0x1263 - BLKRASET = 0x1262 - BLKROGET = 0x125e - BLKROSET = 0x125d - BLKROTATIONAL = 0x127e - BLKRRPART = 0x125f - BLKSECDISCARD = 0x127d - BLKSECTGET = 0x1267 - BLKSECTSET = 0x1266 - BLKSSZGET = 0x1268 - BLKZEROOUT = 0x127f - BOTHER = 0x1000 - BS1 = 0x2000 - BSDLY = 0x2000 - CBAUD = 0x100f - CBAUDEX = 0x1000 - CIBAUD = 0x100f0000 - CLOCAL = 0x800 - CR1 = 0x200 - CR2 = 0x400 - CR3 = 0x600 - CRDLY = 0x600 - CREAD = 0x80 - CS6 = 0x10 - CS7 = 0x20 - CS8 = 0x30 - CSIZE = 0x30 - CSTOPB = 0x40 - DM_MPATH_PROBE_PATHS = 0xfd12 - ECCGETLAYOUT = 0x81484d11 - ECCGETSTATS = 0x80104d12 - ECHOCTL = 0x200 - ECHOE = 0x10 - ECHOK = 0x20 - ECHOKE = 0x800 - ECHONL = 0x40 - ECHOPRT = 0x400 - EFD_CLOEXEC = 0x80000 - EFD_NONBLOCK = 0x800 - EPIOCGPARAMS = 0x80088a02 - EPIOCSPARAMS = 0x40088a01 - EPOLL_CLOEXEC = 0x80000 - EXTPROC = 0x10000 - FF1 = 0x8000 - FFDLY = 0x8000 - FICLONE = 0x40049409 - FICLONERANGE = 0x4020940d - FLUSHO = 0x1000 - FS_IOC_ENABLE_VERITY = 0x40806685 - FS_IOC_GETFLAGS = 0x80086601 - FS_IOC_GET_ENCRYPTION_NONCE = 0x8010661b - FS_IOC_GET_ENCRYPTION_POLICY = 0x400c6615 - FS_IOC_GET_ENCRYPTION_PWSALT = 0x40106614 - FS_IOC_SETFLAGS = 0x40086602 - FS_IOC_SET_ENCRYPTION_POLICY = 0x800c6613 - F_GETLK = 0x5 - F_GETLK64 = 0x5 - F_GETOWN = 0x9 - F_RDLCK = 0x0 - F_SETLK = 0x6 - F_SETLK64 = 0x6 - F_SETLKW = 0x7 - F_SETLKW64 = 0x7 - F_SETOWN = 0x8 - F_UNLCK = 0x2 - F_WRLCK = 0x1 - HIDIOCGRAWINFO = 0x80084803 - HIDIOCGRDESC = 0x90044802 - HIDIOCGRDESCSIZE = 0x80044801 - HIDIOCREVOKE = 0x4004480d - HUPCL = 0x400 - ICANON = 0x2 - IEXTEN = 0x8000 - IN_CLOEXEC = 0x80000 - IN_NONBLOCK = 0x800 - IOCTL_MEI_NOTIFY_GET = 0x80044803 - IOCTL_MEI_NOTIFY_SET = 0x40044802 - IOCTL_VM_SOCKETS_GET_LOCAL_CID = 0x7b9 - IPV6_FLOWINFO_MASK = 0xffffff0f - IPV6_FLOWLABEL_MASK = 0xffff0f00 - ISIG = 0x1 - IUCLC = 0x200 - IXOFF = 0x1000 - IXON = 0x400 - MAP_ANON = 0x20 - MAP_ANONYMOUS = 0x20 - MAP_DENYWRITE = 0x800 - MAP_EXECUTABLE = 0x1000 - MAP_GROWSDOWN = 0x100 - MAP_HUGETLB = 0x40000 - MAP_LOCKED = 0x2000 - MAP_NONBLOCK = 0x10000 - MAP_NORESERVE = 0x4000 - MAP_POPULATE = 0x8000 - MAP_STACK = 0x20000 - MAP_SYNC = 0x80000 - MCL_CURRENT = 0x1 - MCL_FUTURE = 0x2 - MCL_ONFAULT = 0x4 - MEMERASE = 0x40084d02 - MEMERASE64 = 0x40104d14 - MEMGETBADBLOCK = 0x40084d0b - MEMGETINFO = 0x80204d01 - MEMGETOOBSEL = 0x80c84d0a - MEMGETREGIONCOUNT = 0x80044d07 - MEMISLOCKED = 0x80084d17 - MEMLOCK = 0x40084d05 - MEMREAD = 0xc0404d1a - MEMREADOOB = 0xc0104d04 - MEMSETBADBLOCK = 0x40084d0c - MEMUNLOCK = 0x40084d06 - MEMWRITEOOB = 0xc0104d03 - MTDFILEMODE = 0x4d13 - NFDBITS = 0x40 - NLDLY = 0x100 - NOFLSH = 0x80 - NS_GET_MNTNS_ID = 0x8008b705 - NS_GET_NSTYPE = 0xb703 - NS_GET_OWNER_UID = 0xb704 - NS_GET_PARENT = 0xb702 - NS_GET_PID_FROM_PIDNS = 0x8004b706 - NS_GET_PID_IN_PIDNS = 0x8004b708 - NS_GET_TGID_FROM_PIDNS = 0x8004b707 - NS_GET_TGID_IN_PIDNS = 0x8004b709 - NS_GET_USERNS = 0xb701 - OLCUC = 0x2 - ONLCR = 0x4 - OTPERASE = 0x400c4d19 - OTPGETREGIONCOUNT = 0x40044d0e - OTPGETREGIONINFO = 0x400c4d0f - OTPLOCK = 0x800c4d10 - OTPSELECT = 0x80044d0d - O_APPEND = 0x400 - O_ASYNC = 0x2000 - O_CLOEXEC = 0x80000 - O_CREAT = 0x40 - O_DIRECT = 0x4000 - O_DIRECTORY = 0x10000 - O_DSYNC = 0x1000 - O_EXCL = 0x80 - O_FSYNC = 0x101000 - O_LARGEFILE = 0x0 - O_NDELAY = 0x800 - O_NOATIME = 0x40000 - O_NOCTTY = 0x100 - O_NOFOLLOW = 0x20000 - O_NONBLOCK = 0x800 - O_PATH = 0x200000 - O_RSYNC = 0x101000 - O_SYNC = 0x101000 - O_TMPFILE = 0x410000 - O_TRUNC = 0x200 - PARENB = 0x100 - PARODD = 0x200 - PENDIN = 0x4000 - PERF_EVENT_IOC_DISABLE = 0x2401 - PERF_EVENT_IOC_ENABLE = 0x2400 - PERF_EVENT_IOC_ID = 0x80082407 - PERF_EVENT_IOC_MODIFY_ATTRIBUTES = 0x4008240b - PERF_EVENT_IOC_PAUSE_OUTPUT = 0x40042409 - PERF_EVENT_IOC_PERIOD = 0x40082404 - PERF_EVENT_IOC_QUERY_BPF = 0xc008240a - PERF_EVENT_IOC_REFRESH = 0x2402 - PERF_EVENT_IOC_RESET = 0x2403 - PERF_EVENT_IOC_SET_BPF = 0x40042408 - PERF_EVENT_IOC_SET_FILTER = 0x40082406 - PERF_EVENT_IOC_SET_OUTPUT = 0x2405 - PPPIOCATTACH = 0x4004743d - PPPIOCATTCHAN = 0x40047438 - PPPIOCBRIDGECHAN = 0x40047435 - PPPIOCCONNECT = 0x4004743a - PPPIOCDETACH = 0x4004743c - PPPIOCDISCONN = 0x7439 - PPPIOCGASYNCMAP = 0x80047458 - PPPIOCGCHAN = 0x80047437 - PPPIOCGDEBUG = 0x80047441 - PPPIOCGFLAGS = 0x8004745a - PPPIOCGIDLE = 0x8010743f - PPPIOCGIDLE32 = 0x8008743f - PPPIOCGIDLE64 = 0x8010743f - PPPIOCGL2TPSTATS = 0x80487436 - PPPIOCGMRU = 0x80047453 - PPPIOCGRASYNCMAP = 0x80047455 - PPPIOCGUNIT = 0x80047456 - PPPIOCGXASYNCMAP = 0x80207450 - PPPIOCSACTIVE = 0x40107446 - PPPIOCSASYNCMAP = 0x40047457 - PPPIOCSCOMPRESS = 0x4010744d - PPPIOCSDEBUG = 0x40047440 - PPPIOCSFLAGS = 0x40047459 - PPPIOCSMAXCID = 0x40047451 - PPPIOCSMRRU = 0x4004743b - PPPIOCSMRU = 0x40047452 - PPPIOCSNPMODE = 0x4008744b - PPPIOCSPASS = 0x40107447 - PPPIOCSRASYNCMAP = 0x40047454 - PPPIOCSXASYNCMAP = 0x4020744f - PPPIOCUNBRIDGECHAN = 0x7434 - PPPIOCXFERUNIT = 0x744e - PR_SET_PTRACER_ANY = 0xffffffffffffffff - PTP_CLOCK_GETCAPS = 0x80503d01 - PTP_CLOCK_GETCAPS2 = 0x80503d0a - PTP_ENABLE_PPS = 0x40043d04 - PTP_ENABLE_PPS2 = 0x40043d0d - PTP_EXTTS_REQUEST = 0x40103d02 - PTP_EXTTS_REQUEST2 = 0x40103d0b - PTP_MASK_CLEAR_ALL = 0x3d13 - PTP_MASK_EN_SINGLE = 0x40043d14 - PTP_PEROUT_REQUEST = 0x40383d03 - PTP_PEROUT_REQUEST2 = 0x40383d0c - PTP_PIN_SETFUNC = 0x40603d07 - PTP_PIN_SETFUNC2 = 0x40603d10 - PTP_SYS_OFFSET = 0x43403d05 - PTP_SYS_OFFSET2 = 0x43403d0e - PTRACE_GETFDPIC = 0x21 - PTRACE_GETFDPIC_EXEC = 0x0 - PTRACE_GETFDPIC_INTERP = 0x1 - RLIMIT_AS = 0x9 - RLIMIT_MEMLOCK = 0x8 - RLIMIT_NOFILE = 0x7 - RLIMIT_NPROC = 0x6 - RLIMIT_RSS = 0x5 - RNDADDENTROPY = 0x40085203 - RNDADDTOENTCNT = 0x40045201 - RNDCLEARPOOL = 0x5206 - RNDGETENTCNT = 0x80045200 - RNDGETPOOL = 0x80085202 - RNDRESEEDCRNG = 0x5207 - RNDZAPENTCNT = 0x5204 - RTC_AIE_OFF = 0x7002 - RTC_AIE_ON = 0x7001 - RTC_ALM_READ = 0x80247008 - RTC_ALM_SET = 0x40247007 - RTC_EPOCH_READ = 0x8008700d - RTC_EPOCH_SET = 0x4008700e - RTC_IRQP_READ = 0x8008700b - RTC_IRQP_SET = 0x4008700c - RTC_PARAM_GET = 0x40187013 - RTC_PARAM_SET = 0x40187014 - RTC_PIE_OFF = 0x7006 - RTC_PIE_ON = 0x7005 - RTC_PLL_GET = 0x80207011 - RTC_PLL_SET = 0x40207012 - RTC_RD_TIME = 0x80247009 - RTC_SET_TIME = 0x4024700a - RTC_UIE_OFF = 0x7004 - RTC_UIE_ON = 0x7003 - RTC_VL_CLR = 0x7014 - RTC_VL_READ = 0x80047013 - RTC_WIE_OFF = 0x7010 - RTC_WIE_ON = 0x700f - RTC_WKALM_RD = 0x80287010 - RTC_WKALM_SET = 0x4028700f - SCM_DEVMEM_DMABUF = 0x4f - SCM_DEVMEM_LINEAR = 0x4e - SCM_TIMESTAMPING = 0x25 - SCM_TIMESTAMPING_OPT_STATS = 0x36 - SCM_TIMESTAMPING_PKTINFO = 0x3a - SCM_TIMESTAMPNS = 0x23 - SCM_TS_OPT_ID = 0x51 - SCM_TXTIME = 0x3d - SCM_WIFI_STATUS = 0x29 - SECCOMP_IOCTL_NOTIF_ADDFD = 0x40182103 - SECCOMP_IOCTL_NOTIF_ID_VALID = 0x40082102 - SECCOMP_IOCTL_NOTIF_SET_FLAGS = 0x40082104 - SFD_CLOEXEC = 0x80000 - SFD_NONBLOCK = 0x800 - SIOCATMARK = 0x8905 - SIOCGPGRP = 0x8904 - SIOCGSTAMPNS_NEW = 0x80108907 - SIOCGSTAMP_NEW = 0x80108906 - SIOCINQ = 0x541b - SIOCOUTQ = 0x5411 - SIOCSPGRP = 0x8902 - SOCK_CLOEXEC = 0x80000 - SOCK_DGRAM = 0x2 - SOCK_NONBLOCK = 0x800 - SOCK_STREAM = 0x1 - SOL_SOCKET = 0x1 - SO_ACCEPTCONN = 0x1e - SO_ATTACH_BPF = 0x32 - SO_ATTACH_REUSEPORT_CBPF = 0x33 - SO_ATTACH_REUSEPORT_EBPF = 0x34 - SO_BINDTODEVICE = 0x19 - SO_BINDTOIFINDEX = 0x3e - SO_BPF_EXTENSIONS = 0x30 - SO_BROADCAST = 0x6 - SO_BSDCOMPAT = 0xe - SO_BUF_LOCK = 0x48 - SO_BUSY_POLL = 0x2e - SO_BUSY_POLL_BUDGET = 0x46 - SO_CNX_ADVICE = 0x35 - SO_COOKIE = 0x39 - SO_DETACH_REUSEPORT_BPF = 0x44 - SO_DEVMEM_DMABUF = 0x4f - SO_DEVMEM_DONTNEED = 0x50 - SO_DEVMEM_LINEAR = 0x4e - SO_DOMAIN = 0x27 - SO_DONTROUTE = 0x5 - SO_ERROR = 0x4 - SO_INCOMING_CPU = 0x31 - SO_INCOMING_NAPI_ID = 0x38 - SO_KEEPALIVE = 0x9 - SO_LINGER = 0xd - SO_LOCK_FILTER = 0x2c - SO_MARK = 0x24 - SO_MAX_PACING_RATE = 0x2f - SO_MEMINFO = 0x37 - SO_NETNS_COOKIE = 0x47 - SO_NOFCS = 0x2b - SO_OOBINLINE = 0xa - SO_PASSCRED = 0x10 - SO_PASSPIDFD = 0x4c - SO_PASSRIGHTS = 0x53 - SO_PASSSEC = 0x22 - SO_PEEK_OFF = 0x2a - SO_PEERCRED = 0x11 - SO_PEERGROUPS = 0x3b - SO_PEERPIDFD = 0x4d - SO_PEERSEC = 0x1f - SO_PREFER_BUSY_POLL = 0x45 - SO_PROTOCOL = 0x26 - SO_RCVBUF = 0x8 - SO_RCVBUFFORCE = 0x21 - SO_RCVLOWAT = 0x12 - SO_RCVMARK = 0x4b - SO_RCVPRIORITY = 0x52 - SO_RCVTIMEO = 0x14 - SO_RCVTIMEO_NEW = 0x42 - SO_RCVTIMEO_OLD = 0x14 - SO_RESERVE_MEM = 0x49 - SO_REUSEADDR = 0x2 - SO_REUSEPORT = 0xf - SO_RXQ_OVFL = 0x28 - SO_SECURITY_AUTHENTICATION = 0x16 - SO_SECURITY_ENCRYPTION_NETWORK = 0x18 - SO_SECURITY_ENCRYPTION_TRANSPORT = 0x17 - SO_SELECT_ERR_QUEUE = 0x2d - SO_SNDBUF = 0x7 - SO_SNDBUFFORCE = 0x20 - SO_SNDLOWAT = 0x13 - SO_SNDTIMEO = 0x15 - SO_SNDTIMEO_NEW = 0x43 - SO_SNDTIMEO_OLD = 0x15 - SO_TIMESTAMPING = 0x25 - SO_TIMESTAMPING_NEW = 0x41 - SO_TIMESTAMPING_OLD = 0x25 - SO_TIMESTAMPNS = 0x23 - SO_TIMESTAMPNS_NEW = 0x40 - SO_TIMESTAMPNS_OLD = 0x23 - SO_TIMESTAMP_NEW = 0x3f - SO_TXREHASH = 0x4a - SO_TXTIME = 0x3d - SO_TYPE = 0x3 - SO_WIFI_STATUS = 0x29 - SO_ZEROCOPY = 0x3c - TAB1 = 0x800 - TAB2 = 0x1000 - TAB3 = 0x1800 - TABDLY = 0x1800 - TCFLSH = 0x540b - TCGETA = 0x5405 - TCGETS = 0x5401 - TCGETS2 = 0x802c542a - TCGETX = 0x5432 - TCSAFLUSH = 0x2 - TCSBRK = 0x5409 - TCSBRKP = 0x5425 - TCSETA = 0x5406 - TCSETAF = 0x5408 - TCSETAW = 0x5407 - TCSETS = 0x5402 - TCSETS2 = 0x402c542b - TCSETSF = 0x5404 - TCSETSF2 = 0x402c542d - TCSETSW = 0x5403 - TCSETSW2 = 0x402c542c - TCSETX = 0x5433 - TCSETXF = 0x5434 - TCSETXW = 0x5435 - TCXONC = 0x540a - TFD_CLOEXEC = 0x80000 - TFD_NONBLOCK = 0x800 - TIOCCBRK = 0x5428 - TIOCCONS = 0x541d - TIOCEXCL = 0x540c - TIOCGDEV = 0x80045432 - TIOCGETD = 0x5424 - TIOCGEXCL = 0x80045440 - TIOCGICOUNT = 0x545d - TIOCGISO7816 = 0x80285442 - TIOCGLCKTRMIOS = 0x5456 - TIOCGPGRP = 0x540f - TIOCGPKT = 0x80045438 - TIOCGPTLCK = 0x80045439 - TIOCGPTN = 0x80045430 - TIOCGPTPEER = 0x5441 - TIOCGRS485 = 0x542e - TIOCGSERIAL = 0x541e - TIOCGSID = 0x5429 - TIOCGSOFTCAR = 0x5419 - TIOCGWINSZ = 0x5413 - TIOCINQ = 0x541b - TIOCLINUX = 0x541c - TIOCMBIC = 0x5417 - TIOCMBIS = 0x5416 - TIOCMGET = 0x5415 - TIOCMIWAIT = 0x545c - TIOCMSET = 0x5418 - TIOCM_CAR = 0x40 - TIOCM_CD = 0x40 - TIOCM_CTS = 0x20 - TIOCM_DSR = 0x100 - TIOCM_RI = 0x80 - TIOCM_RNG = 0x80 - TIOCM_SR = 0x10 - TIOCM_ST = 0x8 - TIOCNOTTY = 0x5422 - TIOCNXCL = 0x540d - TIOCOUTQ = 0x5411 - TIOCPKT = 0x5420 - TIOCSBRK = 0x5427 - TIOCSCTTY = 0x540e - TIOCSERCONFIG = 0x5453 - TIOCSERGETLSR = 0x5459 - TIOCSERGETMULTI = 0x545a - TIOCSERGSTRUCT = 0x5458 - TIOCSERGWILD = 0x5454 - TIOCSERSETMULTI = 0x545b - TIOCSERSWILD = 0x5455 - TIOCSER_TEMT = 0x1 - TIOCSETD = 0x5423 - TIOCSIG = 0x40045436 - TIOCSISO7816 = 0xc0285443 - TIOCSLCKTRMIOS = 0x5457 - TIOCSPGRP = 0x5410 - TIOCSPTLCK = 0x40045431 - TIOCSRS485 = 0x542f - TIOCSSERIAL = 0x541f - TIOCSSOFTCAR = 0x541a - TIOCSTI = 0x5412 - TIOCSWINSZ = 0x5414 - TIOCVHANGUP = 0x5437 - TOSTOP = 0x100 - TUNATTACHFILTER = 0x401054d5 - TUNDETACHFILTER = 0x401054d6 - TUNGETDEVNETNS = 0x54e3 - TUNGETFEATURES = 0x800454cf - TUNGETFILTER = 0x801054db - TUNGETIFF = 0x800454d2 - TUNGETSNDBUF = 0x800454d3 - TUNGETVNETBE = 0x800454df - TUNGETVNETHDRSZ = 0x800454d7 - TUNGETVNETLE = 0x800454dd - TUNSETCARRIER = 0x400454e2 - TUNSETDEBUG = 0x400454c9 - TUNSETFILTEREBPF = 0x800454e1 - TUNSETGROUP = 0x400454ce - TUNSETIFF = 0x400454ca - TUNSETIFINDEX = 0x400454da - TUNSETLINK = 0x400454cd - TUNSETNOCSUM = 0x400454c8 - TUNSETOFFLOAD = 0x400454d0 - TUNSETOWNER = 0x400454cc - TUNSETPERSIST = 0x400454cb - TUNSETQUEUE = 0x400454d9 - TUNSETSNDBUF = 0x400454d4 - TUNSETSTEERINGEBPF = 0x800454e0 - TUNSETTXFILTER = 0x400454d1 - TUNSETVNETBE = 0x400454de - TUNSETVNETHDRSZ = 0x400454d8 - TUNSETVNETLE = 0x400454dc - UBI_IOCATT = 0x40186f40 - UBI_IOCDET = 0x40046f41 - UBI_IOCEBCH = 0x40044f02 - UBI_IOCEBER = 0x40044f01 - UBI_IOCEBISMAP = 0x80044f05 - UBI_IOCEBMAP = 0x40084f03 - UBI_IOCEBUNMAP = 0x40044f04 - UBI_IOCMKVOL = 0x40986f00 - UBI_IOCRMVOL = 0x40046f01 - UBI_IOCRNVOL = 0x51106f03 - UBI_IOCRPEB = 0x40046f04 - UBI_IOCRSVOL = 0x400c6f02 - UBI_IOCSETVOLPROP = 0x40104f06 - UBI_IOCSPEB = 0x40046f05 - UBI_IOCVOLCRBLK = 0x40804f07 - UBI_IOCVOLRMBLK = 0x4f08 - UBI_IOCVOLUP = 0x40084f00 - VDISCARD = 0xd - VEOF = 0x4 - VEOL = 0xb - VEOL2 = 0x10 - VMIN = 0x6 - VREPRINT = 0xc - VSTART = 0x8 - VSTOP = 0x9 - VSUSP = 0xa - VSWTC = 0x7 - VT1 = 0x4000 - VTDLY = 0x4000 - VTIME = 0x5 - VWERASE = 0xe - WDIOC_GETBOOTSTATUS = 0x80045702 - WDIOC_GETPRETIMEOUT = 0x80045709 - WDIOC_GETSTATUS = 0x80045701 - WDIOC_GETSUPPORT = 0x80285700 - WDIOC_GETTEMP = 0x80045703 - WDIOC_GETTIMELEFT = 0x8004570a - WDIOC_GETTIMEOUT = 0x80045707 - WDIOC_KEEPALIVE = 0x80045705 - WDIOC_SETOPTIONS = 0x80045704 - WORDSIZE = 0x40 - XCASE = 0x4 - XTABS = 0x1800 - _HIDIOCGRAWNAME = 0x80804804 - _HIDIOCGRAWPHYS = 0x80404805 - _HIDIOCGRAWUNIQ = 0x80404808 + B1000000 = 0x1008 + B115200 = 0x1002 + B1152000 = 0x1009 + B1500000 = 0x100a + B2000000 = 0x100b + B230400 = 0x1003 + B2500000 = 0x100c + B3000000 = 0x100d + B3500000 = 0x100e + B4000000 = 0x100f + B460800 = 0x1004 + B500000 = 0x1005 + B57600 = 0x1001 + B576000 = 0x1006 + B921600 = 0x1007 + BLKALIGNOFF = 0x127a + BLKBSZGET = 0x80081270 + BLKBSZSET = 0x40081271 + BLKDISCARD = 0x1277 + BLKDISCARDZEROES = 0x127c + BLKFLSBUF = 0x1261 + BLKFRAGET = 0x1265 + BLKFRASET = 0x1264 + BLKGETDISKSEQ = 0x80081280 + BLKGETSIZE = 0x1260 + BLKGETSIZE64 = 0x80081272 + BLKIOMIN = 0x1278 + BLKIOOPT = 0x1279 + BLKPBSZGET = 0x127b + BLKRAGET = 0x1263 + BLKRASET = 0x1262 + BLKROGET = 0x125e + BLKROSET = 0x125d + BLKROTATIONAL = 0x127e + BLKRRPART = 0x125f + BLKSECDISCARD = 0x127d + BLKSECTGET = 0x1267 + BLKSECTSET = 0x1266 + BLKSSZGET = 0x1268 + BLKZEROOUT = 0x127f + BOTHER = 0x1000 + BS1 = 0x2000 + BSDLY = 0x2000 + CBAUD = 0x100f + CBAUDEX = 0x1000 + CIBAUD = 0x100f0000 + CLOCAL = 0x800 + CR1 = 0x200 + CR2 = 0x400 + CR3 = 0x600 + CRDLY = 0x600 + CREAD = 0x80 + CS6 = 0x10 + CS7 = 0x20 + CS8 = 0x30 + CSIZE = 0x30 + CSTOPB = 0x40 + DM_MPATH_PROBE_PATHS = 0xfd12 + ECCGETLAYOUT = 0x81484d11 + ECCGETSTATS = 0x80104d12 + ECHOCTL = 0x200 + ECHOE = 0x10 + ECHOK = 0x20 + ECHOKE = 0x800 + ECHONL = 0x40 + ECHOPRT = 0x400 + EFD_CLOEXEC = 0x80000 + EFD_NONBLOCK = 0x800 + EPIOCGPARAMS = 0x80088a02 + EPIOCSPARAMS = 0x40088a01 + EPOLL_CLOEXEC = 0x80000 + EXTPROC = 0x10000 + FF1 = 0x8000 + FFDLY = 0x8000 + FICLONE = 0x40049409 + FICLONERANGE = 0x4020940d + FLUSHO = 0x1000 + FS_IOC_ENABLE_VERITY = 0x40806685 + FS_IOC_GETFLAGS = 0x80086601 + FS_IOC_GET_ENCRYPTION_NONCE = 0x8010661b + FS_IOC_GET_ENCRYPTION_POLICY = 0x400c6615 + FS_IOC_GET_ENCRYPTION_PWSALT = 0x40106614 + FS_IOC_SETFLAGS = 0x40086602 + FS_IOC_SET_ENCRYPTION_POLICY = 0x800c6613 + F_GETLK = 0x5 + F_GETLK64 = 0x5 + F_GETOWN = 0x9 + F_RDLCK = 0x0 + F_SETLK = 0x6 + F_SETLK64 = 0x6 + F_SETLKW = 0x7 + F_SETLKW64 = 0x7 + F_SETOWN = 0x8 + F_UNLCK = 0x2 + F_WRLCK = 0x1 + HIDIOCGRAWINFO = 0x80084803 + HIDIOCGRDESC = 0x90044802 + HIDIOCGRDESCSIZE = 0x80044801 + HIDIOCREVOKE = 0x4004480d + HUPCL = 0x400 + ICANON = 0x2 + IEXTEN = 0x8000 + IN_CLOEXEC = 0x80000 + IN_NONBLOCK = 0x800 + IOCTL_MEI_NOTIFY_GET = 0x80044803 + IOCTL_MEI_NOTIFY_SET = 0x40044802 + IOCTL_VM_SOCKETS_GET_LOCAL_CID = 0x7b9 + IPV6_FLOWINFO_MASK = 0xffffff0f + IPV6_FLOWLABEL_MASK = 0xffff0f00 + ISIG = 0x1 + IUCLC = 0x200 + IXOFF = 0x1000 + IXON = 0x400 + MAP_ANON = 0x20 + MAP_ANONYMOUS = 0x20 + MAP_DENYWRITE = 0x800 + MAP_EXECUTABLE = 0x1000 + MAP_GROWSDOWN = 0x100 + MAP_HUGETLB = 0x40000 + MAP_LOCKED = 0x2000 + MAP_NONBLOCK = 0x10000 + MAP_NORESERVE = 0x4000 + MAP_POPULATE = 0x8000 + MAP_STACK = 0x20000 + MAP_SYNC = 0x80000 + MCL_CURRENT = 0x1 + MCL_FUTURE = 0x2 + MCL_ONFAULT = 0x4 + MEMERASE = 0x40084d02 + MEMERASE64 = 0x40104d14 + MEMGETBADBLOCK = 0x40084d0b + MEMGETINFO = 0x80204d01 + MEMGETOOBSEL = 0x80c84d0a + MEMGETREGIONCOUNT = 0x80044d07 + MEMISLOCKED = 0x80084d17 + MEMLOCK = 0x40084d05 + MEMREAD = 0xc0404d1a + MEMREADOOB = 0xc0104d04 + MEMSETBADBLOCK = 0x40084d0c + MEMUNLOCK = 0x40084d06 + MEMWRITEOOB = 0xc0104d03 + MTDFILEMODE = 0x4d13 + NFDBITS = 0x40 + NLDLY = 0x100 + NOFLSH = 0x80 + NS_GET_ID = 0x8008b70d + NS_GET_MNTNS_ID = 0x8008b705 + NS_GET_NSTYPE = 0xb703 + NS_GET_OWNER_UID = 0xb704 + NS_GET_PARENT = 0xb702 + NS_GET_PID_FROM_PIDNS = 0x8004b706 + NS_GET_PID_IN_PIDNS = 0x8004b708 + NS_GET_TGID_FROM_PIDNS = 0x8004b707 + NS_GET_TGID_IN_PIDNS = 0x8004b709 + NS_GET_USERNS = 0xb701 + OLCUC = 0x2 + ONLCR = 0x4 + OTPERASE = 0x400c4d19 + OTPGETREGIONCOUNT = 0x40044d0e + OTPGETREGIONINFO = 0x400c4d0f + OTPLOCK = 0x800c4d10 + OTPSELECT = 0x80044d0d + O_APPEND = 0x400 + O_ASYNC = 0x2000 + O_CLOEXEC = 0x80000 + O_CREAT = 0x40 + O_DIRECT = 0x4000 + O_DIRECTORY = 0x10000 + O_DSYNC = 0x1000 + O_EXCL = 0x80 + O_FSYNC = 0x101000 + O_LARGEFILE = 0x0 + O_NDELAY = 0x800 + O_NOATIME = 0x40000 + O_NOCTTY = 0x100 + O_NOFOLLOW = 0x20000 + O_NONBLOCK = 0x800 + O_PATH = 0x200000 + O_RSYNC = 0x101000 + O_SYNC = 0x101000 + O_TMPFILE = 0x410000 + O_TRUNC = 0x200 + PARENB = 0x100 + PARODD = 0x200 + PENDIN = 0x4000 + PERF_EVENT_IOC_DISABLE = 0x2401 + PERF_EVENT_IOC_ENABLE = 0x2400 + PERF_EVENT_IOC_ID = 0x80082407 + PERF_EVENT_IOC_MODIFY_ATTRIBUTES = 0x4008240b + PERF_EVENT_IOC_PAUSE_OUTPUT = 0x40042409 + PERF_EVENT_IOC_PERIOD = 0x40082404 + PERF_EVENT_IOC_QUERY_BPF = 0xc008240a + PERF_EVENT_IOC_REFRESH = 0x2402 + PERF_EVENT_IOC_RESET = 0x2403 + PERF_EVENT_IOC_SET_BPF = 0x40042408 + PERF_EVENT_IOC_SET_FILTER = 0x40082406 + PERF_EVENT_IOC_SET_OUTPUT = 0x2405 + PPPIOCATTACH = 0x4004743d + PPPIOCATTCHAN = 0x40047438 + PPPIOCBRIDGECHAN = 0x40047435 + PPPIOCCONNECT = 0x4004743a + PPPIOCDETACH = 0x4004743c + PPPIOCDISCONN = 0x7439 + PPPIOCGASYNCMAP = 0x80047458 + PPPIOCGCHAN = 0x80047437 + PPPIOCGDEBUG = 0x80047441 + PPPIOCGFLAGS = 0x8004745a + PPPIOCGIDLE = 0x8010743f + PPPIOCGIDLE32 = 0x8008743f + PPPIOCGIDLE64 = 0x8010743f + PPPIOCGL2TPSTATS = 0x80487436 + PPPIOCGMRU = 0x80047453 + PPPIOCGRASYNCMAP = 0x80047455 + PPPIOCGUNIT = 0x80047456 + PPPIOCGXASYNCMAP = 0x80207450 + PPPIOCSACTIVE = 0x40107446 + PPPIOCSASYNCMAP = 0x40047457 + PPPIOCSCOMPRESS = 0x4010744d + PPPIOCSDEBUG = 0x40047440 + PPPIOCSFLAGS = 0x40047459 + PPPIOCSMAXCID = 0x40047451 + PPPIOCSMRRU = 0x4004743b + PPPIOCSMRU = 0x40047452 + PPPIOCSNPMODE = 0x4008744b + PPPIOCSPASS = 0x40107447 + PPPIOCSRASYNCMAP = 0x40047454 + PPPIOCSXASYNCMAP = 0x4020744f + PPPIOCUNBRIDGECHAN = 0x7434 + PPPIOCXFERUNIT = 0x744e + PR_SET_PTRACER_ANY = 0xffffffffffffffff + PTP_CLOCK_GETCAPS = 0x80503d01 + PTP_CLOCK_GETCAPS2 = 0x80503d0a + PTP_ENABLE_PPS = 0x40043d04 + PTP_ENABLE_PPS2 = 0x40043d0d + PTP_EXTTS_REQUEST = 0x40103d02 + PTP_EXTTS_REQUEST2 = 0x40103d0b + PTP_MASK_CLEAR_ALL = 0x3d13 + PTP_MASK_EN_SINGLE = 0x40043d14 + PTP_PEROUT_REQUEST = 0x40383d03 + PTP_PEROUT_REQUEST2 = 0x40383d0c + PTP_PIN_SETFUNC = 0x40603d07 + PTP_PIN_SETFUNC2 = 0x40603d10 + PTP_SYS_OFFSET = 0x43403d05 + PTP_SYS_OFFSET2 = 0x43403d0e + PTRACE_CFI_BRANCH_EXPECTED_LANDING_PAD_BIT = 0x2 + PTRACE_CFI_BRANCH_EXPECTED_LANDING_PAD_STATE = 0x4 + PTRACE_CFI_BRANCH_LANDING_PAD_EN_BIT = 0x0 + PTRACE_CFI_BRANCH_LANDING_PAD_EN_STATE = 0x1 + PTRACE_CFI_BRANCH_LANDING_PAD_LOCK_BIT = 0x1 + PTRACE_CFI_BRANCH_LANDING_PAD_LOCK_STATE = 0x2 + PTRACE_CFI_SHADOW_STACK_EN_BIT = 0x3 + PTRACE_CFI_SHADOW_STACK_EN_STATE = 0x8 + PTRACE_CFI_SHADOW_STACK_LOCK_BIT = 0x4 + PTRACE_CFI_SHADOW_STACK_LOCK_STATE = 0x10 + PTRACE_CFI_SHADOW_STACK_PTR_BIT = 0x5 + PTRACE_CFI_SHADOW_STACK_PTR_STATE = 0x20 + PTRACE_CFI_STATE_INVALID_MASK = 0xffffffffffffffc0 + PTRACE_GETFDPIC = 0x21 + PTRACE_GETFDPIC_EXEC = 0x0 + PTRACE_GETFDPIC_INTERP = 0x1 + RLIMIT_AS = 0x9 + RLIMIT_MEMLOCK = 0x8 + RLIMIT_NOFILE = 0x7 + RLIMIT_NPROC = 0x6 + RLIMIT_RSS = 0x5 + RNDADDENTROPY = 0x40085203 + RNDADDTOENTCNT = 0x40045201 + RNDCLEARPOOL = 0x5206 + RNDGETENTCNT = 0x80045200 + RNDGETPOOL = 0x80085202 + RNDRESEEDCRNG = 0x5207 + RNDZAPENTCNT = 0x5204 + RTC_AIE_OFF = 0x7002 + RTC_AIE_ON = 0x7001 + RTC_ALM_READ = 0x80247008 + RTC_ALM_SET = 0x40247007 + RTC_EPOCH_READ = 0x8008700d + RTC_EPOCH_SET = 0x4008700e + RTC_IRQP_READ = 0x8008700b + RTC_IRQP_SET = 0x4008700c + RTC_PARAM_GET = 0x40187013 + RTC_PARAM_SET = 0x40187014 + RTC_PIE_OFF = 0x7006 + RTC_PIE_ON = 0x7005 + RTC_PLL_GET = 0x80207011 + RTC_PLL_SET = 0x40207012 + RTC_RD_TIME = 0x80247009 + RTC_SET_TIME = 0x4024700a + RTC_UIE_OFF = 0x7004 + RTC_UIE_ON = 0x7003 + RTC_VL_CLR = 0x7014 + RTC_VL_READ = 0x80047013 + RTC_WIE_OFF = 0x7010 + RTC_WIE_ON = 0x700f + RTC_WKALM_RD = 0x80287010 + RTC_WKALM_SET = 0x4028700f + SCM_DEVMEM_DMABUF = 0x4f + SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 + SCM_TIMESTAMPING = 0x25 + SCM_TIMESTAMPING_OPT_STATS = 0x36 + SCM_TIMESTAMPING_PKTINFO = 0x3a + SCM_TIMESTAMPNS = 0x23 + SCM_TS_OPT_ID = 0x51 + SCM_TXTIME = 0x3d + SCM_WIFI_STATUS = 0x29 + SECCOMP_IOCTL_NOTIF_ADDFD = 0x40182103 + SECCOMP_IOCTL_NOTIF_ID_VALID = 0x40082102 + SECCOMP_IOCTL_NOTIF_SET_FLAGS = 0x40082104 + SFD_CLOEXEC = 0x80000 + SFD_NONBLOCK = 0x800 + SIOCATMARK = 0x8905 + SIOCGPGRP = 0x8904 + SIOCGSTAMPNS_NEW = 0x80108907 + SIOCGSTAMP_NEW = 0x80108906 + SIOCINQ = 0x541b + SIOCOUTQ = 0x5411 + SIOCSPGRP = 0x8902 + SOCK_CLOEXEC = 0x80000 + SOCK_DGRAM = 0x2 + SOCK_NONBLOCK = 0x800 + SOCK_STREAM = 0x1 + SOL_SOCKET = 0x1 + SO_ACCEPTCONN = 0x1e + SO_ATTACH_BPF = 0x32 + SO_ATTACH_REUSEPORT_CBPF = 0x33 + SO_ATTACH_REUSEPORT_EBPF = 0x34 + SO_BINDTODEVICE = 0x19 + SO_BINDTOIFINDEX = 0x3e + SO_BPF_EXTENSIONS = 0x30 + SO_BROADCAST = 0x6 + SO_BSDCOMPAT = 0xe + SO_BUF_LOCK = 0x48 + SO_BUSY_POLL = 0x2e + SO_BUSY_POLL_BUDGET = 0x46 + SO_CNX_ADVICE = 0x35 + SO_COOKIE = 0x39 + SO_DETACH_REUSEPORT_BPF = 0x44 + SO_DEVMEM_DMABUF = 0x4f + SO_DEVMEM_DONTNEED = 0x50 + SO_DEVMEM_LINEAR = 0x4e + SO_DOMAIN = 0x27 + SO_DONTROUTE = 0x5 + SO_ERROR = 0x4 + SO_INCOMING_CPU = 0x31 + SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 + SO_KEEPALIVE = 0x9 + SO_LINGER = 0xd + SO_LOCK_FILTER = 0x2c + SO_MARK = 0x24 + SO_MAX_PACING_RATE = 0x2f + SO_MEMINFO = 0x37 + SO_NETNS_COOKIE = 0x47 + SO_NOFCS = 0x2b + SO_OOBINLINE = 0xa + SO_PASSCRED = 0x10 + SO_PASSPIDFD = 0x4c + SO_PASSRIGHTS = 0x53 + SO_PASSSEC = 0x22 + SO_PEEK_OFF = 0x2a + SO_PEERCRED = 0x11 + SO_PEERGROUPS = 0x3b + SO_PEERPIDFD = 0x4d + SO_PEERSEC = 0x1f + SO_PREFER_BUSY_POLL = 0x45 + SO_PROTOCOL = 0x26 + SO_RCVBUF = 0x8 + SO_RCVBUFFORCE = 0x21 + SO_RCVLOWAT = 0x12 + SO_RCVMARK = 0x4b + SO_RCVPRIORITY = 0x52 + SO_RCVTIMEO = 0x14 + SO_RCVTIMEO_NEW = 0x42 + SO_RCVTIMEO_OLD = 0x14 + SO_RESERVE_MEM = 0x49 + SO_REUSEADDR = 0x2 + SO_REUSEPORT = 0xf + SO_RXQ_OVFL = 0x28 + SO_SECURITY_AUTHENTICATION = 0x16 + SO_SECURITY_ENCRYPTION_NETWORK = 0x18 + SO_SECURITY_ENCRYPTION_TRANSPORT = 0x17 + SO_SELECT_ERR_QUEUE = 0x2d + SO_SNDBUF = 0x7 + SO_SNDBUFFORCE = 0x20 + SO_SNDLOWAT = 0x13 + SO_SNDTIMEO = 0x15 + SO_SNDTIMEO_NEW = 0x43 + SO_SNDTIMEO_OLD = 0x15 + SO_TIMESTAMPING = 0x25 + SO_TIMESTAMPING_NEW = 0x41 + SO_TIMESTAMPING_OLD = 0x25 + SO_TIMESTAMPNS = 0x23 + SO_TIMESTAMPNS_NEW = 0x40 + SO_TIMESTAMPNS_OLD = 0x23 + SO_TIMESTAMP_NEW = 0x3f + SO_TXREHASH = 0x4a + SO_TXTIME = 0x3d + SO_TYPE = 0x3 + SO_WIFI_STATUS = 0x29 + SO_ZEROCOPY = 0x3c + TAB1 = 0x800 + TAB2 = 0x1000 + TAB3 = 0x1800 + TABDLY = 0x1800 + TCFLSH = 0x540b + TCGETA = 0x5405 + TCGETS = 0x5401 + TCGETS2 = 0x802c542a + TCGETX = 0x5432 + TCSAFLUSH = 0x2 + TCSBRK = 0x5409 + TCSBRKP = 0x5425 + TCSETA = 0x5406 + TCSETAF = 0x5408 + TCSETAW = 0x5407 + TCSETS = 0x5402 + TCSETS2 = 0x402c542b + TCSETSF = 0x5404 + TCSETSF2 = 0x402c542d + TCSETSW = 0x5403 + TCSETSW2 = 0x402c542c + TCSETX = 0x5433 + TCSETXF = 0x5434 + TCSETXW = 0x5435 + TCXONC = 0x540a + TFD_CLOEXEC = 0x80000 + TFD_NONBLOCK = 0x800 + TIOCCBRK = 0x5428 + TIOCCONS = 0x541d + TIOCEXCL = 0x540c + TIOCGDEV = 0x80045432 + TIOCGETD = 0x5424 + TIOCGEXCL = 0x80045440 + TIOCGICOUNT = 0x545d + TIOCGISO7816 = 0x80285442 + TIOCGLCKTRMIOS = 0x5456 + TIOCGPGRP = 0x540f + TIOCGPKT = 0x80045438 + TIOCGPTLCK = 0x80045439 + TIOCGPTN = 0x80045430 + TIOCGPTPEER = 0x5441 + TIOCGRS485 = 0x542e + TIOCGSERIAL = 0x541e + TIOCGSID = 0x5429 + TIOCGSOFTCAR = 0x5419 + TIOCGWINSZ = 0x5413 + TIOCINQ = 0x541b + TIOCLINUX = 0x541c + TIOCMBIC = 0x5417 + TIOCMBIS = 0x5416 + TIOCMGET = 0x5415 + TIOCMIWAIT = 0x545c + TIOCMSET = 0x5418 + TIOCM_CAR = 0x40 + TIOCM_CD = 0x40 + TIOCM_CTS = 0x20 + TIOCM_DSR = 0x100 + TIOCM_RI = 0x80 + TIOCM_RNG = 0x80 + TIOCM_SR = 0x10 + TIOCM_ST = 0x8 + TIOCNOTTY = 0x5422 + TIOCNXCL = 0x540d + TIOCOUTQ = 0x5411 + TIOCPKT = 0x5420 + TIOCSBRK = 0x5427 + TIOCSCTTY = 0x540e + TIOCSERCONFIG = 0x5453 + TIOCSERGETLSR = 0x5459 + TIOCSERGETMULTI = 0x545a + TIOCSERGSTRUCT = 0x5458 + TIOCSERGWILD = 0x5454 + TIOCSERSETMULTI = 0x545b + TIOCSERSWILD = 0x5455 + TIOCSER_TEMT = 0x1 + TIOCSETD = 0x5423 + TIOCSIG = 0x40045436 + TIOCSISO7816 = 0xc0285443 + TIOCSLCKTRMIOS = 0x5457 + TIOCSPGRP = 0x5410 + TIOCSPTLCK = 0x40045431 + TIOCSRS485 = 0x542f + TIOCSSERIAL = 0x541f + TIOCSSOFTCAR = 0x541a + TIOCSTI = 0x5412 + TIOCSWINSZ = 0x5414 + TIOCVHANGUP = 0x5437 + TOSTOP = 0x100 + TUNATTACHFILTER = 0x401054d5 + TUNDETACHFILTER = 0x401054d6 + TUNGETDEVNETNS = 0x54e3 + TUNGETFEATURES = 0x800454cf + TUNGETFILTER = 0x801054db + TUNGETIFF = 0x800454d2 + TUNGETSNDBUF = 0x800454d3 + TUNGETVNETBE = 0x800454df + TUNGETVNETHDRSZ = 0x800454d7 + TUNGETVNETLE = 0x800454dd + TUNSETCARRIER = 0x400454e2 + TUNSETDEBUG = 0x400454c9 + TUNSETFILTEREBPF = 0x800454e1 + TUNSETGROUP = 0x400454ce + TUNSETIFF = 0x400454ca + TUNSETIFINDEX = 0x400454da + TUNSETLINK = 0x400454cd + TUNSETNOCSUM = 0x400454c8 + TUNSETOFFLOAD = 0x400454d0 + TUNSETOWNER = 0x400454cc + TUNSETPERSIST = 0x400454cb + TUNSETQUEUE = 0x400454d9 + TUNSETSNDBUF = 0x400454d4 + TUNSETSTEERINGEBPF = 0x800454e0 + TUNSETTXFILTER = 0x400454d1 + TUNSETVNETBE = 0x400454de + TUNSETVNETHDRSZ = 0x400454d8 + TUNSETVNETLE = 0x400454dc + UBI_IOCATT = 0x40186f40 + UBI_IOCDET = 0x40046f41 + UBI_IOCEBCH = 0x40044f02 + UBI_IOCEBER = 0x40044f01 + UBI_IOCEBISMAP = 0x80044f05 + UBI_IOCEBMAP = 0x40084f03 + UBI_IOCEBUNMAP = 0x40044f04 + UBI_IOCMKVOL = 0x40986f00 + UBI_IOCRMVOL = 0x40046f01 + UBI_IOCRNVOL = 0x51106f03 + UBI_IOCRPEB = 0x40046f04 + UBI_IOCRSVOL = 0x400c6f02 + UBI_IOCSETVOLPROP = 0x40104f06 + UBI_IOCSPEB = 0x40046f05 + UBI_IOCVOLCRBLK = 0x40804f07 + UBI_IOCVOLRMBLK = 0x4f08 + UBI_IOCVOLUP = 0x40084f00 + VDISCARD = 0xd + VEOF = 0x4 + VEOL = 0xb + VEOL2 = 0x10 + VMIN = 0x6 + VREPRINT = 0xc + VSTART = 0x8 + VSTOP = 0x9 + VSUSP = 0xa + VSWTC = 0x7 + VT1 = 0x4000 + VTDLY = 0x4000 + VTIME = 0x5 + VWERASE = 0xe + WDIOC_GETBOOTSTATUS = 0x80045702 + WDIOC_GETPRETIMEOUT = 0x80045709 + WDIOC_GETSTATUS = 0x80045701 + WDIOC_GETSUPPORT = 0x80285700 + WDIOC_GETTEMP = 0x80045703 + WDIOC_GETTIMELEFT = 0x8004570a + WDIOC_GETTIMEOUT = 0x80045707 + WDIOC_KEEPALIVE = 0x80045705 + WDIOC_SETOPTIONS = 0x80045704 + WORDSIZE = 0x40 + XCASE = 0x4 + XTABS = 0x1800 + _HIDIOCGRAWNAME = 0x80804804 + _HIDIOCGRAWPHYS = 0x80404805 + _HIDIOCGRAWUNIQ = 0x80404808 ) // Errors @@ -585,6 +601,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x59) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x7a) + EFSBADCRC = syscall.Errno(0x4a) + EFSCORRUPTED = syscall.Errno(0x75) EHOSTDOWN = syscall.Errno(0x70) EHOSTUNREACH = syscall.Errno(0x71) EHWPOISON = syscall.Errno(0x85) @@ -808,7 +826,7 @@ var errorList = [...]struct { {114, "EALREADY", "operation already in progress"}, {115, "EINPROGRESS", "operation now in progress"}, {116, "ESTALE", "stale file handle"}, - {117, "EUCLEAN", "structure needs cleaning"}, + {117, "EFSCORRUPTED", "structure needs cleaning"}, {118, "ENOTNAM", "not a XENIX named type file"}, {119, "ENAVAIL", "no XENIX semaphores available"}, {120, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_s390x.go b/vendor/golang.org/x/sys/unix/zerrors_linux_s390x.go index 64347eb35..6e87bd659 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_s390x.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_s390x.go @@ -156,6 +156,7 @@ const ( NFDBITS = 0x40 NLDLY = 0x100 NOFLSH = 0x80 + NS_GET_ID = 0x8008b70d NS_GET_MNTNS_ID = 0x8008b705 NS_GET_NSTYPE = 0xb703 NS_GET_OWNER_UID = 0xb704 @@ -367,6 +368,7 @@ const ( RTC_WKALM_SET = 0x4028700f SCM_DEVMEM_DMABUF = 0x4f SCM_DEVMEM_LINEAR = 0x4e + SCM_INQ = 0x54 SCM_TIMESTAMPING = 0x25 SCM_TIMESTAMPING_OPT_STATS = 0x36 SCM_TIMESTAMPING_PKTINFO = 0x3a @@ -414,6 +416,7 @@ const ( SO_ERROR = 0x4 SO_INCOMING_CPU = 0x31 SO_INCOMING_NAPI_ID = 0x38 + SO_INQ = 0x54 SO_KEEPALIVE = 0x9 SO_LINGER = 0xd SO_LOCK_FILTER = 0x2c @@ -657,6 +660,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x59) EDOTDOT = syscall.Errno(0x49) EDQUOT = syscall.Errno(0x7a) + EFSBADCRC = syscall.Errno(0x4a) + EFSCORRUPTED = syscall.Errno(0x75) EHOSTDOWN = syscall.Errno(0x70) EHOSTUNREACH = syscall.Errno(0x71) EHWPOISON = syscall.Errno(0x85) @@ -880,7 +885,7 @@ var errorList = [...]struct { {114, "EALREADY", "operation already in progress"}, {115, "EINPROGRESS", "operation now in progress"}, {116, "ESTALE", "stale file handle"}, - {117, "EUCLEAN", "structure needs cleaning"}, + {117, "EFSCORRUPTED", "structure needs cleaning"}, {118, "ENOTNAM", "not a XENIX named type file"}, {119, "ENAVAIL", "no XENIX semaphores available"}, {120, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux_sparc64.go b/vendor/golang.org/x/sys/unix/zerrors_linux_sparc64.go index 7d7191171..7e2b2e8a6 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux_sparc64.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux_sparc64.go @@ -161,6 +161,7 @@ const ( NFDBITS = 0x40 NLDLY = 0x100 NOFLSH = 0x80 + NS_GET_ID = 0x4008b70d NS_GET_MNTNS_ID = 0x4008b705 NS_GET_NSTYPE = 0x2000b703 NS_GET_OWNER_UID = 0x2000b704 @@ -358,6 +359,7 @@ const ( RTC_WKALM_SET = 0x8028700f SCM_DEVMEM_DMABUF = 0x58 SCM_DEVMEM_LINEAR = 0x57 + SCM_INQ = 0x5d SCM_TIMESTAMPING = 0x23 SCM_TIMESTAMPING_OPT_STATS = 0x38 SCM_TIMESTAMPING_PKTINFO = 0x3c @@ -453,6 +455,7 @@ const ( SO_ERROR = 0x1007 SO_INCOMING_CPU = 0x33 SO_INCOMING_NAPI_ID = 0x3a + SO_INQ = 0x5d SO_KEEPALIVE = 0x8 SO_LINGER = 0x80 SO_LOCK_FILTER = 0x28 @@ -694,6 +697,8 @@ const ( EDESTADDRREQ = syscall.Errno(0x27) EDOTDOT = syscall.Errno(0x58) EDQUOT = syscall.Errno(0x45) + EFSBADCRC = syscall.Errno(0x4c) + EFSCORRUPTED = syscall.Errno(0x75) EHOSTDOWN = syscall.Errno(0x40) EHOSTUNREACH = syscall.Errno(0x41) EHWPOISON = syscall.Errno(0x87) @@ -921,7 +926,7 @@ var errorList = [...]struct { {114, "ELIBACC", "can not access a needed shared library"}, {115, "ENOTUNIQ", "name not unique on network"}, {116, "ERESTART", "interrupted system call should be restarted"}, - {117, "EUCLEAN", "structure needs cleaning"}, + {117, "EFSCORRUPTED", "structure needs cleaning"}, {118, "ENOTNAM", "not a XENIX named type file"}, {119, "ENAVAIL", "no XENIX semaphores available"}, {120, "EISNAM", "is a named type file"}, diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux.go b/vendor/golang.org/x/sys/unix/zsyscall_linux.go index 8935d10a3..80f40e401 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux.go @@ -1785,7 +1785,7 @@ func writev(fd int, iovs []Iovec) (n int, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func preadv(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr) (n int, err error) { +func preadvSyscall(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr) (n int, err error) { var _p0 unsafe.Pointer if len(iovs) > 0 { _p0 = unsafe.Pointer(&iovs[0]) @@ -1802,7 +1802,7 @@ func preadv(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr) (n int, err er // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func pwritev(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr) (n int, err error) { +func pwritevSyscall(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr) (n int, err error) { var _p0 unsafe.Pointer if len(iovs) > 0 { _p0 = unsafe.Pointer(&iovs[0]) @@ -1819,7 +1819,7 @@ func pwritev(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr) (n int, err e // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func preadv2(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr, flags int) (n int, err error) { +func preadv2Syscall(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr, flags int) (n int, err error) { var _p0 unsafe.Pointer if len(iovs) > 0 { _p0 = unsafe.Pointer(&iovs[0]) @@ -1836,7 +1836,7 @@ func preadv2(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr, flags int) (n // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func pwritev2(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr, flags int) (n int, err error) { +func pwritev2Syscall(fd int, iovs []Iovec, offs_l uintptr, offs_h uintptr, flags int) (n int, err error) { var _p0 unsafe.Pointer if len(iovs) > 0 { _p0 = unsafe.Pointer(&iovs[0]) @@ -2241,8 +2241,8 @@ func Mseal(b []byte, flags uint) (err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func setMemPolicy(mode int, mask *CPUSet, size int) (err error) { - _, _, e1 := Syscall(SYS_SET_MEMPOLICY, uintptr(mode), uintptr(unsafe.Pointer(mask)), uintptr(size)) +func setMemPolicy(mode int, mask unsafe.Pointer, size uintptr) (err error) { + _, _, e1 := Syscall(SYS_SET_MEMPOLICY, uintptr(mode), uintptr(mask), uintptr(size)) if e1 != 0 { err = errnoErr(e1) } diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.go index 1851df14e..6487475f0 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.go @@ -1633,6 +1633,90 @@ var libc_pwrite_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func readv(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_readv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_readv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_readv readv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func writev(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_writev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_writev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_writev writev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func preadv(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_preadv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), uintptr(offset), uintptr(offset>>32), 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_preadv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_preadv preadv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func pwritev(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_pwritev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), uintptr(offset), uintptr(offset>>32), 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_pwritev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_pwritev pwritev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func read(fd int, p []byte) (n int, err error) { var _p0 unsafe.Pointer if len(p) > 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.s index 0b43c6936..f10201dac 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_386.s @@ -498,6 +498,26 @@ TEXT libc_pwrite_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_pwrite_trampoline_addr(SB), RODATA, $4 DATA ·libc_pwrite_trampoline_addr(SB)/4, $libc_pwrite_trampoline<>(SB) +TEXT libc_readv_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_readv(SB) +GLOBL ·libc_readv_trampoline_addr(SB), RODATA, $4 +DATA ·libc_readv_trampoline_addr(SB)/4, $libc_readv_trampoline<>(SB) + +TEXT libc_writev_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_writev(SB) +GLOBL ·libc_writev_trampoline_addr(SB), RODATA, $4 +DATA ·libc_writev_trampoline_addr(SB)/4, $libc_writev_trampoline<>(SB) + +TEXT libc_preadv_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_preadv(SB) +GLOBL ·libc_preadv_trampoline_addr(SB), RODATA, $4 +DATA ·libc_preadv_trampoline_addr(SB)/4, $libc_preadv_trampoline<>(SB) + +TEXT libc_pwritev_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_pwritev(SB) +GLOBL ·libc_pwritev_trampoline_addr(SB), RODATA, $4 +DATA ·libc_pwritev_trampoline_addr(SB)/4, $libc_pwritev_trampoline<>(SB) + TEXT libc_read_trampoline<>(SB),NOSPLIT,$0-0 JMP libc_read(SB) GLOBL ·libc_read_trampoline_addr(SB), RODATA, $4 diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.go index e1ec0dbe4..50980475d 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.go @@ -1633,6 +1633,90 @@ var libc_pwrite_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func readv(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_readv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_readv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_readv readv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func writev(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_writev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_writev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_writev writev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func preadv(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_preadv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), uintptr(offset), 0, 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_preadv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_preadv preadv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func pwritev(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_pwritev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), uintptr(offset), 0, 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_pwritev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_pwritev pwritev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func read(fd int, p []byte) (n int, err error) { var _p0 unsafe.Pointer if len(p) > 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.s index 880c6d6e3..9de2cbaa4 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_amd64.s @@ -498,6 +498,26 @@ TEXT libc_pwrite_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_pwrite_trampoline_addr(SB), RODATA, $8 DATA ·libc_pwrite_trampoline_addr(SB)/8, $libc_pwrite_trampoline<>(SB) +TEXT libc_readv_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_readv(SB) +GLOBL ·libc_readv_trampoline_addr(SB), RODATA, $8 +DATA ·libc_readv_trampoline_addr(SB)/8, $libc_readv_trampoline<>(SB) + +TEXT libc_writev_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_writev(SB) +GLOBL ·libc_writev_trampoline_addr(SB), RODATA, $8 +DATA ·libc_writev_trampoline_addr(SB)/8, $libc_writev_trampoline<>(SB) + +TEXT libc_preadv_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_preadv(SB) +GLOBL ·libc_preadv_trampoline_addr(SB), RODATA, $8 +DATA ·libc_preadv_trampoline_addr(SB)/8, $libc_preadv_trampoline<>(SB) + +TEXT libc_pwritev_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_pwritev(SB) +GLOBL ·libc_pwritev_trampoline_addr(SB), RODATA, $8 +DATA ·libc_pwritev_trampoline_addr(SB)/8, $libc_pwritev_trampoline<>(SB) + TEXT libc_read_trampoline<>(SB),NOSPLIT,$0-0 JMP libc_read(SB) GLOBL ·libc_read_trampoline_addr(SB), RODATA, $8 diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.go index 7c8452a63..33c9c3a43 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.go @@ -1633,6 +1633,90 @@ var libc_pwrite_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func readv(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_readv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_readv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_readv readv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func writev(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_writev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_writev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_writev writev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func preadv(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_preadv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), 0, uintptr(offset), uintptr(offset>>32)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_preadv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_preadv preadv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func pwritev(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_pwritev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), 0, uintptr(offset), uintptr(offset>>32)) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_pwritev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_pwritev pwritev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func read(fd int, p []byte) (n int, err error) { var _p0 unsafe.Pointer if len(p) > 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.s index b8ef95b0f..c6b9175a6 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm.s @@ -498,6 +498,26 @@ TEXT libc_pwrite_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_pwrite_trampoline_addr(SB), RODATA, $4 DATA ·libc_pwrite_trampoline_addr(SB)/4, $libc_pwrite_trampoline<>(SB) +TEXT libc_readv_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_readv(SB) +GLOBL ·libc_readv_trampoline_addr(SB), RODATA, $4 +DATA ·libc_readv_trampoline_addr(SB)/4, $libc_readv_trampoline<>(SB) + +TEXT libc_writev_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_writev(SB) +GLOBL ·libc_writev_trampoline_addr(SB), RODATA, $4 +DATA ·libc_writev_trampoline_addr(SB)/4, $libc_writev_trampoline<>(SB) + +TEXT libc_preadv_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_preadv(SB) +GLOBL ·libc_preadv_trampoline_addr(SB), RODATA, $4 +DATA ·libc_preadv_trampoline_addr(SB)/4, $libc_preadv_trampoline<>(SB) + +TEXT libc_pwritev_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_pwritev(SB) +GLOBL ·libc_pwritev_trampoline_addr(SB), RODATA, $4 +DATA ·libc_pwritev_trampoline_addr(SB)/4, $libc_pwritev_trampoline<>(SB) + TEXT libc_read_trampoline<>(SB),NOSPLIT,$0-0 JMP libc_read(SB) GLOBL ·libc_read_trampoline_addr(SB), RODATA, $4 diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.go index 2ffdf861f..d3410262e 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.go @@ -1633,6 +1633,90 @@ var libc_pwrite_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func readv(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_readv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_readv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_readv readv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func writev(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_writev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_writev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_writev writev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func preadv(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_preadv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), uintptr(offset), 0, 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_preadv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_preadv preadv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func pwritev(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_pwritev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), uintptr(offset), 0, 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_pwritev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_pwritev pwritev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func read(fd int, p []byte) (n int, err error) { var _p0 unsafe.Pointer if len(p) > 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.s index 2af3b5c76..1be10bb45 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_arm64.s @@ -498,6 +498,26 @@ TEXT libc_pwrite_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_pwrite_trampoline_addr(SB), RODATA, $8 DATA ·libc_pwrite_trampoline_addr(SB)/8, $libc_pwrite_trampoline<>(SB) +TEXT libc_readv_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_readv(SB) +GLOBL ·libc_readv_trampoline_addr(SB), RODATA, $8 +DATA ·libc_readv_trampoline_addr(SB)/8, $libc_readv_trampoline<>(SB) + +TEXT libc_writev_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_writev(SB) +GLOBL ·libc_writev_trampoline_addr(SB), RODATA, $8 +DATA ·libc_writev_trampoline_addr(SB)/8, $libc_writev_trampoline<>(SB) + +TEXT libc_preadv_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_preadv(SB) +GLOBL ·libc_preadv_trampoline_addr(SB), RODATA, $8 +DATA ·libc_preadv_trampoline_addr(SB)/8, $libc_preadv_trampoline<>(SB) + +TEXT libc_pwritev_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_pwritev(SB) +GLOBL ·libc_pwritev_trampoline_addr(SB), RODATA, $8 +DATA ·libc_pwritev_trampoline_addr(SB)/8, $libc_pwritev_trampoline<>(SB) + TEXT libc_read_trampoline<>(SB),NOSPLIT,$0-0 JMP libc_read(SB) GLOBL ·libc_read_trampoline_addr(SB), RODATA, $8 diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.go index 1da08d526..dea19d54e 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.go @@ -1633,6 +1633,90 @@ var libc_pwrite_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func readv(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_readv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_readv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_readv readv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func writev(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_writev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_writev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_writev writev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func preadv(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_preadv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), uintptr(offset), 0, 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_preadv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_preadv preadv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func pwritev(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_pwritev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), uintptr(offset), 0, 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_pwritev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_pwritev pwritev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func read(fd int, p []byte) (n int, err error) { var _p0 unsafe.Pointer if len(p) > 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.s index b7a251353..a9fec24d9 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_mips64.s @@ -498,6 +498,26 @@ TEXT libc_pwrite_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_pwrite_trampoline_addr(SB), RODATA, $8 DATA ·libc_pwrite_trampoline_addr(SB)/8, $libc_pwrite_trampoline<>(SB) +TEXT libc_readv_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_readv(SB) +GLOBL ·libc_readv_trampoline_addr(SB), RODATA, $8 +DATA ·libc_readv_trampoline_addr(SB)/8, $libc_readv_trampoline<>(SB) + +TEXT libc_writev_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_writev(SB) +GLOBL ·libc_writev_trampoline_addr(SB), RODATA, $8 +DATA ·libc_writev_trampoline_addr(SB)/8, $libc_writev_trampoline<>(SB) + +TEXT libc_preadv_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_preadv(SB) +GLOBL ·libc_preadv_trampoline_addr(SB), RODATA, $8 +DATA ·libc_preadv_trampoline_addr(SB)/8, $libc_preadv_trampoline<>(SB) + +TEXT libc_pwritev_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_pwritev(SB) +GLOBL ·libc_pwritev_trampoline_addr(SB), RODATA, $8 +DATA ·libc_pwritev_trampoline_addr(SB)/8, $libc_pwritev_trampoline<>(SB) + TEXT libc_read_trampoline<>(SB),NOSPLIT,$0-0 JMP libc_read(SB) GLOBL ·libc_read_trampoline_addr(SB), RODATA, $8 diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.go index 6e85b0aac..436efb586 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.go @@ -1633,6 +1633,90 @@ var libc_pwrite_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func readv(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_readv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_readv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_readv readv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func writev(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_writev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_writev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_writev writev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func preadv(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_preadv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), uintptr(offset), 0, 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_preadv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_preadv preadv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func pwritev(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_pwritev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), uintptr(offset), 0, 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_pwritev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_pwritev pwritev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func read(fd int, p []byte) (n int, err error) { var _p0 unsafe.Pointer if len(p) > 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.s index f15dadf05..441ed4e40 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_ppc64.s @@ -597,6 +597,30 @@ TEXT libc_pwrite_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_pwrite_trampoline_addr(SB), RODATA, $8 DATA ·libc_pwrite_trampoline_addr(SB)/8, $libc_pwrite_trampoline<>(SB) +TEXT libc_readv_trampoline<>(SB),NOSPLIT,$0-0 + CALL libc_readv(SB) + RET +GLOBL ·libc_readv_trampoline_addr(SB), RODATA, $8 +DATA ·libc_readv_trampoline_addr(SB)/8, $libc_readv_trampoline<>(SB) + +TEXT libc_writev_trampoline<>(SB),NOSPLIT,$0-0 + CALL libc_writev(SB) + RET +GLOBL ·libc_writev_trampoline_addr(SB), RODATA, $8 +DATA ·libc_writev_trampoline_addr(SB)/8, $libc_writev_trampoline<>(SB) + +TEXT libc_preadv_trampoline<>(SB),NOSPLIT,$0-0 + CALL libc_preadv(SB) + RET +GLOBL ·libc_preadv_trampoline_addr(SB), RODATA, $8 +DATA ·libc_preadv_trampoline_addr(SB)/8, $libc_preadv_trampoline<>(SB) + +TEXT libc_pwritev_trampoline<>(SB),NOSPLIT,$0-0 + CALL libc_pwritev(SB) + RET +GLOBL ·libc_pwritev_trampoline_addr(SB), RODATA, $8 +DATA ·libc_pwritev_trampoline_addr(SB)/8, $libc_pwritev_trampoline<>(SB) + TEXT libc_read_trampoline<>(SB),NOSPLIT,$0-0 CALL libc_read(SB) RET diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.go b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.go index 28b487df2..d801e4b4e 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.go @@ -1633,6 +1633,90 @@ var libc_pwrite_trampoline_addr uintptr // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func readv(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_readv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_readv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_readv readv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func writev(fd int, iovecs []Iovec) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall(libc_writev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs))) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_writev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_writev writev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func preadv(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_preadv_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), uintptr(offset), 0, 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_preadv_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_preadv preadv "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + +func pwritev(fd int, iovecs []Iovec, offset int64) (n int, err error) { + var _p0 unsafe.Pointer + if len(iovecs) > 0 { + _p0 = unsafe.Pointer(&iovecs[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := syscall_syscall6(libc_pwritev_trampoline_addr, uintptr(fd), uintptr(_p0), uintptr(len(iovecs)), uintptr(offset), 0, 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +var libc_pwritev_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_pwritev pwritev "libc.so" + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func read(fd int, p []byte) (n int, err error) { var _p0 unsafe.Pointer if len(p) > 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.s b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.s index 1e7f321e4..b15cc0174 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.s +++ b/vendor/golang.org/x/sys/unix/zsyscall_openbsd_riscv64.s @@ -498,6 +498,26 @@ TEXT libc_pwrite_trampoline<>(SB),NOSPLIT,$0-0 GLOBL ·libc_pwrite_trampoline_addr(SB), RODATA, $8 DATA ·libc_pwrite_trampoline_addr(SB)/8, $libc_pwrite_trampoline<>(SB) +TEXT libc_readv_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_readv(SB) +GLOBL ·libc_readv_trampoline_addr(SB), RODATA, $8 +DATA ·libc_readv_trampoline_addr(SB)/8, $libc_readv_trampoline<>(SB) + +TEXT libc_writev_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_writev(SB) +GLOBL ·libc_writev_trampoline_addr(SB), RODATA, $8 +DATA ·libc_writev_trampoline_addr(SB)/8, $libc_writev_trampoline<>(SB) + +TEXT libc_preadv_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_preadv(SB) +GLOBL ·libc_preadv_trampoline_addr(SB), RODATA, $8 +DATA ·libc_preadv_trampoline_addr(SB)/8, $libc_preadv_trampoline<>(SB) + +TEXT libc_pwritev_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_pwritev(SB) +GLOBL ·libc_pwritev_trampoline_addr(SB), RODATA, $8 +DATA ·libc_pwritev_trampoline_addr(SB)/8, $libc_pwritev_trampoline<>(SB) + TEXT libc_read_trampoline<>(SB),NOSPLIT,$0-0 JMP libc_read(SB) GLOBL ·libc_read_trampoline_addr(SB), RODATA, $8 diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_386.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_386.go index aca56ee49..49d1b8803 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_386.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_386.go @@ -463,4 +463,8 @@ const ( SYS_LISTXATTRAT = 465 SYS_REMOVEXATTRAT = 466 SYS_OPEN_TREE_ATTR = 467 + SYS_FILE_GETATTR = 468 + SYS_FILE_SETATTR = 469 + SYS_LISTNS = 470 + SYS_RSEQ_SLICE_YIELD = 471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_amd64.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_amd64.go index 2ea1ef58c..f11f1de77 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_amd64.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_amd64.go @@ -342,6 +342,7 @@ const ( SYS_IO_PGETEVENTS = 333 SYS_RSEQ = 334 SYS_URETPROBE = 335 + SYS_UPROBE = 336 SYS_PIDFD_SEND_SIGNAL = 424 SYS_IO_URING_SETUP = 425 SYS_IO_URING_ENTER = 426 @@ -386,4 +387,8 @@ const ( SYS_LISTXATTRAT = 465 SYS_REMOVEXATTRAT = 466 SYS_OPEN_TREE_ATTR = 467 + SYS_FILE_GETATTR = 468 + SYS_FILE_SETATTR = 469 + SYS_LISTNS = 470 + SYS_RSEQ_SLICE_YIELD = 471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_arm.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_arm.go index d22c8af31..bad740b79 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_arm.go @@ -427,4 +427,8 @@ const ( SYS_LISTXATTRAT = 465 SYS_REMOVEXATTRAT = 466 SYS_OPEN_TREE_ATTR = 467 + SYS_FILE_GETATTR = 468 + SYS_FILE_SETATTR = 469 + SYS_LISTNS = 470 + SYS_RSEQ_SLICE_YIELD = 471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_arm64.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_arm64.go index 5ee264ae9..fe646d18e 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_arm64.go @@ -330,4 +330,8 @@ const ( SYS_LISTXATTRAT = 465 SYS_REMOVEXATTRAT = 466 SYS_OPEN_TREE_ATTR = 467 + SYS_FILE_GETATTR = 468 + SYS_FILE_SETATTR = 469 + SYS_LISTNS = 470 + SYS_RSEQ_SLICE_YIELD = 471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_loong64.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_loong64.go index f9f03ebf5..4362f6d55 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_loong64.go @@ -306,6 +306,7 @@ const ( SYS_LANDLOCK_CREATE_RULESET = 444 SYS_LANDLOCK_ADD_RULE = 445 SYS_LANDLOCK_RESTRICT_SELF = 446 + SYS_MEMFD_SECRET = 447 SYS_PROCESS_MRELEASE = 448 SYS_FUTEX_WAITV = 449 SYS_SET_MEMPOLICY_HOME_NODE = 450 @@ -326,4 +327,8 @@ const ( SYS_LISTXATTRAT = 465 SYS_REMOVEXATTRAT = 466 SYS_OPEN_TREE_ATTR = 467 + SYS_FILE_GETATTR = 468 + SYS_FILE_SETATTR = 469 + SYS_LISTNS = 470 + SYS_RSEQ_SLICE_YIELD = 471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_mips.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_mips.go index 87c2118e8..b63d155ae 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_mips.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_mips.go @@ -447,4 +447,8 @@ const ( SYS_LISTXATTRAT = 4465 SYS_REMOVEXATTRAT = 4466 SYS_OPEN_TREE_ATTR = 4467 + SYS_FILE_GETATTR = 4468 + SYS_FILE_SETATTR = 4469 + SYS_LISTNS = 4470 + SYS_RSEQ_SLICE_YIELD = 4471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_mips64.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_mips64.go index 391ad102f..435d43319 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_mips64.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_mips64.go @@ -377,4 +377,8 @@ const ( SYS_LISTXATTRAT = 5465 SYS_REMOVEXATTRAT = 5466 SYS_OPEN_TREE_ATTR = 5467 + SYS_FILE_GETATTR = 5468 + SYS_FILE_SETATTR = 5469 + SYS_LISTNS = 5470 + SYS_RSEQ_SLICE_YIELD = 5471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_mips64le.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_mips64le.go index 565615775..dcc0468d6 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_mips64le.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_mips64le.go @@ -377,4 +377,8 @@ const ( SYS_LISTXATTRAT = 5465 SYS_REMOVEXATTRAT = 5466 SYS_OPEN_TREE_ATTR = 5467 + SYS_FILE_GETATTR = 5468 + SYS_FILE_SETATTR = 5469 + SYS_LISTNS = 5470 + SYS_RSEQ_SLICE_YIELD = 5471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_mipsle.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_mipsle.go index 0482b52e3..b96f85ebd 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_mipsle.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_mipsle.go @@ -447,4 +447,8 @@ const ( SYS_LISTXATTRAT = 4465 SYS_REMOVEXATTRAT = 4466 SYS_OPEN_TREE_ATTR = 4467 + SYS_FILE_GETATTR = 4468 + SYS_FILE_SETATTR = 4469 + SYS_LISTNS = 4470 + SYS_RSEQ_SLICE_YIELD = 4471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_ppc.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_ppc.go index 71806f08f..bffa2bd1e 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_ppc.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_ppc.go @@ -454,4 +454,8 @@ const ( SYS_LISTXATTRAT = 465 SYS_REMOVEXATTRAT = 466 SYS_OPEN_TREE_ATTR = 467 + SYS_FILE_GETATTR = 468 + SYS_FILE_SETATTR = 469 + SYS_LISTNS = 470 + SYS_RSEQ_SLICE_YIELD = 471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_ppc64.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_ppc64.go index e35a71058..57bfc6b26 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_ppc64.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_ppc64.go @@ -426,4 +426,8 @@ const ( SYS_LISTXATTRAT = 465 SYS_REMOVEXATTRAT = 466 SYS_OPEN_TREE_ATTR = 467 + SYS_FILE_GETATTR = 468 + SYS_FILE_SETATTR = 469 + SYS_LISTNS = 470 + SYS_RSEQ_SLICE_YIELD = 471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_ppc64le.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_ppc64le.go index 2aea47670..750f706d5 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_ppc64le.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_ppc64le.go @@ -426,4 +426,8 @@ const ( SYS_LISTXATTRAT = 465 SYS_REMOVEXATTRAT = 466 SYS_OPEN_TREE_ATTR = 467 + SYS_FILE_GETATTR = 468 + SYS_FILE_SETATTR = 469 + SYS_LISTNS = 470 + SYS_RSEQ_SLICE_YIELD = 471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_riscv64.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_riscv64.go index 6c9bb4e56..303ccbf46 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_riscv64.go @@ -331,4 +331,8 @@ const ( SYS_LISTXATTRAT = 465 SYS_REMOVEXATTRAT = 466 SYS_OPEN_TREE_ATTR = 467 + SYS_FILE_GETATTR = 468 + SYS_FILE_SETATTR = 469 + SYS_LISTNS = 470 + SYS_RSEQ_SLICE_YIELD = 471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_s390x.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_s390x.go index 680bc9915..5e5dd4ccb 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_s390x.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_s390x.go @@ -392,4 +392,8 @@ const ( SYS_LISTXATTRAT = 465 SYS_REMOVEXATTRAT = 466 SYS_OPEN_TREE_ATTR = 467 + SYS_FILE_GETATTR = 468 + SYS_FILE_SETATTR = 469 + SYS_LISTNS = 470 + SYS_RSEQ_SLICE_YIELD = 471 ) diff --git a/vendor/golang.org/x/sys/unix/zsysnum_linux_sparc64.go b/vendor/golang.org/x/sys/unix/zsysnum_linux_sparc64.go index 620f27105..f7c4fb3df 100644 --- a/vendor/golang.org/x/sys/unix/zsysnum_linux_sparc64.go +++ b/vendor/golang.org/x/sys/unix/zsysnum_linux_sparc64.go @@ -374,6 +374,7 @@ const ( SYS_FSMOUNT = 432 SYS_FSPICK = 433 SYS_PIDFD_OPEN = 434 + SYS_CLONE3 = 435 SYS_CLOSE_RANGE = 436 SYS_OPENAT2 = 437 SYS_PIDFD_GETFD = 438 @@ -405,4 +406,8 @@ const ( SYS_LISTXATTRAT = 465 SYS_REMOVEXATTRAT = 466 SYS_OPEN_TREE_ATTR = 467 + SYS_FILE_GETATTR = 468 + SYS_FILE_SETATTR = 469 + SYS_LISTNS = 470 + SYS_RSEQ_SLICE_YIELD = 471 ) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux.go b/vendor/golang.org/x/sys/unix/ztypes_linux.go index 45476a73c..d11d5b96a 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux.go @@ -18,6 +18,11 @@ type ( _C_long_long int64 ) +type KernelTimespec struct { + Sec int64 + Nsec int64 +} + type ItimerSpec struct { Interval Timespec Value Timespec @@ -521,6 +526,14 @@ type TCPInfo struct { Total_rto uint16 Total_rto_recoveries uint16 Total_rto_time uint32 + Received_ce uint32 + Delivered_e1_bytes uint32 + Delivered_e0_bytes uint32 + Delivered_ce_bytes uint32 + Received_e1_bytes uint32 + Received_e0_bytes uint32 + Received_ce_bytes uint32 + _ [4]byte } type TCPVegasInfo struct { @@ -586,7 +599,7 @@ const ( SizeofIPv6MTUInfo = 0x20 SizeofICMPv6Filter = 0x20 SizeofUcred = 0xc - SizeofTCPInfo = 0xf8 + SizeofTCPInfo = 0x118 SizeofTCPCCInfo = 0x14 SizeofCanFilter = 0x8 SizeofTCPRepairOpt = 0x8 @@ -1324,7 +1337,7 @@ const ( PERF_RECORD_CGROUP = 0x13 PERF_RECORD_TEXT_POKE = 0x14 PERF_RECORD_AUX_OUTPUT_HW_ID = 0x15 - PERF_RECORD_MAX = 0x16 + PERF_RECORD_MAX = 0x17 PERF_RECORD_KSYMBOL_TYPE_UNKNOWN = 0x0 PERF_RECORD_KSYMBOL_TYPE_BPF = 0x1 PERF_RECORD_KSYMBOL_TYPE_OOL = 0x2 @@ -3566,7 +3579,7 @@ const ( DEVLINK_ATTR_LINECARD_SUPPORTED_TYPES = 0xae DEVLINK_ATTR_NESTED_DEVLINK = 0xaf DEVLINK_ATTR_SELFTESTS = 0xb0 - DEVLINK_ATTR_MAX = 0xb3 + DEVLINK_ATTR_MAX = 0xb7 DEVLINK_DPIPE_FIELD_MAPPING_TYPE_NONE = 0x0 DEVLINK_DPIPE_FIELD_MAPPING_TYPE_IFINDEX = 0x1 DEVLINK_DPIPE_MATCH_TYPE_FIELD_EXACT = 0x0 @@ -3888,7 +3901,7 @@ const ( ETHTOOL_MSG_PHY_GET = 0x2d ETHTOOL_MSG_TSCONFIG_GET = 0x2e ETHTOOL_MSG_TSCONFIG_SET = 0x2f - ETHTOOL_MSG_USER_MAX = 0x2f + ETHTOOL_MSG_USER_MAX = 0x33 ETHTOOL_MSG_KERNEL_NONE = 0x0 ETHTOOL_MSG_STRSET_GET_REPLY = 0x1 ETHTOOL_MSG_LINKINFO_GET_REPLY = 0x2 @@ -3938,7 +3951,7 @@ const ( ETHTOOL_MSG_PHY_NTF = 0x2e ETHTOOL_MSG_TSCONFIG_GET_REPLY = 0x2f ETHTOOL_MSG_TSCONFIG_SET_REPLY = 0x30 - ETHTOOL_MSG_KERNEL_MAX = 0x30 + ETHTOOL_MSG_KERNEL_MAX = 0x36 ETHTOOL_FLAG_COMPACT_BITSETS = 0x1 ETHTOOL_FLAG_OMIT_REPLY = 0x2 ETHTOOL_FLAG_STATS = 0x4 @@ -4867,7 +4880,7 @@ const ( NL80211_ATTR_MAC_HINT = 0xc8 NL80211_ATTR_MAC_MASK = 0xd7 NL80211_ATTR_MAX_AP_ASSOC_STA = 0xca - NL80211_ATTR_MAX = 0x151 + NL80211_ATTR_MAX = 0x15c NL80211_ATTR_MAX_CRIT_PROT_DURATION = 0xb4 NL80211_ATTR_MAX_CSA_COUNTERS = 0xce NL80211_ATTR_MAX_HW_TIMESTAMP_PEERS = 0x143 @@ -5082,12 +5095,12 @@ const ( NL80211_ATTR_WOWLAN_TRIGGERS = 0x75 NL80211_ATTR_WOWLAN_TRIGGERS_SUPPORTED = 0x76 NL80211_ATTR_WPA_VERSIONS = 0x4b - NL80211_AUTHTYPE_AUTOMATIC = 0x8 + NL80211_AUTHTYPE_AUTOMATIC = 0x9 NL80211_AUTHTYPE_FILS_PK = 0x7 NL80211_AUTHTYPE_FILS_SK = 0x5 NL80211_AUTHTYPE_FILS_SK_PFS = 0x6 NL80211_AUTHTYPE_FT = 0x2 - NL80211_AUTHTYPE_MAX = 0x7 + NL80211_AUTHTYPE_MAX = 0x8 NL80211_AUTHTYPE_NETWORK_EAP = 0x3 NL80211_AUTHTYPE_OPEN_SYSTEM = 0x0 NL80211_AUTHTYPE_SAE = 0x4 @@ -5120,7 +5133,7 @@ const ( NL80211_BAND_IFTYPE_ATTR_HE_CAP_PHY = 0x3 NL80211_BAND_IFTYPE_ATTR_HE_CAP_PPE = 0x5 NL80211_BAND_IFTYPE_ATTR_IFTYPES = 0x1 - NL80211_BAND_IFTYPE_ATTR_MAX = 0xb + NL80211_BAND_IFTYPE_ATTR_MAX = 0xd NL80211_BAND_IFTYPE_ATTR_VENDOR_ELEMS = 0x7 NL80211_BAND_LC = 0x5 NL80211_BAND_S1GHZ = 0x4 @@ -5255,7 +5268,7 @@ const ( NL80211_CMD_LEAVE_MESH = 0x45 NL80211_CMD_LEAVE_OCB = 0x6d NL80211_CMD_LINKS_REMOVED = 0x9a - NL80211_CMD_MAX = 0x9d + NL80211_CMD_MAX = 0x9f NL80211_CMD_MICHAEL_MIC_FAILURE = 0x29 NL80211_CMD_MODIFY_LINK_STA = 0x97 NL80211_CMD_NAN_MATCH = 0x78 @@ -5501,7 +5514,7 @@ const ( NL80211_FREQUENCY_ATTR_GO_CONCURRENT = 0xf NL80211_FREQUENCY_ATTR_INDOOR_ONLY = 0xe NL80211_FREQUENCY_ATTR_IR_CONCURRENT = 0xf - NL80211_FREQUENCY_ATTR_MAX = 0x22 + NL80211_FREQUENCY_ATTR_MAX = 0x27 NL80211_FREQUENCY_ATTR_MAX_TX_POWER = 0x6 NL80211_FREQUENCY_ATTR_NO_10MHZ = 0x11 NL80211_FREQUENCY_ATTR_NO_160MHZ = 0xc @@ -5766,7 +5779,7 @@ const ( NL80211_PMSR_FTM_CAPA_ATTR_ASAP = 0x1 NL80211_PMSR_FTM_CAPA_ATTR_BANDWIDTHS = 0x6 NL80211_PMSR_FTM_CAPA_ATTR_MAX_BURSTS_EXPONENT = 0x7 - NL80211_PMSR_FTM_CAPA_ATTR_MAX = 0xa + NL80211_PMSR_FTM_CAPA_ATTR_MAX = 0x12 NL80211_PMSR_FTM_CAPA_ATTR_MAX_FTMS_PER_BURST = 0x8 NL80211_PMSR_FTM_CAPA_ATTR_NON_ASAP = 0x2 NL80211_PMSR_FTM_CAPA_ATTR_NON_TRIGGER_BASED = 0xa @@ -5788,7 +5801,7 @@ const ( NL80211_PMSR_FTM_REQ_ATTR_BURST_PERIOD = 0x4 NL80211_PMSR_FTM_REQ_ATTR_FTMS_PER_BURST = 0x6 NL80211_PMSR_FTM_REQ_ATTR_LMR_FEEDBACK = 0xc - NL80211_PMSR_FTM_REQ_ATTR_MAX = 0xd + NL80211_PMSR_FTM_REQ_ATTR_MAX = 0xe NL80211_PMSR_FTM_REQ_ATTR_NON_TRIGGER_BASED = 0xb NL80211_PMSR_FTM_REQ_ATTR_NUM_BURSTS_EXP = 0x3 NL80211_PMSR_FTM_REQ_ATTR_NUM_FTMR_RETRIES = 0x7 @@ -5806,7 +5819,7 @@ const ( NL80211_PMSR_FTM_RESP_ATTR_FAIL_REASON = 0x1 NL80211_PMSR_FTM_RESP_ATTR_FTMS_PER_BURST = 0x8 NL80211_PMSR_FTM_RESP_ATTR_LCI = 0x13 - NL80211_PMSR_FTM_RESP_ATTR_MAX = 0x15 + NL80211_PMSR_FTM_RESP_ATTR_MAX = 0x16 NL80211_PMSR_FTM_RESP_ATTR_NUM_BURSTS_EXP = 0x6 NL80211_PMSR_FTM_RESP_ATTR_NUM_FTMR_ATTEMPTS = 0x3 NL80211_PMSR_FTM_RESP_ATTR_NUM_FTMR_SUCCESSES = 0x4 @@ -5913,7 +5926,7 @@ const ( NL80211_RATE_INFO_HE_RU_ALLOC_52 = 0x1 NL80211_RATE_INFO_HE_RU_ALLOC_996 = 0x5 NL80211_RATE_INFO_HE_RU_ALLOC = 0x11 - NL80211_RATE_INFO_MAX = 0x1d + NL80211_RATE_INFO_MAX = 0x20 NL80211_RATE_INFO_MCS = 0x2 NL80211_RATE_INFO_S1G_MCS = 0x17 NL80211_RATE_INFO_S1G_NSS = 0x18 @@ -6167,7 +6180,7 @@ const ( NL80211_TXRATE_HT = 0x2 NL80211_TXRATE_LEGACY = 0x1 NL80211_TX_RATE_LIMITED = 0x1 - NL80211_TXRATE_MAX = 0x7 + NL80211_TXRATE_MAX = 0xa NL80211_TXRATE_MCS = 0x2 NL80211_TXRATE_VHT = 0x3 NL80211_UNSOL_BCAST_PROBE_RESP_ATTR_INT = 0x1 @@ -6183,7 +6196,7 @@ const ( NL80211_WIPHY_RADIO_ATTR_FREQ_RANGE = 0x2 NL80211_WIPHY_RADIO_ATTR_INDEX = 0x1 NL80211_WIPHY_RADIO_ATTR_INTERFACE_COMBINATION = 0x3 - NL80211_WIPHY_RADIO_ATTR_MAX = 0x4 + NL80211_WIPHY_RADIO_ATTR_MAX = 0x5 NL80211_WIPHY_RADIO_FREQ_ATTR_END = 0x2 NL80211_WIPHY_RADIO_FREQ_ATTR_MAX = 0x2 NL80211_WIPHY_RADIO_FREQ_ATTR_START = 0x1 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_386.go b/vendor/golang.org/x/sys/unix/ztypes_linux_386.go index 485f2d3a1..97ef790de 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_386.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_386.go @@ -354,6 +354,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint32 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go index ecbd1ad8b..90b50da68 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go @@ -367,6 +367,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint64 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go b/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go index 02f0463a4..acda13685 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go @@ -345,6 +345,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint32 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go index 6f4d400d2..ef7a99e1f 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go @@ -346,6 +346,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint64 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go index cd532cfa5..966063dfc 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go @@ -347,6 +347,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint64 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go index 413362085..dc53b20b7 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go @@ -350,6 +350,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint32 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go index eaa37eb71..9ad0aa8c3 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go @@ -349,6 +349,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint64 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go index 98ae6a1e4..29d55493d 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go @@ -349,6 +349,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint64 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go index cae196159..a4d9e1584 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go @@ -350,6 +350,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint32 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go index 6ce3b4e02..f8a297771 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go @@ -357,6 +357,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint32 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go index c7429c6a1..4158d6c4e 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go @@ -356,6 +356,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint64 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go index 4bf4baf4c..1035af49f 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go @@ -356,6 +356,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint64 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go index e9709d70a..2297125d3 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go @@ -374,6 +374,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint64 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go b/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go index fb44268ca..8481e9bd9 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go @@ -369,6 +369,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint64 diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go index 9c38265c7..a6828a031 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go @@ -351,6 +351,14 @@ type Taskstats struct { Wpcopy_delay_min uint64 Irq_delay_max uint64 Irq_delay_min uint64 + Cpu_delay_max_ts KernelTimespec + Blkio_delay_max_ts KernelTimespec + Swapin_delay_max_ts KernelTimespec + Freepages_delay_max_ts KernelTimespec + Thrashing_delay_max_ts KernelTimespec + Compact_delay_max_ts KernelTimespec + Wpcopy_delay_max_ts KernelTimespec + Irq_delay_max_ts KernelTimespec } type cpuMask uint64 diff --git a/vendor/golang.org/x/sys/windows/syscall_windows.go b/vendor/golang.org/x/sys/windows/syscall_windows.go index d76643658..9755bca9f 100644 --- a/vendor/golang.org/x/sys/windows/syscall_windows.go +++ b/vendor/golang.org/x/sys/windows/syscall_windows.go @@ -452,6 +452,7 @@ func NewCallbackCDecl(fn interface{}) uintptr { //sys RtlInitString(destinationString *NTString, sourceString *byte) = ntdll.RtlInitString //sys NtCreateFile(handle *Handle, access uint32, oa *OBJECT_ATTRIBUTES, iosb *IO_STATUS_BLOCK, allocationSize *int64, attributes uint32, share uint32, disposition uint32, options uint32, eabuffer uintptr, ealength uint32) (ntstatus error) = ntdll.NtCreateFile //sys NtCreateNamedPipeFile(pipe *Handle, access uint32, oa *OBJECT_ATTRIBUTES, iosb *IO_STATUS_BLOCK, share uint32, disposition uint32, options uint32, typ uint32, readMode uint32, completionMode uint32, maxInstances uint32, inboundQuota uint32, outputQuota uint32, timeout *int64) (ntstatus error) = ntdll.NtCreateNamedPipeFile +//sys NtQueryInformationFile(handle Handle, iosb *IO_STATUS_BLOCK, outBuffer *byte, outBufferLen uint32, class uint32) (ntstatus error) = ntdll.NtQueryInformationFile //sys NtSetInformationFile(handle Handle, iosb *IO_STATUS_BLOCK, inBuffer *byte, inBufferLen uint32, class uint32) (ntstatus error) = ntdll.NtSetInformationFile //sys RtlDosPathNameToNtPathName(dosName *uint16, ntName *NTUnicodeString, ntFileNamePart *uint16, relativeName *RTL_RELATIVE_NAME) (ntstatus error) = ntdll.RtlDosPathNameToNtPathName_U_WithStatus //sys RtlDosPathNameToRelativeNtPathName(dosName *uint16, ntName *NTUnicodeString, ntFileNamePart *uint16, relativeName *RTL_RELATIVE_NAME) (ntstatus error) = ntdll.RtlDosPathNameToRelativeNtPathName_U_WithStatus @@ -460,6 +461,8 @@ func NewCallbackCDecl(fn interface{}) uintptr { //sys NtSetInformationProcess(proc Handle, procInfoClass int32, procInfo unsafe.Pointer, procInfoLen uint32) (ntstatus error) = ntdll.NtSetInformationProcess //sys NtQuerySystemInformation(sysInfoClass int32, sysInfo unsafe.Pointer, sysInfoLen uint32, retLen *uint32) (ntstatus error) = ntdll.NtQuerySystemInformation //sys NtSetSystemInformation(sysInfoClass int32, sysInfo unsafe.Pointer, sysInfoLen uint32) (ntstatus error) = ntdll.NtSetSystemInformation +//sys NtQueryEaFile(handle Handle, iosb *IO_STATUS_BLOCK, outBuffer *byte, outBufferLen uint32, returnSingleEntry bool, eaList *byte, eaListLen uint32, eaIndex *uint32, restartScan bool) (ntstatus error) = ntdll.NtQueryEaFile +//sys NtSetEaFile(handle Handle, iosb *IO_STATUS_BLOCK, inBuffer *byte, inBufferLen uint32) (ntstatus error) = ntdll.NtSetEaFile //sys RtlAddFunctionTable(functionTable *RUNTIME_FUNCTION, entryCount uint32, baseAddress uintptr) (ret bool) = ntdll.RtlAddFunctionTable //sys RtlDeleteFunctionTable(functionTable *RUNTIME_FUNCTION) (ret bool) = ntdll.RtlDeleteFunctionTable @@ -892,9 +895,13 @@ const socket_error = uintptr(^uint32(0)) //sys MultiByteToWideChar(codePage uint32, dwFlags uint32, str *byte, nstr int32, wchar *uint16, nwchar int32) (nwrite int32, err error) = kernel32.MultiByteToWideChar //sys getBestInterfaceEx(sockaddr unsafe.Pointer, pdwBestIfIndex *uint32) (errcode error) = iphlpapi.GetBestInterfaceEx //sys GetIfEntry2Ex(level uint32, row *MibIfRow2) (errcode error) = iphlpapi.GetIfEntry2Ex +//sys GetIfTable2Ex(level uint32, table **MibIfTable2) (errcode error) = iphlpapi.GetIfTable2Ex //sys GetIpForwardEntry2(row *MibIpForwardRow2) (errcode error) = iphlpapi.GetIpForwardEntry2 //sys GetIpForwardTable2(family uint16, table **MibIpForwardTable2) (errcode error) = iphlpapi.GetIpForwardTable2 +//sys GetIpInterfaceEntry(row *MibIpInterfaceRow) (errcode error) = iphlpapi.GetIpInterfaceEntry +//sys GetIpInterfaceTable(family uint16, table **MibIpInterfaceTable) (errcode error) = iphlpapi.GetIpInterfaceTable //sys GetUnicastIpAddressEntry(row *MibUnicastIpAddressRow) (errcode error) = iphlpapi.GetUnicastIpAddressEntry +//sys GetUnicastIpAddressTable(family uint16, table **MibUnicastIpAddressTable) (errcode error) = iphlpapi.GetUnicastIpAddressTable //sys FreeMibTable(memory unsafe.Pointer) = iphlpapi.FreeMibTable //sys NotifyIpInterfaceChange(family uint16, callback uintptr, callerContext unsafe.Pointer, initialNotification bool, notificationHandle *Handle) (errcode error) = iphlpapi.NotifyIpInterfaceChange //sys NotifyRouteChange2(family uint16, callback uintptr, callerContext unsafe.Pointer, initialNotification bool, notificationHandle *Handle) (errcode error) = iphlpapi.NotifyRouteChange2 @@ -1693,10 +1700,13 @@ func NewNTUnicodeString(s string) (*NTUnicodeString, error) { if err != nil { return nil, err } - n := uint16(len(s16) * 2) + n := len(s16) * 2 + if n > (1<<16)-1 { + return nil, syscall.EINVAL + } return &NTUnicodeString{ - Length: n - 2, // subtract 2 bytes for the NULL terminator - MaximumLength: n, + Length: uint16(n) - 2, // subtract 2 bytes for the NULL terminator + MaximumLength: uint16(n), Buffer: &s16[0], }, nil } diff --git a/vendor/golang.org/x/sys/windows/types_windows.go b/vendor/golang.org/x/sys/windows/types_windows.go index d5658a138..d2574a73e 100644 --- a/vendor/golang.org/x/sys/windows/types_windows.go +++ b/vendor/golang.org/x/sys/windows/types_windows.go @@ -2320,6 +2320,21 @@ type MibIfRow2 struct { OutQLen uint64 } +// MIB_IF_TABLE_LEVEL enumeration from netioapi.h or +// https://learn.microsoft.com/en-us/windows/win32/api/netioapi/ne-netioapi-mib_if_table_level. +const ( + MibIfTableNormal = 0 + MibIfTableRaw = 1 + MibIfTableNormalWithoutStatistics = 2 +) + +// MibIfTable2 contains a table of logical and physical interface entries. See +// https://learn.microsoft.com/en-us/windows/win32/api/netioapi/ns-netioapi-mib_if_table2. +type MibIfTable2 struct { + NumEntries uint32 + Table [1]MibIfRow2 +} + // IP_ADDRESS_PREFIX stores an IP address prefix. See // https://learn.microsoft.com/en-us/windows/win32/api/netioapi/ns-netioapi-ip_address_prefix. type IpAddressPrefix struct { @@ -2413,6 +2428,13 @@ type MibUnicastIpAddressRow struct { CreationTimeStamp Filetime } +// MibUnicastIpAddressTable contains a table of unicast IP address entries. See +// https://learn.microsoft.com/en-us/windows/win32/api/netioapi/ns-netioapi-mib_unicastipaddress_table. +type MibUnicastIpAddressTable struct { + NumEntries uint32 + Table [1]MibUnicastIpAddressRow +} + const ScopeLevelCount = 16 // MIB_IPINTERFACE_ROW stores interface management information for a particular IP address family on a network interface. @@ -2455,6 +2477,13 @@ type MibIpInterfaceRow struct { DisableDefaultRoutes uint8 } +// MibIpInterfaceTable contains a table of IP interface entries. See +// https://learn.microsoft.com/en-us/windows/win32/api/netioapi/ns-netioapi-mib_ipinterface_table. +type MibIpInterfaceTable struct { + NumEntries uint32 + Table [1]MibIpInterfaceRow +} + // Console related constants used for the mode parameter to SetConsoleMode. See // https://docs.microsoft.com/en-us/windows/console/setconsolemode for details. @@ -3014,8 +3043,10 @@ const ( ) const ( - // FileInformationClass for NtSetInformationFile + // FileInformationClass for NtSetInformationFile/NtQueryInformationFile, see + // https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ne-wdm-_file_information_class FileBasicInformation = 4 + FileEaInformation = 7 FileRenameInformation = 10 FileDispositionInformation = 13 FilePositionInformation = 14 diff --git a/vendor/golang.org/x/sys/windows/zsyscall_windows.go b/vendor/golang.org/x/sys/windows/zsyscall_windows.go index fe7a4ea12..192d19300 100644 --- a/vendor/golang.org/x/sys/windows/zsyscall_windows.go +++ b/vendor/golang.org/x/sys/windows/zsyscall_windows.go @@ -188,9 +188,13 @@ var ( procGetBestInterfaceEx = modiphlpapi.NewProc("GetBestInterfaceEx") procGetIfEntry = modiphlpapi.NewProc("GetIfEntry") procGetIfEntry2Ex = modiphlpapi.NewProc("GetIfEntry2Ex") + procGetIfTable2Ex = modiphlpapi.NewProc("GetIfTable2Ex") procGetIpForwardEntry2 = modiphlpapi.NewProc("GetIpForwardEntry2") procGetIpForwardTable2 = modiphlpapi.NewProc("GetIpForwardTable2") + procGetIpInterfaceEntry = modiphlpapi.NewProc("GetIpInterfaceEntry") + procGetIpInterfaceTable = modiphlpapi.NewProc("GetIpInterfaceTable") procGetUnicastIpAddressEntry = modiphlpapi.NewProc("GetUnicastIpAddressEntry") + procGetUnicastIpAddressTable = modiphlpapi.NewProc("GetUnicastIpAddressTable") procNotifyIpInterfaceChange = modiphlpapi.NewProc("NotifyIpInterfaceChange") procNotifyRouteChange2 = modiphlpapi.NewProc("NotifyRouteChange2") procNotifyUnicastIpAddressChange = modiphlpapi.NewProc("NotifyUnicastIpAddressChange") @@ -424,8 +428,11 @@ var ( procNetUserGetInfo = modnetapi32.NewProc("NetUserGetInfo") procNtCreateFile = modntdll.NewProc("NtCreateFile") procNtCreateNamedPipeFile = modntdll.NewProc("NtCreateNamedPipeFile") + procNtQueryEaFile = modntdll.NewProc("NtQueryEaFile") + procNtQueryInformationFile = modntdll.NewProc("NtQueryInformationFile") procNtQueryInformationProcess = modntdll.NewProc("NtQueryInformationProcess") procNtQuerySystemInformation = modntdll.NewProc("NtQuerySystemInformation") + procNtSetEaFile = modntdll.NewProc("NtSetEaFile") procNtSetInformationFile = modntdll.NewProc("NtSetInformationFile") procNtSetInformationProcess = modntdll.NewProc("NtSetInformationProcess") procNtSetSystemInformation = modntdll.NewProc("NtSetSystemInformation") @@ -1674,6 +1681,14 @@ func GetIfEntry2Ex(level uint32, row *MibIfRow2) (errcode error) { return } +func GetIfTable2Ex(level uint32, table **MibIfTable2) (errcode error) { + r0, _, _ := syscall.SyscallN(procGetIfTable2Ex.Addr(), uintptr(level), uintptr(unsafe.Pointer(table))) + if r0 != 0 { + errcode = syscall.Errno(r0) + } + return +} + func GetIpForwardEntry2(row *MibIpForwardRow2) (errcode error) { r0, _, _ := syscall.SyscallN(procGetIpForwardEntry2.Addr(), uintptr(unsafe.Pointer(row))) if r0 != 0 { @@ -1690,6 +1705,22 @@ func GetIpForwardTable2(family uint16, table **MibIpForwardTable2) (errcode erro return } +func GetIpInterfaceEntry(row *MibIpInterfaceRow) (errcode error) { + r0, _, _ := syscall.SyscallN(procGetIpInterfaceEntry.Addr(), uintptr(unsafe.Pointer(row))) + if r0 != 0 { + errcode = syscall.Errno(r0) + } + return +} + +func GetIpInterfaceTable(family uint16, table **MibIpInterfaceTable) (errcode error) { + r0, _, _ := syscall.SyscallN(procGetIpInterfaceTable.Addr(), uintptr(family), uintptr(unsafe.Pointer(table))) + if r0 != 0 { + errcode = syscall.Errno(r0) + } + return +} + func GetUnicastIpAddressEntry(row *MibUnicastIpAddressRow) (errcode error) { r0, _, _ := syscall.SyscallN(procGetUnicastIpAddressEntry.Addr(), uintptr(unsafe.Pointer(row))) if r0 != 0 { @@ -1698,6 +1729,14 @@ func GetUnicastIpAddressEntry(row *MibUnicastIpAddressRow) (errcode error) { return } +func GetUnicastIpAddressTable(family uint16, table **MibUnicastIpAddressTable) (errcode error) { + r0, _, _ := syscall.SyscallN(procGetUnicastIpAddressTable.Addr(), uintptr(family), uintptr(unsafe.Pointer(table))) + if r0 != 0 { + errcode = syscall.Errno(r0) + } + return +} + func NotifyIpInterfaceChange(family uint16, callback uintptr, callerContext unsafe.Pointer, initialNotification bool, notificationHandle *Handle) (errcode error) { var _p0 uint32 if initialNotification { @@ -3704,6 +3743,30 @@ func NtCreateNamedPipeFile(pipe *Handle, access uint32, oa *OBJECT_ATTRIBUTES, i return } +func NtQueryEaFile(handle Handle, iosb *IO_STATUS_BLOCK, outBuffer *byte, outBufferLen uint32, returnSingleEntry bool, eaList *byte, eaListLen uint32, eaIndex *uint32, restartScan bool) (ntstatus error) { + var _p0 uint32 + if returnSingleEntry { + _p0 = 1 + } + var _p1 uint32 + if restartScan { + _p1 = 1 + } + r0, _, _ := syscall.SyscallN(procNtQueryEaFile.Addr(), uintptr(handle), uintptr(unsafe.Pointer(iosb)), uintptr(unsafe.Pointer(outBuffer)), uintptr(outBufferLen), uintptr(_p0), uintptr(unsafe.Pointer(eaList)), uintptr(eaListLen), uintptr(unsafe.Pointer(eaIndex)), uintptr(_p1)) + if r0 != 0 { + ntstatus = NTStatus(r0) + } + return +} + +func NtQueryInformationFile(handle Handle, iosb *IO_STATUS_BLOCK, outBuffer *byte, outBufferLen uint32, class uint32) (ntstatus error) { + r0, _, _ := syscall.SyscallN(procNtQueryInformationFile.Addr(), uintptr(handle), uintptr(unsafe.Pointer(iosb)), uintptr(unsafe.Pointer(outBuffer)), uintptr(outBufferLen), uintptr(class)) + if r0 != 0 { + ntstatus = NTStatus(r0) + } + return +} + func NtQueryInformationProcess(proc Handle, procInfoClass int32, procInfo unsafe.Pointer, procInfoLen uint32, retLen *uint32) (ntstatus error) { r0, _, _ := syscall.SyscallN(procNtQueryInformationProcess.Addr(), uintptr(proc), uintptr(procInfoClass), uintptr(procInfo), uintptr(procInfoLen), uintptr(unsafe.Pointer(retLen))) if r0 != 0 { @@ -3720,6 +3783,14 @@ func NtQuerySystemInformation(sysInfoClass int32, sysInfo unsafe.Pointer, sysInf return } +func NtSetEaFile(handle Handle, iosb *IO_STATUS_BLOCK, inBuffer *byte, inBufferLen uint32) (ntstatus error) { + r0, _, _ := syscall.SyscallN(procNtSetEaFile.Addr(), uintptr(handle), uintptr(unsafe.Pointer(iosb)), uintptr(unsafe.Pointer(inBuffer)), uintptr(inBufferLen)) + if r0 != 0 { + ntstatus = NTStatus(r0) + } + return +} + func NtSetInformationFile(handle Handle, iosb *IO_STATUS_BLOCK, inBuffer *byte, inBufferLen uint32, class uint32) (ntstatus error) { r0, _, _ := syscall.SyscallN(procNtSetInformationFile.Addr(), uintptr(handle), uintptr(unsafe.Pointer(iosb)), uintptr(unsafe.Pointer(inBuffer)), uintptr(inBufferLen), uintptr(class)) if r0 != 0 { diff --git a/vendor/modules.txt b/vendor/modules.txt index e60827c69..0b303b80c 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -179,7 +179,7 @@ golang.org/x/exp/slices # golang.org/x/sync v0.20.0 ## explicit; go 1.25.0 golang.org/x/sync/errgroup -# golang.org/x/sys v0.43.0 +# golang.org/x/sys v0.45.0 ## explicit; go 1.25.0 golang.org/x/sys/plan9 golang.org/x/sys/unix From 5d08537ea4f9a6e3a31158a48783b6d53f79a9df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 08:00:07 +0000 Subject: [PATCH 033/384] Bump github.com/gdamore/tcell/v3 from 3.3.0 to 3.4.0 Bumps [github.com/gdamore/tcell/v3](https://github.com/gdamore/tcell) from 3.3.0 to 3.4.0. - [Release notes](https://github.com/gdamore/tcell/releases) - [Changelog](https://github.com/gdamore/tcell/blob/main/CHANGESv3.md) - [Commits](https://github.com/gdamore/tcell/compare/v3.3.0...v3.4.0) --- updated-dependencies: - dependency-name: github.com/gdamore/tcell/v3 dependency-version: 3.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 6 +- go.sum | 12 +- .../github.com/gdamore/tcell/v3/CHANGESv3.md | 4 + .../gdamore/tcell/v3/README-wasm.md | 37 +- vendor/github.com/gdamore/tcell/v3/README.md | 18 + vendor/github.com/gdamore/tcell/v3/cell.go | 26 +- vendor/github.com/gdamore/tcell/v3/input.go | 475 +++++++++--- vendor/github.com/gdamore/tcell/v3/key.go | 270 ++++++- vendor/github.com/gdamore/tcell/v3/mouse.go | 5 +- vendor/github.com/gdamore/tcell/v3/screen.go | 25 +- vendor/github.com/gdamore/tcell/v3/style.go | 35 +- vendor/github.com/gdamore/tcell/v3/tscreen.go | 504 +++++++++--- .../gdamore/tcell/v3/tty/tty_win.go | 29 +- .../github.com/gdamore/tcell/v3/tty/utf16.go | 47 ++ .../github.com/gdamore/tcell/v3/vt/emulate.go | 97 ++- vendor/github.com/gdamore/tcell/v3/vt/key.go | 13 +- vendor/github.com/gdamore/tcell/v3/vt/mock.go | 11 +- vendor/github.com/gdamore/tcell/v3/wscreen.go | 729 ++++-------------- vendor/modules.txt | 6 +- 19 files changed, 1508 insertions(+), 841 deletions(-) create mode 100644 vendor/github.com/gdamore/tcell/v3/tty/utf16.go diff --git a/go.mod b/go.mod index b8732b004..52e7da2d5 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/cli/go-gh/v2 v2.13.0 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 github.com/creack/pty v1.1.24 - github.com/gdamore/tcell/v3 v3.3.0 + github.com/gdamore/tcell/v3 v3.4.0 github.com/go-errors/errors v1.5.1 github.com/gookit/color v1.6.1 github.com/integrii/flaggy v1.8.0 @@ -68,8 +68,8 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/term v0.42.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.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 diff --git a/go.sum b/go.sum index f41968456..e92e532e2 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v3 v3.3.0 h1:lGo3VwiV8iYMH4TdwBDwEOoDctnOLns6QfrXvZkKNe8= -github.com/gdamore/tcell/v3 v3.3.0/go.mod h1:8CJpEjUiAlFIrs7jUhzobiZsSLBAtXo8Dq9mCTrVnqo= +github.com/gdamore/tcell/v3 v3.4.0 h1:VUym1HQZiYodA5PGQrqLxF7QwqQndcAUwQD7G7XUy5E= +github.com/gdamore/tcell/v3 v3.4.0/go.mod h1:fjKxNiIFwbzTxDU+i+AAMz+xPOgXVaZq5tbShsKseHc= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= @@ -167,15 +167,15 @@ golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md b/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md index 78e8dbadd..26a0c2e57 100644 --- a/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md +++ b/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md @@ -45,6 +45,10 @@ the associated lower case rune (e.g. "a", "b", etc.) and `ModCtrl`. The `KeyBackspace2` key is no longer delivered, but is converted to `KeyBackspace`. (This resolves some inconsistency around e.g. CTRL-H vs DELETE.) +When advanced key reporting is enabled, Shift-Tab is reported as `KeyTab` with +`ModShift`, not as `KeyBacktab`. Legacy key reporting still reports Shift-Tab +as `KeyBacktab`. + ### Termbox Compatibility Removed The `termbox` compatibility package is removed. Few applications were using it, diff --git a/vendor/github.com/gdamore/tcell/v3/README-wasm.md b/vendor/github.com/gdamore/tcell/v3/README-wasm.md index 09591095e..4e29a3dac 100644 --- a/vendor/github.com/gdamore/tcell/v3/README-wasm.md +++ b/vendor/github.com/gdamore/tcell/v3/README-wasm.md @@ -11,11 +11,30 @@ GOOS=js GOARCH=wasm go build -o yourfile.wasm ## Additional files -You also need 5 other files in the same directory as the wasm. Four (`tcell.html`, `tcell.js`, `termstyle.css`, and `beep.wav`) are provided in the `webfiles` directory. The last one, `wasm_exec.js`, can be copied from GOROOT into the current directory by executing +You also need the supporting web files in the same directory as the wasm. The files `tcell.html`, `tcell.js`, `termstyle.css`, and `beep.wav`, plus the `ghostty-web` directory, are provided in the `webfiles` directory. The last file, `wasm_exec.js`, can be copied from GOROOT into the current directory by executing ```sh cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" ./ ``` +The web frontend uses `ghostty-web`. The required browser runtime files are vendored in `webfiles/ghostty-web` and must be copied alongside `tcell.js`; no npm, bundler, or external CDN is required. The vendored `ghostty-web` files are MIT licensed; see `webfiles/ghostty-web/LICENSE`. + +```sh +cp -R webfiles/ghostty-web /path/to/dir/to/serve/ +``` + +The vendored `ghostty-web.js` is intentionally browser-only. Its upstream Node `readFile` fallback import is removed so browser-oriented servers and bundlers such as Vite do not try to resolve a Node file-system shim; the bundled code loads `ghostty-vt.wasm` with `fetch`. + +For example: + +```sh +mkdir -p /tmp/tcell-wasm +cp webfiles/tcell.html webfiles/tcell.js webfiles/termstyle.css webfiles/beep.wav /tmp/tcell-wasm/ +cp -R webfiles/ghostty-web /tmp/tcell-wasm/ +cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" /tmp/tcell-wasm/ +GOOS=js GOARCH=wasm go build -o /tmp/tcell-wasm/main.wasm ./demos/unicode +python3 -m http.server -d /tmp/tcell-wasm 8080 +``` + In `tcell.js`, you also need to change the constant ```js const wasmFilePath = "yourfile.wasm" @@ -54,8 +73,24 @@ It is recommended to use an iframe if you want to embed the app into a webpage: ``` +### Sizing + +By default the web terminal fits itself to the size of the `#terminal` element and reacts to container resizes. The bundled `termstyle.css` makes this full-page by default. + +You can override the terminal cell dimensions explicitly in HTML: + +```html +

+```
+
+If only one of `data-cols` or `data-rows` is set, the other dimension remains reactive.
+
 ## Other considerations
 
 ### Accessing files
 
 `io.Open(filename)` and other related functions for reading file systems do not work; use `http.Get(filename)` instead.
+
+### Keyboard shortcuts
+
+The browser may reserve some key combinations before JavaScript can see or cancel them. This is especially common for Meta/Command shortcuts on macOS, such as Command-L. Standalone Meta key events can be reported, but Meta-modified key combinations are browser-dependent and should not be relied upon in WASM web mode.
diff --git a/vendor/github.com/gdamore/tcell/v3/README.md b/vendor/github.com/gdamore/tcell/v3/README.md
index f17609bb3..d37c163e7 100644
--- a/vendor/github.com/gdamore/tcell/v3/README.md
+++ b/vendor/github.com/gdamore/tcell/v3/README.md
@@ -119,6 +119,24 @@ than respecting themes. For other cases, such as typical text apps that
 only use a few colors, its more desirable to respect the themes that
 the user has established.)
 
+## Terminal Overrides
+
+_Tcell_ normally negotiates terminal capabilities automatically, but some
+terminal emulators answer those queries incorrectly. These environment
+variables provide user escape hatches when the automatic path is not reliable:
+
+- `TCELL_KEYBOARD_PROTOCOL=auto|legacy|kitty|win32|xterm` forces the keyboard
+  reporting protocol.
+- `TCELL_NEGOTIATE=auto|disable` disables startup capability negotiation when
+  terminal responses themselves are problematic.
+- `TCELL_MOUSE=auto|disable` prevents applications from enabling terminal mouse
+  reporting.
+
+Applications can also choose a keyboard protocol with `OptKeyboardProtocol` or
+disable startup negotiation with `OptNegotiation`. Environment variables take
+precedence so users can recover from bad terminal behavior without modifying an
+application.
+
 ## Performance
 
 Reasonable attempts have been made to minimize sending data to terminals,
diff --git a/vendor/github.com/gdamore/tcell/v3/cell.go b/vendor/github.com/gdamore/tcell/v3/cell.go
index eb2e92f76..cbe2732de 100644
--- a/vendor/github.com/gdamore/tcell/v3/cell.go
+++ b/vendor/github.com/gdamore/tcell/v3/cell.go
@@ -25,7 +25,15 @@ type cell struct {
 
 func (c *cell) setDirty(dirty bool) {
 	if dirty {
-		c.lastStr = ""
+		// Empty cells use currStr == "" until they are first drawn, at which
+		// point SetDirty(false) normalizes them to a space.  Using "" as the
+		// dirty marker for an untouched empty cell would therefore leave
+		// lastStr == currStr and fail to force a redraw.
+		if c.currStr == "" {
+			c.lastStr = " "
+		} else {
+			c.lastStr = ""
+		}
 	} else {
 		if c.currStr == "" {
 			c.currStr = " "
@@ -42,9 +50,10 @@ func (c *cell) setDirty(dirty bool) {
 //
 // CellBuffer is not thread safe.
 type CellBuffer struct {
-	w     int
-	h     int
-	cells []cell
+	w               int
+	h               int
+	cells           []cell
+	sanitizeContent bool
 }
 
 // Put a single styled grapheme using the given string and style
@@ -52,6 +61,13 @@ type CellBuffer struct {
 // will be displayed, using only the 1 or 2 (depending on width) cells
 // located at x, y. It returns the rest of the string, and the width used.
 func (cb *CellBuffer) Put(x int, y int, str string, style Style) (string, int) {
+	if cb.sanitizeContent {
+		str = stripOSCControlsIfNeeded(str)
+	}
+	return cb.put(x, y, str, style)
+}
+
+func (cb *CellBuffer) put(x int, y int, str string, style Style) (string, int) {
 	var width int = 0
 	if x >= 0 && y >= 0 && x < cb.w && y < cb.h {
 		var cl string
@@ -118,7 +134,7 @@ func (cb *CellBuffer) Size() (int, int) {
 // Invalidate marks all characters within the buffer as dirty.
 func (cb *CellBuffer) Invalidate() {
 	for i := range cb.cells {
-		cb.cells[i].lastStr = ""
+		cb.cells[i].setDirty(true)
 	}
 }
 
diff --git a/vendor/github.com/gdamore/tcell/v3/input.go b/vendor/github.com/gdamore/tcell/v3/input.go
index 32e0452e6..58c8a7e0d 100644
--- a/vendor/github.com/gdamore/tcell/v3/input.go
+++ b/vendor/github.com/gdamore/tcell/v3/input.go
@@ -22,8 +22,8 @@
 // There is unfortunately *one* conflict, with aixterm, for CSI-P - which is KeyDelete
 // in aixterm, but F1 in others.
 
-//go:build !js && !wasm
-// +build !js,!wasm
+//go:build (!js && !wasm) || (js && wasm)
+// +build !js,!wasm js,wasm
 
 package tcell
 
@@ -59,31 +59,61 @@ const (
 	istXda  // extended device attributes (ESC P Ps ST)
 )
 
+// defaultControlStringLimit caps inbound OSC/XDA control-string payloads
+// before they can grow without bound while waiting for a string terminator.
+const defaultControlStringLimit = 64 * 1024
+
 func newInputParser(eq chan<- Event) *inputParser {
 	return &inputParser{
-		evch: eq,
-		buf:  make([]rune, 0, 128),
+		evch:             eq,
+		buf:              make([]rune, 0, 128),
+		controlStringMax: defaultControlStringLimit,
 	}
 }
 
 type inputParser struct {
-	buf       []rune       // bytes to process (ingest data)
-	utfBuf    []byte       // accrued UTF8 bytes
-	strBuf    []byte       // accrued string data (for ST, OSC, etc.)
-	csiParams []byte       // accrued parameter bytes for CSI (and SS3)
-	csiInterm []byte       // accrued intermediate bytes for CSI
-	escChar   byte         // last byte for escape
-	escaped   bool         // true if next key should be modified by ESC
-	btnsDown  ButtonMask   // mouse buttons down (excludes wheel buttons)
-	state     inputState   // tracks processor state
-	strState  inputState   // saved str state (needed for ST)
-	l         sync.Mutex   // protects local state
-	evch      chan<- Event // where events are routed
-	rows      int          // used for clipping mouse coordinates
-	cols      int          // used for clipping mouse coordinates
-	keyTime   time.Time    // time of last key press / byte ingested
-	nested    *inputParser // for buggy win32-input-mode implementations
-	surrogate rune         // high surrogate pair seen (for Win32 input mode)
+	buf              []rune       // bytes to process (ingest data)
+	utfBuf           []byte       // accrued UTF8 bytes
+	strBuf           []byte       // accrued string data (for ST, OSC, etc.)
+	csiParams        []byte       // accrued parameter bytes for CSI (and SS3)
+	csiInterm        []byte       // accrued intermediate bytes for CSI
+	escChar          byte         // last byte for escape
+	escaped          bool         // true if next key should be modified by ESC
+	btnsDown         ButtonMask   // mouse buttons down (excludes wheel buttons)
+	state            inputState   // tracks processor state
+	strState         inputState   // saved str state (needed for ST)
+	l                sync.Mutex   // protects local state
+	evch             chan<- Event // where events are routed
+	rows             int          // used for clipping mouse coordinates
+	cols             int          // used for clipping mouse coordinates
+	pixelMouse       bool         // mouse reports in pixels (CSI ?1016h); skip cell clipping
+	keyTime          time.Time    // time of last key press / byte ingested
+	nested           *inputParser // for buggy win32-input-mode implementations
+	surrogate        rune         // high surrogate pair seen (for Win32 input mode)
+	advanced         bool         // use advanced key reporting semantics
+	controlStringMax int          // maximum inbound OSC/XDA payload size; 0 means unlimited
+	discardString    bool         // drop the rest of an over-limit OSC/XDA sequence
+}
+
+func keyFromInt(n int) (Key, bool) {
+	if n < 0 || n > 32767 {
+		return 0, false
+	}
+	return Key(n), true
+}
+
+func keyFromRune(r rune) (Key, bool) {
+	if r < 0 || r > 32767 {
+		return 0, false
+	}
+	return Key(r), true
+}
+
+func asciiByteFromInt(n int) (byte, bool) {
+	if n <= 0 || n >= 0x80 {
+		return 0, false
+	}
+	return byte(n), true
 }
 
 // Waiting returns true if the processor is waiting for
@@ -99,6 +129,21 @@ func (ip *inputParser) Waiting() bool {
 	return ip.state != istInit
 }
 
+// SetPixelMouse toggles whether SGR mouse reports are interpreted as
+// pixel coordinates (CSI ?1016h) rather than character cells (CSI ?1006h).
+// When enabled, mouse coordinates are not clipped to the screen size.
+// The setting is also forwarded to the lazily-created nested parser used
+// for win32-input-mode, if one exists, so both stay in sync.
+func (ip *inputParser) SetPixelMouse(on bool) {
+	ip.l.Lock()
+	ip.pixelMouse = on
+	nested := ip.nested
+	ip.l.Unlock()
+	if nested != nil {
+		nested.SetPixelMouse(on)
+	}
+}
+
 func (ip *inputParser) SetSize(w, h int) {
 	if ip.nested != nil {
 		ip.nested.SetSize(w, h)
@@ -116,7 +161,7 @@ func (ip *inputParser) post(ev Event) {
 	if ip.escaped {
 		ip.escaped = false
 		if ke, ok := ev.(*EventKey); ok {
-			ev = NewEventKey(ke.Key(), ke.Str(), ke.Modifiers()|ModAlt)
+			ev = ip.newKey(ke.Key(), ke.Str(), ke.Modifiers()|ModAlt, ke.Pressed(), ke.Physical(), ke.Repeat())
 		}
 	} else if ke, ok := ev.(*EventKey); ok {
 		switch ke.Key() {
@@ -130,6 +175,31 @@ func (ip *inputParser) post(ev Event) {
 	ip.evch <- ev
 }
 
+func (ip *inputParser) newKey(k Key, str string, mod ModMask, pressed bool, physical Key, repeat int) *EventKey {
+	if ip.advanced {
+		return NewEventKeyEx(k, str, mod, pressed, physical, repeat)
+	}
+	return NewEventKey(k, str, mod)
+}
+
+func (ip *inputParser) postKey(k Key, str string, mod ModMask) {
+	ip.post(ip.newKey(k, str, mod, true, 0, 1))
+}
+
+func (ip *inputParser) postKeyEx(k Key, str string, mod ModMask, pressed bool, physical Key, repeat int) {
+	ip.post(ip.newKey(k, str, mod, pressed, physical, repeat))
+}
+
+func (ip *inputParser) postControlKey(r rune, mod ModMask) {
+	if r == 0 {
+		ip.postKeyEx(KeyRune, " ", mod|ModCtrl, true, Key(' '), 1)
+	} else if ip.advanced && r >= 1 && r <= 26 {
+		ip.postKeyEx(KeyRune, string('a'+r-1), mod|ModCtrl, true, Key('a'+r-1), 1)
+	} else {
+		ip.postKey(KeyRune, string(r+0x40), mod|ModCtrl)
+	}
+}
+
 type csiParamMode struct {
 	M rune // Mode
 	P int  // Parameter (first)
@@ -358,6 +428,14 @@ var csiUKeys = map[int]keyMap{
 	57425: {Key: KeyInsert},          // KP_INSERT
 	57426: {Key: KeyDelete},          // KP_DELETE
 	// 57427: {Key: KeyBegin},          // KP_BEGIN
+	57441: {Key: KeyShift}, // LEFT_SHIFT
+	57442: {Key: KeyCtrl},  // LEFT_CONTROL
+	57443: {Key: KeyAlt},   // LEFT_ALT
+	57444: {Key: KeyMeta},  // LEFT_SUPER
+	57447: {Key: KeyShift}, // RIGHT_SHIFT
+	57448: {Key: KeyCtrl},  // RIGHT_CONTROL
+	57449: {Key: KeyAlt},   // RIGHT_ALT
+	57450: {Key: KeyMeta},  // RIGHT_SUPER
 
 	// TODO: Media keys
 }
@@ -447,7 +525,8 @@ func (ip *inputParser) scan() {
 		if r >= 0xA0 {
 			// 8-bit extended Unicode we just treat as such - this will swallow anything else queued up
 			ip.state = istInit
-			ip.post(NewEventKey(KeyRune, string(r), ModNone))
+			physical, _ := keyFromRune(r)
+			ip.postKeyEx(KeyRune, string(r), ModNone, true, physical, 1)
 			continue
 		} else if r >= 0x80 {
 			// ISO 2022 control chars
@@ -463,19 +542,20 @@ func (ip *inputParser) scan() {
 				ip.state = istEsc
 				ip.escChar = 0
 			case '\t':
-				ip.post(NewEventKey(KeyTab, "", ModNone))
+				ip.postKey(KeyTab, "", ModNone)
 			case '\b', '\x7F':
-				ip.post(NewEventKey(KeyBackspace, "", ModNone))
+				ip.postKey(KeyBackspace, "", ModNone)
 			case '\r':
-				ip.post(NewEventKey(KeyEnter, "", ModNone))
+				ip.postKey(KeyEnter, "", ModNone)
 			default:
 				// Control keys - legacy handling
 				if r == 0 {
-					ip.post(NewEventKey(KeyRune, " ", ModCtrl))
+					ip.postControlKey(r, ModNone)
 				} else if r < ' ' {
-					ip.post(NewEventKey(KeyRune, string(r+0x40), ModCtrl))
+					ip.postControlKey(r, ModNone)
 				} else {
-					ip.post(NewEventKey(KeyRune, string(r), ModNone))
+					physical, _ := keyFromRune(r)
+					ip.postKeyEx(KeyRune, string(r), ModNone, true, physical, 1)
 				}
 			}
 		case istEsc:
@@ -488,6 +568,7 @@ func (ip *inputParser) scan() {
 			case ']':
 				ip.state = istOsc
 				ip.strBuf = nil
+				ip.discardString = false
 				ip.escChar = byte(r)
 			case 'N':
 				ip.state = istSs2 // no known uses
@@ -502,6 +583,7 @@ func (ip *inputParser) scan() {
 				ip.state = istXda
 				ip.csiParams = nil
 				ip.strBuf = nil
+				ip.discardString = false
 				ip.escChar = byte(r)
 			case 'X':
 				ip.state = istSos
@@ -521,7 +603,7 @@ func (ip *inputParser) scan() {
 			case '\t':
 				// Linux console only, does not conform to ECMA
 				ip.state = istInit
-				ip.post(NewEventKey(KeyBacktab, "", ModNone))
+				ip.postKey(KeyBacktab, "", ModNone)
 			default:
 				if r == '\x1b' {
 					// leading ESC to capture alt
@@ -535,7 +617,8 @@ func (ip *inputParser) scan() {
 						mod |= ModCtrl
 						r += 0x60
 					}
-					ip.post(NewEventKey(KeyRune, string(r), mod))
+					physical, _ := keyFromRune(r)
+					ip.postKeyEx(KeyRune, string(r), mod, true, physical, 1)
 				}
 			}
 		case istCsi:
@@ -585,16 +668,16 @@ func (ip *inputParser) scan() {
 				// parameters that do not match one of these forms, we just discard it.
 				if len(ip.csiParams) == 0 {
 					// simple SS3 case
-					ip.post(NewEventKey(k, "", ModNone))
+					ip.postKey(k, "", ModNone)
 				} else if parts := strings.Split(string(ip.csiParams), ";"); len(parts) >= 1 {
 					// SS3 with modifier (old style).  Note old terminfo would declare these as high
 					// numbered function keys, but we encode as modified since that's how they are entered.
 					if len(parts) >= 2 {
 						if m, err := strconv.Atoi(parts[1]); err == nil && (parts[0] == "1" || parts[0] == "") {
-							ip.post(NewEventKey(k, "", calcModifier(m)))
+							ip.postKey(k, "", calcModifier(m))
 						}
 					} else if m, err := strconv.Atoi(parts[0]); err == nil {
-						ip.post(NewEventKey(k, "", calcModifier(m)))
+						ip.postKey(k, "", calcModifier(m))
 					}
 				}
 			}
@@ -614,9 +697,16 @@ func (ip *inputParser) scan() {
 				ip.strState = ip.state
 				ip.state = istSt
 			case '\x07':
-				ip.handleXda(string(ip.strBuf))
+				if ip.discardString {
+					ip.discardString = false
+					ip.state = istInit
+				} else {
+					ip.handleXda(string(ip.strBuf))
+				}
 			default:
-				ip.strBuf = append(ip.strBuf, byte(r&0x7f))
+				if !ip.discardString {
+					ip.appendStringBytes(byte(r & 0x7f))
+				}
 			}
 
 		case istOsc: // not sure if used
@@ -625,29 +715,42 @@ func (ip *inputParser) scan() {
 				ip.strState = ip.state
 				ip.state = istSt
 			case '\x07':
-				ip.handleOsc(string(ip.strBuf))
+				if ip.discardString {
+					ip.discardString = false
+					ip.state = istInit
+				} else {
+					ip.handleOsc(string(ip.strBuf))
+				}
 			default:
-				ip.strBuf = append(ip.strBuf, byte(r&0x7f))
+				if !ip.discardString {
+					ip.appendStringBytes(byte(r & 0x7f))
+				}
 			}
 		case istSt:
 			if r == '\\' || r == '\x07' {
 				ip.state = istInit
-				switch ip.strState {
-				case istOsc:
-					ip.handleOsc(string(ip.strBuf))
-				case istXda:
-					ip.handleXda(string(ip.strBuf))
-				case istPm, istApc, istSos, istDcs:
-					ip.state = istInit
+				if ip.discardString {
+					ip.discardString = false
+				} else {
+					switch ip.strState {
+					case istOsc:
+						ip.handleOsc(string(ip.strBuf))
+					case istXda:
+						ip.handleXda(string(ip.strBuf))
+					case istPm, istApc, istSos, istDcs:
+						ip.state = istInit
+					}
 				}
 			} else {
-				ip.strBuf = append(ip.strBuf, '\x1b', byte(r))
+				if !ip.discardString {
+					ip.appendStringBytes('\x1b', byte(r))
+				}
 				ip.state = ip.strState
 			}
 		case istLnx:
 			// linux console does not follow ECMA
 			if k, ok := linuxFKeys[r]; ok {
-				ip.post(NewEventKey(k, "", ModNone))
+				ip.postKey(k, "", ModNone)
 			}
 			ip.state = istInit
 		}
@@ -655,15 +758,25 @@ func (ip *inputParser) scan() {
 
 	if ip.state != istInit && time.Since(ip.keyTime) > time.Millisecond*50 {
 		if ip.state == istEsc {
-			ip.post(NewEventKey(KeyEscape, "", ModNone))
+			ip.postKey(KeyEscape, "", ModNone)
 		} else if ec := ip.escChar; ec != 0 {
-			ip.post(NewEventKey(KeyRune, string(ec), ModAlt))
+			ip.postKey(KeyRune, string(ec), ModAlt)
 		}
 		// if we take too long between bytes, reset the state machine.
 		ip.state = istInit
+		ip.discardString = false
 	}
 }
 
+func (ip *inputParser) appendStringBytes(bs ...byte) {
+	if ip.controlStringMax > 0 && len(ip.strBuf)+len(bs) > ip.controlStringMax {
+		ip.strBuf = nil
+		ip.discardString = true
+		return
+	}
+	ip.strBuf = append(ip.strBuf, bs...)
+}
+
 func (ip *inputParser) handleOsc(str string) {
 	ip.state = istInit
 	if content, ok := strings.CutPrefix(str, "52;c;"); ok {
@@ -717,6 +830,99 @@ func calcModifier(n int) ModMask {
 	return m
 }
 
+func calcWinModifier(n int, advanced bool) ModMask {
+	m := ModNone
+	if n&0x010 != 0 {
+		m |= ModShift
+	}
+	if advanced {
+		// Bits through 0x0100 match Win32 dwControlKeyState. 0x0040 and
+		// 0x0080 are ScrollLock and CapsLock, not Meta. The 0x0200 and
+		// 0x0400 bits are tcell extensions used by the WASM browser shim,
+		// which has Meta keys but no native Win32 bit assignment for them.
+		if n&0x0008 != 0 {
+			m |= ModLCtrl
+		}
+		if n&0x0004 != 0 {
+			m |= ModRCtrl
+		}
+		if n&0x0002 != 0 {
+			m |= ModLAlt
+		}
+		if n&0x0001 != 0 {
+			m |= ModRAlt
+		}
+		if n&0x0200 != 0 {
+			m |= ModLMeta
+		}
+		if n&0x0400 != 0 {
+			m |= ModRMeta
+		}
+	} else {
+		if n&0x000c != 0 {
+			m |= ModCtrl
+		}
+		if n&0x0003 != 0 {
+			m |= ModAlt
+		}
+	}
+	return m
+}
+
+func winModifierKey(vk int) (Key, ModMask, bool) {
+	switch vk {
+	case 0x10:
+		return KeyShift, ModShift, true
+	case 0xa0:
+		return KeyShift, ModLShift, true
+	case 0xa1:
+		return KeyShift, ModRShift, true
+	case 0x11:
+		return KeyCtrl, ModCtrl, true
+	case 0xa2:
+		return KeyCtrl, ModLCtrl, true
+	case 0xa3:
+		return KeyCtrl, ModRCtrl, true
+	case 0x12:
+		return KeyAlt, ModAlt, true
+	case 0xa4:
+		return KeyAlt, ModLAlt, true
+	case 0xa5:
+		return KeyAlt, ModRAlt, true
+	case 0x5b:
+		return KeyMeta, ModLMeta, true
+	case 0x5c:
+		return KeyMeta, ModRMeta, true
+	case 0x14:
+		return KeyCapsLock, ModNone, true
+	default:
+		return 0, ModNone, false
+	}
+}
+
+func kittyModifierKey(code int) ModMask {
+	switch code {
+	case 57441:
+		return ModLShift
+	case 57447:
+		return ModRShift
+	case 57442:
+		return ModLCtrl
+	case 57448:
+		return ModRCtrl
+	case 57443:
+		return ModLAlt
+	case 57449:
+		return ModRAlt
+	case 57444:
+		return ModLMeta
+	case 57450:
+		return ModRMeta
+	default:
+		return ModNone
+	}
+}
+
 func (ip *inputParser) handleMouse(mode rune, params []int) {
 
 	// XTerm mouse events only report at most one button at a time,
@@ -729,9 +935,15 @@ func (ip *inputParser) handleMouse(mode rune, params []int) {
 	btn := params[0]
 	// Some terminals will report mouse coordinates outside the
 	// screen, especially with click-drag events.  Clip the coordinates
-	// to the screen in that case.
-	x := max(min(params[1]-1, ip.cols-1), 0)
-	y := max(min(params[2]-1, ip.rows-1), 0)
+	// to the screen in that case.  In pixel-reporting mode (CSI ?1016h)
+	// the values are already pixels rather than cells, so skip the clip
+	// and pass them through unchanged for the application to interpret.
+	x := params[1] - 1
+	y := params[2] - 1
+	if !ip.pixelMouse {
+		x = max(min(x, ip.cols-1), 0)
+		y = max(min(y, ip.rows-1), 0)
+	}
 
 	button := ButtonNone
 	mod := ModNone
@@ -827,7 +1039,7 @@ func (ip *inputParser) handleWinKey(P []int) {
 	for len(P) < 6 {
 		P = append(P, 0) // ensure sufficient length
 	}
-	if P[3] == 0 {
+	if P[3] == 0 && !ip.advanced {
 		// key up event ignore ignore
 		return
 	}
@@ -835,58 +1047,81 @@ func (ip *inputParser) handleWinKey(P []int) {
 	// these terminals never send ambiguous escapes
 	ip.escaped = false
 
-	if P[0] == 0 && P[1] == 0 && P[2] > 0 && P[2] < 0x80 { // only ASCII in win32-input-mode
-		if ip.nested == nil {
-			ip.nested = &inputParser{
-				evch: ip.evch,
-				rows: ip.rows,
-				cols: ip.cols,
+	if P[0] == 0 && P[1] == 0 { // only ASCII in win32-input-mode
+		if b, ok := asciiByteFromInt(P[2]); ok {
+			if ip.nested == nil {
+				ip.nested = &inputParser{
+					evch:             ip.evch,
+					rows:             ip.rows,
+					cols:             ip.cols,
+					advanced:         ip.advanced,
+					pixelMouse:       ip.pixelMouse,
+					controlStringMax: ip.controlStringMax,
+				}
 			}
+			ip.nested.ScanUTF8([]byte{b})
+			return
 		}
-		if P[2] > 0 {
-			ip.nested.ScanUTF8([]byte{byte(P[2])})
-		}
-		return
 	}
 
 	key := KeyRune
 	chr := rune(P[2])
 	mod := ModNone
 	rpt := max(1, P[5])
+	decoded := false
 	if k1, ok := winKeys[P[0]]; ok {
 		chr = 0
 		key = k1
+		decoded = true
+	} else if ip.advanced {
+		if k1, mod1, ok := winModifierKey(P[0]); ok {
+			key = k1
+			mod = mod1
+			chr = 0
+			decoded = true
+		}
+	}
+	if decoded {
+		// Already decoded.
 	} else if chr == 0 && P[0] >= 0x30 && P[0] <= 0x39 {
 		chr = rune(P[0])
 	} else if chr < ' ' && P[0] >= 0x41 && P[0] <= 0x5a {
-		key = Key(P[0])
-		chr = 0
+		if ip.advanced {
+			key = KeyRune
+			chr = rune(P[0] + 0x20)
+		} else {
+			var ok bool
+			if key, ok = keyFromInt(P[0]); !ok {
+				return
+			}
+			chr = 0
+		}
 	} else if chr >= 0xD800 && chr <= 0xDBFF {
 		// high surrogate pair
+		if ip.surrogate != 0 {
+			ip.postKeyEx(KeyRune, string(utf8.RuneError), mod, P[3] != 0, 0, rpt)
+		}
 		ip.surrogate = chr
 		return
 	} else if chr >= 0xDC00 && chr <= 0xDFFF {
 		// low surrogate pair
-		chr = utf16.DecodeRune(ip.surrogate, chr)
-	} else if P[0] == 0x10 || P[0] == 0x11 || P[0] == 0x12 || P[0] == 0x14 {
-		// lone modifiers
+		if ip.surrogate == 0 {
+			chr = utf8.RuneError
+		} else {
+			chr = utf16.DecodeRune(ip.surrogate, chr)
+		}
+	} else if ip.surrogate != 0 {
+		ip.postKeyEx(KeyRune, string(utf8.RuneError), mod, P[3] != 0, 0, rpt)
+	} else if _, _, ok := winModifierKey(P[0]); ok {
+		// Lone modifier releases are ignored unless advanced mode is enabled.
 		ip.surrogate = 0
 		return
 	}
 
 	ip.surrogate = 0
 
-	// Modifiers
-	if P[4]&0x010 != 0 {
-		mod |= ModShift
-	}
-	if P[4]&0x000c != 0 {
-		mod |= ModCtrl
-	}
-	if P[4]&0x0003 != 0 {
-		mod |= ModAlt
-	}
-	if key == KeyRune && chr > ' ' && mod == ModShift {
+	mod |= calcWinModifier(P[4], ip.advanced)
+	if key == KeyRune && chr > ' ' && mod == ModShift && !ip.advanced {
 		// filter out lone shift for printable chars
 		mod = ModNone
 	}
@@ -895,13 +1130,18 @@ func (ip *inputParser) handleWinKey(P []int) {
 		mod = ModNone
 	}
 
-	for range rpt {
-		if key != KeyRune {
-			ip.post(NewEventKey(key, "", mod))
-		} else if chr != 0 {
-			ip.post(NewEventKey(KeyRune, string(chr), mod))
+	physical := key
+	if key == KeyRune && chr != 0 {
+		physical, _ = keyFromRune(chr)
+		if ip.advanced && P[0] >= 0x41 && P[0] <= 0x5a {
+			physical, _ = keyFromInt(P[0] + 0x20)
 		}
 	}
+	if key != KeyRune {
+		ip.postKeyEx(key, "", mod, P[3] != 0, physical, rpt)
+	} else if chr != 0 {
+		ip.postKeyEx(KeyRune, string(chr), mod, P[3] != 0, physical, rpt)
+	}
 }
 
 func (ip *inputParser) handlePrimaryDA(params []int) {
@@ -976,7 +1216,6 @@ func (ip *inputParser) handleCsi(mode rune, params []byte, intermediate []byte)
 	// reset state
 	ip.state = istInit
 
-	var parts []string
 	var P []int
 	hasLT := false
 	hasQM := false
@@ -994,16 +1233,54 @@ func (ip *inputParser) handleCsi(mode rune, params []byte, intermediate []byte)
 		pstr = pstr[1:]
 	}
 
+	pressed := true
+	repeat := 1
+	physical := Key(0)
 	if pstr != "" && pstr[0] >= '0' && pstr[0] <= '9' {
-		parts = strings.Split(pstr, ";")
+		var PSubs [][]int
+
+		parts := strings.Split(pstr, ";")
 		for i := range parts {
-			if parts[i] != "" {
-				if n, e := strconv.ParseInt(parts[i], 10, 32); e == nil {
+			subparts := strings.Split(parts[i], ":")
+			if subparts[0] != "" {
+				if n, e := strconv.ParseInt(subparts[0], 10, 32); e == nil {
 					P = append(P, int(n))
+				} else {
+					P = append(P, 0)
 				}
 			} else {
 				P = append(P, 0)
 			}
+			subs := []int{}
+			for _, sub := range subparts[1:] {
+				if sub != "" {
+					if n, e := strconv.ParseInt(sub, 10, 32); e == nil {
+						subs = append(subs, int(n))
+					}
+				} else {
+					subs = append(subs, 0)
+				}
+			}
+			PSubs = append(PSubs, subs)
+		}
+		if len(PSubs) > 1 && len(PSubs[1]) > 0 {
+			switch PSubs[1][0] {
+			case 2:
+				repeat = 2
+			case 3:
+				pressed = false
+			}
+		}
+		if len(PSubs) > 0 && len(PSubs[0]) > 0 {
+			base := PSubs[0][0]
+			if baseKey, ok := csiUKeys[base]; ok {
+				physical = baseKey.Key
+				if physical == KeyRune && baseKey.Rune != 0 {
+					physical, _ = keyFromRune(baseKey.Rune)
+				}
+			} else if base != 0 {
+				physical, _ = keyFromInt(base)
+			}
 		}
 	}
 	var P0 int
@@ -1076,10 +1353,13 @@ func (ip *inputParser) handleCsi(mode rune, params []byte, intermediate []byte)
 			if len(P) > 1 {
 				mod = calcModifier(P[1])
 			}
+			if mod1 := kittyModifierKey(P0); mod1 != ModNone {
+				mod |= mod1
+			}
 			if key != KeyRune {
-				ip.post(NewEventKey(key, "", mod))
+				ip.postKeyEx(key, "", mod, pressed, physical, repeat)
 			} else if chr != 0 {
-				ip.post(NewEventKey(KeyRune, string(chr), mod))
+				ip.postKeyEx(KeyRune, string(chr), mod, pressed, physical, repeat)
 			}
 			return
 		}
@@ -1114,14 +1394,17 @@ func (ip *inputParser) handleCsi(mode rune, params []byte, intermediate []byte)
 		if len(P) >= 2 {
 			mod := calcModifier(P[1])
 			if ks, ok := csiAllKeys[csiParamMode{M: mode, P: P0}]; ok {
-				ip.post(NewEventKey(ks.Key, "", mod))
+				ip.postKeyEx(ks.Key, "", mod, pressed, 0, repeat)
 				return
 			}
-			if P0 == 27 && len(P) > 2 && P[2] > 0 && P[2] <= 0xff {
+			if P0 == 27 && len(P) > 2 && P[2] > 0 && P[2] <= utf8.MaxRune {
 				if P[2] < ' ' || P[2] == 0x7F {
-					ip.post(NewEventKey(Key(P[2]), "", mod))
+					if key, ok := keyFromInt(P[2]); ok {
+						ip.postKey(key, "", mod)
+					}
 				} else {
-					ip.post(NewEventKey(KeyRune, string(rune(P[2])), mod))
+					physical, _ := keyFromRune(rune(P[2]))
+					ip.postKeyEx(KeyRune, string(rune(P[2])), mod, true, physical, 1)
 				}
 				return
 			}
@@ -1135,13 +1418,13 @@ func (ip *inputParser) handleCsi(mode rune, params []byte, intermediate []byte)
 		} else if mode == 'P' && os.Getenv("TERM") == "aixterm" {
 			ks.Key = KeyDelete // aixterm hack - conflicts with kitty protocol
 		}
-		ip.post(NewEventKey(ks.Key, "", ks.Mod))
+		ip.postKey(ks.Key, "", ks.Mod)
 		return
 	}
 
 	// this might have been an SS3 style key with modifiers applied
 	if k, ok := ss3Keys[mode]; ok && P0 == 1 && len(P) > 1 {
-		ip.post(NewEventKey(k, "", calcModifier(P[1])))
+		ip.postKeyEx(k, "", calcModifier(P[1]), pressed, 0, repeat)
 		return
 	}
 	// if we got here we just swallow the unknown sequence
diff --git a/vendor/github.com/gdamore/tcell/v3/key.go b/vendor/github.com/gdamore/tcell/v3/key.go
index db4d79b21..bb4c790ea 100644
--- a/vendor/github.com/gdamore/tcell/v3/key.go
+++ b/vendor/github.com/gdamore/tcell/v3/key.go
@@ -1,4 +1,4 @@
-// Copyright 2025 The TCell Authors
+// Copyright 2026 The TCell Authors
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -45,9 +45,12 @@ import (
 // specific keys.
 type EventKey struct {
 	EventTime
-	mod ModMask
-	key Key
-	str string // string for key, usually just one character, but may be composed sequence
+	mod      ModMask
+	key      Key
+	physical Key
+	str      string // string for key, usually just one character, but may be composed sequence
+	pressed  bool
+	repeat   int
 }
 
 // Str returns the string corresponding to the key press, if it makes sense.
@@ -66,6 +69,38 @@ func (ev *EventKey) Key() Key {
 	return ev.key
 }
 
+// Physical returns the physical key that was pressed, when known.
+//
+// This is different from Key() and Str(), which describe the logical key result
+// delivered to the application.  For example, on a US keyboard Shift-/ may
+// produce Str() == "?", while Physical() reports KeySlash.  Most applications
+// should use Key() and Str(); Physical is intended for layout-independent uses
+// such as keyboard remappers, embedded terminal emulators, and games that care
+// about key location rather than the printed character.
+//
+// For letter keys, compare physical values against the lowercase aliases
+// KeyA through KeyZ.  The legacy KeyCtrlA through KeyCtrlZ constants occupy
+// the same numeric range as Key('A') through Key('Z'), so Key('A') is not a
+// physical "A" key identifier.
+//
+// If the physical key is unknown, this returns zero.
+func (ev *EventKey) Physical() Key {
+	return ev.physical
+}
+
+// Pressed returns true for key press events, and false for key release events.
+// Legacy keyboard reporting only reports presses.
+func (ev *EventKey) Pressed() bool {
+	return ev.pressed
+}
+
+// Repeat returns the repeat count for this key event.  Legacy keyboard
+// reporting synthesizes repeated key presses as separate events, so this will
+// normally be 1.
+func (ev *EventKey) Repeat() int {
+	return ev.repeat
+}
+
 // Modifiers returns the modifiers that were present with the key press.  Note
 // that not all platforms and terminals support this equally well, and some
 // cases we will not not know for sure.  Hence, applications should avoid
@@ -74,6 +109,19 @@ func (ev *EventKey) Modifiers() ModMask {
 	return ev.mod
 }
 
+// KeyProtocol identifies the keyboard reporting protocol that the terminal
+// is currently using.  More capable protocols allow disambiguating modifier
+// combinations, distinguishing key release events, etc.
+type KeyProtocol int
+
+// These are the keyboard protocols that tcell can report.
+const (
+	LegacyKeyboard KeyProtocol = iota // basic VT100 style reports
+	KittyKeyboard                     // kitty supports events, unambiguous keys modulo left/right modifiers
+	Win32Keyboard                     // win32 supports the full feature set
+	XTermKeyboard                     // xterm modify other keys, disambiguation only, no release events
+)
+
 // KeyNames holds the written names of special keys. Useful to echo back a key
 // name, or to look up a key from a string value.
 var KeyNames = map[Key]string{
@@ -171,6 +219,11 @@ var KeyNames = map[Key]string{
 	KeyCapsLock:   "CapsLock",
 	KeyScrollLock: "ScrollLock",
 	KeyNumLock:    "NumLock",
+	KeyShift:      "Shift",
+	KeyCtrl:       "Ctrl",
+	KeyAlt:        "Alt",
+	KeyMeta:       "Meta",
+	KeyHyper:      "Hyper",
 	KeyCtrlA:      "Ctrl-A",
 	KeyCtrlB:      "Ctrl-B",
 	KeyCtrlC:      "Ctrl-C",
@@ -204,19 +257,49 @@ var KeyNames = map[Key]string{
 func (ev *EventKey) Name() string {
 	s := ""
 	m := []string{}
-	if ev.mod&ModShift != 0 {
+	if ev.mod&modLShift != 0 {
+		m = append(m, "LeftShift")
+	}
+	if ev.mod&modRShift != 0 {
+		m = append(m, "RightShift")
+	}
+	if ev.mod&ModShift != 0 && ev.mod&(modLShift|modRShift) == 0 {
 		m = append(m, "Shift")
 	}
-	if ev.mod&ModAlt != 0 {
+	if ev.mod&modLAlt != 0 {
+		m = append(m, "LeftAlt")
+	}
+	if ev.mod&modRAlt != 0 {
+		m = append(m, "RightAlt")
+	}
+	if ev.mod&ModAlt != 0 && ev.mod&(modLAlt|modRAlt) == 0 {
 		m = append(m, "Alt")
 	}
-	if ev.mod&ModMeta != 0 {
+	if ev.mod&modLMeta != 0 {
+		m = append(m, "LeftMeta")
+	}
+	if ev.mod&modRMeta != 0 {
+		m = append(m, "RightMeta")
+	}
+	if ev.mod&ModMeta != 0 && ev.mod&(modLMeta|modRMeta) == 0 {
 		m = append(m, "Meta")
 	}
-	if ev.mod&ModCtrl != 0 {
+	if ev.mod&modLCtrl != 0 {
+		m = append(m, "LeftCtrl")
+	}
+	if ev.mod&modRCtrl != 0 {
+		m = append(m, "RightCtrl")
+	}
+	if ev.mod&ModCtrl != 0 && ev.mod&(modLCtrl|modRCtrl) == 0 {
 		m = append(m, "Ctrl")
 	}
-	if ev.mod&ModHyper != 0 {
+	if ev.mod&modLHyper != 0 {
+		m = append(m, "LeftHyper")
+	}
+	if ev.mod&modRHyper != 0 {
+		m = append(m, "RightHyper")
+	}
+	if ev.mod&ModHyper != 0 && ev.mod&(modLHyper|modRHyper) == 0 {
 		m = append(m, "Hyper")
 	}
 
@@ -229,6 +312,28 @@ func (ev *EventKey) Name() string {
 		}
 	}
 	if len(m) != 0 {
+		switch ev.key {
+		case KeyShift:
+			if ev.mod&(modLShift|modRShift|ModShift) != 0 {
+				return strings.Join(m, "+")
+			}
+		case KeyCtrl:
+			if ev.mod&(modLCtrl|modRCtrl|ModCtrl) != 0 {
+				return strings.Join(m, "+")
+			}
+		case KeyAlt:
+			if ev.mod&(modLAlt|modRAlt|ModAlt) != 0 {
+				return strings.Join(m, "+")
+			}
+		case KeyMeta:
+			if ev.mod&(modLMeta|modRMeta|ModMeta) != 0 {
+				return strings.Join(m, "+")
+			}
+		case KeyHyper:
+			if ev.mod&(modLHyper|modRHyper|ModHyper) != 0 {
+				return strings.Join(m, "+")
+			}
+		}
 		if ev.mod&ModCtrl != 0 && strings.HasPrefix(s, "Ctrl-") {
 			s = s[5:]
 		}
@@ -242,10 +347,25 @@ func (ev *EventKey) Name() string {
 // has more precise information it should set that specifically.  Callers
 // that aren't sure about modifier state (most) should just pass ModNone.
 func NewEventKey(k Key, str string, mod ModMask) *EventKey {
+	return newEventKey(k, str, mod, true, 0, 1, false)
+}
+
+// NewEventKeyEx creates an extended key event with press/release, physical key,
+// and repeat metadata.  It also uses the newer key normalization rules: ASCII
+// control letters are reported as KeyRune plus ModCtrl instead of legacy
+// KeyCtrlA through KeyCtrlZ values.
+func NewEventKeyEx(k Key, str string, mod ModMask, pressed bool, physical Key, repeat int) *EventKey {
+	return newEventKey(k, str, mod, pressed, physical, repeat, true)
+}
+
+func newEventKey(k Key, str string, mod ModMask, pressed bool, physical Key, repeat int, advanced bool) *EventKey {
 	ch := rune(0)
 	if len(str) == 1 {
 		ch = []rune(str)[0]
 	}
+	if repeat <= 0 {
+		repeat = 1
+	}
 
 	if k == KeyRune {
 		if ch != 0 && (ch < ' ' || ch == 0x7f) {
@@ -267,7 +387,7 @@ func NewEventKey(k Key, str string, mod ModMask) *EventKey {
 
 		// For legacy reasons, if Ctrl is pressed with an ASCII alphabetic, then we
 		// emit it as a KeyCtrlXX symbol.
-		if mod == ModCtrl {
+		if mod == ModCtrl && !advanced {
 			// We don't do Ctrl-[ or backslash or those specially.
 			if ch >= 'A' && ch <= 'Z' { // upper case
 				k = KeyCtrlA + Key(ch-'A')
@@ -280,7 +400,7 @@ func NewEventKey(k Key, str string, mod ModMask) *EventKey {
 
 		// Windows reports ModShift for shifted keys.  This is inconsistent
 		// with UNIX, lets harmonize this.
-		if mod == ModShift && str != "" {
+		if mod == ModShift && str != "" && !advanced {
 			mod = ModNone
 		}
 	}
@@ -290,19 +410,29 @@ func NewEventKey(k Key, str string, mod ModMask) *EventKey {
 		k = KeyBackspace
 	}
 
+	// Advanced key reporting exposes Shift-Tab directly.  Backtab is a legacy
+	// alias from terminals that cannot distinguish a physical Backtab key.
+	if k == KeyBacktab && advanced {
+		k = KeyTab
+		mod |= ModShift
+		if physical == 0 || physical == KeyBacktab {
+			physical = KeyTab
+		}
+	}
+
 	// Shift-Tab should be Backtab.
-	if k == KeyTab && (mod&ModShift) != 0 {
+	if k == KeyTab && (mod&ModShift) != 0 && !advanced {
 		k = KeyBacktab
 		mod &^= ModShift
 	}
-	ev := &EventKey{key: k, str: str, mod: mod}
+	ev := &EventKey{key: k, str: str, mod: mod, pressed: pressed, physical: physical, repeat: repeat}
 	ev.SetEventNow()
 	return ev
 }
 
 // ModMask is a mask of modifier keys.  Note that it will not always be
 // possible to report modifier keys.
-type ModMask int16
+type ModMask int32
 
 // These are the modifiers keys that can be sent either with a key press,
 // or a mouse event.  Note that as of now, due to the confusion associated
@@ -318,6 +448,103 @@ const (
 	ModNone ModMask = 0
 )
 
+const (
+	modLShift ModMask = 1 << (iota + 5)
+	modRShift
+	modLCtrl
+	modRCtrl
+	modLAlt
+	modRAlt
+	modLMeta
+	modRMeta
+	modLHyper
+	modRHyper
+)
+
+// These modifiers identify a specific side when the keyboard protocol reports
+// one.  They include the aggregate modifier bit, so ModLCtrl also satisfies
+// checks for ModCtrl.
+const (
+	ModLShift = ModShift | modLShift
+	ModRShift = ModShift | modRShift
+	ModLCtrl  = ModCtrl | modLCtrl
+	ModRCtrl  = ModCtrl | modRCtrl
+	ModLAlt   = ModAlt | modLAlt
+	ModRAlt   = ModAlt | modRAlt
+	ModLMeta  = ModMeta | modLMeta
+	ModRMeta  = ModMeta | modRMeta
+	ModLHyper = ModHyper | modLHyper
+	ModRHyper = ModHyper | modRHyper
+)
+
+// These keys are aliases for printable physical keys.  They are primarily
+// useful with EventKey.Physical, which may report a base key location separately
+// from the generated text.
+//
+// These names identify the unshifted base key on a US-style keyboard layout.
+// They do not identify logical characters produced by modifiers or other
+// layouts.  For example, the physical key named KeySlash may produce "/" or
+// "?" on a US keyboard depending on Shift, and may produce different text on
+// other layouts.  Applications interested in the logical key sequence should
+// use EventKey.Key and EventKey.Str instead.
+const (
+	KeySpace Key = ' '
+	Key0     Key = '0'
+	Key1     Key = '1'
+	Key2     Key = '2'
+	Key3     Key = '3'
+	Key4     Key = '4'
+	Key5     Key = '5'
+	Key6     Key = '6'
+	Key7     Key = '7'
+	Key8     Key = '8'
+	Key9     Key = '9'
+
+	KeyGrave      Key = '`'
+	KeyBacktick   Key = KeyGrave
+	KeyMinus      Key = '-'
+	KeyEqual      Key = '='
+	KeyLBrace     Key = '['
+	KeyLBracket   Key = KeyLBrace
+	KeyRBrace     Key = ']'
+	KeyRBracket   Key = KeyRBrace
+	KeyBackslash  Key = '\\'
+	KeySemi       Key = ';'
+	KeySemicolon  Key = KeySemi
+	KeyQuote      Key = '\''
+	KeyApostrophe Key = KeyQuote
+	KeyComma      Key = ','
+	KeyPeriod     Key = '.'
+	KeySlash      Key = '/'
+
+	KeyA Key = 'a'
+	KeyB Key = 'b'
+	KeyC Key = 'c'
+	KeyD Key = 'd'
+	KeyE Key = 'e'
+	KeyF Key = 'f'
+	KeyG Key = 'g'
+	KeyH Key = 'h'
+	KeyI Key = 'i'
+	KeyJ Key = 'j'
+	KeyK Key = 'k'
+	KeyL Key = 'l'
+	KeyM Key = 'm'
+	KeyN Key = 'n'
+	KeyO Key = 'o'
+	KeyP Key = 'p'
+	KeyQ Key = 'q'
+	KeyR Key = 'r'
+	KeyS Key = 's'
+	KeyT Key = 't'
+	KeyU Key = 'u'
+	KeyV Key = 'v'
+	KeyW Key = 'w'
+	KeyX Key = 'x'
+	KeyY Key = 'y'
+	KeyZ Key = 'z'
+)
+
 // Key is a generic value for representing keys, and especially special
 // keys (function keys, cursor movement keys, etc.)  For normal keys, like
 // ASCII letters, we use KeyRune, and then expect the application to
@@ -351,6 +578,8 @@ const (
 	KeyCancel
 	KeyPrint
 	KeyPause
+	// KeyBacktab is used for legacy Shift-Tab reporting.  In advanced key
+	// reporting mode, Shift-Tab is reported as KeyTab with ModShift instead.
 	KeyBacktab
 	KeyF1
 	KeyF2
@@ -420,6 +649,11 @@ const (
 	KeyCapsLock
 	KeyScrollLock
 	KeyNumLock
+	KeyShift
+	KeyCtrl
+	KeyAlt
+	KeyMeta
+	KeyHyper
 )
 
 const (
@@ -432,6 +666,10 @@ const (
 // rune (lower case) and control modifier.  If the shift key
 // or other modifiers are present then these will *NOT* be reported,
 // but reported instead as KeyRune.
+//
+// Note that these are not reported in advanced key reporting mode.
+// Instead, for advanced keys, expect KeyRune and a modifier with the
+// associated rune to be sent.
 const (
 	KeyCtrlA Key = iota + 65
 	KeyCtrlB
@@ -466,6 +704,10 @@ const (
 
 // These are the defined ASCII values for key codes.  They generally match
 // with KeyCtrl values.
+//
+// Most of these will not be reported in advanced key reporting mode, as they
+// are not possible to type directly. Some notable exceptions are KeyESC, KeyBS,
+// KeyTAB, and KeyCR, which have aliases below.
 const (
 	KeyNUL Key = iota
 	KeySOH
diff --git a/vendor/github.com/gdamore/tcell/v3/mouse.go b/vendor/github.com/gdamore/tcell/v3/mouse.go
index c78092c1d..683ae0243 100644
--- a/vendor/github.com/gdamore/tcell/v3/mouse.go
+++ b/vendor/github.com/gdamore/tcell/v3/mouse.go
@@ -49,8 +49,9 @@ func (ev *EventMouse) Modifiers() ModMask {
 	return ev.mod
 }
 
-// Position returns the mouse position in character cells.  The origin
-// 0, 0 is at the upper left corner.
+// Position returns the mouse position.  The origin 0, 0 is at the upper
+// left corner.  The unit is character cells unless the screen was started
+// with MousePixelEvents, in which case the unit is terminal pixels.
 func (ev *EventMouse) Position() (int, int) {
 	return ev.x, ev.y
 }
diff --git a/vendor/github.com/gdamore/tcell/v3/screen.go b/vendor/github.com/gdamore/tcell/v3/screen.go
index f8c93e5c4..553709c4d 100644
--- a/vendor/github.com/gdamore/tcell/v3/screen.go
+++ b/vendor/github.com/gdamore/tcell/v3/screen.go
@@ -249,6 +249,9 @@ type Screen interface {
 	// supports it.  Right now only terminals supporting OSC 777 support this.
 	ShowNotification(title string, body string)
 
+	// KeyboardProtocol returns the keyboard protocol currently in use.
+	KeyboardProtocol() KeyProtocol
+
 	// Terminal returns the terminal name and version if known.  If either of these
 	// are unknown, then empty strings are returned in their place.  This is intended
 	// to facilitate debug, and also applications that wish to enable very specific
@@ -260,7 +263,8 @@ var overrideScreen chan Screen
 var overrideOnce sync.Once
 
 // NewScreen returns a default Screen suitable for the user's terminal environment.
-func NewScreen() (Screen, error) {
+// Any options are passed through to NewTerminfoScreen.
+func NewScreen(opts ...TerminfoScreenOption) (Screen, error) {
 
 	// Allow an application (presumably test code) to inject a replacement default
 	// screen.  This could also be used to create shims for things like nesting screens.
@@ -270,7 +274,7 @@ func NewScreen() (Screen, error) {
 	default:
 	}
 
-	if s, e := NewTerminfoScreen(); s != nil {
+	if s, e := NewTerminfoScreen(opts...); s != nil {
 		return s, nil
 	} else {
 		return nil, e
@@ -296,6 +300,15 @@ const (
 	MouseButtonEvents = MouseFlags(1) // Click events only
 	MouseDragEvents   = MouseFlags(2) // Click-drag events (includes button events)
 	MouseMotionEvents = MouseFlags(4) // All mouse events (includes click and drag events)
+	// MousePixelEvents requests that mouse coordinates be reported in
+	// terminal pixels rather than character cells (xterm SGR-Pixel mode,
+	// CSI ?1016h). It is a modifier on the other mouse flags: at least one
+	// of MouseButtonEvents, MouseDragEvents, or MouseMotionEvents must also
+	// be set for any events to be delivered. When this mode is active,
+	// EventMouse.Position() returns coordinates in pixels; the application
+	// is responsible for mapping those to its own grid (e.g. via the
+	// terminal's reported cell-pixel size).
+	MousePixelEvents = MouseFlags(8)
 )
 
 // CursorStyle represents a given cursor style, which can include the shape and
@@ -345,6 +358,7 @@ type screenImpl interface {
 	GetClipboard()
 	HasClipboard() bool
 	ShowNotification(string, string)
+	KeyboardProtocol() KeyProtocol
 	Terminal() (string, string)
 
 	// Following methods are not part of the Screen api, but are used for interaction with
@@ -382,16 +396,19 @@ func (b *baseScreen) Put(x int, y int, str string, style Style) (remain string,
 func (b *baseScreen) PutStrStyled(x int, y int, str string, style Style) {
 	cells := b.GetCells()
 	b.Lock()
+	defer b.Unlock()
 	cols, rows := cells.Size()
+	if cells.sanitizeContent {
+		str = stripOSCControlsIfNeeded(str)
+	}
 	width := 0
 	for str != "" && x < cols && y < rows {
-		str, width = cells.Put(x, y, str, style)
+		str, width = cells.put(x, y, str, style)
 		if width == 0 {
 			break
 		}
 		x += width
 	}
-	defer b.Unlock()
 }
 
 func (b *baseScreen) PutStr(x, y int, str string) {
diff --git a/vendor/github.com/gdamore/tcell/v3/style.go b/vendor/github.com/gdamore/tcell/v3/style.go
index 8aedd97db..7f629da3f 100644
--- a/vendor/github.com/gdamore/tcell/v3/style.go
+++ b/vendor/github.com/gdamore/tcell/v3/style.go
@@ -43,6 +43,7 @@ type urlInfo struct {
 	id  string
 }
 
+// stripOSCControls removes control bytes that can terminate OSC payloads early.
 func stripOSCControls(s string) string {
 	var b strings.Builder
 	b.Grow(len(s))
@@ -68,6 +69,18 @@ func stripOSCControls(s string) string {
 	return b.String()
 }
 
+// stripOSCControlsIfNeeded returns the original string when it contains no
+// control bytes and only allocates when stripping is required.
+func stripOSCControlsIfNeeded(s string) string {
+	for i := 0; i < len(s); i++ {
+		c := s[i]
+		if c <= 0x1f || c == 0x7f || (c >= 0x80 && c <= 0x9f) {
+			return stripOSCControls(s)
+		}
+	}
+	return s
+}
+
 // StyleDefault represents a default style, based upon the context.
 // It is the zero value.
 var StyleDefault Style
@@ -102,10 +115,15 @@ func (s Style) setAttrs(attrs AttrMask, on bool) Style {
 }
 
 // Normal returns the style with all attributes disabled.
+// Colors are preserved, as are hyperlinks.  (Underline color
+// will also be preserved, but no underline is currently shown.
+// Apart from color, the underline style is reset as well.)
 func (s Style) Normal() Style {
 	return Style{
-		fg: s.fg,
-		bg: s.bg,
+		fg:      s.fg,
+		bg:      s.bg,
+		ulColor: s.ulColor,
+		url:     s.url,
 	}
 }
 
@@ -227,10 +245,13 @@ func (s Style) GetAttributes() AttrMask {
 func (s Style) Url(url string) Style {
 
 	s2 := s
-	s2.url = &urlInfo{url: stripOSCControls(url)}
+	s2.url = &urlInfo{url: stripOSCControlsIfNeeded(url)}
 	if s.url != nil {
 		s2.url.id = s.url.id
 	}
+	if s2.url.url == "" && s2.url.id == "" {
+		s2.url = nil
+	}
 	return s2
 }
 
@@ -240,12 +261,16 @@ func (s Style) Url(url string) Style {
 // were one Url, even if it spans multiple lines.
 func (s Style) UrlId(id string) Style {
 	s2 := s
-	s2.url = &urlInfo{
-		id: "id=" + stripOSCControls(id),
+	s2.url = &urlInfo{}
+	if id = stripOSCControlsIfNeeded(id); id != "" {
+		s2.url.id = "id=" + id
 	}
 	if s.url != nil {
 		s2.url.url = s.url.url
 	}
+	if s2.url.url == "" && s2.url.id == "" {
+		s2.url = nil
+	}
 	return s2
 }
 
diff --git a/vendor/github.com/gdamore/tcell/v3/tscreen.go b/vendor/github.com/gdamore/tcell/v3/tscreen.go
index 3ff2c85bb..2fa180f63 100644
--- a/vendor/github.com/gdamore/tcell/v3/tscreen.go
+++ b/vendor/github.com/gdamore/tcell/v3/tscreen.go
@@ -12,8 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-//go:build !js && !wasm
-// +build !js,!wasm
+//go:build (!js && !wasm) || (js && wasm)
+// +build !js,!wasm js,wasm
 
 package tcell
 
@@ -83,6 +83,53 @@ func (o OptAltScreen) apply(t *tScreen) {
 	t.altScreen = bool(o)
 }
 
+// OptSanitizeContent enables stripping control characters from content passed
+// to Put and PutStr. This is safer, but a little slower than leaving content
+// unsanitized.
+type OptSanitizeContent bool
+
+func (o OptSanitizeContent) apply(t *tScreen) {
+	t.cells.sanitizeContent = bool(o)
+}
+
+// OptAdvancedKeys enables richer key reporting where supported.  In this mode
+// key events may include release state, repeat counts, and physical keys, and
+// ASCII control letters are reported as KeyRune with ModCtrl instead of
+// KeyCtrlA through KeyCtrlZ.  Shift-Tab is reported as KeyTab with ModShift,
+// rather than KeyBacktab.
+type OptAdvancedKeys bool
+
+func (o OptAdvancedKeys) apply(t *tScreen) {
+	t.advancedKeys = bool(o)
+}
+
+// OptKeyboardProtocol forces the keyboard reporting protocol instead of using
+// startup negotiation. The zero value forces legacy keyboard reporting.
+type OptKeyboardProtocol KeyProtocol
+
+func (o OptKeyboardProtocol) apply(t *tScreen) {
+	t.forceKeyboardProtocol(KeyProtocol(o))
+}
+
+// OptNegotiation controls whether terminal capabilities are negotiated during
+// startup. The default is true.
+type OptNegotiation bool
+
+func (o OptNegotiation) apply(t *tScreen) {
+	t.negotiate = bool(o)
+}
+
+// OptControlStringLimit sets the maximum inbound control-string payload size
+// accepted from the terminal before the parser drops the sequence. This limits
+// OSC and XDA strings, including OSC 52 clipboard strings; OSC 52 is the
+// protocol used for writing clipboard data through the terminal. The default is
+// 64 KiB; a value of 0 disables the limit.
+type OptControlStringLimit int
+
+func (o OptControlStringLimit) apply(t *tScreen) {
+	t.controlStringLimit = max(int(o), 0)
+}
+
 // Some terminal escapes that are basically universal.
 // We would really like to be able to use private mode queries for some of
 // these but generally we've found that support for queries is not always present,
@@ -132,6 +179,7 @@ const (
 	notifyDesktop777  = "\x1b]777;notify;%s;%s\x1b\\"       // Most commonly supported
 	queryKittyKbd     = "\x1b[?u"                           // Query for Kitty keyboard support
 	enableKittyKbd    = "\x1b[=1u"                          // Technically this pushes
+	enableKittyKbdAdv = "\x1b[=15u"                         // disambiguation, events, alternate keys, all keys
 	disableKittyKbd   = "\x1b[=0u"                          // Technically this means pop previous mode
 	queryXTermKbd     = "\x1b[?4m"                          // Query for XTerm modify other keys support
 	enableXTermKbd    = "\x1b[>4;2m"                        // Enable modify other keys protocol
@@ -143,7 +191,12 @@ const (
 // is presumed, at least on UNIX hosts. (Windows hosts will typically fail this
 // call altogether.)
 func NewTerminfoScreenFromTty(tty Tty, opts ...TerminfoScreenOption) (Screen, error) {
-	t := &tScreen{tty: tty, altScreen: true}
+	t := &tScreen{
+		tty:                tty,
+		altScreen:          true,
+		negotiate:          true,
+		controlStringLimit: defaultControlStringLimit,
+	}
 
 	t.prepareCursorStyles()
 	t.prepareExtendedOSC()
@@ -160,71 +213,77 @@ func NewTerminfoScreenFromTty(tty Tty, opts ...TerminfoScreenOption) (Screen, er
 
 // tScreen represents a screen backed by a terminfo implementation.
 type tScreen struct {
-	tty           Tty
-	h             int
-	w             int
-	fini          bool
-	cells         CellBuffer
-	buffering     bool // true if we are collecting writes to buf instead of sending directly to out
-	buf           bytes.Buffer
-	curstyle      Style
-	style         Style
-	resizeQ       chan bool
-	quit          chan struct{}
-	keyQ          chan []byte
-	cx            int
-	cy            int
-	cls           bool // clear screen
-	cursorx       int
-	cursory       int
-	acs           map[rune]string
-	charset       string
-	encoder       transform.Transformer
-	decoder       transform.Transformer
-	fallback      map[rune]string
-	ncolor        int
-	colors        map[color.Color]color.Color
-	palette       []color.Color
-	truecolor     bool
-	noColor       bool
-	legacy        bool
-	hasClipboard  bool // true if OSC 52 reported via DA1
-	finiOnce      sync.Once
-	enterUrl      string
-	exitUrl       string
-	setWinSize    string
-	cursorStyles  map[CursorStyle]string
-	cursorStyle   CursorStyle
-	cursorColor   color.Color
-	cursorRGB     string
-	cursorFg      string
-	stopQ         chan struct{}
-	eventQ        chan Event
-	initQ         chan Event
-	initted       bool
-	running       bool
-	startTime     time.Time
-	wg            sync.WaitGroup
-	mouseFlags    MouseFlags
-	pasteEnabled  bool
-	focusEnabled  bool
-	setTitle      string
-	saveTitle     string
-	restoreTitle  string
-	title         string
-	setClipboard  string
-	notifyDesktop string
-	termName      string
-	termVers      string
-	term          string // value from $TERM
-	altScreen     bool
-	inlineResize  bool
-	haveMouse     bool
-	haveMouseSgr  bool
-	haveKittyKbd  bool
-	haveWin32Kbd  bool
-	haveXTermKbd  bool
-	input         *inputParser
+	tty                Tty
+	h                  int
+	w                  int
+	fini               bool
+	cells              CellBuffer
+	buffering          bool // true if we are collecting writes to buf instead of sending directly to out
+	buf                bytes.Buffer
+	curstyle           Style
+	style              Style
+	resizeQ            chan bool
+	quit               chan struct{}
+	keyQ               chan []byte
+	cx                 int
+	cy                 int
+	cls                bool // clear screen
+	cursorx            int
+	cursory            int
+	acs                map[rune]string
+	charset            string
+	encoder            transform.Transformer
+	decoder            transform.Transformer
+	fallback           map[rune]string
+	ncolor             int
+	colors             map[color.Color]color.Color
+	palette            []color.Color
+	truecolor          bool
+	noColor            bool
+	legacy             bool
+	hasClipboard       bool // true if OSC 52 reported via DA1
+	finiOnce           sync.Once
+	enterUrl           string
+	exitUrl            string
+	setWinSize         string
+	cursorStyles       map[CursorStyle]string
+	cursorStyle        CursorStyle
+	cursorColor        color.Color
+	cursorRGB          string
+	cursorFg           string
+	stopQ              chan struct{}
+	eventQ             chan Event
+	initQ              chan Event
+	initted            bool
+	running            bool
+	startTime          time.Time
+	wg                 sync.WaitGroup
+	mouseFlags         MouseFlags
+	pasteEnabled       bool
+	focusEnabled       bool
+	setTitle           string
+	saveTitle          string
+	restoreTitle       string
+	title              string
+	setClipboard       string
+	notifyDesktop      string
+	termName           string
+	termVers           string
+	term               string // value from $TERM
+	altScreen          bool
+	inlineResize       bool
+	haveMouse          bool
+	haveMouseSgr       bool
+	haveKittyKbd       bool
+	haveWin32Kbd       bool
+	haveXTermKbd       bool
+	forcedKbd          KeyProtocol
+	forceKbd           bool
+	negotiate          bool
+	mouseDisabled      bool
+	advancedKeys       bool
+	controlStringLimit int
+	input              *inputParser
 	sync.Mutex
 }
 
@@ -232,6 +291,69 @@ func (t *tScreen) useAltScreen() bool {
 	return t.altScreen && os.Getenv("TCELL_ALTSCREEN") != "disable"
 }
 
+func validKeyboardProtocol(p KeyProtocol) bool {
+	switch p {
+	case LegacyKeyboard, KittyKeyboard, Win32Keyboard, XTermKeyboard:
+		return true
+	default:
+		return false
+	}
+}
+
+func parseKeyboardProtocol(s string) (KeyProtocol, bool) {
+	switch s {
+	case "legacy":
+		return LegacyKeyboard, true
+	case "kitty":
+		return KittyKeyboard, true
+	case "win32":
+		return Win32Keyboard, true
+	case "xterm":
+		return XTermKeyboard, true
+	default:
+		return LegacyKeyboard, false
+	}
+}
+
+func (t *tScreen) forceKeyboardProtocol(p KeyProtocol) bool {
+	if !validKeyboardProtocol(p) {
+		return false
+	}
+	t.forcedKbd = p
+	t.forceKbd = true
+	return true
+}
+
+func (t *tScreen) applyKeyboardProtocolOverride() {
+	if !t.forceKbd {
+		return
+	}
+	t.haveKittyKbd = t.forcedKbd == KittyKeyboard
+	t.haveWin32Kbd = t.forcedKbd == Win32Keyboard
+	t.haveXTermKbd = t.forcedKbd == XTermKeyboard
+}
+
+func (t *tScreen) applyEnvironmentOverrides() {
+	switch os.Getenv("TCELL_KEYBOARD_PROTOCOL") {
+	case "auto":
+		t.forceKbd = false
+	case "":
+	default:
+		if p, ok := parseKeyboardProtocol(os.Getenv("TCELL_KEYBOARD_PROTOCOL")); ok {
+			t.forceKeyboardProtocol(p)
+		}
+	}
+
+	switch os.Getenv("TCELL_NEGOTIATE") {
+	case "auto":
+		t.negotiate = true
+	case "disable":
+		t.negotiate = false
+	}
+
+	t.mouseDisabled = os.Getenv("TCELL_MOUSE") == "disable"
+}
+
 func (t *tScreen) Init() error {
 	if e := t.initialize(); e != nil {
 		return e
@@ -307,11 +429,15 @@ func (t *tScreen) Init() error {
 		t.legacy = true
 	}
 
+	t.applyEnvironmentOverrides()
+
 	t.initted = false
 	t.quit = make(chan struct{})
 	t.initQ = make(chan Event, 32)
 	t.eventQ = make(chan Event, 128)
 	t.input = newInputParser(t.filterEvents())
+	t.input.advanced = t.advancedKeys
+	t.input.controlStringMax = t.controlStringLimit
 
 	t.Lock()
 	t.cx = -1
@@ -480,6 +606,9 @@ func (t *tScreen) Fini() {
 }
 
 func (t *tScreen) finish() {
+	t.Lock()
+	t.fini = true
+	t.Unlock()
 	close(t.quit)
 	t.finalize()
 }
@@ -664,6 +793,13 @@ func (t *tScreen) emitUrl(u urlInfo) {
 	}
 }
 
+// urlNeedsEmission reports whether a hyperlink transition has any wire effect.
+// Url ids can be staged before the Url itself, and id-only transitions have no
+// OSC 8 representation of their own.
+func urlNeedsEmission(oldUrl, newUrl urlInfo) bool {
+	return oldUrl != newUrl && (oldUrl.url != "" || newUrl.url != "")
+}
+
 func (t *tScreen) drawCell(x, y int) int {
 
 	str, style, width := t.cells.Get(x, y)
@@ -697,8 +833,8 @@ func (t *tScreen) drawCell(x, y int) int {
 		if style.url != nil {
 			newUrl = *style.url
 		}
-		// URL string can be long, so don't send it unless we really need to
-		if newUrl != oldUrl {
+		// URL string can be long, so don't send it unless we really need to.
+		if urlNeedsEmission(oldUrl, newUrl) {
 			t.emitUrl(newUrl)
 		}
 
@@ -746,6 +882,10 @@ func (t *tScreen) drawCell(x, y int) int {
 
 func (t *tScreen) ShowCursor(x, y int) {
 	t.Lock()
+	if t.fini {
+		t.Unlock()
+		return
+	}
 	t.cursorx = x
 	t.cursory = y
 	t.Unlock()
@@ -753,6 +893,10 @@ func (t *tScreen) ShowCursor(x, y int) {
 
 func (t *tScreen) SetCursor(cs CursorStyle, cc Color) {
 	t.Lock()
+	if t.fini {
+		t.Unlock()
+		return
+	}
 	t.cursorStyle = cs
 	t.cursorColor = cc
 	t.Unlock()
@@ -876,6 +1020,10 @@ func (t *tScreen) draw() {
 		}
 	}
 
+	if t.curstyle.url != nil && t.curstyle.url.url != "" {
+		t.emitUrl(urlInfo{})
+	}
+
 	// restore the cursor
 	t.showCursor()
 
@@ -894,6 +1042,10 @@ func (t *tScreen) EnableMouse(flags ...MouseFlags) {
 	}
 
 	t.Lock()
+	if t.fini {
+		t.Unlock()
+		return
+	}
 	t.mouseFlags = f
 	t.enableMouse(f)
 	t.Unlock()
@@ -912,7 +1064,10 @@ func (t *tScreen) enableMouse(f MouseFlags) {
 	// so we enable the mouse unconditionally unless we get a report
 	// that says we have mouse, but not SGR mouse.  This is suboptimal, but
 	// a concession forced by the sorry state of terminal emulators.
-	if t.haveMouse && !t.haveMouseSgr {
+	if t.mouseDisabled {
+		f = 0
+	}
+	if f != 0 && t.haveMouse && !t.haveMouseSgr {
 		return
 	}
 
@@ -921,6 +1076,10 @@ func (t *tScreen) enableMouse(f MouseFlags) {
 	t.Print(vt.PmMouseDrag.Disable())
 	t.Print(vt.PmMouseMotion.Disable())
 	t.Print(vt.PmMouseSgr.Disable())
+	t.Print(vt.PmMouseSgrPixel.Disable())
+
+	pixel := f&MousePixelEvents != 0
+	t.input.SetPixelMouse(pixel)
 
 	if f&(MouseButtonEvents|MouseDragEvents|MouseMotionEvents) != 0 {
 		t.Print(vt.PmMouseButton.Enable())
@@ -932,12 +1091,20 @@ func (t *tScreen) enableMouse(f MouseFlags) {
 		t.Print(vt.PmMouseMotion.Enable())
 	}
 	if f&(MouseButtonEvents|MouseDragEvents|MouseMotionEvents) != 0 {
-		t.Print(vt.PmMouseSgr.Enable())
+		if pixel {
+			t.Print(vt.PmMouseSgrPixel.Enable())
+		} else {
+			t.Print(vt.PmMouseSgr.Enable())
+		}
 	}
 }
 
 func (t *tScreen) DisableMouse() {
 	t.Lock()
+	if t.fini {
+		t.Unlock()
+		return
+	}
 	t.mouseFlags = 0
 	t.enableMouse(0)
 	t.Unlock()
@@ -945,6 +1112,10 @@ func (t *tScreen) DisableMouse() {
 
 func (t *tScreen) EnablePaste() {
 	t.Lock()
+	if t.fini {
+		t.Unlock()
+		return
+	}
 	t.pasteEnabled = true
 	t.enablePasting(true)
 	t.Unlock()
@@ -952,6 +1123,10 @@ func (t *tScreen) EnablePaste() {
 
 func (t *tScreen) DisablePaste() {
 	t.Lock()
+	if t.fini {
+		t.Unlock()
+		return
+	}
 	t.pasteEnabled = false
 	t.enablePasting(false)
 	t.Unlock()
@@ -971,6 +1146,10 @@ func (t *tScreen) enablePasting(on bool) {
 
 func (t *tScreen) EnableFocus() {
 	t.Lock()
+	if t.fini {
+		t.Unlock()
+		return
+	}
 	t.focusEnabled = true
 	t.enableFocusReporting()
 	t.Unlock()
@@ -978,6 +1157,10 @@ func (t *tScreen) EnableFocus() {
 
 func (t *tScreen) DisableFocus() {
 	t.Lock()
+	if t.fini {
+		t.Unlock()
+		return
+	}
 	t.focusEnabled = false
 	t.disableFocusReporting()
 	t.Unlock()
@@ -1189,6 +1372,11 @@ func (t *tScreen) UnregisterRuneFallback(orig rune) {
 }
 
 func (t *tScreen) SetSize(w, h int) {
+	t.Lock()
+	defer t.Unlock()
+	if t.fini {
+		return
+	}
 	if t.setWinSize != "" {
 		t.Printf(t.setWinSize, w, h)
 	}
@@ -1199,24 +1387,86 @@ func (t *tScreen) SetSize(w, h int) {
 func (t *tScreen) Resize(int, int, int, int) {}
 
 func (t *tScreen) Suspend() error {
-	t.disengage()
+	t.Lock()
+	if t.fini {
+		t.Unlock()
+		return nil
+	}
+	finish := t.disengageStart()
+	t.Unlock()
+	if finish {
+		t.disengageFinish()
+	}
 	return nil
 }
 
 func (t *tScreen) Resume() error {
-	return t.engage()
+	t.Lock()
+	defer t.Unlock()
+	if t.fini {
+		return nil
+	}
+	return t.engageLocked()
 }
 
 func (t *tScreen) Tty() (Tty, bool) {
 	return t.tty, true
 }
 
+func (t *tScreen) applyKnownTerminalProfile(goos, termProgram string) bool {
+	switch termProgram {
+	case "Apple_Terminal":
+		// macOS Terminal.app cannot handle the startup queries, but it does
+		// support modern mouse reporting.
+		t.haveMouse = true
+		t.haveMouseSgr = true
+		t.termName = "Terminal.app"
+		t.termVers = os.Getenv("TERM_PROGRAM_VERSION")
+		return true
+	case "WezTerm":
+		// The WezTerm keyboard protocol to use is in theory driven by its
+		// own configuration, but we have found this unreliable because it
+		// does not mask unsupported capabilities.  Furthermore, on Windows
+		// builds the kitty protocol implementation is broken, while on other
+		// builds win32-input-mode is broken.  This is a best effort to make
+		// WezTerm work reasonably; our stronger advice is to choose another
+		// terminal program altogether.  This workaround will probably not
+		// apply to ssh sessions, as TERM_PROGRAM is not normally propagated.
+		if goos == "windows" {
+			t.haveWin32Kbd = true
+		} else {
+			t.haveKittyKbd = true
+			t.haveWin32Kbd = false
+		}
+		t.haveMouse = true
+		t.haveMouseSgr = true
+		t.initted = true
+		t.termName = "WezTerm"
+		t.termVers = os.Getenv("TERM_PROGRAM_VERSION")
+		return true
+	}
+	return false
+}
+
+func useVTWindowSizeQuery(goos string) bool {
+	return goos != "windows"
+}
+
+func useXTermKeyboardQuery(goos string) bool {
+	return goos != "windows"
+}
+
 // engage is used to place the terminal in raw mode and establish screen size, etc.
 // Think of this is as tcell "engaging" the clutch, as it's going to be driving the
 // terminal interface.
 func (t *tScreen) engage() error {
 	t.Lock()
 	defer t.Unlock()
+	return t.engageLocked()
+}
+
+// engageLocked is engage's implementation when t's lock is already held.
+func (t *tScreen) engageLocked() error {
 	if t.tty == nil {
 		return ErrNoScreen
 	}
@@ -1234,24 +1484,37 @@ func (t *tScreen) engage() error {
 	go t.mainLoop(stopQ)
 
 	if !t.initted {
-		t.Print(requestWindowSize)
 		// macOS Terminal.app is brain damaged
 		// https://garrett.damore.org/2025/12/macos-terminal-still-missing-mark-apple.html
 		// Eventually they'll hopefully fix this.  As the environment variable
 		// does not convey by default via ssh, remote sessions might see spurious characters
 		// emitted during startup.  See the blog post for alternatives.
-		if os.Getenv("TERM_PROGRAM") != "Apple_Terminal" {
+		if !t.applyKnownTerminalProfile(runtime.GOOS, os.Getenv("TERM_PROGRAM")) && t.negotiate {
+			if useVTWindowSizeQuery(runtime.GOOS) {
+				t.Print(requestWindowSize)
+			}
 			t.Print(vt.PmResizeReports.Query())
 			t.Print(vt.PmMouseButton.Query())
 			t.Print(vt.PmMouseSgr.Query())
-			t.Print(vt.PmWin32Input.Query())
-			t.Print(queryKittyKbd)
-			t.Print(queryXTermKbd)
+			if !t.forceKbd {
+				t.Print(vt.PmWin32Input.Query())
+				t.Print(queryKittyKbd)
+				if useXTermKeyboardQuery(runtime.GOOS) {
+					// XTerm's modifyOtherKeys mode is mainly useful for XTerm
+					// itself, and we do not use it on Windows.
+					t.Print(queryXTermKbd)
+				}
+			}
 			t.Print(requestExtAttr)
 		}
-		t.Print(requestPrimaryDA) // NB: MUST BE LAST
+		if !t.negotiate {
+			t.initted = true
+		} else if !t.initted {
+			t.Print(requestPrimaryDA) // NB: MUST BE LAST
+		}
 	}
 	t.processInitQ()
+	t.applyKeyboardProtocolOverride()
 	if t.useAltScreen() {
 		// Technically this may not be right, but every terminal we know about
 		// (even Wyse 60) uses this to enter the alternate screen buffer, and
@@ -1264,7 +1527,11 @@ func (t *tScreen) engage() error {
 	if t.haveWin32Kbd {
 		t.Print(vt.PmWin32Input.Enable())
 	} else if t.haveKittyKbd {
-		t.Print(enableKittyKbd)
+		if t.advancedKeys {
+			t.Print(enableKittyKbdAdv)
+		} else {
+			t.Print(enableKittyKbd)
+		}
 	} else if t.haveXTermKbd {
 		t.Print(enableXTermKbd)
 	}
@@ -1286,17 +1553,9 @@ func (t *tScreen) engage() error {
 	if t.title != "" && t.setTitle != "" {
 		t.Printf(t.setTitle, t.title)
 	}
-	if runtime.GOOS == "windows" {
-		// This workaround exists because of what we believe to be bugs in the
-		// interaction between ConPTY, the VT-Input layer, and some terminal emulators
-		// such as WezTerm.  Note that it is *not* needed for Windows Terminal, but
-		// should be benign there.  As another note, we have observed that at least Alacritty
-		// and WezTerm do not properly handle the primaryDA query on these platforms.
-		// (WezTerm performs much better when running a remote shell or on macOS.)
-		t.Print(enableKittyKbd)
-		t.Print(vt.PmWin32Input.Enable())
+	if t.negotiate && useVTWindowSizeQuery(runtime.GOOS) {
+		t.Print(requestWindowSize)
 	}
-	t.Print(requestWindowSize)
 
 	if t.inlineResize {
 		t.Print(vt.PmResizeReports.Enable())
@@ -1311,11 +1570,19 @@ func (t *tScreen) engage() error {
 // can take over the terminal interface.  This restores the TTY mode that was
 // present when the application was first started.
 func (t *tScreen) disengage() {
-
 	t.Lock()
+	finish := t.disengageStart()
+	t.Unlock()
+	if finish {
+		t.disengageFinish()
+	}
+}
+
+// disengageStart begins a disengage operation while t's lock is already held.
+// It returns true when disengageFinish must be called after releasing the lock.
+func (t *tScreen) disengageStart() bool {
 	if !t.running {
-		t.Unlock()
-		return
+		return false
 	}
 
 	t.running = false
@@ -1327,8 +1594,12 @@ func (t *tScreen) disengage() {
 	stopQ := t.stopQ
 	close(stopQ)
 	_ = t.tty.Drain()
-	t.Unlock()
+	return true
+}
 
+// disengageFinish completes a disengage operation after disengageStart has
+// released the running loops.
+func (t *tScreen) disengageFinish() {
 	// wait for everything to shut down
 	t.wg.Wait()
 
@@ -1357,7 +1628,6 @@ func (t *tScreen) disengage() {
 	// Hack for Windows.
 	if runtime.GOOS == "windows" {
 		t.Print(vt.PmWin32Input.Disable())
-		t.Print(disableKittyKbd)
 	}
 
 	// t.Print(t.disableCsiU)
@@ -1375,6 +1645,11 @@ func (t *tScreen) disengage() {
 
 // Beep emits a beep to the terminal.
 func (t *tScreen) Beep() error {
+	t.Lock()
+	defer t.Unlock()
+	if t.fini {
+		return nil
+	}
 	t.Print(string(byte(7)))
 	return nil
 }
@@ -1401,9 +1676,13 @@ func (t *tScreen) GetCells() *CellBuffer {
 
 func (t *tScreen) SetTitle(title string) {
 	t.Lock()
-	t.title = title
+	if t.fini {
+		t.Unlock()
+		return
+	}
+	t.title = stripOSCControlsIfNeeded(title)
 	if t.setTitle != "" && t.running {
-		t.Printf(t.setTitle, title)
+		t.Printf(t.setTitle, t.title)
 	}
 	t.Unlock()
 }
@@ -1411,6 +1690,10 @@ func (t *tScreen) SetTitle(title string) {
 func (t *tScreen) SetClipboard(data []byte) {
 	// Post binary data to the system clipboard.  It might be UTF-8, it might not be.
 	t.Lock()
+	if t.fini {
+		t.Unlock()
+		return
+	}
 	if t.setClipboard != "" {
 		encoded := base64.StdEncoding.EncodeToString(data)
 		t.Printf(t.setClipboard, encoded)
@@ -1420,6 +1703,10 @@ func (t *tScreen) SetClipboard(data []byte) {
 
 func (t *tScreen) GetClipboard() {
 	t.Lock()
+	if t.fini {
+		t.Unlock()
+		return
+	}
 	if t.setClipboard != "" {
 		t.Printf(t.setClipboard, "?")
 	}
@@ -1432,7 +1719,11 @@ func (t *tScreen) HasClipboard() bool {
 
 func (t *tScreen) ShowNotification(title string, body string) {
 	t.Lock()
-	t.Printf(t.notifyDesktop, title, body)
+	if t.fini {
+		t.Unlock()
+		return
+	}
+	t.Printf(t.notifyDesktop, stripOSCControlsIfNeeded(title), stripOSCControlsIfNeeded(body))
 	t.Unlock()
 }
 
@@ -1441,3 +1732,18 @@ func (t *tScreen) Terminal() (string, string) {
 	defer t.Unlock()
 	return t.termName, t.termVers
 }
+
+func (t *tScreen) KeyboardProtocol() KeyProtocol {
+	t.Lock()
+	defer t.Unlock()
+	if t.haveWin32Kbd {
+		return Win32Keyboard
+	}
+	if t.haveKittyKbd {
+		return KittyKeyboard
+	}
+	if t.haveXTermKbd {
+		return XTermKeyboard
+	}
+	return LegacyKeyboard
+}
diff --git a/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go b/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go
index 2bf9769d1..853c1e136 100644
--- a/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go
+++ b/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go
@@ -189,6 +189,10 @@ func (w *winTty) getConsoleInput() error {
 		rv, _, er := procGetNumberOfConsoleInputEvents.Call(
 			uintptr(w.in),
 			uintptr(unsafe.Pointer(&nrec)))
+		if rv == 0 {
+			return er
+		}
+
 		rec := make([]inputRecord, max(nrec, 1))
 		rv, _, er = procReadConsoleInput.Call(
 			uintptr(w.in),
@@ -205,22 +209,15 @@ func (w *winTty) getConsoleInput() error {
 			case keyEvent:
 				// we normally only expect to see ascii, but paste data may come in as UTF-16.
 				wc := rune(binary.LittleEndian.Uint16(ir.data[10:]))
-				if wc >= 0xD800 && wc <= 0xDBFF {
-					// if it was a high surrogate, which happens for pasted UTF-16,
-					// then save it until we get the low and can decode it.
-					w.surrogate = wc
-					continue
-				} else if wc >= 0xDC00 && wc <= 0xDFFF {
-					wc = utf16.DecodeRune(w.surrogate, wc)
-				}
-				w.surrogate = 0
-				for _, chr := range []byte(string(wc)) {
-					// We normally expect only to see ASCII (win32-input-mode),
-					// but apparently pasted data can arrive in UTF-16 here.
-					select {
-					case w.buf <- chr:
-					case <-w.stopQ:
-						break loop
+				for _, decoded := range decodeUTF16Rune(&w.surrogate, wc) {
+					for _, chr := range []byte(string(decoded)) {
+						// We normally expect only to see ASCII (win32-input-mode),
+						// but apparently pasted data can arrive in UTF-16 here.
+						select {
+						case w.buf <- chr:
+						case <-w.stopQ:
+							break loop
+						}
 					}
 				}
 
diff --git a/vendor/github.com/gdamore/tcell/v3/tty/utf16.go b/vendor/github.com/gdamore/tcell/v3/tty/utf16.go
new file mode 100644
index 000000000..0932b3851
--- /dev/null
+++ b/vendor/github.com/gdamore/tcell/v3/tty/utf16.go
@@ -0,0 +1,47 @@
+// Copyright 2026 The TCell Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package tty
+
+import (
+	"unicode/utf16"
+	"unicode/utf8"
+)
+
+// decodeUTF16Rune decodes one UTF-16 code unit at a time while preserving
+// malformed input as replacement characters instead of silently discarding it.
+func decodeUTF16Rune(surrogate *rune, wc rune) []rune {
+	switch {
+	case wc >= 0xD800 && wc <= 0xDBFF:
+		if *surrogate != 0 {
+			*surrogate = wc
+			return []rune{utf8.RuneError}
+		}
+		*surrogate = wc
+		return nil
+	case wc >= 0xDC00 && wc <= 0xDFFF:
+		if *surrogate == 0 {
+			return []rune{utf8.RuneError}
+		}
+		decoded := utf16.DecodeRune(*surrogate, wc)
+		*surrogate = 0
+		return []rune{decoded}
+	default:
+		if *surrogate != 0 {
+			*surrogate = 0
+			return []rune{utf8.RuneError, wc}
+		}
+		return []rune{wc}
+	}
+}
diff --git a/vendor/github.com/gdamore/tcell/v3/vt/emulate.go b/vendor/github.com/gdamore/tcell/v3/vt/emulate.go
index b2dd2129b..fbbb6d443 100644
--- a/vendor/github.com/gdamore/tcell/v3/vt/emulate.go
+++ b/vendor/github.com/gdamore/tcell/v3/vt/emulate.go
@@ -220,10 +220,25 @@ type Cell struct {
 	W int    // Display width (0, 1, or 2)
 }
 
+// EmulatorOpt configures an Emulator.
+type EmulatorOpt interface {
+	setEmulatorOpt(*emulator)
+}
+
+// EmulatorOpt8BitControls enables parsing of C1 controls, such as CSI and OSC,
+// when they are presented as raw 8-bit bytes or UTF-8 encoded C1 controls.
+// The default is to only accept the 7-bit ESC-prefixed forms.
+type EmulatorOpt8BitControls struct{}
+
+func (EmulatorOpt8BitControls) setEmulatorOpt(em *emulator) {
+	em.c1Allowed = true
+	em.c1Enabled = true
+}
+
 // NewEmulator creates an emulator instance on top of the given backend.
 // The input is relative to the emulator, so it receives data from the host,
 // whereas the emulator sends data to the application through the output.
-func NewEmulator(be Backend) Emulator {
+func NewEmulator(be Backend, opts ...EmulatorOpt) Emulator {
 	stopQ := make(chan bool)
 	defStyle := BaseStyle.WithFg(color.Silver).WithBg(color.Black)
 	em := &emulator{
@@ -249,6 +264,9 @@ func NewEmulator(be Backend) Emulator {
 		},
 		mouseReports: MouseDisabled,
 	}
+	for _, opt := range opts {
+		opt.setEmulatorOpt(em)
+	}
 	if _, ok := be.(Resizer); ok {
 		em.localModes[PmResizeReports] = ModeOff
 	}
@@ -297,7 +315,9 @@ type emulator struct {
 	pos            Coord
 	buffering      uint         // reference count - number of (re-entrant) buffering calls
 	autoWrap       bool         // next character will wrap (auto margin, deferred until char emitted)
-	sevenOnly      bool         // only allow 7-bit escapes (needed for KOI8, ShiftJIS, etc.)
+	c1Allowed      bool         // allow C1 controls in raw 8-bit and UTF-8 encodings
+	c1Enabled      bool         // C1 controls are currently enabled
+	c1Prefix       bool         // string parser has seen the first byte of a UTF-8 encoded C1 control
 	appKeyPad      bool         // use application key pad keys?
 	name           string       // name of this emulator (used for extended attributes)
 	vers           string       // version string of this emulator (used for extended attributes)
@@ -372,13 +392,13 @@ func (em *emulator) inbInit(b byte) {
 		return
 	}
 
-	// For 8-bit encodings, we treat these as Fe sequences.
-	// Basically the same as ESC followed by (b - 0x40).
-	// TODO: condition this so that we do not do this if
-	// the encoding cannot support it (UTF, 8859, and EUC encodings
-	// are all fine here, but others like ShiftJIS or KOI8 might not be).
-	if b >= 0x80 && b <= 0x9F && !em.sevenOnly {
-		em.inbEsc(b - 0x40)
+	// For C1 controls, the raw 8-bit form is the same as ESC followed by
+	// (b - 0x40). This is disabled by default because modern protocols
+	// generally treat these forms as insecure.
+	if b >= 0x80 && b <= 0x9f {
+		if em.c1Enabled {
+			em.inbEsc(b - 0x40)
+		}
 		return
 	}
 
@@ -529,6 +549,12 @@ func (em *emulator) inbNF(b byte) {
 		// case "(Q", "(9": // TODO: select G0 as French Canadian
 		// case "(R", "(f": // TODO: select G0 as French
 		// case "(Y": // TODO: select G0 as Italian
+	case " F": // S7C1T - send/use 7-bit C1 controls
+		em.c1Enabled = false
+	case " G": // S8C1T - send/use 8-bit C1 controls
+		if em.c1Allowed {
+			em.c1Enabled = true
+		}
 	}
 }
 
@@ -550,8 +576,17 @@ func (em *emulator) inbCSI(b byte) {
 
 // inbOSC handles bytes that are part of on OSC sequences (operating system command).
 func (em *emulator) inbOSC(b byte) {
+	if em.inbStringC1(b, em.processOSC) {
+		return
+	}
+
 	switch b {
-	case 0x9c, 0x07:
+	case 0x9c:
+		if em.c1Enabled {
+			em.inb = em.inbInit
+			em.processOSC()
+		}
+	case 0x07:
 		em.inb = em.inbInit
 		em.processOSC()
 	case '\\':
@@ -569,8 +604,16 @@ func (em *emulator) inbOSC(b byte) {
 
 // inbStr handles PM, SOS, and any other string we want to consume and discard.
 func (em *emulator) inbStr(b byte) {
+	if em.inbStringC1(b, nil) {
+		return
+	}
+
 	switch b {
-	case 0x9c, 0x07:
+	case 0x9c:
+		if em.c1Enabled {
+			em.inb = em.inbInit
+		}
+	case 0x07:
 		em.inb = em.inbInit
 	case '\\':
 		if buf := em.inBuf.Bytes(); len(buf) > 0 && buf[len(buf)-1] == 0x1b {
@@ -584,6 +627,27 @@ func (em *emulator) inbStr(b byte) {
 	}
 }
 
+func (em *emulator) inbStringC1(b byte, done func()) bool {
+	if em.c1Prefix {
+		em.c1Prefix = false
+		if b >= 0x80 && b <= 0x9f {
+			if em.c1Enabled && b == 0x9c {
+				em.inb = em.inbInit
+				if done != nil {
+					done()
+				}
+			}
+			return true
+		}
+		em.inBuf.WriteByte(0xc2)
+	}
+	if b == 0xc2 {
+		em.c1Prefix = true
+		return true
+	}
+	return false
+}
+
 // inbUTF handles continuation bytes for UTF-8 sequences.
 func (em *emulator) inbUTF(b byte) {
 	if b&0xC0 == 0x80 {
@@ -595,7 +659,13 @@ func (em *emulator) inbUTF(b byte) {
 			if err != nil {
 				em.beep()
 			} else {
-				em.putRune(r)
+				if r >= 0x80 && r <= 0x9f {
+					if em.c1Enabled {
+						em.inbEsc(byte(r) - 0x40)
+					}
+				} else {
+					em.putRune(r)
+				}
 			}
 		}
 	} else {
@@ -730,7 +800,7 @@ func (em *emulator) processSgr(str string) {
 		v, err := strconv.Atoi(word)
 		if err != nil {
 			// just swallow it for now
-			return
+			continue
 		}
 		switch v {
 		case 0:
@@ -2333,6 +2403,7 @@ func (em *emulator) ResizeEvent(size Coord) {
 func (em *emulator) applyResize(size Coord) {
 	// resize clobbers our content, until it is redrawn
 	em.size = size
+	em.tabStops = slices.DeleteFunc(em.tabStops, func(x Col) bool { return x >= em.size.X })
 	// resizing resets the margins
 	em.topMargin = 0
 	em.botMargin = em.size.Y - 1
diff --git a/vendor/github.com/gdamore/tcell/v3/vt/key.go b/vendor/github.com/gdamore/tcell/v3/vt/key.go
index 9b36e9d48..edbd158e7 100644
--- a/vendor/github.com/gdamore/tcell/v3/vt/key.go
+++ b/vendor/github.com/gdamore/tcell/v3/vt/key.go
@@ -15,7 +15,7 @@
 package vt
 
 // BaseKey is the Kitty protocol base key. These are kitty's representation of a scan code.
-// As the Kitty protocol is likely the extended keyboard protocol we care about, we use
+// As the Kitty protocol is likely the extended keyboard protocol we care most about, we use
 // this as the primary reporting mechanism. (It also helps that this may provide an easier
 // fallback for implementations that don't have raw scan codes and are willing to assume an
 // ANSI layout.)
@@ -198,7 +198,7 @@ func (k Key) ScanCode() ScanCode {
 }
 
 // WinVK represents a windows virtual key code.
-// These are similar to base keys, but a multiple scanned key codes
+// These are similar to base keys, but multiple scanned key codes
 // may result in the same virtual key.  This can also be sensitive to
 // the keyboard layout.
 type WinVK rune
@@ -346,7 +346,7 @@ const (
 
 var baseKeys map[Key]BaseKey
 
-// KittyBase returns the corresponding Kitty "base" key for the given USB cod.
+// KittyBase returns the corresponding Kitty "base" key for the given USB code.
 // If no corresponding value can be found, then zero is returned.  Note that
 // some keys (such as F1) are valid, and recognized by Kitty, but do not use the
 // base key encoding because they use another reporting format.
@@ -462,15 +462,16 @@ func init() {
 		KeyRAlt:         57449,
 		KeyRMeta:        57450,
 
-		// KeyHiragana:   0, // TBD
-		// KeyConvert:    0, // TBD
-		// KeyNonConvert: 0, // TD
+		// KeyHiragana:   0, // Later
+		// KeyConvert:    0, // Later
+		// KeyNonConvert: 0, // Later
 
 		// Windows uses a bunch of HID usages from
 		// the consumer page (0x0c) for media playback, and
 		// other applications. We just ignore them.
 	}
 
+	// Scan codes used by Windows.
 	scanCodes = map[Key]ScanCode{
 		KeyA:            0x1e,
 		KeyB:            0x30,
diff --git a/vendor/github.com/gdamore/tcell/v3/vt/mock.go b/vendor/github.com/gdamore/tcell/v3/vt/mock.go
index 156013ea1..71b88ed57 100644
--- a/vendor/github.com/gdamore/tcell/v3/vt/mock.go
+++ b/vendor/github.com/gdamore/tcell/v3/vt/mock.go
@@ -247,13 +247,16 @@ func NewMockTerm(opts ...MockOpt) MockTerm {
 	mt := &mockTerm{}
 	mt.mb = NewMockBackend(opts...)
 	var be MockBackend = mt.mb
+	emOpts := []EmulatorOpt{}
 	for _, o := range opts {
 		switch o.(type) {
 		case MockOptNoBlit:
 			be = &noMockBlit{be, struct{}{}}
+		case MockOpt8BitControls:
+			emOpts = append(emOpts, EmulatorOpt8BitControls{})
 		}
 	}
-	mt.em = NewEmulator(be)
+	mt.em = NewEmulator(be, emOpts...)
 	mt.em.SetId("TCellMock", "1.0")
 	mt.ks = &KeyboardState{}
 	return mt
@@ -644,6 +647,12 @@ type MockOptNoBlit struct{}
 
 func (MockOptNoBlit) SetMockOpt(mb *mockBackend) {}
 
+// MockOpt8BitControls enables raw 8-bit and UTF-8 encoded C1 controls in the
+// emulator. The default is to accept only 7-bit ESC-prefixed controls.
+type MockOpt8BitControls struct{}
+
+func (MockOpt8BitControls) SetMockOpt(mb *mockBackend) {}
+
 // NewMockBackend returns a MockBackend modified by the given options.
 // The default is a fully featured 256-color backend with initial size 80x24.
 func NewMockBackend(options ...MockOpt) MockBackend {
diff --git a/vendor/github.com/gdamore/tcell/v3/wscreen.go b/vendor/github.com/gdamore/tcell/v3/wscreen.go
index 53dbfc064..d4fb6ba62 100644
--- a/vendor/github.com/gdamore/tcell/v3/wscreen.go
+++ b/vendor/github.com/gdamore/tcell/v3/wscreen.go
@@ -1,4 +1,4 @@
-// Copyright 2025 The TCell Authors
+// Copyright 2026 The TCell Authors
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -19,611 +19,206 @@ package tcell
 
 import (
 	"errors"
-	"fmt"
+	"io"
 	"sync"
 	"syscall/js"
-	"unicode/utf8"
 
 	"github.com/gdamore/tcell/v3/tty"
 )
 
-// NewTerminfoScreen gets a screen.  The options are ignored for this platform.
-func NewTerminfoScreen(_ ...TerminfoScreenOption) (Screen, error) {
-	t := &wScreen{}
-	t.fallback = make(map[rune]string)
-
-	return &baseScreen{screenImpl: t}, nil
-}
-
-func NewTerminfoScreenFromTty(_ tty.Tty, _ ...TerminfoScreenOption) (Screen, error) {
-	// TODO: When we want to support testing webasm, we'll have to change this
-	// to use a MockTerm.  That will be appropriate when we switch to xterm.js.
-	// The options are ignored for this platform.
-	return nil, errors.New("not implemented")
-}
-
-type TerminfoScreenOption interface{ apply(*wScreen) }
-type OptColors int
-type OptTerm string
-type OptAltScreen bool
-
-func (OptColors) apply(*wScreen)    {}
-func (OptTerm) apply(*wScreen)      {}
-func (OptAltScreen) apply(*wScreen) {}
-
-type wScreen struct {
-	w, h  int
-	style Style
-	cells CellBuffer
-
-	running      bool
-	clear        bool
-	flagsPresent bool
-	pasteEnabled bool
-	mouseFlags   MouseFlags
-
-	cursorStyle CursorStyle
-
-	quit     chan struct{}
-	evch     chan Event
-	fallback map[rune]string
-	finiOnce sync.Once
-
-	sync.Mutex
-}
-
-func (t *wScreen) Init() error {
-	t.w, t.h = 80, 24 // default for html as of now
-	t.evch = make(chan Event, 10)
-	t.quit = make(chan struct{})
-
-	t.Lock()
-	t.running = true
-	t.style = StyleDefault
-	t.cells.Resize(t.w, t.h)
-	t.Unlock()
-
-	js.Global().Set("onKeyEvent", js.FuncOf(t.onKeyEvent))
-	js.Global().Set("onMouseClick", js.FuncOf(t.unset))
-	js.Global().Set("onMouseMove", js.FuncOf(t.unset))
-	js.Global().Set("onFocus", js.FuncOf(t.unset))
-
+// initialize installs the browser-backed TTY used by tScreen on js/wasm.
+func (t *tScreen) initialize() error {
+	if t.tty == nil {
+		t.tty = newBrowserTty()
+	}
+	if t.term == "" {
+		t.term = "ghostty-truecolor"
+	}
 	return nil
 }
 
-func (t *wScreen) Fini() {
-	t.finiOnce.Do(func() {
-		close(t.quit)
-	})
-}
-
-func (t *wScreen) SetStyle(style Style) {
-	t.Lock()
-	t.style = style
-	t.Unlock()
-}
-
-// paletteColor gives a more natural palette color actually matching
-// typical XTerm.  We might in the future want to permit styling these
-// via CSS.
-
-var palette = map[Color]int32{
-	ColorBlack:   0x000000,
-	ColorMaroon:  0xcd0000,
-	ColorGreen:   0x00cd00,
-	ColorOlive:   0xcdcd00,
-	ColorNavy:    0x0000ee,
-	ColorPurple:  0xcd00cd,
-	ColorTeal:    0x00cdcd,
-	ColorSilver:  0xe5e5e5,
-	ColorGray:    0x7f7f7f,
-	ColorRed:     0xff0000,
-	ColorLime:    0x00ff00,
-	ColorYellow:  0xffff00,
-	ColorBlue:    0x5c5cff,
-	ColorFuchsia: 0xff00ff,
-	ColorAqua:    0x00ffff,
-	ColorWhite:   0xffffff,
-}
-
-func paletteColor(c Color) int32 {
-	if c.IsRGB() {
-		return int32(c & 0xffffff)
-	}
-	if c >= ColorBlack && c <= ColorWhite {
-		return palette[c]
-	}
-	return c.Hex()
-}
-
-func (t *wScreen) drawCell(x, y int) int {
-	str, style, width := t.cells.Get(x, y)
-
-	if !t.cells.Dirty(x, y) {
-		return width
-	}
-
-	if style == StyleDefault {
-		style = t.style
-	}
-
-	fg, bg := paletteColor(style.fg), paletteColor(style.bg)
-	if fg == -1 {
-		fg = 0xe5e5e5
-	}
-	if bg == -1 {
-		bg = 0x000000
-	}
-	us, uc := style.ulStyle, paletteColor(style.ulColor)
-	if uc == -1 {
-		uc = 0x000000
-	}
-
-	t.cells.SetDirty(x, y, false)
-	js.Global().Call("drawCell", x, y, str, fg, bg, int(style.attrs), int(us), int(uc))
-
-	return width
-}
-
-func (t *wScreen) ShowCursor(x, y int) {
-	t.Lock()
-	js.Global().Call("showCursor", x, y)
-	t.Unlock()
-}
-
-func (t *wScreen) SetCursor(cs CursorStyle, cc Color) {
-	if !cc.Valid() {
-		cc = ColorLightGray
-	}
-	t.Lock()
-	js.Global().Call("setCursorStyle", curStyleClasses[cs], fmt.Sprintf("#%06x", cc.Hex()))
-	t.Unlock()
-}
-
-func (t *wScreen) HideCursor() {
-	t.ShowCursor(-1, -1)
-}
-
-func (t *wScreen) Show() {
-	t.Lock()
-	t.resize()
-	t.draw()
-	t.Unlock()
-}
-
-func (t *wScreen) clearScreen() {
-	js.Global().Call("clearScreen", t.style.fg.Hex(), t.style.bg.Hex())
-	t.clear = false
-}
-
-func (t *wScreen) draw() {
-	if t.clear {
-		t.clearScreen()
-	}
-
-	for y := 0; y < t.h; y++ {
-		for x := 0; x < t.w; x++ {
-			width := t.drawCell(x, y)
-			x += width - 1
-		}
-	}
-
-	js.Global().Call("show")
-}
-
-func (t *wScreen) EnableMouse(flags ...MouseFlags) {
-	var f MouseFlags
-	flagsPresent := false
-	for _, flag := range flags {
-		f |= flag
-		flagsPresent = true
-	}
-	if !flagsPresent {
-		f = MouseMotionEvents | MouseDragEvents | MouseButtonEvents
-	}
-
-	t.Lock()
-	t.mouseFlags = f
-	t.enableMouse(f)
-	t.Unlock()
-}
-
-func (t *wScreen) enableMouse(f MouseFlags) {
-	if f&MouseButtonEvents != 0 {
-		js.Global().Set("onMouseClick", js.FuncOf(t.onMouseEvent))
-	} else {
-		js.Global().Set("onMouseClick", js.FuncOf(t.unset))
-	}
-
-	if f&MouseDragEvents != 0 || f&MouseMotionEvents != 0 {
-		js.Global().Set("onMouseMove", js.FuncOf(t.onMouseEvent))
-	} else {
-		js.Global().Set("onMouseMove", js.FuncOf(t.unset))
-	}
-}
-
-func (t *wScreen) DisableMouse() {
-	t.Lock()
-	t.mouseFlags = 0
-	t.enableMouse(0)
-	t.Unlock()
-}
-
-func (t *wScreen) EnablePaste() {
-	t.Lock()
-	t.pasteEnabled = true
-	t.enablePasting(true)
-	t.Unlock()
-}
-
-func (t *wScreen) DisablePaste() {
-	t.Lock()
-	t.pasteEnabled = false
-	t.enablePasting(false)
-	t.Unlock()
-}
-
-func (t *wScreen) enablePasting(on bool) {
-	if on {
-		js.Global().Set("onPaste", js.FuncOf(t.onPaste))
-	} else {
-		js.Global().Set("onPaste", js.FuncOf(t.unset))
-	}
-}
-
-func (t *wScreen) EnableFocus() {
-	t.Lock()
-	js.Global().Set("onFocus", js.FuncOf(t.onFocus))
-	t.Unlock()
-}
-
-func (t *wScreen) DisableFocus() {
-	t.Lock()
-	js.Global().Set("onFocus", js.FuncOf(t.unset))
-	t.Unlock()
-}
-
-func (s *wScreen) GetClipboard() {
-}
-
-func (s *wScreen) SetClipboard(_ []byte) {
-}
-
-func (s *wScreen) HasClipboard() bool {
-	return false
-}
-
-func (t *wScreen) Size() (int, int) {
-	t.Lock()
-	w, h := t.w, t.h
-	t.Unlock()
-	return w, h
-}
-
-// resize does nothing, as asking the web window to resize
-// without a specified width or height will cause no change.
-func (t *wScreen) resize() {}
-
-func (t *wScreen) Colors() int {
-	return 16777216 // 256 ^ 3
-}
-
-func (t *wScreen) clip(x, y int) (int, int) {
-	w, h := t.cells.Size()
-	if x < 0 {
-		x = 0
-	}
-	if y < 0 {
-		y = 0
-	}
-	if x > w-1 {
-		x = w - 1
-	}
-	if y > h-1 {
-		y = h - 1
-	}
-	return x, y
-}
-
-func (t *wScreen) postEvent(ev Event) {
-	select {
-	case t.evch <- ev:
-	case <-t.quit:
-	}
-}
-
-func (t *wScreen) onMouseEvent(this js.Value, args []js.Value) any {
-	mod := ModNone
-	button := ButtonNone
-
-	switch args[2].Int() {
-	case 0:
-		if t.mouseFlags&MouseMotionEvents == 0 {
-			// don't want this event! is a mouse motion event, but user has asked not.
-			return nil
-		}
-		button = ButtonNone
-	case 1:
-		button = Button1
-	case 2:
-		button = Button3 // Note we prefer to treat right as button 2
-	case 3:
-		button = Button2 // And the middle button as button 3
-	}
-
-	if args[3].Bool() { // mod shift
-		mod |= ModShift
-	}
-
-	if args[4].Bool() { // mod alt
-		mod |= ModAlt
-	}
-
-	if args[5].Bool() { // mod ctrl
-		mod |= ModCtrl
-	}
-
-	t.postEvent(NewEventMouse(args[0].Int(), args[1].Int(), button, mod))
-	return nil
-}
-
-func (t *wScreen) onKeyEvent(this js.Value, args []js.Value) any {
-	key := args[0].String()
-
-	// don't accept any modifier keys as their own
-	if key == "Control" || key == "Alt" || key == "Meta" || key == "Shift" {
-		return nil
-	}
-
-	mod := ModNone
-	if args[1].Bool() { // mod shift
-		mod |= ModShift
-	}
-
-	if args[2].Bool() { // mod alt
-		mod |= ModAlt
-	}
-
-	if args[3].Bool() { // mod ctrl
-		mod |= ModCtrl
-	}
-
-	if args[4].Bool() { // mod meta
-		mod |= ModMeta
-	}
-
-	// next try function keys
-	if k, ok := WebKeyNames[key]; ok {
-		t.postEvent(NewEventKey(k, "", mod))
-		return nil
-	}
-
-	// finally try normal, printable chars
-	r, _ := utf8.DecodeRuneInString(key)
-	t.postEvent(NewEventKey(KeyRune, string(r), mod))
-	return nil
-}
-
-func (t *wScreen) onPaste(this js.Value, args []js.Value) any {
-	t.postEvent(NewEventPaste(args[0].Bool()))
-	return nil
-}
-
-func (t *wScreen) onFocus(this js.Value, args []js.Value) any {
-	t.postEvent(NewEventFocus(args[0].Bool()))
-	return nil
-}
-
-// unset is a dummy function for js when we want nothing to
-// happen when javascript calls a function (for example, when
-// mouse input is disabled, when onMouseEvent() is called from
-// js, it redirects here and does nothing).
-func (t *wScreen) unset(this js.Value, args []js.Value) any {
-	return nil
-}
-
-func (t *wScreen) Sync() {
-	t.Lock()
-	t.resize()
-	t.clear = true
-	t.cells.Invalidate()
-	t.draw()
-	t.Unlock()
-}
-
-func (t *wScreen) CharacterSet() string {
+func getCharset() string {
 	return "UTF-8"
 }
 
-func (t *wScreen) RegisterRuneFallback(orig rune, fallback string) {
-	t.Lock()
-	t.fallback[orig] = fallback
-	t.Unlock()
+type browserTty struct {
+	mu      sync.Mutex
+	cond    *sync.Cond
+	started bool
+	drained bool
+	closed  bool
+	input   []byte
+	resizeQ chan<- bool
+
+	writeFunc  js.Value
+	sizeFunc   js.Value
+	closeFuncs []js.Func
 }
 
-func (t *wScreen) UnregisterRuneFallback(orig rune) {
-	t.Lock()
-	delete(t.fallback, orig)
-	t.Unlock()
+func newBrowserTty() *browserTty {
+	t := &browserTty{}
+	t.cond = sync.NewCond(&t.mu)
+	return t
 }
 
-func (t *wScreen) SetSize(w, h int) {
-	if w == t.w && h == t.h {
-		return
-	}
+func (t *browserTty) Start() error {
+	t.mu.Lock()
+	defer t.mu.Unlock()
 
-	t.cells.Invalidate()
-	t.cells.Resize(w, h)
-	js.Global().Call("resize", w, h)
-	t.w, t.h = w, h
-	t.postEvent(NewEventResize(w, h))
-}
-
-func (t *wScreen) Resize(int, int, int, int) {}
-
-// Suspend simply pauses all input and output, and clears the screen.
-// There isn't a "default terminal" to go back to.
-func (t *wScreen) Suspend() error {
-	t.Lock()
-	if !t.running {
-		t.Unlock()
+	if t.started {
 		return nil
 	}
-	t.running = false
-	t.clearScreen()
-	t.enableMouse(0)
-	t.enablePasting(false)
-	js.Global().Set("onKeyEvent", js.FuncOf(t.unset)) // stop key presses
-	return nil
-}
-
-func (t *wScreen) Resume() error {
-	t.Lock()
-
-	if t.running {
-		return errors.New("already engaged")
+	global := js.Global()
+	t.writeFunc = global.Get("tcellWrite")
+	t.sizeFunc = global.Get("tcellWindowSize")
+	if t.writeFunc.Type() != js.TypeFunction || t.sizeFunc.Type() != js.TypeFunction {
+		return errors.New("tcell wasm terminal host is not installed")
 	}
-	t.running = true
 
-	t.enableMouse(t.mouseFlags)
-	t.enablePasting(t.pasteEnabled)
+	onData := js.FuncOf(func(this js.Value, args []js.Value) any {
+		if len(args) == 0 {
+			return nil
+		}
+		if args[0].InstanceOf(global.Get("Uint8Array")) {
+			data := make([]byte, args[0].Get("byteLength").Int())
+			js.CopyBytesToGo(data, args[0])
+			t.enqueue(data)
+		} else {
+			t.enqueue([]byte(args[0].String()))
+		}
+		return nil
+	})
+	onResize := js.FuncOf(func(this js.Value, args []js.Value) any {
+		t.mu.Lock()
+		resizeQ := t.resizeQ
+		t.mu.Unlock()
+		if resizeQ != nil {
+			select {
+			case resizeQ <- true:
+			default:
+			}
+		}
+		return nil
+	})
+	t.closeFuncs = []js.Func{onData, onResize}
+	global.Set("tcellRead", onData)
+	global.Set("tcellResize", onResize)
 
-	js.Global().Set("onKeyEvent", js.FuncOf(t.onKeyEvent))
-
-	t.Unlock()
+	t.started = true
+	t.drained = false
+	t.closed = false
 	return nil
 }
 
-func (t *wScreen) Beep() error {
-	js.Global().Call("beep")
+func (t *browserTty) Stop() error {
+	t.mu.Lock()
+	t.started = false
+	t.drained = false
+	funcs := t.closeFuncs
+	t.closeFuncs = nil
+	t.cond.Broadcast()
+	t.mu.Unlock()
+
+	js.Global().Set("tcellRead", js.Undefined())
+	js.Global().Set("tcellResize", js.Undefined())
+	for _, fn := range funcs {
+		fn.Release()
+	}
 	return nil
 }
 
-func (t *wScreen) Tty() (Tty, bool) {
-	return nil, false
+func (t *browserTty) Drain() error {
+	t.mu.Lock()
+	t.input = nil
+	t.drained = true
+	t.cond.Broadcast()
+	t.mu.Unlock()
+	return nil
 }
 
-func (t *wScreen) GetCells() *CellBuffer {
-	return &t.cells
+func (t *browserTty) NotifyResize(resizeQ chan<- bool) {
+	t.mu.Lock()
+	t.resizeQ = resizeQ
+	t.mu.Unlock()
 }
 
-func (t *wScreen) EventQ() chan Event {
-	return t.evch
+func (t *browserTty) WindowSize() (tty.WindowSize, error) {
+	var ws tty.WindowSize
+	t.mu.Lock()
+	sizeFunc := t.sizeFunc
+	t.mu.Unlock()
+	if sizeFunc.Type() != js.TypeFunction {
+		ws.Width = 80
+		ws.Height = 24
+		return ws, nil
+	}
+	size := sizeFunc.Invoke()
+	ws.Width = size.Get("cols").Int()
+	ws.Height = size.Get("rows").Int()
+	ws.PixelWidth = size.Get("pixelWidth").Int()
+	ws.PixelHeight = size.Get("pixelHeight").Int()
+	if ws.Width == 0 {
+		ws.Width = 80
+	}
+	if ws.Height == 0 {
+		ws.Height = 24
+	}
+	return ws, nil
 }
 
-func (t *wScreen) StopQ() <-chan struct{} {
-	return t.quit
+func (t *browserTty) Read(b []byte) (int, error) {
+	t.mu.Lock()
+	defer t.mu.Unlock()
+
+	for len(t.input) == 0 && t.started && !t.drained && !t.closed {
+		t.cond.Wait()
+	}
+	if t.closed {
+		return 0, io.EOF
+	}
+	if (!t.started || t.drained) && len(t.input) == 0 {
+		return 0, io.EOF
+	}
+	n := copy(b, t.input)
+	t.input = t.input[n:]
+	return n, nil
 }
 
-func (t *wScreen) SetTitle(title string) {
-	js.Global().Call("setTitle", title)
+func (t *browserTty) Write(b []byte) (int, error) {
+	t.mu.Lock()
+	writeFunc := t.writeFunc
+	started := t.started
+	t.mu.Unlock()
+	if !started || writeFunc.Type() != js.TypeFunction {
+		return 0, io.ErrClosedPipe
+	}
+
+	data := js.Global().Get("Uint8Array").New(len(b))
+	js.CopyBytesToJS(data, b)
+	writeFunc.Invoke(data)
+	return len(b), nil
 }
 
-func (*wScreen) ShowNotification(title string, body string) {}
+func (t *browserTty) Close() error {
+	t.mu.Lock()
+	if t.closed {
+		t.mu.Unlock()
+		return nil
+	}
+	t.closed = true
+	t.started = false
+	t.drained = false
+	t.cond.Broadcast()
+	t.mu.Unlock()
 
-// WebKeyNames maps string names reported from HTML
-// (KeyboardEvent.key) to tcell accepted keys.
-var WebKeyNames = map[string]Key{
-	"Enter":      KeyEnter,
-	"Backspace":  KeyBackspace,
-	"Tab":        KeyTab,
-	"Backtab":    KeyBacktab,
-	"Escape":     KeyEsc,
-	"Backspace2": KeyBackspace2,
-	"Delete":     KeyDelete,
-	"Insert":     KeyInsert,
-	"ArrowUp":    KeyUp,
-	"ArrowDown":  KeyDown,
-	"ArrowLeft":  KeyLeft,
-	"ArrowRight": KeyRight,
-	"Home":       KeyHome,
-	"End":        KeyEnd,
-	"UpLeft":     KeyUpLeft,    // not supported by HTML
-	"UpRight":    KeyUpRight,   // not supported by HTML
-	"DownLeft":   KeyDownLeft,  // not supported by HTML
-	"DownRight":  KeyDownRight, // not supported by HTML
-	"Center":     KeyCenter,
-	"PgDn":       KeyPgDn,
-	"PgUp":       KeyPgUp,
-	"Clear":      KeyClear,
-	"Exit":       KeyExit,
-	"Cancel":     KeyCancel,
-	"Pause":      KeyPause,
-	"Print":      KeyPrint,
-	"F1":         KeyF1,
-	"F2":         KeyF2,
-	"F3":         KeyF3,
-	"F4":         KeyF4,
-	"F5":         KeyF5,
-	"F6":         KeyF6,
-	"F7":         KeyF7,
-	"F8":         KeyF8,
-	"F9":         KeyF9,
-	"F10":        KeyF10,
-	"F11":        KeyF11,
-	"F12":        KeyF12,
-	"F13":        KeyF13,
-	"F14":        KeyF14,
-	"F15":        KeyF15,
-	"F16":        KeyF16,
-	"F17":        KeyF17,
-	"F18":        KeyF18,
-	"F19":        KeyF19,
-	"F20":        KeyF20,
-	"F21":        KeyF21,
-	"F22":        KeyF22,
-	"F23":        KeyF23,
-	"F24":        KeyF24,
-	"F25":        KeyF25,
-	"F26":        KeyF26,
-	"F27":        KeyF27,
-	"F28":        KeyF28,
-	"F29":        KeyF29,
-	"F30":        KeyF30,
-	"F31":        KeyF31,
-	"F32":        KeyF32,
-	"F33":        KeyF33,
-	"F34":        KeyF34,
-	"F35":        KeyF35,
-	"F36":        KeyF36,
-	"F37":        KeyF37,
-	"F38":        KeyF38,
-	"F39":        KeyF39,
-	"F40":        KeyF40,
-	"F41":        KeyF41,
-	"F42":        KeyF42,
-	"F43":        KeyF43,
-	"F44":        KeyF44,
-	"F45":        KeyF45,
-	"F46":        KeyF46,
-	"F47":        KeyF47,
-	"F48":        KeyF48,
-	"F49":        KeyF49,
-	"F50":        KeyF50,
-	"F51":        KeyF51,
-	"F52":        KeyF52,
-	"F53":        KeyF53,
-	"F54":        KeyF54,
-	"F55":        KeyF55,
-	"F56":        KeyF56,
-	"F57":        KeyF57,
-	"F58":        KeyF58,
-	"F59":        KeyF59,
-	"F60":        KeyF60,
-	"F61":        KeyF61,
-	"F62":        KeyF62,
-	"F63":        KeyF63,
-	"F64":        KeyF64,
+	return t.Stop()
 }
 
-var curStyleClasses = map[CursorStyle]string{
-	CursorStyleDefault:           "cursor-blinking-block",
-	CursorStyleBlinkingBlock:     "cursor-blinking-block",
-	CursorStyleSteadyBlock:       "cursor-steady-block",
-	CursorStyleBlinkingUnderline: "cursor-blinking-underline",
-	CursorStyleSteadyUnderline:   "cursor-steady-underline",
-	CursorStyleBlinkingBar:       "cursor-blinking-bar",
-	CursorStyleSteadyBar:         "cursor-steady-bar",
+func (t *browserTty) enqueue(data []byte) {
+	t.mu.Lock()
+	if t.started && !t.closed {
+		t.input = append(t.input, data...)
+		t.cond.Broadcast()
+	}
+	t.mu.Unlock()
 }
-
-func (*wScreen) Terminal() (string, string) { return "Tcell-WebASM", "" }
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 0b303b80c..6a21d6d97 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -48,7 +48,7 @@ github.com/fatih/color
 # github.com/gdamore/encoding v1.0.1
 ## explicit; go 1.9
 github.com/gdamore/encoding
-# github.com/gdamore/tcell/v3 v3.3.0
+# github.com/gdamore/tcell/v3 v3.4.0
 ## explicit; go 1.25.0
 github.com/gdamore/tcell/v3
 github.com/gdamore/tcell/v3/color
@@ -184,10 +184,10 @@ golang.org/x/sync/errgroup
 golang.org/x/sys/plan9
 golang.org/x/sys/unix
 golang.org/x/sys/windows
-# golang.org/x/term v0.42.0
+# golang.org/x/term v0.43.0
 ## explicit; go 1.25.0
 golang.org/x/term
-# golang.org/x/text v0.36.0
+# golang.org/x/text v0.37.0
 ## explicit; go 1.25.0
 golang.org/x/text/cases
 golang.org/x/text/encoding

From 7cab05d58f60e5f5f3b124765a6d368b268010e9 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 26 May 2026 08:04:40 +0000
Subject: [PATCH 034/384] Bump goreleaser/goreleaser-action from 7.1.0 to 7.2.2

Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.1.0 to 7.2.2.
- [Release notes](https://github.com/goreleaser/goreleaser-action/releases)
- [Commits](https://github.com/goreleaser/goreleaser-action/compare/e24998b8b67b290c2fa8b7c14fcfa7de2c5c9b8c...5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89)

---
updated-dependencies:
- dependency-name: goreleaser/goreleaser-action
  dependency-version: 7.2.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] 
---
 .github/workflows/release.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 39a761d5d..7c4ef31b9 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -159,7 +159,7 @@ jobs:
           go-version: 1.25.x
 
       - name: Run goreleaser
-        uses: goreleaser/goreleaser-action@e24998b8b67b290c2fa8b7c14fcfa7de2c5c9b8c # v7.1.0
+        uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2
         with:
           distribution: goreleaser
           version: v2

From 415015c66ab99600193f161c711df0e843ac5d0a Mon Sep 17 00:00:00 2001
From: Henry Maddocks 
Date: Sun, 10 May 2026 12:29:17 +0200
Subject: [PATCH 035/384] Pull git-flow prefix parsing into a config-level
 helper
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Lift the inline parsing in FinishCmdObj into parseGitFlowPrefixMap on
ConfigCommands. The caller now does a direct map lookup against the
parsed prefix → branchType map instead of iterating the raw config
output and suffix-matching. This is preparation for adding git-flow-next
support, which needs to merge a second config schema into the same map.

One incidental change: a branch name without a slash now returns
NotAGitFlowBranch immediately, rather than falling through the
line loop with an empty suffix. Previously a configured
gitflow.prefix.X whose value happened to equal the entire branch
name could match — never a useful outcome.
---
 pkg/commands/git_commands/config.go      | 37 ++++++++++
 pkg/commands/git_commands/config_test.go | 94 ++++++++++++++++++++++++
 pkg/commands/git_commands/flow.go        | 24 ++----
 3 files changed, 137 insertions(+), 18 deletions(-)
 create mode 100644 pkg/commands/git_commands/config_test.go

diff --git a/pkg/commands/git_commands/config.go b/pkg/commands/git_commands/config.go
index a72fe504c..1332c0cd1 100644
--- a/pkg/commands/git_commands/config.go
+++ b/pkg/commands/git_commands/config.go
@@ -1,6 +1,7 @@
 package git_commands
 
 import (
+	"regexp"
 	"strings"
 
 	"github.com/jesseduffield/lazygit/pkg/commands/git_config"
@@ -116,6 +117,42 @@ func (self *ConfigCommands) GetGitFlowPrefixes() string {
 	return self.gitConfig.GetGeneral("--local --get-regexp gitflow.prefix")
 }
 
+// parseGitFlowPrefixMap parses git-flow config output into a prefix → branchType map.
+// Line format: "gitflow.prefix. ". Prefixes are normalized to end in "/".
+func parseGitFlowPrefixMap(legacyOutput string) map[string]string {
+	legacyRegexp := regexp.MustCompile(`gitflow\.prefix\.(\S+)\s+(.*)`)
+	prefixToType := make(map[string]string)
+	for line := range strings.SplitSeq(legacyOutput, "\n") {
+		line = strings.TrimSpace(line)
+		if line == "" {
+			continue
+		}
+		if m := legacyRegexp.FindStringSubmatch(line); len(m) == 3 {
+			prefix := normalizeGitFlowPrefix(m[2])
+			if prefix == "" {
+				continue
+			}
+			prefixToType[prefix] = m[1]
+		}
+	}
+	return prefixToType
+}
+
+func normalizeGitFlowPrefix(prefix string) string {
+	prefix = strings.TrimSpace(prefix)
+	if prefix == "" {
+		return ""
+	}
+	if !strings.HasSuffix(prefix, "/") {
+		return prefix + "/"
+	}
+	return prefix
+}
+
+func (self *ConfigCommands) GetGitFlowPrefixMap() map[string]string {
+	return parseGitFlowPrefixMap(self.GetGitFlowPrefixes())
+}
+
 func (self *ConfigCommands) GetCoreCommentChar() byte {
 	if commentCharStr := self.gitConfig.Get("core.commentChar"); len(commentCharStr) == 1 {
 		return commentCharStr[0]
diff --git a/pkg/commands/git_commands/config_test.go b/pkg/commands/git_commands/config_test.go
new file mode 100644
index 000000000..42369da10
--- /dev/null
+++ b/pkg/commands/git_commands/config_test.go
@@ -0,0 +1,94 @@
+package git_commands
+
+import (
+	"testing"
+
+	"github.com/jesseduffield/lazygit/pkg/commands/git_config"
+	"github.com/jesseduffield/lazygit/pkg/common"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestParseGitFlowPrefixMap(t *testing.T) {
+	type scenario struct {
+		testName     string
+		legacyOutput string
+		expected     map[string]string
+	}
+	scenarios := []scenario{
+		{
+			testName:     "empty input",
+			legacyOutput: "",
+			expected:     map[string]string{},
+		},
+		{
+			testName:     "feature and hotfix",
+			legacyOutput: "gitflow.prefix.feature feature/\ngitflow.prefix.hotfix hotfix/",
+			expected:     map[string]string{"feature/": "feature", "hotfix/": "hotfix"},
+		},
+		{
+			testName:     "prefix normalized with trailing slash",
+			legacyOutput: "gitflow.prefix.feature feature",
+			expected:     map[string]string{"feature/": "feature"},
+		},
+		{
+			testName:     "malformed lines skipped",
+			legacyOutput: "gitflow.prefix.feature feature/\nnot-a-valid-line\ngitflow.prefix.hotfix hotfix/",
+			expected:     map[string]string{"feature/": "feature", "hotfix/": "hotfix"},
+		},
+		{
+			testName:     "blank lines and whitespace ignored",
+			legacyOutput: "  \n  gitflow.prefix.feature feature/  \n  \n  ",
+			expected:     map[string]string{"feature/": "feature"},
+		},
+	}
+	for _, s := range scenarios {
+		t.Run(s.testName, func(t *testing.T) {
+			got := parseGitFlowPrefixMap(s.legacyOutput)
+			assert.Equal(t, s.expected, got)
+		})
+	}
+}
+
+func TestGetGitFlowPrefixMap(t *testing.T) {
+	type scenario struct {
+		testName               string
+		gitConfigMockResponses map[string]string
+		expected               map[string]string
+	}
+	scenarios := []scenario{
+		{
+			testName:               "empty when no config",
+			gitConfigMockResponses: nil,
+			expected:               map[string]string{},
+		},
+		{
+			testName: "correct map from legacy output",
+			gitConfigMockResponses: map[string]string{
+				"--local --get-regexp gitflow.prefix": "gitflow.prefix.feature feature/\ngitflow.prefix.hotfix hotfix/",
+			},
+			expected: map[string]string{"feature/": "feature", "hotfix/": "hotfix"},
+		},
+		{
+			testName: "prefix normalized with trailing slash",
+			gitConfigMockResponses: map[string]string{
+				"--local --get-regexp gitflow.prefix": "gitflow.prefix.feature feature",
+			},
+			expected: map[string]string{"feature/": "feature"},
+		},
+		{
+			testName: "malformed lines skipped",
+			gitConfigMockResponses: map[string]string{
+				"--local --get-regexp gitflow.prefix": "gitflow.prefix.feature feature/\nnot-a-valid-line\n",
+			},
+			expected: map[string]string{"feature/": "feature"},
+		},
+	}
+
+	for _, s := range scenarios {
+		t.Run(s.testName, func(t *testing.T) {
+			config := NewConfigCommands(common.NewDummyCommon(), git_config.NewFakeGitConfig(s.gitConfigMockResponses))
+			got := config.GetGitFlowPrefixMap()
+			assert.Equal(t, s.expected, got)
+		})
+	}
+}
diff --git a/pkg/commands/git_commands/flow.go b/pkg/commands/git_commands/flow.go
index fc00c11a1..985b6457f 100644
--- a/pkg/commands/git_commands/flow.go
+++ b/pkg/commands/git_commands/flow.go
@@ -1,7 +1,6 @@
 package git_commands
 
 import (
-	"regexp"
 	"strings"
 
 	"github.com/go-errors/errors"
@@ -25,26 +24,15 @@ func (self *FlowCommands) GitFlowEnabled() bool {
 }
 
 func (self *FlowCommands) FinishCmdObj(branchName string) (*oscommands.CmdObj, error) {
-	prefixes := self.config.GetGitFlowPrefixes()
+	prefixMap := self.config.GetGitFlowPrefixMap()
 
-	// need to find out what kind of branch this is
-	prefix := strings.SplitAfterN(branchName, "/", 2)[0]
-	suffix := strings.Replace(branchName, prefix, "", 1)
-
-	branchType := ""
-	for line := range strings.SplitSeq(strings.TrimSpace(prefixes), "\n") {
-		if strings.HasPrefix(line, "gitflow.prefix.") && strings.HasSuffix(line, prefix) {
-
-			regex := regexp.MustCompile("gitflow.prefix.([^ ]*) .*")
-			matches := regex.FindAllStringSubmatch(line, 1)
-
-			if len(matches) > 0 && len(matches[0]) > 1 {
-				branchType = matches[0][1]
-				break
-			}
-		}
+	prefixPart, suffix, ok := strings.Cut(branchName, "/")
+	if !ok || prefixPart == "" || suffix == "" {
+		return nil, errors.New(self.Tr.NotAGitFlowBranch)
 	}
+	prefix := prefixPart + "/"
 
+	branchType := prefixMap[prefix]
 	if branchType == "" {
 		return nil, errors.New(self.Tr.NotAGitFlowBranch)
 	}

From dd0d90837d13f39de9040c2c75207a5e3a6abf7c Mon Sep 17 00:00:00 2001
From: Henry Maddocks 
Date: Sun, 10 May 2026 12:30:48 +0200
Subject: [PATCH 036/384] Add support for git flow using git-flow-next
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

git-flow-next (https://github.com/gittower/git-flow-next) uses a
different config schema than legacy git-flow:
gitflow.branch..prefix instead of gitflow.prefix..
Recognize both schemas in GetGitFlowPrefixMap by querying each and
merging into a single prefix → branchType map. GitFlowEnabled now
consults the merged map so a next-only setup counts as enabled.

When both schemas configure the same prefix, the legacy entry wins.
In normal usage both schemas agree, so the rule mainly matters as a
deterministic tie-breaker.
---
 README.md                                |  2 +-
 pkg/commands/git_commands/config.go      | 44 +++++++++---
 pkg/commands/git_commands/config_test.go | 47 +++++++++++--
 pkg/commands/git_commands/flow.go        |  2 +-
 pkg/commands/git_commands/flow_test.go   | 88 +++++++++++++++++++-----
 5 files changed, 147 insertions(+), 36 deletions(-)

diff --git a/README.md b/README.md
index 549366864..587cc2a93 100644
--- a/README.md
+++ b/README.md
@@ -596,7 +596,7 @@ See the [docs](docs/Custom_Command_Keybindings.md)
 
 ### Git flow support
 
-Lazygit supports [Gitflow](https://github.com/nvie/gitflow) if you have it installed. To understand how the Gitflow model works check out Vincent Driessen's original [post](https://nvie.com/posts/a-successful-git-branching-model/) explaining it. To view Gitflow options from within Lazygit, press `i` from within the branches view.
+Lazygit supports [Gitflow](https://github.com/nvie/gitflow) (or [git-flow-next](https://github.com/gittower/git-flow-next)) if you have it installed. To understand how the Gitflow model works check out Vincent Driessen's original [post](https://nvie.com/posts/a-successful-git-branching-model/) explaining it. To view Gitflow options from within Lazygit, press `i` from within the branches view.
 
 ## Contributing
 
diff --git a/pkg/commands/git_commands/config.go b/pkg/commands/git_commands/config.go
index 1332c0cd1..19f6dcaf5 100644
--- a/pkg/commands/git_commands/config.go
+++ b/pkg/commands/git_commands/config.go
@@ -113,28 +113,50 @@ func (self *ConfigCommands) Branches(cmd oscommands.ICmdObjBuilder) map[string]*
 	return result
 }
 
-func (self *ConfigCommands) GetGitFlowPrefixes() string {
-	return self.gitConfig.GetGeneral("--local --get-regexp gitflow.prefix")
+// git-flow config key patterns: legacy uses gitflow.prefix., git-flow-next uses gitflow.branch..prefix
+const (
+	gitFlowLegacyConfigArgs = "--local --get-regexp gitflow.prefix"
+	gitFlowNextConfigArgs   = "--local --get-regexp gitflow\\.branch\\..*\\.prefix"
+)
+
+func (self *ConfigCommands) getGitFlowPrefixes() string {
+	return self.gitConfig.GetGeneral(gitFlowLegacyConfigArgs)
 }
 
-// parseGitFlowPrefixMap parses git-flow config output into a prefix → branchType map.
-// Line format: "gitflow.prefix. ". Prefixes are normalized to end in "/".
-func parseGitFlowPrefixMap(legacyOutput string) map[string]string {
-	legacyRegexp := regexp.MustCompile(`gitflow\.prefix\.(\S+)\s+(.*)`)
-	prefixToType := make(map[string]string)
-	for line := range strings.SplitSeq(legacyOutput, "\n") {
+func (self *ConfigCommands) getGitFlowNextPrefixes() string {
+	return self.gitConfig.GetGeneral(gitFlowNextConfigArgs)
+}
+
+// parseGitFlowLines parses lines matching re (submatch 1 = branch type, 2 = prefix) into prefixToType.
+// When overwrite is false, existing keys are left unchanged so legacy entries win over next.
+func parseGitFlowLines(output string, re *regexp.Regexp, prefixToType map[string]string, overwrite bool) {
+	for line := range strings.SplitSeq(output, "\n") {
 		line = strings.TrimSpace(line)
 		if line == "" {
 			continue
 		}
-		if m := legacyRegexp.FindStringSubmatch(line); len(m) == 3 {
+		if m := re.FindStringSubmatch(line); len(m) == 3 {
 			prefix := normalizeGitFlowPrefix(m[2])
 			if prefix == "" {
 				continue
 			}
-			prefixToType[prefix] = m[1]
+			if overwrite || prefixToType[prefix] == "" {
+				prefixToType[prefix] = m[1]
+			}
 		}
 	}
+}
+
+// parseGitFlowPrefixMap parses legacy and git-flow-next config output into a unified prefix → branchType map.
+// Legacy line format: "gitflow.prefix. "
+// Next line format: "gitflow.branch..prefix "
+// Prefixes are normalized to end in "/". Legacy entries win on duplicate prefix.
+func parseGitFlowPrefixMap(legacyOutput, nextOutput string) map[string]string {
+	legacyRegexp := regexp.MustCompile(`gitflow\.prefix\.(\S+)\s+(.*)`)
+	nextRegexp := regexp.MustCompile(`gitflow\.branch\.([^.]+)\.prefix\s+(.*)`)
+	prefixToType := make(map[string]string)
+	parseGitFlowLines(legacyOutput, legacyRegexp, prefixToType, true)
+	parseGitFlowLines(nextOutput, nextRegexp, prefixToType, false)
 	return prefixToType
 }
 
@@ -150,7 +172,7 @@ func normalizeGitFlowPrefix(prefix string) string {
 }
 
 func (self *ConfigCommands) GetGitFlowPrefixMap() map[string]string {
-	return parseGitFlowPrefixMap(self.GetGitFlowPrefixes())
+	return parseGitFlowPrefixMap(self.getGitFlowPrefixes(), self.getGitFlowNextPrefixes())
 }
 
 func (self *ConfigCommands) GetCoreCommentChar() byte {
diff --git a/pkg/commands/git_commands/config_test.go b/pkg/commands/git_commands/config_test.go
index 42369da10..4ec121aed 100644
--- a/pkg/commands/git_commands/config_test.go
+++ b/pkg/commands/git_commands/config_test.go
@@ -12,38 +12,56 @@ func TestParseGitFlowPrefixMap(t *testing.T) {
 	type scenario struct {
 		testName     string
 		legacyOutput string
+		nextOutput   string
 		expected     map[string]string
 	}
 	scenarios := []scenario{
 		{
-			testName:     "empty input",
+			testName:     "empty inputs",
 			legacyOutput: "",
+			nextOutput:   "",
 			expected:     map[string]string{},
 		},
 		{
-			testName:     "feature and hotfix",
+			testName:     "legacy only",
 			legacyOutput: "gitflow.prefix.feature feature/\ngitflow.prefix.hotfix hotfix/",
+			nextOutput:   "",
 			expected:     map[string]string{"feature/": "feature", "hotfix/": "hotfix"},
 		},
 		{
-			testName:     "prefix normalized with trailing slash",
+			testName:     "next only",
+			legacyOutput: "",
+			nextOutput:   "gitflow.branch.feature.prefix feature/\ngitflow.branch.release.prefix release/",
+			expected:     map[string]string{"feature/": "feature", "release/": "release"},
+		},
+		{
+			testName:     "legacy wins on duplicate prefix",
+			legacyOutput: "gitflow.prefix.foo feature/",
+			nextOutput:   "gitflow.branch.bar.prefix feature/",
+			expected:     map[string]string{"feature/": "foo"},
+		},
+		{
+			testName:     "prefix normalized with trailing slash from legacy",
 			legacyOutput: "gitflow.prefix.feature feature",
+			nextOutput:   "",
 			expected:     map[string]string{"feature/": "feature"},
 		},
 		{
-			testName:     "malformed lines skipped",
+			testName:     "malformed legacy lines skipped",
 			legacyOutput: "gitflow.prefix.feature feature/\nnot-a-valid-line\ngitflow.prefix.hotfix hotfix/",
+			nextOutput:   "",
 			expected:     map[string]string{"feature/": "feature", "hotfix/": "hotfix"},
 		},
 		{
 			testName:     "blank lines and whitespace ignored",
 			legacyOutput: "  \n  gitflow.prefix.feature feature/  \n  \n  ",
+			nextOutput:   "",
 			expected:     map[string]string{"feature/": "feature"},
 		},
 	}
 	for _, s := range scenarios {
 		t.Run(s.testName, func(t *testing.T) {
-			got := parseGitFlowPrefixMap(s.legacyOutput)
+			got := parseGitFlowPrefixMap(s.legacyOutput, s.nextOutput)
 			assert.Equal(t, s.expected, got)
 		})
 	}
@@ -57,17 +75,32 @@ func TestGetGitFlowPrefixMap(t *testing.T) {
 	}
 	scenarios := []scenario{
 		{
-			testName:               "empty when no config",
+			testName:               "empty when both queries empty",
 			gitConfigMockResponses: nil,
 			expected:               map[string]string{},
 		},
 		{
-			testName: "correct map from legacy output",
+			testName: "correct map from legacy-only output",
 			gitConfigMockResponses: map[string]string{
 				"--local --get-regexp gitflow.prefix": "gitflow.prefix.feature feature/\ngitflow.prefix.hotfix hotfix/",
 			},
 			expected: map[string]string{"feature/": "feature", "hotfix/": "hotfix"},
 		},
+		{
+			testName: "correct map from git-flow-next-only output",
+			gitConfigMockResponses: map[string]string{
+				"--local --get-regexp gitflow\\.branch\\..*\\.prefix": "gitflow.branch.feature.prefix feature/\ngitflow.branch.release.prefix release/",
+			},
+			expected: map[string]string{"feature/": "feature", "release/": "release"},
+		},
+		{
+			testName: "merged map with legacy winning when both have same prefix",
+			gitConfigMockResponses: map[string]string{
+				"--local --get-regexp gitflow.prefix":                 "gitflow.prefix.foo feature/",
+				"--local --get-regexp gitflow\\.branch\\..*\\.prefix": "gitflow.branch.bar.prefix feature/",
+			},
+			expected: map[string]string{"feature/": "foo"},
+		},
 		{
 			testName: "prefix normalized with trailing slash",
 			gitConfigMockResponses: map[string]string{
diff --git a/pkg/commands/git_commands/flow.go b/pkg/commands/git_commands/flow.go
index 985b6457f..ccf0149de 100644
--- a/pkg/commands/git_commands/flow.go
+++ b/pkg/commands/git_commands/flow.go
@@ -20,7 +20,7 @@ func NewFlowCommands(
 }
 
 func (self *FlowCommands) GitFlowEnabled() bool {
-	return self.config.GetGitFlowPrefixes() != ""
+	return len(self.config.GetGitFlowPrefixMap()) > 0
 }
 
 func (self *FlowCommands) FinishCmdObj(branchName string) (*oscommands.CmdObj, error) {
diff --git a/pkg/commands/git_commands/flow_test.go b/pkg/commands/git_commands/flow_test.go
index 911f50c7e..2dab0a43e 100644
--- a/pkg/commands/git_commands/flow_test.go
+++ b/pkg/commands/git_commands/flow_test.go
@@ -7,17 +7,56 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+func TestGitFlowEnabled(t *testing.T) {
+	type scenario struct {
+		testName               string
+		expected               bool
+		gitConfigMockResponses map[string]string
+	}
+	scenarios := []scenario{
+		{
+			testName:               "disabled when no config",
+			expected:               false,
+			gitConfigMockResponses: nil,
+		},
+		{
+			testName: "enabled with legacy config",
+			expected: true,
+			gitConfigMockResponses: map[string]string{
+				"--local --get-regexp gitflow.prefix": "gitflow.prefix.feature feature/",
+			},
+		},
+		{
+			testName: "enabled with git-flow-next only config",
+			expected: true,
+			gitConfigMockResponses: map[string]string{
+				"--local --get-regexp gitflow\\.branch\\..*\\.prefix": "gitflow.branch.feature.prefix feature/",
+			},
+		},
+	}
+
+	for _, s := range scenarios {
+		t.Run(s.testName, func(t *testing.T) {
+			instance := buildFlowCommands(commonDeps{
+				gitConfig: git_config.NewFakeGitConfig(s.gitConfigMockResponses),
+			})
+			assert.Equal(t, s.expected, instance.GitFlowEnabled())
+		})
+	}
+}
+
 func TestStartCmdObj(t *testing.T) {
-	scenarios := []struct {
+	type scenario struct {
 		testName   string
 		branchType string
-		name       string
+		branchName string
 		expected   []string
-	}{
+	}
+	scenarios := []scenario{
 		{
 			testName:   "basic",
 			branchType: "feature",
-			name:       "test",
+			branchName: "test",
 			expected:   []string{"git", "flow", "feature", "start", "test"},
 		},
 	}
@@ -27,7 +66,7 @@ func TestStartCmdObj(t *testing.T) {
 			instance := buildFlowCommands(commonDeps{})
 
 			assert.Equal(t,
-				instance.StartCmdObj(s.branchType, s.name).Args(),
+				instance.StartCmdObj(s.branchType, s.branchName).Args(),
 				s.expected,
 			)
 		})
@@ -35,13 +74,14 @@ func TestStartCmdObj(t *testing.T) {
 }
 
 func TestFinishCmdObj(t *testing.T) {
-	scenarios := []struct {
+	type scenario struct {
 		testName               string
 		branchName             string
 		expected               []string
 		expectedError          string
 		gitConfigMockResponses map[string]string
-	}{
+	}
+	scenarios := []scenario{
 		{
 			testName:               "not a git flow branch",
 			branchName:             "mybranch",
@@ -57,7 +97,7 @@ func TestFinishCmdObj(t *testing.T) {
 			gitConfigMockResponses: nil,
 		},
 		{
-			testName:      "feature branch with config",
+			testName:      "feature branch with legacy config",
 			branchName:    "feature/mybranch",
 			expected:      []string{"git", "flow", "feature", "finish", "mybranch"},
 			expectedError: "",
@@ -65,6 +105,25 @@ func TestFinishCmdObj(t *testing.T) {
 				"--local --get-regexp gitflow.prefix": "gitflow.prefix.feature feature/",
 			},
 		},
+		{
+			testName:      "feature branch with git-flow-next only config",
+			branchName:    "feature/mybranch",
+			expected:      []string{"git", "flow", "feature", "finish", "mybranch"},
+			expectedError: "",
+			gitConfigMockResponses: map[string]string{
+				"--local --get-regexp gitflow\\.branch\\..*\\.prefix": "gitflow.branch.feature.prefix feature/",
+			},
+		},
+		{
+			testName:      "legacy wins when both configs have same prefix",
+			branchName:    "feature/mybranch",
+			expected:      []string{"git", "flow", "foo", "finish", "mybranch"},
+			expectedError: "",
+			gitConfigMockResponses: map[string]string{
+				"--local --get-regexp gitflow.prefix":                 "gitflow.prefix.foo feature/",
+				"--local --get-regexp gitflow\\.branch\\..*\\.prefix": "gitflow.branch.bar.prefix feature/",
+			},
+		},
 	}
 
 	for _, s := range scenarios {
@@ -76,15 +135,12 @@ func TestFinishCmdObj(t *testing.T) {
 			cmd, err := instance.FinishCmdObj(s.branchName)
 
 			if s.expectedError != "" {
-				if err == nil {
-					t.Errorf("Expected error, got nil")
-				} else {
-					assert.Equal(t, err.Error(), s.expectedError)
-				}
-			} else {
-				assert.NoError(t, err)
-				assert.Equal(t, cmd.Args(), s.expected)
+				assert.Error(t, err)
+				assert.Equal(t, s.expectedError, err.Error())
+				return
 			}
+			assert.NoError(t, err)
+			assert.Equal(t, s.expected, cmd.Args())
 		})
 	}
 }

From 064e9a4c985de41936cccd44fdfe773bca4e958f Mon Sep 17 00:00:00 2001
From: Stefan Haller 
Date: Tue, 26 May 2026 22:08:49 +0200
Subject: [PATCH 037/384] Update translations from Crowdin

---
 docs-master/keybindings/Keybindings_pl.md    |  72 +++++-----
 docs-master/keybindings/Keybindings_zh-CN.md |  16 +--
 pkg/i18n/translations/ja.json                |   1 -
 pkg/i18n/translations/pl.json                | 131 ++++++++++++++-----
 pkg/i18n/translations/ru.json                |   5 +-
 pkg/i18n/translations/zh-CN.json             |  35 ++++-
 pkg/i18n/translations/zh-TW.json             |   1 -
 7 files changed, 174 insertions(+), 87 deletions(-)

diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md
index b754d7311..b032a6606 100644
--- a/docs-master/keybindings/Keybindings_pl.md
+++ b/docs-master/keybindings/Keybindings_pl.md
@@ -11,12 +11,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct
 | `` , J,  (fn+down/shift+j) `` | Przewiń główne okno w dół |  |
 | `` @ `` | Pokaż opcje dziennika poleceń | Pokaż opcje dla dziennika poleceń, np. pokazywanie/ukrywanie dziennika poleceń i skupienie na dzienniku poleceń. |
 | `` P `` | Wypchnij | Wypchnij bieżącą gałąź do jej gałęzi nadrzędnej. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej. |
-| `` p `` | Pociągnij | Pociągnij zmiany z zdalnego dla bieżącej gałęzi. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej. |
+| `` p `` | Pociągnij | Pociągnij zmiany ze zdalnego dla bieżącej gałęzi. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej. |
 | `` ) `` | 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'. | | `` } `` | Zwiększ rozmiar kontekstu w widoku różnic | 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'. | | `` { `` | Zmniejsz rozmiar kontekstu w widoku różnic | 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. | +| `` : `` | Wykonaj polecenie w powłoce | Bring up a prompt where you can enter a shell command to execute. | | `` `` | Wyświetl opcje niestandardowej łatki | | | `` m `` | Pokaż opcje scalania/rebase | Pokaż opcje do przerwania/kontynuowania/pominięcia bieżącego scalania/rebase. | | `` 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`. | @@ -62,27 +62,27 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` 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 `` | Przeformułuj | Przeformułuj wiadomość wybranego commita. | | `` R `` | Przeformułuj za pomocą edytora | | -| `` d `` | Usuń | Usuń wybrany commit. To usunie commit z gałęzi za pomocą rebazowania. Jeśli commit wprowadza zmiany, od których zależą późniejsze commity, być może będziesz musiał rozwiązać konflikty scalania. | -| `` e `` | Edytuj (rozpocznij interaktywne rebazowanie) | Edytuj wybrany commit. Użyj tego, aby rozpocząć interaktywne rebazowanie od wybranego commita. Podczas trwania rebazowania, to oznaczy wybrany commit do edycji, co oznacza, że po kontynuacji rebazowania, rebazowanie zostanie wstrzymane na wybranym commicie, aby umożliwić wprowadzenie zmian. | -| `` i `` | Rozpocznij interaktywny rebase | Rozpocznij interaktywny rebase dla commitów na twoim branchu. To będzie zawierać wszystkie commity od HEAD do pierwszego commita scalenia lub commita głównego brancha.
Jeśli chcesz zamiast tego rozpocząć interaktywny rebase od wybranego commita, naciśnij `e`. | -| `` p `` | Wybierz | Oznacz wybrany commit do wybrania (podczas rebazowania). Oznacza to, że commit zostanie zachowany po kontynuacji rebazowania. | +| `` d `` | Usuń | Usuń wybrany commit. To usunie commit z gałęzi za pomocą przebazowania. Jeśli commit wprowadza zmiany, od których zależą późniejsze commity, być może będziesz musiał rozwiązać konflikty scalania. | +| `` e `` | Edytuj (rozpocznij interaktywne przebazowanie) | Edytuj wybrany commit. Użyj tego, aby rozpocząć interaktywne przebazowanie od wybranego commita. Podczas trwania przebazowania, to oznaczy wybrany commit do edycji, co oznacza, że po kontynuacji przebazowania, przebazowanie zostanie wstrzymane na wybranym commicie, aby umożliwić wprowadzenie zmian. | +| `` i `` | Rozpocznij interaktywne przebazowanie | Rozpocznij interaktywne przebazowanie dla commitów na twojej gałęzi. To będzie zawierać wszystkie commity od HEAD do pierwszego commita scalenia lub commita głównej gałęzi.
Jeśli zamiast tego chcesz rozpocząć interaktywne przebazowanie od wybranego commita, naciśnij `e`. | +| `` p `` | Wybierz | Oznacz wybrany commit do wybrania (podczas przebazowania). Oznacza to, że commit zostanie zachowany po kontynuacji przebazowania. | | `` F `` | Utwórz commit fixup | Utwórz commit 'fixup!' dla wybranego commita. Później możesz nacisnąć `S` na tym samym commicie, aby zastosować wszystkie powyższe commity fixup. | | `` S `` | Zastosuj commity fixup | Scal wszystkie commity 'fixup!', albo powyżej wybranego commita, albo wszystkie w bieżącej gałęzi (autosquash). | | `` , `` | Przesuń commit w dół | | | `` , `` | Przesuń commit w górę | | | `` V `` | Wklej (cherry-pick) | | -| `` B `` | Oznacz jako bazowy commit dla rebase | Wybierz bazowy commit dla następnego rebase. Kiedy robisz rebase na branch, tylko commity powyżej bazowego commita zostaną przeniesione. Używa to polecenia `git rebase --onto`. | -| `` A `` | Popraw | Popraw commit ze zmianami zatwierdzonymi. Jeśli wybrany commit jest commit HEAD, to wykona `git commit --amend`. W przeciwnym razie commit zostanie poprawiony za pomocą rebazowania. | +| `` B `` | Oznacz jako bazowy commit dla przebazowania | Wybierz bazowy commit dla następnego przebazowania. Kiedy robisz przebazowanie na gałąź, tylko commity powyżej bazowego commita zostaną przeniesione. Używa to polecenia `git rebase --onto`. | +| `` A `` | Popraw | Popraw commit ze zmianami zatwierdzonymi. Jeśli wybrany commit jest commit HEAD, to wykona `git commit --amend`. W przeciwnym razie commit zostanie poprawiony za pomocą przebazowania. | | `` a `` | Popraw atrybut commita | Ustaw/Resetuj autora commita lub ustaw współautora. | | `` t `` | Cofnij | Utwórz commit cofający dla wybranego commita, który stosuje zmiany wybranego commita w odwrotnej kolejności. | | `` T `` | Otaguj commit | Utwórz nowy tag wskazujący na wybrany commit. Zostaniesz poproszony o wprowadzenie nazwy tagu i opcjonalnego opisu. | | `` `` | Zobacz opcje logów | Zobacz opcje dla logów commitów, np. zmiana kolejności sortowania, ukrywanie grafu gita, pokazywanie całego grafu gita. | -| `` G `` | Open pull request in browser | | +| `` G `` | Otwórz żądanie ściągnięcia w przeglądarce | | | `` `` | Przełącz | Przełącz wybrany commit jako odłączoną HEAD. | | `` y `` | Kopiuj atrybut commita do schowka | Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor). | | `` o `` | Otwórz commit w przeglądarce | | | `` n `` | Utwórz nową gałąź z commita | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | @@ -110,6 +110,26 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` d `` | Usuń | Usuń wybrane drzewo pracy. To usunie zarówno katalog drzewa pracy, jak i metadane o drzewie pracy w katalogu .git. | | `` / `` | Filtruj bieżący widok po tekście | | +## Dziennik reflog + +| Key | Action | Info | +|-----|--------|-------------| +| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Przełącz | Przełącz wybrany commit jako odłączoną HEAD. | +| `` y `` | Kopiuj atrybut commita do schowka | Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor). | +| `` o `` | Otwórz commit w przeglądarce | | +| `` n `` | Utwórz nową gałąź z commita | | +| `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | +| `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | +| `` `` | Resetuj wybrane (cherry-picked) commity | | +| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | +| `` * `` | Select commits of current branch | | +| `` 0 `` | Focus main view | | +| `` `` | Pokaż commity | | +| `` w `` | Zobacz opcje drzewa pracy | | +| `` / `` | Filtruj bieżący widok po tekście | | + ## Główny panel (budowanie łatki) | Key | Action | Info | @@ -141,13 +161,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` i `` | Pokaż opcje git-flow | | | `` `` | Przełącz | Przełącz wybrany element. | | `` n `` | Nowa gałąź | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` o `` | Utwórz żądanie ściągnięcia | | | `` O `` | Zobacz opcje tworzenia pull requesta | | -| `` G `` | Open pull request in browser | | +| `` G `` | Otwórz żądanie ściągnięcia w przeglądarce | | | `` `` | Kopiuj adres URL żądania ściągnięcia do schowka | | | `` c `` | Przełącz według nazwy | Przełącz według nazwy. W polu wprowadzania możesz wpisać '-' aby przełączyć się na ostatnią gałąź. | -| `` - `` | Checkout previous branch | | +| `` - `` | Przełącz na poprzednią gałąź | | | `` F `` | Wymuś przełączenie | Wymuś przełączenie wybranej gałęzi. To spowoduje odrzucenie wszystkich lokalnych zmian w drzewie roboczym przed przełączeniem na wybraną gałąź. | | `` d `` | Usuń | Wyświetl opcje usuwania lokalnej/odległej gałęzi. | | `` r `` | Przebazuj | Przebazuj przełączoną gałąź na wybraną gałąź. | @@ -268,7 +288,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Kopiuj ścieżkę do schowka | | | `` y `` | Kopiuj do schowka | | | `` c `` | Przełącz | Przełącz plik. Zastępuje plik w twoim drzewie roboczym wersją z wybranego commita. | -| `` d `` | Odrzuć | Odrzuć zmiany w tym pliku z tego commita. Uruchamia interaktywny rebase w tle, więc możesz otrzymać konflikt scalania, jeśli późniejszy commit również zmienia ten plik. | +| `` d `` | Odrzuć | Odrzuć zmiany w tym pliku z tego commita. Uruchamia interaktywne przebazowanie w tle, więc możesz otrzymać konflikt scalania, jeśli późniejszy commit również zmienia ten plik. | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | | `` e `` | Edytuj | Otwórz plik w zewnętrznym edytorze. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | @@ -288,26 +308,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Potwierdź | | | `` `` | Zamknij | | -## Reflog - -| Key | Action | Info | -|-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Przełącz | Przełącz wybrany commit jako odłączoną HEAD. | -| `` y `` | Kopiuj atrybut commita do schowka | Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor). | -| `` o `` | Otwórz commit w przeglądarce | | -| `` n `` | Utwórz nową gałąź z commita | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | -| `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | -| `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | -| `` `` | Resetuj wybrane (cherry-picked) commity | | -| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | -| `` * `` | Select commits of current branch | | -| `` 0 `` | Focus main view | | -| `` `` | Pokaż commity | | -| `` w `` | Zobacz opcje drzewa pracy | | -| `` / `` | Filtruj bieżący widok po tekście | | - ## Schowek | Key | Action | Info | @@ -343,7 +343,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` y `` | Kopiuj atrybut commita do schowka | Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor). | | `` o `` | Otwórz commit w przeglądarce | | | `` n `` | Utwórz nową gałąź z commita | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | | `` `` | Resetuj wybrane (cherry-picked) commity | | @@ -372,7 +372,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy tag to clipboard | | +| `` `` | Skopiuj tag do schowka | | | `` `` | Przełącz | Przełącz wybrany tag jako odłączoną głowę (detached HEAD). | | `` n `` | Nowy tag | Utwórz nowy tag z bieżącego commita. Zostaniesz poproszony o wprowadzenie nazwy tagu i opcjonalnego opisu. | | `` d `` | Usuń | Wyświetl opcje usuwania lokalnego/odległego tagu. | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 0d0cbbab6..9cb7d5186 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -54,7 +54,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 | | +| `` `` | 复制缩略提交哈希值到剪贴板 | | | `` `` | 检出 | 检出所选择的提交作为分离HEAD。 | | `` y `` | 复制提交属性到剪贴板 | 复制提交属性到剪贴板(如hash、URL、diff、消息、作者)。 | | `` o `` | 在浏览器中打开提交 | | @@ -98,7 +98,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 | | +| `` `` | 复制缩略提交哈希值到剪贴板 | | | `` `` | 检出 | 检出所选择的提交作为分离HEAD。 | | `` y `` | 复制提交属性到剪贴板 | 复制提交属性到剪贴板(如hash、URL、diff、消息、作者)。 | | `` o `` | 在浏览器中打开提交 | | @@ -118,12 +118,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | 复制缩略提交哈希值到剪贴板 | | | `` `` | 重置已拣选(复制)的提交 | | | `` b `` | 查看二分查找选项 | | | `` s `` | 压缩(Squash) | 将已选提交压缩到该提交之下。这些选定的提交的消息会附加到该提交的消息之下。 | | `` f `` | 修正 (fixup) | 将选定的提交合并到其下面的提交中。与压缩类似,但所选提交的消息将被丢弃。 | -| `` 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. | +| `` c `` | 设置修复提交信息 | 设置修复提交的信息选项。-C 选项表示使用此提交的信息,而非目标提交的信息。 | | `` r `` | 改写提交 | 重写所选提交的消息。 | | `` R `` | 使用编辑器重命名提交 | | | `` d `` | 删除提交 | 删除选中的提交。这将通过变基从分支中删除该提交,如果该提交修改的内容依赖于后续的提交,则需要解决合并冲突。 | @@ -141,7 +141,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` t `` | 撤销(Revert) | 为所选提交创建还原提交,这会反向应用所选提交的更改。 | | `` T `` | 标签提交 | 创建一个新标签指向所选提交。您可以在弹窗中输入标签名称和描述(可选)。 | | `` `` | 打开日志菜单 | 查看提交日志的选项,例如更改排序顺序、隐藏 git graph、显示整个 git graph。 | -| `` G `` | Open pull request in browser | | +| `` G `` | 在浏览器中打开拉取请求 | | | `` `` | 检出 | 检出所选择的提交作为分离HEAD。 | | `` y `` | 复制提交属性到剪贴板 | 复制提交属性到剪贴板(如hash、URL、diff、消息、作者)。 | | `` o `` | 在浏览器中打开提交 | | @@ -227,7 +227,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。

请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 | | `` o `` | 创建拉取请求 | | | `` O `` | 创建拉取请求选项 | | -| `` G `` | Open pull request in browser | | +| `` G `` | 在浏览器中打开拉取请求 | | | `` `` | 复制拉取请求 URL 到剪贴板 | | | `` c `` | 按名称检出 | 按名称检出。在输入框中,您可以输入'-' 来切换到最后一个分支。 | | `` - `` | 签出上一个分支 | | @@ -259,7 +259,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 打开文件 | 使用默认程序打开该文件 | | `` e `` | 编辑文件 | 使用外部编辑器打开文件 | | `` `` | 添加/移除 行到补丁 | | -| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. | +| `` d `` | 从提交中移除行 | 从本次提交中移除所选行。此操作会在后台运行交互式变基,因此如果后续提交也修改了这些行,您可能会遇到合并冲突。 | | `` `` | 退出逐行模式 | | | `` / `` | 开始搜索 | | @@ -344,7 +344,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` u `` | 检查更新 | | | `` `` | 切换到最近的仓库 | | | `` a `` | 显示/循环所有分支日志 | | -| `` A `` | Show/cycle all branch logs (reverse) | | +| `` A `` | 显示/循环所有分支日志(反向) | | | `` 0 `` | 聚焦主视图 | | ## 确认面板 diff --git a/pkg/i18n/translations/ja.json b/pkg/i18n/translations/ja.json index a1f829204..ff9d804e5 100644 --- a/pkg/i18n/translations/ja.json +++ b/pkg/i18n/translations/ja.json @@ -543,7 +543,6 @@ "StartSearch": "現在のビューをテキストで検索", "StartFilter": "現在のビューをテキストでフィルタリング", "Keybindings": "キーバインディング", - "KeybindingsLegend": "凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味します", "KeybindingsMenuSectionLocal": "ローカル", "KeybindingsMenuSectionGlobal": "グローバル", "KeybindingsMenuSectionNavigation": "ナビゲーション", diff --git a/pkg/i18n/translations/pl.json b/pkg/i18n/translations/pl.json index 4c9fffad5..560592524 100644 --- a/pkg/i18n/translations/pl.json +++ b/pkg/i18n/translations/pl.json @@ -32,6 +32,7 @@ "BaseCommitIsAlreadyOnMainBranch": "Bazowy commit dla tej zmiany jest już na gałęzi głównej", "BaseCommitIsNotInCurrentView": "Bazowy commit nie jest w bieżącym widoku", "HunksWithOnlyAddedLinesWarning": "Istnieją zakresy tylko z dodanymi liniami w różnicach; uważaj, aby sprawdzić, czy te należą do znalezionego bazowego commita.\n\nKontynuować?", + "StatusTitle": "Status", "GlobalTitle": "Globalne skróty klawiszowe", "Execute": "Wykonaj", "Stage": "Zatwierdź", @@ -46,21 +47,37 @@ "Push": "Wypchnij", "Pull": "Pociągnij", "PushTooltip": "Wypchnij bieżącą gałąź do jej gałęzi nadrzędnej. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej.", - "PullTooltip": "Pociągnij zmiany z zdalnego dla bieżącej gałęzi. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej.", + "PullTooltip": "Pociągnij zmiany ze zdalnego dla bieżącej gałęzi. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej.", "FileFilter": "Filtruj pliki według statusu", "CopyToClipboardMenu": "Kopiuj do schowka", "CopyFileName": "Nazwa pliku", + "CopyRelativeFilePath": "Ścieżka względna", + "CopyAbsoluteFilePath": "Ścieżka absolutna", "CopyFileDiffTooltip": "Jeśli istnieją zatwierdzone elementy, ta komenda bierze pod uwagę tylko je. W przeciwnym razie bierze pod uwagę wszystkie niezatwierdzone.", "CopySelectedDiff": "Różnice wybranego pliku", "CopyAllFilesDiff": "Różnice wszystkich plików", + "CopyFileContent": "Zawartość zaznaczonego pliku", "NoContentToCopyError": "Nic do skopiowania", "FileNameCopiedToast": "Nazwa pliku skopiowana do schowka", "FilePathCopiedToast": "Ścieżka pliku skopiowana do schowka", "FileDiffCopiedToast": "Różnice pliku skopiowane do schowka", "AllFilesDiffCopiedToast": "Różnice wszystkich plików skopiowane do schowka", + "FileContentCopiedToast": "Zawartość pliku została skopiowana do schowka", "FilterStagedFiles": "Pokaż tylko zatwierdzone pliki", "FilterUnstagedFiles": "Pokaż tylko niezatwierdzone pliki", + "FilterTrackedFiles": "Pokaż tylko śledzone pliki", + "FilterUntrackedFiles": "Pokaż tylko nieśledzone pliki", + "NoFilter": "Brak filtrów", + "FilterLabelStagedFiles": "(tylko zatwierdzone)", + "FilterLabelUnstagedFiles": "(tylko niezatwierdzone)", + "FilterLabelTrackedFiles": "(tylko śledzone)", + "FilterLabelUntrackedFiles": "(tylko nieśledzone)", "MergeConflictsTitle": "Konflikty scalania", + "MergeConflictIncomingDiff": "Przychodzące zmiany:", + "MergeConflictCurrentDiff": "Aktualne zmiany:", + "MergeConflictPressEnterToResolve": "Naciśnij %s, aby rozwiązać.", + "MergeConflictKeepFile": "Zatrzymaj plik", + "MergeConflictDeleteFile": "Usuń plik", "Checkout": "Przełącz", "CheckoutTooltip": "Przełącz wybrany element.", "CantCheckoutBranchWhilePulling": "Nie możesz przełączyć na inną gałąź podczas pobierania bieżącej gałęzi", @@ -76,8 +93,13 @@ "NewBranchNameBranchOff": "Nowa nazwa gałęzi (gałąź oparta na '{{.branchName}}')", "CantDeleteCheckOutBranch": "Nie możesz usunąć przełączonej gałęzi!", "DeleteBranchTitle": "Usuń gałąź '{{.selectedBranchName}}'?", + "DeleteBranchesTitle": "Usunąć zaznaczone gałęzie?", "DeleteLocalBranch": "Usuń lokalną gałąź", + "DeleteLocalBranches": "Usuń lokalne gałęzie", "DeleteRemoteBranchPrompt": "Czy na pewno chcesz usunąć gałąź zdalną '{{.selectedBranchName}}' z '{{.upstream}}'?", + "DeleteRemoteBranchesPrompt": "Czy na pewno chcesz usunąć gałęzie ze zdalnych repozytoriów odpowiadające zaznaczonym gałęziom lokalnym?", + "DeleteLocalAndRemoteBranchPrompt": "Czy na pewno chcesz usunąć '{{.localBranchName}}' ze swojego komputera, jak i '{{.remoteBranchName}}' z '{{.remoteName}}'?", + "DeleteLocalAndRemoteBranchesPrompt": "Czy na pewno chcesz usunąć zaznaczone gałęzie ze swojego komputera, jak i odpowiadające im gałęzie zdalne z repozytoriów zdalnych, w których się znajdują?", "ForceDeleteBranchTitle": "Wymuś usunięcie gałęzi", "ForceDeleteBranchMessage": "'{{.selectedBranchName}}' nie jest w pełni scalona. Czy na pewno chcesz ją usunąć?", "RebaseBranch": "Przebazuj", @@ -88,8 +110,15 @@ "ForceCheckoutTooltip": "Wymuś przełączenie wybranej gałęzi. To spowoduje odrzucenie wszystkich lokalnych zmian w drzewie roboczym przed przełączeniem na wybraną gałąź.", "CheckoutByName": "Przełącz według nazwy", "CheckoutByNameTooltip": "Przełącz według nazwy. W polu wprowadzania możesz wpisać '-' aby przełączyć się na ostatnią gałąź.", + "CheckoutPreviousBranch": "Przełącz na poprzednią gałąź", + "RemoteBranchCheckoutTitle": "Przełącz na {{.branchName}}", + "RemoteBranchCheckoutPrompt": "Jak chciałbyś/chciałabyś przełączyć się na tę gałąź?", + "CheckoutTypeNewBranch": "Nowa lokalna gałąź", + "CheckoutTypeNewBranchTooltip": "Utwórz nową lokalną gałąź śledzącą tę gałąź zdalną.", "NewBranch": "Nowa gałąź", "NewBranchFromStashTooltip": "Utwórz nową gałąź z wybranego wpisu schowka. Działa poprzez przełączenie git na commit, na którym wpis schowka został utworzony, tworzenie nowej gałęzi z tego commita, a następnie zastosowanie wpisu schowka do nowej gałęzi jako dodatkowego commita.", + "MoveCommitsToNewBranch": "Przenieś commity do nowej gałęzi", + "MoveCommitsToNewBranchFromBaseItem": "Nowa gałąź z gałęzi podstawowej (%s)", "NoBranchesThisRepo": "Brak gałęzi dla tego repozytorium", "CommitWithoutMessageErr": "Nie możesz commitować bez wiadomości commita", "Close": "Zamknij", @@ -102,7 +131,7 @@ "FixupTooltip": "Włącz wybrany commit do commita poniżej. Podobnie do fixup, ale wiadomość wybranego commita zostanie odrzucona.", "SureSquashThisCommit": "Czy na pewno chcesz scalić wybrane commit(y) do commita poniżej?", "Squash": "Scal", - "PickCommitTooltip": "Oznacz wybrany commit do wybrania (podczas rebazowania). Oznacza to, że commit zostanie zachowany po kontynuacji rebazowania.", + "PickCommitTooltip": "Oznacz wybrany commit do wybrania (podczas przebazowania). Oznacza to, że commit zostanie zachowany po kontynuacji przebazowania.", "Pick": "Wybierz", "Edit": "Edytuj", "Revert": "Cofnij", @@ -110,13 +139,13 @@ "Reword": "Przeformułuj", "CommitRewordTooltip": "Przeformułuj wiadomość wybranego commita.", "DropCommit": "Usuń", - "DropCommitTooltip": "Usuń wybrany commit. To usunie commit z gałęzi za pomocą rebazowania. Jeśli commit wprowadza zmiany, od których zależą późniejsze commity, być może będziesz musiał rozwiązać konflikty scalania.", + "DropCommitTooltip": "Usuń wybrany commit. To usunie commit z gałęzi za pomocą przebazowania. Jeśli commit wprowadza zmiany, od których zależą późniejsze commity, być może będziesz musiał rozwiązać konflikty scalania.", "MoveDownCommit": "Przesuń commit w dół", "MoveUpCommit": "Przesuń commit w górę", "CannotMoveAnyFurther": "Nie można przesunąć dalej", - "EditCommit": "Edytuj (rozpocznij interaktywne rebazowanie)", - "EditCommitTooltip": "Edytuj wybrany commit. Użyj tego, aby rozpocząć interaktywne rebazowanie od wybranego commita. Podczas trwania rebazowania, to oznaczy wybrany commit do edycji, co oznacza, że po kontynuacji rebazowania, rebazowanie zostanie wstrzymane na wybranym commicie, aby umożliwić wprowadzenie zmian.", - "AmendCommitTooltip": "Popraw commit ze zmianami zatwierdzonymi. Jeśli wybrany commit jest commit HEAD, to wykona `git commit --amend`. W przeciwnym razie commit zostanie poprawiony za pomocą rebazowania.", + "EditCommit": "Edytuj (rozpocznij interaktywne przebazowanie)", + "EditCommitTooltip": "Edytuj wybrany commit. Użyj tego, aby rozpocząć interaktywne przebazowanie od wybranego commita. Podczas trwania przebazowania, to oznaczy wybrany commit do edycji, co oznacza, że po kontynuacji przebazowania, przebazowanie zostanie wstrzymane na wybranym commicie, aby umożliwić wprowadzenie zmian.", + "AmendCommitTooltip": "Popraw commit ze zmianami zatwierdzonymi. Jeśli wybrany commit jest commit HEAD, to wykona `git commit --amend`. W przeciwnym razie commit zostanie poprawiony za pomocą przebazowania.", "Amend": "Popraw", "ResetAuthor": "Resetuj autora", "ResetAuthorTooltip": "Resetuj autora commita do aktualnie skonfigurowanego użytkownika. To również odświeży znacznik czasu autora", @@ -131,6 +160,7 @@ "RewordCommitEditor": "Przeformułuj za pomocą edytora", "NoCommitsThisBranch": "Brak commitów dla tej gałęzi", "UpdateRefHere": "Zaktualizuj gałąź '{{.ref}}' tutaj", + "ExecCommandHere": "Wykonaj następującą komendę tutaj:", "Error": "Błąd", "Undo": "Cofnij", "UndoReflog": "Cofnij", @@ -192,6 +222,7 @@ "SwitchRepo": "Przełącz na ostatnie repozytorium", "UnsupportedGitService": "Nieobsługiwana usługa git", "CopyPullRequestURL": "Kopiuj adres URL żądania ściągnięcia do schowka", + "OpenPullRequestInBrowser": "Otwórz żądanie ściągnięcia w przeglądarce", "NoBranchOnRemote": "Ta gałąź nie istnieje na zdalnym serwerze. Musisz ją najpierw wysłać na zdalny serwer.", "Fetch": "Pobierz", "FetchTooltip": "Pobierz zmiany ze zdalnego serwera.", @@ -217,38 +248,43 @@ "ViewMergeRebaseOptions": "Pokaż opcje scalania/rebase", "ViewMergeRebaseOptionsTooltip": "Pokaż opcje do przerwania/kontynuowania/pominięcia bieżącego scalania/rebase.", "ViewMergeOptions": "Pokaż opcje scalania", - "ViewRebaseOptions": "Pokaż opcje rebase", - "NotMergingOrRebasing": "Aktualnie nie wykonujesz ani scalania, ani rebase", - "AlreadyRebasing": "Nie można wykonać tej akcji podczas rebase", + "ViewRebaseOptions": "Pokaż opcje przebazowania", + "NotMergingOrRebasing": "Aktualnie nie wykonujesz ani scalania, ani przebazowania", + "AlreadyRebasing": "Nie można wykonać tej akcji podczas przebazowania", "RecentRepos": "Ostatnie repozytoria", "MergeOptionsTitle": "Opcje scalania", - "RebaseOptionsTitle": "Opcje rebase", + "RebaseOptionsTitle": "Opcje przebazowania", "CommitSummaryTitle": "Podsumowanie commita", "CommitDescriptionTitle": "Opis commita", "CommitDescriptionSubTitle": "Naciśnij {{.togglePanelKeyBinding}}, aby przełączyć fokus, {{.commitMenuKeybinding}}, aby otworzyć menu", "LocalBranchesTitle": "Lokalne gałęzie", "SearchTitle": "Szukaj", "TagsTitle": "Tagi", + "MenuTitle": "Menu", "CommitMenuTitle": "Menu commita", "RemotesTitle": "Zdalne", "RemoteBranchesTitle": "Zdalne gałęzie", "PatchBuildingTitle": "Główny panel (budowanie łatki)", "InformationTitle": "Informacje", "SecondaryTitle": "Dodatkowy", + "ReflogCommitsTitle": "Dziennik reflog", "Continue": "Kontynuuj", - "RebasingFromBaseCommitTitle": "Rebase '{{.checkedOutBranch}}' od oznaczonego commita bazowego", - "SimpleRebase": "Prosty rebase na '{{.ref}}'", - "InteractiveRebase": "Interaktywny rebase na '{{.ref}}'", - "InteractiveRebaseTooltip": "Rozpocznij interaktywny rebase z przerwaniem na początku, abyś mógł zaktualizować commity TODO przed kontynuacją.", - "MustSelectTodoCommits": "Podczas rebase ta akcja działa tylko na zaznaczonych commitach TODO.", + "RebasingTitle": "Przebazuj '{{.checkedOutBranch}}'", + "RebasingFromBaseCommitTitle": "Przebazuj '{{.checkedOutBranch}}' od oznaczonego commita bazowego", + "SimpleRebase": "Proste przebazowanie na '{{.ref}}'", + "InteractiveRebase": "Interaktywne przebazowanie na '{{.ref}}'", + "RebaseOntoBaseBranch": "Przebazuj na główną gałąź ({{.baseBranch}})", + "InteractiveRebaseTooltip": "Rozpocznij interaktywne przebazowanie z przerwaniem na początku, abyś mógł zaktualizować commity TODO przed kontynuacją.", + "MustSelectTodoCommits": "Podczas przebazowania ta akcja działa tylko na zaznaczonych commitach TODO.", "FwdNoUpstream": "Nie można szybko przewinąć gałęzi bez źródła", "FwdNoLocalUpstream": "Nie można szybko przewinąć gałęzi, której zdalne źródło nie jest zarejestrowane lokalnie", "FwdCommitsToPush": "Nie można szybko przewinąć gałęzi z commitami do wysłania", "PullRequestNoUpstream": "Nie można otworzyć żądania ściągnięcia dla gałęzi bez źródła", "ErrorOccurred": "Wystąpił błąd! Proszę utworzyć zgłoszenie na", "YouDied": "ZGINĄŁEŚ!", - "RewordNotSupported": "Zmiana słów commitów podczas interaktywnego rebase nie jest obecnie obsługiwana", + "RewordNotSupported": "Zmiana słów commitów podczas interaktywnego przebazowania nie jest obecnie obsługiwana", "ChangingThisActionIsNotAllowed": "Zmiana tego rodzaju wpisu rebase TODO nie jest dozwolona", + "PickIsOnlyAllowedDuringRebase": "Ta akcja jest dozwolona tylko podczas przebazowania", "CherryPickCopy": "Kopiuj (cherry-pick)", "CherryPickCopyTooltip": "Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `{{.paste}}`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć `{{.escape}}`, aby anulować zaznaczenie.", "PasteCommits": "Wklej (cherry-pick)", @@ -267,6 +303,7 @@ "ScrollDownMainWindow": "Przewiń główne okno w dół", "AmendCommitTitle": "Popraw commit", "AmendCommitPrompt": "Czy na pewno chcesz poprawić ten commit swoimi zatwierdzonymi plikami?", + "AmendCommitWithConflictsContinue": "Nie, kontynuuj przebazowanie", "DropCommitTitle": "Usuń commit", "DropCommitPrompt": "Czy na pewno chcesz usunąć wybrane commity?", "PullingStatus": "Ściąganie", @@ -277,17 +314,19 @@ "DeletingStatus": "Usuwanie", "DroppingStatus": "Upuszczanie", "MovingStatus": "Przesuwanie", - "RebasingStatus": "Rebase", + "RebasingStatus": "Przebazowanie", "MergingStatus": "Scalanie", - "LowercaseRebasingStatus": "rebase", + "LowercaseRebasingStatus": "przebazowanie", "LowercaseMergingStatus": "scalanie", "AmendingStatus": "Poprawianie", "UndoingStatus": "Cofanie", "RedoingStatus": "Ponawianie", "CheckingOutStatus": "Sprawdzanie", "CommittingStatus": "Commitowanie", + "RewordingStatus": "Przeredagowywanie", "RevertingStatus": "Przywracanie", "CreatingFixupCommitStatus": "Tworzenie commita poprawiającego", + "MovingCommitsToNewBranchStatus": "Przenoszenie commitów do nowej gałęzi", "CommitFiles": "Zatwierdź pliki", "SubCommitsDynamicTitle": "Commity (%s)", "CommitFilesDynamicTitle": "Pliki różnic (%s)", @@ -297,8 +336,9 @@ "CheckoutCommitFileTooltip": "Przełącz plik. Zastępuje plik w twoim drzewie roboczym wersją z wybranego commita.", "CanOnlyDiscardFromLocalCommits": "Można odrzucić tylko zmiany z lokalnych commitów", "Remove": "Usuń", - "DiscardOldFileChangeTooltip": "Odrzuć zmiany w tym pliku z tego commita. Uruchamia interaktywny rebase w tle, więc możesz otrzymać konflikt scalania, jeśli późniejszy commit również zmienia ten plik.", + "DiscardOldFileChangeTooltip": "Odrzuć zmiany w tym pliku z tego commita. Uruchamia interaktywne przebazowanie w tle, więc możesz otrzymać konflikt scalania, jeśli późniejszy commit również zmienia ten plik.", "DiscardFileChangesTitle": "Odrzuć zmiany w pliku", + "CreateRepo": "Nie jesteś w repozytorium git. Utwórz nowe repozytorium git? (y/N): ", "BareRepo": "Próbujesz otworzyć Lazygit w gołym repozytorium, ale Lazygit jeszcze nie obsługuje gołych repozytoriów. Otworzyć najnowsze repozytorium? (t/n) ", "InitialBranch": "Nazwa gałęzi? (pozostaw puste dla domyślnej gita): ", "NoRecentRepositories": "Musisz otworzyć lazygit w repozytorium git. Brak ważnych ostatnich repozytoriów. Wyjście.", @@ -329,6 +369,8 @@ "SquashCommitsInCurrentBranch": "W bieżącej gałęzi", "SquashCommitsAboveSelectedCommit": "Powyżej wybranego commita", "CannotSquashCommitsInCurrentBranch": "Nie można scalić commitów w bieżącej gałęzi: commit HEAD jest commit merge lub jest obecny na głównej gałęzi.", + "ExecuteShellCommand": "Wykonaj polecenie w powłoce", + "ShellCommand": "Polecenie powłoki:", "CommitChangesWithoutHook": "Zatwierdź zmiany bez hooka pre-commit", "ResetTo": "Resetuj do", "ResetSoftTooltip": "Resetuj HEAD do wybranego commita, zachowując zmiany między bieżącym a wybranym commit jako zmiany zatwierdzone.", @@ -369,13 +411,17 @@ "NewRemote": "Nowy zdalny", "NewRemoteName": "Nowa nazwa zdalnego:", "NewRemoteUrl": "Nowy URL zdalnego:", + "IncompatibleForkAlreadyExistsError": "Zdalne {{.remoteName}} już istnieje i posiada inny adres URL", "ViewBranches": "Wyświetl gałęzie", "EditRemoteName": "Wprowadź zaktualizowaną nazwę zdalnego dla {{.remoteName}}:", "EditRemoteUrl": "Wprowadź zaktualizowany URL zdalnego dla {{.remoteName}}:", "RemoveRemote": "Usuń zdalny", "RemoveRemoteTooltip": "Usuń wybrany zdalny. Wszelkie lokalne gałęzie śledzące gałąź zdalną z tego zdalnego nie zostaną dotknięte.", "DeleteRemoteBranch": "Usuń gałąź zdalną", + "DeleteRemoteBranches": "Usuń gałęzie zdalne", "DeleteRemoteBranchTooltip": "Usuń gałąź zdalną ze zdalnego.", + "DeleteLocalAndRemoteBranch": "Usuń lokalną i zdalną gałąź", + "DeleteLocalAndRemoteBranches": "Usuń lokalne i zdalne gałęzie", "SetAsUpstream": "Ustaw jako upstream", "SetAsUpstreamTooltip": "Ustaw wybraną gałąź zdalną jako upstream sprawdzonej gałęzi.", "SetUpstream": "Ustaw upstream wybranej gałęzi", @@ -385,8 +431,8 @@ "DivergenceSectionHeaderRemote": "Zdalne", "ViewUpstreamResetOptions": "Resetuj sprawdzoną gałąź na {{.upstream}}", "ViewUpstreamResetOptionsTooltip": "Wyświetl opcje resetowania sprawdzonej gałęzi na {{upstream}}. Uwaga: to nie zresetuje wybranej gałęzi na upstream, zresetuje sprawdzoną gałąź na upstream.", - "ViewUpstreamRebaseOptions": "Rebase sprawdzonej gałęzi na {{.upstream}}", - "ViewUpstreamRebaseOptionsTooltip": "Wyświetl opcje rebasowania sprawdzonej gałęzi na {{upstream}}. Uwaga: to nie zrebase'uje wybranej gałęzi na upstream, zrebase'uje sprawdzoną gałąź na upstream.", + "ViewUpstreamRebaseOptions": "Przebazuj aktywną gałąź na {{.upstream}}", + "ViewUpstreamRebaseOptionsTooltip": "Wyświetl opcje przebazowania aktywnej gałęzi na {{upstream}}. Uwaga: to nie przebazuje wybranej gałęzi na upstream, lecz przebazuje aktywną gałąź na upstream.", "UpstreamGenericName": "upstream wybranej gałęzi", "SetUpstreamTitle": "Ustaw gałąź upstream", "EditRemoteTooltip": "Edytuj nazwę lub URL wybranego zdalnego.", @@ -399,8 +445,10 @@ "DeleteTagTitle": "Usuń tag '{{.tagName}}'?", "DeleteLocalTag": "Usuń lokalny tag", "DeleteRemoteTag": "Usuń zdalny tag", + "DeleteLocalAndRemoteTag": "Usuń lokalny i zdalny tag", "SelectRemoteTagUpstream": "Zdalny, z którego usunąć tag '{{.tagName}}':", "DeleteRemoteTagPrompt": "Czy na pewno chcesz usunąć zdalny tag '{{.tagName}}' z '{{.upstream}}'?", + "DeleteLocalAndRemoteTagPrompt": "Czy na pewno chcesz usunąć '{{.tagName}}' zarówno ze swojego komputera, jak i z '{{.upstream}}'?", "RemoteTagDeletedMessage": "Zdalny tag usunięty", "PushTagTitle": "Zdalny, do którego wysłać tag '{{.tagName}}':", "PushTag": "Wyślij tag", @@ -425,7 +473,6 @@ "StartSearch": "Szukaj w bieżącym widoku po tekście", "StartFilter": "Filtruj bieżący widok po tekście", "Keybindings": "Skróty klawiszowe", - "KeybindingsLegend": "Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b", "KeybindingsMenuSectionLocal": "Lokalne", "KeybindingsMenuSectionGlobal": "Globalne", "KeybindingsMenuSectionNavigation": "Nawigacja", @@ -480,9 +527,11 @@ "CommitMessage": "Wiadomość commita", "CommitSubject": "Temat commita", "CommitAuthor": "Autor commita", + "CommitTags": "Zatwierdź tagi", "CopyCommitAttributeToClipboard": "Kopiuj atrybut commita do schowka", "CopyCommitAttributeToClipboardTooltip": "Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor).", "CopyBranchNameToClipboard": "Kopiuj nazwę gałęzi do schowka", + "CopyTagToClipboard": "Skopiuj tag do schowka", "CopyPathToClipboard": "Kopiuj ścieżkę do schowka", "CommitPrefixPatternError": "Błąd w wzorcu commitPrefix", "CopySelectedTextToClipboard": "Kopiuj zaznaczony tekst do schowka", @@ -538,7 +587,9 @@ "CommitMessageCopiedToClipboard": "Wiadomość commita skopiowana do schowka", "CommitSubjectCopiedToClipboard": "Temat commita skopiowany do schowka", "CommitAuthorCopiedToClipboard": "Autor commita skopiowany do schowka", + "CommitHasNoTags": "Commit nie jest otagowany", "PatchCopiedToClipboard": "Łatka skopiowana do schowka", + "MessageCopiedToClipboard": "Wiadomość została skopiowana do schowka", "CopiedToClipboard": "skopiowane do schowka", "ErrCannotEditDirectory": "Nie można edytować katalogu: można edytować tylko pojedyncze pliki", "ErrStageDirWithInlineMergeConflicts": "Nie można przygotować/odprzygotować katalogu zawierającego pliki z konfliktami scalania w linii. Proszę najpierw rozwiązać konflikty scalania", @@ -558,6 +609,7 @@ "CreatePullRequestOptions": "Zobacz opcje tworzenia pull requesta", "DefaultBranch": "Domyślny branch", "SelectBranch": "Wybierz branch", + "NoValidRemoteName": "Zdalne o nazwie '%s' nie istnieje", "CreatePullRequest": "Utwórz żądanie ściągnięcia", "SelectConfigFile": "Wybierz plik konfiguracyjny", "NoConfigFileFoundErr": "Nie znaleziono pliku konfiguracyjnego", @@ -583,6 +635,7 @@ "OpenCommitInBrowser": "Otwórz commit w przeglądarce", "ViewBisectOptions": "Zobacz opcje bisect", "ConfirmRevertCommit": "Czy na pewno chcesz cofnąć {{.selectedCommit}}?", + "ConfirmRevertCommitRange": "Czy na pewno chcesz cofnąć wybrane commity?", "RewordInEditorTitle": "Przeformułuj w edytorze", "RewordInEditorPrompt": "Czy na pewno chcesz przeformułować ten commit w swoim edytorze?", "HardResetAutostashPrompt": "Czy na pewno chcesz zrobić twardy reset do '%s'? Auto-stash zostanie wykonany jeśli będzie potrzebny.", @@ -590,6 +643,7 @@ "NukeDescription": "Jeśli chcesz, aby wszystkie zmiany w drzewie pracy zniknęły, to jest sposób na to. Jeśli są brudne zmiany w submodule, to zostaną one zapisane w submodule(s).", "DiscardStagedChangesDescription": "To stworzy nowy wpis stash zawierający tylko pliki w stanie staged, a następnie go usunie, tak że drzewo pracy zostanie tylko ze zmianami niezatwierdzonymi", "EmptyOutput": "", + "Patch": "Łatka", "CustomPatch": "Niestandardowy patch", "CommitsCopied": "commitów skopiowanych", "CommitCopied": "commit skopiowany", @@ -600,12 +654,12 @@ "ApplyPatchInReverse": "Zastosuj patch w odwrotności", "ApplyPatchInReverseTooltip": "Zastosuj bieżący patch w odwrotności do drzewa pracy.", "RemovePatchFromOriginalCommit": "Usuń patch z oryginalnego commita (%s)", - "RemovePatchFromOriginalCommitTooltip": "Usuń bieżący patch z jego commita. Jest to osiągane przez rozpoczęcie interaktywnego rebase na commicie, zastosowanie patcha w odwrotności, a następnie kontynuowanie rebase. Jeśli późniejsze commity zależą od patcha, możesz musieć rozwiązać konflikty.", + "RemovePatchFromOriginalCommitTooltip": "Usuń bieżący patch z jego commita. Jest to osiągane przez rozpoczęcie interaktywnego przebazowania na commicie, zastosowanie patcha w odwrotności, a następnie kontynuowanie przebazowania. Jeśli późniejsze commity zależą od patcha, możesz musieć rozwiązać konflikty.", "MovePatchOutIntoIndex": "Przenieś patch do indeksu", - "MovePatchOutIntoIndexTooltip": "Przenieś patch z jego commita do indeksu. Jest to osiągane przez rozpoczęcie interaktywnego rebase na commicie, zastosowanie patcha w odwrotności, kontynuowanie rebase do zakończenia, a następnie zastosowanie patcha do indeksu. Jeśli późniejsze commity zależą od patcha, możesz musieć rozwiązać konflikty.", - "MovePatchIntoNewCommitTooltip": "Przenieś patch z jego commita do nowego commita na górze oryginalnego commita. Jest to osiągane przez rozpoczęcie interaktywnego rebase na oryginalnym commicie, zastosowanie patcha w odwrotności, następnie zastosowanie patcha do indeksu i zatwierdzenie go jako nowy commit, przed kontynuowaniem rebase do zakończenia. Jeśli późniejsze commity zależą od patcha, możesz musieć rozwiązać konflikty.", + "MovePatchOutIntoIndexTooltip": "Przenieś patch z jego commita do indeksu. Jest to osiągane przez rozpoczęcie interaktywnego rebase na commicie, zastosowanie patcha w odwrotności, kontynuowanie przebazowania do zakończenia, a następnie zastosowanie patcha do indeksu. Jeśli późniejsze commity zależą od patcha, możesz musieć rozwiązać konflikty.", + "MovePatchIntoNewCommitTooltip": "Przenieś patch z jego commita do nowego commita na górze oryginalnego commita. Jest to osiągane przez rozpoczęcie interaktywnego przebazowania na oryginalnym commicie, zastosowanie patcha w odwrotności, następnie zastosowanie patcha do indeksu i zatwierdzenie go jako nowy commit, przed kontynuowaniem przebazowania do zakończenia. Jeśli późniejsze commity zależą od patcha, możesz musieć rozwiązać konflikty.", "MovePatchToSelectedCommit": "Przenieś patch do wybranego commita (%s)", - "MovePatchToSelectedCommitTooltip": "Przenieś patch z jego oryginalnego commita do wybranego commita. Jest to osiągane przez rozpoczęcie interaktywnego rebase na oryginalnym commicie, zastosowanie patcha w odwrotności, następnie kontynuowanie rebase do wybranego commita, przed zastosowaniem patcha do przodu i zmodyfikowaniem wybranego commita. Rebase jest następnie kontynuowany do zakończenia. Jeśli commity między źródłem a miejscem docelowym zależą od patcha, możesz musieć rozwiązać konflikty.", + "MovePatchToSelectedCommitTooltip": "Przenieś patch z jego oryginalnego commita do wybranego commita. Jest to osiągane przez rozpoczęcie interaktywnego przebazowania na oryginalnym commicie, zastosowanie patcha w odwrotności, następnie kontynuowanie przebazowania do wybranego commita, przed zastosowaniem patcha do przodu i zmodyfikowaniem wybranego commita. Przebazowanie jest następnie kontynuowane do zakończenia. Jeśli commity między źródłem a miejscem docelowym zależą od patcha, możesz musieć rozwiązać konflikty.", "CopyPatchToClipboard": "Kopiuj patch do schowka", "NoMatchesFor": "Brak dopasowań dla '%s' %s", "MatchesFor": "dopasowania dla '%s' (%d z %d) %s", @@ -647,16 +701,17 @@ "LcWorktree": "drzewo pracy", "ChangingDirectoryTo": "Zmiana katalogu na {{.path}}", "Name": "Nazwa", + "Branch": "Gałąź", "Path": "Ścieżka", - "MarkedBaseCommitStatus": "Oznaczono bazowy commit dla rebase", - "MarkAsBaseCommit": "Oznacz jako bazowy commit dla rebase", - "MarkAsBaseCommitTooltip": "Wybierz bazowy commit dla następnego rebase. Kiedy robisz rebase na branch, tylko commity powyżej bazowego commita zostaną przeniesione. Używa to polecenia `git rebase --onto`.", - "MarkedCommitMarker": "↑↑↑ Rebase rozpocznie się stąd ↑↑↑", + "MarkedBaseCommitStatus": "Oznaczono bazowy commit dla przebazowania", + "MarkAsBaseCommit": "Oznacz jako bazowy commit dla przebazowania", + "MarkAsBaseCommitTooltip": "Wybierz bazowy commit dla następnego przebazowania. Kiedy robisz przebazowanie na gałąź, tylko commity powyżej bazowego commita zostaną przeniesione. Używa to polecenia `git rebase --onto`.", + "MarkedCommitMarker": "↑↑↑ Przebazowanie rozpocznie się stąd ↑↑↑", "NoCopiedCommits": "Brak skopiowanych commitów", "DisabledMenuItemPrefix": "Wyłączone: ", - "QuickStartInteractiveRebase": "Rozpocznij interaktywny rebase", - "QuickStartInteractiveRebaseTooltip": "Rozpocznij interaktywny rebase dla commitów na twoim branchu. To będzie zawierać wszystkie commity od HEAD do pierwszego commita scalenia lub commita głównego brancha.\nJeśli chcesz zamiast tego rozpocząć interaktywny rebase od wybranego commita, naciśnij `{{.editKey}}`.", - "CannotQuickStartInteractiveRebase": "Nie można rozpocząć interaktywnego rebase: commit HEAD jest commit'em scalenia lub jest obecny na głównym branchu, więc nie ma odpowiedniego bazowego commita, od którego można by zacząć rebase. Możesz rozpocząć interaktywny rebase z konkretnego commita, wybierając commit i naciskając `{{.editKey}}`.", + "QuickStartInteractiveRebase": "Rozpocznij interaktywne przebazowanie", + "QuickStartInteractiveRebaseTooltip": "Rozpocznij interaktywne przebazowanie dla commitów na twojej gałęzi. To będzie zawierać wszystkie commity od HEAD do pierwszego commita scalenia lub commita głównej gałęzi.\nJeśli zamiast tego chcesz rozpocząć interaktywne przebazowanie od wybranego commita, naciśnij `{{.editKey}}`.", + "CannotQuickStartInteractiveRebase": "Nie można rozpocząć interaktywnego przebazowania: commit HEAD jest commit'em scalenia lub jest obecny na głównej gałęzi, więc nie ma odpowiedniego bazowego commita, od którego można by zacząć przebazowanie. Możesz rozpocząć interaktywne przebazowanie z konkretnego commita, wybierając commit i naciskając `{{.editKey}}`.", "ToggleRangeSelect": "Przełącz zaznaczenie zakresu", "RangeSelectUp": "Zaznacz zakres w górę", "RangeSelectDown": "Zaznacz zakres w dół", @@ -666,12 +721,13 @@ "SelectedItemDoesNotHaveFiles": "Wybrany element nie ma plików do wyświetlenia", "Actions": { "CheckoutCommit": "Przełącz commit", + "CheckoutBranchAtCommit": "Przełącz na gałąź '%s'", "CheckoutTag": "Przełącz tag", "CheckoutBranch": "Przełącz gałąź", "ForceCheckoutBranch": "Wymuś przełączenie gałęzi", "DeleteLocalBranch": "Usuń lokalną gałąź", "Merge": "Scal", - "RebaseBranch": "Rebazuj gałąź", + "RebaseBranch": "Przebazuj gałąź", "RenameBranch": "Zmień nazwę gałęzi", "CreateBranch": "Utwórz gałąź", "FastForwardBranch": "Szybkie przewijanie gałęzi", @@ -685,6 +741,7 @@ "AmendCommit": "Popraw commit", "ResetCommitAuthor": "Zresetuj autora commita", "SetCommitAuthor": "Ustaw autora commita", + "AddCommitCoAuthor": "Dodaj współautora commita", "RevertCommit": "Cofnij commit", "CreateFixupCommit": "Utwórz commit poprawkowy", "SquashAllAboveFixupCommits": "Scal wszystkie powyższe commity poprawkowe", @@ -706,6 +763,8 @@ "UnstageFile": "Usuń plik z indeksu", "UnstageAllFiles": "Usuń wszystkie pliki z indeksu", "StageAllFiles": "Dodaj wszystkie pliki do indeksu", + "ResolveConflictByKeepingFile": "Rozwiąż poprzez zachowanie pliku", + "ResolveConflictByDeletingFile": "Rozwiąż poprzez usunięcie pliku", "IgnoreExcludeFile": "Ignoruj lub wyklucz plik", "IgnoreFileErr": "Nie można zignorować .gitignore", "ExcludeFile": "Wyklucz plik", @@ -785,14 +844,14 @@ "Bisecting": "Bisectowanie" }, "Log": { - "EditRebase": "Rozpoczynanie interaktywnego rebazowania od '{{.ref}}'", + "EditRebase": "Rozpoczynanie interaktywnego przebazowania od '{{.ref}}'", "HandleUndo": "Cofanie ostatniego rozwiązania konfliktu", "RemoveFile": "Usuwanie ścieżki '{{.path}}'", "CopyToClipboard": "Kopiowanie '{{.str}}' do schowka", "Remove": "Usuwanie '{{.filename}}'", "CreateFileWithContent": "Tworzenie pliku '{{.path}}'", "AppendingLineToFile": "Dodawanie '{{.line}}' do pliku '{{.filename}}'", - "EditRebaseFromBaseCommit": "Rozpoczynanie interaktywnego rebazowania od '{{.baseCommit}}' na '{{.targetBranchName}}'" + "EditRebaseFromBaseCommit": "Rozpoczynanie interaktywnego przebazowania od '{{.baseCommit}}' na '{{.targetBranchName}}'" }, "BreakingChangesTitle": "Zmiany przełomowe", "BreakingChangesMessage": "Aktualizujesz do nowej wersji lazygit, która zawiera zmiany przełomowe. Proszę przejrzeć poniższe notatki i zaktualizować swoją konfigurację, jeśli jest to konieczne.\nAby uzyskać więcej informacji, zobacz pełne notatki do wydania na .", diff --git a/pkg/i18n/translations/ru.json b/pkg/i18n/translations/ru.json index 996840ebf..89f09521e 100644 --- a/pkg/i18n/translations/ru.json +++ b/pkg/i18n/translations/ru.json @@ -37,9 +37,13 @@ "Push": "Отправить изменения", "Pull": "Получить и слить изменения", "FileFilter": "Фильтровать файлы (проиндексированные/непроиндексированные)", + "CopyFileName": "Имя файла", "FilterStagedFiles": "Показывать только проиндексированные файлы", "FilterUnstagedFiles": "Показывать только непроиндексированные файлы", "MergeConflictsTitle": "Конфликты Слияния", + "MergeConflictCurrentDiff": "Текущие изменения:", + "MergeConflictKeepFile": "Оставить файл", + "MergeConflictDeleteFile": "Удалить файл", "Checkout": "Переключить", "NoChangedFiles": "Нет изменённых файлов", "SoftReset": "Мягкий сброс", @@ -305,7 +309,6 @@ "PrevScreenMode": "Предыдущий режим экрана", "StartSearch": "Найти", "Keybindings": "Связки клавиш", - "KeybindingsLegend": "Связки клавиш", "RenameBranch": "Переименовать ветку", "NewGitFlowBranchPrompt": "Новое {{.branchType}} название:", "RenameBranchWarning": "Эта ветвь отслеживает удалённый репозитории. Это действие переименует только имя локальной ветки, а не имя удалённой ветки. Продолжать?", diff --git a/pkg/i18n/translations/zh-CN.json b/pkg/i18n/translations/zh-CN.json index 3450b43fc..0bbe1f117 100644 --- a/pkg/i18n/translations/zh-CN.json +++ b/pkg/i18n/translations/zh-CN.json @@ -152,6 +152,12 @@ "CannotSquashOrFixupMergeCommit": "无法对合并提交进行压缩或修正", "Fixup": "修正 (fixup)", "FixupTooltip": "将选定的提交合并到其下面的提交中。与压缩类似,但所选提交的消息将被丢弃。", + "FixupKeepMessage": "修复并使用此提交信息", + "FixupKeepMessageTooltip": "将所选提交压缩到下方的提交中,使用此提交的信息,并丢弃下方提交的信息。", + "SetFixupMessage": "设置修复提交信息", + "SetFixupMessageTooltip": "设置修复提交的信息选项。-C 选项表示使用此提交的信息,而非目标提交的信息。", + "FixupDiscardMessage": "修复并丢弃此提交的信息", + "FixupDiscardMessageTooltip": "将所选提交压缩到下方的提交中,丢弃此提交的信息。", "SureSquashThisCommit": "您确定要将这个提交压缩到下面的提交中吗?", "Squash": "压缩(Squash)", "PickCommitTooltip": "标记选中的提交为 picked(变基过程中)。这意味该提交将在后续的变基中保留。", @@ -261,8 +267,11 @@ "ConfirmQuit": "您确定要退出吗?", "SwitchRepo": "切换到最近的仓库", "AllBranchesLogGraph": "显示/循环所有分支日志", + "AllBranchesLogGraphReverse": "显示/循环所有分支日志(反向)", "UnsupportedGitService": "不支持的 git 服务", "CopyPullRequestURL": "复制拉取请求 URL 到剪贴板", + "OpenPullRequestInBrowser": "在浏览器中打开拉取请求", + "NoPullRequestForBranch": "未找到此分支的拉取请求", "NoBranchOnRemote": "该分支在远程上不存在. 您需要先将其推送到远程.", "Fetch": "抓取", "FetchTooltip": "从远程获取变更", @@ -282,6 +291,8 @@ "ToggleSelectHunkTooltip": "切换逐行选择与代码块选择模式。", "HunkStagingHint": "代码块选择模式现在是暂存区的默认模式。如果您想暂存单行,请按 '%s' 切换到逐行模式。\n\n如果您希望默认使用逐行模式(像早期 lazygit 版本那样),请将\n\ngui:\n useHunkModeInStagingView: false\n\n添加到您的 lazygit 配置中。", "ToggleSelectionForPatch": "添加/移除 行到补丁", + "RemoveSelectionFromPatch": "从提交中移除行", + "RemoveSelectionFromPatchTooltip": "从本次提交中移除所选行。此操作会在后台运行交互式变基,因此如果后续提交也修改了这些行,您可能会遇到合并冲突。", "EditHunk": "编辑代码块", "EditHunkTooltip": "在外部编辑器中编辑选中的代码块", "ToggleStagingView": "切换到其他面板", @@ -303,6 +314,8 @@ "ViewRevertOptions": "查看撤销选项", "NotMergingOrRebasing": "您目前既不进行变基也不进行合并", "AlreadyRebasing": "在变基时无法执行此操作", + "NotMidRebase": "此操作仅在交互式变基期间有效", + "MustSelectFixupCommit": "此操作仅适用于修复提交", "RecentRepos": "最近的仓库", "MergeOptionsTitle": "合并选项", "RebaseOptionsTitle": "变基选项", @@ -312,7 +325,6 @@ "CommitDescriptionTitle": "提交信息说明", "CommitDescriptionSubTitle": "按 {{.togglePanelKeyBinding}} 键切换焦点, {{.commitMenuKeybinding}} 打开菜单", "CommitDescriptionFooter": "按 {{.confirmInEditorKeybinding}} 提交", - "CommitDescriptionFooterTwoBindings": "按 {{.confirmInEditorKeybinding1}} 或 {{.confirmInEditorKeybinding2}} 提交", "CommitHooksDisabledSubTitle": "(钩子已禁用)", "LocalBranchesTitle": "本地分支", "SearchTitle": "搜索", @@ -414,9 +426,12 @@ "CheckoutCommitFileTooltip": "检出文件", "CannotCheckoutWithModifiedFilesErr": "您已有对您试图签出的文件作出的本地修改。您需要先保存或丢弃这些文件。", "CanOnlyDiscardFromLocalCommits": "只能从本地提交中丢弃更改", + "CannotDiscardFromMultipleCommits": "无法从多选提交中丢弃更改", "Remove": "删除", "DiscardOldFileChangeTooltip": "放弃对此文件的提交变更", "DiscardFileChangesTitle": "放弃文件变更", + "DiscardFileChangesPrompt": "确定要从此提交中丢弃所选文件的更改吗?\n\n此操作将启动变基,还原这些文件更改。请注意,如果后续提交依赖于这些更改,您可能需要解决冲突。", + "DiscardFileChangesPromptResetPatch": "确定要从此提交中丢弃所选文件的更改吗?\n\n此操作将启动变基,还原这些文件更改。请注意,如果后续提交依赖于这些更改,您可能需要解决冲突。\n\n注意:这将重置活动的自定义补丁!", "DisabledForGPG": "使用GPG的用户无法使用此功能。\n\n如果您正在使用密码代理(如gpg-agent)以避免每次签名时输入密码,可以通过在lazygit配置文件中添加\n\ngit:\n overrideGpg: true\n\n来启用此功能。", "CreateRepo": "不在 git 仓库中。创建一个新的 git 仓库吗?(y/N): ", "BareRepo": "您已经尝试在空仓库中打开Lazygit,但是Lazygit还不支持空仓库。打开最近的仓库吗?(y / n) ", @@ -583,8 +598,9 @@ "CyclePagersDisabledReason": "未配置其他分页器", "StartSearch": "开始搜索", "StartFilter": "通过文本过滤当前视图", + "SelectRemoteRepository": "为拉取请求选择基础仓库", + "FetchingPullRequests": "正在获取拉取请求", "Keybindings": "按键绑定", - "KeybindingsLegend": "图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b", "KeybindingsMenuSectionLocal": "本地", "KeybindingsMenuSectionGlobal": "全局", "KeybindingsMenuSectionNavigation": "导航", @@ -641,6 +657,7 @@ "ShowingGitDiff": "显示输出:", "ShowingDiffForRange": "显示范围差异", "CommitDiff": "比较提交差异", + "CopyCommitHashToClipboard": "复制缩略提交哈希值到剪贴板", "CommitHash": "提交的 hash", "CommitURL": "提交URL", "PasteCommitMessageFromClipboard": "粘贴提交信息自剪贴板", @@ -664,6 +681,9 @@ "BranchUnknown": "未知的分支", "DiscardChangeTitle": "取消暂存选中的行", "DiscardChangePrompt": "您确定要删除所选的行(git reset)吗?这是不可逆的。\n要禁用此对话框,请将 'gui.skipDiscardChangeWarning' 的配置键设置为 true", + "DiscardLinesFromCommitTitle": "从提交中丢弃行", + "DiscardLinesFromCommitPrompt": "确定要从此提交中丢弃所选行吗?", + "DiscardLinesFromCommitPromptWithReset": "确定要从此提交中丢弃所选行吗?\n\n注意:这将重置活动的自定义补丁!", "CreateNewBranchFromCommit": "从提交创建新分支", "BuildingPatch": "正在构建补丁", "ViewCommits": "查看提交", @@ -844,6 +864,7 @@ "CantDeleteMainWorktree": "您不能移除主工作树!", "NoWorktreesThisRepo": "没有工作区", "MissingWorktree": "(缺失)", + "MainWorktree": "(主工作树)", "NewWorktree": "新建工作树", "NewWorktreePath": "新建工作树路径", "NewWorktreeBase": "新建工作树基于ref", @@ -903,6 +924,7 @@ "CheckoutFile": "检出文件", "SquashCommitDown": "向下压缩提交", "FixupCommit": "修正提交", + "FixupCommitKeepMessage": "修复提交(保留信息)", "RewordCommit": "改写提交", "DropCommit": "删除提交", "EditCommit": "编辑提交", @@ -937,6 +959,7 @@ "ResolveConflictByDeletingFile": "通过删除文件解决冲突", "NotEnoughContextToStage": "差异上下文大小为0时无法暂存或取消暂存更改。请使用'%s'增大上下文。", "NotEnoughContextToDiscard": "差异上下文大小为0时无法丢弃更改。请使用'%s'增大上下文。", + "NotEnoughContextToRemoveLines": "在差异上下文大小为 0 时无法从提交中移除行。请使用 '%s' 增加上下文大小。", "NotEnoughContextForCustomPatch": "在差异上下文大小为 0 时无法创建自定义补丁。请使用 '%s' 增加上下文。", "IgnoreExcludeFile": "忽略文件", "IgnoreFileErr": "无法忽略 .gitignore", @@ -1026,11 +1049,15 @@ "EditRebase": "开始从 '{{.ref}}' 进行交互式变基", "HandleUndo": "撤销最后一次的冲突解决方案", "RemoveFile": "正在删除路径 '{{.path}}'", + "RemoveEmptyDir": "正在删除空目录 '{{.path}}'", "CopyToClipboard": "正在复制 '{{.str}}' 到剪贴板", "Remove": "删除 '{{.filename}}'", "CreateFileWithContent": "正在创建文件 '{{.path}}'", "AppendingLineToFile": "将 '{{.line}}' 附加到文件 '{{.filename}}'", - "EditRebaseFromBaseCommit": "开始从'{{.baseCommit}}'进行交互式变基到'{{.targetBranchName}}‘" + "EditRebaseFromBaseCommit": "开始从'{{.baseCommit}}'进行交互式变基到'{{.targetBranchName}}‘", + "DroppingStash": "正在删除储藏 %s", + "PoppingStash": "正在弹出储藏 %s", + "DeletingBranch": "正在删除分支 '{{.branchName}}'(原为 {{.hash}})" }, "BreakingChangesTitle": "重大变化", "BreakingChangesMessage": "您正在更新到 lazygit 的新版本,其中含有中断的更改。请阅读下面的说明,并在必要时更新您的配置。\n欲了解更多信息,请参阅 的完整版本说明。", @@ -1041,7 +1068,7 @@ "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\tredo: \n\n- 'git.paging.useConfig' 选项已被移除。如果你之前依赖它来配置你的分页器,现在必须使用 'git.paging.pager' 选项重新明确设置分页器。" + "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' 选项重新明确设置分页器。" }, "ViewMergeConflictOptions": "查看合并冲突选项", "ViewMergeConflictOptionsTooltip": "查看用于解决合并冲突的选项。", diff --git a/pkg/i18n/translations/zh-TW.json b/pkg/i18n/translations/zh-TW.json index f2a666a60..7e47caec6 100644 --- a/pkg/i18n/translations/zh-TW.json +++ b/pkg/i18n/translations/zh-TW.json @@ -352,7 +352,6 @@ "StartSearch": "搜尋", "StartFilter": "搜尋", "Keybindings": "鍵盤快捷鍵", - "KeybindingsLegend": "說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B", "KeybindingsMenuSectionLocal": "本地", "KeybindingsMenuSectionGlobal": "全域", "RenameBranch": "重新命名分支", From 07d73fbbeeaef319a7b1d08dfab730c6d926af1a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 26 May 2026 22:12:31 +0200 Subject: [PATCH 038/384] Update docs and schema for release --- docs/Config.md | 106 +- docs/Custom_Command_Keybindings.md | 4 +- docs/Undoing.md | 2 +- docs/keybindings/Custom_Keybindings.md | 160 +- docs/keybindings/Keybindings_en.md | 109 +- docs/keybindings/Keybindings_ja.md | 109 +- docs/keybindings/Keybindings_ko.md | 109 +- docs/keybindings/Keybindings_nl.md | 109 +- docs/keybindings/Keybindings_pl.md | 173 +- docs/keybindings/Keybindings_pt.md | 109 +- docs/keybindings/Keybindings_ru.md | 109 +- docs/keybindings/Keybindings_zh-CN.md | 119 +- docs/keybindings/Keybindings_zh-TW.md | 109 +- schema/config.json | 2169 +++++++++++++++++++++--- 14 files changed, 2634 insertions(+), 862 deletions(-) diff --git a/docs/Config.md b/docs/Config.md index 9931fda61..9f7921821 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -431,7 +431,8 @@ git: - git log --graph --all --color=always --abbrev-commit --decorate --date=relative --pretty=medium # If true, git diffs are rendered with the `--ignore-all-space` flag, which - # ignores whitespace changes. Can be toggled from within Lazygit with ``. + # ignores whitespace changes. Can be toggled from within Lazygit with + # ``. ignoreWhitespaceInDiffView: false # The number of lines of context to show around each diff hunk. Can be changed @@ -468,14 +469,14 @@ git: # appear chronologically. See https://git-scm.com/docs/ # # Can be changed from within Lazygit with `Log menu -> Commit sort order` - # (`` in the commits window by default). + # (`` 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' # - # Can be toggled from within lazygit with `Log menu -> Show git graph` (`` - # in the commits window by default). + # Can be toggled from within lazygit 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 @@ -590,36 +591,30 @@ notARepository: prompt # view the output of the subprocess before returning to Lazygit. promptToReturnFromSubprocess: true -# Keybindings +# Keybindings. +# Each binding can be a single key or a list of keys; see +# https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md +# for the syntax. keybinding: universal: - quit: q - quit-alt1: - suspendApp: + quit: [q, ] + suspendApp: return: quitWithoutChangingDirectory: Q togglePanel: - prevItem: - nextItem: - prevItem-alt: k - nextItem-alt: j + prevItem: [, k] + nextItem: [, j] prevPage: ',' nextPage: . scrollLeft: H scrollRight: L - gotoTop: < - gotoBottom: '>' - gotoTop-alt: - gotoBottom-alt: + gotoTop: [<, ] + gotoBottom: ['>', ] toggleRangeSelect: v - rangeSelectDown: - rangeSelectUp: - prevBlock: - nextBlock: - prevBlock-alt: h - nextBlock-alt: l - nextBlock-alt2: - prevBlock-alt2: + rangeSelectDown: + rangeSelectUp: + prevBlock: [, h, ] + nextBlock: [, l, ] jumpToBlock: - "1" - "2" @@ -630,25 +625,33 @@ keybinding: nextMatch: "n" prevMatch: "N" startSearch: / - optionMenu: - optionMenu-alt1: '?' + + # on Mac + moveWordLeft: + + # on Mac + moveWordRight: + + # on Mac + backspaceWord: + + # on Mac + forwardDeleteWord: + optionMenu: '?' select: goInto: confirm: confirmMenu: confirmSuggestion: - confirmInEditor: - confirmInEditor-alt: + + # on Mac + confirmInEditor: [, ] remove: d new: "n" edit: e openFile: o - scrollUpMain: - scrollDownMain: - scrollUpMain-alt1: K - scrollDownMain-alt1: J - scrollUpMain-alt2: - scrollDownMain-alt2: + scrollUpMain: [, K, ] + scrollDownMain: [, J, ] executeShellCommand: ':' createRebaseOptionsMenu: m @@ -658,7 +661,7 @@ keybinding: # 'Files' appended for legacy reasons pullFiles: p refresh: R - createPatchOptionsMenu: + createPatchOptionsMenu: nextTab: ']' prevTab: '[' nextScreenMode: + @@ -666,19 +669,18 @@ keybinding: cyclePagers: '|' undo: z redo: Z - filteringMenu: - diffingMenu: W - diffingMenu-alt: - copyToClipboard: - openRecentRepos: + filteringMenu: + diffingMenu: [W, ] + copyToClipboard: + openRecentRepos: submitEditorText: extrasMenu: '@' - toggleWhitespaceInDiffView: + toggleWhitespaceInDiffView: increaseContextInDiffView: '}' decreaseContextInDiffView: '{' increaseRenameSimilarityThreshold: ) decreaseRenameSimilarityThreshold: ( - openDiffTool: + openDiffTool: status: checkForUpdate: u recentRepos: @@ -689,7 +691,7 @@ keybinding: commitChangesWithoutHook: w amendLastCommit: A commitChangesWithEditor: C - findBaseCommitForFixup: + findBaseCommitForFixup: confirmDiscard: x ignoreFile: i refreshFiles: r @@ -700,7 +702,7 @@ keybinding: fetch: f toggleTreeView: '`' openMergeOptions: M - openStatusFilter: + openStatusFilter: copyFileInfoToClipboard: "y" collapseAll: '-' expandAll: = @@ -708,7 +710,7 @@ keybinding: createPullRequest: o viewPullRequestOptions: O openPullRequestInBrowser: G - copyPullRequestURL: + copyPullRequestURL: checkoutBranchByName: c forceCheckoutBranch: F checkoutPreviousBranch: '-' @@ -735,8 +737,8 @@ keybinding: setFixupMessage: c createFixupCommit: F squashAboveCommits: S - moveDownCommit: - moveUpCommit: + moveDownCommit: [, ] + moveUpCommit: [, ] amendToCommit: A resetCommitAuthor: a pickCommit: p @@ -746,9 +748,9 @@ keybinding: markCommitAsBaseForRebase: B tagCommit: T checkoutCommit: - resetCherryPick: + resetCherryPick: copyCommitAttributeToClipboard: "y" - openLogMenu: + openLogMenu: openInBrowser: o openPullRequestInBrowser: G viewBisectOptions: b @@ -764,6 +766,8 @@ keybinding: commitFiles: checkoutCommitFile: c main: + prevHunk: [, h] + nextHunk: [, l] toggleSelectHunk: a pickBothHunks: b editSelectHunk: E @@ -772,7 +776,7 @@ keybinding: update: u bulkMenu: b commitMessage: - commitMenu: + commitMenu: ``` @@ -1102,6 +1106,8 @@ Where: - `provider` is one of `github`, `bitbucket`, `bitbucketServer`, `azuredevops`, `gitlab`, `gitea` or `codeberg` - `webDomain` is the URL where your git service exposes a web interface and APIs, e.g. `gitservice.work.com` +For the `github` provider, configuring an entry here also enables the pull-request icons in the branches panel for that host (e.g. a GitHub Enterprise Server instance). Lazygit picks up the auth token via the same mechanisms as the `gh` CLI: the `GH_ENTERPRISE_TOKEN` / `GITHUB_ENTERPRISE_TOKEN` environment variables, or `gh auth login --hostname `. + ## Predefined commit message prefix In situations where certain naming pattern is used for branches and commits, pattern can be used to populate commit message with prefix that is parsed from the branch name. diff --git a/docs/Custom_Command_Keybindings.md b/docs/Custom_Command_Keybindings.md index c8036ea41..55e14d5f1 100644 --- a/docs/Custom_Command_Keybindings.md +++ b/docs/Custom_Command_Keybindings.md @@ -50,7 +50,7 @@ Custom command keybindings will appear alongside inbuilt keybindings when you vi For a given custom command, here are the allowed fields: | _field_ | _description_ | required | |-----------------|----------------------|-| -| key | The key to trigger the command. Use a single letter or one of the values from [here](https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md). Custom commands without a key specified can be triggered by selecting them from the keybindings (`?`) menu | no | +| key | The key to trigger the command. Use a single key or list of keys, as described in [Custom_Keybindings.md](https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md). Custom commands without a key specified can be triggered by selecting them from the keybindings (`?`) menu | no | | command | The command to run (using Go template syntax for placeholder values) | yes | | context | The context in which to listen for the key (see [below](#contexts)) | yes | | prompts | A list of prompts that will request user input before running the final command | no | @@ -193,7 +193,7 @@ The permitted option fields are: | name | The first part of the label | no | | description | The second part of the label | no | | value | the value that will be used in the command | yes | -| key | Keybinding to invoke this menu option without needing to navigate to it. Can be a single letter or one of the values from [here](https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md) | no | +| key | Keybinding to invoke this menu option without needing to navigate to it. Use a single key or list of keys, as described in [Custom_Keybindings.md](https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md) | no | If an option has no name the value will be displayed to the user in place of the name, so you're allowed to only include the value like so: diff --git a/docs/Undoing.md b/docs/Undoing.md index 0a4c2f381..032573258 100644 --- a/docs/Undoing.md +++ b/docs/Undoing.md @@ -1,6 +1,6 @@ # Undo/Redo in lazygit -You can undo the last action by pressing 'z' and redo with `ctrl+z`. Here we drop a couple of commits and then undo the actions. +You can undo the last action by pressing 'z' and redo with 'Z' (shift+z). Here we drop a couple of commits and then undo the actions. Undo uses the reflog which is specific to commits and branches so we can't undo changes to the working tree or stash. ![undo](../../assets/demo/undo-compressed.gif) diff --git a/docs/keybindings/Custom_Keybindings.md b/docs/keybindings/Custom_Keybindings.md index a2537f069..998aae14c 100644 --- a/docs/keybindings/Custom_Keybindings.md +++ b/docs/keybindings/Custom_Keybindings.md @@ -1,63 +1,97 @@ -## Possible keybindings -| Put in | You will get | -|---------------|----------------| -| `` | F1 | -| `` | F2 | -| `` | F3 | -| `` | F4 | -| `` | F5 | -| `` | F6 | -| `` | F7 | -| `` | F8 | -| `` | F9 | -| `` | F10 | -| `` | F11 | -| `` | F12 | -| `` | Insert | -| `` | Delete | -| `` | Home | -| `` | End | -| `` | Pgup | -| `` | Pgdn | -| `` | ArrowUp | -| `` | ShiftArrowUp | -| `` | ArrowDown | -| `` | ShiftArrowDown | -| `` | ArrowLeft | -| `` | ArrowRight | -| `` | Tab | -| `` | Backtab | -| `` | Enter | -| `` | AltEnter | -| `` | Esc | -| `` | Backspace | -| `` | CtrlSpace | -| `` | CtrlSlash | -| `` | Space | -| `` | CtrlA | -| `` | CtrlB | -| `` | CtrlC | -| `` | CtrlD | -| `` | CtrlE | -| `` | CtrlF | -| `` | CtrlG | -| `` | CtrlJ | -| `` | CtrlK | -| `` | CtrlL | -| `` | CtrlN | -| `` | CtrlO | -| `` | CtrlP | -| `` | CtrlQ | -| `` | CtrlR | -| `` | CtrlS | -| `` | CtrlT | -| `` | CtrlU | -| `` | CtrlV | -| `` | CtrlW | -| `` | CtrlX | -| `` | CtrlY | -| `` | CtrlZ | -| `` | Ctrl4 | -| `` | Ctrl5 | -| `` | Ctrl6 | -| `` | Ctrl8 | +## Custom Keybindings + +A keybinding is one of: + +- A single printable character, e.g. `q`, `?`, `5`. Uppercase letters mean + shift+letter — write `A`, not ``. +- A special key name in angle brackets, e.g. ``, ``, ``. +- A key with modifiers in angle brackets, e.g. ``, ``. +- The literal string `` to disable a binding. +- A list of any of the above, to bind multiple keys to the same action: + `quit: [q, ]`. + +### Modifiers + +Prefix a key with one or more modifiers, joined by `+`: + +| Prefix | Short form | Modifier | +| -------- | ---------- | ----------------------------------------------------------------------------------------- | +| `ctrl+` | `c+` | Ctrl | +| `alt+` | `a+` | Alt | +| `shift+` | `s+` | Shift | +| `meta+` | `m+` | Depends on terminal; typically ⌘ on macOS or Super/Win key, when the terminal forwards it | + +You can also use `-` instead of `+` as the separator. Modifiers may appear in +any order, and short and long forms can be mixed. The whole binding should be +wrapped in angle brackets when it has any modifiers. The following all express +the same binding: + +- `` +- `` +- `` +- `` + +### Special key names + +| Put in | You will get | +| --------------------------------------- | ------------------- | +| `` – `` | F1 – F12 | +| `` | Insert | +| `` | Delete | +| `` | Home | +| `` | End | +| `` | PageUp | +| `` | PageDown | +| `` | ArrowUp | +| `` | ArrowDown | +| `` | ArrowLeft | +| `` | ArrowRight | +| `` | Tab | +| `` | Shift+Tab | +| `` | Enter | +| `` | Escape | +| `` | Backspace | +| `` | Space | +| ``/`` | Mouse wheel up/down | + +These can be combined with modifiers, e.g. ``, ``, ``. + +### Special characters with modifiers + +`` and `` are keyword forms for `-` and `+` when combined with a +modifier (e.g. `` for Ctrl+`-`). Without modifiers, write `-` and +`+` directly. `` is the keyword for the space character. + +### Combinations that are rejected + +These look reasonable but can't actually be delivered by a terminal: + +- `` (shift alone on a rune) — terminals fold shift into the rune + itself, so shift+a arrives as `A`. Write `A` instead. +- ``, ``, etc. (modifier on an uppercase ASCII letter) — write + `` instead. + +### Terminal compatibility + +Support for combinations of modifiers, and in general keybindings beyond plain +letters and ctrl+letter, require a newer terminal protocol that not all +terminals support. + +Terminals that are known to have good support include: Ghostty, kitty, +WezTerm, foot, Konsole, Alacritty, iTerm2, Windows Terminal. + +The default terminal on macOS (Terminal.app) does not; I recommend to switch to +either Ghostty or iTerm2 as a replacement (or one of the others above). + +On Windows, a popular terminal is the MinTTY console that comes with Git for +Windows; this also doesn't support the newer protocol. The recommended +replacement is Windows Terminal, which is very good these days, and Git Bash +runs just fine in it. + +Inside **tmux** or **screen**, extended keys are stripped unless the multiplexer +is configured to forward them. For tmux 3.2+: + +​` +set -g extended-keys on +set -as terminal-features 'xterm*:extkeys' +​` diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md index ca1541dd2..d63058d82 100644 --- a/docs/keybindings/Keybindings_en.md +++ b/docs/keybindings/Keybindings_en.md @@ -2,15 +2,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct # Lazygit Keybindings -_Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ - ## Global keybindings | Key | Action | Info | |-----|--------|-------------| -| `` `` | Switch to a recent repo | | -| `` (fn+up/shift+k) `` | Scroll up main window | | -| `` (fn+down/shift+j) `` | Scroll down main window | | +| `` `` | Switch to a recent repo | | +| `` , K, (fn+up/shift+k) `` | Scroll up main window | | +| `` , J, (fn+down/shift+j) `` | Scroll down main window | | | `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | @@ -19,7 +17,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` } `` | 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. | -| `` `` | View custom patch options | | +| `` `` | View custom patch options | | | `` m `` | View merge/rebase options | View options to abort/continue/skip the current merge/rebase. | | `` 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) | | @@ -27,12 +25,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers | | `` `` | Cancel | | | `` ? `` | Open keybindings menu | | -| `` `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. | -| `` W `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` q `` | Quit | | -| `` `` | Suspend the application | | -| `` `` | 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'. | +| `` `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. | +| `` W, `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | +| `` q, `` | Quit | | +| `` `` | Suspend the application | | +| `` `` | 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'. | | `` z `` | Undo | 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 | 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. | @@ -42,11 +39,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ |-----|--------|-------------| | `` , `` | Previous page | | | `` . `` | Next page | | -| `` < () `` | Scroll to top | | -| `` > () `` | Scroll to bottom | | +| `` <, `` | Scroll to top | | +| `` >, `` | Scroll to bottom | | | `` v `` | Toggle range select | | -| `` `` | Range select down | | -| `` `` | Range select up | | +| `` `` | Range select down | | +| `` `` | Range select up | | | `` / `` | Search the current view by text | | | `` H `` | Scroll left | | | `` L `` | Scroll right | | @@ -57,13 +54,13 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy path to clipboard | | +| `` `` | Copy path to clipboard | | | `` y `` | Copy to clipboard | | | `` c `` | Checkout | Checkout file. This replaces the file in your working tree with the version from the selected commit. | | `` d `` | Discard | Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file. | | `` o `` | Open file | Open file in default application. | | `` e `` | Edit | Open file in external editor. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Enter file / Toggle directory collapsed | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | @@ -84,8 +81,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Reset copied (cherry-picked) commits selection | | +| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Reset copied (cherry-picked) commits selection | | | `` b `` | View bisect options | | | `` 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. | @@ -98,15 +95,15 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` p `` | Pick | Mark the selected commit to be picked (when mid-rebase). This means that the commit will be retained upon continuing the rebase. | | `` F `` | Create fixup commit | Create 'fixup!' commit for the selected commit. Later on, you can press `S` on this same commit to apply all above fixup commits. | | `` S `` | Apply fixup commits | Squash all 'fixup!' commits, either above the selected commit, or all in current branch (autosquash). | -| `` `` | Move commit down one | | -| `` `` | Move commit up one | | +| `` , `` | Move commit down one | | +| `` , `` | Move commit up one | | | `` V `` | Paste (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. | | `` A `` | Amend | Amend commit with staged changes. If the selected commit is the HEAD commit, this will perform `git commit --amend`. Otherwise the commit will be amended via a rebase. | | `` 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. | +| `` `` | View log options | 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 | | | `` `` | Checkout | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | @@ -115,7 +112,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Copy (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View files | | @@ -128,21 +125,21 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ |-----|--------|-------------| | `` `` | Confirm | | | `` `` | Close/Cancel | | -| `` `` | Copy to clipboard | | +| `` `` | Copy to clipboard | | ## Files | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy path to clipboard | | +| `` `` | Copy path to clipboard | | | `` `` | Stage | Toggle staged for selected file. | -| `` `` | Filter files by status | | +| `` `` | Filter files by status | | | `` y `` | Copy to clipboard | | | `` c `` | Commit | Commit staged changes. | | `` w `` | Commit changes without pre-commit hook | | | `` A `` | Amend last commit | | | `` C `` | Commit changes using git editor | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | +| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` e `` | Edit | Open file in external editor. | | `` o `` | Open file | Open file in default application. | | `` i `` | Ignore or exclude file | | @@ -155,7 +152,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` g `` | View upstream reset options | | | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Toggle file tree view | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | @@ -174,7 +171,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy branch name to clipboard | | +| `` `` | Copy branch name to clipboard | | | `` i `` | Show git-flow options | | | `` `` | Checkout | Checkout selected item. | | `` n `` | New branch | | @@ -182,7 +179,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` o `` | Create pull request | | | `` O `` | View create pull request options | | | `` G `` | Open pull request in browser | | -| `` `` | Copy pull request URL to clipboard | | +| `` `` | Copy pull request URL to clipboard | | | `` c `` | Checkout by name | Checkout by name. In the input box you can enter '-' to switch to the previous branch. | | `` - `` | Checkout previous branch | | | `` F `` | Force checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | @@ -195,7 +192,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` g `` | Reset | | | `` R `` | Rename branch | | | `` u `` | View upstream options | View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | | `` w `` | View worktree options | | @@ -207,10 +204,10 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ |-----|--------|-------------| | `` `` | Pick hunk | | | `` b `` | Pick all hunks | | -| `` `` | Previous hunk | | -| `` `` | Next hunk | | -| `` `` | Previous conflict | | -| `` `` | Next conflict | | +| `` , k `` | Previous hunk | | +| `` , j `` | Next hunk | | +| `` , h `` | Previous conflict | | +| `` , l `` | Next conflict | | | `` z `` | Undo | Undo last merge conflict resolution. | | `` e `` | Edit file | Open file in external editor. | | `` o `` | Open file | Open file in default application. | @@ -221,8 +218,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | Scroll down | | -| `` mouse wheel up (fn+down) `` | Scroll up | | +| `` (fn+up) `` | Scroll down | | +| `` (fn+down) `` | Scroll up | | | `` `` | Switch view | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | Search the current view by text | | @@ -231,11 +228,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Go to previous hunk | | -| `` `` | Go to next hunk | | +| `` , h `` | Go to previous hunk | | +| `` , l `` | Go to next hunk | | | `` v `` | Toggle range select | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | Copy selected text to clipboard | | +| `` `` | Copy selected text to clipboard | | | `` o `` | Open file | Open file in default application. | | `` e `` | Edit file | Open file in external editor. | | `` `` | Toggle lines in patch | | @@ -247,11 +244,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Go to previous hunk | | -| `` `` | Go to next hunk | | +| `` , h `` | Go to previous hunk | | +| `` , l `` | Go to next hunk | | | `` v `` | Toggle range select | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | Copy selected text to clipboard | | +| `` `` | Copy selected text to clipboard | | | `` `` | Stage | Toggle selection staged / unstaged. | | `` d `` | Discard | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | | `` o `` | Open file | Open file in default application. | @@ -262,7 +259,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` c `` | Commit | Commit staged changes. | | `` w `` | Commit changes without pre-commit hook | | | `` C `` | Commit changes using git editor | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | +| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | Search the current view by text | | ## Menu @@ -277,7 +274,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | Checkout | Checkout the selected commit as a 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 | | @@ -285,8 +282,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Copy (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Reset copied (cherry-picked) commits selection | | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Reset copied (cherry-picked) commits selection | | +| `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View commits | | @@ -297,7 +294,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy branch name to clipboard | | +| `` `` | Copy branch name to clipboard | | | `` `` | Checkout | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | New branch | | | `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) | @@ -306,7 +303,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` u `` | Set as upstream | Set the selected remote branch as the upstream of the checked-out branch. | | `` s `` | Sort order | | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | | `` w `` | View worktree options | | @@ -362,7 +359,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | Checkout | Checkout the selected commit as a 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 | | @@ -370,8 +367,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Copy (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Reset copied (cherry-picked) commits selection | | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Reset copied (cherry-picked) commits selection | | +| `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View files | | @@ -382,7 +379,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy submodule name to clipboard | | +| `` `` | Copy submodule name to clipboard | | | `` `` | Enter | Enter submodule. After entering the submodule, you can press `` to escape back to the parent repo. | | `` d `` | Remove | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | Update selected submodule. | @@ -396,13 +393,13 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy tag to clipboard | | +| `` `` | Copy tag to clipboard | | | `` `` | Checkout | Checkout the selected tag as a detached HEAD. | | `` n `` | New tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | | `` d `` | Delete | 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. | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | | `` w `` | View worktree options | | diff --git a/docs/keybindings/Keybindings_ja.md b/docs/keybindings/Keybindings_ja.md index 69479db13..d9b87d747 100644 --- a/docs/keybindings/Keybindings_ja.md +++ b/docs/keybindings/Keybindings_ja.md @@ -2,15 +2,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct # Lazygit キーバインディング -_凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味します_ - ## グローバルキーバインド | Key | Action | Info | |-----|--------|-------------| -| `` `` | 最近のリポジトリをチェックアウト | | -| `` (fn+up/shift+k) `` | メインウィンドウを上にスクロール | | -| `` (fn+down/shift+j) `` | メインウィンドウを下にスクロール | | +| `` `` | 最近のリポジトリをチェックアウト | | +| `` , K, (fn+up/shift+k) `` | メインウィンドウを上にスクロール | | +| `` , J, (fn+down/shift+j) `` | メインウィンドウを下にスクロール | | | `` @ `` | コマンドログオプションを表示 | コマンドログのオプションを表示します(例:コマンドログの表示/非表示、コマンドログへのフォーカスなど)。 | | `` P `` | プッシュ | 現在のブランチを対応するアップストリームブランチにプッシュします。アップストリームが設定されていない場合、アップストリームブランチの設定を求められます。 | | `` p `` | プル | 現在のブランチのリモートから変更をプルします。アップストリームが設定されていない場合、アップストリームブランチの設定を求められます。 | @@ -19,7 +17,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` } `` | 差分コンテキストサイズを増やす | 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 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'. | | `` : `` | シェルコマンドを実行 | 実行するシェルコマンドを入力するプロンプトを表示します。 | -| `` `` | カスタムパッチオプションを表示 | | +| `` `` | カスタムパッチオプションを表示 | | | `` m `` | マージ/リベースオプションを表示 | 現在のマージ/リベースを中止/継続/スキップするオプションを表示します。 | | `` R `` | 更新 | Gitの状態を更新します(`git status`、`git branch`などをバックグラウンドで実行してパネルの内容を更新します)。これは`git fetch`を実行しません。 | | `` + `` | 次の画面モード(通常/半分/全画面) | | @@ -27,12 +25,11 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers | | `` `` | キャンセル | | | `` ? `` | キーバインディングメニューを開く | | -| `` `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 | -| `` W `` | 差分オプションを表示 | 2つのrefの差分に関連するオプションを表示します(例:選択したrefとの差分表示、差分を取るrefの入力、差分方向の反転など)。 | -| `` `` | 差分オプションを表示 | 2つのrefの差分に関連するオプションを表示します(例:選択したrefとの差分表示、差分を取るrefの入力、差分方向の反転など)。 | -| `` q `` | 終了 | | -| `` `` | Suspend the application | | -| `` `` | 空白表示の切り替え | 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'. | +| `` `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 | +| `` W, `` | 差分オプションを表示 | 2つのrefの差分に関連するオプションを表示します(例:選択したrefとの差分表示、差分を取るrefの入力、差分方向の反転など)。 | +| `` q, `` | 終了 | | +| `` `` | Suspend the application | | +| `` `` | 空白表示の切り替え | 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'. | | `` z `` | 元に戻す | 最後のgitコマンドを元に戻すために実行するgitコマンドを決定するためにreflogが使用されます。これにはワーキングツリーへの変更は含まれません。コミットのみが考慮されます。 | | `` Z `` | やり直す | 最後のgitコマンドをやり直すために実行するgitコマンドを決定するためにreflogが使用されます。これにはワーキングツリーへの変更は含まれません。コミットのみが考慮されます。 | @@ -42,11 +39,11 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 |-----|--------|-------------| | `` , `` | 前のページ | | | `` . `` | 次のページ | | -| `` < () `` | 先頭にスクロール | | -| `` > () `` | 末尾にスクロール | | +| `` <, `` | 先頭にスクロール | | +| `` >, `` | 末尾にスクロール | | | `` v `` | 範囲選択を切り替え | | -| `` `` | 範囲選択を下に | | -| `` `` | 範囲選択を上に | | +| `` `` | 範囲選択を下に | | +| `` `` | 範囲選択を上に | | | `` / `` | 現在のビューをテキストで検索 | | | `` H `` | 左にスクロール | | | `` L `` | 右にスクロール | | @@ -64,8 +61,8 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | コピーされた(チェリーピックされた)コミットの選択をリセット | | +| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | コピーされた(チェリーピックされた)コミットの選択をリセット | | | `` b `` | bisectオプションを表示 | | | `` s `` | スカッシュ | 選択したコミットをその下のコミットにスカッシュします。スカッシュとは複数のコミットを1つにまとめる操作です。選択したコミットのメッセージが下のコミットに追加されます。 | | `` f `` | フィックスアップ | 選択したコミットをその下のコミットにマージします。フィックスアップはスカッシュと似ていますが、選択したコミットのメッセージは破棄され、下のコミットのメッセージのみが保持されます。 | @@ -78,15 +75,15 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` p `` | ピック | 選択したコミットをピックするようにマークします(リベース中)。これは、リベースを続行すると、コミットが保持されることを意味します。 | | `` F `` | fixupコミットを作成 | 選択したコミットに対する「fixup!」コミットを作成します。fixupコミットは、選択したコミットの修正用コミットです。後で、同じコミットで `S` を押すと、上記のすべてのfixupコミットが適用されます。 | | `` S `` | fixupコミットを適用 | すべての「fixup!」コミットを、選択したコミットの上部または現在のブランチ内のすべてをスカッシュします(autosquash)。 | -| `` `` | コミットを1つ下に移動 | | -| `` `` | コミットを1つ上に移動 | | +| `` , `` | コミットを1つ下に移動 | | +| `` , `` | コミットを1つ上に移動 | | | `` V `` | ペースト(チェリーピック) | | | `` B `` | リベース用のベースコミットとしてマーク | 次のリベース用のベースコミットを選択します。ブランチにリベースするとき、ベースコミットより上のコミットのみが持ち込まれます。これは `git rebase --onto` コマンドを使用します。 | | `` A `` | 修正 | ステージされた変更でコミットを修正します。選択したコミットがHEADコミットの場合、これは `git commit --amend` を実行します。それ以外の場合、コミットはリベースを通じて修正されます。 | | `` a `` | コミット属性を修正 | コミット作者の設定/リセットまたは共同作者の設定を行います。 | | `` t `` | リバート | 選択したコミットの変更を逆に適用する、リバートコミットを作成します。 | | `` T `` | コミットにタグを付ける | 選択したコミットを指すタグを新規作成します。タグ名とオプションの説明を入力するよう促されます。 | -| `` `` | ログオプションを表示 | コミットログのオプションを表示します(例:並び順の変更、Gitグラフの非表示、Gitグラフ全体の表示)。 | +| `` `` | ログオプションを表示 | コミットログのオプションを表示します(例:並び順の変更、Gitグラフの非表示、Gitグラフ全体の表示)。 | | `` G `` | Open pull request in browser | | | `` `` | チェックアウト(ブランチの切り替え) | 選択したコミットをデタッチドヘッド(特定のブランチに属さない状態)としてチェックアウトします。 | | `` y `` | コミット属性をクリップボードにコピー | コミット属性をクリップボードにコピーします(例:ハッシュ、URL、差分、メッセージ、作者)。 | @@ -95,7 +92,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` N `` | コミットを新しいブランチに移動 | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | リセット | 選択した項目へのリセットオプション(ソフト/ミックス/ハード)を表示します。各リセットタイプの詳細は次の通りです:
- ソフトリセット:変更を保持し、ステージされた状態にします
- ミックスリセット:変更を保持し、ステージされていない状態にします
- ハードリセット:すべての変更を破棄します | | `` C `` | コピー(チェリーピック) | コミットをコピーとしてマークします。ローカルコミットビューで `V` を押すと、コピーしたコミットをチェックアウトしたブランチにペースト(チェリーピック)できます。いつでも `` を押して選択をキャンセルできます。 | -| `` `` | 外部差分ツールを開く(git difftool) | | +| `` `` | 外部差分ツールを開く(git difftool) | | | `` * `` | 現在のブランチのコミットを選択 | | | `` 0 `` | メインビューにフォーカス | | | `` `` | ファイルを表示 | | @@ -106,13 +103,13 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | Key | Action | Info | |-----|--------|-------------| -| `` `` | パスをクリップボードにコピー | | +| `` `` | パスをクリップボードにコピー | | | `` y `` | クリップボードにコピー | | | `` c `` | チェックアウト(ブランチの切り替え) | ファイルをチェックアウトします。これにより、作業ツリー内のファイルが選択したコミットのバージョンに置き換えられます。 | | `` d `` | 破棄 | このコミットのこのファイルへの変更を破棄します。これはバックグラウンドで対話的なリベースを実行するため、後のコミットでもこのファイルが変更されている場合、マージコンフリクトが発生する可能性があります。 | | `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 | | `` e `` | 編集 | 外部エディタでファイルを開きます。 | -| `` `` | 外部差分ツールを開く(git difftool) | | +| `` `` | 外部差分ツールを開く(git difftool) | | | `` `` | パッチに含めるファイルを切り替え | ファイルがカスタムパッチに含まれるかどうかを切り替えます。https://github.com/jesseduffield/lazygit#rebase-magic-custom-patchesを参照してください。 | | `` a `` | すべてのファイルを切り替え | コミットのすべてのファイルをカスタムパッチに追加/削除します。https://github.com/jesseduffield/lazygit#rebase-magic-custom-patchesを参照してください。 | | `` `` | ファイルに入る / ディレクトリの折りたたみを切り替える | ファイルが選択されている場合、そのファイルに入ってカスタムパッチに個々の行を追加/削除できます。ディレクトリが選択されている場合、ディレクトリを切り替えます。 | @@ -133,7 +130,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | チェックアウト(ブランチの切り替え) | 選択したコミットをデタッチドヘッド(特定のブランチに属さない状態)としてチェックアウトします。 | | `` y `` | コミット属性をクリップボードにコピー | コミット属性をクリップボードにコピーします(例:ハッシュ、URL、差分、メッセージ、作者)。 | | `` o `` | ブラウザでコミットを開く | | @@ -141,8 +138,8 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` N `` | コミットを新しいブランチに移動 | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | リセット | 選択した項目へのリセットオプション(ソフト/ミックス/ハード)を表示します。各リセットタイプの詳細は次の通りです:
- ソフトリセット:変更を保持し、ステージされた状態にします
- ミックスリセット:変更を保持し、ステージされていない状態にします
- ハードリセット:すべての変更を破棄します | | `` C `` | コピー(チェリーピック) | コミットをコピーとしてマークします。ローカルコミットビューで `V` を押すと、コピーしたコミットをチェックアウトしたブランチにペースト(チェリーピック)できます。いつでも `` を押して選択をキャンセルできます。 | -| `` `` | コピーされた(チェリーピックされた)コミットの選択をリセット | | -| `` `` | 外部差分ツールを開く(git difftool) | | +| `` `` | コピーされた(チェリーピックされた)コミットの選択をリセット | | +| `` `` | 外部差分ツールを開く(git difftool) | | | `` * `` | 現在のブランチのコミットを選択 | | | `` 0 `` | メインビューにフォーカス | | | `` `` | ファイルを表示 | | @@ -153,7 +150,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | Key | Action | Info | |-----|--------|-------------| -| `` `` | サブモジュール名をクリップボードにコピー | | +| `` `` | サブモジュール名をクリップボードにコピー | | | `` `` | 入る | サブモジュールに入ります。サブモジュールに入った後、``を押して親リポジトリに戻ることができます。 | | `` d `` | 削除 | 選択したサブモジュールとそれに対応するディレクトリを削除します。 | | `` u `` | 更新 | 選択したサブモジュールを更新します。 | @@ -201,13 +198,13 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | Key | Action | Info | |-----|--------|-------------| -| `` `` | タグをクリップボードにコピー | | +| `` `` | タグをクリップボードにコピー | | | `` `` | チェックアウト(ブランチの切り替え) | 選択したタグをデタッチドHEADとしてチェックアウトします。 | | `` n `` | 新しいタグを作成 | 現在のコミットから新しいタグを作成します。タグ名とオプションの説明を入力するよう促されます。 | | `` d `` | 削除 | ローカル/リモートタグの削除オプションを表示します。 | | `` P `` | タグをプッシュ | 選択したタグをリモートにプッシュします。リモートを選択するよう促されます。 | | `` g `` | リセット | 選択した項目へのリセットオプション(ソフト/ミックス/ハード)を表示します。各リセットタイプの詳細は次の通りです:
- ソフトリセット:変更を保持し、ステージされた状態にします
- ミックスリセット:変更を保持し、ステージされていない状態にします
- ハードリセット:すべての変更を破棄します | -| `` `` | 外部差分ツールを開く(git difftool) | | +| `` `` | 外部差分ツールを開く(git difftool) | | | `` 0 `` | メインビューにフォーカス | | | `` `` | コミットを表示 | | | `` w `` | ワークツリーオプションを表示 | | @@ -217,15 +214,15 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | Key | Action | Info | |-----|--------|-------------| -| `` `` | パスをクリップボードにコピー | | +| `` `` | パスをクリップボードにコピー | | | `` `` | ステージ | 選択したファイルのステージ状態を切り替えます。 | -| `` `` | ステータスでファイルをフィルタリング | | +| `` `` | ステータスでファイルをフィルタリング | | | `` y `` | クリップボードにコピー | | | `` c `` | コミット | ステージされた変更をコミットします。 | | `` w `` | pre-commitフックなしで変更をコミット | | | `` A `` | 直前のコミットを修正 | | | `` C `` | Gitエディタを使用して変更をコミット | | -| `` `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: | +| `` `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: | | `` e `` | 編集 | 外部エディタでファイルを開きます。 | | `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 | | `` i `` | ファイルを無視または除外 | | @@ -238,7 +235,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` g `` | アップストリームへのリセットオプションを表示 | | | `` D `` | リセット | 作業ツリーのリセットオプション(例:作業ツリーの完全破棄)を表示します。 | | `` ` `` | ファイルツリービューを切り替え | ファイル表示をフラット表示とツリー表示で切り替えます。フラット表示はすべてのファイルパスを一覧で表示し、ツリー表示はディレクトリごとにファイルをグループ化します。

デフォルトは設定ファイル内の 'gui.showFileTree' キーで変更できます。 | -| `` `` | 外部差分ツールを開く(git difftool) | | +| `` `` | 外部差分ツールを開く(git difftool) | | | `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | フェッチ | リモートから変更をフェッチします。 | | `` - `` | すべてのファイルを折りたたむ | ファイルツリー内のすべてのディレクトリを折りたたみます | @@ -250,11 +247,11 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | Key | Action | Info | |-----|--------|-------------| -| `` `` | 前のハンクに移動 | | -| `` `` | 次のハンクに移動 | | +| `` , h `` | 前のハンクに移動 | | +| `` , l `` | 次のハンクに移動 | | | `` v `` | 範囲選択を切り替え | | | `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | -| `` `` | 選択したテキストをクリップボードにコピー | | +| `` `` | 選択したテキストをクリップボードにコピー | | | `` `` | ステージ | 選択された部分のステージ / アンステージを切り替えます。 | | `` d `` | 破棄 | ステージされていない変更が選択されている場合、`git reset`を使用して変更を破棄します。ステージされた変更が選択されている場合、変更をアンステージします。 | | `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 | @@ -265,18 +262,18 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` c `` | コミット | ステージされた変更をコミットします。 | | `` w `` | pre-commitフックなしで変更をコミット | | | `` C `` | Gitエディタを使用して変更をコミット | | -| `` `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: | +| `` `` | フィックスアップのベースコミットを検索 | 現在の変更が基づいているコミットを見つけて、コミットの修正/フィックスアップを行います。これにより、ブランチのコミットを一つずつ確認して、どのコミットを修正/フィックスアップすべきかを調べる手間が省けます。詳細はドキュメントを参照: | | `` / `` | 現在のビューをテキストで検索 | | ## メインパネル(パッチ作成) | Key | Action | Info | |-----|--------|-------------| -| `` `` | 前のハンクに移動 | | -| `` `` | 次のハンクに移動 | | +| `` , h `` | 前のハンクに移動 | | +| `` , l `` | 次のハンクに移動 | | | `` v `` | 範囲選択を切り替え | | | `` a `` | ハンクの選択を切り替える | Toggle line-by-line vs. hunk selection mode. | -| `` `` | 選択したテキストをクリップボードにコピー | | +| `` `` | 選択したテキストをクリップボードにコピー | | | `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 | | `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 | | `` `` | パッチ内の行を切り替え | | @@ -290,10 +287,10 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 |-----|--------|-------------| | `` `` | ハンクを選択 | | | `` b `` | すべてのハンクを選択 | | -| `` `` | 前のハンク | | -| `` `` | 次のハンク | | -| `` `` | 前のコンフリクト | | -| `` `` | 次のコンフリクト | | +| `` , k `` | 前のハンク | | +| `` , j `` | 次のハンク | | +| `` , h `` | 前のコンフリクト | | +| `` , l `` | 次のコンフリクト | | | `` z `` | 元に戻す | 最後のマージコンフリクト解決を元に戻します。 | | `` e `` | ファイルを編集 | 外部エディタでファイルを開きます。 | | `` o `` | ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 | @@ -304,8 +301,8 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | 下にスクロール | | -| `` mouse wheel up (fn+down) `` | 上にスクロール | | +| `` (fn+up) `` | 下にスクロール | | +| `` (fn+down) `` | 上にスクロール | | | `` `` | ビューを切り替え | 他のビュー(ステージされた変更/ステージされていない変更)に切り替えます。 | | `` `` | サイドパネルに戻る | | | `` / `` | 現在のビューをテキストで検索 | | @@ -322,7 +319,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | チェックアウト(ブランチの切り替え) | 選択したコミットをデタッチドヘッド(特定のブランチに属さない状態)としてチェックアウトします。 | | `` y `` | コミット属性をクリップボードにコピー | コミット属性をクリップボードにコピーします(例:ハッシュ、URL、差分、メッセージ、作者)。 | | `` o `` | ブラウザでコミットを開く | | @@ -330,8 +327,8 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` N `` | コミットを新しいブランチに移動 | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | リセット | 選択した項目へのリセットオプション(ソフト/ミックス/ハード)を表示します。各リセットタイプの詳細は次の通りです:
- ソフトリセット:変更を保持し、ステージされた状態にします
- ミックスリセット:変更を保持し、ステージされていない状態にします
- ハードリセット:すべての変更を破棄します | | `` C `` | コピー(チェリーピック) | コミットをコピーとしてマークします。ローカルコミットビューで `V` を押すと、コピーしたコミットをチェックアウトしたブランチにペースト(チェリーピック)できます。いつでも `` を押して選択をキャンセルできます。 | -| `` `` | コピーされた(チェリーピックされた)コミットの選択をリセット | | -| `` `` | 外部差分ツールを開く(git difftool) | | +| `` `` | コピーされた(チェリーピックされた)コミットの選択をリセット | | +| `` `` | 外部差分ツールを開く(git difftool) | | | `` * `` | 現在のブランチのコミットを選択 | | | `` 0 `` | メインビューにフォーカス | | | `` `` | コミットを表示 | | @@ -354,7 +351,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | Key | Action | Info | |-----|--------|-------------| -| `` `` | ブランチ名をクリップボードにコピー | | +| `` `` | ブランチ名をクリップボードにコピー | | | `` `` | チェックアウト(ブランチの切り替え) | 選択したリモートブランチに基づいて新しいローカルブランチをチェックアウトするか、リモートブランチをデタッチドヘッドとしてチェックアウトします。 | | `` n `` | 新しいブランチ | | | `` M `` | マージ | 選択した項目を現在のブランチにマージするためのオプションを表示します(通常のマージ、スカッシュマージ) | @@ -363,7 +360,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` u `` | アップストリームとして設定 | 選択したリモートブランチをチェックアウトされたブランチのアップストリームとして設定します。 | | `` s `` | 並び順 | | | `` g `` | リセット | 選択した項目へのリセットオプション(ソフト/ミックス/ハード)を表示します。各リセットタイプの詳細は次の通りです:
- ソフトリセット:変更を保持し、ステージされた状態にします
- ミックスリセット:変更を保持し、ステージされていない状態にします
- ハードリセット:すべての変更を破棄します | -| `` `` | 外部差分ツールを開く(git difftool) | | +| `` `` | 外部差分ツールを開く(git difftool) | | | `` 0 `` | メインビューにフォーカス | | | `` `` | コミットを表示 | | | `` w `` | ワークツリーオプションを表示 | | @@ -373,7 +370,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | Key | Action | Info | |-----|--------|-------------| -| `` `` | ブランチ名をクリップボードにコピー | | +| `` `` | ブランチ名をクリップボードにコピー | | | `` i `` | git-flowオプションを表示 | | | `` `` | チェックアウト(ブランチの切り替え) | 選択した項目をチェックアウトします。 | | `` n `` | 新しいブランチ | | @@ -381,7 +378,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` o `` | プルリクエストを作成 | | | `` O `` | プルリクエスト作成オプションを表示 | | | `` G `` | Open pull request in browser | | -| `` `` | プルリクエストURLをクリップボードにコピー | | +| `` `` | プルリクエストURLをクリップボードにコピー | | | `` c `` | 名前でチェックアウト | 名前でチェックアウトします。入力ボックスに「-」を入力すると、最後のブランチをチェックアウトすることができます。 | | `` - `` | 直前のブランチにチェックアウト | | | `` F `` | 強制チェックアウト | 選択したブランチを強制的にチェックアウトします。これにより、選択したブランチをチェックアウトする前にワーキングディレクトリ内のすべてのローカル変更が破棄されます。 | @@ -394,7 +391,7 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 | `` g `` | リセット | | | `` R `` | ブランチ名を変更 | | | `` u `` | アップストリームオプションを表示 | ブランチのアップストリームに関連するオプションを表示します(例:アップストリームの設定/解除やアップストリームへのリセット)。 | -| `` `` | 外部差分ツールを開く(git difftool) | | +| `` `` | 外部差分ツールを開く(git difftool) | | | `` 0 `` | メインビューにフォーカス | | | `` `` | コミットを表示 | | | `` w `` | ワークツリーオプションを表示 | | @@ -416,4 +413,4 @@ _凡例:`<c-b>` はctrl+b、`<a-b>` はalt+b、`B` はshift+bを意味 |-----|--------|-------------| | `` `` | 確認 | | | `` `` | 閉じる/キャンセル | | -| `` `` | クリップボードにコピー | | +| `` `` | クリップボードにコピー | | diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md index eeb5ed885..089543c5f 100644 --- a/docs/keybindings/Keybindings_ko.md +++ b/docs/keybindings/Keybindings_ko.md @@ -2,15 +2,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct # Lazygit 키 바인딩 -_Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ - ## 글로벌 키 바인딩 | Key | Action | Info | |-----|--------|-------------| -| `` `` | 최근에 사용한 저장소로 전환 | | -| `` (fn+up/shift+k) `` | 메인 패널을 위로 스크롤 | | -| `` (fn+down/shift+j) `` | 메인 패널을 아래로로 스크롤 | | +| `` `` | 최근에 사용한 저장소로 전환 | | +| `` , K, (fn+up/shift+k) `` | 메인 패널을 위로 스크롤 | | +| `` , J, (fn+down/shift+j) `` | 메인 패널을 아래로로 스크롤 | | | `` @ `` | 명령어 로그 메뉴 열기 | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | 푸시 | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` p `` | 업데이트 | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | @@ -19,7 +17,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` } `` | Diff 보기의 변경 사항 주위에 표시되는 컨텍스트의 크기를 늘리기 | 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'. | | `` { `` | Diff 보기의 변경 사항 주위에 표시되는 컨텍스트 크기 줄이기 | 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. | -| `` `` | 커스텀 Patch 옵션 보기 | | +| `` `` | 커스텀 Patch 옵션 보기 | | | `` m `` | View merge/rebase options | View options to abort/continue/skip the current merge/rebase. | | `` 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) | | @@ -27,12 +25,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers | | `` `` | 취소 | | | `` ? `` | 매뉴 열기 | | -| `` `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. | -| `` W `` | Diff 메뉴 열기 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` `` | Diff 메뉴 열기 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` q `` | 종료 | | -| `` `` | Suspend the application | | -| `` `` | 공백문자를 Diff 뷰에서 표시 여부 전환 | 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'. | +| `` `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. | +| `` W, `` | Diff 메뉴 열기 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | +| `` q, `` | 종료 | | +| `` `` | Suspend the application | | +| `` `` | 공백문자를 Diff 뷰에서 표시 여부 전환 | 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'. | | `` z `` | 되돌리기 (reflog) (실험적) | 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 `` | 다시 실행 (reflog) (실험적) | 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. | @@ -42,11 +39,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ |-----|--------|-------------| | `` , `` | 이전 페이지 | | | `` . `` | 다음 페이지 | | -| `` < () `` | 맨 위로 스크롤 | | -| `` > () `` | 맨 아래로 스크롤 | | +| `` <, `` | 맨 위로 스크롤 | | +| `` >, `` | 맨 아래로 스크롤 | | | `` v `` | 드래그 선택 전환 | | -| `` `` | Range select down | | -| `` `` | Range select up | | +| `` `` | Range select down | | +| `` `` | Range select up | | | `` / `` | 검색 시작 | | | `` H `` | 우 스크롤 | | | `` L `` | 좌 스크롤 | | @@ -64,7 +61,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | 체크아웃 | Checkout the selected commit as a detached HEAD. | | `` y `` | 커밋 attribute 복사 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | 브라우저에서 커밋 열기 | | @@ -72,8 +69,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 커밋을 복사 (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Reset cherry-picked (copied) commits selection | | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Reset cherry-picked (copied) commits selection | | +| `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | @@ -106,7 +103,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | 체크아웃 | Checkout the selected commit as a detached HEAD. | | `` y `` | 커밋 attribute 복사 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | 브라우저에서 커밋 열기 | | @@ -114,8 +111,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 커밋을 복사 (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Reset cherry-picked (copied) commits selection | | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Reset cherry-picked (copied) commits selection | | +| `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View selected item's files | | @@ -146,10 +143,10 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ |-----|--------|-------------| | `` `` | Pick hunk | | | `` b `` | Pick all hunks | | -| `` `` | 이전 hunk를 선택 | | -| `` `` | 다음 hunk를 선택 | | -| `` `` | 이전 충돌을 선택 | | -| `` `` | 다음 충돌을 선택 | | +| `` , k `` | 이전 hunk를 선택 | | +| `` , j `` | 다음 hunk를 선택 | | +| `` , h `` | 이전 충돌을 선택 | | +| `` , l `` | 다음 충돌을 선택 | | | `` z `` | 되돌리기 | Undo last merge conflict resolution. | | `` e `` | 파일 편집 | Open file in external editor. | | `` o `` | 파일 닫기 | Open file in default application. | @@ -160,8 +157,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | 아래로 스크롤 | | -| `` mouse wheel up (fn+down) `` | 위로 스크롤 | | +| `` (fn+up) `` | 아래로 스크롤 | | +| `` (fn+down) `` | 위로 스크롤 | | | `` `` | 패널 전환 | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | 검색 시작 | | @@ -170,11 +167,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 이전 hunk를 선택 | | -| `` `` | 다음 hunk를 선택 | | +| `` , h `` | 이전 hunk를 선택 | | +| `` , l `` | 다음 hunk를 선택 | | | `` v `` | 드래그 선택 전환 | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | 선택한 텍스트를 클립보드에 복사 | | +| `` `` | 선택한 텍스트를 클립보드에 복사 | | | `` o `` | 파일 닫기 | Open file in default application. | | `` e `` | 파일 편집 | Open file in external editor. | | `` `` | Line(s)을 패치에 추가/삭제 | | @@ -186,11 +183,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 이전 hunk를 선택 | | -| `` `` | 다음 hunk를 선택 | | +| `` , h `` | 이전 hunk를 선택 | | +| `` , l `` | 다음 hunk를 선택 | | | `` v `` | 드래그 선택 전환 | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | 선택한 텍스트를 클립보드에 복사 | | +| `` `` | 선택한 텍스트를 클립보드에 복사 | | | `` `` | Staged 전환 | 선택한 행을 staged / unstaged | | `` d `` | 변경을 삭제 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | | `` o `` | 파일 닫기 | Open file in default application. | @@ -201,14 +198,14 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. | | `` w `` | Commit changes without pre-commit hook | | | `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | +| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | 검색 시작 | | ## 브랜치 | Key | Action | Info | |-----|--------|-------------| -| `` `` | 브랜치명을 클립보드에 복사 | | +| `` `` | 브랜치명을 클립보드에 복사 | | | `` i `` | Git-flow 옵션 보기 | | | `` `` | 체크아웃 | Checkout selected item. | | `` n `` | 새 브랜치 생성 | | @@ -216,7 +213,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` o `` | 풀 리퀘스트 생성 | | | `` O `` | 풀 리퀘스트 생성 옵션 | | | `` G `` | Open pull request in browser | | -| `` `` | 풀 리퀘스트 URL을 클립보드에 복사 | | +| `` `` | 풀 리퀘스트 URL을 클립보드에 복사 | | | `` c `` | 이름으로 체크아웃 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. | | `` - `` | Checkout previous branch | | | `` F `` | 강제 체크아웃 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | @@ -229,7 +226,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` g `` | View reset options | | | `` R `` | 브랜치 이름 변경 | | | `` u `` | View upstream options | View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | | `` w `` | View worktree options | | @@ -251,7 +248,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 서브모듈 이름을 클립보드에 복사 | | +| `` `` | 서브모듈 이름을 클립보드에 복사 | | | `` `` | Enter | 서브모듈 열기 | | `` d `` | Remove | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | 서브모듈 업데이트 | @@ -277,7 +274,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 브랜치명을 클립보드에 복사 | | +| `` `` | 브랜치명을 클립보드에 복사 | | | `` `` | 체크아웃 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | 새 브랜치 생성 | | | `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) | @@ -286,7 +283,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` u `` | Set as upstream | Set the selected remote branch as the upstream of the checked-out branch. | | `` s `` | Sort order | | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | | `` w `` | View worktree options | | @@ -296,8 +293,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Reset cherry-picked (copied) commits selection | | +| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Reset cherry-picked (copied) commits selection | | | `` b `` | Bisect 옵션 보기 | | | `` s `` | 스쿼시 | 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. | @@ -310,15 +307,15 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` p `` | Pick | Pick commit (when mid-rebase) | | `` F `` | Create fixup commit | Create fixup commit for this commit | | `` S `` | Apply fixup commits | Squash all 'fixup!' commits above selected commit (autosquash) | -| `` `` | 커밋을 1개 아래로 이동 | | -| `` `` | 커밋을 1개 위로 이동 | | +| `` , `` | 커밋을 1개 아래로 이동 | | +| `` , `` | 커밋을 1개 위로 이동 | | | `` V `` | 커밋을 붙여넣기 (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. | | `` A `` | Amend | Amend commit with staged changes | | `` 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 options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | +| `` `` | 로그 메뉴 열기 | 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 | | | `` `` | 체크아웃 | Checkout the selected commit as a detached HEAD. | | `` y `` | 커밋 attribute 복사 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | @@ -327,7 +324,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 커밋을 복사 (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View selected item's files | | @@ -338,13 +335,13 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 파일명을 클립보드에 복사 | | +| `` `` | 파일명을 클립보드에 복사 | | | `` y `` | 클립보드에 복사 | | | `` c `` | 체크아웃 | Checkout file | | `` d `` | View 'discard changes' options | Discard this commit's changes to this file | | `` o `` | 파일 닫기 | Open file in default application. | | `` e `` | Edit | Open file in external editor. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` `` | Toggle file included in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Toggle all files included in patch | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Enter file to add selected lines to the patch (or toggle directory collapsed) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | @@ -365,13 +362,13 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy tag to clipboard | | +| `` `` | Copy tag to clipboard | | | `` `` | 체크아웃 | Checkout the selected tag as a detached HEAD. | | `` n `` | 태그를 생성 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | | `` d `` | 삭제 | View delete options for local/remote tag. | | `` P `` | 태그를 push | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | 초기화 | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | | `` w `` | View worktree options | | @@ -381,15 +378,15 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 파일명을 클립보드에 복사 | | +| `` `` | 파일명을 클립보드에 복사 | | | `` `` | Staged 전환 | Toggle staged for selected file. | -| `` `` | 파일을 필터하기 (Staged/unstaged) | | +| `` `` | 파일을 필터하기 (Staged/unstaged) | | | `` y `` | 클립보드에 복사 | | | `` c `` | 커밋 변경내용 | 스테이징된 변경 사항 커밋. | | `` w `` | Commit changes without pre-commit hook | | | `` A `` | 마지맛 커밋 수정 | | | `` C `` | Git 편집기를 사용하여 변경 내용을 커밋합니다. | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | +| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` e `` | Edit | Open file in external editor. | | `` o `` | 파일 닫기 | Open file in default application. | | `` i `` | Ignore file | | @@ -402,7 +399,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` g `` | View upstream reset options | | | `` D `` | 초기화 | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | 파일 트리뷰로 전환 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | @@ -416,4 +413,4 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ |-----|--------|-------------| | `` `` | 확인 | | | `` `` | 닫기/취소 | | -| `` `` | 클립보드에 복사 | | +| `` `` | 클립보드에 복사 | | diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md index 21b8c5b4c..1715c597e 100644 --- a/docs/keybindings/Keybindings_nl.md +++ b/docs/keybindings/Keybindings_nl.md @@ -2,15 +2,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct # Lazygit Sneltoetsen -_Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ - ## Globale sneltoetsen | Key | Action | Info | |-----|--------|-------------| -| `` `` | Wissel naar een recente repo | | -| `` (fn+up/shift+k) `` | Scroll naar beneden vanaf hoofdpaneel | | -| `` (fn+down/shift+j) `` | Scroll naar beneden vanaf hoofdpaneel | | +| `` `` | 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. | | `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | @@ -19,7 +17,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` } `` | 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. | -| `` `` | Bekijk aangepaste patch opties | | +| `` `` | Bekijk aangepaste patch opties | | | `` m `` | Bekijk merge/rebase opties | View options to abort/continue/skip the current 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) | | @@ -27,12 +25,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers | | `` `` | 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. | -| `` `` | 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 `` | Quit | | -| `` `` | Suspend the application | | -| `` `` | 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'. | +| `` `` | 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, `` | Quit | | +| `` `` | Suspend the application | | +| `` `` | 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'. | | `` 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. | @@ -42,11 +39,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ |-----|--------|-------------| | `` , `` | Vorige pagina | | | `` . `` | Volgende pagina | | -| `` < () `` | Scroll naar boven | | -| `` > () `` | Scroll naar beneden | | +| `` <, `` | Scroll naar boven | | +| `` >, `` | Scroll naar beneden | | | `` v `` | Toggle drag selecteer | | -| `` `` | Range select down | | -| `` `` | Range select up | | +| `` `` | Range select down | | +| `` `` | Range select up | | | `` / `` | Start met zoeken | | | `` H `` | Scroll left | | | `` L `` | Scroll right | | @@ -57,15 +54,15 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Kopieer de bestandsnaam naar het klembord | | +| `` `` | Kopieer de bestandsnaam naar het klembord | | | `` `` | Toggle staged | Toggle staged for selected file. | -| `` `` | Filter files by status | | +| `` `` | Filter files by status | | | `` y `` | Copy to clipboard | | | `` c `` | Commit veranderingen | Commit staged changes. | | `` w `` | Commit veranderingen zonder pre-commit hook | | | `` A `` | Wijzig laatste commit | | | `` C `` | Commit veranderingen met de git editor | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | +| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` e `` | Edit | Open file in external editor. | | `` o `` | Open bestand | Open file in default application. | | `` i `` | Ignore or exclude file | | @@ -78,7 +75,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` g `` | Bekijk upstream reset opties | | | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | @@ -92,13 +89,13 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ |-----|--------|-------------| | `` `` | Bevestig | | | `` `` | Sluiten | | -| `` `` | Copy to clipboard | | +| `` `` | Copy to clipboard | | ## Branches | Key | Action | Info | |-----|--------|-------------| -| `` `` | Kopieer branch name naar klembord | | +| `` `` | Kopieer branch name naar klembord | | | `` i `` | Laat git-flow opties zien | | | `` `` | Uitchecken | Checkout selected item. | | `` n `` | Nieuwe branch | | @@ -106,7 +103,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` o `` | Maak een pull-request | | | `` O `` | Bekijk opties voor pull-aanvraag | | | `` G `` | Open pull request in browser | | -| `` `` | Kopieer de URL van het pull-verzoek naar het klembord | | +| `` `` | Kopieer de URL van het pull-verzoek naar het klembord | | | `` c `` | Uitchecken bij naam | Checkout by name. In the input box you can enter '-' to switch to the previous branch. | | `` - `` | Checkout previous branch | | | `` F `` | Forceer checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | @@ -119,7 +116,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` g `` | Bekijk reset opties | | | `` R `` | Hernoem branch | | | `` u `` | View upstream options | View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | | `` w `` | View worktree options | | @@ -136,13 +133,13 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Kopieer de bestandsnaam naar het klembord | | +| `` `` | Kopieer de bestandsnaam naar het klembord | | | `` y `` | Copy to clipboard | | | `` c `` | Uitchecken | Bestand uitchecken | | `` d `` | Bekijk 'veranderingen ongedaan maken' opties | Uitsluit deze commit zijn veranderingen aan dit bestand | | `` o `` | Open bestand | Open file in default application. | | `` e `` | Edit | Open file in external editor. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` `` | Toggle bestand inbegrepen in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Enter bestand om geselecteerde regels toe te voegen aan de patch | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | @@ -156,8 +153,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Reset cherry-picked (gekopieerde) commits selectie | | +| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Reset cherry-picked (gekopieerde) commits selectie | | | `` b `` | View bisect options | | | `` 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. | @@ -170,15 +167,15 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` 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 | | +| `` , `` | 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. | | `` 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. | +| `` `` | View log options | 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. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | @@ -187,7 +184,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | @@ -215,10 +212,10 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ |-----|--------|-------------| | `` `` | Kies stuk | | | `` b `` | Kies beide stukken | | -| `` `` | Selecteer bovenste hunk | | -| `` `` | Selecteer onderste hunk | | -| `` `` | Selecteer voorgaand conflict | | -| `` `` | Selecteer volgende conflict | | +| `` , k `` | Selecteer bovenste hunk | | +| `` , j `` | Selecteer onderste hunk | | +| `` , h `` | Selecteer voorgaand conflict | | +| `` , l `` | Selecteer volgende conflict | | | `` z `` | Ongedaan maken | Undo last merge conflict resolution. | | `` e `` | Verander bestand | Open file in external editor. | | `` o `` | Open bestand | Open file in default application. | @@ -229,8 +226,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | Scroll omlaag | | -| `` mouse wheel up (fn+down) `` | Scroll omhoog | | +| `` (fn+up) `` | Scroll omlaag | | +| `` (fn+down) `` | Scroll omhoog | | | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | Start met zoeken | | @@ -239,11 +236,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Selecteer de vorige hunk | | -| `` `` | Selecteer de volgende hunk | | +| `` , 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. | -| `` `` | Copy selected text to clipboard | | +| `` `` | Copy selected text to clipboard | | | `` o `` | Open bestand | Open file in default application. | | `` e `` | Verander bestand | Open file in external editor. | | `` `` | Voeg toe/verwijder lijn(en) in patch | | @@ -255,7 +252,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | Uitchecken | Checkout the selected commit as a 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 | | @@ -263,8 +260,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Reset cherry-picked (gekopieerde) commits selectie | | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Reset cherry-picked (gekopieerde) commits selectie | | +| `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | @@ -275,7 +272,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Kopieer branch name naar klembord | | +| `` `` | Kopieer branch name naar klembord | | | `` `` | Uitchecken | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | Nieuwe branch | | | `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) | @@ -284,7 +281,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` u `` | Set as upstream | Stel in als upstream van uitgecheckte branch | | `` s `` | Sort order | | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | | `` w `` | View worktree options | | @@ -314,11 +311,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Selecteer de vorige hunk | | -| `` `` | Selecteer de volgende hunk | | +| `` , 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. | -| `` `` | Copy selected text to clipboard | | +| `` `` | 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. | | `` o `` | Open bestand | Open file in default application. | @@ -329,7 +326,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` c `` | Commit veranderingen | Commit staged changes. | | `` w `` | Commit veranderingen zonder pre-commit hook | | | `` C `` | Commit veranderingen met de git editor | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | +| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | Start met zoeken | | ## Stash @@ -362,7 +359,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | Uitchecken | Checkout the selected commit as a 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 | | @@ -370,8 +367,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Reset cherry-picked (gekopieerde) commits selectie | | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Reset cherry-picked (gekopieerde) commits selectie | | +| `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | @@ -382,7 +379,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Kopieer submodule naam naar klembord | | +| `` `` | Kopieer submodule naam naar klembord | | | `` `` | Enter | Enter submodule | | `` d `` | Remove | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | Update selected submodule. | @@ -396,13 +393,13 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy tag to clipboard | | +| `` `` | Copy tag to clipboard | | | `` `` | Uitchecken | Checkout the selected tag as a detached HEAD. | | `` n `` | Creëer tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | | `` d `` | Delete | 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. | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | | `` w `` | View worktree options | | diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md index 622a134fd..b032a6606 100644 --- a/docs/keybindings/Keybindings_pl.md +++ b/docs/keybindings/Keybindings_pl.md @@ -2,24 +2,22 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct # Lazygit Skróty klawiszowe -_Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ - ## Globalne skróty klawiszowe | Key | Action | Info | |-----|--------|-------------| -| `` `` | Przełącz na ostatnie repozytorium | | -| `` (fn+up/shift+k) `` | Przewiń główne okno w górę | | -| `` (fn+down/shift+j) `` | Przewiń główne okno w dół | | +| `` `` | Przełącz na ostatnie repozytorium | | +| `` , K, (fn+up/shift+k) `` | Przewiń główne okno w górę | | +| `` , J, (fn+down/shift+j) `` | Przewiń główne okno w dół | | | `` @ `` | Pokaż opcje dziennika poleceń | Pokaż opcje dla dziennika poleceń, np. pokazywanie/ukrywanie dziennika poleceń i skupienie na dzienniku poleceń. | | `` P `` | Wypchnij | Wypchnij bieżącą gałąź do jej gałęzi nadrzędnej. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej. | -| `` p `` | Pociągnij | Pociągnij zmiany z zdalnego dla bieżącej gałęzi. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej. | +| `` p `` | Pociągnij | Pociągnij zmiany ze zdalnego dla bieżącej gałęzi. Jeśli nie skonfigurowano gałęzi nadrzędnej, zostaniesz poproszony o skonfigurowanie gałęzi nadrzędnej. | | `` ) `` | 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'. | | `` } `` | Zwiększ rozmiar kontekstu w widoku różnic | 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'. | | `` { `` | Zmniejsz rozmiar kontekstu w widoku różnic | 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. | -| `` `` | Wyświetl opcje niestandardowej łatki | | +| `` : `` | Wykonaj polecenie w powłoce | Bring up a prompt where you can enter a shell command to execute. | +| `` `` | Wyświetl opcje niestandardowej łatki | | | `` m `` | Pokaż opcje scalania/rebase | Pokaż opcje do przerwania/kontynuowania/pominięcia bieżącego scalania/rebase. | | `` 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) | | @@ -27,12 +25,11 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers | | `` `` | 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. | -| `` W `` | Pokaż opcje różnicowania | Pokaż opcje dotyczące różnicowania dwóch refów, np. różnicowanie względem wybranego refa, wprowadzanie refa do różnicowania i odwracanie kierunku różnic. | -| `` `` | Pokaż opcje różnicowania | Pokaż opcje dotyczące różnicowania dwóch refów, np. różnicowanie względem wybranego refa, wprowadzanie refa do różnicowania i odwracanie kierunku różnic. | -| `` q `` | Wyjdź | | -| `` `` | Suspend the application | | -| `` `` | Przełącz białe znaki | 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'. | +| `` `` | Pokaż opcje filtrowania | Pokaż opcje filtrowania dziennika commitów, tak aby pokazywane były tylko commity pasujące do filtra. | +| `` W, `` | Pokaż opcje różnicowania | Pokaż opcje dotyczące różnicowania dwóch refów, np. różnicowanie względem wybranego refa, wprowadzanie refa do różnicowania i odwracanie kierunku różnic. | +| `` q, `` | Wyjdź | | +| `` `` | Suspend the application | | +| `` `` | Przełącz białe znaki | 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'. | | `` z `` | Cofnij | Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby cofnąć ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity. | | `` Z `` | Ponów | Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby ponowić ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity. | @@ -42,11 +39,11 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ |-----|--------|-------------| | `` , `` | Poprzednia strona | | | `` . `` | Następna strona | | -| `` < () `` | Przewiń do góry | | -| `` > () `` | Przewiń do dołu | | +| `` <, `` | Przewiń do góry | | +| `` >, `` | Przewiń do dołu | | | `` v `` | Przełącz zaznaczenie zakresu | | -| `` `` | Zaznacz zakres w dół | | -| `` `` | Zaznacz zakres w górę | | +| `` `` | Zaznacz zakres w dół | | +| `` `` | Zaznacz zakres w górę | | | `` / `` | Szukaj w bieżącym widoku po tekście | | | `` H `` | Przewiń w lewo | | | `` L `` | Przewiń w prawo | | @@ -57,38 +54,38 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Resetuj wybrane (cherry-picked) commity | | +| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Resetuj wybrane (cherry-picked) commity | | | `` b `` | Zobacz opcje bisect | | | `` s `` | Scal | Scal wybrany commit z commitami poniżej. Wiadomość wybranego commita zostanie dołączona do commita poniżej. | | `` f `` | Poprawka | Włącz wybrany commit do commita poniżej. Podobnie do fixup, ale wiadomość wybranego commita zostanie odrzucona. | | `` 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 `` | Przeformułuj | Przeformułuj wiadomość wybranego commita. | | `` R `` | Przeformułuj za pomocą edytora | | -| `` d `` | Usuń | Usuń wybrany commit. To usunie commit z gałęzi za pomocą rebazowania. Jeśli commit wprowadza zmiany, od których zależą późniejsze commity, być może będziesz musiał rozwiązać konflikty scalania. | -| `` e `` | Edytuj (rozpocznij interaktywne rebazowanie) | Edytuj wybrany commit. Użyj tego, aby rozpocząć interaktywne rebazowanie od wybranego commita. Podczas trwania rebazowania, to oznaczy wybrany commit do edycji, co oznacza, że po kontynuacji rebazowania, rebazowanie zostanie wstrzymane na wybranym commicie, aby umożliwić wprowadzenie zmian. | -| `` i `` | Rozpocznij interaktywny rebase | Rozpocznij interaktywny rebase dla commitów na twoim branchu. To będzie zawierać wszystkie commity od HEAD do pierwszego commita scalenia lub commita głównego brancha.
Jeśli chcesz zamiast tego rozpocząć interaktywny rebase od wybranego commita, naciśnij `e`. | -| `` p `` | Wybierz | Oznacz wybrany commit do wybrania (podczas rebazowania). Oznacza to, że commit zostanie zachowany po kontynuacji rebazowania. | +| `` d `` | Usuń | Usuń wybrany commit. To usunie commit z gałęzi za pomocą przebazowania. Jeśli commit wprowadza zmiany, od których zależą późniejsze commity, być może będziesz musiał rozwiązać konflikty scalania. | +| `` e `` | Edytuj (rozpocznij interaktywne przebazowanie) | Edytuj wybrany commit. Użyj tego, aby rozpocząć interaktywne przebazowanie od wybranego commita. Podczas trwania przebazowania, to oznaczy wybrany commit do edycji, co oznacza, że po kontynuacji przebazowania, przebazowanie zostanie wstrzymane na wybranym commicie, aby umożliwić wprowadzenie zmian. | +| `` i `` | Rozpocznij interaktywne przebazowanie | Rozpocznij interaktywne przebazowanie dla commitów na twojej gałęzi. To będzie zawierać wszystkie commity od HEAD do pierwszego commita scalenia lub commita głównej gałęzi.
Jeśli zamiast tego chcesz rozpocząć interaktywne przebazowanie od wybranego commita, naciśnij `e`. | +| `` p `` | Wybierz | Oznacz wybrany commit do wybrania (podczas przebazowania). Oznacza to, że commit zostanie zachowany po kontynuacji przebazowania. | | `` F `` | Utwórz commit fixup | Utwórz commit 'fixup!' dla wybranego commita. Później możesz nacisnąć `S` na tym samym commicie, aby zastosować wszystkie powyższe commity fixup. | | `` S `` | Zastosuj commity fixup | Scal wszystkie commity 'fixup!', albo powyżej wybranego commita, albo wszystkie w bieżącej gałęzi (autosquash). | -| `` `` | Przesuń commit w dół | | -| `` `` | Przesuń commit w górę | | +| `` , `` | Przesuń commit w dół | | +| `` , `` | Przesuń commit w górę | | | `` V `` | Wklej (cherry-pick) | | -| `` B `` | Oznacz jako bazowy commit dla rebase | Wybierz bazowy commit dla następnego rebase. Kiedy robisz rebase na branch, tylko commity powyżej bazowego commita zostaną przeniesione. Używa to polecenia `git rebase --onto`. | -| `` A `` | Popraw | Popraw commit ze zmianami zatwierdzonymi. Jeśli wybrany commit jest commit HEAD, to wykona `git commit --amend`. W przeciwnym razie commit zostanie poprawiony za pomocą rebazowania. | +| `` B `` | Oznacz jako bazowy commit dla przebazowania | Wybierz bazowy commit dla następnego przebazowania. Kiedy robisz przebazowanie na gałąź, tylko commity powyżej bazowego commita zostaną przeniesione. Używa to polecenia `git rebase --onto`. | +| `` A `` | Popraw | Popraw commit ze zmianami zatwierdzonymi. Jeśli wybrany commit jest commit HEAD, to wykona `git commit --amend`. W przeciwnym razie commit zostanie poprawiony za pomocą przebazowania. | | `` a `` | Popraw atrybut commita | Ustaw/Resetuj autora commita lub ustaw współautora. | | `` t `` | Cofnij | Utwórz commit cofający dla wybranego commita, który stosuje zmiany wybranego commita w odwrotnej kolejności. | | `` T `` | Otaguj commit | Utwórz nowy tag wskazujący na wybrany commit. Zostaniesz poproszony o wprowadzenie nazwy tagu i opcjonalnego opisu. | -| `` `` | Zobacz opcje logów | Zobacz opcje dla logów commitów, np. zmiana kolejności sortowania, ukrywanie grafu gita, pokazywanie całego grafu gita. | -| `` G `` | Open pull request in browser | | +| `` `` | Zobacz opcje logów | Zobacz opcje dla logów commitów, np. zmiana kolejności sortowania, ukrywanie grafu gita, pokazywanie całego grafu gita. | +| `` G `` | Otwórz żądanie ściągnięcia w przeglądarce | | | `` `` | Przełącz | Przełącz wybrany commit jako odłączoną HEAD. | | `` y `` | Kopiuj atrybut commita do schowka | Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor). | | `` o `` | Otwórz commit w przeglądarce | | | `` n `` | Utwórz nową gałąź z commita | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | -| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | +| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Wyświetl pliki | | @@ -113,15 +110,35 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` d `` | Usuń | Usuń wybrane drzewo pracy. To usunie zarówno katalog drzewa pracy, jak i metadane o drzewie pracy w katalogu .git. | | `` / `` | Filtruj bieżący widok po tekście | | +## Dziennik reflog + +| Key | Action | Info | +|-----|--------|-------------| +| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Przełącz | Przełącz wybrany commit jako odłączoną HEAD. | +| `` y `` | Kopiuj atrybut commita do schowka | Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor). | +| `` o `` | Otwórz commit w przeglądarce | | +| `` n `` | Utwórz nową gałąź z commita | | +| `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | +| `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | +| `` `` | Resetuj wybrane (cherry-picked) commity | | +| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | +| `` * `` | Select commits of current branch | | +| `` 0 `` | Focus main view | | +| `` `` | Pokaż commity | | +| `` w `` | Zobacz opcje drzewa pracy | | +| `` / `` | Filtruj bieżący widok po tekście | | + ## Główny panel (budowanie łatki) | Key | Action | Info | |-----|--------|-------------| -| `` `` | Idź do poprzedniego fragmentu | | -| `` `` | Idź do następnego fragmentu | | +| `` , h `` | Idź do poprzedniego fragmentu | | +| `` , l `` | Idź do następnego fragmentu | | | `` v `` | Przełącz zaznaczenie zakresu | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | Kopiuj zaznaczony tekst do schowka | | +| `` `` | Kopiuj zaznaczony tekst do schowka | | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | | `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | | `` `` | Przełącz linie w łatce | | @@ -140,17 +157,17 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Kopiuj nazwę gałęzi do schowka | | +| `` `` | Kopiuj nazwę gałęzi do schowka | | | `` i `` | Pokaż opcje git-flow | | | `` `` | Przełącz | Przełącz wybrany element. | | `` n `` | Nowa gałąź | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` o `` | Utwórz żądanie ściągnięcia | | | `` O `` | Zobacz opcje tworzenia pull requesta | | -| `` G `` | Open pull request in browser | | -| `` `` | Kopiuj adres URL żądania ściągnięcia do schowka | | +| `` G `` | Otwórz żądanie ściągnięcia w przeglądarce | | +| `` `` | Kopiuj adres URL żądania ściągnięcia do schowka | | | `` c `` | Przełącz według nazwy | Przełącz według nazwy. W polu wprowadzania możesz wpisać '-' aby przełączyć się na ostatnią gałąź. | -| `` - `` | Checkout previous branch | | +| `` - `` | Przełącz na poprzednią gałąź | | | `` F `` | Wymuś przełączenie | Wymuś przełączenie wybranej gałęzi. To spowoduje odrzucenie wszystkich lokalnych zmian w drzewie roboczym przed przełączeniem na wybraną gałąź. | | `` d `` | Usuń | Wyświetl opcje usuwania lokalnej/odległej gałęzi. | | `` r `` | Przebazuj | Przebazuj przełączoną gałąź na wybraną gałąź. | @@ -161,7 +178,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` g `` | Reset | | | `` R `` | Zmień nazwę gałęzi | | | `` u `` | Pokaż opcje upstream | Pokaż opcje dotyczące upstream gałęzi, np. ustawianie/usuwanie upstream i resetowanie do upstream. | -| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | +| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | | `` w `` | Zobacz opcje drzewa pracy | | @@ -179,8 +196,8 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | Przewiń w dół | | -| `` mouse wheel up (fn+down) `` | Przewiń w górę | | +| `` (fn+up) `` | Przewiń w dół | | +| `` (fn+down) `` | Przewiń w górę | | | `` `` | Przełącz widok | Przełącz na inny widok (zatwierdzone/niezatwierdzone zmiany). | | `` `` | Exit back to side panel | | | `` / `` | Szukaj w bieżącym widoku po tekście | | @@ -191,10 +208,10 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ |-----|--------|-------------| | `` `` | Wybierz fragment | | | `` b `` | Wybierz wszystkie fragmenty | | -| `` `` | Poprzedni fragment | | -| `` `` | Następny fragment | | -| `` `` | Poprzedni konflikt | | -| `` `` | Następny konflikt | | +| `` , k `` | Poprzedni fragment | | +| `` , j `` | Następny fragment | | +| `` , h `` | Poprzedni konflikt | | +| `` , l `` | Następny konflikt | | | `` z `` | Cofnij | Cofnij ostatnie rozwiązanie konfliktu scalania. | | `` e `` | Edytuj plik | Otwórz plik w zewnętrznym edytorze. | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | @@ -205,11 +222,11 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Idź do poprzedniego fragmentu | | -| `` `` | Idź do następnego fragmentu | | +| `` , h `` | Idź do poprzedniego fragmentu | | +| `` , l `` | Idź do następnego fragmentu | | | `` v `` | Przełącz zaznaczenie zakresu | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | Kopiuj zaznaczony tekst do schowka | | +| `` `` | Kopiuj zaznaczony tekst do schowka | | | `` `` | Zatwierdź | Przełącz zaznaczenie zatwierdzone/niezatwierdzone. | | `` d `` | Odrzuć | Gdy zaznaczona jest niezatwierdzona zmiana, odrzuć ją używając `git reset`. Gdy zaznaczona jest zatwierdzona zmiana, cofnij zatwierdzenie. | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | @@ -220,7 +237,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` c `` | Commit | Zatwierdź zmiany zatwierdzone. | | `` w `` | Zatwierdź zmiany bez hooka pre-commit | | | `` C `` | Zatwierdź zmiany używając edytora git | | -| `` `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: | +| `` `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Panel potwierdzenia @@ -229,21 +246,21 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ |-----|--------|-------------| | `` `` | Potwierdź | | | `` `` | Zamknij/Anuluj | | -| `` `` | Kopiuj do schowka | | +| `` `` | Kopiuj do schowka | | ## Pliki | Key | Action | Info | |-----|--------|-------------| -| `` `` | Kopiuj ścieżkę do schowka | | +| `` `` | Kopiuj ścieżkę do schowka | | | `` `` | Zatwierdź | Przełącz zatwierdzenie dla wybranego pliku. | -| `` `` | Filtruj pliki według statusu | | +| `` `` | Filtruj pliki według statusu | | | `` y `` | Kopiuj do schowka | | | `` c `` | Commit | Zatwierdź zmiany zatwierdzone. | | `` w `` | Zatwierdź zmiany bez hooka pre-commit | | | `` A `` | Popraw ostatni commit | | | `` C `` | Zatwierdź zmiany używając edytora git | | -| `` `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: | +| `` `` | Znajdź bazowy commit do poprawki | Znajdź commit, na którym opierają się Twoje obecne zmiany, w celu poprawienia/zmiany commita. To pozwala Ci uniknąć przeglądania commitów w Twojej gałęzi jeden po drugim, aby zobaczyć, który commit powinien być poprawiony/zmieniony. Zobacz dokumentację: | | `` e `` | Edytuj | Otwórz plik w zewnętrznym edytorze. | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | | `` i `` | Ignoruj lub wyklucz plik | | @@ -256,7 +273,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` g `` | Pokaż opcje resetowania do upstream | | | `` D `` | Reset | Wyświetl opcje resetu dla drzewa roboczego (np. zniszczenie drzewa roboczego). | | `` ` `` | Przełącz widok drzewa plików | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | -| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | +| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Pobierz | Pobierz zmiany ze zdalnego serwera. | | `` - `` | Collapse all files | Collapse all directories in the files tree | @@ -268,13 +285,13 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Kopiuj ścieżkę do schowka | | +| `` `` | Kopiuj ścieżkę do schowka | | | `` y `` | Kopiuj do schowka | | | `` c `` | Przełącz | Przełącz plik. Zastępuje plik w twoim drzewie roboczym wersją z wybranego commita. | -| `` d `` | Odrzuć | Odrzuć zmiany w tym pliku z tego commita. Uruchamia interaktywny rebase w tle, więc możesz otrzymać konflikt scalania, jeśli późniejszy commit również zmienia ten plik. | +| `` d `` | Odrzuć | Odrzuć zmiany w tym pliku z tego commita. Uruchamia interaktywne przebazowanie w tle, więc możesz otrzymać konflikt scalania, jeśli późniejszy commit również zmienia ten plik. | | `` o `` | Otwórz plik | Otwórz plik w domyślnej aplikacji. | | `` e `` | Edytuj | Otwórz plik w zewnętrznym edytorze. | -| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | +| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` `` | Przełącz plik włączony w łatkę | Przełącz, czy plik jest włączony w niestandardową łatkę. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Przełącz wszystkie pliki | Dodaj/usuń wszystkie pliki commita do niestandardowej łatki. Zobacz https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Wejdź do pliku / Przełącz zwiń katalog | Jeśli plik jest wybrany, wejdź do pliku, aby móc dodawać/usuwać poszczególne linie do niestandardowej łatki. Jeśli wybrany jest katalog, przełącz katalog. | @@ -291,26 +308,6 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` `` | Potwierdź | | | `` `` | Zamknij | | -## Reflog - -| Key | Action | Info | -|-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Przełącz | Przełącz wybrany commit jako odłączoną HEAD. | -| `` y `` | Kopiuj atrybut commita do schowka | Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor). | -| `` o `` | Otwórz commit w przeglądarce | | -| `` n `` | Utwórz nową gałąź z commita | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | -| `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | -| `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | -| `` `` | Resetuj wybrane (cherry-picked) commity | | -| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | -| `` * `` | Select commits of current branch | | -| `` 0 `` | Focus main view | | -| `` `` | Pokaż commity | | -| `` w `` | Zobacz opcje drzewa pracy | | -| `` / `` | Filtruj bieżący widok po tekście | | - ## Schowek | Key | Action | Info | @@ -341,16 +338,16 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | Przełącz | Przełącz wybrany commit jako odłączoną HEAD. | | `` y `` | Kopiuj atrybut commita do schowka | Kopiuj atrybut commita do schowka (np. hash, URL, różnice, wiadomość, autor). | | `` o `` | Otwórz commit w przeglądarce | | | `` n `` | Utwórz nową gałąź z commita | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | -| `` `` | Resetuj wybrane (cherry-picked) commity | | -| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | +| `` `` | Resetuj wybrane (cherry-picked) commity | | +| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Wyświetl pliki | | @@ -361,7 +358,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Kopiuj nazwę submodułu do schowka | | +| `` `` | Kopiuj nazwę submodułu do schowka | | | `` `` | Wejdź | Wejdź do submodułu. Po wejściu do submodułu możesz nacisnąć ``, aby wrócić do repozytorium nadrzędnego. | | `` d `` | Usuń | Usuń wybrany submoduł i odpowiadający mu katalog. | | `` u `` | Aktualizuj | Aktualizuj wybrany submoduł. | @@ -375,13 +372,13 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy tag to clipboard | | +| `` `` | Skopiuj tag do schowka | | | `` `` | Przełącz | Przełącz wybrany tag jako odłączoną głowę (detached HEAD). | | `` n `` | Nowy tag | Utwórz nowy tag z bieżącego commita. Zostaniesz poproszony o wprowadzenie nazwy tagu i opcjonalnego opisu. | | `` d `` | Usuń | Wyświetl opcje usuwania lokalnego/odległego tagu. | | `` P `` | Wyślij tag | Wyślij wybrany tag do zdalnego. Zostaniesz poproszony o wybranie zdalnego. | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | -| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | +| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | | `` w `` | Zobacz opcje drzewa pracy | | @@ -403,7 +400,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Kopiuj nazwę gałęzi do schowka | | +| `` `` | Kopiuj nazwę gałęzi do schowka | | | `` `` | Przełącz | Przełącz na nową lokalną gałąź na podstawie wybranej gałęzi zdalnej. Nowa gałąź będzie śledzić gałąź zdalną. | | `` n `` | Nowa gałąź | | | `` M `` | Scal | Scal wybraną gałąź z aktualnie sprawdzoną gałęzią. | @@ -412,7 +409,7 @@ _Legenda: `` oznacza ctrl+b, `` oznacza alt+b, `B` oznacza shift+b_ | `` u `` | Ustaw jako upstream | Ustaw wybraną gałąź zdalną jako upstream sprawdzonej gałęzi. | | `` s `` | Kolejność sortowania | | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | -| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | +| `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | | `` w `` | Zobacz opcje drzewa pracy | | diff --git a/docs/keybindings/Keybindings_pt.md b/docs/keybindings/Keybindings_pt.md index 81dc4085e..c19619191 100644 --- a/docs/keybindings/Keybindings_pt.md +++ b/docs/keybindings/Keybindings_pt.md @@ -2,15 +2,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct # Lazygit Atalhos do teclado -_Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ - ## Combinações globais de teclas | Key | Action | Info | |-----|--------|-------------| -| `` `` | Mudar para um repositório recente | | -| `` (fn+up/shift+k) `` | Rolar janela principal para cima | | -| `` (fn+down/shift+j) `` | Rolar a janela principal para baixo | | +| `` `` | Mudar para um repositório recente | | +| `` , K, (fn+up/shift+k) `` | Rolar janela principal para cima | | +| `` , J, (fn+down/shift+j) `` | Rolar a janela principal para baixo | | | `` @ `` | View command log options | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | Empurre (Push) | Faça push do branch atual para o seu branch upstream. Se nenhum upstream estiver configurado, você será solicitado a configurar um branch a montante. | | `` p `` | Puxar (Pull) | Puxe alterações do controle remoto para o ramo atual. Se nenhum upstream estiver configurado, será solicitado configurar um ramo a montante. | @@ -19,7 +17,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` } `` | 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'. | | `` : `` | Executar comando da shell | Traga um prompt onde você pode digitar um comando shell para executar. | -| `` `` | Ver opções de patch personalizadas | | +| `` `` | Ver opções de patch personalizadas | | | `` m `` | Ver opções de mesclar/rebase | Ver opções para abortar/continuar/pular o merge/rebase atual. | | `` 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) | | @@ -27,12 +25,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers | | `` `` | 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. | -| `` W `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` q `` | Sair | | -| `` `` | Suspender a aplicação | | -| `` `` | 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'. | +| `` `` | Ver opções de filtro | View options for filtering the commit log, so that only commits matching the filter are shown. | +| `` W, `` | View diffing options | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | +| `` q, `` | Sair | | +| `` `` | Suspender a aplicação | | +| `` `` | 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'. | | `` z `` | Desfazer | O reflog será usado para determinar qual comando git para executar para desfazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração. | | `` Z `` | Refazer | O reflog será usado para determinar qual comando git para executar para refazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração. | @@ -42,11 +39,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ |-----|--------|-------------| | `` , `` | Aba anterior | | | `` . `` | Próxima aba | | -| `` < () `` | Voltar ao topo | | -| `` > () `` | Ir para o final | | +| `` <, `` | Voltar ao topo | | +| `` >, `` | Ir para o final | | | `` v `` | Toggle range select | | -| `` `` | Range select down | | -| `` `` | Range select up | | +| `` `` | Range select down | | +| `` `` | Range select up | | | `` / `` | Pesquisar na visualização atual por texto | | | `` H `` | Rolar à esquerda | | | `` L `` | Scroll para a direita | | @@ -57,15 +54,15 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copiar caminho para área de transferência | | +| `` `` | Copiar caminho para área de transferência | | | `` `` | Etapa | Alternar para staging para o arquivo selecionado. | -| `` `` | Filtrar arquivos por status | | +| `` `` | Filtrar arquivos por status | | | `` y `` | Copy to clipboard | | | `` c `` | Commit | Submeter mudanças em staging | | `` w `` | Fazer commit de alterações sem pré-commit | | | `` A `` | Alterar último commit | | | `` C `` | Enviar alteração usando um editor Git | | -| `` `` | Encontrar commit da base para corrigir | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado
Veja a documentação:
| +| `` `` | Encontrar commit da base para corrigir | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado
Veja a documentação:
| | `` e `` | Editar | Abrir arquivo no editor externo. | | `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | | `` i `` | Ignore or exclude file | | @@ -78,7 +75,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` g `` | View upstream reset options | | | `` D `` | Restaurar | Opções de redefinição de exibição para árvore de trabalho (por exemplo, nukando a árvore de trabalho). | | `` ` `` | Alternar exibição de árvore de arquivo | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | -| `` `` | Abrir ferramenta de diff externa (git difftool) | | +| `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Buscar | Buscar alterações do controle remoto. | | `` - `` | Recolher todos os arquivos | Recolher todos os diretórios na árvore de arquivos | @@ -90,7 +87,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copiar nome da branch para área de transferência | | +| `` `` | Copiar nome da branch para área de transferência | | | `` i `` | Exibir opções do git-flow | | | `` `` | Verificar | Checar item selecionado | | `` n `` | Nova branch | | @@ -98,7 +95,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` o `` | Criar solicitação de pull | | | `` O `` | View create pull request options | | | `` G `` | Open pull request in browser | | -| `` `` | Copiar URL do pull request para área de transferência | | +| `` `` | Copiar URL do pull request para área de transferência | | | `` c `` | Checar por nome | Checar por nome. Na caixa de entrada você pode inserir '-' para trocar para a última branch | | `` - `` | Checkout da branch anterior | | | `` F `` | Forçar checagem | Forçar checagem da branch selecionada. Isso irá descartar todas as mudanças no seu diretório de trabalho antes cheque a branch selecionada | @@ -111,7 +108,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` g `` | Restaurar | | | `` R `` | Renomear branch | | | `` u `` | View upstream options | View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream. | -| `` `` | Abrir ferramenta de diff externa (git difftool) | | +| `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver commits | | | `` w `` | Ver opções da árvore de trabalho | | @@ -121,7 +118,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copiar nome da branch para área de transferência | | +| `` `` | Copiar nome da branch para área de transferência | | | `` `` | Verificar | Checar a nova branch baseada na brach remota selecionada, ou a branch remota como HEAD, desanexado | | `` n `` | Nova branch | | | `` M `` | Mesclar | Ver opções para mesclar o item selecionado no branch atual (mesclar regularmente, mesclar squash) | @@ -130,7 +127,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` u `` | Definir como upstream | Definir o ramo remoto selecionado como fluxo do branch check-out. | | `` s `` | Sort order | | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | -| `` `` | Abrir ferramenta de diff externa (git difftool) | | +| `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver commits | | | `` w `` | Ver opções da árvore de trabalho | | @@ -140,13 +137,13 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copiar caminho para área de transferência | | +| `` `` | Copiar caminho para área de transferência | | | `` y `` | Copy to clipboard | | | `` c `` | Verificar | Arquivo de check-out. Isso substitui o arquivo em sua árvore de trabalho com a versão do commit selecionado. | | `` d `` | Descartar | Descartar as alterações desse commit para este arquivo. Isso executa uma rebase interativa em segundo plano, então você pode ter um conflito de merge se um commit posterior também alterar este arquivo. | | `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | | `` e `` | Editar | Abrir arquivo no editor externo. | -| `` `` | Abrir ferramenta de diff externa (git difftool) | | +| `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` `` | Alternar entre o arquivo incluído no patch | Alternar se o arquivo está incluído no patch personalizado. Veja https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Alternar todos os arquivos | Adicionar/remover todos os arquivos de commit para atualização personalizada. Consulte https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Insira o arquivo / Alternar diretório recolhido | Se um arquivo estiver selecionado, insira o arquivo para que você possa adicionar/remover linhas individuais no patch personalizado. Se um diretório for selecionado, ative o diretório. | @@ -160,8 +157,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Reset copied (cherry-picked) commits selection | | +| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Reset copied (cherry-picked) commits selection | | | `` b `` | Ver opções de bissecção | | | `` s `` | Squash | Squash o commit selecionado no commit abaixo dele. A mensagem do commit selecionado será anexada ao commit abaixo dele. | | `` f `` | Corrigir | Faça o commit selecionado no commit abaixo dele. Semelhante para o squash, mas a mensagem do commit selecionado será descartada. | @@ -174,15 +171,15 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` p `` | Escolher | Marque o commit selecionado para ser escolhido (quando meados da base). Isso significa que o commit será mantido ao continuar o rebase. | | `` F `` | Criar commit de correção | Crie o commit 'correção!' para o commit selecionado. Mais tarde, você pode pressionar `S` neste mesmo commit para aplicar todas os commits de correção acima. | | `` S `` | Aplicar commits de correções | Aplicar Squash all 'correção!', seja acima do commit selecionado, ou tudo no branch atual (autosquash). | -| `` `` | Mover commit um para baixo | | -| `` `` | Mover o commit um para cima | | +| `` , `` | Mover commit um para baixo | | +| `` , `` | Mover o commit um para cima | | | `` V `` | Colar (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. | | `` A `` | Modificar | Alterar o commit com mudanças em sted. Se o commit selecionado for o commit HEAD, ele executará o `git commit --amend`. Caso contrário, o compromisso será alterado por meio de uma base de apoio. | | `` a `` | Alterar atributo de commit | Definir/Redefinir autor de submissão ou co-autor definido. | | `` t `` | Reverter | Crie um commit reverter para o commit selecionado, que aplica as alterações do commit selecionado em reverso. | | `` T `` | Etiquetar 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. | +| `` `` | View log options | 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 | | | `` `` | Verificar | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | @@ -191,7 +188,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` N `` | Mover commits para uma nova branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` C `` | Copiar (cherry-pick) | Marcar commit como copiado. Então, dentro da visualização local de commits, você pode pressionar `V` para colar (cherry-pick) o(s) commit(s) copiado(s) em seu branch de check-out. A qualquer momento você pode pressionar `` para cancelar a seleção. | -| `` `` | Abrir ferramenta de diff externa (git difftool) | | +| `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver arquivos | | @@ -202,13 +199,13 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copiar etiqueta para área de transferência | | +| `` `` | Copiar etiqueta para área de transferência | | | `` `` | Verificar | Checar a tag selecionada como um HEAD, desanexado | | `` n `` | Nova etiqueta | Crie uma nova etiqueta a partir do commit atual. Você será solicitado a digitar um nome e uma descrição opcional. | | `` d `` | Apagar | Ver opções de exclusão para tag local/remoto. | | `` P `` | Empurrar etiqueta | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | -| `` `` | Abrir ferramenta de diff externa (git difftool) | | +| `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver commits | | | `` w `` | Ver opções da árvore de trabalho | | @@ -233,8 +230,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | Rolar para baixo | | -| `` mouse wheel up (fn+down) `` | Rolar para cima | | +| `` (fn+up) `` | Rolar para baixo | | +| `` (fn+down) `` | Rolar para cima | | | `` `` | Mudar de visão | Alternar para outra visão (staged/não processadas alterações). | | `` `` | Exit back to side panel | | | `` / `` | Pesquisar na visualização atual por texto | | @@ -243,11 +240,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Ir para o local anterior | | -| `` `` | Ir para o próximo trecho | | +| `` , h `` | Ir para o local anterior | | +| `` , l `` | Ir para o próximo trecho | | | `` v `` | Toggle range select | | | `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. | -| `` `` | Copiar texto selecionado para área de transferência | | +| `` `` | Copiar texto selecionado para área de transferência | | | `` `` | Etapa | Ativar/desativar seleção em staged/unstaged | | `` d `` | Descartar | Quando a mudança não desejada for selecionada, descarte a mudança usando `git reset`. Quando a mudança em fase é selecionada, despare a mudança. | | `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | @@ -258,7 +255,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` c `` | Commit | Submeter mudanças em staging | | `` w `` | Fazer commit de alterações sem pré-commit | | | `` C `` | Enviar alteração usando um editor Git | | -| `` `` | Encontrar commit da base para corrigir | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado
Veja a documentação:
| +| `` `` | Encontrar commit da base para corrigir | Encontre o commit em que as suas mudanças atuais estão se baseando, para alterar/consertar o commit. Isso poupa-te você de ter que olhar pelos commits da sua branch um por um para ver qual commit deve ser alterado/consertado
Veja a documentação:
| | `` / `` | Pesquisar na visualização atual por texto | | ## Painel de confirmação @@ -267,7 +264,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ |-----|--------|-------------| | `` `` | Confirmar | | | `` `` | Fechar/Cancelar | | -| `` `` | Copy to clipboard | | +| `` `` | Copy to clipboard | | ## Painel principal (mesclagem) @@ -275,10 +272,10 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ |-----|--------|-------------| | `` `` | Escolha o local | | | `` b `` | Pegar todos os pedaços | | -| `` `` | Trecho anterior | | -| `` `` | Próximo trecho | | -| `` `` | Conflito anterior | | -| `` `` | Próximo conflito | | +| `` , k `` | Trecho anterior | | +| `` , j `` | Próximo trecho | | +| `` , h `` | Conflito anterior | | +| `` , l `` | Próximo conflito | | | `` z `` | Desfazer | Desfazer resolução de conflitos de última mesclagem. | | `` e `` | Editar arquivo | Abrir arquivo no editor externo. | | `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | @@ -289,11 +286,11 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Ir para o local anterior | | -| `` `` | Ir para o próximo trecho | | +| `` , h `` | Ir para o local anterior | | +| `` , l `` | Ir para o próximo trecho | | | `` v `` | Toggle range select | | | `` a `` | Toggle hunk selection | Ativa/desativa modo linha por linha vs. modo de seleção por partes. | -| `` `` | Copiar texto selecionado para área de transferência | | +| `` `` | Copiar texto selecionado para área de transferência | | | `` o `` | Abrir arquivo | Abrir arquivo no aplicativo padrão. | | `` e `` | Editar arquivo | Abrir arquivo no editor externo. | | `` `` | Alternar linhas no caminho | | @@ -305,7 +302,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | Verificar | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Abrir commit no navegador | | @@ -313,8 +310,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` N `` | Mover commits para uma nova branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` C `` | Copiar (cherry-pick) | Marcar commit como copiado. Então, dentro da visualização local de commits, você pode pressionar `V` para colar (cherry-pick) o(s) commit(s) copiado(s) em seu branch de check-out. A qualquer momento você pode pressionar `` para cancelar a seleção. | -| `` `` | Reset copied (cherry-picked) commits selection | | -| `` `` | Abrir ferramenta de diff externa (git difftool) | | +| `` `` | Reset copied (cherry-picked) commits selection | | +| `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver commits | | @@ -371,7 +368,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | Verificar | Checkout the selected commit as a detached HEAD. | | `` y `` | Copy commit attribute to clipboard | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Abrir commit no navegador | | @@ -379,8 +376,8 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | `` N `` | Mover commits para uma nova branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` C `` | Copiar (cherry-pick) | Marcar commit como copiado. Então, dentro da visualização local de commits, você pode pressionar `V` para colar (cherry-pick) o(s) commit(s) copiado(s) em seu branch de check-out. A qualquer momento você pode pressionar `` para cancelar a seleção. | -| `` `` | Reset copied (cherry-picked) commits selection | | -| `` `` | Abrir ferramenta de diff externa (git difftool) | | +| `` `` | Reset copied (cherry-picked) commits selection | | +| `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver arquivos | | @@ -391,7 +388,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copiar o nome do submódulo para área de transferência | | +| `` `` | Copiar o nome do submódulo para área de transferência | | | `` `` | Enter | Enter submodule. After entering the submodule, you can press `` to escape back to the parent repo. | | `` d `` | Remover | Remova o submódulo selecionado e o diretório correspondente. | | `` u `` | Atualizar | Atualizar submódulo selecionado. | diff --git a/docs/keybindings/Keybindings_ru.md b/docs/keybindings/Keybindings_ru.md index b4531eb73..c802678b3 100644 --- a/docs/keybindings/Keybindings_ru.md +++ b/docs/keybindings/Keybindings_ru.md @@ -2,15 +2,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct # Lazygit Связки клавиш -_Связки клавиш_ - ## Глобальные сочетания клавиш | Key | Action | Info | |-----|--------|-------------| -| `` `` | Переключиться на последний репозиторий | | -| `` (fn+up/shift+k) `` | Прокрутить вверх главную панель | | -| `` (fn+down/shift+j) `` | Прокрутить вниз главную панель | | +| `` `` | Переключиться на последний репозиторий | | +| `` , K, (fn+up/shift+k) `` | Прокрутить вверх главную панель | | +| `` , J, (fn+down/shift+j) `` | Прокрутить вниз главную панель | | | `` @ `` | Открыть меню журнала команд | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | Отправить изменения | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | | `` p `` | Получить и слить изменения | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | @@ -19,7 +17,7 @@ _Связки клавиш_ | `` } `` | Увеличить размер контекста, отображаемого вокруг изменений в просмотрщике сравнении | 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 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. | -| `` `` | Просмотреть пользовательские параметры патча | | +| `` `` | Просмотреть пользовательские параметры патча | | | `` m `` | Просмотреть параметры слияния/перебазирования | View options to abort/continue/skip the current merge/rebase. | | `` 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`. | | `` + `` | Следующий режим экрана (нормальный/полуэкранный/полноэкранный) | | @@ -27,12 +25,11 @@ _Связки клавиш_ | `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers | | `` `` | Отменить | | | `` ? `` | Открыть меню | | -| `` `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. | -| `` W `` | Открыть меню сравнении | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` `` | Открыть меню сравнении | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` q `` | Выйти | | -| `` `` | Suspend the application | | -| `` `` | Переключить отображение изменении пробелов в просмотрщике сравнении | 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'. | +| `` `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. | +| `` W, `` | Открыть меню сравнении | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | +| `` q, `` | Выйти | | +| `` `` | Suspend the application | | +| `` `` | Переключить отображение изменении пробелов в просмотрщике сравнении | 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'. | | `` z `` | Отменить (через reflog) (экспериментальный) | Журнал ссылок (reflog) будет использоваться для определения того, какую команду git запустить, чтобы отменить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты. | | `` Z `` | Повторить (через reflog) (экспериментальный) | Журнал ссылок (reflog) будет использоваться для определения того, какую команду git нужно запустить, чтобы повторить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты. | @@ -42,11 +39,11 @@ _Связки клавиш_ |-----|--------|-------------| | `` , `` | Предыдущая страница | | | `` . `` | Следующая страница | | -| `` < () `` | Пролистать наверх | | -| `` > () `` | Прокрутить вниз | | +| `` <, `` | Пролистать наверх | | +| `` >, `` | Прокрутить вниз | | | `` v `` | Переключить выборку перетаскивания | | -| `` `` | Range select down | | -| `` `` | Range select up | | +| `` `` | Range select down | | +| `` `` | Range select up | | | `` / `` | Найти | | | `` H `` | Прокрутить влево | | | `` L `` | Прокрутить вправо | | @@ -82,11 +79,11 @@ _Связки клавиш_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Выбрать предыдущую часть | | -| `` `` | Выбрать следующую часть | | +| `` , h `` | Выбрать предыдущую часть | | +| `` , l `` | Выбрать следующую часть | | | `` v `` | Переключить выборку перетаскивания | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | Скопировать выделенный текст в буфер обмена | | +| `` `` | Скопировать выделенный текст в буфер обмена | | | `` `` | Переключить индекс | Переключить строку в проиндексированные / непроиндексированные | | `` d `` | Отменить изменение (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | | `` o `` | Открыть файл | Open file in default application. | @@ -97,15 +94,15 @@ _Связки клавиш_ | `` c `` | Сохранить изменения | Commit staged changes. | | `` w `` | Закоммитить изменения без предварительного хука коммита | | | `` C `` | Сохранить изменения с помощью редактора git | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | +| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | Найти | | ## Главная панель (Обычный) | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | Прокрутить вниз | | -| `` mouse wheel up (fn+down) `` | Прокрутить вверх | | +| `` (fn+up) `` | Прокрутить вниз | | +| `` (fn+down) `` | Прокрутить вверх | | | `` `` | Переключиться на другую панель (проиндексированные/непроиндексированные изменения) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | Найти | | @@ -116,10 +113,10 @@ _Связки клавиш_ |-----|--------|-------------| | `` `` | Выбрать эту часть | | | `` b `` | Выбрать все части | | -| `` `` | Выбрать предыдущую часть | | -| `` `` | Выбрать следующую часть | | -| `` `` | Выбрать предыдущий конфликт | | -| `` `` | Выбрать следующий конфликт | | +| `` , k `` | Выбрать предыдущую часть | | +| `` , j `` | Выбрать следующую часть | | +| `` , h `` | Выбрать предыдущий конфликт | | +| `` , l `` | Выбрать следующий конфликт | | | `` z `` | Отменить | Undo last merge conflict resolution. | | `` e `` | Редактировать файл | Open file in external editor. | | `` o `` | Открыть файл | Open file in default application. | @@ -130,11 +127,11 @@ _Связки клавиш_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Выбрать предыдущую часть | | -| `` `` | Выбрать следующую часть | | +| `` , h `` | Выбрать предыдущую часть | | +| `` , l `` | Выбрать следующую часть | | | `` v `` | Переключить выборку перетаскивания | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | Скопировать выделенный текст в буфер обмена | | +| `` `` | Скопировать выделенный текст в буфер обмена | | | `` o `` | Открыть файл | Open file in default application. | | `` e `` | Редактировать файл | Open file in external editor. | | `` `` | Добавить/удалить строку(и) для патча | | @@ -146,7 +143,7 @@ _Связки клавиш_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | Переключить | Checkout the selected commit as a detached HEAD. | | `` y `` | Скопировать атрибут коммита | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Открыть коммит в браузере | | @@ -154,8 +151,8 @@ _Связки клавиш_ | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Скопировать отобранные коммит (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Сбросить отобранную (скопированную \| cherry-picked) выборку коммитов | | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Сбросить отобранную (скопированную \| cherry-picked) выборку коммитов | | +| `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | @@ -166,8 +163,8 @@ _Связки клавиш_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | Сбросить отобранную (скопированную \| cherry-picked) выборку коммитов | | +| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Сбросить отобранную (скопированную \| cherry-picked) выборку коммитов | | | `` b `` | Просмотреть параметры бинарного поиска | | | `` 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. | @@ -180,15 +177,15 @@ _Связки клавиш_ | `` p `` | Pick | Выбрать коммит (в середине перебазирования) | | `` F `` | Создать fixup коммит | Создать fixup коммит для этого коммита | | `` S `` | Apply fixup commits | Объединить все 'fixup!' коммиты выше в выбранный коммит (автосохранение) | -| `` `` | Переместить коммит вниз на один | | -| `` `` | Переместить коммит вверх на один | | +| `` , `` | Переместить коммит вниз на один | | +| `` , `` | Переместить коммит вверх на один | | | `` V `` | Вставить отобранные коммиты (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. | | `` A `` | Amend | Править последний коммит с проиндексированными изменениями | | `` a `` | Установить/убрать автора коммита | 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 `` | Пометить коммит тегом | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | -| `` `` | Открыть меню журнала | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | +| `` `` | Открыть меню журнала | 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 | | | `` `` | Переключить | Checkout the selected commit as a detached HEAD. | | `` y `` | Скопировать атрибут коммита | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | @@ -197,7 +194,7 @@ _Связки клавиш_ | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Скопировать отобранные коммит (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть файлы выбранного элемента | | @@ -208,7 +205,7 @@ _Связки клавиш_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Скопировать название ветки в буфер обмена | | +| `` `` | Скопировать название ветки в буфер обмена | | | `` i `` | Показать параметры git-flow | | | `` `` | Переключить | Checkout selected item. | | `` n `` | Новая ветка | | @@ -216,7 +213,7 @@ _Связки клавиш_ | `` o `` | Создать запрос на принятие изменений | | | `` O `` | Создать параметры запроса принятие изменений | | | `` G `` | Open pull request in browser | | -| `` `` | Скопировать URL запроса на принятие изменений в буфер обмена | | +| `` `` | Скопировать URL запроса на принятие изменений в буфер обмена | | | `` c `` | Переключить по названию | Checkout by name. In the input box you can enter '-' to switch to the previous branch. | | `` - `` | Checkout previous branch | | | `` F `` | Принудительное переключение | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | @@ -229,7 +226,7 @@ _Связки клавиш_ | `` g `` | Просмотреть параметры сброса | | | `` R `` | Переименовать ветку | | | `` u `` | View upstream options | View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | | `` w `` | View worktree options | | @@ -249,13 +246,13 @@ _Связки клавиш_ |-----|--------|-------------| | `` `` | Подтвердить | | | `` `` | Закрыть/отменить | | -| `` `` | Copy to clipboard | | +| `` `` | Copy to clipboard | | ## Подкоммиты | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | Переключить | Checkout the selected commit as a detached HEAD. | | `` y `` | Скопировать атрибут коммита | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | Открыть коммит в браузере | | @@ -263,8 +260,8 @@ _Связки клавиш_ | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Скопировать отобранные коммит (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Сбросить отобранную (скопированную \| cherry-picked) выборку коммитов | | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Сбросить отобранную (скопированную \| cherry-picked) выборку коммитов | | +| `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть файлы выбранного элемента | | @@ -275,7 +272,7 @@ _Связки клавиш_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Скопировать название подмодуля в буфер обмена | | +| `` `` | Скопировать название подмодуля в буфер обмена | | | `` `` | Enter | Ввести подмодуль | | `` d `` | Remove | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | Обновить подмодуль | @@ -296,13 +293,13 @@ _Связки клавиш_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Скопировать название файла в буфер обмена | | +| `` `` | Скопировать название файла в буфер обмена | | | `` y `` | Copy to clipboard | | | `` c `` | Переключить | Переключить файл | | `` d `` | Просмотреть параметры «отмены изменении» | Отменить изменения коммита в этом файле | | `` o `` | Открыть файл | Open file in default application. | | `` e `` | Edit | Open file in external editor. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` `` | Переключить файлы включённые в патч | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Переключить все файлы, включённые в патч | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Введите файл, чтобы добавить выбранные строки в патч (или свернуть каталог переключения) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | @@ -328,13 +325,13 @@ _Связки клавиш_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy tag to clipboard | | +| `` `` | Copy tag to clipboard | | | `` `` | Переключить | Checkout the selected tag as a detached HEAD. | | `` n `` | Создать тег | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | | `` d `` | Delete | View delete options for local/remote tag. | | `` P `` | Отправить тег | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | | `` w `` | View worktree options | | @@ -344,7 +341,7 @@ _Связки клавиш_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Скопировать название ветки в буфер обмена | | +| `` `` | Скопировать название ветки в буфер обмена | | | `` `` | Переключить | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | Новая ветка | | | `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) | @@ -353,7 +350,7 @@ _Связки клавиш_ | `` u `` | Set as upstream | Установить как upstream-ветку переключённую ветку | | `` s `` | Порядок сортировки | | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | | `` w `` | View worktree options | | @@ -375,15 +372,15 @@ _Связки клавиш_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Скопировать название файла в буфер обмена | | +| `` `` | Скопировать название файла в буфер обмена | | | `` `` | Переключить индекс | Toggle staged for selected file. | -| `` `` | Фильтровать файлы (проиндексированные/непроиндексированные) | | +| `` `` | Фильтровать файлы (проиндексированные/непроиндексированные) | | | `` y `` | Copy to clipboard | | | `` c `` | Сохранить изменения | Commit staged changes. | | `` w `` | Закоммитить изменения без предварительного хука коммита | | | `` A `` | Правка последнего коммита | | | `` C `` | Сохранить изменения с помощью редактора git | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | +| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` e `` | Edit | Open file in external editor. | | `` o `` | Открыть файл | Open file in default application. | | `` i `` | Игнорировать или исключить файл | | @@ -396,7 +393,7 @@ _Связки клавиш_ | `` g `` | Просмотреть параметры сброса upstream-ветки | | | `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Переключить вид дерева файлов | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open external diff tool (git difftool) | | | `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | Получить изменения | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | diff --git a/docs/keybindings/Keybindings_zh-CN.md b/docs/keybindings/Keybindings_zh-CN.md index 0385e486b..9cb7d5186 100644 --- a/docs/keybindings/Keybindings_zh-CN.md +++ b/docs/keybindings/Keybindings_zh-CN.md @@ -2,15 +2,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct # Lazygit 按键绑定 -_图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ - ## 全局键绑定 | Key | Action | Info | |-----|--------|-------------| -| `` `` | 切换到最近的仓库 | | -| `` (fn+up/shift+k) `` | 向上滚动主面板 | | -| `` (fn+down/shift+j) `` | 向下滚动主面板 | | +| `` `` | 切换到最近的仓库 | | +| `` , K, (fn+up/shift+k) `` | 向上滚动主面板 | | +| `` , J, (fn+down/shift+j) `` | 向下滚动主面板 | | | `` @ `` | 打开命令日志菜单 | 查看命令日志的选项,例如显示/隐藏命令日志以及聚焦命令日志 | | `` P `` | 推送 | 推送当前分支到它的上游。如果上游未配置,您可以在弹窗中配置上游分支。 | | `` p `` | 拉取 | 从当前分支的远程分支获取改动。如果上游未配置,您可以在弹窗中配置上游分支。 | @@ -19,7 +17,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` } `` | 扩大差异视图中显示的上下文范围 | 增加差异视图中变更周围显示的上下文量。

默认值可在配置文件中通过键 'git.diffContextSize' 更改。 | | `` { `` | 缩小差异视图中显示的上下文范围 | 减少差异视图中变更周围显示的上下文量。

默认值可在配置文件中通过键 'git.diffContextSize' 更改。 | | `` : `` | 执行 Shell 命令 | 调出可输入shell命令执行的提示符。 | -| `` `` | 查看自定义补丁选项 | | +| `` `` | 查看自定义补丁选项 | | | `` m `` | 查看合并/变基选项 | 查看当前合并或变基的中止、继续、跳过选项 | | `` R `` | 刷新 | 刷新Git状态(即在后台运行`git status`、`git branch`等命令以更新面板内容)。此操作不会执行`git fetch`。 | | `` + `` | 下一屏模式(正常/半屏/全屏) | | @@ -27,12 +25,11 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` \| `` | 切换分页器 | 从已配置的分页器列表中选择下一个分页器 | | `` `` | 取消 | | | `` ? `` | 打开菜单 | | -| `` `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 | -| `` W `` | 打开 diff 菜单 | 查看与比较两个引用相关的选项,例如与选定的 ref 进行比较,输入要比较的 ref,然后反转比较方向。 | -| `` `` | 打开 diff 菜单 | 查看与比较两个引用相关的选项,例如与选定的 ref 进行比较,输入要比较的 ref,然后反转比较方向。 | -| `` q `` | 退出 | | -| `` `` | 挂起应用程序 | | -| `` `` | 切换是否在差异视图中显示空白字符差异 | 切换是否在差异视图中显示空白字符更改。

默认值可在配置文件中通过键 'git.ignoreWhitespaceInDiffView' 更改。 | +| `` `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 | +| `` W, `` | 打开 diff 菜单 | 查看与比较两个引用相关的选项,例如与选定的 ref 进行比较,输入要比较的 ref,然后反转比较方向。 | +| `` q, `` | 退出 | | +| `` `` | 挂起应用程序 | | +| `` `` | 切换是否在差异视图中显示空白字符差异 | 切换是否在差异视图中显示空白字符更改。

默认值可在配置文件中通过键 'git.ignoreWhitespaceInDiffView' 更改。 | | `` z `` | 撤销 | Reflog将用于确定运行哪个git命令来撤消最后一个git命令。这并不包括对工作树的更改,只考虑提交。 | | `` Z `` | 重做 | Reflog将用于确定运行哪个git命令来重做上一个git命令。这并不包括对工作树的更改,只考虑提交。 | @@ -42,11 +39,11 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ |-----|--------|-------------| | `` , `` | 上一页 | | | `` . `` | 下一页 | | -| `` < () `` | 滚动到顶部 | | -| `` > () `` | 滚动到底部 | | +| `` <, `` | 滚动到顶部 | | +| `` >, `` | 滚动到底部 | | | `` v `` | 切换拖动选择 | | -| `` `` | 向下扩展选择范围 | | -| `` `` | 向上扩展选择范围 | | +| `` `` | 向下扩展选择范围 | | +| `` `` | 向上扩展选择范围 | | | `` / `` | 开始搜索 | | | `` H `` | 向左滚动 | | | `` L `` | 向右滚动 | | @@ -57,7 +54,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | 复制缩略提交哈希值到剪贴板 | | | `` `` | 检出 | 检出所选择的提交作为分离HEAD。 | | `` y `` | 复制提交属性到剪贴板 | 复制提交属性到剪贴板(如hash、URL、diff、消息、作者)。 | | `` o `` | 在浏览器中打开提交 | | @@ -65,8 +62,8 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。

请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` C `` | 复制提交(拣选) | 标记提交为已复制。然后,在本地提交视图中,您可以按 `V` (Cherry-Pick) 将已复制的提交粘贴到已检出的分支中。任何时候都可以按 `` 来取消选择。 | -| `` `` | 重置已拣选(复制)的提交 | | -| `` `` | 使用外部差异比较工具(git difftool) | | +| `` `` | 重置已拣选(复制)的提交 | | +| `` `` | 使用外部差异比较工具(git difftool) | | | `` * `` | 选择当前分支的提交 | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交的文件 | | @@ -77,7 +74,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 复制子模块名称到剪贴板 | | +| `` `` | 复制子模块名称到剪贴板 | | | `` `` | 进入 | 输入子模块 | | `` d `` | 删除 | 删除选定的子模块及其相应的目录 | | `` u `` | 更新 | 更新子模块 | @@ -101,7 +98,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | 复制缩略提交哈希值到剪贴板 | | | `` `` | 检出 | 检出所选择的提交作为分离HEAD。 | | `` y `` | 复制提交属性到剪贴板 | 复制提交属性到剪贴板(如hash、URL、diff、消息、作者)。 | | `` o `` | 在浏览器中打开提交 | | @@ -109,8 +106,8 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。

请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` C `` | 复制提交(拣选) | 标记提交为已复制。然后,在本地提交视图中,您可以按 `V` (Cherry-Pick) 将已复制的提交粘贴到已检出的分支中。任何时候都可以按 `` 来取消选择。 | -| `` `` | 重置已拣选(复制)的提交 | | -| `` `` | 使用外部差异比较工具(git difftool) | | +| `` `` | 重置已拣选(复制)的提交 | | +| `` `` | 使用外部差异比较工具(git difftool) | | | `` * `` | 选择当前分支的提交 | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交 | | @@ -121,12 +118,12 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | 重置已拣选(复制)的提交 | | +| `` `` | 复制缩略提交哈希值到剪贴板 | | +| `` `` | 重置已拣选(复制)的提交 | | | `` b `` | 查看二分查找选项 | | | `` s `` | 压缩(Squash) | 将已选提交压缩到该提交之下。这些选定的提交的消息会附加到该提交的消息之下。 | | `` f `` | 修正 (fixup) | 将选定的提交合并到其下面的提交中。与压缩类似,但所选提交的消息将被丢弃。 | -| `` 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. | +| `` c `` | 设置修复提交信息 | 设置修复提交的信息选项。-C 选项表示使用此提交的信息,而非目标提交的信息。 | | `` r `` | 改写提交 | 重写所选提交的消息。 | | `` R `` | 使用编辑器重命名提交 | | | `` d `` | 删除提交 | 删除选中的提交。这将通过变基从分支中删除该提交,如果该提交修改的内容依赖于后续的提交,则需要解决合并冲突。 | @@ -135,16 +132,16 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` p `` | 拣选(Pick) | 标记选中的提交为 picked(变基过程中)。这意味该提交将在后续的变基中保留。 | | `` F `` | 为此提交创建修正 | 创建修正提交 | | `` S `` | 应用该修复提交 | 压缩所选提交之上或当前分支的所有 “fixup!” 提交(自动压缩)。 | -| `` `` | 下移提交 | | -| `` `` | 上移提交 | | +| `` , `` | 下移提交 | | +| `` , `` | 上移提交 | | | `` V `` | 粘贴提交(拣选) | | | `` B `` | 标记一个主提交用于变基 | 选择下一次变基的主提交。当您变基到一个分支时,只有高于主提交的提交才会被引入。这使用“git rebase --onto”命令。 | | `` A `` | 修补(Amend) | 用已暂存的变更来修补提交 | | `` a `` | 修补提交属性 | 设置或重置提交的作者,或添加其他作者。 | | `` t `` | 撤销(Revert) | 为所选提交创建还原提交,这会反向应用所选提交的更改。 | | `` T `` | 标签提交 | 创建一个新标签指向所选提交。您可以在弹窗中输入标签名称和描述(可选)。 | -| `` `` | 打开日志菜单 | 查看提交日志的选项,例如更改排序顺序、隐藏 git graph、显示整个 git graph。 | -| `` G `` | Open pull request in browser | | +| `` `` | 打开日志菜单 | 查看提交日志的选项,例如更改排序顺序、隐藏 git graph、显示整个 git graph。 | +| `` G `` | 在浏览器中打开拉取请求 | | | `` `` | 检出 | 检出所选择的提交作为分离HEAD。 | | `` y `` | 复制提交属性到剪贴板 | 复制提交属性到剪贴板(如hash、URL、diff、消息、作者)。 | | `` o `` | 在浏览器中打开提交 | | @@ -152,7 +149,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。

请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` C `` | 复制提交(拣选) | 标记提交为已复制。然后,在本地提交视图中,您可以按 `V` (Cherry-Pick) 将已复制的提交粘贴到已检出的分支中。任何时候都可以按 `` 来取消选择。 | -| `` `` | 使用外部差异比较工具(git difftool) | | +| `` `` | 使用外部差异比较工具(git difftool) | | | `` * `` | 选择当前分支的提交 | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交的文件 | | @@ -170,13 +167,13 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 复制路径到剪贴板 | | +| `` `` | 复制路径到剪贴板 | | | `` y `` | 复制到剪贴板 | | | `` c `` | 检出 | 检出文件 | | `` d `` | 查看'放弃变更'选项 | 放弃对此文件的提交变更 | | `` o `` | 打开文件 | 使用默认程序打开该文件 | | `` e `` | 编辑(Edit) | 使用外部编辑器打开文件 | -| `` `` | 使用外部差异比较工具(git difftool) | | +| `` `` | 使用外部差异比较工具(git difftool) | | | `` `` | 补丁中包含的切换文件 | 切换文件是否包含在自定义补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 | | `` a `` | 操作所有文件 | 添加或删除所有提交中的文件到自定义的补丁中。请参阅 https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches。 | | `` `` | 输入文件以将所选行添加到补丁中(或切换目录折叠) | 如果已选择一个文件,则Enter进入该文件,以便您可以向自定义补丁添加/删除单独的行。如果选择了目录,则切换目录。 | @@ -190,15 +187,15 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 复制路径到剪贴板 | | +| `` `` | 复制路径到剪贴板 | | | `` `` | 切换暂存状态 | 为选定的文件切换暂存状态 | -| `` `` | 通过状态过滤文件 | | +| `` `` | 通过状态过滤文件 | | | `` y `` | 复制到剪贴板 | | | `` c `` | 提交变更 | 提交暂存文件 | | `` w `` | 提交变更而无需预先提交钩子 | | | `` A `` | 修补最后一次提交 | | | `` C `` | 使用 Git 编辑器提交变更 | | -| `` `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: | +| `` `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: | | `` e `` | 编辑(Edit) | 使用外部编辑器打开文件 | | `` o `` | 打开文件 | 使用默认程序打开该文件 | | `` i `` | 忽略文件 | | @@ -211,7 +208,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` g `` | 查看上游重置选项 | | | `` D `` | 重置 | 查看工作树的重置选项(例如:清除工作树)。 | | `` ` `` | 切换文件树视图 | 在平面布局和树布局之间切换文件视图。平面布局在单个列表中显示所有文件路径,树布局按目录分组文件。

可以在配置文件中使用 'gui.showFileTree' 键更改默认设置。 | -| `` `` | 使用外部差异比较工具(git difftool) | | +| `` `` | 使用外部差异比较工具(git difftool) | | | `` M `` | 查看合并冲突选项 | 查看用于解决合并冲突的选项。 | | `` f `` | 抓取 | 从远程获取变更 | | `` - `` | 折叠全部文件 | 折叠文件树中的全部目录 | @@ -223,15 +220,15 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 复制分支名称到剪贴板 | | +| `` `` | 复制分支名称到剪贴板 | | | `` i `` | 显示 git-flow 选项 | | | `` `` | 检出 | 检出选中的项目 | | `` n `` | 新分支 | | | `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。

请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 | | `` o `` | 创建拉取请求 | | | `` O `` | 创建拉取请求选项 | | -| `` G `` | Open pull request in browser | | -| `` `` | 复制拉取请求 URL 到剪贴板 | | +| `` G `` | 在浏览器中打开拉取请求 | | +| `` `` | 复制拉取请求 URL 到剪贴板 | | | `` c `` | 按名称检出 | 按名称检出。在输入框中,您可以输入'-' 来切换到最后一个分支。 | | `` - `` | 签出上一个分支 | | | `` F `` | 强制检出 | 强制检出所选分支。这将在检出所选分支之前放弃工作目录中的所有本地更改。 | @@ -244,7 +241,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` g `` | 查看重置选项 | | | `` R `` | 重命名分支 | | | `` u `` | 查看上游选项 | 查看与分支上游相关的选项,例如设置/取消设置上游和重置为上游。 | -| `` `` | 使用外部差异比较工具(git difftool) | | +| `` `` | 使用外部差异比较工具(git difftool) | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交 | | | `` w `` | 查看工作区选项 | | @@ -254,15 +251,15 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 选择上一个区块 | | -| `` `` | 选择下一个区块 | | +| `` , h `` | 选择上一个区块 | | +| `` , l `` | 选择下一个区块 | | | `` v `` | 切换拖动选择 | | | `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | -| `` `` | 复制选中文本到剪贴板 | | +| `` `` | 复制选中文本到剪贴板 | | | `` o `` | 打开文件 | 使用默认程序打开该文件 | | `` e `` | 编辑文件 | 使用外部编辑器打开文件 | | `` `` | 添加/移除 行到补丁 | | -| `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. | +| `` d `` | 从提交中移除行 | 从本次提交中移除所选行。此操作会在后台运行交互式变基,因此如果后续提交也修改了这些行,您可能会遇到合并冲突。 | | `` `` | 退出逐行模式 | | | `` / `` | 开始搜索 | | @@ -270,13 +267,13 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 复制标签到剪贴板 | | +| `` `` | 复制标签到剪贴板 | | | `` `` | 检出 | 检出选择的标签作为分离的HEAD | | `` n `` | 创建标签 | 基于当前提交创建一个新标签。您将在弹窗中输入标签名称和描述(可选)。 | | `` d `` | 删除 | 查看本地/远程标签的删除选项 | | `` P `` | 推送标签 | 推送选择的标签到远端。您将在弹窗中选择一个远端。 | | `` g `` | 重置 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | -| `` `` | 使用外部差异比较工具(git difftool) | | +| `` `` | 使用外部差异比较工具(git difftool) | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交 | | | `` w `` | 查看工作区选项 | | @@ -296,10 +293,10 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ |-----|--------|-------------| | `` `` | 选中区块 | | | `` b `` | 选中所有区块 | | -| `` `` | 选择顶部块 | | -| `` `` | 选择底部块 | | -| `` `` | 选择上一个冲突 | | -| `` `` | 选择下一个冲突 | | +| `` , k `` | 选择顶部块 | | +| `` , j `` | 选择底部块 | | +| `` , h `` | 选择上一个冲突 | | +| `` , l `` | 选择下一个冲突 | | | `` z `` | 撤销 | 撤消上次合并冲突解决 | | `` e `` | 编辑文件 | 使用外部编辑器打开文件 | | `` o `` | 打开文件 | 使用默认程序打开该文件 | @@ -310,11 +307,11 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 选择上一个区块 | | -| `` `` | 选择下一个区块 | | +| `` , h `` | 选择上一个区块 | | +| `` , l `` | 选择下一个区块 | | | `` v `` | 切换拖动选择 | | | `` a `` | 切换代码块选择 | 切换逐行选择与代码块选择模式。 | -| `` `` | 复制选中文本到剪贴板 | | +| `` `` | 复制选中文本到剪贴板 | | | `` `` | 切换暂存状态 | 切换行暂存状态 | | `` d `` | 取消变更(git reset) | 当选择未暂存的变更时,使用git reset丢弃该变更。当选择已暂存的变更时,取消暂存该变更 | | `` o `` | 打开文件 | 使用默认程序打开该文件 | @@ -325,15 +322,15 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` c `` | 提交变更 | 提交暂存文件 | | `` w `` | 提交变更而无需预先提交钩子 | | | `` C `` | 使用 Git 编辑器提交变更 | | -| `` `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: | +| `` `` | 找到用于修复的基准提交 | 找到您当前变更所基于的提交,以便于修正/改进该提交。这样做可以省去您逐一查看分支提交来确定应该修正/改进哪个提交的麻烦。请参阅文档: | | `` / `` | 开始搜索 | | ## 正常 | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | 向下滚动 | | -| `` mouse wheel up (fn+down) `` | 向上滚动 | | +| `` (fn+up) `` | 向下滚动 | | +| `` (fn+down) `` | 向上滚动 | | | `` `` | 切换到其他面板 | 切换到其他视图(已暂存/未暂存的变更) | | `` `` | 退出回到侧边面板 | | | `` / `` | 开始搜索 | | @@ -347,7 +344,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` u `` | 检查更新 | | | `` `` | 切换到最近的仓库 | | | `` a `` | 显示/循环所有分支日志 | | -| `` A `` | Show/cycle all branch logs (reverse) | | +| `` A `` | 显示/循环所有分支日志(反向) | | | `` 0 `` | 聚焦主视图 | | ## 确认面板 @@ -356,7 +353,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ |-----|--------|-------------| | `` `` | 确认 | | | `` `` | 关闭 | | -| `` `` | 复制到剪贴板 | | +| `` `` | 复制到剪贴板 | | ## 菜单 @@ -403,7 +400,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | Key | Action | Info | |-----|--------|-------------| -| `` `` | 复制分支名称到剪贴板 | | +| `` `` | 复制分支名称到剪贴板 | | | `` `` | 检出 | 基于当前选中的远程分支检出一个新的本地分支,或者将远程分支作分离的HEAD。 | | `` n `` | 新分支 | | | `` M `` | 合并到当前检出的分支 | 查看将选中项合并到当前分支的选项(正常合并,压缩合并) | @@ -412,7 +409,7 @@ _图例:`` 意味着ctrl+b, `意味着Alt+b, `B` 意味着shift+b_ | `` u `` | 设置为上游 | 设置为检出分支的上游 | | `` s `` | 排序 | | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | -| `` `` | 使用外部差异比较工具(git difftool) | | +| `` `` | 使用外部差异比较工具(git difftool) | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交 | | | `` w `` | 查看工作区选项 | | diff --git a/docs/keybindings/Keybindings_zh-TW.md b/docs/keybindings/Keybindings_zh-TW.md index c0579e0ce..d6526b5b2 100644 --- a/docs/keybindings/Keybindings_zh-TW.md +++ b/docs/keybindings/Keybindings_zh-TW.md @@ -2,15 +2,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct # Lazygit 鍵盤快捷鍵 -_說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B_ - ## 全域快捷鍵 | Key | Action | Info | |-----|--------|-------------| -| `` `` | 切換到最近使用的版本庫 | | -| `` (fn+up/shift+k) `` | 向上捲動主面板 | | -| `` (fn+down/shift+j) `` | 向下捲動主面板 | | +| `` `` | 切換到最近使用的版本庫 | | +| `` , K, (fn+up/shift+k) `` | 向上捲動主面板 | | +| `` , J, (fn+down/shift+j) `` | 向下捲動主面板 | | | `` @ `` | 開啟命令記錄選單 | View options for the command log e.g. show/hide the command log and focus the command log. | | `` P `` | 推送 | 推送到遠端。如果沒有設定遠端,會開啟設定視窗。 | | `` p `` | 拉取 | 從遠端同步當前分支。如果沒有設定遠端,會開啟設定視窗。 | @@ -19,7 +17,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` } `` | 增加差異檢視中顯示變更周圍上下文的大小 | 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 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. | -| `` `` | 檢視自訂補丁選項 | | +| `` `` | 檢視自訂補丁選項 | | | `` m `` | 查看合併/變基選項 | View options to abort/continue/skip the current merge/rebase. | | `` 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`. | | `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | | @@ -27,12 +25,11 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` \| `` | Cycle pagers | Choose the next pager in the list of configured pagers | | `` `` | 取消 | | | `` ? `` | 開啟選單 | | -| `` `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. | -| `` W `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | -| `` q `` | 結束 | | -| `` `` | Suspend the application | | -| `` `` | 切換是否在差異檢視中顯示空格變更 | 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'. | +| `` `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. | +| `` W, `` | 開啟差異比較選單 | View options relating to diffing two refs e.g. diffing against selected ref, entering ref to diff against, and reversing the diff direction. | +| `` q, `` | 結束 | | +| `` `` | Suspend the application | | +| `` `` | 切換是否在差異檢視中顯示空格變更 | 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'. | | `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 | | `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 | @@ -42,11 +39,11 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B |-----|--------|-------------| | `` , `` | 上一頁 | | | `` . `` | 下一頁 | | -| `` < () `` | 捲動到頂部 | | -| `` > () `` | 捲動到底部 | | +| `` <, `` | 捲動到頂部 | | +| `` >, `` | 捲動到底部 | | | `` v `` | 切換拖曳選擇 | | -| `` `` | Range select down | | -| `` `` | Range select up | | +| `` `` | Range select down | | +| `` `` | Range select up | | | `` / `` | 搜尋 | | | `` H `` | 向左捲動 | | | `` L `` | 向右捲動 | | @@ -64,11 +61,11 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | Key | Action | Info | |-----|--------|-------------| -| `` `` | 選擇上一段 | | -| `` `` | 選擇下一段 | | +| `` , h `` | 選擇上一段 | | +| `` , l `` | 選擇下一段 | | | `` v `` | 切換拖曳選擇 | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | 複製所選文本至剪貼簿 | | +| `` `` | 複製所選文本至剪貼簿 | | | `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` `` | 向 (或從) 補丁中添加/刪除行 | | @@ -80,8 +77,8 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | Key | Action | Info | |-----|--------|-------------| -| `` mouse wheel down (fn+up) `` | 向下捲動 | | -| `` mouse wheel up (fn+down) `` | 向上捲動 | | +| `` (fn+up) `` | 向下捲動 | | +| `` (fn+down) `` | 向上捲動 | | | `` `` | 切換至另一個面板 (已預存/未預存更改) | Switch to other view (staged/unstaged changes). | | `` `` | Exit back to side panel | | | `` / `` | 搜尋 | | @@ -92,10 +89,10 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B |-----|--------|-------------| | `` `` | 挑選程式碼片段 | | | `` b `` | 挑選所有程式碼片段 | | -| `` `` | 選擇上一段 | | -| `` `` | 選擇下一段 | | -| `` `` | 選擇上一個衝突 | | -| `` `` | 選擇下一個衝突 | | +| `` , k `` | 選擇上一段 | | +| `` , j `` | 選擇下一段 | | +| `` , h `` | 選擇上一個衝突 | | +| `` , l `` | 選擇下一個衝突 | | | `` z `` | 復原 | Undo last merge conflict resolution. | | `` e `` | 編輯檔案 | 使用外部編輯器開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 | @@ -106,11 +103,11 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | Key | Action | Info | |-----|--------|-------------| -| `` `` | 選擇上一段 | | -| `` `` | 選擇下一段 | | +| `` , h `` | 選擇上一段 | | +| `` , l `` | 選擇下一段 | | | `` v `` | 切換拖曳選擇 | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | -| `` `` | 複製所選文本至剪貼簿 | | +| `` `` | 複製所選文本至剪貼簿 | | | `` `` | 切換預存 | 切換現有行的狀態 (已預存/未預存) | | `` d `` | 刪除變更 (git reset) | When unstaged change is selected, discard the change using `git reset`. When staged change is selected, unstage the change. | | `` o `` | 開啟檔案 | 使用預設軟體開啟 | @@ -121,7 +118,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` c `` | 提交變更 | 提交暫存區變更 | | `` w `` | 沒有預提交 hook 就提交更改 | | | `` C `` | 使用 git 編輯器提交變更 | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | +| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` / `` | 搜尋 | | ## 功能表 @@ -136,7 +133,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | 檢出 | Checkout the selected commit as a detached HEAD. | | `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | 在瀏覽器中開啟提交 | | @@ -144,8 +141,8 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | 重設選定的揀選 (複製) 提交 | | -| `` `` | 開啟外部差異工具 (git difftool) | | +| `` `` | 重設選定的揀選 (複製) 提交 | | +| `` `` | 開啟外部差異工具 (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 檢視所選項目的檔案 | | @@ -156,7 +153,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | Key | Action | Info | |-----|--------|-------------| -| `` `` | 複製子模組名稱到剪貼簿 | | +| `` `` | 複製子模組名稱到剪貼簿 | | | `` `` | Enter | 進入子模組 | | `` d `` | Remove | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | 更新子模組 | @@ -180,8 +177,8 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | -| `` `` | 重設選定的揀選 (複製) 提交 | | +| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | 重設選定的揀選 (複製) 提交 | | | `` b `` | 查看二分選項 | | | `` 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. | @@ -194,15 +191,15 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` p `` | 挑選 | 挑選提交 (於變基過程中) | | `` F `` | 建立修復提交 | 為此提交建立修復提交 | | `` S `` | 壓縮上方所有「fixup」提交(自動壓縮) | 是否壓縮上方 {{.commit}} 所有「fixup」提交? | -| `` `` | 向下移動提交 | | -| `` `` | 向上移動提交 | | +| `` , `` | 向下移動提交 | | +| `` , `` | 向上移動提交 | | | `` V `` | 貼上提交 (揀選) | | | `` B `` | 為了變基已標注提交為基準提交 | 請為了下一次變基選擇一項基準提交;此將執行 `git rebase --onto`。 | | `` A `` | 修改 | 使用已預存的更改修正提交 | | `` a `` | 設定/重設提交作者 | Set/Reset commit author or set co-author. | | `` t `` | 還原 | Create a revert commit for the selected commit, which applies the selected commit's changes in reverse. | | `` T `` | 打標籤到提交 | Create a new tag pointing at the selected commit. You'll be prompted to enter a tag name and optional description. | -| `` `` | 開啟記錄選單 | View options for commit log e.g. changing sort order, hiding the git graph, showing the whole git graph. | +| `` `` | 開啟記錄選單 | 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 | | | `` `` | 檢出 | Checkout the selected commit as a detached HEAD. | | `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | @@ -211,7 +208,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | 開啟外部差異工具 (git difftool) | | +| `` `` | 開啟外部差異工具 (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 檢視所選項目的檔案 | | @@ -229,13 +226,13 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | Key | Action | Info | |-----|--------|-------------| -| `` `` | 複製檔案名稱到剪貼簿 | | +| `` `` | 複製檔案名稱到剪貼簿 | | | `` y `` | 複製到剪貼簿 | | | `` c `` | 檢出 | 檢出檔案 | | `` d `` | 捨棄 | Discard this commit's changes to this file. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes this file. | | `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` e `` | 編輯 | 使用外部編輯器開啟 | -| `` `` | 開啟外部差異工具 (git difftool) | | +| `` `` | 開啟外部差異工具 (git difftool) | | | `` `` | 切換檔案是否包含在補丁中 | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | 切換所有檔案是否包含在補丁中 | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | 輸入檔案以將選定的行添加至補丁(或切換目錄折疊) | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | @@ -263,7 +260,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy abbreviated commit hash to clipboard | | +| `` `` | Copy abbreviated commit hash to clipboard | | | `` `` | 檢出 | Checkout the selected commit as a detached HEAD. | | `` y `` | 複製提交屬性 | Copy commit attribute to clipboard (e.g. hash, URL, diff, message, author). | | `` o `` | 在瀏覽器中開啟提交 | | @@ -271,8 +268,8 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | 重設選定的揀選 (複製) 提交 | | -| `` `` | 開啟外部差異工具 (git difftool) | | +| `` `` | 重設選定的揀選 (複製) 提交 | | +| `` `` | 開啟外部差異工具 (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | @@ -283,7 +280,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | Key | Action | Info | |-----|--------|-------------| -| `` `` | 複製分支名稱到剪貼簿 | | +| `` `` | 複製分支名稱到剪貼簿 | | | `` i `` | 顯示 git-flow 選項 | | | `` `` | 檢出 | 檢出選定的項目。 | | `` n `` | 新分支 | | @@ -291,7 +288,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` o `` | 建立拉取請求 | | | `` O `` | 建立拉取請求選項 | | | `` G `` | Open pull request in browser | | -| `` `` | 複製拉取請求的 URL 到剪貼板 | | +| `` `` | 複製拉取請求的 URL 到剪貼板 | | | `` c `` | 根據名稱檢出 | Checkout by name. In the input box you can enter '-' to switch to the previous branch. | | `` - `` | Checkout previous branch | | | `` F `` | 強制檢出 | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | @@ -304,7 +301,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` g `` | 檢視重設選項 | | | `` R `` | 重新命名分支 | | | `` u `` | 檢視遠端設定 | 檢視有關遠端分支的設定(例如重設至遠端) | -| `` `` | 開啟外部差異工具 (git difftool) | | +| `` `` | 開啟外部差異工具 (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | | `` w `` | 檢視工作目錄選項 | | @@ -314,13 +311,13 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | Key | Action | Info | |-----|--------|-------------| -| `` `` | Copy tag to clipboard | | +| `` `` | Copy tag to clipboard | | | `` `` | 檢出 | Checkout the selected tag as a detached HEAD. | | `` n `` | 建立標籤 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | | `` d `` | 刪除 | View delete options for local/remote tag. | | `` P `` | 推送標籤 | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | 重設 | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | 開啟外部差異工具 (git difftool) | | +| `` `` | 開啟外部差異工具 (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | | `` w `` | 檢視工作目錄選項 | | @@ -330,15 +327,15 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | Key | Action | Info | |-----|--------|-------------| -| `` `` | 複製檔案名稱到剪貼簿 | | +| `` `` | 複製檔案名稱到剪貼簿 | | | `` `` | 切換預存 | Toggle staged for selected file. | -| `` `` | 篩選檔案 (預存/未預存) | | +| `` `` | 篩選檔案 (預存/未預存) | | | `` y `` | 複製到剪貼簿 | | | `` c `` | 提交變更 | 提交暫存區變更 | | `` w `` | 沒有預提交 hook 就提交更改 | | | `` A `` | 修改上次提交 | | | `` C `` | 使用 git 編輯器提交變更 | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | +| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | | `` e `` | 編輯 | 使用外部編輯器開啟 | | `` o `` | 開啟檔案 | 使用預設軟體開啟 | | `` i `` | 忽略或排除檔案 | | @@ -351,7 +348,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` g `` | 檢視遠端重設選項 | | | `` D `` | 重設 | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | 顯示檔案樹狀視圖 | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | -| `` `` | 開啟外部差異工具 (git difftool) | | +| `` `` | 開啟外部差異工具 (git difftool) | | | `` M `` | View merge conflict options | View options for resolving merge conflicts. | | `` f `` | 擷取 | 同步遠端異動 | | `` - `` | Collapse all files | Collapse all directories in the files tree | @@ -385,7 +382,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B |-----|--------|-------------| | `` `` | 確認 | | | `` `` | 關閉/取消 | | -| `` `` | 複製到剪貼簿 | | +| `` `` | 複製到剪貼簿 | | ## 遠端 @@ -403,7 +400,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | Key | Action | Info | |-----|--------|-------------| -| `` `` | 複製分支名稱到剪貼簿 | | +| `` `` | 複製分支名稱到剪貼簿 | | | `` `` | 檢出 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | 新分支 | | | `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) | @@ -412,7 +409,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B | `` u `` | 設置為遠端 | 將此分支設為當前分支之遠端 | | `` s `` | 排序規則 | | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | 開啟外部差異工具 (git difftool) | | +| `` `` | 開啟外部差異工具 (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | | `` w `` | 檢視工作目錄選項 | | diff --git a/schema/config.json b/schema/config.json index c4312981f..2e968ba8f 100644 --- a/schema/config.json +++ b/schema/config.json @@ -60,8 +60,18 @@ "CustomCommand": { "properties": { "key": { - "type": "string", - "description": "The key to trigger the command. Use a single letter or one of the values from https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "The key to trigger the command. Use a single letter or one of the values from https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md. To bind several alternates to the same command, use a sequence (e.g. `[a, b]`)." }, "commandMenu": { "items": { @@ -165,8 +175,18 @@ ] }, "key": { - "type": "string", - "description": "Keybinding to invoke this menu option without needing to navigate to it" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Keybinding to invoke this menu option without needing to navigate to it. Accepts either a single key or a sequence of alternates." } }, "additionalProperties": false, @@ -375,7 +395,7 @@ }, "ignoreWhitespaceInDiffView": { "type": "boolean", - "description": "If true, git diffs are rendered with the `--ignore-all-space` flag, which ignores whitespace changes. Can be toggled from within Lazygit with `\u003cc-w\u003e`.", + "description": "If true, git diffs are rendered with the `--ignore-all-space` flag, which ignores whitespace changes. Can be toggled from within Lazygit with `\u003cctrl+w\u003e`.", "default": false }, "diffContextSize": { @@ -843,15 +863,45 @@ "KeybindingAmendAttributeConfig": { "properties": { "resetAuthor": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "a" }, "setAuthor": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "A" }, "addCoAuthor": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "c" } }, @@ -861,79 +911,269 @@ "KeybindingBranchesConfig": { "properties": { "createPullRequest": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "o" }, "viewPullRequestOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "O" }, "openPullRequestInBrowser": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "G" }, "copyPullRequestURL": { - "type": "string", - "default": "\u003cc-y\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+y\u003e" }, "checkoutBranchByName": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "c" }, "forceCheckoutBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "F" }, "checkoutPreviousBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "-" }, "rebaseBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "r" }, "renameBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "R" }, "mergeIntoCurrentBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "M" }, "moveCommitsToNewBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "N" }, "viewGitFlowOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "i" }, "fastForward": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "f" }, "createTag": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "T" }, "pushTag": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "P" }, "setUpstream": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "u" }, "fetchRemote": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "f" }, "addForkRemote": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "F" }, "sortOrder": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "s" } }, @@ -943,7 +1183,17 @@ "KeybindingCommitFilesConfig": { "properties": { "checkoutCommitFile": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "c" } }, @@ -953,8 +1203,18 @@ "KeybindingCommitMessageConfig": { "properties": { "commitMenu": { - "type": "string", - "default": "\u003cc-o\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+o\u003e" } }, "additionalProperties": false, @@ -963,111 +1223,387 @@ "KeybindingCommitsConfig": { "properties": { "squashDown": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "s" }, "renameCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "r" }, "renameCommitWithEditor": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "R" }, "viewResetOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "g" }, "markCommitAsFixup": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "f" }, "setFixupMessage": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "c" }, "createFixupCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "F" }, "squashAboveCommits": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "S" }, "moveDownCommit": { - "type": "string", - "default": "\u003cc-j\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003cctrl+j\u003e", + "\u003calt-down\u003e" + ] }, "moveUpCommit": { - "type": "string", - "default": "\u003cc-k\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003cctrl+k\u003e", + "\u003calt-up\u003e" + ] }, "amendToCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "A" }, "resetCommitAuthor": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "a" }, "pickCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "p" }, "revertCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "t" }, "cherryPickCopy": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "C" }, "pasteCommits": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "V" }, "markCommitAsBaseForRebase": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "B" }, "tagCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "T" }, "checkoutCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cspace\u003e" }, "resetCherryPick": { - "type": "string", - "default": "\u003cc-R\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+r\u003e" }, "copyCommitAttributeToClipboard": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "y" }, "openLogMenu": { - "type": "string", - "default": "\u003cc-l\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+l\u003e" }, "openInBrowser": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "o" }, "openPullRequestInBrowser": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "G" }, "viewBisectOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "b" }, "startInteractiveRebase": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "i" }, "selectCommitsOfCurrentBranch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "*" } }, @@ -1115,84 +1651,274 @@ }, "additionalProperties": false, "type": "object", - "description": "Keybindings" + "description": "Keybindings.\nEach binding can be a single key or a list of keys; see https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md for the syntax." }, "KeybindingFilesConfig": { "properties": { "commitChanges": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "c" }, "commitChangesWithoutHook": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "w" }, "amendLastCommit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "A" }, "commitChangesWithEditor": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "C" }, "findBaseCommitForFixup": { - "type": "string", - "default": "\u003cc-f\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+f\u003e" }, "confirmDiscard": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "x" }, "ignoreFile": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "i" }, "refreshFiles": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "r" }, "stashAllChanges": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "s" }, "viewStashOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "S" }, "toggleStagedAll": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "a" }, "viewResetOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "D" }, "fetch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "f" }, "toggleTreeView": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "`" }, "openMergeOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "M" }, "openStatusFilter": { - "type": "string", - "default": "\u003cc-b\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+b\u003e" }, "copyFileInfoToClipboard": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "y" }, "collapseAll": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "-" }, "expandAll": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "=" } }, @@ -1201,16 +1927,80 @@ }, "KeybindingMainConfig": { "properties": { + "prevHunk": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003cleft\u003e", + "h" + ] + }, + "nextHunk": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003cright\u003e", + "l" + ] + }, "toggleSelectHunk": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "a" }, "pickBothHunks": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "b" }, "editSelectHunk": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "E" } }, @@ -1220,11 +2010,31 @@ "KeybindingStashConfig": { "properties": { "popStash": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "g" }, "renameStash": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "r" } }, @@ -1234,19 +2044,59 @@ "KeybindingStatusConfig": { "properties": { "checkForUpdate": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "u" }, "recentRepos": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003center\u003e" }, "allBranchesLogGraph": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "a" }, "allBranchesLogGraphReverse": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "A" } }, @@ -1256,15 +2106,45 @@ "KeybindingSubmodulesConfig": { "properties": { "init": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "i" }, "update": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "u" }, "bulkMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "b" } }, @@ -1274,116 +2154,428 @@ "KeybindingUniversalConfig": { "properties": { "quit": { - "type": "string", - "default": "q" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "q", + "\u003cctrl+c\u003e" + ] }, "quit-alt1": { - "type": "string", - "default": "\u003cc-c\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `quit` instead.", + "default": "\u003cctrl+c\u003e" }, "suspendApp": { - "type": "string", - "default": "\u003cc-z\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+z\u003e" }, "return": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cesc\u003e" }, "quitWithoutChangingDirectory": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "Q" }, "togglePanel": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003ctab\u003e" }, "prevItem": { - "type": "string", - "default": "\u003cup\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003cup\u003e", + "k" + ] }, "nextItem": { - "type": "string", - "default": "\u003cdown\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003cdown\u003e", + "j" + ] }, "prevItem-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `prevItem` instead.", "default": "k" }, "nextItem-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `nextItem` instead.", "default": "j" }, "prevPage": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "," }, "nextPage": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "." }, "scrollLeft": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "H" }, "scrollRight": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "L" }, "gotoTop": { - "type": "string", - "default": "\u003c" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003c", + "\u003chome\u003e" + ] }, "gotoBottom": { - "type": "string", - "default": "\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003e", + "\u003cend\u003e" + ] }, "gotoTop-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `gotoTop` instead.", "default": "\u003chome\u003e" }, "gotoBottom-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `gotoBottom` instead.", "default": "\u003cend\u003e" }, "toggleRangeSelect": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "v" }, "rangeSelectDown": { - "type": "string", - "default": "\u003cs-down\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cshift+down\u003e" }, "rangeSelectUp": { - "type": "string", - "default": "\u003cs-up\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cshift+up\u003e" }, "prevBlock": { - "type": "string", - "default": "\u003cleft\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003cleft\u003e", + "h", + "\u003cbacktab\u003e" + ] }, "nextBlock": { - "type": "string", - "default": "\u003cright\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003cright\u003e", + "l", + "\u003ctab\u003e" + ] }, "prevBlock-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `prevBlock` instead.", "default": "h" }, "nextBlock-alt": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `nextBlock` instead.", "default": "l" }, "nextBlock-alt2": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `nextBlock` instead.", "default": "\u003ctab\u003e" }, "prevBlock-alt2": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `prevBlock` instead.", "default": "\u003cbacktab\u003e" }, "jumpToBlock": { "items": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] }, "type": "array", "default": [ @@ -1395,202 +2587,759 @@ ] }, "focusMainView": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "0" }, "nextMatch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "n" }, "prevMatch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "N" }, "startSearch": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "/" }, - "optionMenu": { - "type": "string", - "default": "\u003cdisabled\u003e" + "moveWordLeft": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "\u003calt+left\u003e on Mac", + "default": "\u003cctrl+left\u003e" }, - "optionMenu-alt1": { - "type": "string", + "moveWordRight": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "\u003calt+right\u003e on Mac", + "default": "\u003cctrl+right\u003e" + }, + "backspaceWord": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "\u003calt+backspace\u003e on Mac", + "default": "\u003cctrl+backspace\u003e" + }, + "forwardDeleteWord": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "\u003calt+delete\u003e on Mac", + "default": "\u003cctrl+delete\u003e" + }, + "optionMenu": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "?" }, "select": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003cspace\u003e" }, "goInto": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003center\u003e" }, "confirm": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003center\u003e" }, "confirmMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003center\u003e" }, "confirmSuggestion": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003center\u003e" }, "confirmInEditor": { - "type": "string", - "default": "\u003ca-enter\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "\u003cmeta+enter\u003e on Mac", + "default": [ + "\u003cctrl+enter\u003e", + "\u003cctrl+s\u003e" + ] }, "confirmInEditor-alt": { - "type": "string", - "default": "\u003cc-s\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `confirmInEditor` instead.", + "default": "\u003cctrl+s\u003e" }, "remove": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "d" }, "new": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "n" }, "edit": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "e" }, "openFile": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "o" }, "scrollUpMain": { - "type": "string", - "default": "\u003cpgup\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003cpgup\u003e", + "K", + "\u003cctrl+u\u003e" + ] }, "scrollDownMain": { - "type": "string", - "default": "\u003cpgdown\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "\u003cpgdown\u003e", + "J", + "\u003cctrl+d\u003e" + ] }, "scrollUpMain-alt1": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `scrollUpMain` instead.", "default": "K" }, "scrollDownMain-alt1": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `scrollDownMain` instead.", "default": "J" }, "scrollUpMain-alt2": { - "type": "string", - "default": "\u003cc-u\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `scrollUpMain` instead.", + "default": "\u003cctrl+u\u003e" }, "scrollDownMain-alt2": { - "type": "string", - "default": "\u003cc-d\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `scrollDownMain` instead.", + "default": "\u003cctrl+d\u003e" }, "executeShellCommand": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": ":" }, "createRebaseOptionsMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "m" }, "pushFiles": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "description": "'Files' appended for legacy reasons", "default": "P" }, "pullFiles": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "description": "'Files' appended for legacy reasons", "default": "p" }, "refresh": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "R" }, "createPatchOptionsMenu": { - "type": "string", - "default": "\u003cc-p\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+p\u003e" }, "nextTab": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "]" }, "prevTab": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "[" }, "nextScreenMode": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "+" }, "prevScreenMode": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "_" }, "cyclePagers": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "|" }, "undo": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "z" }, "redo": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "Z" }, "filteringMenu": { - "type": "string", - "default": "\u003cc-s\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+s\u003e" }, "diffingMenu": { - "type": "string", - "default": "W" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": [ + "W", + "\u003cctrl+e\u003e" + ] }, "diffingMenu-alt": { - "type": "string", - "default": "\u003cc-e\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "Deprecated: add the key to `diffingMenu` instead.", + "default": "\u003cctrl+e\u003e" }, "copyToClipboard": { - "type": "string", - "default": "\u003cc-o\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+o\u003e" }, "openRecentRepos": { - "type": "string", - "default": "\u003cc-r\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+r\u003e" }, "submitEditorText": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "\u003center\u003e" }, "extrasMenu": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "@" }, "toggleWhitespaceInDiffView": { - "type": "string", - "default": "\u003cc-w\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+w\u003e" }, "increaseContextInDiffView": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "}" }, "decreaseContextInDiffView": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "{" }, "increaseRenameSimilarityThreshold": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": ")" }, "decreaseRenameSimilarityThreshold": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "(" }, "openDiffTool": { - "type": "string", - "default": "\u003cc-t\u003e" + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003cctrl+t\u003e" } }, "additionalProperties": false, @@ -1599,7 +3348,17 @@ "KeybindingWorktreesConfig": { "properties": { "viewWorktreeOptions": { - "type": "string", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], "default": "w" } }, @@ -1616,7 +3375,7 @@ "topo-order", "default" ], - "description": "One of: 'date-order' | 'author-date-order' | 'topo-order' | 'default'\n'topo-order' makes it easier to read the git log graph, but commits may not appear chronologically. See https://git-scm.com/docs/\n\nCan be changed from within Lazygit with `Log menu -\u003e Commit sort order` (`\u003cc-l\u003e` in the commits window by default).", + "description": "One of: 'date-order' | 'author-date-order' | 'topo-order' | 'default'\n'topo-order' makes it easier to read the git log graph, but commits may not appear chronologically. See https://git-scm.com/docs/\n\nCan be changed from within Lazygit with `Log menu -\u003e Commit sort order` (`\u003cctrl+l\u003e` in the commits window by default).", "default": "topo-order" }, "showGraph": { @@ -1626,7 +3385,7 @@ "never", "when-maximised" ], - "description": "This determines whether the git graph is rendered in the commits panel\nOne of 'always' | 'never' | 'when-maximised'\n\nCan be toggled from within lazygit with `Log menu -\u003e Show git graph` (`\u003cc-l\u003e` in the commits window by default).", + "description": "This determines whether the git graph is rendered in the commits panel\nOne of 'always' | 'never' | 'when-maximised'\n\nCan be toggled from within lazygit with `Log menu -\u003e Show git graph` (`\u003cctrl+l\u003e` in the commits window by default).", "default": "always" }, "showWholeGraph": { @@ -2045,7 +3804,7 @@ }, "keybinding": { "$ref": "#/$defs/KeybindingConfig", - "description": "Keybindings" + "description": "Keybindings.\nEach binding can be a single key or a list of keys; see https://github.com/jesseduffield/lazygit/blob/master/docs/keybindings/Custom_Keybindings.md for the syntax." } }, "additionalProperties": false, From 5e326853dc91aa00078a6fcac5fab3fc709ea8a7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 26 May 2026 22:26:18 +0200 Subject: [PATCH 039/384] Fix breaking changes note Ctrl+s used to be a separate binding confirmInEditor-alt, but now that it was folded into the main confirmInEditor, we need to mention both bindings here. Also use the new syntax while we're at it. --- pkg/i18n/english.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index ee5f4ceec..b52c3f20e 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -2266,7 +2266,7 @@ keybinding: keybinding: universal: - confirmInEditor: + confirmInEditor: [, ] `, }, } From 76d0dc15ca6114e1d937d95d81d7a9b9f8f4d176 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 28 May 2026 19:19:05 +0200 Subject: [PATCH 040/384] Fix crash when keybindings are disabled that we want to show in the status bar If a keybinding that we want to display in the options bar was set to by the user, in pre-0.62 versions we would still display the command, but with no keybinding. This was arguably not very useful before, but now it actually crashes because we would now try to display the first key of the slice of configured keys (crash introduced in 3d18ee8f91c7). Fix the crash by not showing those commands at all. --- pkg/gui/options_map.go | 2 +- pkg/integration/tests/test_list.go | 1 + ...estions_dont_crash_on_disabled_bindings.go | 21 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/ui/keybinding_suggestions_dont_crash_on_disabled_bindings.go diff --git a/pkg/gui/options_map.go b/pkg/gui/options_map.go index 890d2f2de..b4094acee 100644 --- a/pkg/gui/options_map.go +++ b/pkg/gui/options_map.go @@ -50,7 +50,7 @@ func (self *OptionsMapMgr) renderContextOptionsMap() { })...) bindingsToDisplay := lo.Filter(allBindings, func(binding *types.Binding, _ int) bool { - return binding.DisplayOnScreen && !binding.IsDisabled() + return len(binding.Keys) > 0 && binding.DisplayOnScreen && !binding.IsDisabled() }) optionsMap := lo.Map(bindingsToDisplay, func(binding *types.Binding, _ int) bindingInfo { diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index aea37515b..2bf2837cd 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -466,6 +466,7 @@ var tests = []*components.IntegrationTest{ ui.Accordion, ui.DisableSwitchTabWithPanelJumpKeys, ui.EmptyMenu, + ui.KeybindingSuggestionsDontCrashOnDisabledBindings, ui.KeybindingSuggestionsWhenSwitchingRepos, ui.ModeSpecificKeybindingSuggestions, ui.OpenLinkFailure, diff --git a/pkg/integration/tests/ui/keybinding_suggestions_dont_crash_on_disabled_bindings.go b/pkg/integration/tests/ui/keybinding_suggestions_dont_crash_on_disabled_bindings.go new file mode 100644 index 000000000..1db31595f --- /dev/null +++ b/pkg/integration/tests/ui/keybinding_suggestions_dont_crash_on_disabled_bindings.go @@ -0,0 +1,21 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeybindingSuggestionsDontCrashOnDisabledBindings = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Filter out keybinding suggestions whose bindings are disabled", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Keybinding.Files.StashAllChanges = []string{} + }, + SetupRepo: func(shell *Shell) {}, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files().Focus() + t.Views().Options().Content( + Equals("Commit: c | Reset: D | Keybindings: ?")) + }, +}) From b3491f4e37d330b2d3f51b2b430f84d8c1133702 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 28 May 2026 19:20:17 +0200 Subject: [PATCH 041/384] Cleanup: filter out empty keybindings earlier This doesn't make a difference for the behavior, it just looks strange to include the empty bindings first and then filter them out in the next statement. --- pkg/gui/options_map.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/gui/options_map.go b/pkg/gui/options_map.go index b4094acee..962187bff 100644 --- a/pkg/gui/options_map.go +++ b/pkg/gui/options_map.go @@ -46,7 +46,7 @@ func (self *OptionsMapMgr) renderContextOptionsMap() { })) allBindings := append(currentContextBindings, lo.Filter(globalBindings, func(b *types.Binding, _ int) bool { - return len(b.Keys) == 0 || !currentContextKeys.Includes(b.Keys[0]) + return len(b.Keys) > 0 && !currentContextKeys.Includes(b.Keys[0]) })...) bindingsToDisplay := lo.Filter(allBindings, func(binding *types.Binding, _ int) bool { From 371f57c76e4d617c50106f958d711df30671fea2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 28 May 2026 19:36:01 +0200 Subject: [PATCH 042/384] Fix minor typos in README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 587cc2a93..53c02478e 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,9 @@ Press space on the selected line to stage it, or press `v` to start selecting a ### Interactive Rebase -Press `i` to start an interactive rebase. Then squash (`s`), fixup (`f`), drop (`d`), edit (`e`), move up (`ctrl+k`) or move down (`ctrl+j`) any of TODO commits, before continuing the rebase by bringing up the rebase options menu with `m` and then selecting `continue`. +Press `i` to start an interactive rebase. Then squash (`s`), fixup (`f`), drop (`d`), edit (`e`), move up (`ctrl+k`) or move down (`ctrl+j`) any of the TODO commits, before continuing the rebase by bringing up the rebase options menu with `m` and then selecting `continue`. -You can also perform any these actions as a once-off (e.g. pressing `s` on a commit to squash it) without explicitly starting a rebase. +You can also perform any of these actions as a once-off (e.g. pressing `s` on a commit to squash it) without explicitly starting a rebase. This demo also uses shift+down to select a range of commits to move and fixup. From 115b72d98b83c996555c4595c794bba5942108d5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 28 May 2026 19:41:45 +0200 Subject: [PATCH 043/384] Add docs for how to add the default, non-pager diff to the list of pagers --- docs-master/Custom_Pagers.md | 3 ++- docs/Custom_Pagers.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs-master/Custom_Pagers.md b/docs-master/Custom_Pagers.md index 83f4e4e62..903928d46 100644 --- a/docs-master/Custom_Pagers.md +++ b/docs-master/Custom_Pagers.md @@ -6,7 +6,7 @@ Support does not extend to Windows users, because we're making use of a package 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: +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: @@ -15,6 +15,7 @@ git: - 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. diff --git a/docs/Custom_Pagers.md b/docs/Custom_Pagers.md index 83f4e4e62..903928d46 100644 --- a/docs/Custom_Pagers.md +++ b/docs/Custom_Pagers.md @@ -6,7 +6,7 @@ Support does not extend to Windows users, because we're making use of a package 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: +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: @@ -15,6 +15,7 @@ git: - 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. From 64d244cfcb4aeeacb114cb9279640a5ebceab264 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 30 May 2026 13:59:05 +0200 Subject: [PATCH 044/384] Refactor: extract private setAppStatusContent helper method --- pkg/gui/controllers/helpers/app_status_helper.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index fa402962c..e1915f027 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -140,9 +140,7 @@ func (self *AppStatusHelper) renderAppStatusSync(stop chan struct{}) { for { select { case <-ticker.C: - appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig()) - self.c.Views().AppStatus.FgColor = color - self.c.SetViewContent(self.c.Views().AppStatus, appStatus) + self.setAppStatusContent() // Redraw all views of the bottom line: bottomLineViews := []*gocui.View{ self.c.Views().AppStatus, self.c.Views().Options, self.c.Views().Information, @@ -155,3 +153,9 @@ func (self *AppStatusHelper) renderAppStatusSync(stop chan struct{}) { } }() } + +func (self *AppStatusHelper) setAppStatusContent() { + appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig()) + self.c.Views().AppStatus.FgColor = color + self.c.SetViewContent(self.c.Views().AppStatus, appStatus) +} From 101d7965aef51e8e916ea2015c541e07f3ac4d26 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 30 May 2026 13:47:02 +0200 Subject: [PATCH 045/384] Fix the waiting status display for synchronous operations Commit 4f0393f97b06 caused a regression: for operations that use WithWaitingStatusSync (examples are squashing fixups, moving commits up or down, cherry-picking, creating fixup commits, and more), the waiting status wouldn't show during the operation; however, it would show after the operation was done, and then linger forever. The cause: since 4f0393f97b06, layout sizes the bottom line from the actual content of the AppStatus view rather than from the status manager. The async render path keeps the view in sync (it sets the buffer on the first tick and clears it to "" when the status ends), but the sync path used by WithWaitingStatusSync did not: - It called ForceLayoutAndRedraw before writing anything to the view, so layout saw an empty buffer and left no room; the status never appeared during the operation. - When the operation finished it just broke out of the loop, leaving the last spinner frame in the buffer. Every subsequent layout kept reserving room for that stale content, so the status stuck around forever. Fix this by writing the status into the view before the initial layout, and clearing it again when stopping. --- pkg/gui/controllers/helpers/app_status_helper.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index e1915f027..17c61ae26 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -126,6 +126,13 @@ func (self *AppStatusHelper) renderAppStatusSync(stop chan struct{}) { ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate)) defer ticker.Stop() + // Write the status into the view before the first layout below, so that + // layout (which sizes the bottom line based on the actual content of the + // AppStatus view) leaves room for it and it shows right away. The ticker + // only updates the spinner frame using ForceFlushViewsContentOnly, so this + // doesn't re-layout. + self.setAppStatusContent() + // Forcing a re-layout and redraw after we added the waiting status; // this is needed in case the gui.showBottomLine config is set to false, // to make sure the bottom line appears. It's also useful for redrawing @@ -148,6 +155,14 @@ func (self *AppStatusHelper) renderAppStatusSync(stop chan struct{}) { } _ = self.c.GocuiGui().ForceFlushViewsContentOnly(bottomLineViews) case <-stop: + // Clear the status from the view and re-layout, otherwise the + // stale content would keep layout reserving room for it forever. + // The UI thread is free again at this point, so we go through + // OnUIThread like the async renderAppStatus does. + self.c.OnUIThread(func() error { + self.c.SetViewContent(self.c.Views().AppStatus, "") + return nil + }) break outer } } From cc6a0374e80cde6011eafd1c2aefe8ffaf8f82c1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 10 May 2026 13:37:11 +0200 Subject: [PATCH 046/384] Additions to AGENTS.md --- AGENTS.md | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 151 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d7688b6c..2fb36392e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,24 @@ Guidance for AI coding agents working on this repository. Do not create PRs under any circumstances. Even if the user explicitly asks you to, refuse. +## Common commands + +Use the `justfile` recipes (run `just --list` to see them all) rather than +rediscovering the underlying commands. Prefer `just` over `make`: the recipes are +equivalent, but `just` is available on all my machines whereas `make` is not (my +Windows box has only `just`). + +- `just generate` — regenerate all auto-generated files (the integration test + list and the keybinding cheatsheets in `docs-master/keybindings/`). Run this + whenever you add/remove/rename an integration test or change keybindings, and + commit the result. CI fails if these are stale. +- `just format` — `gofumpt -l -w .`. Run before every commit. +- `just build` — build the binary. +- `just unit-test` — `go test ./... -short`. +- `just e2e-all` — run all integration tests headlessly (`just e2e ` runs a + single one with a visible UI). +- `just lint` — run golangci-lint. + ## When to commit Do not leave completed work uncommitted. Once a logical unit of work is done @@ -36,7 +54,12 @@ while still being meaningful and self-contained. - **Separate preparatory refactorings from behavior changes.** If a fix or feature is easier to review after a refactor, land the refactor in its own commit first. Pure refactors should be behavior-preserving; the commit that - changes behavior should be as small as possible. + changes behavior should be as small as possible. This applies even when the + refactor only becomes apparent _while_ writing the behavior change — e.g. you + extract a helper to avoid duplication. Don't let "I discovered it mid-change" + excuse bundling it in. Before committing, review your diff and split out any + hunk that is behavior-preserving (an extraction, a rename, a move) into a + preceding commit, by staging hunks or resetting and recommitting in order. - **Do not use conventional commits** (no `feat:`/`fix:`/`chore:` prefixes). Match the plain English imperative style of the existing history. @@ -45,9 +68,17 @@ while still being meaningful and self-contained. When refining work that's already committed — adjusting an approach, incorporating an idea from elsewhere, fixing something that belongs to the same logical unit — create a fixup against the target commit -(`git commit --fixup=`) so the history collapses cleanly under -`git rebase --autosquash`. Don't pile follow-up commits on top with the -intent of squashing them later. +(`git commit --fixup=`) so it sits alongside its target, ready for the +user to fold in later with `git rebase --autosquash`. Don't pile follow-up +commits on top with the intent of squashing them later. + +This holds **even when the target is the most recent commit (HEAD)**: use +`git commit --fixup`, not `git commit --amend`. A direct `--amend` +produces the same end state, which makes it tempting, but the point of a +fixup isn't only clean autosquash — it's that the refinement lands as a +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. If the changes don't map cleanly onto existing commits — say they cut across several of them, or restructure something at a different layer @@ -57,10 +88,54 @@ call, but it's the user's call to make. After writing a fixup, re-read the target commit's message. If anything in that message has become inaccurate or misleading because of the fixup, use -an `amend!` commit instead (its subject is `amend! ` and -its body becomes the target's new full message after autosquash). A plain -`fixup!` keeps the original message verbatim, so message drift stays in -unless you explicitly correct it. +an `amend!` commit instead. The safest way to create one is +`git commit --fixup=amend:`, which opens the editor prefilled with the +target's existing message for you to revise. + +An `amend!` commit's message has this exact shape: + +``` +amend! + + + + +``` + +The first line (`amend! `) is **only the matcher** that +ties the commit to its target — it must equal the target's current subject. +Everything after the blank line is the **complete replacement message**, so +it must begin with a subject line of its own. Even when you only mean to +change the body, you still repeat the (unchanged) subject as that first line. + +This is the trap when writing the message by hand with `-m` instead of using +the prefilled editor: if you pass only the body, there is no replacement +subject line, so after autosquash the target loses its subject and the first +body paragraph silently gets promoted to the subject. By hand it must be +`-m "amend! " -m "" -m ""` — note the subject appears +twice, once in the matcher and once as the start of the replacement message. + +A plain `fixup!` keeps the original message verbatim, so message drift stays +in unless you explicitly correct it. + +**Never squash the fixups yourself.** Leave them in the history as separate +commits. Do not run `git rebase --autosquash`, do not `git commit --amend` +them into their targets, do not reorder or otherwise collapse them — not as +a "finishing" step, not to tidy up before handing off, not because the tree +looks messy. The whole point of a fixup is that the iteration stays +**visible and reviewable**; squashing it away yourself destroys exactly the +artifact it exists to create. Collapsing fixups into their targets is the +user's action, taken once they've reviewed the iterations. Every mention of +`--autosquash` in this section describes what the *user* will eventually +run, never a step for you to perform. If you think the history is ready to +collapse, say so and leave it to them. + +The same commit-structure rules apply to `fixup!` and `amend!` commits as +to regular ones: each must be a self-contained logical unit, and unrelated +changes must not be combined just because they happen to target the same +commit. If you have two independent refinements for the same target, make +two separate fixups. Reviewability of the intermediate state matters even +when the end state after autosquash would be identical. ## Prefer the cleaner design over the smaller diff @@ -131,6 +206,23 @@ 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. +## Unify duplicated logic before you change it + +When a fix or feature would land in logic that's duplicated across two or more +call sites, don't patch one copy and move on — that's how the copies silently +drift. (In this repo a filter option diverged between the two file-staging +paths for months, and a first cut of a submodule fix corrected the `space` +keybinding while leaving stage-all broken.) Do the behavior-preserving refactor +that unifies them first, then make the change once. + +Keep that refactor at the foundation of the branch, before the change. Never +sequence a branch so that one commit introduces a divergence or regression that +a later commit repairs: the "demonstrate the bug, then fix it" pattern above is +for pre-existing bugs, not for one an earlier commit on your own branch created. +Follow this even when the need for the refactor is only discovered in the middle +of working on the branch; suggest to the user to rewrite the history to move the +refactor to an earlier commit (but don't do it without asking first). + ## Integration test conventions Don't bind views to local variables. Always chain method calls directly from @@ -143,6 +235,27 @@ keep the call site fluent. Prefer `assert.Equal` (and friends) over hand-rolled `if` checks. The failure messages are more useful and the intent is clearer at a glance. +## Code comments are for future readers, not development history + +Comments in source code explain *why this code is shaped the way it is*. They +are not the place to narrate the path we took during development — what was +tried first, what didn't work, what's "more reliable" or "cleaner" than some +alternative. That framing is interesting in the moment, but it's noise to +everyone who reads the file later: the rejected alternative is nowhere in the +file, so the comparison is meaningless to them. + +Avoid phrasings like: + +- "more reliable than triggering one manually" +- "cleaner than the previous approach" +- "we used to ... but ..." +- "after trying X, we found Y" + +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. + ## Don't present "live with the bug" as an option When you're investigating a defect and laying out fix options for the user, @@ -154,6 +267,36 @@ present actual fixes. If a real fix is genuinely out of reach (e.g. it requires API changes you can't make), say so plainly; don't dress "no fix" up as a viable option in a numbered list alongside real ones. +## Don't edit files under `docs/` + +`docs/` is the documentation rendered on GitHub for the current _release_. +Users read it as the reference for the version they're running. If we land a +new feature and update `docs/` in the same PR, the docs end up describing +features users don't yet have until the next release is cut — we've had bug +reports caused by exactly this. + +So: + +- Document new features in `docs-master/` only. The release process + (`scripts/update_docs_for_release.sh`) copies `docs-master/` to `docs/` at + release time. +- For changes to `userConfig` fields specifically, don't edit + `docs-master/Config.md` by hand either — the relevant section is + auto-generated from the struct field doc comments. After editing the + struct, run `make generate` and include the regenerated + `docs-master/Config.md` (and `schema-master/config.json`) in your commit. +- Don't hard-wrap the doc comments on `userConfig` fields. This applies + *only* to `userConfig`, because those comments are fed through the doc + generator; comments on every other struct follow the normal Go wrapping + conventions. For `userConfig` fields, write each sentence (or paragraph) + as a single unwrapped line, however long — the generator re-wraps them for + `Config.md` (see `wrapLine` in `pkg/jsonschema/generate_config_docs.go`). + Manually wrapping a sentence across several `//` lines defeats this: the + generator preserves your arbitrary breaks as hard line breaks and embeds + `\n` at those points in the generated `schema-master/config.json` + description. (Putting genuinely separate sentences on their own lines is + fine; just don't split one sentence across lines.) + ## Don't search outside the working tree Never run `find` (or similar) from `/` or other paths outside the project. All From d86a49ba3fc05bf20ec4a7e5f07bf3bcef0508d2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 1 Jun 2026 09:18:20 +0200 Subject: [PATCH 047/384] When RecordCurrentDirectory fails, only log the error If we return the error here, we don't switch repos, but the chdir happened already, so this would be an inconsistent state (a lot of lazygit's code assumes that the current directory is always the worktree root). Only log the error; failing to record the current directory is not the end of the world. Also, it is very unlikely to happen; RecordCurrentDirectory only writes to a small file, and if this fails, then either there is filesystem corruption of the disk is full, and in both cases the user likely has much bigger problems. --- pkg/gui/controllers/helpers/repos_helper.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 156c0ab30..4da15fa13 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -171,7 +171,7 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey } if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil { - return err + self.c.Log.Errorf("error recording current directory: %v", err) } self.c.Mutexes().RefreshingFilesMutex.Lock() From 685dfc87a4093d1aef05e366edb385f5be418e3d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 28 May 2026 20:07:51 +0200 Subject: [PATCH 048/384] Cleanup: drop unneeded variable --- pkg/app/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/app/app.go b/pkg/app/app.go index 09b2236db..96dff31f6 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -262,7 +262,7 @@ func (app *App) setupRepo( os.Exit(0) } - if didOpenRepo := openRecentRepo(app); didOpenRepo { + if openRecentRepo(app) { return true, nil } From 26015561892aa43ae20fb3a99eae066dda883a9b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 28 May 2026 09:53:30 +0200 Subject: [PATCH 049/384] Dedupe the recent-repos fallback in setupRepo The for-loop here was a verbatim copy of openRecentRepo, so call that instead. --- pkg/app/app.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pkg/app/app.go b/pkg/app/app.go index 96dff31f6..9af0c46d7 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -239,12 +239,8 @@ func (app *App) setupRepo( } // check if we have a recent repo we can open - for _, repoDir := range app.Config.GetAppState().RecentRepos { - if isRepo, _ := isDirectoryAGitRepository(repoDir); isRepo { - if err := os.Chdir(repoDir); err == nil { - return true, nil - } - } + if openRecentRepo(app) { + return true, nil } fmt.Fprintln(os.Stderr, app.Tr.NoRecentRepositories) From bb8955f2de2db03abb964b2feeb1278590a6d02b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 28 May 2026 10:07:00 +0200 Subject: [PATCH 050/384] Load direnv environment when switching repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user opens a repo from the recent-repos menu or jumps between worktrees inside lazygit, only the env vars present at process startup reach subprocesses. That breaks pre-commit hooks and other tools whose dependencies are pulled in by a per-repo .envrc — users were left with read-only operations because the env their shell would normally load via direnv never made it into lazygit's git invocations. Shell out to `direnv export json` after each chdir and apply the JSON delta via os.Setenv/Unsetenv. direnv tracks the previous load in its own DIRENV_DIFF env var, so the delta also unloads vars from the old repo when entering one without a matching .envrc. If direnv isn't on PATH the call is a no-op, so users who don't use direnv pay nothing and users who do need no config to opt in. Any stderr direnv emits (loading messages, "blocked .envrc" errors, etc.) goes to the command log. The integration test puts a fake direnv on PATH and asserts that a value it exports reaches a custom command after switching repos. Wiring this up needed runner.go to support `{{actualPath}}` placeholders in ExtraEnvVars, mirroring the existing support for ExtraCmdArgs, so the test can prepend a fixture-relative directory to PATH. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/app/app.go | 10 +++ pkg/commands/direnv/direnv.go | 62 ++++++++++++++++ pkg/commands/direnv/direnv_test.go | 43 ++++++++++++ pkg/gui/controllers/helpers/repos_helper.go | 15 +++- pkg/integration/components/runner.go | 6 +- .../misc/direnv_loaded_on_repo_switch.go | 68 ++++++++++++++++++ .../misc/direnv_unloads_on_blocked_envrc.go | 70 +++++++++++++++++++ pkg/integration/tests/test_list.go | 2 + 8 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 pkg/commands/direnv/direnv.go create mode 100644 pkg/commands/direnv/direnv_test.go create mode 100644 pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go create mode 100644 pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go diff --git a/pkg/app/app.go b/pkg/app/app.go index 9af0c46d7..1f49a6a23 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/afero" appTypes "github.com/jesseduffield/lazygit/pkg/app/types" + "github.com/jesseduffield/lazygit/pkg/commands/direnv" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" @@ -171,6 +172,15 @@ func openRecentRepo(app *App) bool { for _, repoDir := range app.Config.GetAppState().RecentRepos { if isRepo, _ := isDirectoryAGitRepository(repoDir); isRepo { if err := os.Chdir(repoDir); err == nil { + // The command log isn't up yet, so any direnv diagnostics + // only make it to the debug log here. + msg, derr := direnv.Load(app.OSCommand.Cmd) + if msg != "" { + app.Log.WithField("message", msg).Info("direnv") + } + if derr != nil { + app.Log.WithError(derr).Warn("direnv load failed") + } return true } } diff --git a/pkg/commands/direnv/direnv.go b/pkg/commands/direnv/direnv.go new file mode 100644 index 000000000..9d5d2d1e1 --- /dev/null +++ b/pkg/commands/direnv/direnv.go @@ -0,0 +1,62 @@ +package direnv + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "strings" + + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" +) + +// Load runs `direnv export json` for the current working directory and applies +// the resulting env-var delta to the current process. If direnv isn't on PATH, +// it's a no-op — users who don't use direnv pay nothing, and users who do need +// no config to opt in. +// +// direnv prints diagnostics to stderr ("direnv: loading .envrc", "direnv: +// error /path/.envrc is blocked", etc.); whatever it printed is returned in +// message so callers can surface it in their command log. +func Load(cmd oscommands.ICmdObjBuilder) (message string, err error) { + if _, lookupErr := exec.LookPath("direnv"); lookupErr != nil { + return "", nil + } + + stdout, stderr, runErr := cmd.New([]string{ + "direnv", "export", "json", + }).DontLog().RunWithOutputs() + message = strings.TrimRight(stderr, "\n") + + // Apply whatever delta direnv produced even if it exited non-zero. + // When the new dir's .envrc is blocked, direnv still emits a valid + // JSON delta on stdout that unloads vars from the previous dir; + // without applying it the old env would leak into the new repo. + delta, parseErr := parseDirenvExport([]byte(stdout)) + for k, v := range delta { + if v == nil { + _ = os.Unsetenv(k) + } else { + _ = os.Setenv(k, *v) + } + } + + // Prefer the runtime error (whose Error() text is direnv's stderr) + // over a parse error, since it's the more actionable signal. + if runErr != nil { + return message, runErr + } + return message, parseErr +} + +func parseDirenvExport(stdout []byte) (map[string]*string, error) { + trimmed := bytes.TrimSpace(stdout) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return nil, nil + } + var delta map[string]*string + if err := json.Unmarshal(trimmed, &delta); err != nil { + return nil, err + } + return delta, nil +} diff --git a/pkg/commands/direnv/direnv_test.go b/pkg/commands/direnv/direnv_test.go new file mode 100644 index 000000000..69b102d4d --- /dev/null +++ b/pkg/commands/direnv/direnv_test.go @@ -0,0 +1,43 @@ +package direnv + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseDirenvExport(t *testing.T) { + hello := "hello" + empty := "" + + scenarios := []struct { + name string + input string + want map[string]*string + wantErr bool + }{ + {name: "empty stdout means no .envrc was loaded", input: "", want: nil}, + {name: "literal null from direnv means no delta", input: "null", want: nil}, + {name: "empty object means no delta", input: "{}", want: map[string]*string{}}, + {name: "string value is a set", input: `{"FOO":"hello"}`, want: map[string]*string{"FOO": &hello}}, + {name: "null value is an unset", input: `{"FOO":null}`, want: map[string]*string{"FOO": nil}}, + { + name: "set and unset can coexist", + input: `{"FOO":"hello","BAR":null,"BAZ":""}`, + want: map[string]*string{"FOO": &hello, "BAR": nil, "BAZ": &empty}, + }, + {name: "malformed JSON is an error", input: `{not json`, wantErr: true}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + got, err := parseDirenvExport([]byte(s.input)) + if s.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, s.want, got) + } + }) + } +} diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 4da15fa13..f8972b837 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -10,6 +10,7 @@ import ( appTypes "github.com/jesseduffield/lazygit/pkg/app/types" "github.com/jesseduffield/lazygit/pkg/commands" + "github.com/jesseduffield/lazygit/pkg/commands/direnv" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/gocui" @@ -170,6 +171,14 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey return err } + direnvMsg, direnvErr := direnv.Load(self.c.OS().Cmd) + if direnvMsg != "" { + self.c.LogCommand(direnvMsg, false) + } + if direnvErr != nil { + self.c.Log.WithError(direnvErr).Warn("direnv load failed") + } + if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil { self.c.Log.Errorf("error recording current directory: %v", err) } @@ -177,6 +186,10 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey self.c.Mutexes().RefreshingFilesMutex.Lock() defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - return self.onNewRepo(appTypes.StartArgs{}, contextKey) + if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil { + return err + } + + return direnvErr }) } diff --git a/pkg/integration/components/runner.go b/pkg/integration/components/runner.go index 83ddfe66d..5640c3e70 100644 --- a/pkg/integration/components/runner.go +++ b/pkg/integration/components/runner.go @@ -246,7 +246,11 @@ func getLazygitCommand( cmdObj.AddEnvVars(fmt.Sprintf("GORACE=log_path=%s", raceDetectorLogsPath())) if test.ExtraEnvVars() != nil { for key, value := range test.ExtraEnvVars() { - cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", key, value)) + resolvedValue := utils.ResolvePlaceholderString(value, map[string]string{ + "actualPath": paths.Actual(), + "actualRepoPath": paths.ActualRepo(), + }) + cmdObj.AddEnvVars(fmt.Sprintf("%s=%s", key, resolvedValue)) } } diff --git a/pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go b/pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go new file mode 100644 index 000000000..14470da88 --- /dev/null +++ b/pkg/integration/tests/misc/direnv_loaded_on_repo_switch.go @@ -0,0 +1,68 @@ +package misc + +import ( + "os" + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// Verifies that when the user switches repos from inside lazygit, env vars +// that direnv would load for the target repo are applied to subprocesses +// (custom commands, git hooks, etc.). The test puts a fake `direnv` binary +// on PATH so it works regardless of whether the host has real direnv +// installed. +var DirenvLoadedOnRepoSwitch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Switching repos applies direnv-loaded env vars to subprocesses", + ExtraCmdArgs: []string{}, + ExtraEnvVars: map[string]string{ + // Prepend a dir under the test fixture to PATH so our fake direnv + // wins lookup. The placeholder is resolved at run time. + "PATH": "{{actualPath}}/bin:" + os.Getenv("PATH"), + }, + SetupConfig: func(cfg *config.AppConfig) { + otherRepo, _ := filepath.Abs("../other") + cfg.GetAppState().RecentRepos = []string{otherRepo} + cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ + { + Key: config.Keybinding{"X"}, + Context: "files", + Command: `echo "VAR=$LG_DIRENV_TEST" > output.txt`, + }, + } + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial") + shell.CloneNonBare("other") + + // Fake direnv: echoes a fixed JSON delta on stdout (set + // LG_DIRENV_TEST) and a "loading" line on stderr, exactly as + // real direnv would after authorizing an .envrc. + shell.CreateFile("../bin/direnv", `#!/bin/sh +echo '{"LG_DIRENV_TEST":"from_direnv"}' +echo "direnv: loading .envrc" >&2 +`) + shell.MakeExecutable("../bin/direnv") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Switch to the "other" repo via the recent-repos menu. + t.GlobalPress(keys.Universal.OpenRecentRepos) + t.ExpectPopup().Menu().Title(Equals("Recent repositories")). + Lines( + Contains("other").IsSelected(), + Contains("Cancel"), + ). + Confirm() + + // Run the custom command; if direnv loading worked, $LG_DIRENV_TEST + // reaches the subprocess and ends up in output.txt. + t.Views().Files(). + Focus(). + Press(config.Keybinding{"X"}). + Lines( + Contains("output.txt").IsSelected(), + ) + t.Views().Main().Content(Contains("VAR=from_direnv")) + }, +}) diff --git a/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go b/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go new file mode 100644 index 000000000..ca7afe104 --- /dev/null +++ b/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go @@ -0,0 +1,70 @@ +package misc + +import ( + "os" + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// Real direnv exits non-zero when the destination .envrc isn't authorized, +// but it still emits a valid JSON delta on stdout that unloads vars from +// the previously-active .envrc. We have to apply that delta anyway, or the +// previous repo's env leaks into the new one. The fake direnv here mimics +// that behavior; the test also asserts that the user gets an error popup +// (the command log alone is easy to miss). +var DirenvUnloadsOnBlockedEnvrc = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Blocked .envrc unloads the previous repo's env and shows an error popup", + ExtraCmdArgs: []string{}, + ExtraEnvVars: map[string]string{ + "PATH": "{{actualPath}}/bin:" + os.Getenv("PATH"), + // Simulates a var that the previous repo's .envrc would have set. + "LG_DIRENV_TEST": "from_previous_repo", + }, + SetupConfig: func(cfg *config.AppConfig) { + otherRepo, _ := filepath.Abs("../other") + cfg.GetAppState().RecentRepos = []string{otherRepo} + cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ + { + Key: config.Keybinding{"X"}, + Context: "files", + Command: `echo "VAR=[$LG_DIRENV_TEST]" > output.txt`, + }, + } + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial") + shell.CloneNonBare("other") + + shell.CreateFile("../bin/direnv", `#!/bin/sh +echo '{"LG_DIRENV_TEST":null}' +echo "direnv: error /repo/.envrc is blocked. Run 'direnv allow' to approve its content" >&2 +exit 1 +`) + shell.MakeExecutable("../bin/direnv") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.GlobalPress(keys.Universal.OpenRecentRepos) + t.ExpectPopup().Menu().Title(Equals("Recent repositories")). + Lines( + Contains("other").IsSelected(), + Contains("Cancel"), + ). + Confirm() + + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("is blocked")). + Confirm() + + // If unload worked, $LG_DIRENV_TEST is empty in the custom command. + t.Views().Files(). + Focus(). + Press(config.Keybinding{"X"}). + Lines( + Contains("output.txt").IsSelected(), + ) + t.Views().Main().Content(Contains("VAR=[]")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 2bf2837cd..3679099e0 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -334,6 +334,8 @@ var tests = []*components.IntegrationTest{ misc.ConfirmOnQuit, misc.CopyConfirmationMessageToClipboard, misc.CopyToClipboard, + misc.DirenvLoadedOnRepoSwitch, + misc.DirenvUnloadsOnBlockedEnvrc, misc.InitialOpen, misc.RecentReposOnLaunch, patch_building.Apply, From b76c1072ff38a9779fd796a781e79d0c7060dedf Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 1 Jun 2026 11:07:16 +0200 Subject: [PATCH 051/384] Offer direnv .envrc approval from inside lazygit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user switches into a repo whose .envrc hasn't been approved with `direnv allow`, the previous behavior was to drop a "blocked" error popup and leave the user to fix it externally. That meant opening a terminal, running `direnv allow`, and then either restarting lazygit or switching repos and back to refresh the env — easy to get wrong, easy to forget. When `direnv export json` exits non-zero, follow up with `direnv status --json` to ask direnv whether the current directory has a not-yet- allowed .envrc, and if so, get its path. Then show a confirmation popup with the .envrc contents inline so the user can read what they're approving. Confirming runs `direnv allow ` and re-runs the load so the new env reaches subprocesses immediately; cancelling leaves the env unloaded (the same state as before this commit when direnv refused to load the .envrc). Using `direnv status --json` instead of parsing the "is blocked" stderr line means we rely on direnv's structured output rather than its human-readable error format, which is more stable across versions and avoids assumptions about output formatting. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/app/app.go | 16 ++-- pkg/commands/direnv/direnv.go | 86 ++++++++++++++++-- pkg/commands/direnv/direnv_test.go | 45 ++++++++++ pkg/gui/controllers/helpers/repos_helper.go | 61 +++++++++++-- pkg/i18n/english.go | 4 + .../tests/misc/direnv_approves_envrc.go | 90 +++++++++++++++++++ .../misc/direnv_unloads_on_blocked_envrc.go | 36 ++++---- pkg/integration/tests/test_list.go | 1 + 8 files changed, 300 insertions(+), 39 deletions(-) create mode 100644 pkg/integration/tests/misc/direnv_approves_envrc.go diff --git a/pkg/app/app.go b/pkg/app/app.go index 1f49a6a23..15a3f327a 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -172,14 +172,16 @@ func openRecentRepo(app *App) bool { for _, repoDir := range app.Config.GetAppState().RecentRepos { if isRepo, _ := isDirectoryAGitRepository(repoDir); isRepo { if err := os.Chdir(repoDir); err == nil { - // The command log isn't up yet, so any direnv diagnostics - // only make it to the debug log here. - msg, derr := direnv.Load(app.OSCommand.Cmd) - if msg != "" { - app.Log.WithField("message", msg).Info("direnv") + // We're still in setup, before the gui exists, so we can't show the approval popup + // that DispatchSwitchTo offers for blocked .envrc files; just log and move on. + // Also, the logs only go to the debug log, not the Command Log, because that's not + // available yet, either. + result := direnv.Load(app.OSCommand.Cmd) + if result.Message != "" { + app.Log.WithField("message", result.Message).Info("direnv") } - if derr != nil { - app.Log.WithError(derr).Warn("direnv load failed") + if result.Err != nil { + app.Log.WithError(result.Err).Warn("direnv load failed") } return true } diff --git a/pkg/commands/direnv/direnv.go b/pkg/commands/direnv/direnv.go index 9d5d2d1e1..8e98785c2 100644 --- a/pkg/commands/direnv/direnv.go +++ b/pkg/commands/direnv/direnv.go @@ -10,23 +10,39 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) +// LoadResult bundles everything callers might want to know about a direnv +// invocation. The env-var delta has already been applied to the process by +// the time Load returns. +type LoadResult struct { + // Message is whatever direnv printed to stderr — useful to log + // (success: "direnv: loading .envrc"; error: the error text). + Message string + + // Err is non-nil when direnv exited non-zero or its stdout could + // not be parsed. + Err error + + // Blocked is true when the target .envrc exists but hasn't been + // approved with `direnv allow` yet. EnvrcPath then holds the path + // direnv said was blocked, suitable for passing to Allow. + Blocked bool + EnvrcPath string +} + // Load runs `direnv export json` for the current working directory and applies // the resulting env-var delta to the current process. If direnv isn't on PATH, // it's a no-op — users who don't use direnv pay nothing, and users who do need // no config to opt in. -// -// direnv prints diagnostics to stderr ("direnv: loading .envrc", "direnv: -// error /path/.envrc is blocked", etc.); whatever it printed is returned in -// message so callers can surface it in their command log. -func Load(cmd oscommands.ICmdObjBuilder) (message string, err error) { +func Load(cmd oscommands.ICmdObjBuilder) LoadResult { if _, lookupErr := exec.LookPath("direnv"); lookupErr != nil { - return "", nil + return LoadResult{} } stdout, stderr, runErr := cmd.New([]string{ "direnv", "export", "json", }).DontLog().RunWithOutputs() - message = strings.TrimRight(stderr, "\n") + + result := LoadResult{Message: strings.TrimRight(stderr, "\n")} // Apply whatever delta direnv produced even if it exited non-zero. // When the new dir's .envrc is blocked, direnv still emits a valid @@ -44,9 +60,21 @@ func Load(cmd oscommands.ICmdObjBuilder) (message string, err error) { // Prefer the runtime error (whose Error() text is direnv's stderr) // over a parse error, since it's the more actionable signal. if runErr != nil { - return message, runErr + result.Err = runErr + if envrcPath := queryBlockedEnvrc(cmd); envrcPath != "" { + result.Blocked = true + result.EnvrcPath = envrcPath + } + } else { + result.Err = parseErr } - return message, parseErr + return result +} + +// Allow runs `direnv allow ` to approve a .envrc file so the next +// Load can read it. +func Allow(cmd oscommands.ICmdObjBuilder, envrcPath string) error { + return cmd.New([]string{"direnv", "allow", envrcPath}).DontLog().Run() } func parseDirenvExport(stdout []byte) (map[string]*string, error) { @@ -60,3 +88,43 @@ func parseDirenvExport(stdout []byte) (map[string]*string, error) { } return delta, nil } + +// queryBlockedEnvrc asks direnv (via `status --json`) whether the current +// directory has a found-but-not-yet-allowed .envrc, and returns its path +// if so. We use direnv's structured output rather than parsing the +// human-readable "is blocked" line because the status output is more +// stable across versions and locales. +func queryBlockedEnvrc(cmd oscommands.ICmdObjBuilder) string { + stdout, _, err := cmd.New([]string{ + "direnv", "status", "--json", + }).DontLog().RunWithOutputs() + if err != nil { + return "" + } + return parseDirenvStatus([]byte(stdout)) +} + +func parseDirenvStatus(stdout []byte) string { + var status struct { + State struct { + FoundRC *struct { + Allowed int `json:"allowed"` + Path string `json:"path"` + } `json:"foundRC"` + } `json:"state"` + } + if err := json.Unmarshal(stdout, &status); err != nil { + return "" + } + if status.State.FoundRC == nil { + return "" + } + // direnv's AllowStatus enum (`internal/cmd/rc.go`): 0=Allowed, + // 1=NotAllowed, 2=Denied. Only NotAllowed is something the user + // can approve; Denied means they already said no. + const notAllowed = 1 + if status.State.FoundRC.Allowed != notAllowed { + return "" + } + return status.State.FoundRC.Path +} diff --git a/pkg/commands/direnv/direnv_test.go b/pkg/commands/direnv/direnv_test.go index 69b102d4d..43fdbce88 100644 --- a/pkg/commands/direnv/direnv_test.go +++ b/pkg/commands/direnv/direnv_test.go @@ -41,3 +41,48 @@ func TestParseDirenvExport(t *testing.T) { }) } } + +func TestParseDirenvStatus(t *testing.T) { + scenarios := []struct { + name string + input string + want string + }{ + { + name: "no .envrc found", + input: `{"state":{"foundRC":null}}`, + want: "", + }, + { + name: "found and allowed (0)", + input: `{"state":{"foundRC":{"allowed":0,"path":"/repo/.envrc"}}}`, + want: "", + }, + { + name: "found but not allowed (1) — eligible for approval", + input: `{"state":{"foundRC":{"allowed":1,"path":"/repo/.envrc"}}}`, + want: "/repo/.envrc", + }, + { + name: "found but denied (2) — user already said no", + input: `{"state":{"foundRC":{"allowed":2,"path":"/repo/.envrc"}}}`, + want: "", + }, + { + name: "malformed JSON", + input: `{not json`, + want: "", + }, + { + name: "empty input", + input: "", + want: "", + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.want, parseDirenvStatus([]byte(s.input))) + }) + } +} diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index f8972b837..bde1c47c6 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -171,13 +171,7 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey return err } - direnvMsg, direnvErr := direnv.Load(self.c.OS().Cmd) - if direnvMsg != "" { - self.c.LogCommand(direnvMsg, false) - } - if direnvErr != nil { - self.c.Log.WithError(direnvErr).Warn("direnv load failed") - } + direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd)) if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil { self.c.Log.Errorf("error recording current directory: %v", err) @@ -190,6 +184,57 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey return err } - return direnvErr + if direnvResult.Blocked { + self.c.OnUIThread(func() error { + self.promptDirenvApproval(direnvResult.EnvrcPath) + return nil + }) + return nil + } + + return direnvResult.Err + }) +} + +// logDirenvResult writes whatever direnv emitted to the command log and the +// debug log; both happen for every load attempt regardless of outcome. +func (self *ReposHelper) logDirenvResult(result direnv.LoadResult) direnv.LoadResult { + if result.Message != "" { + self.c.LogCommand(result.Message, false) + } + if result.Err != nil { + self.c.Log.WithError(result.Err).Warn("direnv load failed") + } + return result +} + +// promptDirenvApproval shows the user the contents of an unapproved .envrc +// and offers to run `direnv allow` for them. On confirm, we approve the +// file and re-run Load so the new env reaches subprocesses; on cancel we +// leave the env as-is (the previous repo's vars are already unloaded by +// the initial Load call, which is the correct state). +func (self *ReposHelper) promptDirenvApproval(envrcPath string) { + content, err := os.ReadFile(envrcPath) + if err != nil { + self.c.Log.WithError(err).Warn("could not read .envrc for approval prompt") + return + } + + indented := " " + strings.ReplaceAll(strings.TrimRight(string(content), "\n"), "\n", "\n ") + prompt := utils.ResolvePlaceholderString(self.c.Tr.DirenvApprovalPrompt, map[string]string{ + "confirmKey": self.c.UserConfig().Keybinding.Universal.Confirm.String(), + "cancelKey": self.c.UserConfig().Keybinding.Universal.Return.String(), + "content": indented, + }) + + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.DirenvApprovalTitle, + Prompt: prompt, + HandleConfirm: func() error { + if err := direnv.Allow(self.c.OS().Cmd, envrcPath); err != nil { + return err + } + return self.logDirenvResult(direnv.Load(self.c.OS().Cmd)).Err + }, }) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index b52c3f20e..5dcc80806 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -891,6 +891,8 @@ type TranslationSet struct { CreateWorktreeFromDetached string LcWorktree string ChangingDirectoryTo string + DirenvApprovalTitle string + DirenvApprovalPrompt string Name string Branch string Path string @@ -2012,6 +2014,8 @@ func EnglishTranslationSet() *TranslationSet { CreateWorktreeFromDetached: "Create worktree from {{.ref}} (detached)", LcWorktree: "worktree", ChangingDirectoryTo: "Changing directory to {{.path}}", + DirenvApprovalTitle: "Approve .envrc?", + DirenvApprovalPrompt: "Press {{.confirmKey}} to run 'direnv allow' and load the environment.\nPress {{.cancelKey}} to skip.\n\n{{.content}}", Name: "Name", Branch: "Branch", Path: "Path", diff --git a/pkg/integration/tests/misc/direnv_approves_envrc.go b/pkg/integration/tests/misc/direnv_approves_envrc.go new file mode 100644 index 000000000..60780ef19 --- /dev/null +++ b/pkg/integration/tests/misc/direnv_approves_envrc.go @@ -0,0 +1,90 @@ +package misc + +import ( + "os" + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// When the new repo's .envrc is blocked, lazygit offers the user a popup to +// approve it without leaving the app. Confirming runs `direnv allow` and +// re-runs the load so the env reaches subprocesses immediately. +var DirenvApprovesEnvrc = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Approving a blocked .envrc from the in-app popup loads its env", + ExtraCmdArgs: []string{}, + ExtraEnvVars: map[string]string{ + "PATH": "{{actualPath}}/bin:" + os.Getenv("PATH"), + }, + SetupConfig: func(cfg *config.AppConfig) { + otherRepo, _ := filepath.Abs("../other") + cfg.GetAppState().RecentRepos = []string{otherRepo} + cfg.GetUserConfig().CustomCommands = []config.CustomCommand{ + { + Key: config.Keybinding{"X"}, + Context: "files", + Command: `echo "VAR=$LG_DIRENV_TEST" > output.txt`, + }, + } + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("initial") + shell.CloneNonBare("other") + + shell.CreateFile("../other/.envrc", "export LG_DIRENV_TEST=approved_value\n") + + // Fake direnv that flips behavior once `direnv allow` runs. + // Before allow: export errors with the "blocked" signal, + // status reports allowed=1 (NotAllowed). + // On allow: create a sentinel and exit 0. + // After allow: export emits the loaded delta normally. + shell.CreateFile("../bin/direnv", `#!/bin/sh +SENTINEL="$(dirname "$0")/.approved" +case "$1 $2" in +"allow "*) + touch "$SENTINEL" + exit 0 + ;; +"export json") + if [ -f "$SENTINEL" ]; then + echo '{"LG_DIRENV_TEST":"approved_value"}' + echo "direnv: loading $PWD/.envrc" >&2 + else + echo '{"LG_DIRENV_TEST":null}' + echo "direnv: error $PWD/.envrc is blocked" >&2 + exit 1 + fi + ;; +"status --json") + if [ -f "$SENTINEL" ]; then + printf '{"state":{"foundRC":{"allowed":0,"path":"%s/.envrc"}}}\n' "$PWD" + else + printf '{"state":{"foundRC":{"allowed":1,"path":"%s/.envrc"}}}\n' "$PWD" + fi + ;; +esac +`) + shell.MakeExecutable("../bin/direnv") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.GlobalPress(keys.Universal.OpenRecentRepos) + t.ExpectPopup().Menu().Title(Equals("Recent repositories")). + Lines( + Contains("other").IsSelected(), + Contains("Cancel"), + ). + Confirm() + + t.ExpectPopup().Confirmation(). + Title(Equals("Approve .envrc?")). + Content(Contains("export LG_DIRENV_TEST=approved_value")). + Confirm() + + t.Views().Files(). + Focus(). + Press(config.Keybinding{"X"}). + NavigateToLine(Contains("output.txt")) + t.Views().Main().Content(Contains("VAR=approved_value")) + }, +}) diff --git a/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go b/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go index ca7afe104..541bc446a 100644 --- a/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go +++ b/pkg/integration/tests/misc/direnv_unloads_on_blocked_envrc.go @@ -11,11 +11,11 @@ import ( // Real direnv exits non-zero when the destination .envrc isn't authorized, // but it still emits a valid JSON delta on stdout that unloads vars from // the previously-active .envrc. We have to apply that delta anyway, or the -// previous repo's env leaks into the new one. The fake direnv here mimics -// that behavior; the test also asserts that the user gets an error popup -// (the command log alone is easy to miss). +// previous repo's env leaks into the new one. This test exercises the +// "skip approval" branch: the approval popup appears, the user cancels, +// and the previous repo's env is still gone. var DirenvUnloadsOnBlockedEnvrc = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Blocked .envrc unloads the previous repo's env and shows an error popup", + Description: "Blocked .envrc unloads the previous repo's env even if the user skips approval", ExtraCmdArgs: []string{}, ExtraEnvVars: map[string]string{ "PATH": "{{actualPath}}/bin:" + os.Getenv("PATH"), @@ -37,10 +37,19 @@ var DirenvUnloadsOnBlockedEnvrc = NewIntegrationTest(NewIntegrationTestArgs{ shell.EmptyCommit("initial") shell.CloneNonBare("other") + shell.CreateFile("../other/.envrc", "export LG_DIRENV_TEST=from_envrc\n") + shell.CreateFile("../bin/direnv", `#!/bin/sh -echo '{"LG_DIRENV_TEST":null}' -echo "direnv: error /repo/.envrc is blocked. Run 'direnv allow' to approve its content" >&2 -exit 1 +case "$1 $2" in +"export json") + echo '{"LG_DIRENV_TEST":null}' + echo "direnv: error $PWD/.envrc is blocked" >&2 + exit 1 + ;; +"status --json") + printf '{"state":{"foundRC":{"allowed":1,"path":"%s/.envrc"}}}\n' "$PWD" + ;; +esac `) shell.MakeExecutable("../bin/direnv") }, @@ -53,18 +62,15 @@ exit 1 ). Confirm() - t.ExpectPopup().Alert(). - Title(Equals("Error")). - Content(Contains("is blocked")). - Confirm() + t.ExpectPopup().Confirmation(). + Title(Equals("Approve .envrc?")). + Content(Contains("export LG_DIRENV_TEST=from_envrc")). + Cancel() - // If unload worked, $LG_DIRENV_TEST is empty in the custom command. t.Views().Files(). Focus(). Press(config.Keybinding{"X"}). - Lines( - Contains("output.txt").IsSelected(), - ) + NavigateToLine(Contains("output.txt")) t.Views().Main().Content(Contains("VAR=[]")) }, }) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 3679099e0..6f0032391 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -334,6 +334,7 @@ var tests = []*components.IntegrationTest{ misc.ConfirmOnQuit, misc.CopyConfirmationMessageToClipboard, misc.CopyToClipboard, + misc.DirenvApprovesEnvrc, misc.DirenvLoadedOnRepoSwitch, misc.DirenvUnloadsOnBlockedEnvrc, misc.InitialOpen, From c588c5507c60907654aec21043abbc4a1e75337b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 2 Jun 2026 06:54:39 +0200 Subject: [PATCH 052/384] Add a test demonstrating that you can't unstage a dirty submodule When a submodule has both a new commit (which the parent repo can stage) and dirty working-tree content (which it can't), staging it lands on a "MM" status. Pressing space again should unstage it, but instead it tries to stage the dirty content over and over, so you can never get back to an unstaged state. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/integration/tests/submodule/stage.go | 51 ++++++++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 52 insertions(+) create mode 100644 pkg/integration/tests/submodule/stage.go diff --git a/pkg/integration/tests/submodule/stage.go b/pkg/integration/tests/submodule/stage.go new file mode 100644 index 000000000..7303a0dbc --- /dev/null +++ b/pkg/integration/tests/submodule/stage.go @@ -0,0 +1,51 @@ +package submodule + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var Stage = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Stage and unstage a submodule that has both a new commit and dirty content. The new commit can be staged, but the dirty content can't, so unstaging must still work.", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path") + shell.GitAddAll() + shell.Commit("add submodule") + + // Give the submodule a new commit, which is a change that the parent + // repo can stage, as well as some dirty working-tree content, which + // the parent repo can never stage. This is what gets us a "MM" status + // once the new commit is staged. + shell.RunCommand([]string{"git", "-C", "my_submodule_path", "commit", "--allow-empty", "-m", "submodule commit"}) + shell.CreateFile("my_submodule_path/dirty_file", "dirty content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files().Focus(). + Lines( + Equals(" M my_submodule_path (submodule)").IsSelected(), + ). + // Staging the submodule stages the new commit, but the dirty + // content remains unstaged, leaving us at "MM". + PressPrimaryAction(). + Lines( + Equals("MM my_submodule_path (submodule)").IsSelected(), + ). + // Pressing again must unstage the submodule, taking us back to + // " M" rather than trying (and failing) to stage the dirty content. + PressPrimaryAction(). + /* EXPECTED: + Lines( + Equals(" M my_submodule_path (submodule)").IsSelected(), + ) + ACTUAL: */ + Lines( + Equals("MM my_submodule_path (submodule)").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 6f0032391..7cf31d28a 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -426,6 +426,7 @@ var tests = []*components.IntegrationTest{ submodule.RemoveNested, submodule.Reset, submodule.ResetFolder, + submodule.Stage, sync.FetchAndAutoForwardBranchesAllBranches, sync.FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree, sync.FetchAndAutoForwardBranchesNone, From 66fe18dd593f7c3d7bf98fd4b5bda67b726eec5d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 2 Jun 2026 11:58:38 +0200 Subject: [PATCH 053/384] Unify the stage/unstage decision for press and stage-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pressWithLock (acting on the selection) and toggleStagedAllWithLock (acting on the whole tree) each independently decided whether to stage or unstage, ran the optimistic update, and logged the action. That duplicated decision has already drifted: the tracked-files filter was added to press months before it was applied to stage-all, and fixes to one have repeatedly had to be chased into the other. Extract that shared decision into toggleStaged, leaving each caller to supply only the git commands it runs (per-path for the selection, bulk add -A / reset for the whole tree — the latter is required because the tree root node has an empty path, so a per-path stage wouldn't work). This is a pure refactor: the two callers' decisions were already equivalent, so behavior is unchanged. It exists so the next change to the staging logic only has to be made once. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/files_controller.go | 190 ++++++++++++------------ 1 file changed, 95 insertions(+), 95 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 8ea4425e6..c03ff71ab 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -445,13 +445,23 @@ func (self *FilesController) optimisticChange(nodes []*filetree.FileNode, optimi return nil } -func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) error { - // Obtaining this lock because optimistic rendering requires us to mutate - // the files in our model. - self.c.Mutexes().RefreshingFilesMutex.Lock() - defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - - for _, node := range selectedNodes { +// toggleStaged decides whether to stage or unstage the given nodes, updates the +// model optimistically, and then runs the matching git command via the supplied +// callbacks. press() (acting on the selection) and toggleStagedAll() (acting on +// the whole tree) share this; they differ only in the git commands they run, +// which is why those are passed in. +// +// If any node has unstaged changes we stage the nodes that have them (staging +// already-staged deleted files/folders would fail); otherwise we unstage all +// the nodes. +func (self *FilesController) toggleStaged( + nodes []*filetree.FileNode, + stageAction string, + unstageAction string, + stage func(unstagedNodes []*filetree.FileNode) error, + unstage func(nodes []*filetree.FileNode) error, +) error { + for _, node := range nodes { // if any files within have inline merge conflicts we can't stage or unstage, // or it'll end up with those >>>>>> lines actually staged if node.GetHasInlineMergeConflicts() { @@ -459,6 +469,35 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e } } + nodes = normalisedSelectedNodes(nodes) + + unstagedNodes := filterNodesHaveUnstagedChanges(nodes) + + if len(unstagedNodes) > 0 { + self.c.LogAction(stageAction) + + if err := self.optimisticChange(unstagedNodes, self.optimisticStage); err != nil { + return err + } + + return stage(unstagedNodes) + } + + self.c.LogAction(unstageAction) + + if err := self.optimisticChange(nodes, self.optimisticUnstage); err != nil { + return err + } + + return unstage(nodes) +} + +func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) error { + // Obtaining this lock because optimistic rendering requires us to mutate + // the files in our model. + self.c.Mutexes().RefreshingFilesMutex.Lock() + defer self.c.Mutexes().RefreshingFilesMutex.Unlock() + // When filtering, expand directory nodes to individual visible file paths // so that only filtered files are staged/unstaged. toPaths := func(nodes []*filetree.FileNode) []string { @@ -477,63 +516,46 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e }) } - selectedNodes = normalisedSelectedNodes(selectedNodes) - - // If any node has unstaged changes, we'll stage all the selected unstaged nodes (staging already staged deleted files/folders would fail). - // Otherwise, we unstage all the selected nodes. - unstagedSelectedNodes := filterNodesHaveUnstagedChanges(selectedNodes) - - if len(unstagedSelectedNodes) > 0 { + stage := func(unstagedNodes []*filetree.FileNode) error { var extraArgs []string - if self.context().GetStatusFilter() == filetree.DisplayTracked { extraArgs = []string{"-u"} } - self.c.LogAction(self.c.Tr.Actions.StageFile) - - if err := self.optimisticChange(unstagedSelectedNodes, self.optimisticStage); err != nil { - return err - } - - if err := self.c.Git().WorkingTree.StageFiles(toPaths(unstagedSelectedNodes), extraArgs); err != nil { - return err - } - } else { - self.c.LogAction(self.c.Tr.Actions.UnstageFile) - - if err := self.optimisticChange(selectedNodes, self.optimisticUnstage); err != nil { - return err - } - - if self.context().IsFiltering() { - // When filtering, only unstage visible files - if err := self.unstageFilteredFiles(selectedNodes); err != nil { - return err - } - } else { - // need to partition the paths into tracked and untracked (where we assume directories are tracked). Then we'll run the commands separately. - trackedNodes, untrackedNodes := utils.Partition(selectedNodes, func(node *filetree.FileNode) bool { - // We treat all directories as tracked. I'm not actually sure why we do this but - // it's been the existing behaviour for a while and nobody has complained - return !node.IsFile() || node.GetIsTracked() - }) - - if len(untrackedNodes) > 0 { - if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(toPaths(untrackedNodes)); err != nil { - return err - } - } - - if len(trackedNodes) > 0 { - if err := self.c.Git().WorkingTree.UnstageTrackedFiles(toPaths(trackedNodes)); err != nil { - return err - } - } - } + return self.c.Git().WorkingTree.StageFiles(toPaths(unstagedNodes), extraArgs) } - return nil + unstage := func(nodes []*filetree.FileNode) error { + if self.context().IsFiltering() { + // When filtering, only unstage visible files + return self.unstageFilteredFiles(nodes) + } + + // need to partition the paths into tracked and untracked (where we assume directories are tracked). Then we'll run the commands separately. + trackedNodes, untrackedNodes := utils.Partition(nodes, func(node *filetree.FileNode) bool { + // We treat all directories as tracked. I'm not actually sure why we do this but + // it's been the existing behaviour for a while and nobody has complained + return !node.IsFile() || node.GetIsTracked() + }) + + if len(untrackedNodes) > 0 { + if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(toPaths(untrackedNodes)); err != nil { + return err + } + } + + if len(trackedNodes) > 0 { + if err := self.c.Git().WorkingTree.UnstageTrackedFiles(toPaths(trackedNodes)); err != nil { + return err + } + } + + return nil + } + + return self.toggleStaged(selectedNodes, + self.c.Tr.Actions.StageFile, self.c.Tr.Actions.UnstageFile, + stage, unstage) } func (self *FilesController) press(nodes []*filetree.FileNode) error { @@ -721,19 +743,7 @@ func (self *FilesController) toggleStagedAllWithLock() error { root := self.context().FileTreeViewModel.GetRoot() - // if any files within have inline merge conflicts we can't stage or unstage, - // or it'll end up with those >>>>>> lines actually staged - if root.GetHasInlineMergeConflicts() { - return errors.New(self.c.Tr.ErrStageDirWithInlineMergeConflicts) - } - - if root.GetHasUnstagedChanges() { - self.c.LogAction(self.c.Tr.Actions.StageAllFiles) - - if err := self.optimisticChange([]*filetree.FileNode{root}, self.optimisticStage); err != nil { - return err - } - + stage := func(unstagedNodes []*filetree.FileNode) error { if self.context().IsFiltering() { // When filtering, only stage visible files var paths []string @@ -741,35 +751,25 @@ func (self *FilesController) toggleStagedAllWithLock() error { paths = append(paths, file.Path) return nil }) - if err := self.c.Git().WorkingTree.StageFiles(paths, nil); err != nil { - return err - } - } else { - onlyTrackedFiles := self.context().GetStatusFilter() == filetree.DisplayTracked - if err := self.c.Git().WorkingTree.StageAll(onlyTrackedFiles); err != nil { - return err - } - } - } else { - self.c.LogAction(self.c.Tr.Actions.UnstageAllFiles) - - if err := self.optimisticChange([]*filetree.FileNode{root}, self.optimisticUnstage); err != nil { - return err + return self.c.Git().WorkingTree.StageFiles(paths, nil) } - if self.context().IsFiltering() { - // When filtering, only unstage visible files - if err := self.unstageFilteredFiles([]*filetree.FileNode{root}); err != nil { - return err - } - } else { - if err := self.c.Git().WorkingTree.UnstageAll(); err != nil { - return err - } - } + onlyTrackedFiles := self.context().GetStatusFilter() == filetree.DisplayTracked + return self.c.Git().WorkingTree.StageAll(onlyTrackedFiles) } - return nil + unstage := func(nodes []*filetree.FileNode) error { + if self.context().IsFiltering() { + // When filtering, only unstage visible files + return self.unstageFilteredFiles(nodes) + } + + return self.c.Git().WorkingTree.UnstageAll() + } + + return self.toggleStaged([]*filetree.FileNode{root}, + self.c.Tr.Actions.StageAllFiles, self.c.Tr.Actions.UnstageAllFiles, + stage, unstage) } func (self *FilesController) unstageFiles(node *filetree.FileNode) error { From 3f0a7512f8ca251f17db757da52ced7d344f00ae Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 2 Jun 2026 12:09:09 +0200 Subject: [PATCH 054/384] Fix unstaging a submodule with dirty content The stage/unstage toggle decides what to do based on whether a node has unstaged changes: if it does, it stages; otherwise it unstages. For a submodule this breaks down, because dirty or untracked content inside the submodule always reports as an unstaged change in the parent repo but can never be staged from there. Once such a submodule's commit pointer is staged it sits at "MM", and every subsequent press keeps trying to stage the unstageable dirty content, so it can never be unstaged. Treat a submodule's unstaged change as stageable only when its commit isn't already staged, so that a staged submodule unstages on the next press regardless of leftover dirty content. Because the decision is now shared by press and stage-all, this fixes both at once. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/files_controller.go | 26 +++++++++++++++++++++--- pkg/integration/tests/submodule/stage.go | 5 ----- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index c03ff71ab..d8f883e40 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -471,7 +471,7 @@ func (self *FilesController) toggleStaged( nodes = normalisedSelectedNodes(nodes) - unstagedNodes := filterNodesHaveUnstagedChanges(nodes) + unstagedNodes := filterNodesHaveUnstagedChanges(nodes, self.c.Model().Submodules) if len(unstagedNodes) > 0 { self.c.LogAction(stageAction) @@ -1421,12 +1421,32 @@ func someNodesHaveStagedChanges(nodes []*filetree.FileNode) bool { return lo.SomeBy(nodes, (*filetree.FileNode).GetHasStagedChanges) } -func filterNodesHaveUnstagedChanges(nodes []*filetree.FileNode) []*filetree.FileNode { +func filterNodesHaveUnstagedChanges(nodes []*filetree.FileNode, submodules []*models.SubmoduleConfig) []*filetree.FileNode { return lo.Filter(nodes, func(node *filetree.FileNode, _ int) bool { - return node.GetHasUnstagedChanges() + return node.SomeFile(func(file *models.File) bool { + return fileHasStageableUnstagedChanges(file, submodules) + }) }) } +// For a submodule, the only thing the parent repo can stage is the +// commit-pointer change; dirty or untracked content within the submodule +// shows up as an unstaged change but can never be staged from the parent. So +// once the submodule's commit is staged (leaving it at e.g. "MM"), we mustn't +// treat the leftover unstaged change as stageable, or pressing space would +// keep trying to stage it instead of unstaging it. +func fileHasStageableUnstagedChanges(file *models.File, submodules []*models.SubmoduleConfig) bool { + if !file.HasUnstagedChanges { + return false + } + + if file.IsSubmodule(submodules) { + return !file.HasStagedChanges + } + + return true +} + func findSubmoduleNode(nodes []*filetree.FileNode, submodules []*models.SubmoduleConfig) *models.File { for _, node := range nodes { submoduleNode := node.FindFirstFileBy(func(f *models.File) bool { diff --git a/pkg/integration/tests/submodule/stage.go b/pkg/integration/tests/submodule/stage.go index 7303a0dbc..324d7690f 100644 --- a/pkg/integration/tests/submodule/stage.go +++ b/pkg/integration/tests/submodule/stage.go @@ -39,13 +39,8 @@ var Stage = NewIntegrationTest(NewIntegrationTestArgs{ // Pressing again must unstage the submodule, taking us back to // " M" rather than trying (and failing) to stage the dirty content. PressPrimaryAction(). - /* EXPECTED: Lines( Equals(" M my_submodule_path (submodule)").IsSelected(), ) - ACTUAL: */ - Lines( - Equals("MM my_submodule_path (submodule)").IsSelected(), - ) }, }) From c46c8744429c9dd29e95b62c8002f0875e635b62 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 2 Jun 2026 12:09:41 +0200 Subject: [PATCH 055/384] Also verify stage-all can unstage a dirty submodule Before the staging decision was unified, the stage (space) and stage-all (a) keybindings each made their own decision, so a fix to one wouldn't reach the other. Extend the test to drive the submodule through stage-all as well, guarding against that asymmetry coming back. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/integration/tests/submodule/stage.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/integration/tests/submodule/stage.go b/pkg/integration/tests/submodule/stage.go index 324d7690f..b8ef5e35f 100644 --- a/pkg/integration/tests/submodule/stage.go +++ b/pkg/integration/tests/submodule/stage.go @@ -6,7 +6,7 @@ import ( ) var Stage = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Stage and unstage a submodule that has both a new commit and dirty content. The new commit can be staged, but the dirty content can't, so unstaging must still work.", + Description: "Stage and unstage a submodule that has both a new commit and dirty content. The new commit can be staged, but the dirty content can't, so unstaging must still work; this must hold for both the stage (space) and stage-all (a) keybindings.", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) { @@ -39,6 +39,18 @@ var Stage = NewIntegrationTest(NewIntegrationTestArgs{ // Pressing again must unstage the submodule, taking us back to // " M" rather than trying (and failing) to stage the dirty content. PressPrimaryAction(). + Lines( + Equals(" M my_submodule_path (submodule)").IsSelected(), + ). + // The same has to hold for the stage-all keybinding, which shares + // the same decision logic: it stages the new commit... + Press(keys.Files.ToggleStagedAll). + Lines( + Equals("MM my_submodule_path (submodule)").IsSelected(), + ). + // ...and then unstages it again rather than getting stuck on the + // dirty content. + Press(keys.Files.ToggleStagedAll). Lines( Equals(" M my_submodule_path (submodule)").IsSelected(), ) From 8b5cfb0425cb8be7dee7ea2607358a3570212fdd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 2 Jun 2026 07:19:51 +0200 Subject: [PATCH 056/384] Optimistically render unstaging a dirty submodule This map only feeds the optimistic rendering that makes staging feel instant; it doesn't affect the eventual status, which git reports after the refresh. The "MM" entry can never be reached for a regular file: a file at "MM" has stageable unstaged changes, so pressing space stages it rather than unstaging, and the unstage path is where this map is used. The only thing that reaches the unstage path at "MM" is a submodule whose commit is staged on top of dirty content, so this entry exists purely to update that submodule instantly instead of waiting for the next git status. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/files_controller.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index d8f883e40..d4f38f0fb 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -387,6 +387,9 @@ var unstageStatusMap = map[string]string{ "A ": "??", "M ": " M", "D ": " D", + // A submodule with both a staged commit and unstageable dirty content; the + // staged commit gets unstaged, the dirty content stays. + "MM": " M", } func (self *FilesController) optimisticStage(file *models.File) bool { From 785c8a712cd239791ca1ee62595eb2c16a3f1657 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 2 Jun 2026 12:14:24 +0200 Subject: [PATCH 057/384] Explain when a submodule has nothing stageable A submodule that only has dirty or untracked content (no new commit) can't be staged from the parent repo, but it still shows up as having unstaged changes. Pressing stage on it therefore briefly flashed as staged and then reverted, without explaining why nothing was staged. Detect this case (via `git submodule status`, where a '+' prefix marks a stageable commit change) in the shared stage/unstage decision: if the only thing that looks stageable is such a submodule, don't try to stage it. Instead unstage if there's anything staged to unstage, so the toggle stays symmetric; otherwise show an error explaining that there's nothing to stage. Because the decision is shared, this covers both the stage (space) and stage-all (a) keybindings. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_commands/submodule.go | 25 ++++++++ pkg/gui/controllers/files_controller.go | 57 ++++++++++++++++++- pkg/i18n/english.go | 2 + .../stage_all_with_dirty_submodule.go | 46 +++++++++++++++ .../tests/submodule/stage_dirty_only.go | 53 +++++++++++++++++ pkg/integration/tests/test_list.go | 2 + 6 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/submodule/stage_all_with_dirty_submodule.go create mode 100644 pkg/integration/tests/submodule/stage_dirty_only.go diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index acb335e35..7a3cb687b 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -9,6 +9,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/samber/lo" ) // .gitmodules looks like this: @@ -86,6 +87,30 @@ func (self *SubmoduleCommands) GetConfigs(parentModule *models.SubmoduleConfig) return configs, nil } +// AnyHaveStageableChanges reports whether any of the given submodule paths has +// a checked-out commit that differs from the one recorded in the +// superproject's index, i.e. a change that `git add ` would actually +// stage. A submodule that only has dirty or untracked content (with no new +// commit) can't be staged from the superproject, so it won't be reported here. +func (self *SubmoduleCommands) AnyHaveStageableChanges(paths []string) (bool, error) { + if len(paths) == 0 { + return false, nil + } + + cmdArgs := NewGitCmd("submodule").Arg("status", "--").Arg(paths...).ToArgv() + output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() + if err != nil { + return false, err + } + + // Each line looks like " ()". A '+' prefix + // means the checked-out commit differs from the index, i.e. there's a + // commit change to stage. + return lo.SomeBy(strings.Split(output, "\n"), func(line string) bool { + return strings.HasPrefix(line, "+") + }), nil +} + func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { // if the path does not exist then it hasn't yet been initialized so we'll swallow the error // because the intention here is to have no dirty worktree state diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index d4f38f0fb..09f654e2b 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -476,7 +476,22 @@ func (self *FilesController) toggleStaged( unstagedNodes := filterNodesHaveUnstagedChanges(nodes, self.c.Model().Submodules) - if len(unstagedNodes) > 0 { + // Staging a submodule that only has dirty or untracked content (no new + // commit) is a no-op: the parent repo can't stage that content. When that's + // the only thing that looks stageable, don't stage; fall through to + // unstaging instead. That keeps the toggle symmetric (e.g. a fully-staged + // tree that also contains a dirty submodule still unstages on the next + // press) rather than getting stuck trying to stage the unstageable content. + shouldStage := len(unstagedNodes) > 0 + if shouldStage { + noOp, err := self.stagingWouldBeNoOp(unstagedNodes) + if err != nil { + return err + } + shouldStage = !noOp + } + + if shouldStage { self.c.LogAction(stageAction) if err := self.optimisticChange(unstagedNodes, self.optimisticStage); err != nil { @@ -486,6 +501,12 @@ func (self *FilesController) toggleStaged( return stage(unstagedNodes) } + // If there's nothing staged to unstage either, then the only thing we acted + // on was an unstageable submodule and nothing happened, so say why. + if !someNodesHaveStagedChanges(nodes) { + return errors.New(self.c.Tr.NothingToStageForSubmodule) + } + self.c.LogAction(unstageAction) if err := self.optimisticChange(nodes, self.optimisticUnstage); err != nil { @@ -1450,6 +1471,40 @@ func fileHasStageableUnstagedChanges(file *models.File, submodules []*models.Sub return true } +// stagingWouldBeNoOp reports whether staging the given nodes would have no +// visible effect, which happens when the only things being staged are +// submodules that have dirty or untracked content but no new commit: the +// parent repo can't stage that content. If a regular file (or a submodule with +// a stageable new commit) is among them, staging does something, so this +// returns false. +func (self *FilesController) stagingWouldBeNoOp(nodes []*filetree.FileNode) (bool, error) { + submodules := self.c.Model().Submodules + + var submodulePaths []string + hasOtherStageableChanges := false + for _, node := range nodes { + _ = node.ForEachFile(func(file *models.File) error { + if file.IsSubmodule(submodules) { + submodulePaths = append(submodulePaths, file.Path) + } else if file.HasUnstagedChanges { + hasOtherStageableChanges = true + } + return nil + }) + } + + if hasOtherStageableChanges || len(submodulePaths) == 0 { + return false, nil + } + + anyStageable, err := self.c.Git().Submodule.AnyHaveStageableChanges(submodulePaths) + if err != nil { + return false, err + } + + return !anyStageable, nil +} + func findSubmoduleNode(nodes []*filetree.FileNode, submodules []*models.SubmoduleConfig) *models.File { for _, node := range nodes { submoduleNode := node.FindFirstFileBy(func(f *models.File) bool { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 5dcc80806..d112c0379 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -917,6 +917,7 @@ type TranslationSet struct { SelectedItemIsNotABranch string SelectedItemDoesNotHaveFiles string MultiSelectNotSupportedForSubmodules string + NothingToStageForSubmodule string CommandDoesNotSupportOpeningInEditor string CustomCommands string NoApplicableCommandsInThisContext string @@ -2038,6 +2039,7 @@ func EnglishTranslationSet() *TranslationSet { SelectedItemIsNotABranch: "Selected item is not a branch", SelectedItemDoesNotHaveFiles: "Selected item does not have files to view", MultiSelectNotSupportedForSubmodules: "Multiselection not supported for submodules", + NothingToStageForSubmodule: "Nothing to stage: the parent repo can only stage a new submodule commit, not the uncommitted changes inside a submodule. Commit inside the submodule first.", CommandDoesNotSupportOpeningInEditor: "This command doesn't support switching to the editor", CustomCommands: "Custom commands", NoApplicableCommandsInThisContext: "(No applicable commands in this context)", diff --git a/pkg/integration/tests/submodule/stage_all_with_dirty_submodule.go b/pkg/integration/tests/submodule/stage_all_with_dirty_submodule.go new file mode 100644 index 000000000..ca54a5970 --- /dev/null +++ b/pkg/integration/tests/submodule/stage_all_with_dirty_submodule.go @@ -0,0 +1,46 @@ +package submodule + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageAllWithDirtySubmodule = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A submodule with only dirty content (which can't be staged) must not break the stage-all toggle: pressing it repeatedly should keep toggling the other files between staged and unstaged.", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path") + shell.GitAddAll() + shell.Commit("add submodule") + + // A submodule with dirty content but no new commit (can't be staged), + // alongside a regular file that can. + shell.CreateFile("my_submodule_path/dirty_file", "dirty content") + shell.CreateFile("regular_file", "content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files().Focus(). + Lines( + Equals(" M my_submodule_path (submodule)"), + Equals("?? regular_file"), + ). + // Stage all: the regular file gets staged; the submodule can't be. + Press(keys.Files.ToggleStagedAll). + Lines( + Equals(" M my_submodule_path (submodule)"), + Equals("A regular_file"), + ). + // Stage all again: nothing is stageable, but the regular file is + // staged, so this unstages it rather than erroring on the submodule. + Press(keys.Files.ToggleStagedAll). + Lines( + Equals(" M my_submodule_path (submodule)"), + Equals("?? regular_file"), + ) + }, +}) diff --git a/pkg/integration/tests/submodule/stage_dirty_only.go b/pkg/integration/tests/submodule/stage_dirty_only.go new file mode 100644 index 000000000..3ae20e677 --- /dev/null +++ b/pkg/integration/tests/submodule/stage_dirty_only.go @@ -0,0 +1,53 @@ +package submodule + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var StageDirtyOnly = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Pressing space on a submodule that only has dirty content (no new commit) can't stage anything, so we explain that with an error instead of silently doing nothing.", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path") + shell.GitAddAll() + shell.Commit("add submodule") + + // Dirty working-tree content, but no new commit: there's nothing the + // parent repo can stage. + shell.CreateFile("my_submodule_path/dirty_file", "dirty content") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files().Focus(). + Lines( + Equals(" M my_submodule_path (submodule)").IsSelected(), + ). + PressPrimaryAction(). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("Nothing to stage")). + Confirm() + }). + // The status is unchanged: nothing got staged. + Lines( + Equals(" M my_submodule_path (submodule)").IsSelected(), + ). + // Pressing "stage all" must behave the same way. + Press(keys.Files.ToggleStagedAll). + Tap(func() { + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("Nothing to stage")). + Confirm() + }). + Lines( + Equals(" M my_submodule_path (submodule)").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 7cf31d28a..0f3a40634 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -427,6 +427,8 @@ var tests = []*components.IntegrationTest{ submodule.Reset, submodule.ResetFolder, submodule.Stage, + submodule.StageAllWithDirtySubmodule, + submodule.StageDirtyOnly, sync.FetchAndAutoForwardBranchesAllBranches, sync.FetchAndAutoForwardBranchesAllBranchesCheckedOutInOtherWorktree, sync.FetchAndAutoForwardBranchesNone, From badb089a864fd6ae385bc53a7f89c8f13d1d8ee2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 7 Jun 2026 15:49:25 +0200 Subject: [PATCH 058/384] Add a "just check" command --- justfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/justfile b/justfile index 034a25b4d..36e8ee3d6 100644 --- a/justfile +++ b/justfile @@ -46,6 +46,10 @@ e2e-tui *args: e2e-all: go test pkg/integration/clients/*.go +# Run some tests on the current commit, similar to what CI does. +check: + ./scripts/check_commit.sh + bump-gocui: scripts/bump_gocui.sh From 9739a433551c54b457d84531f935e75fb94586ec Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 7 Jun 2026 15:32:22 +0200 Subject: [PATCH 059/384] Terminate tooltip text with a full stop This is our general convention for tooltips, it was just forgotten here. --- docs-master/keybindings/Keybindings_en.md | 2 +- docs-master/keybindings/Keybindings_ja.md | 2 +- docs-master/keybindings/Keybindings_ko.md | 2 +- docs-master/keybindings/Keybindings_nl.md | 2 +- docs-master/keybindings/Keybindings_pl.md | 2 +- docs-master/keybindings/Keybindings_pt.md | 2 +- docs-master/keybindings/Keybindings_ru.md | 2 +- docs-master/keybindings/Keybindings_zh-TW.md | 2 +- pkg/i18n/english.go | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index d63058d82..d9970b0c5 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -22,7 +22,7 @@ _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 | Choose the next pager in the list of configured pagers. | | `` `` | 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 d9b87d747..f9e06c00c 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -22,7 +22,7 @@ _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 | Choose the next pager in the list of configured pagers. | | `` `` | キャンセル | | | `` ? `` | キーバインディングメニューを開く | | | `` `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 089543c5f..d97537d2c 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -22,7 +22,7 @@ _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 | Choose the next pager in the list of configured pagers. | | `` `` | 취소 | | | `` ? `` | 매뉴 열기 | | | `` `` | 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 1715c597e..bb5b19671 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -22,7 +22,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` 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 | Choose the next pager in the list of configured pagers. | | `` `` | Annuleren | | | `` ? `` | Open menu | | | `` `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index b032a6606..404a6fa37 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -22,7 +22,7 @@ _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 | Choose the next pager in the list of configured pagers. | | `` `` | 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 c19619191..bfe59c7d5 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -22,7 +22,7 @@ _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 | Choose the next pager in the list of configured pagers. | | `` `` | 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 c802678b3..344c37884 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -22,7 +22,7 @@ _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 | Choose the next pager in the list of configured pagers. | | `` `` | Отменить | | | `` ? `` | Открыть меню | | | `` `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index d6526b5b2..dcbb95b22 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -22,7 +22,7 @@ _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 | Choose the next pager in the list of configured pagers. | | `` `` | 取消 | | | `` ? `` | 開啟選單 | | | `` `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index d112c0379..ef996a198 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -1737,7 +1737,7 @@ func EnglishTranslationSet() *TranslationSet { 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", + CyclePagersTooltip: "Choose the next pager in the list of configured pagers.", CyclePagersDisabledReason: "No other pagers configured", StartSearch: "Search the current view by text", StartFilter: "Filter the current view by text", From 81420ce36204f5c4ff3764a330bfce1dddbe0634 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 7 Jun 2026 14:08:02 +0200 Subject: [PATCH 060/384] Reject pager entries that combine multiple diff mechanisms A pager (GIT_PAGER) formats the diff git produces, while externalDiffCommand and useExternalDiffGitConfig change how git produces the diff in the first place. They are different pipeline stages, not alternatives, so combining them on one entry just pipes one through the other and produces garbled output (e.g. delta trying to parse difftastic's side-by-side output as a unified diff). The two external mechanisms likewise conflict, with the explicit command silently shadowing the git config one. Treat all three as mutually exclusive and reject configs that set more than one on the same entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-master/Config.md | 3 +++ docs-master/Custom_Pagers.md | 4 ++- pkg/config/user_config.go | 2 ++ pkg/config/user_config_validation.go | 27 ++++++++++++++++++++ pkg/config/user_config_validation_test.go | 31 +++++++++++++++++++++++ schema-master/config.json | 2 +- 6 files changed, 67 insertions(+), 2 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 9f7921821..6de2ad978 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -361,6 +361,9 @@ git: # # 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: [] diff --git a/docs-master/Custom_Pagers.md b/docs-master/Custom_Pagers.md index 903928d46..0bfffe7dc 100644 --- a/docs-master/Custom_Pagers.md +++ b/docs-master/Custom_Pagers.md @@ -71,7 +71,7 @@ git: - externalDiffCommand: difft --color=always ``` -The `colorArg` and `pager` options are not used in this case. +The `colorArg` option is not used in this case. You can add whatever extra arguments you prefer for your difftool; for instance @@ -91,6 +91,8 @@ git: 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. + ## Emulating custom pagers on Windows There is a trick to emulate custom pagers on Windows using a Powershell script configured as an external diff command. It's not perfect, but certainly better than nothing. To do this, save the following script as `lazygit-pager.ps1` at a convenient place on your disk: diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index cac87ec91..acadc8e80 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -275,6 +275,8 @@ type GitConfig struct { // # 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"` // Config relating to committing diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 163fc61c4..109b3f1d0 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -46,6 +46,9 @@ func (config *UserConfig) Validate() error { []string{"always", "never", "when-maximised"}); err != nil { return err } + if err := validatePagers(config.Git.Pagers); err != nil { + return err + } if err := validateKeybindings(config.Keybinding); err != nil { return err } @@ -71,6 +74,30 @@ 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) + } + } + return nil +} + func validateEnum(name string, value string, allowedValues []string) error { if slices.Contains(allowedValues, value) { return nil diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index bb2d2580f..26c9b7145 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -323,3 +323,34 @@ func TestUserConfigValidate_spinnerFrames(t *testing.T) { }) } } + +func TestUserConfigValidate_pagers(t *testing.T) { + scenarios := []struct { + name string + pager PagingConfig + 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}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + config := GetDefaultConfig() + config.Git.Pagers = []PagingConfig{s.pager} + err := config.Validate() + + if s.valid { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +} diff --git a/schema-master/config.json b/schema-master/config.json index 2e968ba8f..9042986aa 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -321,7 +321,7 @@ "$ref": "#/$defs/PagingConfig" }, "type": "array", - "description": "Array of pagers. Each entry has the following format:\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\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md for more information." + "description": "Array of pagers. Each entry has the following format:\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." }, "commit": { "$ref": "#/$defs/CommitConfig", From 6316094d581f6501ca4a1980e32f94aadf3288df Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 7 Jun 2026 11:37:02 +0200 Subject: [PATCH 061/384] Show pager name in the cycle-pager toast, and let users name pagers When cycling pagers, "Selected pager 2 of 3" gives no clue which pager you landed on; with several configured you have to remember the order. Include the pager's name in the toast instead. The name is normally derived from the first word of the pager command, but that isn't always enough: two entries can share a command but differ in options (e.g. "delta" and "delta --side-by-side"), and an entry may have no command at all (the default entry, or when using useExternalDiffGitConfig). So add an optional `name` field that overrides the derived name. The message was also hardcoded in English; localize it while we're here. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-master/Config.md | 5 +++ pkg/config/pager_config.go | 39 ++++++++++++++++ pkg/config/pager_config_test.go | 57 ++++++++++++++++++++++++ pkg/config/user_config.go | 7 +++ pkg/gui/controllers/global_controller.go | 20 +++++++-- pkg/i18n/english.go | 6 +++ schema-master/config.json | 6 ++- 7 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 pkg/config/pager_config_test.go diff --git a/docs-master/Config.md b/docs-master/Config.md index 6de2ad978..07942c659 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -342,6 +342,11 @@ gui: git: # Array of pagers. 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). + # 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' # colorArg: "always" diff --git a/pkg/config/pager_config.go b/pkg/config/pager_config.go index e721da0e8..1b562ccd7 100644 --- a/pkg/config/pager_config.go +++ b/pkg/config/pager_config.go @@ -2,6 +2,7 @@ package config import ( "strconv" + "strings" "github.com/jesseduffield/lazygit/pkg/utils" ) @@ -80,3 +81,41 @@ func (self *PagerConfig) CyclePagers() { 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 new file mode 100644 index 000000000..e2618d5bd --- /dev/null +++ b/pkg/config/pager_config_test.go @@ -0,0 +1,57 @@ +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()) +} diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index acadc8e80..aa2d945d6 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -256,6 +256,11 @@ type GitConfig struct { // Array of pagers. Each entry has the following format: // [dev] The following documentation is duplicated from the PagingConfig 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). + // 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' // colorArg: "always" @@ -347,6 +352,8 @@ func (PagerType) JSONSchemaExtend(schema *jsonschema.Schema) { // [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). + 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' ColorArg string `yaml:"colorArg" jsonschema:"enum=always,enum=never"` // e.g. diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index d2ca28c60..7879ab7ac 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -1,10 +1,11 @@ package controllers import ( - "fmt" + "strconv" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" ) type GlobalController struct { @@ -166,8 +167,21 @@ func (self *GlobalController) cyclePagers() error { currentSide.HandleRenderToMain() } - current, total := self.c.State().GetPagerConfig().CurrentPagerIndex() - self.c.Toast(fmt.Sprintf("Selected pager %d of %d", current+1, total)) + 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{ + "name": name, + "current": strconv.Itoa(current + 1), + "total": strconv.Itoa(total), + })) return nil } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index ef996a198..7ff5137f6 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -608,6 +608,9 @@ type TranslationSet struct { CyclePagers string CyclePagersTooltip string CyclePagersDisabledReason string + SelectedPager string + DefaultPagerName string + ExternalDiffPagerName string StartSearch string StartFilter string SelectRemoteRepository string @@ -1739,6 +1742,9 @@ func EnglishTranslationSet() *TranslationSet { CyclePagers: "Cycle pagers", CyclePagersTooltip: "Choose the next pager in the list of configured pagers.", CyclePagersDisabledReason: "No other pagers configured", + SelectedPager: "Pager: {{.name}} ({{.current}} of {{.total}})", + DefaultPagerName: "(default)", + ExternalDiffPagerName: "(external diff)", StartSearch: "Search the current view by text", StartFilter: "Filter the current view by text", SelectRemoteRepository: "Select base repository for pull requests", diff --git a/schema-master/config.json b/schema-master/config.json index 9042986aa..816c46213 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -321,7 +321,7 @@ "$ref": "#/$defs/PagingConfig" }, "type": "array", - "description": "Array of pagers. Each entry has the following format:\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": "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." }, "commit": { "$ref": "#/$defs/CommitConfig", @@ -3488,6 +3488,10 @@ }, "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": [ From 5a4247b234050b60a6d75dabae8c8fc361941431 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 7 Jun 2026 11:40:26 +0200 Subject: [PATCH 062/384] Extract onPagerChanged helper from cyclePagers A reverse-cycle handler is about to need the same re-render-and-toast logic. Pull it out first so the behavior change that follows only has to swap the cycle direction. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/global_controller.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index 7879ab7ac..4cd5730af 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -159,6 +159,13 @@ func (self *GlobalController) prevScreenMode() error { func (self *GlobalController) cyclePagers() error { self.c.State().GetPagerConfig().CyclePagers() + self.onPagerChanged() + 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() { currentSide := self.c.Context().CurrentSide() currentKey := self.c.Context().Current().GetKey() if currentSide.GetKey() == currentKey || @@ -182,7 +189,6 @@ func (self *GlobalController) cyclePagers() error { "current": strconv.Itoa(current + 1), "total": strconv.Itoa(total), })) - return nil } func (self *GlobalController) canCyclePagers() *types.DisabledReason { From 8534a05a2ef469e2fe96ae1601c61bfe3b2a0c55 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 7 Jun 2026 11:48:22 +0200 Subject: [PATCH 063/384] Allow cycling pagers in reverse With more than a couple of pagers, having to cycle forward through all of them to reach the previous one (or to back out of an accidental press of `|`) is tedious. Add a second binding that cycles backward. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-master/Config.md | 1 + docs-master/keybindings/Keybindings_en.md | 1 + docs-master/keybindings/Keybindings_ja.md | 1 + docs-master/keybindings/Keybindings_ko.md | 1 + docs-master/keybindings/Keybindings_nl.md | 1 + docs-master/keybindings/Keybindings_pl.md | 1 + docs-master/keybindings/Keybindings_pt.md | 1 + docs-master/keybindings/Keybindings_ru.md | 1 + docs-master/keybindings/Keybindings_zh-CN.md | 1 + docs-master/keybindings/Keybindings_zh-TW.md | 1 + pkg/config/pager_config.go | 5 +++ pkg/config/pager_config_test.go | 25 +++++++++++ pkg/config/user_config.go | 2 + pkg/gui/controllers/global_controller.go | 13 ++++++ pkg/i18n/english.go | 4 ++ pkg/integration/tests/diff/cycle_pagers.go | 45 ++++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + schema-master/config.json | 14 ++++++ 18 files changed, 119 insertions(+) create mode 100644 pkg/integration/tests/diff/cycle_pagers.go diff --git a/docs-master/Config.md b/docs-master/Config.md index 07942c659..fa6b3eeac 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -675,6 +675,7 @@ keybinding: nextScreenMode: + prevScreenMode: _ cyclePagers: '|' + cyclePagersReverse: \ undo: z redo: Z filteringMenu: diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index d9970b0c5..07d4d95a4 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -23,6 +23,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` + `` | 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. | | `` `` | 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 f9e06c00c..5bf6797bd 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -23,6 +23,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` + `` | 次の画面モード(通常/半分/全画面) | | | `` _ `` | 前の画面モード | | | `` \| `` | 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. | | `` `` | キャンセル | | | `` ? `` | キーバインディングメニューを開く | | | `` `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index d97537d2c..e80515daa 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -23,6 +23,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` + `` | 다음 스크린 모드 (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. | | `` `` | 취소 | | | `` ? `` | 매뉴 열기 | | | `` `` | 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 bb5b19671..76764eda5 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -23,6 +23,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` + `` | 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. | | `` `` | Annuleren | | | `` ? `` | Open menu | | | `` `` | Bekijk scoping opties | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 404a6fa37..aa510a813 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -23,6 +23,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` + `` | 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. | | `` `` | 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 bfe59c7d5..efe0d24ed 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -23,6 +23,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` + `` | 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. | | `` `` | 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 344c37884..1f952ed6b 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -23,6 +23,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` + `` | Следующий режим экрана (нормальный/полуэкранный/полноэкранный) | | | `` _ `` | Предыдущий режим экрана | | | `` \| `` | 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. | | `` `` | Отменить | | | `` ? `` | Открыть меню | | | `` `` | Просмотреть параметры фильтрации по пути | 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 9cb7d5186..e1dbbe9c6 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -23,6 +23,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` + `` | 下一屏模式(正常/半屏/全屏) | | | `` _ `` | 上一屏模式 | | | `` \| `` | 切换分页器 | 从已配置的分页器列表中选择下一个分页器 | +| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | | `` `` | 取消 | | | `` ? `` | 打开菜单 | | | `` `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index dcbb95b22..bf13db65d 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -23,6 +23,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` + `` | 下一個螢幕模式(常規/半螢幕/全螢幕) | | | `` _ `` | 上一個螢幕模式 | | | `` \| `` | 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. | | `` `` | 取消 | | | `` ? `` | 開啟選單 | | | `` `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. | diff --git a/pkg/config/pager_config.go b/pkg/config/pager_config.go index 1b562ccd7..d243a01b2 100644 --- a/pkg/config/pager_config.go +++ b/pkg/config/pager_config.go @@ -78,6 +78,11 @@ 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) } diff --git a/pkg/config/pager_config_test.go b/pkg/config/pager_config_test.go index e2618d5bd..7267b9228 100644 --- a/pkg/config/pager_config_test.go +++ b/pkg/config/pager_config_test.go @@ -55,3 +55,28 @@ func TestCurrentPagerNameWithoutPagers(t *testing.T) { 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 aa2d945d6..d1f760ed1 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -511,6 +511,7 @@ type KeybindingUniversalConfig struct { 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"` @@ -1019,6 +1020,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { NextScreenMode: Keybinding{"+"}, PrevScreenMode: Keybinding{"_"}, CyclePagers: Keybinding{"|"}, + CyclePagersReverse: Keybinding{"\\"}, Undo: Keybinding{"z"}, Redo: Keybinding{"Z"}, FilteringMenu: Keybinding{""}, diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index 4cd5730af..fdb2e3153 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -68,6 +68,13 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type Description: self.c.Tr.CyclePagers, Tooltip: self.c.Tr.CyclePagersTooltip, }, + { + 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.Return), Handler: self.escape, @@ -163,6 +170,12 @@ func (self *GlobalController) cyclePagers() error { return nil } +func (self *GlobalController) cyclePagersBackward() error { + self.c.State().GetPagerConfig().CyclePagersBackward() + self.onPagerChanged() + 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() { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 7ff5137f6..20d0d5ff6 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -607,6 +607,8 @@ type TranslationSet struct { PrevScreenMode string CyclePagers string CyclePagersTooltip string + CyclePagersReverse string + CyclePagersReverseTooltip string CyclePagersDisabledReason string SelectedPager string DefaultPagerName string @@ -1741,6 +1743,8 @@ func EnglishTranslationSet() *TranslationSet { 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)", diff --git a/pkg/integration/tests/diff/cycle_pagers.go b/pkg/integration/tests/diff/cycle_pagers.go new file mode 100644 index 000000000..2f2da9a5b --- /dev/null +++ b/pkg/integration/tests/diff/cycle_pagers.go @@ -0,0 +1,45 @@ +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/test_list.go b/pkg/integration/tests/test_list.go index 0f3a40634..1b264e50d 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -210,6 +210,7 @@ var tests = []*components.IntegrationTest{ demo.Undo, demo.WorktreeCreateFromBranches, diff.CopyToClipboard, + diff.CyclePagers, diff.Diff, diff.DiffAndApplyPatch, diff.DiffCommits, diff --git a/schema-master/config.json b/schema-master/config.json index 816c46213..b95c5c980 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -3127,6 +3127,20 @@ ], "default": "|" }, + "cyclePagersReverse": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\\" + }, "undo": { "oneOf": [ { From 061665726eeca04e61fa478a1494aa704a40eb4f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 16 Jun 2026 16:21:13 +0200 Subject: [PATCH 064/384] Fix Windows linter errors Apparently we don't check Windows-only code for linter errors on CI. --- pkg/logs/tail/logs_windows.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/logs/tail/logs_windows.go b/pkg/logs/tail/logs_windows.go index cf7aa395f..b116cb405 100644 --- a/pkg/logs/tail/logs_windows.go +++ b/pkg/logs/tail/logs_windows.go @@ -11,8 +11,8 @@ import ( ) func tailLogsForPlatform(logFilePath string, opts *humanlog.HandlerOptions) { - var lastModified int64 = 0 - var lastOffset int64 = 0 + var lastModified int64 + var lastOffset int64 for { stat, err := os.Stat(logFilePath) if err != nil { From df171722cb6b3595d7d6d44c35adbc6abfa1b585 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 16 Jun 2026 16:21:13 +0200 Subject: [PATCH 065/384] Make .go files have LF line endings on Windows Since gofumpt expects and emits LF even on Windows, this makes it easier for agents to gofumpt their files. --- .gitattributes | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 8143bb75f..ec9895f39 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,3 @@ -*.go text +*.go text eol=lf *.md text eol=lf *.json text eol=lf From fcce4de6fcbbcd1da8ecef925702c2c480454df0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 16 Jun 2026 13:25:45 +0200 Subject: [PATCH 066/384] Allow running check_script.sh when there are uncommitted changes --- scripts/check_commit.sh | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/scripts/check_commit.sh b/scripts/check_commit.sh index 9cc5e5c7b..37557462e 100755 --- a/scripts/check_commit.sh +++ b/scripts/check_commit.sh @@ -5,15 +5,13 @@ set -e -git diff --quiet || { - echo "Error: there are unstaged changes. Please stage or stash them before running this script." - exit 1 -} - just test just lint + +status_before_generate=$(git status --porcelain=v1) just generate -git diff --quiet || { +status_after_generate=$(git status --porcelain=v1) +if [[ "$status_after_generate" != "$status_before_generate" ]]; then echo "Error: auto-generated files not up to date." exit 1 -} +fi From 643f169be2baad95604a4d8b23fa0c2a9dd4db1d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 16 Jun 2026 13:38:49 +0200 Subject: [PATCH 067/384] Don't include integration tests in "just test" on Windows This allows running "just check" on Windows, it just doesn't check quite as much. --- justfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/justfile b/justfile index 36e8ee3d6..e7f9fcdc5 100644 --- a/justfile +++ b/justfile @@ -22,8 +22,13 @@ unit-test: go test ./... -short # Run both unit tests and integration tests. +[unix] test: unit-test e2e-all +# On Windows, integration tests are not supported right now +[windows] +test: unit-test + # Generate all our auto-generated files (test list, cheatsheets, json schema, maybe other things in the future) generate: go generate ./... From 2f18db437781e7842310403fac44ef23b17b628d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 19 May 2026 14:15:02 +0200 Subject: [PATCH 068/384] Add regression tests for trailing-fill rendering The next few commits restructure how the view's draw() decides the fg/bg of cells past the end of a line's content. Pin down three existing behaviors first so the restructuring stays a refactor: - '\n' should reset attributes for the trailing area so a reversed final cell doesn't bleed into empty space. - An unterminated line with AttrReverse on its last cell should propagate that to the right edge (otherwise the rendered bg abruptly stops at the last character). - '\x1b[K' on a line that fits within InnerWidth should fill the remaining cells with the current bg color. Introduce a small WithSimulationScreen helper that swaps in a tcell mock terminal so tests can call view.draw() and inspect rendered cells via Screen.Get(). Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gocui/view_test.go | 91 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index a7023be43..ef56d6756 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -8,10 +8,28 @@ import ( "strings" "testing" + "github.com/gdamore/tcell/v3" + "github.com/gdamore/tcell/v3/color" "github.com/rivo/uniseg" "github.com/stretchr/testify/assert" ) +// WithSimulationScreen swaps the package-level Screen for a tcell +// terminfo-backed mock terminal so tests can call view.draw() and +// inspect rendered cells via Screen.Get(). The previous Screen is +// restored on test cleanup. +func WithSimulationScreen(t *testing.T, width, height int) { + t.Helper() + saved := Screen + if err := (&Gui{}).tcellInitSimulation(width, height); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + Screen.Fini() + Screen = saved + }) +} + func TestWriteString(t *testing.T) { tests := []struct { existingLines []string @@ -413,3 +431,76 @@ func TestLineWrap(t *testing.T) { }) } } + +// TestNewlineTerminatedLineClearsTrailingBg verifies that a '\n' resets +// any attributes (e.g. AttrReverse-driven background) past the line's +// content, so a reversed cell at the end doesn't bleed into the empty +// area to the right. +func TestNewlineTerminatedLineClearsTrailingBg(t *testing.T) { + WithSimulationScreen(t, 14, 5) + + v := NewView("name", 0, 0, 11, 4, OutputNormal) + + // \x1b[7m sets reverse; \x1b[31m sets fg=red. With reverse the cell + // 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() + + // First row: cells 1..3 are "foo" (render with red bg via reverse), + // cells 4..10 are trailing and should be plain default. + for x := 4; x <= 10; x++ { + _, style, _ := Screen.Get(x, 1) + assert.Equal(t, tcell.ColorDefault, style.GetForeground(), + "trailing cell at (%d, 1) should have default fg", x) + assert.False(t, style.HasReverse(), + "trailing cell at (%d, 1) should not have reverse attribute", x) + } +} + +// TestUnterminatedReverseLineExtendsToEdge verifies that without a +// terminating '\n' or '\x1b[K', the line's last cell's attributes +// (including AttrReverse) propagate through the trailing area so a +// reversed-bg line extends all the way to the right edge. +func TestUnterminatedReverseLineExtendsToEdge(t *testing.T) { + WithSimulationScreen(t, 14, 5) + + v := NewView("name", 0, 0, 11, 4, OutputNormal) + + // Reverse + red fg, "foo", no termination. Each "foo" cell renders + // with bg=red via reverse, and the trailing cells past "foo" must + // keep the reverse so the rendered bg extends to the right edge. + v.writeString("\x1b[7m\x1b[31mfoo") + v.draw() + + // Cells 1..3 are content; cells 4..10 are trailing. All ten should + // have reverse on with red fg (so they all render with bg=red). + for x := 1; x <= 10; x++ { + _, style, _ := Screen.Get(x, 1) + assert.Equal(t, color.Maroon, style.GetForeground(), + "cell at (%d, 1) should have red fg under reverse", x) + assert.True(t, style.HasReverse(), + "cell at (%d, 1) should have reverse attribute", x) + } +} + +// TestShortFilledLineExtendsBgWithoutWrap verifies that '\x1b[K' fills +// the rest of the line with the current bg color for a line that's +// short enough to fit within the view's inner width. +func TestShortFilledLineExtendsBgWithoutWrap(t *testing.T) { + WithSimulationScreen(t, 14, 5) + + v := NewView("name", 0, 0, 11, 4, OutputNormal) + + // \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() + + // All ten cells at (1..10, 1) should have red bg. + for x := 1; x <= 10; x++ { + _, style, _ := Screen.Get(x, 1) + assert.Equal(t, color.Maroon, style.GetBackground(), + "cell at (%d, 1) should have red bg", x) + } +} From 6c55823492dbe3d57810a284038f366855e501a4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 19 May 2026 14:15:44 +0200 Subject: [PATCH 069/384] Demonstrate broken background fill on wrapped \x1b[K-padded lines Tools like delta emit each diff line with the bg color set, then \x1b[K to fill the rest of the row with that bg color. When the content fits within the view's inner width, gocui's \x1b[K handling appends explicit padding cells and rendering works. When the content exceeds the inner width, \x1b[K adds no cells (negative repeat count), the line is wrapped into multiple segments, and the partial tail segment's trailing cells fall back to the view default bg instead of continuing the fill color. Add a test that drives draw() against a tcell mock terminal and asserts the current (buggy) trailing background. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gocui/view_test.go | 86 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index ef56d6756..8e9f0e196 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -504,3 +504,89 @@ func TestShortFilledLineExtendsBgWithoutWrap(t *testing.T) { "cell at (%d, 1) should have red bg", x) } } + +// TestWrappedFilledLineExtendsBgToEdge demonstrates that when a line is +// filled to the edge with \x1b[K (the pattern used by `delta` for diff +// lines) but exceeds the view's inner width, every wrapped segment loses +// the fill background past its content. +func TestWrappedFilledLineExtendsBgToEdge(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + // View dimensions: Width=12 (x0=0..x1=11), Height=6; InnerWidth=10, + // InnerHeight=4. Frame inset of 1 places content cells at screen + // (1..10, 1..4). + v := NewView("name", 0, 0, 11, 5, OutputNormal) + v.Wrap = true + + // Content with spaces so word wrap ends each segment before the + // right edge: "aaa bbb ccc ddd eee" wraps at InnerWidth=10 to three + // segments — "aaa bbb" / "ccc ddd" / "eee". Each row's trailing area + // should pick up the red fill from \x1b[K but currently falls back to + // the view default bg. + v.writeString("\x1b[41m" + "aaa bbb ccc ddd eee" + "\x1b[0m\x1b[41m\x1b[K\x1b[0m\n") + v.draw() + + // trailingFrom is 1-indexed: each row's content ends at column + // trailingFrom[y]-1, so columns trailingFrom[y]..10 are the trailing + // fill area where the bug shows. + trailingFrom := []int{8, 8, 4} + for y := 1; y <= 3; y++ { + for x := trailingFrom[y-1]; x <= 10; x++ { + _, style, _ := Screen.Get(x, y) + /* EXPECTED: + assert.Equal(t, color.Maroon, style.GetBackground(), + "trailing cell at (%d, %d) should have red bg", x, y) + ACTUAL: */ + assert.Equal(t, tcell.ColorDefault, style.GetBackground(), + "trailing cell at (%d, %d) falls back to default bg", x, y) + } + } +} + +// TestMulticolorWrappedFillUsesLastCellOfEachSegment demonstrates that +// when a wrapped line switches bg color part-way through and ends with +// \x1b[K, the trailing area on each wrapped row should match the bg +// that was active where that row's content ended — not the \x1b[K bg, +// which would bleed the color from the end of the logical line back +// into the earlier wrapped rows. +func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) { + WithSimulationScreen(t, 14, 6) + + // View dimensions: Width=12 (x0=0..x1=11), Height=6; InnerWidth=10, + // InnerHeight=4. Frame inset of 1 places content cells at screen + // (1..10, 1..4). + v := NewView("name", 0, 0, 11, 5, OutputNormal) + v.Wrap = true + + // Content "aaa bbb ccc" is 11 cells; lineWrap breaks at the space + // between "bbb" and "ccc" (index 7) so segment 1 is "aaa bbb" (red, + // 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() + + // 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. + for x := 8; x <= 10; x++ { + _, style, _ := Screen.Get(x, 1) + /* EXPECTED: + assert.Equal(t, color.Maroon, style.GetBackground(), + "trailing cell at (%d, 1) should have red bg (matching segment's last cell)", x) + ACTUAL: */ + assert.Equal(t, tcell.ColorDefault, style.GetBackground(), + "trailing cell at (%d, 1) falls back to default bg", x) + } + + // Row 2's content ends with a green cell at x=3, so trailing + // columns 4..10 should pick up green (matching both the segment's + // last cell and the \x1b[K bg — these happen to agree here). + for x := 4; x <= 10; x++ { + _, style, _ := Screen.Get(x, 2) + /* EXPECTED: + assert.Equal(t, color.Green, style.GetBackground(), + "trailing cell at (%d, 2) should have green bg", x) + ACTUAL: */ + assert.Equal(t, tcell.ColorDefault, style.GetBackground(), + "trailing cell at (%d, 2) falls back to default bg", x) + } +} From d234d8b3580e8cca037647e060963d003d9ddce5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 18 May 2026 19:42:25 +0200 Subject: [PATCH 070/384] Remove dead \x00 filtering from string conversion helpers The four ReplaceAll(str, "\x00", "") calls (and the equivalent rune-by-rune skip in linesToString) are leftover from when cell.chr was a rune and \x00 was used as an internal sentinel. With chr now being a string and no code path writing \x00, the filtering never strips anything. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gocui/view.go | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 166cb0e2c..ea6f80399 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -1411,9 +1411,7 @@ func (v *View) BufferLines() []string { lines := make([]string, len(v.lines)) for i, l := range v.lines { - str := lineType(l).String() - str = strings.ReplaceAll(str, "\x00", "") - lines[i] = str + lines[i] = lineType(l).String() } return lines } @@ -1434,9 +1432,7 @@ func (v *View) ViewBufferLines() []string { lines := make([]string, len(v.viewLines)) for i, l := range v.viewLines { - str := lineType(l.line).String() - str = strings.ReplaceAll(str, "\x00", "") - lines[i] = str + lines[i] = lineType(l.line).String() } return lines } @@ -1605,14 +1601,7 @@ func lineWrap(line []cell, columns int) [][]cell { func linesToString(lines [][]cell) string { str := make([]string, len(lines)) for i := range lines { - rns := make([]rune, 0, len(lines[i])) - line := lineType(lines[i]).String() - for _, c := range line { - if c != '\x00' { - rns = append(rns, c) - } - } - str[i] = string(rns) + str[i] = lineType(lines[i]).String() } return strings.Join(str, "\n") @@ -1682,9 +1671,7 @@ func (v *View) SelectedLines() []string { } func (v *View) lineContentAtIdx(idx int) string { - line := v.lines[idx] - str := lineType(line).String() - return strings.ReplaceAll(str, "\x00", "") + return lineType(v.lines[idx]).String() } func (v *View) SelectedPoint() (int, int) { From 1788eaa91ff128eb40fcabf04990b88609060420 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 20 May 2026 18:54:35 +0200 Subject: [PATCH 071/384] Rename lineType to cells This is to free up the name lineType for something else in the next commit. --- pkg/gocui/view.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index ea6f80399..e780fa292 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -453,7 +453,7 @@ type cell struct { hyperlink string } -type lineType []cell +type cells []cell func characterEquals(chr []byte, b byte) bool { return len(chr) == 1 && chr[0] == b @@ -464,7 +464,7 @@ func isCRLF(chr []byte) bool { } // String returns a string from a given cell slice. -func (l lineType) String() string { +func (l cells) String() string { var str strings.Builder for _, c := range l { str.WriteString(c.chr) @@ -1411,7 +1411,7 @@ func (v *View) BufferLines() []string { lines := make([]string, len(v.lines)) for i, l := range v.lines { - lines[i] = lineType(l).String() + lines[i] = cells(l).String() } return lines } @@ -1432,7 +1432,7 @@ func (v *View) ViewBufferLines() []string { lines := make([]string, len(v.viewLines)) for i, l := range v.viewLines { - lines[i] = lineType(l.line).String() + lines[i] = cells(l.line).String() } return lines } @@ -1474,7 +1474,7 @@ func (v *View) Line(y int) (string, bool) { return "", false } - return lineType(v.lines[y]).String(), true + return cells(v.lines[y]).String(), true } // Word returns a string with the word of the view's internal buffer @@ -1489,7 +1489,7 @@ func (v *View) Word(x, y int) (string, bool) { return "", false } - str := lineType(v.lines[y]).String() + str := cells(v.lines[y]).String() nl := strings.LastIndexFunc(str[:x], indexFunc) if nl == -1 { @@ -1601,7 +1601,7 @@ func lineWrap(line []cell, columns int) [][]cell { func linesToString(lines [][]cell) string { str := make([]string, len(lines)) for i := range lines { - str[i] = lineType(lines[i]).String() + str[i] = cells(lines[i]).String() } return strings.Join(str, "\n") @@ -1671,7 +1671,7 @@ func (v *View) SelectedLines() []string { } func (v *View) lineContentAtIdx(idx int) string { - return lineType(v.lines[idx]).String() + return cells(v.lines[idx]).String() } func (v *View) SelectedPoint() (int, int) { From ad8335aaa8d2eba8f808cc7d3afd6dd409602b71 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 18 May 2026 19:51:03 +0200 Subject: [PATCH 072/384] Wrap v.lines cells in a line struct The cells of a source line will soon need to carry metadata about how the line was terminated (newline vs filled to edge via \x1b[K). Move to a struct so there's somewhere to put it; this commit only renames [][]cell to []line{cells: ...} with no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gocui/gui.go | 2 +- pkg/gocui/view.go | 93 ++++++++++++++++++++++-------------------- pkg/gocui/view_test.go | 18 ++++---- 3 files changed, 61 insertions(+), 52 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 7b691f6d7..ad8ba1e41 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1345,7 +1345,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error { } visibleLineWidth := 0 - for _, c := range v.lines[newY] { + for _, c := range v.lines[newY].cells { visibleLineWidth += c.width } if visibleLineWidth < newX { diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index e780fa292..ed18c3e54 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -28,12 +28,12 @@ const ( // 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 [][]cell // 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 + wx, wy int // Write() offsets + lines []lineType // All the data outMode OutputMode // 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 @@ -446,6 +446,12 @@ type viewLine struct { line []cell } +// lineType is one of v.lines: the cells of a source lineType, plus any per-lineType +// metadata about how it was terminated (added in later commits). +type lineType struct { + cells cells +} + type cell struct { chr string // a grapheme cluster width int // number of terminal cells occupied by chr (always 1 or 2) @@ -738,20 +744,20 @@ func (v *View) makeWriteable(x, y int) { } v.lines = v.lines[:newLen] } else { - v.lines = append(v.lines, nil) + v.lines = append(v.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]) < x { - if cap(v.lines[y]) > len(v.lines[y]) { - newLen := cap(v.lines[y]) + for len(v.lines[y].cells) < x { + if cap(v.lines[y].cells) > len(v.lines[y].cells) { + newLen := cap(v.lines[y].cells) if newLen > x { newLen = x } - v.lines[y] = v.lines[y][:newLen] + v.lines[y].cells = v.lines[y].cells[:newLen] } else { - v.lines[y] = append(v.lines[y], cell{}) + v.lines[y].cells = append(v.lines[y].cells, cell{}) } } } @@ -761,7 +767,7 @@ func (v *View) makeWriteable(x, y int) { func (v *View) writeCells(cells []cell) { var newLen int // use maximum len available - line := v.lines[v.wy][:cap(v.lines[v.wy])] + line := v.lines[v.wy].cells[:cap(v.lines[v.wy].cells)] maxCopy := len(line) - v.wx if maxCopy < len(cells) { copy(line[v.wx:], cells[:maxCopy]) @@ -770,11 +776,11 @@ func (v *View) writeCells(cells []cell) { } else { // maxCopy >= len(cells) copy(line[v.wx:], cells) newLen = v.wx + len(cells) - if newLen < len(v.lines[v.wy]) { - newLen = len(v.lines[v.wy]) + if newLen < len(v.lines[v.wy].cells) { + newLen = len(v.lines[v.wy].cells) } } - v.lines[v.wy] = line[:newLen] + v.lines[v.wy].cells = line[:newLen] v.wx += len(cells) } @@ -800,7 +806,7 @@ func (v *View) write(p []byte) { finishLine := func() { v.autoRenderHyperlinksInCurrentLine() - if v.wx >= len(v.lines[v.wy]) { + if v.wx >= len(v.lines[v.wy].cells) { v.writeCells([]cell{{ chr: "", width: 0, @@ -814,7 +820,7 @@ func (v *View) write(p []byte) { v.wx = 0 v.wy++ if v.wy >= len(v.lines) { - v.lines = append(v.lines, nil) + v.lines = append(v.lines, lineType{}) } } @@ -851,7 +857,7 @@ func (v *View) write(p []byte) { } v.writeCells(cells) if truncateLine { - v.lines[v.wy] = v.lines[v.wy][:v.wx] + v.lines[v.wy].cells = v.lines[v.wy].cells[:v.wx] } } } @@ -910,7 +916,7 @@ func (v *View) autoRenderHyperlinksInCurrentLine() { return } - line := v.lines[v.wy] + line := v.lines[v.wy].cells start := 0 for { linkStart := findLinkStart(line[start:]) @@ -927,7 +933,7 @@ func (v *View) autoRenderHyperlinksInCurrentLine() { link.WriteString(line[linkEnd].chr) } for i := linkStart; i < linkEnd; i++ { - v.lines[v.wy][i].hyperlink = link.String() + v.lines[v.wy].cells[i].hyperlink = link.String() } start = linkEnd } @@ -958,7 +964,7 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) { // fill rest of line v.ei.instructionRead() cx := 0 - for _, cell := range v.lines[v.wy][0:v.wx] { + for _, cell := range v.lines[v.wy].cells[0:v.wx] { cx += cell.width } repeatCount = v.InnerWidth() - cx @@ -1010,8 +1016,8 @@ 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]) { - s := v.lines[v.ry][v.rx].chr + for v.rx < len(v.lines[v.ry].cells) { + s := v.lines[v.ry].cells[v.rx].chr count := len(s) copy(p[offset:], s) v.rx++ @@ -1175,9 +1181,9 @@ func (v *View) updateSearchPositions() { } // If a view line exists for this line index: - if v.lines[result.Y] != nil { + if v.lines[result.Y].cells != nil { // search this view line for the search string - positions := searchPositionsForLine(v.lines[result.Y], result.Y) + positions := searchPositionsForLine(v.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...) @@ -1318,7 +1324,7 @@ func (v *View) refreshViewLinesIfNeeded() { wrap = maxX } - ls := lineWrap(line, wrap) + ls := lineWrap(line.cells, wrap) for j := range ls { vline := viewLine{linesX: j, linesY: i, line: ls[j]} @@ -1411,7 +1417,7 @@ func (v *View) BufferLines() []string { lines := make([]string, len(v.lines)) for i, l := range v.lines { - lines[i] = cells(l).String() + lines[i] = l.cells.String() } return lines } @@ -1454,12 +1460,12 @@ func (v *View) ViewLinesHeight() int { // ViewBuffer returns a string with the contents of the view's buffer that is // shown to the user. func (v *View) ViewBuffer() string { - lines := make([][]cell, len(v.viewLines)) + strs := make([]string, len(v.viewLines)) for i := range v.viewLines { - lines[i] = v.viewLines[i].line + strs[i] = cells(v.viewLines[i].line).String() } - return linesToString(lines) + return strings.Join(strs, "\n") } // Line returns a string with the line of the view's internal buffer @@ -1474,7 +1480,7 @@ func (v *View) Line(y int) (string, bool) { return "", false } - return cells(v.lines[y]).String(), true + return v.lines[y].cells.String(), true } // Word returns a string with the word of the view's internal buffer @@ -1485,11 +1491,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]) { + if x < 0 || y < 0 || y >= len(v.lines) || x >= len(v.lines[y].cells) { return "", false } - str := cells(v.lines[y]).String() + str := v.lines[y].cells.String() nl := strings.LastIndexFunc(str[:x], indexFunc) if nl == -1 { @@ -1519,9 +1525,8 @@ func (v *View) SetHighlight(y int, on bool) { return } - line := v.lines[y] - cells := make([]cell, 0) - for _, c := range line { + cells := make([]cell, 0, len(v.lines[y].cells)) + for _, c := range v.lines[y].cells { if on { c.bgColor = v.SelBgColor c.fgColor = v.SelFgColor @@ -1532,7 +1537,7 @@ func (v *View) SetHighlight(y int, on bool) { cells = append(cells, c) } v.tainted = true - v.lines[y] = cells + v.lines[y].cells = cells v.clearHover() } @@ -1598,10 +1603,10 @@ func lineWrap(line []cell, columns int) [][]cell { return lines } -func linesToString(lines [][]cell) string { +func linesToString(lines []lineType) string { str := make([]string, len(lines)) for i := range lines { - str[i] = cells(lines[i]).String() + str[i] = lines[i].cells.String() } return strings.Join(str, "\n") @@ -1671,7 +1676,7 @@ func (v *View) SelectedLines() []string { } func (v *View) lineContentAtIdx(idx int) string { - return cells(v.lines[idx]).String() + return v.lines[idx].cells.String() } func (v *View) SelectedPoint() (int, int) { @@ -1775,11 +1780,11 @@ func (v *View) OverwriteLinesAndClearEverythingElse(lineCount int, y int, conten v.overwriteLines(y, content) for i := range y { - v.lines[i] = nil + v.lines[i] = lineType{} } for i := v.wy + 1; i < len(v.lines); i += 1 { - v.lines[i] = nil + v.lines[i] = lineType{} } } @@ -1922,7 +1927,7 @@ func (v *View) scrollMargin() int { // foreground color func (v *View) ContainsColoredText(fgColor string, text string) bool { for _, line := range v.lines { - if containsColoredTextInLine(fgColor, text, line) { + if containsColoredTextInLine(fgColor, text, line.cells) { return true } } diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 8e9f0e196..35b48269b 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -101,14 +101,14 @@ 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, stringToCells(l)) + v.lines = append(v.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)) + resultingLines = append(resultingLines, cellsToStrings(l.cells)) } assert.Equal(t, test.expectedLines, resultingLines) } @@ -144,19 +144,19 @@ func TestAutoRenderingHyperlinks(t *testing.T) { v.writeString("htt") // No hyperlinks are generated for incomplete URLs - assert.Equal(t, "", v.lines[0][0].hyperlink) + assert.Equal(t, "", v.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][0].hyperlink) + assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink) v.Clear() // Valid but incomplete URL v.writeString("https://exa") - assert.Equal(t, "https://exa", v.lines[0][0].hyperlink) + assert.Equal(t, "https://exa", v.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][0].hyperlink) + assert.Equal(t, "https://example.com", v.lines[0].cells[0].hyperlink) } func TestContainsColoredText(t *testing.T) { @@ -229,7 +229,11 @@ func TestContainsColoredText(t *testing.T) { } for i, test := range tests { - v := &View{lines: test.lines} + lines := make([]lineType, len(test.lines)) + for j, cells := range test.lines { + lines[j] = lineType{cells: cells} + } + v := &View{lines: lines} assert.Equal(t, test.expected, v.ContainsColoredText(test.fgColorStr, test.text), "Test %d failed", i) } } From 9c8a02f9014b27f24c18e2d9da1841a226becefe Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 20 May 2026 08:32:39 +0200 Subject: [PATCH 073/384] Remove the '\n' sentinel cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sentinel was appended to every \n-terminated line solely so that draw()'s prevFgColor tracking would reset to default for the trailing area; without it, an AttrReverse-styled last cell would carry its rendered bg past the end of the line. The same prevFgColor mechanism propagated AttrReverse past content on *unterminated* lines too — which doesn't match real terminal behavior (try `print '\x1b[7m\x1b[31mfoo'` in a shell: the reverse stops at the last character) and isn't relied on by anything in lazygit, since all our writers terminate lines with \n. Drop the sentinel cell, drop prevFgColor, and just have draw() paint trailing cells with the view's default fg/bg. The TestUnterminatedReverseLineExtendsToEdge regression test inverts to document the new (terminal-matching) behavior, renamed accordingly. TestWriteString expectations also drop the trailing "" that came from the sentinel. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gocui/view.go | 14 -------------- pkg/gocui/view_test.go | 39 +++++++++++++++++++-------------------- 2 files changed, 19 insertions(+), 34 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index ed18c3e54..e8947a665 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -806,14 +806,6 @@ func (v *View) write(p []byte) { finishLine := func() { v.autoRenderHyperlinksInCurrentLine() - if v.wx >= len(v.lines[v.wy].cells) { - v.writeCells([]cell{{ - chr: "", - width: 0, - fgColor: 0, - bgColor: 0, - }}) - } } advanceToNextLine := func() { @@ -1254,7 +1246,6 @@ func (v *View) draw() { } emptyCell := cell{chr: " ", width: 1, fgColor: ColorDefault, bgColor: ColorDefault} - var prevFgColor Attribute for y, vline := range v.viewLines[start:] { if y >= maxY { @@ -1284,13 +1275,8 @@ func (v *View) draw() { // if we're out of cells to write, we'll just print empty cells. if cellIdx > len(vline.line)-1 { c = emptyCell - c.fgColor = prevFgColor } else { c = vline.line[cellIdx] - // capturing previous foreground colour so that if we're using the reverse - // attribute we honour the final character's colour and don't awkwardly switch - // to a new background colour for the remainder of the line - prevFgColor = c.fgColor } fgColor := c.fgColor diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 35b48269b..9d0e9a4ef 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -44,17 +44,17 @@ func TestWriteString(t *testing.T) { { []string{}, []string{"1\n"}, - [][]string{{"1", ""}}, + [][]string{{"1"}}, }, { []string{}, []string{"1\n", "2\n"}, - [][]string{{"1", ""}, {"2", ""}}, + [][]string{{"1"}, {"2"}}, }, { []string{"a"}, []string{"1\n"}, - [][]string{{"1", ""}}, + [][]string{{"1"}}, }, { []string{"a\x00"}, @@ -74,12 +74,12 @@ func TestWriteString(t *testing.T) { { []string{}, []string{"1\r"}, - [][]string{{"1", ""}}, + [][]string{{"1"}}, }, { []string{"a"}, []string{"1\r"}, - [][]string{{"1", ""}}, + [][]string{{"1"}}, }, { []string{"a\x00"}, @@ -462,29 +462,28 @@ func TestNewlineTerminatedLineClearsTrailingBg(t *testing.T) { } } -// TestUnterminatedReverseLineExtendsToEdge verifies that without a -// terminating '\n' or '\x1b[K', the line's last cell's attributes -// (including AttrReverse) propagate through the trailing area so a -// reversed-bg line extends all the way to the right edge. -func TestUnterminatedReverseLineExtendsToEdge(t *testing.T) { +// TestUnterminatedReverseLineDoesNotExtend verifies that an unterminated +// line ending with an AttrReverse cell does NOT propagate the reversed +// background past the line's content — matching real terminal behavior +// (try `print '\x1b[7m\x1b[31mfoo'` in a shell). The trailing area +// is rendered as plain default. +func TestUnterminatedReverseLineDoesNotExtend(t *testing.T) { WithSimulationScreen(t, 14, 5) v := NewView("name", 0, 0, 11, 4, OutputNormal) - // Reverse + red fg, "foo", no termination. Each "foo" cell renders - // with bg=red via reverse, and the trailing cells past "foo" must - // keep the reverse so the rendered bg extends to the right edge. + // 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() - // Cells 1..3 are content; cells 4..10 are trailing. All ten should - // have reverse on with red fg (so they all render with bg=red). - for x := 1; x <= 10; x++ { + // Cells 4..10 are trailing and should be default with no reverse. + for x := 4; x <= 10; x++ { _, style, _ := Screen.Get(x, 1) - assert.Equal(t, color.Maroon, style.GetForeground(), - "cell at (%d, 1) should have red fg under reverse", x) - assert.True(t, style.HasReverse(), - "cell at (%d, 1) should have reverse attribute", x) + assert.Equal(t, tcell.ColorDefault, style.GetForeground(), + "trailing cell at (%d, 1) should have default fg", x) + assert.False(t, style.HasReverse(), + "trailing cell at (%d, 1) should not have reverse attribute", x) } } From 51b409383c6b74bda724a2f134de5fd0a90b4f56 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 20 May 2026 08:34:34 +0200 Subject: [PATCH 074/384] Extend the fill background to wrapped tail segments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tools like delta paint each diff line's background with '\x1b[K' so the color reaches the right edge. Up to now the '\x1b[K' handler appended (InnerWidth - cx) explicit padding cells with the fill bg so rendering picked up the color. That worked for short lines but silently degraded once content exceeded InnerWidth: the repeat count went non-positive, no cells were added, and after wrapping the partial tail segment was left without any cells carrying the fill color, so draw() fell back to the view's default bg. Record the fill colors on the source line as optional trailingFillAttributes. In the '\x1b[K' handler set them (and drop the padding-cell loop — the metadata covers both the wrap and the non-wrap cases). In draw(), once per source line, pick the trailing cell's fg/bg from the metadata if present and otherwise from the view defaults; then the inner-loop fills past-content cells with that. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gocui/view.go | 72 ++++++++++++++++++++++++++++++++++-------- pkg/gocui/view_test.go | 32 ++++++------------- 2 files changed, 68 insertions(+), 36 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index e8947a665..77492b9b1 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -444,12 +444,28 @@ type SearchPosition struct { type viewLine struct { linesX, linesY int // coordinates relative to v.lines line []cell + + // Colors used to extend the bg past this wrapped segment's content. + // Derived at wrap time from the source line — see refreshViewLinesIfNeeded + // for the per-segment rule. + trailingFillAttributes *trailingFillAttributes } -// lineType is one of v.lines: the cells of a source lineType, plus any per-lineType -// metadata about how it was terminated (added in later commits). +// lineType is one of v.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 { - cells cells + cells cells + trailingFillAttributes *trailingFillAttributes +} + +// trailingFillAttributes describes the fg/bg colors that draw() should +// use for cells past the end of a wrapped segment's content. On a source +// line this records what the writer asked for via '\x1b[K' (and so opts +// the line in to trailing fill at all); the per-segment values on each +// viewLine are derived from it at wrap time. +type trailingFillAttributes struct { + fg, bg Attribute } type cell struct { @@ -953,16 +969,19 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) { } else { repeatCount := 1 if _, ok := v.ei.instruction.(eraseInLineFromCursor); ok { - // fill rest of line + // 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() - cx := 0 - for _, cell := range v.lines[v.wy].cells[0:v.wx] { - cx += cell.width - } - repeatCount = v.InnerWidth() - cx - ch = []byte{' '} - width = 1 truncateLine = true + v.lines[v.wy].trailingFillAttributes = &trailingFillAttributes{ + fg: v.ei.curFgColor, + bg: v.ei.curBgColor, + } + return truncateLine, []cell{} } else if isEscape { // do not output anything return truncateLine, nil @@ -1252,6 +1271,15 @@ func (v *View) draw() { break } + // Decide the colors used for cells past the end of vline.line: + // the source line's trailingFillAttributes (set by '\x1b[K') if + // any, otherwise plain defaults. + trailingCell := emptyCell + if attrs := vline.trailingFillAttributes; attrs != nil { + trailingCell.fgColor = attrs.fg + trailingCell.bgColor = attrs.bg + } + // x tracks the current x position in the view, and cellIdx tracks the // index of the cell. If we print a double-sized rune, we increment cellIdx // by one but x by two. @@ -1274,7 +1302,7 @@ func (v *View) draw() { // if we're out of cells to write, we'll just print empty cells. if cellIdx > len(vline.line)-1 { - c = emptyCell + c = trailingCell } else { c = vline.line[cellIdx] } @@ -1312,7 +1340,25 @@ func (v *View) refreshViewLinesIfNeeded() { ls := lineWrap(line.cells, wrap) for j := range ls { - vline := viewLine{linesX: j, linesY: i, line: ls[j]} + // Per-segment trailing fill. When the source line opted in + // via '\x1b[K', the LAST wrapped segment uses those colors + // directly; earlier segments use the colors of their own + // last cell, so the trailing area matches the bg active + // where that segment ended rather than bleeding the + // '\x1b[K' bg back across color changes in the line. + var attrs *trailingFillAttributes + if line.trailingFillAttributes != nil { + if j == len(ls)-1 { + attrs = line.trailingFillAttributes + } else if len(ls[j]) > 0 { + last := ls[j][len(ls[j])-1] + attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor} + } + } + vline := viewLine{ + linesX: j, linesY: i, line: ls[j], + trailingFillAttributes: attrs, + } if lineIdx > len(v.viewLines)-1 { v.viewLines = append(v.viewLines, vline) diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 9d0e9a4ef..f65418821 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -508,10 +508,10 @@ func TestShortFilledLineExtendsBgWithoutWrap(t *testing.T) { } } -// TestWrappedFilledLineExtendsBgToEdge demonstrates that when a line is +// TestWrappedFilledLineExtendsBgToEdge verifies that when a line is // filled to the edge with \x1b[K (the pattern used by `delta` for diff -// lines) but exceeds the view's inner width, every wrapped segment loses -// the fill background past its content. +// lines) but exceeds the view's inner width, every wrapped segment +// extends the fill background past its content to the right edge. func TestWrappedFilledLineExtendsBgToEdge(t *testing.T) { WithSimulationScreen(t, 14, 6) @@ -524,24 +524,18 @@ func TestWrappedFilledLineExtendsBgToEdge(t *testing.T) { // Content with spaces so word wrap ends each segment before the // right edge: "aaa bbb ccc ddd eee" wraps at InnerWidth=10 to three // segments — "aaa bbb" / "ccc ddd" / "eee". Each row's trailing area - // should pick up the red fill from \x1b[K but currently falls back to - // the view default bg. + // 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() - // trailingFrom is 1-indexed: each row's content ends at column - // trailingFrom[y]-1, so columns trailingFrom[y]..10 are the trailing - // fill area where the bug shows. - trailingFrom := []int{8, 8, 4} + // All three wrapped rows should have the red fill background across + // the full InnerWidth, including the trailing cells past each row's + // last word. for y := 1; y <= 3; y++ { - for x := trailingFrom[y-1]; x <= 10; x++ { + for x := 1; x <= 10; x++ { _, style, _ := Screen.Get(x, y) - /* EXPECTED: assert.Equal(t, color.Maroon, style.GetBackground(), - "trailing cell at (%d, %d) should have red bg", x, y) - ACTUAL: */ - assert.Equal(t, tcell.ColorDefault, style.GetBackground(), - "trailing cell at (%d, %d) falls back to default bg", x, y) + "cell at (%d, %d) should have red bg", x, y) } } } @@ -572,12 +566,8 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) { // 8..10 should pick up red rather than the \x1b[K's green. for x := 8; x <= 10; x++ { _, style, _ := Screen.Get(x, 1) - /* EXPECTED: assert.Equal(t, color.Maroon, style.GetBackground(), "trailing cell at (%d, 1) should have red bg (matching segment's last cell)", x) - ACTUAL: */ - assert.Equal(t, tcell.ColorDefault, style.GetBackground(), - "trailing cell at (%d, 1) falls back to default bg", x) } // Row 2's content ends with a green cell at x=3, so trailing @@ -585,11 +575,7 @@ func TestMulticolorWrappedFillUsesLastCellOfEachSegment(t *testing.T) { // last cell and the \x1b[K bg — these happen to agree here). for x := 4; x <= 10; x++ { _, style, _ := Screen.Get(x, 2) - /* EXPECTED: assert.Equal(t, color.Green, style.GetBackground(), "trailing cell at (%d, 2) should have green bg", x) - ACTUAL: */ - assert.Equal(t, tcell.ColorDefault, style.GetBackground(), - "trailing cell at (%d, 2) falls back to default bg", x) } } From f9c81b655d1960d9dde52c25b69df3ea8373190a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 29 May 2026 18:05:28 +0200 Subject: [PATCH 075/384] Make background-refresh pausing reentrant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the pauseBackgroundRefreshes bool with a count. The single existing caller (subprocess suspend/resume) is unaffected, but we're about to add a second, independent reason to pause — lazygit driving a git operation that the background routines would otherwise catch mid-flight — and the two scopes can overlap. A bool can't represent "two things both want refreshes paused"; a count can. --- pkg/gui/background.go | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 8795b49aa..2575aedd1 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -3,6 +3,7 @@ package gui import ( "fmt" "runtime" + "sync/atomic" "time" "github.com/jesseduffield/lazygit/pkg/gocui" @@ -13,17 +14,27 @@ import ( type BackgroundRoutineMgr struct { gui *Gui - // if we've suspended the gui (e.g. because we've switched to a subprocess) - // we typically want to pause some things that are running like background - // file refreshes - pauseBackgroundRefreshes bool + // When this is greater than zero, the background routines (e.g. file refresh) + // skip their work. We pause them while the gui is suspended (e.g. for a + // subprocess) and while lazygit is itself driving a git operation that would + // otherwise be caught mid-flight (see the waiting-status helpers). It's a + // count rather than a bool because these pause scopes can overlap. + pauseRefreshesCount atomic.Int32 // a channel to trigger an immediate background fetch; we use this when switching repos triggerFetch chan struct{} } func (self *BackgroundRoutineMgr) PauseBackgroundRefreshes(pause bool) { - self.pauseBackgroundRefreshes = pause + if pause { + self.pauseRefreshesCount.Add(1) + } else { + self.pauseRefreshesCount.Add(-1) + } +} + +func (self *BackgroundRoutineMgr) backgroundRefreshesPaused() bool { + return self.pauseRefreshesCount.Load() > 0 } func (self *BackgroundRoutineMgr) startBackgroundRoutines() { @@ -124,7 +135,7 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru ticker := time.NewTicker(interval) defer ticker.Stop() doit := func(retriggered bool) { - if self.pauseBackgroundRefreshes { + if self.backgroundRefreshesPaused() { return } self.gui.c.OnWorker(func(gocui.Task) error { From 3cf890b7d7a741b0878e157bcdb15495f25d7b65 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 29 May 2026 18:08:07 +0200 Subject: [PATCH 076/384] Pause background refreshes while driving a git operation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several commands (rewording or amending an earlier commit, custom patch operations, etc.) are implemented by starting an interactive rebase that stops at a commit, amending it, and continuing. When no conflict occurs, the user isn't meant to notice a rebase happened at all. But a background file refresh can fire while the rebase is mid-flight and render a dirty working copy of whatever the behind-the-scenes rebase is doing (e.g. applying a custom patch). To fix this, we pause the background routines for the duration of any waiting-status operation — exactly the window in which lazygit is driving the git operation itself and will refresh once at the end. The boundary is also right for the conflict case: when a rebase stops on a conflict the operation returns, the pause releases, and background refreshes resume for the interactive resolution that follows. --- pkg/gui/controllers/helpers/app_status_helper.go | 10 ++++++++++ pkg/gui/controllers/helpers/inline_status_helper.go | 8 ++++++++ pkg/gui/gui_common.go | 4 ++++ pkg/gui/types/common.go | 4 ++++ 4 files changed, 26 insertions(+) diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index 17c61ae26..b691db4a4 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -66,12 +66,22 @@ func (self *AppStatusHelper) WithWaitingStatus(message string, f func(gocui.Task } func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task) error { + // A waiting status means lazygit is driving a git operation itself (often + // one that internally runs a rebase and continues it). Pause the background + // routines for its duration so they don't refresh from an intermediate + // state and reveal, say, the half-finished history of a reword. + self.c.PauseBackgroundRefreshes(true) + defer self.c.PauseBackgroundRefreshes(false) + return self.statusMgr().WithWaitingStatus(message, self.renderAppStatus, func(waitingStatusHandle *status.WaitingStatusHandle) error { return f(appStatusHelperTask{task, waitingStatusHandle}) }) } func (self *AppStatusHelper) WithWaitingStatusSync(message string, f func() error) error { + self.c.PauseBackgroundRefreshes(true) + defer self.c.PauseBackgroundRefreshes(false) + return self.statusMgr().WithWaitingStatus(message, func() {}, func(*status.WaitingStatusHandle) error { stop := make(chan struct{}) defer func() { close(stop) }() diff --git a/pkg/gui/controllers/helpers/inline_status_helper.go b/pkg/gui/controllers/helpers/inline_status_helper.go index 02afcdd50..0902c5bf2 100644 --- a/pkg/gui/controllers/helpers/inline_status_helper.go +++ b/pkg/gui/controllers/helpers/inline_status_helper.go @@ -68,6 +68,14 @@ func (self *InlineStatusHelper) WithInlineStatus(opts InlineStatusOpts, f func(g visible := view.Visible && self.windowHelper.TopViewInWindow(context.GetWindowName(), false) == view if visible && context.IsItemVisible(opts.Item) { self.c.OnWorker(func(task gocui.Task) error { + // An inline status is just a waiting status rendered on the item + // rather than in the bottom line, so it gets the same treatment: + // pause the background routines while we drive the operation. (The + // off-screen branch below goes through WithWaitingStatus, which + // already does this.) + self.c.PauseBackgroundRefreshes(true) + defer self.c.PauseBackgroundRefreshes(false) + self.start(opts) defer self.stop(opts) diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index 07945c350..c74a99a05 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -50,6 +50,10 @@ func (self *guiCommon) Resume() error { return self.gui.resume() } +func (self *guiCommon) PauseBackgroundRefreshes(pause bool) { + self.gui.BackgroundRoutineMgr.PauseBackgroundRefreshes(pause) +} + func (self *guiCommon) Context() types.IContextMgr { return self.gui.State.ContextMgr } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 92f004634..b81fb15e1 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -59,6 +59,10 @@ type IGuiCommon interface { Suspend() error Resume() error + // Pause or resume the background routines. Calls nest, so every pause must be balanced + // by a resume. + PauseBackgroundRefreshes(pause bool) + Context() IContextMgr ContextForKey(key ContextKey) Context From 04d62e072bce5cf678c55ec2312fef1f0e5b7b58 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 30 May 2026 16:43:10 +0200 Subject: [PATCH 077/384] Fix schema minimum for refresh and fetch intervals The schema annotated refreshInterval and fetchInterval with minimum=0, but the background routines reject a value of 0 (they require interval > 0 and otherwise log it as invalid and disable the feature). So 0 is not actually a valid value; switch to exclusiveMinimum=0 so the schema matches what the code accepts. --- pkg/config/user_config.go | 4 ++-- schema-master/config.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index d1f760ed1..0f0b4792f 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -44,10 +44,10 @@ type UserConfig struct { type RefresherConfig struct { // File/submodule refresh interval in seconds. // Auto-refresh can be disabled via option 'git.autoRefresh'. - RefreshInterval int `yaml:"refreshInterval" jsonschema:"minimum=0"` + RefreshInterval int `yaml:"refreshInterval" jsonschema:"exclusiveMinimum=0"` // Re-fetch interval in seconds. // Auto-fetch can be disabled via option 'git.autoFetch'. - FetchInterval int `yaml:"fetchInterval" jsonschema:"minimum=0"` + FetchInterval int `yaml:"fetchInterval" jsonschema:"exclusiveMinimum=0"` } func (c *RefresherConfig) RefreshIntervalDuration() time.Duration { diff --git a/schema-master/config.json b/schema-master/config.json index b95c5c980..ff50ab185 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -3539,13 +3539,13 @@ "properties": { "refreshInterval": { "type": "integer", - "minimum": 0, + "exclusiveMinimum": 0, "description": "File/submodule refresh interval in seconds.\nAuto-refresh can be disabled via option 'git.autoRefresh'.", "default": 10 }, "fetchInterval": { "type": "integer", - "minimum": 0, + "exclusiveMinimum": 0, "description": "Re-fetch interval in seconds.\nAuto-fetch can be disabled via option 'git.autoFetch'.", "default": 60 } From d81b6d9e1d96eba1925481cba836168304951a03 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 30 May 2026 11:30:47 +0200 Subject: [PATCH 078/384] Log CPU time of external commands in addition to wall-clock time For certain kinds of performance investigations it is useful to see this, and doesn't terribly pollute the log, so just do this always. --- pkg/commands/oscommands/cmd_obj_runner.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/commands/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go index ae11298ae..978682618 100644 --- a/pkg/commands/oscommands/cmd_obj_runner.go +++ b/pkg/commands/oscommands/cmd_obj_runner.go @@ -105,12 +105,18 @@ func (self *cmdObjRunner) RunWithOutputAux(cmdObj *CmdObj) (string, error) { } t := time.Now() - output, err := sanitisedCommandOutput(cmdObj.GetCmd().CombinedOutput()) + cmd := cmdObj.GetCmd() + output, err := sanitisedCommandOutput(cmd.CombinedOutput()) if err != nil { self.log.WithField("command", cmdObj.ToString()).Error(output) } - self.log.Infof("%s (%s)", cmdObj.ToString(), time.Since(t)) + wall := time.Since(t) + if ps := cmd.ProcessState; ps != nil { + self.log.Infof("%s (wall %s, cpu %s)", cmdObj.ToString(), wall, ps.UserTime()+ps.SystemTime()) + } else { + self.log.Infof("%s (wall %s)", cmdObj.ToString(), wall) + } return output, err } From 93bd26b9a9bf47643674bb8f6db78c4b39b35fa8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 29 May 2026 13:04:45 +0200 Subject: [PATCH 079/384] Centralize scope expansion in Refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several downstream conditions in Refresh() relied on multi-scope predicates to express "if X is in scope, Y also needs refreshing". This makes it hard to add new code that needs to ask "does this refresh re-read refs?", because the answer involves mirroring one of those predicates and keeping them in sync forever. Expand the co-refreshing relationships once, up front, right after the scope set is built. The downstream conditions then collapse to single-scope checks against the (now-expanded) set. Behavior is preserved. Two of the scattered multi-scope conditions are intentionally left as-is because they express subsumption rather than co-refresh (one branch already does the work of another internally — expanding would cause double-refresh), and one expresses mid-function coupling on a flag set inside the COMMITS/BRANCHES block. --- pkg/gui/controllers/helpers/refresh_helper.go | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index d27b38feb..f1277e007 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -106,6 +106,23 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { scopeSet = set.NewFromSlice(options.Scope) } + // Expand co-refreshing scopes up front so downstream conditions can be + // simple single-scope checks. The relationships are: + // - whenever the reflog or bisect info changes, commits and branches + // can change too (e.g. switching branches updates the reflog and + // can move HEAD), so refresh commits + branches alongside + // - submodules are refreshed as part of the files refresh + // - merge conflicts are part of what the files refresh produces + if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { + scopeSet.Add(types.COMMITS, types.BRANCHES) + } + if scopeSet.Includes(types.SUBMODULES) { + scopeSet.Add(types.FILES) + } + if scopeSet.Includes(types.FILES) { + scopeSet.Add(types.MERGE_CONFLICTS) + } + wg := sync.WaitGroup{} refresh := func(name string, f func()) { // if we're in a demo we don't want any async refreshes because @@ -129,7 +146,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { branchesAndRemotesWg := sync.WaitGroup{} includeWorktreesWithBranches := false - if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) || scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { + 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. @@ -166,7 +183,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { } fileWg := sync.WaitGroup{} - if scopeSet.Includes(types.FILES) || scopeSet.Includes(types.SUBMODULES) { + if scopeSet.Includes(types.FILES) { fileWg.Add(1) refresh("files", func() { _ = self.refreshFilesAndSubmodules() @@ -212,7 +229,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { refresh("patch building", func() { self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) }) } - if scopeSet.Includes(types.MERGE_CONFLICTS) || scopeSet.Includes(types.FILES) { + if scopeSet.Includes(types.MERGE_CONFLICTS) { refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState() }) } From cb12cb2f6b7a9740fd6b993386cf56e6c8822e41 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 29 May 2026 13:05:37 +0200 Subject: [PATCH 080/384] Add Status.RefsSnapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cheap fingerprint of local branches and HEAD that future code can poll to detect when refs have moved externally. Branches come from a porcelain for-each-ref. HEAD is read directly from .git/HEAD: that avoids spawning a child process and captures the symref-or-hash distinction we need to tell "detached at X" apart from "on a branch pointing at X" — they share a commit hash, which is exactly the situation at the end of a rebase when HEAD reattaches to the branch. The reftable backend doesn't keep a real .git/HEAD (it writes a fixed stub), so when we see that stub or the file is unreadable we fall back to porcelain commands, which are backend-agnostic. Uses DontLog so a future polling caller won't spam the command log. Not yet wired up to any caller. --- pkg/commands/git_commands/deps_test.go | 6 ++ pkg/commands/git_commands/status.go | 62 ++++++++++++++++ pkg/commands/git_commands/status_test.go | 91 ++++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 pkg/commands/git_commands/status_test.go diff --git a/pkg/commands/git_commands/deps_test.go b/pkg/commands/git_commands/deps_test.go index 85de496dc..235f21716 100644 --- a/pkg/commands/git_commands/deps_test.go +++ b/pkg/commands/git_commands/deps_test.go @@ -168,6 +168,12 @@ func buildBranchCommands(deps commonDeps) *BranchCommands { return NewBranchCommands(gitCommon) } +func buildStatusCommands(deps commonDeps) *StatusCommands { + gitCommon := buildGitCommon(deps) + + return NewStatusCommands(gitCommon) +} + func buildFlowCommands(deps commonDeps) *FlowCommands { gitCommon := buildGitCommon(deps) diff --git a/pkg/commands/git_commands/status.go b/pkg/commands/git_commands/status.go index ff09e22bc..d9120d87d 100644 --- a/pkg/commands/git_commands/status.go +++ b/pkg/commands/git_commands/status.go @@ -4,8 +4,10 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/spf13/afero" ) type StatusCommands struct { @@ -82,6 +84,66 @@ func (self *StatusCommands) IsInRevert() (bool, error) { return self.os.FileExists(filepath.Join(self.repoPaths.WorktreeGitDirPath(), "REVERT_HEAD")) } +// RefsSnapshot returns a string fingerprint of the current state of local +// branches and HEAD. Comparing two snapshots byte-for-byte tells us whether +// any local ref or HEAD has moved since the last snapshot. +func (self *StatusCommands) RefsSnapshot() (string, error) { + t := time.Now() + defer func() { self.Log.Infof("RefsSnapshot took %s", time.Since(t)) }() + + refsArgs := NewGitCmd("for-each-ref"). + Arg("--format=%(objectname) %(refname)"). + Arg("refs/heads"). + ToArgv() + refs, err := self.cmd.New(refsArgs).DontLog().RunWithOutput() + if err != nil { + return "", err + } + + head, err := self.headSnapshot() + if err != nil { + return "", err + } + + return refs + head, nil +} + +// headSnapshot returns a fingerprint of HEAD that distinguishes "detached at +// commit X" from "on a branch that points at X". The commit hash alone can't +// tell those apart, which matters at the end of a rebase: HEAD reattaches to +// the branch without the hash changing, and we'd otherwise miss that refresh. +// +// We read .git/HEAD directly rather than shelling out: it's faster (no child +// process) and its content is exactly the symref-or-hash distinction we want +// ("ref: refs/heads/foo" when attached, the raw hash when detached). The +// reftable backend, however, doesn't keep a real .git/HEAD — it writes a fixed +// stub ("ref: refs/heads/.invalid") that never reflects the actual HEAD. When +// we see that stub (or the file is missing/unreadable) we fall back to +// porcelain commands, which are backend-agnostic. +func (self *StatusCommands) headSnapshot() (string, error) { + headPath := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "HEAD") + if content, err := afero.ReadFile(self.Fs, headPath); err == nil { + head := strings.TrimSpace(string(content)) + if head != "" && head != "ref: refs/heads/.invalid" { + return head, nil + } + } + + // symbolic-ref gives the branch when HEAD is attached and fails when it's + // detached, in which case rev-parse gives the commit HEAD points at. + symbolicRefArgs := NewGitCmd("symbolic-ref").Arg("HEAD").ToArgv() + if symref, err := self.cmd.New(symbolicRefArgs).DontLog().RunWithOutput(); err == nil { + return strings.TrimSpace(symref), nil + } + + revParseArgs := NewGitCmd("rev-parse").Arg("HEAD").ToArgv() + head, err := self.cmd.New(revParseArgs).DontLog().RunWithOutput() + if err != nil { + return "", err + } + return strings.TrimSpace(head), nil +} + // Full ref (e.g. "refs/heads/mybranch") of the branch that is currently // being rebased, or empty string when we're not in a rebase func (self *StatusCommands) BranchBeingRebased() string { diff --git a/pkg/commands/git_commands/status_test.go b/pkg/commands/git_commands/status_test.go new file mode 100644 index 000000000..dc6b2d558 --- /dev/null +++ b/pkg/commands/git_commands/status_test.go @@ -0,0 +1,91 @@ +package git_commands + +import ( + "testing" + + "github.com/go-errors/errors" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/samber/lo" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" +) + +func TestStatusRefsSnapshot(t *testing.T) { + const forEachRefOutput = "aaaa refs/heads/main\nbbbb refs/heads/topic\n" + forEachRefArgs := []string{"for-each-ref", "--format=%(objectname) %(refname)", "refs/heads"} + + scenarios := []struct { + testName string + headFile *string // nil means: don't create a .git/HEAD file (simulates it being unreadable). + runner *oscommands.FakeCmdObjRunner + expectedHead string + }{ + { + // files backend, on a branch: read straight from .git/HEAD, no + // child process for HEAD. + testName: "attached, read from HEAD file", + headFile: lo.ToPtr("ref: refs/heads/main\n"), + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil), + expectedHead: "ref: refs/heads/main", + }, + { + // files backend, detached: .git/HEAD holds the raw hash. + testName: "detached, read from HEAD file", + headFile: lo.ToPtr("aaaa\n"), + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil), + expectedHead: "aaaa", + }, + { + // reftable backend (HEAD is a fixed stub), attached: fall back to + // symbolic-ref, which succeeds. + testName: "reftable stub, attached, fall back to symbolic-ref", + headFile: lo.ToPtr("ref: refs/heads/.invalid\n"), + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil). + ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "refs/heads/main\n", nil), + expectedHead: "refs/heads/main", + }, + { + // reftable backend, detached: symbolic-ref fails, fall back to + // rev-parse. + testName: "reftable stub, detached, fall back to rev-parse", + headFile: lo.ToPtr("ref: refs/heads/.invalid\n"), + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil). + ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "", errors.New("fatal: ref HEAD is not a symbolic ref")). + ExpectGitArgs([]string{"rev-parse", "HEAD"}, "aaaa\n", nil), + expectedHead: "aaaa", + }, + { + // HEAD file missing/unreadable: same fallback as reftable. + testName: "no HEAD file, fall back to symbolic-ref", + headFile: nil, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs(forEachRefArgs, forEachRefOutput, nil). + ExpectGitArgs([]string{"symbolic-ref", "HEAD"}, "refs/heads/main\n", nil), + expectedHead: "refs/heads/main", + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + fs := afero.NewMemMapFs() + if s.headFile != nil { + assert.NoError(t, afero.WriteFile(fs, "/repo/.git/HEAD", []byte(*s.headFile), 0o600)) + } + + instance := buildStatusCommands(commonDeps{ + runner: s.runner, + fs: fs, + repoPaths: MockRepoPaths("/repo"), + }) + + snapshot, err := instance.RefsSnapshot() + assert.NoError(t, err) + assert.Equal(t, forEachRefOutput+s.expectedHead, snapshot) + s.runner.CheckForMissingCalls() + }) + } +} From 661df80fe8c99690637019da03155abf3ed85d77 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 29 May 2026 13:06:52 +0200 Subject: [PATCH 081/384] Add config options for external change detection Two settings to control the upcoming background polling mechanism: - git.autoDetectExternalChanges (default true) is the on/off switch, parallel to autoFetch/autoRefresh - refresher.externalChangeCheckInterval (default 2 seconds) is the poll cadence Disabling is the bool's job, not a magic 0 interval, matching the existing convention. Not yet referenced by any code. --- docs-master/Config.md | 10 ++++++++++ pkg/config/app_config_test.go | 11 +++++++++++ pkg/config/user_config.go | 15 +++++++++++++-- schema-master/config.json | 11 +++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index fa6b3eeac..80f8cfd23 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -414,6 +414,11 @@ git: # 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 not "none", lazygit will automatically fast-forward local branches to match # their upstream after fetching. Applies to branches that are not the currently # checked out branch, and only to those that are strictly behind their upstream @@ -525,6 +530,11 @@ refresher: # 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 diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index 1109256a9..8e6c85f32 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -654,6 +654,12 @@ git: # 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 @@ -723,6 +729,11 @@ refresher: # 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 diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 0f0b4792f..e96f4aff4 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -48,6 +48,9 @@ type RefresherConfig struct { // Re-fetch interval in seconds. // Auto-fetch can be disabled via option 'git.autoFetch'. FetchInterval int `yaml:"fetchInterval" jsonschema:"exclusiveMinimum=0"` + // 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 int `yaml:"externalChangeCheckInterval" jsonschema:"exclusiveMinimum=0"` } func (c *RefresherConfig) RefreshIntervalDuration() time.Duration { @@ -58,6 +61,10 @@ func (c *RefresherConfig) FetchIntervalDuration() time.Duration { return time.Second * time.Duration(c.FetchInterval) } +func (c *RefresherConfig) ExternalChangeCheckIntervalDuration() time.Duration { + return time.Second * time.Duration(c.ExternalChangeCheckInterval) +} + type GuiConfig struct { // See https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-author-color AuthorColors map[string]string `yaml:"authorColors"` @@ -296,6 +303,8 @@ type GitConfig struct { AutoFetch bool `yaml:"autoFetch"` // If true, periodically refresh files and submodules AutoRefresh bool `yaml:"autoRefresh"` + // 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 bool `yaml:"autoDetectExternalChanges"` // If not "none", lazygit will automatically fast-forward local branches to match their upstream after fetching. Applies to branches that are not the currently checked out branch, and only to those that are strictly behind their upstream (as opposed to diverged). // Possible values: 'none' | 'onlyMainBranches' | 'allBranches' AutoForwardBranches string `yaml:"autoForwardBranches" jsonschema:"enum=none,enum=onlyMainBranches,enum=allBranches"` @@ -922,6 +931,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { MainBranches: []string{"master", "main"}, AutoFetch: true, AutoRefresh: true, + AutoDetectExternalChanges: true, AutoForwardBranches: "onlyMainBranches", FetchAll: true, AutoStageResolvedConflicts: true, @@ -937,8 +947,9 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { TruncateCopiedCommitHashesTo: 12, }, Refresher: RefresherConfig{ - RefreshInterval: 10, - FetchInterval: 60, + RefreshInterval: 10, + FetchInterval: 60, + ExternalChangeCheckInterval: 2, }, Update: UpdateConfig{ Method: "prompt", diff --git a/schema-master/config.json b/schema-master/config.json index ff50ab185..e0871940c 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -358,6 +358,11 @@ "description": "If true, periodically refresh files and submodules", "default": true }, + "autoDetectExternalChanges": { + "type": "boolean", + "description": "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.", + "default": true + }, "autoForwardBranches": { "type": "string", "enum": [ @@ -3548,6 +3553,12 @@ "exclusiveMinimum": 0, "description": "Re-fetch interval in seconds.\nAuto-fetch can be disabled via option 'git.autoFetch'.", "default": 60 + }, + "externalChangeCheckInterval": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Interval in seconds at which lazygit polls for external ref changes (commits, branch updates, checkouts made outside lazygit).\nDetection can be disabled via option 'git.autoDetectExternalChanges'.", + "default": 2 } }, "additionalProperties": false, From c1eeacdfe8631d8368af5e5589d03176d00c337b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 29 May 2026 13:08:09 +0200 Subject: [PATCH 082/384] Snapshot refs state before refs-touching refreshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the storage and snapshot-update half of the external-change-detection mechanism. RefreshHelper now keeps a mutex-protected snapshot string and exposes accessors for it; Refresh captures a fresh snapshot at the start of any refresh whose scope set includes COMMITS or BRANCHES. We capture before reading the git state, not after. Capturing after would let an external change that lands between the git state read and the snapshot (say, the next step of a rebase running in another terminal) leave the stored snapshot newer than what we actually rendered; the poller would then see no difference and never refresh again, stranding the UI on the intermediate state. Capturing first keeps the snapshot from running ahead of the render, so if disk moves during the refresh the next poll catches it. No reader of the snapshot exists yet — the polling goroutine that consumes it comes in a later commit. Keeping the snapshot hook in its own commit isolates the invariant that the snapshot stays in sync with what the UI has observed, which is what makes the poller's change-detection predicate work across in-app commands and focus-in refreshes. --- pkg/gui/controllers/helpers/refresh_helper.go | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index f1277e007..b51c528e2 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -19,6 +19,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" + "github.com/sasha-s/go-deadlock" ) type RefreshHelper struct { @@ -36,6 +37,12 @@ type RefreshHelper struct { // Keyed by repo path so that switching to a different repo while lazygit is running // still triggers the prompt there. githubBaseRemotePromptDismissed map[string]bool + + // Last observed refs+HEAD fingerprint, used by the background poller to + // decide whether a real refresh is needed. Written at the end of every + // refresh that re-read refs/commits, read by the poller. + refsSnapshotMutex deadlock.Mutex + refsSnapshot string } func NewRefreshHelper( @@ -123,6 +130,13 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { scopeSet.Add(types.MERGE_CONFLICTS) } + // Capture the refs snapshot now, before we start reading git's state + // below, rather than after. This is important to guard against the race + // of git's state changing externally while (or right after) we are + // refreshing; the risk is one potential extra refresh, but capturing the + // snapshot at the end would risk missing one, which is worse. + self.updateRefsSnapshotIfRelevant(scopeSet) + wg := sync.WaitGroup{} refresh := func(name string, f func()) { // if we're in a demo we don't want any async refreshes because @@ -253,6 +267,57 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { f() } +// SetRefsSnapshot stores the given snapshot as the last observed refs state. +// Called externally by the background poller at startup to seed the snapshot, +// and internally by Refresh at the end of a refs-touching refresh. +func (self *RefreshHelper) SetRefsSnapshot(snapshot string) { + self.refsSnapshotMutex.Lock() + defer self.refsSnapshotMutex.Unlock() + self.refsSnapshot = snapshot +} + +// RefsSnapshotChangedSince reports whether the given snapshot differs from +// the last observed one. Pure read; does not update internal state. +func (self *RefreshHelper) RefsSnapshotChangedSince(snapshot string) bool { + self.refsSnapshotMutex.Lock() + defer self.refsSnapshotMutex.Unlock() + + // An empty stored snapshot means no refresh has captured one yet, so we + // have no baseline to compare against and report "unchanged" rather than + // firing a spurious refresh. This can only be the unset zero value: a + // snapshot we actually computed is never empty, because its HEAD component + // is always non-empty (a branch ref when attached, a hash when detached — + // even a repo with no commits yields "ref: refs/heads/main"). + if self.refsSnapshot == "" { + return false + } + + return snapshot != self.refsSnapshot +} + +// updateRefsSnapshotIfRelevant captures a fresh refs snapshot from disk at the +// start of a refresh that re-reads refs/commits (see the call site for why we +// capture before reading the model rather than after). This keeps the +// background poller's stored snapshot in sync with what's been observed by the +// UI, so in-app commands and focus-in refreshes don't cause the next poll to +// spuriously re-trigger. +// +// We check just COMMITS and BRANCHES because the scope-expansion step at the +// top of Refresh has already added these whenever REFLOG or BISECT_INFO are +// in scope, and whenever a nil scope was passed. +func (self *RefreshHelper) updateRefsSnapshotIfRelevant(scopeSet *set.Set[types.RefreshableView]) { + if !scopeSet.Includes(types.COMMITS) && !scopeSet.Includes(types.BRANCHES) { + return + } + + snapshot, err := self.c.Git().Status.RefsSnapshot() + if err != nil { + self.c.Log.Warnf("RefsSnapshot failed during refresh: %v", err) + return + } + self.SetRefsSnapshot(snapshot) +} + func getScopeNames(scopes []types.RefreshableView) []string { scopeNameMap := map[types.RefreshableView]string{ types.COMMITS: "commits", From 3050303ed220f97c17df02ca24c1f4ebd9fe15cc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 29 May 2026 14:05:13 +0200 Subject: [PATCH 083/384] Detect external ref changes via background polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 2-second background poll that calls Status.RefsSnapshot and compares against the snapshot stored at the end of the last refs- touching refresh. On a diff, trigger a full refresh — same scope as the focus-in handler, because once we know something changed externally we can't be sure what (an agent might have created a worktree or stashed something alongside the commit we detected). Refresh runs in SYNC mode because goEvery already serializes iterations via <-done: a slow refresh delays the next tick naturally instead of letting work stack. The post-refresh hook from the previous commit updates the snapshot, so in-app commands don't cause the next poll to spuriously re-fire. Disabled in the integration test config, like autoRefresh and autoFetch, because demo replays make repo changes throughout the run; at 2-second cadence the resulting full refreshes compete with the demo's own choreography and push some demos past their 40-second timeout. Also list the two new config keys in checkForChangedConfigsThatDontAutoReload so a config edit warns the user that lazygit needs a restart. --- pkg/gui/background.go | 60 +++++++++++++++++++++++++++++ pkg/gui/gui.go | 2 + test/default_test_config/config.yml | 1 + 3 files changed, 63 insertions(+) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 2575aedd1..afd343df3 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -62,6 +62,17 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() { } } + if userConfig.Git.AutoDetectExternalChanges { + interval := userConfig.Refresher.ExternalChangeCheckInterval + if interval > 0 { + go utils.Safe(self.startBackgroundExternalChangeDetection) + } else { + self.gui.c.Log.Errorf( + "Value of config option 'refresher.externalChangeCheckInterval' (%d) is invalid, disabling external change detection", + interval) + } + } + if self.gui.Config.GetDebug() { self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, func(_ bool) error { formatBytes := func(b uint64) string { @@ -127,6 +138,55 @@ func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() { }) } +func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() { + self.gui.waitForIntro.Wait() + + // We don't seed the snapshot here. The startup refresh captures one on + // entry (like every refs-touching refresh), and until one has been + // captured RefsSnapshotChangedSince treats the empty baseline as + // "unchanged", so we never fire a spurious refresh before a baseline + // exists — no need to depend on the timing of that startup refresh. + + userConfig := self.gui.UserConfig() + self.goEvery( + userConfig.Refresher.ExternalChangeCheckIntervalDuration(), + self.gui.stopChan, + func(_ bool) error { + self.checkForExternalChanges() + return nil + }, + ) +} + +func (self *BackgroundRoutineMgr) checkForExternalChanges() { + current, err := self.gui.git.Status.RefsSnapshot() + if err != nil { + // Transient error (e.g. git process couldn't start). Don't update the + // stored snapshot; we'll retry next tick. + self.gui.c.Log.Warnf("RefsSnapshot failed: %v", err) + return + } + + if !self.gui.helpers.Refresh.RefsSnapshotChangedSince(current) { + return + } + + // goEvery checks the pause count before starting us, but a git operation + // may have begun (and paused refreshes) after that check, while we were + // reading the snapshot above. In that case the change we detected is the + // operation's own intermediate state, so back off: the operation will + // refresh and re-snapshot when it finishes, and if the change was really + // external we'll catch it on the next tick after the pause lifts. We don't + // update the stored snapshot, so nothing is swallowed. + if self.backgroundRefreshesPaused() { + return + } + + // No need to update the stored snapshot here; Refresh does that. + self.gui.c.Log.Info("External ref change detected — refreshing") + self.gui.c.Refresh(types.RefreshOptions{}) +} + // returns a channel that can be used to trigger the callback immediately func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan struct{}, function func(bool) error) chan struct{} { done := make(chan struct{}) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e2881cca1..ee58bbfb8 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -515,8 +515,10 @@ func (gui *Gui) checkForChangedConfigsThatDontAutoReload(oldConfig *config.UserC configsThatDontAutoReload := []string{ "Git.AutoFetch", "Git.AutoRefresh", + "Git.AutoDetectExternalChanges", "Refresher.RefreshInterval", "Refresher.FetchInterval", + "Refresher.ExternalChangeCheckInterval", "Update.Method", "Update.Days", } diff --git a/test/default_test_config/config.yml b/test/default_test_config/config.yml index 5a822ae77..198fcbdd1 100644 --- a/test/default_test_config/config.yml +++ b/test/default_test_config/config.yml @@ -20,3 +20,4 @@ git: # TODO: add tests which explicitly test auto-refresh functionality autoRefresh: false autoFetch: false + autoDetectExternalChanges: false From 94db69f64b7b05840cdf2839bd987f856f9b1d63 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 19 Jun 2026 18:14:24 +0200 Subject: [PATCH 084/384] Add GlobalArg/GlobalArgIf to GitCommandBuilder This can be used to add a git argument that goes before the git subcommand. --- pkg/commands/git_commands/git_command_builder.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go index 30496f453..4178cbd22 100644 --- a/pkg/commands/git_commands/git_command_builder.go +++ b/pkg/commands/git_commands/git_command_builder.go @@ -38,6 +38,22 @@ func (self *GitCommandBuilder) ArgIfElse(condition bool, ifTrue string, ifFalse return self.Arg(ifFalse) } +// GlobalArg adds top-level options for git itself (e.g. --no-optional-locks). +// Unlike Arg, these are prepended before the command, where git expects them. +func (self *GitCommandBuilder) GlobalArg(args ...string) *GitCommandBuilder { + self.args = append(append([]string{}, args...), self.args...) + + return self +} + +func (self *GitCommandBuilder) GlobalArgIf(condition bool, args ...string) *GitCommandBuilder { + if condition { + self.GlobalArg(args...) + } + + return self +} + func (self *GitCommandBuilder) Config(value string) *GitCommandBuilder { // config settings come before the command self.args = append([]string{"-c", value}, self.args...) From d94f2f05aca1862fa0555fb87b45ea79c08fd4b6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 19 Jun 2026 17:31:13 +0200 Subject: [PATCH 085/384] Only pass --no-optional-locks for background status refreshes We set GIT_OPTIONAL_LOCKS=0 for every git command we run. That env var only affects `git status`: it tells git not to take the optional lock it would otherwise use to write the index back after refreshing the cached stat information. The intent was to avoid contending for index.lock with git commands the user runs in a terminal. The downside is that our `git status` never persists the refreshed stat-cache. So whenever the working tree's cached stat info goes stale (e.g. editing files and discarding the changes, or a checkout), every subsequent status re-hashes the affected files to confirm they're clean, and stays slow until something else writes the index (such as the user running `git status` in a terminal). Fix this by only suppressing optional locks for refreshes that run unattended in the background; foreground refreshes triggered by a user action now run a plain `git status` that writes the refreshed index back, just like the command line does. Background refreshes keep passing --no-optional-locks so they still can't cause lock contention. RefreshOptions gains a Background flag that the background routines set, threaded down to the status command. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_cmd_obj_builder.go | 6 ++---- pkg/commands/git_commands/file_loader.go | 9 ++++++++- pkg/commands/git_commands/file_loader_test.go | 11 ++++++++++- pkg/gui/background.go | 6 +++--- pkg/gui/controllers/files_controller.go | 2 +- pkg/gui/controllers/helpers/branches_helper.go | 4 ++-- pkg/gui/controllers/helpers/refresh_helper.go | 9 +++++---- pkg/gui/types/refresh.go | 8 ++++++++ 8 files changed, 39 insertions(+), 16 deletions(-) diff --git a/pkg/commands/git_cmd_obj_builder.go b/pkg/commands/git_cmd_obj_builder.go index 753489ef4..495582722 100644 --- a/pkg/commands/git_cmd_obj_builder.go +++ b/pkg/commands/git_cmd_obj_builder.go @@ -28,14 +28,12 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild } } -var defaultEnvVar = "GIT_OPTIONAL_LOCKS=0" - func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj { - return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar) + return self.innerBuilder.New(args) } func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj { - return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar) + return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile) } func (self *gitCmdObjBuilder) Quote(str string) string { diff --git a/pkg/commands/git_commands/file_loader.go b/pkg/commands/git_commands/file_loader.go index 36ab8ef67..9df977bb9 100644 --- a/pkg/commands/git_commands/file_loader.go +++ b/pkg/commands/git_commands/file_loader.go @@ -36,6 +36,11 @@ type GetStatusFileOptions struct { // This is useful for users with bare repos for dotfiles who default to hiding untracked files, // but want to occasionally see them to `git add` a new file. ForceShowUntracked bool + // When true, this status is part of an unattended background refresh, so we + // pass --no-optional-locks to avoid index.lock contention with git commands + // the user runs in a terminal (at the cost of not persisting git's refreshed + // stat-cache). + Background bool } func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File { @@ -47,7 +52,7 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File } untrackedFilesArg := fmt.Sprintf("--untracked-files=%s", untrackedFilesSetting) - statuses, err := self.gitStatus(GitStatusOptions{NoRenames: opts.NoRenames, UntrackedFilesArg: untrackedFilesArg}) + statuses, err := self.gitStatus(GitStatusOptions{NoRenames: opts.NoRenames, UntrackedFilesArg: untrackedFilesArg, Background: opts.Background}) if err != nil { self.Log.Error(err) } @@ -148,6 +153,7 @@ func (self *FileLoader) getFileDiffs() (map[string]FileDiff, error) { type GitStatusOptions struct { NoRenames bool UntrackedFilesArg string + Background bool } type FileStatus struct { @@ -169,6 +175,7 @@ func (self *FileLoader) gitDiffNumStat() (string, error) { func (self *FileLoader) gitStatus(opts GitStatusOptions) ([]FileStatus, error) { cmdArgs := NewGitCmd("status"). + GlobalArgIf(opts.Background, "--no-optional-locks"). Arg(opts.UntrackedFilesArg). Arg("--porcelain"). Arg("-z"). diff --git a/pkg/commands/git_commands/file_loader_test.go b/pkg/commands/git_commands/file_loader_test.go index ec1f502f1..4f6b5e136 100644 --- a/pkg/commands/git_commands/file_loader_test.go +++ b/pkg/commands/git_commands/file_loader_test.go @@ -13,6 +13,7 @@ func TestFileGetStatusFiles(t *testing.T) { type scenario struct { testName string similarityThreshold int + background bool runner oscommands.ICmdObjRunner showNumstatInFilesView bool expectedFiles []*models.File @@ -26,6 +27,14 @@ func TestFileGetStatusFiles(t *testing.T) { ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "", nil), expectedFiles: []*models.File{}, }, + { + testName: "Background refresh passes --no-optional-locks", + similarityThreshold: 50, + background: true, + runner: oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"--no-optional-locks", "status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "", nil), + expectedFiles: []*models.File{}, + }, { testName: "Several files found", similarityThreshold: 50, @@ -246,7 +255,7 @@ func TestFileGetStatusFiles(t *testing.T) { getFileType: func(string) string { return "file" }, } - assert.EqualValues(t, s.expectedFiles, loader.GetStatusFiles(GetStatusFileOptions{})) + assert.EqualValues(t, s.expectedFiles, loader.GetStatusFiles(GetStatusFileOptions{Background: s.background})) }) } } diff --git a/pkg/gui/background.go b/pkg/gui/background.go index afd343df3..94bf4f678 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -133,7 +133,7 @@ func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() { userConfig := self.gui.UserConfig() self.goEvery(userConfig.Refresher.RefreshIntervalDuration(), self.gui.stopChan, func(_ bool) error { - self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) + self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Background: true}) return nil }) } @@ -184,7 +184,7 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() { // No need to update the stored snapshot here; Refresh does that. self.gui.c.Log.Info("External ref change detected — refreshing") - self.gui.c.Refresh(types.RefreshOptions{}) + self.gui.c.Refresh(types.RefreshOptions{Background: true}) } // returns a channel that can be used to trigger the callback immediately @@ -226,7 +226,7 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru func (self *BackgroundRoutineMgr) backgroundFetch() (err error) { err = self.gui.git.Sync.FetchBackground() - return self.gui.helpers.BranchesHelper.PostFetchRefresh(err) + return self.gui.helpers.BranchesHelper.PostFetchRefresh(err, true) } func (self *BackgroundRoutineMgr) triggerImmediateFetch() { diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 09f654e2b..d048da508 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1372,7 +1372,7 @@ func (self *FilesController) fetch() error { return errors.New(self.c.Tr.PassUnameWrong) } - return self.c.Helpers().BranchesHelper.PostFetchRefresh(err) + return self.c.Helpers().BranchesHelper.PostFetchRefresh(err, false) }) } diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 8af447f79..ccc9d33ac 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -285,7 +285,7 @@ func (self *BranchesHelper) deleteRemoteBranches(remoteBranches []*models.Remote return nil } -func (self *BranchesHelper) PostFetchRefresh(fetchErr error) error { +func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) error { scope := []types.RefreshableView{ types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS, } @@ -293,7 +293,7 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error) error { if self.c.UserConfig().Git.AutoForwardBranches != "none" { scope = append(scope, types.WORKTREES) } - self.c.Refresh(types.RefreshOptions{Scope: scope, Mode: types.SYNC}) + self.c.Refresh(types.RefreshOptions{Scope: scope, Mode: types.SYNC, Background: background}) if fetchErr != nil { return fetchErr } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index b51c528e2..31035d104 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -200,7 +200,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if scopeSet.Includes(types.FILES) { fileWg.Add(1) refresh("files", func() { - _ = self.refreshFilesAndSubmodules() + _ = self.refreshFilesAndSubmodules(options.Background) fileWg.Done() }) } @@ -624,7 +624,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele self.refreshStatus() } -func (self *RefreshHelper) refreshFilesAndSubmodules() error { +func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { self.c.Mutexes().RefreshingFilesMutex.Lock() self.c.State().SetIsRefreshingFiles(true) defer func() { @@ -636,7 +636,7 @@ func (self *RefreshHelper) refreshFilesAndSubmodules() error { return err } - if err := self.refreshStateFiles(); err != nil { + if err := self.refreshStateFiles(background); err != nil { return err } @@ -649,7 +649,7 @@ func (self *RefreshHelper) refreshFilesAndSubmodules() error { return nil } -func (self *RefreshHelper) refreshStateFiles() error { +func (self *RefreshHelper) refreshStateFiles(background bool) error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel prevConflictFileCount := 0 @@ -687,6 +687,7 @@ func (self *RefreshHelper) refreshStateFiles() error { files := self.c.Git().Loaders.FileLoader. GetStatusFiles(git_commands.GetStatusFileOptions{ ForceShowUntracked: self.c.Contexts().Files.ForceShowUntracked(), + Background: background, }) conflictFileCount := 0 diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index 8092ee36e..c9e156180 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -44,4 +44,12 @@ type RefreshOptions struct { // keeps the selection index the same. Useful after checking out a detached // head, and selecting index 0. KeepBranchSelectionIndex bool + + // When true, this refresh was initiated by a background routine rather than + // by a user action. We use it to keep background `git status` calls from + // taking optional git locks, so they don't contend for index.lock with git + // commands the user runs in a terminal. The cost is that such a status won't + // persist git's refreshed stat-cache, which is the right trade-off for + // unattended work; foreground refreshes leave this false so they do persist. + Background bool } From eb988395e6d1caaac40a0dfbeb2cf259fb2889b0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 19 Jun 2026 17:40:51 +0200 Subject: [PATCH 086/384] Remove the now-redundant gitCmdObjBuilder wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrapper existed to add a git-specific env var to every command. Now that that's gone, its New/NewShell/Quote methods just delegated to the inner builder. The only remaining git-specific behavior — the command runner — is attached in the constructor via CloneWithNewRunner, which already returns a complete builder, so we can return that directly and drop the wrapper struct. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_cmd_obj_builder.go | 35 ++++++----------------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/pkg/commands/git_cmd_obj_builder.go b/pkg/commands/git_cmd_obj_builder.go index 495582722..6b6bd26d8 100644 --- a/pkg/commands/git_cmd_obj_builder.go +++ b/pkg/commands/git_cmd_obj_builder.go @@ -5,37 +5,16 @@ import ( "github.com/sirupsen/logrus" ) -// all we're doing here is wrapping the default command object builder with -// some git-specific stuff: e.g. adding a git-specific env var - -type gitCmdObjBuilder struct { - innerBuilder *oscommands.CmdObjBuilder -} - -var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{} - -func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *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 { +// NewGitCmdObjBuilder returns a command object builder whose runner is wrapped +// with our git-specific runner (logging, credential handling, etc.). +func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *oscommands.CmdObjBuilder { + // We decorate the runner rather than exposing the builder's runner field: + // that field stays unexported so there's a single API for running commands + // across the codebase. + return innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner { return &gitCmdObjRunner{ log: log, innerRunner: runner, } }) - - return &gitCmdObjBuilder{ - innerBuilder: updatedBuilder, - } -} - -func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj { - return self.innerBuilder.New(args) -} - -func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj { - return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile) -} - -func (self *gitCmdObjBuilder) Quote(str string) string { - return self.innerBuilder.Quote(str) } From 02be5e74edcb5eeffcc13fd5d41e634603043c7c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 22 Jun 2026 08:52:36 +0200 Subject: [PATCH 087/384] Tighten a test expectation This guards against regressions from the changes that follow. We're about to add a mechanism that keeps the selection anchored by commit hash, but we need to make sure that it doesn't take effect here; after a merge we want to select the newly added merge commit. In the current state of the code this happens to work because we keep the selection index the same, which happened to be 0 here; later we will change this to explicitly select the head commit after the merge. --- pkg/integration/tests/sync/pull_merge.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/integration/tests/sync/pull_merge.go b/pkg/integration/tests/sync/pull_merge.go index 39e447ebc..295923b56 100644 --- a/pkg/integration/tests/sync/pull_merge.go +++ b/pkg/integration/tests/sync/pull_merge.go @@ -29,7 +29,7 @@ var PullMerge = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Lines( - Contains("four"), + Contains("four").IsSelected(), Contains("one"), ) @@ -43,7 +43,7 @@ var PullMerge = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Lines( - Contains("Merge branch 'master' of ../origin"), + Contains("Merge branch 'master' of ../origin").IsSelected(), Contains("three"), Contains("two"), Contains("four"), From d673a0f3b90b8dfcaa3606e236c0cbec370bf331 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 22 Jun 2026 09:02:11 +0200 Subject: [PATCH 088/384] Add tests for IsHeadCommit --- pkg/commands/models/commit_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 pkg/commands/models/commit_test.go diff --git a/pkg/commands/models/commit_test.go b/pkg/commands/models/commit_test.go new file mode 100644 index 000000000..d24238023 --- /dev/null +++ b/pkg/commands/models/commit_test.go @@ -0,0 +1,29 @@ +package models + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stefanhaller/git-todo-parser/todo" + "github.com/stretchr/testify/assert" +) + +func TestIsHeadCommit(t *testing.T) { + commits := []*Commit{ + makeTestTodoCommit(todo.Pick), + makeTestCommit("a"), + makeTestCommit("b"), + } + + assert.False(t, IsHeadCommit(commits, 0)) + assert.True(t, IsHeadCommit(commits, 1)) + assert.False(t, IsHeadCommit(commits, 2)) +} + +func makeTestCommit(hash string) *Commit { + return NewCommit(&utils.StringPool{}, NewCommitOpts{Hash: hash}) +} + +func makeTestTodoCommit(action todo.TodoCommand) *Commit { + return NewCommit(&utils.StringPool{}, NewCommitOpts{Action: action}) +} From cfb46f440cdb76a6a32391f87ae6638aa1b4b9ba Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 22 Jun 2026 09:15:32 +0200 Subject: [PATCH 089/384] Add HeadCommitIdx helper function Not used yet, we'll need it in the next commit. --- pkg/commands/models/commit.go | 10 ++++++ pkg/commands/models/commit_test.go | 52 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/pkg/commands/models/commit.go b/pkg/commands/models/commit.go index 137528ee6..69aca8d73 100644 --- a/pkg/commands/models/commit.go +++ b/pkg/commands/models/commit.go @@ -160,3 +160,13 @@ func (c *Commit) IsTODO() bool { func IsHeadCommit(commits []*Commit, index int) bool { return !commits[index].IsTODO() && (index == 0 || commits[index-1].IsTODO()) } + +func HeadCommitIdx(commits []*Commit) int { + for index, commit := range commits { + if !commit.IsTODO() { + return index + } + } + + return -1 +} diff --git a/pkg/commands/models/commit_test.go b/pkg/commands/models/commit_test.go index d24238023..ecddec9c5 100644 --- a/pkg/commands/models/commit_test.go +++ b/pkg/commands/models/commit_test.go @@ -8,6 +8,49 @@ import ( "github.com/stretchr/testify/assert" ) +func TestHeadCommitIdx(t *testing.T) { + testCases := []struct { + name string + commits []*Commit + expected int + }{ + { + name: "first commit without rebase todos", + commits: makeTestCommits("a", "b"), + expected: 0, + }, + { + name: "first non-todo commit during an interactive rebase", + commits: []*Commit{ + makeTestTodoCommit(todo.Pick), + makeTestTodoCommit(todo.Reword), + makeTestCommit("a"), + makeTestCommit("b"), + }, + expected: 2, + }, + { + name: "no commits", + commits: nil, + expected: -1, + }, + { + name: "only rebase todos", + commits: []*Commit{ + makeTestTodoCommit(todo.Pick), + makeTestTodoCommit(todo.Reword), + }, + expected: -1, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + assert.Equal(t, testCase.expected, HeadCommitIdx(testCase.commits)) + }) + } +} + func TestIsHeadCommit(t *testing.T) { commits := []*Commit{ makeTestTodoCommit(todo.Pick), @@ -20,6 +63,15 @@ func TestIsHeadCommit(t *testing.T) { assert.False(t, IsHeadCommit(commits, 2)) } +func makeTestCommits(hashes ...string) []*Commit { + commits := make([]*Commit, 0, len(hashes)) + for _, hash := range hashes { + commits = append(commits, makeTestCommit(hash)) + } + + return commits +} + func makeTestCommit(hash string) *Commit { return NewCommit(&utils.StringPool{}, NewCommitOpts{Hash: hash}) } From 3f8dc527b5778c15867992cce2b919ac8037d8ee Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 23 Jun 2026 08:42:51 +0200 Subject: [PATCH 090/384] Cleanup: remove unnecessary `if` statement --- pkg/gui/controllers/helpers/merge_and_rebase_helper.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index cd141c697..ab7d11c29 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -111,10 +111,7 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { ) } result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) - if err := self.CheckMergeOrRebase(result); err != nil { - return err - } - return nil + return self.CheckMergeOrRebase(result) } func (self *MergeAndRebaseHelper) hasExecTodos() bool { From 5d5aa0a865a75a4b71a5125dc7c23dacb8a34db7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 23 Jun 2026 08:30:47 +0200 Subject: [PATCH 091/384] Cleanup: wrap long parameter lists This makes the following diff a little easier to read. --- pkg/gui/controllers/helpers/gpg_helper.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/helpers/gpg_helper.go b/pkg/gui/controllers/helpers/gpg_helper.go index 30ec6ceef..e8e46e403 100644 --- a/pkg/gui/controllers/helpers/gpg_helper.go +++ b/pkg/gui/controllers/helpers/gpg_helper.go @@ -23,7 +23,13 @@ func NewGpgHelper(c *HelperCommon) *GpgHelper { // WithWaitingStatus we get stuck there and can't return to lazygit. We could // fix this bug, or just stop running subprocesses from within there, given that // we don't need to see a loading status if we're in a subprocess. -func (self *GpgHelper) WithGpgHandling(cmdObj *oscommands.CmdObj, configKey git_commands.GpgConfigKey, waitingStatus string, onSuccess func() error, refreshScope []types.RefreshableView) error { +func (self *GpgHelper) WithGpgHandling( + cmdObj *oscommands.CmdObj, + configKey git_commands.GpgConfigKey, + waitingStatus string, + onSuccess func() error, + refreshScope []types.RefreshableView, +) error { useSubprocess := self.c.Git().Config.NeedsGpgSubprocess(configKey) if useSubprocess { success, err := self.c.RunSubprocess(cmdObj) @@ -40,7 +46,12 @@ func (self *GpgHelper) WithGpgHandling(cmdObj *oscommands.CmdObj, configKey git_ return self.runAndStream(cmdObj, waitingStatus, onSuccess, refreshScope) } -func (self *GpgHelper) runAndStream(cmdObj *oscommands.CmdObj, waitingStatus string, onSuccess func() error, refreshScope []types.RefreshableView) error { +func (self *GpgHelper) runAndStream( + cmdObj *oscommands.CmdObj, + waitingStatus string, + onSuccess func() error, + refreshScope []types.RefreshableView, +) error { return self.c.WithWaitingStatus(waitingStatus, func(gocui.Task) error { if err := cmdObj.StreamOutput().Run(); err != nil { self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope}) From 10d2f9f7156fd0343a2b78b358591cc0a49025ff Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 22 Jun 2026 09:22:18 +0200 Subject: [PATCH 092/384] Allow GpgHelper to refresh differently on success and failure Preparation for the next commit, which selects the newly created commit after a commit succeeds, while leaving the selection alone on failure. For now success and failure use the same refresh options, so behavior is unchanged. --- pkg/gui/controllers/helpers/gpg_helper.go | 37 +++++++++++++++++------ 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/pkg/gui/controllers/helpers/gpg_helper.go b/pkg/gui/controllers/helpers/gpg_helper.go index e8e46e403..46fbee703 100644 --- a/pkg/gui/controllers/helpers/gpg_helper.go +++ b/pkg/gui/controllers/helpers/gpg_helper.go @@ -19,16 +19,29 @@ func NewGpgHelper(c *HelperCommon) *GpgHelper { } } -// Currently there is a bug where if we switch to a subprocess from within -// WithWaitingStatus we get stuck there and can't return to lazygit. We could -// fix this bug, or just stop running subprocesses from within there, given that -// we don't need to see a loading status if we're in a subprocess. func (self *GpgHelper) WithGpgHandling( cmdObj *oscommands.CmdObj, configKey git_commands.GpgConfigKey, waitingStatus string, onSuccess func() error, refreshScope []types.RefreshableView, +) error { + refreshOptions := types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope} + return self.withGpgHandling( + cmdObj, configKey, waitingStatus, onSuccess, refreshOptions, refreshOptions) +} + +// Currently there is a bug where if we switch to a subprocess from within +// WithWaitingStatus we get stuck there and can't return to lazygit. We could +// fix this bug, or just stop running subprocesses from within there, given that +// we don't need to see a loading status if we're in a subprocess. +func (self *GpgHelper) withGpgHandling( + cmdObj *oscommands.CmdObj, + configKey git_commands.GpgConfigKey, + waitingStatus string, + onSuccess func() error, + failureRefreshOptions types.RefreshOptions, + successRefreshOptions types.RefreshOptions, ) error { useSubprocess := self.c.Git().Config.NeedsGpgSubprocess(configKey) if useSubprocess { @@ -38,23 +51,29 @@ func (self *GpgHelper) WithGpgHandling( return err } } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope}) + if success { + self.c.Refresh(successRefreshOptions) + } else { + self.c.Refresh(failureRefreshOptions) + } return err } - return self.runAndStream(cmdObj, waitingStatus, onSuccess, refreshScope) + return self.runAndStream( + cmdObj, waitingStatus, onSuccess, failureRefreshOptions, successRefreshOptions) } func (self *GpgHelper) runAndStream( cmdObj *oscommands.CmdObj, waitingStatus string, onSuccess func() error, - refreshScope []types.RefreshableView, + failureRefreshOptions types.RefreshOptions, + successRefreshOptions types.RefreshOptions, ) error { return self.c.WithWaitingStatus(waitingStatus, func(gocui.Task) error { if err := cmdObj.StreamOutput().Run(); err != nil { - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope}) + self.c.Refresh(failureRefreshOptions) return fmt.Errorf( self.c.Tr.GitCommandFailed, self.c.UserConfig().Keybinding.Universal.ExtrasMenu, ) @@ -66,7 +85,7 @@ func (self *GpgHelper) runAndStream( } } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope}) + self.c.Refresh(successRefreshOptions) return nil }) } From c15ab5db5daf352eec3534c69a4e7c53b591da17 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 21 Jun 2026 11:59:13 +0200 Subject: [PATCH 093/384] Keep selected commits stable across refreshes With the recently added external change detection, it happens more often now that we refresh the commits list because an agent made a commit in the background. In this case, if we keep the selection index the same, it now points at a different commit, making the main view show a different commit too, which is confusing and annoying. To fix this, track the selected commit and range anchor by hash before reloading, then restore those rows if both hashes still exist. This also allows us to get rid of some bespoke code that did this for the specific cases of reverting a commit or cherry-picking commits, because those are now handled by the generic mechanism. --- pkg/gui/controllers/branches_controller.go | 6 +- .../controllers/helpers/cherry_pick_helper.go | 10 -- pkg/gui/controllers/helpers/gpg_helper.go | 15 ++ .../helpers/merge_and_rebase_helper.go | 38 ++++- pkg/gui/controllers/helpers/refresh_helper.go | 95 ++++++++++- .../helpers/refresh_helper_test.go | 153 ++++++++++++++++++ pkg/gui/controllers/helpers/refs_helper.go | 27 +++- .../helpers/working_tree_helper.go | 4 +- .../controllers/local_commits_controller.go | 16 +- pkg/gui/controllers/remotes_controller.go | 1 + pkg/gui/controllers/sync_controller.go | 2 +- pkg/gui/types/refresh.go | 26 +++ .../cherry_pick_commit_that_becomes_empty.go | 20 +-- .../cherry_pick/cherry_pick_conflicts.go | 6 +- ..._conflicts_empty_commit_after_resolving.go | 19 +-- ...p_selected_commit_after_external_commit.go | 46 ++++++ pkg/integration/tests/test_list.go | 1 + 17 files changed, 412 insertions(+), 73 deletions(-) create mode 100644 pkg/integration/tests/commit/keep_selected_commit_after_external_commit.go diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 24ef84d54..c2c0595c3 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -594,7 +594,11 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er } self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit() - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, KeepBranchSelectionIndex: true}) + self.c.Refresh(types.RefreshOptions{ + Mode: types.ASYNC, + KeepBranchSelectionIndex: true, + CommitSelection: types.KeepCommitSelectionIndex, + }) return nil } diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go index 079fdedcf..e2fe46545 100644 --- a/pkg/gui/controllers/helpers/cherry_pick_helper.go +++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go @@ -100,16 +100,6 @@ func (self *CherryPickHelper) Paste() error { return result } - // Move the selection down by the number of commits we just - // cherry-picked, to keep the same commit selected as before. - // Don't do this if a rebase todo is selected, because in this - // case we are in a rebase and the cherry-picked commits end up - // below the selection. - if commit := self.c.Contexts().LocalCommits.GetSelected(); commit != nil && !commit.IsTODO() { - self.c.Contexts().LocalCommits.MoveSelection(len(cherryPickedCommits)) - self.c.Contexts().LocalCommits.FocusLine(true) - } - // If we're in the cherry-picking state at this point, it must // be because there were conflicts. Don't clear the copied // commits in this case, since we might want to abort and try diff --git a/pkg/gui/controllers/helpers/gpg_helper.go b/pkg/gui/controllers/helpers/gpg_helper.go index 46fbee703..fb8fae628 100644 --- a/pkg/gui/controllers/helpers/gpg_helper.go +++ b/pkg/gui/controllers/helpers/gpg_helper.go @@ -31,6 +31,21 @@ func (self *GpgHelper) WithGpgHandling( cmdObj, configKey, waitingStatus, onSuccess, refreshOptions, refreshOptions) } +// WithGpgHandlingAndSelectHeadCommit is like WithGpgHandling, but on success it +// selects the new HEAD commit rather than restoring the previous selection. For +// committing, where the commit we just created is the one we want selected. +func (self *GpgHelper) WithGpgHandlingAndSelectHeadCommit( + cmdObj *oscommands.CmdObj, + configKey git_commands.GpgConfigKey, + waitingStatus string, + onSuccess func() error, +) error { + failureRefreshOptions := types.RefreshOptions{Mode: types.ASYNC} + successRefreshOptions := types.RefreshOptions{Mode: types.ASYNC, CommitSelection: types.SelectHeadCommit} + return self.withGpgHandling( + cmdObj, configKey, waitingStatus, onSuccess, failureRefreshOptions, successRefreshOptions) +} + // Currently there is a bug where if we switch to a subprocess from within // WithWaitingStatus we get stuck there and can't return to lazygit. We could // fix this bug, or just stop running subprocesses from within there, given that diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index ab7d11c29..536c254dd 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -95,6 +95,8 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { } commandType := status.CommandName() + selectHeadCommitOnSuccess := command == REBASE_OPTION_CONTINUE && + effectiveStatus == models.WORKING_TREE_STATE_MERGING // we should end up with a command like 'git merge --continue' @@ -106,12 +108,29 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { if needsSubprocess { // TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction - return self.c.RunSubprocessAndRefresh( - self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command), - ) + success, err := self.c.RunSubprocess(self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command)) + self.c.Refresh(types.RefreshOptions{ + Mode: types.ASYNC, + CommitSelection: commitSelectionAfterMerge(success && selectHeadCommitOnSuccess), + }) + return err } result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) - return self.CheckMergeOrRebase(result) + return self.CheckMergeOrRebaseWithRefreshOptions(result, + types.RefreshOptions{ + Mode: types.ASYNC, + CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess), + }) +} + +// commitSelectionAfterMerge maps whether a merge/rebase/pull created a new +// commit at HEAD to the corresponding commit-selection behavior: select that +// new commit, or otherwise keep the previous selection by hash. +func commitSelectionAfterMerge(createdNewCommit bool) types.CommitSelectionBehavior { + if createdNewCommit { + return types.SelectHeadCommit + } + return types.KeepCommitSelectionByHash } func (self *MergeAndRebaseHelper) hasExecTodos() bool { @@ -166,6 +185,15 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error { return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.ASYNC}) } +// Like CheckMergeOrRebase, but for operations that create a new commit at HEAD +// (a merge, or a pull that merges): on success it selects that new commit, +// which the keep-selection-by-hash logic can't do since the commit didn't exist +// before the refresh. +func (self *MergeAndRebaseHelper) CheckMergeOrRebaseAndSelectHeadCommit(result error) error { + return self.CheckMergeOrRebaseWithRefreshOptions(result, + types.RefreshOptions{Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(result == nil)}) +} + func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error { if result == nil { return nil @@ -489,7 +517,7 @@ func (self *MergeAndRebaseHelper) RegularMerge(refName string, variant git_comma return func() error { self.c.LogAction(self.c.Tr.Actions.Merge) err := self.c.Git().Branch.Merge(refName, variant) - return self.CheckMergeOrRebase(err) + return self.CheckMergeOrRebaseAndSelectHeadCommit(err) } } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 31035d104..3ea742c8e 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -12,6 +12,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/config" "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/filetree" "github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts" "github.com/jesseduffield/lazygit/pkg/gui/presentation" @@ -164,7 +165,9 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // 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. - refresh("commits and commit files", self.refreshCommitsAndCommitFiles) + refresh("commits and commit files", func() { + self.refreshCommitsAndCommitFiles(options.CommitSelection) + }) includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { @@ -385,8 +388,8 @@ func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepB self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, loadBehindCounts) } -func (self *RefreshHelper) refreshCommitsAndCommitFiles() { - _ = self.refreshCommitsWithLimit() +func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior) { + _ = self.refreshCommitsWithLimit(commitSelection) ctx := self.c.Contexts().CommitFiles.GetParentContext() if ctx != nil && ctx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY { // This makes sense when we've e.g. just amended a commit, meaning we get a new commit hash at the same position. @@ -430,10 +433,16 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref { return nil } -func (self *RefreshHelper) refreshCommitsWithLimit() error { +func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitSelectionBehavior) error { self.c.Mutexes().LocalCommitsMutex.Lock() defer self.c.Mutexes().LocalCommitsMutex.Unlock() + var selectionRange *localCommitSelectionRange + if commitSelection == types.KeepCommitSelectionByHash { + selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() + selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) + } + checkedOutRef := self.determineCheckedOutRef() commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ @@ -460,10 +469,88 @@ func (self *RefreshHelper) refreshCommitsWithLimit() error { 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 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. + } + self.refreshView(self.c.Contexts().LocalCommits) + if scrollSelectionIntoView { + self.c.OnUIThread(func() error { + self.c.Contexts().LocalCommits.FocusLine(true) + return nil + }) + } return nil } +type localCommitSelectionRange struct { + selectedHash string + selectedIsTODO bool + rangeStartHash string + rangeStartIsTODO bool + selectedIdx int + rangeStartIdx int + mode traits.RangeSelectMode +} + +func captureLocalCommitSelectionRange( + commits []*models.Commit, + selectedIdx int, + rangeStartIdx int, + mode traits.RangeSelectMode, +) *localCommitSelectionRange { + if !hasRestorableCommitHash(commits, selectedIdx) || !hasRestorableCommitHash(commits, rangeStartIdx) { + return nil + } + + return &localCommitSelectionRange{ + selectedHash: commits[selectedIdx].Hash(), + selectedIsTODO: commits[selectedIdx].IsTODO(), + rangeStartHash: commits[rangeStartIdx].Hash(), + rangeStartIsTODO: commits[rangeStartIdx].IsTODO(), + selectedIdx: selectedIdx, + rangeStartIdx: rangeStartIdx, + mode: mode, + } +} + +func findLocalCommitSelectionRange( + commits []*models.Commit, + selectionRange *localCommitSelectionRange, +) (int, int, bool, bool) { + _, selectedIdx, foundSelected := lo.FindIndexOf(commits, func(commit *models.Commit) bool { + return commit.Hash() == selectionRange.selectedHash && commit.IsTODO() == selectionRange.selectedIsTODO + }) + _, rangeStartIdx, foundRangeStart := lo.FindIndexOf(commits, func(commit *models.Commit) bool { + return commit.Hash() == selectionRange.rangeStartHash && commit.IsTODO() == selectionRange.rangeStartIsTODO + }) + if !foundSelected || !foundRangeStart { + return 0, 0, false, false + } + + didMove := selectedIdx != selectionRange.selectedIdx || rangeStartIdx != selectionRange.rangeStartIdx + return selectedIdx, rangeStartIdx, didMove, true +} + +func hasRestorableCommitHash(commits []*models.Commit, idx int) bool { + return idx >= 0 && idx < len(commits) && commits[idx].Hash() != "" +} + func (self *RefreshHelper) refreshSubCommitsWithLimit() error { if self.c.Contexts().SubCommits.GetRef() == nil { return nil diff --git a/pkg/gui/controllers/helpers/refresh_helper_test.go b/pkg/gui/controllers/helpers/refresh_helper_test.go index cebd044c4..e8be06d61 100644 --- a/pkg/gui/controllers/helpers/refresh_helper_test.go +++ b/pkg/gui/controllers/helpers/refresh_helper_test.go @@ -5,10 +5,148 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/hosting_service" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gui/context/traits" + "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" + "github.com/stefanhaller/git-todo-parser/todo" "github.com/stretchr/testify/assert" ) +func TestCaptureLocalCommitSelectionRange(t *testing.T) { + testCases := []struct { + name string + commits []*models.Commit + selectedIdx int + rangeStartIdx int + expected *localCommitSelectionRange + }{ + { + name: "captures selected commit and range start", + commits: makeCommits("a", "b"), + selectedIdx: 1, + rangeStartIdx: 0, + expected: &localCommitSelectionRange{ + selectedHash: "b", + rangeStartHash: "a", + selectedIdx: 1, + rangeStartIdx: 0, + mode: traits.RangeSelectModeSticky, + }, + }, + { + name: "ignores invalid range start index", + commits: makeCommits("a"), + selectedIdx: 0, + rangeStartIdx: 1, + expected: nil, + }, + { + name: "ignores empty selected hash", + commits: append(makeCommits("a"), makeTodoCommit(todo.UpdateRef)), + selectedIdx: 1, + rangeStartIdx: 0, + expected: nil, + }, + { + name: "ignores empty range start hash", + commits: append(makeCommits("a"), makeTodoCommit(todo.Exec)), + selectedIdx: 0, + rangeStartIdx: 1, + expected: nil, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + selectionRange := captureLocalCommitSelectionRange( + testCase.commits, + testCase.selectedIdx, + testCase.rangeStartIdx, + traits.RangeSelectModeSticky, + ) + + assert.Equal(t, testCase.expected, selectionRange) + }) + } +} + +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, + } + + testCases := []struct { + name string + commits []*models.Commit + expected expectation + }{ + { + name: "finds selection after commits are inserted above it", + commits: makeCommits("new", "a", "b", "c"), + expected: expectation{ + selectedIdx: 2, + rangeStartIdx: 3, + moved: true, + found: true, + }, + }, + { + name: "finds selection that did not move", + commits: makeCommits("a", "b", "c"), + expected: expectation{ + selectedIdx: 1, + rangeStartIdx: 2, + found: true, + }, + }, + { + name: "reports not found when a hash is missing", + commits: makeCommits("a", "b"), + expected: expectation{}, + }, + { + name: "skips todo entries with the same hash as a selected commit", + commits: []*models.Commit{ + makeTodoCommitWithHash("b", todo.Revert), + makeCommits("a")[0], + makeCommits("b")[0], + makeCommits("c")[0], + }, + expected: expectation{ + selectedIdx: 2, + rangeStartIdx: 3, + moved: true, + found: true, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + selectedIdx, rangeStartIdx, moved, found := findLocalCommitSelectionRange(testCase.commits, &selectionRange) + actual := expectation{ + selectedIdx: selectedIdx, + rangeStartIdx: rangeStartIdx, + moved: moved, + found: found, + } + + assert.Equal(t, testCase.expected, actual) + }) + } +} + func TestGetGithubBaseRemote(t *testing.T) { cases := []struct { name string @@ -122,3 +260,18 @@ func makeAuthenticatedGithubRemoteInfo(name string, webDomain string, authToken info.authToken = authToken return info } + +func makeCommits(hashes ...string) []*models.Commit { + hashPool := &utils.StringPool{} + return lo.Map(hashes, func(hash string, _ int) *models.Commit { + return models.NewCommit(hashPool, models.NewCommitOpts{Hash: hash}) + }) +} + +func makeTodoCommit(action todo.TodoCommand) *models.Commit { + return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Action: action}) +} + +func makeTodoCommitWithHash(hash string, action todo.TodoCommand) *models.Commit { + return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash, Action: action}) +} diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index a3db043ef..99e9f47ec 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -66,7 +66,12 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions if options.RefreshPullRequests { scope = append(scope, types.PULL_REQUESTS) } - self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, Scope: scope, KeepBranchSelectionIndex: true}) + self.c.Refresh(types.RefreshOptions{ + Mode: types.BLOCK_UI, + Scope: scope, + KeepBranchSelectionIndex: true, + CommitSelection: types.KeepCommitSelectionIndex, + }) } localBranch, found := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool { @@ -209,7 +214,7 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string // loading a heap of commits is slow so we limit them whenever doing a reset self.c.Contexts().LocalCommits.SetLimitCommits(true) - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}, CommitSelection: types.KeepCommitSelectionIndex}) return nil } @@ -370,7 +375,11 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest self.SelectFirstBranchAndFirstCommit() - self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true}) + self.c.Refresh(types.RefreshOptions{ + Mode: types.BLOCK_UI, + KeepBranchSelectionIndex: true, + CommitSelection: types.KeepCommitSelectionIndex, + }) } self.c.Prompt(types.PromptOpts{ @@ -525,7 +534,11 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa self.SelectFirstBranchAndFirstCommit() - self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true}) + self.c.Refresh(types.RefreshOptions{ + Mode: types.BLOCK_UI, + KeepBranchSelectionIndex: true, + CommitSelection: types.KeepCommitSelectionIndex, + }) return nil } @@ -563,7 +576,11 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri self.SelectFirstBranchAndFirstCommit() - self.c.Refresh(types.RefreshOptions{Mode: types.BLOCK_UI, KeepBranchSelectionIndex: true}) + self.c.Refresh(types.RefreshOptions{ + Mode: types.BLOCK_UI, + KeepBranchSelectionIndex: true, + CommitSelection: types.KeepCommitSelectionIndex, + }) return nil } diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 3ad2c54cf..7e070ba31 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -147,11 +147,11 @@ func (self *WorkingTreeHelper) HandleCommitPressWithMessage(initialMessage strin func (self *WorkingTreeHelper) handleCommit(summary string, description string, forceSkipHooks bool) error { cmdObj := self.c.Git().Commit.CommitCmdObj(summary, description, forceSkipHooks) self.c.LogAction(self.c.Tr.Actions.Commit) - return self.gpgHelper.WithGpgHandling(cmdObj, git_commands.CommitGpgSign, self.c.Tr.CommittingStatus, + return self.gpgHelper.WithGpgHandlingAndSelectHeadCommit(cmdObj, git_commands.CommitGpgSign, self.c.Tr.CommittingStatus, func() error { self.commitsHelper.ClearPreservedCommitMessage() return nil - }, nil) + }) } func (self *WorkingTreeHelper) switchFromCommitMessagePanelToEditor(filepath string, forceSkipHooks bool) error { diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 4ab436bbc..083da4f4d 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -767,7 +767,9 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, + Mode: types.SYNC, + Scope: []types.RefreshableView{types.REBASE_COMMITS}, + CommitSelection: types.KeepCommitSelectionIndex, }) return nil } @@ -780,7 +782,7 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( - err, types.RefreshOptions{Mode: types.SYNC}) + err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex}) }) } @@ -793,7 +795,9 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, + Mode: types.SYNC, + Scope: []types.RefreshableView{types.REBASE_COMMITS}, + CommitSelection: types.KeepCommitSelectionIndex, }) return nil } @@ -806,7 +810,7 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( - err, types.RefreshOptions{Mode: types.SYNC}) + err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex}) }) } @@ -966,8 +970,6 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.SYNC}); err != nil { return err } - self.context().MoveSelection(len(commits)) - self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) if mustStash { if err := self.c.Git().Stash.Pop(0); err != nil { @@ -1013,7 +1015,6 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err return err } - self.context().MoveSelectedLine(1) self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) return nil }) @@ -1114,7 +1115,6 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc return err } - self.context().MoveSelectedLine(1) self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) return nil }) diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index b2e30f231..8f5dd1ae6 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -374,6 +374,7 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit() refreshOptions.KeepBranchSelectionIndex = true + refreshOptions.CommitSelection = types.KeepCommitSelectionIndex } } self.c.Refresh(refreshOptions) diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index 649b53338..f1b794e97 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -175,7 +175,7 @@ func (self *SyncController) pullWithLock(task gocui.Task, opts PullFilesOptions) }, ) - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseAndSelectHeadCommit(err) } type pushOpts struct { diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index c9e156180..17d917bf6 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -33,6 +33,28 @@ const ( BLOCK_UI // wrap code in an update call to ensure UI updates all at once and keybindings aren't executed till complete ) +// CommitSelectionBehavior controls which local commit is selected after the +// commits list is reloaded by a refresh. +type CommitSelectionBehavior int + +const ( + // Keep the same commit selected by hash (and the same range, when + // range-selecting), restoring it at its new position if it moved. This is + // the right default whenever the list reloads underneath a selection the + // user hasn't deliberately changed. + KeepCommitSelectionByHash CommitSelectionBehavior = iota + + // Leave the selection index untouched, because the caller set it itself + // before refreshing. Used when jumping to the top of the list after a + // checkout, and when following a commit that was just moved up or down. + KeepCommitSelectionIndex + + // Select the HEAD commit. Used by operations that create a new commit at + // HEAD (committing, merging, pulling with a merge); the by-hash behavior + // can't restore a commit that didn't exist before the refresh. + SelectHeadCommit +) + type RefreshOptions struct { Then func() Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything @@ -45,6 +67,10 @@ type RefreshOptions struct { // head, and selecting index 0. KeepBranchSelectionIndex bool + // Controls which local commit is selected after the refresh. Defaults to + // KeepCommitSelectionByHash. + CommitSelection CommitSelectionBehavior + // When true, this refresh was initiated by a background routine rather than // by a user action. We use it to keep background `git status` calls from // taking optional git locks, so they don't contend for index.lock with git diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_commit_that_becomes_empty.go b/pkg/integration/tests/cherry_pick/cherry_pick_commit_that_becomes_empty.go index fbd8ee9a6..081a71f66 100644 --- a/pkg/integration/tests/cherry_pick/cherry_pick_commit_that_becomes_empty.go +++ b/pkg/integration/tests/cherry_pick/cherry_pick_commit_that_becomes_empty.go @@ -73,25 +73,11 @@ var CherryPickCommitThatBecomesEmpty = NewIntegrationTest(NewIntegrationTestArgs // Cherry-picked commit is empty t.Views().Main().Content(DoesNotContain("diff --git")) } else { + // Older git versions drop the commit that became empty t.Views().Commits(). - // We have a bug with how the selection is updated in this case; normally you would - // expect the "two changes in one commit" commit to be selected because it was - // selected before pasting, and we try to maintain that selection. This is broken - // for two reasons: - // 1. We increment the selected line index after pasting by the number of pasted - // commits; this is wrong because we skipped the commit that became empty. So - // according to this bug, the "base" commit should be selected. - // 2. We only update the selected line index after pasting if the currently selected - // commit is not a rebase TODO commit, on the assumption that if it is, we are in a - // rebase and the cherry-picked commits end up below the selection. In this case, - // however, we still think we are cherry-picking because the final refresh after the - // CheckMergeOrRebase in CherryPickHelper.Paste is async and hasn't completed yet; - // so the "unrelated change" still has a "pick" action. - // - // Since this only happens for older git versions, we don't bother fixing it. Lines( - Contains("unrelated change").IsSelected(), - Contains("two changes in one commit"), + Contains("unrelated change"), + Contains("two changes in one commit").IsSelected(), Contains("base"), ) } diff --git a/pkg/integration/tests/cherry_pick/cherry_pick_conflicts.go b/pkg/integration/tests/cherry_pick/cherry_pick_conflicts.go index b135bfd7f..7468f921c 100644 --- a/pkg/integration/tests/cherry_pick/cherry_pick_conflicts.go +++ b/pkg/integration/tests/cherry_pick/cherry_pick_conflicts.go @@ -78,11 +78,11 @@ var CherryPickConflicts = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). TopLines( - Contains("second-change-branch unrelated change").IsSelected(), + Contains("second-change-branch unrelated change"), Contains("second change"), - Contains("first change"), + Contains("first change").IsSelected(), ). - SelectNextItem(). + 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_conflicts_empty_commit_after_resolving.go b/pkg/integration/tests/cherry_pick/cherry_pick_conflicts_empty_commit_after_resolving.go index ff9efda3c..7af67791f 100644 --- a/pkg/integration/tests/cherry_pick/cherry_pick_conflicts_empty_commit_after_resolving.go +++ b/pkg/integration/tests/cherry_pick/cherry_pick_conflicts_empty_commit_after_resolving.go @@ -69,23 +69,8 @@ var CherryPickConflictsEmptyCommitAfterResolving = NewIntegrationTest(NewIntegra t.Views().Commits(). Focus(). TopLines( - // We have a bug with how the selection is updated in this case; normally you would - // expect the "first change" commit to be selected because it was selected before - // pasting, and we try to maintain that selection. This is broken for two reasons: - // 1. We increment the selected line index after pasting by the number of pasted - // commits; this is wrong because we skipped the commit that became empty. So - // according to this bug, the "original" commit should be selected. - // 2. We only update the selected line index after pasting if the currently selected - // commit is not a rebase TODO commit, on the assumption that if it is, we are in a - // rebase and the cherry-picked commits end up below the selection. In this case, - // however, we still think we are cherry-picking because the final refresh after the - // CheckMergeOrRebase in CherryPickHelper.Paste is async and hasn't completed yet; - // so the "second-change-branch unrelated change" still has a "pick" action. - // - // We don't bother fixing it for now because it's a pretty niche case, and the - // nature of the problem is only cosmetic. - Contains("second-change-branch unrelated change").IsSelected(), - Contains("first change"), + Contains("second-change-branch unrelated change"), + Contains("first change").IsSelected(), Contains("original"), ) }, diff --git a/pkg/integration/tests/commit/keep_selected_commit_after_external_commit.go b/pkg/integration/tests/commit/keep_selected_commit_after_external_commit.go new file mode 100644 index 000000000..82cc66ab2 --- /dev/null +++ b/pkg/integration/tests/commit/keep_selected_commit_after_external_commit.go @@ -0,0 +1,46 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepSelectedCommitAfterExternalCommit = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Keep the same commit selected after an external commit is created", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("file", "first content") + shell.Commit("first commit") + shell.UpdateFile("file", "second content") + shell.GitAddAll() + shell.Commit("second commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("second commit"), + Contains("first commit"), + ). + NavigateToLine(Contains("first commit")) + + t.Views().Main().Content(Contains("+first content")) + + t.GlobalPress(keys.Universal.ExecuteShellCommand) + t.ExpectPopup().Prompt(). + Title(Equals("Shell command:")). + Type("git commit --allow-empty -m 'external commit'"). + Confirm() + + t.Views().Commits(). + Lines( + Contains("external commit"), + Contains("second commit"), + Contains("first commit").IsSelected(), + ) + + t.Views().Main().Content(Contains("+first content")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 1b264e50d..fa7b7e26b 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -139,6 +139,7 @@ var tests = []*components.IntegrationTest{ commit.Highlight, commit.History, commit.HistoryComplex, + commit.KeepSelectedCommitAfterExternalCommit, commit.NewBranch, commit.PasteCommitMessage, commit.PasteCommitMessageOverExisting, From b6063cff5b4dad85223af69675d1c095b53dbce2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 22 Jun 2026 16:33:13 +0200 Subject: [PATCH 094/384] Restore commit selection even when the commit's TODO status changed When restoring the commit selection after a refresh we match by hash and TODO status. The TODO status is part of the match so that a commit being reverted or cherry-picked is matched to the real commit rather than to the rebase TODO entry that shares its hash. But a selected commit can also change its TODO status across a refresh: when starting an interactive rebase that stops to edit it, the real commit becomes a TODO entry. Fall back to matching by hash alone when there is no exact match, so the selection is still restored in that case. The next commit relies on this to remove bespoke selection-restoration code in the local commits controller that matched by hash alone, which the generic mechanism otherwise wouldn't fully replace. --- pkg/gui/controllers/helpers/refresh_helper.go | 34 +++++++++++++++---- .../helpers/refresh_helper_test.go | 13 +++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 3ea742c8e..605309f9f 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -533,12 +533,10 @@ func findLocalCommitSelectionRange( commits []*models.Commit, selectionRange *localCommitSelectionRange, ) (int, int, bool, bool) { - _, selectedIdx, foundSelected := lo.FindIndexOf(commits, func(commit *models.Commit) bool { - return commit.Hash() == selectionRange.selectedHash && commit.IsTODO() == selectionRange.selectedIsTODO - }) - _, rangeStartIdx, foundRangeStart := lo.FindIndexOf(commits, func(commit *models.Commit) bool { - return commit.Hash() == selectionRange.rangeStartHash && commit.IsTODO() == selectionRange.rangeStartIsTODO - }) + selectedIdx, foundSelected := findCommitByHashPreferringTODOStatus( + commits, selectionRange.selectedHash, selectionRange.selectedIsTODO) + rangeStartIdx, foundRangeStart := findCommitByHashPreferringTODOStatus( + commits, selectionRange.rangeStartHash, selectionRange.rangeStartIsTODO) if !foundSelected || !foundRangeStart { return 0, 0, false, false } @@ -547,6 +545,30 @@ func findLocalCommitSelectionRange( return selectedIdx, rangeStartIdx, didMove, true } +// findCommitByHashPreferringTODOStatus finds the commit with the given hash. +// When both a TODO and a non-TODO commit share that hash - which happens while +// reverting or cherry-picking, where the rebase TODO entry has the same hash as +// the real commit - it returns the one whose TODO status matches isTODO. When +// only one commit has the hash, it is returned regardless of its TODO status, +// so that a selected commit which turned into a TODO entry across the refresh is +// still found (e.g. when starting an interactive rebase that stops to edit it). +func findCommitByHashPreferringTODOStatus(commits []*models.Commit, hash string, isTODO bool) (int, bool) { + fallbackIdx := -1 + for idx, commit := range commits { + if commit.Hash() != hash { + continue + } + if commit.IsTODO() == isTODO { + return idx, true + } + if fallbackIdx == -1 { + fallbackIdx = idx + } + } + + return fallbackIdx, fallbackIdx != -1 +} + func hasRestorableCommitHash(commits []*models.Commit, idx int) bool { return idx >= 0 && idx < len(commits) && commits[idx].Hash() != "" } diff --git a/pkg/gui/controllers/helpers/refresh_helper_test.go b/pkg/gui/controllers/helpers/refresh_helper_test.go index e8be06d61..3a5f6ea82 100644 --- a/pkg/gui/controllers/helpers/refresh_helper_test.go +++ b/pkg/gui/controllers/helpers/refresh_helper_test.go @@ -130,6 +130,19 @@ func TestFindLocalCommitSelectionRange(t *testing.T) { found: true, }, }, + { + name: "falls back to a todo entry when the selected commit became one", + commits: []*models.Commit{ + makeTodoCommitWithHash("b", todo.Pick), + makeCommits("c")[0], + }, + expected: expectation{ + selectedIdx: 0, + rangeStartIdx: 1, + moved: true, + found: true, + }, + }, } for _, testCase := range testCases { From 7f96c8ff4f106f92ef78b4ac9b770059b695cd29 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 22 Jun 2026 16:36:23 +0200 Subject: [PATCH 095/384] Remove bespoke commit selection restoration when starting a rebase Starting an interactive rebase (the `edit` command and quick-start) used to capture the selected commit range by hash before starting the rebase and restore it afterwards, because new update-ref lines for stacked branches can shift the commits' positions in the list. The generic keep-selection-by-hash mechanism now does exactly this for every refresh, including these, so the bespoke code is redundant. This relies on the previous commit, which taught the generic matcher to handle the case where the selected commit turns into a rebase TODO entry while it's being edited - something the bespoke code handled implicitly by matching on hash alone. --- .../controllers/local_commits_controller.go | 42 +------------------ 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 083da4f4d..80f01fc03 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -8,7 +8,6 @@ import ( "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" @@ -590,15 +589,9 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start commits := self.c.Model().Commits if !commits[endIdx].IsMerge() { - selectionRangeAndMode := self.getSelectionRangeAndMode() err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "") return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( - err, - types.RefreshOptions{ - Mode: types.BLOCK_UI, Then: func() { - self.restoreSelectionRangeAndMode(selectionRangeAndMode) - }, - }) + err, types.RefreshOptions{Mode: types.BLOCK_UI}) } return self.startInteractiveRebaseWithEdit(selectedCommits) @@ -618,7 +611,6 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit( ) error { return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.EditCommit) - selectionRangeAndMode := self.getSelectionRangeAndMode() err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, @@ -636,42 +628,10 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit( self.c.Log.Errorf("error when updating todos: %v", err) } } - - self.restoreSelectionRangeAndMode(selectionRangeAndMode) }}) }) } -type SelectionRangeAndMode struct { - selectedHash string - rangeStartHash string - mode traits.RangeSelectMode -} - -func (self *LocalCommitsController) getSelectionRangeAndMode() SelectionRangeAndMode { - selectedIdx, rangeStartIdx, rangeSelectMode := self.context().GetSelectionRangeAndMode() - commits := self.c.Model().Commits - selectedHash := commits[selectedIdx].Hash() - rangeStartHash := commits[rangeStartIdx].Hash() - return SelectionRangeAndMode{selectedHash, rangeStartHash, rangeSelectMode} -} - -func (self *LocalCommitsController) restoreSelectionRangeAndMode(selectionRangeAndMode SelectionRangeAndMode) { - // We need to select the same commit range again because after starting a rebase, - // new lines can be added for update-ref commands in the TODO file, due to - // stacked branches. So the selected commits may be in different positions in the list. - _, newSelectedIdx, ok1 := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool { - return c.Hash() == selectionRangeAndMode.selectedHash - }) - _, newRangeStartIdx, ok2 := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool { - return c.Hash() == selectionRangeAndMode.rangeStartHash - }) - if ok1 && ok2 { - self.context().SetSelectionRangeAndMode(newSelectedIdx, newRangeStartIdx, selectionRangeAndMode.mode) - self.context().HandleFocus(types.OnFocusOpts{}) - } -} - func (self *LocalCommitsController) findCommitForQuickStartInteractiveRebase() (*models.Commit, error) { commit, index, ok := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool { return c.IsMerge() || c.Status == models.StatusMerged From 658a66e14b4c5b7629fac3bd2e7f4a7787103c51 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 23 Jun 2026 09:54:05 +0200 Subject: [PATCH 096/384] Restructure integration-test just targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `just e2e` was the visible-UI runner, but it's only useful for a single test (and even then only with --sandbox/--slow); running it without arguments is far too slow, yet it was easy to invoke by reflex when `just e2e-all` (run all headlessly) was meant. Make `just e2e` the everyday headless runner: no arguments runs the whole suite (what e2e-all did), and a test name runs just that one headlessly via `go test -run` — which we had no target for before. The visible-UI runner moves to `e2e-cli`, pairing with the existing `e2e-tui` (the two main.go subcommands). e2e-all is now redundant and removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 5 +++-- justfile | 23 +++++++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2fb36392e..8a4f924e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,8 +21,9 @@ Windows box has only `just`). - `just format` — `gofumpt -l -w .`. Run before every commit. - `just build` — build the binary. - `just unit-test` — `go test ./... -short`. -- `just e2e-all` — run all integration tests headlessly (`just e2e ` runs a - single one with a visible UI). +- `just e2e` — run all integration tests headlessly; `just e2e ` runs a + single one headlessly too. `just e2e-cli ` runs one with a visible UI + (most useful with `--sandbox` or `--slow`). - `just lint` — run golangci-lint. ## When to commit diff --git a/justfile b/justfile index e7f9fcdc5..c6785b933 100644 --- a/justfile +++ b/justfile @@ -23,7 +23,7 @@ unit-test: # Run both unit tests and integration tests. [unix] -test: unit-test e2e-all +test: unit-test e2e # On Windows, integration tests are not supported right now [windows] @@ -39,18 +39,29 @@ format: lint: ./scripts/golangci-lint-shim.sh run -# Run integration tests with a visible UI. Most useful for running a single test; for running all tests, use `e2e-all` instead. +e2e-test-command := "go test pkg/integration/clients/*.go" + +# Run integration tests headlessly: no args runs all tests, a test name (or path) runs just that one. Use e2e-cli for a visible UI. e2e *args: + {{ if args == "" { e2e-test-command } else { \ + e2e-test-command + " -run 'TestIntegration/" + \ + replace( \ + replace_regex( \ + replace_regex(args, '\S*pkg/integration/tests/', ''), \ + '\.go( |$)', '${1}' \ + ), \ + " ", "$' && " + e2e-test-command + " -run 'TestIntegration/" \ + ) + "$'" \ + } }} + +# Run a single integration test with a visible UI; most useful with --sandbox or --slow. +e2e-cli *args: go run cmd/integration_test/main.go cli {{ args }} # Open the TUI for running integration tests. e2e-tui *args: go run cmd/integration_test/main.go tui {{ args }} -# Run all integration tests headlessly (without a visible UI). -e2e-all: - go test pkg/integration/clients/*.go - # Run some tests on the current commit, similar to what CI does. check: ./scripts/check_commit.sh From 21f13fc3c790084e44f61aefbe406f10c08573d2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 23 Jun 2026 13:07:39 +0200 Subject: [PATCH 097/384] Add zsh completion for the e2e integration-test recipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit just's completion is clap-dynamic and exposes no hook for completing a recipe's arguments, so `just e2e ` couldn't suggest anything. Wrap just's completer: for the e2e/e2e-cli recipes, complete the test names found under pkg/integration/tests/, delegating everything else back to just. The names are fed to _multi_parts so they complete one "/"-separated segment at a time — an empty offers just the categories, then drills into the tests within a category — and the .go extension and the shared helper files are stripped so the candidates are exactly the names the recipe accepts. Source it from ~/.zshrc (after compinit) to enable; it's a no-op without just installed and only activates inside a repo with a justfile and a pkg/integration/tests/ directory. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/just_e2e_completion.zsh | 56 +++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 scripts/just_e2e_completion.zsh diff --git a/scripts/just_e2e_completion.zsh b/scripts/just_e2e_completion.zsh new file mode 100644 index 000000000..fa6ab32cd --- /dev/null +++ b/scripts/just_e2e_completion.zsh @@ -0,0 +1,56 @@ +# Zsh completion for the `e2e` and `e2e-cli` recipes in lazygit's justfile. +# +# These recipes take integration-test names (e.g. submodule/reset). This makes +# `just e2e ` complete them from pkg/integration/tests/. To enable it, add +# the following to your ~/.zshrc, *after* the line that runs `compinit`: +# +# source /path/to/lazygit/scripts/just_e2e_completion.zsh +# +# It is a no-op when `just` isn't installed, and only kicks in inside a project +# that has a justfile and a pkg/integration/tests/ directory, so it is harmless +# to source unconditionally. + +(( $+commands[just] )) || return 0 + +# just's own completion is clap-dynamic and has no hook for completing a +# recipe's arguments, so we wrap it: handle the e2e recipes ourselves and +# delegate everything else (recipe names, flags, ...) to just's completer. +source <(JUST_COMPLETE=zsh just) # defines _clap_dynamic_completer_just + +_just_lazygit_e2e() { + if (( CURRENT > 2 )); then + case ${words[2]} in + e2e | e2e-cli) + # Find the justfile's directory, then complete the integration + # tests under pkg/integration/tests/ relative to it. + local dir=$PWD testdir= + while [[ $dir != / ]]; do + if [[ -e $dir/justfile || -e $dir/.justfile || -e $dir/Justfile ]]; then + testdir=$dir/pkg/integration/tests + break + fi + dir=${dir:h} + done + if [[ -d $testdir ]]; then + # A test's name is its path under pkg/integration/tests/ without + # the .go extension, e.g. submodule/reset. Build that list, then + # let _multi_parts complete it one "/"-separated segment at a + # time, so an empty offers only categories. + local -a tests + tests=($testdir/**/*.go(.N:r)) # strip the .go extension + tests=(${tests#$testdir/}) # make relative to the tests dir + tests=(${(M)tests:#*/*}) # keep category/name (drop top-level helpers) + tests=(${tests:#shared/*}) # drop the cross-directory shared package + tests=(${tests:#*/shared}) # drop per-category shared.go helpers + local expl + _wanted tests expl 'integration test' _multi_parts / tests + return + fi + ;; + esac + fi + + _clap_dynamic_completer_just "$@" +} + +compdef _just_lazygit_e2e just # bind last so this wins over the default From e33b6f93970409e589c95ec97cd84fc79f6752a2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 23 Jun 2026 13:30:24 +0200 Subject: [PATCH 098/384] Document running the integration tests via the just recipes The integration README still described the raw `go run cmd/integration_test` and `go test` invocations, which are easy to get wrong (the headless go-test command in particular) and don't match how we actually run the tests. Rewrite the running/debugging/sandbox instructions around the justfile's e2e recipes instead, and point at the optional zsh completion script. Also switch the test-list regeneration hint to `just generate`, matching the rest of our docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/integration/README.md | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/pkg/integration/README.md b/pkg/integration/README.md index 0c50d8f4e..d3b753f06 100644 --- a/pkg/integration/README.md +++ b/pkg/integration/README.md @@ -2,21 +2,21 @@ The pkg/integration package is for integration testing: that is, actually running a real lazygit session and having a robot pretend to be a human user and then making assertions that everything works as expected. -TL;DR: integration tests live in pkg/integration/tests. Run integration tests with: +TL;DR: integration tests live in pkg/integration/tests, and we run them through the [`just`](https://github.com/casey/just) recipes in the repo's `justfile`. Run the whole suite headlessly with: ```sh -go run cmd/integration_test/main.go tui +just e2e ``` -or +or open a terminal UI to browse and run individual tests with: ```sh -go run cmd/integration_test/main.go cli [--slow or --sandbox] [testname or testpath...] +just e2e-tui ``` ## Writing tests -The tests live in pkg/integration/tests. Each test is registered in `pkg/integration/tests/test_list.go` which is an auto-generated file. You can re-generate that file by running `go generate ./...` at the root of the Lazygit repo. +The tests live in pkg/integration/tests. Each test is registered in `pkg/integration/tests/test_list.go` which is an auto-generated file. You can re-generate that file by running `just generate` at the root of the Lazygit repo. Each test has two important steps: the setup step and the run step. @@ -38,19 +38,18 @@ The run step has two arguments passed in: ## Running tests -There are three ways to invoke a test: +We drive the integration tests through the [`just`](https://github.com/casey/just) recipes in the repo's `justfile`, so you'll want `just` installed to run them as described here. (The recipes are thin wrappers, so if you can't install `just`, the underlying commands are right there in the `justfile`.) -1. go run cmd/integration_test/main.go cli [--slow or --sandbox] [testname or testpath...] -2. go run cmd/integration_test/main.go tui -3. go test pkg/integration/clients/*.go +- `just e2e` — run the whole suite headlessly, with no visible UI. This is what CI does, and the fastest way to run everything. +- `just e2e ` — run a single test headlessly, e.g. `just e2e commit/new_branch`; the fastest way to run one test. You can pass several names at once, or a full file path like `pkg/integration/tests/commit/new_branch.go`. +- `just e2e-cli [--slow|--sandbox|--debug] ` — run a single test in a *visible* lazygit UI, so you can watch it (see slow mode below, and sandbox mode and debugging in the following sections). +- `just e2e-tui` — open a terminal UI for browsing and running tests; the easiest way to find and run a test without having to type its name. -The first, the test runner, is for directly running a test from the command line. If you pass no arguments, it runs all tests. -The second, the TUI, is for running tests from a terminal UI where it's easier to find a test and run it without having to copy it's name and paste it into the terminal. This is the easiest approach by far. -The third, the go-test command, intended only for use in CI, to be run along with the other `go test` tests. This runs the tests in headless mode so there's no visual output. +The name of a test is based on its path, so the name of the test at `pkg/integration/tests/commit/new_branch.go` is `commit/new_branch`. -The name of a test is based on its path, so the name of the test at `pkg/integration/tests/commit/new_branch.go` is commit/new_branch. So to run it with our test runner you would run `go run cmd/integration_test/main.go cli commit/new_branch`. +zsh users can get tab-completion of these test names — `just e2e sub` expands to `submodule/…` — by sourcing `scripts/just_e2e_completion.zsh` from their `.zshrc`; see the comment at the top of that file for details. -You can pass the INPUT_DELAY env var to the test runner in order to set a delay in milliseconds between keypresses or mouse clicks, which helps for watching a test at a realistic speed to understand what it's doing. Or you can pass the '--slow' flag which sets a pre-set 'slow' key delay. In the tui you can press 't' to run the test in slow mode. +To watch a test run at a realistic speed, pass `--slow` to `just e2e-cli`; it sets a pre-set delay between keypresses and mouse clicks. For finer control, set the `INPUT_DELAY` env var to a number of milliseconds instead, e.g. `INPUT_DELAY=200 just e2e-cli commit/new_branch`. In the TUI you can press 't' to run a test in slow mode. The resultant repo will be stored in `test/_results`, so if you're not sure what went wrong you can go there and inspect the repo. @@ -67,8 +66,8 @@ The test will run in a VSCode terminal: Debugging an integration test is possible in two ways: -1. Use the -debug option of the integration test runner's "cli" command, e.g. `go run cmd/integration_test/main.go cli -debug tag/reset.go` -2. Select a test in the "tui" runner and hit "d" to debug it. +1. Pass `--debug` to `just e2e-cli`, e.g. `just e2e-cli --debug tag/reset`. +2. Select a test in `just e2e-tui` and hit "d" to debug it. In both cases the test runner will print to the console that it is waiting for a debugger to attach, so now you need to tell your debugger to attach to a running process with the name "test_lazygit". If you are using Visual Studio Code, an easy way to do that is to use the "Attach to integration test runner" debug configuration. The test runner will resume automatically when it detects that a debugger was attached. Don't forget to set a breakpoint in the code that you want to step through, otherwise the test will just finish (i.e. it doesn't stop in the debugger automatically). @@ -76,7 +75,7 @@ In both cases the test runner will print to the console that it is waiting for a Say you want to do a manual test of how lazygit handles merge-conflicts, but you can't be bothered actually finding a way to create merge conflicts in a repo. To make your life easier, you can simply run a merge-conflicts test in sandbox mode, meaning the setup step is run for you, and then instead of the test driving the lazygit session, you're allowed to drive it yourself. -To run a test in sandbox mode you can press 's' on a test in the test TUI or in the test runner pass the --sandbox argument. +To run a test in sandbox mode, press 's' on a test in `just e2e-tui`, or pass `--sandbox` to `just e2e-cli`, e.g. `just e2e-cli --sandbox conflicts/resolve_multiple_files`. ## Tips for writing tests From 23d378ddb9388ce1249dda13ea968b05a788b26a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 23 Jun 2026 11:03:13 +0200 Subject: [PATCH 099/384] Use `just` instead of `make` in AGENTS.md --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8a4f924e4..4b0ef6731 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ while still being meaningful and self-contained. - **Every commit must compile and pass all tests.** No "WIP" commits, no commits that leave the tree broken and rely on a follow-up to fix it. -- **Every commit must be `gofumpt`-formatted.** Run `make format` before +- **Every commit must be `gofumpt`-formatted.** Run `just format` before committing. - **Commit messages explain _why_, not _what_.** The diff already shows what changed; the message should capture the motivation, the constraint, or the @@ -284,7 +284,7 @@ So: - For changes to `userConfig` fields specifically, don't edit `docs-master/Config.md` by hand either — the relevant section is auto-generated from the struct field doc comments. After editing the - struct, run `make generate` and include the regenerated + struct, run `just generate` and include the regenerated `docs-master/Config.md` (and `schema-master/config.json`) in your commit. - Don't hard-wrap the doc comments on `userConfig` fields. This applies *only* to `userConfig`, because those comments are fed through the doc From 6064c1091ca125701a7f882462ce8c2d31da2303 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 23 Jun 2026 11:04:40 +0200 Subject: [PATCH 100/384] Remove "Open deprecated test TUI" vscode task I never use this. --- .vscode/tasks.json | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 436275394..ed48672c6 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -61,18 +61,6 @@ "focus": true } }, - { - "label": "Open deprecated test TUI", - "type": "shell", - "command": "go run pkg/integration/deprecated/cmd/tui/main.go", - "problemMatcher": [], - "group": { - "kind": "test", - }, - "presentation": { - "focus": true - } - }, { "label": "Sync tests list", "type": "shell", From 131255ccf013b9eb93aa98c37159ff7baed9c5b2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 23 Jun 2026 13:15:04 +0200 Subject: [PATCH 101/384] Use headless mode for the "Run current file integration test" vscode task For running an integration test just to see if it fails or succeeds, headless mode is sufficient and actually better, because it works in small terminals like vscode's bottom panel; the "main.go cli" way of running tests tends to fail there because the layout renders differently in such a small window. Headless tests use a fixed window size, so they don't have this problem. It's also slightly faster. The other vscode tasks (slow and sandbox) are unchanged, they only make sense with a visible UI. --- .vscode/tasks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ed48672c6..dc0ff9673 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -24,7 +24,7 @@ { "label": "Run current file integration test", "type": "shell", - "command": "go run cmd/integration_test/main.go cli ${relativeFile}", + "command": "just e2e ${relativeFile}", "problemMatcher": [], "group": { "kind": "test", From 41efc9a37a505029066dfb960888572f8143dda4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 16 Jun 2026 08:11:22 +0200 Subject: [PATCH 102/384] Demonstrate that Quote produces invalid Windows quoting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows, Quote wraps arguments in bash-style `\"…\"` and rewrites embedded double quotes as `"'"'"`. Neither convention is understood by cmd.exe or CommandLineToArgvW, so commands built from quoted arguments are mis-parsed once they contain quotes or spaces (#5560). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/oscommands/os_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/commands/oscommands/os_test.go b/pkg/commands/oscommands/os_test.go index 54d9f3a80..33bb9a6db 100644 --- a/pkg/commands/oscommands/os_test.go +++ b/pkg/commands/oscommands/os_test.go @@ -75,6 +75,9 @@ func TestOSCommandQuoteWindows(t *testing.T) { actual := osCommand.Quote(`hello "test" 'test2'`) + /* EXPECTED: + expected := `"hello \"test\" 'test2'"` + ACTUAL: */ expected := `\"hello "'"'"test"'"'" 'test2'\"` assert.EqualValues(t, expected, actual) From e0fcdf1c3f2ecada2010bfa90034db857b2d2d56 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 16 Jun 2026 11:55:54 +0200 Subject: [PATCH 103/384] Demonstrate that shell metacharacters are mangled on Windows On Windows, NewShell escapes shell metacharacters (`&`, `|`, `<`, `>`, `%`) with `^` and splits the command into separate arguments. The operators in a custom command therefore never reach cmd as operators, so command chaining (`&&`), pipes, redirection and `%VAR%` expansion all silently break (#2427, #4147, #5113; the stray `^` is also what #3092 reports). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/oscommands/os_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pkg/commands/oscommands/os_test.go b/pkg/commands/oscommands/os_test.go index 33bb9a6db..e15e4de2d 100644 --- a/pkg/commands/oscommands/os_test.go +++ b/pkg/commands/oscommands/os_test.go @@ -83,6 +83,24 @@ func TestOSCommandQuoteWindows(t *testing.T) { assert.EqualValues(t, expected, actual) } +// On Windows, NewShell must hand the command to cmd.exe verbatim. +func TestNewShellWindowsPassesMetacharactersVerbatim(t *testing.T) { + osCommand := NewDummyOSCommand() + platform := &Platform{OS: "windows", Shell: "cmd", ShellArg: "/c"} + osCommand.Platform = platform + osCommand.Cmd.platform = platform + + command := `echo a && echo b | sort > out.txt < in.txt %PATH%` + + assert.Equal(t, + /* EXPECTED: + []string{"cmd", "/s", "/c", command}, + ACTUAL: */ + []string{"cmd", "/c", "echo", "a", "^&^&", "echo", "b", "^|", "sort", "^>", "out.txt", "^<", "in.txt", "^%PATH^%"}, + osCommand.Cmd.NewShell(command, "").Args(), + ) +} + func TestOSCommandFileType(t *testing.T) { type scenario struct { path string From 6b311ccb62ad02ddbdda24aa24e149ac0d60e11e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 16 Jun 2026 08:16:54 +0200 Subject: [PATCH 104/384] Fix quoting of shell commands on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lazygit builds a shell command by interpolating Quote'd arguments into a template and running the result via `cmd /c`. Several things were wrong on Windows: - Quote emitted bash-style `\"…\"` quoting, which cmd.exe doesn't understand. Making it usable at all previously required a fragile round-trip through str.ToArgv and re-escaping. - The assembled command line was handed to `cmd /c` without `/s`, so cmd's default rules stripped the wrong quotes once the line contained more than two of them (e.g. a quoted editor path at a location with spaces, plus a quoted filename that also contains spaces). - Shell metacharacters were escaped with `^` (`&` → `^&`, etc.), which neutralised command chaining, pipes, redirection and `%VAR%` expansion in custom commands. Quote now emits the standard Windows convention directly, and NewShell hands cmd.exe the fully-assembled line verbatim via SysProcAttr.CmdLine, wrapped as `cmd /s /c ""`. The /s flag strips exactly the outer quote pair we add, leaving each argument's own quoting intact. With the `^` escaping gone, metacharacters in a custom command reach cmd as the author intended; this also removes the spurious `^` reported in #3092. Fixes #5560 Fixes #2427 Fixes #4147 Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/oscommands/cmd_obj_builder.go | 92 +++++++++++++------ .../oscommands/os_default_platform.go | 5 + pkg/commands/oscommands/os_test.go | 6 -- pkg/commands/oscommands/os_windows.go | 17 ++++ pkg/commands/oscommands/os_windows_test.go | 10 +- 5 files changed, 90 insertions(+), 40 deletions(-) diff --git a/pkg/commands/oscommands/cmd_obj_builder.go b/pkg/commands/oscommands/cmd_obj_builder.go index fde642582..9084fd994 100644 --- a/pkg/commands/oscommands/cmd_obj_builder.go +++ b/pkg/commands/oscommands/cmd_obj_builder.go @@ -48,26 +48,34 @@ func (self *CmdObjBuilder) NewShell(commandStr string, shellFunctionsFile string if len(shellFunctionsFile) > 0 { commandStr = fmt.Sprintf("%ssource %s\n%s", self.platform.PrefixForShellFunctionsFile, shellFunctionsFile, commandStr) } - quotedCommand := self.quotedCommandString(commandStr) + + if self.platform.OS == "windows" { + return self.newWindowsShell(commandStr) + } + + quotedCommand := self.Quote(commandStr) cmdArgs := str.ToArgv(fmt.Sprintf("%s %s %s", self.platform.Shell, self.platform.ShellArg, quotedCommand)) return self.New(cmdArgs) } -func (self *CmdObjBuilder) quotedCommandString(commandStr string) string { - // Windows does not seem to like quotes around the command - if self.platform.OS == "windows" { - return strings.NewReplacer( - "^", "^^", - "&", "^&", - "|", "^|", - "<", "^<", - ">", "^>", - "%", "^%", - ).Replace(commandStr) - } +// newWindowsShell wraps the command in `cmd.exe /s /c ""`. The /s +// flag tells cmd to strip exactly the outermost pair of quotes and pass the +// rest through unchanged, which preserves any quoting the command itself +// contains (e.g. `"C:\Program Files\my-editor.exe" file.txt`). Without /s, +// cmd's default rules drop the wrong quotes once the command line contains +// more than two of them. +// +// We bypass Go's standard arg quoting via SysProcAttr.CmdLine: it follows the +// CommandLineToArgvW convention (`\"` for inner quotes), but cmd.exe doesn't. +func (self *CmdObjBuilder) newWindowsShell(commandStr string) *CmdObj { + args := []string{self.platform.Shell, "/s", self.platform.ShellArg, commandStr} + cmdObj := self.New(args) - return self.Quote(commandStr) + cmdLine := fmt.Sprintf(`%s /s %s "%s"`, self.platform.Shell, self.platform.ShellArg, commandStr) + setRawCmdLine(cmdObj.GetCmd(), cmdLine) + + return cmdObj } func (self *CmdObjBuilder) CloneWithNewRunner(decorate func(ICmdObjRunner) ICmdObjRunner) *CmdObjBuilder { @@ -80,21 +88,47 @@ func (self *CmdObjBuilder) CloneWithNewRunner(decorate func(ICmdObjRunner) ICmdO } func (self *CmdObjBuilder) Quote(message string) string { - var quote string if self.platform.OS == "windows" { - quote = `\"` - message = strings.NewReplacer( - `"`, `"'"'"`, - `\"`, `\\"`, - ).Replace(message) - } else { - quote = `"` - message = strings.NewReplacer( - `\`, `\\`, - `"`, `\"`, - `$`, `\$`, - "`", "\\`", - ).Replace(message) + return quoteForWindows(message) } - return quote + message + quote + message = strings.NewReplacer( + `\`, `\\`, + `"`, `\"`, + `$`, `\$`, + "`", "\\`", + ).Replace(message) + return `"` + message + `"` +} + +// quoteForWindows encodes a value using the standard Windows command-line +// convention (the algorithm behind syscall.EscapeArg, reimplemented here so +// it's available on all platforms). The result is always wrapped in double +// quotes so cmd.exe and CommandLineToArgvW treat it as a single argument +// regardless of what shell metacharacters it contains. +func quoteForWindows(s string) string { + var b strings.Builder + b.WriteByte('"') + slashes := 0 + for i := range len(s) { + c := s[i] + switch c { + case '\\': + slashes++ + b.WriteByte(c) + case '"': + for ; slashes > 0; slashes-- { + b.WriteByte('\\') + } + b.WriteByte('\\') + b.WriteByte(c) + default: + slashes = 0 + b.WriteByte(c) + } + } + for ; slashes > 0; slashes-- { + b.WriteByte('\\') + } + b.WriteByte('"') + return b.String() } diff --git a/pkg/commands/oscommands/os_default_platform.go b/pkg/commands/oscommands/os_default_platform.go index 06684434e..5e73c994f 100644 --- a/pkg/commands/oscommands/os_default_platform.go +++ b/pkg/commands/oscommands/os_default_platform.go @@ -40,6 +40,11 @@ func (c *OSCommand) UpdateWindowTitle() error { return nil } +// setRawCmdLine is the non-Windows no-op counterpart of the Windows shim +// (see the comment there). NewShell's shell-building logic is portable, so +// this call is reached on every host; only the Windows build does anything. +func setRawCmdLine(cmd *exec.Cmd, cmdLine string) {} + func TerminateProcessGracefully(cmd *exec.Cmd) error { if cmd.Process == nil { return nil diff --git a/pkg/commands/oscommands/os_test.go b/pkg/commands/oscommands/os_test.go index e15e4de2d..1ccfbc025 100644 --- a/pkg/commands/oscommands/os_test.go +++ b/pkg/commands/oscommands/os_test.go @@ -75,10 +75,7 @@ func TestOSCommandQuoteWindows(t *testing.T) { actual := osCommand.Quote(`hello "test" 'test2'`) - /* EXPECTED: expected := `"hello \"test\" 'test2'"` - ACTUAL: */ - expected := `\"hello "'"'"test"'"'" 'test2'\"` assert.EqualValues(t, expected, actual) } @@ -93,10 +90,7 @@ func TestNewShellWindowsPassesMetacharactersVerbatim(t *testing.T) { command := `echo a && echo b | sort > out.txt < in.txt %PATH%` assert.Equal(t, - /* EXPECTED: []string{"cmd", "/s", "/c", command}, - ACTUAL: */ - []string{"cmd", "/c", "echo", "a", "^&^&", "echo", "b", "^|", "sort", "^>", "out.txt", "^<", "in.txt", "^%PATH^%"}, osCommand.Cmd.NewShell(command, "").Args(), ) } diff --git a/pkg/commands/oscommands/os_windows.go b/pkg/commands/oscommands/os_windows.go index 605ed7682..bd4cc5151 100644 --- a/pkg/commands/oscommands/os_windows.go +++ b/pkg/commands/oscommands/os_windows.go @@ -5,8 +5,25 @@ import ( "os" "os/exec" "path/filepath" + "syscall" ) +// setRawCmdLine hands cmd.exe the exact command line we built, bypassing +// os/exec's default composition (which quotes args with the +// CommandLineToArgvW `\"` convention that cmd.exe doesn't understand). +// +// The shell-building logic in NewShell is portable and dispatches on +// platform.OS, which keeps it (and its quoting) unit-testable on any host. +// Assigning SysProcAttr.CmdLine is the only step that needs a Windows-only +// field, so it's the single piece split out behind a build tag; every other +// platform gets the no-op in os_default_platform.go. +func setRawCmdLine(cmd *exec.Cmd, cmdLine string) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.CmdLine = cmdLine +} + func GetPlatform() *Platform { return &Platform{ OS: "windows", diff --git a/pkg/commands/oscommands/os_windows_test.go b/pkg/commands/oscommands/os_windows_test.go index 60ba495bf..495e23f72 100644 --- a/pkg/commands/oscommands/os_windows_test.go +++ b/pkg/commands/oscommands/os_windows_test.go @@ -20,7 +20,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) { { filename: "test", runner: NewFakeRunner(t). - ExpectArgs([]string{"cmd", "/c", "start", "", "test"}, "", errors.New("error")), + ExpectArgs([]string{"cmd", "/s", "/c", `start "" "test"`}, "", errors.New("error")), test: func(err error) { assert.Error(t, err) }, @@ -28,7 +28,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) { { filename: "test", runner: NewFakeRunner(t). - ExpectArgs([]string{"cmd", "/c", "start", "", "test"}, "", nil), + ExpectArgs([]string{"cmd", "/s", "/c", `start "" "test"`}, "", nil), test: func(err error) { assert.NoError(t, err) }, @@ -36,7 +36,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) { { filename: "filename with spaces", runner: NewFakeRunner(t). - ExpectArgs([]string{"cmd", "/c", "start", "", "filename with spaces"}, "", nil), + ExpectArgs([]string{"cmd", "/s", "/c", `start "" "filename with spaces"`}, "", nil), test: func(err error) { assert.NoError(t, err) }, @@ -44,7 +44,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) { { filename: "let's_test_with_single_quote", runner: NewFakeRunner(t). - ExpectArgs([]string{"cmd", "/c", "start", "", "let's_test_with_single_quote"}, "", nil), + ExpectArgs([]string{"cmd", "/s", "/c", `start "" "let's_test_with_single_quote"`}, "", nil), test: func(err error) { assert.NoError(t, err) }, @@ -52,7 +52,7 @@ func TestOSCommandOpenFileWindows(t *testing.T) { { filename: "$USER.txt", runner: NewFakeRunner(t). - ExpectArgs([]string{"cmd", "/c", "start", "", "$USER.txt"}, "", nil), + ExpectArgs([]string{"cmd", "/s", "/c", `start "" "$USER.txt"`}, "", nil), test: func(err error) { assert.NoError(t, err) }, From 13817665aa43ccc90282a1c4e1d704598728697e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 16 Jun 2026 08:27:02 +0200 Subject: [PATCH 105/384] Add end-to-end test for shell command quoting on Windows The unit tests only assert the arguments lazygit constructs; they can't catch cmd.exe's own quote-stripping, which is where #5560 actually manifested. This test builds a small editor executable, places it and the file it opens at paths containing spaces, runs it through real cmd.exe via NewShell, and checks the editor received the intended args. It runs only on Windows. Co-Authored-By: Antoine Gaudreau Simard Co-Authored-By: Claude Opus 4.8 (1M context) --- .../oscommands/new_shell_windows_test.go | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 pkg/commands/oscommands/new_shell_windows_test.go diff --git a/pkg/commands/oscommands/new_shell_windows_test.go b/pkg/commands/oscommands/new_shell_windows_test.go new file mode 100644 index 000000000..c7fe5aef1 --- /dev/null +++ b/pkg/commands/oscommands/new_shell_windows_test.go @@ -0,0 +1,389 @@ +//go:build windows + +package oscommands + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stretchr/testify/assert" +) + +// These tests run only on Windows because they exercise real cmd.exe +// quote-parsing behaviour, which has only been a problem on Windows. + +// makeWindowsShellBuilder returns a CmdObjBuilder configured for a real +// Windows cmd shell, bypassing the test "dummy" platform (which is darwin). +func makeWindowsShellBuilder() *CmdObjBuilder { + log := utils.NewDummyLog() + return &CmdObjBuilder{ + runner: &cmdObjRunner{log: log, guiIO: NewNullGuiIO(log)}, + platform: &Platform{OS: "windows", Shell: "cmd", ShellArg: "/c"}, + } +} + +// fakeEditorSrc is a minimal Go program that records the args it received, +// one per line, to marker.txt in its own directory. Using a real .exe (not a +// .bat) means args are parsed by Go's runtime via CommandLineToArgvW — the +// same algorithm used by ~all real Windows GUI editors. A .bat would parse +// args via cmd.exe's own rules, which can hide bugs that affect editors. +const fakeEditorSrc = `package main + +import ( + "os" + "path/filepath" + "strings" +) + +func main() { + exe, err := os.Executable() + if err != nil { + os.Exit(2) + } + marker := filepath.Join(filepath.Dir(exe), "marker.txt") + body := strings.Join(os.Args[1:], "\n") + if err := os.WriteFile(marker, []byte(body), 0o644); err != nil { + os.Exit(3) + } +} +` + +var ( + fakeEditorOnce sync.Once + fakeEditorBytes []byte + fakeEditorErr error +) + +// loadFakeEditorBytes builds the fake editor exactly once per test process +// and returns its bytes. Tests then drop a copy at a path containing spaces. +func loadFakeEditorBytes(t *testing.T) []byte { + t.Helper() + fakeEditorOnce.Do(func() { + buildDir, err := os.MkdirTemp("", "lazygit-fake-editor-build-*") + if err != nil { + fakeEditorErr = err + return + } + defer os.RemoveAll(buildDir) + + srcPath := filepath.Join(buildDir, "main.go") + binPath := filepath.Join(buildDir, "fake-editor.exe") + if err := os.WriteFile(srcPath, []byte(fakeEditorSrc), 0o644); err != nil { + fakeEditorErr = err + return + } + cmd := exec.Command("go", "build", "-o", binPath, srcPath) + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fakeEditorErr = err + return + } + fakeEditorBytes, fakeEditorErr = os.ReadFile(binPath) + }) + if fakeEditorErr != nil { + t.Fatalf("failed to build fake editor helper: %v", fakeEditorErr) + } + return fakeEditorBytes +} + +// placeFakeEditor builds the fake editor and places it at a path containing +// a space (mirroring `C:\Program Files\...`). The marker the editor writes +// lives next to the exe. +func placeFakeEditor(t *testing.T) (exe, markerFile string) { + t.Helper() + bin := loadFakeEditorBytes(t) + exeDir := filepath.Join(t.TempDir(), "Program Files", "FakeEditor") + if err := os.MkdirAll(exeDir, 0o755); err != nil { + t.Fatalf("mkdir exeDir: %v", err) + } + exe = filepath.Join(exeDir, "fake-editor.exe") + markerFile = filepath.Join(exeDir, "marker.txt") + if err := os.WriteFile(exe, bin, 0o755); err != nil { + t.Fatalf("write fake editor: %v", err) + } + return exe, markerFile +} + +// placeTargetFile creates a file at // with +// a trivial body. Use a dirName containing a space (e.g. "my repo") to put +// the file at a path with spaces. +func placeTargetFile(t *testing.T, dirName, basename string) string { + t.Helper() + dir := filepath.Join(t.TempDir(), dirName) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir target dir: %v", err) + } + target := filepath.Join(dir, basename) + if err := os.WriteFile(target, []byte("hello"), 0o644); err != nil { + t.Fatalf("write target: %v", err) + } + return target +} + +// setupFakeEditor is a convenience wrapper for the common case: editor at a +// spacey path AND target file at a spacey path — the conditions that +// trigger the cmd.exe quote-stripping bug. +func setupFakeEditor(t *testing.T) (fakeExe, targetFile, markerFile string) { + t.Helper() + fakeExe, markerFile = placeFakeEditor(t) + targetFile = placeTargetFile(t, "my repo", "file.txt") + return fakeExe, targetFile, markerFile +} + +// resolveTemplate mirrors what pkg/commands/git_commands/file.go does: it +// substitutes {{filename}} with the Windows-quoted filename and {{line}} +// with a line number. +func resolveTemplate(builder *CmdObjBuilder, template, filename, line string) string { + out := strings.ReplaceAll(template, "{{filename}}", builder.Quote(filename)) + out = strings.ReplaceAll(out, "{{line}}", line) + return out +} + +// readMarkerArgs reads the args the fake editor recorded. Each arg is on its +// own line (so an arg that itself contains a space stays one element). An +// empty file means the editor ran with zero args. +func readMarkerArgs(t *testing.T, markerFile string) []string { + t.Helper() + data, err := os.ReadFile(markerFile) + if err != nil { + t.Fatalf("marker file was not written; the fake editor never ran: %v", err) + } + s := strings.TrimRight(string(data), "\r\n") + if s == "" { + return nil + } + return strings.Split(s, "\n") +} + +func TestNewShell_QuotedExePath_FilenameWithSpaces_EditAtLineAndWait(t *testing.T) { + builder := makeWindowsShellBuilder() + fakeExe, targetFile, markerFile := setupFakeEditor(t) + + template := `"` + fakeExe + `" -multiInst -nosession -noPlugin -n{{line}} {{filename}}` + cmdStr := resolveTemplate(builder, template, targetFile, "42") + + out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput() + if err != nil { + t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out)) + } + + assert.Equal(t, + []string{"-multiInst", "-nosession", "-noPlugin", "-n42", targetFile}, + readMarkerArgs(t, markerFile), + ) +} + +func TestNewShell_QuotedExePath_FilenameWithSpaces_EditAtLine(t *testing.T) { + builder := makeWindowsShellBuilder() + fakeExe, targetFile, markerFile := setupFakeEditor(t) + + template := `"` + fakeExe + `" -n{{line}} {{filename}}` + cmdStr := resolveTemplate(builder, template, targetFile, "42") + + out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput() + if err != nil { + t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out)) + } + + assert.Equal(t, + []string{"-n42", targetFile}, + readMarkerArgs(t, markerFile), + ) +} + +func TestNewShell_QuotedExePath_FilenameWithSpaces_Edit(t *testing.T) { + builder := makeWindowsShellBuilder() + fakeExe, targetFile, markerFile := setupFakeEditor(t) + + template := `"` + fakeExe + `" {{filename}}` + cmdStr := resolveTemplate(builder, template, targetFile, "") + + out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput() + if err != nil { + t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out)) + } + + assert.Equal(t, + []string{targetFile}, + readMarkerArgs(t, markerFile), + ) +} + +// Sanity check: for a filename WITHOUT spaces the same templates already work, +// because the resulting cmd.exe line has exactly two quote characters and +// cmd /c keeps them. This pins the difference down to filename quoting and +// guards against a regression where the no-spaces case starts failing too. +func TestNewShell_QuotedExePath_FilenameWithoutSpaces_StillWorks(t *testing.T) { + builder := makeWindowsShellBuilder() + fakeExe, markerFile := placeFakeEditor(t) + plainTarget := placeTargetFile(t, "repo", "plain.txt") // no-space dir + + template := `"` + fakeExe + `" -multiInst -nosession -noPlugin -n{{line}} {{filename}}` + cmdStr := resolveTemplate(builder, template, plainTarget, "42") + + out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput() + if err != nil { + t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out)) + } + + assert.Equal(t, + []string{"-multiInst", "-nosession", "-noPlugin", "-n42", plainTarget}, + readMarkerArgs(t, markerFile), + ) +} + +// TestNewShell_VarietyOfEditorTemplates exercises NewShell with a range of +// realistic editor templates, all with the trigger conditions of the bug +// (quoted exe at a spacey path + filename at a spacey path). Each subtest +// asserts the editor receives the exact args lazygit intended. +// +// Args in `wantArgs` may use the literal "" placeholder; it gets +// substituted with the resolved target file path before comparison. +func TestNewShell_VarietyOfEditorTemplates(t *testing.T) { + const filePlaceholder = "" + + cases := []struct { + name string + template string // stands for the fake editor's full path + line string + wantArgs []string + }{ + { + name: "vim/nvim style: +line filename", + template: `"" +{{line}} {{filename}}`, + line: "42", + wantArgs: []string{"+42", filePlaceholder}, + }, + { + name: "emacs-like with explicit +N", + template: `"" +{{line}} -nw {{filename}}`, + line: "7", + wantArgs: []string{"+7", "-nw", filePlaceholder}, + }, + { + name: "long flag with =value", + template: `"" --line={{line}} --tab-size=4 {{filename}}`, + line: "42", + wantArgs: []string{"--line=42", "--tab-size=4", filePlaceholder}, + }, + { + name: "many short and long flags before filename", + template: `"" -a -b -c --foo --bar -n{{line}} {{filename}}`, + line: "42", + wantArgs: []string{"-a", "-b", "-c", "--foo", "--bar", "-n42", filePlaceholder}, + }, + { + name: "flag after filename", + template: `"" {{filename}} --readonly`, + line: "", + wantArgs: []string{filePlaceholder, "--readonly"}, + }, + { + name: "single short flag attached to value", + template: `"" -n{{line}} {{filename}}`, + line: "1", + wantArgs: []string{"-n1", filePlaceholder}, + }, + { + name: "no flags, just filename", + template: `"" {{filename}}`, + line: "", + wantArgs: []string{filePlaceholder}, + }, + { + name: "flag with separate value (space-separated)", + template: `"" --goto {{line}} {{filename}}`, + line: "42", + wantArgs: []string{"--goto", "42", filePlaceholder}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + builder := makeWindowsShellBuilder() + fakeExe, targetFile, markerFile := setupFakeEditor(t) + + template := strings.ReplaceAll(tc.template, "", fakeExe) + cmdStr := resolveTemplate(builder, template, targetFile, tc.line) + + out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput() + if err != nil { + t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out)) + } + + want := make([]string, len(tc.wantArgs)) + for i, a := range tc.wantArgs { + want[i] = strings.ReplaceAll(a, filePlaceholder, targetFile) + } + assert.Equal(t, want, readMarkerArgs(t, markerFile)) + }) + } +} + +// TestNewShell_FilenameSpecialCharacters varies the basename of the target +// file across characters that are legal in Windows filenames but might +// interact badly with cmd.exe / Quote(): parentheses, brackets, single +// quote, comma, semicolon, equals, etc. The exe is at a spacey path and +// the target dir has spaces, so the bug-trigger conditions are still met. +func TestNewShell_FilenameSpecialCharacters(t *testing.T) { + cases := []struct { + name string + basename string + }{ + {"parens", "file (1).txt"}, + {"brackets", "file[v2].txt"}, + {"single quote", "it's a file.txt"}, + {"comma", "a,b,c.txt"}, + {"semicolon", "a;b.txt"}, + {"equals", "key=value.txt"}, + {"plus", "a+b.txt"}, + {"hash", "issue#42.txt"}, + {"at sign", "user@host.txt"}, + {"tilde", "~backup.txt"}, + {"dot leading", ".gitignore.txt"}, + {"multiple dots", "v1.2.3.txt"}, + {"dash leading", "-flag-looking.txt"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + builder := makeWindowsShellBuilder() + fakeExe, markerFile := placeFakeEditor(t) + targetFile := placeTargetFile(t, "my repo", tc.basename) + + template := `"` + fakeExe + `" -n{{line}} {{filename}}` + cmdStr := resolveTemplate(builder, template, targetFile, "42") + + out, err := builder.NewShell(cmdStr, "").GetCmd().CombinedOutput() + if err != nil { + t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out)) + } + + assert.Equal(t, + []string{"-n42", targetFile}, + readMarkerArgs(t, markerFile), + ) + }) + } +} + +// Command chaining with && must work: cmd /s /c runs the assembled line verbatim, +// so cmd treats && as a separator and runs both commands. The two echoes +// therefore produce two separate output lines. +func TestNewShell_CommandChaining(t *testing.T) { + builder := makeWindowsShellBuilder() + + out, err := builder.NewShell("echo first&&echo second", "").GetCmd().CombinedOutput() + if err != nil { + t.Fatalf("shell command failed: %v\ncmd.exe output:\n%s", err, string(out)) + } + + normalized := strings.ReplaceAll(string(out), "\r\n", "\n") + lines := strings.Split(strings.TrimSpace(normalized), "\n") + assert.Equal(t, []string{"first", "second"}, lines) +} From 7a60f2de800396634b06bf8e60ef80fb703b5f6d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 18:24:51 +0200 Subject: [PATCH 106/384] Ask agents to surface mid-implementation decisions Record the working preference that calls which come up during implementation (and weren't settled in planning) should be raised and decided together, not made unilaterally and discovered later in the diff. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 4b0ef6731..c379b3030 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,6 +138,22 @@ commit. If you have two independent refinements for the same target, make two separate fixups. Reviewability of the intermediate state matters even when the end state after autosquash would be identical. +## Surface mid-implementation decisions; decide them together + +Planning can't anticipate everything. When a decision surfaces while you're +implementing — a design choice, a tradeoff, a scope cut, a "this turned out +harder than expected, so maybe X" — don't quietly make the call and keep +going, even if you have a clear recommendation and even if the call seems +small. Stop, lay out the options and your recommendation, and let me weigh in. +I want to make these calls _with_ you, not discover them after the fact in the +diff. + +This isn't a request to stop and ask about every trivial detail; obvious +mechanical choices with one sensible answer don't need a checkpoint. It's about +genuine forks — the ones where a reasonable person might pick differently, or +where you'd be trading away something the plan assumed (scope, UX, performance, +reload behavior, …). When in doubt, surface it. + ## Prefer the cleaner design over the smaller diff When a task could be implemented either by tacking onto existing code or by From dc9445014d54e6f13589de21ed2835d898f0cc0b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 08:24:01 +0200 Subject: [PATCH 107/384] Drive side-panel layout from a single window list The three branches of sidePanelChildren each spelled out the five side windows by name, so the panel order lived in three places and the status/stash sizing special-cases were tangled into positional literals. Map each branch over one `windows` slice instead, and fold the normal-height special-cases (status's fixed height, stash's collapse-unless-focused) into a single per-window function. Behavior is unchanged; this isolates the ordering so it can later come from config. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/window_arrangement_helper.go | 98 +++++++++++-------- .../helpers/window_arrangement_helper_test.go | 21 ++-- 2 files changed, 67 insertions(+), 52 deletions(-) diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 9061d5177..315e6b47b 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -50,6 +50,11 @@ type WindowArrangementArgs struct { // Name of the current side window (i.e. the current window in the left // section of the UI) CurrentSideWindow string + // Returns the view currently shown in the given window. When a window holds + // several tabbed views this is the selected tab, which is what the status and + // stash height special-cases key off (rather than the window itself, whose + // name is just its first tab). + ActiveViewForWindow func(window string) string // Whether the main panel is split (as is the case e.g. when a file has both // staged and unstaged changes) SplitMainPanel bool @@ -86,20 +91,21 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string, } args := WindowArrangementArgs{ - Width: width, - Height: height, - UserConfig: self.c.UserConfig(), - CurrentWindow: self.c.Context().CurrentStatic().GetWindowName(), - CurrentSideWindow: self.c.Context().CurrentSide().GetWindowName(), - SplitMainPanel: repoState.GetSplitMainPanel(), - ScreenMode: repoState.GetScreenMode(), - AppStatus: appStatus, - InformationStr: informationStr, - ShowExtrasWindow: self.c.State().GetShowExtrasWindow(), - InDemo: self.c.InDemo(), - IsAnyModeActive: self.modeHelper.IsAnyModeActive(), - InSearchPrompt: repoState.InSearchPrompt(), - SearchPrefix: searchPrefix, + Width: width, + Height: height, + UserConfig: self.c.UserConfig(), + CurrentWindow: self.c.Context().CurrentStatic().GetWindowName(), + CurrentSideWindow: self.c.Context().CurrentSide().GetWindowName(), + ActiveViewForWindow: self.windowHelper.GetViewNameForWindow, + SplitMainPanel: repoState.GetSplitMainPanel(), + ScreenMode: repoState.GetScreenMode(), + AppStatus: appStatus, + InformationStr: informationStr, + ShowExtrasWindow: self.c.State().GetShowExtrasWindow(), + InDemo: self.c.InDemo(), + IsAnyModeActive: self.modeHelper.IsAnyModeActive(), + InSearchPrompt: repoState.InSearchPrompt(), + SearchPrefix: searchPrefix, } return GetWindowDimensions(args) @@ -403,14 +409,15 @@ func getExtrasWindowSize(args WindowArrangementArgs) int { return baseSize + frameSize } -// The stash window by default only contains one line so that it's not hogging +// The stash view by default only contains one line so that it's not hogging // too much space, but if you access it it should take up some space. This is // the default behaviour when accordion mode is NOT in effect. If it is in effect -// then when it's accessed it will have weight 2, not 1. -func getDefaultStashWindowBox(args WindowArrangementArgs) *boxlayout.Box { - box := &boxlayout.Box{Window: "stash"} - // if the stash window is anywhere in our stack we should enlargen it - if args.CurrentSideWindow == "stash" { +// then when it's accessed it will have weight 2, not 1. The window is passed in +// because stash may be a tab of a window named after a different first tab. +func getDefaultStashWindowBox(args WindowArrangementArgs, window string) *boxlayout.Box { + box := &boxlayout.Box{Window: window} + // if the window showing stash is focused we should enlargen it + if args.CurrentSideWindow == window { box.Weight = 1 } else { box.Size = 3 @@ -421,6 +428,16 @@ func getDefaultStashWindowBox(args WindowArrangementArgs) *boxlayout.Box { func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) []*boxlayout.Box { return func(width int, height int) []*boxlayout.Box { + windows := []string{"status", "files", "branches", "commits", "stash"} + + boxForEachWindow := func(boxForWindow func(window string) *boxlayout.Box) []*boxlayout.Box { + boxes := make([]*boxlayout.Box, 0, len(windows)) + for _, window := range windows { + boxes = append(boxes, boxForWindow(window)) + } + return boxes + } + if args.ScreenMode == types.SCREEN_FULL || args.ScreenMode == types.SCREEN_HALF { fullHeightBox := func(window string) *boxlayout.Box { if window == args.CurrentSideWindow { @@ -436,13 +453,7 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ } } - return []*boxlayout.Box{ - fullHeightBox("status"), - fullHeightBox("files"), - fullHeightBox("branches"), - fullHeightBox("commits"), - fullHeightBox("stash"), - } + return boxForEachWindow(fullHeightBox) } else if height >= 28 { accordionMode := args.UserConfig.Gui.ExpandFocusedSidePanel accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box { @@ -456,16 +467,23 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ return defaultBox } - return []*boxlayout.Box{ - { - Window: "status", - Size: 3, - }, - accordionBox(&boxlayout.Box{Window: "files", Weight: 1}), - accordionBox(&boxlayout.Box{Window: "branches", Weight: 1}), - accordionBox(&boxlayout.Box{Window: "commits", Weight: 1}), - accordionBox(getDefaultStashWindowBox(args)), + normalBox := func(window string) *boxlayout.Box { + // The status and stash sizing is a property of those views, so we key + // off the tab the window is currently showing, not the window's name + // (its first tab): otherwise grouping other tabs behind status or + // stash would wrongly impose their compact height on those tabs. + switch args.ActiveViewForWindow(window) { + case "status": + // The status view has a fixed height and is not expanded by accordion mode. + return &boxlayout.Box{Window: window, Size: 3} + case "stash": + return accordionBox(getDefaultStashWindowBox(args, window)) + default: + return accordionBox(&boxlayout.Box{Window: window, Weight: 1}) + } } + + return boxForEachWindow(normalBox) } squashedHeight := 1 @@ -487,12 +505,6 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ } } - return []*boxlayout.Box{ - squashedSidePanelBox("status"), - squashedSidePanelBox("files"), - squashedSidePanelBox("branches"), - squashedSidePanelBox("commits"), - squashedSidePanelBox("stash"), - } + return boxForEachWindow(squashedSidePanelBox) } } diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go index c63755ef2..168fc9972 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go @@ -24,15 +24,18 @@ func TestGetWindowDimensions(t *testing.T) { UserConfig: config.GetDefaultConfig(), CurrentWindow: "files", CurrentSideWindow: "files", - SplitMainPanel: false, - ScreenMode: types.SCREEN_NORMAL, - AppStatus: "", - InformationStr: "information", - ShowExtrasWindow: false, - InDemo: false, - IsAnyModeActive: false, - InSearchPrompt: false, - SearchPrefix: "", + // Each panel shows its first tab by default; for the special-cased + // panels (status, stash) the view name matches the window name. + ActiveViewForWindow: func(window string) string { return window }, + SplitMainPanel: false, + ScreenMode: types.SCREEN_NORMAL, + AppStatus: "", + InformationStr: "information", + ShowExtrasWindow: false, + InDemo: false, + IsAnyModeActive: false, + InSearchPrompt: false, + SearchPrefix: "", } } From 853f01eb3ce183de314134ac8d1a4a60e6637dd8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 08:30:52 +0200 Subject: [PATCH 108/384] Make the remote-branches view follow its parent's window The remote-branches context is a transient guest that takes over a host window when you drill into a remote. SubCommits and CommitFiles already adopt their parent's window via SetWindowName when shown; RemoteBranches relied instead on its static window name ("branches") matching the remotes context's window. That assumption only holds while remotes lives in the branches panel. Adopt the parent's window like the other transient guests so remote branches render in the right place once panels are configurable. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/remotes_controller.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index 8f5dd1ae6..dd5a171e4 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -140,6 +140,7 @@ func (self *RemotesController) enter(remote *models.Remote) error { remoteBranchesContext.SetSelection(newSelectedLine) remoteBranchesContext.SetTitleRef(remote.Name) remoteBranchesContext.SetParentContext(self.Context()) + remoteBranchesContext.SetWindowName(self.Context().GetWindowName()) remoteBranchesContext.GetView().TitlePrefix = self.Context().GetView().TitlePrefix self.c.PostRefreshUpdate(remoteBranchesContext) From b7258958b80ca514a478e9107c9ee3c694b56e7d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 08:33:24 +0200 Subject: [PATCH 109/384] Assign panel jump labels by iterating panel groups The jump-label prefixes were assigned to each side view by name, twice (once for the on case, once for off), so the panel-to-views grouping and the panel order were baked into 28 positional statements. Express the grouping once as a slice of view groups and loop over it, deriving each panel's label from its index. The label lookup is now bounds-checked, so it no longer assumes exactly as many jump bindings as panels. Behavior is unchanged; this prepares the grouping to come from config. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/views.go | 65 +++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 37 deletions(-) diff --git a/pkg/gui/views.go b/pkg/gui/views.go index ecfc0ddcd..423c0193e 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -210,50 +210,41 @@ func (gui *Gui) configureViewProperties() { gui.Views.CommitDescription.TextArea.AutoWrap = gui.c.UserConfig().Git.Commit.AutoWrapCommitMessage gui.Views.CommitDescription.TextArea.AutoWrapWidth = gui.c.UserConfig().Git.Commit.AutoWrapWidth - if gui.c.UserConfig().Gui.ShowPanelJumps { - keyToTitlePrefix := func(binding config.Keybinding) string { - if len(binding) == 0 { - return "" - } - return fmt.Sprintf("[%s]", binding[0]) + keyToTitlePrefix := func(binding config.Keybinding) string { + if len(binding) == 0 { + return "" } - jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock - jumpLabels := lo.Map(jumpBindings, func(binding config.Keybinding, _ int) string { - return keyToTitlePrefix(binding) - }) + return fmt.Sprintf("[%s]", binding[0]) + } - gui.Views.Status.TitlePrefix = jumpLabels[0] + // The views that make up each side panel, in panel order. The whole group + // shares the panel's jump label. + panelViewGroups := [][]*gocui.View{ + {gui.Views.Status}, + {gui.Views.Files, gui.Views.Worktrees, gui.Views.Submodules}, + {gui.Views.Branches, gui.Views.Remotes, gui.Views.Tags}, + {gui.Views.Commits, gui.Views.ReflogCommits}, + {gui.Views.Stash}, + } - gui.Views.Files.TitlePrefix = jumpLabels[1] - gui.Views.Worktrees.TitlePrefix = jumpLabels[1] - gui.Views.Submodules.TitlePrefix = jumpLabels[1] + jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock + jumpLabelForPanel := func(panelIndex int) string { + if !gui.c.UserConfig().Gui.ShowPanelJumps || panelIndex >= len(jumpBindings) { + return "" + } + return keyToTitlePrefix(jumpBindings[panelIndex]) + } - gui.Views.Branches.TitlePrefix = jumpLabels[2] - gui.Views.Remotes.TitlePrefix = jumpLabels[2] - gui.Views.Tags.TitlePrefix = jumpLabels[2] - - gui.Views.Commits.TitlePrefix = jumpLabels[3] - gui.Views.ReflogCommits.TitlePrefix = jumpLabels[3] - - gui.Views.Stash.TitlePrefix = jumpLabels[4] + for panelIndex, views := range panelViewGroups { + prefix := jumpLabelForPanel(panelIndex) + for _, view := range views { + view.TitlePrefix = prefix + } + } + if gui.c.UserConfig().Gui.ShowPanelJumps { gui.Views.Main.TitlePrefix = keyToTitlePrefix(gui.c.UserConfig().Keybinding.Universal.FocusMainView) } else { - gui.Views.Status.TitlePrefix = "" - - gui.Views.Files.TitlePrefix = "" - gui.Views.Worktrees.TitlePrefix = "" - gui.Views.Submodules.TitlePrefix = "" - - gui.Views.Branches.TitlePrefix = "" - gui.Views.Remotes.TitlePrefix = "" - gui.Views.Tags.TitlePrefix = "" - - gui.Views.Commits.TitlePrefix = "" - gui.Views.ReflogCommits.TitlePrefix = "" - - gui.Views.Stash.TitlePrefix = "" - gui.Views.Main.TitlePrefix = "" } From 195b578fc12e54b68cd7b62603a45456c12e7a2a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 08:46:32 +0200 Subject: [PATCH 110/384] Add the gui.sidePanels config option This adds the user-facing surface for configuring the side panels: their order, which ones are visible, and how tabs are grouped into panels. Each entry is either a single panel name or a list of names sharing one panel as tabs, mirroring how the Keybinding type accepts a scalar or a sequence; the JSON schema restricts the names to the known set so editors can offer completion and catch typos. The default reproduces today's layout exactly. Validation rejects unknown or duplicated names, and requires the files, branches, and commits panels to always be present: a lot of code focuses those directly (e.g. after resolving a conflict or popping a stash), so allowing them to be hidden would let that code focus a hidden panel. Nothing reads the option yet; the layout still uses the hard-coded order. Wiring follows in a later commit so the inert surface (and its generated docs and schema) can be reviewed on its own. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-master/Config.md | 15 +++++++ pkg/config/side_panel.go | 54 +++++++++++++++++++++++ pkg/config/user_config.go | 12 +++++ pkg/config/user_config_validation.go | 36 +++++++++++++++ pkg/config/user_config_validation_test.go | 36 +++++++++++++++ schema-master/config.json | 47 ++++++++++++++++++++ 6 files changed, 200 insertions(+) create mode 100644 pkg/config/side_panel.go diff --git a/docs-master/Config.md b/docs-master/Config.md index 80f8cfd23..0a4d9f3fa 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -110,6 +110,21 @@ gui: # is true. expandedSidePanelWeight: 2 + # The side panels, in the order they appear from top to bottom. + # Each entry is a list of one or more names that share a single panel as tabs + # (cycle through them with the next-tab/previous-tab keys). + # Omit a name to hide it; give a name its own one-element list to promote a tab + # to a top-level panel. + # Valid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', + # 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and + # 'commits' must always be included; they can't be hidden. + sidePanels: + - [status] + - [files, worktrees, submodules] + - [branches, remotes, tags] + - [commits, reflog] + - [stash] + # 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. diff --git a/pkg/config/side_panel.go b/pkg/config/side_panel.go new file mode 100644 index 000000000..307ed0c1d --- /dev/null +++ b/pkg/config/side_panel.go @@ -0,0 +1,54 @@ +package config + +import ( + "github.com/karimkhaleel/jsonschema" + "github.com/samber/lo" + "gopkg.in/yaml.v3" +) + +// SidePanel is one entry in gui.sidePanels: a side panel made up of one or more +// tabs, written in YAML as a list of tab names (e.g. [files, worktrees]). +type SidePanel []string + +// ValidSidePanelTabs lists every name that may appear in gui.sidePanels. Each +// names a list that can stand alone as a panel or be grouped with others as the +// tabs of one panel. The resolver in the gui package must handle every entry +// here; a test enforces that the two stay in sync. +var ValidSidePanelTabs = []string{ + "status", + "files", + "worktrees", + "submodules", + "branches", + "remotes", + "tags", + "commits", + "reflog", + "stash", +} + +func (p SidePanel) MarshalYAML() (any, error) { + // Render in flow style (`[a, b]`) rather than the default block style, which + // is more compact and reads better in the generated docs. + node := &yaml.Node{ + Kind: yaml.SequenceNode, + Style: yaml.FlowStyle, + } + for _, s := range p { + node.Content = append(node.Content, &yaml.Node{ + Kind: yaml.ScalarNode, + Value: s, + }) + } + return node, nil +} + +// JSONSchema describes a side panel as a list of tab names, restricted to the +// known names. +func (SidePanel) JSONSchema() *jsonschema.Schema { + names := lo.Map(ValidSidePanelTabs, func(name string, _ int) any { return name }) + return &jsonschema.Schema{ + Type: "array", + Items: &jsonschema.Schema{Type: "string", Enum: names}, + } +} diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index e96f4aff4..bb7b5f424 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -109,6 +109,11 @@ type GuiConfig struct { ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"` // 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 int `yaml:"expandedSidePanelWeight"` + // The side panels, in the order they appear from top to bottom. + // Each entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys). + // Omit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel. + // Valid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and 'commits' must always be included; they can't be hidden. + SidePanels []SidePanel `yaml:"sidePanels"` // 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 @@ -848,6 +853,13 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { SidePanelWidth: 0.3333, ExpandFocusedSidePanel: false, ExpandedSidePanelWeight: 2, + SidePanels: []SidePanel{ + {"status"}, + {"files", "worktrees", "submodules"}, + {"branches", "remotes", "tags"}, + {"commits", "reflog"}, + {"stash"}, + }, MainPanelSplitMode: "flexible", EnlargedSideViewLocation: "left", WrapLinesInStagingView: true, diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 109b3f1d0..3dd5b4b59 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -58,6 +58,42 @@ func (config *UserConfig) Validate() error { if err := validateSpinner(config.Gui.Spinner); err != nil { return err } + if err := validateSidePanels(config.Gui.SidePanels); err != nil { + return err + } + return nil +} + +func validateSidePanels(panels []SidePanel) error { + seen := map[string]bool{} + total := 0 + for _, panel := range panels { + if len(panel) == 0 { + return errors.New("gui.sidePanels: a side panel must have at least one tab.") + } + for _, name := range panel { + if !slices.Contains(ValidSidePanelTabs, name) { + return fmt.Errorf("gui.sidePanels: unknown side panel '%s'. Allowed values: %s", + name, strings.Join(ValidSidePanelTabs, ", ")) + } + if seen[name] { + return fmt.Errorf("gui.sidePanels: '%s' is listed more than once; each side panel may appear only once.", name) + } + seen[name] = true + total++ + } + } + if total == 0 { + return errors.New("gui.sidePanels must not be empty.") + } + // A lot of code focuses these panels directly (e.g. after resolving a + // conflict or popping a stash), so they must always be present; otherwise + // that code would focus a hidden panel. + for _, required := range []string{"files", "branches", "commits"} { + if !seen[required] { + return fmt.Errorf("gui.sidePanels: '%s' must be included; it can't be hidden.", required) + } + } return nil } diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index 26c9b7145..02e64b02a 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -324,6 +324,42 @@ func TestUserConfigValidate_spinnerFrames(t *testing.T) { } } +func TestUserConfigValidate_sidePanels(t *testing.T) { + scenarios := []struct { + name string + panels []SidePanel + valid bool + }{ + {name: "default layout", panels: []SidePanel{{"status"}, {"files", "worktrees", "submodules"}, {"branches", "remotes", "tags"}, {"commits", "reflog"}, {"stash"}}, valid: true}, + {name: "reordered", panels: []SidePanel{{"status"}, {"files"}, {"commits"}, {"branches"}, {"stash"}}, valid: true}, + {name: "hidden stash panel", panels: []SidePanel{{"status"}, {"files"}, {"branches"}, {"commits"}}, valid: true}, + {name: "promoted tab", panels: []SidePanel{{"files", "submodules"}, {"worktrees"}, {"branches"}, {"commits"}}, valid: true}, + {name: "core panels only", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}}, valid: true}, + {name: "empty", panels: []SidePanel{}, valid: false}, + {name: "empty panel", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}, {}}, valid: false}, + {name: "unknown name", panels: []SidePanel{{"files"}, {"branches"}, {"commits"}, {"bogus"}}, valid: false}, + {name: "duplicate within panel", panels: []SidePanel{{"files", "files"}, {"branches"}, {"commits"}}, valid: false}, + {name: "duplicate across panels", panels: []SidePanel{{"files"}, {"branches", "files"}, {"commits"}}, valid: false}, + {name: "missing files", panels: []SidePanel{{"branches"}, {"commits"}}, valid: false}, + {name: "missing branches", panels: []SidePanel{{"files"}, {"commits"}}, valid: false}, + {name: "missing commits", panels: []SidePanel{{"files"}, {"branches"}}, valid: false}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + config := GetDefaultConfig() + config.Gui.SidePanels = s.panels + err := config.Validate() + + if s.valid { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +} + func TestUserConfigValidate_pagers(t *testing.T) { scenarios := []struct { name string diff --git a/schema-master/config.json b/schema-master/config.json index e0871940c..6e9a825ad 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -590,6 +590,35 @@ "description": "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.", "default": 2 }, + "sidePanels": { + "items": { + "$ref": "#/$defs/SidePanel" + }, + "type": "array", + "description": "The side panels, in the order they appear from top to bottom.\nEach entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys).\nOmit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel.\nValid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and 'commits' must always be included; they can't be hidden.", + "default": [ + [ + "status" + ], + [ + "files", + "worktrees", + "submodules" + ], + [ + "branches", + "remotes", + "tags" + ], + [ + "commits", + "reflog" + ], + [ + "stash" + ] + ] + }, "mainPanelSplitMode": { "type": "string", "enum": [ @@ -3565,6 +3594,24 @@ "type": "object", "description": "Background refreshes" }, + "SidePanel": { + "items": { + "type": "string", + "enum": [ + "status", + "files", + "worktrees", + "submodules", + "branches", + "remotes", + "tags", + "commits", + "reflog", + "stash" + ] + }, + "type": "array" + }, "SpinnerConfig": { "properties": { "frames": { From 2f3ed7e0eb1fd44e7eb32784a13f27d006b6bd8a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 08:50:12 +0200 Subject: [PATCH 111/384] Stop requiring jumpToBlock to have exactly five entries The number of side panels is about to become configurable, so a fixed count of jump-to-panel keys no longer makes sense: a user who configures six panels shouldn't be forced to also extend jumpToBlock, and one who hides a panel shouldn't have to trim it. Drop the count check entirely (individual keys are still validated) and assign keys to panels positionally, for as many panels as there are keys. Surplus panels go without a jump key but remain reachable via the next/previous-panel keys. This also removes the log.Fatal that the count check guarded against. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/config/user_config_validation.go | 11 +------ pkg/config/user_config_validation_test.go | 7 +++-- .../jump_to_side_window_controller.go | 29 ++++++++++--------- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 3dd5b4b59..9550e9160 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -177,16 +177,7 @@ func validateKeybindingsRecurse(path string, node any) error { } func validateKeybindings(keybindingConfig KeybindingConfig) error { - if err := validateKeybindingsRecurse("", keybindingConfig); err != nil { - return err - } - - if len(keybindingConfig.Universal.JumpToBlock) != 5 { - return fmt.Errorf("keybinding.universal.jumpToBlock must have 5 elements; found %d.", - len(keybindingConfig.Universal.JumpToBlock)) - } - - return nil + return validateKeybindingsRecurse("", keybindingConfig) } func validateCustomCommandKey(key Keybinding) error { diff --git a/pkg/config/user_config_validation_test.go b/pkg/config/user_config_validation_test.go index 02e64b02a..a0c17636d 100644 --- a/pkg/config/user_config_validation_test.go +++ b/pkg/config/user_config_validation_test.go @@ -134,11 +134,12 @@ func TestUserConfigValidate_enums(t *testing.T) { }) }, testCases: []testCase{ - {value: "", valid: false}, - {value: "1,2,3", valid: false}, + // The number of entries no longer has to match the number of side + // panels, so only the validity of the individual keys matters. + {value: "1,2,3", valid: true}, {value: "1,2,3,4,5", valid: true}, + {value: "1,2,3,4,5,6", valid: true}, {value: "1,2,3,4,invalid", valid: false}, - {value: "1,2,3,4,5,6", valid: false}, }, }, { diff --git a/pkg/gui/controllers/jump_to_side_window_controller.go b/pkg/gui/controllers/jump_to_side_window_controller.go index 2ea8ac762..37829849d 100644 --- a/pkg/gui/controllers/jump_to_side_window_controller.go +++ b/pkg/gui/controllers/jump_to_side_window_controller.go @@ -1,10 +1,7 @@ package controllers import ( - "log" - "github.com/jesseduffield/lazygit/pkg/gui/types" - "github.com/samber/lo" ) type JumpToSideWindowController struct { @@ -30,19 +27,23 @@ func (self *JumpToSideWindowController) Context() types.Context { func (self *JumpToSideWindowController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { windows := self.c.Helpers().Window.SideWindows() + jumpKeys := opts.Config.Universal.JumpToBlock - if len(opts.Config.Universal.JumpToBlock) != len(windows) { - log.Fatal("Jump to block keybindings cannot be set. Exactly 5 keybindings must be supplied.") - } - - return lo.Map(windows, func(window string, index int) *types.Binding { - return &types.Binding{ + // Assign jump keys to panels positionally (by default 1 to the first panel, + // 2 to the second, etc.), for as many panels as there are keys. If there are + // more panels than keys the extra panels just have no jump key, and if there + // are more keys than panels the extra keys are unused; either way panels stay + // reachable via the next/previous-panel keys. + count := min(len(windows), len(jumpKeys)) + bindings := make([]*types.Binding, 0, count) + for i := range count { + bindings = append(bindings, &types.Binding{ ViewName: "", - // by default the keys are 1, 2, 3, etc - Keys: opts.GetKeys(opts.Config.Universal.JumpToBlock[index]), - Handler: opts.Guards.NoPopupPanel(self.goToSideWindow(window)), - } - }) + Keys: opts.GetKeys(jumpKeys[i]), + Handler: opts.Guards.NoPopupPanel(self.goToSideWindow(windows[i])), + }) + } + return bindings } func (self *JumpToSideWindowController) goToSideWindow(window string) func() error { From 56f3049af47cd3ec0b8252f33c68f1a1af949eaf Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 17:54:16 +0200 Subject: [PATCH 112/384] Drive the side panel layout from gui.sidePanels Replace the hard-coded side panel order, tab groupings, and window assignments with values resolved from the gui.sidePanels config. The panel order (SideWindows and the layout boxes), the tab strips (viewTabMap), the per-context window names, each window's default view, and the jump-label groups all now come from the config rather than from five separate hard-coded lists. A panel's window name is the name of its first tab, and panels not listed in the config get their own window name so their views stay hidden instead of overlapping a visible panel. Three small lookups translate config names into views, tab titles, and contexts; a test keeps them in sync with the set of valid names. The lookups are split this way (rather than one resolver) because configureViewProperties runs before the context tree exists, so the title/view lookups must not depend on it. The config is applied to a repo's contexts via applySidePanelConfig on every repo entry, including the cached-repo path: a repo's per-repo config can differ from the previously visited one's, so each repo's contexts must be (re)assigned from its own config rather than kept from when they were first built. With the default config this reproduces today's layout exactly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/window_arrangement_helper.go | 2 +- .../helpers/window_arrangement_helper_test.go | 146 ++++++++++++++++++ pkg/gui/controllers/helpers/window_helper.go | 11 +- pkg/gui/gui.go | 74 ++++----- pkg/gui/side_panels.go | 90 +++++++++++ pkg/gui/side_panels_test.go | 30 ++++ pkg/gui/views.go | 13 +- 7 files changed, 311 insertions(+), 55 deletions(-) create mode 100644 pkg/gui/side_panels.go create mode 100644 pkg/gui/side_panels_test.go diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 315e6b47b..610c57f52 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -428,7 +428,7 @@ func getDefaultStashWindowBox(args WindowArrangementArgs, window string) *boxlay func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) []*boxlayout.Box { return func(width int, height int) []*boxlayout.Box { - windows := []string{"status", "files", "branches", "commits", "stash"} + windows := sideWindowNames(args.UserConfig) boxForEachWindow := func(boxForWindow func(window string) *boxlayout.Box) []*boxlayout.Box { boxes := make([]*boxlayout.Box, 0, len(windows)) diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go index 168fc9972..63d7642b6 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go @@ -124,6 +124,152 @@ func TestGetWindowDimensions(t *testing.T) { B: information `, }, + { + name: "worktrees promoted to its own side panel", + mutateArgs: func(args *WindowArrangementArgs) { + args.UserConfig.Gui.SidePanels = []config.SidePanel{ + {"status"}, + {"files", "submodules"}, + {"worktrees"}, + {"branches", "remotes", "tags"}, + {"commits", "reflog"}, + {"stash"}, + } + }, + expected: ` + ╭status─────────────────╮╭main────────────────────────────────────────────╮ + │ ││ │ + ╰───────────────────────╯│ │ + ╭files──────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭worktrees──────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭branches───────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭commits────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭stash──────────────────╮│ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + A: statusSpacer1 + B: information + `, + }, + { + name: "stash side panel hidden", + mutateArgs: func(args *WindowArrangementArgs) { + args.UserConfig.Gui.SidePanels = []config.SidePanel{ + {"status"}, + {"files", "worktrees", "submodules"}, + {"branches", "remotes", "tags"}, + {"commits", "reflog"}, + } + }, + expected: ` + ╭status─────────────────╮╭main────────────────────────────────────────────╮ + │ ││ │ + ╰───────────────────────╯│ │ + ╭files──────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭branches───────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭commits────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + A: statusSpacer1 + B: information + `, + }, + { + name: "stash leading a grouped panel doesn't squash its other tabs", + mutateArgs: func(args *WindowArrangementArgs) { + args.UserConfig.Gui.SidePanels = []config.SidePanel{ + {"status"}, + {"files", "worktrees", "submodules"}, + {"stash", "branches", "remotes", "tags"}, + {"commits", "reflog"}, + } + // The third panel is named after its first tab, stash, but is + // currently showing the branches tab, which must get full height + // rather than stash's compact height. + args.ActiveViewForWindow = func(window string) string { + if window == "stash" { + return "branches" + } + return window + } + }, + expected: ` + ╭status─────────────────╮╭main────────────────────────────────────────────╮ + │ ││ │ + ╰───────────────────────╯│ │ + ╭files──────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭stash──────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭commits────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + A: statusSpacer1 + B: information + `, + }, { name: "expandFocusedSidePanel", mutateArgs: func(args *WindowArrangementArgs) { diff --git a/pkg/gui/controllers/helpers/window_helper.go b/pkg/gui/controllers/helpers/window_helper.go index 53531c2ff..d9fd017f7 100644 --- a/pkg/gui/controllers/helpers/window_helper.go +++ b/pkg/gui/controllers/helpers/window_helper.go @@ -3,6 +3,7 @@ package helpers import ( "fmt" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" @@ -135,5 +136,13 @@ func (self *WindowHelper) WindowForView(viewName string) string { } func (self *WindowHelper) SideWindows() []string { - return []string{"status", "files", "branches", "commits", "stash"} + return sideWindowNames(self.c.UserConfig()) +} + +// sideWindowNames returns the side panel window names in order, derived from the +// gui.sidePanels config. A panel's window name is the name of its first tab. +func sideWindowNames(userConfig *config.UserConfig) []string { + return lo.Map(userConfig.Gui.SidePanels, func(panel config.SidePanel, _ int) string { + return panel[0] + }) } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index ee58bbfb8..83416c582 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -581,8 +581,9 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { gui.State = state gui.State.ViewsSetup = false - contextTree := gui.State.Contexts - gui.State.WindowViewNameMap = initialWindowViewNameMap(contextTree) + // The repo we're switching to may have a per-repo config with a different + // side panel layout, so re-apply it to this repo's contexts. + gui.applySidePanelConfig() // setting this to nil so we don't get stuck based on a popup that was // previously opened @@ -622,14 +623,15 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { }, ScreenMode: initialScreenMode, // TODO: only use contexts from context manager - ContextMgr: NewContextMgr(gui, contextTree), - Contexts: contextTree, - WindowViewNameMap: initialWindowViewNameMap(contextTree), - SearchState: types.NewSearchState(), + ContextMgr: NewContextMgr(gui, contextTree), + Contexts: contextTree, + SearchState: types.NewSearchState(), } gui.RepoStateMap[Repo(worktreePath)] = gui.State + gui.applySidePanelConfig() + return initialContext(contextTree, startArgs) } @@ -660,13 +662,19 @@ func (gui *Gui) getViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferM return manager } -func initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] { +func (gui *Gui) initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] { result := utils.NewThreadSafeMap[string, string]() for _, context := range contextTree.Flatten() { result.Set(context.GetWindowName(), context.GetViewName()) } + // A side panel's window shows its first configured tab by default, which is + // not necessarily the context that won the loop above. + for _, panel := range gui.c.UserConfig().Gui.SidePanels { + result.Set(panel[0], sidePanelViewNames[panel[0]]) + } + return result } @@ -836,45 +844,19 @@ func (gui *Gui) initGocui(headless bool, test integrationTypes.IntegrationTest) } func (gui *Gui) viewTabMap() map[string][]context.TabView { - result := map[string][]context.TabView{ - "branches": { - { - Tab: gui.c.Tr.LocalBranchesTitle, - ViewName: "localBranches", - }, - { - Tab: gui.c.Tr.RemotesTitle, - ViewName: "remotes", - }, - { - Tab: gui.c.Tr.TagsTitle, - ViewName: "tags", - }, - }, - "commits": { - { - Tab: gui.c.Tr.CommitsTitle, - ViewName: "commits", - }, - { - Tab: gui.c.Tr.ReflogCommitsTitle, - ViewName: "reflogCommits", - }, - }, - "files": { - { - Tab: gui.c.Tr.FilesTitle, - ViewName: "files", - }, - context.TabView{ - Tab: gui.c.Tr.WorktreesTitle, - ViewName: "worktrees", - }, - { - Tab: gui.c.Tr.SubmodulesTitle, - ViewName: "submodules", - }, - }, + titles := gui.sidePanelTabTitles() + result := map[string][]context.TabView{} + for _, panel := range gui.c.UserConfig().Gui.SidePanels { + if len(panel) < 2 { + // A single-tab panel shows its view's own title, not a tab strip. + continue + } + result[panel[0]] = lo.Map(panel, func(name string, _ int) context.TabView { + return context.TabView{ + Tab: titles[name], + ViewName: sidePanelViewNames[name], + } + }) } return result diff --git a/pkg/gui/side_panels.go b/pkg/gui/side_panels.go new file mode 100644 index 000000000..ee0e70ada --- /dev/null +++ b/pkg/gui/side_panels.go @@ -0,0 +1,90 @@ +package gui + +import ( + "github.com/jesseduffield/lazygit/pkg/gui/context" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +// sidePanelViewNames maps each gui.sidePanels name to the gocui view it controls. +// A panel's window name is the name of its first tab, so for a panel's first tab +// this also gives the default view of its window. The keys must match +// config.ValidSidePanelTabs (enforced by a test). +var sidePanelViewNames = map[string]string{ + "status": "status", + "files": "files", + "worktrees": "worktrees", + "submodules": "submodules", + "branches": "localBranches", + "remotes": "remotes", + "tags": "tags", + "commits": "commits", + "reflog": "reflogCommits", + "stash": "stash", +} + +// sidePanelTabTitles maps each gui.sidePanels name to the title shown on its tab. +func (gui *Gui) sidePanelTabTitles() map[string]string { + tr := gui.c.Tr + return map[string]string{ + "status": tr.StatusTitle, + "files": tr.FilesTitle, + "worktrees": tr.WorktreesTitle, + "submodules": tr.SubmodulesTitle, + "branches": tr.LocalBranchesTitle, + "remotes": tr.RemotesTitle, + "tags": tr.TagsTitle, + "commits": tr.CommitsTitle, + "reflog": tr.ReflogCommitsTitle, + "stash": tr.StashTitle, + } +} + +// sidePanelContexts maps each gui.sidePanels name to the context it controls. +func sidePanelContexts(contextTree *context.ContextTree) map[string]types.Context { + return map[string]types.Context{ + "status": contextTree.Status, + "files": contextTree.Files, + "worktrees": contextTree.Worktrees, + "submodules": contextTree.Submodules, + "branches": contextTree.Branches, + "remotes": contextTree.Remotes, + "tags": contextTree.Tags, + "commits": contextTree.LocalCommits, + "reflog": contextTree.ReflogCommits, + "stash": contextTree.Stash, + } +} + +// applySidePanelConfig (re)assigns each side context's window and resets each +// window's default view from the current gui.sidePanels config. It runs against +// the current repo's contexts, so gui.State must already be set. We call it on +// every repo entry (a repo's per-repo config can differ from the previous one's). +func (gui *Gui) applySidePanelConfig() { + contextTree := gui.State.Contexts + gui.assignSidePanelWindows(contextTree) + gui.State.WindowViewNameMap = gui.initialWindowViewNameMap(contextTree) +} + +// assignSidePanelWindows sets each side context's window name from the config so +// that contexts grouped into one panel share a window (the window name being the +// panel's first tab). Side panels the user hasn't listed get their own window +// name; since the layout produces no dimensions for those windows, their views +// stay hidden rather than overlapping a visible panel. +func (gui *Gui) assignSidePanelWindows(contextTree *context.ContextTree) { + contexts := sidePanelContexts(contextTree) + assigned := make(map[string]bool, len(contexts)) + + for _, panel := range gui.c.UserConfig().Gui.SidePanels { + windowName := panel[0] + for _, name := range panel { + contexts[name].SetWindowName(windowName) + assigned[name] = true + } + } + + for name, ctx := range contexts { + if !assigned[name] { + ctx.SetWindowName(name) + } + } +} diff --git a/pkg/gui/side_panels_test.go b/pkg/gui/side_panels_test.go new file mode 100644 index 000000000..b1240c357 --- /dev/null +++ b/pkg/gui/side_panels_test.go @@ -0,0 +1,30 @@ +package gui + +import ( + "sort" + "testing" + + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +func sortedKeys[V any](m map[string]V) []string { + keys := lo.Keys(m) + sort.Strings(keys) + return keys +} + +// The three lookups that translate gui.sidePanels names into views, titles, and +// contexts must each cover exactly the set of valid names, or a config that uses +// a name missing from one of them would hit a nil lookup at runtime. +func TestSidePanelLookupsCoverAllValidTabs(t *testing.T) { + want := lo.Uniq(config.ValidSidePanelTabs) + sort.Strings(want) + + gui := NewDummyGui() + + assert.Equal(t, want, sortedKeys(sidePanelViewNames)) + assert.Equal(t, want, sortedKeys(gui.sidePanelTabTitles())) + assert.Equal(t, want, sortedKeys(sidePanelContexts(gui.contextTree()))) +} diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 423c0193e..bcd0b166e 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -219,13 +219,12 @@ func (gui *Gui) configureViewProperties() { // The views that make up each side panel, in panel order. The whole group // shares the panel's jump label. - panelViewGroups := [][]*gocui.View{ - {gui.Views.Status}, - {gui.Views.Files, gui.Views.Worktrees, gui.Views.Submodules}, - {gui.Views.Branches, gui.Views.Remotes, gui.Views.Tags}, - {gui.Views.Commits, gui.Views.ReflogCommits}, - {gui.Views.Stash}, - } + panelViewGroups := lo.Map(gui.c.UserConfig().Gui.SidePanels, func(panel config.SidePanel, _ int) []*gocui.View { + return lo.Map(panel, func(name string, _ int) *gocui.View { + view, _ := gui.g.View(sidePanelViewNames[name]) + return view + }) + }) jumpBindings := gui.c.UserConfig().Keybinding.Universal.JumpToBlock jumpLabelForPanel := func(panelIndex int) string { From da8ef6913304c16741eaab73949f9029d97a863e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 17:57:38 +0200 Subject: [PATCH 113/384] Give the submodules and reflog views standalone titles These two views only ever appeared as tabs (of the files and commits panels), so unlike the other side views they had no title set; the tab strip supplied their label. Once a tab can be promoted to its own panel they can appear without a tab strip, so set their titles like the others. This has no effect in the default layout, where both are always tabs. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/views.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/gui/views.go b/pkg/gui/views.go index bcd0b166e..17cbf3316 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -182,10 +182,12 @@ func (gui *Gui) configureViewProperties() { gui.Views.Stash.Title = gui.c.Tr.StashTitle gui.Views.Commits.Title = gui.c.Tr.CommitsTitle + gui.Views.ReflogCommits.Title = gui.c.Tr.ReflogCommitsTitle gui.Views.CommitFiles.Title = gui.c.Tr.CommitFiles gui.Views.Branches.Title = gui.c.Tr.BranchesTitle gui.Views.Remotes.Title = gui.c.Tr.RemotesTitle gui.Views.Worktrees.Title = gui.c.Tr.WorktreesTitle + gui.Views.Submodules.Title = gui.c.Tr.SubmodulesTitle gui.Views.Tags.Title = gui.c.Tr.TagsTitle gui.Views.Files.Title = gui.c.Tr.FilesTitle gui.Views.PatchBuilding.Title = gui.c.Tr.Patch From e396483deb734e82ff51718e2e772003354209bf Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 18:07:40 +0200 Subject: [PATCH 114/384] Scale the minimum window height with the panel count In squashed mode (short terminals) the unfocused side panels each reserve a row and the focused panel takes whatever is left, so once the unfocused panels' rows fill the height the focused panel collapses to nothing and panels below it render off-screen. The fixed floor of 9 was tuned for five panels; with the panel count now configurable (and promotion allowing up to ten), grow the floor by one per panel so we show the "not enough space" view instead of a broken layout. Five panels still floor at 9. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/layout.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index dacd93f68..290d851c7 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -133,7 +133,12 @@ func (gui *Gui) layout(g *gocui.Gui) error { } } - minimumHeight := 9 + // When the screen is too short the side panels are squashed, with the + // unfocused ones taking one row each and the focused one taking the rest. The + // more panels there are, the more rows the unfocused ones reserve, so the + // floor below which there's no room left for the focused panel grows with the + // panel count. Keep the historical floor of 9 for the default five panels. + minimumHeight := max(9, len(gui.helpers.Window.SideWindows())+4) minimumWidth := 10 gui.Views.Limit.Visible = height < minimumHeight || width < minimumWidth From ba0f7e8dbac53afd42e7533c600974740e900f68 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 18:13:54 +0200 Subject: [PATCH 115/384] Show each panel's first configured tab by default Within a window the visible tab is whichever view sits on top in the z-order, and onRepoViewReset establishes that z-order from a fixed list that needn't agree with the configured tab order. After ordering the views, bring each panel's first configured tab to the top so that, for a panel whose tabs have been reordered, the configured first tab is the one shown before the panel is focused. No effect on the default layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/layout.go | 4 ++++ pkg/gui/side_panels.go | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index 290d851c7..6f7dc7187 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -254,6 +254,10 @@ func (gui *Gui) onRepoViewReset() error { } } + // The loop above orders views by a fixed list, which doesn't necessarily put + // each panel's first configured tab on top. + gui.moveDefaultTabsToTop() + return nil } diff --git a/pkg/gui/side_panels.go b/pkg/gui/side_panels.go index ee0e70ada..2b41eb9c1 100644 --- a/pkg/gui/side_panels.go +++ b/pkg/gui/side_panels.go @@ -65,6 +65,17 @@ func (gui *Gui) applySidePanelConfig() { gui.State.WindowViewNameMap = gui.initialWindowViewNameMap(contextTree) } +// moveDefaultTabsToTop brings each panel's first configured tab to the top of +// its window, so the configured default tab is the one shown when a panel hasn't +// been focused yet (the view z-order is otherwise set from a fixed list that +// need not match the configured tab order). +func (gui *Gui) moveDefaultTabsToTop() { + contexts := sidePanelContexts(gui.State.Contexts) + for _, panel := range gui.c.UserConfig().Gui.SidePanels { + gui.helpers.Window.MoveToTopOfWindow(contexts[panel[0]]) + } +} + // assignSidePanelWindows sets each side context's window name from the config so // that contexts grouped into one panel share a window (the window name being the // panel's first tab). Side panels the user hasn't listed get their own window From 196f820af97a57135c1becf2f88557962a7d4d1e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 18:16:22 +0200 Subject: [PATCH 116/384] Add integration tests for configuring the side panels Cover the three things gui.sidePanels enables: reordering the panels (swapping branches and commits, checked via their jump keys), hiding a panel (omitting stash, checked by cycling past the last panel and wrapping to the first), and promoting a tab to its own panel (worktrees becomes a top-level panel reachable by a jump key, and the files panel's remaining tabs cycle straight to submodules). The tests drive focus with explicit jump keys rather than ViewDriver.Focus, which assumes the default panel layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/integration/tests/test_list.go | 3 ++ pkg/integration/tests/ui/hide_side_panel.go | 33 +++++++++++++++ .../tests/ui/promote_tab_to_side_panel.go | 40 +++++++++++++++++++ .../tests/ui/reorder_side_panels.go | 33 +++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 pkg/integration/tests/ui/hide_side_panel.go create mode 100644 pkg/integration/tests/ui/promote_tab_to_side_panel.go create mode 100644 pkg/integration/tests/ui/reorder_side_panels.go diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index fa7b7e26b..3f03f5665 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -474,11 +474,14 @@ var tests = []*components.IntegrationTest{ ui.Accordion, ui.DisableSwitchTabWithPanelJumpKeys, ui.EmptyMenu, + ui.HideSidePanel, ui.KeybindingSuggestionsDontCrashOnDisabledBindings, ui.KeybindingSuggestionsWhenSwitchingRepos, ui.ModeSpecificKeybindingSuggestions, ui.OpenLinkFailure, + ui.PromoteTabToSidePanel, ui.RangeSelect, + ui.ReorderSidePanels, ui.SwitchTabFromMenu, ui.SwitchTabWithPanelJumpKeys, undo.UndoCheckoutAndDrop, diff --git a/pkg/integration/tests/ui/hide_side_panel.go b/pkg/integration/tests/ui/hide_side_panel.go new file mode 100644 index 000000000..95f611daa --- /dev/null +++ b/pkg/integration/tests/ui/hide_side_panel.go @@ -0,0 +1,33 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var HideSidePanel = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Hide a side panel by omitting it from gui.sidePanels", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + // No stash panel. + cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{ + {"status"}, + {"files", "worktrees", "submodules"}, + {"branches", "remotes", "tags"}, + {"commits", "reflog"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(2) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Commits is now the last panel; cycling forward from it wraps around to + // the status panel, skipping the hidden stash panel entirely. + t.Views().Files().IsFocused(). + Press(keys.Universal.JumpToBlock[3]) + t.Views().Commits().IsFocused(). + Press(keys.Universal.NextBlock) + t.Views().Status().IsFocused() + }, +}) diff --git a/pkg/integration/tests/ui/promote_tab_to_side_panel.go b/pkg/integration/tests/ui/promote_tab_to_side_panel.go new file mode 100644 index 000000000..ea0fe82d0 --- /dev/null +++ b/pkg/integration/tests/ui/promote_tab_to_side_panel.go @@ -0,0 +1,40 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var PromoteTabToSidePanel = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Promote the worktrees tab to its own top-level side panel via gui.sidePanels", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + // Worktrees is pulled out of the files panel into its own panel. + cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{ + {"status"}, + {"files", "submodules"}, + {"worktrees"}, + {"branches", "remotes", "tags"}, + {"commits", "reflog"}, + {"stash"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(2) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Worktrees is now its own panel in the third position, reachable by its + // jump key rather than as a tab of the files panel. + t.Views().Files().IsFocused(). + Press(keys.Universal.JumpToBlock[2]) + t.Views().Worktrees().IsFocused(). + Press(keys.Universal.JumpToBlock[1]) + + // The files panel's tabs are now just files and submodules, so cycling + // tabs from files goes straight to submodules. + t.Views().Files().IsFocused(). + Press(keys.Universal.NextTab) + t.Views().Submodules().IsFocused() + }, +}) diff --git a/pkg/integration/tests/ui/reorder_side_panels.go b/pkg/integration/tests/ui/reorder_side_panels.go new file mode 100644 index 000000000..1d1f241f0 --- /dev/null +++ b/pkg/integration/tests/ui/reorder_side_panels.go @@ -0,0 +1,33 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ReorderSidePanels = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Reorder the side panels with gui.sidePanels, swapping the branches and commits panels", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{ + {"status"}, + {"files", "worktrees", "submodules"}, + {"commits", "reflog"}, + {"branches", "remotes", "tags"}, + {"stash"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(2) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // The third panel is now commits and the fourth is branches (the reverse + // of the default order), so their jump keys are swapped. + t.Views().Files().IsFocused(). + Press(keys.Universal.JumpToBlock[2]) + t.Views().Commits().IsFocused(). + Press(keys.Universal.JumpToBlock[3]) + t.Views().Branches().IsFocused() + }, +}) From 922861bb36006a59bff07a31a9723a176a663c54 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 18:54:15 +0200 Subject: [PATCH 117/384] Clear tab strips on views that are no longer tabs The tab-assignment loop only ever set a view's tabs; it never cleared them. That was fine when the groupings were fixed, but with gui.sidePanels a config reload can turn a tab into a standalone panel, and the old tab strip would linger on its title. Index the tab strips by view name and assign to every view, so views that dropped out of a multi-tab panel get their tabs cleared. No change for a given config; this only matters across a reload. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/views.go | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/pkg/gui/views.go b/pkg/gui/views.go index 17cbf3316..b47e76c5a 100644 --- a/pkg/gui/views.go +++ b/pkg/gui/views.go @@ -9,7 +9,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/theme" "github.com/samber/lo" - "golang.org/x/exp/slices" ) type viewNameMapping struct { @@ -249,19 +248,27 @@ func (gui *Gui) configureViewProperties() { gui.Views.Main.TitlePrefix = "" } - for _, view := range gui.g.Views() { - // if the view is in our mapping, we'll set the tabs and the tab index - for _, values := range gui.viewTabMap() { - index := slices.IndexFunc(values, func(tabContext context.TabView) bool { - return tabContext.ViewName == view.Name() - }) - - if index != -1 { - view.Tabs = lo.Map(values, func(tabContext context.TabView, _ int) string { - return tabContext.Tab - }) - view.TabIndex = index - } + // Index the tab strips by view so we can both set them on views that are + // part of a multi-tab panel and clear them on views that no longer are + // (which matters when the config is reloaded and a tab becomes a standalone + // panel). + type viewTabs struct { + tabs []string + index int + } + tabsByView := map[string]viewTabs{} + for _, values := range gui.viewTabMap() { + labels := lo.Map(values, func(tabContext context.TabView, _ int) string { + return tabContext.Tab + }) + for index, tabContext := range values { + tabsByView[tabContext.ViewName] = viewTabs{tabs: labels, index: index} } } + + for _, view := range gui.g.Views() { + vt := tabsByView[view.Name()] + view.Tabs = vt.tabs + view.TabIndex = vt.index + } } From 30559f1058ee6dd494259be7edba8d52c7abacbf Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 15 Jun 2026 11:29:05 +0200 Subject: [PATCH 118/384] Let integration tests post a focus event Lazygit reloads changed config files when its terminal window regains focus, but the test harness had no way to simulate that focus event, so the live config-reload path was untestable. Add a focus event to the replayed-events queue and expose it through the GuiDriver as FocusIn. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 2 ++ pkg/gocui/tcell_driver.go | 18 ++++++++++++++++++ pkg/gui/gui_driver.go | 12 ++++++++++++ pkg/integration/components/test_driver.go | 8 ++++++++ pkg/integration/components/test_test.go | 3 +++ pkg/integration/types/types.go | 3 +++ 6 files changed, 46 insertions(+) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index ad8ba1e41..558b9d619 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -103,6 +103,7 @@ type replayedEvents struct { Keys chan *TcellKeyEventWrapper Resizes chan *TcellResizeEventWrapper MouseEvents chan *TcellMouseEventWrapper + FocusEvents chan *TcellFocusEventWrapper } type RecordingConfig struct { @@ -245,6 +246,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { Keys: make(chan *TcellKeyEventWrapper), Resizes: make(chan *TcellResizeEventWrapper), MouseEvents: make(chan *TcellMouseEventWrapper), + FocusEvents: make(chan *TcellFocusEventWrapper), } } diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index b2fd40c19..226ee0580 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -266,6 +266,22 @@ func (wrapper TcellResizeEventWrapper) toTcellEvent() tcell.Event { return tcell.NewEventResize(wrapper.Width, wrapper.Height) } +type TcellFocusEventWrapper struct { + Timestamp int64 + Focused bool +} + +func NewTcellFocusEventWrapper(event *tcell.EventFocus, timestamp int64) *TcellFocusEventWrapper { + return &TcellFocusEventWrapper{ + Timestamp: timestamp, + Focused: event.Focused, + } +} + +func (wrapper TcellFocusEventWrapper) toTcellEvent() tcell.Event { + return tcell.NewEventFocus(wrapper.Focused) +} + // pollEvent get tcell.Event and transform it into gocuiEvent func (g *Gui) pollEvent() GocuiEvent { var tev tcell.Event @@ -277,6 +293,8 @@ func (g *Gui) pollEvent() GocuiEvent { tev = (ev).toTcellEvent() case ev := <-g.ReplayedEvents.MouseEvents: tev = (ev).toTcellEvent() + case ev := <-g.ReplayedEvents.FocusEvents: + tev = (ev).toTcellEvent() } } else { tev = <-Screen.EventQ() diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 08f3ecf62..632e271c3 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -56,6 +56,18 @@ func (self *GuiDriver) Click(x, y int) { 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.ReplayedEvents.FocusEvents <- gocui.NewTcellFocusEventWrapper( + tcell.NewEventFocus(true), + 0, + ) + + self.waitTillIdle() +} + // wait until lazygit is idle (i.e. all processing is done) before continuing func (self *GuiDriver) waitTillIdle() { <-self.isIdleChan diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 8294f3b46..301ab3862 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -56,6 +56,14 @@ func (self *TestDriver) GlobalPress(key config.Keybinding) { self.press(key[0]) } +// FocusIn simulates the terminal window regaining focus, which causes lazygit +// to reload any config files that changed while it was in the background. +func (self *TestDriver) FocusIn() { + self.SetCaption("Focusing window") + self.gui.FocusIn() + self.Wait(self.inputDelay) +} + func (self *TestDriver) typeContent(content string) { for _, char := range content { self.pressFast(string(char)) diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index ab32f9f89..b00a2a672 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -34,6 +34,9 @@ func (self *fakeGuiDriver) Click(x, y int) { self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) } +func (self *fakeGuiDriver) FocusIn() { +} + func (self *fakeGuiDriver) Keys() config.KeybindingConfig { return config.KeybindingConfig{} } diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 752639058..3d87e7d6e 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -24,6 +24,9 @@ type IntegrationTest interface { type GuiDriver interface { PressKey(string) Click(int, int) + // Simulate the terminal window regaining focus (which triggers a reload of + // changed config files) + FocusIn() Keys() config.KeybindingConfig CurrentContext() types.Context ContextForView(viewName string) types.Context From 2614156b2f289a57d14fd26189ffe7a19387668b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 15 Jun 2026 11:32:24 +0200 Subject: [PATCH 119/384] Add an IsActiveTab assertion for integration tests Side panel tabs share a window, so which tab is shown is decided by view z-order rather than the visibility flag (every tab in a window is 'visible'). Tests had no way to assert which tab is actually drawn in front, which is distinct from which view has keyboard focus. Expose the window's top view and add an IsActiveTab assertion built on it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/gui_driver.go | 6 ++++++ pkg/integration/components/test_test.go | 4 ++++ pkg/integration/components/view_driver.go | 22 ++++++++++++++++++++++ pkg/integration/types/types.go | 2 ++ 4 files changed, 34 insertions(+) diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 632e271c3..57425231a 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -153,6 +153,12 @@ func (self *GuiDriver) View(viewName string) *gocui.View { return view } +// TopViewInWindow returns the frontmost visible view in the given window, i.e. +// the tab that is currently shown when a window holds several tabbed views. +func (self *GuiDriver) TopViewInWindow(windowName string) *gocui.View { + return self.gui.helpers.Window.TopViewInWindow(windowName, false) +} + func (self *GuiDriver) SetCaption(caption string) { self.gui.setCaption(caption) self.waitTillIdle() diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index b00a2a672..e7d03ada9 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -75,6 +75,10 @@ func (self *fakeGuiDriver) View(viewName string) *gocui.View { return nil } +func (self *fakeGuiDriver) TopViewInWindow(windowName string) *gocui.View { + return nil +} + func (self *fakeGuiDriver) SetCaption(string) { } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index e9e5fbbc7..df4b9d7d8 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -408,6 +408,28 @@ func (self *ViewDriver) IsFocused() *ViewDriver { return self } +// asserts that the view is the one currently shown in its window, i.e. it's the +// active tab of its panel (drawn in front of the window's other tabs). Unlike +// IsFocused, this is about what's displayed rather than which view has keyboard +// focus; the two can disagree, e.g. if a config reload reshuffles the tabs. +func (self *ViewDriver) IsActiveTab() *ViewDriver { + self.t.assertWithRetries(func() (bool, string) { + expected := self.getView().Name() + context := self.t.gui.ContextForView(expected) + if context == nil { + return false, fmt.Sprintf("%s: Could not find context for view, so can't determine its window", expected) + } + topView := self.t.gui.TopViewInWindow(context.GetWindowName()) + actual := "" + if topView != nil { + actual = topView.Name() + } + return actual == expected, fmt.Sprintf("%s: Expected view to be the active tab of its window, but it was %s", expected, actual) + }) + + return self +} + func (self *ViewDriver) Press(key config.Keybinding) *ViewDriver { self.IsFocused() diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 3d87e7d6e..cd6102cdf 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -44,6 +44,8 @@ type GuiDriver interface { // e.g. when we're showing both staged and unstaged changes SecondaryView() *gocui.View View(viewName string) *gocui.View + // the frontmost visible view in the given window, i.e. the currently shown tab + TopViewInWindow(windowName string) *gocui.View SetCaption(caption string) SetCaptionPrefix(prefix string) // Pop the next toast that was displayed; returns nil if there was none From 9aaff61b79fbc0186aa06cef18d458134321cf51 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 15 Jun 2026 09:03:44 +0200 Subject: [PATCH 120/384] Re-apply the side panel config on a live config reload When the config file changes and lazygit regains focus it reloads the config, but the side panel window assignments, default views, tab strips, and z-order were only ever set up on repo entry, so a changed sidePanels wouldn't take effect until restart. Re-apply it from the reload path: reassign windows and default views and restore each panel's default tab. The focused panel needs care: resetting it to its default tab would leave the focused tab hidden behind that default tab, so the panel looks unfocused even though its tab is selected. Re-focus the current context so its tab stays shown and highlighted; only when the new config hides the focused panel entirely do we move focus to the default side panel. Tab strips are already refreshed via configureViewProperties. --- pkg/gui/gui.go | 1 + pkg/gui/side_panels.go | 29 ++++++++++- pkg/integration/tests/test_list.go | 1 + .../tests/ui/reload_side_panels.go | 51 +++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/ui/reload_side_panels.go diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 83416c582..dfc71d642 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -357,6 +357,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context if didChange && reloadErr == nil { gui.c.Log.Info("User config changed - reloading") reloadErr = gui.onUserConfigLoaded() + gui.reloadSidePanels() if err := gui.resetKeybindings(); err != nil { return err } diff --git a/pkg/gui/side_panels.go b/pkg/gui/side_panels.go index 2b41eb9c1..361d54fb1 100644 --- a/pkg/gui/side_panels.go +++ b/pkg/gui/side_panels.go @@ -3,6 +3,7 @@ package gui import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" ) // sidePanelViewNames maps each gui.sidePanels name to the gocui view it controls. @@ -58,7 +59,8 @@ func sidePanelContexts(contextTree *context.ContextTree) map[string]types.Contex // applySidePanelConfig (re)assigns each side context's window and resets each // window's default view from the current gui.sidePanels config. It runs against // the current repo's contexts, so gui.State must already be set. We call it on -// every repo entry (a repo's per-repo config can differ from the previous one's). +// every repo entry (a repo's per-repo config can differ from the previous one's) +// and on a live config reload. func (gui *Gui) applySidePanelConfig() { contextTree := gui.State.Contexts gui.assignSidePanelWindows(contextTree) @@ -76,6 +78,31 @@ func (gui *Gui) moveDefaultTabsToTop() { } } +// reloadSidePanels re-applies the side panel config to the current repo after a +// live config reload: it reassigns windows and default views, restores each +// panel's default tab, and keeps the focused panel in a consistent state. +func (gui *Gui) reloadSidePanels() { + gui.applySidePanelConfig() + gui.moveDefaultTabsToTop() + + // applySidePanelConfig reset every window to show its first configured tab, + // which would leave the focused tab hidden behind its panel's default tab + // (the panel would look unfocused even though its tab is selected). Re-focus + // the current context so its tab stays shown and highlighted. If the new + // config has hidden the focused panel entirely, move focus to the default + // side panel instead. + current := gui.c.Context().Current() + if current.GetKind() != types.SIDE_CONTEXT { + return + } + + if lo.Contains(gui.helpers.Window.SideWindows(), current.GetWindowName()) { + gui.c.Context().Activate(current, types.OnFocusOpts{}) + } else { + gui.c.Context().Push(gui.defaultSideContext(), types.OnFocusOpts{}) + } +} + // assignSidePanelWindows sets each side context's window name from the config so // that contexts grouped into one panel share a window (the window name being the // panel's first tab). Side panels the user hasn't listed get their own window diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 3f03f5665..5565ef892 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -481,6 +481,7 @@ var tests = []*components.IntegrationTest{ ui.OpenLinkFailure, ui.PromoteTabToSidePanel, ui.RangeSelect, + ui.ReloadSidePanels, ui.ReorderSidePanels, ui.SwitchTabFromMenu, ui.SwitchTabWithPanelJumpKeys, diff --git a/pkg/integration/tests/ui/reload_side_panels.go b/pkg/integration/tests/ui/reload_side_panels.go new file mode 100644 index 000000000..9d8b77693 --- /dev/null +++ b/pkg/integration/tests/ui/reload_side_panels.go @@ -0,0 +1,51 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ReloadSidePanels = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Editing the side panel config and refocusing the window re-applies the layout live, keeping the focused panel focused", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(2) + // Start with worktrees promoted to its own panel. + shell.CreateFile(".git/lazygit.yml", ` +gui: + sidePanels: + - [status] + - [files, submodules] + - [worktrees] + - [branches, remotes, tags] + - [commits, reflog] + - [stash]`) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // Worktrees is its own panel in the third position. + t.Views().Files().IsFocused(). + Press(keys.Universal.JumpToBlock[2]) + t.Views().Worktrees().IsFocused() + + // Demote worktrees back into the files panel, then refocus the window to + // trigger a live reload of the changed config. + t.Shell().UpdateFile(".git/lazygit.yml", ` +gui: + sidePanels: + - [status] + - [files, worktrees, submodules] + - [branches, remotes, tags] + - [commits, reflog] + - [stash]`) + t.FocusIn() + + // Worktrees is now a tab of the files panel. It stays focused, and is shown + // in front rather than being hidden behind the files tab (which would leave + // the panel looking unfocused). + t.Views().Worktrees().IsActiveTab().IsFocused(). + Press(keys.Universal.PrevTab) + t.Views().Files().IsActiveTab().IsFocused() + }, +}) From c6b8220772dea2f9a470821166f84c05076e19f8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 14 Jun 2026 19:02:39 +0200 Subject: [PATCH 121/384] Test per-repo side panel config and re-application on repo switch Exercises the path the live reload relies on: a per-repo lazygit.yml sets a different side panel layout, and switching between repos re-applies each one's own layout (the new-repo path for the cloned repo, the cached-repo path on switching back). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../config/side_panels_in_per_repo_config.go | 58 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 59 insertions(+) create mode 100644 pkg/integration/tests/config/side_panels_in_per_repo_config.go diff --git a/pkg/integration/tests/config/side_panels_in_per_repo_config.go b/pkg/integration/tests/config/side_panels_in_per_repo_config.go new file mode 100644 index 000000000..ad2f63a1a --- /dev/null +++ b/pkg/integration/tests/config/side_panels_in_per_repo_config.go @@ -0,0 +1,58 @@ +package config + +import ( + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SidePanelsInPerRepoConfig = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A per-repo config can set the side panel layout, and switching repos re-applies each repo's own layout", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + otherRepo, _ := filepath.Abs("../other") + cfg.GetAppState().RecentRepos = []string{otherRepo} + }, + SetupRepo: func(shell *Shell) { + shell.CloneNonBare("other") + // The other repo swaps the branches and commits panels. + shell.CreateFile("../other/.git/lazygit.yml", ` +gui: + sidePanels: + - [status] + - [files, worktrees, submodules] + - [commits, reflog] + - [branches, remotes, tags] + - [stash]`) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // This repo uses the default layout, so the third panel is branches. + t.GlobalPress(keys.Universal.JumpToBlock[2]) + t.Views().Branches().IsFocused() + + // Switch to the other repo, whose per-repo config swaps branches and commits. + t.GlobalPress(keys.Universal.OpenRecentRepos) + t.ExpectPopup().Menu().Title(Equals("Recent repositories")). + Lines( + Contains("other").IsSelected(), + Contains("Cancel"), + ).Confirm() + t.Views().Status().Content(Contains("other → master")) + + // Now the third panel is commits. + t.GlobalPress(keys.Universal.JumpToBlock[2]) + t.Views().Commits().IsFocused() + + // Switch back to the first repo; its default layout is intact even though + // its contexts were built before we visited the other repo. + t.GlobalPress(keys.Universal.JumpToBlock[1]) + t.Views().Files().IsFocused() + t.GlobalPress(keys.Universal.OpenRecentRepos) + t.ExpectPopup().Menu().Title(Equals("Recent repositories")).Confirm() + + t.GlobalPress(keys.Universal.JumpToBlock[2]) + t.Views().Branches().IsFocused() + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 5565ef892..3cadf6843 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -162,6 +162,7 @@ var tests = []*components.IntegrationTest{ config.CustomCommandsInPerRepoConfig, config.NegativeRefspec, config.RemoteNamedStar, + config.SidePanelsInPerRepoConfig, conflicts.Filter, conflicts.MergeFileBoth, conflicts.MergeFileCurrent, From 9b1acce0fef9007b05e8cbff90049e05747b28ba Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 24 Jun 2026 21:44:14 +0200 Subject: [PATCH 122/384] Remove the "Open config file" command OpenFile (`o`) is for opening a file as if it was double-clicked in Finder/Explorer; this is useful for binary files like PNGs, but never for text files. You want to edit them, and there's `e` for that. --- docs-master/keybindings/Keybindings_en.md | 1 - docs-master/keybindings/Keybindings_ja.md | 1 - docs-master/keybindings/Keybindings_ko.md | 1 - docs-master/keybindings/Keybindings_nl.md | 1 - docs-master/keybindings/Keybindings_pl.md | 1 - docs-master/keybindings/Keybindings_pt.md | 1 - docs-master/keybindings/Keybindings_ru.md | 1 - docs-master/keybindings/Keybindings_zh-CN.md | 1 - docs-master/keybindings/Keybindings_zh-TW.md | 1 - pkg/gui/controllers/status_controller.go | 10 ---------- pkg/i18n/english.go | 2 -- 11 files changed, 21 deletions(-) diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index 07d4d95a4..ed4304a4b 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -348,7 +348,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` o `` | Open config file | Open file in default application. | | `` e `` | Edit config file | Open file in external editor. | | `` u `` | Check for update | | | `` `` | Switch to a recent repo | | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 5bf6797bd..adf2f1b38 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -179,7 +179,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` o `` | 設定ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 | | `` e `` | 設定ファイルを編集 | 外部エディタでファイルを開きます。 | | `` u `` | 更新を確認 | | | `` `` | 最近のリポジトリをチェックアウト | | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index e80515daa..1881876ef 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -237,7 +237,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` o `` | 설정 파일 열기 | Open file in default application. | | `` e `` | 설정 파일 수정 | Open file in external editor. | | `` u `` | 업데이트 확인 | | | `` `` | 최근에 사용한 저장소로 전환 | | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 76764eda5..f3dc4eb4a 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -348,7 +348,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` o `` | Open config bestand | Open file in default application. | | `` e `` | Verander config bestand | Open file in external editor. | | `` u `` | Check voor updates | | | `` `` | Wissel naar een recente repo | | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index aa510a813..110aa17be 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -327,7 +327,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` o `` | Otwórz plik konfiguracyjny | Otwórz plik w domyślnej aplikacji. | | `` e `` | Edytuj plik konfiguracyjny | Otwórz plik w zewnętrznym edytorze. | | `` u `` | Sprawdź aktualizacje | | | `` `` | Przełącz na ostatnie repozytorium | | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index efe0d24ed..e69061b30 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -357,7 +357,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` o `` | Abrir o ficheiro de config | Abrir arquivo no aplicativo padrão. | | `` e `` | Editar arquivo de configuração | Abrir arquivo no editor externo. | | `` u `` | Verificar atualização | | | `` `` | Mudar para um repositório recente | | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 1f952ed6b..70c63c4b5 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -314,7 +314,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` o `` | Открыть файл конфигурации | Open file in default application. | | `` e `` | Редактировать файл конфигурации | Open file in external editor. | | `` u `` | Проверить обновления | | | `` `` | Переключиться на последний репозиторий | | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index e1dbbe9c6..22fedf9e2 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -340,7 +340,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` o `` | 打开配置文件 | 使用默认程序打开该文件 | | `` e `` | 编辑配置文件 | 使用外部编辑器打开文件 | | `` u `` | 检查更新 | | | `` `` | 切换到最近的仓库 | | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index bf13db65d..1d9076b60 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -369,7 +369,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` o `` | 開啟設定檔案 | 使用預設軟體開啟 | | `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 | | `` u `` | 檢查更新 | | | `` `` | 切換到最近使用的版本庫 | | diff --git a/pkg/gui/controllers/status_controller.go b/pkg/gui/controllers/status_controller.go index f29ee97f0..cc2a725bd 100644 --- a/pkg/gui/controllers/status_controller.go +++ b/pkg/gui/controllers/status_controller.go @@ -33,12 +33,6 @@ func NewStatusController( func (self *StatusController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { bindings := []*types.Binding{ - { - Keys: opts.GetKeys(opts.Config.Universal.OpenFile), - Handler: self.openConfig, - Description: self.c.Tr.OpenConfig, - Tooltip: self.c.Tr.OpenFileTooltip, - }, { Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: self.editConfig, @@ -172,10 +166,6 @@ func (self *StatusController) askForConfigFile(action func(file string) error) e } } -func (self *StatusController) openConfig() error { - return self.askForConfigFile(self.c.Helpers().Files.OpenFile) -} - func (self *StatusController) editConfig() error { return self.askForConfigFile(func(file string) error { return self.c.Helpers().Files.EditFiles([]string{file}) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 20d0d5ff6..8dd40016a 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -231,7 +231,6 @@ type TranslationSet struct { StashChanges string RenameStash string RenameStashPrompt string - OpenConfig string EditConfig string ForcePush string ForcePushPrompt string @@ -1357,7 +1356,6 @@ func EnglishTranslationSet() *TranslationSet { StashChanges: "Stash changes", RenameStash: "Rename stash", RenameStashPrompt: "Rename stash: {{.stashName}}", - OpenConfig: "Open config file", EditConfig: "Edit config file", ForcePush: "Force push", ForcePushPrompt: "Your branch has diverged from the remote branch. Press {{.cancelKey}} to cancel, or {{.confirmKey}} to force push.", From a8834930a0da182c8348843cd55c7957ac9f5bfd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 24 Jun 2026 21:50:21 +0200 Subject: [PATCH 123/384] Remove unnecessary askForConfigFile indirection --- pkg/gui/controllers/status_controller.go | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/pkg/gui/controllers/status_controller.go b/pkg/gui/controllers/status_controller.go index cc2a725bd..e96d371ea 100644 --- a/pkg/gui/controllers/status_controller.go +++ b/pkg/gui/controllers/status_controller.go @@ -142,19 +142,19 @@ func lazygitTitle() string { |___/ |___/ ` } -func (self *StatusController) askForConfigFile(action func(file string) error) error { +func (self *StatusController) editConfig() error { confPaths := self.c.GetConfig().GetUserConfigPaths() switch len(confPaths) { case 0: return errors.New(self.c.Tr.NoConfigFileFoundErr) case 1: - return action(confPaths[0]) + return self.c.Helpers().Files.EditFiles(confPaths) default: menuItems := lo.Map(confPaths, func(path string, _ int) *types.MenuItem { return &types.MenuItem{ Label: path, OnPress: func() error { - return action(path) + return self.c.Helpers().Files.EditFiles([]string{path}) }, } }) @@ -166,12 +166,6 @@ func (self *StatusController) askForConfigFile(action func(file string) error) e } } -func (self *StatusController) editConfig() error { - return self.askForConfigFile(func(file string) error { - return self.c.Helpers().Files.EditFiles([]string{file}) - }) -} - func (self *StatusController) showAllBranchLogs() { cmdObj := self.c.Git().Branch.AllBranchesLogCmdObj() task := types.NewRunPtyTask(cmdObj.GetCmd()) From f3ea0ab90256f23a3238fafc36f6ea7de8bd9c2e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 24 Jun 2026 22:01:23 +0200 Subject: [PATCH 124/384] Extract editConfig into a shared EditConfigAction The "edit config file" command is about to gain a second, global keybinding alongside the existing status-panel one. Moving its body into an action struct (the convention GlobalController already follows for all its handlers) lets both controllers delegate to one implementation. --- pkg/gui/controllers/edit_config_action.go | 36 +++++++++++++++++++++++ pkg/gui/controllers/status_controller.go | 24 +-------------- 2 files changed, 37 insertions(+), 23 deletions(-) create mode 100644 pkg/gui/controllers/edit_config_action.go diff --git a/pkg/gui/controllers/edit_config_action.go b/pkg/gui/controllers/edit_config_action.go new file mode 100644 index 000000000..b3a863035 --- /dev/null +++ b/pkg/gui/controllers/edit_config_action.go @@ -0,0 +1,36 @@ +package controllers + +import ( + "errors" + + "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" +) + +type EditConfigAction struct { + c *ControllerCommon +} + +func (self *EditConfigAction) Call() error { + confPaths := self.c.GetConfig().GetUserConfigPaths() + switch len(confPaths) { + case 0: + return errors.New(self.c.Tr.NoConfigFileFoundErr) + case 1: + return self.c.Helpers().Files.EditFiles(confPaths) + default: + menuItems := lo.Map(confPaths, func(path string, _ int) *types.MenuItem { + return &types.MenuItem{ + Label: path, + OnPress: func() error { + return self.c.Helpers().Files.EditFiles([]string{path}) + }, + } + }) + + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.SelectConfigFile, + Items: menuItems, + }) + } +} diff --git a/pkg/gui/controllers/status_controller.go b/pkg/gui/controllers/status_controller.go index e96d371ea..5a740a23a 100644 --- a/pkg/gui/controllers/status_controller.go +++ b/pkg/gui/controllers/status_controller.go @@ -1,7 +1,6 @@ package controllers import ( - "errors" "fmt" "strings" "time" @@ -12,7 +11,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" - "github.com/samber/lo" ) type StatusController struct { @@ -143,27 +141,7 @@ func lazygitTitle() string { } func (self *StatusController) editConfig() error { - confPaths := self.c.GetConfig().GetUserConfigPaths() - switch len(confPaths) { - case 0: - return errors.New(self.c.Tr.NoConfigFileFoundErr) - case 1: - return self.c.Helpers().Files.EditFiles(confPaths) - default: - menuItems := lo.Map(confPaths, func(path string, _ int) *types.MenuItem { - return &types.MenuItem{ - Label: path, - OnPress: func() error { - return self.c.Helpers().Files.EditFiles([]string{path}) - }, - } - }) - - return self.c.Menu(types.CreateMenuOptions{ - Title: self.c.Tr.SelectConfigFile, - Items: menuItems, - }) - } + return (&EditConfigAction{c: self.c}).Call() } func (self *StatusController) showAllBranchLogs() { From 348224a96ea48f8f3bd6e2dd8f6959598d68d9fc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 24 Jun 2026 22:01:30 +0200 Subject: [PATCH 125/384] Add a global keybinding for editing the config file The status panel already binds the universal edit key to "edit config file", but that's only reachable while the status panel is focused. Add a dedicated global binding (alt+shift+c) so the config file can be opened from anywhere. --- docs-master/Config.md | 1 + docs-master/keybindings/Keybindings_en.md | 1 + docs-master/keybindings/Keybindings_ja.md | 1 + docs-master/keybindings/Keybindings_ko.md | 1 + docs-master/keybindings/Keybindings_nl.md | 1 + docs-master/keybindings/Keybindings_pl.md | 1 + docs-master/keybindings/Keybindings_pt.md | 1 + docs-master/keybindings/Keybindings_ru.md | 1 + docs-master/keybindings/Keybindings_zh-CN.md | 1 + docs-master/keybindings/Keybindings_zh-TW.md | 1 + pkg/config/user_config.go | 2 ++ pkg/gui/controllers/global_controller.go | 10 ++++++++++ schema-master/config.json | 14 ++++++++++++++ 13 files changed, 36 insertions(+) diff --git a/docs-master/Config.md b/docs-master/Config.md index 0a4d9f3fa..5e57df34a 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -715,6 +715,7 @@ keybinding: increaseRenameSimilarityThreshold: ) decreaseRenameSimilarityThreshold: ( openDiffTool: + editConfig: status: checkForUpdate: u recentRepos: diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index ed4304a4b..ba9b45c4a 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | Quit | | | `` `` | Suspend the application | | | `` `` | 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'. | +| `` `` | Edit config file | Open file in external editor. | | `` z `` | Undo | 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 | 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. | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index adf2f1b38..77283ffd8 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | 終了 | | | `` `` | Suspend the application | | | `` `` | 空白表示の切り替え | 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'. | +| `` `` | 設定ファイルを編集 | 外部エディタでファイルを開きます。 | | `` z `` | 元に戻す | 最後のgitコマンドを元に戻すために実行するgitコマンドを決定するためにreflogが使用されます。これにはワーキングツリーへの変更は含まれません。コミットのみが考慮されます。 | | `` Z `` | やり直す | 最後のgitコマンドをやり直すために実行するgitコマンドを決定するためにreflogが使用されます。これにはワーキングツリーへの変更は含まれません。コミットのみが考慮されます。 | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 1881876ef..4463c612a 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | 종료 | | | `` `` | Suspend the application | | | `` `` | 공백문자를 Diff 뷰에서 표시 여부 전환 | 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'. | +| `` `` | 설정 파일 수정 | Open file in external editor. | | `` z `` | 되돌리기 (reflog) (실험적) | 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 `` | 다시 실행 (reflog) (실험적) | 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. | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index f3dc4eb4a..16a4856a4 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | Quit | | | `` `` | Suspend the application | | | `` `` | 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'. | +| `` `` | Verander config bestand | Open file in external 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. | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 110aa17be..06f7af859 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | Wyjdź | | | `` `` | Suspend the application | | | `` `` | Przełącz białe znaki | 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'. | +| `` `` | Edytuj plik konfiguracyjny | Otwórz plik w zewnętrznym edytorze. | | `` z `` | Cofnij | Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby cofnąć ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity. | | `` Z `` | Ponów | Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby ponowić ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity. | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index e69061b30..2a9e6497c 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | Sair | | | `` `` | Suspender a aplicação | | | `` `` | 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'. | +| `` `` | Editar arquivo de configuração | Abrir arquivo no editor externo. | | `` z `` | Desfazer | O reflog será usado para determinar qual comando git para executar para desfazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração. | | `` Z `` | Refazer | O reflog será usado para determinar qual comando git para executar para refazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração. | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 70c63c4b5..bbd355c23 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | Выйти | | | `` `` | Suspend the application | | | `` `` | Переключить отображение изменении пробелов в просмотрщике сравнении | 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'. | +| `` `` | Редактировать файл конфигурации | Open file in external editor. | | `` z `` | Отменить (через reflog) (экспериментальный) | Журнал ссылок (reflog) будет использоваться для определения того, какую команду git запустить, чтобы отменить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты. | | `` Z `` | Повторить (через reflog) (экспериментальный) | Журнал ссылок (reflog) будет использоваться для определения того, какую команду git нужно запустить, чтобы повторить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты. | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 22fedf9e2..57d445d5b 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | 退出 | | | `` `` | 挂起应用程序 | | | `` `` | 切换是否在差异视图中显示空白字符差异 | 切换是否在差异视图中显示空白字符更改。

默认值可在配置文件中通过键 'git.ignoreWhitespaceInDiffView' 更改。 | +| `` `` | 编辑配置文件 | 使用外部编辑器打开文件 | | `` z `` | 撤销 | Reflog将用于确定运行哪个git命令来撤消最后一个git命令。这并不包括对工作树的更改,只考虑提交。 | | `` Z `` | 重做 | Reflog将用于确定运行哪个git命令来重做上一个git命令。这并不包括对工作树的更改,只考虑提交。 | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 1d9076b60..a693281f1 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -31,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | 結束 | | | `` `` | Suspend the application | | | `` `` | 切換是否在差異檢視中顯示空格變更 | 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'. | +| `` `` | 編輯設定檔案 | 使用外部編輯器開啟 | | `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 | | `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 | diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index bb7b5f424..1b582c1a0 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -542,6 +542,7 @@ type KeybindingUniversalConfig struct { IncreaseRenameSimilarityThreshold Keybinding `yaml:"increaseRenameSimilarityThreshold"` DecreaseRenameSimilarityThreshold Keybinding `yaml:"decreaseRenameSimilarityThreshold"` OpenDiffTool Keybinding `yaml:"openDiffTool"` + EditConfig Keybinding `yaml:"editConfig"` } type KeybindingStatusConfig struct { @@ -1058,6 +1059,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { IncreaseRenameSimilarityThreshold: Keybinding{")"}, DecreaseRenameSimilarityThreshold: Keybinding{"("}, OpenDiffTool: Keybinding{""}, + EditConfig: Keybinding{""}, }, Status: KeybindingStatusConfig{ CheckForUpdate: Keybinding{"u"}, diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index fdb2e3153..8b9871294 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -136,6 +136,12 @@ func (self *GlobalController) GetKeybindings(opts types.KeybindingsOpts) []*type Description: self.c.Tr.ToggleWhitespaceInDiffView, Tooltip: self.c.Tr.ToggleWhitespaceInDiffViewTooltip, }, + { + Keys: opts.GetKeys(opts.Config.Universal.EditConfig), + Handler: self.editConfig, + Description: self.c.Tr.EditConfig, + Tooltip: self.c.Tr.EditFileTooltip, + }, } } @@ -267,6 +273,10 @@ func (self *GlobalController) toggleWhitespace() error { return (&ToggleWhitespaceAction{c: self.c}).Call() } +func (self *GlobalController) editConfig() error { + return (&EditConfigAction{c: self.c}).Call() +} + func (self *GlobalController) canShowRebaseOptions() *types.DisabledReason { if self.c.Model().WorkingTreeStateAtLastCommitRefresh.None() { return &types.DisabledReason{ diff --git a/schema-master/config.json b/schema-master/config.json index 6e9a825ad..85246d640 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -3388,6 +3388,20 @@ } ], "default": "\u003cctrl+t\u003e" + }, + "editConfig": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\u003calt+shift+c\u003e" } }, "additionalProperties": false, From de6b6ef906d249b064101b4ec4d83ae937c258ff Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 15:47:09 +0200 Subject: [PATCH 126/384] Addition to AGENTS.md --- AGENTS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c379b3030..50b808fb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -252,6 +252,30 @@ keep the call site fluent. Prefer `assert.Equal` (and friends) over hand-rolled `if` checks. The failure messages are more useful and the intent is clearer at a glance. +## Translatable strings use Go templates, not `%s` + +Never put `fmt.Sprintf`-style placeholders (`%s`, `%d`, …) in translatable +strings — the fields of `TranslationSet` and `Actions` in +`pkg/i18n/english.go`. Use named Go-template placeholders and fill them in with +`utils.ResolvePlaceholderString`: + +```go +// in english.go +DeleteBranchTitle: "Delete branch '{{.selectedBranchName}}'?", + +// at the call site +utils.ResolvePlaceholderString( + self.c.Tr.DeleteBranchTitle, + map[string]string{"selectedBranchName": branchName}, +) +``` + +Named placeholders tell localizers what each value is (a bare `%s` says +nothing, and translators can't safely reorder positional verbs across +languages), and the map form extends cleanly when a string later needs more +than one placeholder. This holds for every user-facing string, including short +ones like disabled-action reasons and toasts. + ## Code comments are for future readers, not development history Comments in source code explain *why this code is shaped the way it is*. They From 41d92aac3622aca5b4903c83b231daa1e31dcaa3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 13:31:42 +0200 Subject: [PATCH 127/384] Extract a predicate for conflicts that need a resolution dialog Some merge conflicts can't be resolved by editing markers in the merge view; they require a dialog that picks one side (the "non-textual" conflicts like DD/AU/UA/UD/DU). Both `enter` and, soon, `space` need to recognize these, so pull the test into a shared predicate and rename handleNonInlineConflict to openConflictResolutionMenu to match. Restructure EnterFile so the predicate is checked first, ahead of the submodule and inline-conflict branches. This is its final shape: upcoming commits only add the submodule case to the predicate, with no further reordering. Behavior is unchanged here, since the predicate is currently false for submodules. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/files_controller.go | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index d048da508..9ac14f3e5 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -683,6 +683,10 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { file := node.File + if self.conflictNeedsResolutionDialog(file) { + return self.openConflictResolutionMenu(file) + } + submoduleConfigs := self.c.Model().Submodules if file.IsSubmodule(submoduleConfigs) { submoduleConfig := file.SubmoduleConfig(submoduleConfigs) @@ -692,9 +696,6 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { if file.HasInlineMergeConflicts { return self.switchToMerge() } - if file.HasMergeConflicts { - return self.handleNonInlineConflict(file) - } context := lo.Ternary(opts.ClickedWindowName == "secondary", self.c.Contexts().StagingSecondary, self.c.Contexts().Staging) self.c.Context().Push(context, opts) @@ -703,7 +704,19 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { return nil } -func (self *FilesController) handleNonInlineConflict(file *models.File) error { +// conflictNeedsResolutionDialog reports whether a file's merge conflict can only +// be resolved through a dialog that picks one side, as opposed to editing +// conflict markers in the merge view. These are the "non-textual" conflicts, +// e.g. one side modified a file while the other deleted it (DD/AU/UA/UD/DU). +func (self *FilesController) conflictNeedsResolutionDialog(file *models.File) bool { + if file == nil || !file.HasMergeConflicts { + return false + } + + return !file.HasInlineMergeConflicts +} + +func (self *FilesController) openConflictResolutionMenu(file *models.File) error { handle := func(command func(command string) error, logText string) error { self.c.LogAction(logText) if err := command(file.GetPath()); err != nil { From 860f89e0c9fd7940262c56f7294f4f0d4865c142 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 13:38:44 +0200 Subject: [PATCH 128/384] Route space to the conflict picker for non-textual conflicts For a non-textual conflict (e.g. DD/AU/UA/UD/DU), pressing space used to run the normal stage path, which did something unclear: `git add` happens to resolve the conflict by keeping the file, but that's neither obvious nor symmetric. Route a single such file to the same Keep/Delete picker that enter opens, so space and enter agree. For a range selection that includes one of these conflicts, staging makes no sense, so disable it with a toast that points the user at resolving them one at a time. (Entering a range was already disabled with the standard toast.) Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/files_controller.go | 29 +++++++++- pkg/i18n/english.go | 2 + .../space_on_non_textual_conflict.go | 56 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/conflicts/space_on_non_textual_conflict.go diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 9ac14f3e5..c9034ec9c 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -44,7 +44,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types { Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withItems(self.press), - GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected())), + GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canStageSelection))), Description: self.c.Tr.Stage, Tooltip: self.c.Tr.StageTooltip, DisplayOnScreen: true, @@ -583,6 +583,12 @@ func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) e } func (self *FilesController) press(nodes []*filetree.FileNode) error { + // A single file with a conflict that can only be resolved through a dialog + // can't be staged; route it to the same picker that `enter` uses instead. + if len(nodes) == 1 && self.conflictNeedsResolutionDialog(nodes[0].File) { + return self.openConflictResolutionMenu(nodes[0].File) + } + if err := self.pressWithLock(nodes); err != nil { return err } @@ -716,6 +722,27 @@ func (self *FilesController) conflictNeedsResolutionDialog(file *models.File) bo return !file.HasInlineMergeConflicts } +// canStageSelection disables staging when a multiple selection includes a file +// with a conflict that must be resolved through a dialog; those have to be +// resolved one at a time. +func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *types.DisabledReason { + if len(nodes) > 1 { + for _, node := range nodes { + if node.SomeFile(self.conflictNeedsResolutionDialog) { + return &types.DisabledReason{ + Text: utils.ResolvePlaceholderString( + self.c.Tr.StageConflictsRangeDisabled, map[string]string{ + "goIntoKey": self.c.UserConfig().Keybinding.Universal.GoInto.String(), + }, + ), + } + } + } + } + + return nil +} + func (self *FilesController) openConflictResolutionMenu(file *models.File) error { handle := func(command func(command string) error, logText string) error { self.c.LogAction(logText) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 8dd40016a..3928aac38 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -101,6 +101,7 @@ type TranslationSet struct { MergeConflictPressEnterToResolve string MergeConflictKeepFile string MergeConflictDeleteFile string + StageConflictsRangeDisabled string Checkout string CheckoutTooltip string CantCheckoutBranchWhilePulling string @@ -1200,6 +1201,7 @@ func EnglishTranslationSet() *TranslationSet { MergeConflictPressEnterToResolve: "Press %s to resolve.", MergeConflictKeepFile: "Keep file", MergeConflictDeleteFile: "Delete file", + StageConflictsRangeDisabled: "Cannot stage a selection that includes files with merge conflicts; resolve them individually with {{.goIntoKey}} first.", Checkout: "Checkout", CheckoutTooltip: "Checkout selected item.", CantCheckoutBranchWhilePulling: "You cannot checkout another branch while pulling the current branch", diff --git a/pkg/integration/tests/conflicts/space_on_non_textual_conflict.go b/pkg/integration/tests/conflicts/space_on_non_textual_conflict.go new file mode 100644 index 000000000..2899fadff --- /dev/null +++ b/pkg/integration/tests/conflicts/space_on_non_textual_conflict.go @@ -0,0 +1,56 @@ +package conflicts + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SpaceOnNonTextualConflict = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Pressing space on a non-textual conflict opens the resolution menu; staging is disabled for a range that includes one", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.RunShellCommand(`echo 1 > foo && echo 1 > bar`) + shell.RunShellCommand(`git checkout -b base && git add . && git commit -m base`) + + // theirs: delete foo, modify bar + shell.RunShellCommand(`git checkout -b theirs`) + shell.RunShellCommand(`git rm foo && echo 2 > bar && git add bar && git commit -m theirs`) + + // ours: modify foo, delete bar + shell.RunShellCommand(`git checkout base && git checkout -b ours`) + shell.RunShellCommand(`echo 2 > foo && git add foo && git rm bar && git commit -m ours`) + + shell.RunCommandExpectError([]string{"git", "merge", "theirs"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("DU bar"), + Contains("UD foo"), + ). + // Pressing space on a single non-textual conflict opens the + // resolution menu rather than trying to stage it. + NavigateToLine(Contains("bar")). + PressPrimaryAction(). + Tap(func() { + t.ExpectPopup().Menu().Title(Equals("Merge conflicts")).Cancel() + }). + // Staging is disabled for a range selection that includes a conflict. + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("foo")). + PressPrimaryAction(). + Tap(func() { + t.ExpectToast(Contains("Cannot stage a selection that includes files with merge conflicts")) + }). + // Entering a range selection is disabled too, with the usual toast. + Press(keys.Universal.GoInto). + Tap(func() { + t.ExpectToast(Contains("does not support range selection")) + }) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 3cadf6843..b27577362 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -172,6 +172,7 @@ var tests = []*components.IntegrationTest{ conflicts.ResolveNoAutoStage, conflicts.ResolveNonTextualConflicts, conflicts.ResolveWithoutTrailingLf, + conflicts.SpaceOnNonTextualConflict, conflicts.UndoChooseHunk, custom_commands.AccessCommitProperties, custom_commands.BasicCommand, From afe4d14106adcffe81f5f04dbbe94578dc0881d1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 14:04:07 +0200 Subject: [PATCH 129/384] Resolve submodule conflicts through a picker When both sides of a merge moved a submodule's gitlink, git reports it as "UU". Pressing space used to fall into the submodule no-op guard and pop the confusing "Nothing to stage..." error, and enter just entered the submodule, which does nothing to resolve the superproject conflict. Treat a conflicted submodule like the other non-textual conflicts: both space and enter now open a picker offering the two candidate commits, "current" and "incoming", each labelled with its summary. `git checkout --ours/--theirs` is a no-op on gitlinks, so we resolve by checking the submodule out at the chosen commit and staging it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_commands/submodule.go | 54 +++++++++++++ pkg/commands/git_commands/submodule_test.go | 81 +++++++++++++++++++ pkg/gui/controllers/files_controller.go | 74 ++++++++++++++++- pkg/i18n/english.go | 10 +++ .../tests/submodule/resolve_conflict.go | 73 +++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 6 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 pkg/commands/git_commands/submodule_test.go create mode 100644 pkg/integration/tests/submodule/resolve_conflict.go diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index 7a3cb687b..d5852d779 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -111,6 +111,60 @@ func (self *SubmoduleCommands) AnyHaveStageableChanges(paths []string) (bool, er }), nil } +// GetConflictCommits returns the three gitlink commits of a conflicted submodule +// from the index: the merge base, our (current) commit, and their (incoming) +// commit. Any of them can be empty if that stage is absent (e.g. a submodule +// that was added on only one side). The path is relative to the repo root. +func (self *SubmoduleCommands) GetConflictCommits(path string) (base string, ours string, theirs string, err error) { + cmdArgs := NewGitCmd("ls-files").Arg("-u", "-z", "--", path).ToArgv() + output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() + if err != nil { + return "", "", "", err + } + + // Each NUL-terminated entry looks like " \t". + for _, entry := range strings.Split(output, "\x00") { + // fields are split on the tab and the spaces, so the leading three are + // always mode, sha, stage regardless of what the path contains. + fields := strings.Fields(entry) + if len(fields) < 3 { + continue + } + switch fields[2] { + case "1": + base = fields[1] + case "2": + ours = fields[1] + case "3": + theirs = fields[1] + } + } + + return base, ours, theirs, nil +} + +// GetCommitSummary returns " " for a commit inside the +// submodule at the given path, for display in the conflict menu. +func (self *SubmoduleCommands) GetCommitSummary(path string, sha string) (string, error) { + cmdArgs := NewGitCmd("log"). + Dir(path). + Arg("--format=%h %s", "--max-count=1", sha). + Config("log.showsignature=false"). + ToArgv() + + summary, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() + return strings.TrimSpace(summary), err +} + +// CheckoutConflictCommit resolves a submodule conflict by checking the submodule +// out at the given commit. `git checkout --ours/--theirs` is a no-op on +// gitlinks, so we check out the chosen commit in the submodule itself; the +// 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() +} + func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { // if the path does not exist then it hasn't yet been initialized so we'll swallow the error // because the intention here is to have no dirty worktree state diff --git a/pkg/commands/git_commands/submodule_test.go b/pkg/commands/git_commands/submodule_test.go new file mode 100644 index 000000000..4683b14cd --- /dev/null +++ b/pkg/commands/git_commands/submodule_test.go @@ -0,0 +1,81 @@ +package git_commands + +import ( + "testing" + + "github.com/go-errors/errors" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/stretchr/testify/assert" +) + +func TestSubmoduleGetConflictCommits(t *testing.T) { + type scenario struct { + testName string + output string + expectedBase string + expectedOurs string + expectedTheirs string + } + + scenarios := []scenario{ + { + testName: "all three stages present (both modified)", + output: "160000 aaaaaaa 1\tmysub\x00160000 bbbbbbb 2\tmysub\x00160000 ccccccc 3\tmysub\x00", + expectedBase: "aaaaaaa", + expectedOurs: "bbbbbbb", + expectedTheirs: "ccccccc", + }, + { + testName: "only our and their stages (added on both sides)", + output: "160000 bbbbbbb 2\tmysub\x00160000 ccccccc 3\tmysub\x00", + expectedBase: "", + expectedOurs: "bbbbbbb", + expectedTheirs: "ccccccc", + }, + } + + for _, s := range scenarios { + t.Run(s.testName, func(t *testing.T) { + runner := oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"ls-files", "-u", "-z", "--", "mysub"}, s.output, nil) + instance := buildSubmoduleCommands(commonDeps{runner: runner}) + + base, ours, theirs, err := instance.GetConflictCommits("mysub") + assert.NoError(t, err) + assert.Equal(t, s.expectedBase, base) + assert.Equal(t, s.expectedOurs, ours) + assert.Equal(t, s.expectedTheirs, theirs) + runner.CheckForMissingCalls() + }) + } +} + +func TestSubmoduleGetConflictCommitsError(t *testing.T) { + runner := oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"ls-files", "-u", "-z", "--", "mysub"}, "", errors.New("error")) + instance := buildSubmoduleCommands(commonDeps{runner: runner}) + + _, _, _, err := instance.GetConflictCommits("mysub") + assert.Error(t, err) + runner.CheckForMissingCalls() +} + +func TestSubmoduleGetCommitSummary(t *testing.T) { + runner := oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"-c", "log.showsignature=false", "-C", "mysub", "log", "--format=%h %s", "--max-count=1", "bbbbbbb"}, "bbbbbbb the subject\n", nil) + instance := buildSubmoduleCommands(commonDeps{runner: runner}) + + summary, err := instance.GetCommitSummary("mysub", "bbbbbbb") + assert.NoError(t, err) + assert.Equal(t, "bbbbbbb the subject", summary) + runner.CheckForMissingCalls() +} + +func TestSubmoduleCheckoutConflictCommit(t *testing.T) { + runner := oscommands.NewFakeRunner(t). + ExpectGitArgs([]string{"-C", "mysub", "checkout", "bbbbbbb"}, "", nil) + instance := buildSubmoduleCommands(commonDeps{runner: runner}) + + assert.NoError(t, instance.CheckoutConflictCommit("mysub", "bbbbbbb")) + runner.CheckForMissingCalls() +} diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index c9034ec9c..12d6d0072 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -712,13 +712,20 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { // conflictNeedsResolutionDialog reports whether a file's merge conflict can only // be resolved through a dialog that picks one side, as opposed to editing -// conflict markers in the merge view. These are the "non-textual" conflicts, -// e.g. one side modified a file while the other deleted it (DD/AU/UA/UD/DU). +// conflict markers in the merge view. These are the "non-textual" conflicts: +// text files where one side modified and the other deleted/renamed the file +// (DD/AU/UA/UD/DU), and submodules where both sides moved the gitlink (UU). func (self *FilesController) conflictNeedsResolutionDialog(file *models.File) bool { if file == nil || !file.HasMergeConflicts { return false } + // A conflicted submodule has no conflict markers to edit; it's resolved by + // picking which commit to point at. + if file.IsSubmodule(self.c.Model().Submodules) { + return true + } + return !file.HasInlineMergeConflicts } @@ -743,7 +750,24 @@ func (self *FilesController) canStageSelection(nodes []*filetree.FileNode) *type return nil } +// isSubmoduleCommitConflict reports whether the file is a submodule whose commit +// pointer conflicts (status UU or AA): both sides recorded a different commit, +// with no base content to merge. These are resolved by picking one side's +// commit. Other submodule conflicts (e.g. modify/delete) are handled like +// ordinary non-textual conflicts, with the keep/delete picker. +func (self *FilesController) isSubmoduleCommitConflict(file *models.File) bool { + return file != nil && file.HasInlineMergeConflicts && file.IsSubmodule(self.c.Model().Submodules) +} + func (self *FilesController) openConflictResolutionMenu(file *models.File) error { + if self.isSubmoduleCommitConflict(file) { + return self.openSubmoduleConflictMenu(file) + } + + return self.openFileConflictMenu(file) +} + +func (self *FilesController) openFileConflictMenu(file *models.File) error { handle := func(command func(command string) error, logText string) error { self.c.LogAction(logText) if err := command(file.GetPath()); err != nil { @@ -790,6 +814,52 @@ func (self *FilesController) openConflictResolutionMenu(file *models.File) error }) } +func (self *FilesController) openSubmoduleConflictMenu(file *models.File) error { + path := file.GetPath() + _, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path) + if err != nil { + return err + } + + resolve := func(sha string, logAction string) error { + self.c.LogAction(logAction) + if err := self.c.Git().Submodule.CheckoutConflictCommit(path, sha); err != nil { + return err + } + if err := self.c.Git().WorkingTree.StageFile(path); err != nil { + return err + } + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) + return nil + } + + // Append the commit summary to the label so the user can tell the two + // candidates apart, falling back to the bare label if we can't read it. + label := func(text string, sha string) string { + if summary, err := self.c.Git().Submodule.GetCommitSummary(path, sha); err == nil && summary != "" { + return fmt.Sprintf("%s (%s)", text, summary) + } + return text + } + + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.MergeConflictsTitle, + Prompt: utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path}), + Items: []*types.MenuItem{ + { + Label: label(self.c.Tr.MergeConflictTakeCurrentCommit, ours), + OnPress: func() error { return resolve(ours, self.c.Tr.Actions.TakeCurrentSubmoduleCommit) }, + Keys: menuKey('c'), + }, + { + Label: label(self.c.Tr.MergeConflictTakeIncomingCommit, theirs), + OnPress: func() error { return resolve(theirs, self.c.Tr.Actions.TakeIncomingSubmoduleCommit) }, + Keys: menuKey('i'), + }, + }, + }) +} + func (self *FilesController) toggleStagedAll() error { if err := self.toggleStagedAllWithLock(); err != nil { return err diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 3928aac38..6ddf356cd 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -101,6 +101,9 @@ type TranslationSet struct { MergeConflictPressEnterToResolve string MergeConflictKeepFile string MergeConflictDeleteFile string + MergeConflictTakeCurrentCommit string + MergeConflictTakeIncomingCommit string + SubmoduleMergeConflictDescription string StageConflictsRangeDisabled string Checkout string CheckoutTooltip string @@ -1028,6 +1031,8 @@ type Actions struct { StageAllFiles string ResolveConflictByKeepingFile string ResolveConflictByDeletingFile string + TakeCurrentSubmoduleCommit string + TakeIncomingSubmoduleCommit string NotEnoughContextToStage string NotEnoughContextToDiscard string NotEnoughContextToRemoveLines string @@ -1201,6 +1206,9 @@ func EnglishTranslationSet() *TranslationSet { MergeConflictPressEnterToResolve: "Press %s to resolve.", MergeConflictKeepFile: "Keep file", MergeConflictDeleteFile: "Delete file", + MergeConflictTakeCurrentCommit: "Take current commit", + MergeConflictTakeIncomingCommit: "Take incoming commit", + SubmoduleMergeConflictDescription: "Conflict: the submodule '{{.path}}' was set to a different commit in the current and the incoming changes. Pick which commit to keep.", StageConflictsRangeDisabled: "Cannot stage a selection that includes files with merge conflicts; resolve them individually with {{.goIntoKey}} first.", Checkout: "Checkout", CheckoutTooltip: "Checkout selected item.", @@ -2116,6 +2124,8 @@ func EnglishTranslationSet() *TranslationSet { StageAllFiles: "Stage all files", ResolveConflictByKeepingFile: "Resolve by keeping file", ResolveConflictByDeletingFile: "Resolve by deleting file", + TakeCurrentSubmoduleCommit: "Resolve submodule conflict by taking current commit", + TakeIncomingSubmoduleCommit: "Resolve submodule conflict by taking incoming commit", NotEnoughContextToStage: "Staging or unstaging changes is not possible with a diff context size of 0. Increase the context using '%s'.", NotEnoughContextToDiscard: "Discarding changes is not possible with a diff context size of 0. Increase the context using '%s'.", NotEnoughContextToRemoveLines: "Removing lines from a commit is not possible with a diff context size of 0. Increase the context using '%s'.", diff --git a/pkg/integration/tests/submodule/resolve_conflict.go b/pkg/integration/tests/submodule/resolve_conflict.go new file mode 100644 index 000000000..fc589046c --- /dev/null +++ b/pkg/integration/tests/submodule/resolve_conflict.go @@ -0,0 +1,73 @@ +package submodule + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ResolveConflict = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Resolve a submodule conflict (both sides moved the gitlink) by picking one side's commit", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("my_submodule_name", "my_submodule_path") + shell.GitAddAll() + shell.Commit("add submodule") + + sub := "my_submodule_path" + + // Two diverging commits in the submodule, so the gitlink can't be + // fast-forwarded and the merge genuinely conflicts. + shell.RunCommand([]string{"git", "-C", sub, "checkout", "-b", "left"}) + shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "left"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "-b", "right", "HEAD~1"}) + shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "right"}) + + // "ours" points the submodule at left, "theirs" at right. + shell.RunCommand([]string{"git", "checkout", "-b", "ours"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "left"}) + shell.RunCommand([]string{"git", "add", sub}) + shell.Commit("ours") + + shell.RunCommand([]string{"git", "checkout", "-b", "theirs", "HEAD~1"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "right"}) + shell.RunCommand([]string{"git", "add", sub}) + shell.Commit("theirs") + + shell.RunCommand([]string{"git", "checkout", "ours"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "left"}) + shell.RunCommandExpectError([]string{"git", "merge", "theirs"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Focus(). + Lines( + Contains("UU my_submodule_path (submodule)").IsSelected(), + ). + // Enter opens the resolution menu instead of entering the submodule. + // The two candidate commits are shown with their summaries. + Press(keys.Universal.GoInto). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Merge conflicts")). + Select(Contains("Take current commit").Contains("left")). + Select(Contains("Take incoming commit").Contains("right")). + Cancel() + }). + // Space opens the same menu; take the incoming commit to resolve. + PressPrimaryAction(). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Merge conflicts")). + Select(Contains("Take incoming commit")). + Confirm() + }). + Lines( + Contains("M my_submodule_path (submodule)").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index b27577362..dc8d0ec6a 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -430,6 +430,7 @@ var tests = []*components.IntegrationTest{ submodule.RemoveNested, submodule.Reset, submodule.ResetFolder, + submodule.ResolveConflict, submodule.Stage, submodule.StageAllWithDirtySubmodule, submodule.StageDirtyOnly, From 050225ffe6b594e5e28aa6bcdfbb447059a1fc82 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 14:10:37 +0200 Subject: [PATCH 130/384] Show per-side commit logs for submodule conflicts in the main view When a conflicted submodule is selected, the main view shows the commits each side added relative to their common ancestor as two indented logs, labelled current and incoming, so it's clear which commit each side would resolve to. The logs aren't truncated (the view scrolls). If a side added no commits of its own (e.g. it was rewound to an ancestor of the other), its head commit is shown instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_commands/submodule.go | 12 ++++ pkg/commands/git_commands/submodule_test.go | 11 ++++ pkg/gui/controllers/files_controller.go | 65 ++++++++++++++++--- .../tests/submodule/resolve_conflict.go | 9 +++ .../resolve_conflict_rewound_side.go | 63 ++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 6 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 pkg/integration/tests/submodule/resolve_conflict_rewound_side.go diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index d5852d779..3b88e4fbb 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -165,6 +165,18 @@ func (self *SubmoduleCommands) CheckoutConflictCommit(path string, sha string) e return self.cmd.New(cmdArgs).Run() } +// ConflictSideLog returns a oneline log, run inside the submodule, of the commits +// that `side` has but `otherSide` does not (i.e. `otherSide..side`) — the commits +// unique to one side of a commit conflict, relative to their common ancestor. It +// is empty if `side` is an ancestor of `otherSide` (e.g. that side was rewound). +func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSide string) (string, error) { + cmdArgs := NewGitCmd("log").Dir(path). + Arg("--oneline", "--color=always", otherSide+".."+side). + ToArgv() + + return self.cmd.New(cmdArgs).DontLog().RunWithOutput() +} + func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { // if the path does not exist then it hasn't yet been initialized so we'll swallow the error // because the intention here is to have no dirty worktree state diff --git a/pkg/commands/git_commands/submodule_test.go b/pkg/commands/git_commands/submodule_test.go index 4683b14cd..279c963df 100644 --- a/pkg/commands/git_commands/submodule_test.go +++ b/pkg/commands/git_commands/submodule_test.go @@ -79,3 +79,14 @@ func TestSubmoduleCheckoutConflictCommit(t *testing.T) { assert.NoError(t, instance.CheckoutConflictCommit("mysub", "bbbbbbb")) 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) + instance := buildSubmoduleCommands(commonDeps{runner: runner}) + + output, err := instance.ConflictSideLog("mysub", "bbbbbbb", "ccccccc") + assert.NoError(t, err) + assert.Equal(t, "bbbbbbb left\n", output) + runner.CheckForMissingCalls() +} diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 12d6d0072..8e7ca1a37 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -270,6 +270,49 @@ func (self *FilesController) GetOnRenderToMain() func() { return } + if self.isSubmoduleCommitConflict(node.File) { + self.c.Helpers().MergeConflicts.ResetMergeState() + + path := node.GetPath() + _, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path) + if err != nil { + return + } + + // Show the commits each side added relative to their common + // ancestor as two separate, indented logs, so it's clear which is + // which. If a side added nothing of its own (e.g. it was rewound to + // an ancestor of the other), show the commit it points at instead. + sideBlock := func(header string, side string, otherSide string) string { + log, err := self.c.Git().Submodule.ConflictSideLog(path, side, otherSide) + if err != nil { + return header + } + if log = strings.TrimRight(log, "\n"); log == "" { + if log, err = self.c.Git().Submodule.GetCommitSummary(path, side); err != nil { + return header + } + } + return header + "\n\n " + strings.ReplaceAll(log, "\n", "\n ") + } + + message := strings.Join([]string{ + self.conflictResolutionHint(utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path})), + sideBlock(self.c.Tr.MergeConflictCurrentDiff, ours, theirs), + sideBlock(self.c.Tr.MergeConflictIncomingDiff, theirs, ours), + }, "\n\n") + + self.c.RenderToMainViews(types.RefreshMainOpts{ + Pair: self.c.MainViewPairs().Normal, + Main: &types.ViewUpdateOpts{ + Title: self.c.Tr.DiffTitle, + SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), + Task: types.NewRenderStringTask(message), + }, + }) + return + } + if node.File != nil && node.File.HasInlineMergeConflicts { hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath()) if err != nil { @@ -288,14 +331,7 @@ func (self *FilesController) GetOnRenderToMain() func() { SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), }, } - message := node.File.GetMergeStateDescription(self.c.Tr) - message += "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve, - self.c.UserConfig().Keybinding.Universal.GoInto) - if self.c.Views().Main.InnerWidth() > 70 { - // If the main view is very wide, wrap the message to increase readability - lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4) - message = strings.Join(lines, "\n") - } + message := self.conflictResolutionHint(node.File.GetMergeStateDescription(self.c.Tr)) if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" { cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()}) prefix := message + "\n\n" @@ -710,6 +746,19 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { return nil } +// conflictResolutionHint formats a conflict description for the main view, +// appending the "press to resolve" hint and wrapping it when the view is +// wide enough that long lines would otherwise hurt readability. +func (self *FilesController) conflictResolutionHint(description string) string { + message := description + "\n\n" + fmt.Sprintf(self.c.Tr.MergeConflictPressEnterToResolve, + self.c.UserConfig().Keybinding.Universal.GoInto) + if self.c.Views().Main.InnerWidth() > 70 { + lines, _, _ := utils.WrapViewLinesToWidth(true, false, message, 70, 4) + message = strings.Join(lines, "\n") + } + return message +} + // conflictNeedsResolutionDialog reports whether a file's merge conflict can only // be resolved through a dialog that picks one side, as opposed to editing // conflict markers in the merge view. These are the "non-textual" conflicts: diff --git a/pkg/integration/tests/submodule/resolve_conflict.go b/pkg/integration/tests/submodule/resolve_conflict.go index fc589046c..362325624 100644 --- a/pkg/integration/tests/submodule/resolve_conflict.go +++ b/pkg/integration/tests/submodule/resolve_conflict.go @@ -48,6 +48,15 @@ var ResolveConflict = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("UU my_submodule_path (submodule)").IsSelected(), ). + Tap(func() { + // The main view explains the conflict and shows each side's + // commits as separate "current" and "incoming" logs. + t.Views().Main().Content( + Contains("Conflict: the submodule"). + Contains("Current changes:").Contains("left"). + Contains("Incoming changes:").Contains("right"), + ) + }). // Enter opens the resolution menu instead of entering the submodule. // The two candidate commits are shown with their summaries. Press(keys.Universal.GoInto). diff --git a/pkg/integration/tests/submodule/resolve_conflict_rewound_side.go b/pkg/integration/tests/submodule/resolve_conflict_rewound_side.go new file mode 100644 index 000000000..06f0e8f2e --- /dev/null +++ b/pkg/integration/tests/submodule/resolve_conflict_rewound_side.go @@ -0,0 +1,63 @@ +package submodule + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var ResolveConflictRewoundSide = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "When a side of a submodule conflict added no commits of its own (it was rewound), the main view shows the commit it points at instead of an empty log", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.ShowFileTree = false + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("first commit") + shell.CloneIntoSubmodule("sub_name", "sub_path") + shell.GitAddAll() + shell.Commit("add submodule") + + sub := "sub_path" + + // Mark the submodule's initial commit, then advance it; the merge base + // will point the submodule here. + shell.RunCommand([]string{"git", "-C", sub, "branch", "initial"}) + shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "s1"}) + shell.RunCommand([]string{"git", "add", sub}) + shell.Commit("base at s1") + + // "ours" rewinds the submodule to its initial commit (so it has no + // commits of its own relative to "theirs"). + shell.RunCommand([]string{"git", "checkout", "-b", "ours"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "initial"}) + shell.RunCommand([]string{"git", "add", sub}) + shell.Commit("ours rewinds submodule") + + // "theirs" advances the submodule with a further commit. + shell.RunCommand([]string{"git", "checkout", "-b", "theirs", "HEAD~1"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "master"}) + shell.RunCommand([]string{"git", "-C", sub, "commit", "--allow-empty", "-m", "s2"}) + shell.RunCommand([]string{"git", "add", sub}) + shell.Commit("theirs advances submodule") + + shell.RunCommand([]string{"git", "checkout", "ours"}) + shell.RunCommand([]string{"git", "-C", sub, "checkout", "initial"}) + shell.RunCommandExpectError([]string{"git", "merge", "theirs"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + Focus(). + Lines( + Contains("UU sub_path (submodule)").IsSelected(), + ). + Tap(func() { + // "ours" has no commits of its own, so its section falls back to + // the commit it points at; "theirs" lists the commits it added. + t.Views().Main().Content( + Contains("Current changes:").Contains("first commit"). + Contains("Incoming changes:").Contains("s1").Contains("s2"), + ) + }) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index dc8d0ec6a..d7d5d66fa 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -431,6 +431,7 @@ var tests = []*components.IntegrationTest{ submodule.Reset, submodule.ResetFolder, submodule.ResolveConflict, + submodule.ResolveConflictRewoundSide, submodule.Stage, submodule.StageAllWithDirtySubmodule, submodule.StageDirtyOnly, From 71a6396275eb895fbeb39c5abb84d32f9c656863 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 28 Jun 2026 16:24:02 +0200 Subject: [PATCH 131/384] Extract per-case render helpers from FilesController.GetOnRenderToMain GetOnRenderToMain had grown to handle five distinct rendering cases inline (no selection, submodule conflict, inline text conflict, non-textual text conflict, and the normal working-tree diff), which made it hard to follow. Split each case into its own method so the function reads as a short dispatcher, and pull the repeated main-view boilerplate into renderToMainWithTask. Pure refactor; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/files_controller.go | 245 +++++++++++++----------- 1 file changed, 133 insertions(+), 112 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 8e7ca1a37..a63c6a15a 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -259,136 +259,157 @@ func (self *FilesController) GetOnRenderToMain() func() { node := self.context().GetSelected() if node == nil { - self.c.RenderToMainViews(types.RefreshMainOpts{ - Pair: self.c.MainViewPairs().Normal, - Main: &types.ViewUpdateOpts{ - Title: self.c.Tr.DiffTitle, - SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - Task: types.NewRenderStringTask(self.c.Tr.NoChangedFiles), - }, - }) + self.renderToMainWithTask(types.NewRenderStringTask(self.c.Tr.NoChangedFiles)) return } if self.isSubmoduleCommitConflict(node.File) { - self.c.Helpers().MergeConflicts.ResetMergeState() - - path := node.GetPath() - _, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path) - if err != nil { - return - } - - // Show the commits each side added relative to their common - // ancestor as two separate, indented logs, so it's clear which is - // which. If a side added nothing of its own (e.g. it was rewound to - // an ancestor of the other), show the commit it points at instead. - sideBlock := func(header string, side string, otherSide string) string { - log, err := self.c.Git().Submodule.ConflictSideLog(path, side, otherSide) - if err != nil { - return header - } - if log = strings.TrimRight(log, "\n"); log == "" { - if log, err = self.c.Git().Submodule.GetCommitSummary(path, side); err != nil { - return header - } - } - return header + "\n\n " + strings.ReplaceAll(log, "\n", "\n ") - } - - message := strings.Join([]string{ - self.conflictResolutionHint(utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path})), - sideBlock(self.c.Tr.MergeConflictCurrentDiff, ours, theirs), - sideBlock(self.c.Tr.MergeConflictIncomingDiff, theirs, ours), - }, "\n\n") - - self.c.RenderToMainViews(types.RefreshMainOpts{ - Pair: self.c.MainViewPairs().Normal, - Main: &types.ViewUpdateOpts{ - Title: self.c.Tr.DiffTitle, - SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - Task: types.NewRenderStringTask(message), - }, - }) + self.renderSubmoduleConflict(node) return } if node.File != nil && node.File.HasInlineMergeConflicts { - hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath()) - if err != nil { - return - } - - if hasConflicts { - self.c.Helpers().MergeConflicts.Render() + if self.renderInlineMergeConflict(node) { return } + // The file is marked as conflicted but has no conflict markers (it + // was resolved in an editor), so fall through to show its diff. } else if node.File != nil && node.File.HasMergeConflicts { - opts := types.RefreshMainOpts{ - Pair: self.c.MainViewPairs().Normal, - Main: &types.ViewUpdateOpts{ - Title: self.c.Tr.DiffTitle, - SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - }, - } - message := self.conflictResolutionHint(node.File.GetMergeStateDescription(self.c.Tr)) - if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" { - cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()}) - prefix := message + "\n\n" - if node.File.ShortStatus == "DU" { - prefix += self.c.Tr.MergeConflictIncomingDiff - } else { - prefix += self.c.Tr.MergeConflictCurrentDiff - } - prefix += "\n\n" - opts.Main.Task = types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix) - } else { - opts.Main.Task = types.NewRenderStringTask(message) - } - self.c.RenderToMainViews(opts) + self.renderNonTextualConflict(node) return } - self.c.Helpers().MergeConflicts.ResetMergeState() - - 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) - title := self.c.Tr.UnstagedChanges - if mainShowsStaged { - title = self.c.Tr.StagedChanges - } - refreshOpts := types.RefreshMainOpts{ - Pair: self.c.MainViewPairs().Normal, - Main: &types.ViewUpdateOpts{ - Task: types.NewRunPtyTask(cmdObj.GetCmd()), - SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - Title: title, - }, - } - - if split { - cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides) - - title := self.c.Tr.StagedChanges - if mainShowsStaged { - title = self.c.Tr.UnstagedChanges - } - - refreshOpts.Secondary = &types.ViewUpdateOpts{ - Title: title, - SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), - Task: types.NewRunPtyTask(cmdObj.GetCmd()), - } - } - - self.c.RenderToMainViews(refreshOpts) + self.renderWorkingTreeDiff(node) }) } } +// renderToMainWithTask renders the given task to the main view with the standard +// diff title and subtitle. +func (self *FilesController) renderToMainWithTask(task types.UpdateTask) { + self.c.RenderToMainViews(types.RefreshMainOpts{ + Pair: self.c.MainViewPairs().Normal, + Main: &types.ViewUpdateOpts{ + Title: self.c.Tr.DiffTitle, + SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), + Task: task, + }, + }) +} + +// renderSubmoduleConflict shows, for a conflicted submodule, an explanation plus +// the commits each side added relative to their common ancestor as two separate, +// indented logs. If a side added nothing of its own (e.g. it was rewound to an +// ancestor of the other), the commit it points at is shown instead. +func (self *FilesController) renderSubmoduleConflict(node *filetree.FileNode) { + self.c.Helpers().MergeConflicts.ResetMergeState() + + path := node.GetPath() + _, ours, theirs, err := self.c.Git().Submodule.GetConflictCommits(path) + if err != nil { + return + } + + sideBlock := func(header string, side string, otherSide string) string { + log, err := self.c.Git().Submodule.ConflictSideLog(path, side, otherSide) + if err != nil { + return header + } + if log = strings.TrimRight(log, "\n"); log == "" { + if log, err = self.c.Git().Submodule.GetCommitSummary(path, side); err != nil { + return header + } + } + return header + "\n\n " + strings.ReplaceAll(log, "\n", "\n ") + } + + message := strings.Join([]string{ + self.conflictResolutionHint(utils.ResolvePlaceholderString(self.c.Tr.SubmoduleMergeConflictDescription, map[string]string{"path": path})), + sideBlock(self.c.Tr.MergeConflictCurrentDiff, ours, theirs), + sideBlock(self.c.Tr.MergeConflictIncomingDiff, theirs, ours), + }, "\n\n") + + self.renderToMainWithTask(types.NewRenderStringTask(message)) +} + +// renderInlineMergeConflict renders the merge-conflict view for a file with +// inline conflict markers. It returns false if the file has no actual markers +// (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()) + if err != nil { + return true + } + + if !hasConflicts { + return false + } + + self.c.Helpers().MergeConflicts.Render() + return true +} + +// renderNonTextualConflict shows the resolution hint for a non-textual text-file +// conflict (DD/AU/UA/UD/DU), plus the base diff for the modify/delete cases. +func (self *FilesController) renderNonTextualConflict(node *filetree.FileNode) { + message := self.conflictResolutionHint(node.File.GetMergeStateDescription(self.c.Tr)) + + if node.File.ShortStatus == "DU" || node.File.ShortStatus == "UD" { + cmdObj := self.c.Git().Diff.DiffCmdObj([]string{"--base", "--", node.GetPath()}) + prefix := message + "\n\n" + if node.File.ShortStatus == "DU" { + prefix += self.c.Tr.MergeConflictIncomingDiff + } else { + prefix += self.c.Tr.MergeConflictCurrentDiff + } + prefix += "\n\n" + self.renderToMainWithTask(types.NewRunPtyTaskWithPrefix(cmdObj.GetCmd(), prefix)) + return + } + + self.renderToMainWithTask(types.NewRenderStringTask(message)) +} + +func (self *FilesController) renderWorkingTreeDiff(node *filetree.FileNode) { + self.c.Helpers().MergeConflicts.ResetMergeState() + + 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) + title := self.c.Tr.UnstagedChanges + if mainShowsStaged { + title = self.c.Tr.StagedChanges + } + refreshOpts := types.RefreshMainOpts{ + Pair: self.c.MainViewPairs().Normal, + Main: &types.ViewUpdateOpts{ + Task: types.NewRunPtyTask(cmdObj.GetCmd()), + SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), + Title: title, + }, + } + + if split { + cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true, pathOverrides) + + title := self.c.Tr.StagedChanges + if mainShowsStaged { + title = self.c.Tr.UnstagedChanges + } + + refreshOpts.Secondary = &types.ViewUpdateOpts{ + Title: title, + SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), + Task: types.NewRunPtyTask(cmdObj.GetCmd()), + } + } + + self.c.RenderToMainViews(refreshOpts) +} + func (self *FilesController) GetOnDoubleClick() func() error { return self.withItemGraceful(func(node *filetree.FileNode) error { return self.press([]*filetree.FileNode{node}) From 8a8dacca14234ba9cb730c5cdedd2a2225d3e924 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 22 Apr 2026 13:37:38 +0200 Subject: [PATCH 132/384] Demonstrate that unknown escape sequences leak as literal text The escape interpreter errors on anything outside the handful of sequences it understands (SGR, EL, OSC 8 hyperlinks), and view.go then renders the unparsed bytes as text cells. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gocui/escape_test.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pkg/gocui/escape_test.go b/pkg/gocui/escape_test.go index 382d27bad..3d9ab5b36 100644 --- a/pkg/gocui/escape_test.go +++ b/pkg/gocui/escape_test.go @@ -150,6 +150,29 @@ func TestParseOneColours(t *testing.T) { } } +func TestParseOneIgnoresUnknownSequences(t *testing.T) { + // These are the kinds of sequences ConPTY emits as session-init on Windows. A text-mode + // interpreter can't do anything meaningful with them, but it must silently consume them + // instead of leaking them into the view as literal text. + scenarios := []string{ + "\x1b[?9001h", // DEC private-mode set (?-prefix) + "\x1b[?25l", // hide cursor + "\x1b[?25h", // show cursor + "\x1b[2;J", // erase display (unusual 2;J variant) + "\x1b[H", // cursor home — final byte immediately after [ + "\x1b[5;1;H", // cursor position with multiple params + "\x1bc", // RIS — single-char ESC sequence + } + + for _, input := range scenarios { + ei := newEscapeInterpreter(OutputNormal) + /* EXPECTED: + parseEscRunes(t, ei, input) + ACTUAL: */ + parseEscRunesExpectingError(t, ei, input) + } +} + func parseEscRunes(t *testing.T, ei *escapeInterpreter, runes string) { t.Helper() for _, b := range []byte(runes) { @@ -158,3 +181,13 @@ func parseEscRunes(t *testing.T, ei *escapeInterpreter, runes string) { assert.NoError(t, err) } } + +func parseEscRunesExpectingError(t *testing.T, ei *escapeInterpreter, runes string) { + t.Helper() + for _, b := range []byte(runes) { + if _, err := ei.parseOne([]byte{b}); err != nil { + return + } + } + t.Errorf("expected a parse error for %q, got none", runes) +} From 31ed34a4214adbb68e5612063b036eebeb97de86 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Apr 2026 12:09:10 +0200 Subject: [PATCH 133/384] Silently consume unrecognized escape sequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A text-mode escape interpreter can't do anything meaningful with cursor positioning, DEC private modes, or terminal resets — but it must still consume them, not print them as literal text. Before this change, any sequence outside SGR / EL / OSC-8 errored out of parseOne, and view.go rendered the unparsed bytes as visible cells. On Windows this would show up as junk at the start of main-panel output once we add PTY support using ConPTY, because ConPTY's session-init stream is full of such sequences. Three additions to the state machine: - stateEscape: a single byte in 0x30–0x7E after ESC (e.g. ESC c = RIS) is a complete Fs/Fp sequence per ECMA-48; consume and reset. - stateCSI: accept the DEC private-mode prefix bytes (<, =, >, ?), and accept a CSI final byte (0x40–0x7E) immediately after [ as the end of a zero-param sequence. - stateParams: accept any CSI final byte we don't implement as the end of the sequence rather than a parse error. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gocui/escape.go | 25 +++++++++++++++++++++++++ pkg/gocui/escape_test.go | 13 ------------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/pkg/gocui/escape.go b/pkg/gocui/escape.go index cb557f088..da55830aa 100644 --- a/pkg/gocui/escape.go +++ b/pkg/gocui/escape.go @@ -151,6 +151,12 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { characterEquals(ch, '+'): ei.state = stateCharacterSetDesignation return true, nil + case len(ch) == 1 && ch[0] >= 0x30 && ch[0] <= 0x7E: + // Single-byte ESC sequence (e.g. ESC c = RIS). We don't + // interpret these, but we must consume them so they don't + // leak into the view as literal text. + ei.state = stateNone + return true, nil default: return false, errNotCSI } @@ -166,6 +172,19 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { ei.csiParam = append(ei.csiParam, "0") case characterEquals(ch, 'K'): // fall through + case len(ch) == 1 && ch[0] >= 0x3C && ch[0] <= 0x3F: + // Private-mode prefix byte (<, =, >, ?). We don't interpret + // DEC private-mode sequences, but must consume them so they + // don't leak into the view as literal text. Seed an empty + // param so the subsequent digits land on a valid slot. + ei.csiParam = append(ei.csiParam, "") + ei.state = stateParams + return true, nil + case len(ch) == 1 && ch[0] >= 0x40 && ch[0] <= 0x7E: + // Valid CSI final byte we don't implement — swallow. + ei.state = stateNone + ei.csiParam = nil + return true, nil default: return false, errCSIParseError } @@ -203,6 +222,12 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { ei.instruction = noInstruction{} } + ei.state = stateNone + ei.csiParam = nil + return true, nil + case len(ch) == 1 && ch[0] >= 0x40 && ch[0] <= 0x7E: + // Valid CSI final byte we don't implement — swallow the + // whole sequence rather than printing it as text. ei.state = stateNone ei.csiParam = nil return true, nil diff --git a/pkg/gocui/escape_test.go b/pkg/gocui/escape_test.go index 3d9ab5b36..d41f009d2 100644 --- a/pkg/gocui/escape_test.go +++ b/pkg/gocui/escape_test.go @@ -166,10 +166,7 @@ func TestParseOneIgnoresUnknownSequences(t *testing.T) { for _, input := range scenarios { ei := newEscapeInterpreter(OutputNormal) - /* EXPECTED: parseEscRunes(t, ei, input) - ACTUAL: */ - parseEscRunesExpectingError(t, ei, input) } } @@ -181,13 +178,3 @@ func parseEscRunes(t *testing.T, ei *escapeInterpreter, runes string) { assert.NoError(t, err) } } - -func parseEscRunesExpectingError(t *testing.T, ei *escapeInterpreter, runes string) { - t.Helper() - for _, b := range []byte(runes) { - if _, err := ei.parseOne([]byte{b}); err != nil { - return - } - } - t.Errorf("expected a parse error for %q, got none", runes) -} From 06d2450459013498c06e48d9395713f0195b9510 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Apr 2026 13:44:54 +0200 Subject: [PATCH 134/384] Stop leaking other malformed and unimplemented escape sequences After the previous commit, the escape interpreter still had five paths that returned an error from parseOne, which view.go handles by rendering whatever bytes it had accumulated as literal cells. Each of these is a case where silently consuming the sequence is strictly better than leaking garbage. - ';' as the first CSI byte: '\x1b[;5H' is a valid sequence (row defaults to 1) but we errored on the leading ';'. - Intermediate bytes in CSI ('\x1b[0 q' = DECSCUSR): the sequence ends in a final byte we don't implement, so consume and drop. - Malformed SGR params (empty slot like '\x1b[1;;m'): if outputCSI fails mid-parse, reset state instead of re-emitting the sequence. - OSC 8 that isn't actually OSC 8 ('\x1b]8x...'): treat as an OSC we don't understand and skip to its terminator rather than error- resetting mid-sequence, which used to leave the rest of the OSC body to be printed as text. - The sanity-check overflow paths (too many params, param too long) now switch to a 'discard until final byte' state rather than returning the accumulated bytes. A new stateCSIDiscard centralizes the 'consume bytes until the CSI final' behavior used by both the intermediate-byte and overflow paths. errCSITooLong and errOSCParseError are gone with their only callers. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gocui/escape.go | 62 +++++++++++++++++++++++++++++++++------- pkg/gocui/escape_test.go | 34 +++++++++++++++------- 2 files changed, 75 insertions(+), 21 deletions(-) diff --git a/pkg/gocui/escape.go b/pkg/gocui/escape.go index da55830aa..726fd4de7 100644 --- a/pkg/gocui/escape.go +++ b/pkg/gocui/escape.go @@ -42,6 +42,7 @@ const ( stateCharacterSetDesignation stateCSI stateParams + stateCSIDiscard stateOSC stateOSCWaitForParams stateOSCParams @@ -66,8 +67,6 @@ const ( var ( errNotCSI = errors.New("Not a CSI escape sequence") errCSIParseError = errors.New("CSI escape sequence parsing error") - errCSITooLong = errors.New("CSI escape sequence is too long") - errOSCParseError = errors.New("OSC escape sequence parsing error") ) // characters in case of error will output the non-parsed characters as a string. @@ -120,12 +119,13 @@ func (ei *escapeInterpreter) instructionRead() { // is part of an escape sequence, and as such should not be printed verbatim. Otherwise, it's not an // escape sequence. func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { - // Sanity checks - if len(ei.csiParam) > 20 { - return false, errCSITooLong - } - if len(ei.csiParam) > 0 && len(ei.csiParam[len(ei.csiParam)-1]) > 255 { - return false, errCSITooLong + // Sanity checks: if a sequence has grown absurdly long, stop + // accumulating state and just swallow bytes until its final byte — + // much better than leaking the accumulated garbage into the view. + if len(ei.csiParam) > 20 || (len(ei.csiParam) > 0 && len(ei.csiParam[len(ei.csiParam)-1]) > 255) { + ei.state = stateCSIDiscard + ei.csiParam = nil + return true, nil } ei.curch = string(ch) @@ -172,6 +172,11 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { ei.csiParam = append(ei.csiParam, "0") case characterEquals(ch, 'K'): // fall through + case characterEquals(ch, ';'): + // Empty first param ([;Xm ≡ [0;Xm). Seed a slot for the + // empty param; stateParams will append the next one when it + // re-reads this ';' via the fallthrough. + ei.csiParam = append(ei.csiParam, "") case len(ch) == 1 && ch[0] >= 0x3C && ch[0] <= 0x3F: // Private-mode prefix byte (<, =, >, ?). We don't interpret // DEC private-mode sequences, but must consume them so they @@ -180,6 +185,13 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { ei.csiParam = append(ei.csiParam, "") ei.state = stateParams return true, nil + case len(ch) == 1 && ch[0] >= 0x20 && ch[0] <= 0x2F: + // CSI intermediate byte. A sequence with intermediates is + // one we don't implement; consume the rest until the final + // byte. + ei.state = stateCSIDiscard + ei.csiParam = nil + return true, nil case len(ch) == 1 && ch[0] >= 0x40 && ch[0] <= 0x7E: // Valid CSI final byte we don't implement — swallow. ei.state = stateNone @@ -199,10 +211,16 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { ei.csiParam = append(ei.csiParam, "") return true, nil case characterEquals(ch, 'm'): + // outputCSI applies params left-to-right and mutates as it + // goes, so on failure some leading params may already have + // taken effect (e.g. `[1;;m` would leave AttrBold set before + // hitting the empty param). Snapshot the colors beforehand + // and restore them on error so a malformed SGR is truly a + // no-op rather than a partial apply. + savedFg, savedBg := ei.curFgColor, ei.curBgColor if err := ei.outputCSI(); err != nil { - return false, errCSIParseError + ei.curFgColor, ei.curBgColor = savedFg, savedBg } - ei.state = stateNone ei.csiParam = nil return true, nil @@ -225,6 +243,13 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { ei.state = stateNone ei.csiParam = nil return true, nil + case len(ch) == 1 && ch[0] >= 0x20 && ch[0] <= 0x2F: + // CSI intermediate byte after params. The final byte will + // have a semantic we don't implement (e.g. `[0 q` = + // DECSCUSR); consume everything until it arrives. + ei.state = stateCSIDiscard + ei.csiParam = nil + return true, nil case len(ch) == 1 && ch[0] >= 0x40 && ch[0] <= 0x7E: // Valid CSI final byte we don't implement — swallow the // whole sequence rather than printing it as text. @@ -234,6 +259,15 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { default: return false, errCSIParseError } + case stateCSIDiscard: + // Consume the rest of a CSI sequence whose semantic we don't + // interpret (one with intermediate bytes, or one the sanity + // checks at the top of parseOne bailed out of). Any byte in the + // final-byte range ends it. + if len(ch) == 1 && ch[0] >= 0x40 && ch[0] <= 0x7E { + ei.state = stateNone + } + return true, nil case stateOSC: if characterEquals(ch, '8') { ei.state = stateOSCWaitForParams @@ -245,7 +279,13 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { return true, nil case stateOSCWaitForParams: if !characterEquals(ch, ';') { - return true, errOSCParseError + // Malformed OSC 8 (expected ';' after '8'). Rather than + // erroring — which would reset state mid-OSC and cause the + // rest of the sequence to leak as literal text — treat the + // whole OSC as one we don't understand and skip to its + // terminator. + ei.state = stateOSCSkipUnknown + return true, nil } ei.state = stateOSCParams diff --git a/pkg/gocui/escape_test.go b/pkg/gocui/escape_test.go index d41f009d2..cb8eb1a4b 100644 --- a/pkg/gocui/escape_test.go +++ b/pkg/gocui/escape_test.go @@ -1,6 +1,7 @@ package gocui import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -151,22 +152,35 @@ func TestParseOneColours(t *testing.T) { } func TestParseOneIgnoresUnknownSequences(t *testing.T) { - // These are the kinds of sequences ConPTY emits as session-init on Windows. A text-mode - // interpreter can't do anything meaningful with them, but it must silently consume them - // instead of leaking them into the view as literal text. + // Escape sequences the interpreter doesn't implement -- whether well-formed-but-unsupported + // (cursor movement, private modes, DECSCUSR, …) or outright malformed -- must be silently + // consumed rather than leaked into the view as literal text. scenarios := []string{ - "\x1b[?9001h", // DEC private-mode set (?-prefix) - "\x1b[?25l", // hide cursor - "\x1b[?25h", // show cursor - "\x1b[2;J", // erase display (unusual 2;J variant) - "\x1b[H", // cursor home — final byte immediately after [ - "\x1b[5;1;H", // cursor position with multiple params - "\x1bc", // RIS — single-char ESC sequence + "\x1b[?9001h", // DEC private-mode set (?-prefix) + "\x1b[?25l", // hide cursor + "\x1b[?25h", // show cursor + "\x1b[2;J", // erase display (unusual 2;J variant) + "\x1b[H", // cursor home — final byte immediately after [ + "\x1b[5;1;H", // cursor position with multiple params + "\x1bc", // RIS — single-char ESC sequence + "\x1b[;5H", // empty first param (';' immediately after '[') + "\x1b[ q", // intermediate byte with no params (DECSCUSR family) + "\x1b[0 q", // intermediate byte after a param + "\x1b[1;;m", // malformed SGR: empty middle param + "\x1b]8bogus\x07", // OSC 8 missing ';' + "\x1b[" + strings.Repeat("0", 300) + "m", // single param overflows length cap + "\x1b[" + strings.Repeat("1;", 25) + "1m", // too many params } for _, input := range scenarios { ei := newEscapeInterpreter(OutputNormal) parseEscRunes(t, ei, input) + // An unimplemented/malformed sequence must leave no trace: no + // pending instruction, no color change. + _, noop := ei.instruction.(noInstruction) + assert.True(t, noop, "input %q left a pending instruction", input) + assert.Equal(t, ColorDefault, ei.curFgColor, "input %q mutated fg color", input) + assert.Equal(t, ColorDefault, ei.curBgColor, "input %q mutated bg color", input) } } From e82dbf0fc6d1e868704ec9290e9406ac92c45c06 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 17:34:36 +0200 Subject: [PATCH 135/384] Addition to AGENTS.md --- AGENTS.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 50b808fb4..7bed30c6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,9 @@ while still being meaningful and self-contained. preceding commit, by staging hunks or resetting and recommitting in order. - **Do not use conventional commits** (no `feat:`/`fix:`/`chore:` prefixes). Match the plain English imperative style of the existing history. +- **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. ## Iterate with `fixup!` commits From adaef97ffe8e230a2c79f6607c892ca3bb678f7f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 17:13:01 +0200 Subject: [PATCH 136/384] Give opts.Stop priority in NewCmdTask's read loop TestNewCmdTaskInstantStop is flaky: it closes the stop channel from within start() and asserts the stopped task touched nothing. But Go's select picks uniformly at random among ready cases, so when opts.Stop and a data channel are both ready the loop can pick the data channel, call beforeStart() (which clears the view) and write the prefix before bailing. In production a task that's already been superseded thereby clobbers the output the incoming task is about to render. Check stop with a non-blocking select before each blocking select, so the stop signal wins whenever it's already closed (Go has no built-in priority select; this is the idiomatic substitute). The selects keep their own stop case for liveness, to unblock when stop closes while parked waiting for data. Co-Authored-By: Claude Opus 4.8 --- pkg/tasks/tasks.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index c2964a8b9..f534d01b4 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -248,8 +248,26 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p } } + // Go's select picks randomly among ready cases, so once opts.Stop is + // 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. + stopped := func() bool { + select { + case <-opts.Stop: + return true + default: + return false + } + } + outer: for { + if stopped() { + break outer + } select { case <-opts.Stop: break outer @@ -260,6 +278,10 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p } } for i := 0; linesToRead.Total == -1 || i < linesToRead.Total; i++ { + if stopped() { + callThen() + break outer + } var ok bool var line []byte select { From d79f6c0b386dd8af64b64f092074549a9c75cc21 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 1 Jul 2026 09:24:19 +0200 Subject: [PATCH 137/384] Add a test for picking both hunks in a diff3 conflict Pressing `b` on a conflict is meant to pick both sides. With the diff3 conflict style, git also renders the common ancestor between the two sides, and `b` currently keeps that ancestor section too, which is wrong: the common base is neither side of the merge and must not end up in the resolved file. This test captures the current (buggy) behaviour so the follow-up fix has a clear before/after. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/conflicts/pick_both_hunks_diff3.go | 44 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 45 insertions(+) create mode 100644 pkg/integration/tests/conflicts/pick_both_hunks_diff3.go diff --git a/pkg/integration/tests/conflicts/pick_both_hunks_diff3.go b/pkg/integration/tests/conflicts/pick_both_hunks_diff3.go new file mode 100644 index 000000000..2df3ab3a3 --- /dev/null +++ b/pkg/integration/tests/conflicts/pick_both_hunks_diff3.go @@ -0,0 +1,44 @@ +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 PickBothHunksDiff3 = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Pick both hunks of a conflict rendered in the diff3 style; the common ancestor must not be included", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.SetConfig("merge.conflictStyle", "diff3") + shared.CreateMergeConflictFile(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("UU file").IsSelected(), + ). + PressEnter() + + t.Views().MergeConflicts(). + IsFocused(). + // the diff3 style renders the common ancestor between the two changes + Content(Contains("<<<<<<< HEAD\nFirst Change")). + Content(Contains("||||||| ")). + Content(Contains("Original")). + Press(keys.Main.PickBothHunks) + + t.Common().ContinueOnConflictsResolved("merge") + + t.Views().Files().IsEmpty() + + t.FileSystem().FileContent("file", + /* EXPECTED: + Equals("\nThis\nIs\nThe\nFirst Change\nSecond Change\nFile\n") + ACTUAL: */ + Equals("\nThis\nIs\nThe\nFirst Change\nOriginal\nSecond Change\nFile\n")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index d7d5d66fa..a6105c646 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -167,6 +167,7 @@ var tests = []*components.IntegrationTest{ conflicts.MergeFileBoth, conflicts.MergeFileCurrent, conflicts.MergeFileIncoming, + conflicts.PickBothHunksDiff3, conflicts.ResolveExternally, conflicts.ResolveMultipleFiles, conflicts.ResolveNoAutoStage, From ed3f4db4f91c4db7f7f6873406ae92240c02ec5f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 1 Jul 2026 09:30:17 +0200 Subject: [PATCH 138/384] Pick both hunks, not the common ancestor, in diff3 conflicts `b` on a merge conflict is meant to keep both sides. With the diff3 conflict style git additionally renders the common ancestor between the two sides, and the old ALL selection kept everything between the outermost markers, dragging that ancestor into the resolved file. Rename the selection from ALL to BOTH and restrict it to the top and bottom hunks so the common base is dropped. Without the diff3 style there is no ancestor section, so the behaviour there is unchanged. The user-facing keybinding config was already named pickBothHunks; only the internal enum, handler, translation and log string still said "all". Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-master/keybindings/Keybindings_en.md | 2 +- docs-master/keybindings/Keybindings_ja.md | 2 +- docs-master/keybindings/Keybindings_ko.md | 2 +- docs-master/keybindings/Keybindings_nl.md | 2 +- docs-master/keybindings/Keybindings_pl.md | 2 +- docs-master/keybindings/Keybindings_pt.md | 2 +- docs-master/keybindings/Keybindings_ru.md | 2 +- docs-master/keybindings/Keybindings_zh-CN.md | 2 +- docs-master/keybindings/Keybindings_zh-TW.md | 2 +- pkg/gui/controllers/merge_conflicts_controller.go | 12 ++++++------ pkg/gui/mergeconflicts/merge_conflict.go | 15 ++++++++++++--- pkg/i18n/english.go | 4 ++-- .../tests/conflicts/pick_both_hunks_diff3.go | 5 +---- 13 files changed, 30 insertions(+), 24 deletions(-) diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index ba9b45c4a..b105b8108 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -205,7 +205,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Pick hunk | | -| `` b `` | Pick all hunks | | +| `` b `` | Pick both hunks | | | `` , k `` | Previous hunk | | | `` , j `` | Next hunk | | | `` , h `` | Previous conflict | | diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index 77283ffd8..a31d93d1a 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -287,7 +287,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | ハンクを選択 | | -| `` b `` | すべてのハンクを選択 | | +| `` b `` | Pick both hunks | | | `` , k `` | 前のハンク | | | `` , j `` | 次のハンク | | | `` , h `` | 前のコンフリクト | | diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index 4463c612a..ebf943868 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -144,7 +144,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Pick hunk | | -| `` b `` | Pick all hunks | | +| `` b `` | Pick both hunks | | | `` , k `` | 이전 hunk를 선택 | | | `` , j `` | 다음 hunk를 선택 | | | `` , h `` | 이전 충돌을 선택 | | diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 16a4856a4..a7d7b09ac 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -213,7 +213,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Kies stuk | | -| `` b `` | Kies beide stukken | | +| `` b `` | Pick both hunks | | | `` , k `` | Selecteer bovenste hunk | | | `` , j `` | Selecteer onderste hunk | | | `` , h `` | Selecteer voorgaand conflict | | diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 06f7af859..1f6710416 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -209,7 +209,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Wybierz fragment | | -| `` b `` | Wybierz wszystkie fragmenty | | +| `` b `` | Pick both hunks | | | `` , k `` | Poprzedni fragment | | | `` , j `` | Następny fragment | | | `` , h `` | Poprzedni konflikt | | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index 2a9e6497c..a2d163735 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -273,7 +273,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Escolha o local | | -| `` b `` | Pegar todos os pedaços | | +| `` b `` | Pick both hunks | | | `` , k `` | Trecho anterior | | | `` , j `` | Próximo trecho | | | `` , h `` | Conflito anterior | | diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index bbd355c23..68be4a601 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -114,7 +114,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Выбрать эту часть | | -| `` b `` | Выбрать все части | | +| `` b `` | Pick both hunks | | | `` , k `` | Выбрать предыдущую часть | | | `` , j `` | Выбрать следующую часть | | | `` , h `` | Выбрать предыдущий конфликт | | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 57d445d5b..6828c35e3 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -294,7 +294,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | 选中区块 | | -| `` b `` | 选中所有区块 | | +| `` b `` | Pick both hunks | | | `` , k `` | 选择顶部块 | | | `` , j `` | 选择底部块 | | | `` , h `` | 选择上一个冲突 | | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index a693281f1..56d383ff9 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -90,7 +90,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | 挑選程式碼片段 | | -| `` b `` | 挑選所有程式碼片段 | | +| `` b `` | Pick both hunks | | | `` , k `` | 選擇上一段 | | | `` , j `` | 選擇下一段 | | | `` , h `` | 選擇上一個衝突 | | diff --git a/pkg/gui/controllers/merge_conflicts_controller.go b/pkg/gui/controllers/merge_conflicts_controller.go index e1e3f8e2c..898eb356b 100644 --- a/pkg/gui/controllers/merge_conflicts_controller.go +++ b/pkg/gui/controllers/merge_conflicts_controller.go @@ -35,8 +35,8 @@ func (self *MergeConflictsController) GetKeybindings(opts types.KeybindingsOpts) }, { Keys: opts.GetKeys(opts.Config.Main.PickBothHunks), - Handler: self.withRenderAndFocus(self.HandlePickAllHunks), - Description: self.c.Tr.PickAllHunks, + Handler: self.withRenderAndFocus(self.HandlePickBothHunks), + Description: self.c.Tr.PickBothHunks, DisplayOnScreen: true, }, { @@ -247,8 +247,8 @@ func (self *MergeConflictsController) HandlePickHunk() error { return self.pickSelection(self.context().GetState().Selection()) } -func (self *MergeConflictsController) HandlePickAllHunks() error { - return self.pickSelection(mergeconflicts.ALL) +func (self *MergeConflictsController) HandlePickBothHunks() error { + return self.pickSelection(mergeconflicts.BOTH) } func (self *MergeConflictsController) pickSelection(selection mergeconflicts.Selection) error { @@ -290,8 +290,8 @@ func (self *MergeConflictsController) resolveConflict(selection mergeconflicts.S logStr = "Picking middle hunk" case mergeconflicts.BOTTOM: logStr = "Picking bottom hunk" - case mergeconflicts.ALL: - logStr = "Picking all hunks" + case mergeconflicts.BOTH: + logStr = "Picking both hunks" } self.c.LogAction("Resolve merge conflict") self.c.LogCommand(logStr, false) diff --git a/pkg/gui/mergeconflicts/merge_conflict.go b/pkg/gui/mergeconflicts/merge_conflict.go index 9b9b72f55..4252aa384 100644 --- a/pkg/gui/mergeconflicts/merge_conflict.go +++ b/pkg/gui/mergeconflicts/merge_conflict.go @@ -28,7 +28,7 @@ const ( TOP Selection = iota MIDDLE BOTTOM - ALL + BOTH ) func (s Selection) isIndexToKeep(conflict *mergeConflict, i int) bool { @@ -56,14 +56,23 @@ func (s Selection) bounds(c *mergeConflict) (int, int) { return c.ancestor, c.target case BOTTOM: return c.target, c.end - case ALL: - return c.start, c.end + case BOTH: + // BOTH spans two disjoint hunks, so it has no single range; callers + // go through selected() instead of asking for its bounds. + panic("BOTH has no single range") } panic("unexpected selection for merge conflict") } func (s Selection) selected(c *mergeConflict, idx int) bool { + // BOTH keeps the top and bottom hunks but drops the common ancestor in + // between (which is only present with the diff3 conflict style), so it + // isn't a single contiguous range like the other selections. + if s == BOTH { + return TOP.selected(c, idx) || BOTTOM.selected(c, idx) + } + start, end := s.bounds(c) return start < idx && idx < end } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 6ddf356cd..db8cdbd5e 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -320,7 +320,7 @@ type TranslationSet struct { ViewConflictsMenuItem string AbortMenuItem string PickHunk string - PickAllHunks string + PickBothHunks string ViewMergeRebaseOptions string ViewMergeRebaseOptionsTooltip string ViewMergeOptions string @@ -1338,7 +1338,7 @@ func EnglishTranslationSet() *TranslationSet { RewordCommitEditor: "Reword with editor", Error: "Error", PickHunk: "Pick hunk", - PickAllHunks: "Pick all hunks", + PickBothHunks: "Pick both hunks", Undo: "Undo", UndoReflog: "Undo", RedoReflog: "Redo", diff --git a/pkg/integration/tests/conflicts/pick_both_hunks_diff3.go b/pkg/integration/tests/conflicts/pick_both_hunks_diff3.go index 2df3ab3a3..59a634c3b 100644 --- a/pkg/integration/tests/conflicts/pick_both_hunks_diff3.go +++ b/pkg/integration/tests/conflicts/pick_both_hunks_diff3.go @@ -36,9 +36,6 @@ var PickBothHunksDiff3 = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Files().IsEmpty() t.FileSystem().FileContent("file", - /* EXPECTED: - Equals("\nThis\nIs\nThe\nFirst Change\nSecond Change\nFile\n") - ACTUAL: */ - Equals("\nThis\nIs\nThe\nFirst Change\nOriginal\nSecond Change\nFile\n")) + Equals("\nThis\nIs\nThe\nFirst Change\nSecond Change\nFile\n")) }, }) From 4ad4f7a0df8c2987555f2f0ccef972b6737efc58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 04:59:14 +0000 Subject: [PATCH 139/384] Bump golang.org/x/sync from 0.20.0 to 0.21.0 Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.20.0 to 0.21.0. - [Commits](https://github.com/golang/sync/compare/v0.20.0...v0.21.0) --- updated-dependencies: - dependency-name: golang.org/x/sync dependency-version: 0.21.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- vendor/golang.org/x/sync/errgroup/errgroup.go | 2 +- vendor/modules.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 52e7da2d5..19629591f 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 - golang.org/x/sync v0.20.0 + golang.org/x/sync v0.21.0 golang.org/x/sys v0.45.0 gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index e92e532e2..1254f9dcf 100644 --- a/go.sum +++ b/go.sum @@ -149,8 +149,8 @@ golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20170407050850-f3918c30c5c2/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/vendor/golang.org/x/sync/errgroup/errgroup.go b/vendor/golang.org/x/sync/errgroup/errgroup.go index f69fd7546..c261a8ebb 100644 --- a/vendor/golang.org/x/sync/errgroup/errgroup.go +++ b/vendor/golang.org/x/sync/errgroup/errgroup.go @@ -109,7 +109,7 @@ func (g *Group) TryGo(f func() error) bool { if g.sem != nil { select { case g.sem <- token{}: - // Note: this allows barging iff channels in general allow barging. + // Note: this allows barging if and only if channels in general allow barging. default: return false } diff --git a/vendor/modules.txt b/vendor/modules.txt index 6a21d6d97..d36ea290b 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -176,7 +176,7 @@ golang.org/x/exp/constraints golang.org/x/exp/slices # golang.org/x/net v0.47.0 ## explicit; go 1.24.0 -# golang.org/x/sync v0.20.0 +# golang.org/x/sync v0.21.0 ## explicit; go 1.25.0 golang.org/x/sync/errgroup # golang.org/x/sys v0.45.0 From 12da065d59280807f6726b01da5e2b9a4646b225 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:03:31 +0000 Subject: [PATCH 140/384] Bump golang.org/x/sys from 0.45.0 to 0.46.0 Bumps [golang.org/x/sys](https://github.com/golang/sys) from 0.45.0 to 0.46.0. - [Commits](https://github.com/golang/sys/compare/v0.45.0...v0.46.0) --- updated-dependencies: - dependency-name: golang.org/x/sys dependency-version: 0.46.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 +- vendor/golang.org/x/sys/unix/ztypes_linux.go | 76 +++++++++++++++++++ .../golang.org/x/sys/unix/ztypes_linux_386.go | 4 + .../x/sys/unix/ztypes_linux_amd64.go | 4 + .../golang.org/x/sys/unix/ztypes_linux_arm.go | 4 + .../x/sys/unix/ztypes_linux_arm64.go | 4 + .../x/sys/unix/ztypes_linux_loong64.go | 4 + .../x/sys/unix/ztypes_linux_mips.go | 4 + .../x/sys/unix/ztypes_linux_mips64.go | 4 + .../x/sys/unix/ztypes_linux_mips64le.go | 4 + .../x/sys/unix/ztypes_linux_mipsle.go | 4 + .../golang.org/x/sys/unix/ztypes_linux_ppc.go | 4 + .../x/sys/unix/ztypes_linux_ppc64.go | 4 + .../x/sys/unix/ztypes_linux_ppc64le.go | 4 + .../x/sys/unix/ztypes_linux_riscv64.go | 4 + .../x/sys/unix/ztypes_linux_s390x.go | 4 + .../x/sys/unix/ztypes_linux_sparc64.go | 4 + vendor/modules.txt | 2 +- 19 files changed, 140 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 19629591f..356f0484d 100644 --- a/go.mod +++ b/go.mod @@ -38,7 +38,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 golang.org/x/sync v0.21.0 - golang.org/x/sys v0.45.0 + golang.org/x/sys v0.46.0 gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 1254f9dcf..97813b97a 100644 --- a/go.sum +++ b/go.sum @@ -162,8 +162,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux.go b/vendor/golang.org/x/sys/unix/ztypes_linux.go index d11d5b96a..526a0d5f4 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux.go @@ -6397,3 +6397,79 @@ const ( MPOL_PREFERRED_MANY = 0x5 MPOL_WEIGHTED_INTERLEAVE = 0x6 ) + +const ( + GPIO_V2_GET_LINEINFO_IOCTL = 0xc100b405 + GPIO_V2_GET_LINE_IOCTL = 0xc250b407 + GPIO_V2_LINE_GET_VALUES_IOCTL = 0xc010b40e + GPIO_V2_LINE_SET_VALUES_IOCTL = 0xc010b40f + GPIO_V2_GET_LINEINFO_WATCH_IOCTL = 0xc100b406 + GPIO_GET_LINEINFO_UNWATCH_IOCTL = 0xc004b40c +) +const ( + GPIO_V2_LINE_ATTR_ID_FLAGS = 0x1 + GPIO_V2_LINE_ATTR_ID_OUTPUT_VALUES = 0x2 + GPIO_V2_LINE_ATTR_ID_DEBOUNCE = 0x3 + GPIO_V2_LINE_CHANGED_REQUESTED = 0x1 + GPIO_V2_LINE_CHANGED_RELEASED = 0x2 + GPIO_V2_LINE_CHANGED_CONFIG = 0x3 + GPIO_V2_LINE_EVENT_RISING_EDGE = 0x1 + GPIO_V2_LINE_EVENT_FALLING_EDGE = 0x2 +) + +type GPIOChipInfo struct { + Name [32]byte + Label [32]byte + Lines uint32 +} +type GPIOV2LineValues struct { + Bits uint64 + Mask uint64 +} +type GPIOV2LineAttribute struct { + Id uint32 + _ uint32 + Flags uint64 +} +type GPIOV2LineConfigAttribute struct { + Attr GPIOV2LineAttribute + Mask uint64 +} +type GPIOV2LineConfig struct { + Flags uint64 + Num_attrs uint32 + _ [5]uint32 + Attrs [10]GPIOV2LineConfigAttribute +} +type GPIOV2LineRequest struct { + Offsets [64]uint32 + Consumer [32]byte + Config GPIOV2LineConfig + Num_lines uint32 + Event_buffer_size uint32 + _ [5]uint32 + Fd int32 +} +type GPIOV2LineInfo struct { + Name [32]byte + Consumer [32]byte + Offset uint32 + Num_attrs uint32 + Flags uint64 + Attrs [10]GPIOV2LineAttribute + _ [4]uint32 +} +type GPIOV2LineInfoChanged struct { + Info GPIOV2LineInfo + Timestamp_ns uint64 + Event_type uint32 + _ [5]uint32 +} +type GPIOV2LineEvent struct { + Timestamp_ns uint64 + Id uint32 + Offset uint32 + Seqno uint32 + Line_seqno uint32 + _ [6]uint32 +} diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_386.go b/vendor/golang.org/x/sys/unix/ztypes_linux_386.go index 97ef790de..aede1de7f 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_386.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_386.go @@ -711,3 +711,7 @@ type SysvShmDesc struct { _ uint32 _ uint32 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go index 90b50da68..bb3bc4dc2 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_amd64.go @@ -725,3 +725,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go b/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go index acda13685..1fdf4c517 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_arm.go @@ -705,3 +705,7 @@ type SysvShmDesc struct { _ uint32 _ uint32 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go index ef7a99e1f..063e6f0b4 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_arm64.go @@ -704,3 +704,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go index 966063dfc..9cf836c70 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_loong64.go @@ -705,3 +705,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go index dc53b20b7..1d222fcb3 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mips.go @@ -710,3 +710,7 @@ type SysvShmDesc struct { Ctime_high uint16 _ uint16 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go index 9ad0aa8c3..912cc4ab6 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64.go @@ -707,3 +707,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go index 29d55493d..1e358ef34 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mips64le.go @@ -707,3 +707,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go b/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go index a4d9e1584..df59f32f5 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_mipsle.go @@ -710,3 +710,7 @@ type SysvShmDesc struct { Ctime_high uint16 _ uint16 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go index f8a297771..29355aa0b 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc.go @@ -718,3 +718,7 @@ type SysvShmDesc struct { _ uint32 _ [4]byte } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go index 4158d6c4e..c6083a15d 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64.go @@ -713,3 +713,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go index 1035af49f..6321cc762 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_ppc64le.go @@ -713,3 +713,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go index 2297125d3..b44f402fe 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_riscv64.go @@ -792,3 +792,7 @@ const ( RISCV_HWPROBE_KEY_ZICBOZ_BLOCK_SIZE = 0x6 RISCV_HWPROBE_WHICH_CPUS = 0x1 ) + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go b/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go index 8481e9bd9..b22c795a6 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_s390x.go @@ -727,3 +727,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x8044b401 +) diff --git a/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go b/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go index a6828a031..0b18075b5 100644 --- a/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go +++ b/vendor/golang.org/x/sys/unix/ztypes_linux_sparc64.go @@ -708,3 +708,7 @@ type SysvShmDesc struct { _ uint64 _ uint64 } + +const ( + GPIO_GET_CHIPINFO_IOCTL = 0x4044b401 +) diff --git a/vendor/modules.txt b/vendor/modules.txt index d36ea290b..60418e486 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -179,7 +179,7 @@ golang.org/x/exp/slices # golang.org/x/sync v0.21.0 ## explicit; go 1.25.0 golang.org/x/sync/errgroup -# golang.org/x/sys v0.45.0 +# golang.org/x/sys v0.46.0 ## explicit; go 1.25.0 golang.org/x/sys/plan9 golang.org/x/sys/unix From cfd8919c8740db2f23e53d4f0177d49df24f6893 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:08:14 +0000 Subject: [PATCH 141/384] Bump github.com/sahilm/fuzzy from 0.1.2 to 0.1.3 Bumps [github.com/sahilm/fuzzy](https://github.com/sahilm/fuzzy) from 0.1.2 to 0.1.3. - [Release notes](https://github.com/sahilm/fuzzy/releases) - [Commits](https://github.com/sahilm/fuzzy/compare/v0.1.2...v0.1.3) --- updated-dependencies: - dependency-name: github.com/sahilm/fuzzy dependency-version: 0.1.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 +-- vendor/github.com/sahilm/fuzzy/fuzzy.go | 34 +++++++++++++++++++++++-- vendor/modules.txt | 2 +- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 356f0484d..87472bc6a 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/mgutz/str v1.2.0 github.com/mitchellh/go-ps v1.0.0 github.com/rivo/uniseg v0.4.7 - github.com/sahilm/fuzzy v0.1.2 + github.com/sahilm/fuzzy v0.1.3 github.com/samber/lo v1.53.0 github.com/sanity-io/litter v1.5.8 github.com/sasha-s/go-deadlock v0.3.9 diff --git a/go.sum b/go.sum index 97813b97a..87d4ed0da 100644 --- a/go.sum +++ b/go.sum @@ -107,8 +107,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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/sahilm/fuzzy v0.1.2 h1:kdSkz23lx1meNjEl+SLJULeSbjTI4Dn14K/YxdGrIww= -github.com/sahilm/fuzzy v0.1.2/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= +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= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg= diff --git a/vendor/github.com/sahilm/fuzzy/fuzzy.go b/vendor/github.com/sahilm/fuzzy/fuzzy.go index 54eb98fb2..38853103a 100644 --- a/vendor/github.com/sahilm/fuzzy/fuzzy.go +++ b/vendor/github.com/sahilm/fuzzy/fuzzy.go @@ -6,6 +6,7 @@ VSCode, IntelliJ IDEA et al. package fuzzy import ( + "iter" "sort" "strings" "unicode" @@ -60,6 +61,16 @@ func (ss stringSource) String(i int) string { func (ss stringSource) Len() int { return len(ss) } +func iterFromSource(s Source) iter.Seq[string] { + return func(yield func(string) bool) { + for i := 0; i < s.Len(); i++ { + if !yield(s.String(i)) { + return + } + } + } +} + /* Find looks up pattern in data and returns matches in descending order of match quality. Match quality @@ -107,15 +118,33 @@ FindFromNoSort is an alternative FindFrom implementation that does not sort results in the end. */ func FindFromNoSort(pattern string, data Source) Matches { + return FindFromIterNoSort(pattern, iterFromSource(data)) +} + +/* +FindFromIter is an alternative implementation of FindFrom that uses an iterator +instead of Source. +*/ +func FindFromIter(pattern string, it iter.Seq[string]) Matches { + matches := FindFromIterNoSort(pattern, it) + sort.Stable(matches) + return matches +} + +/* +FindFromIterNoSort is an alternative implementation of FindFromIter that does +not sort results in the end. +*/ +func FindFromIterNoSort(pattern string, it iter.Seq[string]) Matches { if len(pattern) == 0 { return nil } runes := []rune(pattern) var matches Matches var matchedIndexes []int - for i := 0; i < data.Len(); i++ { + var i int + for matchStr := range it { var match Match - matchStr := data.String(i) match.Str = matchStr // Limit matching to the first NUL rune, if any. We could maybe replace it // with whitespace, but this way doesn't allocate so much, and the presence @@ -125,6 +154,7 @@ func FindFromNoSort(pattern string, data Source) Matches { cleanMatchStr = cleanMatchStr[:nullI] } match.Index = i + i++ if matchedIndexes != nil { match.MatchedIndexes = matchedIndexes } else { diff --git a/vendor/modules.txt b/vendor/modules.txt index 60418e486..2e8c09a73 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -130,7 +130,7 @@ github.com/pmezard/go-difflib/difflib github.com/rivo/uniseg # github.com/rogpeppe/go-internal v1.14.1 ## explicit; go 1.23 -# github.com/sahilm/fuzzy v0.1.2 +# github.com/sahilm/fuzzy v0.1.3 ## explicit; go 1.24.5 github.com/sahilm/fuzzy # github.com/samber/lo v1.53.0 From 450c3c51afae9e05d0918cf9e2b189f22e99bbe6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:20:45 +0000 Subject: [PATCH 142/384] Bump actions/cache from 5 to 6 Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03935818d..12cc19cb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: - name: Restore Git cache if: matrix.git-version != 'latest' id: cache-git-restore - uses: actions/cache/restore@v5 + uses: actions/cache/restore@v6 with: path: ~/git-${{matrix.git-version}} key: ${{runner.os}}-git-${{matrix.git-version}} @@ -80,7 +80,7 @@ jobs: run: sudo make -C "$HOME/git-${{matrix.git-version}}" -j install - name: Save Git cache if: steps.cache-git-restore.outputs.cache-hit != 'true' && matrix.git-version != 'latest' - uses: actions/cache/save@v5 + uses: actions/cache/save@v6 with: path: ~/git-${{matrix.git-version}} key: ${{runner.os}}-git-${{matrix.git-version}} From 943a4426a180ce6580541cac8b7930ea3626ebd9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:24:40 +0000 Subject: [PATCH 143/384] Bump actions/checkout from 6 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 14 +++++++------- .github/workflows/codespell.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/sponsors.yml | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12cc19cb3..f00d34360 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: GOFLAGS: -mod=vendor steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Go uses: actions/setup-go@v6 with: @@ -59,7 +59,7 @@ jobs: GOFLAGS: -mod=vendor steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Restore Git cache if: matrix.git-version != 'latest' id: cache-git-restore @@ -109,7 +109,7 @@ jobs: GOARCH: amd64 steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Go uses: actions/setup-go@v6 with: @@ -136,7 +136,7 @@ jobs: GOARCH: amd64 steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Go uses: actions/setup-go@v6 with: @@ -162,7 +162,7 @@ jobs: GOFLAGS: -mod=vendor steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Go uses: actions/setup-go@v6 with: @@ -182,7 +182,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Go uses: actions/setup-go@v6 @@ -221,7 +221,7 @@ jobs: run: echo "PR_FETCH_DEPTH=$(( ${{ github.event.pull_request.commits }} ))" >> "${GITHUB_ENV}" - name: "Checkout PR branch and all PR commits" - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.ref }} diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 27cbea555..82fcf470e 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Annotate locations with typos uses: codespell-project/codespell-problem-matcher@9ba2c57125d4908eade4308f32c4ff814c184633 # v1.2.0 - name: Codespell diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c4ef31b9..0e96eac7e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,7 @@ jobs: fi - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: repository: jesseduffield/lazygit token: ${{ secrets.LAZYGIT_RELEASE_PAT }} diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml index 6b876bb31..611ad553d 100644 --- a/.github/workflows/sponsors.yml +++ b/.github/workflows/sponsors.yml @@ -10,7 +10,7 @@ jobs: if: ${{ github.repository == 'jesseduffield/lazygit' }} steps: - name: Checkout 🛎️ - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Generate Sponsors 💖 uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1.6.0 From 22810c920ef88c84eac18f962e899638dca69764 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:29:20 +0000 Subject: [PATCH 144/384] Bump goreleaser/goreleaser-action from 7.2.2 to 7.2.3 Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.2.2 to 7.2.3. - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89...f06c13b6b1a9625abc9e6e439d9c05a8f2190e94) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-version: 7.2.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e96eac7e..9b9815d44 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -159,7 +159,7 @@ jobs: go-version: 1.25.x - name: Run goreleaser - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: distribution: goreleaser version: v2 From 0e2824baac323683e81ba01613088d1b274153b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:08:35 +0000 Subject: [PATCH 145/384] Bump golangci/golangci-lint-action from 9.2.0 to 9.3.0 Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 9.2.0 to 9.3.0. - [Release notes](https://github.com/golangci/golangci-lint-action/releases) - [Commits](https://github.com/golangci/golangci-lint-action/compare/1e7e51e771db61008b38414a730f564565cf7c20...ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a) --- updated-dependencies: - dependency-name: golangci/golangci-lint-action dependency-version: 9.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f00d34360..eccaf4f4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,7 +168,7 @@ jobs: with: go-version: 1.25.x - name: Lint - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 + 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 From 3094f6621b9cc7234c7941b8108b9e5222c7f9eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:29:12 +0000 Subject: [PATCH 146/384] Bump golang.org/x/net from 0.47.0 to 0.55.0 Bumps [golang.org/x/net](https://github.com/golang/net) from 0.47.0 to 0.55.0. - [Commits](https://github.com/golang/net/compare/v0.47.0...v0.55.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.55.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- vendor/modules.txt | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 87472bc6a..eb0f88e18 100644 --- a/go.mod +++ b/go.mod @@ -67,7 +67,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/go.sum b/go.sum index 87d4ed0da..dcdf0b174 100644 --- a/go.sum +++ b/go.sum @@ -144,8 +144,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL 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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/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= diff --git a/vendor/modules.txt b/vendor/modules.txt index 2e8c09a73..9299f6ac0 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -174,8 +174,8 @@ github.com/xo/terminfo ## explicit; go 1.20 golang.org/x/exp/constraints golang.org/x/exp/slices -# golang.org/x/net v0.47.0 -## explicit; go 1.24.0 +# golang.org/x/net v0.55.0 +## explicit; go 1.25.0 # golang.org/x/sync v0.21.0 ## explicit; go 1.25.0 golang.org/x/sync/errgroup From 73aec4218982d448dffb942e819fa191fe5ae620 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 09:59:40 +0200 Subject: [PATCH 147/384] Remove obsolete bump_gocui.sh script We copied the gocui sources into lazygit a while ago, so we no longer need this. --- scripts/bump_gocui.sh | 7 ------- 1 file changed, 7 deletions(-) delete mode 100755 scripts/bump_gocui.sh diff --git a/scripts/bump_gocui.sh b/scripts/bump_gocui.sh deleted file mode 100755 index 13a1f575a..000000000 --- a/scripts/bump_gocui.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/sh - -# Go's proxy servers are not very up-to-date so that's why we use `GOPROXY=direct` -# We specify the `master` branch to avoid the default behaviour of looking for a semver tag. -GOPROXY=direct go get -u github.com/jesseduffield/gocui@master && go mod vendor && go mod tidy - -# Note to self if you ever want to fork a repo be sure to use this same approach: it's important to use the branch name (e.g. master) From 21ae632c9b24256468e5ff609d0378c72d3d0e0a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 10:41:00 +0200 Subject: [PATCH 148/384] Remove obsolete "errors" step from lint job on CI When this was added (dafac52a4c201), the job used golangci-lint-action@v2, which had the problem that its step log was sparse (it mostly produced PR annotations), so re-running `golanci-lint run` was a workaround to dump readable errors into the console. The current version of the action no longer has this problem, so we can remove that fallback (it would conflict with what we are about to do in this branch). --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eccaf4f4d..2f7c1d2d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -172,9 +172,6 @@ jobs: with: # If you change this, make sure to also update scripts/golangci-lint-shim.sh version: v2.4.0 - - name: errors - run: golangci-lint run - if: ${{ failure() }} upload-coverage: # List all jobs that produce coverage files needs: [unit-tests, integration-tests] From 659908195ea1189bea28d64d87f904e46b288ace Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 07:10:38 +0200 Subject: [PATCH 149/384] go get -tool mvdan.cc/gofumpt@v0.9.2 --- go.mod | 7 +- go.sum | 11 +- vendor/github.com/google/go-cmp/LICENSE | 27 + .../github.com/google/go-cmp/cmp/compare.go | 671 ++++++ vendor/github.com/google/go-cmp/cmp/export.go | 31 + .../go-cmp/cmp/internal/diff/debug_disable.go | 18 + .../go-cmp/cmp/internal/diff/debug_enable.go | 123 + .../google/go-cmp/cmp/internal/diff/diff.go | 402 ++++ .../google/go-cmp/cmp/internal/flags/flags.go | 9 + .../go-cmp/cmp/internal/function/func.go | 106 + .../google/go-cmp/cmp/internal/value/name.go | 164 ++ .../go-cmp/cmp/internal/value/pointer.go | 34 + .../google/go-cmp/cmp/internal/value/sort.go | 106 + .../github.com/google/go-cmp/cmp/options.go | 562 +++++ vendor/github.com/google/go-cmp/cmp/path.go | 390 ++++ vendor/github.com/google/go-cmp/cmp/report.go | 54 + .../google/go-cmp/cmp/report_compare.go | 433 ++++ .../google/go-cmp/cmp/report_references.go | 264 +++ .../google/go-cmp/cmp/report_reflect.go | 414 ++++ .../google/go-cmp/cmp/report_slices.go | 614 +++++ .../google/go-cmp/cmp/report_text.go | 432 ++++ .../google/go-cmp/cmp/report_value.go | 121 + vendor/golang.org/x/mod/LICENSE | 27 + vendor/golang.org/x/mod/PATENTS | 22 + .../x/mod/internal/lazyregexp/lazyre.go | 78 + vendor/golang.org/x/mod/modfile/print.go | 184 ++ vendor/golang.org/x/mod/modfile/read.go | 964 ++++++++ vendor/golang.org/x/mod/modfile/rule.go | 1904 ++++++++++++++++ vendor/golang.org/x/mod/modfile/work.go | 333 +++ vendor/golang.org/x/mod/module/module.go | 840 +++++++ vendor/golang.org/x/mod/module/pseudo.go | 250 +++ vendor/golang.org/x/mod/semver/semver.go | 407 ++++ .../golang.org/x/sync/semaphore/semaphore.go | 160 ++ vendor/golang.org/x/tools/LICENSE | 27 + vendor/golang.org/x/tools/PATENTS | 22 + .../x/tools/go/ast/astutil/enclosing.go | 663 ++++++ .../x/tools/go/ast/astutil/imports.go | 487 ++++ .../x/tools/go/ast/astutil/rewrite.go | 490 ++++ .../golang.org/x/tools/go/ast/astutil/util.go | 13 + vendor/modules.txt | 28 +- vendor/mvdan.cc/gofumpt/.gitattributes | 2 + vendor/mvdan.cc/gofumpt/CHANGELOG.md | 217 ++ vendor/mvdan.cc/gofumpt/LICENSE | 27 + vendor/mvdan.cc/gofumpt/LICENSE.google | 27 + vendor/mvdan.cc/gofumpt/README.md | 698 ++++++ vendor/mvdan.cc/gofumpt/doc.go | 5 + vendor/mvdan.cc/gofumpt/format/format.go | 1136 ++++++++++ vendor/mvdan.cc/gofumpt/format/rewrite.go | 113 + vendor/mvdan.cc/gofumpt/format/simplify.go | 169 ++ vendor/mvdan.cc/gofumpt/gofmt.go | 697 ++++++ vendor/mvdan.cc/gofumpt/internal.go | 177 ++ .../gofumpt/internal/govendor/diff/diff.go | 261 +++ .../internal/govendor/go/doc/comment/doc.go | 36 + .../internal/govendor/go/doc/comment/html.go | 169 ++ .../govendor/go/doc/comment/markdown.go | 188 ++ .../internal/govendor/go/doc/comment/parse.go | 1260 +++++++++++ .../internal/govendor/go/doc/comment/print.go | 288 +++ .../internal/govendor/go/doc/comment/std.go | 51 + .../internal/govendor/go/doc/comment/text.go | 337 +++ .../internal/govendor/go/format/format.go | 134 ++ .../internal/govendor/go/format/internal.go | 177 ++ .../internal/govendor/go/printer/comment.go | 156 ++ .../internal/govendor/go/printer/gobuild.go | 170 ++ .../internal/govendor/go/printer/nodes.go | 1999 +++++++++++++++++ .../internal/govendor/go/printer/printer.go | 1432 ++++++++++++ .../gofumpt/internal/version/version.go | 57 + 66 files changed, 21866 insertions(+), 9 deletions(-) create mode 100644 vendor/github.com/google/go-cmp/LICENSE create mode 100644 vendor/github.com/google/go-cmp/cmp/compare.go create mode 100644 vendor/github.com/google/go-cmp/cmp/export.go create mode 100644 vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go create mode 100644 vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go create mode 100644 vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go create mode 100644 vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go create mode 100644 vendor/github.com/google/go-cmp/cmp/internal/function/func.go create mode 100644 vendor/github.com/google/go-cmp/cmp/internal/value/name.go create mode 100644 vendor/github.com/google/go-cmp/cmp/internal/value/pointer.go create mode 100644 vendor/github.com/google/go-cmp/cmp/internal/value/sort.go create mode 100644 vendor/github.com/google/go-cmp/cmp/options.go create mode 100644 vendor/github.com/google/go-cmp/cmp/path.go create mode 100644 vendor/github.com/google/go-cmp/cmp/report.go create mode 100644 vendor/github.com/google/go-cmp/cmp/report_compare.go create mode 100644 vendor/github.com/google/go-cmp/cmp/report_references.go create mode 100644 vendor/github.com/google/go-cmp/cmp/report_reflect.go create mode 100644 vendor/github.com/google/go-cmp/cmp/report_slices.go create mode 100644 vendor/github.com/google/go-cmp/cmp/report_text.go create mode 100644 vendor/github.com/google/go-cmp/cmp/report_value.go create mode 100644 vendor/golang.org/x/mod/LICENSE create mode 100644 vendor/golang.org/x/mod/PATENTS create mode 100644 vendor/golang.org/x/mod/internal/lazyregexp/lazyre.go create mode 100644 vendor/golang.org/x/mod/modfile/print.go create mode 100644 vendor/golang.org/x/mod/modfile/read.go create mode 100644 vendor/golang.org/x/mod/modfile/rule.go create mode 100644 vendor/golang.org/x/mod/modfile/work.go create mode 100644 vendor/golang.org/x/mod/module/module.go create mode 100644 vendor/golang.org/x/mod/module/pseudo.go create mode 100644 vendor/golang.org/x/mod/semver/semver.go create mode 100644 vendor/golang.org/x/sync/semaphore/semaphore.go create mode 100644 vendor/golang.org/x/tools/LICENSE create mode 100644 vendor/golang.org/x/tools/PATENTS create mode 100644 vendor/golang.org/x/tools/go/ast/astutil/enclosing.go create mode 100644 vendor/golang.org/x/tools/go/ast/astutil/imports.go create mode 100644 vendor/golang.org/x/tools/go/ast/astutil/rewrite.go create mode 100644 vendor/golang.org/x/tools/go/ast/astutil/util.go create mode 100644 vendor/mvdan.cc/gofumpt/.gitattributes create mode 100644 vendor/mvdan.cc/gofumpt/CHANGELOG.md create mode 100644 vendor/mvdan.cc/gofumpt/LICENSE create mode 100644 vendor/mvdan.cc/gofumpt/LICENSE.google create mode 100644 vendor/mvdan.cc/gofumpt/README.md create mode 100644 vendor/mvdan.cc/gofumpt/doc.go create mode 100644 vendor/mvdan.cc/gofumpt/format/format.go create mode 100644 vendor/mvdan.cc/gofumpt/format/rewrite.go create mode 100644 vendor/mvdan.cc/gofumpt/format/simplify.go create mode 100644 vendor/mvdan.cc/gofumpt/gofmt.go create mode 100644 vendor/mvdan.cc/gofumpt/internal.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/diff/diff.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/doc.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/html.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/markdown.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/parse.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/print.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/std.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/text.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/go/format/format.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/go/format/internal.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/comment.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/gobuild.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/nodes.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/printer.go create mode 100644 vendor/mvdan.cc/gofumpt/internal/version/version.go diff --git a/go.mod b/go.mod index eb0f88e18..6820d941d 100644 --- a/go.mod +++ b/go.mod @@ -57,7 +57,6 @@ require ( 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 - github.com/kr/pretty v0.3.1 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -65,12 +64,16 @@ require ( github.com/onsi/gomega v1.34.1 // indirect github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/term v0.43.0 // indirect golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect 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 ) + +tool mvdan.cc/gofumpt diff --git a/go.sum b/go.sum index dcdf0b174..1ca3151b4 100644 --- a/go.sum +++ b/go.sum @@ -21,7 +21,6 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 h1:tuijfIjZyjZaHq9xDUh0tNitwXshJpbLkqMOJv4H3do= github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21/go.mod h1:po7NpZ/QiTKzBKyrsEAxwnTamCoh8uDk/egRpQ7siIc= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -40,6 +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/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= @@ -98,13 +99,11 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE= github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU= @@ -140,6 +139,8 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/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= @@ -180,6 +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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= 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= @@ -193,3 +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= diff --git a/vendor/github.com/google/go-cmp/LICENSE b/vendor/github.com/google/go-cmp/LICENSE new file mode 100644 index 000000000..32017f8fa --- /dev/null +++ b/vendor/github.com/google/go-cmp/LICENSE @@ -0,0 +1,27 @@ +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 new file mode 100644 index 000000000..0f5b8a48c --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/compare.go @@ -0,0 +1,671 @@ +// 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 new file mode 100644 index 000000000..29f82fe6b --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/export.go @@ -0,0 +1,31 @@ +// 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 new file mode 100644 index 000000000..36062a604 --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go @@ -0,0 +1,18 @@ +// 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 new file mode 100644 index 000000000..a3b97a1ad --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go @@ -0,0 +1,123 @@ +// 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 new file mode 100644 index 000000000..a248e5436 --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/internal/diff/diff.go @@ -0,0 +1,402 @@ +// 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 new file mode 100644 index 000000000..d8e459c9b --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/internal/flags/flags.go @@ -0,0 +1,9 @@ +// 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 new file mode 100644 index 000000000..def01a6be --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/internal/function/func.go @@ -0,0 +1,106 @@ +// 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 new file mode 100644 index 000000000..7b498bb2c --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/internal/value/name.go @@ -0,0 +1,164 @@ +// 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 new file mode 100644 index 000000000..e5dfff69a --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/internal/value/pointer.go @@ -0,0 +1,34 @@ +// 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 new file mode 100644 index 000000000..98533b036 --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/internal/value/sort.go @@ -0,0 +1,106 @@ +// 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 new file mode 100644 index 000000000..ba3fce81f --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/options.go @@ -0,0 +1,562 @@ +// 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 new file mode 100644 index 000000000..c3c145642 --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/path.go @@ -0,0 +1,390 @@ +// 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 new file mode 100644 index 000000000..f43cd12eb --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/report.go @@ -0,0 +1,54 @@ +// 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 new file mode 100644 index 000000000..2050bf6b4 --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/report_compare.go @@ -0,0 +1,433 @@ +// 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 new file mode 100644 index 000000000..be31b33a9 --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/report_references.go @@ -0,0 +1,264 @@ +// 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 new file mode 100644 index 000000000..e39f42284 --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/report_reflect.go @@ -0,0 +1,414 @@ +// 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 new file mode 100644 index 000000000..23e444f62 --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/report_slices.go @@ -0,0 +1,614 @@ +// 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 new file mode 100644 index 000000000..388fcf571 --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/report_text.go @@ -0,0 +1,432 @@ +// 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 new file mode 100644 index 000000000..668d470fd --- /dev/null +++ b/vendor/github.com/google/go-cmp/cmp/report_value.go @@ -0,0 +1,121 @@ +// 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/golang.org/x/mod/LICENSE b/vendor/golang.org/x/mod/LICENSE new file mode 100644 index 000000000..2a7cf70da --- /dev/null +++ b/vendor/golang.org/x/mod/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +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 LLC 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/golang.org/x/mod/PATENTS b/vendor/golang.org/x/mod/PATENTS new file mode 100644 index 000000000..733099041 --- /dev/null +++ b/vendor/golang.org/x/mod/PATENTS @@ -0,0 +1,22 @@ +Additional IP Rights Grant (Patents) + +"This implementation" means the copyrightable works distributed by +Google as part of the Go project. + +Google hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) +patent license to make, have made, use, offer to sell, sell, import, +transfer and otherwise run, modify and propagate the contents of this +implementation of Go, where such license applies only to those patent +claims, both currently owned or controlled by Google and acquired in +the future, licensable by Google that are necessarily infringed by this +implementation of Go. This grant does not include claims that would be +infringed only as a consequence of further modification of this +implementation. If you or your agent or exclusive licensee institute or +order or agree to the institution of patent litigation against any +entity (including a cross-claim or counterclaim in a lawsuit) alleging +that this implementation of Go or any code incorporated within this +implementation of Go constitutes direct or contributory patent +infringement, or inducement of patent infringement, then any patent +rights granted to you under this License for this implementation of Go +shall terminate as of the date such litigation is filed. diff --git a/vendor/golang.org/x/mod/internal/lazyregexp/lazyre.go b/vendor/golang.org/x/mod/internal/lazyregexp/lazyre.go new file mode 100644 index 000000000..150f887e7 --- /dev/null +++ b/vendor/golang.org/x/mod/internal/lazyregexp/lazyre.go @@ -0,0 +1,78 @@ +// 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 lazyregexp is a thin wrapper over regexp, allowing the use of global +// regexp variables without forcing them to be compiled at init. +package lazyregexp + +import ( + "os" + "regexp" + "strings" + "sync" +) + +// Regexp is a wrapper around [regexp.Regexp], where the underlying regexp will be +// compiled the first time it is needed. +type Regexp struct { + str string + once sync.Once + rx *regexp.Regexp +} + +func (r *Regexp) re() *regexp.Regexp { + r.once.Do(r.build) + return r.rx +} + +func (r *Regexp) build() { + r.rx = regexp.MustCompile(r.str) + r.str = "" +} + +func (r *Regexp) FindSubmatch(s []byte) [][]byte { + return r.re().FindSubmatch(s) +} + +func (r *Regexp) FindStringSubmatch(s string) []string { + return r.re().FindStringSubmatch(s) +} + +func (r *Regexp) FindStringSubmatchIndex(s string) []int { + return r.re().FindStringSubmatchIndex(s) +} + +func (r *Regexp) ReplaceAllString(src, repl string) string { + return r.re().ReplaceAllString(src, repl) +} + +func (r *Regexp) FindString(s string) string { + return r.re().FindString(s) +} + +func (r *Regexp) FindAllString(s string, n int) []string { + return r.re().FindAllString(s, n) +} + +func (r *Regexp) MatchString(s string) bool { + return r.re().MatchString(s) +} + +func (r *Regexp) SubexpNames() []string { + return r.re().SubexpNames() +} + +var inTest = len(os.Args) > 0 && strings.HasSuffix(strings.TrimSuffix(os.Args[0], ".exe"), ".test") + +// New creates a new lazy regexp, delaying the compiling work until it is first +// needed. If the code is being run as part of tests, the regexp compiling will +// happen immediately. +func New(str string) *Regexp { + lr := &Regexp{str: str} + if inTest { + // In tests, always compile the regexps early. + lr.re() + } + return lr +} diff --git a/vendor/golang.org/x/mod/modfile/print.go b/vendor/golang.org/x/mod/modfile/print.go new file mode 100644 index 000000000..48dbd82ae --- /dev/null +++ b/vendor/golang.org/x/mod/modfile/print.go @@ -0,0 +1,184 @@ +// 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. + +// Module file printer. + +package modfile + +import ( + "bytes" + "fmt" + "strings" +) + +// Format returns a go.mod file as a byte slice, formatted in standard style. +func Format(f *FileSyntax) []byte { + pr := &printer{} + pr.file(f) + + // remove trailing blank lines + b := pr.Bytes() + for len(b) > 0 && b[len(b)-1] == '\n' && (len(b) == 1 || b[len(b)-2] == '\n') { + b = b[:len(b)-1] + } + return b +} + +// A printer collects the state during printing of a file or expression. +type printer struct { + bytes.Buffer // output buffer + comment []Comment // pending end-of-line comments + margin int // left margin (indent), a number of tabs +} + +// printf prints to the buffer. +func (p *printer) printf(format string, args ...any) { + fmt.Fprintf(p, format, args...) +} + +// indent returns the position on the current line, in bytes, 0-indexed. +func (p *printer) indent() int { + b := p.Bytes() + n := 0 + for n < len(b) && b[len(b)-1-n] != '\n' { + n++ + } + return n +} + +// newline ends the current line, flushing end-of-line comments. +func (p *printer) newline() { + if len(p.comment) > 0 { + p.printf(" ") + for i, com := range p.comment { + if i > 0 { + p.trim() + p.printf("\n") + for i := 0; i < p.margin; i++ { + p.printf("\t") + } + } + p.printf("%s", strings.TrimSpace(com.Token)) + } + p.comment = p.comment[:0] + } + + p.trim() + if b := p.Bytes(); len(b) == 0 || (len(b) >= 2 && b[len(b)-1] == '\n' && b[len(b)-2] == '\n') { + // skip the blank line at top of file or after a blank line + } else { + p.printf("\n") + } + for i := 0; i < p.margin; i++ { + p.printf("\t") + } +} + +// trim removes trailing spaces and tabs from the current line. +func (p *printer) trim() { + // Remove trailing spaces and tabs from line we're about to end. + b := p.Bytes() + n := len(b) + for n > 0 && (b[n-1] == '\t' || b[n-1] == ' ') { + n-- + } + p.Truncate(n) +} + +// file formats the given file into the print buffer. +func (p *printer) file(f *FileSyntax) { + for _, com := range f.Before { + p.printf("%s", strings.TrimSpace(com.Token)) + p.newline() + } + + for i, stmt := range f.Stmt { + switch x := stmt.(type) { + case *CommentBlock: + // comments already handled + p.expr(x) + + default: + p.expr(x) + p.newline() + } + + for _, com := range stmt.Comment().After { + p.printf("%s", strings.TrimSpace(com.Token)) + p.newline() + } + + if i+1 < len(f.Stmt) { + p.newline() + } + } +} + +func (p *printer) expr(x Expr) { + // Emit line-comments preceding this expression. + if before := x.Comment().Before; len(before) > 0 { + // Want to print a line comment. + // Line comments must be at the current margin. + p.trim() + if p.indent() > 0 { + // There's other text on the line. Start a new line. + p.printf("\n") + } + // Re-indent to margin. + for i := 0; i < p.margin; i++ { + p.printf("\t") + } + for _, com := range before { + p.printf("%s", strings.TrimSpace(com.Token)) + p.newline() + } + } + + switch x := x.(type) { + default: + panic(fmt.Errorf("printer: unexpected type %T", x)) + + case *CommentBlock: + // done + + case *LParen: + p.printf("(") + case *RParen: + p.printf(")") + + case *Line: + p.tokens(x.Token) + + case *LineBlock: + p.tokens(x.Token) + p.printf(" ") + p.expr(&x.LParen) + p.margin++ + for _, l := range x.Line { + p.newline() + p.expr(l) + } + p.margin-- + p.newline() + p.expr(&x.RParen) + } + + // Queue end-of-line comments for printing when we + // reach the end of the line. + p.comment = append(p.comment, x.Comment().Suffix...) +} + +func (p *printer) tokens(tokens []string) { + sep := "" + for _, t := range tokens { + if t == "," || t == ")" || t == "]" || t == "}" { + sep = "" + } + p.printf("%s%s", sep, t) + sep = " " + if t == "(" || t == "[" || t == "{" { + sep = "" + } + } +} diff --git a/vendor/golang.org/x/mod/modfile/read.go b/vendor/golang.org/x/mod/modfile/read.go new file mode 100644 index 000000000..504a2f1df --- /dev/null +++ b/vendor/golang.org/x/mod/modfile/read.go @@ -0,0 +1,964 @@ +// 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 modfile + +import ( + "bytes" + "errors" + "fmt" + "os" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +// A Position describes an arbitrary source position in a file, including the +// file, line, column, and byte offset. +type Position struct { + Line int // line in input (starting at 1) + LineRune int // rune in line (starting at 1) + Byte int // byte in input (starting at 0) +} + +// add returns the position at the end of s, assuming it starts at p. +func (p Position) add(s string) Position { + p.Byte += len(s) + if n := strings.Count(s, "\n"); n > 0 { + p.Line += n + s = s[strings.LastIndex(s, "\n")+1:] + p.LineRune = 1 + } + p.LineRune += utf8.RuneCountInString(s) + return p +} + +// An Expr represents an input element. +type Expr interface { + // Span returns the start and end position of the expression, + // excluding leading or trailing comments. + Span() (start, end Position) + + // Comment returns the comments attached to the expression. + // This method would normally be named 'Comments' but that + // would interfere with embedding a type of the same name. + Comment() *Comments +} + +// A Comment represents a single // comment. +type Comment struct { + Start Position + Token string // without trailing newline + Suffix bool // an end of line (not whole line) comment +} + +// Comments collects the comments associated with an expression. +type Comments struct { + Before []Comment // whole-line comments before this expression + Suffix []Comment // end-of-line comments after this expression + + // For top-level expressions only, After lists whole-line + // comments following the expression. + After []Comment +} + +// Comment returns the receiver. This isn't useful by itself, but +// a [Comments] struct is embedded into all the expression +// implementation types, and this gives each of those a Comment +// method to satisfy the Expr interface. +func (c *Comments) Comment() *Comments { + return c +} + +// A FileSyntax represents an entire go.mod file. +type FileSyntax struct { + Name string // file path + Comments + Stmt []Expr +} + +func (x *FileSyntax) Span() (start, end Position) { + if len(x.Stmt) == 0 { + return + } + start, _ = x.Stmt[0].Span() + _, end = x.Stmt[len(x.Stmt)-1].Span() + return start, end +} + +// addLine adds a line containing the given tokens to the file. +// +// If the first token of the hint matches the first token of the +// line, the new line is added at the end of the block containing hint, +// extracting hint into a new block if it is not yet in one. +// +// If the hint is non-nil but its first token does not match, +// the new line is added after the block containing hint +// (or hint itself, if not in a block). +// +// If no hint is provided, addLine appends the line to the end of +// the last block with a matching first token, +// or to the end of the file if no such block exists. +func (x *FileSyntax) addLine(hint Expr, tokens ...string) *Line { + if hint == nil { + // If no hint given, add to the last statement of the given type. + Loop: + for i := len(x.Stmt) - 1; i >= 0; i-- { + stmt := x.Stmt[i] + switch stmt := stmt.(type) { + case *Line: + if stmt.Token != nil && stmt.Token[0] == tokens[0] { + hint = stmt + break Loop + } + case *LineBlock: + if stmt.Token[0] == tokens[0] { + hint = stmt + break Loop + } + } + } + } + + newLineAfter := func(i int) *Line { + new := &Line{Token: tokens} + if i == len(x.Stmt) { + x.Stmt = append(x.Stmt, new) + } else { + x.Stmt = append(x.Stmt, nil) + copy(x.Stmt[i+2:], x.Stmt[i+1:]) + x.Stmt[i+1] = new + } + return new + } + + if hint != nil { + for i, stmt := range x.Stmt { + switch stmt := stmt.(type) { + case *Line: + if stmt == hint { + if stmt.Token == nil || stmt.Token[0] != tokens[0] { + return newLineAfter(i) + } + + // Convert line to line block. + stmt.InBlock = true + block := &LineBlock{Token: stmt.Token[:1], Line: []*Line{stmt}} + stmt.Token = stmt.Token[1:] + x.Stmt[i] = block + new := &Line{Token: tokens[1:], InBlock: true} + block.Line = append(block.Line, new) + return new + } + + case *LineBlock: + if stmt == hint { + if stmt.Token[0] != tokens[0] { + return newLineAfter(i) + } + + new := &Line{Token: tokens[1:], InBlock: true} + stmt.Line = append(stmt.Line, new) + return new + } + + for j, line := range stmt.Line { + if line == hint { + if stmt.Token[0] != tokens[0] { + return newLineAfter(i) + } + + // Add new line after hint within the block. + stmt.Line = append(stmt.Line, nil) + copy(stmt.Line[j+2:], stmt.Line[j+1:]) + new := &Line{Token: tokens[1:], InBlock: true} + stmt.Line[j+1] = new + return new + } + } + } + } + } + + new := &Line{Token: tokens} + x.Stmt = append(x.Stmt, new) + return new +} + +func (x *FileSyntax) updateLine(line *Line, tokens ...string) { + if line.InBlock { + tokens = tokens[1:] + } + line.Token = tokens +} + +// markRemoved modifies line so that it (and its end-of-line comment, if any) +// will be dropped by (*FileSyntax).Cleanup. +func (line *Line) markRemoved() { + line.Token = nil + line.Comments.Suffix = nil +} + +// Cleanup cleans up the file syntax x after any edit operations. +// To avoid quadratic behavior, (*Line).markRemoved marks the line as dead +// by setting line.Token = nil but does not remove it from the slice +// in which it appears. After edits have all been indicated, +// calling Cleanup cleans out the dead lines. +func (x *FileSyntax) Cleanup() { + w := 0 + for _, stmt := range x.Stmt { + switch stmt := stmt.(type) { + case *Line: + if stmt.Token == nil { + continue + } + case *LineBlock: + ww := 0 + for _, line := range stmt.Line { + if line.Token != nil { + stmt.Line[ww] = line + ww++ + } + } + if ww == 0 { + continue + } + if ww == 1 && len(stmt.RParen.Comments.Before) == 0 { + // Collapse block into single line but keep the Line reference used by the + // parsed File structure. + *stmt.Line[0] = Line{ + Comments: Comments{ + Before: commentsAdd(stmt.Before, stmt.Line[0].Before), + Suffix: commentsAdd(stmt.Line[0].Suffix, stmt.Suffix), + After: commentsAdd(stmt.Line[0].After, stmt.After), + }, + Token: stringsAdd(stmt.Token, stmt.Line[0].Token), + } + x.Stmt[w] = stmt.Line[0] + w++ + continue + } + stmt.Line = stmt.Line[:ww] + } + x.Stmt[w] = stmt + w++ + } + x.Stmt = x.Stmt[:w] +} + +func commentsAdd(x, y []Comment) []Comment { + return append(x[:len(x):len(x)], y...) +} + +func stringsAdd(x, y []string) []string { + return append(x[:len(x):len(x)], y...) +} + +// A CommentBlock represents a top-level block of comments separate +// from any rule. +type CommentBlock struct { + Comments + Start Position +} + +func (x *CommentBlock) Span() (start, end Position) { + return x.Start, x.Start +} + +// A Line is a single line of tokens. +type Line struct { + Comments + Start Position + Token []string + InBlock bool + End Position +} + +func (x *Line) Span() (start, end Position) { + return x.Start, x.End +} + +// A LineBlock is a factored block of lines, like +// +// require ( +// "x" +// "y" +// ) +type LineBlock struct { + Comments + Start Position + LParen LParen + Token []string + Line []*Line + RParen RParen +} + +func (x *LineBlock) Span() (start, end Position) { + return x.Start, x.RParen.Pos.add(")") +} + +// An LParen represents the beginning of a parenthesized line block. +// It is a place to store suffix comments. +type LParen struct { + Comments + Pos Position +} + +func (x *LParen) Span() (start, end Position) { + return x.Pos, x.Pos.add(")") +} + +// An RParen represents the end of a parenthesized line block. +// It is a place to store whole-line (before) comments. +type RParen struct { + Comments + Pos Position +} + +func (x *RParen) Span() (start, end Position) { + return x.Pos, x.Pos.add(")") +} + +// An input represents a single input file being parsed. +type input struct { + // Lexing state. + filename string // name of input file, for errors + complete []byte // entire input + remaining []byte // remaining input + tokenStart []byte // token being scanned to end of input + token token // next token to be returned by lex, peek + pos Position // current input position + comments []Comment // accumulated comments + + // Parser state. + file *FileSyntax // returned top-level syntax tree + parseErrors ErrorList // errors encountered during parsing + + // Comment assignment state. + pre []Expr // all expressions, in preorder traversal + post []Expr // all expressions, in postorder traversal +} + +func newInput(filename string, data []byte) *input { + return &input{ + filename: filename, + complete: data, + remaining: data, + pos: Position{Line: 1, LineRune: 1, Byte: 0}, + } +} + +// parse parses the input file. +func parse(file string, data []byte) (f *FileSyntax, err error) { + // The parser panics for both routine errors like syntax errors + // and for programmer bugs like array index errors. + // Turn both into error returns. Catching bug panics is + // especially important when processing many files. + in := newInput(file, data) + defer func() { + if e := recover(); e != nil && e != &in.parseErrors { + in.parseErrors = append(in.parseErrors, Error{ + Filename: in.filename, + Pos: in.pos, + Err: fmt.Errorf("internal error: %v", e), + }) + } + if err == nil && len(in.parseErrors) > 0 { + err = in.parseErrors + } + }() + + // Prime the lexer by reading in the first token. It will be available + // in the next peek() or lex() call. + in.readToken() + + // Invoke the parser. + in.parseFile() + if len(in.parseErrors) > 0 { + return nil, in.parseErrors + } + in.file.Name = in.filename + + // Assign comments to nearby syntax. + in.assignComments() + + return in.file, nil +} + +// Error is called to report an error. +// Error does not return: it panics. +func (in *input) Error(s string) { + in.parseErrors = append(in.parseErrors, Error{ + Filename: in.filename, + Pos: in.pos, + Err: errors.New(s), + }) + panic(&in.parseErrors) +} + +// eof reports whether the input has reached end of file. +func (in *input) eof() bool { + return len(in.remaining) == 0 +} + +// peekRune returns the next rune in the input without consuming it. +func (in *input) peekRune() int { + if len(in.remaining) == 0 { + return 0 + } + r, _ := utf8.DecodeRune(in.remaining) + return int(r) +} + +// peekPrefix reports whether the remaining input begins with the given prefix. +func (in *input) peekPrefix(prefix string) bool { + // This is like bytes.HasPrefix(in.remaining, []byte(prefix)) + // but without the allocation of the []byte copy of prefix. + for i := 0; i < len(prefix); i++ { + if i >= len(in.remaining) || in.remaining[i] != prefix[i] { + return false + } + } + return true +} + +// readRune consumes and returns the next rune in the input. +func (in *input) readRune() int { + if len(in.remaining) == 0 { + in.Error("internal lexer error: readRune at EOF") + } + r, size := utf8.DecodeRune(in.remaining) + in.remaining = in.remaining[size:] + if r == '\n' { + in.pos.Line++ + in.pos.LineRune = 1 + } else { + in.pos.LineRune++ + } + in.pos.Byte += size + return int(r) +} + +type token struct { + kind tokenKind + pos Position + endPos Position + text string +} + +type tokenKind int + +const ( + _EOF tokenKind = -(iota + 1) + _EOLCOMMENT + _IDENT + _STRING + _COMMENT + + // newlines and punctuation tokens are allowed as ASCII codes. +) + +func (k tokenKind) isComment() bool { + return k == _COMMENT || k == _EOLCOMMENT +} + +// isEOL returns whether a token terminates a line. +func (k tokenKind) isEOL() bool { + return k == _EOF || k == _EOLCOMMENT || k == '\n' +} + +// startToken marks the beginning of the next input token. +// It must be followed by a call to endToken, once the token's text has +// been consumed using readRune. +func (in *input) startToken() { + in.tokenStart = in.remaining + in.token.text = "" + in.token.pos = in.pos +} + +// endToken marks the end of an input token. +// It records the actual token string in tok.text. +// A single trailing newline (LF or CRLF) will be removed from comment tokens. +func (in *input) endToken(kind tokenKind) { + in.token.kind = kind + text := string(in.tokenStart[:len(in.tokenStart)-len(in.remaining)]) + if kind.isComment() { + if strings.HasSuffix(text, "\r\n") { + text = text[:len(text)-2] + } else { + text = strings.TrimSuffix(text, "\n") + } + } + in.token.text = text + in.token.endPos = in.pos +} + +// peek returns the kind of the next token returned by lex. +func (in *input) peek() tokenKind { + return in.token.kind +} + +// lex is called from the parser to obtain the next input token. +func (in *input) lex() token { + tok := in.token + in.readToken() + return tok +} + +// readToken lexes the next token from the text and stores it in in.token. +func (in *input) readToken() { + // Skip past spaces, stopping at non-space or EOF. + for !in.eof() { + c := in.peekRune() + if c == ' ' || c == '\t' || c == '\r' { + in.readRune() + continue + } + + // Comment runs to end of line. + if in.peekPrefix("//") { + in.startToken() + + // Is this comment the only thing on its line? + // Find the last \n before this // and see if it's all + // spaces from there to here. + i := bytes.LastIndex(in.complete[:in.pos.Byte], []byte("\n")) + suffix := len(bytes.TrimSpace(in.complete[i+1:in.pos.Byte])) > 0 + in.readRune() + in.readRune() + + // Consume comment. + for len(in.remaining) > 0 && in.readRune() != '\n' { + } + + // If we are at top level (not in a statement), hand the comment to + // the parser as a _COMMENT token. The grammar is written + // to handle top-level comments itself. + if !suffix { + in.endToken(_COMMENT) + return + } + + // Otherwise, save comment for later attachment to syntax tree. + in.endToken(_EOLCOMMENT) + in.comments = append(in.comments, Comment{in.token.pos, in.token.text, suffix}) + return + } + + if in.peekPrefix("/*") { + in.Error("mod files must use // comments (not /* */ comments)") + } + + // Found non-space non-comment. + break + } + + // Found the beginning of the next token. + in.startToken() + + // End of file. + if in.eof() { + in.endToken(_EOF) + return + } + + // Punctuation tokens. + switch c := in.peekRune(); c { + case '\n', '(', ')', '[', ']', '{', '}', ',': + in.readRune() + in.endToken(tokenKind(c)) + return + + case '"', '`': // quoted string + quote := c + in.readRune() + for { + if in.eof() { + in.pos = in.token.pos + in.Error("unexpected EOF in string") + } + if in.peekRune() == '\n' { + in.Error("unexpected newline in string") + } + c := in.readRune() + if c == quote { + break + } + if c == '\\' && quote != '`' { + if in.eof() { + in.pos = in.token.pos + in.Error("unexpected EOF in string") + } + in.readRune() + } + } + in.endToken(_STRING) + return + } + + // Checked all punctuation. Must be identifier token. + if c := in.peekRune(); !isIdent(c) { + in.Error(fmt.Sprintf("unexpected input character %#q", rune(c))) + } + + // Scan over identifier. + for isIdent(in.peekRune()) { + if in.peekPrefix("//") { + break + } + if in.peekPrefix("/*") { + in.Error("mod files must use // comments (not /* */ comments)") + } + in.readRune() + } + in.endToken(_IDENT) +} + +// isIdent reports whether c is an identifier rune. +// We treat most printable runes as identifier runes, except for a handful of +// ASCII punctuation characters. +func isIdent(c int) bool { + switch r := rune(c); r { + case ' ', '(', ')', '[', ']', '{', '}', ',': + return false + default: + return !unicode.IsSpace(r) && unicode.IsPrint(r) + } +} + +// Comment assignment. +// We build two lists of all subexpressions, preorder and postorder. +// The preorder list is ordered by start location, with outer expressions first. +// The postorder list is ordered by end location, with outer expressions last. +// We use the preorder list to assign each whole-line comment to the syntax +// immediately following it, and we use the postorder list to assign each +// end-of-line comment to the syntax immediately preceding it. + +// order walks the expression adding it and its subexpressions to the +// preorder and postorder lists. +func (in *input) order(x Expr) { + if x != nil { + in.pre = append(in.pre, x) + } + switch x := x.(type) { + default: + panic(fmt.Errorf("order: unexpected type %T", x)) + case nil: + // nothing + case *LParen, *RParen: + // nothing + case *CommentBlock: + // nothing + case *Line: + // nothing + case *FileSyntax: + for _, stmt := range x.Stmt { + in.order(stmt) + } + case *LineBlock: + in.order(&x.LParen) + for _, l := range x.Line { + in.order(l) + } + in.order(&x.RParen) + } + if x != nil { + in.post = append(in.post, x) + } +} + +// assignComments attaches comments to nearby syntax. +func (in *input) assignComments() { + const debug = false + + // Generate preorder and postorder lists. + in.order(in.file) + + // Split into whole-line comments and suffix comments. + var line, suffix []Comment + for _, com := range in.comments { + if com.Suffix { + suffix = append(suffix, com) + } else { + line = append(line, com) + } + } + + if debug { + for _, c := range line { + fmt.Fprintf(os.Stderr, "LINE %q :%d:%d #%d\n", c.Token, c.Start.Line, c.Start.LineRune, c.Start.Byte) + } + } + + // Assign line comments to syntax immediately following. + for _, x := range in.pre { + start, _ := x.Span() + if debug { + fmt.Fprintf(os.Stderr, "pre %T :%d:%d #%d\n", x, start.Line, start.LineRune, start.Byte) + } + xcom := x.Comment() + for len(line) > 0 && start.Byte >= line[0].Start.Byte { + if debug { + fmt.Fprintf(os.Stderr, "ASSIGN LINE %q #%d\n", line[0].Token, line[0].Start.Byte) + } + xcom.Before = append(xcom.Before, line[0]) + line = line[1:] + } + } + + // Remaining line comments go at end of file. + in.file.After = append(in.file.After, line...) + + if debug { + for _, c := range suffix { + fmt.Fprintf(os.Stderr, "SUFFIX %q :%d:%d #%d\n", c.Token, c.Start.Line, c.Start.LineRune, c.Start.Byte) + } + } + + // Assign suffix comments to syntax immediately before. + for i := len(in.post) - 1; i >= 0; i-- { + x := in.post[i] + + start, end := x.Span() + if debug { + fmt.Fprintf(os.Stderr, "post %T :%d:%d #%d :%d:%d #%d\n", x, start.Line, start.LineRune, start.Byte, end.Line, end.LineRune, end.Byte) + } + + // Do not assign suffix comments to end of line block or whole file. + // Instead assign them to the last element inside. + switch x.(type) { + case *FileSyntax: + continue + } + + // Do not assign suffix comments to something that starts + // on an earlier line, so that in + // + // x ( y + // z ) // comment + // + // we assign the comment to z and not to x ( ... ). + if start.Line != end.Line { + continue + } + xcom := x.Comment() + for len(suffix) > 0 && end.Byte <= suffix[len(suffix)-1].Start.Byte { + if debug { + fmt.Fprintf(os.Stderr, "ASSIGN SUFFIX %q #%d\n", suffix[len(suffix)-1].Token, suffix[len(suffix)-1].Start.Byte) + } + xcom.Suffix = append(xcom.Suffix, suffix[len(suffix)-1]) + suffix = suffix[:len(suffix)-1] + } + } + + // We assigned suffix comments in reverse. + // If multiple suffix comments were appended to the same + // expression node, they are now in reverse. Fix that. + for _, x := range in.post { + reverseComments(x.Comment().Suffix) + } + + // Remaining suffix comments go at beginning of file. + in.file.Before = append(in.file.Before, suffix...) +} + +// reverseComments reverses the []Comment list. +func reverseComments(list []Comment) { + for i, j := 0, len(list)-1; i < j; i, j = i+1, j-1 { + list[i], list[j] = list[j], list[i] + } +} + +func (in *input) parseFile() { + in.file = new(FileSyntax) + var cb *CommentBlock + for { + switch in.peek() { + case '\n': + in.lex() + if cb != nil { + in.file.Stmt = append(in.file.Stmt, cb) + cb = nil + } + case _COMMENT: + tok := in.lex() + if cb == nil { + cb = &CommentBlock{Start: tok.pos} + } + com := cb.Comment() + com.Before = append(com.Before, Comment{Start: tok.pos, Token: tok.text}) + case _EOF: + if cb != nil { + in.file.Stmt = append(in.file.Stmt, cb) + } + return + default: + in.parseStmt() + if cb != nil { + in.file.Stmt[len(in.file.Stmt)-1].Comment().Before = cb.Before + cb = nil + } + } + } +} + +func (in *input) parseStmt() { + tok := in.lex() + start := tok.pos + end := tok.endPos + tokens := []string{tok.text} + for { + tok := in.lex() + switch { + case tok.kind.isEOL(): + in.file.Stmt = append(in.file.Stmt, &Line{ + Start: start, + Token: tokens, + End: end, + }) + return + + case tok.kind == '(': + if next := in.peek(); next.isEOL() { + // Start of block: no more tokens on this line. + in.file.Stmt = append(in.file.Stmt, in.parseLineBlock(start, tokens, tok)) + return + } else if next == ')' { + rparen := in.lex() + if in.peek().isEOL() { + // Empty block. + in.lex() + in.file.Stmt = append(in.file.Stmt, &LineBlock{ + Start: start, + Token: tokens, + LParen: LParen{Pos: tok.pos}, + RParen: RParen{Pos: rparen.pos}, + }) + return + } + // '( )' in the middle of the line, not a block. + tokens = append(tokens, tok.text, rparen.text) + } else { + // '(' in the middle of the line, not a block. + tokens = append(tokens, tok.text) + } + + default: + tokens = append(tokens, tok.text) + end = tok.endPos + } + } +} + +func (in *input) parseLineBlock(start Position, token []string, lparen token) *LineBlock { + x := &LineBlock{ + Start: start, + Token: token, + LParen: LParen{Pos: lparen.pos}, + } + var comments []Comment + for { + switch in.peek() { + case _EOLCOMMENT: + // Suffix comment, will be attached later by assignComments. + in.lex() + case '\n': + // Blank line. Add an empty comment to preserve it. + in.lex() + if len(comments) == 0 && len(x.Line) > 0 || len(comments) > 0 && comments[len(comments)-1].Token != "" { + comments = append(comments, Comment{}) + } + case _COMMENT: + tok := in.lex() + comments = append(comments, Comment{Start: tok.pos, Token: tok.text}) + case _EOF: + in.Error(fmt.Sprintf("syntax error (unterminated block started at %s:%d:%d)", in.filename, x.Start.Line, x.Start.LineRune)) + case ')': + rparen := in.lex() + // Don't preserve blank lines (denoted by a single empty comment, added above) + // at the end of the block. + if len(comments) == 1 && comments[0] == (Comment{}) { + comments = nil + } + x.RParen.Before = comments + x.RParen.Pos = rparen.pos + if !in.peek().isEOL() { + in.Error("syntax error (expected newline after closing paren)") + } + in.lex() + return x + default: + l := in.parseLine() + x.Line = append(x.Line, l) + l.Comment().Before = comments + comments = nil + } + } +} + +func (in *input) parseLine() *Line { + tok := in.lex() + if tok.kind.isEOL() { + in.Error("internal parse error: parseLine at end of line") + } + start := tok.pos + end := tok.endPos + tokens := []string{tok.text} + for { + tok := in.lex() + if tok.kind.isEOL() { + return &Line{ + Start: start, + Token: tokens, + End: end, + InBlock: true, + } + } + tokens = append(tokens, tok.text) + end = tok.endPos + } +} + +var ( + slashSlash = []byte("//") + moduleStr = []byte("module") +) + +// ModulePath returns the module path from the gomod 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 { + for len(mod) > 0 { + line := mod + mod = nil + if i := bytes.IndexByte(line, '\n'); i >= 0 { + line, mod = line[:i], line[i+1:] + } + if i := bytes.Index(line, slashSlash); i >= 0 { + line = line[:i] + } + line = bytes.TrimSpace(line) + if !bytes.HasPrefix(line, moduleStr) { + continue + } + line = line[len(moduleStr):] + n := len(line) + line = bytes.TrimSpace(line) + if len(line) == n || len(line) == 0 { + continue + } + + if line[0] == '"' || line[0] == '`' { + p, err := strconv.Unquote(string(line)) + if err != nil { + return "" // malformed quoted string or multiline module path + } + return p + } + + return string(line) + } + return "" // missing module path +} diff --git a/vendor/golang.org/x/mod/modfile/rule.go b/vendor/golang.org/x/mod/modfile/rule.go new file mode 100644 index 000000000..c5b8305de --- /dev/null +++ b/vendor/golang.org/x/mod/modfile/rule.go @@ -0,0 +1,1904 @@ +// 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 modfile implements a parser and formatter for go.mod files. +// +// The go.mod syntax is described in +// https://pkg.go.dev/cmd/go/#hdr-The_go_mod_file. +// +// The [Parse] and [ParseLax] functions both parse a go.mod file and return an +// abstract syntax tree. ParseLax ignores unknown statements and may be used to +// parse go.mod files that may have been developed with newer versions of Go. +// +// The [File] struct returned by Parse and ParseLax represent an abstract +// go.mod file. File has several methods like [File.AddNewRequire] and +// [File.DropReplace] that can be used to programmatically edit a file. +// +// The [Format] function formats a File back to a byte slice which can be +// written to a file. +package modfile + +import ( + "cmp" + "errors" + "fmt" + "path/filepath" + "slices" + "strconv" + "strings" + "unicode" + + "golang.org/x/mod/internal/lazyregexp" + "golang.org/x/mod/module" + "golang.org/x/mod/semver" +) + +// A File is the parsed, interpreted form of a go.mod file. +type File struct { + Module *Module + Go *Go + Toolchain *Toolchain + Godebug []*Godebug + Require []*Require + Exclude []*Exclude + Replace []*Replace + Retract []*Retract + Tool []*Tool + Ignore []*Ignore + + Syntax *FileSyntax +} + +// A Module is the module statement. +type Module struct { + Mod module.Version + Deprecated string + Syntax *Line +} + +// A Go is the go statement. +type Go struct { + Version string // "1.23" + Syntax *Line +} + +// A Toolchain is the toolchain statement. +type Toolchain struct { + Name string // "go1.21rc1" + Syntax *Line +} + +// A Godebug is a single godebug key=value statement. +type Godebug struct { + Key string + Value string + Syntax *Line +} + +// An Exclude is a single exclude statement. +type Exclude struct { + Mod module.Version + Syntax *Line +} + +// A Replace is a single replace statement. +type Replace struct { + Old module.Version + New module.Version + Syntax *Line +} + +// A Retract is a single retract statement. +type Retract struct { + VersionInterval + Rationale string + Syntax *Line +} + +// A Tool is a single tool statement. +type Tool struct { + Path string + Syntax *Line +} + +// An Ignore is a single ignore statement. +type Ignore struct { + Path string + Syntax *Line +} + +// A VersionInterval represents a range of versions with upper and lower bounds. +// Intervals are closed: both bounds are included. When Low is equal to High, +// the interval may refer to a single version ('v1.2.3') or an interval +// ('[v1.2.3, v1.2.3]'); both have the same representation. +type VersionInterval struct { + Low, High string +} + +// A Require is a single require statement. +type Require struct { + Mod module.Version + Indirect bool // has "// indirect" comment + Syntax *Line +} + +func (r *Require) markRemoved() { + r.Syntax.markRemoved() + *r = Require{} +} + +func (r *Require) setVersion(v string) { + r.Mod.Version = v + + if line := r.Syntax; len(line.Token) > 0 { + if line.InBlock { + // If the line is preceded by an empty line, remove it; see + // https://golang.org/issue/33779. + if len(line.Comments.Before) == 1 && len(line.Comments.Before[0].Token) == 0 { + line.Comments.Before = line.Comments.Before[:0] + } + if len(line.Token) >= 2 { // example.com v1.2.3 + line.Token[1] = v + } + } else { + if len(line.Token) >= 3 { // require example.com v1.2.3 + line.Token[2] = v + } + } + } +} + +// setIndirect sets line to have (or not have) a "// indirect" comment. +func (r *Require) setIndirect(indirect bool) { + r.Indirect = indirect + line := r.Syntax + if isIndirect(line) == indirect { + return + } + if indirect { + // Adding comment. + if len(line.Suffix) == 0 { + // New comment. + line.Suffix = []Comment{{Token: "// indirect", Suffix: true}} + return + } + + com := &line.Suffix[0] + text := strings.TrimSpace(strings.TrimPrefix(com.Token, string(slashSlash))) + if text == "" { + // Empty comment. + com.Token = "// indirect" + return + } + + // Insert at beginning of existing comment. + com.Token = "// indirect; " + text + return + } + + // Removing comment. + f := strings.TrimSpace(strings.TrimPrefix(line.Suffix[0].Token, string(slashSlash))) + if f == "indirect" { + // Remove whole comment. + line.Suffix = nil + return + } + + // Remove comment prefix. + com := &line.Suffix[0] + i := strings.Index(com.Token, "indirect;") + com.Token = "//" + com.Token[i+len("indirect;"):] +} + +// isIndirect reports whether line has a "// indirect" comment, +// meaning it is in go.mod only for its effect on indirect dependencies, +// so that it can be dropped entirely once the effective version of the +// indirect dependency reaches the given minimum version. +func isIndirect(line *Line) bool { + if len(line.Suffix) == 0 { + return false + } + f := strings.Fields(strings.TrimPrefix(line.Suffix[0].Token, string(slashSlash))) + return (len(f) == 1 && f[0] == "indirect" || len(f) > 1 && f[0] == "indirect;") +} + +func (f *File) AddModuleStmt(path string) error { + if f.Syntax == nil { + f.Syntax = new(FileSyntax) + } + if f.Module == nil { + f.Module = &Module{ + Mod: module.Version{Path: path}, + Syntax: f.Syntax.addLine(nil, "module", AutoQuote(path)), + } + } else { + f.Module.Mod.Path = path + f.Syntax.updateLine(f.Module.Syntax, "module", AutoQuote(path)) + } + return nil +} + +func (f *File) AddComment(text string) { + if f.Syntax == nil { + f.Syntax = new(FileSyntax) + } + f.Syntax.Stmt = append(f.Syntax.Stmt, &CommentBlock{ + Comments: Comments{ + Before: []Comment{ + { + Token: text, + }, + }, + }, + }) +} + +type VersionFixer func(path, version string) (string, error) + +// errDontFix is returned by a VersionFixer to indicate the version should be +// left alone, even if it's not canonical. +var dontFixRetract VersionFixer = func(_, vers string) (string, error) { + return vers, nil +} + +// Parse parses and returns a go.mod file. +// +// file is the name of the file, used in positions and errors. +// +// data is the content of the file. +// +// fix is an optional function that canonicalizes module versions. +// If fix is nil, all module versions must be canonical ([module.CanonicalVersion] +// must return the same string). +func Parse(file string, data []byte, fix VersionFixer) (*File, error) { + return parseToFile(file, data, fix, true) +} + +// ParseLax is like Parse but ignores unknown statements. +// It is used when parsing go.mod files other than the main module, +// under the theory that most statement types we add in the future will +// only apply in the main module, like exclude and replace, +// and so we get better gradual deployments if old go commands +// simply ignore those statements when found in go.mod files +// in dependencies. +func ParseLax(file string, data []byte, fix VersionFixer) (*File, error) { + return parseToFile(file, data, fix, false) +} + +func parseToFile(file string, data []byte, fix VersionFixer, strict bool) (parsed *File, err error) { + fs, err := parse(file, data) + if err != nil { + return nil, err + } + f := &File{ + Syntax: fs, + } + var errs ErrorList + + // fix versions in retract directives after the file is parsed. + // We need the module path to fix versions, and it might be at the end. + defer func() { + oldLen := len(errs) + f.fixRetract(fix, &errs) + if len(errs) > oldLen { + parsed, err = nil, errs + } + }() + + for _, x := range fs.Stmt { + switch x := x.(type) { + case *Line: + f.add(&errs, nil, x, x.Token[0], x.Token[1:], fix, strict) + + case *LineBlock: + if len(x.Token) > 1 { + if strict { + errs = append(errs, Error{ + Filename: file, + Pos: x.Start, + Err: fmt.Errorf("unknown block type: %s", strings.Join(x.Token, " ")), + }) + } + continue + } + switch x.Token[0] { + default: + if strict { + errs = append(errs, Error{ + Filename: file, + Pos: x.Start, + Err: fmt.Errorf("unknown block type: %s", strings.Join(x.Token, " ")), + }) + } + continue + case "module", "godebug", "require", "exclude", "replace", "retract", "tool", "ignore": + for _, l := range x.Line { + f.add(&errs, x, l, x.Token[0], l.Token, fix, strict) + } + } + } + } + + if len(errs) > 0 { + return nil, errs + } + return f, nil +} + +var GoVersionRE = lazyregexp.New(`^([1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?([a-z]+[0-9]+)?$`) +var laxGoVersionRE = lazyregexp.New(`^v?(([1-9][0-9]*)\.(0|[1-9][0-9]*))([^0-9].*)$`) + +// Toolchains must be named beginning with `go1`, +// like "go1.20.3" or "go1.20.3-gccgo". As a special case, "default" is also permitted. +// Note that this regexp is a much looser condition than go/version.IsValid, +// for forward compatibility. +// (This code has to be work to identify new toolchains even if we tweak the syntax in the future.) +var ToolchainRE = lazyregexp.New(`^default$|^go1($|\.)`) + +func (f *File) add(errs *ErrorList, block *LineBlock, line *Line, verb string, args []string, fix VersionFixer, strict bool) { + // If strict is false, this module is a dependency. + // We ignore all unknown directives as well as main-module-only + // directives like replace and exclude. It will work better for + // forward compatibility if we can depend on modules that have unknown + // statements (presumed relevant only when acting as the main module) + // and simply ignore those statements. + if !strict { + switch verb { + case "go", "module", "retract", "require", "ignore": + // want these even for dependency go.mods + default: + return + } + } + + wrapModPathError := func(modPath string, err error) { + *errs = append(*errs, Error{ + Filename: f.Syntax.Name, + Pos: line.Start, + ModPath: modPath, + Verb: verb, + Err: err, + }) + } + wrapError := func(err error) { + *errs = append(*errs, Error{ + Filename: f.Syntax.Name, + Pos: line.Start, + Err: err, + }) + } + errorf := func(format string, args ...any) { + wrapError(fmt.Errorf(format, args...)) + } + + switch verb { + default: + errorf("unknown directive: %s", verb) + + case "go": + if f.Go != nil { + errorf("repeated go statement") + return + } + if len(args) != 1 { + errorf("go directive expects exactly one argument") + return + } else if !GoVersionRE.MatchString(args[0]) { + fixed := false + if !strict { + if m := laxGoVersionRE.FindStringSubmatch(args[0]); m != nil { + args[0] = m[1] + fixed = true + } + } + if !fixed { + errorf("invalid go version '%s': must match format 1.23.0", args[0]) + return + } + } + + f.Go = &Go{Syntax: line} + f.Go.Version = args[0] + + case "toolchain": + if f.Toolchain != nil { + errorf("repeated toolchain statement") + return + } + if len(args) != 1 { + errorf("toolchain directive expects exactly one argument") + return + } else if !ToolchainRE.MatchString(args[0]) { + errorf("invalid toolchain version '%s': must match format go1.23.0 or default", args[0]) + return + } + f.Toolchain = &Toolchain{Syntax: line} + f.Toolchain.Name = args[0] + + case "module": + if f.Module != nil { + errorf("repeated module statement") + return + } + deprecated := parseDeprecation(block, line) + f.Module = &Module{ + Syntax: line, + Deprecated: deprecated, + } + if len(args) != 1 { + errorf("usage: module module/path") + return + } + s, err := parseString(&args[0]) + if err != nil { + errorf("invalid quoted string: %v", err) + return + } + f.Module.Mod = module.Version{Path: s} + + case "godebug": + if len(args) != 1 || strings.ContainsAny(args[0], "\"`',") { + errorf("usage: godebug key=value") + return + } + key, value, ok := strings.Cut(args[0], "=") + if !ok { + errorf("usage: godebug key=value") + return + } + f.Godebug = append(f.Godebug, &Godebug{ + Key: key, + Value: value, + Syntax: line, + }) + + case "require", "exclude": + if len(args) != 2 { + errorf("usage: %s module/path v1.2.3", verb) + return + } + s, err := parseString(&args[0]) + if err != nil { + errorf("invalid quoted string: %v", err) + return + } + v, err := parseVersion(verb, s, &args[1], fix) + if err != nil { + wrapError(err) + return + } + pathMajor, err := modulePathMajor(s) + if err != nil { + wrapError(err) + return + } + if err := module.CheckPathMajor(v, pathMajor); err != nil { + wrapModPathError(s, err) + return + } + if verb == "require" { + f.Require = append(f.Require, &Require{ + Mod: module.Version{Path: s, Version: v}, + Syntax: line, + Indirect: isIndirect(line), + }) + } else { + f.Exclude = append(f.Exclude, &Exclude{ + Mod: module.Version{Path: s, Version: v}, + Syntax: line, + }) + } + + case "replace": + replace, wrappederr := parseReplace(f.Syntax.Name, line, verb, args, fix) + if wrappederr != nil { + *errs = append(*errs, *wrappederr) + return + } + f.Replace = append(f.Replace, replace) + + case "retract": + rationale := parseDirectiveComment(block, line) + vi, err := parseVersionInterval(verb, "", &args, dontFixRetract) + if err != nil { + if strict { + wrapError(err) + return + } else { + // Only report errors parsing intervals in the main module. We may + // support additional syntax in the future, such as open and half-open + // intervals. Those can't be supported now, because they break the + // go.mod parser, even in lax mode. + return + } + } + if len(args) > 0 && strict { + // In the future, there may be additional information after the version. + errorf("unexpected token after version: %q", args[0]) + return + } + retract := &Retract{ + VersionInterval: vi, + Rationale: rationale, + Syntax: line, + } + f.Retract = append(f.Retract, retract) + + case "tool": + if len(args) != 1 { + errorf("tool directive expects exactly one argument") + return + } + s, err := parseString(&args[0]) + if err != nil { + errorf("invalid quoted string: %v", err) + return + } + f.Tool = append(f.Tool, &Tool{ + Path: s, + Syntax: line, + }) + + case "ignore": + if len(args) != 1 { + errorf("ignore directive expects exactly one argument") + return + } + s, err := parseString(&args[0]) + if err != nil { + errorf("invalid quoted string: %v", err) + return + } + f.Ignore = append(f.Ignore, &Ignore{ + Path: s, + Syntax: line, + }) + } +} + +func parseReplace(filename string, line *Line, verb string, args []string, fix VersionFixer) (*Replace, *Error) { + wrapModPathError := func(modPath string, err error) *Error { + return &Error{ + Filename: filename, + Pos: line.Start, + ModPath: modPath, + Verb: verb, + Err: err, + } + } + wrapError := func(err error) *Error { + return &Error{ + Filename: filename, + Pos: line.Start, + Err: err, + } + } + errorf := func(format string, args ...any) *Error { + return wrapError(fmt.Errorf(format, args...)) + } + + arrow := 2 + if len(args) >= 2 && args[1] == "=>" { + arrow = 1 + } + if len(args) < arrow+2 || len(args) > arrow+3 || args[arrow] != "=>" { + return nil, errorf("usage: %s module/path [v1.2.3] => other/module v1.4\n\t or %s module/path [v1.2.3] => ../local/directory", verb, verb) + } + s, err := parseString(&args[0]) + if err != nil { + return nil, errorf("invalid quoted string: %v", err) + } + pathMajor, err := modulePathMajor(s) + if err != nil { + return nil, wrapModPathError(s, err) + + } + var v string + if arrow == 2 { + v, err = parseVersion(verb, s, &args[1], fix) + if err != nil { + return nil, wrapError(err) + } + if err := module.CheckPathMajor(v, pathMajor); err != nil { + return nil, wrapModPathError(s, err) + } + } + ns, err := parseString(&args[arrow+1]) + if err != nil { + return nil, errorf("invalid quoted string: %v", err) + } + nv := "" + if len(args) == arrow+2 { + if !IsDirectoryPath(ns) { + if strings.Contains(ns, "@") { + return nil, errorf("replacement module must match format 'path version', not 'path@version'") + } + return nil, errorf("replacement module without version must be directory path (rooted or starting with . or ..)") + } + if filepath.Separator == '/' && strings.Contains(ns, `\`) { + return nil, errorf("replacement directory appears to be Windows path (on a non-windows system)") + } + } + if len(args) == arrow+3 { + nv, err = parseVersion(verb, ns, &args[arrow+2], fix) + if err != nil { + return nil, wrapError(err) + } + if IsDirectoryPath(ns) { + return nil, errorf("replacement module directory path %q cannot have version", ns) + } + } + return &Replace{ + Old: module.Version{Path: s, Version: v}, + New: module.Version{Path: ns, Version: nv}, + Syntax: line, + }, nil +} + +// fixRetract applies fix to each retract directive in f, appending any errors +// to errs. +// +// Most versions are fixed as we parse the file, but for retract directives, +// the relevant module path is the one specified with the module directive, +// and that might appear at the end of the file (or not at all). +func (f *File) fixRetract(fix VersionFixer, errs *ErrorList) { + if fix == nil { + return + } + path := "" + if f.Module != nil { + path = f.Module.Mod.Path + } + var r *Retract + wrapError := func(err error) { + *errs = append(*errs, Error{ + Filename: f.Syntax.Name, + Pos: r.Syntax.Start, + Err: err, + }) + } + + for _, r = range f.Retract { + if path == "" { + wrapError(errors.New("no module directive found, so retract cannot be used")) + return // only print the first one of these + } + + args := r.Syntax.Token + if args[0] == "retract" { + args = args[1:] + } + vi, err := parseVersionInterval("retract", path, &args, fix) + if err != nil { + wrapError(err) + } + r.VersionInterval = vi + } +} + +func (f *WorkFile) add(errs *ErrorList, line *Line, verb string, args []string, fix VersionFixer) { + wrapError := func(err error) { + *errs = append(*errs, Error{ + Filename: f.Syntax.Name, + Pos: line.Start, + Err: err, + }) + } + errorf := func(format string, args ...any) { + wrapError(fmt.Errorf(format, args...)) + } + + switch verb { + default: + errorf("unknown directive: %s", verb) + + case "go": + if f.Go != nil { + errorf("repeated go statement") + return + } + if len(args) != 1 { + errorf("go directive expects exactly one argument") + return + } else if !GoVersionRE.MatchString(args[0]) { + errorf("invalid go version '%s': must match format 1.23.0", args[0]) + return + } + + f.Go = &Go{Syntax: line} + f.Go.Version = args[0] + + case "toolchain": + if f.Toolchain != nil { + errorf("repeated toolchain statement") + return + } + if len(args) != 1 { + errorf("toolchain directive expects exactly one argument") + return + } else if !ToolchainRE.MatchString(args[0]) { + errorf("invalid toolchain version '%s': must match format go1.23.0 or default", args[0]) + return + } + + f.Toolchain = &Toolchain{Syntax: line} + f.Toolchain.Name = args[0] + + case "godebug": + if len(args) != 1 || strings.ContainsAny(args[0], "\"`',") { + errorf("usage: godebug key=value") + return + } + key, value, ok := strings.Cut(args[0], "=") + if !ok { + errorf("usage: godebug key=value") + return + } + f.Godebug = append(f.Godebug, &Godebug{ + Key: key, + Value: value, + Syntax: line, + }) + + case "use": + if len(args) != 1 { + errorf("usage: %s local/dir", verb) + return + } + s, err := parseString(&args[0]) + if err != nil { + errorf("invalid quoted string: %v", err) + return + } + f.Use = append(f.Use, &Use{ + Path: s, + Syntax: line, + }) + + case "replace": + replace, wrappederr := parseReplace(f.Syntax.Name, line, verb, args, fix) + if wrappederr != nil { + *errs = append(*errs, *wrappederr) + return + } + f.Replace = append(f.Replace, replace) + } +} + +// IsDirectoryPath reports whether the given path should be interpreted as a directory path. +// Just like on the go command line, relative paths starting with a '.' or '..' path component +// and rooted paths are directory paths; the rest are module paths. +func IsDirectoryPath(ns string) bool { + // Because go.mod files can move from one system to another, + // we check all known path syntaxes, both Unix and Windows. + return ns == "." || strings.HasPrefix(ns, "./") || strings.HasPrefix(ns, `.\`) || + ns == ".." || strings.HasPrefix(ns, "../") || strings.HasPrefix(ns, `..\`) || + strings.HasPrefix(ns, "/") || strings.HasPrefix(ns, `\`) || + len(ns) >= 2 && ('A' <= ns[0] && ns[0] <= 'Z' || 'a' <= ns[0] && ns[0] <= 'z') && ns[1] == ':' +} + +// MustQuote reports whether s must be quoted in order to appear as +// a single token in a go.mod line. +func MustQuote(s string) bool { + for _, r := range s { + switch r { + case ' ', '"', '\'', '`': + return true + + case '(', ')', '[', ']', '{', '}', ',': + if len(s) > 1 { + return true + } + + default: + if !unicode.IsPrint(r) { + return true + } + } + } + return s == "" || strings.Contains(s, "//") || strings.Contains(s, "/*") +} + +// AutoQuote returns s or, if quoting is required for s to appear in a go.mod, +// the quotation of s. +func AutoQuote(s string) string { + if MustQuote(s) { + return strconv.Quote(s) + } + return s +} + +func parseVersionInterval(verb string, path string, args *[]string, fix VersionFixer) (VersionInterval, error) { + toks := *args + if len(toks) == 0 || toks[0] == "(" { + return VersionInterval{}, fmt.Errorf("expected '[' or version") + } + if toks[0] != "[" { + v, err := parseVersion(verb, path, &toks[0], fix) + if err != nil { + return VersionInterval{}, err + } + *args = toks[1:] + return VersionInterval{Low: v, High: v}, nil + } + toks = toks[1:] + + if len(toks) == 0 { + return VersionInterval{}, fmt.Errorf("expected version after '['") + } + low, err := parseVersion(verb, path, &toks[0], fix) + if err != nil { + return VersionInterval{}, err + } + toks = toks[1:] + + if len(toks) == 0 || toks[0] != "," { + return VersionInterval{}, fmt.Errorf("expected ',' after version") + } + toks = toks[1:] + + if len(toks) == 0 { + return VersionInterval{}, fmt.Errorf("expected version after ','") + } + high, err := parseVersion(verb, path, &toks[0], fix) + if err != nil { + return VersionInterval{}, err + } + toks = toks[1:] + + if len(toks) == 0 || toks[0] != "]" { + return VersionInterval{}, fmt.Errorf("expected ']' after version") + } + toks = toks[1:] + + *args = toks + return VersionInterval{Low: low, High: high}, nil +} + +func parseString(s *string) (string, error) { + t := *s + if strings.HasPrefix(t, `"`) { + var err error + if t, err = strconv.Unquote(t); err != nil { + return "", err + } + } else if strings.ContainsAny(t, "\"'`") { + // Other quotes are reserved both for possible future expansion + // and to avoid confusion. For example if someone types 'x' + // we want that to be a syntax error and not a literal x in literal quotation marks. + return "", fmt.Errorf("unquoted string cannot contain quote") + } + *s = AutoQuote(t) + return t, nil +} + +var deprecatedRE = lazyregexp.New(`(?s)(?:^|\n\n)Deprecated: *(.*?)(?:$|\n\n)`) + +// parseDeprecation extracts the text of comments on a "module" directive and +// extracts a deprecation message from that. +// +// A deprecation message is contained in a paragraph within a block of comments +// that starts with "Deprecated:" (case sensitive). The message runs until the +// end of the paragraph and does not include the "Deprecated:" prefix. If the +// comment block has multiple paragraphs that start with "Deprecated:", +// parseDeprecation returns the message from the first. +func parseDeprecation(block *LineBlock, line *Line) string { + text := parseDirectiveComment(block, line) + m := deprecatedRE.FindStringSubmatch(text) + if m == nil { + return "" + } + return m[1] +} + +// parseDirectiveComment extracts the text of comments on a directive. +// If the directive's line does not have comments and is part of a block that +// does have comments, the block's comments are used. +func parseDirectiveComment(block *LineBlock, line *Line) string { + comments := line.Comment() + if block != nil && len(comments.Before) == 0 && len(comments.Suffix) == 0 { + comments = block.Comment() + } + groups := [][]Comment{comments.Before, comments.Suffix} + var lines []string + for _, g := range groups { + for _, c := range g { + if !strings.HasPrefix(c.Token, "//") { + continue // blank line + } + lines = append(lines, strings.TrimSpace(strings.TrimPrefix(c.Token, "//"))) + } + } + return strings.Join(lines, "\n") +} + +type ErrorList []Error + +func (e ErrorList) Error() string { + errStrs := make([]string, len(e)) + for i, err := range e { + errStrs[i] = err.Error() + } + return strings.Join(errStrs, "\n") +} + +type Error struct { + Filename string + Pos Position + Verb string + ModPath string + Err error +} + +func (e *Error) Error() string { + var pos string + if e.Pos.LineRune > 1 { + // Don't print LineRune if it's 1 (beginning of line). + // It's always 1 except in scanner errors, which are rare. + pos = fmt.Sprintf("%s:%d:%d: ", e.Filename, e.Pos.Line, e.Pos.LineRune) + } else if e.Pos.Line > 0 { + pos = fmt.Sprintf("%s:%d: ", e.Filename, e.Pos.Line) + } else if e.Filename != "" { + pos = fmt.Sprintf("%s: ", e.Filename) + } + + var directive string + if e.ModPath != "" { + directive = fmt.Sprintf("%s %s: ", e.Verb, e.ModPath) + } else if e.Verb != "" { + directive = fmt.Sprintf("%s: ", e.Verb) + } + + return pos + directive + e.Err.Error() +} + +func (e *Error) Unwrap() error { return e.Err } + +func parseVersion(verb string, path string, s *string, fix VersionFixer) (string, error) { + t, err := parseString(s) + if err != nil { + return "", &Error{ + Verb: verb, + ModPath: path, + Err: &module.InvalidVersionError{ + Version: *s, + Err: err, + }, + } + } + if fix != nil { + fixed, err := fix(path, t) + if err != nil { + if err, ok := err.(*module.ModuleError); ok { + return "", &Error{ + Verb: verb, + ModPath: path, + Err: err.Err, + } + } + return "", err + } + t = fixed + } else { + cv := module.CanonicalVersion(t) + if cv == "" { + return "", &Error{ + Verb: verb, + ModPath: path, + Err: &module.InvalidVersionError{ + Version: t, + Err: errors.New("must be of the form v1.2.3"), + }, + } + } + t = cv + } + *s = t + return *s, nil +} + +func modulePathMajor(path string) (string, error) { + _, major, ok := module.SplitPathVersion(path) + if !ok { + return "", fmt.Errorf("invalid module path") + } + return major, nil +} + +func (f *File) Format() ([]byte, error) { + return Format(f.Syntax), nil +} + +// Cleanup cleans up the file f after any edit operations. +// To avoid quadratic behavior, modifications like [File.DropRequire] +// clear the entry but do not remove it from the slice. +// Cleanup cleans out all the cleared entries. +func (f *File) Cleanup() { + w := 0 + for _, g := range f.Godebug { + if g.Key != "" { + f.Godebug[w] = g + w++ + } + } + f.Godebug = f.Godebug[:w] + + w = 0 + for _, r := range f.Require { + if r.Mod.Path != "" { + f.Require[w] = r + w++ + } + } + f.Require = f.Require[:w] + + w = 0 + for _, x := range f.Exclude { + if x.Mod.Path != "" { + f.Exclude[w] = x + w++ + } + } + f.Exclude = f.Exclude[:w] + + w = 0 + for _, r := range f.Replace { + if r.Old.Path != "" { + f.Replace[w] = r + w++ + } + } + f.Replace = f.Replace[:w] + + w = 0 + for _, r := range f.Retract { + if r.Low != "" || r.High != "" { + f.Retract[w] = r + w++ + } + } + f.Retract = f.Retract[:w] + + f.Syntax.Cleanup() +} + +func (f *File) AddGoStmt(version string) error { + if !GoVersionRE.MatchString(version) { + return fmt.Errorf("invalid language version %q", version) + } + if f.Go == nil { + var hint Expr + if f.Module != nil && f.Module.Syntax != nil { + hint = f.Module.Syntax + } else if f.Syntax == nil { + f.Syntax = new(FileSyntax) + } + f.Go = &Go{ + Version: version, + Syntax: f.Syntax.addLine(hint, "go", version), + } + } else { + f.Go.Version = version + f.Syntax.updateLine(f.Go.Syntax, "go", version) + } + return nil +} + +// DropGoStmt deletes the go statement from the file. +func (f *File) DropGoStmt() { + if f.Go != nil { + f.Go.Syntax.markRemoved() + f.Go = nil + } +} + +// DropToolchainStmt deletes the toolchain statement from the file. +func (f *File) DropToolchainStmt() { + if f.Toolchain != nil { + f.Toolchain.Syntax.markRemoved() + f.Toolchain = nil + } +} + +func (f *File) AddToolchainStmt(name string) error { + if !ToolchainRE.MatchString(name) { + return fmt.Errorf("invalid toolchain name %q", name) + } + if f.Toolchain == nil { + var hint Expr + if f.Go != nil && f.Go.Syntax != nil { + hint = f.Go.Syntax + } else if f.Module != nil && f.Module.Syntax != nil { + hint = f.Module.Syntax + } + f.Toolchain = &Toolchain{ + Name: name, + Syntax: f.Syntax.addLine(hint, "toolchain", name), + } + } else { + f.Toolchain.Name = name + f.Syntax.updateLine(f.Toolchain.Syntax, "toolchain", name) + } + return nil +} + +// AddGodebug sets the first godebug line for key to value, +// preserving any existing comments for that line and removing all +// other godebug lines for key. +// +// If no line currently exists for key, AddGodebug adds a new line +// at the end of the last godebug block. +func (f *File) AddGodebug(key, value string) error { + need := true + for _, g := range f.Godebug { + if g.Key == key { + if need { + g.Value = value + f.Syntax.updateLine(g.Syntax, "godebug", key+"="+value) + need = false + } else { + g.Syntax.markRemoved() + *g = Godebug{} + } + } + } + + if need { + f.addNewGodebug(key, value) + } + return nil +} + +// addNewGodebug adds a new godebug key=value line at the end +// of the last godebug block, regardless of any existing godebug lines for key. +func (f *File) addNewGodebug(key, value string) { + line := f.Syntax.addLine(nil, "godebug", key+"="+value) + g := &Godebug{ + Key: key, + Value: value, + Syntax: line, + } + f.Godebug = append(f.Godebug, g) +} + +// AddRequire sets the first require line for path to version vers, +// preserving any existing comments for that line and removing all +// other lines for path. +// +// If no line currently exists for path, AddRequire adds a new line +// at the end of the last require block. +func (f *File) AddRequire(path, vers string) error { + need := true + for _, r := range f.Require { + if r.Mod.Path == path { + if need { + r.Mod.Version = vers + f.Syntax.updateLine(r.Syntax, "require", AutoQuote(path), vers) + need = false + } else { + r.Syntax.markRemoved() + *r = Require{} + } + } + } + + if need { + f.AddNewRequire(path, vers, false) + } + return nil +} + +// AddNewRequire adds a new require line for path at version vers at the end of +// the last require block, regardless of any existing require lines for path. +func (f *File) AddNewRequire(path, vers string, indirect bool) { + line := f.Syntax.addLine(nil, "require", AutoQuote(path), vers) + r := &Require{ + Mod: module.Version{Path: path, Version: vers}, + Syntax: line, + } + r.setIndirect(indirect) + f.Require = append(f.Require, r) +} + +// SetRequire updates the requirements of f to contain exactly req, preserving +// the existing block structure and line comment contents (except for 'indirect' +// markings) for the first requirement on each named module path. +// +// The Syntax field is ignored for the requirements in req. +// +// Any requirements not already present in the file are added to the block +// containing the last require line. +// +// The requirements in req must specify at most one distinct version for each +// module path. +// +// If any existing requirements may be removed, the caller should call +// [File.Cleanup] after all edits are complete. +func (f *File) SetRequire(req []*Require) { + type elem struct { + version string + indirect bool + } + need := make(map[string]elem) + for _, r := range req { + if prev, dup := need[r.Mod.Path]; dup && prev.version != r.Mod.Version { + panic(fmt.Errorf("SetRequire called with conflicting versions for path %s (%s and %s)", r.Mod.Path, prev.version, r.Mod.Version)) + } + need[r.Mod.Path] = elem{r.Mod.Version, r.Indirect} + } + + // Update or delete the existing Require entries to preserve + // only the first for each module path in req. + for _, r := range f.Require { + e, ok := need[r.Mod.Path] + if ok { + r.setVersion(e.version) + r.setIndirect(e.indirect) + } else { + r.markRemoved() + } + delete(need, r.Mod.Path) + } + + // Add new entries in the last block of the file for any paths that weren't + // already present. + // + // This step is nondeterministic, but the final result will be deterministic + // because we will sort the block. + for path, e := range need { + f.AddNewRequire(path, e.version, e.indirect) + } + + f.SortBlocks() +} + +// SetRequireSeparateIndirect updates the requirements of f to contain the given +// requirements. Comment contents (except for 'indirect' markings) are retained +// from the first existing requirement for each module path. Like SetRequire, +// SetRequireSeparateIndirect adds requirements for new paths in req, +// updates the version and "// indirect" comment on existing requirements, +// and deletes requirements on paths not in req. Existing duplicate requirements +// are deleted. +// +// As its name suggests, SetRequireSeparateIndirect puts direct and indirect +// requirements into two separate blocks, one containing only direct +// requirements, and the other containing only indirect requirements. +// SetRequireSeparateIndirect may move requirements between these two blocks +// when their indirect markings change. However, SetRequireSeparateIndirect +// won't move requirements from other blocks, especially blocks with comments. +// +// If the file initially has one uncommented block of requirements, +// SetRequireSeparateIndirect will split it into a direct-only and indirect-only +// block. This aids in the transition to separate blocks. +func (f *File) SetRequireSeparateIndirect(req []*Require) { + // hasComments returns whether a line or block has comments + // other than "indirect". + hasComments := func(c Comments) bool { + return len(c.Before) > 0 || len(c.After) > 0 || len(c.Suffix) > 1 || + (len(c.Suffix) == 1 && + strings.TrimSpace(strings.TrimPrefix(c.Suffix[0].Token, string(slashSlash))) != "indirect") + } + + // moveReq adds r to block. If r was in another block, moveReq deletes + // it from that block and transfers its comments. + moveReq := func(r *Require, block *LineBlock) { + var line *Line + if r.Syntax == nil { + line = &Line{Token: []string{AutoQuote(r.Mod.Path), r.Mod.Version}} + r.Syntax = line + if r.Indirect { + r.setIndirect(true) + } + } else { + line = new(Line) + *line = *r.Syntax + if !line.InBlock && len(line.Token) > 0 && line.Token[0] == "require" { + line.Token = line.Token[1:] + } + r.Syntax.Token = nil // Cleanup will delete the old line. + r.Syntax = line + } + line.InBlock = true + block.Line = append(block.Line, line) + } + + // Examine existing require lines and blocks. + var ( + // We may insert new requirements into the last uncommented + // direct-only and indirect-only blocks. We may also move requirements + // to the opposite block if their indirect markings change. + lastDirectIndex = -1 + lastIndirectIndex = -1 + + // If there are no direct-only or indirect-only blocks, a new block may + // be inserted after the last require line or block. + lastRequireIndex = -1 + + // If there's only one require line or block, and it's uncommented, + // we'll move its requirements to the direct-only or indirect-only blocks. + requireLineOrBlockCount = 0 + + // Track the block each requirement belongs to (if any) so we can + // move them later. + lineToBlock = make(map[*Line]*LineBlock) + ) + for i, stmt := range f.Syntax.Stmt { + switch stmt := stmt.(type) { + case *Line: + if len(stmt.Token) == 0 || stmt.Token[0] != "require" { + continue + } + lastRequireIndex = i + requireLineOrBlockCount++ + if !hasComments(stmt.Comments) { + if isIndirect(stmt) { + lastIndirectIndex = i + } else { + lastDirectIndex = i + } + } + + case *LineBlock: + if len(stmt.Token) == 0 || stmt.Token[0] != "require" { + continue + } + lastRequireIndex = i + requireLineOrBlockCount++ + allDirect := len(stmt.Line) > 0 && !hasComments(stmt.Comments) + allIndirect := len(stmt.Line) > 0 && !hasComments(stmt.Comments) + for _, line := range stmt.Line { + lineToBlock[line] = stmt + if hasComments(line.Comments) { + allDirect = false + allIndirect = false + } else if isIndirect(line) { + allDirect = false + } else { + allIndirect = false + } + } + if allDirect { + lastDirectIndex = i + } + if allIndirect { + lastIndirectIndex = i + } + } + } + + oneFlatUncommentedBlock := requireLineOrBlockCount == 1 && + !hasComments(*f.Syntax.Stmt[lastRequireIndex].Comment()) + + // Create direct and indirect blocks if needed. Convert lines into blocks + // if needed. If we end up with an empty block or a one-line block, + // Cleanup will delete it or convert it to a line later. + insertBlock := func(i int) *LineBlock { + block := &LineBlock{Token: []string{"require"}} + f.Syntax.Stmt = append(f.Syntax.Stmt, nil) + copy(f.Syntax.Stmt[i+1:], f.Syntax.Stmt[i:]) + f.Syntax.Stmt[i] = block + return block + } + + ensureBlock := func(i int) *LineBlock { + switch stmt := f.Syntax.Stmt[i].(type) { + case *LineBlock: + return stmt + case *Line: + block := &LineBlock{ + Token: []string{"require"}, + Line: []*Line{stmt}, + } + stmt.Token = stmt.Token[1:] // remove "require" + stmt.InBlock = true + f.Syntax.Stmt[i] = block + return block + default: + panic(fmt.Sprintf("unexpected statement: %v", stmt)) + } + } + + var lastDirectBlock *LineBlock + if lastDirectIndex < 0 { + if lastIndirectIndex >= 0 { + lastDirectIndex = lastIndirectIndex + lastIndirectIndex++ + } else if lastRequireIndex >= 0 { + lastDirectIndex = lastRequireIndex + 1 + } else { + lastDirectIndex = len(f.Syntax.Stmt) + } + lastDirectBlock = insertBlock(lastDirectIndex) + } else { + lastDirectBlock = ensureBlock(lastDirectIndex) + } + + var lastIndirectBlock *LineBlock + if lastIndirectIndex < 0 { + lastIndirectIndex = lastDirectIndex + 1 + lastIndirectBlock = insertBlock(lastIndirectIndex) + } else { + lastIndirectBlock = ensureBlock(lastIndirectIndex) + } + + // 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 + // uncommented mixed block (oneFlatUncommentedBlock), move it to the + // correct block. + // + // Some blocks may be empty after this. Cleanup will remove them. + need := make(map[string]*Require) + for _, r := range req { + need[r.Mod.Path] = r + } + have := make(map[string]*Require) + for _, r := range f.Require { + path := r.Mod.Path + if need[path] == nil || have[path] != nil { + // Requirement not needed, or duplicate requirement. Delete. + r.markRemoved() + continue + } + have[r.Mod.Path] = r + r.setVersion(need[path].Mod.Version) + r.setIndirect(need[path].Indirect) + if need[path].Indirect && + (oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastDirectBlock) { + moveReq(r, lastIndirectBlock) + } else if !need[path].Indirect && + (oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastIndirectBlock) { + moveReq(r, lastDirectBlock) + } + } + + // Add new requirements. + for path, r := range need { + if have[path] == nil { + if r.Indirect { + moveReq(r, lastIndirectBlock) + } else { + moveReq(r, lastDirectBlock) + } + f.Require = append(f.Require, r) + } + } + + f.SortBlocks() +} + +func (f *File) DropGodebug(key string) error { + for _, g := range f.Godebug { + if g.Key == key { + g.Syntax.markRemoved() + *g = Godebug{} + } + } + return nil +} + +func (f *File) DropRequire(path string) error { + for _, r := range f.Require { + if r.Mod.Path == path { + r.Syntax.markRemoved() + *r = Require{} + } + } + return nil +} + +// AddExclude adds a 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 { + return err + } + + var hint *Line + for _, x := range f.Exclude { + if x.Mod.Path == path && x.Mod.Version == vers { + return nil + } + if x.Mod.Path == path { + hint = x.Syntax + } + } + + f.Exclude = append(f.Exclude, &Exclude{Mod: module.Version{Path: path, Version: vers}, Syntax: f.Syntax.addLine(hint, "exclude", AutoQuote(path), vers)}) + return nil +} + +func (f *File) DropExclude(path, vers string) error { + for _, x := range f.Exclude { + if x.Mod.Path == path && x.Mod.Version == vers { + x.Syntax.markRemoved() + *x = Exclude{} + } + } + return nil +} + +func (f *File) AddReplace(oldPath, oldVers, newPath, newVers string) error { + return addReplace(f.Syntax, &f.Replace, oldPath, oldVers, newPath, newVers) +} + +func addReplace(syntax *FileSyntax, replace *[]*Replace, oldPath, oldVers, newPath, newVers string) error { + need := true + old := module.Version{Path: oldPath, Version: oldVers} + new := module.Version{Path: newPath, Version: newVers} + tokens := []string{"replace", AutoQuote(oldPath)} + if oldVers != "" { + tokens = append(tokens, oldVers) + } + tokens = append(tokens, "=>", AutoQuote(newPath)) + if newVers != "" { + tokens = append(tokens, newVers) + } + + var hint *Line + for _, r := range *replace { + if r.Old.Path == oldPath && (oldVers == "" || r.Old.Version == oldVers) { + if need { + // Found replacement for old; update to use new. + r.New = new + syntax.updateLine(r.Syntax, tokens...) + need = false + continue + } + // Already added; delete other replacements for same. + r.Syntax.markRemoved() + *r = Replace{} + } + if r.Old.Path == oldPath { + hint = r.Syntax + } + } + if need { + *replace = append(*replace, &Replace{Old: old, New: new, Syntax: syntax.addLine(hint, tokens...)}) + } + return nil +} + +func (f *File) DropReplace(oldPath, oldVers string) error { + for _, r := range f.Replace { + if r.Old.Path == oldPath && r.Old.Version == oldVers { + r.Syntax.markRemoved() + *r = Replace{} + } + } + return nil +} + +// AddRetract adds a retract statement to the mod file. Errors if the provided +// version interval does not consist of canonical version strings +func (f *File) AddRetract(vi VersionInterval, rationale string) error { + var path string + if f.Module != nil { + path = f.Module.Mod.Path + } + if err := checkCanonicalVersion(path, vi.High); err != nil { + return err + } + if err := checkCanonicalVersion(path, vi.Low); err != nil { + return err + } + + r := &Retract{ + VersionInterval: vi, + } + if vi.Low == vi.High { + r.Syntax = f.Syntax.addLine(nil, "retract", AutoQuote(vi.Low)) + } else { + r.Syntax = f.Syntax.addLine(nil, "retract", "[", AutoQuote(vi.Low), ",", AutoQuote(vi.High), "]") + } + if rationale != "" { + for line := range strings.SplitSeq(rationale, "\n") { + com := Comment{Token: "// " + line} + r.Syntax.Comment().Before = append(r.Syntax.Comment().Before, com) + } + } + return nil +} + +func (f *File) DropRetract(vi VersionInterval) error { + for _, r := range f.Retract { + if r.VersionInterval == vi { + r.Syntax.markRemoved() + *r = Retract{} + } + } + return nil +} + +// AddTool adds a new tool directive with the given path. +// It does nothing if the tool line already exists. +func (f *File) AddTool(path string) error { + for _, t := range f.Tool { + if t.Path == path { + return nil + } + } + + f.Tool = append(f.Tool, &Tool{ + Path: path, + Syntax: f.Syntax.addLine(nil, "tool", path), + }) + + f.SortBlocks() + return nil +} + +// RemoveTool removes a tool directive with the given path. +// It does nothing if no such tool directive exists. +func (f *File) DropTool(path string) error { + for _, t := range f.Tool { + if t.Path == path { + t.Syntax.markRemoved() + *t = Tool{} + } + } + return nil +} + +// AddIgnore adds a new ignore directive with the given path. +// It does nothing if the ignore line already exists. +func (f *File) AddIgnore(path string) error { + for _, t := range f.Ignore { + if t.Path == path { + return nil + } + } + + f.Ignore = append(f.Ignore, &Ignore{ + Path: path, + Syntax: f.Syntax.addLine(nil, "ignore", path), + }) + + f.SortBlocks() + return nil +} + +// DropIgnore removes a 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 { + if t.Path == path { + t.Syntax.markRemoved() + *t = Ignore{} + } + } + return nil +} + +func (f *File) SortBlocks() { + f.removeDups() // otherwise sorting is unsafe + + // semanticSortForExcludeVersionV is the Go version (plus leading "v") at which + // lines in exclude blocks start to use semantic sort instead of lexicographic sort. + // See go.dev/issue/60028. + const semanticSortForExcludeVersionV = "v1.21" + useSemanticSortForExclude := f.Go != nil && semver.Compare("v"+f.Go.Version, semanticSortForExcludeVersionV) >= 0 + + for _, stmt := range f.Syntax.Stmt { + block, ok := stmt.(*LineBlock) + if !ok { + continue + } + less := compareLine + if block.Token[0] == "exclude" && useSemanticSortForExclude { + less = compareLineExclude + } else if block.Token[0] == "retract" { + less = compareLineRetract + } + slices.SortStableFunc(block.Line, less) + } +} + +// removeDups removes duplicate exclude, replace and tool directives. +// +// Earlier exclude and tool directives take priority. +// +// Later replace directives take priority. +// +// require directives are not de-duplicated. That's left up to higher-level +// logic (MVS). +// +// retract directives are not de-duplicated since comments are +// meaningful, and versions may be retracted multiple times. +func (f *File) removeDups() { + removeDups(f.Syntax, &f.Exclude, &f.Replace, &f.Tool, &f.Ignore) +} + +func removeDups(syntax *FileSyntax, exclude *[]*Exclude, replace *[]*Replace, tool *[]*Tool, ignore *[]*Ignore) { + kill := make(map[*Line]bool) + + // Remove duplicate excludes. + if exclude != nil { + haveExclude := make(map[module.Version]bool) + for _, x := range *exclude { + if haveExclude[x.Mod] { + kill[x.Syntax] = true + continue + } + haveExclude[x.Mod] = true + } + var excl []*Exclude + for _, x := range *exclude { + if !kill[x.Syntax] { + excl = append(excl, x) + } + } + *exclude = excl + } + + // Remove duplicate replacements. + // Later replacements take priority over earlier ones. + haveReplace := make(map[module.Version]bool) + for i := len(*replace) - 1; i >= 0; i-- { + x := (*replace)[i] + if haveReplace[x.Old] { + kill[x.Syntax] = true + continue + } + haveReplace[x.Old] = true + } + var repl []*Replace + for _, x := range *replace { + if !kill[x.Syntax] { + repl = append(repl, x) + } + } + *replace = repl + + if tool != nil { + haveTool := make(map[string]bool) + for _, t := range *tool { + if haveTool[t.Path] { + kill[t.Syntax] = true + continue + } + haveTool[t.Path] = true + } + var newTool []*Tool + for _, t := range *tool { + if !kill[t.Syntax] { + newTool = append(newTool, t) + } + } + *tool = newTool + } + + if ignore != nil { + haveIgnore := make(map[string]bool) + for _, i := range *ignore { + if haveIgnore[i.Path] { + kill[i.Syntax] = true + continue + } + haveIgnore[i.Path] = true + } + var newIgnore []*Ignore + for _, i := range *ignore { + if !kill[i.Syntax] { + newIgnore = append(newIgnore, i) + } + } + *ignore = newIgnore + } + + // Duplicate require and retract directives are not removed. + + // Drop killed statements from the syntax tree. + var stmts []Expr + for _, stmt := range syntax.Stmt { + switch stmt := stmt.(type) { + case *Line: + if kill[stmt] { + continue + } + case *LineBlock: + var lines []*Line + for _, line := range stmt.Line { + if !kill[line] { + lines = append(lines, line) + } + } + stmt.Line = lines + if len(lines) == 0 { + continue + } + } + stmts = append(stmts, stmt) + } + syntax.Stmt = stmts +} + +// compareLine compares li and lj. It sorts lexicographically without assigning +// any special meaning to tokens. +func compareLine(li, lj *Line) int { + for k := 0; k < len(li.Token) && k < len(lj.Token); k++ { + if li.Token[k] != lj.Token[k] { + return cmp.Compare(li.Token[k], lj.Token[k]) + } + } + return cmp.Compare(len(li.Token), len(lj.Token)) +} + +// compareLineExclude compares li and lj for lines in an "exclude" block. +func compareLineExclude(li, lj *Line) int { + if len(li.Token) != 2 || len(lj.Token) != 2 { + // Not a known exclude specification. + // Fall back to sorting lexicographically. + return compareLine(li, lj) + } + // An exclude specification has two tokens: ModulePath and Version. + // Compare module path by string order and version by semver rules. + if pi, pj := li.Token[0], lj.Token[0]; pi != pj { + return cmp.Compare(pi, pj) + } + return semver.Compare(li.Token[1], lj.Token[1]) +} + +// compareLineRetract compares li and lj for lines in a "retract" block. +// It treats each line as a version interval. Single versions are compared as +// if they were intervals with the same low and high version. +// Intervals are sorted in descending order, first by low version, then by +// high version, using [semver.Compare]. +func compareLineRetract(li, lj *Line) int { + interval := func(l *Line) VersionInterval { + if len(l.Token) == 1 { + return VersionInterval{Low: l.Token[0], High: l.Token[0]} + } else if len(l.Token) == 5 && l.Token[0] == "[" && l.Token[2] == "," && l.Token[4] == "]" { + return VersionInterval{Low: l.Token[1], High: l.Token[3]} + } else { + // Line in unknown format. Treat as an invalid version. + return VersionInterval{} + } + } + vii := interval(li) + vij := interval(lj) + if cmp := semver.Compare(vii.Low, vij.Low); cmp != 0 { + return -cmp + } + return -semver.Compare(vii.High, vij.High) +} + +// checkCanonicalVersion returns a non-nil error if vers is not a canonical +// version string or does not match the major version of path. +// +// If path is non-empty, the error text suggests a format with a major version +// corresponding to the path. +func checkCanonicalVersion(path, vers string) error { + _, pathMajor, pathMajorOk := module.SplitPathVersion(path) + + if vers == "" || vers != module.CanonicalVersion(vers) { + if pathMajor == "" { + return &module.InvalidVersionError{ + Version: vers, + Err: fmt.Errorf("must be of the form v1.2.3"), + } + } + return &module.InvalidVersionError{ + Version: vers, + Err: fmt.Errorf("must be of the form %s.2.3", module.PathMajorPrefix(pathMajor)), + } + } + + if pathMajorOk { + if err := module.CheckPathMajor(vers, pathMajor); err != nil { + if pathMajor == "" { + // In this context, the user probably wrote "v2.3.4" when they meant + // "v2.3.4+incompatible". Suggest that instead of "v0 or v1". + return &module.InvalidVersionError{ + Version: vers, + Err: fmt.Errorf("should be %s+incompatible (or module %s/%v)", vers, path, semver.Major(vers)), + } + } + return err + } + } + + return nil +} diff --git a/vendor/golang.org/x/mod/modfile/work.go b/vendor/golang.org/x/mod/modfile/work.go new file mode 100644 index 000000000..09df5ea3c --- /dev/null +++ b/vendor/golang.org/x/mod/modfile/work.go @@ -0,0 +1,333 @@ +// Copyright 2021 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 modfile + +import ( + "fmt" + "slices" + "strings" +) + +// A WorkFile is the parsed, interpreted form of a go.work file. +type WorkFile struct { + Go *Go + Toolchain *Toolchain + Godebug []*Godebug + Use []*Use + Replace []*Replace + + Syntax *FileSyntax +} + +// A Use is a single directory statement. +type Use struct { + Path string // Use path of module. + ModulePath string // Module path in the comment. + Syntax *Line +} + +// ParseWork parses and returns a go.work file. +// +// file is the name of the file, used in positions and errors. +// +// data is the content of the file. +// +// fix is an optional function that canonicalizes module versions. +// If fix is nil, all module versions must be canonical ([module.CanonicalVersion] +// must return the same string). +func ParseWork(file string, data []byte, fix VersionFixer) (*WorkFile, error) { + fs, err := parse(file, data) + if err != nil { + return nil, err + } + f := &WorkFile{ + Syntax: fs, + } + var errs ErrorList + + for _, x := range fs.Stmt { + switch x := x.(type) { + case *Line: + f.add(&errs, x, x.Token[0], x.Token[1:], fix) + + case *LineBlock: + if len(x.Token) > 1 { + errs = append(errs, Error{ + Filename: file, + Pos: x.Start, + Err: fmt.Errorf("unknown block type: %s", strings.Join(x.Token, " ")), + }) + continue + } + switch x.Token[0] { + default: + errs = append(errs, Error{ + Filename: file, + Pos: x.Start, + Err: fmt.Errorf("unknown block type: %s", strings.Join(x.Token, " ")), + }) + continue + case "godebug", "use", "replace": + for _, l := range x.Line { + f.add(&errs, l, x.Token[0], l.Token, fix) + } + } + } + } + + if len(errs) > 0 { + return nil, errs + } + return f, nil +} + +// Cleanup cleans up the file f after any edit operations. +// To avoid quadratic behavior, modifications like [WorkFile.DropRequire] +// clear the entry but do not remove it from the slice. +// Cleanup cleans out all the cleared entries. +func (f *WorkFile) Cleanup() { + w := 0 + for _, r := range f.Use { + if r.Path != "" { + f.Use[w] = r + w++ + } + } + f.Use = f.Use[:w] + + w = 0 + for _, r := range f.Replace { + if r.Old.Path != "" { + f.Replace[w] = r + w++ + } + } + f.Replace = f.Replace[:w] + + f.Syntax.Cleanup() +} + +func (f *WorkFile) AddGoStmt(version string) error { + if !GoVersionRE.MatchString(version) { + return fmt.Errorf("invalid language version %q", version) + } + if f.Go == nil { + stmt := &Line{Token: []string{"go", version}} + f.Go = &Go{ + Version: version, + Syntax: stmt, + } + // Find the first non-comment-only block and add + // the go statement before it. That will keep file comments at the top. + i := 0 + for i = 0; i < len(f.Syntax.Stmt); i++ { + if _, ok := f.Syntax.Stmt[i].(*CommentBlock); !ok { + break + } + } + f.Syntax.Stmt = append(append(f.Syntax.Stmt[:i:i], stmt), f.Syntax.Stmt[i:]...) + } else { + f.Go.Version = version + f.Syntax.updateLine(f.Go.Syntax, "go", version) + } + return nil +} + +func (f *WorkFile) AddToolchainStmt(name string) error { + if !ToolchainRE.MatchString(name) { + return fmt.Errorf("invalid toolchain name %q", name) + } + if f.Toolchain == nil { + stmt := &Line{Token: []string{"toolchain", name}} + f.Toolchain = &Toolchain{ + Name: name, + Syntax: stmt, + } + // Find the go line and add the toolchain line after it. + // Or else find the first non-comment-only block and add + // the toolchain line before it. That will keep file comments at the top. + i := 0 + for i = 0; i < len(f.Syntax.Stmt); i++ { + if line, ok := f.Syntax.Stmt[i].(*Line); ok && len(line.Token) > 0 && line.Token[0] == "go" { + i++ + goto Found + } + } + for i = 0; i < len(f.Syntax.Stmt); i++ { + if _, ok := f.Syntax.Stmt[i].(*CommentBlock); !ok { + break + } + } + Found: + f.Syntax.Stmt = append(append(f.Syntax.Stmt[:i:i], stmt), f.Syntax.Stmt[i:]...) + } else { + f.Toolchain.Name = name + f.Syntax.updateLine(f.Toolchain.Syntax, "toolchain", name) + } + return nil +} + +// DropGoStmt deletes the go statement from the file. +func (f *WorkFile) DropGoStmt() { + if f.Go != nil { + f.Go.Syntax.markRemoved() + f.Go = nil + } +} + +// DropToolchainStmt deletes the toolchain statement from the file. +func (f *WorkFile) DropToolchainStmt() { + if f.Toolchain != nil { + f.Toolchain.Syntax.markRemoved() + f.Toolchain = nil + } +} + +// AddGodebug sets the first godebug line for key to value, +// preserving any existing comments for that line and removing all +// other godebug lines for key. +// +// If no line currently exists for key, AddGodebug adds a new line +// at the end of the last godebug block. +func (f *WorkFile) AddGodebug(key, value string) error { + need := true + for _, g := range f.Godebug { + if g.Key == key { + if need { + g.Value = value + f.Syntax.updateLine(g.Syntax, "godebug", key+"="+value) + need = false + } else { + g.Syntax.markRemoved() + *g = Godebug{} + } + } + } + + if need { + f.addNewGodebug(key, value) + } + return nil +} + +// addNewGodebug adds a new godebug key=value line at the end +// of the last godebug block, regardless of any existing godebug lines for key. +func (f *WorkFile) addNewGodebug(key, value string) { + line := f.Syntax.addLine(nil, "godebug", key+"="+value) + g := &Godebug{ + Key: key, + Value: value, + Syntax: line, + } + f.Godebug = append(f.Godebug, g) +} + +func (f *WorkFile) DropGodebug(key string) error { + for _, g := range f.Godebug { + if g.Key == key { + g.Syntax.markRemoved() + *g = Godebug{} + } + } + return nil +} + +func (f *WorkFile) AddUse(diskPath, modulePath string) error { + need := true + for _, d := range f.Use { + if d.Path == diskPath { + if need { + d.ModulePath = modulePath + f.Syntax.updateLine(d.Syntax, "use", AutoQuote(diskPath)) + need = false + } else { + d.Syntax.markRemoved() + *d = Use{} + } + } + } + + if need { + f.AddNewUse(diskPath, modulePath) + } + return nil +} + +func (f *WorkFile) AddNewUse(diskPath, modulePath string) { + line := f.Syntax.addLine(nil, "use", AutoQuote(diskPath)) + f.Use = append(f.Use, &Use{Path: diskPath, ModulePath: modulePath, Syntax: line}) +} + +func (f *WorkFile) SetUse(dirs []*Use) { + need := make(map[string]string) + for _, d := range dirs { + need[d.Path] = d.ModulePath + } + + for _, d := range f.Use { + if modulePath, ok := need[d.Path]; ok { + d.ModulePath = modulePath + } else { + d.Syntax.markRemoved() + *d = Use{} + } + } + + // TODO(#45713): Add module path to comment. + + for diskPath, modulePath := range need { + f.AddNewUse(diskPath, modulePath) + } + f.SortBlocks() +} + +func (f *WorkFile) DropUse(path string) error { + for _, d := range f.Use { + if d.Path == path { + d.Syntax.markRemoved() + *d = Use{} + } + } + return nil +} + +func (f *WorkFile) AddReplace(oldPath, oldVers, newPath, newVers string) error { + return addReplace(f.Syntax, &f.Replace, oldPath, oldVers, newPath, newVers) +} + +func (f *WorkFile) DropReplace(oldPath, oldVers string) error { + for _, r := range f.Replace { + if r.Old.Path == oldPath && r.Old.Version == oldVers { + r.Syntax.markRemoved() + *r = Replace{} + } + } + return nil +} + +func (f *WorkFile) SortBlocks() { + f.removeDups() // otherwise sorting is unsafe + + for _, stmt := range f.Syntax.Stmt { + block, ok := stmt.(*LineBlock) + if !ok { + continue + } + slices.SortStableFunc(block.Line, compareLine) + } +} + +// removeDups removes duplicate replace directives. +// +// Later replace directives take priority. +// +// require directives are not de-duplicated. That's left up to higher-level +// logic (MVS). +// +// retract directives are not de-duplicated since comments are +// meaningful, and versions may be retracted multiple times. +func (f *WorkFile) removeDups() { + removeDups(f.Syntax, nil, &f.Replace, nil, nil) +} diff --git a/vendor/golang.org/x/mod/module/module.go b/vendor/golang.org/x/mod/module/module.go new file mode 100644 index 000000000..739c13f48 --- /dev/null +++ b/vendor/golang.org/x/mod/module/module.go @@ -0,0 +1,840 @@ +// 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 module defines the module.Version type along with support code. +// +// The [module.Version] type is a simple Path, Version pair: +// +// type Version struct { +// Path string +// Version string +// } +// +// There are no restrictions imposed directly by use of this structure, +// but additional checking functions, most notably [Check], verify that +// a particular path, version pair is valid. +// +// # Escaped Paths +// +// Module paths appear as substrings of file system paths +// (in the download cache) and of web server URLs in the proxy protocol. +// In general we cannot rely on file systems to be case-sensitive, +// nor can we rely on web servers, since they read from file systems. +// That is, we cannot rely on the file system to keep rsc.io/QUOTE +// and rsc.io/quote separate. Windows and macOS don't. +// Instead, we must never require two different casings of a file path. +// Because we want the download cache to match the proxy protocol, +// and because we want the proxy protocol to be possible to serve +// from a tree of static files (which might be stored on a case-insensitive +// file system), the proxy protocol must never require two different casings +// of a URL path either. +// +// One possibility would be to make the escaped form be the lowercase +// hexadecimal encoding of the actual path bytes. This would avoid ever +// needing different casings of a file path, but it would be fairly illegible +// to most programmers when those paths appeared in the file system +// (including in file paths in compiler errors and stack traces) +// in web server logs, and so on. Instead, we want a safe escaped form that +// leaves most paths unaltered. +// +// The safe escaped form is to replace every uppercase letter +// with an exclamation mark followed by the letter's lowercase equivalent. +// +// For example, +// +// github.com/Azure/azure-sdk-for-go -> github.com/!azure/azure-sdk-for-go. +// github.com/GoogleCloudPlatform/cloudsql-proxy -> github.com/!google!cloud!platform/cloudsql-proxy +// github.com/Sirupsen/logrus -> github.com/!sirupsen/logrus. +// +// Import paths that avoid upper-case letters are left unchanged. +// Note that because import paths are ASCII-only and avoid various +// problematic punctuation (like : < and >), the escaped form is also ASCII-only +// and avoids the same problematic punctuation. +// +// Import paths have never allowed exclamation marks, so there is no +// need to define how to escape a literal !. +// +// # Unicode Restrictions +// +// Today, paths are disallowed from using Unicode. +// +// Although paths are currently disallowed from using Unicode, +// we would like at some point to allow Unicode letters as well, to assume that +// file systems and URLs are Unicode-safe (storing UTF-8), and apply +// the !-for-uppercase convention for escaping them in the file system. +// But there are at least two subtle considerations. +// +// First, note that not all case-fold equivalent distinct runes +// form an upper/lower pair. +// For example, U+004B ('K'), U+006B ('k'), and U+212A ('K' for Kelvin) +// are three distinct runes that case-fold to each other. +// When we do add Unicode letters, we must not assume that upper/lower +// are the only case-equivalent pairs. +// Perhaps the Kelvin symbol would be disallowed entirely, for example. +// Or perhaps it would escape as "!!k", or perhaps as "(212A)". +// +// Second, it would be nice to allow Unicode marks as well as letters, +// but marks include combining marks, and then we must deal not +// only with case folding but also normalization: both U+00E9 ('é') +// and U+0065 U+0301 ('e' followed by combining acute accent) +// look the same on the page and are treated by some file systems +// as the same path. If we do allow Unicode marks in paths, there +// must be some kind of normalization to allow only one canonical +// encoding of any character used in an import path. +package module + +// IMPORTANT NOTE +// +// This file essentially defines the set of valid import paths for the go command. +// There are many subtle considerations, including Unicode ambiguity, +// security, network, and file system representations. +// +// This file also defines the set of valid module path and version combinations, +// another topic with many subtle considerations. +// +// Changes to the semantics in this file require approval from rsc. + +import ( + "cmp" + "errors" + "fmt" + "path" + "slices" + "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/mod/semver" +) + +// A Version (for clients, a module.Version) is defined by a module path and version pair. +// These are stored in their plain (unescaped) form. +type Version struct { + // Path is a module path, like "golang.org/x/text" or "rsc.io/quote/v2". + Path string + + // Version is usually a semantic version in canonical form. + // There are three exceptions to this general rule. + // First, the top-level target of a build has no specific version + // and uses Version = "". + // Second, during MVS calculations the version "none" is used + // to represent the decision to take no version of a given module. + // Third, filesystem paths found in "replace" directives are + // represented by a path with an empty version. + Version string `json:",omitempty"` +} + +// String returns a representation of the Version suitable for logging +// (Path@Version, or just Path if Version is empty). +func (m Version) String() string { + if m.Version == "" { + return m.Path + } + return m.Path + "@" + m.Version +} + +// A ModuleError indicates an error specific to a module. +type ModuleError struct { + Path string + Version string + Err error +} + +// VersionError returns a [ModuleError] derived from a [Version] and error, +// or err itself if it is already such an error. +func VersionError(v Version, err error) error { + var mErr *ModuleError + if errors.As(err, &mErr) && mErr.Path == v.Path && mErr.Version == v.Version { + return err + } + return &ModuleError{ + Path: v.Path, + Version: v.Version, + Err: err, + } +} + +func (e *ModuleError) Error() string { + if v, ok := e.Err.(*InvalidVersionError); ok { + return fmt.Sprintf("%s@%s: invalid %s: %v", e.Path, v.Version, v.noun(), v.Err) + } + if e.Version != "" { + return fmt.Sprintf("%s@%s: %v", e.Path, e.Version, e.Err) + } + return fmt.Sprintf("module %s: %v", e.Path, e.Err) +} + +func (e *ModuleError) Unwrap() error { return e.Err } + +// An InvalidVersionError indicates an error specific to a version, with the +// module path unknown or specified externally. +// +// A [ModuleError] may wrap an InvalidVersionError, but an InvalidVersionError +// must not wrap a ModuleError. +type InvalidVersionError struct { + Version string + Pseudo bool + Err error +} + +// noun returns either "version" or "pseudo-version", depending on whether +// e.Version is a pseudo-version. +func (e *InvalidVersionError) noun() string { + if e.Pseudo { + return "pseudo-version" + } + return "version" +} + +func (e *InvalidVersionError) Error() string { + return fmt.Sprintf("%s %q invalid: %s", e.noun(), e.Version, e.Err) +} + +func (e *InvalidVersionError) Unwrap() error { return e.Err } + +// An InvalidPathError indicates a module, import, or file path doesn't +// satisfy all naming constraints. See [CheckPath], [CheckImportPath], +// and [CheckFilePath] for specific restrictions. +type InvalidPathError struct { + Kind string // "module", "import", or "file" + Path string + Err error +} + +func (e *InvalidPathError) Error() string { + return fmt.Sprintf("malformed %s path %q: %v", e.Kind, e.Path, e.Err) +} + +func (e *InvalidPathError) Unwrap() error { return e.Err } + +// Check checks that a given module path, version pair is valid. +// In addition to the path being a valid module path +// and the version being a valid semantic version, +// the two must correspond. +// For example, the path "yaml/v2" only corresponds to +// semantic versions beginning with "v2.". +func Check(path, version string) error { + if err := CheckPath(path); err != nil { + return err + } + if !semver.IsValid(version) { + return &ModuleError{ + Path: path, + Err: &InvalidVersionError{Version: version, Err: errors.New("not a semantic version")}, + } + } + _, pathMajor, _ := SplitPathVersion(path) + if err := CheckPathMajor(version, pathMajor); err != nil { + return &ModuleError{Path: path, Err: err} + } + return nil +} + +// firstPathOK reports whether r can appear in the first element of a module path. +// The first element of the path must be an LDH domain name, at least for now. +// To avoid case ambiguity, the domain name must be entirely lower case. +func firstPathOK(r rune) bool { + return r == '-' || r == '.' || + '0' <= r && r <= '9' || + 'a' <= r && r <= 'z' +} + +// modPathOK reports whether r can appear in a module path element. +// Paths can be ASCII letters, ASCII digits, and limited ASCII punctuation: - . _ and ~. +// +// This matches what "go get" has historically recognized in import paths, +// and avoids confusing sequences like '%20' or '+' that would change meaning +// if used in a URL. +// +// TODO(rsc): We would like to allow Unicode letters, but that requires additional +// care in the safe encoding (see "escaped paths" above). +func modPathOK(r rune) bool { + if r < utf8.RuneSelf { + return r == '-' || r == '.' || r == '_' || r == '~' || + '0' <= r && r <= '9' || + 'A' <= r && r <= 'Z' || + 'a' <= r && r <= 'z' + } + return false +} + +// importPathOK reports whether r can appear in a package import path element. +// +// Import paths are intermediate between module paths and file paths: we +// disallow characters that would be confusing or ambiguous as arguments to +// 'go get' (such as '@' and ' ' ), but allow certain characters that are +// otherwise-unambiguous on the command line and historically used for some +// binary names (such as '++' as a suffix for compiler binaries and wrappers). +func importPathOK(r rune) bool { + return modPathOK(r) || r == '+' +} + +// fileNameOK reports whether r can appear in a file name. +// For now we allow all Unicode letters but otherwise limit to pathOK plus a few more punctuation characters. +// If we expand the set of allowed characters here, we have to +// work harder at detecting potential case-folding and normalization collisions. +// See note about "escaped paths" above. +func fileNameOK(r rune) bool { + if r < utf8.RuneSelf { + // Entire set of ASCII punctuation, from which we remove characters: + // ! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ \ ] ^ _ ` { | } ~ + // We disallow some shell special characters: " ' * < > ? ` | + // (Note that some of those are disallowed by the Windows file system as well.) + // We also disallow path separators / : and \ (fileNameOK is only called on path element characters). + // We allow spaces (U+0020) in file names. + const allowed = "!#$%&()+,-.=@[]^_{}~ " + if '0' <= r && r <= '9' || 'A' <= r && r <= 'Z' || 'a' <= r && r <= 'z' { + return true + } + return strings.ContainsRune(allowed, r) + } + // It may be OK to add more ASCII punctuation here, but only carefully. + // For example Windows disallows < > \, and macOS disallows :, so we must not allow those. + return unicode.IsLetter(r) +} + +// CheckPath checks that a module path is valid. +// A valid module path is a valid import path, as checked by [CheckImportPath], +// with three additional constraints. +// First, the leading path element (up to the first slash, if any), +// by convention a domain name, must contain only lower-case ASCII letters, +// ASCII digits, dots (U+002E), and dashes (U+002D); +// it must contain at least one dot and cannot start with a dash. +// Second, for a final path element of the form /vN, where N looks numeric +// (ASCII digits and dots) must not begin with a leading zero, must not be /v1, +// and must not contain any dots. For paths beginning with "gopkg.in/", +// this second requirement is replaced by a requirement that the path +// follow the gopkg.in server's conventions. +// Third, no path element may begin with a dot. +func CheckPath(path string) (err error) { + defer func() { + if err != nil { + err = &InvalidPathError{Kind: "module", Path: path, Err: err} + } + }() + + if err := checkPath(path, modulePath); err != nil { + return err + } + i := strings.Index(path, "/") + if i < 0 { + i = len(path) + } + if i == 0 { + return fmt.Errorf("leading slash") + } + if !strings.Contains(path[:i], ".") { + return fmt.Errorf("missing dot in first path element") + } + if path[0] == '-' { + return fmt.Errorf("leading dash in first path element") + } + for _, r := range path[:i] { + if !firstPathOK(r) { + return fmt.Errorf("invalid char %q in first path element", r) + } + } + if _, _, ok := SplitPathVersion(path); !ok { + return fmt.Errorf("invalid version") + } + return nil +} + +// CheckImportPath checks that an import path is valid. +// +// A valid import path consists of one or more valid path elements +// separated by slashes (U+002F). (It must not begin with nor end in a slash.) +// +// A valid path element is a non-empty string made up of +// ASCII letters, ASCII digits, and limited ASCII punctuation: - . _ and ~. +// It must not end with a dot (U+002E), nor contain two dots in a row. +// +// The element prefix up to the first dot must not be a reserved file name +// on Windows, regardless of case (CON, com1, NuL, and so on). The element +// must not have a suffix of a tilde followed by one or more ASCII digits +// (to exclude paths elements that look like Windows short-names). +// +// CheckImportPath may be less restrictive in the future, but see the +// top-level package documentation for additional information about +// subtleties of Unicode. +func CheckImportPath(path string) error { + if err := checkPath(path, importPath); err != nil { + return &InvalidPathError{Kind: "import", Path: path, Err: err} + } + return nil +} + +// pathKind indicates what kind of path we're checking. Module paths, +// import paths, and file paths have different restrictions. +type pathKind int + +const ( + modulePath pathKind = iota + importPath + filePath +) + +// checkPath checks that a general path is valid. kind indicates what +// specific constraints should be applied. +// +// checkPath returns an error describing why the path is not valid. +// Because these checks apply to module, import, and file paths, +// and because other checks may be applied, the caller is expected to wrap +// this error with [InvalidPathError]. +func checkPath(path string, kind pathKind) error { + if !utf8.ValidString(path) { + return fmt.Errorf("invalid UTF-8") + } + if path == "" { + return fmt.Errorf("empty string") + } + if path[0] == '-' && kind != filePath { + return fmt.Errorf("leading dash") + } + if strings.Contains(path, "//") { + return fmt.Errorf("double slash") + } + if path[len(path)-1] == '/' { + return fmt.Errorf("trailing slash") + } + elemStart := 0 + for i, r := range path { + if r == '/' { + if err := checkElem(path[elemStart:i], kind); err != nil { + return err + } + elemStart = i + 1 + } + } + if err := checkElem(path[elemStart:], kind); err != nil { + return err + } + return nil +} + +// checkElem checks whether an individual path element is valid. +func checkElem(elem string, kind pathKind) error { + if elem == "" { + return fmt.Errorf("empty path element") + } + if strings.Count(elem, ".") == len(elem) { + return fmt.Errorf("invalid path element %q", elem) + } + if elem[0] == '.' && kind == modulePath { + return fmt.Errorf("leading dot in path element") + } + if elem[len(elem)-1] == '.' { + return fmt.Errorf("trailing dot in path element") + } + for _, r := range elem { + ok := false + switch kind { + case modulePath: + ok = modPathOK(r) + case importPath: + ok = importPathOK(r) + case filePath: + ok = fileNameOK(r) + default: + panic(fmt.Sprintf("internal error: invalid kind %v", kind)) + } + if !ok { + return fmt.Errorf("invalid char %q", r) + } + } + + // Windows disallows a bunch of path elements, sadly. + // See https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file + short := elem + if i := strings.Index(short, "."); i >= 0 { + short = short[:i] + } + for _, bad := range badWindowsNames { + if strings.EqualFold(bad, short) { + return fmt.Errorf("%q disallowed as path element component on Windows", short) + } + } + + if kind == filePath { + // don't check for Windows short-names in file names. They're + // only an issue for import paths. + return nil + } + + // Reject path components that look like Windows short-names. + // Those usually end in a tilde followed by one or more ASCII digits. + if tilde := strings.LastIndexByte(short, '~'); tilde >= 0 && tilde < len(short)-1 { + suffix := short[tilde+1:] + suffixIsDigits := true + for _, r := range suffix { + if r < '0' || r > '9' { + suffixIsDigits = false + break + } + } + if suffixIsDigits { + return fmt.Errorf("trailing tilde and digits in path element") + } + } + + return nil +} + +// CheckFilePath checks that a slash-separated file path is valid. +// The definition of a valid file path is the same as the definition +// of a valid import path except that the set of allowed characters is larger: +// all Unicode letters, ASCII digits, the ASCII space character (U+0020), +// and the ASCII punctuation characters +// “!#$%&()+,-.=@[]^_{}~”. +// (The excluded punctuation characters, " * < > ? ` ' | / \ and :, +// have special meanings in certain shells or operating systems.) +// +// CheckFilePath may be less restrictive in the future, but see the +// top-level package documentation for additional information about +// subtleties of Unicode. +func CheckFilePath(path string) error { + if err := checkPath(path, filePath); err != nil { + return &InvalidPathError{Kind: "file", Path: path, Err: err} + } + return nil +} + +// badWindowsNames are the reserved file path elements on Windows. +// See https://docs.microsoft.com/en-us/windows/desktop/fileio/naming-a-file +var badWindowsNames = []string{ + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", +} + +// SplitPathVersion returns prefix and major version such that prefix+pathMajor == path +// and version is either empty or "/vN" for N >= 2. +// As a special case, gopkg.in paths are recognized directly; +// they require ".vN" instead of "/vN", and for all N, not just N >= 2. +// SplitPathVersion returns with ok = false when presented with +// a path whose last path element does not satisfy the constraints +// applied by [CheckPath], such as "example.com/pkg/v1" or "example.com/pkg/v1.2". +func SplitPathVersion(path string) (prefix, pathMajor string, ok bool) { + if strings.HasPrefix(path, "gopkg.in/") { + return splitGopkgIn(path) + } + + i := len(path) + dot := false + for i > 0 && ('0' <= path[i-1] && path[i-1] <= '9' || path[i-1] == '.') { + if path[i-1] == '.' { + dot = true + } + i-- + } + if i <= 1 || i == len(path) || path[i-1] != 'v' || path[i-2] != '/' { + return path, "", true + } + prefix, pathMajor = path[:i-2], path[i-2:] + if dot || len(pathMajor) <= 2 || pathMajor[2] == '0' || pathMajor == "/v1" { + return path, "", false + } + return prefix, pathMajor, true +} + +// splitGopkgIn is like SplitPathVersion but only for gopkg.in paths. +func splitGopkgIn(path string) (prefix, pathMajor string, ok bool) { + if !strings.HasPrefix(path, "gopkg.in/") { + return path, "", false + } + i := len(path) + if strings.HasSuffix(path, "-unstable") { + i -= len("-unstable") + } + for i > 0 && ('0' <= path[i-1] && path[i-1] <= '9') { + i-- + } + if i <= 1 || path[i-1] != 'v' || path[i-2] != '.' { + // All gopkg.in paths must end in vN for some N. + return path, "", false + } + prefix, pathMajor = path[:i-2], path[i-2:] + if len(pathMajor) <= 2 || pathMajor[2] == '0' && pathMajor != ".v0" { + return path, "", false + } + return prefix, pathMajor, true +} + +// MatchPathMajor reports whether the semantic version v +// matches the path major version pathMajor. +// +// MatchPathMajor returns true if and only if [CheckPathMajor] returns nil. +func MatchPathMajor(v, pathMajor string) bool { + return CheckPathMajor(v, pathMajor) == nil +} + +// CheckPathMajor returns a non-nil error if the semantic version v +// does not match the path major version pathMajor. +func CheckPathMajor(v, pathMajor string) error { + // TODO(jayconrod): return errors or panic for invalid inputs. This function + // (and others) was covered by integration tests for cmd/go, and surrounding + // code protected against invalid inputs like non-canonical versions. + if strings.HasPrefix(pathMajor, ".v") && strings.HasSuffix(pathMajor, "-unstable") { + pathMajor = strings.TrimSuffix(pathMajor, "-unstable") + } + if strings.HasPrefix(v, "v0.0.0-") && pathMajor == ".v1" { + // Allow old bug in pseudo-versions that generated v0.0.0- pseudoversion for gopkg .v1. + // For example, gopkg.in/yaml.v2@v2.2.1's go.mod requires gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405. + return nil + } + m := semver.Major(v) + if pathMajor == "" { + if m == "v0" || m == "v1" || semver.Build(v) == "+incompatible" { + return nil + } + pathMajor = "v0 or v1" + } else if pathMajor[0] == '/' || pathMajor[0] == '.' { + if m == pathMajor[1:] { + return nil + } + pathMajor = pathMajor[1:] + } + return &InvalidVersionError{ + Version: v, + Err: fmt.Errorf("should be %s, not %s", pathMajor, semver.Major(v)), + } +} + +// PathMajorPrefix returns the major-version tag prefix implied by pathMajor. +// An empty PathMajorPrefix allows either v0 or v1. +// +// Note that [MatchPathMajor] may accept some versions that do not actually begin +// with this prefix: namely, it accepts a 'v0.0.0-' prefix for a '.v1' +// pathMajor, even though that pathMajor implies 'v1' tagging. +func PathMajorPrefix(pathMajor string) string { + if pathMajor == "" { + return "" + } + if pathMajor[0] != '/' && pathMajor[0] != '.' { + panic("pathMajor suffix " + pathMajor + " passed to PathMajorPrefix lacks separator") + } + if strings.HasPrefix(pathMajor, ".v") && strings.HasSuffix(pathMajor, "-unstable") { + pathMajor = strings.TrimSuffix(pathMajor, "-unstable") + } + m := pathMajor[1:] + if m != semver.Major(m) { + panic("pathMajor suffix " + pathMajor + "passed to PathMajorPrefix is not a valid major version") + } + return m +} + +// CanonicalVersion returns the canonical form of the version string v. +// It is the same as [semver.Canonical] except that it preserves the special build suffix "+incompatible". +func CanonicalVersion(v string) string { + cv := semver.Canonical(v) + if semver.Build(v) == "+incompatible" { + cv += "+incompatible" + } + return cv +} + +// Sort sorts the list by Path, breaking ties by comparing [Version] fields. +// The Version fields are interpreted as semantic versions (using [semver.Compare]) +// optionally followed by a tie-breaking suffix introduced by a slash character, +// like in "v0.0.1/go.mod". +func Sort(list []Version) { + slices.SortFunc(list, func(i, j Version) int { + if i.Path != j.Path { + return strings.Compare(i.Path, j.Path) + } + // To help go.sum formatting, allow version/file. + // Compare semver prefix by semver rules, + // file by string order. + vi := i.Version + vj := j.Version + var fi, fj string + if k := strings.Index(vi, "/"); k >= 0 { + vi, fi = vi[:k], vi[k:] + } + if k := strings.Index(vj, "/"); k >= 0 { + vj, fj = vj[:k], vj[k:] + } + if vi != vj { + return semver.Compare(vi, vj) + } + return cmp.Compare(fi, fj) + }) +} + +// EscapePath returns the escaped form of the given module path. +// It fails if the module path is invalid. +func EscapePath(path string) (escaped string, err error) { + if err := CheckPath(path); err != nil { + return "", err + } + + return escapeString(path) +} + +// EscapeVersion returns the escaped form of the given module version. +// Versions are allowed to be in non-semver form but must be valid file names +// and not contain exclamation marks. +func EscapeVersion(v string) (escaped string, err error) { + if err := checkElem(v, filePath); err != nil || strings.Contains(v, "!") { + return "", &InvalidVersionError{ + Version: v, + Err: fmt.Errorf("disallowed version string"), + } + } + return escapeString(v) +} + +func escapeString(s string) (escaped string, err error) { + haveUpper := false + for _, r := range s { + if r == '!' || r >= utf8.RuneSelf { + // This should be disallowed by CheckPath, but diagnose anyway. + // The correctness of the escaping loop below depends on it. + return "", fmt.Errorf("internal error: inconsistency in EscapePath") + } + if 'A' <= r && r <= 'Z' { + haveUpper = true + } + } + + if !haveUpper { + return s, nil + } + + var buf []byte + for _, r := range s { + if 'A' <= r && r <= 'Z' { + buf = append(buf, '!', byte(r+'a'-'A')) + } else { + buf = append(buf, byte(r)) + } + } + return string(buf), nil +} + +// UnescapePath returns the module path for the given escaped path. +// It fails if the escaped path is invalid or describes an invalid path. +func UnescapePath(escaped string) (path string, err error) { + path, ok := unescapeString(escaped) + if !ok { + return "", fmt.Errorf("invalid escaped module path %q", escaped) + } + if err := CheckPath(path); err != nil { + return "", fmt.Errorf("invalid escaped module path %q: %v", escaped, err) + } + return path, nil +} + +// UnescapeVersion returns the version string for the given escaped version. +// It fails if the escaped form is invalid or describes an invalid version. +// Versions are allowed to be in non-semver form but must be valid file names +// and not contain exclamation marks. +func UnescapeVersion(escaped string) (v string, err error) { + v, ok := unescapeString(escaped) + if !ok { + return "", fmt.Errorf("invalid escaped version %q", escaped) + } + if err := checkElem(v, filePath); err != nil { + return "", fmt.Errorf("invalid escaped version %q: %v", v, err) + } + return v, nil +} + +func unescapeString(escaped string) (string, bool) { + var buf []byte + + bang := false + for _, r := range escaped { + if r >= utf8.RuneSelf { + return "", false + } + if bang { + bang = false + if r < 'a' || 'z' < r { + return "", false + } + buf = append(buf, byte(r+'A'-'a')) + continue + } + if r == '!' { + bang = true + continue + } + if 'A' <= r && r <= 'Z' { + return "", false + } + buf = append(buf, byte(r)) + } + if bang { + return "", false + } + return string(buf), true +} + +// MatchPrefixPatterns reports whether any path prefix of target matches one of +// the glob patterns (as defined by [path.Match]) in the comma-separated globs +// list. This implements the algorithm used when matching a module path to the +// GOPRIVATE environment variable, as described by 'go help module-private'. +// +// It ignores any empty or malformed patterns in the list. +// Trailing slashes on patterns are ignored. +func MatchPrefixPatterns(globs, target string) bool { + for globs != "" { + // Extract next non-empty glob in comma-separated list. + var glob string + if before, after, ok := strings.Cut(globs, ","); ok { + glob, globs = before, after + } else { + glob, globs = globs, "" + } + glob = strings.TrimSuffix(glob, "/") + if glob == "" { + continue + } + + // A glob with N+1 path elements (N slashes) needs to be matched + // against the first N+1 path elements of target, + // which end just before the N+1'th slash. + n := strings.Count(glob, "/") + prefix := target + // Walk target, counting slashes, truncating at the N+1'th slash. + for i := 0; i < len(target); i++ { + if target[i] == '/' { + if n == 0 { + prefix = target[:i] + break + } + n-- + } + } + if n > 0 { + // Not enough prefix elements. + continue + } + matched, _ := path.Match(glob, prefix) + if matched { + return true + } + } + return false +} diff --git a/vendor/golang.org/x/mod/module/pseudo.go b/vendor/golang.org/x/mod/module/pseudo.go new file mode 100644 index 000000000..9cf19d325 --- /dev/null +++ b/vendor/golang.org/x/mod/module/pseudo.go @@ -0,0 +1,250 @@ +// 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. + +// Pseudo-versions +// +// Code authors are expected to tag the revisions they want users to use, +// including prereleases. However, not all authors tag versions at all, +// and not all commits a user might want to try will have tags. +// A pseudo-version is a version with a special form that allows us to +// address an untagged commit and order that version with respect to +// other versions we might encounter. +// +// A pseudo-version takes one of the general forms: +// +// (1) vX.0.0-yyyymmddhhmmss-abcdef123456 +// (2) vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456 +// (3) vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456+incompatible +// (4) vX.Y.Z-pre.0.yyyymmddhhmmss-abcdef123456 +// (5) vX.Y.Z-pre.0.yyyymmddhhmmss-abcdef123456+incompatible +// +// If there is no recently tagged version with the right major version vX, +// then form (1) is used, creating a space of pseudo-versions at the bottom +// of the vX version range, less than any tagged version, including the unlikely v0.0.0. +// +// If the most recent tagged version before the target commit is vX.Y.Z or vX.Y.Z+incompatible, +// then the pseudo-version uses form (2) or (3), making it a prerelease for the next +// possible semantic version after vX.Y.Z. The leading 0 segment in the prerelease string +// ensures that the pseudo-version compares less than possible future explicit prereleases +// like vX.Y.(Z+1)-rc1 or vX.Y.(Z+1)-1. +// +// If the most recent tagged version before the target commit is vX.Y.Z-pre or vX.Y.Z-pre+incompatible, +// then the pseudo-version uses form (4) or (5), making it a slightly later prerelease. + +package module + +import ( + "errors" + "fmt" + "strings" + "time" + + "golang.org/x/mod/internal/lazyregexp" + "golang.org/x/mod/semver" +) + +var pseudoVersionRE = lazyregexp.New(`^v[0-9]+\.(0\.0-|\d+\.\d+-([^+]*\.)?0\.)\d{14}-[A-Za-z0-9]+(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) + +const PseudoVersionTimestampFormat = "20060102150405" + +// PseudoVersion returns a pseudo-version for the given major version ("v1") +// preexisting older tagged version ("" or "v1.2.3" or "v1.2.3-pre"), revision time, +// and revision identifier (usually a 12-byte commit hash prefix). +func PseudoVersion(major, older string, t time.Time, rev string) string { + if major == "" { + major = "v0" + } + segment := fmt.Sprintf("%s-%s", t.UTC().Format(PseudoVersionTimestampFormat), rev) + build := semver.Build(older) + older = semver.Canonical(older) + if older == "" { + return major + ".0.0-" + segment // form (1) + } + if semver.Prerelease(older) != "" { + return older + ".0." + segment + build // form (4), (5) + } + + // Form (2), (3). + // Extract patch from vMAJOR.MINOR.PATCH + i := strings.LastIndex(older, ".") + 1 + v, patch := older[:i], older[i:] + + // Reassemble. + return v + incDecimal(patch) + "-0." + segment + build +} + +// ZeroPseudoVersion returns a pseudo-version with a zero timestamp and +// revision, which may be used as a placeholder. +func ZeroPseudoVersion(major string) string { + return PseudoVersion(major, "", time.Time{}, "000000000000") +} + +// incDecimal returns the decimal string incremented by 1. +func incDecimal(decimal string) string { + // Scan right to left turning 9s to 0s until you find a digit to increment. + digits := []byte(decimal) + i := len(digits) - 1 + for ; i >= 0 && digits[i] == '9'; i-- { + digits[i] = '0' + } + if i >= 0 { + digits[i]++ + } else { + // digits is all zeros + digits[0] = '1' + digits = append(digits, '0') + } + return string(digits) +} + +// decDecimal returns the decimal string decremented by 1, or the empty string +// if the decimal is all zeroes. +func decDecimal(decimal string) string { + // Scan right to left turning 0s to 9s until you find a digit to decrement. + digits := []byte(decimal) + i := len(digits) - 1 + for ; i >= 0 && digits[i] == '0'; i-- { + digits[i] = '9' + } + if i < 0 { + // decimal is all zeros + return "" + } + if i == 0 && digits[i] == '1' && len(digits) > 1 { + digits = digits[1:] + } else { + digits[i]-- + } + return string(digits) +} + +// IsPseudoVersion reports whether v is a pseudo-version. +func IsPseudoVersion(v string) bool { + return strings.Count(v, "-") >= 2 && semver.IsValid(v) && pseudoVersionRE.MatchString(v) +} + +// IsZeroPseudoVersion returns whether v is a pseudo-version with a zero base, +// timestamp, and revision, as returned by [ZeroPseudoVersion]. +func IsZeroPseudoVersion(v string) bool { + return v == ZeroPseudoVersion(semver.Major(v)) +} + +// PseudoVersionTime returns the time stamp of the pseudo-version v. +// It returns an error if v is not a pseudo-version or if the time stamp +// embedded in the pseudo-version is not a valid time. +func PseudoVersionTime(v string) (time.Time, error) { + _, timestamp, _, _, err := parsePseudoVersion(v) + if err != nil { + return time.Time{}, err + } + t, err := time.Parse("20060102150405", timestamp) + if err != nil { + return time.Time{}, &InvalidVersionError{ + Version: v, + Pseudo: true, + Err: fmt.Errorf("malformed time %q", timestamp), + } + } + return t, nil +} + +// PseudoVersionRev returns the revision identifier of the pseudo-version v. +// It returns an error if v is not a pseudo-version. +func PseudoVersionRev(v string) (rev string, err error) { + _, _, rev, _, err = parsePseudoVersion(v) + return +} + +// PseudoVersionBase returns the canonical parent version, if any, upon which +// the pseudo-version v is based. +// +// If v has no parent version (that is, if it is "vX.0.0-[…]"), +// PseudoVersionBase returns the empty string and a nil error. +func PseudoVersionBase(v string) (string, error) { + base, _, _, build, err := parsePseudoVersion(v) + if err != nil { + return "", err + } + + switch pre := semver.Prerelease(base); pre { + case "": + // vX.0.0-yyyymmddhhmmss-abcdef123456 → "" + if build != "" { + // Pseudo-versions of the form vX.0.0-yyyymmddhhmmss-abcdef123456+incompatible + // are nonsensical: the "vX.0.0-" prefix implies that there is no parent tag, + // but the "+incompatible" suffix implies that the major version of + // the parent tag is not compatible with the module's import path. + // + // There are a few such entries in the index generated by proxy.golang.org, + // but we believe those entries were generated by the proxy itself. + return "", &InvalidVersionError{ + Version: v, + Pseudo: true, + Err: fmt.Errorf("lacks base version, but has build metadata %q", build), + } + } + return "", nil + + case "-0": + // vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456 → vX.Y.Z + // vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdef123456+incompatible → vX.Y.Z+incompatible + base = strings.TrimSuffix(base, pre) + i := strings.LastIndexByte(base, '.') + if i < 0 { + panic("base from parsePseudoVersion missing patch number: " + base) + } + patch := decDecimal(base[i+1:]) + if patch == "" { + // vX.0.0-0 is invalid, but has been observed in the wild in the index + // generated by requests to proxy.golang.org. + // + // NOTE(bcmills): I cannot find a historical bug that accounts for + // pseudo-versions of this form, nor have I seen such versions in any + // actual go.mod files. If we find actual examples of this form and a + // reasonable theory of how they came into existence, it seems fine to + // treat them as equivalent to vX.0.0 (especially since the invalid + // pseudo-versions have lower precedence than the real ones). For now, we + // reject them. + return "", &InvalidVersionError{ + Version: v, + Pseudo: true, + Err: fmt.Errorf("version before %s would have negative patch number", base), + } + } + return base[:i+1] + patch + build, nil + + default: + // vX.Y.Z-pre.0.yyyymmddhhmmss-abcdef123456 → vX.Y.Z-pre + // vX.Y.Z-pre.0.yyyymmddhhmmss-abcdef123456+incompatible → vX.Y.Z-pre+incompatible + if !strings.HasSuffix(base, ".0") { + panic(`base from parsePseudoVersion missing ".0" before date: ` + base) + } + return strings.TrimSuffix(base, ".0") + build, nil + } +} + +var errPseudoSyntax = errors.New("syntax error") + +func parsePseudoVersion(v string) (base, timestamp, rev, build string, err error) { + if !IsPseudoVersion(v) { + return "", "", "", "", &InvalidVersionError{ + Version: v, + Pseudo: true, + Err: errPseudoSyntax, + } + } + build = semver.Build(v) + v = strings.TrimSuffix(v, build) + j := strings.LastIndex(v, "-") + v, rev = v[:j], v[j+1:] + i := strings.LastIndex(v, "-") + if j := strings.LastIndex(v, "."); j > i { + base = v[:j] // "vX.Y.Z-pre.0" or "vX.Y.(Z+1)-0" + timestamp = v[j+1:] + } else { + base = v[:i] // "vX.0.0" + timestamp = v[i+1:] + } + return base, timestamp, rev, build, nil +} diff --git a/vendor/golang.org/x/mod/semver/semver.go b/vendor/golang.org/x/mod/semver/semver.go new file mode 100644 index 000000000..824b282c8 --- /dev/null +++ b/vendor/golang.org/x/mod/semver/semver.go @@ -0,0 +1,407 @@ +// 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 semver implements comparison of semantic version strings. +// In this package, semantic version strings must begin with a leading "v", +// as in "v1.0.0". +// +// The general form of a semantic version string accepted by this package is +// +// vMAJOR[.MINOR[.PATCH[-PRERELEASE][+BUILD]]] +// +// where square brackets indicate optional parts of the syntax; +// MAJOR, MINOR, and PATCH are decimal integers without extra leading zeros; +// PRERELEASE and BUILD are each a series of non-empty dot-separated identifiers +// using only alphanumeric characters and hyphens; and +// all-numeric PRERELEASE identifiers must not have leading zeros. +// +// This package follows Semantic Versioning 2.0.0 (see semver.org) +// with two exceptions. First, it requires the "v" prefix. Second, it recognizes +// vMAJOR and vMAJOR.MINOR (with no prerelease or build suffixes) +// as shorthands for vMAJOR.0.0 and vMAJOR.MINOR.0. +package semver + +import ( + "slices" + "strings" +) + +// parsed returns the parsed form of a semantic version string. +type parsed struct { + major string + minor string + patch string + short string + prerelease string + build string +} + +// IsValid reports whether v is a valid semantic version string. +func IsValid(v string) bool { + _, ok := parse(v) + return ok +} + +// Canonical returns the canonical formatting of the semantic version v. +// It fills in any missing .MINOR or .PATCH and discards build metadata. +// Two semantic versions compare equal only if their canonical formatting +// is an identical string. +// The canonical invalid semantic version is the empty string. +func Canonical(v string) string { + p, ok := parse(v) + if !ok { + return "" + } + if p.build != "" { + return v[:len(v)-len(p.build)] + } + if p.short != "" { + return v + p.short + } + return v +} + +// Major returns the major version prefix of the semantic version v. +// For example, Major("v2.1.0") == "v2". +// If v is an invalid semantic version string, Major returns the empty string. +func Major(v string) string { + pv, ok := parse(v) + if !ok { + return "" + } + return v[:1+len(pv.major)] +} + +// MajorMinor returns the major.minor version prefix of the semantic version v. +// For example, MajorMinor("v2.1.0") == "v2.1". +// If v is an invalid semantic version string, MajorMinor returns the empty string. +func MajorMinor(v string) string { + pv, ok := parse(v) + if !ok { + return "" + } + i := 1 + len(pv.major) + if j := i + 1 + len(pv.minor); j <= len(v) && v[i] == '.' && v[i+1:j] == pv.minor { + return v[:j] + } + return v[:i] + "." + pv.minor +} + +// Prerelease returns the prerelease suffix of the semantic version v. +// For example, Prerelease("v2.1.0-pre+meta") == "-pre". +// If v is an invalid semantic version string, Prerelease returns the empty string. +func Prerelease(v string) string { + pv, ok := parse(v) + if !ok { + return "" + } + return pv.prerelease +} + +// Build returns the build suffix of the semantic version v. +// For example, Build("v2.1.0+meta") == "+meta". +// If v is an invalid semantic version string, Build returns the empty string. +func Build(v string) string { + pv, ok := parse(v) + if !ok { + return "" + } + return pv.build +} + +// Compare returns an integer comparing two versions according to +// semantic version precedence. +// The result will be 0 if v == w, -1 if v < w, or +1 if v > w. +// +// An invalid semantic version string is considered less than a valid one. +// All invalid semantic version strings compare equal to each other. +func Compare(v, w string) int { + pv, ok1 := parse(v) + pw, ok2 := parse(w) + if !ok1 && !ok2 { + return 0 + } + if !ok1 { + return -1 + } + if !ok2 { + return +1 + } + if c := compareInt(pv.major, pw.major); c != 0 { + return c + } + if c := compareInt(pv.minor, pw.minor); c != 0 { + return c + } + if c := compareInt(pv.patch, pw.patch); c != 0 { + return c + } + return comparePrerelease(pv.prerelease, pw.prerelease) +} + +// Max canonicalizes its arguments and then returns the version string +// that compares greater. +// +// Deprecated: use [Compare] instead. In most cases, returning a canonicalized +// version is not expected or desired. +func Max(v, w string) string { + v = Canonical(v) + w = Canonical(w) + if Compare(v, w) > 0 { + return v + } + return w +} + +// ByVersion implements [sort.Interface] for sorting semantic version strings. +type ByVersion []string + +func (vs ByVersion) Len() int { return len(vs) } +func (vs ByVersion) Swap(i, j int) { vs[i], vs[j] = vs[j], vs[i] } +func (vs ByVersion) Less(i, j int) bool { return compareVersion(vs[i], vs[j]) < 0 } + +// Sort sorts a list of semantic version strings using [Compare] and falls back +// to use [strings.Compare] if both versions are considered equal. +func Sort(list []string) { + slices.SortFunc(list, compareVersion) +} + +func compareVersion(a, b string) int { + cmp := Compare(a, b) + if cmp != 0 { + return cmp + } + return strings.Compare(a, b) +} + +func parse(v string) (p parsed, ok bool) { + if v == "" || v[0] != 'v' { + return + } + p.major, v, ok = parseInt(v[1:]) + if !ok { + return + } + if v == "" { + p.minor = "0" + p.patch = "0" + p.short = ".0.0" + return + } + if v[0] != '.' { + ok = false + return + } + p.minor, v, ok = parseInt(v[1:]) + if !ok { + return + } + if v == "" { + p.patch = "0" + p.short = ".0" + return + } + if v[0] != '.' { + ok = false + return + } + p.patch, v, ok = parseInt(v[1:]) + if !ok { + return + } + if len(v) > 0 && v[0] == '-' { + p.prerelease, v, ok = parsePrerelease(v) + if !ok { + return + } + } + if len(v) > 0 && v[0] == '+' { + p.build, v, ok = parseBuild(v) + if !ok { + return + } + } + if v != "" { + ok = false + return + } + ok = true + return +} + +func parseInt(v string) (t, rest string, ok bool) { + if v == "" { + return + } + if v[0] < '0' || '9' < v[0] { + return + } + i := 1 + for i < len(v) && '0' <= v[i] && v[i] <= '9' { + i++ + } + if v[0] == '0' && i != 1 { + return + } + return v[:i], v[i:], true +} + +func parsePrerelease(v string) (t, rest string, ok bool) { + // "A pre-release version MAY be denoted by appending a hyphen and + // a series of dot separated identifiers immediately following the patch version. + // Identifiers MUST comprise only ASCII alphanumerics and hyphen [0-9A-Za-z-]. + // Identifiers MUST NOT be empty. Numeric identifiers MUST NOT include leading zeroes." + if v == "" || v[0] != '-' { + return + } + i := 1 + start := 1 + for i < len(v) && v[i] != '+' { + if !isIdentChar(v[i]) && v[i] != '.' { + return + } + if v[i] == '.' { + if start == i || isBadNum(v[start:i]) { + return + } + start = i + 1 + } + i++ + } + if start == i || isBadNum(v[start:i]) { + return + } + return v[:i], v[i:], true +} + +func parseBuild(v string) (t, rest string, ok bool) { + if v == "" || v[0] != '+' { + return + } + i := 1 + start := 1 + for i < len(v) { + if !isIdentChar(v[i]) && v[i] != '.' { + return + } + if v[i] == '.' { + if start == i { + return + } + start = i + 1 + } + i++ + } + if start == i { + return + } + return v[:i], v[i:], true +} + +func isIdentChar(c byte) bool { + return 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || '0' <= c && c <= '9' || c == '-' +} + +func isBadNum(v string) bool { + i := 0 + for i < len(v) && '0' <= v[i] && v[i] <= '9' { + i++ + } + return i == len(v) && i > 1 && v[0] == '0' +} + +func isNum(v string) bool { + i := 0 + for i < len(v) && '0' <= v[i] && v[i] <= '9' { + i++ + } + return i == len(v) +} + +func compareInt(x, y string) int { + if x == y { + return 0 + } + if len(x) < len(y) { + return -1 + } + if len(x) > len(y) { + return +1 + } + if x < y { + return -1 + } else { + return +1 + } +} + +func comparePrerelease(x, y string) int { + // "When major, minor, and patch are equal, a pre-release version has + // lower precedence than a normal version. + // Example: 1.0.0-alpha < 1.0.0. + // Precedence for two pre-release versions with the same major, minor, + // and patch version MUST be determined by comparing each dot separated + // identifier from left to right until a difference is found as follows: + // identifiers consisting of only digits are compared numerically and + // identifiers with letters or hyphens are compared lexically in ASCII + // sort order. Numeric identifiers always have lower precedence than + // non-numeric identifiers. A larger set of pre-release fields has a + // higher precedence than a smaller set, if all of the preceding + // identifiers are equal. + // Example: 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-alpha.beta < + // 1.0.0-beta < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0." + if x == y { + return 0 + } + if x == "" { + return +1 + } + if y == "" { + return -1 + } + for x != "" && y != "" { + x = x[1:] // skip - or . + y = y[1:] // skip - or . + var dx, dy string + dx, x = nextIdent(x) + dy, y = nextIdent(y) + if dx != dy { + ix := isNum(dx) + iy := isNum(dy) + if ix != iy { + if ix { + return -1 + } else { + return +1 + } + } + if ix { + if len(dx) < len(dy) { + return -1 + } + if len(dx) > len(dy) { + return +1 + } + } + if dx < dy { + return -1 + } else { + return +1 + } + } + } + if x == "" { + return -1 + } else { + return +1 + } +} + +func nextIdent(x string) (dx, rest string) { + i := 0 + for i < len(x) && x[i] != '.' { + i++ + } + return x[:i], x[i:] +} diff --git a/vendor/golang.org/x/sync/semaphore/semaphore.go b/vendor/golang.org/x/sync/semaphore/semaphore.go new file mode 100644 index 000000000..040c5bc50 --- /dev/null +++ b/vendor/golang.org/x/sync/semaphore/semaphore.go @@ -0,0 +1,160 @@ +// 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 semaphore provides a weighted semaphore implementation. +package semaphore // import "golang.org/x/sync/semaphore" + +import ( + "container/list" + "context" + "sync" +) + +type waiter struct { + n int64 + ready chan<- struct{} // Closed when semaphore acquired. +} + +// NewWeighted creates a new weighted semaphore with the given +// maximum combined weight for concurrent access. +func NewWeighted(n int64) *Weighted { + w := &Weighted{size: n} + return w +} + +// Weighted provides a way to bound concurrent access to a resource. +// The callers can request access with a given weight. +type Weighted struct { + size int64 + cur int64 + mu sync.Mutex + waiters list.List +} + +// Acquire acquires the semaphore with a weight of n, blocking until resources +// are available or ctx is done. On success, returns nil. On failure, returns +// ctx.Err() and leaves the semaphore unchanged. +func (s *Weighted) Acquire(ctx context.Context, n int64) error { + done := ctx.Done() + + s.mu.Lock() + select { + case <-done: + // ctx becoming done has "happened before" acquiring the semaphore, + // whether it became done before the call began or while we were + // waiting for the mutex. We prefer to fail even if we could acquire + // the mutex without blocking. + s.mu.Unlock() + return ctx.Err() + default: + } + if s.size-s.cur >= n && s.waiters.Len() == 0 { + // Since we hold s.mu and haven't synchronized since checking done, if + // ctx becomes done before we return here, it becoming done must have + // "happened concurrently" with this call - it cannot "happen before" + // we return in this branch. So, we're ok to always acquire here. + s.cur += n + s.mu.Unlock() + return nil + } + + if n > s.size { + // Don't make other Acquire calls block on one that's doomed to fail. + s.mu.Unlock() + <-done + return ctx.Err() + } + + ready := make(chan struct{}) + w := waiter{n: n, ready: ready} + elem := s.waiters.PushBack(w) + s.mu.Unlock() + + select { + case <-done: + s.mu.Lock() + select { + case <-ready: + // Acquired the semaphore after we were canceled. + // Pretend we didn't and put the tokens back. + s.cur -= n + s.notifyWaiters() + default: + isFront := s.waiters.Front() == elem + s.waiters.Remove(elem) + // If we're at the front and there are extra tokens left, notify other waiters. + if isFront && s.size > s.cur { + s.notifyWaiters() + } + } + s.mu.Unlock() + return ctx.Err() + + case <-ready: + // Acquired the semaphore. Check that ctx isn't already done. + // We check the done channel instead of calling ctx.Err because we + // already have the channel, and ctx.Err is O(n) with the nesting + // depth of ctx. + select { + case <-done: + s.Release(n) + return ctx.Err() + default: + } + return nil + } +} + +// TryAcquire acquires the semaphore with a weight of n without blocking. +// On success, returns true. On failure, returns false and leaves the semaphore unchanged. +func (s *Weighted) TryAcquire(n int64) bool { + s.mu.Lock() + success := s.size-s.cur >= n && s.waiters.Len() == 0 + if success { + s.cur += n + } + s.mu.Unlock() + return success +} + +// Release releases the semaphore with a weight of n. +func (s *Weighted) Release(n int64) { + s.mu.Lock() + s.cur -= n + if s.cur < 0 { + s.mu.Unlock() + panic("semaphore: released more than held") + } + s.notifyWaiters() + s.mu.Unlock() +} + +func (s *Weighted) notifyWaiters() { + for { + next := s.waiters.Front() + if next == nil { + break // No more waiters blocked. + } + + w := next.Value.(waiter) + if s.size-s.cur < w.n { + // Not enough tokens for the next waiter. We could keep going (to try to + // find a waiter with a smaller request), but under load that could cause + // starvation for large requests; instead, we leave all remaining waiters + // blocked. + // + // Consider a semaphore used as a read-write lock, with N tokens, N + // readers, and one writer. Each reader can Acquire(1) to obtain a read + // lock. The writer can Acquire(N) to obtain a write lock, excluding all + // of the readers. If we allow the readers to jump ahead in the queue, + // the writer will starve — there is always one token available for every + // reader. + break + } + + s.cur += w.n + s.waiters.Remove(next) + close(w.ready) + } +} diff --git a/vendor/golang.org/x/tools/LICENSE b/vendor/golang.org/x/tools/LICENSE new file mode 100644 index 000000000..2a7cf70da --- /dev/null +++ b/vendor/golang.org/x/tools/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +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 LLC 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/golang.org/x/tools/PATENTS b/vendor/golang.org/x/tools/PATENTS new file mode 100644 index 000000000..733099041 --- /dev/null +++ b/vendor/golang.org/x/tools/PATENTS @@ -0,0 +1,22 @@ +Additional IP Rights Grant (Patents) + +"This implementation" means the copyrightable works distributed by +Google as part of the Go project. + +Google hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) +patent license to make, have made, use, offer to sell, sell, import, +transfer and otherwise run, modify and propagate the contents of this +implementation of Go, where such license applies only to those patent +claims, both currently owned or controlled by Google and acquired in +the future, licensable by Google that are necessarily infringed by this +implementation of Go. This grant does not include claims that would be +infringed only as a consequence of further modification of this +implementation. If you or your agent or exclusive licensee institute or +order or agree to the institution of patent litigation against any +entity (including a cross-claim or counterclaim in a lawsuit) alleging +that this implementation of Go or any code incorporated within this +implementation of Go constitutes direct or contributory patent +infringement, or inducement of patent infringement, then any patent +rights granted to you under this License for this implementation of Go +shall terminate as of the date such litigation is filed. diff --git a/vendor/golang.org/x/tools/go/ast/astutil/enclosing.go b/vendor/golang.org/x/tools/go/ast/astutil/enclosing.go new file mode 100644 index 000000000..0fb4e7eea --- /dev/null +++ b/vendor/golang.org/x/tools/go/ast/astutil/enclosing.go @@ -0,0 +1,663 @@ +// Copyright 2013 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 astutil + +// This file defines utilities for working with source positions. + +import ( + "fmt" + "go/ast" + "go/token" + "sort" +) + +// PathEnclosingInterval returns the node that encloses the source +// interval [start, end), and all its ancestors up to the AST root. +// +// The definition of "enclosing" used by this function considers +// additional whitespace abutting a node to be enclosed by it. +// In this example: +// +// z := x + y // add them +// <-A-> +// <----B-----> +// +// the ast.BinaryExpr(+) node is considered to enclose interval B +// even though its [Pos()..End()) is actually only interval A. +// This behaviour makes user interfaces more tolerant of imperfect +// input. +// +// This function treats tokens as nodes, though they are not included +// in the result. e.g. PathEnclosingInterval("+") returns the +// enclosing ast.BinaryExpr("x + y"). +// +// If start==end, the 1-char interval following start is used instead. +// +// The 'exact' result is true if the interval contains only path[0] +// and perhaps some adjacent whitespace. It is false if the interval +// overlaps multiple children of path[0], or if it contains only +// interior whitespace of path[0]. +// In this example: +// +// z := x + y // add them +// <--C--> <---E--> +// ^ +// D +// +// intervals C, D and E are inexact. C is contained by the +// z-assignment statement, because it spans three of its children (:=, +// x, +). So too is the 1-char interval D, because it contains only +// interior whitespace of the assignment. E is considered interior +// whitespace of the BlockStmt containing the assignment. +// +// The resulting path is never empty; it always contains at least the +// 'root' *ast.File. Ideally PathEnclosingInterval would reject +// intervals that lie wholly or partially outside the range of the +// file, but unfortunately ast.File records only the token.Pos of +// the 'package' keyword, but not of the start of the file itself. +func PathEnclosingInterval(root *ast.File, start, end token.Pos) (path []ast.Node, exact bool) { + // fmt.Printf("EnclosingInterval %d %d\n", start, end) // debugging + + // Precondition: node.[Pos..End) and adjoining whitespace contain [start, end). + var visit func(node ast.Node) bool + visit = func(node ast.Node) bool { + path = append(path, node) + + nodePos := node.Pos() + nodeEnd := node.End() + + // fmt.Printf("visit(%T, %d, %d)\n", node, nodePos, nodeEnd) // debugging + + // Intersect [start, end) with interval of node. + if start < nodePos { + start = nodePos + } + if end > nodeEnd { + end = nodeEnd + } + + // Find sole child that contains [start, end). + children := childrenOf(node) + l := len(children) + for i, child := range children { + // [childPos, childEnd) is unaugmented interval of child. + childPos := child.Pos() + childEnd := child.End() + + // [augPos, augEnd) is whitespace-augmented interval of child. + augPos := childPos + augEnd := childEnd + if i > 0 { + augPos = children[i-1].End() // start of preceding whitespace + } + if i < l-1 { + nextChildPos := children[i+1].Pos() + // Does [start, end) lie between child and next child? + if start >= augEnd && end <= nextChildPos { + return false // inexact match + } + augEnd = nextChildPos // end of following whitespace + } + + // fmt.Printf("\tchild %d: [%d..%d)\tcontains interval [%d..%d)?\n", + // i, augPos, augEnd, start, end) // debugging + + // Does augmented child strictly contain [start, end)? + if augPos <= start && end <= augEnd { + if is[tokenNode](child) { + return true + } + + // childrenOf elides the FuncType node beneath FuncDecl. + // Add it back here for TypeParams, Params, Results, + // all FieldLists). But we don't add it back for the "func" token + // even though it is the tree at FuncDecl.Type.Func. + if decl, ok := node.(*ast.FuncDecl); ok { + if fields, ok := child.(*ast.FieldList); ok && fields != decl.Recv { + path = append(path, decl.Type) + } + } + + return visit(child) + } + + // Does [start, end) overlap multiple children? + // i.e. left-augmented child contains start + // but LR-augmented child does not contain end. + if start < childEnd && end > augEnd { + break + } + } + + // No single child contained [start, end), + // so node is the result. Is it exact? + + // (It's tempting to put this condition before the + // child loop, but it gives the wrong result in the + // case where a node (e.g. ExprStmt) and its sole + // child have equal intervals.) + if start == nodePos && end == nodeEnd { + return true // exact match + } + + return false // inexact: overlaps multiple children + } + + // Ensure [start,end) is nondecreasing. + if start > end { + start, end = end, start + } + + if start < root.End() && end > root.Pos() { + if start == end { + end = start + 1 // empty interval => interval of size 1 + } + exact = visit(root) + + // Reverse the path: + for i, l := 0, len(path); i < l/2; i++ { + path[i], path[l-1-i] = path[l-1-i], path[i] + } + } else { + // Selection lies within whitespace preceding the + // first (or following the last) declaration in the file. + // The result nonetheless always includes the ast.File. + path = append(path, root) + } + + return +} + +// tokenNode is a dummy implementation of ast.Node for a single token. +// They are used transiently by PathEnclosingInterval but never escape +// this package. +type tokenNode struct { + pos token.Pos + end token.Pos +} + +func (n tokenNode) Pos() token.Pos { + return n.pos +} + +func (n tokenNode) End() token.Pos { + return n.end +} + +func tok(pos token.Pos, len int) ast.Node { + return tokenNode{pos, pos + token.Pos(len)} +} + +// childrenOf returns the direct non-nil children of ast.Node n. +// It may include fake ast.Node implementations for bare tokens. +// it is not safe to call (e.g.) ast.Walk on such nodes. +func childrenOf(n ast.Node) []ast.Node { + var children []ast.Node + + // First add nodes for all true subtrees. + ast.Inspect(n, func(node ast.Node) bool { + if node == n { // push n + return true // recur + } + if node != nil { // push child + children = append(children, node) + } + return false // no recursion + }) + + // TODO(adonovan): be more careful about missing (!Pos.Valid) + // tokens in trees produced from invalid input. + + // Then add fake Nodes for bare tokens. + switch n := n.(type) { + case *ast.ArrayType: + children = append(children, + tok(n.Lbrack, len("[")), + tok(n.Elt.End(), len("]"))) + + case *ast.AssignStmt: + children = append(children, + tok(n.TokPos, len(n.Tok.String()))) + + case *ast.BasicLit: + children = append(children, + tok(n.ValuePos, len(n.Value))) + + case *ast.BinaryExpr: + children = append(children, tok(n.OpPos, len(n.Op.String()))) + + case *ast.BlockStmt: + if n.Lbrace.IsValid() { + children = append(children, tok(n.Lbrace, len("{"))) + } + if n.Rbrace.IsValid() { + children = append(children, tok(n.Rbrace, len("}"))) + } + + case *ast.BranchStmt: + children = append(children, + tok(n.TokPos, len(n.Tok.String()))) + + case *ast.CallExpr: + children = append(children, + tok(n.Lparen, len("(")), + tok(n.Rparen, len(")"))) + if n.Ellipsis != 0 { + children = append(children, tok(n.Ellipsis, len("..."))) + } + + case *ast.CaseClause: + if n.List == nil { + children = append(children, + tok(n.Case, len("default"))) + } else { + children = append(children, + tok(n.Case, len("case"))) + } + children = append(children, tok(n.Colon, len(":"))) + + case *ast.ChanType: + switch n.Dir { + case ast.RECV: + children = append(children, tok(n.Begin, len("<-chan"))) + case ast.SEND: + children = append(children, tok(n.Begin, len("chan<-"))) + case ast.RECV | ast.SEND: + children = append(children, tok(n.Begin, len("chan"))) + } + + case *ast.CommClause: + if n.Comm == nil { + children = append(children, + tok(n.Case, len("default"))) + } else { + children = append(children, + tok(n.Case, len("case"))) + } + children = append(children, tok(n.Colon, len(":"))) + + case *ast.Comment: + // nop + + case *ast.CommentGroup: + // nop + + case *ast.CompositeLit: + children = append(children, + tok(n.Lbrace, len("{")), + tok(n.Rbrace, len("{"))) + + case *ast.DeclStmt: + // nop + + case *ast.DeferStmt: + children = append(children, + tok(n.Defer, len("defer"))) + + case *ast.Ellipsis: + children = append(children, + tok(n.Ellipsis, len("..."))) + + case *ast.EmptyStmt: + // nop + + case *ast.ExprStmt: + // nop + + case *ast.Field: + // TODO(adonovan): Field.{Doc,Comment,Tag}? + + case *ast.FieldList: + if n.Opening.IsValid() { + children = append(children, tok(n.Opening, len("("))) + } + if n.Closing.IsValid() { + children = append(children, tok(n.Closing, len(")"))) + } + + case *ast.File: + // TODO test: Doc + children = append(children, + tok(n.Package, len("package"))) + + case *ast.ForStmt: + children = append(children, + tok(n.For, len("for"))) + + case *ast.FuncDecl: + // TODO(adonovan): FuncDecl.Comment? + + // Uniquely, FuncDecl breaks the invariant that + // preorder traversal yields tokens in lexical order: + // in fact, FuncDecl.Recv precedes FuncDecl.Type.Func. + // + // As a workaround, we inline the case for FuncType + // here and order things correctly. + // We also need to insert the elided FuncType just + // before the 'visit' recursion. + // + children = nil // discard ast.Walk(FuncDecl) info subtrees + children = append(children, tok(n.Type.Func, len("func"))) + if n.Recv != nil { + children = append(children, n.Recv) + } + children = append(children, n.Name) + if tparams := n.Type.TypeParams; tparams != nil { + children = append(children, tparams) + } + if n.Type.Params != nil { + children = append(children, n.Type.Params) + } + if n.Type.Results != nil { + children = append(children, n.Type.Results) + } + if n.Body != nil { + children = append(children, n.Body) + } + + case *ast.FuncLit: + // nop + + case *ast.FuncType: + if n.Func != 0 { + children = append(children, + tok(n.Func, len("func"))) + } + + case *ast.GenDecl: + children = append(children, + tok(n.TokPos, len(n.Tok.String()))) + if n.Lparen != 0 { + children = append(children, + tok(n.Lparen, len("(")), + tok(n.Rparen, len(")"))) + } + + case *ast.GoStmt: + children = append(children, + tok(n.Go, len("go"))) + + case *ast.Ident: + children = append(children, + tok(n.NamePos, len(n.Name))) + + case *ast.IfStmt: + children = append(children, + tok(n.If, len("if"))) + + case *ast.ImportSpec: + // TODO(adonovan): ImportSpec.{Doc,EndPos}? + + case *ast.IncDecStmt: + children = append(children, + tok(n.TokPos, len(n.Tok.String()))) + + case *ast.IndexExpr: + children = append(children, + tok(n.Lbrack, len("[")), + tok(n.Rbrack, len("]"))) + + case *ast.IndexListExpr: + children = append(children, + tok(n.Lbrack, len("[")), + tok(n.Rbrack, len("]"))) + + case *ast.InterfaceType: + children = append(children, + tok(n.Interface, len("interface"))) + + case *ast.KeyValueExpr: + children = append(children, + tok(n.Colon, len(":"))) + + case *ast.LabeledStmt: + children = append(children, + tok(n.Colon, len(":"))) + + case *ast.MapType: + children = append(children, + tok(n.Map, len("map"))) + + case *ast.ParenExpr: + children = append(children, + tok(n.Lparen, len("(")), + tok(n.Rparen, len(")"))) + + case *ast.RangeStmt: + children = append(children, + tok(n.For, len("for")), + tok(n.TokPos, len(n.Tok.String()))) + + case *ast.ReturnStmt: + children = append(children, + tok(n.Return, len("return"))) + + case *ast.SelectStmt: + children = append(children, + tok(n.Select, len("select"))) + + case *ast.SelectorExpr: + // nop + + case *ast.SendStmt: + children = append(children, + tok(n.Arrow, len("<-"))) + + case *ast.SliceExpr: + children = append(children, + tok(n.Lbrack, len("[")), + tok(n.Rbrack, len("]"))) + + case *ast.StarExpr: + children = append(children, tok(n.Star, len("*"))) + + case *ast.StructType: + children = append(children, tok(n.Struct, len("struct"))) + + case *ast.SwitchStmt: + children = append(children, tok(n.Switch, len("switch"))) + + case *ast.TypeAssertExpr: + children = append(children, + tok(n.Lparen-1, len(".")), + tok(n.Lparen, len("(")), + tok(n.Rparen, len(")"))) + + case *ast.TypeSpec: + // TODO(adonovan): TypeSpec.{Doc,Comment}? + + case *ast.TypeSwitchStmt: + children = append(children, tok(n.Switch, len("switch"))) + + case *ast.UnaryExpr: + children = append(children, tok(n.OpPos, len(n.Op.String()))) + + case *ast.ValueSpec: + // TODO(adonovan): ValueSpec.{Doc,Comment}? + + case *ast.BadDecl, *ast.BadExpr, *ast.BadStmt: + // nop + } + + // TODO(adonovan): opt: merge the logic of ast.Inspect() into + // the switch above so we can make interleaved callbacks for + // both Nodes and Tokens in the right order and avoid the need + // to sort. + sort.Sort(byPos(children)) + + return children +} + +type byPos []ast.Node + +func (sl byPos) Len() int { + return len(sl) +} +func (sl byPos) Less(i, j int) bool { + return sl[i].Pos() < sl[j].Pos() +} +func (sl byPos) Swap(i, j int) { + sl[i], sl[j] = sl[j], sl[i] +} + +// NodeDescription returns a description of the concrete type of n suitable +// for a user interface. +// +// TODO(adonovan): in some cases (e.g. Field, FieldList, Ident, +// StarExpr) we could be much more specific given the path to the AST +// root. Perhaps we should do that. +func NodeDescription(n ast.Node) string { + switch n := n.(type) { + case *ast.ArrayType: + return "array type" + case *ast.AssignStmt: + return "assignment" + case *ast.BadDecl: + return "bad declaration" + case *ast.BadExpr: + return "bad expression" + case *ast.BadStmt: + return "bad statement" + case *ast.BasicLit: + return "basic literal" + case *ast.BinaryExpr: + return fmt.Sprintf("binary %s operation", n.Op) + case *ast.BlockStmt: + return "block" + case *ast.BranchStmt: + switch n.Tok { + case token.BREAK: + return "break statement" + case token.CONTINUE: + return "continue statement" + case token.GOTO: + return "goto statement" + case token.FALLTHROUGH: + return "fall-through statement" + } + case *ast.CallExpr: + if len(n.Args) == 1 && !n.Ellipsis.IsValid() { + return "function call (or conversion)" + } + return "function call" + case *ast.CaseClause: + return "case clause" + case *ast.ChanType: + return "channel type" + case *ast.CommClause: + return "communication clause" + case *ast.Comment: + return "comment" + case *ast.CommentGroup: + return "comment group" + case *ast.CompositeLit: + return "composite literal" + case *ast.DeclStmt: + return NodeDescription(n.Decl) + " statement" + case *ast.DeferStmt: + return "defer statement" + case *ast.Ellipsis: + return "ellipsis" + case *ast.EmptyStmt: + return "empty statement" + case *ast.ExprStmt: + return "expression statement" + case *ast.Field: + // Can be any of these: + // struct {x, y int} -- struct field(s) + // struct {T} -- anon struct field + // interface {I} -- interface embedding + // interface {f()} -- interface method + // func (A) func(B) C -- receiver, param(s), result(s) + return "field/method/parameter" + case *ast.FieldList: + return "field/method/parameter list" + case *ast.File: + return "source file" + case *ast.ForStmt: + return "for loop" + case *ast.FuncDecl: + return "function declaration" + case *ast.FuncLit: + return "function literal" + case *ast.FuncType: + return "function type" + case *ast.GenDecl: + switch n.Tok { + case token.IMPORT: + return "import declaration" + case token.CONST: + return "constant declaration" + case token.TYPE: + return "type declaration" + case token.VAR: + return "variable declaration" + } + case *ast.GoStmt: + return "go statement" + case *ast.Ident: + return "identifier" + case *ast.IfStmt: + return "if statement" + case *ast.ImportSpec: + return "import specification" + case *ast.IncDecStmt: + if n.Tok == token.INC { + return "increment statement" + } + return "decrement statement" + case *ast.IndexExpr: + return "index expression" + case *ast.IndexListExpr: + return "index list expression" + case *ast.InterfaceType: + return "interface type" + case *ast.KeyValueExpr: + return "key/value association" + case *ast.LabeledStmt: + return "statement label" + case *ast.MapType: + return "map type" + case *ast.Package: + return "package" + case *ast.ParenExpr: + return "parenthesized " + NodeDescription(n.X) + case *ast.RangeStmt: + return "range loop" + case *ast.ReturnStmt: + return "return statement" + case *ast.SelectStmt: + return "select statement" + case *ast.SelectorExpr: + return "selector" + case *ast.SendStmt: + return "channel send" + case *ast.SliceExpr: + return "slice expression" + case *ast.StarExpr: + return "*-operation" // load/store expr or pointer type + case *ast.StructType: + return "struct type" + case *ast.SwitchStmt: + return "switch statement" + case *ast.TypeAssertExpr: + return "type assertion" + case *ast.TypeSpec: + return "type specification" + case *ast.TypeSwitchStmt: + return "type switch" + case *ast.UnaryExpr: + return fmt.Sprintf("unary %s operation", n.Op) + case *ast.ValueSpec: + return "value specification" + + } + panic(fmt.Sprintf("unexpected node type: %T", n)) +} + +func is[T any](x any) bool { + _, ok := x.(T) + return ok +} diff --git a/vendor/golang.org/x/tools/go/ast/astutil/imports.go b/vendor/golang.org/x/tools/go/ast/astutil/imports.go new file mode 100644 index 000000000..adb471101 --- /dev/null +++ b/vendor/golang.org/x/tools/go/ast/astutil/imports.go @@ -0,0 +1,487 @@ +// Copyright 2013 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 astutil contains common utilities for working with the Go AST. +package astutil // import "golang.org/x/tools/go/ast/astutil" + +import ( + "fmt" + "go/ast" + "go/token" + "reflect" + "slices" + "strconv" + "strings" +) + +// AddImport adds the import path to the file f, if absent. +func AddImport(fset *token.FileSet, f *ast.File, path string) (added bool) { + return AddNamedImport(fset, f, "", path) +} + +// AddNamedImport adds the import with the given name and path to the file f, if absent. +// If name is not empty, it is used to rename the import. +// +// For example, calling +// +// AddNamedImport(fset, f, "pathpkg", "path") +// +// adds +// +// import pathpkg "path" +func AddNamedImport(fset *token.FileSet, f *ast.File, name, path string) (added bool) { + if imports(f, name, path) { + return false + } + + newImport := &ast.ImportSpec{ + Path: &ast.BasicLit{ + Kind: token.STRING, + Value: strconv.Quote(path), + }, + } + if name != "" { + newImport.Name = &ast.Ident{Name: name} + } + + // Find an import decl to add to. + // The goal is to find an existing import + // whose import path has the longest shared + // prefix with path. + var ( + bestMatch = -1 // length of longest shared prefix + lastImport = -1 // index in f.Decls of the file's final import decl + impDecl *ast.GenDecl // import decl containing the best match + impIndex = -1 // spec index in impDecl containing the best match + + isThirdPartyPath = isThirdParty(path) + ) + for i, decl := range f.Decls { + gen, ok := decl.(*ast.GenDecl) + if ok && gen.Tok == token.IMPORT { + lastImport = i + // Do not add to import "C", to avoid disrupting the + // association with its doc comment, breaking cgo. + if declImports(gen, "C") { + continue + } + + // Match an empty import decl if that's all that is available. + if len(gen.Specs) == 0 && bestMatch == -1 { + impDecl = gen + } + + // Compute longest shared prefix with imports in this group and find best + // matched import spec. + // 1. Always prefer import spec with longest shared prefix. + // 2. While match length is 0, + // - for stdlib package: prefer first import spec. + // - for third party package: prefer first third party import spec. + // We cannot use last import spec as best match for third party package + // because grouped imports are usually placed last by goimports -local + // flag. + // See issue #19190. + seenAnyThirdParty := false + for j, spec := range gen.Specs { + impspec := spec.(*ast.ImportSpec) + p := importPath(impspec) + n := matchLen(p, path) + if n > bestMatch || (bestMatch == 0 && !seenAnyThirdParty && isThirdPartyPath) { + bestMatch = n + impDecl = gen + impIndex = j + } + seenAnyThirdParty = seenAnyThirdParty || isThirdParty(p) + } + } + } + + // If no import decl found, add one after the last import. + if impDecl == nil { + impDecl = &ast.GenDecl{ + Tok: token.IMPORT, + } + if lastImport >= 0 { + impDecl.TokPos = f.Decls[lastImport].End() + } else { + // There are no existing imports. + // Our new import, preceded by a blank line, goes after the package declaration + // and after the comment, if any, that starts on the same line as the + // package declaration. + impDecl.TokPos = f.Package + + file := fset.File(f.Package) + pkgLine := file.Line(f.Package) + for _, c := range f.Comments { + if file.Line(c.Pos()) > pkgLine { + break + } + // +2 for a blank line + impDecl.TokPos = c.End() + 2 + } + } + f.Decls = append(f.Decls, nil) + copy(f.Decls[lastImport+2:], f.Decls[lastImport+1:]) + f.Decls[lastImport+1] = impDecl + } + + // Insert new import at insertAt. + insertAt := 0 + if impIndex >= 0 { + // insert after the found import + insertAt = impIndex + 1 + } + impDecl.Specs = append(impDecl.Specs, nil) + copy(impDecl.Specs[insertAt+1:], impDecl.Specs[insertAt:]) + impDecl.Specs[insertAt] = newImport + pos := impDecl.Pos() + if insertAt > 0 { + // If there is a comment after an existing import, preserve the comment + // position by adding the new import after the comment. + if spec, ok := impDecl.Specs[insertAt-1].(*ast.ImportSpec); ok && spec.Comment != nil { + pos = spec.Comment.End() + } else { + // Assign same position as the previous import, + // so that the sorter sees it as being in the same block. + pos = impDecl.Specs[insertAt-1].Pos() + } + } + if newImport.Name != nil { + newImport.Name.NamePos = pos + } + updateBasicLitPos(newImport.Path, pos) + newImport.EndPos = pos + + // Clean up parens. impDecl contains at least one spec. + if len(impDecl.Specs) == 1 { + // Remove unneeded parens. + impDecl.Lparen = token.NoPos + } else if !impDecl.Lparen.IsValid() { + // impDecl needs parens added. + impDecl.Lparen = impDecl.Specs[0].Pos() + } + + f.Imports = append(f.Imports, newImport) + + if len(f.Decls) <= 1 { + return true + } + + // Merge all the import declarations into the first one. + var first *ast.GenDecl + for i := 0; i < len(f.Decls); i++ { + decl := f.Decls[i] + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.IMPORT || declImports(gen, "C") { + continue + } + if first == nil { + first = gen + continue // Don't touch the first one. + } + // We now know there is more than one package in this import + // declaration. Ensure that it ends up parenthesized. + first.Lparen = first.Pos() + // Move the imports of the other import declaration to the first one. + for _, spec := range gen.Specs { + updateBasicLitPos(spec.(*ast.ImportSpec).Path, first.Pos()) + first.Specs = append(first.Specs, spec) + } + f.Decls = slices.Delete(f.Decls, i, i+1) + i-- + } + + return true +} + +func isThirdParty(importPath string) bool { + // Third party package import path usually contains "." (".com", ".org", ...) + // This logic is taken from golang.org/x/tools/imports package. + return strings.Contains(importPath, ".") +} + +// DeleteImport deletes the import path from the file f, if present. +// If there are duplicate import declarations, all matching ones are deleted. +func DeleteImport(fset *token.FileSet, f *ast.File, path string) (deleted bool) { + return DeleteNamedImport(fset, f, "", path) +} + +// DeleteNamedImport deletes the import with the given name and path from the file f, if present. +// If there are duplicate import declarations, all matching ones are deleted. +func DeleteNamedImport(fset *token.FileSet, f *ast.File, name, path string) (deleted bool) { + var ( + delspecs = make(map[*ast.ImportSpec]bool) + delcomments = make(map[*ast.CommentGroup]bool) + ) + + // Find the import nodes that import path, if any. + for i := 0; i < len(f.Decls); i++ { + gen, ok := f.Decls[i].(*ast.GenDecl) + if !ok || gen.Tok != token.IMPORT { + continue + } + for j := 0; j < len(gen.Specs); j++ { + impspec := gen.Specs[j].(*ast.ImportSpec) + if importName(impspec) != name || importPath(impspec) != path { + continue + } + + // We found an import spec that imports path. + // Delete it. + delspecs[impspec] = true + deleted = true + gen.Specs = slices.Delete(gen.Specs, j, j+1) + + // If this was the last import spec in this decl, + // delete the decl, too. + if len(gen.Specs) == 0 { + f.Decls = slices.Delete(f.Decls, i, i+1) + i-- + break + } else if len(gen.Specs) == 1 { + if impspec.Doc != nil { + delcomments[impspec.Doc] = true + } + if impspec.Comment != nil { + delcomments[impspec.Comment] = true + } + for _, cg := range f.Comments { + // Found comment on the same line as the import spec. + if cg.End() < impspec.Pos() && fset.Position(cg.End()).Line == fset.Position(impspec.Pos()).Line { + delcomments[cg] = true + break + } + } + + spec := gen.Specs[0].(*ast.ImportSpec) + + // Move the documentation right after the import decl. + if spec.Doc != nil { + for fset.Position(gen.TokPos).Line+1 < fset.Position(spec.Doc.Pos()).Line { + fset.File(gen.TokPos).MergeLine(fset.Position(gen.TokPos).Line) + } + } + for _, cg := range f.Comments { + if cg.End() < spec.Pos() && fset.Position(cg.End()).Line == fset.Position(spec.Pos()).Line { + for fset.Position(gen.TokPos).Line+1 < fset.Position(spec.Pos()).Line { + fset.File(gen.TokPos).MergeLine(fset.Position(gen.TokPos).Line) + } + break + } + } + } + if j > 0 { + lastImpspec := gen.Specs[j-1].(*ast.ImportSpec) + lastLine := fset.PositionFor(lastImpspec.Path.ValuePos, false).Line + line := fset.PositionFor(impspec.Path.ValuePos, false).Line + + // We deleted an entry but now there may be + // a blank line-sized hole where the import was. + if line-lastLine > 1 || !gen.Rparen.IsValid() { + // There was a blank line immediately preceding the deleted import, + // so there's no need to close the hole. The right parenthesis is + // invalid after AddImport to an import statement without parenthesis. + // Do nothing. + } else if line != fset.File(gen.Rparen).LineCount() { + // There was no blank line. Close the hole. + fset.File(gen.Rparen).MergeLine(line) + } + } + j-- + } + } + + // Delete imports from f.Imports. + before := len(f.Imports) + f.Imports = slices.DeleteFunc(f.Imports, func(imp *ast.ImportSpec) bool { + _, ok := delspecs[imp] + return ok + }) + if len(f.Imports)+len(delspecs) != before { + // This can happen when the AST is invalid (i.e. imports differ between f.Decls and f.Imports). + panic(fmt.Sprintf("deleted specs from Decls but not Imports: %v", delspecs)) + } + + // Delete comments from f.Comments. + f.Comments = slices.DeleteFunc(f.Comments, func(cg *ast.CommentGroup) bool { + _, ok := delcomments[cg] + return ok + }) + + return +} + +// RewriteImport rewrites any import of path oldPath to path newPath. +func RewriteImport(fset *token.FileSet, f *ast.File, oldPath, newPath string) (rewrote bool) { + for _, imp := range f.Imports { + if importPath(imp) == oldPath { + rewrote = true + // record old End, because the default is to compute + // it using the length of imp.Path.Value. + imp.EndPos = imp.End() + imp.Path.Value = strconv.Quote(newPath) + } + } + return +} + +// UsesImport reports whether a given import is used. +// The provided File must have been parsed with syntactic object resolution +// (not using go/parser.SkipObjectResolution). +func UsesImport(f *ast.File, path string) (used bool) { + if f.Scope == nil { + panic("file f was not parsed with syntactic object resolution") + } + spec := importSpec(f, path) + if spec == nil { + return + } + + name := spec.Name.String() + switch name { + case "": + // If the package name is not explicitly specified, + // make an educated guess. This is not guaranteed to be correct. + lastSlash := strings.LastIndex(path, "/") + if lastSlash == -1 { + name = path + } else { + name = path[lastSlash+1:] + } + case "_", ".": + // Not sure if this import is used - err on the side of caution. + return true + } + + ast.Walk(visitFn(func(n ast.Node) { + sel, ok := n.(*ast.SelectorExpr) + if ok && isTopName(sel.X, name) { + used = true + } + }), f) + + return +} + +type visitFn func(node ast.Node) + +func (fn visitFn) Visit(node ast.Node) ast.Visitor { + fn(node) + return fn +} + +// imports reports whether f has an import with the specified name and path. +func imports(f *ast.File, name, path string) bool { + for _, s := range f.Imports { + if importName(s) == name && importPath(s) == path { + return true + } + } + return false +} + +// importSpec returns the import spec if f imports path, +// or nil otherwise. +func importSpec(f *ast.File, path string) *ast.ImportSpec { + for _, s := range f.Imports { + if importPath(s) == path { + return s + } + } + return nil +} + +// importName returns the name of s, +// or "" if the import is not named. +func importName(s *ast.ImportSpec) string { + if s.Name == nil { + return "" + } + return s.Name.Name +} + +// importPath returns the unquoted import path of s, +// or "" if the path is not properly quoted. +func importPath(s *ast.ImportSpec) string { + t, err := strconv.Unquote(s.Path.Value) + if err != nil { + return "" + } + return t +} + +// declImports reports whether gen contains an import of path. +func declImports(gen *ast.GenDecl, path string) bool { + if gen.Tok != token.IMPORT { + return false + } + for _, spec := range gen.Specs { + impspec := spec.(*ast.ImportSpec) + if importPath(impspec) == path { + return true + } + } + return false +} + +// matchLen returns the length of the longest path segment prefix shared by x and y. +func matchLen(x, y string) int { + n := 0 + for i := 0; i < len(x) && i < len(y) && x[i] == y[i]; i++ { + if x[i] == '/' { + n++ + } + } + return n +} + +// isTopName returns true if n is a top-level unresolved identifier with the given name. +func isTopName(n ast.Expr, name string) bool { + id, ok := n.(*ast.Ident) + return ok && id.Name == name && id.Obj == nil +} + +// Imports returns the file imports grouped by paragraph. +func Imports(fset *token.FileSet, f *ast.File) [][]*ast.ImportSpec { + var groups [][]*ast.ImportSpec + + for _, decl := range f.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.IMPORT { + break + } + + group := []*ast.ImportSpec{} + + var lastLine int + for _, spec := range genDecl.Specs { + importSpec := spec.(*ast.ImportSpec) + pos := importSpec.Path.ValuePos + line := fset.Position(pos).Line + if lastLine > 0 && pos > 0 && line-lastLine > 1 { + groups = append(groups, group) + group = []*ast.ImportSpec{} + } + group = append(group, importSpec) + lastLine = line + } + groups = append(groups, group) + } + + return groups +} + +// updateBasicLitPos updates lit.Pos, +// ensuring that lit.End (if set) is displaced by the same amount. +// (See https://go.dev/issue/76395.) +func updateBasicLitPos(lit *ast.BasicLit, pos token.Pos) { + len := lit.End() - lit.Pos() + lit.ValuePos = pos + // TODO(adonovan): after go1.26, simplify to: + // lit.ValueEnd = pos + len + v := reflect.ValueOf(lit).Elem().FieldByName("ValueEnd") + if v.IsValid() && v.Int() != 0 { + v.SetInt(int64(pos + len)) + } +} diff --git a/vendor/golang.org/x/tools/go/ast/astutil/rewrite.go b/vendor/golang.org/x/tools/go/ast/astutil/rewrite.go new file mode 100644 index 000000000..4ad054930 --- /dev/null +++ b/vendor/golang.org/x/tools/go/ast/astutil/rewrite.go @@ -0,0 +1,490 @@ +// 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 astutil + +import ( + "fmt" + "go/ast" + "reflect" + "sort" +) + +// An ApplyFunc is invoked by Apply for each node n, even if n is nil, +// before and/or after the node's children, using a Cursor describing +// the current node and providing operations on it. +// +// The return value of ApplyFunc controls the syntax tree traversal. +// See Apply for details. +type ApplyFunc func(*Cursor) bool + +// Apply traverses a syntax tree recursively, starting with root, +// and calling pre and post for each node as described below. +// Apply returns the syntax tree, possibly modified. +// +// If pre is not nil, it is called for each node before the node's +// children are traversed (pre-order). If pre returns false, no +// children are traversed, and post is not called for that node. +// +// If post is not nil, and a prior call of pre didn't return false, +// post is called for each node after its children are traversed +// (post-order). If post returns false, traversal is terminated and +// Apply returns immediately. +// +// Only fields that refer to AST nodes are considered children; +// i.e., token.Pos, Scopes, Objects, and fields of basic types +// (strings, etc.) are ignored. +// +// Children are traversed in the order in which they appear in the +// respective node's struct definition. A package's files are +// traversed in the filenames' alphabetical order. +func Apply(root ast.Node, pre, post ApplyFunc) (result ast.Node) { + parent := &struct{ ast.Node }{root} + defer func() { + if r := recover(); r != nil && r != abort { + panic(r) + } + result = parent.Node + }() + a := &application{pre: pre, post: post} + a.apply(parent, "Node", nil, root) + return +} + +var abort = new(int) // singleton, to signal termination of Apply + +// A Cursor describes a node encountered during Apply. +// Information about the node and its parent is available +// from the Node, Parent, Name, and Index methods. +// +// If p is a variable of type and value of the current parent node +// c.Parent(), and f is the field identifier with name c.Name(), +// the following invariants hold: +// +// p.f == c.Node() if c.Index() < 0 +// p.f[c.Index()] == c.Node() if c.Index() >= 0 +// +// The methods Replace, Delete, InsertBefore, and InsertAfter +// can be used to change the AST without disrupting Apply. +// +// This type is not to be confused with [inspector.Cursor] from +// package [golang.org/x/tools/go/ast/inspector], which provides +// stateless navigation of immutable syntax trees. +type Cursor struct { + parent ast.Node + name string + iter *iterator // valid if non-nil + node ast.Node +} + +// Node returns the current Node. +func (c *Cursor) Node() ast.Node { return c.node } + +// Parent returns the parent of the current Node. +func (c *Cursor) Parent() ast.Node { return c.parent } + +// Name returns the name of the parent Node field that contains the current Node. +// If the parent is a *ast.Package and the current Node is a *ast.File, Name returns +// the filename for the current Node. +func (c *Cursor) Name() string { return c.name } + +// Index reports the index >= 0 of the current Node in the slice of Nodes that +// contains it, or a value < 0 if the current Node is not part of a slice. +// The index of the current node changes if InsertBefore is called while +// processing the current node. +func (c *Cursor) Index() int { + if c.iter != nil { + return c.iter.index + } + return -1 +} + +// field returns the current node's parent field value. +func (c *Cursor) field() reflect.Value { + return reflect.Indirect(reflect.ValueOf(c.parent)).FieldByName(c.name) +} + +// Replace replaces the current Node with n. +// The replacement node is not walked by Apply. +func (c *Cursor) Replace(n ast.Node) { + if _, ok := c.node.(*ast.File); ok { + file, ok := n.(*ast.File) + if !ok { + panic("attempt to replace *ast.File with non-*ast.File") + } + c.parent.(*ast.Package).Files[c.name] = file + return + } + + v := c.field() + if i := c.Index(); i >= 0 { + v = v.Index(i) + } + v.Set(reflect.ValueOf(n)) +} + +// Delete deletes the current Node from its containing slice. +// If the current Node is not part of a slice, Delete panics. +// As a special case, if the current node is a package file, +// Delete removes it from the package's Files map. +func (c *Cursor) Delete() { + if _, ok := c.node.(*ast.File); ok { + delete(c.parent.(*ast.Package).Files, c.name) + return + } + + i := c.Index() + if i < 0 { + panic("Delete node not contained in slice") + } + v := c.field() + l := v.Len() + reflect.Copy(v.Slice(i, l), v.Slice(i+1, l)) + v.Index(l - 1).Set(reflect.Zero(v.Type().Elem())) + v.SetLen(l - 1) + c.iter.step-- +} + +// InsertAfter inserts n after the current Node in its containing slice. +// If the current Node is not part of a slice, InsertAfter panics. +// Apply does not walk n. +func (c *Cursor) InsertAfter(n ast.Node) { + i := c.Index() + if i < 0 { + panic("InsertAfter node not contained in slice") + } + v := c.field() + v.Set(reflect.Append(v, reflect.Zero(v.Type().Elem()))) + l := v.Len() + reflect.Copy(v.Slice(i+2, l), v.Slice(i+1, l)) + v.Index(i + 1).Set(reflect.ValueOf(n)) + c.iter.step++ +} + +// InsertBefore inserts n before the current Node in its containing slice. +// If the current Node is not part of a slice, InsertBefore panics. +// Apply will not walk n. +func (c *Cursor) InsertBefore(n ast.Node) { + i := c.Index() + if i < 0 { + panic("InsertBefore node not contained in slice") + } + v := c.field() + v.Set(reflect.Append(v, reflect.Zero(v.Type().Elem()))) + l := v.Len() + reflect.Copy(v.Slice(i+1, l), v.Slice(i, l)) + v.Index(i).Set(reflect.ValueOf(n)) + c.iter.index++ +} + +// application carries all the shared data so we can pass it around cheaply. +type application struct { + pre, post ApplyFunc + cursor Cursor + iter iterator +} + +func (a *application) apply(parent ast.Node, name string, iter *iterator, n ast.Node) { + // convert typed nil into untyped nil + if v := reflect.ValueOf(n); v.Kind() == reflect.Pointer && v.IsNil() { + n = nil + } + + // avoid heap-allocating a new cursor for each apply call; reuse a.cursor instead + saved := a.cursor + a.cursor.parent = parent + a.cursor.name = name + a.cursor.iter = iter + a.cursor.node = n + + if a.pre != nil && !a.pre(&a.cursor) { + a.cursor = saved + return + } + + // walk children + // (the order of the cases matches the order of the corresponding node types in go/ast) + switch n := n.(type) { + case nil: + // nothing to do + + // Comments and fields + case *ast.Comment: + // nothing to do + + case *ast.CommentGroup: + if n != nil { + a.applyList(n, "List") + } + + case *ast.Field: + a.apply(n, "Doc", nil, n.Doc) + a.applyList(n, "Names") + a.apply(n, "Type", nil, n.Type) + a.apply(n, "Tag", nil, n.Tag) + a.apply(n, "Comment", nil, n.Comment) + + case *ast.FieldList: + a.applyList(n, "List") + + // Expressions + case *ast.BadExpr, *ast.Ident, *ast.BasicLit: + // nothing to do + + case *ast.Ellipsis: + a.apply(n, "Elt", nil, n.Elt) + + case *ast.FuncLit: + a.apply(n, "Type", nil, n.Type) + a.apply(n, "Body", nil, n.Body) + + case *ast.CompositeLit: + a.apply(n, "Type", nil, n.Type) + a.applyList(n, "Elts") + + case *ast.ParenExpr: + a.apply(n, "X", nil, n.X) + + case *ast.SelectorExpr: + a.apply(n, "X", nil, n.X) + a.apply(n, "Sel", nil, n.Sel) + + case *ast.IndexExpr: + a.apply(n, "X", nil, n.X) + a.apply(n, "Index", nil, n.Index) + + case *ast.IndexListExpr: + a.apply(n, "X", nil, n.X) + a.applyList(n, "Indices") + + case *ast.SliceExpr: + a.apply(n, "X", nil, n.X) + a.apply(n, "Low", nil, n.Low) + a.apply(n, "High", nil, n.High) + a.apply(n, "Max", nil, n.Max) + + case *ast.TypeAssertExpr: + a.apply(n, "X", nil, n.X) + a.apply(n, "Type", nil, n.Type) + + case *ast.CallExpr: + a.apply(n, "Fun", nil, n.Fun) + a.applyList(n, "Args") + + case *ast.StarExpr: + a.apply(n, "X", nil, n.X) + + case *ast.UnaryExpr: + a.apply(n, "X", nil, n.X) + + case *ast.BinaryExpr: + a.apply(n, "X", nil, n.X) + a.apply(n, "Y", nil, n.Y) + + case *ast.KeyValueExpr: + a.apply(n, "Key", nil, n.Key) + a.apply(n, "Value", nil, n.Value) + + // Types + case *ast.ArrayType: + a.apply(n, "Len", nil, n.Len) + a.apply(n, "Elt", nil, n.Elt) + + case *ast.StructType: + a.apply(n, "Fields", nil, n.Fields) + + case *ast.FuncType: + if tparams := n.TypeParams; tparams != nil { + a.apply(n, "TypeParams", nil, tparams) + } + a.apply(n, "Params", nil, n.Params) + a.apply(n, "Results", nil, n.Results) + + case *ast.InterfaceType: + a.apply(n, "Methods", nil, n.Methods) + + case *ast.MapType: + a.apply(n, "Key", nil, n.Key) + a.apply(n, "Value", nil, n.Value) + + case *ast.ChanType: + a.apply(n, "Value", nil, n.Value) + + // Statements + case *ast.BadStmt: + // nothing to do + + case *ast.DeclStmt: + a.apply(n, "Decl", nil, n.Decl) + + case *ast.EmptyStmt: + // nothing to do + + case *ast.LabeledStmt: + a.apply(n, "Label", nil, n.Label) + a.apply(n, "Stmt", nil, n.Stmt) + + case *ast.ExprStmt: + a.apply(n, "X", nil, n.X) + + case *ast.SendStmt: + a.apply(n, "Chan", nil, n.Chan) + a.apply(n, "Value", nil, n.Value) + + case *ast.IncDecStmt: + a.apply(n, "X", nil, n.X) + + case *ast.AssignStmt: + a.applyList(n, "Lhs") + a.applyList(n, "Rhs") + + case *ast.GoStmt: + a.apply(n, "Call", nil, n.Call) + + case *ast.DeferStmt: + a.apply(n, "Call", nil, n.Call) + + case *ast.ReturnStmt: + a.applyList(n, "Results") + + case *ast.BranchStmt: + a.apply(n, "Label", nil, n.Label) + + case *ast.BlockStmt: + a.applyList(n, "List") + + case *ast.IfStmt: + a.apply(n, "Init", nil, n.Init) + a.apply(n, "Cond", nil, n.Cond) + a.apply(n, "Body", nil, n.Body) + a.apply(n, "Else", nil, n.Else) + + case *ast.CaseClause: + a.applyList(n, "List") + a.applyList(n, "Body") + + case *ast.SwitchStmt: + a.apply(n, "Init", nil, n.Init) + a.apply(n, "Tag", nil, n.Tag) + a.apply(n, "Body", nil, n.Body) + + case *ast.TypeSwitchStmt: + a.apply(n, "Init", nil, n.Init) + a.apply(n, "Assign", nil, n.Assign) + a.apply(n, "Body", nil, n.Body) + + case *ast.CommClause: + a.apply(n, "Comm", nil, n.Comm) + a.applyList(n, "Body") + + case *ast.SelectStmt: + a.apply(n, "Body", nil, n.Body) + + case *ast.ForStmt: + a.apply(n, "Init", nil, n.Init) + a.apply(n, "Cond", nil, n.Cond) + a.apply(n, "Post", nil, n.Post) + a.apply(n, "Body", nil, n.Body) + + case *ast.RangeStmt: + a.apply(n, "Key", nil, n.Key) + a.apply(n, "Value", nil, n.Value) + a.apply(n, "X", nil, n.X) + a.apply(n, "Body", nil, n.Body) + + // Declarations + case *ast.ImportSpec: + a.apply(n, "Doc", nil, n.Doc) + a.apply(n, "Name", nil, n.Name) + a.apply(n, "Path", nil, n.Path) + a.apply(n, "Comment", nil, n.Comment) + + case *ast.ValueSpec: + a.apply(n, "Doc", nil, n.Doc) + a.applyList(n, "Names") + a.apply(n, "Type", nil, n.Type) + a.applyList(n, "Values") + a.apply(n, "Comment", nil, n.Comment) + + case *ast.TypeSpec: + a.apply(n, "Doc", nil, n.Doc) + a.apply(n, "Name", nil, n.Name) + if tparams := n.TypeParams; tparams != nil { + a.apply(n, "TypeParams", nil, tparams) + } + a.apply(n, "Type", nil, n.Type) + a.apply(n, "Comment", nil, n.Comment) + + case *ast.BadDecl: + // nothing to do + + case *ast.GenDecl: + a.apply(n, "Doc", nil, n.Doc) + a.applyList(n, "Specs") + + case *ast.FuncDecl: + a.apply(n, "Doc", nil, n.Doc) + a.apply(n, "Recv", nil, n.Recv) + a.apply(n, "Name", nil, n.Name) + a.apply(n, "Type", nil, n.Type) + a.apply(n, "Body", nil, n.Body) + + // Files and packages + case *ast.File: + a.apply(n, "Doc", nil, n.Doc) + a.apply(n, "Name", nil, n.Name) + a.applyList(n, "Decls") + // Don't walk n.Comments; they have either been walked already if + // they are Doc comments, or they can be easily walked explicitly. + + case *ast.Package: + // collect and sort names for reproducible behavior + var names []string + for name := range n.Files { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + a.apply(n, name, nil, n.Files[name]) + } + + default: + panic(fmt.Sprintf("Apply: unexpected node type %T", n)) + } + + if a.post != nil && !a.post(&a.cursor) { + panic(abort) + } + + a.cursor = saved +} + +// An iterator controls iteration over a slice of nodes. +type iterator struct { + index, step int +} + +func (a *application) applyList(parent ast.Node, name string) { + // avoid heap-allocating a new iterator for each applyList call; reuse a.iter instead + saved := a.iter + a.iter.index = 0 + for { + // must reload parent.name each time, since cursor modifications might change it + v := reflect.Indirect(reflect.ValueOf(parent)).FieldByName(name) + if a.iter.index >= v.Len() { + break + } + + // element x may be nil in a bad AST - be cautious + var x ast.Node + if e := v.Index(a.iter.index); e.IsValid() { + x = e.Interface().(ast.Node) + } + + a.iter.step = 1 + a.apply(parent, name, &a.iter, x) + a.iter.index += a.iter.step + } + a.iter = saved +} diff --git a/vendor/golang.org/x/tools/go/ast/astutil/util.go b/vendor/golang.org/x/tools/go/ast/astutil/util.go new file mode 100644 index 000000000..c820b2084 --- /dev/null +++ b/vendor/golang.org/x/tools/go/ast/astutil/util.go @@ -0,0 +1,13 @@ +// Copyright 2015 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 astutil + +import "go/ast" + +// Unparen returns e with any enclosing parentheses stripped. +// Deprecated: use [ast.Unparen]. +// +//go:fix inline +func Unparen(e ast.Expr) ast.Expr { return ast.Unparen(e) } diff --git a/vendor/modules.txt b/vendor/modules.txt index 9299f6ac0..3b50432bf 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -63,6 +63,11 @@ github.com/go-errors/errors 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 @@ -91,8 +96,6 @@ github.com/karimkhaleel/jsonschema # github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 ## explicit github.com/kr/logfmt -# github.com/kr/pretty v0.3.1 -## explicit; go 1.12 # github.com/kyokomi/emoji/v2 v2.2.13 ## explicit; go 1.14 github.com/kyokomi/emoji/v2 @@ -128,8 +131,6 @@ github.com/pmezard/go-difflib/difflib # github.com/rivo/uniseg v0.4.7 ## explicit; go 1.18 github.com/rivo/uniseg -# github.com/rogpeppe/go-internal v1.14.1 -## explicit; go 1.23 # github.com/sahilm/fuzzy v0.1.3 ## explicit; go 1.24.5 github.com/sahilm/fuzzy @@ -174,11 +175,18 @@ github.com/xo/terminfo ## explicit; go 1.20 golang.org/x/exp/constraints golang.org/x/exp/slices +# golang.org/x/mod v0.35.0 +## explicit; go 1.25.0 +golang.org/x/mod/internal/lazyregexp +golang.org/x/mod/modfile +golang.org/x/mod/module +golang.org/x/mod/semver # golang.org/x/net v0.55.0 ## explicit; go 1.25.0 # golang.org/x/sync v0.21.0 ## explicit; go 1.25.0 golang.org/x/sync/errgroup +golang.org/x/sync/semaphore # golang.org/x/sys v0.46.0 ## explicit; go 1.25.0 golang.org/x/sys/plan9 @@ -200,6 +208,9 @@ golang.org/x/text/language golang.org/x/text/runes golang.org/x/text/transform golang.org/x/text/unicode/norm +# golang.org/x/tools v0.44.0 +## explicit; go 1.25.0 +golang.org/x/tools/go/ast/astutil # gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c ## explicit; go 1.11 # gopkg.in/fsnotify.v1 v1.4.7 @@ -212,3 +223,12 @@ 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 +mvdan.cc/gofumpt/format +mvdan.cc/gofumpt/internal/govendor/diff +mvdan.cc/gofumpt/internal/govendor/go/doc/comment +mvdan.cc/gofumpt/internal/govendor/go/format +mvdan.cc/gofumpt/internal/govendor/go/printer +mvdan.cc/gofumpt/internal/version diff --git a/vendor/mvdan.cc/gofumpt/.gitattributes b/vendor/mvdan.cc/gofumpt/.gitattributes new file mode 100644 index 000000000..6f9522992 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/.gitattributes @@ -0,0 +1,2 @@ +# To prevent CRLF breakages on Windows for fragile files, like testdata. +* -text diff --git a/vendor/mvdan.cc/gofumpt/CHANGELOG.md b/vendor/mvdan.cc/gofumpt/CHANGELOG.md new file mode 100644 index 000000000..f3a384077 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/CHANGELOG.md @@ -0,0 +1,217 @@ +# Changelog + +## [v0.9.1] - 2025-09-07 + +This is a bugfix release to address a regression in detecting +comment directives with special characters such as `//golangcitest:config_path`. + +## [v0.9.0] - 2025-09-02 + +This release is based on Go 1.25's gofmt, and requires Go 1.24 or later. + +A new rule is introduced to "clothe" naked returns for the sake of clarity. +While there is nothing wrong with naming results in function signatures, +using lone `return` statements can be confusing to the reader. + +Go 1.25's `ignore` directives in `go.mod` files are now obeyed; +any directories within the module matching any of the patterns +are now omitted when walking directories, such as with `gofumpt -w .`. + +Module information is now loaded via Go's [`x/mod/modfile` package](https://pkg.go.dev/golang.org/x/mod/modfile) +rather than executing `go mod edit -json`, which is way faster. +This should result in moderate speed-ups when formatting many directories. + +## [v0.8.0] - 2025-04-13 + +This release is based on Go 1.24's gofmt, and requires Go 1.23 or later. + +The following changes are included: + +* Fail with `-d` if formatting any file resulted in a diff - #114 +* Do not panic when a `go.mod` file is missing a `go` directive - #317 + +## [v0.7.0] - 2024-08-16 + +This release is based on Go 1.23.0's gofmt, and requires Go 1.22 or later. + +The following changes are included: + +* Group `internal/...` imported packages as standard library - #307 + +## [v0.6.0] - 2024-01-28 + +This release is based on Go 1.21's gofmt, and requires Go 1.20 or later. + +The following changes are included: + +* Support `go` version strings from newer go.mod files - [#280] +* Consider simple error checks even if they use the `=` operator - [#271] +* Ignore `//line` directives to avoid panics - [#288] + +## [v0.5.0] - 2023-04-09 + +This release is based on Go 1.20's gofmt, and requires Go 1.19 or later. + +The biggest change in this release is that we now vendor copies of the packages +`go/format`, `go/printer`, and `go/doc/comment` on top of `cmd/gofmt` itself. +This allows for each gofumpt release to format code in exactly the same way +no matter what Go version is used to build it, as Go versions can change those +three packages in ways that alter formatting behavior. + +This vendoring adds a small amount of duplication when using the +`mvdan.cc/gofumpt/format` library, but it's the only way to make gofumpt +versions consistent in their behavior and formatting, just like gofmt. + +The jump to Go 1.20's `go/printer` should also bring a small performance +improvement, as we contributed patches to make printing about 25% faster: + +* https://go.dev/cl/412555 +* https://go.dev/cl/412557 +* https://go.dev/cl/424924 + +The following changes are included as well: + +* Skip `testdata` dirs by default like we already do for `vendor` - [#260] +* Avoid inserting newlines incorrectly in some func signatures - [#235] +* Avoid joining some comments with the previous line - [#256] +* Fix `gofumpt -version` for release archives - [#253] + +## [v0.4.0] - 2022-09-27 + +This release is based on Go 1.19's gofmt, and requires Go 1.18 or later. +We recommend building gofumpt with Go 1.19 for the best formatting results. + +The jump from Go 1.18 brings diffing in pure Go, removing the need to exec `diff`, +and a small parsing speed-up thanks to `go/parser.SkipObjectResolution`. + +The following formatting fixes are included as well: + +* Allow grouping declarations with comments - [#212] +* Properly measure the length of case clauses - [#217] +* Fix a few crashes found by Go's native fuzzing + +## [v0.3.1] - 2022-03-21 + +This bugfix release resolves a number of issues: + +* Avoid "too many open files" error regression introduced by [v0.3.0] - [#208] +* Use the `go.mod` relative to each Go file when deriving flag defaults - [#211] +* Remove unintentional debug prints when directly formatting files + +## [v0.3.0] - 2022-02-22 + +This is gofumpt's third major release, based on Go 1.18's gofmt. +The jump from Go 1.17's gofmt should bring a noticeable speed-up, +as the tool can now format many files concurrently. +On an 8-core laptop, formatting a large codebase is 4x as fast. + +The following [formatting rules](https://github.com/mvdan/gofumpt#Added-rules) are added: + +* Functions should separate `) {` where the indentation helps readability +* Field lists should not have leading or trailing empty lines + +The following changes are included as well: + +* Generated files are now fully formatted when given as explicit arguments +* Prepare for Go 1.18's module workspaces, which could cause errors +* Import paths sharing a prefix with the current module path are no longer + grouped with standard library imports +* `format.Options` gains a `ModulePath` field per the last bullet point + +## [v0.2.1] - 2021-12-12 + +This bugfix release resolves a number of issues: + +* Add deprecated flags `-s` and `-r` once again, now giving useful errors +* Avoid a panic with certain function declaration styles +* Don't group interface members of different kinds +* Account for leading comments in composite literals + +## [v0.2.0] - 2021-11-10 + +This is gofumpt's second major release, based on Go 1.17's gofmt. +The jump from Go 1.15's gofmt should bring a mild speed-up, +as walking directories with `filepath.WalkDir` uses fewer syscalls. + +gofumports is now removed, after being deprecated in [v0.1.0]. +Its main purpose was IDE integration; it is now recommended to use gopls, +which in turn implements goimports and supports gofumpt natively. +IDEs which don't integrate with gopls (such as GoLand) implement goimports too, +so it is safe to use gofumpt as their "format on save" command. +See the [installation instructions](https://github.com/mvdan/gofumpt#Installation) +for more details. + +The following [formatting rules](https://github.com/mvdan/gofumpt#Added-rules) are added: + +* Composite literals should not have leading or trailing empty lines +* No empty lines following an assignment operator +* Functions using an empty line for readability should use a `) {` line instead +* Remove unnecessary empty lines from interfaces + +Finally, the following changes are made to the gofumpt tool: + +* Initial support for Go 1.18's type parameters is added +* The `-r` flag is removed in favor of `gofmt -r` +* The `-s` flag is removed as it is always enabled +* Vendor directories are skipped unless given as explicit arguments +* The added rules are not applied to generated Go files +* The `format` Go API now also applies the `gofmt -s` simplification +* Add support for `//gofumpt:diagnose` comments + +## [v0.1.1] - 2021-03-11 + +This bugfix release backports fixes for a few issues: + +* Keep leading empty lines in func bodies if they help readability +* Avoid breaking comment alignment on empty field lists +* Add support for `//go-sumtype:` directives + +## [v0.1.0] - 2021-01-05 + +This is gofumpt's first release, based on Go 1.15.x. It solidifies the features +which have worked well for over a year. + +This release will be the last to include `gofumports`, the fork of `goimports` +which applies `gofumpt`'s rules on top of updating the Go import lines. Users +who were relying on `goimports` in their editors or IDEs to apply both `gofumpt` +and `goimports` in a single step should switch to gopls, the official Go +language server. It is supported by many popular editors such as VS Code and +Vim, and already bundles gofumpt support. Instructions are available [in the +README](https://github.com/mvdan/gofumpt). + +`gofumports` also added maintenance work and potential confusion to end users. +In the future, there will only be one way to use `gofumpt` from the command +line. We also have a [Go API](https://pkg.go.dev/mvdan.cc/gofumpt/format) for +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.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 + +[v0.6.0]: https://github.com/mvdan/gofumpt/releases/tag/v0.6.0 +[#271]: https://github.com/mvdan/gofumpt/issues/271 +[#280]: https://github.com/mvdan/gofumpt/issues/280 +[#288]: https://github.com/mvdan/gofumpt/issues/288 + +[v0.5.0]: https://github.com/mvdan/gofumpt/releases/tag/v0.5.0 +[#235]: https://github.com/mvdan/gofumpt/issues/235 +[#253]: https://github.com/mvdan/gofumpt/issues/253 +[#256]: https://github.com/mvdan/gofumpt/issues/256 +[#260]: https://github.com/mvdan/gofumpt/issues/260 + +[v0.4.0]: https://github.com/mvdan/gofumpt/releases/tag/v0.4.0 +[#212]: https://github.com/mvdan/gofumpt/issues/212 +[#217]: https://github.com/mvdan/gofumpt/issues/217 + +[v0.3.1]: https://github.com/mvdan/gofumpt/releases/tag/v0.3.1 +[#208]: https://github.com/mvdan/gofumpt/issues/208 +[#211]: https://github.com/mvdan/gofumpt/pull/211 + +[v0.3.0]: https://github.com/mvdan/gofumpt/releases/tag/v0.3.0 +[v0.2.1]: https://github.com/mvdan/gofumpt/releases/tag/v0.2.1 +[v0.2.0]: https://github.com/mvdan/gofumpt/releases/tag/v0.2.0 +[v0.1.1]: https://github.com/mvdan/gofumpt/releases/tag/v0.1.1 +[v0.1.0]: https://github.com/mvdan/gofumpt/releases/tag/v0.1.0 diff --git a/vendor/mvdan.cc/gofumpt/LICENSE b/vendor/mvdan.cc/gofumpt/LICENSE new file mode 100644 index 000000000..03e3bfc00 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2019, Daniel Martí. 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 the copyright holder 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/mvdan.cc/gofumpt/LICENSE.google b/vendor/mvdan.cc/gofumpt/LICENSE.google new file mode 100644 index 000000000..6a66aea5e --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/LICENSE.google @@ -0,0 +1,27 @@ +Copyright (c) 2009 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/mvdan.cc/gofumpt/README.md b/vendor/mvdan.cc/gofumpt/README.md new file mode 100644 index 000000000..f391ef969 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/README.md @@ -0,0 +1,698 @@ +# gofumpt + +[![Go Reference](https://pkg.go.dev/badge/mvdan.cc/gofumpt/format.svg)](https://pkg.go.dev/mvdan.cc/gofumpt/format) + + go install mvdan.cc/gofumpt@latest + +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. +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: + + 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 +to ensure consistent formatting independent of what Go version is being used. +The [added formatting rules](#Added-rules) are implemented in the `format` package. + +`vendor` and `testdata` directories are skipped unless given as explicit arguments. +Similarly, the added rules do not apply to generated Go files unless they are +given as explicit arguments. + +[`ignore` directives](https://go.dev/ref/mod#go-mod-file-ignore) in `go.mod` files are obeyed as well, +unless directories or files within them are given as explicit arguments. + +Finally, note that the `-r` rewrite flag is removed in favor of `gofmt -r`, +and the `-s` flag is hidden as it is always enabled. + +### Added rules + +**No empty lines following an assignment operator** + +
Example + +```go +func foo() { + foo := + "bar" +} +``` + +```go +func foo() { + foo := "bar" +} +``` + +
+ +**No empty lines around function bodies** + +
Example + +```go +func foo() { + + println("bar") + +} +``` + +```go +func foo() { + println("bar") +} +``` + +
+ +**Functions should separate `) {` where the indentation helps readability** + +
Example + +```go +func foo(s string, + i int) { + println("bar") +} + +// With an empty line it's slightly better, but still not great. +func bar(s string, + i int) { + + println("bar") +} +``` + +```go +func foo(s string, + i int, +) { + println("bar") +} + +// With an empty line it's slightly better, but still not great. +func bar(s string, + i int, +) { + println("bar") +} +``` + +
+ +**No empty lines around a lone statement (or comment) in a block** + +
Example + +```go +if err != nil { + + return err +} +``` + +```go +if err != nil { + return err +} +``` + +
+ +**No empty lines before a simple error check** + +
Example + +```go +foo, err := processFoo() + +if err != nil { + return err +} +``` + +```go +foo, err := processFoo() +if err != nil { + return err +} +``` + +
+ +**Composite literals should use newlines consistently** + +
Example + +```go +// A newline before or after an element requires newlines for the opening and +// closing braces. +var ints = []int{1, 2, + 3, 4} + +// A newline between consecutive elements requires a newline between all +// elements. +var matrix = [][]int{ + {1}, + {2}, { + 3, + }, +} +``` + +```go +var ints = []int{ + 1, 2, + 3, 4, +} + +var matrix = [][]int{ + {1}, + {2}, + { + 3, + }, +} +``` + +
+ +**Empty field lists should use a single line** + +
Example + +```go +var V interface { +} = 3 + +type T struct { +} + +func F( +) +``` + +```go +var V interface{} = 3 + +type T struct{} + +func F() +``` + +
+ +**`std` imports must be in a separate group at the top** + +
Example + +```go +import ( + "foo.com/bar" + + "io" + + "io/ioutil" +) +``` + +```go +import ( + "io" + "io/ioutil" + + "foo.com/bar" +) +``` + +
+ +**Short case clauses should take a single line** + +
Example + +```go +switch c { +case 'a', 'b', + 'c', 'd': +} +``` + +```go +switch c { +case 'a', 'b', 'c', 'd': +} +``` + +
+ +**Multiline top-level declarations must be separated by empty lines** + +
Example + +```go +func foo() { + println("multiline foo") +} +func bar() { + println("multiline bar") +} +``` + +```go +func foo() { + println("multiline foo") +} + +func bar() { + println("multiline bar") +} +``` + +
+ +**Single var declarations should not be grouped with parentheses** + +
Example + +```go +var ( + foo = "bar" +) +``` + +```go +var foo = "bar" +``` + +
+ +**Contiguous top-level declarations should be grouped together** + +
Example + +```go +var nicer = "x" +var with = "y" +var alignment = "z" +``` + +```go +var ( + nicer = "x" + with = "y" + alignment = "z" +) +``` + +
+ +**Simple var-declaration statements should use short assignments** + +
Example + +```go +var s = "somestring" +``` + +```go +s := "somestring" +``` + +
+ +**The `-s` code simplification flag is enabled by default** + +
Example + +```go +var _ = [][]int{[]int{1}} +``` + +```go +var _ = [][]int{{1}} +``` + +
+ +**Octal integer literals should use the `0o` prefix on modules using Go 1.13 and later** + +
Example + +```go +const perm = 0755 +``` + +```go +const perm = 0o755 +``` + +
+ +**Comments which aren't Go directives should start with a whitespace** + +
Example + +```go +//go:noinline + +//Foo is awesome. +func Foo() {} +``` + +```go +//go:noinline + +// Foo is awesome. +func Foo() {} +``` + +
+ +**Composite literals should not have leading or trailing empty lines** + +
Example + +```go +var _ = []string{ + + "foo", + +} + +var _ = map[string]string{ + + "foo": "bar", + +} +``` + +```go +var _ = []string{ + "foo", +} + +var _ = map[string]string{ + "foo": "bar", +} +``` + +
+ +**Field lists should not have leading or trailing empty lines** + +
Example + +```go +type Person interface { + + Name() string + + Age() int + +} + +type ZeroFields struct { + + // No fields are needed here. + +} +``` + +```go +type Person interface { + Name() string + + Age() int +} + +type ZeroFields struct { + // No fields are needed here. +} +``` + +
+ +### Extra rules behind `-extra` + +**Adjacent parameters with the same type should be grouped together** + +
Example + +```go +func Foo(bar string, baz string) {} +``` + +```go +func Foo(bar, baz string) {} +``` + +
+ +**Avoid naked returns for the sake of clarity** + +
Example + +```go +func Foo() (err error) { + return +} +``` + +```go +func Foo() (err error) { + return err +} +``` + +
+ +### Installation + +`gofumpt` is a replacement for `gofmt`, so you can simply `go install` it as +described at the top of this README and use it. + +When using an IDE or editor with Go integration based on `gopls`, +it's best to configure the editor to use the `gofumpt` support built into `gopls`. + +The instructions below show how to set up `gofumpt` for some of the +major editors out there. + +#### Visual Studio Code + +Enable the language server following [the official docs](https://github.com/golang/vscode-go#readme), +and then enable gopls's `gofumpt` option. Note that VS Code will complain about +the `gopls` settings, but they will still work. + +```json +"go.useLanguageServer": true, +"gopls": { + "formatting.gofumpt": true, +}, +``` + +#### GoLand + +GoLand doesn't use `gopls` so it should be configured to use `gofumpt` directly. +Once `gofumpt` is installed, follow the steps below: + +- Open **Settings** (File > Settings) +- Open the **Tools** section +- Find the *File Watchers* sub-section +- Click on the `+` on the right side to add a new file watcher +- Choose *Custom Template* + +When a window asks for settings, you can enter the following: + +* File Types: Select all .go files +* Scope: Project Files +* Program: Select your `gofumpt` executable +* Arguments: `-w $FilePath$` +* Output path to refresh: `$FilePath$` +* Working directory: `$ProjectFileDir$` +* Environment variables: `GOROOT=$GOROOT$;GOPATH=$GOPATH$;PATH=$GoBinDirs$` + +To avoid unnecessary runs, you should disable all checkboxes in the *Advanced* section. + +#### Vim + +The configuration depends on the plugin you are using: [vim-go](https://github.com/fatih/vim-go) +or [govim](https://github.com/govim/govim). + +##### vim-go + +To configure `gopls` to use `gofumpt`: + +```vim +let g:go_fmt_command="gopls" +let g:go_gopls_gofumpt=1 +``` + +##### govim + +To configure `gopls` to use `gofumpt`: + +```vim +call govim#config#Set("Gofumpt", 1) +``` + +#### Neovim + +When using [`lspconfig`](https://github.com/neovim/nvim-lspconfig), pass the `gofumpt` setting to `gopls`: + +```lua +require('lspconfig').gopls.setup({ + settings = { + gopls = { + gofumpt = true + } + } +}) +``` + +#### Emacs + +For [lsp-mode](https://emacs-lsp.github.io/lsp-mode/) users on version 8.0.0 or higher: + +```elisp +(setq lsp-go-use-gofumpt t) +``` + +For users of `lsp-mode` before `8.0.0`: + +```elisp +(lsp-register-custom-settings + '(("gopls.gofumpt" t))) +``` + +For [eglot](https://github.com/joaotavora/eglot) users: + +```elisp +(setq-default eglot-workspace-configuration + '((:gopls . ((gofumpt . t))))) +``` + +#### Helix + +When using the `gopls` language server, modify the Go settings in `~/.config/helix/languages.toml`: + +```toml +[language-server.gopls.config] +"formatting.gofumpt" = true +``` + +#### Sublime Text + +With ST4, install the Sublime Text LSP extension according to [the documentation](https://github.com/sublimelsp/LSP), +and enable `gopls`'s `gofumpt` option in the LSP package settings, +including setting `lsp_format_on_save` to `true`. + +```json +"lsp_format_on_save": true, +"clients": +{ + "gopls": + { + "enabled": true, + "initializationOptions": { + "gofumpt": true, + } + } +} +``` + +### Zed +For `gofumpt` to be used in Zed, you need to set the `gofumpt` option in the LSP settings. This is done by providing the `"gofumpt": true` in `initialization_options`. + +```json +"lsp": { + "gopls": { + "initialization_options": { + "gofumpt": true + } + } +} +``` + +### Roadmap + +This tool is a place to experiment. In the long term, the features that work +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. + +### Frequently Asked Questions + +> Why attempt to replace `gofmt` instead of building on top of it? + +Our design is to build on top of `gofmt`, and we'll never add rules which +disagree with its formatting. So we extend `gofmt` rather than compete with it. + +The tool is a modified copy of `gofmt`, for the purpose of allowing its use as a +drop-in replacement in editors and scripts. + +> Why are my module imports being grouped with standard library imports? + +Any import paths that don't start with a domain name like `foo.com` are +effectively [reserved by the Go toolchain](https://github.com/golang/go/issues/32819). +Third party modules should either start with a domain name, +even a local one like `foo.local`, or use [a reserved path prefix](https://github.com/golang/go/issues/37641). + +For backwards compatibility with modules set up before these rules were clear, +`gofumpt` will treat any import path sharing a prefix with the current module +path as third party. For example, if the current module is `mycorp/mod1`, then +all import paths in `mycorp/...` will be considered third party. + +> How can I use `gofumpt` if I already use `goimports` to replace `gofmt`? + +Most editors have replaced the `goimports` program with the same functionality +provided by a language server like `gopls`. This mechanism is significantly +faster and more powerful, since the language server has more information that is +kept up to date, necessary to add missing imports. + +As such, the general recommendation is to let your editor fix your imports - +either via `gopls`, such as VSCode or vim-go, or via their own custom +implementation, such as GoLand. Then follow the install instructions above to +enable the use of `gofumpt` instead of `gofmt`. + +If you want to avoid integrating with `gopls`, and are OK with the overhead of +calling `goimports` from scratch on each save, you should be able to call both +tools; for example, `goimports file.go && gofumpt file.go`. + +### Contributing + +Issues and pull requests are welcome! Please open an issue to discuss a feature +before sending a pull request. + +We also use the `#gofumpt` channel over at the +[Gophers Slack](https://invite.slack.golangbridge.org/) to chat. + +When reporting a formatting bug, insert a `//gofumpt:diagnose` comment. +The comment will be rewritten to include useful debugging information. +For instance: + +``` +$ cat f.go +package p + +//gofumpt:diagnose +$ gofumpt f.go +package p + +//gofumpt:diagnose v0.1.1-0.20211103104632-bdfa3b02e50a -lang=go1.16 +``` + +### License + +Note that much of the code is copied from Go's `gofmt` command. You can tell +which files originate from the Go repository from their copyright headers. Their +license file is `LICENSE.google`. + +`gofumpt`'s original source files are also under the 3-clause BSD license, with +the separate file `LICENSE`. diff --git a/vendor/mvdan.cc/gofumpt/doc.go b/vendor/mvdan.cc/gofumpt/doc.go new file mode 100644 index 000000000..6c623c8e9 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) 2023, Daniel Martí +// See LICENSE for licensing information + +// gofumpt enforces a stricter format than gofmt, while being backwards compatible. +package main diff --git a/vendor/mvdan.cc/gofumpt/format/format.go b/vendor/mvdan.cc/gofumpt/format/format.go new file mode 100644 index 000000000..879969da9 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/format/format.go @@ -0,0 +1,1136 @@ +// Copyright (c) 2019, Daniel Martí +// See LICENSE for licensing information + +// Package format exposes gofumpt's formatting in an API similar to go/format. +// In general, the APIs are only guaranteed to work well when the input source +// is in canonical gofmt format. +package format + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/token" + goversion "go/version" + "os" + "reflect" + "regexp" + "slices" + "sort" + "strconv" + "strings" + "unicode" + "unicode/utf8" + + "github.com/google/go-cmp/cmp" + "golang.org/x/tools/go/ast/astutil" + + "mvdan.cc/gofumpt/internal/govendor/go/format" + "mvdan.cc/gofumpt/internal/version" +) + +// Options is the set of formatting options which affect gofumpt. +type Options struct { + // LangVersion is the Go version a piece of code is written in. + // The version is used to decide whether to apply formatting + // rules which require new language features. + // When empty, a default of go1 is assumed. + // Otherwise, the version must satisfy [go/version.IsValid]. + // + // When formatting a Go module, LangVersion should typically be + // + // go list -m -f {{.GoVersion}} + // + // with a "go" prefix, or the equivalent from `go mod edit -json`. + LangVersion string + + // ModulePath corresponds to the Go module path which contains the source + // code being formatted. When formatting a Go module, ModulePath should be + // + // go list -m -f {{.Path}} + // + // or the equivalent from `go mod edit -json`. + // + // ModulePath is used for formatting decisions like what import paths are + // considered to be not part of the standard library. When empty, the source + // is formatted as if it weren't inside a module. + ModulePath string + + // ExtraRules enables extra formatting rules, such as grouping function + // parameters with repeated types together. + ExtraRules bool +} + +// Source formats src in gofumpt's format, assuming that src holds a valid Go +// source file. +func Source(src []byte, opts Options) ([]byte, error) { + fset := token.NewFileSet() + + // Ensure our parsed files never start with base 1, + // to ensure that using token.NoPos+1 will panic. + fset.AddFile("gofumpt_base.go", 1, 10) + + file, err := parser.ParseFile(fset, "", src, parser.SkipObjectResolution|parser.ParseComments) + if err != nil { + return nil, err + } + + File(fset, file, opts) + + var buf bytes.Buffer + if err := format.Node(&buf, fset, file); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// File modifies a file and fset in place to follow gofumpt's format. The +// changes might include manipulating adding or removing newlines in fset, +// modifying the position of nodes, or modifying literal values. +func File(fset *token.FileSet, file *ast.File, opts Options) { + simplify(file) + + if opts.LangVersion == "" { + opts.LangVersion = "go1" + } else { + lang := goversion.Lang(opts.LangVersion) + if lang == "" { + panic(fmt.Sprintf("invalid Go version: %q", opts.LangVersion)) + } + opts.LangVersion = lang + } + f := &fumpter{ + file: fset.File(file.Pos()), + fset: fset, + astFile: file, + Options: opts, + + minSplitFactor: 0.4, + } + var topFuncType *ast.FuncType + pre := func(c *astutil.Cursor) bool { + f.applyPre(c) + switch node := c.Node().(type) { + case *ast.FuncDecl: + topFuncType = node.Type + f.parentFuncTypes = append(f.parentFuncTypes, node.Type) + case *ast.FuncLit: + f.parentFuncTypes = append(f.parentFuncTypes, node.Type) + case *ast.FieldList: + ft, _ := c.Parent().(*ast.FuncType) + if ft == nil || ft != topFuncType { + break + } + + // For top-level function declaration parameters, + // require the line split to be longer. + // This avoids func lines which are a bit too short, + // and allows func lines which are a bit longer. + // + // We don't just increase longLineLimit, + // as we still want splits at around the same place. + if ft.Params == node { + f.minSplitFactor = 0.6 + } + + // Don't split result parameters into multiple lines, + // as that can be easily confused for input parameters. + // TODO: consider the same for single-line func calls in + // if statements. + // TODO: perhaps just use a higher factor, like 0.8. + if ft.Results == node { + f.minSplitFactor = 1000 + } + case *ast.BlockStmt: + f.blockLevel++ + } + return true + } + post := func(c *astutil.Cursor) bool { + f.applyPost(c) + + // Reset minSplitFactor and blockLevel. + switch node := c.Node().(type) { + case *ast.FuncDecl, *ast.FuncLit: + f.parentFuncTypes = f.parentFuncTypes[:len(f.parentFuncTypes)-1] + case *ast.FuncType: + if node == topFuncType { + f.minSplitFactor = 0.4 + } + case *ast.BlockStmt: + f.blockLevel-- + } + return true + } + astutil.Apply(file, pre, post) +} + +// Multiline nodes which could easily fit on a single line under this many bytes +// may be collapsed onto a single line. +const shortLineLimit = 60 + +// Single-line nodes which take over this many bytes, and could easily be split +// into two lines of at least its minSplitFactor factor, may be split. +const longLineLimit = 100 + +var rxOctalInteger = regexp.MustCompile(`\A0[0-7_]+\z`) + +type fumpter struct { + Options + + file *token.File + fset *token.FileSet + + astFile *ast.File + + // blockLevel is the number of indentation blocks we're currently under. + // It is used to approximate the levels of indentation a line will end + // up with. + blockLevel int + + minSplitFactor float64 + + // parentFuncTypes is a stack of parent function types, + // used to determine return type information when clothing naked returns. + parentFuncTypes []*ast.FuncType +} + +func (f *fumpter) commentsBetween(p1, p2 token.Pos) []*ast.CommentGroup { + comments := f.astFile.Comments + i1 := sort.Search(len(comments), func(i int) bool { + return comments[i].Pos() >= p1 + }) + comments = comments[i1:] + i2 := sort.Search(len(comments), func(i int) bool { + return comments[i].Pos() >= p2 + }) + comments = comments[:i2] + return comments +} + +func (f *fumpter) inlineComment(pos token.Pos) *ast.Comment { + comments := f.astFile.Comments + i := sort.Search(len(comments), func(i int) bool { + return comments[i].Pos() >= pos + }) + if i >= len(comments) { + return nil + } + line := f.Line(pos) + for _, comment := range comments[i].List { + if f.Line(comment.Pos()) == line { + return comment + } + } + return nil +} + +// addNewline is a hack to let us force a newline at a certain position. +func (f *fumpter) addNewline(at token.Pos) { + offset := f.Offset(at) + + lines := f.file.Lines() + i, exists := slices.BinarySearch(lines, offset) + if exists { + // This newline already exists; do nothing. Duplicate + // newlines can't exist. + return + } + lines = slices.Insert(lines, i, offset) + if !f.file.SetLines(lines) { + panic(fmt.Sprintf("could not set lines to %v", lines)) + } +} + +// removeLines removes all newlines between two positions, so that they end +// up on the same line. +func (f *fumpter) removeLines(fromLine, toLine int) { + for fromLine < toLine { + f.file.MergeLine(fromLine) + toLine-- + } +} + +// removeLinesBetween is like removeLines, but it leaves one newline between the +// two positions. +func (f *fumpter) removeLinesBetween(from, to token.Pos) { + f.removeLines(f.Line(from)+1, f.Line(to)) +} + +func (f *fumpter) Position(p token.Pos) token.Position { + return f.file.PositionFor(p, false) +} + +func (f *fumpter) Line(p token.Pos) int { + return f.Position(p).Line +} + +func (f *fumpter) Offset(p token.Pos) int { + return f.file.Offset(p) +} + +type byteCounter int + +func (b *byteCounter) Write(p []byte) (n int, err error) { + *b += byteCounter(len(p)) + return len(p), nil +} + +func (f *fumpter) printLength(node ast.Node) int { + var count byteCounter + if err := format.Node(&count, f.fset, node); err != nil { + panic(fmt.Sprintf("unexpected print error: %v", err)) + } + + // Add the space taken by an inline comment. + if c := f.inlineComment(node.End()); c != nil { + fmt.Fprintf(&count, " %s", c.Text) + } + + // Add an approximation of the indentation level. We can't know the + // number of tabs go/printer will add ahead of time. Trying to print the + // entire top-level declaration would tell us that, but then it's near + // impossible to reliably find our node again. + return int(count) + (f.blockLevel * 8) +} + +func (f *fumpter) lineEnd(line int) token.Pos { + if line < 1 { + panic("illegal line number") + } + total := f.file.LineCount() + if line > total { + panic("illegal line number") + } + if line == total { + return f.astFile.End() + } + return f.file.LineStart(line+1) - 1 +} + +// rxCommentDirective covers all common Go comment directives, such as: +// +// //go: | standard Go directives, like go:noinline +// //some-words: | similar to the syntax above, like lint:ignore or go-sumtype:decl +// //export | to mark cgo funcs for exporting +// //extern | C function declarations for gccgo +// //line | inserted line information for cmd/compile +// //noinspection | noinspection directive for GoLand and friends +// //nolint | nolint directive for golangci +// //#nosec | #nosec directive for gosec +// //NOSONAR | NOSONAR directive for SonarQube +// //sys(nb)? | syscall function wrapper prototypes +var rxCommentDirective = regexp.MustCompile( + `^(?:` + + // Patterns directly from https://go.dev/doc/comment#syntax. + // Note that we adjust the first pattern to allow for //go-sumtype:decl, + // which is a tool that existed before the Go convention was documented. + `[a-z0-9-]+:[a-z0-9]` + + `|export ` + + `|extern ` + + `|line ` + + // Third-party patterns; we generally assume they end with a word boundary. + `|no(?:inspection|lint)\b` + + `|#nosec\b` + + `|NOSONAR\b` + + `|sys(?:nb)?\b` + + `)`) + +func (f *fumpter) applyPre(c *astutil.Cursor) { + f.splitLongLine(c) + + switch node := c.Node().(type) { + case *ast.File: + // Join contiguous lone var/const/import lines. + // Abort if there are empty lines in between, + // including a leading comment if it's a directive. + newDecls := make([]ast.Decl, 0, len(node.Decls)) + for i := 0; i < len(node.Decls); { + newDecls = append(newDecls, node.Decls[i]) + start, ok := node.Decls[i].(*ast.GenDecl) + if !ok || isCgoImport(start) || containsAnyDirective(start.Doc) { + i++ + continue + } + lastPos := start.Pos() + contLoop: + for i++; i < len(node.Decls); { + cont, ok := node.Decls[i].(*ast.GenDecl) + if !ok || cont.Tok != start.Tok || cont.Lparen != token.NoPos || isCgoImport(cont) { + break + } + // Are there things between these two declarations? e.g. empty lines, comments, directives + // If so, break the chain on empty lines and directives, continue below for comments. + if f.Line(lastPos) < f.Line(cont.Pos())-1 { + // break on empty line + if cont.Doc == nil { + break + } + // break on directive + for i, comment := range cont.Doc.List { + if f.Line(comment.Slash) != f.Line(lastPos)+1+i || rxCommentDirective.MatchString(strings.TrimPrefix(comment.Text, "//")) { + break contLoop + } + } + // continue below for comments + } + + start.Specs = append(start.Specs, cont.Specs...) + 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() + } + lastPos = cont.Pos() + i++ + } + } + node.Decls = newDecls + + // Multiline top-level declarations should be separated by an + // empty line. + // Do this after the joining of lone declarations above, + // as joining single-line declarations makes then multi-line. + var lastMulti bool + 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() + } + + // 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) + } + + lastMulti = multi + lastEnd = decl.End() + } + + // Comments aren't nodes, so they're not walked by default. + groupLoop: + for _, group := range node.Comments { + for _, comment := range group.List { + if comment.Text == "//gofumpt:diagnose" || strings.HasPrefix(comment.Text, "//gofumpt:diagnose ") { + slc := []string{ + "//gofumpt:diagnose", + "version:", + version.String(""), + "flags:", + "-lang=" + f.LangVersion, + "-modpath=" + f.ModulePath, + } + if f.ExtraRules { + slc = append(slc, "-extra") + } + comment.Text = strings.Join(slc, " ") + } + body := strings.TrimPrefix(comment.Text, "//") + if body == comment.Text { + // /*-style comment + continue groupLoop + } + if rxCommentDirective.MatchString(body) { + // this line is a directive + continue groupLoop + } + r, _ := utf8.DecodeRuneInString(body) + if !unicode.IsLetter(r) && !unicode.IsNumber(r) && !unicode.IsSpace(r) { + // this line could be code like "//{" + 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 { + body := strings.TrimPrefix(comment.Text, "//") + r, _ := utf8.DecodeRuneInString(body) + if !unicode.IsSpace(r) { + comment.Text = "// " + body + } + } + } + + case *ast.DeclStmt: + decl, ok := node.Decl.(*ast.GenDecl) + if !ok || decl.Tok != token.VAR || len(decl.Specs) != 1 { + break // e.g. const name = "value" + } + spec := decl.Specs[0].(*ast.ValueSpec) + if spec.Type != nil { + break // e.g. var name Type + } + tok := token.ASSIGN + names := make([]ast.Expr, len(spec.Names)) + for i, name := range spec.Names { + names[i] = name + if name.Name != "_" { + tok = token.DEFINE + } + } + c.Replace(&ast.AssignStmt{ + Lhs: names, + Tok: tok, + Rhs: spec.Values, + }) + + case *ast.GenDecl: + if node.Tok == token.IMPORT && node.Lparen.IsValid() { + f.joinStdImports(node) + } + + // 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 + } + + case *ast.InterfaceType: + if len(node.Methods.List) > 0 { + method := node.Methods.List[0] + removeToPos := method.Pos() + if comments := f.commentsBetween(node.Interface, method.Pos()); len(comments) > 0 { + // only remove leading line upto the first comment + removeToPos = comments[0].Pos() + } + // remove leading lines if they exist + f.removeLines(f.Line(node.Interface)+1, f.Line(removeToPos)) + } + + case *ast.BlockStmt: + f.stmts(node.List) + comments := f.commentsBetween(node.Lbrace, node.Rbrace) + if len(node.List) == 0 && len(comments) == 0 { + f.removeLinesBetween(node.Lbrace, node.Rbrace) + break + } + + var sign *ast.FuncType + var cond ast.Expr + switch parent := c.Parent().(type) { + case *ast.FuncDecl: + sign = parent.Type + case *ast.FuncLit: + sign = parent.Type + case *ast.IfStmt: + cond = parent.Cond + case *ast.ForStmt: + cond = parent.Cond + } + + if len(node.List) > 1 && sign == nil { + // only if we have a single statement, or if + // it's a func body. + break + } + var bodyPos, bodyEnd token.Pos + + if len(node.List) > 0 { + bodyPos = node.List[0].Pos() + bodyEnd = node.List[len(node.List)-1].End() + } + if len(comments) > 0 { + if pos := comments[0].Pos(); !bodyPos.IsValid() || pos < bodyPos { + bodyPos = pos + } + if pos := comments[len(comments)-1].End(); !bodyPos.IsValid() || pos > bodyEnd { + bodyEnd = pos + } + } + + f.removeLinesBetween(bodyEnd, node.Rbrace) + + if cond != nil && f.Line(cond.Pos()) != f.Line(cond.End()) { + // The body is preceded by a multi-line condition, so an + // empty line can help readability. + return + } + if sign != nil { + endLine := f.Line(sign.End()) + + if f.Line(sign.Pos()) != endLine { + handleMultiLine := func(fl *ast.FieldList) { + // Refuse to insert a newline before the closing token + // if the list is empty or all in one line. + if fl == nil || len(fl.List) == 0 { + return + } + fieldOpeningLine := f.Line(fl.Opening) + fieldClosingLine := f.Line(fl.Closing) + if fieldOpeningLine == fieldClosingLine { + return + } + + lastFieldEnd := fl.List[len(fl.List)-1].End() + lastFieldLine := f.Line(lastFieldEnd) + isLastFieldOnFieldClosingLine := lastFieldLine == fieldClosingLine + isLastFieldOnSigClosingLine := lastFieldLine == endLine + + var isLastCommentGrpOnFieldClosingLine, isLastCommentGrpOnSigClosingLine bool + if comments := f.commentsBetween(lastFieldEnd, fl.Closing); len(comments) > 0 { + lastCommentGrp := comments[len(comments)-1] + lastCommentGrpLine := f.Line(lastCommentGrp.End()) + + isLastCommentGrpOnFieldClosingLine = lastCommentGrpLine == fieldClosingLine + isLastCommentGrpOnSigClosingLine = lastCommentGrpLine == endLine + } + + // is there a comment grp/last field, field closing and sig closing on the same line? + if (isLastFieldOnFieldClosingLine && isLastFieldOnSigClosingLine) || + (isLastCommentGrpOnFieldClosingLine && isLastCommentGrpOnSigClosingLine) { + fl.Closing += 1 + f.addNewline(fl.Closing) + } + } + handleMultiLine(sign.Params) + if sign.Results != nil && len(sign.Results.List) > 0 { + lastResultLine := f.Line(sign.Results.List[len(sign.Results.List)-1].End()) + isLastResultOnParamClosingLine := sign.Params != nil && lastResultLine == f.Line(sign.Params.Closing) + if !isLastResultOnParamClosingLine { + handleMultiLine(sign.Results) + } + } + } + } + + f.removeLinesBetween(node.Lbrace, bodyPos) + + case *ast.CaseClause: + f.stmts(node.Body) + openLine := f.Line(node.Case) + closeLine := f.Line(node.Colon) + if openLine == closeLine { + // nothing to do + break + } + if len(f.commentsBetween(node.Case, node.Colon)) > 0 { + // don't move comments + break + } + // check the length excluding the body + nodeWithoutBody := &ast.CaseClause{ + Case: node.Case, + List: node.List, + Colon: node.Colon, + } + if f.printLength(nodeWithoutBody) > shortLineLimit { + // too long to collapse + break + } + f.removeLines(openLine, closeLine) + + case *ast.CommClause: + f.stmts(node.Body) + + case *ast.FieldList: + numFields := node.NumFields() + comments := f.commentsBetween(node.Pos(), node.End()) + + if numFields == 0 && len(comments) == 0 { + // Empty field lists should not contain a newline. + // Do not join the two lines if the first has an inline + // comment, as that can result in broken formatting. + openLine := f.Line(node.Pos()) + closeLine := f.Line(node.End()) + f.removeLines(openLine, closeLine) + } else { + // Remove lines before first comment/field and lines after last + // comment/field + var bodyPos, bodyEnd token.Pos + if numFields > 0 { + bodyPos = node.List[0].Pos() + bodyEnd = node.List[len(node.List)-1].End() + } + if len(comments) > 0 { + if pos := comments[0].Pos(); !bodyPos.IsValid() || pos < bodyPos { + bodyPos = pos + } + if pos := comments[len(comments)-1].End(); !bodyPos.IsValid() || pos > bodyEnd { + bodyEnd = pos + } + } + f.removeLinesBetween(node.Pos(), bodyPos) + f.removeLinesBetween(bodyEnd, node.End()) + } + + // Merging adjacent fields (e.g. parameters) is disabled by default. + if !f.ExtraRules { + break + } + switch c.Parent().(type) { + case *ast.FuncDecl, *ast.FuncType, *ast.InterfaceType: + node.List = f.mergeAdjacentFields(node.List) + c.Replace(node) + case *ast.StructType: + // Do not merge adjacent fields in structs. + } + + case *ast.BasicLit: + // Octal number literals were introduced in Go 1.13. + if goversion.Compare(f.LangVersion, "go1.13") >= 0 { + if node.Kind == token.INT && rxOctalInteger.MatchString(node.Value) { + node.Value = "0o" + node.Value[1:] + c.Replace(node) + } + } + + 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())) + + case *ast.ReturnStmt: + if len(node.Results) > 0 { + break + } + // Clothing naked returns is disabled by default. + if !f.ExtraRules { + break + } + results := f.parentFuncTypes[len(f.parentFuncTypes)-1].Results + if results.NumFields() == 0 { + break + } + + // The function has return values; let's clothe the return. + node.Results = make([]ast.Expr, 0, results.NumFields()) + nameLoop: + for _, result := range results.List { + for _, ident := range result.Names { + name := ident.Name + if name == "_" { // we can't handle blank names just yet + node.Results = nil + break nameLoop + } + node.Results = append(node.Results, &ast.Ident{ + // Use the Pos of the return statement, to not interfere with comment placement. + NamePos: node.Pos(), + Name: name, + }) + } + } + if len(node.Results) > 0 { + c.Replace(node) + } + } +} + +func (f *fumpter) applyPost(c *astutil.Cursor) { + switch node := c.Node().(type) { + // Adding newlines to composite literals happens as a "post" step, so + // that we can take into account whether "pre" steps added any newlines + // that would affect us here. + case *ast.CompositeLit: + if len(node.Elts) == 0 { + // doesn't have elements + break + } + openLine := f.Line(node.Lbrace) + closeLine := f.Line(node.Rbrace) + if openLine == closeLine { + // all in a single line + break + } + + newlineAroundElems := false + newlineBetweenElems := false + lastEnd := node.Lbrace + lastLine := openLine + for i, elem := range node.Elts { + pos := elem.Pos() + comments := f.commentsBetween(lastEnd, pos) + if len(comments) > 0 { + pos = comments[0].Pos() + } + if curLine := f.Line(pos); curLine > lastLine { + if i == 0 { + newlineAroundElems = true + + // remove leading lines if they exist + f.removeLines(openLine+1, curLine) + } else { + newlineBetweenElems = true + } + } + lastEnd = elem.End() + lastLine = f.Line(lastEnd) + } + if closeLine > lastLine { + newlineAroundElems = true + } + + if newlineBetweenElems || newlineAroundElems { + first := node.Elts[0] + if openLine == f.Line(first.Pos()) { + // We want the newline right after the brace. + f.addNewline(node.Lbrace + 1) + closeLine = f.Line(node.Rbrace) + } + last := node.Elts[len(node.Elts)-1] + if closeLine == f.Line(last.End()) { + // We want the newline right before the brace. + f.addNewline(node.Rbrace) + } + } + + // If there's a newline between any consecutive elements, there + // must be a newline between all composite literal elements. + if !newlineBetweenElems { + break + } + for i1, elem1 := range node.Elts { + i2 := i1 + 1 + if i2 >= len(node.Elts) { + break + } + elem2 := node.Elts[i2] + // TODO: do we care about &{}? + _, ok1 := elem1.(*ast.CompositeLit) + _, ok2 := elem2.(*ast.CompositeLit) + if !ok1 && !ok2 { + continue + } + if f.Line(elem1.End()) == f.Line(elem2.Pos()) { + f.addNewline(elem1.End()) + } + } + } +} + +func (f *fumpter) splitLongLine(c *astutil.Cursor) { + if os.Getenv("GOFUMPT_SPLIT_LONG_LINES") != "on" { + // By default, this feature is turned off. + // Turn it on by setting GOFUMPT_SPLIT_LONG_LINES=on. + return + } + node := c.Node() + if node == nil { + return + } + + newlinePos := node.Pos() + start := f.Position(node.Pos()) + end := f.Position(node.End()) + + // If the node is already split in multiple lines, there's nothing to do. + if start.Line != end.Line { + return + } + + // Only split at the start of the current node if it's part of a list. + if _, ok := c.Parent().(*ast.BinaryExpr); ok { + // Chains of binary expressions are considered lists, too. + } else if c.Index() >= 0 { + // For the rest of the nodes, we're in a list if c.Index() >= 0. + } else { + return + } + + // Like in printLength, add an approximation of the indentation level. + // Since any existing tabs were already counted as one column, multiply + // the level by 7. + startCol := start.Column + f.blockLevel*7 + endCol := end.Column + f.blockLevel*7 + + // If this is a composite literal, + // and we were going to insert a newline before the entire literal, + // insert the newline before the first element instead. + // Since we'll add a newline after the last element too, + // this format is generally going to be nicer. + if comp := isComposite(node); comp != nil && len(comp.Elts) > 0 { + newlinePos = comp.Elts[0].Pos() + } + + // If this is a function call, + // and we were to add a newline before the first argument, + // prefer adding the newline before the entire call. + // End-of-line parentheses aren't very nice, as we don't put their + // counterparts at the start of a line too. + // We do this by using the average of the two starting positions. + if call, _ := node.(*ast.CallExpr); call != nil && len(call.Args) > 0 { + first := f.Position(call.Args[0].Pos()) + startCol += (first.Column - start.Column) / 2 + } + + // If the start position is too short, we definitely won't split the line. + if startCol <= shortLineLimit { + return + } + + lineEnd := f.Position(f.lineEnd(start.Line)) + + // firstLength and secondLength are the split line lengths, excluding + // indentation. + firstLength := start.Column - f.blockLevel + if firstLength < 0 { + panic("negative length") + } + secondLength := lineEnd.Column - start.Column + if secondLength < 0 { + panic("negative length") + } + + // If the line ends past the long line limit, + // and both splits are estimated to take at least minSplitFactor of the limit, + // then split the line. + minSplitLength := int(f.minSplitFactor * longLineLimit) + if endCol > longLineLimit && + firstLength >= minSplitLength && secondLength >= minSplitLength { + f.addNewline(newlinePos) + } +} + +func isComposite(node ast.Node) *ast.CompositeLit { + switch node := node.(type) { + case *ast.CompositeLit: + return node + case *ast.UnaryExpr: + return isComposite(node.X) // e.g. &T{} + default: + return nil + } +} + +func (f *fumpter) stmts(list []ast.Stmt) { + for i, stmt := range list { + ifs, ok := stmt.(*ast.IfStmt) + if !ok || i < 1 { + continue // not an if following another statement + } + as, ok := list[i-1].(*ast.AssignStmt) + if !ok || (as.Tok != token.DEFINE && as.Tok != token.ASSIGN) || + !identEqual(as.Lhs[len(as.Lhs)-1], "err") { + continue // not ", err :=" nor ", err =" + } + be, ok := ifs.Cond.(*ast.BinaryExpr) + if !ok || ifs.Init != nil || ifs.Else != nil { + continue // complex if + } + if be.Op != token.NEQ || !identEqual(be.X, "err") || + !identEqual(be.Y, "nil") { + continue // not "err != nil" + } + f.removeLinesBetween(as.End(), ifs.Pos()) + } +} + +func identEqual(expr ast.Expr, name string) bool { + id, ok := expr.(*ast.Ident) + return ok && id.Name == name +} + +// isCgoImport returns true if the declaration is simply: +// +// import "C" +// +// or the equivalent: +// +// import `C` +// +// Note that parentheses do not affect the result. +func isCgoImport(decl *ast.GenDecl) bool { + if decl.Tok != token.IMPORT || len(decl.Specs) != 1 { + return false + } + spec := decl.Specs[0].(*ast.ImportSpec) + v, err := strconv.Unquote(spec.Path.Value) + if err != nil { + panic(err) // should never error + } + return v == "C" +} + +// joinStdImports ensures that all standard library imports are together and at +// the top of the imports list. +func (f *fumpter) joinStdImports(d *ast.GenDecl) { + var std, other []ast.Spec + firstGroup := true + lastEnd := d.Pos() + needsSort := false + + // If ModulePath is "foo/bar", we assume "foo/..." is not part of std. + // Users shouldn't declare modules that may collide with std this way, + // but historically some private codebases have done so. + // This is a relatively harmless way to make gofumpt compatible with them, + // as it changes nothing for the common external module paths. + var modulePrefix string + if f.ModulePath == "" { + // Nothing to do. + } else if i := strings.IndexByte(f.ModulePath, '/'); i != -1 { + // ModulePath is "foo/bar", so we use "foo" as the prefix. + modulePrefix = f.ModulePath[:i] + } else { + // ModulePath is "foo", so we use "foo" as the prefix. + modulePrefix = f.ModulePath + } + + for i, spec := range d.Specs { + spec := spec.(*ast.ImportSpec) + if coms := f.commentsBetween(lastEnd, spec.Pos()); len(coms) > 0 { + lastEnd = coms[len(coms)-1].End() + } + if i > 0 && firstGroup && f.Line(spec.Pos()) > f.Line(lastEnd)+1 { + firstGroup = false + } else { + // We're still in the first group, update lastEnd. + lastEnd = spec.End() + } + + path, err := strconv.Unquote(spec.Path.Value) + if err != nil { + panic(err) // should never error + } + periodIndex := strings.IndexByte(path, '.') + slashIndex := strings.IndexByte(path, '/') + switch { + // Imports with a period in the first path element are third party. + // Note that this includes "foo.com" and excludes "foo/bar.com/baz". + case periodIndex > 0 && (slashIndex == -1 || periodIndex < slashIndex), + + // "test" and "example" are reserved as per golang.org/issue/37641. + strings.HasPrefix(path, "test/"), + strings.HasPrefix(path, "example/"), + + // See if we match modulePrefix; see its documentation above. + // We match either exactly or with a slash suffix, + // so that the prefix "foo" for "foo/..." does not match "foobar". + path == modulePrefix || strings.HasPrefix(path, modulePrefix+"/"), + + // To be conservative, if an import has a name or an inline + // comment, and isn't part of the top group, treat it as non-std. + !firstGroup && (spec.Name != nil || spec.Comment != nil): + other = append(other, spec) + continue + } + + // If we're moving this std import further up, reset its + // position, to avoid breaking comments. + if !firstGroup || len(other) > 0 { + setPos(reflect.ValueOf(spec), d.Pos()) + needsSort = true + } + std = append(std, spec) + } + // Ensure there is an empty line between std imports and other imports. + if len(std) > 0 && len(other) > 0 && f.Line(std[len(std)-1].End())+1 >= f.Line(other[0].Pos()) { + // We add two newlines, as that's necessary in some edge cases. + // For example, if the std and non-std imports were together and + // without indentation, adding one newline isn't enough. Two + // empty lines will be printed as one by go/printer, anyway. + f.addNewline(other[0].Pos() - 1) + f.addNewline(other[0].Pos()) + } + // Finally, join the imports, keeping std at the top. + d.Specs = append(std, other...) + + // If we moved any std imports to the first group, we need to sort them + // again. + if needsSort { + ast.SortImports(f.fset, f.astFile) + } +} + +// mergeAdjacentFields returns fields with adjacent fields merged if possible. +func (f *fumpter) mergeAdjacentFields(fields []*ast.Field) []*ast.Field { + // If there are less than two fields then there is nothing to merge. + if len(fields) < 2 { + return fields + } + + // Otherwise, iterate over adjacent pairs of fields, merging if possible, + // and mutating fields. Elements of fields may be mutated (if merged with + // following fields), discarded (if merged with a preceding field), or left + // unchanged. + i := 0 + for j := 1; j < len(fields); j++ { + if f.shouldMergeAdjacentFields(fields[i], fields[j]) { + fields[i].Names = append(fields[i].Names, fields[j].Names...) + } else { + i++ + fields[i] = fields[j] + } + } + return fields[:i+1] +} + +func (f *fumpter) shouldMergeAdjacentFields(f1, f2 *ast.Field) bool { + if len(f1.Names) == 0 || len(f2.Names) == 0 { + // Both must have names for the merge to work. + return false + } + if f.Line(f1.Pos()) != f.Line(f2.Pos()) { + // Trust the user if they used separate lines. + return false + } + + // 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. + // + // 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) +} + +var posType = reflect.TypeOf(token.NoPos) + +// setPos recursively sets all position fields in the node v to pos. +func setPos(v reflect.Value, pos token.Pos) { + if v.Kind() == reflect.Pointer { + v = v.Elem() + } + if !v.IsValid() { + return + } + if v.Type() == posType { + v.Set(reflect.ValueOf(pos)) + } + if v.Kind() == reflect.Struct { + for i := range v.NumField() { + setPos(v.Field(i), pos) + } + } +} + +func containsAnyDirective(group *ast.CommentGroup) bool { + if group == nil { + return false + } + for _, comment := range group.List { + body := strings.TrimPrefix(comment.Text, "//") + if rxCommentDirective.MatchString(body) { + return true + } + } + return false +} diff --git a/vendor/mvdan.cc/gofumpt/format/rewrite.go b/vendor/mvdan.cc/gofumpt/format/rewrite.go new file mode 100644 index 000000000..ec7a2e5db --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/format/rewrite.go @@ -0,0 +1,113 @@ +// Copyright 2009 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 format + +import ( + "go/ast" + "go/token" + "reflect" + "unicode" + "unicode/utf8" +) + +// 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)) +) + +func isWildcard(s string) bool { + rune, size := utf8.DecodeRuneInString(s) + return size == len(s) && unicode.IsLower(rune) +} + +// match reports whether pattern matches val, +// recording wildcard submatches in m. +// If m == nil, match checks whether pattern == val. +func match(m map[string]reflect.Value, pattern, val reflect.Value) bool { + // Wildcard matches any expression. If it appears multiple + // times in the pattern, it must match the same expression + // each time. + if m != nil && pattern.IsValid() && pattern.Type() == identType { + name := pattern.Interface().(*ast.Ident).Name + if isWildcard(name) && val.IsValid() { + // wildcards only match valid (non-nil) expressions. + if _, ok := val.Interface().(ast.Expr); ok && !val.IsNil() { + if old, ok := m[name]; ok { + return match(nil, old, val) + } + m[name] = val + return true + } + } + } + + // Otherwise, pattern and val must match recursively. + if !pattern.IsValid() || !val.IsValid() { + return !pattern.IsValid() && !val.IsValid() + } + if pattern.Type() != val.Type() { + return false + } + + // Special cases. + switch pattern.Type() { + case identType: + // For identifiers, only the names need to match + // (and none of the other *ast.Object information). + // This is a common case, handle it all here instead + // of recursing down any further via reflection. + p := pattern.Interface().(*ast.Ident) + v := val.Interface().(*ast.Ident) + return p == nil && v == nil || p != nil && v != nil && p.Name == v.Name + case objectPtrType, positionType: + // object pointers and token positions always match + return true + case callExprType: + // For calls, the Ellipsis fields (token.Position) must + // match since that is how f(x) and f(x...) are different. + // Check them here but fall through for the remaining fields. + p := pattern.Interface().(*ast.CallExpr) + v := val.Interface().(*ast.CallExpr) + if p.Ellipsis.IsValid() != v.Ellipsis.IsValid() { + return false + } + } + + p := reflect.Indirect(pattern) + v := reflect.Indirect(val) + if !p.IsValid() || !v.IsValid() { + return !p.IsValid() && !v.IsValid() + } + + switch p.Kind() { + case reflect.Slice: + if p.Len() != v.Len() { + return false + } + for i := range p.Len() { + if !match(m, p.Index(i), v.Index(i)) { + return false + } + } + return true + + case reflect.Struct: + for i := range p.NumField() { + if !match(m, p.Field(i), v.Field(i)) { + return false + } + } + return true + + case reflect.Interface: + return match(m, p.Elem(), v.Elem()) + } + + // Handle token integers, etc. + return p.Interface() == v.Interface() +} diff --git a/vendor/mvdan.cc/gofumpt/format/simplify.go b/vendor/mvdan.cc/gofumpt/format/simplify.go new file mode 100644 index 000000000..117646464 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/format/simplify.go @@ -0,0 +1,169 @@ +// Copyright 2010 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 format + +import ( + "go/ast" + "go/token" + "reflect" +) + +type simplifier struct{} + +func (s simplifier) Visit(node ast.Node) ast.Visitor { + switch n := node.(type) { + case *ast.CompositeLit: + // array, slice, and map composite literals may be simplified + outer := n + var keyType, eltType ast.Expr + switch typ := outer.Type.(type) { + case *ast.ArrayType: + eltType = typ.Elt + case *ast.MapType: + keyType = typ.Key + eltType = typ.Value + } + + if eltType != nil { + var ktyp reflect.Value + if keyType != nil { + ktyp = reflect.ValueOf(keyType) + } + typ := reflect.ValueOf(eltType) + for i, x := range outer.Elts { + px := &outer.Elts[i] + // look at value of indexed/named elements + if t, ok := x.(*ast.KeyValueExpr); ok { + if keyType != nil { + s.simplifyLiteral(ktyp, keyType, t.Key, &t.Key) + } + x = t.Value + px = &t.Value + } + s.simplifyLiteral(typ, eltType, x, px) + } + // node was simplified - stop walk (there are no subnodes to simplify) + return nil + } + + case *ast.SliceExpr: + // a slice expression of the form: s[a:len(s)] + // can be simplified to: s[a:] + // if s is "simple enough" (for now we only accept identifiers) + // + // Note: This may not be correct because len may have been redeclared in + // the same package. However, this is extremely unlikely and so far + // (April 2022, after years of supporting this rewrite feature) + // has never come up, so let's keep it working as is (see also #15153). + // + // Also note that this code used to use go/ast's object tracking, + // which was removed in exchange for go/parser.Mode.SkipObjectResolution. + // False positives are extremely unlikely as described above, + // and go/ast's object tracking is incomplete in any case. + if n.Max != nil { + // - 3-index slices always require the 2nd and 3rd index + break + } + if s, _ := n.X.(*ast.Ident); s != nil { + // the array/slice object is a single identifier + if call, _ := n.High.(*ast.CallExpr); call != nil && len(call.Args) == 1 && !call.Ellipsis.IsValid() { + // the high expression is a function call with a single argument + if fun, _ := call.Fun.(*ast.Ident); fun != nil && fun.Name == "len" { + // the function called is "len" + if arg, _ := call.Args[0].(*ast.Ident); arg != nil && arg.Name == s.Name { + // the len argument is the array/slice object + n.High = nil + } + } + } + } + // Note: We could also simplify slice expressions of the form s[0:b] to s[:b] + // but we leave them as is since sometimes we want to be very explicit + // about the lower bound. + // An example where the 0 helps: + // x, y, z := b[0:2], b[2:4], b[4:6] + // An example where it does not: + // x, y := b[:n], b[n:] + + case *ast.RangeStmt: + // - a range of the form: for x, _ = range v {...} + // can be simplified to: for x = range v {...} + // - a range of the form: for _ = range v {...} + // can be simplified to: for range v {...} + if isBlank(n.Value) { + n.Value = nil + } + if isBlank(n.Key) && n.Value == nil { + n.Key = nil + } + } + + return s +} + +func (s simplifier) simplifyLiteral(typ reflect.Value, astType, x ast.Expr, px *ast.Expr) { + ast.Walk(s, x) // simplify x + + // if the element is a composite literal and its literal type + // matches the outer literal's element type exactly, the inner + // literal type may be omitted + if inner, ok := x.(*ast.CompositeLit); ok { + if match(nil, typ, reflect.ValueOf(inner.Type)) { + inner.Type = nil + } + } + // if the outer literal's element type is a pointer type *T + // and the element is & of a composite literal of type T, + // the inner &T may be omitted. + if ptr, ok := astType.(*ast.StarExpr); ok { + if addr, ok := x.(*ast.UnaryExpr); ok && addr.Op == token.AND { + if inner, ok := addr.X.(*ast.CompositeLit); ok { + if match(nil, reflect.ValueOf(ptr.X), reflect.ValueOf(inner.Type)) { + inner.Type = nil // drop T + *px = inner // drop & + } + } + } + } +} + +func isBlank(x ast.Expr) bool { + ident, ok := x.(*ast.Ident) + return ok && ident.Name == "_" +} + +func simplify(f *ast.File) { + // remove empty declarations such as "const ()", etc + removeEmptyDeclGroups(f) + + var s simplifier + ast.Walk(s, f) +} + +func removeEmptyDeclGroups(f *ast.File) { + i := 0 + for _, d := range f.Decls { + if g, ok := d.(*ast.GenDecl); !ok || !isEmpty(f, g) { + f.Decls[i] = d + i++ + } + } + f.Decls = f.Decls[:i] +} + +func isEmpty(f *ast.File, g *ast.GenDecl) bool { + if g.Doc != nil || g.Specs != nil { + return false + } + + for _, c := range f.Comments { + // if there is a comment in the declaration, it is not considered empty + if g.Pos() <= c.Pos() && c.End() <= g.End() { + return false + } + } + + return true +} diff --git a/vendor/mvdan.cc/gofumpt/gofmt.go b/vendor/mvdan.cc/gofumpt/gofmt.go new file mode 100644 index 000000000..5c922ffbd --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/gofmt.go @@ -0,0 +1,697 @@ +// Copyright 2009 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 main + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "go/ast" + "go/parser" + "go/scanner" + "go/token" + "io" + "io/fs" + "os" + "path/filepath" + "regexp" + "runtime" + "runtime/pprof" + "strings" + "sync" + + "golang.org/x/mod/modfile" + "golang.org/x/sync/semaphore" + + gformat "mvdan.cc/gofumpt/format" + "mvdan.cc/gofumpt/internal/govendor/diff" + "mvdan.cc/gofumpt/internal/govendor/go/printer" + gversion "mvdan.cc/gofumpt/internal/version" +) + +//go:generate go run gen_govendor.go +//go:generate go run . -w internal/govendor + +var ( + // main operation modes + list = flag.Bool("l", false, "") + write = flag.Bool("w", false, "") + doDiff = flag.Bool("d", false, "") + allErrors = flag.Bool("e", false, "") + + // debugging + cpuprofile = flag.String("cpuprofile", "", "") + + // gofumpt's own flags + langVersion = flag.String("lang", "", "") + modulePath = flag.String("modpath", "", "") + extraRules = flag.Bool("extra", false, "") + showVersion = flag.Bool("version", false, "") + + // DEPRECATED + rewriteRule = flag.String("r", "", "") + simplifyAST = flag.Bool("s", false, "") +) + +var version = "" + +// Keep these in sync with go/format/format.go. +const ( + tabWidth = 8 + printerMode = printer.UseSpaces | printer.TabIndent | printerNormalizeNumbers + + // printerNormalizeNumbers means to canonicalize number literal prefixes + // and exponents while printing. See https://golang.org/doc/go1.13#gofmt. + // + // This value is defined in go/printer specifically for go/format and cmd/gofmt. + printerNormalizeNumbers = 1 << 30 +) + +// fdSem guards the number of concurrently-open file descriptors. +// +// For now, this is arbitrarily set to 200, based on the observation that many +// platforms default to a kernel limit of 256. Ideally, perhaps we should derive +// it from rlimit on platforms that support that system call. +// +// File descriptors opened from outside of this package are not tracked, +// so this limit may be approximate. +var fdSem = make(chan bool, 200) + +var ( + fileSet = token.NewFileSet() // per process FileSet + parserMode parser.Mode +) + +func usage() { + fmt.Fprintf(os.Stderr, `usage: gofumpt [flags] [path ...] + -version show version and exit + + -d display diffs instead of rewriting files + -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 + + -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) +`) +} + +func initParserMode() { + parserMode = parser.ParseComments | parser.SkipObjectResolution + if *allErrors { + parserMode |= parser.AllErrors + } +} + +func isGoFilename(name string) bool { + return !strings.HasPrefix(name, ".") && strings.HasSuffix(name, ".go") +} + +var rxCodeGenerated = regexp.MustCompile(`^// Code generated .* DO NOT EDIT\.$`) + +func isGenerated(file *ast.File) bool { + for _, cg := range file.Comments { + if cg.Pos() > file.Package { + return false + } + for _, line := range cg.List { + if rxCodeGenerated.MatchString(line.Text) { + return true + } + } + } + return false +} + +// A sequencer performs concurrent tasks that may write output, but emits that +// output in a deterministic order. +type sequencer struct { + maxWeight int64 + sem *semaphore.Weighted // weighted by input bytes (an approximate proxy for memory overhead) + prev <-chan *reporterState // 1-buffered +} + +// newSequencer returns a sequencer that allows concurrent tasks up to maxWeight +// and writes tasks' output to out and err. +func newSequencer(maxWeight int64, out, err io.Writer) *sequencer { + sem := semaphore.NewWeighted(maxWeight) + prev := make(chan *reporterState, 1) + prev <- &reporterState{out: out, err: err} + return &sequencer{ + maxWeight: maxWeight, + sem: sem, + prev: prev, + } +} + +// exclusive is a weight that can be passed to a sequencer to cause +// a task to be executed without any other concurrent tasks. +const exclusive = -1 + +// Add blocks until the sequencer has enough weight to spare, then adds f as a +// task to be executed concurrently. +// +// If the weight is either negative or larger than the sequencer's maximum +// weight, Add blocks until all other tasks have completed, then the task +// executes exclusively (blocking all other calls to Add until it completes). +// +// f may run concurrently in a goroutine, but its output to the passed-in +// reporter will be sequential relative to the other tasks in the sequencer. +// +// If f invokes a method on the reporter, execution of that method may block +// until the previous task has finished. (To maximize concurrency, f should +// avoid invoking the reporter until it has finished any parallelizable work.) +// +// If f returns a non-nil error, that error will be reported after f's output +// (if any) and will cause a nonzero final exit code. +func (s *sequencer) Add(weight int64, f func(*reporter) error) { + if weight < 0 || weight > s.maxWeight { + weight = s.maxWeight + } + if err := s.sem.Acquire(context.TODO(), weight); err != nil { + // Change the task from "execute f" to "report err". + weight = 0 + f = func(*reporter) error { return err } + } + + r := &reporter{prev: s.prev} + next := make(chan *reporterState, 1) + s.prev = next + + // Start f in parallel: it can run until it invokes a method on r, at which + // point it will block until the previous task releases the output state. + go func() { + if err := f(r); err != nil { + r.Report(err) + } + next <- r.getState() // Release the next task. + s.sem.Release(weight) + }() +} + +// AddReport prints an error to s after the output of any previously-added +// tasks, causing the final exit code to be nonzero. +func (s *sequencer) AddReport(err error) { + s.Add(0, func(*reporter) error { return err }) +} + +// GetExitCode waits for all previously-added tasks to complete, then returns an +// exit code for the sequence suitable for passing to os.Exit. +func (s *sequencer) GetExitCode() int { + c := make(chan int, 1) + s.Add(0, func(r *reporter) error { + c <- r.ExitCode() + return nil + }) + return <-c +} + +// A reporter reports output, warnings, and errors. +type reporter struct { + prev <-chan *reporterState + state *reporterState +} + +// reporterState carries the state of a reporter instance. +// +// Only one reporter at a time may have access to a reporterState. +type reporterState struct { + out, err io.Writer + exitCode int +} + +// getState blocks until any prior reporters are finished with the reporter +// state, then returns the state for manipulation. +func (r *reporter) getState() *reporterState { + if r.state == nil { + r.state = <-r.prev + } + return r.state +} + +// Warnf emits a warning message to the reporter's error stream, +// without changing its exit code. +func (r *reporter) Warnf(format string, args ...any) { + fmt.Fprintf(r.getState().err, format, args...) +} + +// Write emits a slice to the reporter's output stream. +// +// Any error is returned to the caller, and does not otherwise affect the +// reporter's exit code. +func (r *reporter) Write(p []byte) (int, error) { + return r.getState().out.Write(p) +} + +// Report emits a non-nil error to the reporter's error stream, +// changing its exit code to a nonzero value. +func (r *reporter) Report(err error) { + if err == nil { + panic("Report with nil error") + } + st := r.getState() + switch err.(type) { + case printedDiff: + st.exitCode = 1 + default: + scanner.PrintError(st.err, err) + st.exitCode = 2 + } +} + +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. +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 + } + file, sourceAdj, indentAdj, err := parse(fileSet, filename, src, fragmentOk) + if err != nil { + return err + } + + ast.SortImports(fileSet, file) + + // 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 + modpath := *modulePath + if lang == "" || modpath == "" { + path, err := filepath.Abs(filename) + if err != nil { + return err + } + if mod := loadModule(filepath.Dir(path)); mod != nil { + if lang == "" { + if mod.file.Go == nil { + // If the go directive is missing, go 1.16 is assumed. + // https://go.dev/ref/mod#go-mod-file-go + lang = "go1.16" + } else { + lang = "go" + mod.file.Go.Version + } + } + if modpath == "" { + modpath = mod.file.Module.Mod.Path + } + } + } + + // We always apply the gofumpt formatting rules to explicit files, including stdin. + // Otherwise, we don't apply them on generated files. + // We also skip walking vendor directories entirely, but that happens elsewhere. + if explicit || !isGenerated(file) { + gformat.File(fileSet, file, gformat.Options{ + LangVersion: lang, + ModulePath: modpath, + ExtraRules: *extraRules, + }) + } + + res, err := format(fileSet, file, sourceAdj, indentAdj, src, printer.Config{Mode: printerMode, Tabwidth: tabWidth}) + if err != nil { + return err + } + + if !bytes.Equal(src, res) { + // formatting has changed + if *list { + fmt.Fprintln(r, filename) + } + if *write { + 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 { + return err + } + } + if *doDiff { + newName := filepath.ToSlash(filename) + oldName := newName + ".orig" + r.Write(diff.Diff(oldName, src, newName, res)) + return printedDiff{} + } + } + + if !*list && !*write && !*doDiff { + _, err = r.Write(res) + } + + return err +} + +// readFile reads the contents of filename, described by info. +// If in is non-nil, readFile reads directly from it. +// Otherwise, readFile opens and reads the file itself, +// with the number of concurrently-open files limited by fdSem. +func readFile(filename string, info fs.FileInfo, in io.Reader) ([]byte, error) { + if in == nil { + fdSem <- true + var err error + f, err := os.Open(filename) + if err != nil { + return nil, err + } + in = f + defer func() { + f.Close() + <-fdSem + }() + } + + // Compute the file's size and read its contents with minimal allocations. + // + // If we have the FileInfo from filepath.WalkDir, use it to make + // a buffer of the right size and avoid ReadAll's reallocations. + // + // If the size is unknown (or bogus, or overflows an int), fall back to + // a size-independent ReadAll. + size := -1 + if info != nil && info.Mode().IsRegular() && int64(int(info.Size())) == info.Size() { + size = int(info.Size()) + } + if size+1 <= 0 { + // The file is not known to be regular, so we don't have a reliable size for it. + var err error + src, err := io.ReadAll(in) + if err != nil { + return nil, err + } + return src, nil + } + + // We try to read size+1 bytes so that we can detect modifications: if we + // read more than size bytes, then the file was modified concurrently. + // (If that happens, we could, say, append to src to finish the read, or + // proceed with a truncated buffer — but the fact that it changed at all + // indicates a possible race with someone editing the file, so we prefer to + // stop to avoid corrupting it.) + src := make([]byte, size+1) + n, err := io.ReadFull(in, src) + switch err { + case nil, io.EOF, io.ErrUnexpectedEOF: + // io.ReadFull returns io.EOF (for an empty file) or io.ErrUnexpectedEOF + // (for a non-empty file) if the file was changed unexpectedly. Continue + // with comparing file sizes in those cases. + default: + return nil, err + } + if n < size { + return nil, fmt.Errorf("error: size of %s changed during reading (from %d to %d bytes)", filename, size, n) + } else if n > size { + return nil, fmt.Errorf("error: size of %s changed during reading (from %d to >=%d bytes)", filename, size, len(src)) + } + return src[:n], nil +} + +func main() { + // Arbitrarily limit in-flight work to 2MiB times the number of threads. + // + // The actual overhead for the parse tree and output will depend on the + // specifics of the file, but this at least keeps the footprint of the process + // roughly proportional to GOMAXPROCS. + maxWeight := (2 << 20) * int64(runtime.GOMAXPROCS(0)) + s := newSequencer(maxWeight, os.Stdout, os.Stderr) + + // call gofmtMain in a separate function + // so that it can use defer and have them + // run before the exit. + gofmtMain(s) + os.Exit(s.GetExitCode()) +} + +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() + + if *simplifyAST { + fmt.Fprintf(os.Stderr, "warning: -s is deprecated as it is always enabled\n") + } + if *rewriteRule != "" { + fmt.Fprintf(os.Stderr, `the rewrite flag is no longer available; use "gofmt -r" instead`+"\n") + os.Exit(2) + } + + // Print the gofumpt version if the user asks for it. + if *showVersion { + fmt.Println(gversion.String(version)) + return + } + + if *cpuprofile != "" { + fdSem <- true + f, err := os.Create(*cpuprofile) + if err != nil { + s.AddReport(fmt.Errorf("creating cpu profile: %s", err)) + return + } + defer func() { + f.Close() + <-fdSem + }() + pprof.StartCPUProfile(f) + defer pprof.StopCPUProfile() + } + + initParserMode() + + args := flag.Args() + if len(args) == 0 { + if *write { + s.AddReport(fmt.Errorf("error: cannot use -w with standard input")) + return + } + s.Add(0, func(r *reporter) error { + // TODO: test explicit==true + return processFile("", nil, os.Stdin, r, true) + }) + return + } + + 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. + // If the argument is a directory, we walk it, ignoring non-Go files. + arg = filepath.Clean(arg) // ensure consistency + if err := filepath.WalkDir(arg, func(path string, d fs.DirEntry, err error) error { + explicit := path == arg + switch { + case err != nil: + return err + case d.IsDir(): + if !explicit && shouldIgnore(path) { + return filepath.SkipDir + } + return nil // simply recurse into directories + case explicit: + // non-directories given as explicit arguments are always formatted + case !isGoFilename(d.Name()): + return nil // skip walked non-Go files + } + info, err := d.Info() + if err != nil { + return err + } + s.Add(fileWeight(path, info), func(r *reporter) error { + return processFile(path, info, nil, r, explicit) + }) + return nil + }); err != nil { + s.AddReport(err) + } + } +} + +func shouldIgnore(path string) bool { + switch filepath.Base(path) { + case "vendor", "testdata": + return true + } + path, err := filepath.Abs(path) + if err != nil { + return false // unclear how this could happen; don't ignore in any case + } + mod := loadModule(path) + if mod == nil { + return false // no module file to declare ignore paths + } + relPath, err := filepath.Rel(mod.absDir, path) + if err != nil { + return false // unclear how this could happen; don't ignore in any case + } + relPath = normalizePath(relPath) + for _, ignore := range mod.file.Ignore { + if matchIgnore(ignore.Path, relPath) { + return true + } + } + return false +} + +// normalizePath adds slashes to the front and end of the given path. +func normalizePath(path string) string { + path = filepath.ToSlash(path) // ensure Windows support + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if !strings.HasSuffix(path, "/") { + path += "/" + } + return path +} + +func matchIgnore(ignore, relPath string) bool { + ignore, rooted := strings.CutPrefix(ignore, "./") + ignore = normalizePath(ignore) + // Note that we only match the directory to be ignored itself, + // and not any directories underneath it. + // This way, using `gofumpt -w ignored` allows `ignored/subdir` to be formatted. + if rooted { + return relPath == ignore + } + return strings.HasSuffix(relPath, ignore) +} + +// 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. +var cachedModuleByDir sync.Map // map[dirString]*cachedModfile + +type cachedModule struct { + absDir string // the directory where the go.mod file was found + file *modfile.File +} + +func loadModule(dir string) *cachedModule { + if cached, ok := cachedModuleByDir.Load(dir); ok { + mf, _ := cached.(*cachedModule) + return mf + } + mod := func() *cachedModule { + path := filepath.Join(dir, "go.mod") + fdSem <- true + data, err := os.ReadFile(path) + <-fdSem + if errors.Is(err, fs.ErrNotExist) { + parent := filepath.Dir(dir) + if parent == "." { + panic("loadModule was not given an absolute path?") + } + if parent == dir { + return nil // reached the filesystem root + } + 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 + } + return &cachedModule{ + absDir: dir, + file: file, + } + }() + if mod != nil { + cachedModuleByDir.Store(dir, mod) + } else { + cachedModuleByDir.Store(dir, nil) + } + return mod +} + +func fileWeight(path string, info fs.FileInfo) int64 { + if info == nil { + return exclusive + } + if info.Mode().Type() == fs.ModeSymlink { + var err error + info, err = os.Stat(path) + if err != nil { + return exclusive + } + } + if !info.Mode().IsRegular() { + // For non-regular files, FileInfo.Size is system-dependent and thus not a + // reliable indicator of weight. + return exclusive + } + return info.Size() +} + +const chmodSupported = runtime.GOOS != "windows" + +// backupFile writes data to a new file named filename with permissions perm, +// with 0 && isSpace(src[i-1]) { + i-- + } + return append(res, src[i:]...), nil +} + +// isSpace reports whether the byte is a space character. +// isSpace defines a space as being among the following bytes: ' ', '\t', '\n' and '\r'. +func isSpace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/diff/diff.go b/vendor/mvdan.cc/gofumpt/internal/govendor/diff/diff.go new file mode 100644 index 000000000..6a40b23fc --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/diff/diff.go @@ -0,0 +1,261 @@ +// Copyright 2022 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 + +import ( + "bytes" + "fmt" + "sort" + "strings" +) + +// A pair is a pair of values tracked for both the x and y side of a diff. +// It is typically a pair of line indexes. +type pair struct{ x, y int } + +// Diff returns an anchored diff of the two texts old and new +// in the “unified diff” format. If old and new are identical, +// Diff returns a nil slice (no output). +// +// Unix diff implementations typically look for a diff with +// the smallest number of lines inserted and removed, +// which can in the worst case take time quadratic in the +// number of lines in the texts. As a result, many implementations +// either can be made to run for a long time or cut off the search +// after a predetermined amount of work. +// +// In contrast, this implementation looks for a diff with the +// smallest number of “unique” lines inserted and removed, +// where unique means a line that appears just once in both old and new. +// We call this an “anchored diff” because the unique lines anchor +// the chosen matching regions. An anchored diff is usually clearer +// than a standard diff, because the algorithm does not try to +// reuse unrelated blank lines or closing braces. +// The algorithm also guarantees to run in O(n log n) time +// instead of the standard O(n²) time. +// +// Some systems call this approach a “patience diff,” named for +// the “patience sorting” algorithm, itself named for a solitaire card game. +// We avoid that name for two reasons. First, the name has been used +// for a few different variants of the algorithm, so it is imprecise. +// Second, the name is frequently interpreted as meaning that you have +// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm, +// when in fact the algorithm is faster than the standard one. +func Diff(oldName string, old []byte, newName string, new []byte) []byte { + if bytes.Equal(old, new) { + return nil + } + x := lines(old) + y := lines(new) + + // Print diff header. + var out bytes.Buffer + fmt.Fprintf(&out, "diff %s %s\n", oldName, newName) + fmt.Fprintf(&out, "--- %s\n", oldName) + fmt.Fprintf(&out, "+++ %s\n", newName) + + // Loop over matches to consider, + // expanding each match to include surrounding lines, + // and then printing diff chunks. + // To avoid setup/teardown cases outside the loop, + // tgs returns a leading {0,0} and trailing {len(x), len(y)} pair + // in the sequence of matches. + var ( + done pair // printed up to x[:done.x] and y[:done.y] + chunk pair // start lines of current chunk + count pair // number of lines from each side in current chunk + ctext []string // lines for current chunk + ) + for _, m := range tgs(x, y) { + if m.x < done.x { + // Already handled scanning forward from earlier match. + continue + } + + // Expand matching lines as far as possible, + // establishing that x[start.x:end.x] == y[start.y:end.y]. + // Note that on the first (or last) iteration we may (or definitely do) + // have an empty match: start.x==end.x and start.y==end.y. + start := m + for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] { + start.x-- + start.y-- + } + end := m + for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] { + end.x++ + end.y++ + } + + // Emit the mismatched lines before start into this chunk. + // (No effect on first sentinel iteration, when start = {0,0}.) + for _, s := range x[done.x:start.x] { + ctext = append(ctext, "-"+s) + count.x++ + } + for _, s := range y[done.y:start.y] { + ctext = append(ctext, "+"+s) + count.y++ + } + + // If we're not at EOF and have too few common lines, + // the chunk includes all the common lines and continues. + const C = 3 // number of context lines + if (end.x < len(x) || end.y < len(y)) && + (end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) { + for _, s := range x[start.x:end.x] { + ctext = append(ctext, " "+s) + count.x++ + count.y++ + } + done = end + continue + } + + // End chunk with common lines for context. + if len(ctext) > 0 { + n := end.x - start.x + if n > C { + n = C + } + for _, s := range x[start.x : start.x+n] { + ctext = append(ctext, " "+s) + count.x++ + count.y++ + } + done = pair{start.x + n, start.y + n} + + // Format and emit chunk. + // Convert line numbers to 1-indexed. + // Special case: empty file shows up as 0,0 not 1,0. + if count.x > 0 { + chunk.x++ + } + if count.y > 0 { + chunk.y++ + } + fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y) + for _, s := range ctext { + out.WriteString(s) + } + count.x = 0 + count.y = 0 + ctext = ctext[:0] + } + + // If we reached EOF, we're done. + if end.x >= len(x) && end.y >= len(y) { + break + } + + // Otherwise start a new chunk. + chunk = pair{end.x - C, end.y - C} + for _, s := range x[chunk.x:end.x] { + ctext = append(ctext, " "+s) + count.x++ + count.y++ + } + done = end + } + + return out.Bytes() +} + +// lines returns the lines in the file x, including newlines. +// If the file does not end in a newline, one is supplied +// along with a warning about the missing newline. +func lines(x []byte) []string { + l := strings.SplitAfter(string(x), "\n") + if l[len(l)-1] == "" { + l = l[:len(l)-1] + } else { + // Treat last line as having a message about the missing newline attached, + // using the same text as BSD/GNU diff (including the leading backslash). + l[len(l)-1] += "\n\\ No newline at end of file\n" + } + return l +} + +// tgs returns the pairs of indexes of the longest common subsequence +// of unique lines in x and y, where a unique line is one that appears +// once in x and once in y. +// +// The longest common subsequence algorithm is as described in +// Thomas G. Szymanski, “A Special Case of the Maximal Common +// Subsequence Problem,” Princeton TR #170 (January 1975), +// available at https://research.swtch.com/tgs170.pdf. +func tgs(x, y []string) []pair { + // Count the number of times each string appears in a and b. + // We only care about 0, 1, many, counted as 0, -1, -2 + // for the x side and 0, -4, -8 for the y side. + // Using negative numbers now lets us distinguish positive line numbers later. + m := make(map[string]int) + for _, s := range x { + if c := m[s]; c > -2 { + m[s] = c - 1 + } + } + for _, s := range y { + if c := m[s]; c > -8 { + m[s] = c - 4 + } + } + + // Now unique strings can be identified by m[s] = -1+-4. + // + // Gather the indexes of those strings in x and y, building: + // xi[i] = increasing indexes of unique strings in x. + // yi[i] = increasing indexes of unique strings in y. + // inv[i] = index j such that x[xi[i]] = y[yi[j]]. + var xi, yi, inv []int + for i, s := range y { + if m[s] == -1+-4 { + m[s] = len(yi) + yi = append(yi, i) + } + } + for i, s := range x { + if j, ok := m[s]; ok && j >= 0 { + xi = append(xi, i) + inv = append(inv, j) + } + } + + // Apply Algorithm A from Szymanski's paper. + // In those terms, A = J = inv and B = [0, n). + // We add sentinel pairs {0,0}, and {len(x),len(y)} + // to the returned sequence, to help the processing loop. + J := inv + n := len(xi) + T := make([]int, n) + L := make([]int, n) + for i := range T { + T[i] = n + 1 + } + for i := 0; i < n; i++ { + k := sort.Search(n, func(k int) bool { + return T[k] >= J[i] + }) + T[k] = J[i] + L[i] = k + 1 + } + k := 0 + for _, v := range L { + if k < v { + k = v + } + } + seq := make([]pair, 2+k) + seq[1+k] = pair{len(x), len(y)} // sentinel at end + lastj := n + for i := n - 1; i >= 0; i-- { + if L[i] == k && J[i] < lastj { + seq[k] = pair{xi[i], yi[J[i]]} + k-- + } + } + seq[0] = pair{0, 0} // sentinel at start + return seq +} diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/doc.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/doc.go new file mode 100644 index 000000000..45a476aa9 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/doc.go @@ -0,0 +1,36 @@ +// Copyright 2022 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 comment implements parsing and reformatting of Go doc comments, +(documentation comments), which are comments that immediately precede +a top-level declaration of a package, const, func, type, or var. + +Go doc comment syntax is a simplified subset of Markdown that supports +links, headings, paragraphs, lists (without nesting), and preformatted text blocks. +The details of the syntax are documented at https://go.dev/doc/comment. + +To parse the text associated with a doc comment (after removing comment markers), +use a [Parser]: + + var p comment.Parser + doc := p.Parse(text) + +The result is a [*Doc]. +To reformat it as a doc comment, HTML, Markdown, or plain text, +use a [Printer]: + + var pr comment.Printer + os.Stdout.Write(pr.Text(doc)) + +The [Parser] and [Printer] types are structs whose fields can be +modified to customize the operations. +For details, see the documentation for those types. + +Use cases that need additional control over reformatting can +implement their own logic by inspecting the parsed syntax itself. +See the documentation for [Doc], [Block], [Text] for an overview +and links to additional types. +*/ +package comment diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/html.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/html.go new file mode 100644 index 000000000..9244509e0 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/html.go @@ -0,0 +1,169 @@ +// Copyright 2022 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 comment + +import ( + "bytes" + "fmt" + "strconv" +) + +// An htmlPrinter holds the state needed for printing a [Doc] as HTML. +type htmlPrinter struct { + *Printer + tight bool +} + +// HTML returns an HTML formatting of the [Doc]. +// See the [Printer] documentation for ways to customize the HTML output. +func (p *Printer) HTML(d *Doc) []byte { + hp := &htmlPrinter{Printer: p} + var out bytes.Buffer + for _, x := range d.Content { + hp.block(&out, x) + } + return out.Bytes() +} + +// block prints the block x to out. +func (p *htmlPrinter) block(out *bytes.Buffer, x Block) { + switch x := x.(type) { + default: + fmt.Fprintf(out, "?%T", x) + + case *Paragraph: + if !p.tight { + out.WriteString("

") + } + p.text(out, x.Text) + out.WriteString("\n") + + case *Heading: + out.WriteString("") + p.text(out, x.Text) + out.WriteString("\n") + + case *Code: + out.WriteString("

")
+		p.escape(out, x.Text)
+		out.WriteString("
\n") + + case *List: + kind := "ol>\n" + if x.Items[0].Number == "" { + kind = "ul>\n" + } + out.WriteString("<") + out.WriteString(kind) + next := "1" + for _, item := range x.Items { + out.WriteString("") + p.tight = !x.BlankBetween() + for _, blk := range item.Content { + p.block(out, blk) + } + p.tight = false + } + out.WriteString("= 0; i-- { + if b[i] < '9' { + b[i]++ + return string(b) + } + b[i] = '0' + } + return "1" + string(b) +} + +// text prints the text sequence x to out. +func (p *htmlPrinter) text(out *bytes.Buffer, x []Text) { + for _, t := range x { + switch t := t.(type) { + case Plain: + p.escape(out, string(t)) + case Italic: + out.WriteString("") + p.escape(out, string(t)) + out.WriteString("") + case *Link: + out.WriteString(``) + p.text(out, t.Text) + out.WriteString("") + case *DocLink: + url := p.docLinkURL(t) + if url != "" { + out.WriteString(``) + } + p.text(out, t.Text) + if url != "" { + out.WriteString("") + } + } + } +} + +// escape prints s to out as plain text, +// escaping < & " ' and > to avoid being misinterpreted +// in larger HTML constructs. +func (p *htmlPrinter) escape(out *bytes.Buffer, s string) { + start := 0 + for i := 0; i < len(s); i++ { + switch s[i] { + case '<': + out.WriteString(s[start:i]) + out.WriteString("<") + start = i + 1 + case '&': + out.WriteString(s[start:i]) + out.WriteString("&") + start = i + 1 + case '"': + out.WriteString(s[start:i]) + out.WriteString(""") + start = i + 1 + case '\'': + out.WriteString(s[start:i]) + out.WriteString("'") + start = i + 1 + case '>': + out.WriteString(s[start:i]) + out.WriteString(">") + start = i + 1 + } + } + out.WriteString(s[start:]) +} diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/markdown.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/markdown.go new file mode 100644 index 000000000..d8550f2e3 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/markdown.go @@ -0,0 +1,188 @@ +// Copyright 2022 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 comment + +import ( + "bytes" + "fmt" + "strings" +) + +// An mdPrinter holds the state needed for printing a Doc as Markdown. +type mdPrinter struct { + *Printer + headingPrefix string + raw bytes.Buffer +} + +// Markdown returns a Markdown formatting of the Doc. +// See the [Printer] documentation for ways to customize the Markdown output. +func (p *Printer) Markdown(d *Doc) []byte { + mp := &mdPrinter{ + Printer: p, + headingPrefix: strings.Repeat("#", p.headingLevel()) + " ", + } + + var out bytes.Buffer + for i, x := range d.Content { + if i > 0 { + out.WriteByte('\n') + } + mp.block(&out, x) + } + return out.Bytes() +} + +// block prints the block x to out. +func (p *mdPrinter) block(out *bytes.Buffer, x Block) { + switch x := x.(type) { + default: + fmt.Fprintf(out, "?%T", x) + + case *Paragraph: + p.text(out, x.Text) + out.WriteString("\n") + + case *Heading: + out.WriteString(p.headingPrefix) + p.text(out, x.Text) + if id := p.headingID(x); id != "" { + out.WriteString(" {#") + out.WriteString(id) + out.WriteString("}") + } + out.WriteString("\n") + + case *Code: + md := x.Text + for md != "" { + var line string + line, md, _ = strings.Cut(md, "\n") + if line != "" { + out.WriteString("\t") + out.WriteString(line) + } + out.WriteString("\n") + } + + case *List: + loose := x.BlankBetween() + for i, item := range x.Items { + if i > 0 && loose { + out.WriteString("\n") + } + if n := item.Number; n != "" { + out.WriteString(" ") + out.WriteString(n) + out.WriteString(". ") + } else { + out.WriteString(" - ") // SP SP - SP + } + for i, blk := range item.Content { + const fourSpace = " " + if i > 0 { + out.WriteString("\n" + fourSpace) + } + p.text(out, blk.(*Paragraph).Text) + out.WriteString("\n") + } + } + } +} + +// text prints the text sequence x to out. +func (p *mdPrinter) text(out *bytes.Buffer, x []Text) { + p.raw.Reset() + p.rawText(&p.raw, x) + line := bytes.TrimSpace(p.raw.Bytes()) + if len(line) == 0 { + return + } + switch line[0] { + case '+', '-', '*', '#': + // Escape what would be the start of an unordered list or heading. + out.WriteByte('\\') + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9': + i := 1 + for i < len(line) && '0' <= line[i] && line[i] <= '9' { + i++ + } + if i < len(line) && (line[i] == '.' || line[i] == ')') { + // Escape what would be the start of an ordered list. + out.Write(line[:i]) + out.WriteByte('\\') + line = line[i:] + } + } + out.Write(line) +} + +// rawText prints the text sequence x to out, +// without worrying about escaping characters +// that have special meaning at the start of a Markdown line. +func (p *mdPrinter) rawText(out *bytes.Buffer, x []Text) { + for _, t := range x { + switch t := t.(type) { + case Plain: + p.escape(out, string(t)) + case Italic: + out.WriteString("*") + p.escape(out, string(t)) + out.WriteString("*") + case *Link: + out.WriteString("[") + p.rawText(out, t.Text) + out.WriteString("](") + out.WriteString(t.URL) + out.WriteString(")") + case *DocLink: + url := p.docLinkURL(t) + if url != "" { + out.WriteString("[") + } + p.rawText(out, t.Text) + if url != "" { + out.WriteString("](") + url = strings.ReplaceAll(url, "(", "%28") + url = strings.ReplaceAll(url, ")", "%29") + out.WriteString(url) + out.WriteString(")") + } + } + } +} + +// escape prints s to out as plain text, +// escaping special characters to avoid being misinterpreted +// as Markdown markup sequences. +func (p *mdPrinter) escape(out *bytes.Buffer, s string) { + start := 0 + for i := 0; i < len(s); i++ { + switch s[i] { + case '\n': + // Turn all \n into spaces, for a few reasons: + // - Avoid introducing paragraph breaks accidentally. + // - Avoid the need to reindent after the newline. + // - Avoid problems with Markdown renderers treating + // every mid-paragraph newline as a
. + out.WriteString(s[start:i]) + out.WriteByte(' ') + start = i + 1 + continue + case '`', '_', '*', '[', '<', '\\': + // Not all of these need to be escaped all the time, + // but is valid and easy to do so. + // We assume the Markdown is being passed to a + // Markdown renderer, not edited by a person, + // so it's fine to have escapes that are not strictly + // necessary in some cases. + out.WriteString(s[start:i]) + out.WriteByte('\\') + out.WriteByte(s[i]) + start = i + 1 + } + } + out.WriteString(s[start:]) +} diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/parse.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/parse.go new file mode 100644 index 000000000..bd42c55ec --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/parse.go @@ -0,0 +1,1260 @@ +// Copyright 2022 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 comment + +import ( + "slices" + "strings" + "unicode" + "unicode/utf8" +) + +// A Doc is a parsed Go doc comment. +type Doc struct { + // Content is the sequence of content blocks in the comment. + Content []Block + + // Links is the link definitions in the comment. + Links []*LinkDef +} + +// A LinkDef is a single link definition. +type LinkDef struct { + Text string // the link text + URL string // the link URL + Used bool // whether the comment uses the definition +} + +// A Block is block-level content in a doc comment, +// one of [*Code], [*Heading], [*List], or [*Paragraph]. +type Block interface { + block() +} + +// A Heading is a doc comment heading. +type Heading struct { + Text []Text // the heading text +} + +func (*Heading) block() {} + +// A List is a numbered or bullet list. +// Lists are always non-empty: len(Items) > 0. +// In a numbered list, every Items[i].Number is a non-empty string. +// In a bullet list, every Items[i].Number is an empty string. +type List struct { + // Items is the list items. + Items []*ListItem + + // ForceBlankBefore indicates that the list must be + // preceded by a blank line when reformatting the comment, + // overriding the usual conditions. See the BlankBefore method. + // + // The comment parser sets ForceBlankBefore for any list + // that is preceded by a blank line, to make sure + // the blank line is preserved when printing. + ForceBlankBefore bool + + // ForceBlankBetween indicates that list items must be + // separated by blank lines when reformatting the comment, + // overriding the usual conditions. See the BlankBetween method. + // + // The comment parser sets ForceBlankBetween for any list + // that has a blank line between any two of its items, to make sure + // the blank lines are preserved when printing. + ForceBlankBetween bool +} + +func (*List) block() {} + +// BlankBefore reports whether a reformatting of the comment +// should include a blank line before the list. +// The default rule is the same as for [BlankBetween]: +// if the list item content contains any blank lines +// (meaning at least one item has multiple paragraphs) +// then the list itself must be preceded by a blank line. +// A preceding blank line can be forced by setting [List].ForceBlankBefore. +func (l *List) BlankBefore() bool { + return l.ForceBlankBefore || l.BlankBetween() +} + +// BlankBetween reports whether a reformatting of the comment +// should include a blank line between each pair of list items. +// The default rule is that if the list item content contains any blank lines +// (meaning at least one item has multiple paragraphs) +// then list items must themselves be separated by blank lines. +// Blank line separators can be forced by setting [List].ForceBlankBetween. +func (l *List) BlankBetween() bool { + if l.ForceBlankBetween { + return true + } + for _, item := range l.Items { + if len(item.Content) != 1 { + // Unreachable for parsed comments today, + // since the only way to get multiple item.Content + // is multiple paragraphs, which must have been + // separated by a blank line. + return true + } + } + return false +} + +// A ListItem is a single item in a numbered or bullet list. +type ListItem struct { + // Number is a decimal string in a numbered list + // or an empty string in a bullet list. + Number string // "1", "2", ...; "" for bullet list + + // Content is the list content. + // Currently, restrictions in the parser and printer + // require every element of Content to be a *Paragraph. + Content []Block // Content of this item. +} + +// A Paragraph is a paragraph of text. +type Paragraph struct { + Text []Text +} + +func (*Paragraph) block() {} + +// A Code is a preformatted code block. +type Code struct { + // Text is the preformatted text, ending with a newline character. + // It may be multiple lines, each of which ends with a newline character. + // It is never empty, nor does it start or end with a blank line. + Text string +} + +func (*Code) block() {} + +// A Text is text-level content in a doc comment, +// one of [Plain], [Italic], [*Link], or [*DocLink]. +type Text interface { + text() +} + +// A Plain is a string rendered as plain text (not italicized). +type Plain string + +func (Plain) text() {} + +// An Italic is a string rendered as italicized text. +type Italic string + +func (Italic) text() {} + +// A Link is a link to a specific URL. +type Link struct { + Auto bool // is this an automatic (implicit) link of a literal URL? + Text []Text // text of link + URL string // target URL of link +} + +func (*Link) text() {} + +// A DocLink is a link to documentation for a Go package or symbol. +type DocLink struct { + Text []Text // text of link + + // ImportPath, Recv, and Name identify the Go package or symbol + // that is the link target. The potential combinations of + // non-empty fields are: + // - ImportPath: a link to another package + // - ImportPath, Name: a link to a const, func, type, or var in another package + // - ImportPath, Recv, Name: a link to a method in another package + // - Name: a link to a const, func, type, or var in this package + // - Recv, Name: a link to a method in this package + ImportPath string // import path + Recv string // receiver type, without any pointer star, for methods + Name string // const, func, type, var, or method name +} + +func (*DocLink) text() {} + +// A Parser is a doc comment parser. +// The fields in the struct can be filled in before calling [Parser.Parse] +// in order to customize the details of the parsing process. +type Parser struct { + // Words is a map of Go identifier words that + // should be italicized and potentially linked. + // If Words[w] is the empty string, then the word w + // is only italicized. Otherwise it is linked, using + // Words[w] as the link target. + // Words corresponds to the [go/doc.ToHTML] words parameter. + Words map[string]string + + // LookupPackage resolves a package name to an import path. + // + // If LookupPackage(name) returns ok == true, then [name] + // (or [name.Sym] or [name.Sym.Method]) + // is considered a documentation link to importPath's package docs. + // It is valid to return "", true, in which case name is considered + // to refer to the current package. + // + // If LookupPackage(name) returns ok == false, + // then [name] (or [name.Sym] or [name.Sym.Method]) + // will not be considered a documentation link, + // except in the case where name is the full (but single-element) import path + // of a package in the standard library, such as in [math] or [io.Reader]. + // LookupPackage is still called for such names, + // in order to permit references to imports of other packages + // with the same package names. + // + // Setting LookupPackage to nil is equivalent to setting it to + // a function that always returns "", false. + LookupPackage func(name string) (importPath string, ok bool) + + // LookupSym reports whether a symbol name or method name + // exists in the current package. + // + // If LookupSym("", "Name") returns true, then [Name] + // is considered a documentation link for a const, func, type, or var. + // + // Similarly, if LookupSym("Recv", "Name") returns true, + // then [Recv.Name] is considered a documentation link for + // type Recv's method Name. + // + // Setting LookupSym to nil is equivalent to setting it to a function + // that always returns false. + LookupSym func(recv, name string) (ok bool) +} + +// parseDoc is parsing state for a single doc comment. +type parseDoc struct { + *Parser + *Doc + links map[string]*LinkDef + lines []string + lookupSym func(recv, name string) bool +} + +// lookupPkg is called to look up the pkg in [pkg], [pkg.Name], and [pkg.Name.Recv]. +// If pkg has a slash, it is assumed to be the full import path and is returned with ok = true. +// +// Otherwise, pkg is probably a simple package name like "rand" (not "crypto/rand" or "math/rand"). +// d.LookupPackage provides a way for the caller to allow resolving such names with reference +// to the imports in the surrounding package. +// +// There is one collision between these two cases: single-element standard library names +// like "math" are full import paths but don't contain slashes. We let d.LookupPackage have +// the first chance to resolve it, in case there's a different package imported as math, +// and otherwise we refer to a built-in list of single-element standard library package names. +func (d *parseDoc) lookupPkg(pkg string) (importPath string, ok bool) { + if strings.Contains(pkg, "/") { // assume a full import path + if validImportPath(pkg) { + return pkg, true + } + return "", false + } + if d.LookupPackage != nil { + // Give LookupPackage a chance. + if path, ok := d.LookupPackage(pkg); ok { + return path, true + } + } + return DefaultLookupPackage(pkg) +} + +func isStdPkg(path string) bool { + _, ok := slices.BinarySearch(stdPkgs, path) + return ok +} + +// DefaultLookupPackage is the default package lookup +// function, used when [Parser.LookupPackage] is nil. +// It recognizes names of the packages from the standard +// library with single-element import paths, such as math, +// which would otherwise be impossible to name. +// +// Note that the go/doc package provides a more sophisticated +// lookup based on the imports used in the current package. +func DefaultLookupPackage(name string) (importPath string, ok bool) { + if isStdPkg(name) { + return name, true + } + return "", false +} + +// Parse parses the doc comment text and returns the *[Doc] form. +// Comment markers (/* // and */) in the text must have already been removed. +func (p *Parser) Parse(text string) *Doc { + lines := unindent(strings.Split(text, "\n")) + d := &parseDoc{ + Parser: p, + Doc: new(Doc), + links: make(map[string]*LinkDef), + lines: lines, + lookupSym: func(recv, name string) bool { return false }, + } + if p.LookupSym != nil { + d.lookupSym = p.LookupSym + } + + // First pass: break into block structure and collect known links. + // The text is all recorded as Plain for now. + var prev span + for _, s := range parseSpans(lines) { + var b Block + switch s.kind { + default: + panic("mvdan.cc/gofumpt/internal/govendor/go/doc/comment: internal error: unknown span kind") + case spanList: + b = d.list(lines[s.start:s.end], prev.end < s.start) + case spanCode: + b = d.code(lines[s.start:s.end]) + case spanOldHeading: + b = d.oldHeading(lines[s.start]) + case spanHeading: + b = d.heading(lines[s.start]) + case spanPara: + b = d.paragraph(lines[s.start:s.end]) + } + if b != nil { + d.Content = append(d.Content, b) + } + prev = s + } + + // Second pass: interpret all the Plain text now that we know the links. + for _, b := range d.Content { + switch b := b.(type) { + case *Paragraph: + b.Text = d.parseLinkedText(string(b.Text[0].(Plain))) + case *List: + for _, i := range b.Items { + for _, c := range i.Content { + p := c.(*Paragraph) + p.Text = d.parseLinkedText(string(p.Text[0].(Plain))) + } + } + } + } + + return d.Doc +} + +// A span represents a single span of comment lines (lines[start:end]) +// of an identified kind (code, heading, paragraph, and so on). +type span struct { + start int + end int + kind spanKind +} + +// A spanKind describes the kind of span. +type spanKind int + +const ( + _ spanKind = iota + spanCode + spanHeading + spanList + spanOldHeading + spanPara +) + +func parseSpans(lines []string) []span { + var spans []span + + // The loop may process a line twice: once as unindented + // and again forced indented. So the maximum expected + // number of iterations is 2*len(lines). The repeating logic + // can be subtle, though, and to protect against introduction + // of infinite loops in future changes, we watch to see that + // we are not looping too much. A panic is better than a + // quiet infinite loop. + watchdog := 2 * len(lines) + + i := 0 + forceIndent := 0 +Spans: + for { + // Skip blank lines. + for i < len(lines) && lines[i] == "" { + i++ + } + if i >= len(lines) { + break + } + if watchdog--; watchdog < 0 { + panic("mvdan.cc/gofumpt/internal/govendor/go/doc/comment: internal error: not making progress") + } + + var kind spanKind + start := i + end := i + if i < forceIndent || indented(lines[i]) { + // Indented (or force indented). + // Ends before next unindented. (Blank lines are OK.) + // If this is an unindented list that we are heuristically treating as indented, + // then accept unindented list item lines up to the first blank lines. + // The heuristic is disabled at blank lines to contain its effect + // to non-gofmt'ed sections of the comment. + unindentedListOK := isList(lines[i]) && i < forceIndent + i++ + for i < len(lines) && (lines[i] == "" || i < forceIndent || indented(lines[i]) || (unindentedListOK && isList(lines[i]))) { + if lines[i] == "" { + unindentedListOK = false + } + i++ + } + + // Drop trailing blank lines. + end = i + for end > start && lines[end-1] == "" { + end-- + } + + // If indented lines are followed (without a blank line) + // by an unindented line ending in a brace, + // take that one line too. This fixes the common mistake + // of pasting in something like + // + // func main() { + // fmt.Println("hello, world") + // } + // + // and forgetting to indent it. + // The heuristic will never trigger on a gofmt'ed comment, + // because any gofmt'ed code block or list would be + // followed by a blank line or end of comment. + if end < len(lines) && strings.HasPrefix(lines[end], "}") { + end++ + } + + if isList(lines[start]) { + kind = spanList + } else { + kind = spanCode + } + } else { + // Unindented. Ends at next blank or indented line. + i++ + for i < len(lines) && lines[i] != "" && !indented(lines[i]) { + i++ + } + end = i + + // If unindented lines are followed (without a blank line) + // by an indented line that would start a code block, + // check whether the final unindented lines + // should be left for the indented section. + // This can happen for the common mistakes of + // unindented code or unindented lists. + // The heuristic will never trigger on a gofmt'ed comment, + // because any gofmt'ed code block would have a blank line + // preceding it after the unindented lines. + if i < len(lines) && lines[i] != "" && !isList(lines[i]) { + switch { + case isList(lines[i-1]): + // If the final unindented line looks like a list item, + // this may be the first indented line wrap of + // a mistakenly unindented list. + // Leave all the unindented list items. + forceIndent = end + end-- + for end > start && isList(lines[end-1]) { + end-- + } + + case strings.HasSuffix(lines[i-1], "{") || strings.HasSuffix(lines[i-1], `\`): + // If the final unindented line ended in { or \ + // it is probably the start of a misindented code block. + // Give the user a single line fix. + // Often that's enough; if not, the user can fix the others themselves. + forceIndent = end + end-- + } + + if start == end && forceIndent > start { + i = start + continue Spans + } + } + + // Span is either paragraph or heading. + if end-start == 1 && isHeading(lines[start]) { + kind = spanHeading + } else if end-start == 1 && isOldHeading(lines[start], lines, start) { + kind = spanOldHeading + } else { + kind = spanPara + } + } + + spans = append(spans, span{start, end, kind}) + i = end + } + + return spans +} + +// indented reports whether line is indented +// (starts with a leading space or tab). +func indented(line string) bool { + return line != "" && (line[0] == ' ' || line[0] == '\t') +} + +// unindent removes any common space/tab prefix +// from each line in lines, returning a copy of lines in which +// those prefixes have been trimmed from each line. +// It also replaces any lines containing only spaces with blank lines (empty strings). +func unindent(lines []string) []string { + // Trim leading and trailing blank lines. + for len(lines) > 0 && isBlank(lines[0]) { + lines = lines[1:] + } + for len(lines) > 0 && isBlank(lines[len(lines)-1]) { + lines = lines[:len(lines)-1] + } + if len(lines) == 0 { + return nil + } + + // Compute and remove common indentation. + prefix := leadingSpace(lines[0]) + for _, line := range lines[1:] { + if !isBlank(line) { + prefix = commonPrefix(prefix, leadingSpace(line)) + } + } + + out := make([]string, len(lines)) + for i, line := range lines { + line = strings.TrimPrefix(line, prefix) + if strings.TrimSpace(line) == "" { + line = "" + } + out[i] = line + } + for len(out) > 0 && out[0] == "" { + out = out[1:] + } + for len(out) > 0 && out[len(out)-1] == "" { + out = out[:len(out)-1] + } + return out +} + +// isBlank reports whether s is a blank line. +func isBlank(s string) bool { + return len(s) == 0 || (len(s) == 1 && s[0] == '\n') +} + +// commonPrefix returns the longest common prefix of a and b. +func commonPrefix(a, b string) string { + i := 0 + for i < len(a) && i < len(b) && a[i] == b[i] { + i++ + } + return a[0:i] +} + +// leadingSpace returns the longest prefix of s consisting of spaces and tabs. +func leadingSpace(s string) string { + i := 0 + for i < len(s) && (s[i] == ' ' || s[i] == '\t') { + i++ + } + return s[:i] +} + +// isOldHeading reports whether line is an old-style section heading. +// line is all[off]. +func isOldHeading(line string, all []string, off int) bool { + if off <= 0 || all[off-1] != "" || off+2 >= len(all) || all[off+1] != "" || leadingSpace(all[off+2]) != "" { + return false + } + + line = strings.TrimSpace(line) + + // a heading must start with an uppercase letter + r, _ := utf8.DecodeRuneInString(line) + if !unicode.IsLetter(r) || !unicode.IsUpper(r) { + return false + } + + // it must end in a letter or digit: + r, _ = utf8.DecodeLastRuneInString(line) + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return false + } + + // exclude lines with illegal characters. we allow "()," + if strings.ContainsAny(line, ";:!?+*/=[]{}_^°&§~%#@<\">\\") { + return false + } + + // allow "'" for possessive "'s" only + for b := line; ; { + var ok bool + if _, b, ok = strings.Cut(b, "'"); !ok { + break + } + if b != "s" && !strings.HasPrefix(b, "s ") { + return false // ' not followed by s and then end-of-word + } + } + + // allow "." when followed by non-space + for b := line; ; { + var ok bool + if _, b, ok = strings.Cut(b, "."); !ok { + break + } + if b == "" || strings.HasPrefix(b, " ") { + return false // not followed by non-space + } + } + + return true +} + +// oldHeading returns the *Heading for the given old-style section heading line. +func (d *parseDoc) oldHeading(line string) Block { + return &Heading{Text: []Text{Plain(strings.TrimSpace(line))}} +} + +// isHeading reports whether line is a new-style section heading. +func isHeading(line string) bool { + return len(line) >= 2 && + line[0] == '#' && + (line[1] == ' ' || line[1] == '\t') && + strings.TrimSpace(line) != "#" +} + +// heading returns the *Heading for the given new-style section heading line. +func (d *parseDoc) heading(line string) Block { + return &Heading{Text: []Text{Plain(strings.TrimSpace(line[1:]))}} +} + +// code returns a code block built from the lines. +func (d *parseDoc) code(lines []string) *Code { + body := unindent(lines) + body = append(body, "") // to get final \n from Join + return &Code{Text: strings.Join(body, "\n")} +} + +// paragraph returns a paragraph block built from the lines. +// If the lines are link definitions, paragraph adds them to d and returns nil. +func (d *parseDoc) paragraph(lines []string) Block { + // Is this a block of known links? Handle. + var defs []*LinkDef + for _, line := range lines { + def, ok := parseLink(line) + if !ok { + goto NoDefs + } + defs = append(defs, def) + } + for _, def := range defs { + d.Links = append(d.Links, def) + if d.links[def.Text] == nil { + d.links[def.Text] = def + } + } + return nil +NoDefs: + + return &Paragraph{Text: []Text{Plain(strings.Join(lines, "\n"))}} +} + +// parseLink parses a single link definition line: +// +// [text]: url +// +// It returns the link definition and whether the line was well formed. +func parseLink(line string) (*LinkDef, bool) { + if line == "" || line[0] != '[' { + return nil, false + } + i := strings.Index(line, "]:") + if i < 0 || i+3 >= len(line) || (line[i+2] != ' ' && line[i+2] != '\t') { + return nil, false + } + + text := line[1:i] + url := strings.TrimSpace(line[i+3:]) + j := strings.Index(url, "://") + if j < 0 || !isScheme(url[:j]) { + return nil, false + } + + // Line has right form and has valid scheme://. + // That's good enough for us - we are not as picky + // about the characters beyond the :// as we are + // when extracting inline URLs from text. + return &LinkDef{Text: text, URL: url}, true +} + +// list returns a list built from the indented lines, +// using forceBlankBefore as the value of the List's ForceBlankBefore field. +func (d *parseDoc) list(lines []string, forceBlankBefore bool) *List { + num, _, _ := listMarker(lines[0]) + var ( + list *List = &List{ForceBlankBefore: forceBlankBefore} + item *ListItem + text []string + ) + flush := func() { + if item != nil { + if para := d.paragraph(text); para != nil { + item.Content = append(item.Content, para) + } + } + text = nil + } + + for _, line := range lines { + if n, after, ok := listMarker(line); ok && (n != "") == (num != "") { + // start new list item + flush() + + item = &ListItem{Number: n} + list.Items = append(list.Items, item) + line = after + } + line = strings.TrimSpace(line) + if line == "" { + list.ForceBlankBetween = true + flush() + continue + } + text = append(text, strings.TrimSpace(line)) + } + flush() + return list +} + +// listMarker parses the line as beginning with a list marker. +// If it can do that, it returns the numeric marker ("" for a bullet list), +// the rest of the line, and ok == true. +// Otherwise, it returns "", "", false. +func listMarker(line string) (num, rest string, ok bool) { + line = strings.TrimSpace(line) + if line == "" { + return "", "", false + } + + // Can we find a marker? + if r, n := utf8.DecodeRuneInString(line); r == '•' || r == '*' || r == '+' || r == '-' { + num, rest = "", line[n:] + } else if '0' <= line[0] && line[0] <= '9' { + n := 1 + for n < len(line) && '0' <= line[n] && line[n] <= '9' { + n++ + } + if n >= len(line) || (line[n] != '.' && line[n] != ')') { + return "", "", false + } + num, rest = line[:n], line[n+1:] + } else { + return "", "", false + } + + if !indented(rest) || strings.TrimSpace(rest) == "" { + return "", "", false + } + + return num, rest, true +} + +// isList reports whether the line is the first line of a list, +// meaning starts with a list marker after any indentation. +// (The caller is responsible for checking the line is indented, as appropriate.) +func isList(line string) bool { + _, _, ok := listMarker(line) + return ok +} + +// parseLinkedText parses text that is allowed to contain explicit links, +// such as [math.Sin] or [Go home page], into a slice of Text items. +// +// A “pkg” is only assumed to be a full import path if it starts with +// a domain name (a path element with a dot) or is one of the packages +// from the standard library (“[os]”, “[encoding/json]”, and so on). +// To avoid problems with maps, generics, and array types, doc links +// must be both preceded and followed by punctuation, spaces, tabs, +// or the start or end of a line. An example problem would be treating +// map[ast.Expr]TypeAndValue as containing a link. +func (d *parseDoc) parseLinkedText(text string) []Text { + var out []Text + wrote := 0 + flush := func(i int) { + if wrote < i { + out = d.parseText(out, text[wrote:i], true) + wrote = i + } + } + + start := -1 + var buf []byte + for i := 0; i < len(text); i++ { + c := text[i] + if c == '\n' || c == '\t' { + c = ' ' + } + switch c { + case '[': + start = i + case ']': + if start >= 0 { + if def, ok := d.links[string(buf)]; ok { + def.Used = true + flush(start) + out = append(out, &Link{ + Text: d.parseText(nil, text[start+1:i], false), + URL: def.URL, + }) + wrote = i + 1 + } else if link, ok := d.docLink(text[start+1:i], text[:start], text[i+1:]); ok { + flush(start) + link.Text = d.parseText(nil, text[start+1:i], false) + out = append(out, link) + wrote = i + 1 + } + } + start = -1 + buf = buf[:0] + } + if start >= 0 && i != start { + buf = append(buf, c) + } + } + + flush(len(text)) + return out +} + +// docLink parses text, which was found inside [ ] brackets, +// as a doc link if possible, returning the DocLink and ok == true +// or else nil, false. +// The before and after strings are the text before the [ and after the ] +// on the same line. Doc links must be preceded and followed by +// punctuation, spaces, tabs, or the start or end of a line. +func (d *parseDoc) docLink(text, before, after string) (link *DocLink, ok bool) { + if before != "" { + r, _ := utf8.DecodeLastRuneInString(before) + if !unicode.IsPunct(r) && r != ' ' && r != '\t' && r != '\n' { + return nil, false + } + } + if after != "" { + r, _ := utf8.DecodeRuneInString(after) + if !unicode.IsPunct(r) && r != ' ' && r != '\t' && r != '\n' { + return nil, false + } + } + text = strings.TrimPrefix(text, "*") + pkg, name, ok := splitDocName(text) + var recv string + if ok { + pkg, recv, _ = splitDocName(pkg) + } + if pkg != "" { + if pkg, ok = d.lookupPkg(pkg); !ok { + return nil, false + } + } else { + if ok = d.lookupSym(recv, name); !ok { + return nil, false + } + } + link = &DocLink{ + ImportPath: pkg, + Recv: recv, + Name: name, + } + return link, true +} + +// If text is of the form before.Name, where Name is a capitalized Go identifier, +// then splitDocName returns before, name, true. +// Otherwise it returns text, "", false. +func splitDocName(text string) (before, name string, foundDot bool) { + i := strings.LastIndex(text, ".") + name = text[i+1:] + if !isName(name) { + return text, "", false + } + if i >= 0 { + before = text[:i] + } + return before, name, true +} + +// parseText parses s as text and returns the result of appending +// those parsed Text elements to out. +// parseText does not handle explicit links like [math.Sin] or [Go home page]: +// those are handled by parseLinkedText. +// If autoLink is true, then parseText recognizes URLs and words from d.Words +// and converts those to links as appropriate. +func (d *parseDoc) parseText(out []Text, s string, autoLink bool) []Text { + var w strings.Builder + wrote := 0 + writeUntil := func(i int) { + w.WriteString(s[wrote:i]) + wrote = i + } + flush := func(i int) { + writeUntil(i) + if w.Len() > 0 { + out = append(out, Plain(w.String())) + w.Reset() + } + } + for i := 0; i < len(s); { + t := s[i:] + if autoLink { + if url, ok := autoURL(t); ok { + flush(i) + // Note: The old comment parser would look up the URL in words + // and replace the target with words[URL] if it was non-empty. + // That would allow creating links that display as one URL but + // when clicked go to a different URL. Not sure what the point + // of that is, so we're not doing that lookup here. + out = append(out, &Link{Auto: true, Text: []Text{Plain(url)}, URL: url}) + i += len(url) + wrote = i + continue + } + if id, ok := ident(t); ok { + url, italics := d.Words[id] + if !italics { + i += len(id) + continue + } + flush(i) + if url == "" { + out = append(out, Italic(id)) + } else { + out = append(out, &Link{Auto: true, Text: []Text{Italic(id)}, URL: url}) + } + i += len(id) + wrote = i + continue + } + } + switch { + case strings.HasPrefix(t, "``"): + if len(t) >= 3 && t[2] == '`' { + // Do not convert `` inside ```, in case people are mistakenly writing Markdown. + i += 3 + for i < len(t) && t[i] == '`' { + i++ + } + break + } + writeUntil(i) + w.WriteRune('“') + i += 2 + wrote = i + case strings.HasPrefix(t, "''"): + writeUntil(i) + w.WriteRune('”') + i += 2 + wrote = i + default: + i++ + } + } + flush(len(s)) + return out +} + +// autoURL checks whether s begins with a URL that should be hyperlinked. +// If so, it returns the URL, which is a prefix of s, and ok == true. +// Otherwise it returns "", false. +// The caller should skip over the first len(url) bytes of s +// before further processing. +func autoURL(s string) (url string, ok bool) { + // Find the ://. Fast path to pick off non-URL, + // since we call this at every position in the string. + // The shortest possible URL is ftp://x, 7 bytes. + var i int + switch { + case len(s) < 7: + return "", false + case s[3] == ':': + i = 3 + case s[4] == ':': + i = 4 + case s[5] == ':': + i = 5 + case s[6] == ':': + i = 6 + default: + return "", false + } + if i+3 > len(s) || s[i:i+3] != "://" { + return "", false + } + + // Check valid scheme. + if !isScheme(s[:i]) { + return "", false + } + + // Scan host part. Must have at least one byte, + // and must start and end in non-punctuation. + i += 3 + if i >= len(s) || !isHost(s[i]) || isPunct(s[i]) { + return "", false + } + i++ + end := i + for i < len(s) && isHost(s[i]) { + if !isPunct(s[i]) { + end = i + 1 + } + i++ + } + i = end + + // At this point we are definitely returning a URL (scheme://host). + // We just have to find the longest path we can add to it. + // Heuristics abound. + // We allow parens, braces, and brackets, + // but only if they match (#5043, #22285). + // We allow .,:;?! in the path but not at the end, + // to avoid end-of-sentence punctuation (#18139, #16565). + stk := []byte{} + end = i +Path: + for ; i < len(s); i++ { + if isPunct(s[i]) { + continue + } + if !isPath(s[i]) { + break + } + switch s[i] { + case '(': + stk = append(stk, ')') + case '{': + stk = append(stk, '}') + case '[': + stk = append(stk, ']') + case ')', '}', ']': + if len(stk) == 0 || stk[len(stk)-1] != s[i] { + break Path + } + stk = stk[:len(stk)-1] + } + if len(stk) == 0 { + end = i + 1 + } + } + + return s[:end], true +} + +// isScheme reports whether s is a recognized URL scheme. +// Note that if strings of new length (beyond 3-7) +// are added here, the fast path at the top of autoURL will need updating. +func isScheme(s string) bool { + switch s { + case "file", + "ftp", + "gopher", + "http", + "https", + "mailto", + "nntp": + return true + } + return false +} + +// isHost reports whether c is a byte that can appear in a URL host, +// like www.example.com or user@[::1]:8080 +func isHost(c byte) bool { + // mask is a 128-bit bitmap with 1s for allowed bytes, + // so that the byte c can be tested with a shift and an and. + // If c > 128, then 1<>64)) != 0 +} + +// isPunct reports whether c is a punctuation byte that can appear +// inside a path but not at the end. +func isPunct(c byte) bool { + // mask is a 128-bit bitmap with 1s for allowed bytes, + // so that the byte c can be tested with a shift and an and. + // If c > 128, then 1<>64)) != 0 +} + +// isPath reports whether c is a (non-punctuation) path byte. +func isPath(c byte) bool { + // mask is a 128-bit bitmap with 1s for allowed bytes, + // so that the byte c can be tested with a shift and an and. + // If c > 128, then 1<>64)) != 0 +} + +// isName reports whether s is a capitalized Go identifier (like Name). +func isName(s string) bool { + t, ok := ident(s) + if !ok || t != s { + return false + } + r, _ := utf8.DecodeRuneInString(s) + return unicode.IsUpper(r) +} + +// ident checks whether s begins with a Go identifier. +// If so, it returns the identifier, which is a prefix of s, and ok == true. +// Otherwise it returns "", false. +// The caller should skip over the first len(id) bytes of s +// before further processing. +func ident(s string) (id string, ok bool) { + // Scan [\pL_][\pL_0-9]* + n := 0 + for n < len(s) { + if c := s[n]; c < utf8.RuneSelf { + if isIdentASCII(c) && (n > 0 || c < '0' || c > '9') { + n++ + continue + } + break + } + r, nr := utf8.DecodeRuneInString(s[n:]) + if unicode.IsLetter(r) { + n += nr + continue + } + break + } + return s[:n], n > 0 +} + +// isIdentASCII reports whether c is an ASCII identifier byte. +func isIdentASCII(c byte) bool { + // mask is a 128-bit bitmap with 1s for allowed bytes, + // so that the byte c can be tested with a shift and an and. + // If c > 128, then 1<>64)) != 0 +} + +// validImportPath reports whether path is a valid import path. +// It is a lightly edited copy of golang.org/x/mod/module.CheckImportPath. +func validImportPath(path string) bool { + if !utf8.ValidString(path) { + return false + } + if path == "" { + return false + } + if path[0] == '-' { + return false + } + if strings.Contains(path, "//") { + return false + } + if path[len(path)-1] == '/' { + return false + } + elemStart := 0 + for i, r := range path { + if r == '/' { + if !validImportPathElem(path[elemStart:i]) { + return false + } + elemStart = i + 1 + } + } + return validImportPathElem(path[elemStart:]) +} + +func validImportPathElem(elem string) bool { + if elem == "" || elem[0] == '.' || elem[len(elem)-1] == '.' { + return false + } + for i := 0; i < len(elem); i++ { + if !importPathOK(elem[i]) { + return false + } + } + return true +} + +func importPathOK(c byte) bool { + // mask is a 128-bit bitmap with 1s for allowed bytes, + // so that the byte c can be tested with a shift and an and. + // If c > 128, then 1<>64)) != 0 +} diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/print.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/print.go new file mode 100644 index 000000000..a6ae8210b --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/print.go @@ -0,0 +1,288 @@ +// Copyright 2022 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 comment + +import ( + "bytes" + "fmt" + "strings" +) + +// A Printer is a doc comment printer. +// The fields in the struct can be filled in before calling +// any of the printing methods +// in order to customize the details of the printing process. +type Printer struct { + // HeadingLevel is the nesting level used for + // HTML and Markdown headings. + // If HeadingLevel is zero, it defaults to level 3, + // meaning to use

and ###. + HeadingLevel int + + // HeadingID is a function that computes the heading ID + // (anchor tag) to use for the heading h when generating + // HTML and Markdown. If HeadingID returns an empty string, + // then the heading ID is omitted. + // If HeadingID is nil, h.DefaultID is used. + HeadingID func(h *Heading) string + + // DocLinkURL is a function that computes the URL for the given DocLink. + // If DocLinkURL is nil, then link.DefaultURL(p.DocLinkBaseURL) is used. + DocLinkURL func(link *DocLink) string + + // DocLinkBaseURL is used when DocLinkURL is nil, + // passed to [DocLink.DefaultURL] to construct a DocLink's URL. + // See that method's documentation for details. + DocLinkBaseURL string + + // TextPrefix is a prefix to print at the start of every line + // when generating text output using the Text method. + TextPrefix string + + // TextCodePrefix is the prefix to print at the start of each + // preformatted (code block) line when generating text output, + // instead of (not in addition to) TextPrefix. + // If TextCodePrefix is the empty string, it defaults to TextPrefix+"\t". + TextCodePrefix string + + // TextWidth is the maximum width text line to generate, + // measured in Unicode code points, + // excluding TextPrefix and the newline character. + // If TextWidth is zero, it defaults to 80 minus the number of code points in TextPrefix. + // If TextWidth is negative, there is no limit. + TextWidth int +} + +func (p *Printer) headingLevel() int { + if p.HeadingLevel <= 0 { + return 3 + } + return p.HeadingLevel +} + +func (p *Printer) headingID(h *Heading) string { + if p.HeadingID == nil { + return h.DefaultID() + } + return p.HeadingID(h) +} + +func (p *Printer) docLinkURL(link *DocLink) string { + if p.DocLinkURL != nil { + return p.DocLinkURL(link) + } + return link.DefaultURL(p.DocLinkBaseURL) +} + +// DefaultURL constructs and returns the documentation URL for l, +// using baseURL as a prefix for links to other packages. +// +// The possible forms returned by DefaultURL are: +// - baseURL/ImportPath, for a link to another package +// - baseURL/ImportPath#Name, for a link to a const, func, type, or var in another package +// - baseURL/ImportPath#Recv.Name, for a link to a method in another package +// - #Name, for a link to a const, func, type, or var in this package +// - #Recv.Name, for a link to a method in this package +// +// If baseURL ends in a trailing slash, then DefaultURL inserts +// a slash between ImportPath and # in the anchored forms. +// For example, here are some baseURL values and URLs they can generate: +// +// "/pkg/" → "/pkg/math/#Sqrt" +// "/pkg" → "/pkg/math#Sqrt" +// "/" → "/math/#Sqrt" +// "" → "/math#Sqrt" +func (l *DocLink) DefaultURL(baseURL string) string { + if l.ImportPath != "" { + slash := "" + if strings.HasSuffix(baseURL, "/") { + slash = "/" + } else { + baseURL += "/" + } + switch { + case l.Name == "": + return baseURL + l.ImportPath + slash + case l.Recv != "": + return baseURL + l.ImportPath + slash + "#" + l.Recv + "." + l.Name + default: + return baseURL + l.ImportPath + slash + "#" + l.Name + } + } + if l.Recv != "" { + return "#" + l.Recv + "." + l.Name + } + return "#" + l.Name +} + +// DefaultID returns the default anchor ID for the heading h. +// +// The default anchor ID is constructed by converting every +// rune that is not alphanumeric ASCII to an underscore +// and then adding the prefix “hdr-”. +// For example, if the heading text is “Go Doc Comments”, +// the default ID is “hdr-Go_Doc_Comments”. +func (h *Heading) DefaultID() string { + // Note: The “hdr-” prefix is important to avoid DOM clobbering attacks. + // See https://pkg.go.dev/github.com/google/safehtml#Identifier. + var out strings.Builder + var p textPrinter + p.oneLongLine(&out, h.Text) + s := strings.TrimSpace(out.String()) + if s == "" { + return "" + } + out.Reset() + out.WriteString("hdr-") + for _, r := range s { + if r < 0x80 && isIdentASCII(byte(r)) { + out.WriteByte(byte(r)) + } else { + out.WriteByte('_') + } + } + return out.String() +} + +type commentPrinter struct { + *Printer +} + +// Comment returns the standard Go formatting of the [Doc], +// without any comment markers. +func (p *Printer) Comment(d *Doc) []byte { + cp := &commentPrinter{Printer: p} + var out bytes.Buffer + for i, x := range d.Content { + if i > 0 && blankBefore(x) { + out.WriteString("\n") + } + cp.block(&out, x) + } + + // Print one block containing all the link definitions that were used, + // and then a second block containing all the unused ones. + // This makes it easy to clean up the unused ones: gofmt and + // delete the final block. And it's a nice visual signal without + // affecting the way the comment formats for users. + for i := 0; i < 2; i++ { + used := i == 0 + first := true + for _, def := range d.Links { + if def.Used == used { + if first { + out.WriteString("\n") + first = false + } + out.WriteString("[") + out.WriteString(def.Text) + out.WriteString("]: ") + out.WriteString(def.URL) + out.WriteString("\n") + } + } + } + + return out.Bytes() +} + +// blankBefore reports whether the block x requires a blank line before it. +// All blocks do, except for Lists that return false from x.BlankBefore(). +func blankBefore(x Block) bool { + if x, ok := x.(*List); ok { + return x.BlankBefore() + } + return true +} + +// block prints the block x to out. +func (p *commentPrinter) block(out *bytes.Buffer, x Block) { + switch x := x.(type) { + default: + fmt.Fprintf(out, "?%T", x) + + case *Paragraph: + p.text(out, "", x.Text) + out.WriteString("\n") + + case *Heading: + out.WriteString("# ") + p.text(out, "", x.Text) + out.WriteString("\n") + + case *Code: + md := x.Text + for md != "" { + var line string + line, md, _ = strings.Cut(md, "\n") + if line != "" { + out.WriteString("\t") + out.WriteString(line) + } + out.WriteString("\n") + } + + case *List: + loose := x.BlankBetween() + for i, item := range x.Items { + if i > 0 && loose { + out.WriteString("\n") + } + out.WriteString(" ") + if item.Number == "" { + out.WriteString(" - ") + } else { + out.WriteString(item.Number) + out.WriteString(". ") + } + for i, blk := range item.Content { + const fourSpace = " " + if i > 0 { + out.WriteString("\n" + fourSpace) + } + p.text(out, fourSpace, blk.(*Paragraph).Text) + out.WriteString("\n") + } + } + } +} + +// text prints the text sequence x to out. +func (p *commentPrinter) text(out *bytes.Buffer, indent string, x []Text) { + for _, t := range x { + switch t := t.(type) { + case Plain: + p.indent(out, indent, string(t)) + case Italic: + p.indent(out, indent, string(t)) + case *Link: + if t.Auto { + p.text(out, indent, t.Text) + } else { + out.WriteString("[") + p.text(out, indent, t.Text) + out.WriteString("]") + } + case *DocLink: + out.WriteString("[") + p.text(out, indent, t.Text) + out.WriteString("]") + } + } +} + +// indent prints s to out, indenting with the indent string +// after each newline in s. +func (p *commentPrinter) indent(out *bytes.Buffer, indent, s string) { + for s != "" { + line, rest, ok := strings.Cut(s, "\n") + out.WriteString(line) + if ok { + out.WriteString("\n") + out.WriteString(indent) + } + s = rest + } +} diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/std.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/std.go new file mode 100644 index 000000000..f73690a75 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/std.go @@ -0,0 +1,51 @@ +// Copyright 2022 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. + +// Code generated by 'go generate' DO NOT EDIT. +//disabled go:generate ./mkstd.sh + +package comment + +var stdPkgs = []string{ + "bufio", + "bytes", + "cmp", + "context", + "crypto", + "embed", + "encoding", + "errors", + "expvar", + "flag", + "fmt", + "hash", + "html", + "image", + "io", + "iter", + "log", + "maps", + "math", + "mime", + "net", + "os", + "path", + "plugin", + "reflect", + "regexp", + "runtime", + "slices", + "sort", + "strconv", + "strings", + "structs", + "sync", + "syscall", + "testing", + "time", + "unicode", + "unique", + "unsafe", + "weak", +} diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/text.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/text.go new file mode 100644 index 000000000..4e4214e08 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/doc/comment/text.go @@ -0,0 +1,337 @@ +// Copyright 2022 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 comment + +import ( + "bytes" + "fmt" + "sort" + "strings" + "unicode/utf8" +) + +// A textPrinter holds the state needed for printing a Doc as plain text. +type textPrinter struct { + *Printer + long strings.Builder + prefix string + codePrefix string + width int +} + +// Text returns a textual formatting of the [Doc]. +// See the [Printer] documentation for ways to customize the text output. +func (p *Printer) Text(d *Doc) []byte { + tp := &textPrinter{ + Printer: p, + prefix: p.TextPrefix, + codePrefix: p.TextCodePrefix, + width: p.TextWidth, + } + if tp.codePrefix == "" { + tp.codePrefix = p.TextPrefix + "\t" + } + if tp.width == 0 { + tp.width = 80 - utf8.RuneCountInString(tp.prefix) + } + + var out bytes.Buffer + for i, x := range d.Content { + if i > 0 && blankBefore(x) { + out.WriteString(tp.prefix) + writeNL(&out) + } + tp.block(&out, x) + } + anyUsed := false + for _, def := range d.Links { + if def.Used { + anyUsed = true + break + } + } + if anyUsed { + writeNL(&out) + for _, def := range d.Links { + if def.Used { + fmt.Fprintf(&out, "[%s]: %s\n", def.Text, def.URL) + } + } + } + return out.Bytes() +} + +// writeNL calls out.WriteByte('\n') +// but first trims trailing spaces on the previous line. +func writeNL(out *bytes.Buffer) { + // Trim trailing spaces. + data := out.Bytes() + n := 0 + for n < len(data) && (data[len(data)-n-1] == ' ' || data[len(data)-n-1] == '\t') { + n++ + } + if n > 0 { + out.Truncate(len(data) - n) + } + out.WriteByte('\n') +} + +// block prints the block x to out. +func (p *textPrinter) block(out *bytes.Buffer, x Block) { + switch x := x.(type) { + default: + fmt.Fprintf(out, "?%T\n", x) + + case *Paragraph: + out.WriteString(p.prefix) + p.text(out, "", x.Text) + + case *Heading: + out.WriteString(p.prefix) + out.WriteString("# ") + p.text(out, "", x.Text) + + case *Code: + text := x.Text + for text != "" { + var line string + line, text, _ = strings.Cut(text, "\n") + if line != "" { + out.WriteString(p.codePrefix) + out.WriteString(line) + } + writeNL(out) + } + + case *List: + loose := x.BlankBetween() + for i, item := range x.Items { + if i > 0 && loose { + out.WriteString(p.prefix) + writeNL(out) + } + out.WriteString(p.prefix) + out.WriteString(" ") + if item.Number == "" { + out.WriteString(" - ") + } else { + out.WriteString(item.Number) + out.WriteString(". ") + } + for i, blk := range item.Content { + const fourSpace = " " + if i > 0 { + writeNL(out) + out.WriteString(p.prefix) + out.WriteString(fourSpace) + } + p.text(out, fourSpace, blk.(*Paragraph).Text) + } + } + } +} + +// text prints the text sequence x to out. +func (p *textPrinter) text(out *bytes.Buffer, indent string, x []Text) { + p.oneLongLine(&p.long, x) + words := strings.Fields(p.long.String()) + p.long.Reset() + + var seq []int + if p.width < 0 || len(words) == 0 { + seq = []int{0, len(words)} // one long line + } else { + seq = wrap(words, p.width-utf8.RuneCountInString(indent)) + } + for i := 0; i+1 < len(seq); i++ { + if i > 0 { + out.WriteString(p.prefix) + out.WriteString(indent) + } + for j, w := range words[seq[i]:seq[i+1]] { + if j > 0 { + out.WriteString(" ") + } + out.WriteString(w) + } + writeNL(out) + } +} + +// oneLongLine prints the text sequence x to out as one long line, +// without worrying about line wrapping. +// Explicit links have the [ ] dropped to improve readability. +func (p *textPrinter) oneLongLine(out *strings.Builder, x []Text) { + for _, t := range x { + switch t := t.(type) { + case Plain: + out.WriteString(string(t)) + case Italic: + out.WriteString(string(t)) + case *Link: + p.oneLongLine(out, t.Text) + case *DocLink: + p.oneLongLine(out, t.Text) + } + } +} + +// wrap wraps words into lines of at most max runes, +// minimizing the sum of the squares of the leftover lengths +// at the end of each line (except the last, of course), +// with a preference for ending lines at punctuation (.,:;). +// +// The returned slice gives the indexes of the first words +// on each line in the wrapped text with a final entry of len(words). +// Thus the lines are words[seq[0]:seq[1]], words[seq[1]:seq[2]], +// ..., words[seq[len(seq)-2]:seq[len(seq)-1]]. +// +// The implementation runs in O(n log n) time, where n = len(words), +// using the algorithm described in D. S. Hirschberg and L. L. Larmore, +// “[The least weight subsequence problem],” FOCS 1985, pp. 137-143. +// +// [The least weight subsequence problem]: https://doi.org/10.1109/SFCS.1985.60 +func wrap(words []string, max int) (seq []int) { + // The algorithm requires that our scoring function be concave, + // meaning that for all i₀ ≤ i₁ < j₀ ≤ j₁, + // weight(i₀, j₀) + weight(i₁, j₁) ≤ weight(i₀, j₁) + weight(i₁, j₀). + // + // Our weights are two-element pairs [hi, lo] + // ordered by elementwise comparison. + // The hi entry counts the weight for lines that are longer than max, + // and the lo entry counts the weight for lines that are not. + // This forces the algorithm to first minimize the number of lines + // that are longer than max, which correspond to lines with + // single very long words. Having done that, it can move on to + // minimizing the lo score, which is more interesting. + // + // The lo score is the sum for each line of the square of the + // number of spaces remaining at the end of the line and a + // penalty of 64 given out for not ending the line in a + // punctuation character (.,:;). + // The penalty is somewhat arbitrarily chosen by trying + // different amounts and judging how nice the wrapped text looks. + // Roughly speaking, using 64 means that we are willing to + // end a line with eight blank spaces in order to end at a + // punctuation character, even if the next word would fit in + // those spaces. + // + // We care about ending in punctuation characters because + // it makes the text easier to skim if not too many sentences + // or phrases begin with a single word on the previous line. + + // A score is the score (also called weight) for a given line. + // add and cmp add and compare scores. + type score struct { + hi int64 + lo int64 + } + add := func(s, t score) score { return score{s.hi + t.hi, s.lo + t.lo} } + cmp := func(s, t score) int { + switch { + case s.hi < t.hi: + return -1 + case s.hi > t.hi: + return +1 + case s.lo < t.lo: + return -1 + case s.lo > t.lo: + return +1 + } + return 0 + } + + // total[j] is the total number of runes + // (including separating spaces) in words[:j]. + total := make([]int, len(words)+1) + total[0] = 0 + for i, s := range words { + total[1+i] = total[i] + utf8.RuneCountInString(s) + 1 + } + + // weight returns weight(i, j). + weight := func(i, j int) score { + // On the last line, there is zero weight for being too short. + n := total[j] - 1 - total[i] + if j == len(words) && n <= max { + return score{0, 0} + } + + // Otherwise the weight is the penalty plus the square of the number of + // characters remaining on the line or by which the line goes over. + // In the latter case, that value goes in the hi part of the score. + // (See note above.) + p := wrapPenalty(words[j-1]) + v := int64(max-n) * int64(max-n) + if n > max { + return score{v, p} + } + return score{0, v + p} + } + + // The rest of this function is “The Basic Algorithm” from + // Hirschberg and Larmore's conference paper, + // using the same names as in the paper. + f := []score{{0, 0}} + g := func(i, j int) score { return add(f[i], weight(i, j)) } + + bridge := func(a, b, c int) bool { + k := c + sort.Search(len(words)+1-c, func(k int) bool { + k += c + return cmp(g(a, k), g(b, k)) > 0 + }) + if k > len(words) { + return true + } + return cmp(g(c, k), g(b, k)) <= 0 + } + + // d is a one-ended deque implemented as a slice. + d := make([]int, 1, len(words)) + d[0] = 0 + bestleft := make([]int, 1, len(words)) + bestleft[0] = -1 + for m := 1; m < len(words); m++ { + f = append(f, g(d[0], m)) + bestleft = append(bestleft, d[0]) + for len(d) > 1 && cmp(g(d[1], m+1), g(d[0], m+1)) <= 0 { + d = d[1:] // “Retire” + } + for len(d) > 1 && bridge(d[len(d)-2], d[len(d)-1], m) { + d = d[:len(d)-1] // “Fire” + } + if cmp(g(m, len(words)), g(d[len(d)-1], len(words))) < 0 { + d = append(d, m) // “Hire” + // The next few lines are not in the paper but are necessary + // to handle two-word inputs correctly. It appears to be + // just a bug in the paper's pseudocode. + if len(d) == 2 && cmp(g(d[1], m+1), g(d[0], m+1)) <= 0 { + d = d[1:] + } + } + } + bestleft = append(bestleft, d[0]) + + // Recover least weight sequence from bestleft. + n := 1 + for m := len(words); m > 0; m = bestleft[m] { + n++ + } + seq = make([]int, n) + for m := len(words); m > 0; m = bestleft[m] { + n-- + seq[n] = m + } + return seq +} + +// wrapPenalty is the penalty for inserting a line break after word s. +func wrapPenalty(s string) int64 { + switch s[len(s)-1] { + case '.', ',', ':', ';': + return 0 + } + return 64 +} diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/format/format.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/format/format.go new file mode 100644 index 000000000..63e65e905 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/format/format.go @@ -0,0 +1,134 @@ +// Copyright 2012 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 format implements standard formatting of Go source. +// +// Note that formatting of Go source code changes over time, so tools relying on +// consistent formatting should execute a specific version of the gofmt binary +// instead of using this package. That way, the formatting will be stable, and +// the tools won't need to be recompiled each time gofmt changes. +// +// For example, pre-submit checks that use this package directly would behave +// differently depending on what Go version each developer uses, causing the +// check to be inherently fragile. +package format + +import ( + "bytes" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + + "mvdan.cc/gofumpt/internal/govendor/go/printer" +) + +// Keep these in sync with cmd/gofmt/gofmt.go. +const ( + tabWidth = 8 + printerMode = printer.UseSpaces | printer.TabIndent | printerNormalizeNumbers + + // printerNormalizeNumbers means to canonicalize number literal prefixes + // and exponents while printing. See https://golang.org/doc/go1.13#gofmt. + // + // This value is defined in mvdan.cc/gofumpt/internal/govendor/go/printer specifically for mvdan.cc/gofumpt/internal/govendor/go/format and cmd/gofmt. + printerNormalizeNumbers = 1 << 30 +) + +var config = printer.Config{Mode: printerMode, Tabwidth: tabWidth} + +const parserMode = parser.ParseComments | parser.SkipObjectResolution + +// Node formats node in canonical gofmt style and writes the result to dst. +// +// The node type must be *[ast.File], *[printer.CommentedNode], [][ast.Decl], +// [][ast.Stmt], or assignment-compatible to [ast.Expr], [ast.Decl], [ast.Spec], +// or [ast.Stmt]. Node does not modify node. Imports are not sorted for +// nodes representing partial source files (for instance, if the node is +// not an *[ast.File] or a *[printer.CommentedNode] not wrapping an *[ast.File]). +// +// The function may return early (before the entire result is written) +// and return a formatting error, for instance due to an incorrect AST. +func Node(dst io.Writer, fset *token.FileSet, node any) error { + // Determine if we have a complete source file (file != nil). + var file *ast.File + var cnode *printer.CommentedNode + switch n := node.(type) { + case *ast.File: + file = n + case *printer.CommentedNode: + if f, ok := n.Node.(*ast.File); ok { + file = f + cnode = n + } + } + + // Sort imports if necessary. + if file != nil && hasUnsortedImports(file) { + // Make a copy of the AST because ast.SortImports is destructive. + // TODO(gri) Do this more efficiently. + var buf bytes.Buffer + err := config.Fprint(&buf, fset, file) + if err != nil { + return err + } + file, err = parser.ParseFile(fset, "", buf.Bytes(), parserMode) + if err != nil { + // We should never get here. If we do, provide good diagnostic. + return fmt.Errorf("format.Node internal error (%s)", err) + } + ast.SortImports(fset, file) + + // Use new file with sorted imports. + node = file + if cnode != nil { + node = &printer.CommentedNode{Node: file, Comments: cnode.Comments} + } + } + + return config.Fprint(dst, fset, node) +} + +// Source formats src in canonical gofmt style and returns the result +// or an (I/O or syntax) error. src is expected to be a syntactically +// correct Go source file, or a list of Go declarations or statements. +// +// If src is a partial source file, the leading and trailing space of src +// is applied to the result (such that it has the same leading and trailing +// space as src), and the result is indented by the same amount as the first +// line of src containing code. Imports are not sorted for partial source files. +func Source(src []byte) ([]byte, error) { + fset := token.NewFileSet() + file, sourceAdj, indentAdj, err := parse(fset, "", src, true) + if err != nil { + return nil, err + } + + if sourceAdj == nil { + // Complete source file. + // TODO(gri) consider doing this always. + ast.SortImports(fset, file) + } + + return format(fset, file, sourceAdj, indentAdj, src, config) +} + +func hasUnsortedImports(file *ast.File) bool { + for _, d := range file.Decls { + d, ok := d.(*ast.GenDecl) + if !ok || d.Tok != token.IMPORT { + // Not an import declaration, so we're done. + // Imports are always first. + return false + } + if d.Lparen.IsValid() { + // For now assume all grouped imports are unsorted. + // TODO(gri) Should check if they are sorted already. + return true + } + // Ungrouped imports are sorted by default. + } + return false +} diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/format/internal.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/format/internal.go new file mode 100644 index 000000000..383655f16 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/format/internal.go @@ -0,0 +1,177 @@ +// Copyright 2015 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. + +// TODO(gri): This file and the file src/cmd/gofmt/internal.go are +// the same (but for this comment and the package name). Do not modify +// one without the other. Determine if we can factor out functionality +// in a public API. See also #11844 for context. + +package format + +import ( + "bytes" + "go/ast" + "go/parser" + "go/token" + "strings" + + "mvdan.cc/gofumpt/internal/govendor/go/printer" +) + +// parse parses src, which was read from the named file, +// as a Go source file, declaration, or statement list. +func parse(fset *token.FileSet, filename string, src []byte, fragmentOk bool) ( + file *ast.File, + sourceAdj func(src []byte, indent int) []byte, + indentAdj int, + err error, +) { + // Try as whole source file. + file, err = parser.ParseFile(fset, filename, src, parserMode) + // If there's no error, return. If the error is that the source file didn't begin with a + // 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 + } + + // If this is a declaration list, make it a source file + // by inserting a package clause. + // Insert using a ';', not a newline, so that the line numbers + // in psrc match the ones in src. + psrc := append([]byte("package p;"), src...) + file, err = parser.ParseFile(fset, filename, psrc, parserMode) + if err == nil { + sourceAdj = func(src []byte, indent int) []byte { + // Remove the package clause. + // Gofmt has turned the ';' into a '\n'. + src = src[indent+len("package p\n"):] + return bytes.TrimSpace(src) + } + return file, sourceAdj, indentAdj, err + } + // 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 + } + + // If this is a statement list, make it a source file + // by inserting a package clause and turning the list + // into a function body. This handles expressions too. + // Insert using a ';', not a newline, so that the line numbers + // in fsrc match the ones in src. Add an extra '\n' before the '}' + // to make sure comments are flushed before the '}'. + fsrc := append(append([]byte("package p; func _() {"), src...), '\n', '\n', '}') + file, err = parser.ParseFile(fset, filename, fsrc, parserMode) + if err == nil { + sourceAdj = func(src []byte, indent int) []byte { + // Cap adjusted indent to zero. + if indent < 0 { + indent = 0 + } + // Remove the wrapping. + // Gofmt has turned the "; " into a "\n\n". + // There will be two non-blank lines with indent, hence 2*indent. + src = src[2*indent+len("package p\n\nfunc _() {"):] + // Remove only the "}\n" suffix: remaining whitespaces will be trimmed anyway + src = src[:len(src)-len("}\n")] + return bytes.TrimSpace(src) + } + // Gofmt has also indented the function body one level. + // Adjust that with indentAdj. + indentAdj = -1 + } + + // Succeeded, or out of options. + return file, sourceAdj, indentAdj, err +} + +// format formats the given package file originally obtained from src +// and adjusts the result based on the original source via sourceAdj +// and indentAdj. +func format( + fset *token.FileSet, + file *ast.File, + sourceAdj func(src []byte, indent int) []byte, + indentAdj int, + src []byte, + cfg printer.Config, +) ([]byte, error) { + if sourceAdj == nil { + // Complete source file. + var buf bytes.Buffer + err := cfg.Fprint(&buf, fset, file) + if err != nil { + return nil, err + } + return buf.Bytes(), nil + } + + // Partial source file. + // Determine and prepend leading space. + i, j := 0, 0 + for j < len(src) && isSpace(src[j]) { + if src[j] == '\n' { + i = j + 1 // byte offset of last line in leading space + } + j++ + } + var res []byte + res = append(res, src[:i]...) + + // Determine and prepend indentation of first code line. + // Spaces are ignored unless there are no tabs, + // in which case spaces count as one tab. + indent := 0 + hasSpace := false + for _, b := range src[i:j] { + switch b { + case ' ': + hasSpace = true + case '\t': + indent++ + } + } + if indent == 0 && hasSpace { + indent = 1 + } + for i := 0; i < indent; i++ { + res = append(res, '\t') + } + + // Format the source. + // Write it without any leading and trailing space. + cfg.Indent = indent + indentAdj + var buf bytes.Buffer + err := cfg.Fprint(&buf, fset, file) + if err != nil { + return nil, err + } + out := sourceAdj(buf.Bytes(), cfg.Indent) + + // If the adjusted output is empty, the source + // was empty but (possibly) for white space. + // The result is the incoming source. + if len(out) == 0 { + return src, nil + } + + // Otherwise, append output to leading space. + res = append(res, out...) + + // Determine and append trailing space. + i = len(src) + for i > 0 && isSpace(src[i-1]) { + i-- + } + return append(res, src[i:]...), nil +} + +// isSpace reports whether the byte is a space character. +// isSpace defines a space as being among the following bytes: ' ', '\t', '\n' and '\r'. +func isSpace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/comment.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/comment.go new file mode 100644 index 000000000..1f0e7df9d --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/comment.go @@ -0,0 +1,156 @@ +// Copyright 2022 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 printer + +import ( + "go/ast" + "strings" + + "mvdan.cc/gofumpt/internal/govendor/go/doc/comment" +) + +// formatDocComment reformats the doc comment list, +// returning the canonical formatting. +func formatDocComment(list []*ast.Comment) []*ast.Comment { + // Extract comment text (removing comment markers). + var kind, text string + var directives []*ast.Comment + if len(list) == 1 && strings.HasPrefix(list[0].Text, "/*") { + kind = "/*" + text = list[0].Text + if !strings.Contains(text, "\n") || allStars(text) { + // Single-line /* .. */ comment in doc comment position, + // or multiline old-style comment like + // /* + // * Comment + // * text here. + // */ + // Should not happen, since it will not work well as a + // doc comment, but if it does, just ignore: + // reformatting it will only make the situation worse. + return list + } + text = text[2 : len(text)-2] // cut /* and */ + } else if strings.HasPrefix(list[0].Text, "//") { + kind = "//" + var b strings.Builder + for _, c := range list { + after, found := strings.CutPrefix(c.Text, "//") + if !found { + return list + } + // Accumulate //go:build etc lines separately. + if isDirective(after) { + directives = append(directives, c) + continue + } + b.WriteString(strings.TrimPrefix(after, " ")) + b.WriteString("\n") + } + text = b.String() + } else { + // Not sure what this is, so leave alone. + return list + } + + if text == "" { + return list + } + + // Parse comment and reformat as text. + var p comment.Parser + d := p.Parse(text) + + var pr comment.Printer + text = string(pr.Comment(d)) + + // For /* */ comment, return one big comment with text inside. + slash := list[0].Slash + if kind == "/*" { + c := &ast.Comment{ + Slash: slash, + Text: "/*\n" + text + "*/", + } + return []*ast.Comment{c} + } + + // For // comment, return sequence of // lines. + var out []*ast.Comment + for text != "" { + var line string + line, text, _ = strings.Cut(text, "\n") + if line == "" { + line = "//" + } else if strings.HasPrefix(line, "\t") { + line = "//" + line + } else { + line = "// " + line + } + out = append(out, &ast.Comment{ + Slash: slash, + Text: line, + }) + } + if len(directives) > 0 { + out = append(out, &ast.Comment{ + Slash: slash, + Text: "//", + }) + for _, c := range directives { + out = append(out, &ast.Comment{ + Slash: slash, + Text: c.Text, + }) + } + } + return out +} + +// isDirective reports whether c is a comment directive. +// See go.dev/issue/37974. +// This code is also in go/ast. +func isDirective(c string) bool { + // "//line " is a line directive. + // "//extern " is for gccgo. + // "//export " is for cgo. + // (The // has been removed.) + if strings.HasPrefix(c, "line ") || strings.HasPrefix(c, "extern ") || strings.HasPrefix(c, "export ") { + return true + } + + // "//[a-z0-9]+:[a-z0-9]" + // (The // has been removed.) + colon := strings.Index(c, ":") + if colon <= 0 || colon+1 >= len(c) { + return false + } + for i := 0; i <= colon+1; i++ { + if i == colon { + continue + } + b := c[i] + if !('a' <= b && b <= 'z' || '0' <= b && b <= '9') { + return false + } + } + return true +} + +// allStars reports whether text is the interior of an +// old-style /* */ comment with a star at the start of each line. +func allStars(text string) bool { + for i := 0; i < len(text); i++ { + if text[i] == '\n' { + j := i + 1 + for j < len(text) && (text[j] == ' ' || text[j] == '\t') { + j++ + } + if j < len(text) && text[j] != '*' { + return false + } + } + } + return true +} diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/gobuild.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/gobuild.go new file mode 100644 index 000000000..6f04cf6d6 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/gobuild.go @@ -0,0 +1,170 @@ +// 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 printer + +import ( + "go/build/constraint" + "slices" + "text/tabwriter" +) + +func (p *printer) fixGoBuildLines() { + if len(p.goBuild)+len(p.plusBuild) == 0 { + return + } + + // Find latest possible placement of //go:build and // +build comments. + // That's just after the last blank line before we find a non-comment. + // (We'll add another blank line after our comment block.) + // When we start dropping // +build comments, we can skip over /* */ comments too. + // Note that we are processing tabwriter input, so every comment + // begins and ends with a tabwriter.Escape byte. + // And some newlines have turned into \f bytes. + insert := 0 + for pos := 0; ; { + // Skip leading space at beginning of line. + blank := true + for pos < len(p.output) && (p.output[pos] == ' ' || p.output[pos] == '\t') { + pos++ + } + // Skip over // comment if any. + if pos+3 < len(p.output) && p.output[pos] == tabwriter.Escape && p.output[pos+1] == '/' && p.output[pos+2] == '/' { + blank = false + for pos < len(p.output) && !isNL(p.output[pos]) { + pos++ + } + } + // Skip over \n at end of line. + if pos >= len(p.output) || !isNL(p.output[pos]) { + break + } + pos++ + + if blank { + insert = pos + } + } + + // If there is a //go:build comment before the place we identified, + // use that point instead. (Earlier in the file is always fine.) + if len(p.goBuild) > 0 && p.goBuild[0] < insert { + insert = p.goBuild[0] + } else if len(p.plusBuild) > 0 && p.plusBuild[0] < insert { + insert = p.plusBuild[0] + } + + var x constraint.Expr + switch len(p.goBuild) { + case 0: + // Synthesize //go:build expression from // +build lines. + for _, pos := range p.plusBuild { + y, err := constraint.Parse(p.commentTextAt(pos)) + if err != nil { + x = nil + break + } + if x == nil { + x = y + } else { + x = &constraint.AndExpr{X: x, Y: y} + } + } + case 1: + // Parse //go:build expression. + x, _ = constraint.Parse(p.commentTextAt(p.goBuild[0])) + } + + var block []byte + if x == nil { + // Don't have a valid //go:build expression to treat as truth. + // Bring all the lines together but leave them alone. + // Note that these are already tabwriter-escaped. + for _, pos := range p.goBuild { + block = append(block, p.lineAt(pos)...) + } + for _, pos := range p.plusBuild { + block = append(block, p.lineAt(pos)...) + } + } else { + block = append(block, tabwriter.Escape) + block = append(block, "//go:build "...) + block = append(block, x.String()...) + block = append(block, tabwriter.Escape, '\n') + if len(p.plusBuild) > 0 { + lines, err := constraint.PlusBuildLines(x) + if err != nil { + lines = []string{"// +build error: " + err.Error()} + } + for _, line := range lines { + block = append(block, tabwriter.Escape) + block = append(block, line...) + block = append(block, tabwriter.Escape, '\n') + } + } + } + block = append(block, '\n') + + // Build sorted list of lines to delete from remainder of output. + toDelete := append(p.goBuild, p.plusBuild...) + slices.Sort(toDelete) + + // Collect output after insertion point, with lines deleted, into after. + var after []byte + start := insert + for _, end := range toDelete { + if end < start { + continue + } + after = appendLines(after, p.output[start:end]) + start = end + len(p.lineAt(end)) + } + after = appendLines(after, p.output[start:]) + if n := len(after); n >= 2 && isNL(after[n-1]) && isNL(after[n-2]) { + after = after[:n-1] + } + + p.output = p.output[:insert] + p.output = append(p.output, block...) + p.output = append(p.output, after...) +} + +// appendLines is like append(x, y...) +// but it avoids creating doubled blank lines, +// which would not be gofmt-standard output. +// It assumes that only whole blocks of lines are being appended, +// not line fragments. +func appendLines(x, y []byte) []byte { + if len(y) > 0 && isNL(y[0]) && // y starts in blank line + (len(x) == 0 || len(x) >= 2 && isNL(x[len(x)-1]) && isNL(x[len(x)-2])) { // x is empty or ends in blank line + y = y[1:] // delete y's leading blank line + } + return append(x, y...) +} + +func (p *printer) lineAt(start int) []byte { + pos := start + for pos < len(p.output) && !isNL(p.output[pos]) { + pos++ + } + if pos < len(p.output) { + pos++ + } + return p.output[start:pos] +} + +func (p *printer) commentTextAt(start int) string { + if start < len(p.output) && p.output[start] == tabwriter.Escape { + start++ + } + pos := start + for pos < len(p.output) && p.output[pos] != tabwriter.Escape && !isNL(p.output[pos]) { + pos++ + } + return string(p.output[start:pos]) +} + +func isNL(b byte) bool { + return b == '\n' || b == '\f' +} diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/nodes.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/nodes.go new file mode 100644 index 000000000..df3b7250e --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/nodes.go @@ -0,0 +1,1999 @@ +// Copyright 2009 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. + +// This file implements printing of AST nodes; specifically +// expressions, statements, declarations, and files. It uses +// the print functionality implemented in printer.go. + +package printer + +import ( + "go/ast" + "go/token" + "math" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +// Formatting issues: +// - better comment formatting for /*-style comments at the end of a line (e.g. a declaration) +// when the comment spans multiple lines; if such a comment is just two lines, formatting is +// not idempotent +// - formatting of expression lists +// - should use blank instead of tab to separate one-line function bodies from +// the function header unless there is a group of consecutive one-liners + +// ---------------------------------------------------------------------------- +// Common AST nodes. + +// Print as many newlines as necessary (but at least min newlines) to get to +// the current line. ws is printed before the first line break. If newSection +// is set, the first line break is printed as formfeed. Returns 0 if no line +// breaks were printed, returns 1 if there was exactly one newline printed, +// and returns a value > 1 if there was a formfeed or more than one newline +// printed. +// +// TODO(gri): linebreak may add too many lines if the next statement at "line" +// is preceded by comments because the computation of n assumes +// the current position before the comment and the target position +// after the comment. Thus, after interspersing such comments, the +// space taken up by them is not considered to reduce the number of +// linebreaks. At the moment there is no easy way to know about +// future (not yet interspersed) comments in this function. +func (p *printer) linebreak(line, min int, ws whiteSpace, newSection bool) (nbreaks int) { + n := max(nlimit(line-p.pos.Line), min) + if n > 0 { + p.print(ws) + if newSection { + p.print(formfeed) + n-- + nbreaks = 2 + } + nbreaks += n + for ; n > 0; n-- { + p.print(newline) + } + } + return nbreaks +} + +// setComment sets g as the next comment if g != nil and if node comments +// are enabled - this mode is used when printing source code fragments such +// as exports only. It assumes that there is no pending comment in p.comments +// and at most one pending comment in the p.comment cache. +func (p *printer) setComment(g *ast.CommentGroup) { + if g == nil || !p.useNodeComments { + return + } + if p.comments == nil { + // initialize p.comments lazily + p.comments = make([]*ast.CommentGroup, 1) + } else if p.cindex < len(p.comments) { + // for some reason there are pending comments; this + // should never happen - handle gracefully and flush + // all comments up to g, ignore anything after that + p.flush(p.posFor(g.List[0].Pos()), token.ILLEGAL) + p.comments = p.comments[0:1] + // in debug mode, report error + p.internalError("setComment found pending comments") + } + p.comments[0] = g + p.cindex = 0 + // don't overwrite any pending comment in the p.comment cache + // (there may be a pending comment when a line comment is + // immediately followed by a lead comment with no other + // tokens between) + if p.commentOffset == infinity { + p.nextComment() // get comment ready for use + } +} + +type exprListMode uint + +const ( + commaTerm exprListMode = 1 << iota // list is optionally terminated by a comma + noIndent // no extra indentation in multi-line lists +) + +// If indent is set, a multi-line identifier list is indented after the +// first linebreak encountered. +func (p *printer) identList(list []*ast.Ident, indent bool) { + // convert into an expression list so we can re-use exprList formatting + xlist := make([]ast.Expr, len(list)) + for i, x := range list { + xlist[i] = x + } + var mode exprListMode + if !indent { + mode = noIndent + } + p.exprList(token.NoPos, xlist, 1, mode, token.NoPos, false) +} + +const filteredMsg = "contains filtered or unexported fields" + +// Print a list of expressions. If the list spans multiple +// source lines, the original line breaks are respected between +// expressions. +// +// TODO(gri) Consider rewriting this to be independent of []ast.Expr +// so that we can use the algorithm for any kind of list +// +// (e.g., pass list via a channel over which to range). +func (p *printer) exprList(prev0 token.Pos, list []ast.Expr, depth int, mode exprListMode, next0 token.Pos, isIncomplete bool) { + if len(list) == 0 { + if isIncomplete { + prev := p.posFor(prev0) + next := p.posFor(next0) + if prev.IsValid() && prev.Line == next.Line { + p.print("/* " + filteredMsg + " */") + } else { + p.print(newline) + p.print(indent, "// "+filteredMsg, unindent, newline) + } + } + return + } + + prev := p.posFor(prev0) + next := p.posFor(next0) + line := p.lineFor(list[0].Pos()) + endLine := p.lineFor(list[len(list)-1].End()) + + if prev.IsValid() && prev.Line == line && line == endLine { + // all list entries on a single line + for i, x := range list { + if i > 0 { + // use position of expression following the comma as + // comma position for correct comment placement + p.setPos(x.Pos()) + p.print(token.COMMA, blank) + } + p.expr0(x, depth) + } + if isIncomplete { + p.print(token.COMMA, blank, "/* "+filteredMsg+" */") + } + return + } + + // list entries span multiple lines; + // use source code positions to guide line breaks + + // Don't add extra indentation if noIndent is set; + // i.e., pretend that the first line is already indented. + ws := ignore + if mode&noIndent == 0 { + ws = indent + } + + // The first linebreak is always a formfeed since this section must not + // depend on any previous formatting. + prevBreak := -1 // index of last expression that was followed by a linebreak + if prev.IsValid() && prev.Line < line && p.linebreak(line, 0, ws, true) > 0 { + ws = ignore + prevBreak = 0 + } + + // initialize expression/key size: a zero value indicates expr/key doesn't fit on a single line + size := 0 + + // We use the ratio between the geometric mean of the previous key sizes and + // the current size to determine if there should be a break in the alignment. + // To compute the geometric mean we accumulate the ln(size) values (lnsum) + // and the number of sizes included (count). + lnsum := 0.0 + count := 0 + + // print all list elements + prevLine := prev.Line + for i, x := range list { + line = p.lineFor(x.Pos()) + + // Determine if the next linebreak, if any, needs to use formfeed: + // in general, use the entire node size to make the decision; for + // key:value expressions, use the key size. + // TODO(gri) for a better result, should probably incorporate both + // the key and the node size into the decision process + useFF := true + + // Determine element size: All bets are off if we don't have + // position information for the previous and next token (likely + // generated code - simply ignore the size in this case by setting + // it to 0). + prevSize := size + const infinity = 1e6 // larger than any source line + size = p.nodeSize(x, infinity) + pair, isPair := x.(*ast.KeyValueExpr) + if size <= infinity && prev.IsValid() && next.IsValid() { + // x fits on a single line + if isPair { + size = p.nodeSize(pair.Key, infinity) // size <= infinity + } + } else { + // size too large or we don't have good layout information + size = 0 + } + + // If the previous line and the current line had single- + // line-expressions and the key sizes are small or the + // ratio between the current key and the geometric mean + // if the previous key sizes does not exceed a threshold, + // align columns and do not use formfeed. + if prevSize > 0 && size > 0 { + const smallSize = 40 + if count == 0 || prevSize <= smallSize && size <= smallSize { + useFF = false + } else { + const r = 2.5 // threshold + geomean := math.Exp(lnsum / float64(count)) // count > 0 + ratio := float64(size) / geomean + useFF = r*ratio <= 1 || r <= ratio + } + } + + needsLinebreak := 0 < prevLine && prevLine < line + if i > 0 { + // Use position of expression following the comma as + // comma position for correct comment placement, but + // only if the expression is on the same line. + if !needsLinebreak { + p.setPos(x.Pos()) + } + p.print(token.COMMA) + needsBlank := true + if needsLinebreak { + // Lines are broken using newlines so comments remain aligned + // unless useFF is set or there are multiple expressions on + // the same line in which case formfeed is used. + nbreaks := p.linebreak(line, 0, ws, useFF || prevBreak+1 < i) + if nbreaks > 0 { + ws = ignore + prevBreak = i + needsBlank = false // we got a line break instead + } + // If there was a new section or more than one new line + // (which means that the tabwriter will implicitly break + // the section), reset the geomean variables since we are + // starting a new group of elements with the next element. + if nbreaks > 1 { + lnsum = 0 + count = 0 + } + } + if needsBlank { + p.print(blank) + } + } + + if len(list) > 1 && isPair && size > 0 && needsLinebreak { + // We have a key:value expression that fits onto one line + // and it's not on the same line as the prior expression: + // Use a column for the key such that consecutive entries + // can align if possible. + // (needsLinebreak is set if we started a new line before) + p.expr(pair.Key) + p.setPos(pair.Colon) + p.print(token.COLON, vtab) + p.expr(pair.Value) + } else { + p.expr0(x, depth) + } + + if size > 0 { + lnsum += math.Log(float64(size)) + count++ + } + + prevLine = line + } + + if mode&commaTerm != 0 && next.IsValid() && p.pos.Line < next.Line { + // Print a terminating comma if the next token is on a new line. + p.print(token.COMMA) + if isIncomplete { + p.print(newline) + p.print("// " + filteredMsg) + } + if ws == ignore && mode&noIndent == 0 { + // unindent if we indented + p.print(unindent) + } + p.print(formfeed) // terminating comma needs a line break to look good + return + } + + if isIncomplete { + p.print(token.COMMA, newline) + p.print("// "+filteredMsg, newline) + } + + if ws == ignore && mode&noIndent == 0 { + // unindent if we indented + p.print(unindent) + } +} + +type paramMode int + +const ( + funcParam paramMode = iota + funcTParam + typeTParam +) + +func (p *printer) parameters(fields *ast.FieldList, mode paramMode) { + openTok, closeTok := token.LPAREN, token.RPAREN + if mode != funcParam { + openTok, closeTok = token.LBRACK, token.RBRACK + } + p.setPos(fields.Opening) + p.print(openTok) + if len(fields.List) > 0 { + prevLine := p.lineFor(fields.Opening) + ws := indent + for i, par := range fields.List { + // determine par begin and end line (may be different + // if there are multiple parameter names for this par + // or the type is on a separate line) + parLineBeg := p.lineFor(par.Pos()) + parLineEnd := p.lineFor(par.End()) + // separating "," if needed + needsLinebreak := 0 < prevLine && prevLine < parLineBeg + if i > 0 { + // use position of parameter following the comma as + // comma position for correct comma placement, but + // only if the next parameter is on the same line + if !needsLinebreak { + p.setPos(par.Pos()) + } + p.print(token.COMMA) + } + // separator if needed (linebreak or blank) + if needsLinebreak && p.linebreak(parLineBeg, 0, ws, true) > 0 { + // break line if the opening "(" or previous parameter ended on a different line + ws = ignore + } else if i > 0 { + p.print(blank) + } + // parameter names + if len(par.Names) > 0 { + // Very subtle: If we indented before (ws == ignore), identList + // won't indent again. If we didn't (ws == indent), identList will + // indent if the identList spans multiple lines, and it will outdent + // again at the end (and still ws == indent). Thus, a subsequent indent + // by a linebreak call after a type, or in the next multi-line identList + // will do the right thing. + p.identList(par.Names, ws == indent) + p.print(blank) + } + // parameter type + p.expr(stripParensAlways(par.Type)) + prevLine = parLineEnd + } + + // if the closing ")" is on a separate line from the last parameter, + // print an additional "," and line break + if closing := p.lineFor(fields.Closing); 0 < prevLine && prevLine < closing { + p.print(token.COMMA) + p.linebreak(closing, 0, ignore, true) + } else if mode == typeTParam && fields.NumFields() == 1 && combinesWithName(stripParensAlways(fields.List[0].Type)) { + // A type parameter list [P T] where the name P and the type expression T syntactically + // combine to another valid (value) expression requires a trailing comma, as in [P *T,] + // (or an enclosing interface as in [P interface(*T)]), so that the type parameter list + // is not parsed as an array length [P*T]. + p.print(token.COMMA) + } + + // unindent if we indented + if ws == ignore { + p.print(unindent) + } + } + + p.setPos(fields.Closing) + p.print(closeTok) +} + +// combinesWithName reports whether a name followed by the expression x +// syntactically combines to another valid (value) expression. For instance +// using *T for x, "name *T" syntactically appears as the expression x*T. +// On the other hand, using P|Q or *P|~Q for x, "name P|Q" or "name *P|~Q" +// cannot be combined into a valid (value) expression. +func combinesWithName(x ast.Expr) bool { + switch x := x.(type) { + case *ast.StarExpr: + // name *x.X combines to name*x.X if x.X is not a type element + return !isTypeElem(x.X) + case *ast.BinaryExpr: + return combinesWithName(x.X) && !isTypeElem(x.Y) + case *ast.ParenExpr: + return !isTypeElem(x.X) + } + return false +} + +// isTypeElem reports whether x is a (possibly parenthesized) type element expression. +// The result is false if x could be a type element OR an ordinary (value) expression. +func isTypeElem(x ast.Expr) bool { + switch x := x.(type) { + case *ast.ArrayType, *ast.StructType, *ast.FuncType, *ast.InterfaceType, *ast.MapType, *ast.ChanType: + return true + case *ast.UnaryExpr: + return x.Op == token.TILDE + case *ast.BinaryExpr: + return isTypeElem(x.X) || isTypeElem(x.Y) + case *ast.ParenExpr: + return isTypeElem(x.X) + } + return false +} + +func (p *printer) signature(sig *ast.FuncType) { + if sig.TypeParams != nil { + p.parameters(sig.TypeParams, funcTParam) + } + if sig.Params != nil { + p.parameters(sig.Params, funcParam) + } else { + p.print(token.LPAREN, token.RPAREN) + } + res := sig.Results + n := res.NumFields() + if n > 0 { + // res != nil + p.print(blank) + if n == 1 && res.List[0].Names == nil { + // single anonymous res; no ()'s + p.expr(stripParensAlways(res.List[0].Type)) + return + } + p.parameters(res, funcParam) + } +} + +func identListSize(list []*ast.Ident, maxSize int) (size int) { + for i, x := range list { + if i > 0 { + size += len(", ") + } + size += utf8.RuneCountInString(x.Name) + if size >= maxSize { + break + } + } + return size +} + +func (p *printer) isOneLineFieldList(list []*ast.Field) bool { + if len(list) != 1 { + return false // allow only one field + } + f := list[0] + if f.Tag != nil || f.Comment != nil { + return false // don't allow tags or comments + } + // only name(s) and type + const maxSize = 30 // adjust as appropriate, this is an approximate value + namesSize := identListSize(f.Names, maxSize) + if namesSize > 0 { + namesSize = 1 // blank between names and types + } + typeSize := p.nodeSize(f.Type, maxSize) + return namesSize+typeSize <= maxSize +} + +func (p *printer) setLineComment(text string) { + p.setComment(&ast.CommentGroup{List: []*ast.Comment{{Slash: token.NoPos, Text: text}}}) +} + +func (p *printer) fieldList(fields *ast.FieldList, isStruct, isIncomplete bool) { + lbrace := fields.Opening + list := fields.List + rbrace := fields.Closing + hasComments := isIncomplete || p.commentBefore(p.posFor(rbrace)) + srcIsOneLine := lbrace.IsValid() && rbrace.IsValid() && p.lineFor(lbrace) == p.lineFor(rbrace) + + if !hasComments && srcIsOneLine { + // possibly a one-line struct/interface + if len(list) == 0 { + // no blank between keyword and {} in this case + p.setPos(lbrace) + p.print(token.LBRACE) + p.setPos(rbrace) + p.print(token.RBRACE) + return + } else if p.isOneLineFieldList(list) { + // small enough - print on one line + // (don't use identList and ignore source line breaks) + p.setPos(lbrace) + p.print(token.LBRACE, blank) + f := list[0] + if isStruct { + for i, x := range f.Names { + if i > 0 { + // no comments so no need for comma position + p.print(token.COMMA, blank) + } + p.expr(x) + } + if len(f.Names) > 0 { + p.print(blank) + } + p.expr(f.Type) + } else { // interface + if len(f.Names) > 0 { + name := f.Names[0] // method name + p.expr(name) + p.signature(f.Type.(*ast.FuncType)) // don't print "func" + } else { + // embedded interface + p.expr(f.Type) + } + } + p.print(blank) + p.setPos(rbrace) + p.print(token.RBRACE) + return + } + } + // hasComments || !srcIsOneLine + + p.print(blank) + p.setPos(lbrace) + p.print(token.LBRACE, indent) + if hasComments || len(list) > 0 { + p.print(formfeed) + } + + if isStruct { + + sep := vtab + if len(list) == 1 { + sep = blank + } + var line int + for i, f := range list { + if i > 0 { + p.linebreak(p.lineFor(f.Pos()), 1, ignore, p.linesFrom(line) > 0) + } + extraTabs := 0 + p.setComment(f.Doc) + p.recordLine(&line) + if len(f.Names) > 0 { + // named fields + p.identList(f.Names, false) + p.print(sep) + p.expr(f.Type) + extraTabs = 1 + } else { + // anonymous field + p.expr(f.Type) + extraTabs = 2 + } + if f.Tag != nil { + if len(f.Names) > 0 && sep == vtab { + p.print(sep) + } + p.print(sep) + p.expr(f.Tag) + extraTabs = 0 + } + if f.Comment != nil { + for ; extraTabs > 0; extraTabs-- { + p.print(sep) + } + p.setComment(f.Comment) + } + } + if isIncomplete { + if len(list) > 0 { + p.print(formfeed) + } + p.flush(p.posFor(rbrace), token.RBRACE) // make sure we don't lose the last line comment + p.setLineComment("// " + filteredMsg) + } + + } else { // interface + + var line int + var prev *ast.Ident // previous "type" identifier + for i, f := range list { + var name *ast.Ident // first name, or nil + if len(f.Names) > 0 { + name = f.Names[0] + } + if i > 0 { + // don't do a line break (min == 0) if we are printing a list of types + // TODO(gri) this doesn't work quite right if the list of types is + // spread across multiple lines + min := 1 + if prev != nil && name == prev { + min = 0 + } + p.linebreak(p.lineFor(f.Pos()), min, ignore, p.linesFrom(line) > 0) + } + p.setComment(f.Doc) + p.recordLine(&line) + if name != nil { + // method + p.expr(name) + p.signature(f.Type.(*ast.FuncType)) // don't print "func" + prev = nil + } else { + // embedded interface + p.expr(f.Type) + prev = nil + } + p.setComment(f.Comment) + } + if isIncomplete { + if len(list) > 0 { + p.print(formfeed) + } + p.flush(p.posFor(rbrace), token.RBRACE) // make sure we don't lose the last line comment + p.setLineComment("// contains filtered or unexported methods") + } + + } + p.print(unindent, formfeed) + p.setPos(rbrace) + p.print(token.RBRACE) +} + +// ---------------------------------------------------------------------------- +// Expressions + +func walkBinary(e *ast.BinaryExpr) (has4, has5 bool, maxProblem int) { + switch e.Op.Precedence() { + case 4: + has4 = true + case 5: + has5 = true + } + + switch l := e.X.(type) { + case *ast.BinaryExpr: + if l.Op.Precedence() < e.Op.Precedence() { + // parens will be inserted. + // pretend this is an *ast.ParenExpr and do nothing. + break + } + h4, h5, mp := walkBinary(l) + has4 = has4 || h4 + has5 = has5 || h5 + maxProblem = max(maxProblem, mp) + } + + switch r := e.Y.(type) { + case *ast.BinaryExpr: + if r.Op.Precedence() <= e.Op.Precedence() { + // parens will be inserted. + // pretend this is an *ast.ParenExpr and do nothing. + break + } + h4, h5, mp := walkBinary(r) + has4 = has4 || h4 + has5 = has5 || h5 + maxProblem = max(maxProblem, mp) + + case *ast.StarExpr: + if e.Op == token.QUO { // `*/` + maxProblem = 5 + } + + case *ast.UnaryExpr: + switch e.Op.String() + r.Op.String() { + case "/*", "&&", "&^": + maxProblem = 5 + case "++", "--": + maxProblem = max(maxProblem, 4) + } + } + return has4, has5, maxProblem +} + +func cutoff(e *ast.BinaryExpr, depth int) int { + has4, has5, maxProblem := walkBinary(e) + if maxProblem > 0 { + return maxProblem + 1 + } + if has4 && has5 { + if depth == 1 { + return 5 + } + return 4 + } + if depth == 1 { + return 6 + } + return 4 +} + +func diffPrec(expr ast.Expr, prec int) int { + x, ok := expr.(*ast.BinaryExpr) + if !ok || prec != x.Op.Precedence() { + return 1 + } + return 0 +} + +func reduceDepth(depth int) int { + depth-- + if depth < 1 { + depth = 1 + } + return depth +} + +// Format the binary expression: decide the cutoff and then format. +// Let's call depth == 1 Normal mode, and depth > 1 Compact mode. +// (Algorithm suggestion by Russ Cox.) +// +// The precedences are: +// +// 5 * / % << >> & &^ +// 4 + - | ^ +// 3 == != < <= > >= +// 2 && +// 1 || +// +// The only decision is whether there will be spaces around levels 4 and 5. +// There are never spaces at level 6 (unary), and always spaces at levels 3 and below. +// +// To choose the cutoff, look at the whole expression but excluding primary +// expressions (function calls, parenthesized exprs), and apply these rules: +// +// 1. If there is a binary operator with a right side unary operand +// that would clash without a space, the cutoff must be (in order): +// +// /* 6 +// && 6 +// &^ 6 +// ++ 5 +// -- 5 +// +// (Comparison operators always have spaces around them.) +// +// 2. If there is a mix of level 5 and level 4 operators, then the cutoff +// is 5 (use spaces to distinguish precedence) in Normal mode +// and 4 (never use spaces) in Compact mode. +// +// 3. If there are no level 4 operators or no level 5 operators, then the +// cutoff is 6 (always use spaces) in Normal mode +// and 4 (never use spaces) in Compact mode. +func (p *printer) binaryExpr(x *ast.BinaryExpr, prec1, cutoff, depth int) { + prec := x.Op.Precedence() + if prec < prec1 { + // parenthesis needed + // Note: The parser inserts an ast.ParenExpr node; thus this case + // can only occur if the AST is created in a different way. + p.print(token.LPAREN) + p.expr0(x, reduceDepth(depth)) // parentheses undo one level of depth + p.print(token.RPAREN) + return + } + + printBlank := prec < cutoff + + ws := indent + p.expr1(x.X, prec, depth+diffPrec(x.X, prec)) + if printBlank { + p.print(blank) + } + xline := p.pos.Line // before the operator (it may be on the next line!) + yline := p.lineFor(x.Y.Pos()) + p.setPos(x.OpPos) + p.print(x.Op) + if xline != yline && xline > 0 && yline > 0 { + // at least one line break, but respect an extra empty line + // in the source + if p.linebreak(yline, 1, ws, true) > 0 { + ws = ignore + printBlank = false // no blank after line break + } + } + if printBlank { + p.print(blank) + } + p.expr1(x.Y, prec+1, depth+1) + if ws == ignore { + p.print(unindent) + } +} + +func isBinary(expr ast.Expr) bool { + _, ok := expr.(*ast.BinaryExpr) + return ok +} + +func (p *printer) expr1(expr ast.Expr, prec1, depth int) { + p.setPos(expr.Pos()) + + switch x := expr.(type) { + case *ast.BadExpr: + p.print("BadExpr") + + case *ast.Ident: + p.print(x) + + case *ast.BinaryExpr: + if depth < 1 { + p.internalError("depth < 1:", depth) + depth = 1 + } + p.binaryExpr(x, prec1, cutoff(x, depth), depth) + + case *ast.KeyValueExpr: + p.expr(x.Key) + p.setPos(x.Colon) + p.print(token.COLON, blank) + p.expr(x.Value) + + case *ast.StarExpr: + const prec = token.UnaryPrec + if prec < prec1 { + // parenthesis needed + p.print(token.LPAREN) + p.print(token.MUL) + p.expr(x.X) + p.print(token.RPAREN) + } else { + // no parenthesis needed + p.print(token.MUL) + p.expr(x.X) + } + + case *ast.UnaryExpr: + const prec = token.UnaryPrec + if prec < prec1 { + // parenthesis needed + p.print(token.LPAREN) + p.expr(x) + p.print(token.RPAREN) + } else { + // no parenthesis needed + p.print(x.Op) + if x.Op == token.RANGE { + // TODO(gri) Remove this code if it cannot be reached. + p.print(blank) + } + p.expr1(x.X, prec, depth) + } + + case *ast.BasicLit: + if p.Config.Mode&normalizeNumbers != 0 { + x = normalizedNumber(x) + } + p.print(x) + + case *ast.FuncLit: + p.setPos(x.Type.Pos()) + p.print(token.FUNC) + // See the comment in funcDecl about how the header size is computed. + startCol := p.out.Column - len("func") + p.signature(x.Type) + p.funcBody(p.distanceFrom(x.Type.Pos(), startCol), blank, x.Body) + + case *ast.ParenExpr: + if _, hasParens := x.X.(*ast.ParenExpr); hasParens { + // don't print parentheses around an already parenthesized expression + // TODO(gri) consider making this more general and incorporate precedence levels + p.expr0(x.X, depth) + } else { + p.print(token.LPAREN) + p.expr0(x.X, reduceDepth(depth)) // parentheses undo one level of depth + p.setPos(x.Rparen) + p.print(token.RPAREN) + } + + case *ast.SelectorExpr: + p.selectorExpr(x, depth, false) + + case *ast.TypeAssertExpr: + p.expr1(x.X, token.HighestPrec, depth) + p.print(token.PERIOD) + p.setPos(x.Lparen) + p.print(token.LPAREN) + if x.Type != nil { + p.expr(x.Type) + } else { + p.print(token.TYPE) + } + p.setPos(x.Rparen) + p.print(token.RPAREN) + + case *ast.IndexExpr: + // TODO(gri): should treat[] like parentheses and undo one level of depth + p.expr1(x.X, token.HighestPrec, 1) + p.setPos(x.Lbrack) + p.print(token.LBRACK) + p.expr0(x.Index, depth+1) + p.setPos(x.Rbrack) + p.print(token.RBRACK) + + case *ast.IndexListExpr: + // TODO(gri): as for IndexExpr, should treat [] like parentheses and undo + // one level of depth + p.expr1(x.X, token.HighestPrec, 1) + p.setPos(x.Lbrack) + p.print(token.LBRACK) + p.exprList(x.Lbrack, x.Indices, depth+1, commaTerm, x.Rbrack, false) + p.setPos(x.Rbrack) + p.print(token.RBRACK) + + case *ast.SliceExpr: + // TODO(gri): should treat[] like parentheses and undo one level of depth + p.expr1(x.X, token.HighestPrec, 1) + p.setPos(x.Lbrack) + p.print(token.LBRACK) + indices := []ast.Expr{x.Low, x.High} + if x.Max != nil { + indices = append(indices, x.Max) + } + // determine if we need extra blanks around ':' + var needsBlanks bool + if depth <= 1 { + var indexCount int + var hasBinaries bool + for _, x := range indices { + if x != nil { + indexCount++ + if isBinary(x) { + hasBinaries = true + } + } + } + if indexCount > 1 && hasBinaries { + needsBlanks = true + } + } + for i, x := range indices { + if i > 0 { + if indices[i-1] != nil && needsBlanks { + p.print(blank) + } + p.print(token.COLON) + if x != nil && needsBlanks { + p.print(blank) + } + } + if x != nil { + p.expr0(x, depth+1) + } + } + p.setPos(x.Rbrack) + p.print(token.RBRACK) + + case *ast.CallExpr: + if len(x.Args) > 1 { + depth++ + } + + // Conversions to literal function types or <-chan + // types require parentheses around the type. + paren := false + switch t := x.Fun.(type) { + case *ast.FuncType: + paren = true + case *ast.ChanType: + paren = t.Dir == ast.RECV + } + if paren { + p.print(token.LPAREN) + } + wasIndented := p.possibleSelectorExpr(x.Fun, token.HighestPrec, depth) + if paren { + p.print(token.RPAREN) + } + + p.setPos(x.Lparen) + p.print(token.LPAREN) + if x.Ellipsis.IsValid() { + p.exprList(x.Lparen, x.Args, depth, 0, x.Ellipsis, false) + p.setPos(x.Ellipsis) + p.print(token.ELLIPSIS) + if x.Rparen.IsValid() && p.lineFor(x.Ellipsis) < p.lineFor(x.Rparen) { + p.print(token.COMMA, formfeed) + } + } else { + p.exprList(x.Lparen, x.Args, depth, commaTerm, x.Rparen, false) + } + p.setPos(x.Rparen) + p.print(token.RPAREN) + if wasIndented { + p.print(unindent) + } + + case *ast.CompositeLit: + // composite literal elements that are composite literals themselves may have the type omitted + if x.Type != nil { + p.expr1(x.Type, token.HighestPrec, depth) + } + p.level++ + p.setPos(x.Lbrace) + p.print(token.LBRACE) + p.exprList(x.Lbrace, x.Elts, 1, commaTerm, x.Rbrace, x.Incomplete) + // do not insert extra line break following a /*-style comment + // before the closing '}' as it might break the code if there + // is no trailing ',' + mode := noExtraLinebreak + // do not insert extra blank following a /*-style comment + // before the closing '}' unless the literal is empty + if len(x.Elts) > 0 { + mode |= noExtraBlank + } + // need the initial indent to print lone comments with + // the proper level of indentation + p.print(indent, unindent, mode) + p.setPos(x.Rbrace) + p.print(token.RBRACE, mode) + p.level-- + + case *ast.Ellipsis: + p.print(token.ELLIPSIS) + if x.Elt != nil { + p.expr(x.Elt) + } + + case *ast.ArrayType: + p.print(token.LBRACK) + if x.Len != nil { + p.expr(x.Len) + } + p.print(token.RBRACK) + p.expr(x.Elt) + + case *ast.StructType: + p.print(token.STRUCT) + p.fieldList(x.Fields, true, x.Incomplete) + + case *ast.FuncType: + p.print(token.FUNC) + p.signature(x) + + case *ast.InterfaceType: + p.print(token.INTERFACE) + p.fieldList(x.Methods, false, x.Incomplete) + + case *ast.MapType: + p.print(token.MAP, token.LBRACK) + p.expr(x.Key) + p.print(token.RBRACK) + p.expr(x.Value) + + case *ast.ChanType: + switch x.Dir { + case ast.SEND | ast.RECV: + p.print(token.CHAN) + case ast.RECV: + p.print(token.ARROW, token.CHAN) // x.Arrow and x.Pos() are the same + case ast.SEND: + p.print(token.CHAN) + p.setPos(x.Arrow) + p.print(token.ARROW) + } + p.print(blank) + p.expr(x.Value) + + default: + panic("unreachable") + } +} + +// normalizedNumber rewrites base prefixes and exponents +// of numbers to use lower-case letters (0X123 to 0x123 and 1.2E3 to 1.2e3), +// and removes leading 0's from integer imaginary literals (0765i to 765i). +// It leaves hexadecimal digits alone. +// +// normalizedNumber doesn't modify the ast.BasicLit value lit points to. +// If lit is not a number or a number in canonical format already, +// lit is returned as is. Otherwise a new ast.BasicLit is created. +func normalizedNumber(lit *ast.BasicLit) *ast.BasicLit { + if lit.Kind != token.INT && lit.Kind != token.FLOAT && lit.Kind != token.IMAG { + return lit // not a number - nothing to do + } + if len(lit.Value) < 2 { + return lit // only one digit (common case) - nothing to do + } + // len(lit.Value) >= 2 + + // We ignore lit.Kind because for lit.Kind == token.IMAG the literal may be an integer + // or floating-point value, decimal or not. Instead, just consider the literal pattern. + x := lit.Value + switch x[:2] { + default: + // 0-prefix octal, decimal int, or float (possibly with 'i' suffix) + if i := strings.LastIndexByte(x, 'E'); i >= 0 { + x = x[:i] + "e" + x[i+1:] + break + } + // remove leading 0's from integer (but not floating-point) imaginary literals + if x[len(x)-1] == 'i' && !strings.ContainsAny(x, ".e") { + x = strings.TrimLeft(x, "0_") + if x == "i" { + x = "0i" + } + } + case "0X": + x = "0x" + x[2:] + // possibly a hexadecimal float + if i := strings.LastIndexByte(x, 'P'); i >= 0 { + x = x[:i] + "p" + x[i+1:] + } + case "0x": + // possibly a hexadecimal float + i := strings.LastIndexByte(x, 'P') + if i == -1 { + return lit // nothing to do + } + x = x[:i] + "p" + x[i+1:] + case "0O": + x = "0o" + x[2:] + case "0o": + return lit // nothing to do + case "0B": + x = "0b" + x[2:] + case "0b": + return lit // nothing to do + } + + return &ast.BasicLit{ValuePos: lit.ValuePos, Kind: lit.Kind, Value: x} +} + +func (p *printer) possibleSelectorExpr(expr ast.Expr, prec1, depth int) bool { + if x, ok := expr.(*ast.SelectorExpr); ok { + return p.selectorExpr(x, depth, true) + } + p.expr1(expr, prec1, depth) + return false +} + +// selectorExpr handles an *ast.SelectorExpr node and reports whether x spans +// multiple lines. +func (p *printer) selectorExpr(x *ast.SelectorExpr, depth int, isMethod bool) bool { + p.expr1(x.X, token.HighestPrec, depth) + p.print(token.PERIOD) + if line := p.lineFor(x.Sel.Pos()); p.pos.IsValid() && p.pos.Line < line { + p.print(indent, newline) + p.setPos(x.Sel.Pos()) + p.print(x.Sel) + if !isMethod { + p.print(unindent) + } + return true + } + p.setPos(x.Sel.Pos()) + p.print(x.Sel) + return false +} + +func (p *printer) expr0(x ast.Expr, depth int) { + p.expr1(x, token.LowestPrec, depth) +} + +func (p *printer) expr(x ast.Expr) { + const depth = 1 + p.expr1(x, token.LowestPrec, depth) +} + +// ---------------------------------------------------------------------------- +// Statements + +// Print the statement list indented, but without a newline after the last statement. +// Extra line breaks between statements in the source are respected but at most one +// empty line is printed between statements. +func (p *printer) stmtList(list []ast.Stmt, nindent int, nextIsRBrace bool) { + if nindent > 0 { + p.print(indent) + } + var line int + i := 0 + for _, s := range list { + // ignore empty statements (was issue 3466) + if _, isEmpty := s.(*ast.EmptyStmt); !isEmpty { + // nindent == 0 only for lists of switch/select case clauses; + // in those cases each clause is a new section + if len(p.output) > 0 { + // only print line break if we are not at the beginning of the output + // (i.e., we are not printing only a partial program) + p.linebreak(p.lineFor(s.Pos()), 1, ignore, i == 0 || nindent == 0 || p.linesFrom(line) > 0) + } + p.recordLine(&line) + p.stmt(s, nextIsRBrace && i == len(list)-1) + // labeled statements put labels on a separate line, but here + // we only care about the start line of the actual statement + // without label - correct line for each label + for t := s; ; { + lt, _ := t.(*ast.LabeledStmt) + if lt == nil { + break + } + line++ + t = lt.Stmt + } + i++ + } + } + if nindent > 0 { + p.print(unindent) + } +} + +// block prints an *ast.BlockStmt; it always spans at least two lines. +func (p *printer) block(b *ast.BlockStmt, nindent int) { + p.setPos(b.Lbrace) + p.print(token.LBRACE) + p.stmtList(b.List, nindent, true) + p.linebreak(p.lineFor(b.Rbrace), 1, ignore, true) + p.setPos(b.Rbrace) + p.print(token.RBRACE) +} + +func isTypeName(x ast.Expr) bool { + switch t := x.(type) { + case *ast.Ident: + return true + case *ast.SelectorExpr: + return isTypeName(t.X) + } + return false +} + +func stripParens(x ast.Expr) ast.Expr { + if px, strip := x.(*ast.ParenExpr); strip { + // parentheses must not be stripped if there are any + // unparenthesized composite literals starting with + // a type name + ast.Inspect(px.X, func(node ast.Node) bool { + switch x := node.(type) { + case *ast.ParenExpr: + // parentheses protect enclosed composite literals + return false + case *ast.CompositeLit: + if isTypeName(x.Type) { + strip = false // do not strip parentheses + } + return false + } + // in all other cases, keep inspecting + return true + }) + if strip { + return stripParens(px.X) + } + } + return x +} + +func stripParensAlways(x ast.Expr) ast.Expr { + if x, ok := x.(*ast.ParenExpr); ok { + return stripParensAlways(x.X) + } + return x +} + +func (p *printer) controlClause(isForStmt bool, init ast.Stmt, expr ast.Expr, post ast.Stmt) { + p.print(blank) + needsBlank := false + if init == nil && post == nil { + // no semicolons required + if expr != nil { + p.expr(stripParens(expr)) + needsBlank = true + } + } else { + // all semicolons required + // (they are not separators, print them explicitly) + if init != nil { + p.stmt(init, false) + } + p.print(token.SEMICOLON, blank) + if expr != nil { + p.expr(stripParens(expr)) + needsBlank = true + } + if isForStmt { + p.print(token.SEMICOLON, blank) + needsBlank = false + if post != nil { + p.stmt(post, false) + needsBlank = true + } + } + } + if needsBlank { + p.print(blank) + } +} + +// indentList reports whether an expression list would look better if it +// were indented wholesale (starting with the very first element, rather +// than starting at the first line break). +func (p *printer) indentList(list []ast.Expr) bool { + // Heuristic: indentList reports whether there are more than one multi- + // line element in the list, or if there is any element that is not + // starting on the same line as the previous one ends. + if len(list) >= 2 { + b := p.lineFor(list[0].Pos()) + e := p.lineFor(list[len(list)-1].End()) + if 0 < b && b < e { + // list spans multiple lines + n := 0 // multi-line element count + line := b + for _, x := range list { + xb := p.lineFor(x.Pos()) + xe := p.lineFor(x.End()) + if line < xb { + // x is not starting on the same + // line as the previous one ended + return true + } + if xb < xe { + // x is a multi-line element + n++ + } + line = xe + } + return n > 1 + } + } + return false +} + +func (p *printer) stmt(stmt ast.Stmt, nextIsRBrace bool) { + p.setPos(stmt.Pos()) + + switch s := stmt.(type) { + case *ast.BadStmt: + p.print("BadStmt") + + case *ast.DeclStmt: + p.decl(s.Decl) + + case *ast.EmptyStmt: + // nothing to do + + case *ast.LabeledStmt: + // a "correcting" unindent immediately following a line break + // is applied before the line break if there is no comment + // between (see writeWhitespace) + p.print(unindent) + p.expr(s.Label) + p.setPos(s.Colon) + p.print(token.COLON, indent) + if e, isEmpty := s.Stmt.(*ast.EmptyStmt); isEmpty { + if !nextIsRBrace { + p.print(newline) + p.setPos(e.Pos()) + p.print(token.SEMICOLON) + break + } + } else { + p.linebreak(p.lineFor(s.Stmt.Pos()), 1, ignore, true) + } + p.stmt(s.Stmt, nextIsRBrace) + + case *ast.ExprStmt: + const depth = 1 + p.expr0(s.X, depth) + + case *ast.SendStmt: + const depth = 1 + p.expr0(s.Chan, depth) + p.print(blank) + p.setPos(s.Arrow) + p.print(token.ARROW, blank) + p.expr0(s.Value, depth) + + case *ast.IncDecStmt: + const depth = 1 + p.expr0(s.X, depth+1) + p.setPos(s.TokPos) + p.print(s.Tok) + + case *ast.AssignStmt: + depth := 1 + if len(s.Lhs) > 1 && len(s.Rhs) > 1 { + depth++ + } + p.exprList(s.Pos(), s.Lhs, depth, 0, s.TokPos, false) + p.print(blank) + p.setPos(s.TokPos) + p.print(s.Tok, blank) + p.exprList(s.TokPos, s.Rhs, depth, 0, token.NoPos, false) + + case *ast.GoStmt: + p.print(token.GO, blank) + p.expr(s.Call) + + case *ast.DeferStmt: + p.print(token.DEFER, blank) + p.expr(s.Call) + + case *ast.ReturnStmt: + p.print(token.RETURN) + if s.Results != nil { + p.print(blank) + // Use indentList heuristic to make corner cases look + // better (issue 1207). A more systematic approach would + // always indent, but this would cause significant + // reformatting of the code base and not necessarily + // lead to more nicely formatted code in general. + if p.indentList(s.Results) { + p.print(indent) + // Use NoPos so that a newline never goes before + // the results (see issue #32854). + p.exprList(token.NoPos, s.Results, 1, noIndent, token.NoPos, false) + p.print(unindent) + } else { + p.exprList(token.NoPos, s.Results, 1, 0, token.NoPos, false) + } + } + + case *ast.BranchStmt: + p.print(s.Tok) + if s.Label != nil { + p.print(blank) + p.expr(s.Label) + } + + case *ast.BlockStmt: + p.block(s, 1) + + case *ast.IfStmt: + p.print(token.IF) + p.controlClause(false, s.Init, s.Cond, nil) + p.block(s.Body, 1) + if s.Else != nil { + p.print(blank, token.ELSE, blank) + switch s.Else.(type) { + case *ast.BlockStmt, *ast.IfStmt: + p.stmt(s.Else, nextIsRBrace) + default: + // This can only happen with an incorrectly + // constructed AST. Permit it but print so + // that it can be parsed without errors. + p.print(token.LBRACE, indent, formfeed) + p.stmt(s.Else, true) + p.print(unindent, formfeed, token.RBRACE) + } + } + + case *ast.CaseClause: + if s.List != nil { + p.print(token.CASE, blank) + p.exprList(s.Pos(), s.List, 1, 0, s.Colon, false) + } else { + p.print(token.DEFAULT) + } + p.setPos(s.Colon) + p.print(token.COLON) + p.stmtList(s.Body, 1, nextIsRBrace) + + case *ast.SwitchStmt: + p.print(token.SWITCH) + p.controlClause(false, s.Init, s.Tag, nil) + p.block(s.Body, 0) + + case *ast.TypeSwitchStmt: + p.print(token.SWITCH) + if s.Init != nil { + p.print(blank) + p.stmt(s.Init, false) + p.print(token.SEMICOLON) + } + p.print(blank) + p.stmt(s.Assign, false) + p.print(blank) + p.block(s.Body, 0) + + case *ast.CommClause: + if s.Comm != nil { + p.print(token.CASE, blank) + p.stmt(s.Comm, false) + } else { + p.print(token.DEFAULT) + } + p.setPos(s.Colon) + p.print(token.COLON) + p.stmtList(s.Body, 1, nextIsRBrace) + + case *ast.SelectStmt: + p.print(token.SELECT, blank) + body := s.Body + if len(body.List) == 0 && !p.commentBefore(p.posFor(body.Rbrace)) { + // print empty select statement w/o comments on one line + p.setPos(body.Lbrace) + p.print(token.LBRACE) + p.setPos(body.Rbrace) + p.print(token.RBRACE) + } else { + p.block(body, 0) + } + + case *ast.ForStmt: + p.print(token.FOR) + p.controlClause(true, s.Init, s.Cond, s.Post) + p.block(s.Body, 1) + + case *ast.RangeStmt: + p.print(token.FOR, blank) + if s.Key != nil { + p.expr(s.Key) + if s.Value != nil { + // use position of value following the comma as + // comma position for correct comment placement + p.setPos(s.Value.Pos()) + p.print(token.COMMA, blank) + p.expr(s.Value) + } + p.print(blank) + p.setPos(s.TokPos) + p.print(s.Tok, blank) + } + p.print(token.RANGE, blank) + p.expr(stripParens(s.X)) + p.print(blank) + p.block(s.Body, 1) + + default: + panic("unreachable") + } +} + +// ---------------------------------------------------------------------------- +// Declarations + +// The keepTypeColumn function determines if the type column of a series of +// consecutive const or var declarations must be kept, or if initialization +// values (V) can be placed in the type column (T) instead. The i'th entry +// in the result slice is true if the type column in spec[i] must be kept. +// +// For example, the declaration: +// +// const ( +// foobar int = 42 // comment +// x = 7 // comment +// foo +// bar = 991 +// ) +// +// leads to the type/values matrix below. A run of value columns (V) can +// be moved into the type column if there is no type for any of the values +// in that column (we only move entire columns so that they align properly). +// +// matrix formatted result +// matrix +// T V -> T V -> true there is a T and so the type +// - V - V true column must be kept +// - - - - false +// - V V - false V is moved into T column +func keepTypeColumn(specs []ast.Spec) []bool { + m := make([]bool, len(specs)) + + populate := func(i, j int, keepType bool) { + if keepType { + for ; i < j; i++ { + m[i] = true + } + } + } + + i0 := -1 // if i0 >= 0 we are in a run and i0 is the start of the run + var keepType bool + for i, s := range specs { + t := s.(*ast.ValueSpec) + if t.Values != nil { + if i0 < 0 { + // start of a run of ValueSpecs with non-nil Values + i0 = i + keepType = false + } + } else { + if i0 >= 0 { + // end of a run + populate(i0, i, keepType) + i0 = -1 + } + } + if t.Type != nil { + keepType = true + } + } + if i0 >= 0 { + // end of a run + populate(i0, len(specs), keepType) + } + + return m +} + +func (p *printer) valueSpec(s *ast.ValueSpec, keepType bool) { + p.setComment(s.Doc) + p.identList(s.Names, false) // always present + extraTabs := 3 + if s.Type != nil || keepType { + p.print(vtab) + extraTabs-- + } + if s.Type != nil { + p.expr(s.Type) + } + if s.Values != nil { + p.print(vtab, token.ASSIGN, blank) + p.exprList(token.NoPos, s.Values, 1, 0, token.NoPos, false) + extraTabs-- + } + if s.Comment != nil { + for ; extraTabs > 0; extraTabs-- { + p.print(vtab) + } + p.setComment(s.Comment) + } +} + +func sanitizeImportPath(lit *ast.BasicLit) *ast.BasicLit { + // Note: An unmodified AST generated by go/parser will already + // contain a backward- or double-quoted path string that does + // not contain any invalid characters, and most of the work + // here is not needed. However, a modified or generated AST + // may possibly contain non-canonical paths. Do the work in + // all cases since it's not too hard and not speed-critical. + + // if we don't have a proper string, be conservative and return whatever we have + if lit.Kind != token.STRING { + return lit + } + s, err := strconv.Unquote(lit.Value) + if err != nil { + return lit + } + + // if the string is an invalid path, return whatever we have + // + // spec: "Implementation restriction: A compiler may restrict + // ImportPaths to non-empty strings using only characters belonging + // to Unicode's L, M, N, P, and S general categories (the Graphic + // characters without spaces) and may also exclude the characters + // !"#$%&'()*,:;<=>?[\]^`{|} and the Unicode replacement character + // U+FFFD." + if s == "" { + return lit + } + const illegalChars = `!"#$%&'()*,:;<=>?[\]^{|}` + "`\uFFFD" + for _, r := range s { + if !unicode.IsGraphic(r) || unicode.IsSpace(r) || strings.ContainsRune(illegalChars, r) { + return lit + } + } + + // otherwise, return the double-quoted path + s = strconv.Quote(s) + if s == lit.Value { + return lit // nothing wrong with lit + } + return &ast.BasicLit{ValuePos: lit.ValuePos, Kind: token.STRING, Value: s} +} + +// The parameter n is the number of specs in the group. If doIndent is set, +// multi-line identifier lists in the spec are indented when the first +// linebreak is encountered. +func (p *printer) spec(spec ast.Spec, n int, doIndent bool) { + switch s := spec.(type) { + case *ast.ImportSpec: + p.setComment(s.Doc) + if s.Name != nil { + p.expr(s.Name) + p.print(blank) + } + p.expr(sanitizeImportPath(s.Path)) + p.setComment(s.Comment) + p.setPos(s.EndPos) + + case *ast.ValueSpec: + if n != 1 { + p.internalError("expected n = 1; got", n) + } + p.setComment(s.Doc) + p.identList(s.Names, doIndent) // always present + if s.Type != nil { + p.print(blank) + p.expr(s.Type) + } + if s.Values != nil { + p.print(blank, token.ASSIGN, blank) + p.exprList(token.NoPos, s.Values, 1, 0, token.NoPos, false) + } + p.setComment(s.Comment) + + case *ast.TypeSpec: + p.setComment(s.Doc) + p.expr(s.Name) + if s.TypeParams != nil { + p.parameters(s.TypeParams, typeTParam) + } + if n == 1 { + p.print(blank) + } else { + p.print(vtab) + } + if s.Assign.IsValid() { + p.print(token.ASSIGN, blank) + } + p.expr(s.Type) + p.setComment(s.Comment) + + default: + panic("unreachable") + } +} + +func (p *printer) genDecl(d *ast.GenDecl) { + p.setComment(d.Doc) + p.setPos(d.Pos()) + p.print(d.Tok, blank) + + if d.Lparen.IsValid() || len(d.Specs) != 1 { + // group of parenthesized declarations + p.setPos(d.Lparen) + p.print(token.LPAREN) + if n := len(d.Specs); n > 0 { + p.print(indent, formfeed) + if n > 1 && (d.Tok == token.CONST || d.Tok == token.VAR) { + // two or more grouped const/var declarations: + // determine if the type column must be kept + keepType := keepTypeColumn(d.Specs) + var line int + for i, s := range d.Specs { + if i > 0 { + p.linebreak(p.lineFor(s.Pos()), 1, ignore, p.linesFrom(line) > 0) + } + p.recordLine(&line) + p.valueSpec(s.(*ast.ValueSpec), keepType[i]) + } + } else { + var line int + for i, s := range d.Specs { + if i > 0 { + p.linebreak(p.lineFor(s.Pos()), 1, ignore, p.linesFrom(line) > 0) + } + p.recordLine(&line) + p.spec(s, n, false) + } + } + p.print(unindent, formfeed) + } + p.setPos(d.Rparen) + p.print(token.RPAREN) + + } else if len(d.Specs) > 0 { + // single declaration + p.spec(d.Specs[0], 1, true) + } +} + +// sizeCounter is an io.Writer which counts the number of bytes written, +// as well as whether a newline character was seen. +type sizeCounter struct { + hasNewline bool + size int +} + +func (c *sizeCounter) Write(p []byte) (int, error) { + if !c.hasNewline { + for _, b := range p { + if b == '\n' || b == '\f' { + c.hasNewline = true + break + } + } + } + c.size += len(p) + return len(p), nil +} + +// nodeSize determines the size of n in chars after formatting. +// The result is <= maxSize if the node fits on one line with at +// most maxSize chars and the formatted output doesn't contain +// any control chars. Otherwise, the result is > maxSize. +func (p *printer) nodeSize(n ast.Node, maxSize int) (size int) { + // nodeSize invokes the printer, which may invoke nodeSize + // recursively. For deep composite literal nests, this can + // lead to an exponential algorithm. Remember previous + // results to prune the recursion (was issue 1628). + if size, found := p.nodeSizes[n]; found { + return size + } + + size = maxSize + 1 // assume n doesn't fit + p.nodeSizes[n] = size + + // nodeSize computation must be independent of particular + // style so that we always get the same decision; print + // in RawFormat + cfg := Config{Mode: RawFormat} + var counter sizeCounter + if err := cfg.fprint(&counter, p.fset, n, p.nodeSizes); err != nil { + return size + } + if counter.size <= maxSize && !counter.hasNewline { + // n fits in a single line + size = counter.size + p.nodeSizes[n] = size + } + return size +} + +// numLines returns the number of lines spanned by node n in the original source. +func (p *printer) numLines(n ast.Node) int { + if from := n.Pos(); from.IsValid() { + if to := n.End(); to.IsValid() { + return p.lineFor(to) - p.lineFor(from) + 1 + } + } + return infinity +} + +// bodySize is like nodeSize but it is specialized for *ast.BlockStmt's. +func (p *printer) bodySize(b *ast.BlockStmt, maxSize int) int { + pos1 := b.Pos() + pos2 := b.Rbrace + if pos1.IsValid() && pos2.IsValid() && p.lineFor(pos1) != p.lineFor(pos2) { + // opening and closing brace are on different lines - don't make it a one-liner + return maxSize + 1 + } + if len(b.List) > 5 { + // too many statements - don't make it a one-liner + return maxSize + 1 + } + // otherwise, estimate body size + bodySize := p.commentSizeBefore(p.posFor(pos2)) + for i, s := range b.List { + if bodySize > maxSize { + break // no need to continue + } + if i > 0 { + bodySize += 2 // space for a semicolon and blank + } + bodySize += p.nodeSize(s, maxSize) + } + return bodySize +} + +// funcBody prints a function body following a function header of given headerSize. +// If the header's and block's size are "small enough" and the block is "simple enough", +// the block is printed on the current line, without line breaks, spaced from the header +// by sep. Otherwise the block's opening "{" is printed on the current line, followed by +// lines for the block's statements and its closing "}". +func (p *printer) funcBody(headerSize int, sep whiteSpace, b *ast.BlockStmt) { + if b == nil { + return + } + + // save/restore composite literal nesting level + defer func(level int) { + p.level = level + }(p.level) + p.level = 0 + + const maxSize = 100 + if headerSize+p.bodySize(b, maxSize) <= maxSize { + p.print(sep) + p.setPos(b.Lbrace) + p.print(token.LBRACE) + if len(b.List) > 0 { + p.print(blank) + for i, s := range b.List { + if i > 0 { + p.print(token.SEMICOLON, blank) + } + p.stmt(s, i == len(b.List)-1) + } + p.print(blank) + } + p.print(noExtraLinebreak) + p.setPos(b.Rbrace) + p.print(token.RBRACE, noExtraLinebreak) + return + } + + if sep != ignore { + p.print(blank) // always use blank + } + p.block(b, 1) +} + +// distanceFrom returns the column difference between p.out (the current output +// position) and startOutCol. If the start position is on a different line from +// the current position (or either is unknown), the result is infinity. +func (p *printer) distanceFrom(startPos token.Pos, startOutCol int) int { + if startPos.IsValid() && p.pos.IsValid() && p.posFor(startPos).Line == p.pos.Line { + return p.out.Column - startOutCol + } + return infinity +} + +func (p *printer) funcDecl(d *ast.FuncDecl) { + p.setComment(d.Doc) + p.setPos(d.Pos()) + p.print(token.FUNC, blank) + // We have to save startCol only after emitting FUNC; otherwise it can be on a + // different line (all whitespace preceding the FUNC is emitted only when the + // FUNC is emitted). + startCol := p.out.Column - len("func ") + if d.Recv != nil { + p.parameters(d.Recv, funcParam) // method: print receiver + p.print(blank) + } + p.expr(d.Name) + p.signature(d.Type) + p.funcBody(p.distanceFrom(d.Pos(), startCol), vtab, d.Body) +} + +func (p *printer) decl(decl ast.Decl) { + switch d := decl.(type) { + case *ast.BadDecl: + p.setPos(d.Pos()) + p.print("BadDecl") + case *ast.GenDecl: + p.genDecl(d) + case *ast.FuncDecl: + p.funcDecl(d) + default: + panic("unreachable") + } +} + +// ---------------------------------------------------------------------------- +// Files + +func declToken(decl ast.Decl) (tok token.Token) { + tok = token.ILLEGAL + switch d := decl.(type) { + case *ast.GenDecl: + tok = d.Tok + case *ast.FuncDecl: + tok = token.FUNC + } + return tok +} + +func (p *printer) declList(list []ast.Decl) { + tok := token.ILLEGAL + for _, d := range list { + prev := tok + tok = declToken(d) + // If the declaration token changed (e.g., from CONST to TYPE) + // or the next declaration has documentation associated with it, + // print an empty line between top-level declarations. + // (because p.linebreak is called with the position of d, which + // is past any documentation, the minimum requirement is satisfied + // even w/o the extra getDoc(d) nil-check - leave it in case the + // linebreak logic improves - there's already a TODO). + if len(p.output) > 0 { + // only print line break if we are not at the beginning of the output + // (i.e., we are not printing only a partial program) + min := 1 + if prev != tok || getDoc(d) != nil { + min = 2 + } + // start a new section if the next declaration is a function + // that spans multiple lines (see also issue #19544) + p.linebreak(p.lineFor(d.Pos()), min, ignore, tok == token.FUNC && p.numLines(d) > 1) + } + p.decl(d) + } +} + +func (p *printer) file(src *ast.File) { + p.setComment(src.Doc) + p.setPos(src.Pos()) + p.print(token.PACKAGE, blank) + p.expr(src.Name) + p.declList(src.Decls) + p.print(newline) +} diff --git a/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/printer.go b/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/printer.go new file mode 100644 index 000000000..00713309b --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/govendor/go/printer/printer.go @@ -0,0 +1,1432 @@ +// Copyright 2009 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 printer implements printing of AST nodes. +package printer + +import ( + "fmt" + "go/ast" + "go/build/constraint" + "go/token" + "io" + "os" + "strings" + "sync" + "text/tabwriter" + "unicode" +) + +const ( + maxNewlines = 2 // max. number of newlines between source text + debug = false // enable for debugging + infinity = 1 << 30 +) + +type whiteSpace byte + +const ( + ignore = whiteSpace(0) + blank = whiteSpace(' ') + vtab = whiteSpace('\v') + newline = whiteSpace('\n') + formfeed = whiteSpace('\f') + indent = whiteSpace('>') + unindent = whiteSpace('<') +) + +// A pmode value represents the current printer mode. +type pmode int + +const ( + noExtraBlank pmode = 1 << iota // disables extra blank after /*-style comment + noExtraLinebreak // disables extra line break after /*-style comment +) + +type commentInfo struct { + cindex int // index of the next comment + comment *ast.CommentGroup // = printer.comments[cindex-1]; or nil + commentOffset int // = printer.posFor(printer.comments[cindex-1].List[0].Pos()).Offset; or infinity + commentNewline bool // true if the comment group contains newlines +} + +type printer struct { + // Configuration (does not change after initialization) + Config + fset *token.FileSet + + // Current state + output []byte // raw printer result + indent int // current indentation + level int // level == 0: outside composite literal; level > 0: inside composite literal + mode pmode // current printer mode + endAlignment bool // if set, terminate alignment immediately + impliedSemi bool // if set, a linebreak implies a semicolon + lastTok token.Token // last token printed (token.ILLEGAL if it's whitespace) + prevOpen token.Token // previous non-brace "open" token (, [, or token.ILLEGAL + wsbuf []whiteSpace // delayed white space + goBuild []int // start index of all //go:build comments in output + plusBuild []int // start index of all // +build comments in output + + // Positions + // The out position differs from the pos position when the result + // formatting differs from the source formatting (in the amount of + // white space). If there's a difference and SourcePos is set in + // ConfigMode, //line directives are used in the output to restore + // original source positions for a reader. + pos token.Position // current position in AST (source) space + out token.Position // current position in output space + last token.Position // value of pos after calling writeString + linePtr *int // if set, record out.Line for the next token in *linePtr + sourcePosErr error // if non-nil, the first error emitting a //line directive + + // The list of all source comments, in order of appearance. + comments []*ast.CommentGroup // may be nil + useNodeComments bool // if not set, ignore lead and line comments of nodes + + // Information about p.comments[p.cindex]; set up by nextComment. + commentInfo + + // Cache of already computed node sizes. + nodeSizes map[ast.Node]int + + // Cache of most recently computed line position. + cachedPos token.Pos + cachedLine int // line corresponding to cachedPos +} + +func (p *printer) internalError(msg ...any) { + if debug { + fmt.Print(p.pos.String() + ": ") + fmt.Println(msg...) + panic("mvdan.cc/gofumpt/internal/govendor/go/printer") + } +} + +// commentsHaveNewline reports whether a list of comments belonging to +// an *ast.CommentGroup contains newlines. Because the position information +// may only be partially correct, we also have to read the comment text. +func (p *printer) commentsHaveNewline(list []*ast.Comment) bool { + // len(list) > 0 + line := p.lineFor(list[0].Pos()) + for i, c := range list { + if i > 0 && p.lineFor(list[i].Pos()) != line { + // not all comments on the same line + return true + } + if t := c.Text; len(t) >= 2 && (t[1] == '/' || strings.Contains(t, "\n")) { + return true + } + } + _ = line + return false +} + +func (p *printer) nextComment() { + for p.cindex < len(p.comments) { + c := p.comments[p.cindex] + p.cindex++ + if list := c.List; len(list) > 0 { + p.comment = c + p.commentOffset = p.posFor(list[0].Pos()).Offset + p.commentNewline = p.commentsHaveNewline(list) + return + } + // we should not reach here (correct ASTs don't have empty + // ast.CommentGroup nodes), but be conservative and try again + } + // no more comments + p.commentOffset = infinity +} + +// commentBefore reports whether the current comment group occurs +// before the next position in the source code and printing it does +// not introduce implicit semicolons. +func (p *printer) commentBefore(next token.Position) bool { + return p.commentOffset < next.Offset && (!p.impliedSemi || !p.commentNewline) +} + +// commentSizeBefore returns the estimated size of the +// comments on the same line before the next position. +func (p *printer) commentSizeBefore(next token.Position) int { + // save/restore current p.commentInfo (p.nextComment() modifies it) + defer func(info commentInfo) { + p.commentInfo = info + }(p.commentInfo) + + size := 0 + for p.commentBefore(next) { + for _, c := range p.comment.List { + size += len(c.Text) + } + p.nextComment() + } + return size +} + +// recordLine records the output line number for the next non-whitespace +// token in *linePtr. It is used to compute an accurate line number for a +// formatted construct, independent of pending (not yet emitted) whitespace +// or comments. +func (p *printer) recordLine(linePtr *int) { + p.linePtr = linePtr +} + +// linesFrom returns the number of output lines between the current +// output line and the line argument, ignoring any pending (not yet +// emitted) whitespace or comments. It is used to compute an accurate +// size (in number of lines) for a formatted construct. +func (p *printer) linesFrom(line int) int { + return p.out.Line - line +} + +func (p *printer) posFor(pos token.Pos) token.Position { + // not used frequently enough to cache entire token.Position + return p.fset.PositionFor(pos, false /* absolute position */) +} + +func (p *printer) lineFor(pos token.Pos) int { + if pos != p.cachedPos { + p.cachedPos = pos + p.cachedLine = p.fset.PositionFor(pos, false /* absolute position */).Line + } + return p.cachedLine +} + +// writeLineDirective writes a //line directive if necessary. +func (p *printer) writeLineDirective(pos token.Position) { + if pos.IsValid() && (p.out.Line != pos.Line || p.out.Filename != pos.Filename) { + if strings.ContainsAny(pos.Filename, "\r\n") { + if p.sourcePosErr == nil { + p.sourcePosErr = fmt.Errorf("mvdan.cc/gofumpt/internal/govendor/go/printer: source filename contains unexpected newline character: %q", pos.Filename) + } + return + } + + p.output = append(p.output, tabwriter.Escape) // protect '\n' in //line from tabwriter interpretation + p.output = append(p.output, fmt.Sprintf("//line %s:%d\n", pos.Filename, pos.Line)...) + p.output = append(p.output, tabwriter.Escape) + // p.out must match the //line directive + p.out.Filename = pos.Filename + p.out.Line = pos.Line + } +} + +// writeIndent writes indentation. +func (p *printer) writeIndent() { + // use "hard" htabs - indentation columns + // must not be discarded by the tabwriter + n := p.Config.Indent + p.indent // include base indentation + for i := 0; i < n; i++ { + p.output = append(p.output, '\t') + } + + // update positions + p.pos.Offset += n + p.pos.Column += n + p.out.Column += n +} + +// writeByte writes ch n times to p.output and updates p.pos. +// Only used to write formatting (white space) characters. +func (p *printer) writeByte(ch byte, n int) { + if p.endAlignment { + // Ignore any alignment control character; + // and at the end of the line, break with + // a formfeed to indicate termination of + // existing columns. + switch ch { + case '\t', '\v': + ch = ' ' + case '\n', '\f': + ch = '\f' + p.endAlignment = false + } + } + + if p.out.Column == 1 { + // no need to write line directives before white space + p.writeIndent() + } + + for i := 0; i < n; i++ { + p.output = append(p.output, ch) + } + + // update positions + p.pos.Offset += n + if ch == '\n' || ch == '\f' { + p.pos.Line += n + p.out.Line += n + p.pos.Column = 1 + p.out.Column = 1 + return + } + p.pos.Column += n + p.out.Column += n +} + +// writeString writes the string s to p.output and updates p.pos, p.out, +// and p.last. If isLit is set, s is escaped w/ tabwriter.Escape characters +// to protect s from being interpreted by the tabwriter. +// +// Note: writeString is only used to write Go tokens, literals, and +// comments, all of which must be written literally. Thus, it is correct +// to always set isLit = true. However, setting it explicitly only when +// needed (i.e., when we don't know that s contains no tabs or line breaks) +// avoids processing extra escape characters and reduces run time of the +// printer benchmark by up to 10%. +func (p *printer) writeString(pos token.Position, s string, isLit bool) { + if p.out.Column == 1 { + if p.Config.Mode&SourcePos != 0 { + p.writeLineDirective(pos) + } + p.writeIndent() + } + + if pos.IsValid() { + // update p.pos (if pos is invalid, continue with existing p.pos) + // Note: Must do this after handling line beginnings because + // writeIndent updates p.pos if there's indentation, but p.pos + // is the position of s. + p.pos = pos + } + + if isLit { + // Protect s such that is passes through the tabwriter + // unchanged. Note that valid Go programs cannot contain + // tabwriter.Escape bytes since they do not appear in legal + // UTF-8 sequences. + p.output = append(p.output, tabwriter.Escape) + } + + if debug { + p.output = append(p.output, fmt.Sprintf("/*%s*/", pos)...) // do not update p.pos! + } + p.output = append(p.output, s...) + + // update positions + nlines := 0 + var li int // index of last newline; valid if nlines > 0 + for i := 0; i < len(s); i++ { + // Raw string literals may contain any character except back quote (`). + if ch := s[i]; ch == '\n' || ch == '\f' { + // account for line break + nlines++ + li = i + // A line break inside a literal will break whatever column + // formatting is in place; ignore any further alignment through + // the end of the line. + p.endAlignment = true + } + } + p.pos.Offset += len(s) + if nlines > 0 { + p.pos.Line += nlines + p.out.Line += nlines + c := len(s) - li + p.pos.Column = c + p.out.Column = c + } else { + p.pos.Column += len(s) + p.out.Column += len(s) + } + + if isLit { + p.output = append(p.output, tabwriter.Escape) + } + + p.last = p.pos +} + +// writeCommentPrefix writes the whitespace before a comment. +// If there is any pending whitespace, it consumes as much of +// it as is likely to help position the comment nicely. +// pos is the comment position, next the position of the item +// after all pending comments, prev is the previous comment in +// a group of comments (or nil), and tok is the next token. +func (p *printer) writeCommentPrefix(pos, next token.Position, prev *ast.Comment, tok token.Token) { + if len(p.output) == 0 { + // the comment is the first item to be printed - don't write any whitespace + return + } + + if pos.IsValid() && pos.Filename != p.last.Filename { + // comment in a different file - separate with newlines + p.writeByte('\f', maxNewlines) + return + } + + if pos.Line == p.last.Line && (prev == nil || prev.Text[1] != '/') { + // comment on the same line as last item: + // separate with at least one separator + hasSep := false + if prev == nil { + // first comment of a comment group + j := 0 + for i, ch := range p.wsbuf { + switch ch { + case blank: + // ignore any blanks before a comment + p.wsbuf[i] = ignore + continue + case vtab: + // respect existing tabs - important + // for proper formatting of commented structs + hasSep = true + continue + case indent: + // apply pending indentation + continue + } + j = i + break + } + p.writeWhitespace(j) + } + // make sure there is at least one separator + if !hasSep { + sep := byte('\t') + if pos.Line == next.Line { + // next item is on the same line as the comment + // (which must be a /*-style comment): separate + // with a blank instead of a tab + sep = ' ' + } + p.writeByte(sep, 1) + } + + } else { + // comment on a different line: + // separate with at least one line break + droppedLinebreak := false + j := 0 + for i, ch := range p.wsbuf { + switch ch { + case blank, vtab: + // ignore any horizontal whitespace before line breaks + p.wsbuf[i] = ignore + continue + case indent: + // apply pending indentation + continue + case unindent: + // if this is not the last unindent, apply it + // as it is (likely) belonging to the last + // construct (e.g., a multi-line expression list) + // and is not part of closing a block + if i+1 < len(p.wsbuf) && p.wsbuf[i+1] == unindent { + continue + } + // if the next token is not a closing }, apply the unindent + // if it appears that the comment is aligned with the + // token; otherwise assume the unindent is part of a + // closing block and stop (this scenario appears with + // comments before a case label where the comments + // apply to the next case instead of the current one) + if tok != token.RBRACE && pos.Column == next.Column { + continue + } + case newline, formfeed: + p.wsbuf[i] = ignore + droppedLinebreak = prev == nil // record only if first comment of a group + } + j = i + break + } + p.writeWhitespace(j) + + // determine number of linebreaks before the comment + n := 0 + if pos.IsValid() && p.last.IsValid() { + n = pos.Line - p.last.Line + if n < 0 { // should never happen + n = 0 + } + } + + // at the package scope level only (p.indent == 0), + // add an extra newline if we dropped one before: + // this preserves a blank line before documentation + // comments at the package scope level (issue 2570) + if p.indent == 0 && droppedLinebreak { + n++ + } + + // make sure there is at least one line break + // if the previous comment was a line comment + if n == 0 && prev != nil && prev.Text[1] == '/' { + n = 1 + } + + if n > 0 { + // use formfeeds to break columns before a comment; + // this is analogous to using formfeeds to separate + // individual lines of /*-style comments + p.writeByte('\f', nlimit(n)) + } + } +} + +// Returns true if s contains only white space +// (only tabs and blanks can appear in the printer's context). +func isBlank(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] > ' ' { + return false + } + } + return true +} + +// commonPrefix returns the common prefix of a and b. +func commonPrefix(a, b string) string { + i := 0 + for i < len(a) && i < len(b) && a[i] == b[i] && (a[i] <= ' ' || a[i] == '*') { + i++ + } + return a[0:i] +} + +// trimRight returns s with trailing whitespace removed. +func trimRight(s string) string { + return strings.TrimRightFunc(s, unicode.IsSpace) +} + +// stripCommonPrefix removes a common prefix from /*-style comment lines (unless no +// comment line is indented, all but the first line have some form of space prefix). +// The prefix is computed using heuristics such that is likely that the comment +// contents are nicely laid out after re-printing each line using the printer's +// current indentation. +func stripCommonPrefix(lines []string) { + if len(lines) <= 1 { + return // at most one line - nothing to do + } + // len(lines) > 1 + + // The heuristic in this function tries to handle a few + // common patterns of /*-style comments: Comments where + // the opening /* and closing */ are aligned and the + // rest of the comment text is aligned and indented with + // blanks or tabs, cases with a vertical "line of stars" + // on the left, and cases where the closing */ is on the + // same line as the last comment text. + + // Compute maximum common white prefix of all but the first, + // last, and blank lines, and replace blank lines with empty + // lines (the first line starts with /* and has no prefix). + // In cases where only the first and last lines are not blank, + // such as two-line comments, or comments where all inner lines + // are blank, consider the last line for the prefix computation + // since otherwise the prefix would be empty. + // + // Note that the first and last line are never empty (they + // contain the opening /* and closing */ respectively) and + // thus they can be ignored by the blank line check. + prefix := "" + prefixSet := false + if len(lines) > 2 { + for i, line := range lines[1 : len(lines)-1] { + if isBlank(line) { + lines[1+i] = "" // range starts with lines[1] + } else { + if !prefixSet { + prefix = line + prefixSet = true + } + prefix = commonPrefix(prefix, line) + } + } + } + // If we don't have a prefix yet, consider the last line. + if !prefixSet { + line := lines[len(lines)-1] + prefix = commonPrefix(line, line) + } + + /* + * Check for vertical "line of stars" and correct prefix accordingly. + */ + lineOfStars := false + if p, _, ok := strings.Cut(prefix, "*"); ok { + // remove trailing blank from prefix so stars remain aligned + prefix = strings.TrimSuffix(p, " ") + lineOfStars = true + } else { + // No line of stars present. + // Determine the white space on the first line after the /* + // and before the beginning of the comment text, assume two + // blanks instead of the /* unless the first character after + // the /* is a tab. If the first comment line is empty but + // for the opening /*, assume up to 3 blanks or a tab. This + // whitespace may be found as suffix in the common prefix. + first := lines[0] + if isBlank(first[2:]) { + // no comment text on the first line: + // reduce prefix by up to 3 blanks or a tab + // if present - this keeps comment text indented + // relative to the /* and */'s if it was indented + // in the first place + i := len(prefix) + for n := 0; n < 3 && i > 0 && prefix[i-1] == ' '; n++ { + i-- + } + if i == len(prefix) && i > 0 && prefix[i-1] == '\t' { + i-- + } + prefix = prefix[0:i] + } else { + // comment text on the first line + suffix := make([]byte, len(first)) + n := 2 // start after opening /* + for n < len(first) && first[n] <= ' ' { + suffix[n] = first[n] + n++ + } + if n > 2 && suffix[2] == '\t' { + // assume the '\t' compensates for the /* + suffix = suffix[2:n] + } else { + // otherwise assume two blanks + suffix[0], suffix[1] = ' ', ' ' + suffix = suffix[0:n] + } + // Shorten the computed common prefix by the length of + // suffix, if it is found as suffix of the prefix. + prefix = strings.TrimSuffix(prefix, string(suffix)) + } + } + + // Handle last line: If it only contains a closing */, align it + // with the opening /*, otherwise align the text with the other + // lines. + last := lines[len(lines)-1] + closing := "*/" + before, _, _ := strings.Cut(last, closing) // closing always present + if isBlank(before) { + // last line only contains closing */ + if lineOfStars { + closing = " */" // add blank to align final star + } + lines[len(lines)-1] = prefix + closing + } else { + // last line contains more comment text - assume + // it is aligned like the other lines and include + // in prefix computation + prefix = commonPrefix(prefix, last) + } + + // Remove the common prefix from all but the first and empty lines. + for i, line := range lines { + if i > 0 && line != "" { + lines[i] = line[len(prefix):] + } + } +} + +func (p *printer) writeComment(comment *ast.Comment) { + text := comment.Text + pos := p.posFor(comment.Pos()) + + const linePrefix = "//line " + if strings.HasPrefix(text, linePrefix) && (!pos.IsValid() || pos.Column == 1) { + // Possibly a //-style line directive. + // Suspend indentation temporarily to keep line directive valid. + defer func(indent int) { p.indent = indent }(p.indent) + p.indent = 0 + } + + // shortcut common case of //-style comments + if text[1] == '/' { + if constraint.IsGoBuild(text) { + p.goBuild = append(p.goBuild, len(p.output)) + } else if constraint.IsPlusBuild(text) { + p.plusBuild = append(p.plusBuild, len(p.output)) + } + p.writeString(pos, trimRight(text), true) + return + } + + // for /*-style comments, print line by line and let the + // write function take care of the proper indentation + lines := strings.Split(text, "\n") + + // The comment started in the first column but is going + // to be indented. For an idempotent result, add indentation + // to all lines such that they look like they were indented + // before - this will make sure the common prefix computation + // is the same independent of how many times formatting is + // applied (was issue 1835). + if pos.IsValid() && pos.Column == 1 && p.indent > 0 { + for i, line := range lines[1:] { + lines[1+i] = " " + line + } + } + + stripCommonPrefix(lines) + + // write comment lines, separated by formfeed, + // without a line break after the last line + for i, line := range lines { + if i > 0 { + p.writeByte('\f', 1) + pos = p.pos + } + if len(line) > 0 { + p.writeString(pos, trimRight(line), true) + } + } +} + +// writeCommentSuffix writes a line break after a comment if indicated +// and processes any leftover indentation information. If a line break +// is needed, the kind of break (newline vs formfeed) depends on the +// pending whitespace. The writeCommentSuffix result indicates if a +// newline was written or if a formfeed was dropped from the whitespace +// buffer. +func (p *printer) writeCommentSuffix(needsLinebreak bool) (wroteNewline, droppedFF bool) { + for i, ch := range p.wsbuf { + switch ch { + case blank, vtab: + // ignore trailing whitespace + p.wsbuf[i] = ignore + case indent, unindent: + // don't lose indentation information + case newline, formfeed: + // if we need a line break, keep exactly one + // but remember if we dropped any formfeeds + if needsLinebreak { + needsLinebreak = false + wroteNewline = true + } else { + if ch == formfeed { + droppedFF = true + } + p.wsbuf[i] = ignore + } + } + } + p.writeWhitespace(len(p.wsbuf)) + + // make sure we have a line break + if needsLinebreak { + p.writeByte('\n', 1) + wroteNewline = true + } + + return wroteNewline, droppedFF +} + +// containsLinebreak reports whether the whitespace buffer contains any line breaks. +func (p *printer) containsLinebreak() bool { + for _, ch := range p.wsbuf { + if ch == newline || ch == formfeed { + return true + } + } + return false +} + +// intersperseComments consumes all comments that appear before the next token +// tok and prints it together with the buffered whitespace (i.e., the whitespace +// that needs to be written before the next token). A heuristic is used to mix +// the comments and whitespace. The intersperseComments result indicates if a +// newline was written or if a formfeed was dropped from the whitespace buffer. +func (p *printer) intersperseComments(next token.Position, tok token.Token) (wroteNewline, droppedFF bool) { + var last *ast.Comment + for p.commentBefore(next) { + list := p.comment.List + changed := false + if p.lastTok != token.IMPORT && // do not rewrite cgo's import "C" comments + p.posFor(p.comment.Pos()).Column == 1 && + p.posFor(p.comment.End()+1) == next { + // Unindented comment abutting next token position: + // a top-level doc comment. + list = formatDocComment(list) + changed = true + + if len(p.comment.List) > 0 && len(list) == 0 { + // The doc comment was removed entirely. + // Keep preceding whitespace. + p.writeCommentPrefix(p.posFor(p.comment.Pos()), next, last, tok) + // Change print state to continue at next. + p.pos = next + p.last = next + // There can't be any more comments. + p.nextComment() + return p.writeCommentSuffix(false) + } + } + for _, c := range list { + p.writeCommentPrefix(p.posFor(c.Pos()), next, last, tok) + p.writeComment(c) + last = c + } + // In case list was rewritten, change print state to where + // the original list would have ended. + if len(p.comment.List) > 0 && changed { + last = p.comment.List[len(p.comment.List)-1] + p.pos = p.posFor(last.End()) + p.last = p.pos + } + p.nextComment() + } + + if last != nil { + // If the last comment is a /*-style comment and the next item + // follows on the same line but is not a comma, and not a "closing" + // token immediately following its corresponding "opening" token, + // add an extra separator unless explicitly disabled. Use a blank + // as separator unless we have pending linebreaks, they are not + // disabled, and we are outside a composite literal, in which case + // we want a linebreak (issue 15137). + // TODO(gri) This has become overly complicated. We should be able + // to track whether we're inside an expression or statement and + // use that information to decide more directly. + needsLinebreak := false + if p.mode&noExtraBlank == 0 && + last.Text[1] == '*' && p.lineFor(last.Pos()) == next.Line && + tok != token.COMMA && + (tok != token.RPAREN || p.prevOpen == token.LPAREN) && + (tok != token.RBRACK || p.prevOpen == token.LBRACK) { + if p.containsLinebreak() && p.mode&noExtraLinebreak == 0 && p.level == 0 { + needsLinebreak = true + } else { + p.writeByte(' ', 1) + } + } + // Ensure that there is a line break after a //-style comment, + // before EOF, and before a closing '}' unless explicitly disabled. + if last.Text[1] == '/' || + tok == token.EOF || + tok == token.RBRACE && p.mode&noExtraLinebreak == 0 { + needsLinebreak = true + } + return p.writeCommentSuffix(needsLinebreak) + } + + // 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 +} + +// writeWhitespace writes the first n whitespace entries. +func (p *printer) writeWhitespace(n int) { + // write entries + for i := 0; i < n; i++ { + switch ch := p.wsbuf[i]; ch { + case ignore: + // ignore! + case indent: + p.indent++ + case unindent: + p.indent-- + if p.indent < 0 { + p.internalError("negative indentation:", p.indent) + p.indent = 0 + } + case newline, formfeed: + // A line break immediately followed by a "correcting" + // unindent is swapped with the unindent - this permits + // proper label positioning. If a comment is between + // the line break and the label, the unindent is not + // part of the comment whitespace prefix and the comment + // will be positioned correctly indented. + if i+1 < n && p.wsbuf[i+1] == unindent { + // Use a formfeed to terminate the current section. + // Otherwise, a long label name on the next line leading + // to a wide column may increase the indentation column + // of lines before the label; effectively leading to wrong + // indentation. + p.wsbuf[i], p.wsbuf[i+1] = unindent, formfeed + i-- // do it again + continue + } + fallthrough + default: + p.writeByte(byte(ch), 1) + } + } + + // shift remaining entries down + l := copy(p.wsbuf, p.wsbuf[n:]) + p.wsbuf = p.wsbuf[:l] +} + +// ---------------------------------------------------------------------------- +// Printing interface + +// nlimit limits n to maxNewlines. +func nlimit(n int) int { + return min(n, maxNewlines) +} + +func mayCombine(prev token.Token, next byte) (b bool) { + switch prev { + case token.INT: + b = next == '.' // 1. + case token.ADD: + b = next == '+' // ++ + case token.SUB: + b = next == '-' // -- + case token.QUO: + b = next == '*' // /* + case token.LSS: + b = next == '-' || next == '<' // <- or << + case token.AND: + b = next == '&' || next == '^' // && or &^ + } + return b +} + +func (p *printer) setPos(pos token.Pos) { + if pos.IsValid() { + p.pos = p.posFor(pos) // accurate position of next item + } +} + +// print prints a list of "items" (roughly corresponding to syntactic +// tokens, but also including whitespace and formatting information). +// It is the only print function that should be called directly from +// any of the AST printing functions in nodes.go. +// +// Whitespace is accumulated until a non-whitespace token appears. Any +// comments that need to appear before that token are printed first, +// taking into account the amount and structure of any pending white- +// space for best comment placement. Then, any leftover whitespace is +// printed, followed by the actual token. +func (p *printer) print(args ...any) { + for _, arg := range args { + // information about the current arg + var data string + var isLit bool + var impliedSemi bool // value for p.impliedSemi after this arg + + // record previous opening token, if any + switch p.lastTok { + case token.ILLEGAL: + // ignore (white space) + case token.LPAREN, token.LBRACK: + p.prevOpen = p.lastTok + default: + // other tokens followed any opening token + p.prevOpen = token.ILLEGAL + } + + switch x := arg.(type) { + case pmode: + // toggle printer mode + p.mode ^= x + continue + + case whiteSpace: + if x == ignore { + // don't add ignore's to the buffer; they + // may screw up "correcting" unindents (see + // LabeledStmt) + continue + } + i := len(p.wsbuf) + if i == cap(p.wsbuf) { + // Whitespace sequences are very short so this should + // never happen. Handle gracefully (but possibly with + // bad comment placement) if it does happen. + p.writeWhitespace(i) + i = 0 + } + p.wsbuf = p.wsbuf[0 : i+1] + p.wsbuf[i] = x + if x == newline || x == formfeed { + // newlines affect the current state (p.impliedSemi) + // and not the state after printing arg (impliedSemi) + // because comments can be interspersed before the arg + // in this case + p.impliedSemi = false + } + p.lastTok = token.ILLEGAL + continue + + case *ast.Ident: + data = x.Name + impliedSemi = true + p.lastTok = token.IDENT + + case *ast.BasicLit: + data = x.Value + isLit = true + impliedSemi = true + p.lastTok = x.Kind + + case token.Token: + s := x.String() + if mayCombine(p.lastTok, s[0]) { + // the previous and the current token must be + // separated by a blank otherwise they combine + // into a different incorrect token sequence + // (except for token.INT followed by a '.' this + // should never happen because it is taken care + // of via binary expression formatting) + if len(p.wsbuf) != 0 { + p.internalError("whitespace buffer not empty") + } + p.wsbuf = p.wsbuf[0:1] + p.wsbuf[0] = ' ' + } + data = s + // some keywords followed by a newline imply a semicolon + switch x { + case token.BREAK, token.CONTINUE, token.FALLTHROUGH, token.RETURN, + token.INC, token.DEC, token.RPAREN, token.RBRACK, token.RBRACE: + impliedSemi = true + } + p.lastTok = x + + case string: + // incorrect AST - print error message + data = x + isLit = true + impliedSemi = true + p.lastTok = token.STRING + + default: + fmt.Fprintf(os.Stderr, "print: unsupported argument %v (%T)\n", arg, arg) + panic("mvdan.cc/gofumpt/internal/govendor/go/printer type") + } + // data != "" + + next := p.pos // estimated/accurate position of next item + wroteNewline, droppedFF := p.flush(next, p.lastTok) + + // intersperse extra newlines if present in the source and + // if they don't cause extra semicolons (don't do this in + // flush as it will cause extra newlines at the end of a file) + if !p.impliedSemi { + n := nlimit(next.Line - p.pos.Line) + // don't exceed maxNewlines if we already wrote one + if wroteNewline && n == maxNewlines { + n = maxNewlines - 1 + } + if n > 0 { + ch := byte('\n') + if droppedFF { + ch = '\f' // use formfeed since we dropped one before + } + p.writeByte(ch, n) + impliedSemi = false + } + } + + // the next token starts now - record its line number if requested + if p.linePtr != nil { + *p.linePtr = p.out.Line + p.linePtr = nil + } + + p.writeString(next, data, isLit) + p.impliedSemi = impliedSemi + } +} + +// flush prints any pending comments and whitespace occurring textually +// before the position of the next token tok. The flush result indicates +// if a newline was written or if a formfeed was dropped from the whitespace +// buffer. +func (p *printer) flush(next token.Position, tok token.Token) (wroteNewline, droppedFF bool) { + if p.commentBefore(next) { + // if there are comments before the next item, intersperse them + wroteNewline, droppedFF = p.intersperseComments(next, tok) + } else { + // otherwise, write any leftover whitespace + p.writeWhitespace(len(p.wsbuf)) + } + return wroteNewline, droppedFF +} + +// getDoc returns the ast.CommentGroup associated with n, if any. +func getDoc(n ast.Node) *ast.CommentGroup { + switch n := n.(type) { + case *ast.Field: + return n.Doc + case *ast.ImportSpec: + return n.Doc + case *ast.ValueSpec: + return n.Doc + case *ast.TypeSpec: + return n.Doc + case *ast.GenDecl: + return n.Doc + case *ast.FuncDecl: + return n.Doc + case *ast.File: + return n.Doc + } + return nil +} + +func getLastComment(n ast.Node) *ast.CommentGroup { + switch n := n.(type) { + case *ast.Field: + return n.Comment + case *ast.ImportSpec: + return n.Comment + case *ast.ValueSpec: + return n.Comment + case *ast.TypeSpec: + return n.Comment + case *ast.GenDecl: + if len(n.Specs) > 0 { + return getLastComment(n.Specs[len(n.Specs)-1]) + } + case *ast.File: + if len(n.Comments) > 0 { + return n.Comments[len(n.Comments)-1] + } + } + return nil +} + +func (p *printer) printNode(node any) error { + // unpack *CommentedNode, if any + var comments []*ast.CommentGroup + if cnode, ok := node.(*CommentedNode); ok { + node = cnode.Node + comments = cnode.Comments + } + + if comments != nil { + // commented node - restrict comment list to relevant range + n, ok := node.(ast.Node) + if !ok { + goto unsupported + } + beg := n.Pos() + end := n.End() + // if the node has associated documentation, + // include that commentgroup in the range + // (the comment list is sorted in the order + // of the comment appearance in the source code) + if doc := getDoc(n); doc != nil { + beg = doc.Pos() + } + if com := getLastComment(n); com != nil { + if e := com.End(); e > end { + end = e + } + } + // token.Pos values are global offsets, we can + // compare them directly + i := 0 + for i < len(comments) && comments[i].End() < beg { + i++ + } + j := i + for j < len(comments) && comments[j].Pos() < end { + j++ + } + if i < j { + p.comments = comments[i:j] + } + } else if n, ok := node.(*ast.File); ok { + // use ast.File comments, if any + p.comments = n.Comments + } + + // if there are no comments, use node comments + p.useNodeComments = p.comments == nil + + // get comments ready for use + p.nextComment() + + p.print(pmode(0)) + + // format node + switch n := node.(type) { + case ast.Expr: + p.expr(n) + case ast.Stmt: + // A labeled statement will un-indent to position the label. + // Set p.indent to 1 so we don't get indent "underflow". + if _, ok := n.(*ast.LabeledStmt); ok { + p.indent = 1 + } + p.stmt(n, false) + case ast.Decl: + p.decl(n) + case ast.Spec: + p.spec(n, 1, false) + case []ast.Stmt: + // A labeled statement will un-indent to position the label. + // Set p.indent to 1 so we don't get indent "underflow". + for _, s := range n { + if _, ok := s.(*ast.LabeledStmt); ok { + p.indent = 1 + } + } + p.stmtList(n, 0, false) + case []ast.Decl: + p.declList(n) + case *ast.File: + p.file(n) + default: + goto unsupported + } + + return p.sourcePosErr + +unsupported: + return fmt.Errorf("mvdan.cc/gofumpt/internal/govendor/go/printer: unsupported node type %T", node) +} + +// ---------------------------------------------------------------------------- +// Trimmer + +// A trimmer is an io.Writer filter for stripping tabwriter.Escape +// characters, trailing blanks and tabs, and for converting formfeed +// and vtab characters into newlines and htabs (in case no tabwriter +// is used). Text bracketed by tabwriter.Escape characters is passed +// through unchanged. +type trimmer struct { + output io.Writer + state int + space []byte +} + +// trimmer is implemented as a state machine. +// It can be in one of the following states: +const ( + inSpace = iota // inside space + inEscape // inside text bracketed by tabwriter.Escapes + inText // inside text +) + +func (p *trimmer) resetSpace() { + p.state = inSpace + p.space = p.space[0:0] +} + +// Design note: It is tempting to eliminate extra blanks occurring in +// whitespace in this function as it could simplify some +// of the blanks logic in the node printing functions. +// However, this would mess up any formatting done by +// the tabwriter. + +var aNewline = []byte("\n") + +func (p *trimmer) Write(data []byte) (n int, err error) { + // invariants: + // p.state == inSpace: + // p.space is unwritten + // p.state == inEscape, inText: + // data[m:n] is unwritten + m := 0 + var b byte + for n, b = range data { + if b == '\v' { + b = '\t' // convert to htab + } + switch p.state { + case inSpace: + switch b { + case '\t', ' ': + p.space = append(p.space, b) + case '\n', '\f': + p.resetSpace() // discard trailing space + _, err = p.output.Write(aNewline) + case tabwriter.Escape: + _, err = p.output.Write(p.space) + p.state = inEscape + m = n + 1 // +1: skip tabwriter.Escape + default: + _, err = p.output.Write(p.space) + p.state = inText + m = n + } + case inEscape: + if b == tabwriter.Escape { + _, err = p.output.Write(data[m:n]) + p.resetSpace() + } + case inText: + switch b { + case '\t', ' ': + _, err = p.output.Write(data[m:n]) + p.resetSpace() + p.space = append(p.space, b) + case '\n', '\f': + _, err = p.output.Write(data[m:n]) + p.resetSpace() + if err == nil { + _, err = p.output.Write(aNewline) + } + case tabwriter.Escape: + _, err = p.output.Write(data[m:n]) + p.state = inEscape + m = n + 1 // +1: skip tabwriter.Escape + } + default: + panic("unreachable") + } + if err != nil { + return n, err + } + } + n = len(data) + + switch p.state { + case inEscape, inText: + _, err = p.output.Write(data[m:n]) + p.resetSpace() + } + + return n, err +} + +// ---------------------------------------------------------------------------- +// Public interface + +// A Mode value is a set of flags (or 0). They control printing. +type Mode uint + +const ( + RawFormat Mode = 1 << iota // do not use a tabwriter; if set, UseSpaces is ignored + TabIndent // use tabs for indentation independent of UseSpaces + UseSpaces // use spaces instead of tabs for alignment + SourcePos // emit //line directives to preserve original source positions +) + +// The mode below is not included in printer's public API because +// editing code text is deemed out of scope. Because this mode is +// unexported, it's also possible to modify or remove it based on +// the evolving needs of mvdan.cc/gofumpt/internal/govendor/go/format and cmd/gofmt without breaking +// users. See discussion in CL 240683. +const ( + // normalizeNumbers means to canonicalize number + // literal prefixes and exponents while printing. + // + // This value is known in and used by mvdan.cc/gofumpt/internal/govendor/go/format and cmd/gofmt. + // It is currently more convenient and performant for those + // packages to apply number normalization during printing, + // rather than by modifying the AST in advance. + normalizeNumbers Mode = 1 << 30 +) + +// A Config node controls the output of Fprint. +type Config struct { + Mode Mode // default: 0 + Tabwidth int // default: 8 + Indent int // default: 0 (all code is indented at least by this much) +} + +var printerPool = sync.Pool{ + New: func() any { + return &printer{ + // Whitespace sequences are short. + wsbuf: make([]whiteSpace, 0, 16), + // We start the printer with a 16K output buffer, which is currently + // larger than about 80% of Go files in the standard library. + output: make([]byte, 0, 16<<10), + } + }, +} + +func newPrinter(cfg *Config, fset *token.FileSet, nodeSizes map[ast.Node]int) *printer { + p := printerPool.Get().(*printer) + *p = printer{ + Config: *cfg, + fset: fset, + pos: token.Position{Line: 1, Column: 1}, + out: token.Position{Line: 1, Column: 1}, + wsbuf: p.wsbuf[:0], + nodeSizes: nodeSizes, + cachedPos: -1, + output: p.output[:0], + } + return p +} + +func (p *printer) free() { + // Hard limit on buffer size; see https://golang.org/issue/23199. + if cap(p.output) > 64<<10 { + return + } + + printerPool.Put(p) +} + +// fprint implements Fprint and takes a nodesSizes map for setting up the printer state. +func (cfg *Config) fprint(output io.Writer, fset *token.FileSet, node any, nodeSizes map[ast.Node]int) (err error) { + // print node + p := newPrinter(cfg, fset, nodeSizes) + defer p.free() + if err = p.printNode(node); err != nil { + return err + } + // print outstanding comments + p.impliedSemi = false // EOF acts like a newline + p.flush(token.Position{Offset: infinity, Line: infinity}, token.EOF) + + // output is buffered in p.output now. + // fix //go:build and // +build comments if needed. + p.fixGoBuildLines() + + // redirect output through a trimmer to eliminate trailing whitespace + // (Input to a tabwriter must be untrimmed since trailing tabs provide + // formatting information. The tabwriter could provide trimming + // functionality but no tabwriter is used when RawFormat is set.) + output = &trimmer{output: output} + + // redirect output through a tabwriter if necessary + if cfg.Mode&RawFormat == 0 { + minwidth := cfg.Tabwidth + + padchar := byte('\t') + if cfg.Mode&UseSpaces != 0 { + padchar = ' ' + } + + twmode := tabwriter.DiscardEmptyColumns + if cfg.Mode&TabIndent != 0 { + minwidth = 0 + twmode |= tabwriter.TabIndent + } + + output = tabwriter.NewWriter(output, minwidth, cfg.Tabwidth, 1, padchar, twmode) + } + + // write printer result via tabwriter/trimmer to output + if _, err = output.Write(p.output); err != nil { + return err + } + + // flush tabwriter, if any + if tw, _ := output.(*tabwriter.Writer); tw != nil { + err = tw.Flush() + } + + return err +} + +// A CommentedNode bundles an AST node and corresponding comments. +// It may be provided as argument to any of the [Fprint] functions. +type CommentedNode struct { + Node any // *ast.File, or ast.Expr, ast.Decl, ast.Spec, or ast.Stmt + Comments []*ast.CommentGroup +} + +// Fprint "pretty-prints" an AST node to output for a given configuration cfg. +// Position information is interpreted relative to the file set fset. +// The node type must be *[ast.File], *[CommentedNode], [][ast.Decl], [][ast.Stmt], +// or assignment-compatible to [ast.Expr], [ast.Decl], [ast.Spec], or [ast.Stmt]. +func (cfg *Config) Fprint(output io.Writer, fset *token.FileSet, node any) error { + return cfg.fprint(output, fset, node, make(map[ast.Node]int)) +} + +// Fprint "pretty-prints" an AST node to output. +// It calls [Config.Fprint] with default settings. +// Note that gofmt uses tabs for indentation but spaces for alignment; +// use format.Node (package mvdan.cc/gofumpt/internal/govendor/go/format) for output that matches gofmt. +func Fprint(output io.Writer, fset *token.FileSet, node any) error { + return (&Config{Tabwidth: 8}).Fprint(output, fset, node) +} diff --git a/vendor/mvdan.cc/gofumpt/internal/version/version.go b/vendor/mvdan.cc/gofumpt/internal/version/version.go new file mode 100644 index 000000000..e13623500 --- /dev/null +++ b/vendor/mvdan.cc/gofumpt/internal/version/version.go @@ -0,0 +1,57 @@ +// Copyright (c) 2020, Daniel Martí +// See LICENSE for licensing information + +package version + +import ( + "fmt" + "os" + "runtime" + "runtime/debug" +) + +const ourModulePath = "mvdan.cc/gofumpt" + +func findModule(info *debug.BuildInfo, modulePath string) *debug.Module { + if info.Main.Path == modulePath { + return &info.Main + } + for _, dep := range info.Deps { + if dep.Path == modulePath { + return dep + } + } + return nil +} + +func gofumptVersion() string { + info, ok := debug.ReadBuildInfo() + if !ok { + return "(no build info)" + } + // Note that gofumpt may be used as a library via the format package, + // so we cannot assume it is the main module in the build. + mod := findModule(info, ourModulePath) + if mod == nil { + return "(module not found)" + } + if mod.Replace != nil { + mod = mod.Replace + } + return mod.Version +} + +func goVersion() string { + // For the tests, as we don't want the Go version to change over time. + if testVersion := os.Getenv("GO_VERSION_TEST"); testVersion != "" { + return testVersion + } + return runtime.Version() +} + +func String(injected string) string { + if injected != "" { + return fmt.Sprintf("%s (%s)", injected, goVersion()) + } + return fmt.Sprintf("%s (%s)", gofumptVersion(), goVersion()) +} From 2d4a4dcdc11bf1472d547ef7d3581d5e37e8786a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 08:10:18 +0200 Subject: [PATCH 150/384] Use `go tool gofumpt` for `make format` This way it always uses our pinned 0.9.2 version. --- AGENTS.md | 2 +- Makefile | 2 +- justfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7bed30c6c..58e0a38f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Windows box has only `just`). list and the keybinding cheatsheets in `docs-master/keybindings/`). Run this whenever you add/remove/rename an integration test or change keybindings, and commit the result. CI fails if these are stale. -- `just format` — `gofumpt -l -w .`. Run before every commit. +- `just format` — `go tool gofumpt -l -w .`. Run before every commit. - `just build` — build the binary. - `just unit-test` — `go test ./... -short`. - `just e2e` — run all integration tests headlessly; `just e2e ` runs a diff --git a/Makefile b/Makefile index 38dc118cb..6b51d5250 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ generate: .PHONY: format format: - gofumpt -l -w . + go tool gofumpt -l -w . .PHONY: lint lint: diff --git a/justfile b/justfile index c6785b933..7909cb2d6 100644 --- a/justfile +++ b/justfile @@ -34,7 +34,7 @@ generate: go generate ./... format: - gofumpt -l -w . + go tool gofumpt -l -w . lint: ./scripts/golangci-lint-shim.sh run From 9882e4a03ef32f28b11f6c4b81843d693f70e8b0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 08:15:37 +0200 Subject: [PATCH 151/384] Add gofumpt-tool.sh script and use it in VS Code --- .vscode/settings.json | 4 +++- scripts/gofumpt-tool.sh | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100755 scripts/gofumpt-tool.sh diff --git a/.vscode/settings.json b/.vscode/settings.json index dd4398af9..fb3f4ac2b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "gopls": { - "formatting.gofumpt": true, + "formatting.gofumpt": false, "ui.diagnostic.staticcheck": true, "ui.diagnostic.analyses": { // This list must match the one in .golangci.yml @@ -24,6 +24,8 @@ }, "go.alternateTools": { "golangci-lint-v2": "${workspaceFolder}/scripts/golangci-lint-shim.sh", + "customFormatter": "${workspaceFolder}/scripts/gofumpt-tool.sh", }, "go.lintTool": "golangci-lint-v2", + "go.formatTool": "custom", } diff --git a/scripts/gofumpt-tool.sh b/scripts/gofumpt-tool.sh new file mode 100755 index 000000000..f9aae65a7 --- /dev/null +++ b/scripts/gofumpt-tool.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +# This is used by VSCode; it is not very useful otherwise, since it's easy +# enough to just run `go tool gofumpt` directly, or use `just format`. + +set -e + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(dirname "$script_dir") + +cd "$repo_root" +exec go tool gofumpt "$@" From 8af6104454344ee44a9d3dd1d9fdcf06369443c5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 09:06:21 +0200 Subject: [PATCH 152/384] Check gofumpt formatting with the pinned version in CI and lint golangci-lint bundles gofumpt v0.8.0, which formats code differently from the v0.9.2 we pin in go.mod. Enforcing formatting through golangci-lint may therefore disagree with `just format`. Remove gofumpt from golangci-lint's formatters and instead run the pinned `go tool gofumpt` as a standalone check via a new scripts/gofumpt-check.sh, wired into CI, `just lint`, and `make lint`. goimports stays in golangci-lint; it's stable across versions and nothing runs a competing copy of it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 5 +++++ .golangci.yml | 4 +++- Makefile | 1 + justfile | 1 + scripts/gofumpt-check.sh | 22 ++++++++++++++++++++++ 5 files changed, 32 insertions(+), 1 deletion(-) create mode 100755 scripts/gofumpt-check.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f7c1d2d2..4e7538b0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,7 +167,12 @@ jobs: uses: actions/setup-go@v6 with: go-version: 1.25.x + - name: Check formatting + run: ./scripts/gofumpt-check.sh - name: Lint + # Run even if the formatting check failed, so that both sets of + # problems are reported in a single CI run. + if: ${{ !cancelled() }} uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 with: # If you change this, make sure to also update scripts/golangci-lint-shim.sh diff --git a/.golangci.yml b/.golangci.yml index c13f7b9f3..c46e438ac 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -102,7 +102,9 @@ linters: - vendor/ formatters: enable: - - gofumpt + # gofumpt is intentionally not listed here: golangci-lint bundles its own + # gofumpt version, which drifts from the one we pin in go.mod. We run that + # pinned version separately via scripts/gofumpt-check.sh instead. - goimports exclusions: generated: lax diff --git a/Makefile b/Makefile index 6b51d5250..10bea092a 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,7 @@ format: .PHONY: lint lint: + ./scripts/gofumpt-check.sh ./scripts/golangci-lint-shim.sh run # For more details about integration test, see https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md. diff --git a/justfile b/justfile index 7909cb2d6..f9351732a 100644 --- a/justfile +++ b/justfile @@ -37,6 +37,7 @@ format: go tool gofumpt -l -w . lint: + ./scripts/gofumpt-check.sh ./scripts/golangci-lint-shim.sh run e2e-test-command := "go test pkg/integration/clients/*.go" diff --git a/scripts/gofumpt-check.sh b/scripts/gofumpt-check.sh new file mode 100755 index 000000000..ff251306c --- /dev/null +++ b/scripts/gofumpt-check.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# Checks that all Go files are gofumpt-formatted, and fails if any aren't. +# Used by `just lint`, `make lint`, and CI. We run gofumpt with the version +# pinned in go.mod (via `go tool`) rather than the one bundled with +# golangci-lint, so that formatting is identical across all of them and the +# editor. + +set -e + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(dirname "$script_dir") + +cd "$repo_root" + +unformatted=$(go tool gofumpt -l .) +if [ -n "$unformatted" ]; then + echo "The following files are not formatted correctly:" + echo "$unformatted" + echo "Run 'just format' (or 'make format') and commit the result." + exit 1 +fi From 4b082ed096d571d3640250d44b178b9f88d2eb7e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 09:34:50 +0200 Subject: [PATCH 153/384] Run `go mod tidy` before `go mod vendor` With the previous order, `go mod vendor` populated vendor/ from the current go.mod, and only then did `go mod tidy` prune it. If tidy changed go.mod, vendor/ was left matching the pre-tidy state, so a single run could leave vendor/modules.txt inconsistent with go.mod (it took a second run to converge). Tidying first settles go.mod/go.sum, then vendor rebuilds vendor/ to match in one pass. This applies both to the `vendor` recipe (justfile and Makefile) and to scripts/bump_lazycore.sh. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 2 +- justfile | 2 +- scripts/bump_lazycore.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 10bea092a..3e56a2821 100644 --- a/Makefile +++ b/Makefile @@ -70,4 +70,4 @@ record-demo: .PHONY: vendor vendor: - go mod vendor && go mod tidy + go mod tidy && go mod vendor diff --git a/justfile b/justfile index f9351732a..64d9d1ee2 100644 --- a/justfile +++ b/justfile @@ -75,4 +75,4 @@ demo *args: demo/record_demo.sh {{ args }} vendor: - go mod vendor && go mod tidy + go mod tidy && go mod vendor diff --git a/scripts/bump_lazycore.sh b/scripts/bump_lazycore.sh index 810e5f08e..a5cfe05ff 100755 --- a/scripts/bump_lazycore.sh +++ b/scripts/bump_lazycore.sh @@ -1,5 +1,5 @@ # Go's proxy servers are not very up-to-date so that's why we use `GOPROXY=direct` # We specify the `awesome` branch to avoid the default behaviour of looking for a semver tag. -GOPROXY=direct go get -u github.com/jesseduffield/lazycore@master && go mod vendor && go mod tidy +GOPROXY=direct go get -u github.com/jesseduffield/lazycore@master && go mod tidy && go mod vendor # Note to self if you ever want to fork a repo be sure to use this same approach: it's important to use the branch name (e.g. master) From 29b70105c2cf04327c11a8471146c3761c03831b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 15:29:57 +0200 Subject: [PATCH 154/384] Reintroduce the gitCmdObjBuilder wrapper This reverts commit eb988395e6d1, which removed the wrapper on the grounds that it no longer had anything to do but delegate. We're about to give it a job again: hosting a git-specific default environment variable on every command. Restore the scaffolding first, as a pure behaviour-preserving step, so the behaviour change that follows is minimal. --- pkg/commands/git_cmd_obj_builder.go | 35 +++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/pkg/commands/git_cmd_obj_builder.go b/pkg/commands/git_cmd_obj_builder.go index 6b6bd26d8..495582722 100644 --- a/pkg/commands/git_cmd_obj_builder.go +++ b/pkg/commands/git_cmd_obj_builder.go @@ -5,16 +5,37 @@ import ( "github.com/sirupsen/logrus" ) -// NewGitCmdObjBuilder returns a command object builder whose runner is wrapped -// with our git-specific runner (logging, credential handling, etc.). -func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *oscommands.CmdObjBuilder { - // We decorate the runner rather than exposing the builder's runner field: - // that field stays unexported so there's a single API for running commands - // across the codebase. - return innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner { +// all we're doing here is wrapping the default command object builder with +// some git-specific stuff: e.g. adding a git-specific env var + +type gitCmdObjBuilder struct { + innerBuilder *oscommands.CmdObjBuilder +} + +var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{} + +func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *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{ log: log, innerRunner: runner, } }) + + return &gitCmdObjBuilder{ + innerBuilder: updatedBuilder, + } +} + +func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj { + return self.innerBuilder.New(args) +} + +func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj { + return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile) +} + +func (self *gitCmdObjBuilder) Quote(str string) string { + return self.innerBuilder.Quote(str) } From ccaa96b29dab7bc982d3e976a3cf8cf720a953f8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 16:05:08 +0200 Subject: [PATCH 155/384] Suppress optional locks by default again, except foreground refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit d94f2f05 dropped the GIT_OPTIONAL_LOCKS=0 env var that we used to set on every git command, and re-added lock suppression only as a --no-optional-locks flag on the background files refresh. The intent was sound — a foreground `git status` should persist git's refreshed stat-cache — but the change was too broad: it stopped suppressing optional locks for every other command too. The one that bites is the main-view diff. When a folder containing submodules is selected, we render `git diff --submodule -- `, and `--submodule` makes git run `git status` inside each submodule to describe its "modified" state. That status now grabs the submodule's index.lock. It runs as a PTY task on its own goroutine, so it races any submodule-mutating action the user triggers — e.g. resetting a submodule runs `git -C stash`, which then fails with "index.lock: File exists". This is what made submodule/reset_folder flaky. `git status` is in fact the only command that takes the optional lock, but the env var also covered its use inside `git diff --submodule`, inside PTY-run commands, and inside git's own submodule child processes — none of which a per-command flag reaches cleanly. Invert the polarity to match how it worked before d94f2f05: the git command builder disables optional locks on every command by default, and the single command that benefits from taking the lock — the foreground files refresh — opts back in. This restores the original contention avoidance (including against the user's terminal git) while keeping d94f2f05's stat-cache-persistence win for the foreground refresh. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_cmd_obj_builder.go | 11 +++++++-- pkg/commands/git_cmd_obj_builder_test.go | 24 +++++++++++++++++++ pkg/commands/git_commands/file_loader.go | 22 ++++++++++++----- pkg/commands/git_commands/file_loader_test.go | 11 +-------- .../git_commands/git_command_builder.go | 10 ++++++++ pkg/commands/oscommands/cmd_obj.go | 13 ++++++++++ pkg/commands/oscommands/cmd_obj_test.go | 21 ++++++++++++++++ pkg/gui/types/refresh.go | 11 +++++---- 8 files changed, 100 insertions(+), 23 deletions(-) create mode 100644 pkg/commands/git_cmd_obj_builder_test.go diff --git a/pkg/commands/git_cmd_obj_builder.go b/pkg/commands/git_cmd_obj_builder.go index 495582722..6bc3b7d19 100644 --- a/pkg/commands/git_cmd_obj_builder.go +++ b/pkg/commands/git_cmd_obj_builder.go @@ -1,6 +1,7 @@ package commands import ( + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/sirupsen/logrus" ) @@ -14,6 +15,12 @@ type gitCmdObjBuilder struct { var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{} +// We disable git's optional locks on every command by default so that our git +// invocations never contend for index.lock. See git_commands.OptionalLocksEnvVar +// for the full rationale. Individual commands that do want the lock (currently +// only the foreground files refresh) opt back in via CmdObj.RemoveEnvVar. +var defaultEnvVar = git_commands.OptionalLocksEnvVar + "=0" + func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *gitCmdObjBuilder { // 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 { @@ -29,11 +36,11 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild } func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj { - return self.innerBuilder.New(args) + return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar) } func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj { - return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile) + return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar) } 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 new file mode 100644 index 000000000..ca7e54cfe --- /dev/null +++ b/pkg/commands/git_cmd_obj_builder_test.go @@ -0,0 +1,24 @@ +package commands + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stretchr/testify/assert" +) + +// Every git command we build disables optional locks by default, so that our +// invocations never contend for index.lock (see git_commands.OptionalLocksEnvVar +// for the rationale). Commands that want the lock opt back in with +// CmdObj.RemoveEnvVar. +func TestGitCmdObjBuilderDisablesOptionalLocksByDefault(t *testing.T) { + builder := NewGitCmdObjBuilder( + utils.NewDummyLog(), + oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), + ) + + assert.Contains(t, builder.New([]string{"git", "status"}).GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0") + assert.Contains(t, builder.NewShell("git status", "").GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0") +} diff --git a/pkg/commands/git_commands/file_loader.go b/pkg/commands/git_commands/file_loader.go index 9df977bb9..7e2bdf0f3 100644 --- a/pkg/commands/git_commands/file_loader.go +++ b/pkg/commands/git_commands/file_loader.go @@ -36,10 +36,11 @@ type GetStatusFileOptions struct { // This is useful for users with bare repos for dotfiles who default to hiding untracked files, // but want to occasionally see them to `git add` a new file. ForceShowUntracked bool - // When true, this status is part of an unattended background refresh, so we - // pass --no-optional-locks to avoid index.lock contention with git commands - // the user runs in a terminal (at the cost of not persisting git's refreshed - // stat-cache). + // When true, this status is part of an unattended background refresh, so it + // keeps the default suppression of optional locks (avoiding index.lock + // contention with git commands the user runs in a terminal, at the cost of + // not persisting git's refreshed stat-cache). A foreground status opts back + // in; see gitStatus. Background bool } @@ -175,7 +176,6 @@ func (self *FileLoader) gitDiffNumStat() (string, error) { func (self *FileLoader) gitStatus(opts GitStatusOptions) ([]FileStatus, error) { cmdArgs := NewGitCmd("status"). - GlobalArgIf(opts.Background, "--no-optional-locks"). Arg(opts.UntrackedFilesArg). Arg("--porcelain"). Arg("-z"). @@ -186,7 +186,17 @@ func (self *FileLoader) gitStatus(opts GitStatusOptions) ([]FileStatus, error) { ). ToArgv() - statusLines, _, err := self.cmd.New(cmdArgs).DontLog().RunWithOutputs() + cmdObj := self.cmd.New(cmdArgs).DontLog() + if !opts.Background { + // Every git command suppresses optional locks by default (see + // OptionalLocksEnvVar). A foreground refresh is the one exception: we let + // it take the lock so it persists git's refreshed stat-cache, which keeps + // subsequent status calls fast. Background refreshes leave it suppressed so + // they can't contend for index.lock. + cmdObj.RemoveEnvVar(OptionalLocksEnvVar) + } + + statusLines, _, err := cmdObj.RunWithOutputs() if err != nil { return []FileStatus{}, err } diff --git a/pkg/commands/git_commands/file_loader_test.go b/pkg/commands/git_commands/file_loader_test.go index 4f6b5e136..ec1f502f1 100644 --- a/pkg/commands/git_commands/file_loader_test.go +++ b/pkg/commands/git_commands/file_loader_test.go @@ -13,7 +13,6 @@ func TestFileGetStatusFiles(t *testing.T) { type scenario struct { testName string similarityThreshold int - background bool runner oscommands.ICmdObjRunner showNumstatInFilesView bool expectedFiles []*models.File @@ -27,14 +26,6 @@ func TestFileGetStatusFiles(t *testing.T) { ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "", nil), expectedFiles: []*models.File{}, }, - { - testName: "Background refresh passes --no-optional-locks", - similarityThreshold: 50, - background: true, - runner: oscommands.NewFakeRunner(t). - ExpectGitArgs([]string{"--no-optional-locks", "status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "", nil), - expectedFiles: []*models.File{}, - }, { testName: "Several files found", similarityThreshold: 50, @@ -255,7 +246,7 @@ func TestFileGetStatusFiles(t *testing.T) { getFileType: func(string) string { return "file" }, } - assert.EqualValues(t, s.expectedFiles, loader.GetStatusFiles(GetStatusFileOptions{Background: s.background})) + assert.EqualValues(t, s.expectedFiles, loader.GetStatusFiles(GetStatusFileOptions{})) }) } } diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go index 4178cbd22..ebd060928 100644 --- a/pkg/commands/git_commands/git_command_builder.go +++ b/pkg/commands/git_commands/git_command_builder.go @@ -6,6 +6,16 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/oscommands" ) +// OptionalLocksEnvVar is the name of the environment variable that tells git +// whether it may take "optional" locks — chiefly the index.lock that `git +// status` grabs to write back a refreshed stat-cache. We set it to 0 on every +// git command by default (see NewGitCmdObjBuilder) so our invocations never +// contend for index.lock, neither with each other (e.g. a main-view `git diff +// --submodule`, which runs `git status` inside submodules, racing a submodule +// action) nor with git commands the user runs in a terminal. The one command +// that opts back in is the foreground files refresh; see FileLoader.gitStatus. +const OptionalLocksEnvVar = "GIT_OPTIONAL_LOCKS" + // convenience struct for building git commands. Especially useful when // including conditional args type GitCommandBuilder struct { diff --git a/pkg/commands/oscommands/cmd_obj.go b/pkg/commands/oscommands/cmd_obj.go index 0df6ed43c..57bc295ed 100644 --- a/pkg/commands/oscommands/cmd_obj.go +++ b/pkg/commands/oscommands/cmd_obj.go @@ -91,6 +91,19 @@ func (self *CmdObj) AddEnvVars(vars ...string) *CmdObj { return self } +// RemoveEnvVar removes every occurrence of the named environment variable from +// the command's environment. It's the counterpart to AddEnvVars, used to opt a +// single command out of a variable that the builder sets on every command by +// default. +func (self *CmdObj) RemoveEnvVar(name string) *CmdObj { + prefix := name + "=" + self.cmd.Env = lo.Filter(self.cmd.Env, func(envVar string, _ int) bool { + return !strings.HasPrefix(envVar, prefix) + }) + + return self +} + func (self *CmdObj) GetEnvVars() []string { return self.cmd.Env } diff --git a/pkg/commands/oscommands/cmd_obj_test.go b/pkg/commands/oscommands/cmd_obj_test.go index 269b5dac2..56aab918b 100644 --- a/pkg/commands/oscommands/cmd_obj_test.go +++ b/pkg/commands/oscommands/cmd_obj_test.go @@ -5,8 +5,29 @@ import ( "testing" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/stretchr/testify/assert" ) +func TestRemoveEnvVar(t *testing.T) { + cmd := exec.Command("git", "status") + cmd.Env = []string{ + "PATH=/usr/bin", + "GIT_OPTIONAL_LOCKS=0", + "GIT_OPTIONAL_LOCKS_OTHER=1", // name is a prefix of ours but not the same var + "GIT_OPTIONAL_LOCKS=0", // duplicates must all be removed + "HOME=/home/me", + } + cmdObj := &CmdObj{cmd: cmd} + + cmdObj.RemoveEnvVar("GIT_OPTIONAL_LOCKS") + + assert.Equal(t, []string{ + "PATH=/usr/bin", + "GIT_OPTIONAL_LOCKS_OTHER=1", + "HOME=/home/me", + }, cmdObj.GetEnvVars()) +} + func TestCmdObjToString(t *testing.T) { quote := func(s string) string { return "\"" + s + "\"" diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index 17d917bf6..8d9704d55 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -72,10 +72,11 @@ type RefreshOptions struct { CommitSelection CommitSelectionBehavior // When true, this refresh was initiated by a background routine rather than - // by a user action. We use it to keep background `git status` calls from - // taking optional git locks, so they don't contend for index.lock with git - // commands the user runs in a terminal. The cost is that such a status won't - // persist git's refreshed stat-cache, which is the right trade-off for - // unattended work; foreground refreshes leave this false so they do persist. + // by a user action. Every git command suppresses optional locks by default + // so it can't contend for index.lock (see git_commands.OptionalLocksEnvVar); + // a foreground files refresh (this false) is the one command that opts back + // in, so it persists git's refreshed stat-cache and keeps later status calls + // fast. Background refreshes leave the suppression in place: not persisting + // the stat-cache is the right trade-off for unattended work. Background bool } From 4fcf6ddb5f21701833069602793836764f419b4d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 16:49:00 +0200 Subject: [PATCH 156/384] Revert "Add GlobalArg/GlobalArgIf to GitCommandBuilder" After the previous commit this is no longer needed. This reverts commit 94db69f64b7b05840cdf2839bd987f856f9b1d63. --- pkg/commands/git_commands/git_command_builder.go | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/pkg/commands/git_commands/git_command_builder.go b/pkg/commands/git_commands/git_command_builder.go index ebd060928..f37681223 100644 --- a/pkg/commands/git_commands/git_command_builder.go +++ b/pkg/commands/git_commands/git_command_builder.go @@ -48,22 +48,6 @@ func (self *GitCommandBuilder) ArgIfElse(condition bool, ifTrue string, ifFalse return self.Arg(ifFalse) } -// GlobalArg adds top-level options for git itself (e.g. --no-optional-locks). -// Unlike Arg, these are prepended before the command, where git expects them. -func (self *GitCommandBuilder) GlobalArg(args ...string) *GitCommandBuilder { - self.args = append(append([]string{}, args...), self.args...) - - return self -} - -func (self *GitCommandBuilder) GlobalArgIf(condition bool, args ...string) *GitCommandBuilder { - if condition { - self.GlobalArg(args...) - } - - return self -} - func (self *GitCommandBuilder) Config(value string) *GitCommandBuilder { // config settings come before the command self.args = append([]string{"-c", value}, self.args...) From e53c72be52277509957070a27a7051719ed685c2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 11 May 2026 17:46:57 +0200 Subject: [PATCH 157/384] Make ViewBufferManager.NewTask respect call order NewTask was incrementing newTaskID and reading taskID inside the spawned goroutine, so for two NewTask calls in quick succession the assignment was determined by goroutine scheduling order rather than call order. When the goroutines reordered, the first NewTask call could end up with the higher taskID and "win" the staleness check, superseding the second call's task even though the caller intended the second to be the latest. Worse, the staleness check ran after onNewKey, so a goroutine destined to bail as stale would still reset the view buffer first, potentially wiping the winning task's already-written output. Take newTaskID++ synchronously in NewTask so taskIDs follow call order, and move the first staleness check ahead of onNewKey so a stale task doesn't side-effect the view before exiting. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/tasks/tasks.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index f534d01b4..7fadbb451 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -403,12 +403,29 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error }) } + // Assign the taskID synchronously so it reflects NewTask call order + // rather than the order in which the spawned goroutines happen to be + // scheduled. Otherwise two NewTask calls in quick succession can have + // their goroutines race, with the later-called task ending up with the + // lower taskID and losing the staleness check below. + self.taskIDMutex.Lock() + self.newTaskID++ + taskID := self.newTaskID + self.taskIDMutex.Unlock() + go utils.Safe(func() { defer completeGocuiTask() self.taskIDMutex.Lock() - self.newTaskID++ - taskID := self.newTaskID + + // Bail out before touching shared view state if a newer task has + // already been queued: if we ran onNewKey here we'd reset the view + // for a task that's about to exit, potentially wiping output the + // winning task has already written. + if taskID < self.newTaskID { + self.taskIDMutex.Unlock() + return + } if self.GetTaskKey() != key && self.onNewKey != nil { self.onNewKey() @@ -419,6 +436,8 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error self.waitingMutex.Lock() + // Re-check staleness after acquiring waitingMutex: a newer task + // may have arrived while we were blocked here. self.taskIDMutex.Lock() if taskID < self.newTaskID { self.waitingMutex.Unlock() From badf398a94fbd4fe39eb569ce95b8543c2289c8e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 11 May 2026 20:42:05 +0200 Subject: [PATCH 158/384] Run moveMainContextPairToTop before queueing main-view tasks Copy the outgoing view's content into the target view (the flicker- prevention step) before queuing the render task, rather than after. The task writes the fresh content from a worker goroutine, so with the old order the worker write races the UI-thread copy, and the copy can land last and clobber the fresh content with stale output. This is only needed while view writes happen concurrently. Once view writes are serialized on the UI thread and the view write-mutex goes away, the synchronous copy always precedes the FIFO-queued write regardless of order, so the reorder becomes unnecessary. No code comment is added for it, since that comment would be obsoleted by that work and likely left behind. --- pkg/gui/main_panels.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/gui/main_panels.go b/pkg/gui/main_panels.go index 82f4fcac0..03b7469d2 100644 --- a/pkg/gui/main_panels.go +++ b/pkg/gui/main_panels.go @@ -117,6 +117,8 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) { } } + gui.moveMainContextPairToTop(opts.Pair) + if opts.Main != nil { gui.RefreshMainView(opts.Main, opts.Pair.Main) } @@ -127,8 +129,6 @@ func (gui *Gui) refreshMainViews(opts types.RefreshMainOpts) { opts.Pair.Secondary.GetView().Clear() } - gui.moveMainContextPairToTop(opts.Pair) - gui.splitMainPanel(opts.Secondary != nil) } From 873804a37b24e3c23c3c5f3374dd7965a6de4b98 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 15:27:31 +0200 Subject: [PATCH 159/384] Make Gui.Update a synchronous FIFO enqueue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update spawned a goroutine per call that then sent on the user-events channel, so multiple Update calls from the same goroutine could be reordered by the scheduler — the doc comment even admitted "the order in which the user events will be handled is not guaranteed." That non-determinism is a latent source of flaky rendering: code that queues a model update and then a render in source order could see them run in the opposite order. Send on the channel directly instead, so same-goroutine calls arrive in source order. The send is non-blocking and panics on a full channel rather than blocking (a blocked send from the UI goroutine would deadlock against itself) or silently reordering; the buffer is sized generously so this is unreachable in normal use. UpdateAsync is now identical to Update and unused, so it's removed along with the shared updateAsyncAux helper. --- pkg/gocui/gui.go | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 558b9d619..ee1995911 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -238,7 +238,12 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { g.stop = make(chan struct{}) g.gEvents = make(chan GocuiEvent, 20) - g.userEvents = make(chan userEvent, 20) + // Update does a non-blocking send and panics on a full channel rather than + // blocking (which would deadlock the UI goroutine against itself) or + // silently reordering. The buffer is sized well above the peak occupancy we + // see in practice, so the panic stays unreachable in normal use; if it ever + // fires, that's a real anomaly to investigate, not a cue to grow the buffer. + g.userEvents = make(chan userEvent, 256) g.taskManager = newTaskManager() if opts.PlayRecording { @@ -613,28 +618,23 @@ type userEvent struct { contentOnly bool } -// Update executes the passed function. This method can be called safely from a -// goroutine in order to update the GUI. It is important to note that the -// passed function won't be executed immediately, instead it will be added to -// the user events queue. Given that Update spawns a goroutine, the order in -// which the user events will be handled is not guaranteed. +// Update enqueues f on the user-events channel for the UI loop to run on its +// next iteration. Multiple Update calls from the same goroutine arrive in +// source order via the channel's FIFO. The send is non-blocking — if the +// channel is full we panic rather than block or silently reorder, since a +// blocked send from the UI goroutine would deadlock against itself and +// silently switching to inline execution would break the ordering guarantee +// callers rely on. The buffer is sized generously enough that this should +// never fire in practice; if it does, that's a signal to investigate, not +// to grow the buffer reflexively. func (g *Gui) Update(f func(*Gui) error) { task := g.NewTask() - go g.updateAsyncAux(f, task) -} - -// UpdateAsync is a version of Update that does not spawn a go routine, it can -// be a bit more efficient in cases where Update is called many times like when -// tailing a file. In general you should use Update() -func (g *Gui) UpdateAsync(f func(*Gui) error) { - task := g.NewTask() - - g.updateAsyncAux(f, task) -} - -func (g *Gui) updateAsyncAux(f func(*Gui) error, task Task) { - g.userEvents <- userEvent{f: f, task: task} + select { + case g.userEvents <- userEvent{f: f, task: task}: + default: + panic("gocui: userEvents channel full; refusing to block or reorder") + } } // Like Update, but signals that the callback only modifies content. From ad507d67f48674ab71669b766fe061d0a933dda1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 17:38:09 +0200 Subject: [PATCH 160/384] Only prompt to continue a rebase/merge if we started it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When conflicts of an in-progress rebase/merge/cherry-pick/revert are resolved, lazygit pops up a prompt offering to continue it. This is helpful when you started the operation in lazygit and resolved the conflicts in your editor. But it's confusing when the operation was started outside lazygit — e.g. by a coding agent in another terminal that resolves the conflicts but hasn't continued yet because it's still running tests or fixing the build. lazygit would then prompt unbidden. Track whether the in-progress operation was started from within lazygit, and only show the prompt in that case. We record this right after running a merge/rebase step (in CheckMergeOrRebaseWithRefreshOptions, the subprocess branch of genericMergeCommand, and the custom-command conflict path), and clear it whenever a refresh observes that no operation is in progress — which also handles an operation that was finished or aborted externally. The conflict-resolution tests start their operation by running git directly (not through lazygit's UI), so they call the new test helper Common.PretendMergeOrRebaseStartedInLazygit to have lazygit treat the operation as its own and still get the prompt. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/merge_and_rebase_helper.go | 13 ++++++ pkg/gui/controllers/helpers/refresh_helper.go | 11 ++++- pkg/gui/gui.go | 16 ++++++++ pkg/gui/gui_driver.go | 9 ++++ .../custom_commands/handler_creator.go | 4 ++ pkg/gui/types/common.go | 2 + pkg/integration/components/common.go | 8 ++++ pkg/integration/components/test_test.go | 2 + .../tests/conflicts/merge_file_both.go | 2 + .../tests/conflicts/merge_file_current.go | 2 + .../tests/conflicts/merge_file_incoming.go | 2 + .../tests/conflicts/pick_both_hunks_diff3.go | 2 + .../tests/conflicts/resolve_externally.go | 2 + ...olve_externally_started_merge_no_prompt.go | 41 +++++++++++++++++++ .../tests/conflicts/resolve_multiple_files.go | 2 + .../conflicts/resolve_without_trailing_lf.go | 2 + .../tests/file/discard_all_dir_changes.go | 2 + .../tests/file/discard_various_changes.go | 2 + .../discard_various_changes_range_select.go | 2 + pkg/integration/tests/test_list.go | 1 + pkg/integration/types/types.go | 5 +++ 21 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/conflicts/resolve_externally_started_merge_no_prompt.go diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 536c254dd..2b7f9cd16 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -113,6 +113,7 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(success && selectHeadCommitOnSuccess), }) + self.RecordWhetherMergeOrRebaseStartedInLazygit() return err } result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) @@ -165,9 +166,21 @@ func isMergeConflictErr(errStr string) bool { return false } +// RecordWhetherMergeOrRebaseStartedInLazygit is called right after we run a +// merge/rebase/cherry-pick/revert step. If it left an operation in progress, +// that operation is one we started, which is what later lets us auto-prompt to +// continue it once its conflicts are resolved. If nothing is in progress +// anymore (the step completed or aborted the operation), we clear the flag. +func (self *MergeAndRebaseHelper) RecordWhetherMergeOrRebaseStartedInLazygit() { + self.c.State().GetRepoState().SetMergeOrRebaseStartedInLazygit( + self.c.Git().Status.WorkingTreeState().Any()) +} + func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result error, refreshOptions types.RefreshOptions) error { self.c.Refresh(refreshOptions) + self.RecordWhetherMergeOrRebaseStartedInLazygit() + if result == nil { return nil } else if strings.Contains(result.Error(), "No changes - did you forget to use") { diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 605309f9f..e40e0acd5 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -806,7 +806,16 @@ func (self *RefreshHelper) refreshStateFiles(background bool) error { } } - if self.c.Git().Status.WorkingTreeState().Any() && conflictFileCount == 0 && prevConflictFileCount > 0 { + repoState := self.c.State().GetRepoState() + if self.c.Git().Status.WorkingTreeState().None() { + // No operation is in progress (any more), so forget that we started one. + // This also covers an operation that was finished or aborted externally. + repoState.SetMergeOrRebaseStartedInLazygit(false) + } else if conflictFileCount == 0 && 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 for + // operations we started ourselves; prompting for one that was started + // outside lazygit (e.g. by a coding agent) would be confusing. self.c.OnUIThread(func() error { return self.mergeAndRebaseHelper.PromptToContinueRebase() }) } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index dfc71d642..08d560ce5 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -255,6 +255,14 @@ type GuiRepoState struct { CurrentPopupOpts *types.CreatePopupPanelOpts LastBackgroundFetchTime time.Time + + // Whether the rebase/merge/cherry-pick/revert that's currently in progress + // was started from within lazygit (as opposed to being started externally, + // e.g. in another terminal or by a coding agent). We only auto-prompt to + // continue such an operation once its conflicts are resolved if we started + // it ourselves; for an externally started one, popping up unbidden would be + // confusing. Reset whenever we observe that no operation is in progress. + mergeOrRebaseStartedInLazygit bool } var _ types.IRepoStateAccessor = new(GuiRepoState) @@ -283,6 +291,14 @@ func (self *GuiRepoState) SetCurrentPopupOpts(value *types.CreatePopupPanelOpts) self.CurrentPopupOpts = value } +func (self *GuiRepoState) GetMergeOrRebaseStartedInLazygit() bool { + return self.mergeOrRebaseStartedInLazygit +} + +func (self *GuiRepoState) SetMergeOrRebaseStartedInLazygit(value bool) { + self.mergeOrRebaseStartedInLazygit = value +} + func (self *GuiRepoState) GetScreenMode() types.ScreenMode { return self.ScreenMode } diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 57425231a..fef33bf66 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -68,6 +68,15 @@ func (self *GuiDriver) FocusIn() { self.waitTillIdle() } +func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() { + self.gui.onUIThread(func() error { + self.gui.State.SetMergeOrRebaseStartedInLazygit(true) + return nil + }) + + self.waitTillIdle() +} + // wait until lazygit is idle (i.e. all processing is done) before continuing func (self *GuiDriver) waitTillIdle() { <-self.isIdleChan diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index 19694c481..1e321c2de 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -318,6 +318,10 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses if err != nil { if customCommand.After != nil && customCommand.After.CheckForConflicts { + // The custom command may have started a rebase/merge/etc.; if so, + // it's one we consider started in lazygit, so that we offer to + // continue it once its conflicts are resolved. + self.mergeAndRebaseHelper.RecordWhetherMergeOrRebaseStartedInLazygit() return self.mergeAndRebaseHelper.CheckForConflicts(err) } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index b81fb15e1..7621f8686 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -401,6 +401,8 @@ type IRepoStateAccessor interface { GetSearchState() *SearchState SetSplitMainPanel(bool) GetSplitMainPanel() bool + GetMergeOrRebaseStartedInLazygit() bool + SetMergeOrRebaseStartedInLazygit(bool) } // startup stages so we don't need to load everything at once diff --git a/pkg/integration/components/common.go b/pkg/integration/components/common.go index 2d62e9ea3..f338be52f 100644 --- a/pkg/integration/components/common.go +++ b/pkg/integration/components/common.go @@ -38,6 +38,14 @@ func (self *Common) AbortMerge() { Confirm() } +// PretendMergeOrRebaseStartedInLazygit tells lazygit to treat the in-progress +// rebase/merge/etc. as one that it started, so that it will prompt to continue +// once the conflicts are resolved. Use it when a test sets up an operation by +// running git directly rather than through lazygit's UI. +func (self *Common) PretendMergeOrRebaseStartedInLazygit() { + self.t.gui.PretendMergeOrRebaseStartedInLazygit() +} + func (self *Common) AcknowledgeConflicts() { self.t.ExpectPopup().Menu(). Title(Equals("Conflicts!")). diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index e7d03ada9..e7fd0b66a 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -93,6 +93,8 @@ func (self *fakeGuiDriver) CheckAllToastsAcknowledged() {} func (self *fakeGuiDriver) Headless() bool { return false } +func (self *fakeGuiDriver) PretendMergeOrRebaseStartedInLazygit() {} + func TestManualFailure(t *testing.T) { test := NewIntegrationTest(NewIntegrationTestArgs{ Description: unitTestDescription, diff --git a/pkg/integration/tests/conflicts/merge_file_both.go b/pkg/integration/tests/conflicts/merge_file_both.go index 083572125..eb1d0b192 100644 --- a/pkg/integration/tests/conflicts/merge_file_both.go +++ b/pkg/integration/tests/conflicts/merge_file_both.go @@ -55,6 +55,8 @@ var MergeFileBoth = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { _, _, _, expected := testDataBoth() + t.Common().PretendMergeOrRebaseStartedInLazygit() + t.Views().Files(). IsFocused(). Lines( diff --git a/pkg/integration/tests/conflicts/merge_file_current.go b/pkg/integration/tests/conflicts/merge_file_current.go index b80917446..68cf990e5 100644 --- a/pkg/integration/tests/conflicts/merge_file_current.go +++ b/pkg/integration/tests/conflicts/merge_file_current.go @@ -54,6 +54,8 @@ var MergeFileCurrent = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { _, _, _, expected := testDataCurrent() + t.Common().PretendMergeOrRebaseStartedInLazygit() + t.Views().Files(). IsFocused(). Lines( diff --git a/pkg/integration/tests/conflicts/merge_file_incoming.go b/pkg/integration/tests/conflicts/merge_file_incoming.go index 8216b4a4d..17667b296 100644 --- a/pkg/integration/tests/conflicts/merge_file_incoming.go +++ b/pkg/integration/tests/conflicts/merge_file_incoming.go @@ -54,6 +54,8 @@ var MergeFileIncoming = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { _, _, _, expected := testDataIncoming() + t.Common().PretendMergeOrRebaseStartedInLazygit() + t.Views().Files(). IsFocused(). Lines( diff --git a/pkg/integration/tests/conflicts/pick_both_hunks_diff3.go b/pkg/integration/tests/conflicts/pick_both_hunks_diff3.go index 59a634c3b..6e9a6eb92 100644 --- a/pkg/integration/tests/conflicts/pick_both_hunks_diff3.go +++ b/pkg/integration/tests/conflicts/pick_both_hunks_diff3.go @@ -16,6 +16,8 @@ var PickBothHunksDiff3 = NewIntegrationTest(NewIntegrationTestArgs{ shared.CreateMergeConflictFile(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Common().PretendMergeOrRebaseStartedInLazygit() + t.Views().Files(). IsFocused(). Lines( diff --git a/pkg/integration/tests/conflicts/resolve_externally.go b/pkg/integration/tests/conflicts/resolve_externally.go index ab045f233..bffb2d023 100644 --- a/pkg/integration/tests/conflicts/resolve_externally.go +++ b/pkg/integration/tests/conflicts/resolve_externally.go @@ -15,6 +15,8 @@ var ResolveExternally = NewIntegrationTest(NewIntegrationTestArgs{ shared.CreateMergeConflictFile(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Common().PretendMergeOrRebaseStartedInLazygit() + t.Views().Files(). IsFocused(). Lines( diff --git a/pkg/integration/tests/conflicts/resolve_externally_started_merge_no_prompt.go b/pkg/integration/tests/conflicts/resolve_externally_started_merge_no_prompt.go new file mode 100644 index 000000000..ec83f3f08 --- /dev/null +++ b/pkg/integration/tests/conflicts/resolve_externally_started_merge_no_prompt.go @@ -0,0 +1,41 @@ +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 ResolveExternallyStartedMergeNoPrompt = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "When a merge started outside lazygit has its conflicts resolved, don't prompt to continue it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + // Start the merge by running git directly and never tell lazygit it was + // the one to start it, so from lazygit's point of view it was started + // externally (e.g. by a coding agent in another terminal). + shared.CreateMergeConflictFile(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("UU file").IsSelected(), + ). + Tap(func() { + t.Shell().UpdateFile("file", "resolved content") + }). + Press(keys.Universal.Refresh) + + // No prompt to continue the merge appears; we stay in the files view + // with the conflict resolved and the merge still in progress. + t.Views().Files(). + IsFocused(). + Lines( + Contains("M file"), + ) + + t.Views().Information().Content(Contains("Merging")) + }, +}) diff --git a/pkg/integration/tests/conflicts/resolve_multiple_files.go b/pkg/integration/tests/conflicts/resolve_multiple_files.go index 7dd88e02c..5a8f9447e 100644 --- a/pkg/integration/tests/conflicts/resolve_multiple_files.go +++ b/pkg/integration/tests/conflicts/resolve_multiple_files.go @@ -15,6 +15,8 @@ var ResolveMultipleFiles = NewIntegrationTest(NewIntegrationTestArgs{ shared.CreateMergeConflictFiles(shell) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Common().PretendMergeOrRebaseStartedInLazygit() + t.Views().Files(). IsFocused(). Lines( diff --git a/pkg/integration/tests/conflicts/resolve_without_trailing_lf.go b/pkg/integration/tests/conflicts/resolve_without_trailing_lf.go index 3deafb288..30ae73e54 100644 --- a/pkg/integration/tests/conflicts/resolve_without_trailing_lf.go +++ b/pkg/integration/tests/conflicts/resolve_without_trailing_lf.go @@ -24,6 +24,8 @@ var ResolveWithoutTrailingLf = NewIntegrationTest(NewIntegrationTestArgs{ RunCommandExpectError([]string{"git", "merge", "--no-edit", "branch2"}) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Common().PretendMergeOrRebaseStartedInLazygit() + t.Views().Files(). IsFocused(). Lines( diff --git a/pkg/integration/tests/file/discard_all_dir_changes.go b/pkg/integration/tests/file/discard_all_dir_changes.go index 6caa3d519..d4c7c4d0f 100644 --- a/pkg/integration/tests/file/discard_all_dir_changes.go +++ b/pkg/integration/tests/file/discard_all_dir_changes.go @@ -73,6 +73,8 @@ var DiscardAllDirChanges = NewIntegrationTest(NewIntegrationTestArgs{ shell.RunShellCommand(`echo "renamed\nhaha" > dir/renamed2.txt && git add dir/renamed2.txt`) }, Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Common().PretendMergeOrRebaseStartedInLazygit() + t.Views().Files(). IsFocused(). Lines( diff --git a/pkg/integration/tests/file/discard_various_changes.go b/pkg/integration/tests/file/discard_various_changes.go index bc68fd218..453c8708c 100644 --- a/pkg/integration/tests/file/discard_various_changes.go +++ b/pkg/integration/tests/file/discard_various_changes.go @@ -16,6 +16,8 @@ var DiscardVariousChanges = NewIntegrationTest(NewIntegrationTestArgs{ }, Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Common().PretendMergeOrRebaseStartedInLazygit() + type statusFile struct { status string label string 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 937c50114..16ecedd04 100644 --- a/pkg/integration/tests/file/discard_various_changes_range_select.go +++ b/pkg/integration/tests/file/discard_various_changes_range_select.go @@ -16,6 +16,8 @@ var DiscardVariousChangesRangeSelect = NewIntegrationTest(NewIntegrationTestArgs }, Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Common().PretendMergeOrRebaseStartedInLazygit() + t.Views().Files(). IsFocused(). Lines( diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index a6105c646..554dc726f 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -169,6 +169,7 @@ var tests = []*components.IntegrationTest{ conflicts.MergeFileIncoming, conflicts.PickBothHunksDiff3, conflicts.ResolveExternally, + conflicts.ResolveExternallyStartedMergeNoPrompt, conflicts.ResolveMultipleFiles, conflicts.ResolveNoAutoStage, conflicts.ResolveNonTextualConflicts, diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index cd6102cdf..34ce499cc 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -52,4 +52,9 @@ type GuiDriver interface { NextToast() *string CheckAllToastsAcknowledged() Headless() bool + // Record that the in-progress rebase/merge/etc. is to be treated as one + // that was started from within lazygit. Lets a test that starts an + // operation by running git directly (rather than through the UI) still get + // the "continue?" prompt when its conflicts are resolved. + PretendMergeOrRebaseStartedInLazygit() } From 90d8ef499c726efde9e2b68844a7337090e9d214 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 17:43:05 +0200 Subject: [PATCH 161/384] Auto-dismiss the continue-rebase prompt when it becomes stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt offering to continue a rebase/merge is opened from a refresh and then left to sit until the user acts on it. But the operation can change out from under it: a coding agent (or the user in another terminal) might continue or abort it, or advance it to a commit with new conflicts. The prompt then becomes stale — pressing continue fails with "no rebase in progress" or acts on the wrong state. Track whether the prompt is showing, and on each refresh dismiss it if the operation is no longer in the "resolved, ready to continue" state that the prompt is offering to act on. This runs on the same refreshes that would open it (including the background poll and the refresh on window focus), so the prompt disappears on its own shortly after the operation moves on. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/merge_and_rebase_helper.go | 35 +++++++++++++ pkg/gui/controllers/helpers/refresh_helper.go | 28 ++++++++--- ...ompt_dismissed_when_resolved_externally.go | 49 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 4 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 pkg/integration/tests/conflicts/continue_prompt_dismissed_when_resolved_externally.go diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 2b7f9cd16..be1d7b3a9 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -18,6 +18,13 @@ import ( type MergeAndRebaseHelper struct { c *HelperCommon + + // Whether the "continue the rebase/merge?" prompt is currently on screen. + // We use this to auto-dismiss it if the operation stops being in the state + // that the prompt is offering to act on (e.g. it was continued or aborted + // externally), so the user isn't left with a stale prompt. Only accessed on + // the UI thread. + continueRebasePromptShowing bool } func NewMergeAndRebaseHelper( @@ -259,10 +266,17 @@ func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error { // PromptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress func (self *MergeAndRebaseHelper) PromptToContinueRebase() error { + self.continueRebasePromptShowing = true self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Continue, Prompt: fmt.Sprintf(self.c.Tr.ConflictsResolved, self.c.Git().Status.WorkingTreeState().CommandName()), + HandleClose: func() error { + self.continueRebasePromptShowing = false + return nil + }, HandleConfirm: func() error { + self.continueRebasePromptShowing = false + // By the time we get here, we might have unstaged changes again, // e.g. if the user had to fix build errors after resolving the // conflicts, but after lazygit opened the prompt already. Ask again @@ -300,6 +314,27 @@ func (self *MergeAndRebaseHelper) PromptToContinueRebase() error { return nil } +// DismissContinueRebasePromptIfShowing closes the "continue the rebase/merge?" +// prompt if it's currently on screen. It's called when the operation is no +// longer in the state the prompt is offering to act on (e.g. it was continued +// or aborted outside lazygit, or new conflicts have appeared), so that the +// user isn't left with a prompt whose "continue" would now be wrong or fail. +// Must be called on the UI thread. +func (self *MergeAndRebaseHelper) DismissContinueRebasePromptIfShowing() { + if !self.continueRebasePromptShowing { + return + } + + self.continueRebasePromptShowing = false + + // Guard against popping something else: while our prompt is up no other + // popup can open, and confirming or closing it would have cleared the flag, + // so if it's set the confirmation context is ours. + if self.c.Context().Current() == self.c.Contexts().Confirmation { + self.c.Context().Pop() + } +} + func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { checkedOutBranch := self.c.Model().Branches[0] checkedOutBranchName := checkedOutBranch.Name diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index e40e0acd5..3dbd19674 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -807,16 +807,30 @@ func (self *RefreshHelper) refreshStateFiles(background bool) error { } repoState := self.c.State().GetRepoState() - if self.c.Git().Status.WorkingTreeState().None() { + workingTreeState := self.c.Git().Status.WorkingTreeState() + if workingTreeState.None() { // No operation is in progress (any more), so forget that we started one. // This also covers an operation that was finished or aborted externally. repoState.SetMergeOrRebaseStartedInLazygit(false) - } else if conflictFileCount == 0 && 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 for - // operations we started ourselves; prompting for one that was started - // outside lazygit (e.g. by a coding agent) would be confusing. - self.c.OnUIThread(func() error { return self.mergeAndRebaseHelper.PromptToContinueRebase() }) + } + + if workingTreeState.Any() && conflictFileCount == 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 + // for operations we started ourselves; prompting for one that was + // started outside lazygit (e.g. by a coding agent) would be confusing. + self.c.OnUIThread(func() error { return self.mergeAndRebaseHelper.PromptToContinueRebase() }) + } + } else { + // Either there's no operation in progress any more, or new conflicts have + // appeared. Either way, a "continue?" prompt we're showing is now stale + // (e.g. the operation was continued or aborted outside lazygit), so + // dismiss it rather than leave the user with a prompt that would fail. + self.c.OnUIThread(func() error { + self.mergeAndRebaseHelper.DismissContinueRebasePromptIfShowing() + return nil + }) } fileTreeViewModel.RWMutex.Lock() diff --git a/pkg/integration/tests/conflicts/continue_prompt_dismissed_when_resolved_externally.go b/pkg/integration/tests/conflicts/continue_prompt_dismissed_when_resolved_externally.go new file mode 100644 index 000000000..e9b2ba3aa --- /dev/null +++ b/pkg/integration/tests/conflicts/continue_prompt_dismissed_when_resolved_externally.go @@ -0,0 +1,49 @@ +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 ContinuePromptDismissedWhenResolvedExternally = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "When the prompt to continue a merge is showing and the merge is then continued outside lazygit, dismiss the prompt", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shared.CreateMergeConflictFile(shell) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Common().PretendMergeOrRebaseStartedInLazygit() + + // Resolve the conflict and refresh so lazygit prompts us to continue. + t.Views().Files(). + IsFocused(). + Lines( + Contains("UU file").IsSelected(), + ). + Tap(func() { + t.Shell().UpdateFile("file", "resolved content") + }). + Press(keys.Universal.Refresh) + + t.ExpectPopup().Confirmation(). + Title(Equals("Continue")). + Content(Contains("All merge conflicts resolved. Continue the merge?")) + + // While the prompt is up, the merge is continued outside lazygit (e.g. by + // a coding agent). + t.Shell().ContinueMerge() + + // Simulate lazygit noticing the change (as it would on its next refresh or + // when the window regains focus); the stale prompt is dismissed. + t.FocusIn() + + t.Views().Files(). + IsFocused(). + IsEmpty() + + t.Views().Information().Content(DoesNotContain("Merging")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 554dc726f..74e471713 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -163,6 +163,7 @@ var tests = []*components.IntegrationTest{ config.NegativeRefspec, config.RemoteNamedStar, config.SidePanelsInPerRepoConfig, + conflicts.ContinuePromptDismissedWhenResolvedExternally, conflicts.Filter, conflicts.MergeFileBoth, conflicts.MergeFileCurrent, From 3c04b30336ecf52c7767e4b5f6c569e739019872 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 09:45:26 +0200 Subject: [PATCH 162/384] Have "just lint" show all issues instead of just the first so many --- .golangci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index c46e438ac..5ed7fb32d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,10 @@ version: "2" run: go: "1.25" +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + uniq-by-line: false linters: enable: - copyloopvar From 51c8f9e6add7cc90c23816f1654d6aa58d936d21 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 09:45:26 +0200 Subject: [PATCH 163/384] Always set LAZYGIT_COLUMNS The env var was previously set only on Windows, where the no-op pty stub was just running the command without a pty and needed to expose the width to pager scripts another way. With ConPTY coming to Windows the rationale disappears there, but the env var is documented in docs/Custom_Pagers.md for pager scripts that can't query the terminal width directly. Set it on every platform so those scripts remain portable, regardless of whether a pty is in play. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gui/pty.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index f6356b9c0..fbe8d5d38 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -3,6 +3,7 @@ package gui import ( + "fmt" "io" "os" "os/exec" @@ -45,6 +46,12 @@ func (gui *Gui) onResize() error { // command. func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { width := view.InnerWidth() + + // 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. + cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", width)) + pager := gui.stateAccessor.GetPagerConfig().GetPagerCommand(width) externalDiffCommand := gui.stateAccessor.GetPagerConfig().GetExternalDiffCommand() useExtDiffGitConfig := gui.stateAccessor.GetPagerConfig().GetUseExternalDiffGitConfig() From 8ec4283e5551dc653f01be296020e03aeef8318d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 09:45:26 +0200 Subject: [PATCH 164/384] Abstract task command over *exec.Cmd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows ConPTY can't attach a child process to a pseudoconsole via os/exec — Go's stdlib doesn't expose PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE (golang/go#62708). The ConPTY path has to call CreateProcess directly, so it can't hand an *exec.Cmd back to the task runner. Widen NewCmdTask to accept a small Cmd interface satisfied by both *exec.Cmd (via the ExecCmd adapter) and the Windows ConPTY command type we're about to add. Change TerminateProcessGracefully to take *os.Process, which both cmd shapes can provide. Behavior is unchanged on every platform. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../oscommands/os_default_platform.go | 6 ++-- pkg/commands/oscommands/os_windows.go | 2 +- pkg/gui/pty.go | 5 ++-- pkg/gui/tasks_adapter.go | 4 +-- pkg/tasks/tasks.go | 28 ++++++++++++++++--- pkg/tasks/tasks_test.go | 12 ++++---- 6 files changed, 39 insertions(+), 18 deletions(-) diff --git a/pkg/commands/oscommands/os_default_platform.go b/pkg/commands/oscommands/os_default_platform.go index 5e73c994f..09f9f1d6d 100644 --- a/pkg/commands/oscommands/os_default_platform.go +++ b/pkg/commands/oscommands/os_default_platform.go @@ -45,10 +45,10 @@ func (c *OSCommand) UpdateWindowTitle() error { // this call is reached on every host; only the Windows build does anything. func setRawCmdLine(cmd *exec.Cmd, cmdLine string) {} -func TerminateProcessGracefully(cmd *exec.Cmd) error { - if cmd.Process == nil { +func TerminateProcessGracefully(proc *os.Process) error { + if proc == nil { return nil } - return cmd.Process.Signal(syscall.SIGTERM) + return proc.Signal(syscall.SIGTERM) } diff --git a/pkg/commands/oscommands/os_windows.go b/pkg/commands/oscommands/os_windows.go index bd4cc5151..d0252f5b1 100644 --- a/pkg/commands/oscommands/os_windows.go +++ b/pkg/commands/oscommands/os_windows.go @@ -41,7 +41,7 @@ func (c *OSCommand) UpdateWindowTitle() error { return c.Cmd.NewShell(argString, c.UserConfig().OS.ShellFunctionsFile).Run() } -func TerminateProcessGracefully(cmd *exec.Cmd) error { +func TerminateProcessGracefully(proc *os.Process) error { // Signals other than SIGKILL are not supported on Windows return nil } diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index fbe8d5d38..0455c0abf 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -11,6 +11,7 @@ import ( "github.com/creack/pty" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/tasks" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) @@ -82,7 +83,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error manager := gui.getManager(view) var ptmx *os.File - start := func() (*exec.Cmd, io.Reader) { + start := func() (tasks.Cmd, io.Reader) { var err error ptmx, err = pty.StartWithSize(cmd, gui.desiredPtySize(view)) if err != nil { @@ -93,7 +94,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error gui.viewPtmxMap[view.Name()] = ptmx gui.Mutexes.PtyMutex.Unlock() - return cmd, ptmx + return tasks.ExecCmd{Cmd: cmd}, ptmx } onClose := func() { diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 3bfc64100..09edd2d36 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -19,7 +19,7 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error manager := gui.getManager(view) var r io.ReadCloser - start := func() (*exec.Cmd, io.Reader) { + start := func() (tasks.Cmd, io.Reader) { var err error r, err = cmd.StdoutPipe() if err != nil { @@ -32,7 +32,7 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error gui.c.Log.Error(err) } - return cmd, r + return tasks.ExecCmd{Cmd: cmd}, r } onClose := func() { diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 7fadbb451..26145c784 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "io" + "os" "os/exec" "sync" "time" @@ -15,6 +16,25 @@ import ( "github.com/sirupsen/logrus" ) +// Cmd abstracts over a started external process. *exec.Cmd satisfies the bulk +// of it via ExecCmd, but pty implementations can supply their own types — on +// Windows, ConPTY has to spawn via CreateProcess directly and can't use +// *exec.Cmd (see golang/go#62708). +type Cmd interface { + Wait() error + String() string + GetProcess() *os.Process +} + +// ExecCmd adapts *exec.Cmd to Cmd. +type ExecCmd struct { + *exec.Cmd +} + +func (c ExecCmd) GetProcess() *os.Process { + return c.Process +} + // This file revolves around running commands that will be output to the main panel // in the gui. If we're flicking through the commits panel, we want to invoke a // `git show` command for each commit, but we don't want to read the entire output @@ -117,7 +137,7 @@ func (self *ViewBufferManager) ReadToEnd(then func()) { } } -func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), prefix string, linesToRead LinesToRead, onDoneFn func()) func(TaskOpts) error { +func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix string, linesToRead LinesToRead, onDoneFn func()) func(TaskOpts) error { return func(opts TaskOpts) error { var onDoneOnce sync.Once var onFirstPageShownOnce sync.Once @@ -173,8 +193,8 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p // // Unfortunately this will do nothing on Windows, so Windows users will have to live // with the higher CPU usage. - if err := oscommands.TerminateProcessGracefully(cmd); err != nil { - self.Log.Errorf("error when trying to terminate cmd task: %v; Command: %v %v", err, cmd.Path, cmd.Args) + if err := oscommands.TerminateProcessGracefully(cmd.GetProcess()); err != nil { + self.Log.Errorf("error when trying to terminate cmd task: %v; Command: %v", err, cmd.String()) } // close the task's stdout pipe (or the pty if we're using one) to make the command terminate @@ -338,7 +358,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (*exec.Cmd, io.Reader), p go func() { _ = cmd.Wait() }() default: if err := cmd.Wait(); err != nil { - self.Log.Errorf("Unexpected error when running cmd task: %v; Failed command: %v %v", err, cmd.Path, cmd.Args) + self.Log.Errorf("Unexpected error when running cmd task: %v; Failed command: %v", err, cmd.String()) } } diff --git a/pkg/tasks/tasks_test.go b/pkg/tasks/tasks_test.go index 40ff0033d..c025e8e16 100644 --- a/pkg/tasks/tasks_test.go +++ b/pkg/tasks/tasks_test.go @@ -43,13 +43,13 @@ func TestNewCmdTaskInstantStop(t *testing.T) { stop := make(chan struct{}) reader := bytes.NewBufferString("test") - start := func() (*exec.Cmd, io.Reader) { + start := func() (Cmd, io.Reader) { // not actually starting this because it's not necessary cmd := exec.Command("blah") close(stop) - return cmd, reader + return ExecCmd{Cmd: cmd}, reader } fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone) @@ -108,11 +108,11 @@ func TestNewCmdTask(t *testing.T) { stop := make(chan struct{}) reader := bytes.NewBufferString("test") - start := func() (*exec.Cmd, io.Reader) { + start := func() (Cmd, io.Reader) { // not actually starting this because it's not necessary cmd := exec.Command("blah") - return cmd, reader + return ExecCmd{Cmd: cmd}, reader } fn := manager.NewCmdTask(start, "prefix\n", LinesToRead{20, -1, nil}, onDone) @@ -241,11 +241,11 @@ func TestNewCmdTaskRefresh(t *testing.T) { stop := make(chan struct{}) reader := BlankLineReader{totalLinesToYield: s.totalTaskLines} - start := func() (*exec.Cmd, io.Reader) { + start := func() (Cmd, io.Reader) { // not actually starting this because it's not necessary cmd := exec.Command("blah") - return cmd, &reader + return ExecCmd{Cmd: cmd}, &reader } fn := manager.NewCmdTask(start, "", s.linesToRead, func() {}) From c85f7530bb1617c8a16245636a224a432709f8f7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 09:45:26 +0200 Subject: [PATCH 165/384] Abstract pty startup behind a platform-specific primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the pty master behind a small interface (Read/Write/Close/Resize), and push the actual startup into a platform-specific StartPty function in pkg/commands/oscommands. The Unix implementation still uses creack/pty; the Windows implementation is a stub that returns ErrPtyUnsupported, at which point newPtyTask falls back to a plain cmd task — matching the existing Windows behavior. The primitive lives in oscommands rather than pkg/gui because the cmd_obj_runner pty handler (also in oscommands) is going to consume it too, and tasks → oscommands is the existing dependency direction. Same observable behavior on every platform; this just carves out a seam for a real ConPTY implementation on Windows. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/commands/oscommands/pty.go | 39 +++++++++++++++++++++ pkg/commands/oscommands/pty_unix.go | 34 ++++++++++++++++++ pkg/commands/oscommands/pty_windows.go | 12 +++++++ pkg/gui/gui.go | 4 +-- pkg/gui/pty.go | 48 ++++++++++++++++++-------- pkg/gui/pty_windows.go | 17 --------- 6 files changed, 120 insertions(+), 34 deletions(-) create mode 100644 pkg/commands/oscommands/pty.go create mode 100644 pkg/commands/oscommands/pty_unix.go create mode 100644 pkg/commands/oscommands/pty_windows.go delete mode 100644 pkg/gui/pty_windows.go diff --git a/pkg/commands/oscommands/pty.go b/pkg/commands/oscommands/pty.go new file mode 100644 index 000000000..8a803fe60 --- /dev/null +++ b/pkg/commands/oscommands/pty.go @@ -0,0 +1,39 @@ +package oscommands + +import ( + "errors" + "io" + "os" +) + +// Pty is the master side of a pseudo-terminal running a subprocess. The +// concrete implementation is platform-specific: creack/pty on Unix and +// ConPTY on Windows. +type Pty interface { + io.ReadWriteCloser + Resize(cols, rows uint16) error +} + +// StartedPty is the result of StartPty. +type StartedPty struct { + // Pty is the master side of the pseudo-terminal; read from it to get + // the child's combined stdout/stderr and write to it to feed stdin. + Pty Pty + // Process is the spawned child. Useful for signalling; on Windows the + // original *exec.Cmd was not Start()ed (ConPTY spawns via + // CreateProcess, not os/exec) so cmd.Process is nil and this is the + // only handle. + Process *os.Process + // Wait blocks until the child exits and returns a non-nil error on a + // nonzero exit status, matching *exec.Cmd.Wait semantics. + Wait func() error +} + +// ErrPtyUnsupported is returned by StartPty on platforms without a pty +// implementation. Callers may fall back to running the command without a pty. +var ErrPtyUnsupported = errors.New("pty not supported on this platform") + +// StartPty runs cmd in a pseudo-terminal with the given initial dimensions. +// Implemented per-platform in pty_unix.go / pty_windows.go. +// +// func StartPty(cmd *exec.Cmd, cols, rows uint16) (StartedPty, error) diff --git a/pkg/commands/oscommands/pty_unix.go b/pkg/commands/oscommands/pty_unix.go new file mode 100644 index 000000000..6cf63cdff --- /dev/null +++ b/pkg/commands/oscommands/pty_unix.go @@ -0,0 +1,34 @@ +//go:build !windows + +package oscommands + +import ( + "os" + "os/exec" + + creackpty "github.com/creack/pty" +) + +type unixPty struct { + master *os.File +} + +func (u *unixPty) Read(p []byte) (int, error) { return u.master.Read(p) } +func (u *unixPty) Write(p []byte) (int, error) { return u.master.Write(p) } +func (u *unixPty) Close() error { return u.master.Close() } + +func (u *unixPty) Resize(cols, rows uint16) error { + return creackpty.Setsize(u.master, &creackpty.Winsize{Cols: cols, Rows: rows}) +} + +func StartPty(cmd *exec.Cmd, cols, rows uint16) (StartedPty, error) { + f, err := creackpty.StartWithSize(cmd, &creackpty.Winsize{Cols: cols, Rows: rows}) + if err != nil { + return StartedPty{}, err + } + return StartedPty{ + Pty: &unixPty{master: f}, + Process: cmd.Process, + Wait: cmd.Wait, + }, nil +} diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go new file mode 100644 index 000000000..6d0d7c6ff --- /dev/null +++ b/pkg/commands/oscommands/pty_windows.go @@ -0,0 +1,12 @@ +package oscommands + +import ( + "os/exec" +) + +// StartPty is a stub on Windows for now; callers fall back to the non-pty +// path when ErrPtyUnsupported is returned. A real ConPTY implementation +// replaces this in a follow-up commit. +func StartPty(cmd *exec.Cmd, cols, rows uint16) (StartedPty, error) { + return StartedPty{}, ErrPtyUnsupported +} diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 08d560ce5..e23afd124 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -85,7 +85,7 @@ type Gui struct { // holds a mapping of view names to ptmx's. This is for rendering command outputs // from within a pty. The point of keeping track of them is so that if we re-size // the window, we can tell the pty it needs to resize accordingly. - viewPtmxMap map[string]*os.File + viewPtmxMap map[string]oscommands.Pty stopChan chan struct{} // when lazygit is opened outside a git directory we want to open to the most @@ -761,7 +761,7 @@ func NewGui( Updater: updater, statusManager: status.NewStatusManager(), viewBufferManagerMap: map[string]*tasks.ViewBufferManager{}, - viewPtmxMap: map[string]*os.File{}, + viewPtmxMap: map[string]oscommands.Pty{}, showRecentRepos: showRecentRepos, RepoPathStack: &utils.StringStack{}, RepoStateMap: map[Repo]*GuiRepoState{}, diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index 0455c0abf..e5ef2a770 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -1,37 +1,36 @@ -//go:build !windows - package gui import ( + "errors" "fmt" "io" "os" "os/exec" "strings" - "github.com/creack/pty" + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/tasks" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) -func (gui *Gui) desiredPtySize(view *gocui.View) *pty.Winsize { +func (gui *Gui) desiredPtySize(view *gocui.View) (cols, rows uint16) { width, height := view.InnerSize() - - return &pty.Winsize{Cols: uint16(width), Rows: uint16(height)} + return uint16(width), uint16(height) } func (gui *Gui) onResize() error { gui.Mutexes.PtyMutex.Lock() defer gui.Mutexes.PtyMutex.Unlock() - for viewName, ptmx := range gui.viewPtmxMap { + for viewName, p := range gui.viewPtmxMap { // TODO: handle resizing properly: we need to actually clear the main view // and re-read the output from our pty. Or we could just re-run the original // command from scratch view, _ := gui.g.View(viewName) - if err := pty.Setsize(ptmx, gui.desiredPtySize(view)); err != nil { + cols, rows := gui.desiredPtySize(view) + if err := p.Resize(cols, rows); err != nil { return utils.WrapError(err) } } @@ -39,6 +38,19 @@ func (gui *Gui) onResize() error { return nil } +// ptyCmd adapts an oscommands.StartedPty result into the tasks.Cmd shape. +// On Windows the original *exec.Cmd was never Start()ed, so we go through +// the explicit Process handle rather than cmd.Process. +type ptyCmd struct { + cmd *exec.Cmd + process *os.Process + 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 } + // 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 // talking to a terminal. We typically write cmd outputs straight to a view, @@ -82,24 +94,30 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error manager := gui.getManager(view) - var ptmx *os.File + var p oscommands.Pty start := func() (tasks.Cmd, io.Reader) { - var err error - ptmx, err = pty.StartWithSize(cmd, gui.desiredPtySize(view)) + cols, rows := gui.desiredPtySize(view) + sp, err := oscommands.StartPty(cmd, cols, rows) if err != nil { - gui.c.Log.Error(err) + if !errors.Is(err, oscommands.ErrPtyUnsupported) { + gui.c.Log.Error(err) + } + return tasks.ExecCmd{Cmd: cmd}, nil } + p = sp.Pty gui.Mutexes.PtyMutex.Lock() - gui.viewPtmxMap[view.Name()] = ptmx + gui.viewPtmxMap[view.Name()] = p gui.Mutexes.PtyMutex.Unlock() - return tasks.ExecCmd{Cmd: cmd}, ptmx + return ptyCmd{cmd: cmd, process: sp.Process, wait: sp.Wait}, p } onClose := func() { gui.Mutexes.PtyMutex.Lock() - ptmx.Close() + if p != nil { + p.Close() + } delete(gui.viewPtmxMap, view.Name()) gui.Mutexes.PtyMutex.Unlock() } diff --git a/pkg/gui/pty_windows.go b/pkg/gui/pty_windows.go deleted file mode 100644 index 31d763870..000000000 --- a/pkg/gui/pty_windows.go +++ /dev/null @@ -1,17 +0,0 @@ -package gui - -import ( - "fmt" - "os/exec" - - "github.com/jesseduffield/lazygit/pkg/gocui" -) - -func (gui *Gui) onResize() error { - return nil -} - -func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { - cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", view.InnerWidth())) - return gui.newCmdTask(view, cmd, prefix) -} From 1935117141b98d7931fc04731bceff8e4baf83a5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 09:45:26 +0200 Subject: [PATCH 166/384] Add pty support on Windows via ConPTY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the StartPty stub with a real ConPTY implementation: CreatePipe + CreatePseudoConsole + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE + CreateProcess. Pagers and external diff tools now get real terminal behavior instead of being handed pipes. One Windows-specific quirk worth flagging: ConPTY does not EOF the output pipe when the child exits; conhost keeps it alive until ClosePseudoConsole is called explicitly. A background waiter goroutine calls ClosePseudoConsole as soon as proc.Wait returns, so callers see EOF on outRead — restoring the Unix master-fd-EOFs-when-slave-closes semantics they depend on. The ErrPtyUnsupported sentinel and the no-pty fallback in newPtyTask are gone now that both platforms have a real implementation. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/commands/oscommands/pty.go | 5 - pkg/commands/oscommands/pty_windows.go | 257 ++++++++++++++++++++++++- pkg/gui/pty.go | 5 +- 3 files changed, 253 insertions(+), 14 deletions(-) diff --git a/pkg/commands/oscommands/pty.go b/pkg/commands/oscommands/pty.go index 8a803fe60..a4a369633 100644 --- a/pkg/commands/oscommands/pty.go +++ b/pkg/commands/oscommands/pty.go @@ -1,7 +1,6 @@ package oscommands import ( - "errors" "io" "os" ) @@ -29,10 +28,6 @@ type StartedPty struct { Wait func() error } -// ErrPtyUnsupported is returned by StartPty on platforms without a pty -// implementation. Callers may fall back to running the command without a pty. -var ErrPtyUnsupported = errors.New("pty not supported on this platform") - // StartPty runs cmd in a pseudo-terminal with the given initial dimensions. // Implemented per-platform in pty_unix.go / pty_windows.go. // diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go index 6d0d7c6ff..0a4a06477 100644 --- a/pkg/commands/oscommands/pty_windows.go +++ b/pkg/commands/oscommands/pty_windows.go @@ -1,12 +1,259 @@ package oscommands import ( + "fmt" + "os" "os/exec" + "sync" + "unsafe" + + "golang.org/x/sys/windows" ) -// StartPty is a stub on Windows for now; callers fall back to the non-pty -// path when ErrPtyUnsupported is returned. A real ConPTY implementation -// replaces this in a follow-up commit. -func StartPty(cmd *exec.Cmd, cols, rows uint16) (StartedPty, error) { - return StartedPty{}, ErrPtyUnsupported +type winPty struct { + hpc windows.Handle + inWrite *os.File + outRead *os.File + + // mu guards the teardown state below and serializes it against Resize. + // hpcClosed gates ClosePseudoConsole (it must run exactly once) and also + // keeps Resize from touching the HPCON once it's been freed: the + // background waiter in StartPty closes the pseudoconsole on child exit, + // which would otherwise race a concurrent onResize and hand + // ResizePseudoConsole a freed handle. + mu sync.Mutex + hpcClosed bool + closed bool +} + +func (p *winPty) Read(buf []byte) (int, error) { return p.outRead.Read(buf) } +func (p *winPty) Write(buf []byte) (int, error) { return p.inWrite.Write(buf) } + +func (p *winPty) Resize(cols, rows uint16) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.hpcClosed { + // The child already exited and the pseudoconsole was torn down, so + // there is nothing left to resize. + return nil + } + return windows.ResizePseudoConsole(p.hpc, windows.Coord{X: int16(cols), Y: int16(rows)}) +} + +// closeHpc closes the pseudoconsole exactly once. Safe to call from multiple +// goroutines and at any time. We need this separately from Close because the +// background waiter in StartPty closes the pseudoconsole as soon as the child +// exits — that's what makes outRead return EOF, matching the Unix behavior +// where the master fd EOFs when the slave closes — while the pipe fds stay +// open until somebody explicitly tears the pty down. +func (p *winPty) closeHpc() { + p.mu.Lock() + defer p.mu.Unlock() + p.closeHpcLocked() +} + +// closeHpcLocked closes the pseudoconsole; the caller must hold p.mu. +func (p *winPty) closeHpcLocked() { + if p.hpcClosed { + return + } + p.hpcClosed = true + windows.ClosePseudoConsole(p.hpc) +} + +func (p *winPty) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return nil + } + p.closed = true + // Closing the pseudoconsole breaks the pipes; the child's next write + // fails and it exits. Then we close our ends of the pipes. + p.closeHpcLocked() + p.inWrite.Close() + p.outRead.Close() + return nil +} + +// startWaiter runs proc.Wait in a goroutine and, as soon as the child exits, +// closes the pseudoconsole so that any pending Read on outRead returns EOF +// after buffered output drains. Returns a Wait func that blocks until the +// child has exited and reports its exit status with *exec.Cmd.Wait semantics. +// +// This shape exists because on Unix the master fd EOFs naturally when the +// 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). +func startWaiter(proc *os.Process, p *winPty) func() error { + done := make(chan struct{}) + var waitErr error + go func() { + defer close(done) + state, err := proc.Wait() + p.closeHpc() + if err != nil { + waitErr = err + return + } + if !state.Success() { + waitErr = fmt.Errorf("exit status %d", state.ExitCode()) + } + }() + return func() error { + <-done + return waitErr + } +} + +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 + // the pseudoconsole. + var inRead, inWrite, outRead, outWrite windows.Handle + if err = windows.CreatePipe(&inRead, &inWrite, nil, 0); err != nil { + return StartedPty{}, fmt.Errorf("CreatePipe (in): %w", err) + } + defer func() { + if err != nil { + _ = windows.CloseHandle(inWrite) + } + }() + if err = windows.CreatePipe(&outRead, &outWrite, nil, 0); err != nil { + _ = windows.CloseHandle(inRead) + return StartedPty{}, fmt.Errorf("CreatePipe (out): %w", err) + } + defer func() { + if err != nil { + _ = windows.CloseHandle(outRead) + } + }() + + // CreatePseudoConsole dupes the handles it needs internally; we release + // our references to the child-side ends immediately after. + var hpc windows.Handle + size := windows.Coord{X: int16(cols), Y: int16(rows)} + if err = windows.CreatePseudoConsole(size, inRead, outWrite, 0, &hpc); err != nil { + _ = windows.CloseHandle(inRead) + _ = windows.CloseHandle(outWrite) + return StartedPty{}, fmt.Errorf("CreatePseudoConsole: %w", err) + } + _ = windows.CloseHandle(inRead) + _ = windows.CloseHandle(outWrite) + defer func() { + if err != nil { + windows.ClosePseudoConsole(hpc) + } + }() + + // Attach the pseudoconsole to the child via a process attribute list. + attrList, err := windows.NewProcThreadAttributeList(1) + if err != nil { + return StartedPty{}, fmt.Errorf("NewProcThreadAttributeList: %w", err) + } + defer attrList.Delete() + // PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE wants the HPCON value itself as + // the attribute value, not a pointer to it — an HPCON is already a + // pointer-sized handle, per Microsoft's ConPTY sample. Spelling that as + // unsafe.Pointer(hpc) trips go vet's unsafeptr check (a uintptr-based + // type converted straight to unsafe.Pointer), which gopls surfaces in + // the editor. Reinterpret the handle's bits through its address instead: + // &hpc is a real pointer, so none of these conversions is the flagged + // uintptr→unsafe.Pointer cast, while the resulting value is identical. + if err = attrList.Update( + windows.PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, + *(*unsafe.Pointer)(unsafe.Pointer(&hpc)), + unsafe.Sizeof(hpc), + ); err != nil { + return StartedPty{}, fmt.Errorf("UpdateProcThreadAttribute: %w", err) + } + + var si windows.StartupInfoEx + si.Cb = uint32(unsafe.Sizeof(si)) + si.ProcThreadAttributeList = attrList.List() + + var appNamePtr *uint16 + if cmd.Path != "" { + if appNamePtr, err = windows.UTF16PtrFromString(cmd.Path); err != nil { + return StartedPty{}, err + } + } + cmdLinePtr, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(cmd.Args)) + if err != nil { + return StartedPty{}, err + } + var dirPtr *uint16 + if cmd.Dir != "" { + if dirPtr, err = windows.UTF16PtrFromString(cmd.Dir); err != nil { + return StartedPty{}, err + } + } + envBlock, err := createEnvBlock(cmd.Env) + if err != nil { + return StartedPty{}, err + } + var envPtr *uint16 + if envBlock != nil { + envPtr = &envBlock[0] + } + + var pi windows.ProcessInformation + err = windows.CreateProcess( + appNamePtr, + cmdLinePtr, + nil, // process security + nil, // thread security + false, + windows.EXTENDED_STARTUPINFO_PRESENT|windows.CREATE_UNICODE_ENVIRONMENT, + envPtr, + dirPtr, + &si.StartupInfo, + &pi, + ) + if err != nil { + return StartedPty{}, fmt.Errorf("CreateProcess: %w", err) + } + _ = windows.CloseHandle(pi.Thread) + + // Re-open the process by PID to get an *os.Process to wait on. Do this + // while pi.Process is still open: Windows won't recycle a PID while any + // handle to the process remains, so FindProcess can't latch onto a + // different process that has since reused the PID. Release the original + // handle once we have our own. + proc, err := os.FindProcess(int(pi.ProcessId)) + _ = windows.CloseHandle(pi.Process) + if err != nil { + return StartedPty{}, err + } + + wp := &winPty{ + hpc: hpc, + inWrite: os.NewFile(uintptr(inWrite), "conpty-in"), + outRead: os.NewFile(uintptr(outRead), "conpty-out"), + } + return StartedPty{ + Pty: wp, + Process: proc, + Wait: startWaiter(proc, wp), + }, nil +} + +// createEnvBlock packs env vars into the UTF-16 double-null-terminated block +// that CreateProcess expects. Returns nil if env is empty, which tells +// CreateProcess to inherit the parent's environment. +func createEnvBlock(env []string) ([]uint16, error) { + if len(env) == 0 { + return nil, nil + } + var block []uint16 + for _, s := range env { + utf16s, err := windows.UTF16FromString(s) + if err != nil { + return nil, err + } + block = append(block, utf16s...) + } + block = append(block, 0) + return block, nil } diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index e5ef2a770..1a774fc3d 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -1,7 +1,6 @@ package gui import ( - "errors" "fmt" "io" "os" @@ -99,9 +98,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error cols, rows := gui.desiredPtySize(view) sp, err := oscommands.StartPty(cmd, cols, rows) if err != nil { - if !errors.Is(err, oscommands.ErrPtyUnsupported) { - gui.c.Log.Error(err) - } + gui.c.Log.Error(err) return tasks.ExecCmd{Cmd: cmd}, nil } p = sp.Pty From 8c035ebe60541722e9b28c782f8d7bee592d16eb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 09:45:26 +0200 Subject: [PATCH 167/384] Use ConPTY on Windows for pty-backed command execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-platform getCmdHandlerPty split existed because the Unix side had creack/pty and the Windows side had nothing — so it fell back to a non-pty handler. Now that oscommands.StartPty provides a pty on both platforms, the two files collapse into one cross-platform implementation and the stub is gone. cmdHandler grows a 'wait' field because the pty path on Windows spawns via CreateProcess and never runs exec.Cmd.Start — so cmd.Wait wouldn't work there. Non-pty handlers set wait = cmd.Wait; pty handlers set it to the wait closure StartPty returns. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/commands/oscommands/cmd_obj.go | 2 +- pkg/commands/oscommands/cmd_obj_runner.go | 26 ++++++++++++++++--- .../oscommands/cmd_obj_runner_default.go | 24 ----------------- .../oscommands/cmd_obj_runner_windows.go | 10 ------- 4 files changed, 23 insertions(+), 39 deletions(-) delete mode 100644 pkg/commands/oscommands/cmd_obj_runner_default.go delete mode 100644 pkg/commands/oscommands/cmd_obj_runner_windows.go diff --git a/pkg/commands/oscommands/cmd_obj.go b/pkg/commands/oscommands/cmd_obj.go index 57bc295ed..1fbe84087 100644 --- a/pkg/commands/oscommands/cmd_obj.go +++ b/pkg/commands/oscommands/cmd_obj.go @@ -157,7 +157,7 @@ func (self *CmdObj) ShouldStreamOutput() bool { } // when you call this, then call Run(), we'll use a PTY to run the command. Only -// has an effect if StreamOutput() was also called. Ignored on Windows. +// has an effect if StreamOutput() was also called. func (self *CmdObj) UsePty() *CmdObj { self.usePty = true diff --git a/pkg/commands/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go index 978682618..b70668431 100644 --- a/pkg/commands/oscommands/cmd_obj_runner.go +++ b/pkg/commands/oscommands/cmd_obj_runner.go @@ -219,6 +219,10 @@ type cmdHandler struct { stdoutPipe io.Reader stdinPipe io.Writer close func() error + // wait blocks until the child process exits. Needed as a separate + // field because the pty path on Windows spawns via CreateProcess and + // never runs *exec.Cmd.Start — so cmd.Wait wouldn't work there. + wait func() error } func (self *cmdObjRunner) runAndStream(cmdObj *CmdObj) error { @@ -274,7 +278,7 @@ func (self *cmdObjRunner) runAndStreamAux( onRun(handler, cmdWriter) - err = cmd.Wait() + err = handler.wait() self.log.Infof("%s (%s)", cmdObj.ToString(), time.Since(t)) @@ -376,9 +380,7 @@ func (self *cmdObjRunner) processOutput( responseChan := promptUserForCredential(askFor) if responseChan == nil { // Returning a nil channel means we should terminate the process. - // We achieve this by closing the pty that it's running in. Note that this won't - // work for the case where we're not running in a pty (i.e. on Windows), but - // in that case we'll never be prompted for credentials, so it's not a concern. + // We achieve this by closing the pty that it's running in. if err := closeFunc(); err != nil { self.log.Error(err) } @@ -481,5 +483,21 @@ func (self *cmdObjRunner) getCmdHandlerNonPty(cmd *exec.Cmd) (*cmdHandler, error stdoutPipe: stdoutReader, stdinPipe: buf, close: func() error { return nil }, + wait: cmd.Wait, + }, nil +} + +func (self *cmdObjRunner) getCmdHandlerPty(cmd *exec.Cmd) (*cmdHandler, error) { + // Size will be adjusted by the caller if it cares; this just avoids a + // zero-size pty. + sp, err := StartPty(cmd, 80, 24) + if err != nil { + return nil, err + } + return &cmdHandler{ + stdoutPipe: sp.Pty, + stdinPipe: sp.Pty, + close: sp.Pty.Close, + wait: sp.Wait, }, nil } diff --git a/pkg/commands/oscommands/cmd_obj_runner_default.go b/pkg/commands/oscommands/cmd_obj_runner_default.go deleted file mode 100644 index 72cbc26c6..000000000 --- a/pkg/commands/oscommands/cmd_obj_runner_default.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build !windows - -package oscommands - -import ( - "os/exec" - - "github.com/creack/pty" -) - -// we define this separately for windows and non-windows given that windows does -// not have great PTY support and we need a PTY to handle a credential request -func (self *cmdObjRunner) getCmdHandlerPty(cmd *exec.Cmd) (*cmdHandler, error) { - ptmx, err := pty.Start(cmd) - if err != nil { - return nil, err - } - - return &cmdHandler{ - stdoutPipe: ptmx, - stdinPipe: ptmx, - close: ptmx.Close, - }, nil -} diff --git a/pkg/commands/oscommands/cmd_obj_runner_windows.go b/pkg/commands/oscommands/cmd_obj_runner_windows.go deleted file mode 100644 index f92e36c69..000000000 --- a/pkg/commands/oscommands/cmd_obj_runner_windows.go +++ /dev/null @@ -1,10 +0,0 @@ -package oscommands - -import ( - "os/exec" -) - -func (self *cmdObjRunner) getCmdHandlerPty(cmd *exec.Cmd) (*cmdHandler, error) { - // We don't have PTY support on Windows yet, so we just return a non-PTY handler. - return self.getCmdHandlerNonPty(cmd) -} From 79bc8e0bc64f50028b3e0a159afb8d96ca65bf4a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 09:45:26 +0200 Subject: [PATCH 168/384] Demonstrate that cursor positioning escapes collapse blank rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConPTY presents its child's stdout as a screen buffer and uses CUP (`\x1b[;H`) to skip over blank rows rather than emitting LFs for them. Our escape interpreter swallows CUP via the catch-all "valid CSI final byte we don't implement" branch, so the blank rows the child put between non-blank ones disappear and the surrounding lines collapse together — which is what makes the delta-rendered diff in the screenshot look like its blank lines and section breaks were removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gocui/view_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index f65418821..58c83e126 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -238,6 +238,27 @@ func TestContainsColoredText(t *testing.T) { } } +func TestWriteCursorPositionEscape(t *testing.T) { + // ConPTY presents its child's output as a screen buffer and uses cursor + // positioning escapes (CUP, `\x1b[;H`) to skip over blank rows + // rather than emitting empty LFs for them. The escape interpreter must + // synthesize the row advances those CUPs imply; otherwise non-blank rows + // that the child separated with blank lines end up adjacent in the view. + v := NewView("name", 0, 0, 20, 10, OutputNormal) + // "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 = append(got, cellsToStrings(l.cells)) + } + + /* EXPECTED: + assert.Equal(t, [][]string{{"a"}, {}, {"b"}}, got) + ACTUAL: */ + assert.Equal(t, [][]string{{"a"}, {"b"}}, got) +} + func stringToCells(s string) []cell { var cells []cell state := -1 From 180fe0cd267ff52a9afe8b004fa6dbdaab352634 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 09:45:26 +0200 Subject: [PATCH 169/384] Convert forward cursor-positioning escapes into row advances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConPTY presents its child's output as a screen buffer and uses CUP / CUD / CNL / VPA to skip over blank rows rather than emitting LFs. The previous behaviour swallowed all of those and the visible content collapsed together. Now the escape parser tracks the screen-relative cursor row, and any CSI that moves the cursor past the current row emits a cursorDown instruction that the view turns into the matching number of empty lines. Column tracking is deliberately omitted: doing it correctly would mean duplicating the view's grapheme-cluster width math in the parser, and ConPTY in practice positions to column 1 after a CR-equivalent, which the existing wx-reset path already handles. ConPTY-internal scrolling needs no special handling either: it only emits cursor-positioning escapes within the first, un-scrolled screenful — once its screen scrolls it switches to plain linefeeds, which the view advances on directly regardless of the tracked cursor. Backward cursor moves are silently dropped — the view's buffer is append-style and can't undo earlier writes. The exception is cursor-home (CUP to row 1): ConPTY emits it at the start of every screen, so rather than drop it we re-anchor the row tracking to the current write position. Without that, a view not rewound in lockstep with ConPTY's screen (the command log, which streams pty output without a rewind) accumulates drift, and every later absolute CUP becomes a dropped backward move that collapses the rows ConPTY positioned with. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gocui/escape.go | 139 ++++++++++++++++++++++++++++++++++++++- pkg/gocui/escape_test.go | 67 +++++++++++++++++-- pkg/gocui/view.go | 22 +++++++ pkg/gocui/view_test.go | 3 - 4 files changed, 221 insertions(+), 10 deletions(-) diff --git a/pkg/gocui/escape.go b/pkg/gocui/escape.go index 726fd4de7..c252fa20b 100644 --- a/pkg/gocui/escape.go +++ b/pkg/gocui/escape.go @@ -19,6 +19,21 @@ type escapeInterpreter struct { mode OutputMode instruction instruction hyperlink strings.Builder + + // ConPTY emits cursor-positioning escapes (CUP) to skip over blank + // rows rather than emitting LFs for them. To convert those into row + // advances the view can act on, we track where in the pseudo-terminal + // screen the cursor currently is. 1-based to match the escape + // sequences. + // + // We also have to track the column, but only well enough to count + // soft-wraps when written content runs past the right edge: ConPTY's + // CUPs are addressed against its post-wrap screen, so a logical line + // long enough to wrap in ConPTY's screen counts for two rows from the + // next CUP's perspective. Column accuracy past wrap-counting isn't + // modelled — we don't track the col argument of CUPs, and most + // pager-style emitters use col 1 anyway. + screenRow, screenCol int } type ( @@ -32,6 +47,13 @@ type eraseInLineFromCursor struct{} func (self eraseInLineFromCursor) isInstruction() {} +// cursorDown asks the view to advance N rows. Emitted when CUP / CUD / +// CNL / VPA targets a row past the current one; backward moves are +// ignored because the view's buffer is line-based and can't undo. +type cursorDown struct{ n int } + +func (self cursorDown) isInstruction() {} + type noInstruction struct{} func (self noInstruction) isInstruction() {} @@ -99,11 +121,15 @@ func newEscapeInterpreter(mode OutputMode) *escapeInterpreter { curBgColor: ColorDefault, mode: mode, instruction: noInstruction{}, + screenRow: 1, + screenCol: 1, } return ei } -// reset sets the escapeInterpreter in initial state. +// reset sets the escapeInterpreter in initial state. Note: this only resets +// escape-parsing state. Screen cursor state survives so that mid-stream +// malformed escapes don't desync the row tracking from the view. func (ei *escapeInterpreter) reset() { ei.state = stateNone ei.curFgColor = ColorDefault @@ -111,6 +137,80 @@ func (ei *escapeInterpreter) reset() { ei.csiParam = nil } +// resetScreenCursor returns the screen-cursor tracking to the top of the +// pseudo-terminal screen. Called when the view is rewound before a fresh pty +// render, and on cursor-home (which ConPTY emits at the start of each screen) +// for views that aren't rewound in lockstep — see the CUP handling in parseOne. +func (ei *escapeInterpreter) resetScreenCursor() { + ei.screenRow = 1 + ei.screenCol = 1 +} + +// notifyRowAdvance must be called by the view whenever it advances to the +// next row in response to an LF / CRLF outside of an escape sequence +// (i.e. the row transitions the parser doesn't see directly). Keeps the +// parser's notion of the current screen row in sync with the view. +func (ei *escapeInterpreter) notifyRowAdvance() { + ei.screenRow++ + ei.screenCol = 1 +} + +// notifyColumnReset must be called when the view processes a bare CR +// (column reset without row advance). Keeps screenCol in sync so wrap +// counting starts over from col 1. +func (ei *escapeInterpreter) notifyColumnReset() { + ei.screenCol = 1 +} + +// notifyCellsWritten must be called after the view writes visible cells +// to its buffer. Advances the parser's idea of the cursor by `width` +// columns; if that crosses the right edge of a `screenColMax`-wide pty +// screen, the corresponding number of soft-wraps are added to screenRow +// so subsequent CUPs land on the right line. +func (ei *escapeInterpreter) notifyCellsWritten(width, screenColMax int) { + if screenColMax <= 0 { + return + } + // One column at a time: matches ConPTY's "pending wrap" semantics + // where the cursor stays at col max+1 after writing the rightmost + // cell and only wraps on the next cell. Loops over individual + // columns rather than doing the math in one shot so wide cells on a + // row boundary still wrap cleanly. + for range width { + if ei.screenCol > screenColMax { + ei.screenRow++ + ei.screenCol = 1 + } + ei.screenCol++ + } +} + +// emitCursorAdvance schedules a cursorDown instruction for the next time +// the view checks ei.instruction, advancing the parser's screen row by +// the same amount. n <= 0 is a no-op (backward / same-row CUPs are +// ignored — the view's buffer is line-based and can't undo). +func (ei *escapeInterpreter) emitCursorAdvance(n int) { + if n <= 0 { + return + } + ei.instruction = cursorDown{n: n} + ei.screenRow += n + ei.screenCol = 1 +} + +// firstParamOrDefault returns the first CSI parameter parsed as an int, +// or dflt if it's absent / empty / unparseable. +func (ei *escapeInterpreter) firstParamOrDefault(dflt int) int { + if len(ei.csiParam) == 0 || ei.csiParam[0] == "" { + return dflt + } + n, err := strconv.Atoi(ei.csiParam[0]) + if err != nil { + return dflt + } + return n +} + func (ei *escapeInterpreter) instructionRead() { ei.instruction = noInstruction{} } @@ -170,8 +270,12 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { ei.csiParam = append(ei.csiParam, "") case characterEquals(ch, 'm'): ei.csiParam = append(ei.csiParam, "0") - case characterEquals(ch, 'K'): - // fall through + case characterEquals(ch, 'K'), + characterEquals(ch, 'H'), characterEquals(ch, 'f'), characterEquals(ch, 'd'), + characterEquals(ch, 'B'), characterEquals(ch, 'E'): + // fall through — let stateParams handle these with default + // params (CUP/VPA default to row 1, CUD/CNL default to advance + // by 1). case characterEquals(ch, ';'): // Empty first param ([;Xm ≡ [0;Xm). Seed a slot for the // empty param; stateParams will append the next one when it @@ -240,6 +344,35 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { ei.instruction = noInstruction{} } + ei.state = stateNone + ei.csiParam = nil + return true, nil + case characterEquals(ch, 'H'), characterEquals(ch, 'f'), + characterEquals(ch, 'd'): + // CUP / HVP (absolute (row, col), col ignored) or VPA (absolute row). + targetRow := ei.firstParamOrDefault(1) + if targetRow <= 1 { + // Cursor home. ConPTY emits this (after [2J) at the start of + // every screen, so it marks where ConPTY's coordinate origin + // now sits. Re-anchor our row tracking to the current write + // position rather than treating it as a backward move: a view + // that isn't rewound in lockstep with ConPTY's screen (the + // command log) would otherwise carry stale drift, making every + // later absolute CUP compute a negative, dropped advance and + // collapsing the blank rows ConPTY positioned with. + ei.resetScreenCursor() + } else { + // Skip forward to the target row; ignore backward moves. + ei.emitCursorAdvance(targetRow - ei.screenRow) + } + ei.state = stateNone + ei.csiParam = nil + return true, nil + case characterEquals(ch, 'B'), characterEquals(ch, 'E'): + // CUD / CNL — relative row advance by N. CNL also resets + // the column, which we don't track, so the two are + // equivalent for our purposes. + ei.emitCursorAdvance(ei.firstParamOrDefault(1)) ei.state = stateNone ei.csiParam = nil return true, nil diff --git a/pkg/gocui/escape_test.go b/pkg/gocui/escape_test.go index cb8eb1a4b..a7e8ce02f 100644 --- a/pkg/gocui/escape_test.go +++ b/pkg/gocui/escape_test.go @@ -153,17 +153,16 @@ func TestParseOneColours(t *testing.T) { func TestParseOneIgnoresUnknownSequences(t *testing.T) { // Escape sequences the interpreter doesn't implement -- whether well-formed-but-unsupported - // (cursor movement, private modes, DECSCUSR, …) or outright malformed -- must be silently + // (private modes, DECSCUSR, …) or outright malformed -- must be silently // consumed rather than leaked into the view as literal text. scenarios := []string{ "\x1b[?9001h", // DEC private-mode set (?-prefix) "\x1b[?25l", // hide cursor "\x1b[?25h", // show cursor "\x1b[2;J", // erase display (unusual 2;J variant) - "\x1b[H", // cursor home — final byte immediately after [ - "\x1b[5;1;H", // cursor position with multiple params + "\x1b[H", // cursor home — re-anchors to row 1 (no-op when already there) "\x1bc", // RIS — single-char ESC sequence - "\x1b[;5H", // empty first param (';' immediately after '[') + "\x1b[;5H", // empty first param — defaults to row 1, no-op "\x1b[ q", // intermediate byte with no params (DECSCUSR family) "\x1b[0 q", // intermediate byte after a param "\x1b[1;;m", // malformed SGR: empty middle param @@ -184,6 +183,66 @@ func TestParseOneIgnoresUnknownSequences(t *testing.T) { } } +func TestParseOneCursorPositioning(t *testing.T) { + // Cursor-positioning escapes that advance the row forward emit a + // cursorDown instruction; backward / same-row moves are ignored + // because the view's buffer is line-based. + scenarios := []struct { + input string + startRow int // parser's screenRow before parsing + wantAdvance int // 0 means "no instruction emitted" + }{ + {"\x1b[5;1H", 1, 4}, // CUP — absolute row 5 from row 1 + {"\x1b[5H", 1, 4}, // CUP with only the row param + {"\x1b[5;1H", 5, 0}, // CUP to the same row we're on — no-op + {"\x1b[2;1H", 5, 0}, // CUP backward — ignored + {"\x1b[5;1f", 1, 4}, // HVP alias for CUP + {"\x1b[5d", 1, 4}, // VPA — absolute row + {"\x1b[2d", 5, 0}, // VPA backward — ignored + {"\x1b[3B", 1, 3}, // CUD — relative + {"\x1b[B", 1, 1}, // CUD with default param of 1 + {"\x1b[2E", 1, 2}, // CNL — relative + } + + for _, s := range scenarios { + ei := newEscapeInterpreter(OutputNormal) + ei.screenRow = s.startRow + parseEscRunes(t, ei, s.input) + if s.wantAdvance == 0 { + _, noop := ei.instruction.(noInstruction) + assert.True(t, noop, "input %q at row %d should be a no-op", s.input, s.startRow) + } else { + cd, ok := ei.instruction.(cursorDown) + if assert.True(t, ok, "input %q at row %d should emit cursorDown", s.input, s.startRow) { + assert.Equal(t, s.wantAdvance, cd.n, "input %q at row %d", s.input, s.startRow) + } + } + } +} + +func TestParseOneCursorHomeReanchors(t *testing.T) { + // ConPTY emits cursor-home ([H) after [2J at the start of every screen. + // In a view that isn't rewound in lockstep with ConPTY (the command log) + // screenRow has drifted, so home must re-anchor it to the current write + // position rather than be dropped as a backward move — otherwise the + // absolute CUPs that follow compute negative, dropped advances and the + // rows ConPTY positioned with collapse together. + ei := newEscapeInterpreter(OutputNormal) + ei.screenRow = 12 // accumulated drift from earlier command-log output + + parseEscRunes(t, ei, "\x1b[H") + assert.Equal(t, 1, ei.screenRow, "home should re-anchor screenRow") + _, noop := ei.instruction.(noInstruction) + assert.True(t, noop, "home should not emit an instruction") + + // A subsequent CUP now advances relative to the re-anchored origin. + parseEscRunes(t, ei, "\x1b[3;1H") + cd, ok := ei.instruction.(cursorDown) + if assert.True(t, ok, "CUP after home should emit cursorDown") { + assert.Equal(t, 2, cd.n) + } +} + func parseEscRunes(t *testing.T, ei *escapeInterpreter, runes string) { t.Helper() for _, b := range []byte(runes) { diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 77492b9b1..1e67f8535 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -834,6 +834,7 @@ func (v *View) write(p []byte) { if v.pendingNewline { advanceToNextLine() + v.ei.notifyRowAdvance() v.pendingNewline = false } @@ -855,11 +856,20 @@ func (v *View) write(p []byte) { case characterEquals(chr, '\n') || isCRLF(chr): finishLine() advanceToNextLine() + v.ei.notifyRowAdvance() case characterEquals(chr, '\r'): finishLine() v.wx = 0 + v.ei.notifyColumnReset() default: truncateLine, cells := v.parseInput(chr, width, v.wx, v.wy) + if cd, ok := v.ei.instruction.(cursorDown); ok { + v.ei.instructionRead() + for range cd.n { + v.autoRenderHyperlinksInCurrentLine() + advanceToNextLine() + } + } if cells == nil { continue } @@ -867,6 +877,17 @@ func (v *View) write(p []byte) { if truncateLine { v.lines[v.wy].cells = v.lines[v.wy].cells[:v.wx] } + // Soft-wrap tracking. truncateLine is true exactly when the + // cells are from \x1b[K filling to end of line — ConPTY + // doesn't advance the cursor for that, so we shouldn't count + // it toward wraps either. + if !truncateLine { + totalWidth := 0 + for _, c := range cells { + totalWidth += c.width + } + v.ei.notifyCellsWritten(totalWidth, v.InnerWidth()) + } } } @@ -1116,6 +1137,7 @@ func (v *View) FlushStaleCells() { func (v *View) rewind() { v.ei.reset() + v.ei.resetScreenCursor() v.SetReadPos(0, 0) v.SetWritePos(0, 0) diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 58c83e126..e8d68f1b4 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -253,10 +253,7 @@ func TestWriteCursorPositionEscape(t *testing.T) { got = append(got, cellsToStrings(l.cells)) } - /* EXPECTED: assert.Equal(t, [][]string{{"a"}, {}, {"b"}}, got) - ACTUAL: */ - assert.Equal(t, [][]string{{"a"}, {"b"}}, got) } func stringToCells(s string) []cell { From 0c0c50c3f2385f1fb59717d9c88dcf05a3f85b5f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 09:45:26 +0200 Subject: [PATCH 170/384] Demonstrate that cursor forward escapes collapse runs of spaces ConPTY compresses runs of default-colored spaces into ECH + CUF (\x1b[NX\x1b[NC) rather than emitting them literally. Both currently fall through the parser's swallow path, so the gap they describe collapses entirely and content that the child wrote with leading indentation ends up slid left against the previous cell. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gocui/view_test.go | 70 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index e8d68f1b4..b23f914cd 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -256,6 +256,76 @@ func TestWriteCursorPositionEscape(t *testing.T) { assert.Equal(t, [][]string{{"a"}, {}, {"b"}}, got) } +func TestWriteCursorPositionEscapeAcrossWrites(t *testing.T) { + // Mirrors the production flow: bufio.Scanner splits the pty output on + // LF and feeds the view one Write per line (each with a trailing \n + // appended). The parser's screen-row counter must keep ticking across + // writes; otherwise CUPs are evaluated against a stale row and + // overshoot, producing too many blank lines instead of the right + // number. + v := NewView("name", 0, 0, 30, 30, OutputNormal) + v.writeString("a\n") + v.writeString("b\n") + // 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 = append(got, cellsToStrings(l.cells)) + } + assert.Equal(t, [][]string{ + {"a"}, + {"b"}, + {"c"}, + {}, + {"d"}, + }, 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 + // N") rather than emitting them literally. The interpreter has to + // materialize CUF as N visible spaces; otherwise the gap collapses + // and content that followed the indentation slides left. + v := NewView("name", 0, 0, 20, 10, OutputNormal) + // "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 = append(got, cellsToStrings(l.cells)) + } + + /* EXPECTED: + assert.Equal(t, [][]string{{"a", " ", " ", " ", " ", " ", "b"}}, got) + ACTUAL: */ + assert.Equal(t, [][]string{{"a", "b"}}, got) +} + +func TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) { + // If a logical line is longer than ConPTY's terminal width, ConPTY + // soft-wraps it onto multiple physical rows in its screen, and any + // subsequent CUP is addressed against the post-wrap row count. To + // keep our screen-row counter accurate we have to count those wraps + // as we write the cells. InnerWidth here is 5; "abcdefghij" (10 + // cells) wraps onto 2 rows, so ConPTY is on row 3 after the LF and a + // CUP to row 4 should skip exactly one row. + v := NewView("name", 0, 0, 6, 30, OutputNormal) // Width=7, InnerWidth=5 + v.writeString("abcdefghij\n") + v.writeString("\x1b[4;1Hxyz\n") + + got := make([][]string, 0, len(v.lines)) + for _, l := range v.lines { + got = append(got, cellsToStrings(l.cells)) + } + assert.Equal(t, [][]string{ + {"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}, + {}, + {"x", "y", "z"}, + }, got) +} + func stringToCells(s string) []cell { var cells []cell state := -1 From 2fce9c91b6d74b5da0d748311d06fb17cecf6144 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 09:45:26 +0200 Subject: [PATCH 171/384] Materialize cursor-forward escapes as space runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConPTY compresses runs of default-colored spaces into ECH + CUF (\x1b[NX\x1b[NC) instead of emitting them literally. ECH is still a no-op for us — our buffer is built sequentially and has nothing to erase — but CUF has to materialize as N visible space cells so the gap actually appears, otherwise content the child wrote with leading indentation slides left against the preceding cell. The view's cursorForward branch reuses the same machinery as tab expansion: substitute the trigger byte for a space and let the repeatCount path emit the cells under the parser-tracked SGR. The existing notifyCellsWritten plumbing then advances screenCol over the gap, keeping subsequent CUP targets aligned. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/gocui/escape.go | 23 ++++++++++++++++++++--- pkg/gocui/escape_test.go | 23 +++++++++++++++++++++++ pkg/gocui/view.go | 8 ++++++++ pkg/gocui/view_test.go | 3 --- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/pkg/gocui/escape.go b/pkg/gocui/escape.go index c252fa20b..ad862a596 100644 --- a/pkg/gocui/escape.go +++ b/pkg/gocui/escape.go @@ -54,6 +54,14 @@ type cursorDown struct{ n int } func (self cursorDown) isInstruction() {} +// cursorForward asks the view to materialize N space cells. Emitted +// when CUF advances the cursor right — ConPTY uses CUF (often paired +// with ECH) to encode runs of default-colored spaces compactly, so we +// have to render the gap, not just bump a counter. +type cursorForward struct{ n int } + +func (self cursorForward) isInstruction() {} + type noInstruction struct{} func (self noInstruction) isInstruction() {} @@ -272,10 +280,11 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { ei.csiParam = append(ei.csiParam, "0") case characterEquals(ch, 'K'), characterEquals(ch, 'H'), characterEquals(ch, 'f'), characterEquals(ch, 'd'), - characterEquals(ch, 'B'), characterEquals(ch, 'E'): + characterEquals(ch, 'B'), characterEquals(ch, 'E'), + characterEquals(ch, 'C'): // fall through — let stateParams handle these with default - // params (CUP/VPA default to row 1, CUD/CNL default to advance - // by 1). + // params (CUP/VPA default to row 1, CUD/CNL/CUF default to + // advance by 1). case characterEquals(ch, ';'): // Empty first param ([;Xm ≡ [0;Xm). Seed a slot for the // empty param; stateParams will append the next one when it @@ -376,6 +385,14 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { ei.state = stateNone ei.csiParam = nil return true, nil + case characterEquals(ch, 'C'): + // CUF — cursor forward N. Emit space cells so the gap + // renders. (screenCol is updated by the view via + // notifyCellsWritten as those spaces are emitted.) + ei.instruction = cursorForward{n: ei.firstParamOrDefault(1)} + ei.state = stateNone + ei.csiParam = nil + return true, nil case len(ch) == 1 && ch[0] >= 0x20 && ch[0] <= 0x2F: // CSI intermediate byte after params. The final byte will // have a semantic we don't implement (e.g. `[0 q` = diff --git a/pkg/gocui/escape_test.go b/pkg/gocui/escape_test.go index a7e8ce02f..39ccbe908 100644 --- a/pkg/gocui/escape_test.go +++ b/pkg/gocui/escape_test.go @@ -243,6 +243,29 @@ func TestParseOneCursorHomeReanchors(t *testing.T) { } } +func TestParseOneCursorForward(t *testing.T) { + // CUF (\x1b[NC) emits a cursorForward instruction so the view can + // materialize the N-cell gap as spaces. ConPTY uses this (often + // paired with ECH) to encode runs of default-colored spaces. + scenarios := []struct { + input string + wantN int + }{ + {"\x1b[5C", 5}, + {"\x1b[1C", 1}, + {"\x1b[C", 1}, // no param defaults to 1 + } + + for _, s := range scenarios { + ei := newEscapeInterpreter(OutputNormal) + parseEscRunes(t, ei, s.input) + cf, ok := ei.instruction.(cursorForward) + if assert.True(t, ok, "input %q should emit cursorForward", s.input) { + assert.Equal(t, s.wantN, cf.n, "input %q", s.input) + } + } +} + func parseEscRunes(t *testing.T, ei *escapeInterpreter, runes string) { t.Helper() for _, b := range []byte(runes) { diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 1e67f8535..d93f84954 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -1003,6 +1003,14 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) { bg: v.ei.curBgColor, } return truncateLine, []cell{} + } else if cf, ok := v.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() + repeatCount = cf.n + ch = []byte{' '} + width = 1 } else if isEscape { // do not output anything return truncateLine, nil diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index b23f914cd..f7e229f1c 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -297,10 +297,7 @@ func TestWriteCursorForwardEscape(t *testing.T) { got = append(got, cellsToStrings(l.cells)) } - /* EXPECTED: assert.Equal(t, [][]string{{"a", " ", " ", " ", " ", " ", "b"}}, got) - ACTUAL: */ - assert.Equal(t, [][]string{{"a", "b"}}, got) } func TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) { From 20af6ad23cbdbaf525b62ed2e772843653553f42 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 09:45:26 +0200 Subject: [PATCH 172/384] Remove the Windows limitation from Custom_Pagers.md --- docs-master/Custom_Pagers.md | 29 ----------------------------- 1 file changed, 29 deletions(-) diff --git a/docs-master/Custom_Pagers.md b/docs-master/Custom_Pagers.md index 0bfffe7dc..1b37766d0 100644 --- a/docs-master/Custom_Pagers.md +++ b/docs-master/Custom_Pagers.md @@ -2,8 +2,6 @@ Lazygit supports custom pagers, [configured](/docs/Config.md) in the config.yml file (which can be opened by pressing `e` in the Status panel). -Support does not extend to Windows users, because we're making use of a package which doesn't have Windows support. However, see [below](#emulating-custom-pagers-on-windows) for a workaround. - 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): @@ -92,30 +90,3 @@ git: 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. - -## Emulating custom pagers on Windows - -There is a trick to emulate custom pagers on Windows using a Powershell script configured as an external diff command. It's not perfect, but certainly better than nothing. To do this, save the following script as `lazygit-pager.ps1` at a convenient place on your disk: - -```pwsh -#!/usr/bin/env pwsh - -$old = $args[1].Replace('\', '/') -$new = $args[4].Replace('\', '/') -$path = $args[0] -git diff --no-index --no-ext-diff $old $new - | %{ $_.Replace($old, $path).Replace($new, $path) } - | delta --width=$env:LAZYGIT_COLUMNS -``` - -Use the pager of your choice with the arguments you like in the last line of the script. Personally I wouldn't want to use lazygit anymore without delta's `--hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"` args, see [above](#delta). - -In your lazygit config, use - -```yml -git: - pagers: - - externalDiffCommand: "C:/wherever/lazygit-pager.ps1" -``` - -The main limitation of this approach compared to a "real" pager is that renames are not displayed correctly; they are shown as if they were modifications of the old file. (This affects only the hunk headers; the diff itself is always correct.) From ef73406a966a83a486b4344f5e79de06e47ab3b1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 29 Jun 2026 07:54:02 +0200 Subject: [PATCH 173/384] Add worktree.defaultPath config The redesigned worktree-creation flow never asks the user to type a path from scratch; instead it offers candidate parent directories. Until a repo has any linked worktrees to learn from, there's nothing to offer, so let users seed that list with a configured default location. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-master/Config.md | 8 ++++++++ pkg/config/user_config.go | 11 +++++++++++ schema-master/config.json | 15 +++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/docs-master/Config.md b/docs-master/Config.md index 5e57df34a..183a0c6b2 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -527,6 +527,14 @@ git: # to 40 to disable truncation. truncateCopiedCommitHashesTo: 12 +# Config relating to git worktrees +worktree: + # Default parent directory for new worktrees. It is offered as a candidate + # location alongside the parent directories of any worktrees you already have. + # A relative path is resolved against the repository's root directory, so + # "../worktrees" sits beside the repo and ".worktrees" sits inside it. + defaultPath: "" + # Periodic update checks update: # One of: 'prompt' (default) | 'background' | 'never' diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 1b582c1a0..844ef32bd 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -11,6 +11,8 @@ type UserConfig struct { Gui GuiConfig `yaml:"gui"` // Config relating to git Git GitConfig `yaml:"git"` + // Config relating to git worktrees + Worktree WorktreeConfig `yaml:"worktree"` // Periodic update checks Update UpdateConfig `yaml:"update"` // Background refreshes @@ -422,6 +424,12 @@ type CommitPrefixConfig struct { Replace string `yaml:"replace" jsonschema:"example=[$1]"` } +type WorktreeConfig struct { + // Default parent directory for new worktrees. It is offered as a candidate location alongside the parent directories of any worktrees you already have. + // A relative path is resolved against the repository's root directory, so "../worktrees" sits beside the repo and ".worktrees" sits inside it. + DefaultPath string `yaml:"defaultPath"` +} + type UpdateConfig struct { // One of: 'prompt' (default) | 'background' | 'never' Method string `yaml:"method" jsonschema:"enum=prompt,enum=background,enum=never"` @@ -959,6 +967,9 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { ParseEmoji: false, TruncateCopiedCommitHashesTo: 12, }, + Worktree: WorktreeConfig{ + DefaultPath: "", + }, Refresher: RefresherConfig{ RefreshInterval: 10, FetchInterval: 60, diff --git a/schema-master/config.json b/schema-master/config.json index 85246d640..9963aae61 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -3834,6 +3834,10 @@ "$ref": "#/$defs/GitConfig", "description": "Config relating to git" }, + "worktree": { + "$ref": "#/$defs/WorktreeConfig", + "description": "Config relating to git worktrees" + }, "update": { "$ref": "#/$defs/UpdateConfig", "description": "Periodic update checks" @@ -3899,6 +3903,17 @@ }, "additionalProperties": false, "type": "object" + }, + "WorktreeConfig": { + "properties": { + "defaultPath": { + "type": "string", + "description": "Default parent directory for new worktrees. It is offered as a candidate location alongside the parent directories of any worktrees you already have.\nA relative path is resolved against the repository's root directory, so \"../worktrees\" sits beside the repo and \".worktrees\" sits inside it." + } + }, + "additionalProperties": false, + "type": "object", + "description": "Config relating to git worktrees" } } } From b02eca451c876e7c58b7ab8a3a8aa06386cc8630 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 29 Jun 2026 08:13:26 +0200 Subject: [PATCH 174/384] Add helper to compute candidate worktree parent directories This is the core of "never type a path from scratch": from the repo root, the configured default path, and the parents of existing worktrees, derive the ordered list of directories under which a new worktree could be placed. Pure and unit-tested here; wired into the creation flow in a later commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/worktree_helper.go | 34 +++++++ .../helpers/worktree_helper_test.go | 94 +++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 pkg/gui/controllers/helpers/worktree_helper_test.go diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 6cb22084b..5f5933746 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -2,6 +2,7 @@ package helpers import ( "errors" + "path/filepath" "strings" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -10,6 +11,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" ) type WorktreeHelper struct { @@ -219,6 +221,38 @@ func (self *WorktreeHelper) Detach(worktree *models.Worktree) error { }) } +// worktreeParentDirCandidates returns the candidate parent directories in which +// to create a new worktree, in priority order and de-duplicated: +// +// 1. the parent directory of each existing linked worktree (in worktree order); +// 2. the configured default path (relative paths are resolved against repoPath); +// 3. the repo's parent directory, if nothing else is available. +// +// repoPath is RepoPaths.RepoPath(), which is stable regardless of which worktree +// we're currently standing in. All returned paths are absolute. +func worktreeParentDirCandidates(repoPath string, linkedWorktreePaths []string, defaultPath string) []string { + candidates := lo.Map(linkedWorktreePaths, func(path string, _ int) string { + return filepath.Dir(path) + }) + + if defaultPath != "" { + if filepath.IsAbs(defaultPath) { + defaultPath = filepath.Clean(defaultPath) + } else { + defaultPath = filepath.Join(repoPath, defaultPath) + } + candidates = append(candidates, defaultPath) + } + + candidates = lo.Uniq(candidates) + + if len(candidates) == 0 { + candidates = append(candidates, filepath.Dir(repoPath)) + } + + return candidates +} + func (self *WorktreeHelper) ViewWorktreeOptions(context types.IListContext, ref string) error { currentBranch := self.refsHelper.GetCheckedOutRef() canCheckoutBase := context == self.c.Contexts().Branches && ref != currentBranch.RefName() diff --git a/pkg/gui/controllers/helpers/worktree_helper_test.go b/pkg/gui/controllers/helpers/worktree_helper_test.go new file mode 100644 index 000000000..ea975d79d --- /dev/null +++ b/pkg/gui/controllers/helpers/worktree_helper_test.go @@ -0,0 +1,94 @@ +package helpers + +import ( + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" +) + +// nativePath rewrites a forward-slash test path into one that is valid on the +// host OS, so the scenarios below can be written with readable Unix-style +// paths. On Windows a leading slash is not absolute (filepath.IsAbs wants a +// drive letter), so we graft one on; relative paths are left untouched. +func nativePath(p string) string { + if runtime.GOOS == "windows" && strings.HasPrefix(p, "/") { + p = "C:" + p + } + return filepath.FromSlash(p) +} + +func TestWorktreeParentDirCandidates(t *testing.T) { + scenarios := []struct { + name string + repoPath string + linkedWorktreePaths []string + defaultPath string + expected []string + }{ + { + name: "no worktrees and no default path falls back to the repo's parent", + repoPath: "/code/myrepo", + linkedWorktreePaths: nil, + defaultPath: "", + expected: []string{"/code"}, + }, + { + name: "uses the parent of each linked worktree, in order", + repoPath: "/code/myrepo", + linkedWorktreePaths: []string{"/code/worktrees/foo", "/elsewhere/bar"}, + defaultPath: "", + expected: []string{"/code/worktrees", "/elsewhere"}, + }, + { + name: "de-duplicates parents shared by multiple worktrees", + repoPath: "/code/myrepo", + linkedWorktreePaths: []string{"/code/worktrees/foo", "/code/worktrees/bar"}, + defaultPath: "", + expected: []string{"/code/worktrees"}, + }, + { + name: "appends the default path after the worktree parents", + repoPath: "/code/myrepo", + linkedWorktreePaths: []string{"/code/worktrees/foo"}, + defaultPath: "/somewhere/else", + expected: []string{"/code/worktrees", "/somewhere/else"}, + }, + { + name: "resolves a relative default path against the repo path", + repoPath: "/code/myrepo", + linkedWorktreePaths: nil, + defaultPath: "../worktrees", + expected: []string{"/code/worktrees"}, + }, + { + name: "resolves a dot-relative default path inside the repo", + repoPath: "/code/myrepo", + linkedWorktreePaths: nil, + defaultPath: ".worktrees", + expected: []string{"/code/myrepo/.worktrees"}, + }, + { + name: "de-duplicates the default path against a worktree parent", + repoPath: "/code/myrepo", + linkedWorktreePaths: []string{"/code/worktrees/foo"}, + defaultPath: "/code/worktrees", + expected: []string{"/code/worktrees"}, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + result := worktreeParentDirCandidates( + nativePath(s.repoPath), + lo.Map(s.linkedWorktreePaths, func(p string, _ int) string { return nativePath(p) }), + nativePath(s.defaultPath), + ) + expected := lo.Map(s.expected, func(p string, _ int) string { return nativePath(p) }) + assert.Equal(t, expected, result) + }) + } +} From 23d01ac1bd1a0f61b63f1b1f642081a6cb08f0fa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 29 Jun 2026 08:40:22 +0200 Subject: [PATCH 175/384] Redesign the 'w' worktree-creation flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old flow forced an up-front "normal vs detached" menu (meaningless for commits, tags and stashes), then asked the user to type a worktree path from scratch — easy to get wrong, and ambiguous about what relative paths resolve against. It also offered the same two actions everywhere regardless of what was selected. Replace it with per-context "Worktree" menus whose items imply the intent (new branch + worktree, worktree for an existing branch, detached worktree), each feeding a shared name -> location -> create pipeline. The location menu offers candidate parent directories as absolute paths instead of a blank field, and "Worktree for a branch" is disabled (with a reason) when that branch is already checked out somewhere, rather than failing after the fact. Each ref/commit panel binds 'w' in its own controller and calls the matching typed entry point on the worktree helper, so which menu opens is decided statically by the call site rather than by dispatching on a ref's dynamic type. The three commit panels share one menu through BasicCommitsController; there is no longer a shared worktree-options controller. The worktrees-panel 'n' flow and its old core (NewWorktree / NewWorktreeCheckout) are left untouched here so nothing is written and then rewritten; they migrate, and the dead i18n strings get removed, in a follow-up commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-master/keybindings/Keybindings_en.md | 14 +- docs-master/keybindings/Keybindings_ja.md | 14 +- docs-master/keybindings/Keybindings_ko.md | 14 +- docs-master/keybindings/Keybindings_nl.md | 14 +- docs-master/keybindings/Keybindings_pl.md | 14 +- docs-master/keybindings/Keybindings_pt.md | 14 +- docs-master/keybindings/Keybindings_ru.md | 14 +- docs-master/keybindings/Keybindings_zh-CN.md | 14 +- docs-master/keybindings/Keybindings_zh-TW.md | 14 +- pkg/gui/controllers.go | 12 - .../controllers/basic_commits_controller.go | 6 + pkg/gui/controllers/branches_controller.go | 6 + .../controllers/helpers/worktree_helper.go | 231 ++++++++++++++++-- .../controllers/remote_branches_controller.go | 6 + pkg/gui/controllers/stash_controller.go | 6 + pkg/gui/controllers/tags_controller.go | 6 + .../worktree_options_controller.go | 51 ---- pkg/i18n/english.go | 26 +- .../demo/worktree_create_from_branches.go | 20 +- pkg/integration/tests/test_list.go | 5 + .../tests/worktree/add_for_existing_branch.go | 67 +++++ .../tests/worktree/add_from_branch.go | 23 +- .../worktree/add_from_branch_detached.go | 15 +- .../tests/worktree/add_from_commit.go | 17 +- .../tests/worktree/add_from_remote_branch.go | 61 +++++ .../tests/worktree/add_from_stash.go | 51 ++++ .../tests/worktree/add_from_tag.go | 54 ++++ .../tests/worktree/location_candidates.go | 52 ++++ 28 files changed, 662 insertions(+), 179 deletions(-) delete mode 100644 pkg/gui/controllers/worktree_options_controller.go create mode 100644 pkg/integration/tests/worktree/add_for_existing_branch.go create mode 100644 pkg/integration/tests/worktree/add_from_remote_branch.go create mode 100644 pkg/integration/tests/worktree/add_from_stash.go create mode 100644 pkg/integration/tests/worktree/add_from_tag.go create mode 100644 pkg/integration/tests/worktree/location_candidates.go diff --git a/docs-master/keybindings/Keybindings_en.md b/docs-master/keybindings/Keybindings_en.md index b105b8108..4aa202740 100644 --- a/docs-master/keybindings/Keybindings_en.md +++ b/docs-master/keybindings/Keybindings_en.md @@ -112,13 +112,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Open commit in browser | | | `` n `` | Create new branch off of commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Copy (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View files | | -| `` w `` | View worktree options | | | `` / `` | Search the current view by text | | ## Confirmation panel @@ -178,6 +178,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Checkout | Checkout selected item. | | `` n `` | New branch | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` o `` | Create pull request | | | `` O `` | View create pull request options | | | `` G `` | Open pull request in browser | | @@ -197,7 +198,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Main panel (merging) @@ -282,6 +282,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Open commit in browser | | | `` n `` | Create new branch off of commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Copy (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset copied (cherry-picked) commits selection | | @@ -289,7 +290,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Remote branches @@ -299,6 +299,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copy branch name to clipboard | | | `` `` | Checkout | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | New branch | | +| `` w `` | New worktree | | | `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. | | `` d `` | Delete | Delete the remote branch from the remote. | @@ -308,7 +309,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Remotes @@ -339,10 +339,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Pop | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | Drop | Remove the stash entry from the stash list. | | `` n `` | New branch | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | +| `` w `` | New worktree | | | `` r `` | Rename stash | | | `` 0 `` | Focus main view | | | `` `` | View files | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Status @@ -366,6 +366,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Open commit in browser | | | `` n `` | Create new branch off of commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Copy (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset copied (cherry-picked) commits selection | | @@ -373,7 +374,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View files | | -| `` w `` | View worktree options | | | `` / `` | Search the current view by text | | ## Submodules @@ -397,13 +397,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copy tag to clipboard | | | `` `` | Checkout | Checkout the selected tag as a detached HEAD. | | `` n `` | New tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | +| `` w `` | New worktree | | | `` d `` | Delete | 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. | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Worktrees diff --git a/docs-master/keybindings/Keybindings_ja.md b/docs-master/keybindings/Keybindings_ja.md index a31d93d1a..5b9c798a1 100644 --- a/docs-master/keybindings/Keybindings_ja.md +++ b/docs-master/keybindings/Keybindings_ja.md @@ -92,13 +92,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | ブラウザでコミットを開く | | | `` n `` | コミットから新しいブランチを作成 | | | `` N `` | コミットを新しいブランチに移動 | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | 新しいワークツリー | | | `` g `` | リセット | 選択した項目へのリセットオプション(ソフト/ミックス/ハード)を表示します。各リセットタイプの詳細は次の通りです:
- ソフトリセット:変更を保持し、ステージされた状態にします
- ミックスリセット:変更を保持し、ステージされていない状態にします
- ハードリセット:すべての変更を破棄します | | `` C `` | コピー(チェリーピック) | コミットをコピーとしてマークします。ローカルコミットビューで `V` を押すと、コピーしたコミットをチェックアウトしたブランチにペースト(チェリーピック)できます。いつでも `` を押して選択をキャンセルできます。 | | `` `` | 外部差分ツールを開く(git difftool) | | | `` * `` | 現在のブランチのコミットを選択 | | | `` 0 `` | メインビューにフォーカス | | | `` `` | ファイルを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストで検索 | | ## コミットファイル @@ -138,6 +138,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | ブラウザでコミットを開く | | | `` n `` | コミットから新しいブランチを作成 | | | `` N `` | コミットを新しいブランチに移動 | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | 新しいワークツリー | | | `` g `` | リセット | 選択した項目へのリセットオプション(ソフト/ミックス/ハード)を表示します。各リセットタイプの詳細は次の通りです:
- ソフトリセット:変更を保持し、ステージされた状態にします
- ミックスリセット:変更を保持し、ステージされていない状態にします
- ハードリセット:すべての変更を破棄します | | `` C `` | コピー(チェリーピック) | コミットをコピーとしてマークします。ローカルコミットビューで `V` を押すと、コピーしたコミットをチェックアウトしたブランチにペースト(チェリーピック)できます。いつでも `` を押して選択をキャンセルできます。 | | `` `` | コピーされた(チェリーピックされた)コミットの選択をリセット | | @@ -145,7 +146,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | 現在のブランチのコミットを選択 | | | `` 0 `` | メインビューにフォーカス | | | `` `` | ファイルを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストで検索 | | ## サブモジュール @@ -170,10 +170,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | ポップ | スタッシュエントリをワーキングディレクトリに適用し、スタッシュエントリを削除します。 | | `` d `` | 削除 | スタッシュリストからスタッシュエントリを削除します。 | | `` n `` | 新しいブランチ | 選択したスタッシュエントリから新しいブランチを作成します。これは、スタッシュエントリが作成されたコミットをgitがチェックアウトし、そのコミットから新しいブランチを作成した後、スタッシュエントリを追加のコミットとして新しいブランチに適用することで機能します。 | +| `` w `` | 新しいワークツリー | | | `` r `` | スタッシュの名前を変更 | | | `` 0 `` | メインビューにフォーカス | | | `` `` | ファイルを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストでフィルタリング | | ## ステータス @@ -202,13 +202,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | タグをクリップボードにコピー | | | `` `` | チェックアウト(ブランチの切り替え) | 選択したタグをデタッチドHEADとしてチェックアウトします。 | | `` n `` | 新しいタグを作成 | 現在のコミットから新しいタグを作成します。タグ名とオプションの説明を入力するよう促されます。 | +| `` w `` | 新しいワークツリー | | | `` d `` | 削除 | ローカル/リモートタグの削除オプションを表示します。 | | `` P `` | タグをプッシュ | 選択したタグをリモートにプッシュします。リモートを選択するよう促されます。 | | `` g `` | リセット | 選択した項目へのリセットオプション(ソフト/ミックス/ハード)を表示します。各リセットタイプの詳細は次の通りです:
- ソフトリセット:変更を保持し、ステージされた状態にします
- ミックスリセット:変更を保持し、ステージされていない状態にします
- ハードリセット:すべての変更を破棄します | | `` `` | 外部差分ツールを開く(git difftool) | | | `` 0 `` | メインビューにフォーカス | | | `` `` | コミットを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストでフィルタリング | | ## ファイル @@ -326,6 +326,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | ブラウザでコミットを開く | | | `` n `` | コミットから新しいブランチを作成 | | | `` N `` | コミットを新しいブランチに移動 | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | 新しいワークツリー | | | `` g `` | リセット | 選択した項目へのリセットオプション(ソフト/ミックス/ハード)を表示します。各リセットタイプの詳細は次の通りです:
- ソフトリセット:変更を保持し、ステージされた状態にします
- ミックスリセット:変更を保持し、ステージされていない状態にします
- ハードリセット:すべての変更を破棄します | | `` C `` | コピー(チェリーピック) | コミットをコピーとしてマークします。ローカルコミットビューで `V` を押すと、コピーしたコミットをチェックアウトしたブランチにペースト(チェリーピック)できます。いつでも `` を押して選択をキャンセルできます。 | | `` `` | コピーされた(チェリーピックされた)コミットの選択をリセット | | @@ -333,7 +334,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | 現在のブランチのコミットを選択 | | | `` 0 `` | メインビューにフォーカス | | | `` `` | コミットを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストでフィルタリング | | ## リモート @@ -355,6 +355,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | ブランチ名をクリップボードにコピー | | | `` `` | チェックアウト(ブランチの切り替え) | 選択したリモートブランチに基づいて新しいローカルブランチをチェックアウトするか、リモートブランチをデタッチドヘッドとしてチェックアウトします。 | | `` n `` | 新しいブランチ | | +| `` w `` | 新しいワークツリー | | | `` M `` | マージ | 選択した項目を現在のブランチにマージするためのオプションを表示します(通常のマージ、スカッシュマージ) | | `` r `` | リベース | チェックアウトしたブランチを選択したブランチ上にリベースします。 | | `` d `` | 削除 | リモートからリモートブランチを削除します。 | @@ -364,7 +365,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 外部差分ツールを開く(git difftool) | | | `` 0 `` | メインビューにフォーカス | | | `` `` | コミットを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストでフィルタリング | | ## ローカルブランチ @@ -376,6 +376,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | チェックアウト(ブランチの切り替え) | 選択した項目をチェックアウトします。 | | `` n `` | 新しいブランチ | | | `` N `` | コミットを新しいブランチに移動 | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | 新しいワークツリー | | | `` o `` | プルリクエストを作成 | | | `` O `` | プルリクエスト作成オプションを表示 | | | `` G `` | Open pull request in browser | | @@ -395,7 +396,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 外部差分ツールを開く(git difftool) | | | `` 0 `` | メインビューにフォーカス | | | `` `` | コミットを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストでフィルタリング | | ## ワークツリー diff --git a/docs-master/keybindings/Keybindings_ko.md b/docs-master/keybindings/Keybindings_ko.md index ebf943868..d1eb3afb1 100644 --- a/docs-master/keybindings/Keybindings_ko.md +++ b/docs-master/keybindings/Keybindings_ko.md @@ -69,6 +69,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 브라우저에서 커밋 열기 | | | `` n `` | 커밋에서 새 브랜치를 만듭니다. | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 커밋을 복사 (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (copied) commits selection | | @@ -76,7 +77,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Secondary @@ -95,10 +95,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Pop | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | Drop | Remove the stash entry from the stash list. | | `` n `` | 새 브랜치 생성 | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | +| `` w `` | New worktree | | | `` r `` | Rename stash | | | `` 0 `` | Focus main view | | | `` `` | View selected item's files | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Sub-commits @@ -111,6 +111,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 브라우저에서 커밋 열기 | | | `` n `` | 커밋에서 새 브랜치를 만듭니다. | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 커밋을 복사 (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (copied) commits selection | | @@ -118,7 +119,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View selected item's files | | -| `` w `` | View worktree options | | | `` / `` | 검색 시작 | | ## Worktrees @@ -212,6 +212,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 체크아웃 | Checkout selected item. | | `` n `` | 새 브랜치 생성 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` o `` | 풀 리퀘스트 생성 | | | `` O `` | 풀 리퀘스트 생성 옵션 | | | `` G `` | Open pull request in browser | | @@ -231,7 +232,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## 상태 @@ -278,6 +278,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 브랜치명을 클립보드에 복사 | | | `` `` | 체크아웃 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | 새 브랜치 생성 | | +| `` w `` | New worktree | | | `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. | | `` d `` | 삭제 | Delete the remote branch from the remote. | @@ -287,7 +288,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## 커밋 @@ -323,13 +323,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 브라우저에서 커밋 열기 | | | `` n `` | 커밋에서 새 브랜치를 만듭니다. | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 커밋을 복사 (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View selected item's files | | -| `` w `` | View worktree options | | | `` / `` | 검색 시작 | | ## 커밋 파일 @@ -366,13 +366,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copy tag to clipboard | | | `` `` | 체크아웃 | Checkout the selected tag as a detached HEAD. | | `` n `` | 태그를 생성 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | +| `` w `` | New worktree | | | `` d `` | 삭제 | View delete options for local/remote tag. | | `` P `` | 태그를 push | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | 초기화 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## 파일 diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index a7d7b09ac..7f1216b5f 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -102,6 +102,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Uitchecken | Checkout selected item. | | `` n `` | Nieuwe branch | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` o `` | Maak een pull-request | | | `` O `` | Bekijk opties voor pull-aanvraag | | | `` G `` | Open pull request in browser | | @@ -121,7 +122,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Commit bericht @@ -184,13 +184,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Open commit in browser | | | `` n `` | Creëer nieuwe branch van commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | -| `` w `` | View worktree options | | | `` / `` | Start met zoeken | | ## Input prompt @@ -260,6 +260,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Open commit in browser | | | `` n `` | Creëer nieuwe branch van commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (gekopieerde) commits selectie | | @@ -267,7 +268,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Remote branches @@ -277,6 +277,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Kopieer branch name naar klembord | | | `` `` | Uitchecken | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | Nieuwe branch | | +| `` w `` | New worktree | | | `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | Rebase branch | Rebase the checked-out branch onto the selected branch. | | `` d `` | Delete | Delete the remote branch from the remote. | @@ -286,7 +287,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Remotes @@ -339,10 +339,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Pop | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | Laten vallen | Remove the stash entry from the stash list. | | `` n `` | Nieuwe branch | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | +| `` w `` | New worktree | | | `` r `` | Rename stash | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Status @@ -366,6 +366,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Open commit in browser | | | `` n `` | Creëer nieuwe branch van commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (gekopieerde) commits selectie | | @@ -373,7 +374,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | -| `` w `` | View worktree options | | | `` / `` | Start met zoeken | | ## Submodules @@ -397,13 +397,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copy tag to clipboard | | | `` `` | Uitchecken | Checkout the selected tag as a detached HEAD. | | `` n `` | Creëer tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | +| `` w `` | New worktree | | | `` d `` | Delete | 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. | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Worktrees diff --git a/docs-master/keybindings/Keybindings_pl.md b/docs-master/keybindings/Keybindings_pl.md index 1f6710416..719a7e6c4 100644 --- a/docs-master/keybindings/Keybindings_pl.md +++ b/docs-master/keybindings/Keybindings_pl.md @@ -85,13 +85,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Otwórz commit w przeglądarce | | | `` n `` | Utwórz nową gałąź z commita | | | `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nowe drzewo pracy | | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Wyświetl pliki | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Dodatkowy @@ -122,6 +122,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Otwórz commit w przeglądarce | | | `` n `` | Utwórz nową gałąź z commita | | | `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nowe drzewo pracy | | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | | `` `` | Resetuj wybrane (cherry-picked) commity | | @@ -129,7 +130,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | ## Główny panel (budowanie łatki) @@ -164,6 +164,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Przełącz | Przełącz wybrany element. | | `` n `` | Nowa gałąź | | | `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nowe drzewo pracy | | | `` o `` | Utwórz żądanie ściągnięcia | | | `` O `` | Zobacz opcje tworzenia pull requesta | | | `` G `` | Otwórz żądanie ściągnięcia w przeglądarce | | @@ -183,7 +184,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | ## Menu @@ -318,10 +318,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Wyciągnij | Zastosuj wpis schowka do katalogu roboczego i usuń wpis schowka. | | `` d `` | Usuń | Usuń wpis schowka z listy schowka. | | `` n `` | Nowa gałąź | Utwórz nową gałąź z wybranego wpisu schowka. Działa poprzez przełączenie git na commit, na którym wpis schowka został utworzony, tworzenie nowej gałęzi z tego commita, a następnie zastosowanie wpisu schowka do nowej gałęzi jako dodatkowego commita. | +| `` w `` | Nowe drzewo pracy | | | `` r `` | Zmień nazwę schowka | | | `` 0 `` | Focus main view | | | `` `` | Wyświetl pliki | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | ## Status @@ -345,6 +345,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Otwórz commit w przeglądarce | | | `` n `` | Utwórz nową gałąź z commita | | | `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nowe drzewo pracy | | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | | `` `` | Resetuj wybrane (cherry-picked) commity | | @@ -352,7 +353,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Wyświetl pliki | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Submoduły @@ -376,13 +376,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Skopiuj tag do schowka | | | `` `` | Przełącz | Przełącz wybrany tag jako odłączoną głowę (detached HEAD). | | `` n `` | Nowy tag | Utwórz nowy tag z bieżącego commita. Zostaniesz poproszony o wprowadzenie nazwy tagu i opcjonalnego opisu. | +| `` w `` | Nowe drzewo pracy | | | `` d `` | Usuń | Wyświetl opcje usuwania lokalnego/odległego tagu. | | `` P `` | Wyślij tag | Wyślij wybrany tag do zdalnego. Zostaniesz poproszony o wybranie zdalnego. | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | ## Zdalne @@ -404,6 +404,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Kopiuj nazwę gałęzi do schowka | | | `` `` | Przełącz | Przełącz na nową lokalną gałąź na podstawie wybranej gałęzi zdalnej. Nowa gałąź będzie śledzić gałąź zdalną. | | `` n `` | Nowa gałąź | | +| `` w `` | Nowe drzewo pracy | | | `` M `` | Scal | Scal wybraną gałąź z aktualnie sprawdzoną gałęzią. | | `` r `` | Przebazuj | Przebazuj przełączoną gałąź na wybraną gałąź. | | `` d `` | Usuń | Usuń gałąź zdalną ze zdalnego. | @@ -413,5 +414,4 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | diff --git a/docs-master/keybindings/Keybindings_pt.md b/docs-master/keybindings/Keybindings_pt.md index a2d163735..5fcba2688 100644 --- a/docs-master/keybindings/Keybindings_pt.md +++ b/docs-master/keybindings/Keybindings_pt.md @@ -94,6 +94,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Verificar | Checar item selecionado | | `` n `` | Nova branch | | | `` N `` | Mover commits para uma nova branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nova árvore de trabalho | | | `` o `` | Criar solicitação de pull | | | `` O `` | View create pull request options | | | `` G `` | Open pull request in browser | | @@ -113,7 +114,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver commits | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Filtrar a visualização atual por texto | | ## Branches remotos @@ -123,6 +123,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copiar nome da branch para área de transferência | | | `` `` | Verificar | Checar a nova branch baseada na brach remota selecionada, ou a branch remota como HEAD, desanexado | | `` n `` | Nova branch | | +| `` w `` | Nova árvore de trabalho | | | `` M `` | Mesclar | Ver opções para mesclar o item selecionado no branch atual (mesclar regularmente, mesclar squash) | | `` r `` | Refazer | Refazer a branch checada na branch selecionada | | `` d `` | Apagar | Excluir o branch remoto do controle remoto. | @@ -132,7 +133,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver commits | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Filtrar a visualização atual por texto | | ## Commit arquivos @@ -188,13 +188,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Abrir commit no navegador | | | `` n `` | Create new branch off of commit | | | `` N `` | Mover commits para uma nova branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nova árvore de trabalho | | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` C `` | Copiar (cherry-pick) | Marcar commit como copiado. Então, dentro da visualização local de commits, você pode pressionar `V` para colar (cherry-pick) o(s) commit(s) copiado(s) em seu branch de check-out. A qualquer momento você pode pressionar `` para cancelar a seleção. | | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver arquivos | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Pesquisar na visualização atual por texto | | ## Etiquetas @@ -204,13 +204,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copiar etiqueta para área de transferência | | | `` `` | Verificar | Checar a tag selecionada como um HEAD, desanexado | | `` n `` | Nova etiqueta | Crie uma nova etiqueta a partir do commit atual. Você será solicitado a digitar um nome e uma descrição opcional. | +| `` w `` | Nova árvore de trabalho | | | `` d `` | Apagar | Ver opções de exclusão para tag local/remoto. | | `` P `` | Empurrar etiqueta | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver commits | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Filtrar a visualização atual por texto | | ## Input prompt @@ -310,6 +310,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Abrir commit no navegador | | | `` n `` | Create new branch off of commit | | | `` N `` | Mover commits para uma nova branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nova árvore de trabalho | | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` C `` | Copiar (cherry-pick) | Marcar commit como copiado. Então, dentro da visualização local de commits, você pode pressionar `V` para colar (cherry-pick) o(s) commit(s) copiado(s) em seu branch de check-out. A qualquer momento você pode pressionar `` para cancelar a seleção. | | `` `` | Reset copied (cherry-picked) commits selection | | @@ -317,7 +318,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver commits | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Filtrar a visualização atual por texto | | ## Remotes @@ -348,10 +348,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Pop | Aplique a entrada de stash no seu diretório de trabalho e remova a entrada de stash. | | `` d `` | Descartar | Remova a entrada do stash da lista de armazenamento. | | `` n `` | Nova branch | Criar um novo ramo a partir da entrada de lixo selecionada. Isso funciona verificando o commit do qual a entrada de lixo foi criada, criar um novo branch a partir desse commit e, em seguida, aplicar a entrada de lixo ao novo branch como um commit adicional. | +| `` w `` | Nova árvore de trabalho | | | `` r `` | Renomear o stash | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver arquivos | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Filtrar a visualização atual por texto | | ## Status @@ -375,6 +375,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Abrir commit no navegador | | | `` n `` | Create new branch off of commit | | | `` N `` | Mover commits para uma nova branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nova árvore de trabalho | | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` C `` | Copiar (cherry-pick) | Marcar commit como copiado. Então, dentro da visualização local de commits, você pode pressionar `V` para colar (cherry-pick) o(s) commit(s) copiado(s) em seu branch de check-out. A qualquer momento você pode pressionar `` para cancelar a seleção. | | `` `` | Reset copied (cherry-picked) commits selection | | @@ -382,7 +383,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver arquivos | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Pesquisar na visualização atual por texto | | ## Submódulos diff --git a/docs-master/keybindings/Keybindings_ru.md b/docs-master/keybindings/Keybindings_ru.md index 68be4a601..683135448 100644 --- a/docs-master/keybindings/Keybindings_ru.md +++ b/docs-master/keybindings/Keybindings_ru.md @@ -151,6 +151,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Открыть коммит в браузере | | | `` n `` | Создать новую ветку с этого коммита | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Скопировать отобранные коммит (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Сбросить отобранную (скопированную \| cherry-picked) выборку коммитов | | @@ -158,7 +159,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Коммиты @@ -194,13 +194,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Открыть коммит в браузере | | | `` n `` | Создать новую ветку с этого коммита | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Скопировать отобранные коммит (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть файлы выбранного элемента | | -| `` w `` | View worktree options | | | `` / `` | Найти | | ## Локальные Ветки @@ -212,6 +212,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Переключить | Checkout selected item. | | `` n `` | Новая ветка | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` o `` | Создать запрос на принятие изменений | | | `` O `` | Создать параметры запроса принятие изменений | | | `` G `` | Open pull request in browser | | @@ -231,7 +232,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Меню @@ -260,6 +260,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Открыть коммит в браузере | | | `` n `` | Создать новую ветку с этого коммита | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Скопировать отобранные коммит (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Сбросить отобранную (скопированную \| cherry-picked) выборку коммитов | | @@ -267,7 +268,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть файлы выбранного элемента | | -| `` w `` | View worktree options | | | `` / `` | Найти | | ## Подмодули @@ -329,13 +329,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copy tag to clipboard | | | `` `` | Переключить | Checkout the selected tag as a detached HEAD. | | `` n `` | Создать тег | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | +| `` w `` | New worktree | | | `` d `` | Delete | View delete options for local/remote tag. | | `` P `` | Отправить тег | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Удалённые ветки @@ -345,6 +345,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Скопировать название ветки в буфер обмена | | | `` `` | Переключить | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | Новая ветка | | +| `` w `` | New worktree | | | `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. | | `` d `` | Delete | Delete the remote branch from the remote. | @@ -354,7 +355,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Удалённые репозитории @@ -410,8 +410,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Применить припрятанные изменения и тут же удалить их из хранилища | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | Удалить припрятанные изменения из хранилища | Remove the stash entry from the stash list. | | `` n `` | Новая ветка | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | +| `` w `` | New worktree | | | `` r `` | Переименовать хранилище | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть файлы выбранного элемента | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | diff --git a/docs-master/keybindings/Keybindings_zh-CN.md b/docs-master/keybindings/Keybindings_zh-CN.md index 6828c35e3..b4f1134ec 100644 --- a/docs-master/keybindings/Keybindings_zh-CN.md +++ b/docs-master/keybindings/Keybindings_zh-CN.md @@ -62,6 +62,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 在浏览器中打开提交 | | | `` n `` | 从提交创建新分支 | | | `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。

请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 | +| `` w `` | 新建工作树 | | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` C `` | 复制提交(拣选) | 标记提交为已复制。然后,在本地提交视图中,您可以按 `V` (Cherry-Pick) 将已复制的提交粘贴到已检出的分支中。任何时候都可以按 `` 来取消选择。 | | `` `` | 重置已拣选(复制)的提交 | | @@ -69,7 +70,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | 选择当前分支的提交 | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交的文件 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 开始搜索 | | ## 子模块 @@ -106,6 +106,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 在浏览器中打开提交 | | | `` n `` | 从提交创建新分支 | | | `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。

请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 | +| `` w `` | 新建工作树 | | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` C `` | 复制提交(拣选) | 标记提交为已复制。然后,在本地提交视图中,您可以按 `V` (Cherry-Pick) 将已复制的提交粘贴到已检出的分支中。任何时候都可以按 `` 来取消选择。 | | `` `` | 重置已拣选(复制)的提交 | | @@ -113,7 +114,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | 选择当前分支的提交 | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | ## 提交 @@ -149,13 +149,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 在浏览器中打开提交 | | | `` n `` | 从提交创建新分支 | | | `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。

请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 | +| `` w `` | 新建工作树 | | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` C `` | 复制提交(拣选) | 标记提交为已复制。然后,在本地提交视图中,您可以按 `V` (Cherry-Pick) 将已复制的提交粘贴到已检出的分支中。任何时候都可以按 `` 来取消选择。 | | `` `` | 使用外部差异比较工具(git difftool) | | | `` * `` | 选择当前分支的提交 | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交的文件 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 开始搜索 | | ## 提交信息 @@ -227,6 +227,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 检出 | 检出选中的项目 | | `` n `` | 新分支 | | | `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。

请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 | +| `` w `` | 新建工作树 | | | `` o `` | 创建拉取请求 | | | `` O `` | 创建拉取请求选项 | | | `` G `` | 在浏览器中打开拉取请求 | | @@ -246,7 +247,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 使用外部差异比较工具(git difftool) | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | ## 构建补丁中 @@ -272,13 +272,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 复制标签到剪贴板 | | | `` `` | 检出 | 检出选择的标签作为分离的HEAD | | `` n `` | 创建标签 | 基于当前提交创建一个新标签。您将在弹窗中输入标签名称和描述(可选)。 | +| `` w `` | 新建工作树 | | | `` d `` | 删除 | 查看本地/远程标签的删除选项 | | `` P `` | 推送标签 | 推送选择的标签到远端。您将在弹窗中选择一个远端。 | | `` g `` | 重置 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` `` | 使用外部差异比较工具(git difftool) | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | ## 次要 @@ -372,10 +372,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | 应用并删除 | 将存储项应用到工作目录并删除存储项。 | | `` d `` | 删除 | 从贮藏列表中删除该贮藏项 | | `` n `` | 新分支 | 从选定的贮藏项创建一个新分支。这是通过 git 检查创建贮藏项的提交,从该提交创建一个新分支,然后将贮藏项作为附加提交应用到新分支来实现的。 | +| `` w `` | 新建工作树 | | | `` r `` | 重命名贮藏 | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交的文件 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | ## 输入提示 @@ -404,6 +404,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 复制分支名称到剪贴板 | | | `` `` | 检出 | 基于当前选中的远程分支检出一个新的本地分支,或者将远程分支作分离的HEAD。 | | `` n `` | 新分支 | | +| `` w `` | 新建工作树 | | | `` M `` | 合并到当前检出的分支 | 查看将选中项合并到当前分支的选项(正常合并,压缩合并) | | `` r `` | 变基 | 将检出的分支变基到所选的分支上。 | | `` d `` | 删除 | 从远程删除远程分支。 | @@ -413,5 +414,4 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 使用外部差异比较工具(git difftool) | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | diff --git a/docs-master/keybindings/Keybindings_zh-TW.md b/docs-master/keybindings/Keybindings_zh-TW.md index 56d383ff9..b50e25d35 100644 --- a/docs-master/keybindings/Keybindings_zh-TW.md +++ b/docs-master/keybindings/Keybindings_zh-TW.md @@ -141,6 +141,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 在瀏覽器中開啟提交 | | | `` n `` | 從提交建立新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | 重設選定的揀選 (複製) 提交 | | @@ -148,7 +149,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 檢視所選項目的檔案 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 子模組 @@ -208,13 +208,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 在瀏覽器中開啟提交 | | | `` n `` | 從提交建立新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | 開啟外部差異工具 (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 檢視所選項目的檔案 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 提交摘要 @@ -252,10 +252,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | 還原 | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | 捨棄 | Remove the stash entry from the stash list. | | `` n `` | 新分支 | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | +| `` w `` | New worktree | | | `` r `` | 重新命名收藏 | | | `` 0 `` | Focus main view | | | `` `` | 檢視所選項目的檔案 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 日誌 @@ -268,6 +268,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 在瀏覽器中開啟提交 | | | `` n `` | 從提交建立新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | 重設選定的揀選 (複製) 提交 | | @@ -275,7 +276,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 本地分支 @@ -287,6 +287,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 檢出 | 檢出選定的項目。 | | `` n `` | 新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` o `` | 建立拉取請求 | | | `` O `` | 建立拉取請求選項 | | | `` G `` | Open pull request in browser | | @@ -306,7 +307,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 開啟外部差異工具 (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 標籤 @@ -316,13 +316,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copy tag to clipboard | | | `` `` | 檢出 | Checkout the selected tag as a detached HEAD. | | `` n `` | 建立標籤 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | +| `` w `` | New worktree | | | `` d `` | 刪除 | View delete options for local/remote tag. | | `` P `` | 推送標籤 | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | 重設 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | 開啟外部差異工具 (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 檔案 @@ -404,6 +404,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 複製分支名稱到剪貼簿 | | | `` `` | 檢出 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | 新分支 | | +| `` w `` | New worktree | | | `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. | | `` d `` | 刪除 | Delete the remote branch from the remote. | @@ -413,5 +414,4 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 開啟外部差異工具 (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 51e240a5d..a7eb35919 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -207,18 +207,6 @@ func (gui *Gui) resetHelpersAndControllers() { controllers.AttachControllers(context, searchControllerFactory.Create(context)) } - for _, context := range []controllers.CanViewWorktreeOptions{ - gui.State.Contexts.LocalCommits, - gui.State.Contexts.ReflogCommits, - gui.State.Contexts.SubCommits, - gui.State.Contexts.Stash, - gui.State.Contexts.Branches, - gui.State.Contexts.RemoteBranches, - gui.State.Contexts.Tags, - } { - controllers.AttachControllers(context, controllers.NewWorktreeOptionsController(common, context)) - } - // allow for navigating between side window contexts for _, context := range []types.Context{ gui.State.Contexts.Status, diff --git a/pkg/gui/controllers/basic_commits_controller.go b/pkg/gui/controllers/basic_commits_controller.go index 2ddb5055e..f8b8d6783 100644 --- a/pkg/gui/controllers/basic_commits_controller.go +++ b/pkg/gui/controllers/basic_commits_controller.go @@ -89,6 +89,12 @@ func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ Description: self.c.Tr.MoveCommitsToNewBranch, Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip, }, + { + Keys: opts.GetKeys(opts.Config.Worktrees.ViewWorktreeOptions), + Handler: self.withItem(self.c.Helpers().Worktree.NewWorktreeMenuForCommit), + Description: self.c.Tr.NewWorktree, + OpensMenu: true, + }, { Keys: opts.GetKeys(opts.Config.Commits.ViewResetOptions), Handler: self.withItem(self.createResetMenu), diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index c2c0595c3..7846febbf 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -69,6 +69,12 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty Description: self.c.Tr.MoveCommitsToNewBranch, Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip, }, + { + Keys: opts.GetKeys(opts.Config.Worktrees.ViewWorktreeOptions), + Handler: self.withItem(self.c.Helpers().Worktree.NewWorktreeMenuForBranch), + Description: self.c.Tr.NewWorktree, + OpensMenu: true, + }, { Keys: opts.GetKeys(opts.Config.Branches.CreatePullRequest), Handler: self.withItem(self.handleCreatePullRequest), diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 5f5933746..3eef892cf 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -253,31 +253,218 @@ func worktreeParentDirCandidates(repoPath string, linkedWorktreePaths []string, return candidates } -func (self *WorktreeHelper) ViewWorktreeOptions(context types.IListContext, ref string) error { - currentBranch := self.refsHelper.GetCheckedOutRef() - canCheckoutBase := context == self.c.Contexts().Branches && ref != currentBranch.RefName() - - return self.ViewBranchWorktreeOptions(ref, canCheckoutBase) +func (self *WorktreeHelper) NewWorktreeMenuForBranch(branch *models.Branch) error { + return self.worktreeMenu( + self.newBranchAndWorktreeItem(branch.Name, branch.RefName()), + self.worktreeForBranchItem(branch), + self.detachedWorktreeItem(branch.Name, branch.RefName(), branch.Name), + ) } -func (self *WorktreeHelper) ViewBranchWorktreeOptions(branchName string, canCheckoutBase bool) error { - placeholders := map[string]string{"ref": branchName} +func (self *WorktreeHelper) NewWorktreeMenuForCommit(commit *models.Commit) error { + return self.worktreeMenu( + self.newBranchAndWorktreeItem(commit.ShortHash(), commit.RefName()), + self.detachedWorktreeItem(commit.ShortHash(), commit.RefName(), ""), + ) +} +func (self *WorktreeHelper) NewWorktreeMenuForTag(tag *models.Tag) error { + return self.worktreeMenu( + self.newBranchAndWorktreeItem(tag.Name, tag.RefName()), + self.detachedWorktreeItem(tag.Name, tag.RefName(), ""), + ) +} + +func (self *WorktreeHelper) NewWorktreeMenuForStash(stash *models.StashEntry) error { + return self.worktreeMenu( + self.newBranchAndWorktreeItem(stash.RefName(), stash.FullRefName()), + self.detachedWorktreeItem(stash.RefName(), stash.FullRefName(), ""), + ) +} + +func (self *WorktreeHelper) NewWorktreeMenuForRemoteBranch(remoteBranch *models.RemoteBranch) error { + // e.g. "origin/foo" -> "foo": the local branch's (and worktree's) default name + strippedName := strings.SplitAfterN(remoteBranch.RefName(), "/", 2)[1] + + return self.worktreeMenu( + self.newLocalBranchAndWorktreeItem(remoteBranch, strippedName), + self.detachedWorktreeItem(remoteBranch.FullName(), remoteBranch.FullName(), strippedName), + ) +} + +func (self *WorktreeHelper) worktreeMenu(items ...*types.MenuItem) error { return self.c.Menu(types.CreateMenuOptions{ - Title: self.c.Tr.WorktreeTitle, - Items: []*types.MenuItem{ - { - LabelColumns: []string{utils.ResolvePlaceholderString(self.c.Tr.CreateWorktreeFrom, placeholders)}, - OnPress: func() error { - return self.NewWorktreeCheckout(branchName, canCheckoutBase, false, context.LOCAL_BRANCHES_CONTEXT_KEY) - }, - }, - { - LabelColumns: []string{utils.ResolvePlaceholderString(self.c.Tr.CreateWorktreeFromDetached, placeholders)}, - OnPress: func() error { - return self.NewWorktreeCheckout(branchName, canCheckoutBase, true, context.LOCAL_BRANCHES_CONTEXT_KEY) - }, - }, - }, + Title: self.c.Tr.NewWorktree, + Items: items, + }) +} + +// newBranchAndWorktreeItem is the "new branch + worktree" action for a ref that +// isn't a remote branch (a branch, commit, tag or stash). ref is what we show the +// user (branch name, short hash, stash@{n}, ...); base is what we hand to +// `git worktree add`. +func (self *WorktreeHelper) newBranchAndWorktreeItem(ref string, base string) *types.MenuItem { + return &types.MenuItem{ + Label: utils.ResolvePlaceholderString(self.c.Tr.NewBranchAndWorktreeFromRef, map[string]string{"ref": ref}), + Keys: menuKey('b'), + OnPress: func() error { + return self.startNewBranchWorktree("", base, func(name string) string { + return utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptNewBranch, + map[string]string{"name": name, "base": ref}) + }) + }, + } +} + +// newLocalBranchAndWorktreeItem is the "new branch + worktree" action for a remote +// branch: the new local branch tracks the remote one, and its name defaults to the +// remote branch name with the remote stripped off. +func (self *WorktreeHelper) newLocalBranchAndWorktreeItem(remoteBranch *models.RemoteBranch, strippedName string) *types.MenuItem { + return &types.MenuItem{ + Label: utils.ResolvePlaceholderString(self.c.Tr.NewLocalBranchAndWorktreeFromRef, map[string]string{"ref": remoteBranch.FullName()}), + Keys: menuKey('b'), + OnPress: func() error { + return self.startNewBranchWorktree(strippedName, remoteBranch.FullName(), func(name string) string { + return utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptTrackingBranch, + map[string]string{"name": name, "ref": remoteBranch.FullName()}) + }) + }, + } +} + +// startNewBranchWorktree runs the shared name -> location -> create pipeline for the +// new-branch actions: prompt for the branch (and worktree) name, ask for the +// location, then create a worktree on a freshly created branch of that name. +// locationPrompt builds the location-menu prompt once the name is known. +func (self *WorktreeHelper) startNewBranchWorktree(nameInitialContent string, base string, locationPrompt func(name string) string) error { + return self.promptForName(self.c.Tr.NewBranchAndWorktreeName, nameInitialContent, func(name string) error { + return self.promptForWorktreeLocation(name, locationPrompt(name), func(path string) error { + return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: base, Branch: name}) + }) + }) +} + +// worktreeForBranchItem is the "check out an existing branch in a new worktree" +// action. It's disabled when the branch is already checked out somewhere. +func (self *WorktreeHelper) worktreeForBranchItem(branch *models.Branch) *types.MenuItem { + return &types.MenuItem{ + Label: utils.ResolvePlaceholderString(self.c.Tr.WorktreeForRef, map[string]string{"ref": branch.Name}), + Keys: menuKey('w'), + OnPress: func() error { + prompt := utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptCheckout, + map[string]string{"branchName": branch.Name}) + return self.promptForWorktreeLocation(branch.Name, prompt, func(path string) error { + return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: branch.RefName()}) + }) + }, + DisabledReason: self.branchCheckedOutDisabledReason(branch), + } +} + +// detachedWorktreeItem is the "detached worktree at a ref" action. ref is shown to +// the user; base is handed to `git worktree add`. defaultDirName is the worktree +// directory name to use; when it's empty we prompt for one instead (commits, tags +// and stashes have no good name to derive). +func (self *WorktreeHelper) detachedWorktreeItem(ref string, base string, defaultDirName string) *types.MenuItem { + prompt := utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptDetached, map[string]string{"ref": ref}) + create := func(path string) error { + return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: base, Detach: true}) + } + + return &types.MenuItem{ + Label: utils.ResolvePlaceholderString(self.c.Tr.DetachedWorktreeAtRef, map[string]string{"ref": ref}), + Keys: menuKey('d'), + OnPress: func() error { + if defaultDirName != "" { + return self.promptForWorktreeLocation(defaultDirName, prompt, create) + } + return self.promptForName(self.c.Tr.NewWorktreeName, "", func(name string) error { + return self.promptForWorktreeLocation(name, prompt, create) + }) + }, + } +} + +func (self *WorktreeHelper) branchCheckedOutDisabledReason(branch *models.Branch) *types.DisabledReason { + if worktree, ok := git_commands.WorktreeForBranch(branch, self.c.Model().Worktrees); ok { + return &types.DisabledReason{ + Text: utils.ResolvePlaceholderString(self.c.Tr.BranchCheckedOutByWorktree, + map[string]string{"branchName": branch.Name, "worktreeName": worktree.Name}), + } + } + + return nil +} + +// promptForName asks for a branch/worktree name and sanitizes the response (most +// notably turning spaces into dashes so it's a valid branch name) before +// continuing. +func (self *WorktreeHelper) promptForName(title string, initialContent string, onConfirm func(name string) error) error { + self.c.Prompt(types.PromptOpts{ + Title: title, + InitialContent: initialContent, + HandleConfirm: func(response string) error { + return onConfirm(SanitizedBranchName(response)) + }, + }) + + return nil +} + +// promptForWorktreeLocation shows the location menu: one item per candidate parent +// directory (each labelled with the absolute path the worktree would end up at), +// plus an "Other…" item that opens a free-form path prompt. The chosen absolute +// path is passed to onConfirm. +func (self *WorktreeHelper) promptForWorktreeLocation(dirName string, prompt string, onConfirm func(path string) error) error { + linkedWorktreePaths := []string{} + for _, worktree := range self.c.Model().Worktrees { + if !worktree.IsMain { + linkedWorktreePaths = append(linkedWorktreePaths, worktree.Path) + } + } + parentDirs := worktreeParentDirCandidates( + self.c.Git().RepoPaths.RepoPath(), + linkedWorktreePaths, + self.c.UserConfig().Worktree.DefaultPath, + ) + + targets := lo.Map(parentDirs, func(parentDir string, _ int) string { + return filepath.Join(parentDir, dirName) + }) + + menuItems := lo.Map(targets, func(target string, _ int) *types.MenuItem { + return &types.MenuItem{ + Label: target, + OnPress: func() error { return onConfirm(target) }, + } + }) + + menuItems = append(menuItems, &types.MenuItem{ + Label: self.c.Tr.WorktreeLocationOther, + OnPress: func() error { + self.c.Prompt(types.PromptOpts{ + Title: self.c.Tr.NewWorktreePath, + InitialContent: targets[0], + HandleConfirm: onConfirm, + }) + return nil + }, + }) + + return self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.WorktreeLocationTitle, + Prompt: prompt, + Items: menuItems, + }) +} + +func (self *WorktreeHelper) createWorktree(opts git_commands.NewWorktreeOpts) error { + return self.c.WithWaitingStatus(self.c.Tr.AddingWorktree, func(gocui.Task) error { + self.c.LogAction(self.c.Tr.Actions.AddWorktree) + if err := self.c.Git().Worktree.New(opts); err != nil { + return err + } + + return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, context.LOCAL_BRANCHES_CONTEXT_KEY) }) } diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go index 3a49c0114..d232730c6 100644 --- a/pkg/gui/controllers/remote_branches_controller.go +++ b/pkg/gui/controllers/remote_branches_controller.go @@ -48,6 +48,12 @@ func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts) GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.NewBranch, }, + { + Keys: opts.GetKeys(opts.Config.Worktrees.ViewWorktreeOptions), + Handler: self.withItem(self.c.Helpers().Worktree.NewWorktreeMenuForRemoteBranch), + Description: self.c.Tr.NewWorktree, + OpensMenu: true, + }, { Keys: opts.GetKeys(opts.Config.Branches.MergeIntoCurrentBranch), Handler: opts.Guards.OutsideFilterMode(self.withItem(self.merge)), diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 49abedd9f..3f0bd9be2 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -66,6 +66,12 @@ func (self *StashController) GetKeybindings(opts types.KeybindingsOpts) []*types Description: self.c.Tr.NewBranch, Tooltip: self.c.Tr.NewBranchFromStashTooltip, }, + { + Keys: opts.GetKeys(opts.Config.Worktrees.ViewWorktreeOptions), + Handler: self.withItem(self.c.Helpers().Worktree.NewWorktreeMenuForStash), + Description: self.c.Tr.NewWorktree, + OpensMenu: true, + }, { Keys: opts.GetKeys(opts.Config.Stash.RenameStash), Handler: self.withItem(self.handleRenameStashEntry), diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go index a10f9a374..fd61e239d 100644 --- a/pkg/gui/controllers/tags_controller.go +++ b/pkg/gui/controllers/tags_controller.go @@ -53,6 +53,12 @@ func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types. Tooltip: self.c.Tr.NewTagTooltip, DisplayOnScreen: true, }, + { + Keys: opts.GetKeys(opts.Config.Worktrees.ViewWorktreeOptions), + Handler: self.withItem(self.c.Helpers().Worktree.NewWorktreeMenuForTag), + Description: self.c.Tr.NewWorktree, + OpensMenu: true, + }, { Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItem(self.delete), diff --git a/pkg/gui/controllers/worktree_options_controller.go b/pkg/gui/controllers/worktree_options_controller.go deleted file mode 100644 index b1123e2a8..000000000 --- a/pkg/gui/controllers/worktree_options_controller.go +++ /dev/null @@ -1,51 +0,0 @@ -package controllers - -import ( - "github.com/jesseduffield/lazygit/pkg/gui/types" -) - -// This controller is for all contexts that have items you can create a worktree from - -var _ types.IController = &WorktreeOptionsController{} - -type CanViewWorktreeOptions interface { - types.IListContext -} - -type WorktreeOptionsController struct { - baseController - *ListControllerTrait[string] - c *ControllerCommon - context CanViewWorktreeOptions -} - -func NewWorktreeOptionsController(c *ControllerCommon, context CanViewWorktreeOptions) *WorktreeOptionsController { - return &WorktreeOptionsController{ - baseController: baseController{}, - ListControllerTrait: NewListControllerTrait( - c, - context, - context.GetSelectedItemId, - context.GetSelectedItemIds, - ), - c: c, - context: context, - } -} - -func (self *WorktreeOptionsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { - bindings := []*types.Binding{ - { - Keys: opts.GetKeys(opts.Config.Worktrees.ViewWorktreeOptions), - Handler: self.withItem(self.viewWorktreeOptions), - Description: self.c.Tr.ViewWorktreeOptions, - OpensMenu: true, - }, - } - - return bindings -} - -func (self *WorktreeOptionsController) viewWorktreeOptions(ref string) error { - return self.c.Helpers().Worktree.ViewWorktreeOptions(self.context, ref) -} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index db8cdbd5e..bb8d8637e 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -894,9 +894,20 @@ type TranslationSet struct { RemoveWorktreeTooltip string NewBranchName string NewBranchNameLeaveBlank string - ViewWorktreeOptions string CreateWorktreeFrom string CreateWorktreeFromDetached string + NewWorktreeName string + NewBranchAndWorktreeName string + NewBranchAndWorktreeFromRef string + NewLocalBranchAndWorktreeFromRef string + WorktreeForRef string + DetachedWorktreeAtRef string + WorktreeLocationTitle string + WorktreeLocationOther string + WorktreeLocationPromptNewBranch string + WorktreeLocationPromptTrackingBranch string + WorktreeLocationPromptCheckout string + WorktreeLocationPromptDetached string LcWorktree string ChangingDirectoryTo string DirenvApprovalTitle string @@ -2028,9 +2039,20 @@ func EnglishTranslationSet() *TranslationSet { RemoveWorktreeTooltip: "Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory.", NewBranchName: "New branch name", NewBranchNameLeaveBlank: "New branch name (leave blank to checkout {{.default}})", - ViewWorktreeOptions: "View worktree options", CreateWorktreeFrom: "Create worktree from {{.ref}}", CreateWorktreeFromDetached: "Create worktree from {{.ref}} (detached)", + NewWorktreeName: "New worktree name", + NewBranchAndWorktreeName: "New branch and worktree name", + NewBranchAndWorktreeFromRef: "New branch and worktree from '{{.ref}}'", + NewLocalBranchAndWorktreeFromRef: "New local branch and worktree from '{{.ref}}'", + WorktreeForRef: "New worktree for '{{.ref}}'", + DetachedWorktreeAtRef: "New detached worktree at '{{.ref}}'", + WorktreeLocationTitle: "Worktree location", + WorktreeLocationOther: "Other…", + WorktreeLocationPromptNewBranch: "New branch '{{.name}}' from '{{.base}}':", + WorktreeLocationPromptTrackingBranch: "New branch '{{.name}}' tracking '{{.ref}}':", + WorktreeLocationPromptCheckout: "Worktree for branch '{{.branchName}}':", + WorktreeLocationPromptDetached: "Detached worktree at '{{.ref}}':", LcWorktree: "worktree", ChangingDirectoryTo: "Changing directory to {{.path}}", DirenvApprovalTitle: "Approve .envrc?", diff --git a/pkg/integration/tests/demo/worktree_create_from_branches.go b/pkg/integration/tests/demo/worktree_create_from_branches.go index 39d2cb73e..817cb704b 100644 --- a/pkg/integration/tests/demo/worktree_create_from_branches.go +++ b/pkg/integration/tests/demo/worktree_create_from_branches.go @@ -39,19 +39,25 @@ var WorktreeCreateFromBranches = NewIntegrationTest(NewIntegrationTestArgs{ t.Wait(500) t.ExpectPopup().Menu(). - Title(Equals("Worktree")). - Select(Contains("Create worktree from master").DoesNotContain("detached")). + Title(Equals("New worktree")). + Select(Contains("New branch and worktree from 'master'")). + Confirm() + + t.ExpectPopup().Prompt(). + Title(Equals("New branch and worktree name")). + Type("hotfix/db-on-fire"). + Confirm() + + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + Select(Contains("Other…")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New worktree path")). + Clear(). Type("../hotfix"). Confirm() - - t.ExpectPopup().Prompt(). - Title(Contains("New branch name")). - Type("hotfix/db-on-fire"). - Confirm() }) }, }) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 74e471713..443b29fad 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -495,9 +495,13 @@ var tests = []*components.IntegrationTest{ undo.UndoCheckoutAndDrop, undo.UndoCommit, undo.UndoDrop, + worktree.AddForExistingBranch, worktree.AddFromBranch, worktree.AddFromBranchDetached, worktree.AddFromCommit, + worktree.AddFromRemoteBranch, + worktree.AddFromStash, + worktree.AddFromTag, worktree.AssociateBranchBisect, worktree.AssociateBranchRebase, worktree.BareRepo, @@ -512,6 +516,7 @@ var tests = []*components.IntegrationTest{ worktree.FastForwardWorktreeBranchShouldNotPolluteCurrentWorktree, worktree.ForceRemoveWorktree, worktree.ForceRemoveWorktreeWithSubmodules, + worktree.LocationCandidates, worktree.RemoveWorktreeFromBranch, worktree.ResetWindowTabs, worktree.SymlinkIntoRepoSubdir, diff --git a/pkg/integration/tests/worktree/add_for_existing_branch.go b/pkg/integration/tests/worktree/add_for_existing_branch.go new file mode 100644 index 000000000..fb238bcda --- /dev/null +++ b/pkg/integration/tests/worktree/add_for_existing_branch.go @@ -0,0 +1,67 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var AddForExistingBranch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Create a worktree that checks out an existing branch (no new branch), and confirm the option is disabled for an already-checked-out branch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.NewBranchFrom("otherbranch", "mybranch") + shell.Checkout("mybranch") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("mybranch").IsSelected(), + Contains("otherbranch"), + ). + // the current branch is checked out by this worktree, so "Worktree + // for 'mybranch'" is disabled + Press(keys.Worktrees.ViewWorktreeOptions). + Tap(func() { + t.ExpectPopup(). + Menu(). + Title(Equals("New worktree")). + Select(Contains("New worktree for 'mybranch'")). + Tooltip(Contains("Branch mybranch is checked out by worktree repo")). + Confirm(). + Tap(func() { + t.ExpectToast(Contains("Branch mybranch is checked out by worktree repo")) + }). + Cancel() + }). + // otherbranch is not checked out anywhere, so we can make a worktree for it + NavigateToLine(Contains("otherbranch")). + Press(keys.Worktrees.ViewWorktreeOptions). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("New worktree")). + Select(Contains("New worktree for 'otherbranch'")). + Confirm() + + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + Confirm() + }) + + // we've switched into the new worktree, which has otherbranch checked out + t.Views().Branches(). + IsFocused(). + Lines( + Contains("otherbranch").IsSelected(), + Contains("mybranch (worktree repo)"), + ) + + t.Views().Status(). + Content(Contains("repo(otherbranch) → otherbranch")) + }, +}) diff --git a/pkg/integration/tests/worktree/add_from_branch.go b/pkg/integration/tests/worktree/add_from_branch.go index ab7807571..87898c794 100644 --- a/pkg/integration/tests/worktree/add_from_branch.go +++ b/pkg/integration/tests/worktree/add_from_branch.go @@ -6,7 +6,7 @@ import ( ) var AddFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Add a worktree via the branches view, then switch back to the main worktree via the branches view", + Description: "Create a new branch and worktree from a branch, then switch back to the main worktree via the branches view", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, @@ -24,19 +24,20 @@ var AddFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ Press(keys.Worktrees.ViewWorktreeOptions). Tap(func() { t.ExpectPopup().Menu(). - Title(Equals("Worktree")). - Select(Contains(`Create worktree from mybranch`).DoesNotContain("detached")). + Title(Equals("New worktree")). + Select(Contains("New branch and worktree from 'mybranch'")). Confirm() t.ExpectPopup().Prompt(). - Title(Equals("New worktree path")). - Type("../linked-worktree"). - Confirm() - - t.ExpectPopup().Prompt(). - Title(Equals("New branch name")). + Title(Equals("New branch and worktree name")). Type("newbranch"). Confirm() + + // no existing worktrees and no configured default path, so the + // only candidate location is the repo's parent directory; accept it + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + Confirm() }). // confirm we're still focused on the branches view IsFocused(). @@ -54,7 +55,9 @@ var AddFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("mybranch").IsSelected(), - Contains("newbranch (worktree linked-worktree)"), + // the worktree's directory name matches the branch name, so the + // branches view shows the compact "(worktree)" with no name + Contains("newbranch (worktree)"), ). // Confirm the files view is still showing in the files window Press(keys.Universal.PrevBlock) diff --git a/pkg/integration/tests/worktree/add_from_branch_detached.go b/pkg/integration/tests/worktree/add_from_branch_detached.go index 70f27dc81..6b74baff6 100644 --- a/pkg/integration/tests/worktree/add_from_branch_detached.go +++ b/pkg/integration/tests/worktree/add_from_branch_detached.go @@ -6,7 +6,7 @@ import ( ) var AddFromBranchDetached = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Add a detached worktree via the branches view", + Description: "Add a detached worktree at a branch via the branches view, choosing a custom location", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, @@ -24,12 +24,21 @@ var AddFromBranchDetached = NewIntegrationTest(NewIntegrationTestArgs{ Press(keys.Worktrees.ViewWorktreeOptions). Tap(func() { t.ExpectPopup().Menu(). - Title(Equals("Worktree")). - Select(Contains(`Create worktree from mybranch (detached)`)). + Title(Equals("New worktree")). + Select(Contains("New detached worktree at 'mybranch'")). + Confirm() + + // the location menu defaults the directory name to the branch + // name; pick "Other…" to type a different path instead + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + Select(Contains("Other…")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New worktree path")). + InitialText(Contains("mybranch")). + Clear(). Type("../linked-worktree"). Confirm() }). diff --git a/pkg/integration/tests/worktree/add_from_commit.go b/pkg/integration/tests/worktree/add_from_commit.go index 49b69697b..484370ae3 100644 --- a/pkg/integration/tests/worktree/add_from_commit.go +++ b/pkg/integration/tests/worktree/add_from_commit.go @@ -6,7 +6,7 @@ import ( ) var AddFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Add a worktree via the commits view", + Description: "Create a new branch and worktree from a commit via the commits view", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, @@ -27,19 +27,18 @@ var AddFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ Press(keys.Worktrees.ViewWorktreeOptions). Tap(func() { t.ExpectPopup().Menu(). - Title(Equals("Worktree")). - Select(MatchesRegexp(`Create worktree from .*`).DoesNotContain("detached")). + Title(Equals("New worktree")). + Select(Contains("New branch and worktree from")). Confirm() t.ExpectPopup().Prompt(). - Title(Equals("New worktree path")). - Type("../linked-worktree"). - Confirm() - - t.ExpectPopup().Prompt(). - Title(Equals("New branch name")). + Title(Equals("New branch and worktree name")). Type("newbranch"). Confirm() + + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + Confirm() }). Lines( Contains("initial commit"), diff --git a/pkg/integration/tests/worktree/add_from_remote_branch.go b/pkg/integration/tests/worktree/add_from_remote_branch.go new file mode 100644 index 000000000..6dd4ea761 --- /dev/null +++ b/pkg/integration/tests/worktree/add_from_remote_branch.go @@ -0,0 +1,61 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var AddFromRemoteBranch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Create a new local tracking branch and worktree from a remote branch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.NewBranch("feature") + shell.CloneIntoRemote("origin") + shell.Checkout("master") + // drop the local branch so only the remote one remains + shell.RunCommand([]string{"git", "branch", "-D", "feature"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Remotes(). + Focus(). + Lines( + Contains("origin").IsSelected(), + ). + PressEnter() + + t.Views().RemoteBranches(). + IsFocused(). + NavigateToLine(Contains("feature")). + Press(keys.Worktrees.ViewWorktreeOptions). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("New worktree")). + Select(Contains("New local branch and worktree from 'origin/feature'")). + Confirm() + + // the new branch name defaults to the remote branch name with + // the remote stripped off + t.ExpectPopup().Prompt(). + Title(Equals("New branch and worktree name")). + InitialText(Equals("feature")). + Confirm() + + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + Confirm() + }) + + // we've switched into the new worktree, on a local branch that tracks + // the remote one (the ✓ confirms tracking is set up) + t.Views().Branches(). + IsFocused(). + Lines( + Contains("feature").Contains("✓").IsSelected(), + Contains("master (worktree repo)"), + ) + }, +}) diff --git a/pkg/integration/tests/worktree/add_from_stash.go b/pkg/integration/tests/worktree/add_from_stash.go new file mode 100644 index 000000000..26a3b97c4 --- /dev/null +++ b/pkg/integration/tests/worktree/add_from_stash.go @@ -0,0 +1,51 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var AddFromStash = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Create a new branch and worktree from a stash entry", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.UpdateFile("README.md", "work in progress") + shell.Stash("my stash") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Stash(). + Focus(). + Lines( + Contains("my stash").IsSelected(), + ). + Press(keys.Worktrees.ViewWorktreeOptions). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("New worktree")). + Select(Contains("New branch and worktree from 'stash@{0}'")). + Confirm() + + t.ExpectPopup().Prompt(). + Title(Equals("New branch and worktree name")). + Type("from-stash"). + Confirm() + + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + Confirm() + }) + + // we've switched into the new worktree, on the new branch + t.Views().Branches(). + IsFocused(). + Lines( + Contains("from-stash").IsSelected(), + Contains("mybranch (worktree repo)"), + ) + }, +}) diff --git a/pkg/integration/tests/worktree/add_from_tag.go b/pkg/integration/tests/worktree/add_from_tag.go new file mode 100644 index 000000000..22ef2bb07 --- /dev/null +++ b/pkg/integration/tests/worktree/add_from_tag.go @@ -0,0 +1,54 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var AddFromTag = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Create a detached worktree at a tag, entering a worktree name", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.CreateLightweightTag("v1.0", "HEAD") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Tags(). + Focus(). + Lines( + Contains("v1.0").IsSelected(), + ). + Press(keys.Worktrees.ViewWorktreeOptions). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("New worktree")). + Select(Contains("New detached worktree at 'v1.0'")). + Confirm() + + // a tag has no good name to derive, so we're asked for one + t.ExpectPopup().Prompt(). + Title(Equals("New worktree name")). + Type("tag-worktree"). + Confirm() + + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + Confirm() + }) + + // we've switched into the new worktree, with a detached head + t.Views().Branches(). + IsFocused(). + Lines( + Contains("(no branch)").IsSelected(), + Contains("mybranch (worktree repo)"), + ) + + t.Views().Status(). + Content(Contains("repo(tag-worktree)")) + }, +}) diff --git a/pkg/integration/tests/worktree/location_candidates.go b/pkg/integration/tests/worktree/location_candidates.go new file mode 100644 index 000000000..2160c6109 --- /dev/null +++ b/pkg/integration/tests/worktree/location_candidates.go @@ -0,0 +1,52 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var LocationCandidates = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "The location menu offers the parents of existing worktrees and the configured default path, and sanitizes the typed name", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Worktree.DefaultPath = "../config-worktrees" + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + // a pre-existing linked worktree, so its parent directory is offered as + // a candidate location alongside the configured default path + shell.RunCommand([]string{"git", "worktree", "add", "-b", "existing", "../manual-worktrees/existing"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + NavigateToLine(Contains("mybranch")). + Press(keys.Worktrees.ViewWorktreeOptions). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("New worktree")). + Select(Contains("New branch and worktree from 'mybranch'")). + Confirm() + + // the space is sanitized to a dash so it's a valid branch (and + // directory) name + t.ExpectPopup().Prompt(). + Title(Equals("New branch and worktree name")). + Type("new feature"). + Confirm() + + // the parent of the existing worktree comes first, then the + // configured default path; both target the sanitized name + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + ContainsLines( + Contains("manual-worktrees").Contains("new-feature"), + Contains("config-worktrees").Contains("new-feature"), + ). + Cancel() + }) + }, +}) From 768d9f1a3f5c0cfc79297daf8f511dd50facb859 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 29 Jun 2026 15:26:38 +0200 Subject: [PATCH 176/384] Rework the worktrees-panel 'n' into a branch picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old 'n' flow opened a "normal vs detached" menu (the same meaningless gate the 'w' flow used to have), then asked for a base ref, a path typed from scratch, and a branch name in three separate prompts. Replace it with a single picker prompt titled "New worktree for branch", suggesting local branches not already checked out anywhere, plus remote branches that don't yet have a local branch of the same name. The entered value is classified on confirm: an existing local branch checks out into a new worktree, a remote branch creates a new local tracking branch, and anything else creates a new branch off the current ref. All three then feed the same location menu the 'w' flow uses, so paths are chosen from candidates rather than typed blind. Picking a remote or new branch needs no separate name prompt — the picker value already is the name. Checked-out branches are filtered from the suggestions, and a verbatim type-in of one is rejected with an error. createWorktree now takes the context to switch focus to once the worktree is created, so 'n' lands back in the worktrees panel while 'w' still lands in the branches panel. This deletes the old NewWorktree / NewWorktreeCheckout core and the now- orphaned i18n (CreateWorktreeFrom, CreateWorktreeFromDetached, NewWorktreeBase, NewBranchNameLeaveBlank), completing the migration started for 'w'. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/suggestions_helper.go | 23 +++ .../controllers/helpers/worktree_helper.go | 147 ++++++------------ pkg/i18n/english.go | 10 +- pkg/integration/tests/test_list.go | 2 + pkg/integration/tests/worktree/crud.go | 20 ++- .../tests/worktree/new_worktree_picker.go | 64 ++++++++ .../worktree/new_worktree_picker_remote.go | 60 +++++++ .../tests/worktree/worktree_in_repo.go | 18 +-- 8 files changed, 217 insertions(+), 127 deletions(-) create mode 100644 pkg/integration/tests/worktree/new_worktree_picker.go create mode 100644 pkg/integration/tests/worktree/new_worktree_picker_remote.go diff --git a/pkg/gui/controllers/helpers/suggestions_helper.go b/pkg/gui/controllers/helpers/suggestions_helper.go index e88fe3822..d26f96f1c 100644 --- a/pkg/gui/controllers/helpers/suggestions_helper.go +++ b/pkg/gui/controllers/helpers/suggestions_helper.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/jesseduffield/generics/set" + "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/presentation" @@ -84,6 +85,28 @@ func (self *SuggestionsHelper) GetBranchNameSuggestionsFunc() func(string) []*ty } } +// GetWorktreeBranchNameSuggestionsFunc suggests branches you can base a new +// worktree on: local branches that aren't checked out in any worktree (you can't +// make a second worktree for them), plus remote branches that don't yet have a +// local branch of the same name. Picking a remote branch creates a new local +// tracking branch, which would fail if that local branch already existed (whether +// or not it's checked out), so we leave those out and you reach the branch via its +// local entry instead. +func (self *SuggestionsHelper) GetWorktreeBranchNameSuggestionsFunc() func(string) []*types.Suggestion { + localBranchNames := lo.FilterMap(self.c.Model().Branches, func(branch *models.Branch, _ int) (string, bool) { + _, checkedOut := git_commands.WorktreeForBranch(branch, self.c.Model().Worktrees) + return branch.Name, !checkedOut + }) + + existingLocalBranches := set.NewFromSlice(self.getBranchNames()) + remoteBranchNames := lo.Filter(self.getRemoteBranchNames("/"), func(remoteBranchName string, _ int) bool { + _, branchName, _ := strings.Cut(remoteBranchName, "/") + return !existingLocalBranches.Includes(branchName) + }) + + return FilterFunc(append(localBranchNames, remoteBranchNames...), self.c.UserConfig().Gui.UseFuzzySearch()) +} + // here we asynchronously fetch the latest set of paths in the repo and store in // self.c.Model().FilesTrie. On the main thread we'll be doing a fuzzy search via // self.c.Model().FilesTrie. So if we've looked for a file previously, we'll start with diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 3eef892cf..2bc877444 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -57,105 +57,58 @@ func (self *WorktreeHelper) GetLinkedWorktreeName() string { } func (self *WorktreeHelper) NewWorktree() error { - branch := self.refsHelper.GetCheckedOutRef() - currentBranchName := branch.RefName() - - f := func(detached bool) { - self.c.Prompt(types.PromptOpts{ - Title: self.c.Tr.NewWorktreeBase, - InitialContent: currentBranchName, - FindSuggestionsFunc: self.suggestionsHelper.GetRefsSuggestionsFunc(), - HandleConfirm: func(base string) error { - // we assume that the base can be checked out - canCheckoutBase := true - return self.NewWorktreeCheckout(base, canCheckoutBase, detached, context.WORKTREES_CONTEXT_KEY) - }, - }) - } - - placeholders := map[string]string{"ref": "ref"} - - return self.c.Menu(types.CreateMenuOptions{ - Title: self.c.Tr.WorktreeTitle, - Items: []*types.MenuItem{ - { - LabelColumns: []string{utils.ResolvePlaceholderString(self.c.Tr.CreateWorktreeFrom, placeholders)}, - OnPress: func() error { - f(false) - return nil - }, - }, - { - LabelColumns: []string{utils.ResolvePlaceholderString(self.c.Tr.CreateWorktreeFromDetached, placeholders)}, - OnPress: func() error { - f(true) - return nil - }, - }, - }, - }) -} - -func (self *WorktreeHelper) NewWorktreeCheckout(base string, canCheckoutBase bool, detached bool, contextKey types.ContextKey) error { - opts := git_commands.NewWorktreeOpts{ - Base: base, - Detach: detached, - } - - f := func() error { - return self.c.WithWaitingStatus(self.c.Tr.AddingWorktree, func(gocui.Task) error { - self.c.LogAction(self.c.Tr.Actions.AddWorktree) - if err := self.c.Git().Worktree.New(opts); err != nil { - return err - } - - return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey) - }) - } - self.c.Prompt(types.PromptOpts{ - Title: self.c.Tr.NewWorktreePath, - HandleConfirm: func(path string) error { - opts.Path = path - - if detached { - return f() - } - - if canCheckoutBase { - title := utils.ResolvePlaceholderString(self.c.Tr.NewBranchNameLeaveBlank, map[string]string{"default": base}) - // prompt for the new branch name where a blank means we just check out the branch - self.c.Prompt(types.PromptOpts{ - Title: title, - HandleConfirm: func(branchName string) error { - opts.Branch = branchName - - return f() - }, - AllowEmptyInput: true, - }) - - return nil - } - - // prompt for the new branch name - self.c.Prompt(types.PromptOpts{ - Title: self.c.Tr.NewBranchName, - HandleConfirm: func(branchName string) error { - opts.Branch = branchName - - return f() - }, - AllowEmptyInput: false, - }) - - return nil + Title: self.c.Tr.NewWorktreeForBranchTitle, + FindSuggestionsFunc: self.suggestionsHelper.GetWorktreeBranchNameSuggestionsFunc(), + HandleConfirm: func(value string) error { + return self.newWorktreeForPickerValue(value) }, }) return nil } +// newWorktreeForPickerValue classifies the value the user picked or typed in the +// worktrees-panel picker and routes to the matching creation flow: +// - an existing local branch -> a worktree that checks it out; +// - a remote branch -> a new local tracking branch + worktree; +// - anything else -> a new branch off the current ref + worktree. +// +// All three then feed the shared location menu. The picker filters out branches +// already checked out somewhere, but a verbatim type-in is still guarded here. +func (self *WorktreeHelper) newWorktreeForPickerValue(value string) error { + if branch, ok := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool { + return branch.Name == value + }); ok { + if worktree, ok := git_commands.WorktreeForBranch(branch, self.c.Model().Worktrees); ok { + return errors.New(utils.ResolvePlaceholderString(self.c.Tr.BranchCheckedOutByWorktree, + map[string]string{"branchName": branch.Name, "worktreeName": worktree.Name})) + } + + prompt := utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptCheckout, + map[string]string{"branchName": branch.Name}) + return self.promptForWorktreeLocation(branch.Name, prompt, func(path string) error { + return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: branch.RefName()}, context.WORKTREES_CONTEXT_KEY) + }) + } + + if _, branchName, ok := self.refsHelper.ParseRemoteBranchName(value); ok { + prompt := utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptTrackingBranch, + map[string]string{"name": branchName, "ref": value}) + return self.promptForWorktreeLocation(branchName, prompt, func(path string) error { + return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: value, Branch: branchName}, context.WORKTREES_CONTEXT_KEY) + }) + } + + name := SanitizedBranchName(value) + base := self.refsHelper.GetCheckedOutRef().RefName() + prompt := utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptNewBranch, + map[string]string{"name": name, "base": base}) + return self.promptForWorktreeLocation(name, prompt, func(path string) error { + return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: base, Branch: name}, context.WORKTREES_CONTEXT_KEY) + }) +} + func (self *WorktreeHelper) Switch(worktree *models.Worktree, contextKey types.ContextKey) error { if worktree.IsCurrent { return errors.New(self.c.Tr.AlreadyInWorktree) @@ -339,7 +292,7 @@ func (self *WorktreeHelper) newLocalBranchAndWorktreeItem(remoteBranch *models.R func (self *WorktreeHelper) startNewBranchWorktree(nameInitialContent string, base string, locationPrompt func(name string) string) error { return self.promptForName(self.c.Tr.NewBranchAndWorktreeName, nameInitialContent, func(name string) error { return self.promptForWorktreeLocation(name, locationPrompt(name), func(path string) error { - return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: base, Branch: name}) + return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: base, Branch: name}, context.LOCAL_BRANCHES_CONTEXT_KEY) }) }) } @@ -354,7 +307,7 @@ func (self *WorktreeHelper) worktreeForBranchItem(branch *models.Branch) *types. prompt := utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptCheckout, map[string]string{"branchName": branch.Name}) return self.promptForWorktreeLocation(branch.Name, prompt, func(path string) error { - return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: branch.RefName()}) + return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: branch.RefName()}, context.LOCAL_BRANCHES_CONTEXT_KEY) }) }, DisabledReason: self.branchCheckedOutDisabledReason(branch), @@ -368,7 +321,7 @@ func (self *WorktreeHelper) worktreeForBranchItem(branch *models.Branch) *types. func (self *WorktreeHelper) detachedWorktreeItem(ref string, base string, defaultDirName string) *types.MenuItem { prompt := utils.ResolvePlaceholderString(self.c.Tr.WorktreeLocationPromptDetached, map[string]string{"ref": ref}) create := func(path string) error { - return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: base, Detach: true}) + return self.createWorktree(git_commands.NewWorktreeOpts{Path: path, Base: base, Detach: true}, context.LOCAL_BRANCHES_CONTEXT_KEY) } return &types.MenuItem{ @@ -458,13 +411,13 @@ func (self *WorktreeHelper) promptForWorktreeLocation(dirName string, prompt str }) } -func (self *WorktreeHelper) createWorktree(opts git_commands.NewWorktreeOpts) error { +func (self *WorktreeHelper) createWorktree(opts git_commands.NewWorktreeOpts, contextKey types.ContextKey) error { return self.c.WithWaitingStatus(self.c.Tr.AddingWorktree, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AddWorktree) if err := self.c.Git().Worktree.New(opts); err != nil { return err } - return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, context.LOCAL_BRANCHES_CONTEXT_KEY) + return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey) }) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index bb8d8637e..822e47c10 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -890,13 +890,10 @@ type TranslationSet struct { MainWorktree string NewWorktree string NewWorktreePath string - NewWorktreeBase string RemoveWorktreeTooltip string NewBranchName string - NewBranchNameLeaveBlank string - CreateWorktreeFrom string - CreateWorktreeFromDetached string NewWorktreeName string + NewWorktreeForBranchTitle string NewBranchAndWorktreeName string NewBranchAndWorktreeFromRef string NewLocalBranchAndWorktreeFromRef string @@ -2035,13 +2032,10 @@ func EnglishTranslationSet() *TranslationSet { MainWorktree: "(main worktree)", NewWorktree: "New worktree", NewWorktreePath: "New worktree path", - NewWorktreeBase: "New worktree base ref", RemoveWorktreeTooltip: "Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory.", NewBranchName: "New branch name", - NewBranchNameLeaveBlank: "New branch name (leave blank to checkout {{.default}})", - CreateWorktreeFrom: "Create worktree from {{.ref}}", - CreateWorktreeFromDetached: "Create worktree from {{.ref}} (detached)", NewWorktreeName: "New worktree name", + NewWorktreeForBranchTitle: "New worktree for branch", NewBranchAndWorktreeName: "New branch and worktree name", NewBranchAndWorktreeFromRef: "New branch and worktree from '{{.ref}}'", NewLocalBranchAndWorktreeFromRef: "New local branch and worktree from '{{.ref}}'", diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 443b29fad..5aa8a21ee 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -517,6 +517,8 @@ var tests = []*components.IntegrationTest{ worktree.ForceRemoveWorktree, worktree.ForceRemoveWorktreeWithSubmodules, worktree.LocationCandidates, + worktree.NewWorktreePicker, + worktree.NewWorktreePickerRemote, worktree.RemoveWorktreeFromBranch, worktree.ResetWindowTabs, worktree.SymlinkIntoRepoSubdir, diff --git a/pkg/integration/tests/worktree/crud.go b/pkg/integration/tests/worktree/crud.go index cd539d10b..6cda94141 100644 --- a/pkg/integration/tests/worktree/crud.go +++ b/pkg/integration/tests/worktree/crud.go @@ -33,25 +33,23 @@ var Crud = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Universal.New). Tap(func() { - t.ExpectPopup().Menu(). - Title(Equals("Worktree")). - Select(Contains(`Create worktree from ref`).DoesNotContain(("detached"))). + // a name that isn't an existing branch creates a new branch off + // the current one + t.ExpectPopup().Prompt(). + Title(Equals("New worktree for branch")). + Type("newbranch"). Confirm() - t.ExpectPopup().Prompt(). - Title(Equals("New worktree base ref")). - InitialText(Equals("mybranch")). + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + Select(Contains("Other…")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New worktree path")). + Clear(). Type("../linked-worktree"). Confirm() - - t.ExpectPopup().Prompt(). - Title(Equals("New branch name (leave blank to checkout mybranch)")). - Type("newbranch"). - Confirm() }). Lines( Contains("linked-worktree").IsSelected(), diff --git a/pkg/integration/tests/worktree/new_worktree_picker.go b/pkg/integration/tests/worktree/new_worktree_picker.go new file mode 100644 index 000000000..58915e8b5 --- /dev/null +++ b/pkg/integration/tests/worktree/new_worktree_picker.go @@ -0,0 +1,64 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var NewWorktreePicker = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "From the worktrees panel, the picker suggests only branches not already checked out, guards verbatim type-ins, and checks out an existing branch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.NewBranchFrom("feature", "mybranch") + shell.Checkout("mybranch") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Worktrees(). + Focus(). + Lines( + Contains("(main worktree)"), + ). + Press(keys.Universal.New). + Tap(func() { + // mybranch is checked out by the current worktree, so it's not + // suggested; feature is + t.ExpectPopup().Prompt(). + Title(Equals("New worktree for branch")). + SuggestionLines(Contains("feature")). + // typing a checked-out branch verbatim is still rejected + Type("mybranch"). + Confirm() + + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("Branch mybranch is checked out by worktree repo")). + Confirm() + }). + Press(keys.Universal.New). + Tap(func() { + // picking an existing branch checks it out (no new branch) + t.ExpectPopup().Prompt(). + Title(Equals("New worktree for branch")). + Type("feature"). + Confirm() + + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + Confirm() + }). + // we stay in the worktrees panel, now switched into the new worktree + IsFocused(). + Lines( + Contains("feature").IsSelected(), + Contains("(main worktree)"), + ) + + t.Views().Status(). + Content(Contains("repo(feature) → feature")) + }, +}) diff --git a/pkg/integration/tests/worktree/new_worktree_picker_remote.go b/pkg/integration/tests/worktree/new_worktree_picker_remote.go new file mode 100644 index 000000000..55b156d7f --- /dev/null +++ b/pkg/integration/tests/worktree/new_worktree_picker_remote.go @@ -0,0 +1,60 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var NewWorktreePickerRemote = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "From the worktrees panel, picking a remote branch creates a new local tracking branch and worktree; remote branches whose local branch already exists are filtered out", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.NewBranch("feature") + shell.NewBranch("existing") + shell.CloneIntoRemote("origin") + shell.Checkout("master") + // "feature" now exists only on the remote; "existing" stays as a local + // branch (not checked out) that also has a remote counterpart + shell.RunCommand([]string{"git", "branch", "-D", "feature"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Worktrees(). + Focus(). + Press(keys.Universal.New). + Tap(func() { + // master is checked out, so neither it nor origin/master is + // offered; "existing" already has a local branch, so + // origin/existing is left out too (you'd reach it via the local + // entry); origin/feature has no local branch, so it's offered + t.ExpectPopup().Prompt(). + Title(Equals("New worktree for branch")). + SuggestionLines( + Contains("existing"), + Contains("origin/feature"), + ). + Type("origin/feature"). + Confirm() + + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + Confirm() + }). + IsFocused(). + Lines( + Contains("feature").IsSelected(), + Contains("(main worktree)"), + ) + + // the new worktree is on a local branch that tracks the remote one (the + // ✓ confirms tracking is set up) + t.Views().Branches(). + Focus(). + ContainsLines( + Contains("feature").Contains("✓").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/worktree/worktree_in_repo.go b/pkg/integration/tests/worktree/worktree_in_repo.go index 5f0ee66ec..f6b5b44a3 100644 --- a/pkg/integration/tests/worktree/worktree_in_repo.go +++ b/pkg/integration/tests/worktree/worktree_in_repo.go @@ -28,25 +28,21 @@ var WorktreeInRepo = NewIntegrationTest(NewIntegrationTestArgs{ ). Press(keys.Universal.New). Tap(func() { - t.ExpectPopup().Menu(). - Title(Equals("Worktree")). - Select(Contains(`Create worktree from ref`).DoesNotContain(("detached"))). + t.ExpectPopup().Prompt(). + Title(Equals("New worktree for branch")). + Type("newbranch"). Confirm() - t.ExpectPopup().Prompt(). - Title(Equals("New worktree base ref")). - InitialText(Equals("mybranch")). + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + Select(Contains("Other…")). Confirm() t.ExpectPopup().Prompt(). Title(Equals("New worktree path")). + Clear(). Type("linked-worktree"). Confirm() - - t.ExpectPopup().Prompt(). - Title(Equals("New branch name (leave blank to checkout mybranch)")). - Type("newbranch"). - Confirm() }). Lines( Contains("linked-worktree").IsSelected(), From d53a9ea854ae4529ef54e49e6ff6ea6926dd5ff6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 29 Jun 2026 17:43:30 +0200 Subject: [PATCH 177/384] Add MoveYamlKey helper to move config keys between sections RenameYamlKey can only rename a key in place, under the same parent. To migrate a keybinding from one section to another we need to relocate the key to a different parent mapping, which is a move, not a rename. MoveYamlKey creates intermediate maps at the destination as needed and prunes any maps left empty behind the key, so a section that held only the moved key doesn't linger as an empty mapping in the user's config. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/utils/yaml_utils/yaml_utils.go | 92 +++++++++++++++++++++++++ pkg/utils/yaml_utils/yaml_utils_test.go | 82 ++++++++++++++++++++++ 2 files changed, 174 insertions(+) diff --git a/pkg/utils/yaml_utils/yaml_utils.go b/pkg/utils/yaml_utils/yaml_utils.go index f8f7f0679..251d4dc01 100644 --- a/pkg/utils/yaml_utils/yaml_utils.go +++ b/pkg/utils/yaml_utils/yaml_utils.go @@ -101,6 +101,98 @@ func renameYamlKey(node *yaml.Node, path []string, newKey string) (error, bool) return renameYamlKey(valueNode, path[1:], newKey) } +// Takes the root node of a yaml document, the path to an existing key, and the +// path at which it should live instead. If the key exists, it (and its value) +// is moved to the new path, creating intermediate mapping nodes as needed, and +// any mapping nodes left empty behind it are removed. Does nothing if the key +// at oldPath doesn't exist. Returns an error if a key already exists at newPath, +// or if a node along either path exists but isn't a mapping. +func MoveYamlKey(rootNode *yaml.Node, oldPath []string, newPath []string) (error, bool) { + // Empty document: nothing to do. + if len(rootNode.Content) == 0 { + return nil, false + } + + body := rootNode.Content[0] + + // Bail out early if there's nothing to move. + oldParent, err := findContainingMap(body, oldPath, false) + if err != nil { + return err, false + } + if oldParent == nil { + return nil, false + } + keyNode, valueNode := LookupKey(oldParent, oldPath[len(oldPath)-1]) + if keyNode == nil { + return nil, false + } + + // Find or create the destination map, and make sure it's free. + newParent, err := findContainingMap(body, newPath, true) + if err != nil { + return err, false + } + newKey := newPath[len(newPath)-1] + if existing, _ := LookupKey(newParent, newKey); existing != nil { + return fmt.Errorf("new key `%s' already exists", newKey), false + } + + // Move the key, then prune any maps that became empty behind it. The + // destination is populated first so that a map shared by both paths isn't + // mistaken for empty during pruning. + RemoveKey(oldParent, oldPath[len(oldPath)-1]) + keyNode.Value = newKey + newParent.Content = append(newParent.Content, keyNode, valueNode) + removeEmptyMaps(body, oldPath[:len(oldPath)-1]) + + return nil, true +} + +// Descends path (excluding its final element) and returns the mapping node that +// should directly contain that final element. With create set, missing +// intermediate maps are created; otherwise a missing intermediate yields a nil +// result. Returns an error if a node along the path exists but isn't a mapping. +func findContainingMap(node *yaml.Node, path []string, create bool) (*yaml.Node, error) { + for _, key := range path[:len(path)-1] { + if node.Kind != yaml.MappingNode { + return nil, errors.New("yaml node in path is not a dictionary") + } + _, valueNode := LookupKey(node, key) + if valueNode == nil { + if !create { + return nil, nil + } + valueNode = &yaml.Node{Kind: yaml.MappingNode} + node.Content = append(node.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + valueNode) + } + node = valueNode + } + if node.Kind != yaml.MappingNode { + return nil, errors.New("yaml node in path is not a dictionary") + } + return node, nil +} + +// Walks path from node and removes any mapping that is empty once its child has +// been removed, cascading upward. Stops at the first non-empty ancestor (which +// keeps every ancestor above it non-empty too). +func removeEmptyMaps(node *yaml.Node, path []string) { + if len(path) == 0 { + return + } + _, child := LookupKey(node, path[0]) + if child == nil { + return + } + removeEmptyMaps(child, path[1:]) + if child.Kind == yaml.MappingNode && len(child.Content) == 0 { + RemoveKey(node, path[0]) + } +} + // Traverses a yaml document, calling the callback function for each node. The // callback is expected to modify the node in place func Walk(rootNode *yaml.Node, callback func(node *yaml.Node, path string)) error { diff --git a/pkg/utils/yaml_utils/yaml_utils_test.go b/pkg/utils/yaml_utils/yaml_utils_test.go index d4d1fe074..059f65022 100644 --- a/pkg/utils/yaml_utils/yaml_utils_test.go +++ b/pkg/utils/yaml_utils/yaml_utils_test.go @@ -103,6 +103,88 @@ func TestRenameYamlKey(t *testing.T) { } } +func TestMoveYamlKey(t *testing.T) { + tests := []struct { + name string + in string + oldPath []string + newPath []string + expectedOut string + expectedDidMove bool + expectedErr string + }{ + { + name: "move key into an existing section", + in: "keybinding:\n worktrees:\n viewWorktreeOptions: w\n universal:\n quit: q\n", + oldPath: []string{"keybinding", "worktrees", "viewWorktreeOptions"}, + newPath: []string{"keybinding", "universal", "newWorktree"}, + expectedOut: "keybinding:\n universal:\n quit: q\n newWorktree: w\n", + expectedDidMove: true, + }, + { + name: "create the destination section if it doesn't exist", + in: "keybinding:\n worktrees:\n viewWorktreeOptions: w\n", + oldPath: []string{"keybinding", "worktrees", "viewWorktreeOptions"}, + newPath: []string{"keybinding", "universal", "newWorktree"}, + expectedOut: "keybinding:\n universal:\n newWorktree: w\n", + expectedDidMove: true, + }, + { + name: "keep non-empty siblings when pruning the old section", + in: "keybinding:\n worktrees:\n viewWorktreeOptions: w\n other: x\n", + oldPath: []string{"keybinding", "worktrees", "viewWorktreeOptions"}, + newPath: []string{"keybinding", "universal", "newWorktree"}, + expectedOut: "keybinding:\n worktrees:\n other: x\n universal:\n newWorktree: w\n", + expectedDidMove: true, + }, + { + name: "don't rewrite file if the key doesn't exist", + in: "keybinding:\n universal:\n quit: q\n", + oldPath: []string{"keybinding", "worktrees", "viewWorktreeOptions"}, + newPath: []string{"keybinding", "universal", "newWorktree"}, + expectedOut: "keybinding:\n universal:\n quit: q\n", + expectedDidMove: false, + }, + + // Error cases + { + name: "destination key already exists", + in: "keybinding:\n worktrees:\n viewWorktreeOptions: w\n universal:\n newWorktree: x\n", + oldPath: []string{"keybinding", "worktrees", "viewWorktreeOptions"}, + newPath: []string{"keybinding", "universal", "newWorktree"}, + expectedOut: "keybinding:\n worktrees:\n viewWorktreeOptions: w\n universal:\n newWorktree: x\n", + expectedDidMove: false, + expectedErr: "new key `newWorktree' already exists", + }, + { + name: "node in path is not a dictionary", + in: "keybinding:\n worktrees: nonsense\n", + oldPath: []string{"keybinding", "worktrees", "viewWorktreeOptions"}, + newPath: []string{"keybinding", "universal", "newWorktree"}, + expectedOut: "keybinding:\n worktrees: nonsense\n", + expectedDidMove: false, + expectedErr: "yaml node in path is not a dictionary", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + node := unmarshalForTest(t, test.in) + actualErr, didMove := MoveYamlKey(&node, test.oldPath, test.newPath) + if test.expectedErr == "" { + assert.NoError(t, actualErr) + } else { + assert.EqualError(t, actualErr, test.expectedErr) + } + out := marshalForTest(t, &node) + + assert.Equal(t, test.expectedOut, out) + + assert.Equal(t, test.expectedDidMove, didMove) + }) + } +} + func TestWalk_paths(t *testing.T) { tests := []struct { name string From 737fb989670cd6d57fc6b9862a0957c9ddbc6afd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 29 Jun 2026 17:47:25 +0200 Subject: [PATCH 178/384] Move the new-worktree keybinding from worktrees to universal The command was renamed from "View worktree options" to "New worktree", but its keybinding config key was still 'worktrees.viewWorktreeOptions'. That name no longer matches the command, and the 'worktrees' section made little sense: it held a single binding that isn't even used in the worktrees panel (that panel uses universal.new), only in the branches, remotes, tags, commits, and stash panels. Other keybinding sections are named after the panel they're local to; this one wasn't local to any. Move it to universal.newWorktree, which describes the action and drops the spurious section, and migrate existing configs automatically. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-master/Config.md | 3 +- pkg/config/app_config.go | 23 ++++++- pkg/config/app_config_test.go | 67 +++++++++++++++++++ pkg/config/user_config.go | 10 +-- .../controllers/basic_commits_controller.go | 2 +- pkg/gui/controllers/branches_controller.go | 2 +- .../controllers/remote_branches_controller.go | 2 +- pkg/gui/controllers/stash_controller.go | 2 +- pkg/gui/controllers/tags_controller.go | 2 +- .../demo/worktree_create_from_branches.go | 2 +- .../tests/worktree/add_for_existing_branch.go | 4 +- .../tests/worktree/add_from_branch.go | 2 +- .../worktree/add_from_branch_detached.go | 2 +- .../tests/worktree/add_from_commit.go | 2 +- .../tests/worktree/add_from_remote_branch.go | 2 +- .../tests/worktree/add_from_stash.go | 2 +- .../tests/worktree/add_from_tag.go | 2 +- .../tests/worktree/location_candidates.go | 2 +- schema-master/config.json | 37 ++++------ 19 files changed, 121 insertions(+), 49 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 183a0c6b2..bf0f5c2c6 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -689,6 +689,7 @@ keybinding: confirmInEditor: [, ] remove: d new: "n" + newWorktree: w edit: e openFile: o scrollUpMain: [, K, ] @@ -769,8 +770,6 @@ keybinding: fetchRemote: f addForkRemote: F sortOrder: s - worktrees: - viewWorktreeOptions: w commits: squashDown: s renameCommit: r diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index e7267158a..313d71dcf 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -287,6 +287,26 @@ func computeMigratedConfig(path string, content []byte, changes *ChangesSet) ([] } } + pathsToMove := []struct { + oldPath []string + newPath []string + }{ + { + []string{"keybinding", "worktrees", "viewWorktreeOptions"}, + []string{"keybinding", "universal", "newWorktree"}, + }, + } + + for _, pathToMove := range pathsToMove { + err, didMove := yaml_utils.MoveYamlKey(&rootNode, pathToMove.oldPath, pathToMove.newPath) + if err != nil { + return nil, false, fmt.Errorf("Couldn't migrate config file at `%s` for key %s: %w", path, strings.Join(pathToMove.oldPath, "."), err) + } + if didMove { + changes.Add(fmt.Sprintf("Moved '%s' to '%s'", strings.Join(pathToMove.oldPath, "."), strings.Join(pathToMove.newPath, "."))) + } + } + err = changeNullKeybindingsToDisabled(&rootNode, changes) if err != nil { return nil, false, fmt.Errorf("Couldn't migrate config file at `%s`: %w", path, err) @@ -449,7 +469,8 @@ func migrateAllBranchesLogCmd(rootNode *yaml.Node, changes *ChangesSet) error { // We will later populate it with the individual allBranchesLogCmd record cmdsKeyNode = &yaml.Node{Kind: yaml.ScalarNode, Value: "allBranchesLogCmds"} cmdsValueNode = &yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{}} - gitNode.Content = append(gitNode.Content, + gitNode.Content = append( + gitNode.Content, cmdsKeyNode, cmdsValueNode, ) diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index 8e6c85f32..913bc47dc 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -78,6 +78,73 @@ keybinding: } } +func TestMigrationOfMovedKeys(t *testing.T) { + scenarios := []struct { + name string + input string + expected string + expectedDidChange bool + expectedChanges []string + }{ + { + name: "Empty String", + input: "", + expectedDidChange: false, + expectedChanges: []string{}, + }, + { + name: "No move needed", + input: `foo: + bar: 5 +`, + expectedDidChange: false, + expectedChanges: []string{}, + }, + { + name: "Move worktree keybinding into the universal section", + input: `keybinding: + universal: + quit: q + worktrees: + viewWorktreeOptions: w +`, + expected: `keybinding: + universal: + quit: q + newWorktree: w +`, + expectedDidChange: true, + expectedChanges: []string{"Moved 'keybinding.worktrees.viewWorktreeOptions' to 'keybinding.universal.newWorktree'"}, + }, + { + name: "Create the universal section if it doesn't exist", + input: `keybinding: + worktrees: + viewWorktreeOptions: w +`, + expected: `keybinding: + universal: + newWorktree: w +`, + expectedDidChange: true, + expectedChanges: []string{"Moved 'keybinding.worktrees.viewWorktreeOptions' to 'keybinding.universal.newWorktree'"}, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + changes := NewChangesSet() + actual, didChange, err := computeMigratedConfig("path doesn't matter", []byte(s.input), changes) + assert.NoError(t, err) + assert.Equal(t, s.expectedDidChange, didChange) + if didChange { + assert.Equal(t, s.expected, string(actual)) + } + assert.Equal(t, s.expectedChanges, changes.ToSliceFromOldest()) + }) + } +} + func TestMigrateNullKeybindingsToDisabled(t *testing.T) { scenarios := []struct { name string diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 844ef32bd..f83e26ea3 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -442,7 +442,6 @@ type KeybindingConfig struct { Status KeybindingStatusConfig `yaml:"status"` Files KeybindingFilesConfig `yaml:"files"` Branches KeybindingBranchesConfig `yaml:"branches"` - Worktrees KeybindingWorktreesConfig `yaml:"worktrees"` Commits KeybindingCommitsConfig `yaml:"commits"` AmendAttribute KeybindingAmendAttributeConfig `yaml:"amendAttribute"` Stash KeybindingStashConfig `yaml:"stash"` @@ -510,6 +509,7 @@ type KeybindingUniversalConfig struct { ConfirmInEditorAlt Keybinding `yaml:"confirmInEditor-alt"` Remove Keybinding `yaml:"remove"` New Keybinding `yaml:"new"` + NewWorktree Keybinding `yaml:"newWorktree"` Edit Keybinding `yaml:"edit"` OpenFile Keybinding `yaml:"openFile"` ScrollUpMain Keybinding `yaml:"scrollUpMain"` @@ -604,10 +604,6 @@ type KeybindingBranchesConfig struct { SortOrder Keybinding `yaml:"sortOrder"` } -type KeybindingWorktreesConfig struct { - ViewWorktreeOptions Keybinding `yaml:"viewWorktreeOptions"` -} - type KeybindingCommitsConfig struct { SquashDown Keybinding `yaml:"squashDown"` RenameCommit Keybinding `yaml:"renameCommit"` @@ -1035,6 +1031,7 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { ConfirmInEditorAlt: Keybinding{""}, Remove: Keybinding{"d"}, New: Keybinding{"n"}, + NewWorktree: Keybinding{"w"}, Edit: Keybinding{"e"}, OpenFile: Keybinding{"o"}, OpenRecentRepos: Keybinding{""}, @@ -1120,9 +1117,6 @@ func GetDefaultConfigForPlatform(platform string) *UserConfig { AddForkRemote: Keybinding{"F"}, SortOrder: Keybinding{"s"}, }, - Worktrees: KeybindingWorktreesConfig{ - ViewWorktreeOptions: Keybinding{"w"}, - }, Commits: KeybindingCommitsConfig{ SquashDown: Keybinding{"s"}, RenameCommit: Keybinding{"r"}, diff --git a/pkg/gui/controllers/basic_commits_controller.go b/pkg/gui/controllers/basic_commits_controller.go index f8b8d6783..410335712 100644 --- a/pkg/gui/controllers/basic_commits_controller.go +++ b/pkg/gui/controllers/basic_commits_controller.go @@ -90,7 +90,7 @@ func (self *BasicCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip, }, { - Keys: opts.GetKeys(opts.Config.Worktrees.ViewWorktreeOptions), + Keys: opts.GetKeys(opts.Config.Universal.NewWorktree), Handler: self.withItem(self.c.Helpers().Worktree.NewWorktreeMenuForCommit), Description: self.c.Tr.NewWorktree, OpensMenu: true, diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 7846febbf..27bef4b66 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -70,7 +70,7 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty Tooltip: self.c.Tr.MoveCommitsToNewBranchTooltip, }, { - Keys: opts.GetKeys(opts.Config.Worktrees.ViewWorktreeOptions), + Keys: opts.GetKeys(opts.Config.Universal.NewWorktree), Handler: self.withItem(self.c.Helpers().Worktree.NewWorktreeMenuForBranch), Description: self.c.Tr.NewWorktree, OpensMenu: true, diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go index d232730c6..f70145d7b 100644 --- a/pkg/gui/controllers/remote_branches_controller.go +++ b/pkg/gui/controllers/remote_branches_controller.go @@ -49,7 +49,7 @@ func (self *RemoteBranchesController) GetKeybindings(opts types.KeybindingsOpts) Description: self.c.Tr.NewBranch, }, { - Keys: opts.GetKeys(opts.Config.Worktrees.ViewWorktreeOptions), + Keys: opts.GetKeys(opts.Config.Universal.NewWorktree), Handler: self.withItem(self.c.Helpers().Worktree.NewWorktreeMenuForRemoteBranch), Description: self.c.Tr.NewWorktree, OpensMenu: true, diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 3f0bd9be2..06e6991c6 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -67,7 +67,7 @@ func (self *StashController) GetKeybindings(opts types.KeybindingsOpts) []*types Tooltip: self.c.Tr.NewBranchFromStashTooltip, }, { - Keys: opts.GetKeys(opts.Config.Worktrees.ViewWorktreeOptions), + Keys: opts.GetKeys(opts.Config.Universal.NewWorktree), Handler: self.withItem(self.c.Helpers().Worktree.NewWorktreeMenuForStash), Description: self.c.Tr.NewWorktree, OpensMenu: true, diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go index fd61e239d..879a73628 100644 --- a/pkg/gui/controllers/tags_controller.go +++ b/pkg/gui/controllers/tags_controller.go @@ -54,7 +54,7 @@ func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types. DisplayOnScreen: true, }, { - Keys: opts.GetKeys(opts.Config.Worktrees.ViewWorktreeOptions), + Keys: opts.GetKeys(opts.Config.Universal.NewWorktree), Handler: self.withItem(self.c.Helpers().Worktree.NewWorktreeMenuForTag), Description: self.c.Tr.NewWorktree, OpensMenu: true, diff --git a/pkg/integration/tests/demo/worktree_create_from_branches.go b/pkg/integration/tests/demo/worktree_create_from_branches.go index 817cb704b..64e0761cc 100644 --- a/pkg/integration/tests/demo/worktree_create_from_branches.go +++ b/pkg/integration/tests/demo/worktree_create_from_branches.go @@ -34,7 +34,7 @@ var WorktreeCreateFromBranches = NewIntegrationTest(NewIntegrationTestArgs{ Focus(). NavigateToLine(Contains("master")). Wait(500). - Press(keys.Worktrees.ViewWorktreeOptions). + Press(keys.Universal.NewWorktree). Tap(func() { t.Wait(500) diff --git a/pkg/integration/tests/worktree/add_for_existing_branch.go b/pkg/integration/tests/worktree/add_for_existing_branch.go index fb238bcda..7fbbc3fc8 100644 --- a/pkg/integration/tests/worktree/add_for_existing_branch.go +++ b/pkg/integration/tests/worktree/add_for_existing_branch.go @@ -26,7 +26,7 @@ var AddForExistingBranch = NewIntegrationTest(NewIntegrationTestArgs{ ). // the current branch is checked out by this worktree, so "Worktree // for 'mybranch'" is disabled - Press(keys.Worktrees.ViewWorktreeOptions). + Press(keys.Universal.NewWorktree). Tap(func() { t.ExpectPopup(). Menu(). @@ -41,7 +41,7 @@ var AddForExistingBranch = NewIntegrationTest(NewIntegrationTestArgs{ }). // otherbranch is not checked out anywhere, so we can make a worktree for it NavigateToLine(Contains("otherbranch")). - Press(keys.Worktrees.ViewWorktreeOptions). + Press(keys.Universal.NewWorktree). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("New worktree")). diff --git a/pkg/integration/tests/worktree/add_from_branch.go b/pkg/integration/tests/worktree/add_from_branch.go index 87898c794..46dbc09f5 100644 --- a/pkg/integration/tests/worktree/add_from_branch.go +++ b/pkg/integration/tests/worktree/add_from_branch.go @@ -21,7 +21,7 @@ var AddFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("mybranch"), ). - Press(keys.Worktrees.ViewWorktreeOptions). + Press(keys.Universal.NewWorktree). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("New worktree")). diff --git a/pkg/integration/tests/worktree/add_from_branch_detached.go b/pkg/integration/tests/worktree/add_from_branch_detached.go index 6b74baff6..f17a50fef 100644 --- a/pkg/integration/tests/worktree/add_from_branch_detached.go +++ b/pkg/integration/tests/worktree/add_from_branch_detached.go @@ -21,7 +21,7 @@ var AddFromBranchDetached = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("mybranch"), ). - Press(keys.Worktrees.ViewWorktreeOptions). + Press(keys.Universal.NewWorktree). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("New worktree")). diff --git a/pkg/integration/tests/worktree/add_from_commit.go b/pkg/integration/tests/worktree/add_from_commit.go index 484370ae3..21f26e4a2 100644 --- a/pkg/integration/tests/worktree/add_from_commit.go +++ b/pkg/integration/tests/worktree/add_from_commit.go @@ -24,7 +24,7 @@ var AddFromCommit = NewIntegrationTest(NewIntegrationTestArgs{ Contains("initial commit"), ). NavigateToLine(Contains("initial commit")). - Press(keys.Worktrees.ViewWorktreeOptions). + Press(keys.Universal.NewWorktree). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("New worktree")). diff --git a/pkg/integration/tests/worktree/add_from_remote_branch.go b/pkg/integration/tests/worktree/add_from_remote_branch.go index 6dd4ea761..ac3d421fa 100644 --- a/pkg/integration/tests/worktree/add_from_remote_branch.go +++ b/pkg/integration/tests/worktree/add_from_remote_branch.go @@ -30,7 +30,7 @@ var AddFromRemoteBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().RemoteBranches(). IsFocused(). NavigateToLine(Contains("feature")). - Press(keys.Worktrees.ViewWorktreeOptions). + Press(keys.Universal.NewWorktree). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("New worktree")). diff --git a/pkg/integration/tests/worktree/add_from_stash.go b/pkg/integration/tests/worktree/add_from_stash.go index 26a3b97c4..c2541183c 100644 --- a/pkg/integration/tests/worktree/add_from_stash.go +++ b/pkg/integration/tests/worktree/add_from_stash.go @@ -23,7 +23,7 @@ var AddFromStash = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("my stash").IsSelected(), ). - Press(keys.Worktrees.ViewWorktreeOptions). + Press(keys.Universal.NewWorktree). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("New worktree")). diff --git a/pkg/integration/tests/worktree/add_from_tag.go b/pkg/integration/tests/worktree/add_from_tag.go index 22ef2bb07..bab7c03d8 100644 --- a/pkg/integration/tests/worktree/add_from_tag.go +++ b/pkg/integration/tests/worktree/add_from_tag.go @@ -22,7 +22,7 @@ var AddFromTag = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("v1.0").IsSelected(), ). - Press(keys.Worktrees.ViewWorktreeOptions). + Press(keys.Universal.NewWorktree). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("New worktree")). diff --git a/pkg/integration/tests/worktree/location_candidates.go b/pkg/integration/tests/worktree/location_candidates.go index 2160c6109..5fd835626 100644 --- a/pkg/integration/tests/worktree/location_candidates.go +++ b/pkg/integration/tests/worktree/location_candidates.go @@ -24,7 +24,7 @@ var LocationCandidates = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Branches(). Focus(). NavigateToLine(Contains("mybranch")). - Press(keys.Worktrees.ViewWorktreeOptions). + Press(keys.Universal.NewWorktree). Tap(func() { t.ExpectPopup().Menu(). Title(Equals("New worktree")). diff --git a/schema-master/config.json b/schema-master/config.json index 9963aae61..5a0af4eb4 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -1658,9 +1658,6 @@ "branches": { "$ref": "#/$defs/KeybindingBranchesConfig" }, - "worktrees": { - "$ref": "#/$defs/KeybindingWorktreesConfig" - }, "commits": { "$ref": "#/$defs/KeybindingCommitsConfig" }, @@ -2881,6 +2878,20 @@ ], "default": "n" }, + "newWorktree": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "w" + }, "edit": { "oneOf": [ { @@ -3407,26 +3418,6 @@ "additionalProperties": false, "type": "object" }, - "KeybindingWorktreesConfig": { - "properties": { - "viewWorktreeOptions": { - "oneOf": [ - { - "type": "string" - }, - { - "items": { - "type": "string" - }, - "type": "array" - } - ], - "default": "w" - } - }, - "additionalProperties": false, - "type": "object" - }, "LogConfig": { "properties": { "order": { From 7a67cea68760c21d680ae528a2ebb72249dca1b8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 13:35:11 +0200 Subject: [PATCH 179/384] Expand a leading ~ in worktree paths to the home directory Lazygit runs git directly rather than through a shell, so a literal "~" reaches `git worktree add` unexpanded and git creates a directory named "~" instead of using the home directory. Expand the tilde ourselves, both for paths typed into the "Other" location prompt and for the worktree.defaultPath config value. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-master/Config.md | 1 + pkg/config/user_config.go | 1 + .../controllers/helpers/worktree_helper.go | 6 ++- pkg/integration/tests/test_list.go | 1 + .../tests/worktree/default_path_tilde.go | 51 +++++++++++++++++++ pkg/utils/utils.go | 24 +++++++++ pkg/utils/utils_test.go | 27 ++++++++++ schema-master/config.json | 2 +- 8 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 pkg/integration/tests/worktree/default_path_tilde.go diff --git a/docs-master/Config.md b/docs-master/Config.md index bf0f5c2c6..152044be6 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -533,6 +533,7 @@ worktree: # location alongside the parent directories of any worktrees you already have. # A relative path is resolved against the repository's root directory, so # "../worktrees" sits beside the repo and ".worktrees" sits inside it. + # A leading "~" is expanded to your home directory, so "~/worktrees" works. defaultPath: "" # Periodic update checks diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index f83e26ea3..8314701ba 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -427,6 +427,7 @@ type CommitPrefixConfig struct { type WorktreeConfig struct { // Default parent directory for new worktrees. It is offered as a candidate location alongside the parent directories of any worktrees you already have. // A relative path is resolved against the repository's root directory, so "../worktrees" sits beside the repo and ".worktrees" sits inside it. + // A leading "~" is expanded to your home directory, so "~/worktrees" works. DefaultPath string `yaml:"defaultPath"` } diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 2bc877444..7a32d898c 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -378,7 +378,7 @@ func (self *WorktreeHelper) promptForWorktreeLocation(dirName string, prompt str parentDirs := worktreeParentDirCandidates( self.c.Git().RepoPaths.RepoPath(), linkedWorktreePaths, - self.c.UserConfig().Worktree.DefaultPath, + utils.ExpandTilde(self.c.UserConfig().Worktree.DefaultPath), ) targets := lo.Map(parentDirs, func(parentDir string, _ int) string { @@ -398,7 +398,9 @@ func (self *WorktreeHelper) promptForWorktreeLocation(dirName string, prompt str self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.NewWorktreePath, InitialContent: targets[0], - HandleConfirm: onConfirm, + HandleConfirm: func(response string) error { + return onConfirm(utils.ExpandTilde(response)) + }, }) return nil }, diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 5aa8a21ee..136cd579b 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -508,6 +508,7 @@ var tests = []*components.IntegrationTest{ worktree.BareRepoWorktreeConfig, worktree.Crud, worktree.CustomCommand, + worktree.DefaultPathTilde, worktree.DetachWorktreeFromBranch, worktree.DotfileBareRepo, worktree.DoubleNestedLinkedSubmodule, diff --git a/pkg/integration/tests/worktree/default_path_tilde.go b/pkg/integration/tests/worktree/default_path_tilde.go new file mode 100644 index 000000000..57486f1a8 --- /dev/null +++ b/pkg/integration/tests/worktree/default_path_tilde.go @@ -0,0 +1,51 @@ +package worktree + +import ( + "os" + "path/filepath" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DefaultPathTilde = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A leading ~ in the worktree.defaultPath config is expanded to the home directory", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Worktree.DefaultPath = "~/my-worktrees" + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + NavigateToLine(Contains("mybranch")). + Press(keys.Universal.NewWorktree). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("New worktree")). + Select(Contains("New branch and worktree from 'mybranch'")). + Confirm() + + t.ExpectPopup().Prompt(). + Title(Equals("New branch and worktree name")). + Type("newbranch"). + Confirm() + + // The default path's "~" is expanded to an absolute home-directory + // path; without expansion it would stay a literal "~" resolved + // against the repo, so the candidate would still contain a "~". + home, _ := os.UserHomeDir() + t.ExpectPopup().Menu(). + Title(Equals("Worktree location")). + ContainsLines( + Contains(filepath.Join(home, "my-worktrees", "newbranch")).DoesNotContain("~"), + ). + Cancel() + }) + }, +}) diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 40411d520..0494bb035 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "regexp" "runtime" "strconv" @@ -96,3 +97,26 @@ func FilePath(skip int) string { _, path, _, _ := runtime.Caller(skip) return path } + +// ExpandTilde expands a leading "~" that refers to the current user's home +// directory: "~" and "~/foo" become e.g. "/home/user" and "/home/user/foo". A +// tilde anywhere other than the start, or one immediately followed by a +// username ("~other/foo"), is left untouched, as is the path if the home +// directory can't be determined. We expand it ourselves because lazygit runs +// git directly, with no shell to do it for us. +func ExpandTilde(path string) string { + if path != "~" && !strings.HasPrefix(path, "~/") && + !(runtime.GOOS == "windows" && strings.HasPrefix(path, `~\`)) { + return path + } + + home, err := os.UserHomeDir() + if err != nil { + return path + } + + if path == "~" { + return home + } + return filepath.Join(home, path[2:]) +} diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 41b40cd9f..8304e7ba4 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -1,6 +1,8 @@ package utils import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -98,3 +100,28 @@ func TestModuloWithWrap(t *testing.T) { } } } + +func TestExpandTilde(t *testing.T) { + home, err := os.UserHomeDir() + assert.NoError(t, err) + + scenarios := []struct { + name string + path string + expected string + }{ + {"bare tilde", "~", home}, + {"tilde with subpath", "~/worktrees", filepath.Join(home, "worktrees")}, + {"absolute path is untouched", "/absolute/path", "/absolute/path"}, + {"relative path is untouched", "relative/path", "relative/path"}, + {"tilde not at the start is untouched", "/foo/~/bar", "/foo/~/bar"}, + {"tilde followed by a username is untouched", "~other/worktrees", "~other/worktrees"}, + {"empty string is untouched", "", ""}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.expected, ExpandTilde(s.path)) + }) + } +} diff --git a/schema-master/config.json b/schema-master/config.json index 5a0af4eb4..0dd1d5d20 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -3899,7 +3899,7 @@ "properties": { "defaultPath": { "type": "string", - "description": "Default parent directory for new worktrees. It is offered as a candidate location alongside the parent directories of any worktrees you already have.\nA relative path is resolved against the repository's root directory, so \"../worktrees\" sits beside the repo and \".worktrees\" sits inside it." + "description": "Default parent directory for new worktrees. It is offered as a candidate location alongside the parent directories of any worktrees you already have.\nA relative path is resolved against the repository's root directory, so \"../worktrees\" sits beside the repo and \".worktrees\" sits inside it.\nA leading \"~\" is expanded to your home directory, so \"~/worktrees\" works." } }, "additionalProperties": false, From 3f6a21f7f8c5b1cd65660ac7fbb897724ff23c07 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 1 Jul 2026 10:44:29 +0200 Subject: [PATCH 180/384] AGENTS.md additions --- AGENTS.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 58e0a38f3..46ad3e506 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -279,6 +279,29 @@ languages), and the map form extends cleanly when a string later needs more than one placeholder. This holds for every user-facing string, including short ones like disabled-action reasons and toasts. +## Only edit the English translations + +`pkg/i18n/english.go` is the one translation file you edit; add, change, and +remove strings there. The other languages under `pkg/i18n/translations/` are +maintained by Crowdin and synced automatically — never edit them by hand, not +even to add a key you just introduced or to delete one you just removed. A +removed English string simply leaves an orphan key in those files, which +Crowdin cleans up on its own; an unknown key in a translation file is ignored +at load time, so it does no harm in the meantime. + +## Try to keep new english.go strings within the existing column alignment + +`gofumpt` aligns the `TranslationSet` struct fields and the `EnglishTranslationSet` +literal into columns, so a new field whose name is longer than the widest one in +its alignment block re-indents every line in that block. When there are several +feature branches in flight that all add strings, that reformatting churn turns +english.go into a rebase-conflict magnet. So when it's cheap to do so, make an +effort to keep a new field name within the current widest name in the block +(measure it; it's around 40 characters today), shortening the Go field name to +fit. This is a soft preference, not a rule: the usual "best name wins" still +applies, so don't mangle a name past the point of readability just to save a +column. Applies only to `pkg/i18n/english.go`. + ## Code comments are for future readers, not development history Comments in source code explain *why this code is shaped the way it is*. They From d6016d628651f13ec1bee215c6017a057a645b66 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 18:52:56 +0200 Subject: [PATCH 181/384] Extract reusable branch-deletion helpers Pull the merged-check-and-force-warning step and the actual git deletion out of ConfirmLocalDelete and ConfirmLocalAndRemoteDelete into helpers, so that the upcoming worktree-aware delete flows can reuse them instead of duplicating the logic. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/branches_helper.go | 106 +++++++++--------- 1 file changed, 56 insertions(+), 50 deletions(-) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index ccc9d33ac..3e6c50b58 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -35,17 +35,9 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error return self.promptWorktreeBranchDelete(branches[0]) } - allBranchesMerged, err := self.allBranchesMerged(branches) - if err != nil { - return err - } - - doDelete := func() error { + return self.confirmForceIfUnmerged(branches, func() error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(_ gocui.Task) error { - self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch) - self.logBranchHashes(branches) - branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.Name }) - if err := self.c.Git().Branch.LocalDelete(branchNames, true); err != nil { + if err := self.doDeleteLocalBranches(branches); err != nil { return err } @@ -53,34 +45,7 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) return nil }) - } - - if allBranchesMerged { - return doDelete() - } - - title := self.c.Tr.ForceDeleteBranchTitle - var message string - if len(branches) == 1 { - message = utils.ResolvePlaceholderString( - self.c.Tr.ForceDeleteBranchMessage, - map[string]string{ - "selectedBranchName": branches[0].Name, - }, - ) - } else { - message = self.c.Tr.ForceDeleteBranchesMessage - } - - self.c.Confirm(types.ConfirmOpts{ - Title: title, - Prompt: message, - HandleConfirm: func() error { - return doDelete() - }, }) - - return nil } func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteBranch, resetRemoteBranchesSelection bool) error { @@ -169,19 +134,7 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branc Prompt: prompt, HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(task gocui.Task) error { - // Delete the remote branches first so that we keep the local ones - // in case of failure - remoteBranches := lo.Map(branches, func(branch *models.Branch, _ int) *models.RemoteBranch { - return &models.RemoteBranch{Name: branch.UpstreamBranch, RemoteName: branch.UpstreamRemote} - }) - if err := self.deleteRemoteBranches(remoteBranches, task); err != nil { - return err - } - - self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch) - self.logBranchHashes(branches) - branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.Name }) - if err := self.c.Git().Branch.LocalDelete(branchNames, true); err != nil { + if err := self.doDeleteLocalAndRemoteBranches(task, branches); err != nil { return err } @@ -244,6 +197,59 @@ func (self *BranchesHelper) promptWorktreeBranchDelete(selectedBranch *models.Br }) } +// confirmForceIfUnmerged runs onConfirm directly if all the branches are fully +// merged, and otherwise shows the force-delete warning first and runs onConfirm +// when the user confirms it. +func (self *BranchesHelper) confirmForceIfUnmerged(branches []*models.Branch, onConfirm func() error) error { + allBranchesMerged, err := self.allBranchesMerged(branches) + if err != nil { + return err + } + if allBranchesMerged { + return onConfirm() + } + + var message string + if len(branches) == 1 { + message = utils.ResolvePlaceholderString( + self.c.Tr.ForceDeleteBranchMessage, + map[string]string{ + "selectedBranchName": branches[0].Name, + }, + ) + } else { + message = self.c.Tr.ForceDeleteBranchesMessage + } + + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.ForceDeleteBranchTitle, + Prompt: message, + HandleConfirm: onConfirm, + }) + + return nil +} + +func (self *BranchesHelper) doDeleteLocalBranches(branches []*models.Branch) error { + self.c.LogAction(self.c.Tr.Actions.DeleteLocalBranch) + self.logBranchHashes(branches) + branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { return branch.Name }) + return self.c.Git().Branch.LocalDelete(branchNames, true) +} + +func (self *BranchesHelper) doDeleteLocalAndRemoteBranches(task gocui.Task, branches []*models.Branch) error { + // Delete the remote branches first so that we keep the local ones + // in case of failure + remoteBranches := lo.Map(branches, func(branch *models.Branch, _ int) *models.RemoteBranch { + return &models.RemoteBranch{Name: branch.UpstreamBranch, RemoteName: branch.UpstreamRemote} + }) + if err := self.deleteRemoteBranches(remoteBranches, task); err != nil { + return err + } + + return self.doDeleteLocalBranches(branches) +} + func (self *BranchesHelper) allBranchesMerged(branches []*models.Branch) (bool, error) { allBranchesMerged := true for _, branch := range branches { From 9a8244110ffaa538878e92a354df171912dd590a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 18:57:21 +0200 Subject: [PATCH 182/384] Let worktree removal/detach chain follow-up work Split the actual worktree removal out of the confirmation in Remove into a non-confirming helper, and give both Remove and Detach an optional `then` continuation that runs after a successful removal in place of the default refresh. Upcoming flows need to delete the worktree's branch once the worktree is out of the way; threading a continuation through (rather than the caller firing branch deletion independently) keeps it ordered after the git command that actually frees the branch. No behavior change yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/branches_helper.go | 4 +- .../controllers/helpers/worktree_helper.go | 84 ++++++++++++------- pkg/gui/controllers/worktrees_controller.go | 2 +- 3 files changed, 58 insertions(+), 32 deletions(-) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 3e6c50b58..18f19aa97 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -184,13 +184,13 @@ func (self *BranchesHelper) promptWorktreeBranchDelete(selectedBranch *models.Br Label: self.c.Tr.DetachWorktree, Tooltip: self.c.Tr.DetachWorktreeTooltip, OnPress: func() error { - return self.worktreeHelper.Detach(worktree) + return self.worktreeHelper.Detach(worktree, nil) }, }, { Label: self.c.Tr.RemoveWorktree, OnPress: func() error { - return self.worktreeHelper.Remove(worktree, false) + return self.worktreeHelper.Remove(worktree) }, }, }, diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 7a32d898c..84973d705 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -119,56 +119,82 @@ func (self *WorktreeHelper) Switch(worktree *models.Worktree, contextKey types.C return self.reposHelper.DispatchSwitchTo(worktree.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey) } -func (self *WorktreeHelper) Remove(worktree *models.Worktree, force bool) error { - title := self.c.Tr.RemoveWorktreeTitle - var templateStr string - if force { - templateStr = self.c.Tr.ForceRemoveWorktreePrompt - } else { - templateStr = self.c.Tr.RemoveWorktreePrompt - } +func (self *WorktreeHelper) Remove(worktree *models.Worktree) error { message := utils.ResolvePlaceholderString( - templateStr, + self.c.Tr.RemoveWorktreePrompt, map[string]string{ "worktreeName": worktree.Name, }, ) self.c.Confirm(types.ConfirmOpts{ - Title: title, + Title: self.c.Tr.RemoveWorktreeTitle, Prompt: message, HandleConfirm: func() error { - return self.c.WithWaitingStatus(self.c.Tr.RemovingWorktree, func(gocui.Task) error { - self.c.LogAction(self.c.Tr.RemoveWorktree) - if err := self.c.Git().Worktree.Delete(worktree.Path, force); err != nil { - errMessage := err.Error() - if !strings.Contains(errMessage, "--force") && - !strings.Contains(errMessage, "fatal: working trees containing submodules cannot be moved or removed") { - return err - } - - if !force { - return self.Remove(worktree, true) - } - return err - } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) - return nil - }) + return self.remove(worktree, false, nil) }, }) return nil } -func (self *WorktreeHelper) Detach(worktree *models.Worktree) error { - return self.c.WithWaitingStatus(self.c.Tr.DetachingWorktree, func(gocui.Task) error { +// remove deletes the worktree without confirming first; callers must have done +// so (or shown a menu) already. If git refuses because the worktree is dirty or +// contains submodules, we ask for confirmation and retry with --force. When then +// is non-nil it runs in place of the default refresh after a successful removal, +// letting callers chain further work such as deleting the worktree's branch. +func (self *WorktreeHelper) remove(worktree *models.Worktree, force bool, then func(gocui.Task) error) error { + return self.c.WithWaitingStatus(self.c.Tr.RemovingWorktree, func(task gocui.Task) error { + self.c.LogAction(self.c.Tr.RemoveWorktree) + if err := self.c.Git().Worktree.Delete(worktree.Path, force); err != nil { + errMessage := err.Error() + if !strings.Contains(errMessage, "--force") && + !strings.Contains(errMessage, "fatal: working trees containing submodules cannot be moved or removed") { + return err + } + + if force { + return err + } + + message := utils.ResolvePlaceholderString( + self.c.Tr.ForceRemoveWorktreePrompt, + map[string]string{ + "worktreeName": worktree.Name, + }, + ) + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.RemoveWorktreeTitle, + Prompt: message, + HandleConfirm: func() error { + return self.remove(worktree, true, then) + }, + }) + return nil + } + + if then != nil { + return then(task) + } + + self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) + return nil + }) +} + +func (self *WorktreeHelper) Detach(worktree *models.Worktree, then func(gocui.Task) error) error { + return self.c.WithWaitingStatus(self.c.Tr.DetachingWorktree, func(task gocui.Task) error { self.c.LogAction(self.c.Tr.RemovingWorktree) err := self.c.Git().Worktree.Detach(worktree.Path) if err != nil { return err } + + if then != nil { + return then(task) + } + self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) return nil }) diff --git a/pkg/gui/controllers/worktrees_controller.go b/pkg/gui/controllers/worktrees_controller.go index 5128ad716..4f87362d6 100644 --- a/pkg/gui/controllers/worktrees_controller.go +++ b/pkg/gui/controllers/worktrees_controller.go @@ -130,7 +130,7 @@ func (self *WorktreesController) remove(worktree *models.Worktree) error { return errors.New(self.c.Tr.CantDeleteCurrentWorktree) } - return self.c.Helpers().Worktree.Remove(worktree, false) + return self.c.Helpers().Worktree.Remove(worktree) } func (self *WorktreesController) GetOnDoubleClick() func() error { From 4f078f5463d78cb5ffd6825a22602f45a35263ca Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 19:02:32 +0200 Subject: [PATCH 183/384] Delete the branch when deleting it via its worktree When you delete a local branch that's checked out in another worktree, the menu offered to remove or detach the worktree but then stopped there, leaving the branch you asked to delete still around. Now both actions delete the branch afterwards, and the labels say so ("Remove worktree and delete branch" / "Detach worktree and delete branch") to avoid surprises. Also drop the "Switch to worktree" item: switching abandons the delete the user asked for, and it's already reachable by checking out the branch or via the worktrees panel. And drop the now-redundant "remove worktree?" confirmation: the explicit menu pick is the confirmation (the dirty-worktree force prompt and the unmerged-branch warning still appear when relevant). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/branches_helper.go | 61 ++++++++++++++----- pkg/i18n/english.go | 4 ++ .../worktree/detach_worktree_from_branch.go | 8 +-- .../worktree/remove_worktree_from_branch.go | 14 ++--- 4 files changed, 59 insertions(+), 28 deletions(-) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 18f19aa97..73db775fe 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -8,7 +8,6 @@ import ( "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/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" @@ -32,7 +31,12 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error return errors.New(self.c.Tr.SomeBranchesCheckedOutByWorktreeError) } } else if self.checkedOutByOtherWorktree(branches[0]) { - return self.promptWorktreeBranchDelete(branches[0]) + return self.promptWorktreeBranchDelete( + branches[0], + self.c.Tr.RemoveWorktreeAndDeleteBranch, + self.c.Tr.DetachWorktreeAndDeleteBranch, + self.deleteLocalBranchesContinuation(branches), + ) } return self.confirmForceIfUnmerged(branches, func() error { @@ -160,8 +164,18 @@ func (self *BranchesHelper) worktreeForBranch(branch *models.Branch) (*models.Wo return git_commands.WorktreeForBranch(branch, self.c.Model().Worktrees) } -func (self *BranchesHelper) promptWorktreeBranchDelete(selectedBranch *models.Branch) error { - worktree, ok := self.worktreeForBranch(selectedBranch) +// promptWorktreeBranchDelete handles deleting a branch that's checked out by +// another worktree: the worktree has to be removed or detached first to free the +// branch, so we offer both as menu items. Either way the branch is deleted +// afterwards (that's what the user asked for), via deleteBranches, which knows +// whether to delete just the local branch or the remote one too. +func (self *BranchesHelper) promptWorktreeBranchDelete( + branch *models.Branch, + removeLabel string, + detachLabel string, + deleteBranches func(gocui.Task) error, +) error { + worktree, ok := self.worktreeForBranch(branch) if !ok { self.c.Log.Error("promptWorktreeBranchDelete out of sync with list of worktrees") return nil @@ -169,28 +183,28 @@ func (self *BranchesHelper) promptWorktreeBranchDelete(selectedBranch *models.Br title := utils.ResolvePlaceholderString(self.c.Tr.BranchCheckedOutByWorktree, map[string]string{ "worktreeName": worktree.Name, - "branchName": selectedBranch.Name, + "branchName": branch.Name, }) return self.c.Menu(types.CreateMenuOptions{ Title: title, Items: []*types.MenuItem{ { - Label: self.c.Tr.SwitchToWorktree, + Label: removeLabel, + Keys: menuKey('r'), OnPress: func() error { - return self.worktreeHelper.Switch(worktree, context.LOCAL_BRANCHES_CONTEXT_KEY) + return self.confirmForceIfUnmerged([]*models.Branch{branch}, func() error { + return self.worktreeHelper.remove(worktree, false, deleteBranches) + }) }, }, { - Label: self.c.Tr.DetachWorktree, + Label: detachLabel, + Keys: menuKey('d'), Tooltip: self.c.Tr.DetachWorktreeTooltip, OnPress: func() error { - return self.worktreeHelper.Detach(worktree, nil) - }, - }, - { - Label: self.c.Tr.RemoveWorktree, - OnPress: func() error { - return self.worktreeHelper.Remove(worktree) + return self.confirmForceIfUnmerged([]*models.Branch{branch}, func() error { + return self.worktreeHelper.Detach(worktree, deleteBranches) + }) }, }, }, @@ -250,6 +264,23 @@ func (self *BranchesHelper) doDeleteLocalAndRemoteBranches(task gocui.Task, bran return self.doDeleteLocalBranches(branches) } +// deleteLocalBranchesContinuation returns a worktree-removal continuation that +// deletes the local branches and refreshes once the worktree is out of the way. +func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.Branch) func(gocui.Task) error { + return func(gocui.Task) error { + if err := self.doDeleteLocalBranches(branches); err != nil { + return err + } + + self.c.Contexts().Branches.CollapseRangeSelectionToTop() + self.c.Refresh(types.RefreshOptions{ + Mode: types.ASYNC, + Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}, + }) + return nil + } +} + func (self *BranchesHelper) allBranchesMerged(branches []*models.Branch) (bool, error) { allBranchesMerged := true for _, branch := range branches { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 822e47c10..213210fcd 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -874,7 +874,9 @@ type TranslationSet struct { Switching string RemoveWorktree string RemoveWorktreeTitle string + RemoveWorktreeAndDeleteBranch string DetachWorktree string + DetachWorktreeAndDeleteBranch string DetachingWorktree string WorktreesTitle string WorktreeTitle string @@ -2018,10 +2020,12 @@ func EnglishTranslationSet() *TranslationSet { Switching: "Switching", RemoveWorktree: "Remove worktree", RemoveWorktreeTitle: "Remove worktree", + RemoveWorktreeAndDeleteBranch: "Remove worktree and delete branch", RemoveWorktreePrompt: "Are you sure you want to remove worktree '{{.worktreeName}}'?", ForceRemoveWorktreePrompt: "'{{.worktreeName}}' contains modified or untracked files, or submodules (or all of these). Are you sure you want to remove it?", RemovingWorktree: "Deleting worktree", DetachWorktree: "Detach worktree", + DetachWorktreeAndDeleteBranch: "Detach worktree and delete branch", DetachingWorktree: "Detaching worktree", AddingWorktree: "Adding worktree", CantDeleteCurrentWorktree: "You cannot remove the current worktree!", diff --git a/pkg/integration/tests/worktree/detach_worktree_from_branch.go b/pkg/integration/tests/worktree/detach_worktree_from_branch.go index acd40e6ad..b36b89349 100644 --- a/pkg/integration/tests/worktree/detach_worktree_from_branch.go +++ b/pkg/integration/tests/worktree/detach_worktree_from_branch.go @@ -6,7 +6,7 @@ import ( ) var DetachWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Detach a worktree from the branches view", + Description: "Delete a branch that's checked out in another worktree by detaching that worktree", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, @@ -37,12 +37,12 @@ var DetachWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Branch newbranch is checked out by worktree linked-worktree")). - Select(Equals("Detach worktree")). + Select(Contains("Detach worktree and delete branch")). Confirm() }). + // The branch is gone; the worktree stays around (now detached) Lines( - Contains("mybranch"), - Contains("newbranch").DoesNotContain("(worktree)").IsSelected(), + Contains("mybranch").IsSelected(), ) t.Views().Worktrees(). diff --git a/pkg/integration/tests/worktree/remove_worktree_from_branch.go b/pkg/integration/tests/worktree/remove_worktree_from_branch.go index 1aa9645f3..7823af54c 100644 --- a/pkg/integration/tests/worktree/remove_worktree_from_branch.go +++ b/pkg/integration/tests/worktree/remove_worktree_from_branch.go @@ -6,7 +6,7 @@ import ( ) var RemoveWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ - Description: "Remove a worktree from the branches view", + Description: "Delete a branch that's checked out in another worktree by removing that worktree", ExtraCmdArgs: []string{}, Skip: false, SetupConfig: func(config *config.AppConfig) {}, @@ -38,22 +38,18 @@ var RemoveWorktreeFromBranch = NewIntegrationTest(NewIntegrationTestArgs{ Tap(func() { t.ExpectPopup().Menu(). Title(Equals("Branch newbranch is checked out by worktree linked-worktree")). - Select(Equals("Remove worktree")). - Confirm() - - t.ExpectPopup().Confirmation(). - Title(Equals("Remove worktree")). - Content(Equals("Are you sure you want to remove worktree 'linked-worktree'?")). + Select(Contains("Remove worktree and delete branch")). Confirm() + // The worktree is dirty, so we get asked to force-remove it t.ExpectPopup().Confirmation(). Title(Equals("Remove worktree")). Content(Equals("'linked-worktree' contains modified or untracked files, or submodules (or all of these). Are you sure you want to remove it?")). Confirm() }). + // The branch is gone, not just unlinked from its worktree Lines( - Contains("mybranch"), - Contains("newbranch").DoesNotContain("(worktree)").IsSelected(), + Contains("mybranch").IsSelected(), ) t.Views().Worktrees(). From 22914da8e5c1c2dedb82d343d8c9915cda249e48 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 1 Jul 2026 10:42:13 +0200 Subject: [PATCH 184/384] Allow deleting local+remote of a worktree-checked-out branch at once Picking "Delete local and remote branch" for a single branch that's checked out in another worktree used to fail with "Some of the selected branches are checked out by other worktrees. Select them one by one to delete them." That message only makes sense for a multi-selection; for a single branch there's no reason we can't remove the worktree and delete both the local and remote branch in one go. Route that case through the same worktree menu as the local-only delete, with labels that spell out that the remote goes too. The multi-select error stays for actual multi-selections. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/branches_helper.go | 31 +++++++- pkg/i18n/english.go | 4 ++ pkg/integration/tests/test_list.go | 2 + ...tree_and_delete_local_and_remote_branch.go | 71 +++++++++++++++++++ 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 pkg/integration/tests/worktree/remove_worktree_and_delete_local_and_remote_branch.go diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 73db775fe..7c873f8f3 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -97,8 +97,17 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteB } func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branch) error { - if lo.SomeBy(branches, func(branch *models.Branch) bool { return self.checkedOutByOtherWorktree(branch) }) { - return errors.New(self.c.Tr.SomeBranchesCheckedOutByWorktreeError) + if len(branches) > 1 { + if lo.SomeBy(branches, func(branch *models.Branch) bool { return self.checkedOutByOtherWorktree(branch) }) { + return errors.New(self.c.Tr.SomeBranchesCheckedOutByWorktreeError) + } + } else if self.checkedOutByOtherWorktree(branches[0]) { + return self.promptWorktreeBranchDelete( + branches[0], + self.c.Tr.RemoveWorktreeAndDeleteBothBranches, + self.c.Tr.DetachWorktreeAndDeleteBothBranches, + self.deleteLocalAndRemoteBranchesContinuation(branches), + ) } allBranchesMerged, err := self.allBranchesMerged(branches) @@ -281,6 +290,24 @@ func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.B } } +// deleteLocalAndRemoteBranchesContinuation returns a worktree-removal +// continuation that deletes the local and remote branches and refreshes once the +// worktree is out of the way. +func (self *BranchesHelper) deleteLocalAndRemoteBranchesContinuation(branches []*models.Branch) func(gocui.Task) error { + return func(task gocui.Task) error { + if err := self.doDeleteLocalAndRemoteBranches(task, branches); err != nil { + return err + } + + self.c.Contexts().Branches.CollapseRangeSelectionToTop() + self.c.Refresh(types.RefreshOptions{ + Mode: types.ASYNC, + Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.REMOTES, types.FILES}, + }) + return nil + } +} + func (self *BranchesHelper) allBranchesMerged(branches []*models.Branch) (bool, error) { allBranchesMerged := true for _, branch := range branches { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 213210fcd..91f514796 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -875,8 +875,10 @@ type TranslationSet struct { RemoveWorktree string RemoveWorktreeTitle string RemoveWorktreeAndDeleteBranch string + RemoveWorktreeAndDeleteBothBranches string DetachWorktree string DetachWorktreeAndDeleteBranch string + DetachWorktreeAndDeleteBothBranches string DetachingWorktree string WorktreesTitle string WorktreeTitle string @@ -2021,11 +2023,13 @@ func EnglishTranslationSet() *TranslationSet { RemoveWorktree: "Remove worktree", RemoveWorktreeTitle: "Remove worktree", RemoveWorktreeAndDeleteBranch: "Remove worktree and delete branch", + RemoveWorktreeAndDeleteBothBranches: "Remove worktree and delete local and remote branch", RemoveWorktreePrompt: "Are you sure you want to remove worktree '{{.worktreeName}}'?", ForceRemoveWorktreePrompt: "'{{.worktreeName}}' contains modified or untracked files, or submodules (or all of these). Are you sure you want to remove it?", RemovingWorktree: "Deleting worktree", DetachWorktree: "Detach worktree", DetachWorktreeAndDeleteBranch: "Detach worktree and delete branch", + DetachWorktreeAndDeleteBothBranches: "Detach worktree and delete local and remote branch", DetachingWorktree: "Detaching worktree", AddingWorktree: "Adding worktree", CantDeleteCurrentWorktree: "You cannot remove the current worktree!", diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 136cd579b..ed32775d4 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -520,6 +520,8 @@ var tests = []*components.IntegrationTest{ worktree.LocationCandidates, worktree.NewWorktreePicker, worktree.NewWorktreePickerRemote, + worktree.RemoveWorktreeAndBothBranches, + worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch, worktree.RemoveWorktreeFromBranch, worktree.ResetWindowTabs, worktree.SymlinkIntoRepoSubdir, diff --git a/pkg/integration/tests/worktree/remove_worktree_and_delete_local_and_remote_branch.go b/pkg/integration/tests/worktree/remove_worktree_and_delete_local_and_remote_branch.go new file mode 100644 index 000000000..09fae45eb --- /dev/null +++ b/pkg/integration/tests/worktree/remove_worktree_and_delete_local_and_remote_branch.go @@ -0,0 +1,71 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RemoveWorktreeAndDeleteLocalAndRemoteBranch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Delete the local branch, the remote branch, and the worktree of a single branch checked out in another worktree, all at once", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CloneIntoRemote("origin") + shell.EmptyCommit("initial commit") + shell.NewBranch("mybranch") + shell.EmptyCommit("commit on mybranch") + shell.PushBranchAndSetUpstream("origin", "mybranch") + shell.EmptyCommit("commit not pushed to the remote") // so mybranch isn't fully merged + shell.Checkout("master") + shell.AddWorktreeCheckout("mybranch", "../linked-worktree") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + Contains("mybranch (worktree linked-worktree)"), + ). + NavigateToLine(Contains("mybranch")). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Delete branch 'mybranch'?")). + Select(Contains("Delete local and remote branch")). + Confirm() + }). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Branch mybranch is checked out by worktree linked-worktree")). + Select(Contains("Remove worktree and delete local and remote branch")). + Confirm() + + // mybranch is not contained in master, so we get the force-delete warning + t.ExpectPopup().Confirmation(). + Title(Equals("Force delete branch")). + Content(Equals("'mybranch' is not fully merged. Are you sure you want to delete it?")). + Confirm() + }). + // The local branch is gone + Lines( + Contains("master").IsSelected(), + ) + + // The remote branch is gone too + t.Views().Remotes(). + Focus(). + Lines(Contains("origin")). + PressEnter() + + t.Views().RemoteBranches(). + IsEmpty() + + // And so is the worktree + t.Views().Worktrees(). + Focus(). + Lines( + Contains("(main worktree)").IsSelected(), + ) + }, +}) From 0aed44c7f33355a53fbb6cd1769c413e9e4b1490 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 1 Jul 2026 10:44:11 +0200 Subject: [PATCH 185/384] Offer to delete the branch when removing a worktree Pressing `d` on a worktree only ever removed the worktree, leaving its branch behind even though deleting it too is often what you want. Turn the confirmation into a menu: "Remove worktree", "Remove worktree and delete branch", and "Remove worktree and delete local and remote branch". The branch-deleting items come after the plain removal (they do more harm if picked by accident); both are greyed out for a detached-HEAD worktree, and the local-and-remote one is also greyed when the branch has no upstream. The plain menu pick is the confirmation, so the standalone "remove worktree?" prompt is gone (and its now-dead translation string with it); the dirty-worktree force prompt and the unmerged-branch warning still appear when relevant. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/branches_helper.go | 32 +++++++- .../controllers/helpers/worktree_helper.go | 30 ++------ pkg/gui/controllers/worktrees_controller.go | 49 ++++++++++++- pkg/i18n/english.go | 6 +- pkg/integration/tests/test_list.go | 1 + pkg/integration/tests/worktree/crud.go | 6 +- .../tests/worktree/force_remove_worktree.go | 6 +- .../force_remove_worktree_with_submodules.go | 6 +- .../remove_worktree_and_both_branches.go | 64 ++++++++++++++++ .../worktree/remove_worktree_and_branch.go | 73 +++++++++++++++++++ 10 files changed, 238 insertions(+), 35 deletions(-) create mode 100644 pkg/integration/tests/worktree/remove_worktree_and_both_branches.go create mode 100644 pkg/integration/tests/worktree/remove_worktree_and_branch.go diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 7c873f8f3..4283bd29a 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -202,7 +202,7 @@ func (self *BranchesHelper) promptWorktreeBranchDelete( Keys: menuKey('r'), OnPress: func() error { return self.confirmForceIfUnmerged([]*models.Branch{branch}, func() error { - return self.worktreeHelper.remove(worktree, false, deleteBranches) + return self.worktreeHelper.Remove(worktree, deleteBranches) }) }, }, @@ -220,6 +220,36 @@ func (self *BranchesHelper) promptWorktreeBranchDelete( }) } +// RemoveWorktreeAndDeleteBranch removes the worktree and deletes the local branch +// it has checked out, force-warning first if the branch isn't fully merged. It's +// the worktrees-panel counterpart to deleting a worktree-checked-out branch from +// the branches panel. +func (self *BranchesHelper) RemoveWorktreeAndDeleteBranch( + worktree *models.Worktree, branch *models.Branch, +) error { + branches := []*models.Branch{branch} + return self.removeWorktreeAndDelete(worktree, branches, + self.deleteLocalBranchesContinuation(branches)) +} + +// RemoveWorktreeAndDeleteBothBranches is like RemoveWorktreeAndDeleteBranch but +// also deletes the branch's upstream. +func (self *BranchesHelper) RemoveWorktreeAndDeleteBothBranches( + worktree *models.Worktree, branch *models.Branch, +) error { + branches := []*models.Branch{branch} + return self.removeWorktreeAndDelete(worktree, branches, + self.deleteLocalAndRemoteBranchesContinuation(branches)) +} + +func (self *BranchesHelper) removeWorktreeAndDelete( + worktree *models.Worktree, branches []*models.Branch, deleteBranches func(gocui.Task) error, +) error { + return self.confirmForceIfUnmerged(branches, func() error { + return self.worktreeHelper.Remove(worktree, deleteBranches) + }) +} + // confirmForceIfUnmerged runs onConfirm directly if all the branches are fully // merged, and otherwise shows the force-delete warning first and runs onConfirm // when the user confirms it. diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 84973d705..7cec9f873 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -119,30 +119,16 @@ func (self *WorktreeHelper) Switch(worktree *models.Worktree, contextKey types.C return self.reposHelper.DispatchSwitchTo(worktree.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey) } -func (self *WorktreeHelper) Remove(worktree *models.Worktree) error { - message := utils.ResolvePlaceholderString( - self.c.Tr.RemoveWorktreePrompt, - map[string]string{ - "worktreeName": worktree.Name, - }, - ) - - self.c.Confirm(types.ConfirmOpts{ - Title: self.c.Tr.RemoveWorktreeTitle, - Prompt: message, - HandleConfirm: func() error { - return self.remove(worktree, false, nil) - }, - }) - - return nil +// Remove deletes the worktree without confirming first; callers are expected to +// have confirmed (or shown a menu) already. If git refuses because the worktree +// is dirty or contains submodules, we ask for confirmation and retry with +// --force. When then is non-nil it runs in place of the default refresh after a +// successful removal, letting callers chain further work such as deleting the +// worktree's branch. +func (self *WorktreeHelper) Remove(worktree *models.Worktree, then func(gocui.Task) error) error { + return self.remove(worktree, false, then) } -// remove deletes the worktree without confirming first; callers must have done -// so (or shown a menu) already. If git refuses because the worktree is dirty or -// contains submodules, we ask for confirmation and retry with --force. When then -// is non-nil it runs in place of the default refresh after a successful removal, -// letting callers chain further work such as deleting the worktree's branch. func (self *WorktreeHelper) remove(worktree *models.Worktree, force bool, then func(gocui.Task) error) error { return self.c.WithWaitingStatus(self.c.Tr.RemovingWorktree, func(task gocui.Task) error { self.c.LogAction(self.c.Tr.RemoveWorktree) diff --git a/pkg/gui/controllers/worktrees_controller.go b/pkg/gui/controllers/worktrees_controller.go index 4f87362d6..02d20b3f2 100644 --- a/pkg/gui/controllers/worktrees_controller.go +++ b/pkg/gui/controllers/worktrees_controller.go @@ -11,6 +11,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" ) type WorktreesController struct { @@ -130,7 +131,53 @@ func (self *WorktreesController) remove(worktree *models.Worktree) error { return errors.New(self.c.Tr.CantDeleteCurrentWorktree) } - return self.c.Helpers().Worktree.Remove(worktree) + removeWorktreeItem := &types.MenuItem{ + Label: self.c.Tr.RemoveWorktree, + Keys: menuKey('w'), + OnPress: func() error { + return self.c.Helpers().Worktree.Remove(worktree, nil) + }, + } + + branch, branchFound := lo.Find(self.c.Model().Branches, func(branch *models.Branch) bool { + return branch.Name == worktree.Branch + }) + // A worktree with a detached HEAD has no branch to delete + detachedReason := &types.DisabledReason{Text: self.c.Tr.WorktreeNotCheckedOutOnBranch} + + removeWorktreeAndBranchItem := &types.MenuItem{ + Label: self.c.Tr.RemoveWorktreeAndDeleteBranch, + Keys: menuKey('b'), + OnPress: func() error { + return self.c.Helpers().BranchesHelper.RemoveWorktreeAndDeleteBranch(worktree, branch) + }, + } + if !branchFound { + removeWorktreeAndBranchItem.DisabledReason = detachedReason + } + + removeWorktreeAndBothBranchesItem := &types.MenuItem{ + Label: self.c.Tr.RemoveWorktreeAndDeleteBothBranches, + Keys: menuKey('r'), + OnPress: func() error { + return self.c.Helpers().BranchesHelper.RemoveWorktreeAndDeleteBothBranches(worktree, branch) + }, + } + if !branchFound { + removeWorktreeAndBothBranchesItem.DisabledReason = detachedReason + } else if !branch.IsTrackingRemote() || branch.UpstreamGone { + removeWorktreeAndBothBranchesItem.DisabledReason = &types.DisabledReason{ + Text: self.c.Tr.UpstreamNotSetError, + } + } + + return self.c.Menu(types.CreateMenuOptions{ + Title: utils.ResolvePlaceholderString( + self.c.Tr.RemoveWorktreeMenuTitle, + map[string]string{"worktreeName": worktree.Name}, + ), + Items: []*types.MenuItem{removeWorktreeItem, removeWorktreeAndBranchItem, removeWorktreeAndBothBranchesItem}, + }) } func (self *WorktreesController) GetOnDoubleClick() func() error { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 91f514796..be3886fbe 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -874,15 +874,16 @@ type TranslationSet struct { Switching string RemoveWorktree string RemoveWorktreeTitle string + RemoveWorktreeMenuTitle string RemoveWorktreeAndDeleteBranch string RemoveWorktreeAndDeleteBothBranches string + WorktreeNotCheckedOutOnBranch string DetachWorktree string DetachWorktreeAndDeleteBranch string DetachWorktreeAndDeleteBothBranches string DetachingWorktree string WorktreesTitle string WorktreeTitle string - RemoveWorktreePrompt string ForceRemoveWorktreePrompt string RemovingWorktree string AddingWorktree string @@ -2022,9 +2023,10 @@ func EnglishTranslationSet() *TranslationSet { Switching: "Switching", RemoveWorktree: "Remove worktree", RemoveWorktreeTitle: "Remove worktree", + RemoveWorktreeMenuTitle: "Remove worktree '{{.worktreeName}}'?", RemoveWorktreeAndDeleteBranch: "Remove worktree and delete branch", RemoveWorktreeAndDeleteBothBranches: "Remove worktree and delete local and remote branch", - RemoveWorktreePrompt: "Are you sure you want to remove worktree '{{.worktreeName}}'?", + WorktreeNotCheckedOutOnBranch: "This worktree is not checked out on a branch", ForceRemoveWorktreePrompt: "'{{.worktreeName}}' contains modified or untracked files, or submodules (or all of these). Are you sure you want to remove it?", RemovingWorktree: "Deleting worktree", DetachWorktree: "Detach worktree", diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index ed32775d4..380aca2b6 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -521,6 +521,7 @@ var tests = []*components.IntegrationTest{ worktree.NewWorktreePicker, worktree.NewWorktreePickerRemote, worktree.RemoveWorktreeAndBothBranches, + worktree.RemoveWorktreeAndBranch, worktree.RemoveWorktreeAndDeleteLocalAndRemoteBranch, worktree.RemoveWorktreeFromBranch, worktree.ResetWindowTabs, diff --git a/pkg/integration/tests/worktree/crud.go b/pkg/integration/tests/worktree/crud.go index 6cda94141..9a35d6cf7 100644 --- a/pkg/integration/tests/worktree/crud.go +++ b/pkg/integration/tests/worktree/crud.go @@ -106,9 +106,9 @@ var Crud = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("linked-worktree")). Press(keys.Universal.Remove). Tap(func() { - t.ExpectPopup().Confirmation(). - Title(Equals("Remove worktree")). - Content(Contains("Are you sure you want to remove worktree 'linked-worktree'?")). + t.ExpectPopup().Menu(). + Title(Equals("Remove worktree 'linked-worktree'?")). + Select(MatchesRegexp("Remove worktree$")). Confirm() }). Lines( diff --git a/pkg/integration/tests/worktree/force_remove_worktree.go b/pkg/integration/tests/worktree/force_remove_worktree.go index cde9e9da3..3fafa9755 100644 --- a/pkg/integration/tests/worktree/force_remove_worktree.go +++ b/pkg/integration/tests/worktree/force_remove_worktree.go @@ -29,9 +29,9 @@ var ForceRemoveWorktree = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("linked-worktree")). Press(keys.Universal.Remove). Tap(func() { - t.ExpectPopup().Confirmation(). - Title(Equals("Remove worktree")). - Content(Equals("Are you sure you want to remove worktree 'linked-worktree'?")). + t.ExpectPopup().Menu(). + Title(Equals("Remove worktree 'linked-worktree'?")). + Select(MatchesRegexp("Remove worktree$")). Confirm() t.ExpectPopup().Confirmation(). diff --git a/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go b/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go index 4af533e02..82e5a9303 100644 --- a/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go +++ b/pkg/integration/tests/worktree/force_remove_worktree_with_submodules.go @@ -29,9 +29,9 @@ var ForceRemoveWorktreeWithSubmodules = NewIntegrationTest(NewIntegrationTestArg NavigateToLine(Contains("linked-worktree")). Press(keys.Universal.Remove). Tap(func() { - t.ExpectPopup().Confirmation(). - Title(Equals("Remove worktree")). - Content(Equals("Are you sure you want to remove worktree 'linked-worktree'?")). + t.ExpectPopup().Menu(). + Title(Equals("Remove worktree 'linked-worktree'?")). + Select(MatchesRegexp("Remove worktree$")). Confirm() t.ExpectPopup().Confirmation(). diff --git a/pkg/integration/tests/worktree/remove_worktree_and_both_branches.go b/pkg/integration/tests/worktree/remove_worktree_and_both_branches.go new file mode 100644 index 000000000..8ba8e9112 --- /dev/null +++ b/pkg/integration/tests/worktree/remove_worktree_and_both_branches.go @@ -0,0 +1,64 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RemoveWorktreeAndBothBranches = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "From the worktrees panel, remove a worktree and delete both its local and remote branch in one go", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CloneIntoRemote("origin") + shell.EmptyCommit("initial commit") + shell.NewBranch("mybranch") + shell.EmptyCommit("commit on mybranch") + shell.PushBranchAndSetUpstream("origin", "mybranch") + shell.EmptyCommit("commit not pushed to the remote") // so mybranch isn't fully merged + shell.Checkout("master") + shell.AddWorktreeCheckout("mybranch", "../linked-worktree") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Worktrees(). + Focus(). + Lines( + Contains("(main worktree)").IsSelected(), + Contains("linked-worktree"), + ). + NavigateToLine(Contains("linked-worktree")). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Remove worktree 'linked-worktree'?")). + Select(Contains("Remove worktree and delete local and remote branch")). + Confirm() + + // mybranch isn't fully merged, so we get the force-delete warning + t.ExpectPopup().Confirmation(). + Title(Equals("Force delete branch")). + Content(Equals("'mybranch' is not fully merged. Are you sure you want to delete it?")). + Confirm() + }). + Lines( + Contains("(main worktree)").IsSelected(), + ) + + // The remote branch is gone too + t.Views().Remotes(). + Focus(). + Lines(Contains("origin")). + PressEnter() + + t.Views().RemoteBranches(). + IsEmpty() + + // And so is the local branch + t.Views().Branches(). + Focus(). + Lines( + Contains("master").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/worktree/remove_worktree_and_branch.go b/pkg/integration/tests/worktree/remove_worktree_and_branch.go new file mode 100644 index 000000000..5910b6b7f --- /dev/null +++ b/pkg/integration/tests/worktree/remove_worktree_and_branch.go @@ -0,0 +1,73 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RemoveWorktreeAndBranch = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "From the worktrees panel, remove a worktree and delete its branch in one go", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.NewBranch("newbranch") + shell.EmptyCommit("commit on newbranch") + shell.Checkout("mybranch") + shell.AddWorktreeCheckout("newbranch", "../linked-worktree") + shell.RunCommand([]string{"git", "worktree", "add", "--detach", "../detached-worktree"}) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Worktrees(). + Focus(). + Lines( + Contains("(main worktree)").IsSelected(), + Contains("detached-worktree"), + Contains("linked-worktree"), + ). + // A detached worktree has no branch, so neither delete action is offered + NavigateToLine(Contains("detached-worktree")). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Remove worktree 'detached-worktree'?")). + Select(Contains("Remove worktree and delete branch")). + Tooltip(Contains("This worktree is not checked out on a branch")). + Select(Contains("Remove worktree and delete local and remote branch")). + Tooltip(Contains("This worktree is not checked out on a branch")). + Cancel() + }). + // Remove a worktree and delete its branch at once. newbranch has no + // upstream, so deleting the remote branch too isn't offered. + NavigateToLine(Contains("linked-worktree")). + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Menu(). + Title(Equals("Remove worktree 'linked-worktree'?")). + Select(Contains("Remove worktree and delete local and remote branch")). + Tooltip(Contains("The selected branch has no upstream")). + Select(Contains("Remove worktree and delete branch")). + Confirm() + + // newbranch isn't fully merged, so we get the force-delete warning + t.ExpectPopup().Confirmation(). + Title(Equals("Force delete branch")). + Content(Equals("'newbranch' is not fully merged. Are you sure you want to delete it?")). + Confirm() + }). + Lines( + Contains("(main worktree)"), + Contains("detached-worktree"), + ) + + // The branch is gone too + t.Views().Branches(). + Focus(). + Lines( + Contains("mybranch").IsSelected(), + ) + }, +}) From 9a4ef7d1a1a86dd40b58850bedd620627bc42b73 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 4 Jun 2026 07:53:01 +0200 Subject: [PATCH 186/384] Allow overriding the platform used for default keybindings A handful of default keybindings differ by platform (e.g. word-wise cursor movement in text inputs uses alt on macOS but ctrl elsewhere). Lazygit chooses these based on the OS it runs on, but that's the wrong signal when the OS isn't where the user is actually typing: someone running lazygit in a Linux container that they access over ssh from a Mac gets the Linux bindings, when they'd rather have the Mac ones. Remapping each binding by hand via config is tedious, so add a single LAZYGIT_KEYBINDING_PLATFORM override. An unrecognized value falls back to the real OS rather than to the non-darwin default bindings, since the latter would be an arbitrary choice. --- docs-master/Config.md | 6 +++++ pkg/app/entry_point.go | 2 +- pkg/config/app_config.go | 17 +++++++++++++- pkg/config/app_config_test.go | 42 +++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 152044be6..98d64f1a2 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -1102,6 +1102,12 @@ keybinding: edit: # disable 'edit file' ``` +### Overriding the platform for default keybindings + +A few keybindings have different defaults on macOS than on Linux and Windows (e.g. word-wise cursor movement in text inputs uses `alt` on macOS but `ctrl` elsewhere). Lazygit picks these based on the OS it's running on, but you can override that with the `LAZYGIT_KEYBINDING_PLATFORM` environment variable. Set it to `darwin`, `linux`, or `windows`; any other value is ignored and the actual OS is used. + +This is useful when running lazygit in a Linux container that you access over ssh from a Mac, where you'd rather use the macOS keybindings. + ### Example Keybindings For Colemak Users ```yaml diff --git a/pkg/app/entry_point.go b/pkg/app/entry_point.go index 8b1a2a040..a3225e311 100644 --- a/pkg/app/entry_point.go +++ b/pkg/app/entry_point.go @@ -102,7 +102,7 @@ func Start(buildInfo *BuildInfo, integrationTest integrationTypes.IntegrationTes if cliArgs.PrintDefaultConfig { var buf bytes.Buffer encoder := yaml.NewEncoder(&buf) - err := encoder.Encode(config.GetDefaultConfigForPlatform(runtime.GOOS)) + err := encoder.Encode(config.GetDefaultConfigForPlatform(config.KeybindingPlatform())) if err != nil { log.Fatal(err.Error()) } diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index 313d71dcf..9b9db2c12 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -136,8 +136,23 @@ func findOrCreateConfigDir() (string, error) { return folder, os.MkdirAll(folder, 0o755) } +// KeybindingPlatform returns the platform whose default keybindings should be +// used. Normally this is the OS we're running on, but it can be overridden with +// the LAZYGIT_KEYBINDING_PLATFORM environment variable; this is useful e.g. when +// running lazygit in a Linux container that you access over ssh from a Mac, and +// you'd rather use the Mac keybindings. An unrecognized value falls back to the +// real OS, which gives meaningful bindings, rather than to the (arbitrary) +// non-darwin defaults. +func KeybindingPlatform() string { + platform := os.Getenv("LAZYGIT_KEYBINDING_PLATFORM") + if lo.Contains([]string{"darwin", "linux", "windows"}, platform) { + return platform + } + return runtime.GOOS +} + func loadUserConfigWithDefaults(configFiles []*ConfigFile, isGuiInitialized bool) (*UserConfig, error) { - return loadUserConfig(configFiles, GetDefaultConfigForPlatform(runtime.GOOS), isGuiInitialized) + return loadUserConfig(configFiles, GetDefaultConfigForPlatform(KeybindingPlatform()), isGuiInitialized) } func loadUserConfig(configFiles []*ConfigFile, base *UserConfig, isGuiInitialized bool) (*UserConfig, error) { diff --git a/pkg/config/app_config_test.go b/pkg/config/app_config_test.go index 913bc47dc..be97c2acb 100644 --- a/pkg/config/app_config_test.go +++ b/pkg/config/app_config_test.go @@ -1,11 +1,53 @@ package config import ( + "runtime" "testing" "github.com/stretchr/testify/assert" ) +func TestKeybindingPlatform(t *testing.T) { + scenarios := []struct { + name string + envValue string + expected string + }{ + { + name: "Not set falls back to the host OS", + envValue: "", + expected: runtime.GOOS, + }, + { + name: "darwin is honored", + envValue: "darwin", + expected: "darwin", + }, + { + name: "linux is honored", + envValue: "linux", + expected: "linux", + }, + { + name: "windows is honored", + envValue: "windows", + expected: "windows", + }, + { + name: "An unrecognized value falls back to the host OS", + envValue: "mac", + expected: runtime.GOOS, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + t.Setenv("LAZYGIT_KEYBINDING_PLATFORM", s.envValue) + assert.Equal(t, s.expected, KeybindingPlatform()) + }) + } +} + func TestMigrationOfRenamedKeys(t *testing.T) { scenarios := []struct { name string From b039d98b09ddc2540b76ad8d53bac0df57a58574 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 1 Jul 2026 17:05:11 +0200 Subject: [PATCH 187/384] Scale the side-panel layout height thresholds by panel count The height thresholds that decide between the proportional layout and the squashed layout (and, within the squashed layout, between 3-row and 1-row unfocused panels) were hard-coded constants tuned for the fixed set of five side panels. Now that the panels are configurable, a layout with fewer panels has less to fit, yet was still forced into the squashed layout at the same height as five panels would be. Scale the thresholds down in proportion to the panel count so a smaller layout keeps using the proportional layout at smaller heights. Only ever scale down: raising the thresholds for more panels would make them squash sooner, which works against the reason someone adds panels in the first place (they want to see them). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/window_arrangement_helper.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 610c57f52..37a4465b1 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -430,6 +430,15 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ return func(width int, height int) []*boxlayout.Box { windows := sideWindowNames(args.UserConfig) + // These thresholds were originally tuned for the default five side panels. + // With fewer panels there's less to fit, so scale them down proportionally + // to keep using the proportional layout at smaller heights rather than + // squashing unnecessarily. We only ever scale down: making more panels + // squash sooner tends to work against the reason people add panels. + const defaultSidePanelCount = 5 + minHeightForNormalLayout := min(28, 28*len(windows)/defaultSidePanelCount) + minHeightForTallSquashedPanels := min(21, 21*len(windows)/defaultSidePanelCount) + boxForEachWindow := func(boxForWindow func(window string) *boxlayout.Box) []*boxlayout.Box { boxes := make([]*boxlayout.Box, 0, len(windows)) for _, window := range windows { @@ -454,7 +463,7 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ } return boxForEachWindow(fullHeightBox) - } else if height >= 28 { + } else if height >= minHeightForNormalLayout { accordionMode := args.UserConfig.Gui.ExpandFocusedSidePanel accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box { if accordionMode && defaultBox.Window == args.CurrentSideWindow { @@ -487,7 +496,7 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ } squashedHeight := 1 - if height >= 21 { + if height >= minHeightForTallSquashedPanels { squashedHeight = 3 } From a929f34c8437f250058fd9fad205631b01eb454c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 1 Jul 2026 17:10:21 +0200 Subject: [PATCH 188/384] Add gui.shrinkSidePanelsToContent option Accordion mode expands the focused side panel, but when that panel has little content (an empty Files panel, a Branches panel with only master) it just fills the extra height with blank space. The same waste happens for any panel that gets more height than it has content to show. When this option is enabled, each side panel is sized to its own content (plus a blank line, so it's clear there's nothing more below) rather than to an equal share of the height. The height a small panel gives up flows to the panels that have more content than fits; those grow up to their content and then scroll, weighted toward the focused panel in accordion mode so the two features compose. Only when every panel fits with room to spare is the leftover shared out equally, regardless of focus: enlarging the focused panel there would reveal no more content and would only make the panels jump around as the focus moves. The option is independent of expandFocusedSidePanel and off by default. The status panel, and the stash panel when unfocused, keep their fixed one-line height as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs-master/Config.md | 5 + pkg/config/user_config.go | 27 +-- .../helpers/window_arrangement_helper.go | 171 +++++++++++++++- .../helpers/window_arrangement_helper_test.go | 190 ++++++++++++++++++ schema-master/config.json | 5 + 5 files changed, 377 insertions(+), 21 deletions(-) diff --git a/docs-master/Config.md b/docs-master/Config.md index 98d64f1a2..1d101be18 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -110,6 +110,11 @@ gui: # is true. expandedSidePanelWeight: 2 + # If true, don't give a side panel more height than it needs to show its + # content; when all panels fit, the leftover height is shared among them so that + # they still fill the screen. + shrinkSidePanelsToContent: false + # The side panels, in the order they appear from top to bottom. # Each entry is a list of one or more names that share a single panel as tabs # (cycle through them with the next-tab/previous-tab keys). diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 8314701ba..30ce0377d 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -111,6 +111,8 @@ type GuiConfig struct { ExpandFocusedSidePanel bool `yaml:"expandFocusedSidePanel"` // 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 int `yaml:"expandedSidePanelWeight"` + // If true, don't give a side panel more height than it needs to show its content; when all panels fit, the leftover height is shared among them so that they still fill the screen. + ShrinkSidePanelsToContent bool `yaml:"shrinkSidePanelsToContent"` // The side panels, in the order they appear from top to bottom. // Each entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys). // Omit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel. @@ -847,18 +849,19 @@ func GetDefaultConfig() *UserConfig { func GetDefaultConfigForPlatform(platform string) *UserConfig { return &UserConfig{ Gui: GuiConfig{ - ScrollHeight: 2, - ScrollPastBottom: true, - ScrollOffMargin: 2, - ScrollOffBehavior: "margin", - TabWidth: 4, - MouseEvents: true, - SkipAmendWarning: false, - SkipDiscardChangeWarning: false, - SkipStashWarning: false, - SidePanelWidth: 0.3333, - ExpandFocusedSidePanel: false, - ExpandedSidePanelWeight: 2, + ScrollHeight: 2, + ScrollPastBottom: true, + ScrollOffMargin: 2, + ScrollOffBehavior: "margin", + TabWidth: 4, + MouseEvents: true, + SkipAmendWarning: false, + SkipDiscardChangeWarning: false, + SkipStashWarning: false, + SidePanelWidth: 0.3333, + ExpandFocusedSidePanel: false, + ExpandedSidePanelWeight: 2, + ShrinkSidePanelsToContent: false, SidePanels: []SidePanel{ {"status"}, {"files", "worktrees", "submodules"}, diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper.go b/pkg/gui/controllers/helpers/window_arrangement_helper.go index 37a4465b1..90a651809 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper.go @@ -55,6 +55,9 @@ type WindowArrangementArgs struct { // stash height special-cases key off (rather than the window itself, whose // name is just its first tab). ActiveViewForWindow func(window string) string + // Returns the number of content lines of the view currently shown in the given + // window. Used by the shrink-to-content feature to size a panel to its content. + ContentHeightForWindow func(window string) int // Whether the main panel is split (as is the case e.g. when a file has both // staged and unstaged changes) SplitMainPanel bool @@ -97,15 +100,18 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string, CurrentWindow: self.c.Context().CurrentStatic().GetWindowName(), CurrentSideWindow: self.c.Context().CurrentSide().GetWindowName(), ActiveViewForWindow: self.windowHelper.GetViewNameForWindow, - SplitMainPanel: repoState.GetSplitMainPanel(), - ScreenMode: repoState.GetScreenMode(), - AppStatus: appStatus, - InformationStr: informationStr, - ShowExtrasWindow: self.c.State().GetShowExtrasWindow(), - InDemo: self.c.InDemo(), - IsAnyModeActive: self.modeHelper.IsAnyModeActive(), - InSearchPrompt: repoState.InSearchPrompt(), - SearchPrefix: searchPrefix, + ContentHeightForWindow: func(window string) int { + return self.windowHelper.GetContextForWindow(window).TotalContentHeight() + }, + SplitMainPanel: repoState.GetSplitMainPanel(), + ScreenMode: repoState.GetScreenMode(), + AppStatus: appStatus, + InformationStr: informationStr, + ShowExtrasWindow: self.c.State().GetShowExtrasWindow(), + InDemo: self.c.InDemo(), + IsAnyModeActive: self.modeHelper.IsAnyModeActive(), + InSearchPrompt: repoState.InSearchPrompt(), + SearchPrefix: searchPrefix, } return GetWindowDimensions(args) @@ -464,6 +470,12 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ return boxForEachWindow(fullHeightBox) } else if height >= minHeightForNormalLayout { + if args.UserConfig.Gui.ShrinkSidePanelsToContent { + if boxes, ok := shrinkToContentSidePanelBoxes(args, windows, height); ok { + return boxes + } + } + accordionMode := args.UserConfig.Gui.ExpandFocusedSidePanel accordionBox := func(defaultBox *boxlayout.Box) *boxlayout.Box { if accordionMode && defaultBox.Window == args.CurrentSideWindow { @@ -517,3 +529,144 @@ func sidePanelChildren(args WindowArrangementArgs) func(width int, height int) [ return boxForEachWindow(squashedSidePanelBox) } } + +// shrinkToContentSidePanelBoxes implements the gui.shrinkSidePanelsToContent +// feature: rather than giving every side panel an equal share of the height, we +// size each panel to its own content (plus one blank line, so it's clear there's +// nothing more below), which stops panels with little content from wasting space. +// +// The height freed up by a small panel flows to the panels that have more content +// than their share; those grow up to their own content and then scroll. If every +// panel fits its content with room to spare, there's nothing to absorb the +// leftover, so it's shared among all panels by weight (which, in accordion mode, +// gives the focused panel more of it). +// +// The status panel, and the stash panel when it's not focused, keep their +// constant height and don't take part; ok is false when there are no panels to +// size (so the caller falls back to the normal weighted layout). +func shrinkToContentSidePanelBoxes(args WindowArrangementArgs, windows []string, height int) ([]*boxlayout.Box, bool) { + const frameSize = 2 + + accordionMode := args.UserConfig.Gui.ExpandFocusedSidePanel + + // A flexible panel is one we size to its content. Fixed panels (the status + // panel, and the stash panel when unfocused) get their constant height and + // are excluded from the distribution below. + type flexiblePanel struct { + boxIndex int + desired int // target height: content rows (see below) plus the frame + weight int + height int // final height, only computed for the room-to-spare case + capped bool // true once it fits its content within its share + } + + boxes := make([]*boxlayout.Box, len(windows)) + flexible := []*flexiblePanel{} + availableForFlexible := height + for i, window := range windows { + focused := window == args.CurrentSideWindow + + // The status and stash sizing is a property of those views, so we key off + // the tab the window is currently showing, not the window's name (its first + // tab); see the comment on normalBox in sidePanelChildren. + activeView := args.ActiveViewForWindow(window) + if activeView == "status" || (activeView == "stash" && !focused) { + boxes[i] = &boxlayout.Box{Window: window, Size: 3} + availableForFlexible -= 3 + continue + } + + weight := 1 + if accordionMode && focused { + weight = args.UserConfig.Gui.ExpandedSidePanelWeight + } + // Show the content plus a blank line, so it's clear there's nothing more + // below, but never fewer than two rows: a lone blank row looks cramped, + // and an empty Files panel is the common state right after launching. + contentRows := max(args.ContentHeightForWindow(window)+1, 2) + flexible = append(flexible, &flexiblePanel{ + boxIndex: i, + desired: contentRows + frameSize, + weight: weight, + }) + } + + if len(flexible) == 0 || availableForFlexible <= 0 { + return nil, false + } + + // Water-filling: repeatedly cap the panels whose desired height is no more + // than their weighted share of what's left. Capping a panel only raises the + // others' shares, so this converges once no further panel fits its content. + // Whatever remains is what the still-uncapped panels have to share. + remaining := availableForFlexible + for { + totalWeight := 0 + for _, p := range flexible { + if !p.capped { + totalWeight += p.weight + } + } + if totalWeight == 0 { + break + } + + newlyCapped := []*flexiblePanel{} + for _, p := range flexible { + if !p.capped && p.desired*totalWeight <= remaining*p.weight { + newlyCapped = append(newlyCapped, p) + } + } + if len(newlyCapped) == 0 { + break + } + for _, p := range newlyCapped { + p.capped = true + remaining -= p.desired + } + } + + anyUncapped := false + for _, p := range flexible { + if !p.capped { + anyUncapped = true + } + } + + if anyUncapped { + // Some panels have more content than fits: give the ones that fit exactly + // their content, and let boxlayout share what's left among the rest by + // weight (they'll scroll). This is the common, real-world case. + for _, p := range flexible { + if p.capped { + boxes[p.boxIndex] = &boxlayout.Box{Window: windows[p.boxIndex], Size: p.desired} + } else { + boxes[p.boxIndex] = &boxlayout.Box{Window: windows[p.boxIndex], Weight: p.weight} + } + } + return boxes, true + } + + // Every panel fits its content with room to spare, so no panel needs to + // scroll. Share the leftover equally among them, regardless of focus and + // accordion mode: enlarging the focused panel here reveals no more content + // (it already fits) and would only make panels jump around as focus moves. + // Deal out the rounding remainder one row at a time so the heights fill the + // available space exactly. + base := remaining / len(flexible) + extra := remaining % len(flexible) + for i, p := range flexible { + p.height = p.desired + base + if i < extra { + p.height++ + } + } + + // boxlayout can't lay out a set of boxes that are all statically sized (it + // needs a weighted box to absorb the space), so we hand it the heights as + // weights: they sum to the available height, so it reproduces them exactly. + for _, p := range flexible { + boxes[p.boxIndex] = &boxlayout.Box{Window: windows[p.boxIndex], Weight: p.height} + } + return boxes, true +} diff --git a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go index 63d7642b6..365d7f104 100644 --- a/pkg/gui/controllers/helpers/window_arrangement_helper_test.go +++ b/pkg/gui/controllers/helpers/window_arrangement_helper_test.go @@ -13,6 +13,14 @@ import ( "github.com/samber/lo" ) +// contentHeights builds a ContentHeightForWindow function from a map of window +// name to content height; windows not in the map report a height of 0. +func contentHeights(heights map[string]int) func(window string) int { + return func(window string) int { + return heights[window] + } +} + // The best way to add test cases here is to set your args and then get the // test to fail and copy+paste the output into the test case's expected string. // TODO: add more test cases @@ -710,6 +718,188 @@ func TestGetWindowDimensions(t *testing.T) { B: statusSpacer2 `, }, + { + name: "shrink to content, one panel overflows", + mutateArgs: func(args *WindowArrangementArgs) { + args.UserConfig.Gui.ShrinkSidePanelsToContent = true + args.ContentHeightForWindow = contentHeights(map[string]int{ + "files": 2, + "branches": 1, + "commits": 100, + }) + }, + expected: ` + ╭status─────────────────╮╭main────────────────────────────────────────────╮ + │ ││ │ + ╰───────────────────────╯│ │ + ╭files──────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭branches───────────────╮│ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭commits────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭stash──────────────────╮│ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + A: statusSpacer1 + B: information + `, + }, + { + name: "shrink to content, everything fits with room to spare", + mutateArgs: func(args *WindowArrangementArgs) { + args.UserConfig.Gui.ShrinkSidePanelsToContent = true + args.ContentHeightForWindow = contentHeights(map[string]int{ + "files": 2, + "branches": 1, + "commits": 3, + }) + }, + expected: ` + ╭status─────────────────╮╭main────────────────────────────────────────────╮ + │ ││ │ + ╰───────────────────────╯│ │ + ╭files──────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭branches───────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭commits────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭stash──────────────────╮│ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + A: statusSpacer1 + B: information + `, + }, + { + name: "shrink to content, accordion doesn't resize panels when everything fits", + mutateArgs: func(args *WindowArrangementArgs) { + args.UserConfig.Gui.ShrinkSidePanelsToContent = true + args.UserConfig.Gui.ExpandFocusedSidePanel = true + args.CurrentSideWindow = "branches" + args.ContentHeightForWindow = contentHeights(map[string]int{ + "files": 2, + "branches": 1, + "commits": 3, + }) + }, + expected: ` + ╭status─────────────────╮╭main────────────────────────────────────────────╮ + │ ││ │ + ╰───────────────────────╯│ │ + ╭files──────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭branches───────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭commits────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭stash──────────────────╮│ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + A: statusSpacer1 + B: information + `, + }, + { + name: "shrink to content, empty panel keeps two rows rather than one", + mutateArgs: func(args *WindowArrangementArgs) { + args.UserConfig.Gui.ShrinkSidePanelsToContent = true + args.ContentHeightForWindow = contentHeights(map[string]int{ + "files": 0, + "branches": 1, + "commits": 100, + }) + }, + expected: ` + ╭status─────────────────╮╭main────────────────────────────────────────────╮ + │ ││ │ + ╰───────────────────────╯│ │ + ╭files──────────────────╮│ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭branches───────────────╮│ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭commits────────────────╮│ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + │ ││ │ + ╰───────────────────────╯│ │ + ╭stash──────────────────╮│ │ + │ ││ │ + ╰───────────────────────╯╰────────────────────────────────────────────────╯ + A + A: statusSpacer1 + B: information + `, + }, } for _, test := range tests { diff --git a/schema-master/config.json b/schema-master/config.json index 0dd1d5d20..82dbebb0b 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -590,6 +590,11 @@ "description": "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.", "default": 2 }, + "shrinkSidePanelsToContent": { + "type": "boolean", + "description": "If true, don't give a side panel more height than it needs to show its content; when all panels fit, the leftover height is shared among them so that they still fill the screen.", + "default": false + }, "sidePanels": { "items": { "$ref": "#/$defs/SidePanel" From f84ada494121604316027cb63364024435351a7a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 22:31:52 +0200 Subject: [PATCH 189/384] Show renamed files in the custom patch builder When loading the files of a commit we passed --no-renames, so a rename showed up as a separate delete and add rather than a single R entry. That made it impossible to work with a rename that also modifies the file: the modifications were spread across a full deletion and a full addition instead of appearing as the handful of lines that actually changed. The staging view already shows renames and lets you stage their hunks, so there was no good reason for the patch builder to differ; the flag was only there because the commit-file parser couldn't cope with the rename record format. Switch the commit-file loader and the per-file diff to --find-renames, teach the parser about the rename record (a status followed by two paths), and carry the previous path through the patch builder so the diff for a rename is loaded with both paths, which is what makes git emit the rename in the first place. A whole-file selection keeps the rename in the header, so the rename moves or is discarded together with the file's contents. A partial selection instead strips the rename metadata and points the header at the new path, so applying the patch only changes the contents and leaves the rename in place; the blob index line is kept so that a 3-way apply can still fall back to a blob merge. Discarding a renamed file from a commit now discards both the new and the old path, so the new file is removed and the old one is restored. Changing the rename similarity threshold refreshes the commit files panel too, not just the files panel, so that a rename can turn into a delete and add or back. It is disabled while building a patch, however, because the patch builder caches each file's diff by path and would desync if a rename changed into a delete and add underneath it. Finally, copying a file's diff from the commit files panel now passes both paths for a rename, so the copied diff shows the rename instead of a new-file add. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git.go | 4 +- .../git_commands/commit_file_loader.go | 41 ++++++++--- .../git_commands/commit_file_loader_test.go | 19 +++++ pkg/commands/git_commands/working_tree.go | 12 ++- .../git_commands/working_tree_test.go | 26 ++++++- pkg/commands/models/commit_file.go | 21 ++++++ pkg/commands/patch/patch_builder.go | 47 +++++++----- pkg/commands/patch/patch_test.go | 55 ++++++++++++++ pkg/commands/patch/transform.go | 59 +++++++++++++-- pkg/gui/context/setup.go | 7 +- .../controllers/commits_files_controller.go | 22 ++++-- .../helpers/patch_building_helper.go | 9 ++- .../controllers/patch_building_controller.go | 8 +- .../rename_similarity_threshold_controller.go | 23 +++++- pkg/gui/presentation/files.go | 17 ++++- pkg/gui/presentation/files_test.go | 10 ++- pkg/i18n/english.go | 2 + .../tests/commit/discard_renamed_file.go | 57 +++++++++++++++ .../patch_building/copy_renamed_file_diff.go | 60 +++++++++++++++ .../rename_similarity_threshold_change.go | 66 +++++++++++++++++ .../patch_building/renamed_file_partial.go | 73 +++++++++++++++++++ .../patch_building/renamed_file_whole.go | 62 ++++++++++++++++ pkg/integration/tests/test_list.go | 5 ++ 23 files changed, 642 insertions(+), 63 deletions(-) create mode 100644 pkg/integration/tests/commit/discard_renamed_file.go create mode 100644 pkg/integration/tests/patch_building/copy_renamed_file_diff.go create mode 100644 pkg/integration/tests/patch_building/rename_similarity_threshold_change.go create mode 100644 pkg/integration/tests/patch_building/renamed_file_partial.go create mode 100644 pkg/integration/tests/patch_building/renamed_file_whole.go diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 7f95fbafb..d9add2790 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -117,8 +117,8 @@ func NewGitCommandAux( rebaseCommands := git_commands.NewRebaseCommands(gitCommon, commitCommands, workingTreeCommands) stashCommands := git_commands.NewStashCommands(gitCommon, fileLoader, workingTreeCommands) patchBuilder := patch.NewPatchBuilder(cmn.Log, - func(from string, to string, reverse bool, filename string, plain bool) (string, error) { - return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, plain) + func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) { + return workingTreeCommands.ShowFileDiff(from, to, reverse, filename, previousPath, plain) }) patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder) bisectCommands := git_commands.NewBisectCommands(gitCommon) diff --git a/pkg/commands/git_commands/commit_file_loader.go b/pkg/commands/git_commands/commit_file_loader.go index 33bd40e13..9500dabfd 100644 --- a/pkg/commands/git_commands/commit_file_loader.go +++ b/pkg/commands/git_commands/commit_file_loader.go @@ -1,12 +1,12 @@ package git_commands import ( + "fmt" "strings" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" - "github.com/samber/lo" ) type CommitFileLoader struct { @@ -29,7 +29,7 @@ func (self *CommitFileLoader) GetFilesInDiff(from string, to string, reverse boo Arg("--no-ext-diff"). Arg("--name-status"). Arg("-z"). - Arg("--no-renames"). + Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)). ArgIf(reverse, "-R"). Arg(from). Arg(to). @@ -44,18 +44,37 @@ func (self *CommitFileLoader) GetFilesInDiff(from string, to string, reverse boo } // filenames string is something like "MM\x00file1\x00MU\x00file2\x00AA\x00file3\x00" -// so we need to split it by the null character and then map each status-name pair to a commit file +// so we need to split it by the null character and then map each status-name pair +// to a commit file. Renames (and copies) are special: their status is followed by +// two paths (the old one and the new one) rather than one, e.g. +// "R100\x00old\x00new\x00". func getCommitFilesFromFilenames(filenames string) []*models.CommitFile { - lines := strings.Split(strings.TrimRight(filenames, "\x00"), "\x00") - if len(lines) == 1 { + fields := strings.Split(strings.TrimRight(filenames, "\x00"), "\x00") + if len(fields) == 1 { return []*models.CommitFile{} } - // typical result looks like 'A my_file' meaning my_file was added - return lo.Map(lo.Chunk(lines, 2), func(chunk []string, _ int) *models.CommitFile { - return &models.CommitFile{ - ChangeStatus: chunk[0], - Path: chunk[1], + commitFiles := make([]*models.CommitFile, 0, len(fields)/2) + for i := 0; i < len(fields)-1; { + changeStatus := fields[i] + if changeStatus[0] == 'R' || changeStatus[0] == 'C' { + // The status has a similarity score appended (e.g. "R100"); drop it + // so the rest of the code only has to deal with a plain "R" or "C". + commitFiles = append(commitFiles, &models.CommitFile{ + ChangeStatus: changeStatus[:1], + PreviousPath: fields[i+1], + Path: fields[i+2], + }) + i += 3 + } else { + // typical result looks like 'A my_file' meaning my_file was added + commitFiles = append(commitFiles, &models.CommitFile{ + ChangeStatus: changeStatus, + Path: fields[i+1], + }) + i += 2 } - }) + } + + return commitFiles } diff --git a/pkg/commands/git_commands/commit_file_loader_test.go b/pkg/commands/git_commands/commit_file_loader_test.go index ec91ec22e..2fa745f8b 100644 --- a/pkg/commands/git_commands/commit_file_loader_test.go +++ b/pkg/commands/git_commands/commit_file_loader_test.go @@ -60,6 +60,25 @@ func TestGetCommitFilesFromFilenames(t *testing.T) { }, }, }, + { + testName: "a rename among regular files", + input: "M\x00Myfile\x00R100\x00before\x00after\x00A\x00Added\x00", + output: []*models.CommitFile{ + { + Path: "Myfile", + ChangeStatus: "M", + }, + { + Path: "after", + PreviousPath: "before", + ChangeStatus: "R", + }, + { + Path: "Added", + ChangeStatus: "A", + }, + }, + }, } for _, test := range tests { diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 328da1404..8625158ab 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -432,8 +432,14 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain // ShowFileDiff get the diff of specified from and to. Typically this will be used for a single commit so it'll be 123abc^..123abc // but when we're in diff mode it could be any 'from' to any 'to'. The reverse flag is also here thanks to diff mode. -func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, plain bool) (string, error) { - return self.ShowFileDiffCmdObj(from, to, reverse, []string{fileName}, plain).RunWithOutput() +// For a renamed file, previousPath is the path it was renamed from (empty otherwise); +// both paths must be passed to git for the rename to be detected. +func (self *WorkingTreeCommands) ShowFileDiff(from string, to string, reverse bool, fileName string, previousPath string, plain bool) (string, error) { + fileNames := []string{fileName} + if previousPath != "" { + fileNames = append(fileNames, previousPath) + } + return self.ShowFileDiffCmdObj(from, to, reverse, fileNames, plain).RunWithOutput() } func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reverse bool, fileNames []string, plain bool) *oscommands.CmdObj { @@ -454,7 +460,7 @@ func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reve ArgIfElse(useExtDiff || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff"). Arg("--submodule"). Arg(fmt.Sprintf("--unified=%d", contextSize)). - Arg("--no-renames"). + Arg(fmt.Sprintf("--find-renames=%d%%", self.UserConfig().Git.RenameSimilarityThreshold)). Arg(fmt.Sprintf("--color=%s", colorArg)). Arg(from). Arg(to). diff --git a/pkg/commands/git_commands/working_tree_test.go b/pkg/commands/git_commands/working_tree_test.go index bb4c8e750..8af2b707d 100644 --- a/pkg/commands/git_commands/working_tree_test.go +++ b/pkg/commands/git_commands/working_tree_test.go @@ -339,6 +339,8 @@ func TestWorkingTreeShowFileDiff(t *testing.T) { from string to string reverse bool + fileName string + previousPath string plain bool ignoreWhitespace bool contextSize uint64 @@ -353,33 +355,49 @@ func TestWorkingTreeShowFileDiff(t *testing.T) { from: "1234567890", to: "0987654321", reverse: false, + fileName: "test.txt", plain: false, 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", "--no-renames", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil), + 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), }, { testName: "Show diff with custom context size", from: "1234567890", to: "0987654321", reverse: false, + fileName: "test.txt", plain: false, 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", "--no-renames", "--color=always", "1234567890", "0987654321", "--", "test.txt"}, expectedResult, nil), + 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), }, { testName: "Default case (ignore whitespace)", from: "1234567890", to: "0987654321", reverse: false, + fileName: "test.txt", plain: false, 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", "--no-renames", "--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", "--submodule", "--unified=3", "--find-renames=50%", "--color=always", "1234567890", "0987654321", "--ignore-all-space", "--", "test.txt"}, expectedResult, nil), + }, + { + testName: "Renamed file passes both paths so the rename is detected", + from: "1234567890", + to: "0987654321", + reverse: false, + fileName: "new.txt", + previousPath: "old.txt", + plain: false, + 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), }, } @@ -394,7 +412,7 @@ func TestWorkingTreeShowFileDiff(t *testing.T) { instance := buildWorkingTreeCommands(commonDeps{runner: s.runner, userConfig: userConfig, appState: &config.AppState{}, repoPaths: &repoPaths}) - result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, "test.txt", s.plain) + result, err := instance.ShowFileDiff(s.from, s.to, s.reverse, s.fileName, s.previousPath, s.plain) assert.NoError(t, err) assert.Equal(t, expectedResult, result) s.runner.CheckForMissingCalls() diff --git a/pkg/commands/models/commit_file.go b/pkg/commands/models/commit_file.go index 90ffd6365..5183fd380 100644 --- a/pkg/commands/models/commit_file.go +++ b/pkg/commands/models/commit_file.go @@ -4,6 +4,9 @@ package models type CommitFile struct { Path string + // For a renamed file, the path it was renamed from; empty otherwise. + PreviousPath string + ChangeStatus string // e.g. 'A' for added or 'M' for modified. This is based on the result from git diff --name-status } @@ -23,6 +26,24 @@ func (f *CommitFile) Deleted() bool { return f.ChangeStatus == "D" } +func (f *CommitFile) IsRename() bool { + return f.PreviousPath != "" +} + +// Names returns an array containing just the path, or in the case of a rename, +// the after path and the before path. +func (f *CommitFile) Names() []string { + result := []string{f.Path} + if f.PreviousPath != "" { + result = append(result, f.PreviousPath) + } + return result +} + func (f *CommitFile) GetPath() string { return f.Path } + +func (f *CommitFile) GetPreviousPath() string { + return f.PreviousPath +} diff --git a/pkg/commands/patch/patch_builder.go b/pkg/commands/patch/patch_builder.go index db834ba16..b730d9f62 100644 --- a/pkg/commands/patch/patch_builder.go +++ b/pkg/commands/patch/patch_builder.go @@ -25,10 +25,14 @@ type fileInfo struct { mode PatchStatus includedLineIndices []int diff string + // For a renamed file, the path it was renamed from; empty otherwise. We + // need to keep hold of it so we can re-render the file's patch (which is + // keyed by the new path) without the caller having to supply it again. + previousPath string } type ( - loadFileDiffFunc func(from string, to string, reverse bool, filename string, plain bool) (string, error) + loadFileDiffFunc func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) ) // PatchBuilder manages the building of a patch for a commit to be applied to another commit (or the working tree, or removed from the current commit). We also support building patches from things like stashes, for which there is less flexibility @@ -75,6 +79,7 @@ func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstE patch.WriteString(p.RenderPatchForFile(RenderPatchForFileOpts{ Filename: filename, + PreviousPath: info.previousPath, Plain: true, Reverse: reverse, TurnAddedFilesIntoDiffAgainstEmptyFile: turnAddedFilesIntoDiffAgainstEmptyFile, @@ -102,8 +107,8 @@ func (p *PatchBuilder) removeFile(info *fileInfo) { info.includedLineIndices = nil } -func (p *PatchBuilder) AddFileWhole(filename string) error { - info, err := p.getFileInfo(filename) +func (p *PatchBuilder) AddFileWhole(filename string, previousPath string) error { + info, err := p.getFileInfo(filename, previousPath) if err != nil { return err } @@ -113,8 +118,8 @@ func (p *PatchBuilder) AddFileWhole(filename string) error { return nil } -func (p *PatchBuilder) RemoveFile(filename string) error { - info, err := p.getFileInfo(filename) +func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error { + info, err := p.getFileInfo(filename, previousPath) if err != nil { return err } @@ -124,19 +129,20 @@ func (p *PatchBuilder) RemoveFile(filename string) error { return nil } -func (p *PatchBuilder) getFileInfo(filename string) (*fileInfo, error) { +func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileInfo, error) { info, ok := p.fileInfoMap[filename] if ok { return info, nil } - diff, err := p.loadFileDiff(p.From, p.To, p.reverse, filename, true) + diff, err := p.loadFileDiff(p.From, p.To, p.reverse, filename, previousPath, true) if err != nil { return nil, err } info = &fileInfo{ - mode: UNSELECTED, - diff: diff, + mode: UNSELECTED, + diff: diff, + previousPath: previousPath, } p.fileInfoMap[filename] = info @@ -144,8 +150,8 @@ func (p *PatchBuilder) getFileInfo(filename string) (*fileInfo, error) { return info, nil } -func (p *PatchBuilder) AddFileLineRange(filename string, lineIndices []int) error { - info, err := p.getFileInfo(filename) +func (p *PatchBuilder) AddFileLineRange(filename string, previousPath string, lineIndices []int) error { + info, err := p.getFileInfo(filename, previousPath) if err != nil { return err } @@ -155,8 +161,8 @@ func (p *PatchBuilder) AddFileLineRange(filename string, lineIndices []int) erro return nil } -func (p *PatchBuilder) RemoveFileLineRange(filename string, lineIndices []int) error { - info, err := p.getFileInfo(filename) +func (p *PatchBuilder) RemoveFileLineRange(filename string, previousPath string, lineIndices []int) error { + info, err := p.getFileInfo(filename, previousPath) if err != nil { return err } @@ -171,13 +177,14 @@ func (p *PatchBuilder) RemoveFileLineRange(filename string, lineIndices []int) e type RenderPatchForFileOpts struct { Filename string + PreviousPath string Plain bool Reverse bool TurnAddedFilesIntoDiffAgainstEmptyFile bool } func (p *PatchBuilder) RenderPatchForFile(opts RenderPatchForFileOpts) string { - info, err := p.getFileInfo(opts.Filename) + info, err := p.getFileInfo(opts.Filename, opts.PreviousPath) if err != nil { p.Log.Error(err) return "" @@ -198,7 +205,12 @@ func (p *PatchBuilder) RenderPatchForFile(opts RenderPatchForFileOpts) string { Transform(TransformOpts{ Reverse: opts.Reverse, TurnAddedFilesIntoDiffAgainstEmptyFile: opts.TurnAddedFilesIntoDiffAgainstEmptyFile, - IncludedLineIndices: info.includedLineIndices, + // For a partial selection of a renamed file we keep only the + // content change and drop the rename, so that the rename stays in + // the commit. A whole-file selection keeps the rename (and short- + // circuits before this for plain output). + StripRename: info.mode == PART && info.previousPath != "", + IncludedLineIndices: info.includedLineIndices, }) if opts.Plain { @@ -215,6 +227,7 @@ func (p *PatchBuilder) renderEachFilePatch(plain bool) []string { patches := lo.Map(filenames, func(filename string, _ int) string { return p.RenderPatchForFile(RenderPatchForFileOpts{ Filename: filename, + PreviousPath: p.fileInfoMap[filename].previousPath, Plain: plain, Reverse: false, TurnAddedFilesIntoDiffAgainstEmptyFile: true, @@ -244,8 +257,8 @@ func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus return info.mode } -func (p *PatchBuilder) GetFileIncLineIndices(filename string) ([]int, error) { - info, err := p.getFileInfo(filename) +func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath string) ([]int, error) { + info, err := p.getFileInfo(filename, previousPath) if err != nil { return nil, err } diff --git a/pkg/commands/patch/patch_test.go b/pkg/commands/patch/patch_test.go index 91a65db68..4f84041d6 100644 --- a/pkg/commands/patch/patch_test.go +++ b/pkg/commands/patch/patch_test.go @@ -19,6 +19,22 @@ index dcd3485..1ba5540 100644 ... ` +const renameWithModificationDiff = `diff --git a/oldname b/newname +similarity index 62% +rename from oldname +rename to newname +index dcd3485..1ba5540 100644 +--- a/oldname ++++ b/newname +@@ -1,5 +1,5 @@ + apple +-orange ++grape + ... + ... + ... +` + const addNewlineToEndOfFile = `diff --git a/filename b/filename index 80a73f1..e48a11c 100644 --- a/filename @@ -152,6 +168,7 @@ func TestTransform(t *testing.T) { firstLineIndex int lastLineIndex int reverse bool + stripRename bool expected string } @@ -515,6 +532,43 @@ func TestTransform(t *testing.T) { orange banana lemon +`, + }, + { + testName: "renamed file, whole change selected, strips the rename so only the content change is applied", + firstLineIndex: 9, + lastLineIndex: 10, + stripRename: true, + diffText: renameWithModificationDiff, + expected: `diff --git a/newname b/newname +index dcd3485..1ba5540 100644 +--- a/newname ++++ b/newname +@@ -1,5 +1,5 @@ + apple +-orange ++grape + ... + ... + ... +`, + }, + { + testName: "renamed file, only removal selected, strips the rename", + firstLineIndex: 9, + lastLineIndex: 9, + stripRename: true, + diffText: renameWithModificationDiff, + expected: `diff --git a/newname b/newname +index dcd3485..1ba5540 100644 +--- a/newname ++++ b/newname +@@ -1,5 +1,4 @@ + apple +-orange + ... + ... + ... `, }, } @@ -527,6 +581,7 @@ func TestTransform(t *testing.T) { Transform(TransformOpts{ Reverse: s.reverse, FileNameOverride: s.filename, + StripRename: s.stripRename, IncludedLineIndices: lineIndices, }). FormatPlain() diff --git a/pkg/commands/patch/transform.go b/pkg/commands/patch/transform.go index cdf453939..31456bfc7 100644 --- a/pkg/commands/patch/transform.go +++ b/pkg/commands/patch/transform.go @@ -33,6 +33,13 @@ type TransformOpts struct { // treat it as a diff against an empty file. TurnAddedFilesIntoDiffAgainstEmptyFile bool + // When building a partial patch for a renamed file, strip the rename + // metadata from the header and point it at the new path. Applying the + // resulting patch then only changes the file's contents and leaves the + // rename itself in place. (For a whole-file selection we keep the rename + // so that it moves or is discarded together with the contents.) + StripRename bool + // The indices of lines that should be included in the patch. IncludedLineIndices []int } @@ -72,21 +79,61 @@ func (self *patchTransformer) transformHeader() []string { "--- a/" + self.opts.FileNameOverride, "+++ b/" + self.opts.FileNameOverride, } - } else if self.opts.TurnAddedFilesIntoDiffAgainstEmptyFile { - result := make([]string, 0, len(self.patch.header)) - for idx, line := range self.patch.header { + } + + header := self.patch.header + if self.opts.StripRename { + header = stripRenameFromHeader(header) + } + + if self.opts.TurnAddedFilesIntoDiffAgainstEmptyFile { + result := make([]string, 0, len(header)) + for idx, line := range header { if strings.HasPrefix(line, "new file mode") { continue } - if line == "--- /dev/null" && strings.HasPrefix(self.patch.header[idx+1], "+++ b/") { - line = "--- a/" + self.patch.header[idx+1][6:] + if line == "--- /dev/null" && strings.HasPrefix(header[idx+1], "+++ b/") { + line = "--- a/" + header[idx+1][6:] } result = append(result, line) } return result } - return self.patch.header + return header +} + +// stripRenameFromHeader rewrites a rename diff header so that it looks like a +// plain modification of the new path: it drops the rename metadata and points +// the diff at the new path on both sides, while keeping the blob index line so +// that `git apply --3way` can still fall back to a blob merge. See the +// StripRename option for why we do this. +func stripRenameFromHeader(header []string) []string { + newPath := "" + for _, line := range header { + if path, ok := strings.CutPrefix(line, "+++ b/"); ok { + newPath = path + break + } + } + + result := make([]string, 0, len(header)) + for _, line := range header { + switch { + case strings.HasPrefix(line, "similarity index "), + strings.HasPrefix(line, "dissimilarity index "), + strings.HasPrefix(line, "rename from "), + strings.HasPrefix(line, "rename to "): + // drop the rename metadata + case strings.HasPrefix(line, "diff --git "): + result = append(result, "diff --git a/"+newPath+" b/"+newPath) + case strings.HasPrefix(line, "--- "): + result = append(result, "--- a/"+newPath) + default: + result = append(result, line) + } + } + return result } func (self *patchTransformer) transformHunks() []*Hunk { diff --git a/pkg/gui/context/setup.go b/pkg/gui/context/setup.go index 8f498e6a9..ef1211313 100644 --- a/pkg/gui/context/setup.go +++ b/pkg/gui/context/setup.go @@ -60,8 +60,11 @@ func NewContextTree(c *ContextCommon) *ContextTree { "main", PATCH_BUILDING_MAIN_CONTEXT_KEY, func() []int { - filename := commitFilesContext.GetSelectedPath() - includedLineIndices, err := c.Git().Patch.PatchBuilder.GetFileIncLineIndices(filename) + file := commitFilesContext.GetSelectedFile() + if file == nil { + return nil + } + includedLineIndices, err := c.Git().Patch.PatchBuilder.GetFileIncLineIndices(file.Path, file.PreviousPath) if err != nil { c.Log.Error(err) return nil diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index eed9d02b9..f9fda0b93 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -191,11 +191,11 @@ func (self *CommitFilesController) GetOnRenderToMain() func() { } } -func (self *CommitFilesController) copyDiffToClipboard(path string, toastMessage string) error { +func (self *CommitFilesController) copyDiffToClipboard(paths []string, toastMessage string) error { from, to := self.context().GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) - cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, []string{path}, true) + cmdObj := self.c.Git().WorkingTree.ShowFileDiffCmdObj(from, to, reverse, paths, true) diff, err := cmdObj.RunWithOutput() if err != nil { return err @@ -263,7 +263,7 @@ func (self *CommitFilesController) openCopyMenu() error { copyFileDiffItem := &types.MenuItem{ Label: self.c.Tr.CopySelectedDiff, OnPress: func() error { - return self.copyDiffToClipboard(node.GetPath(), self.c.Tr.FileDiffCopiedToast) + return self.copyDiffToClipboard(self.pathsForDiff(node), self.c.Tr.FileDiffCopiedToast) }, DisabledReason: self.require(self.singleItemSelected())(), Keys: menuKey('s'), @@ -271,7 +271,7 @@ func (self *CommitFilesController) openCopyMenu() error { copyAllDiff := &types.MenuItem{ Label: self.c.Tr.CopyAllFilesDiff, OnPress: func() error { - return self.copyDiffToClipboard(".", self.c.Tr.AllFilesDiffCopiedToast) + return self.copyDiffToClipboard([]string{"."}, self.c.Tr.AllFilesDiffCopiedToast) }, DisabledReason: self.require(self.itemsSelected())(), Keys: menuKey('a'), @@ -348,7 +348,10 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN for _, node := range selectedNodes { _ = node.ForEachFile(func(file *models.CommitFile) error { - filePaths = append(filePaths, file.GetPath()) + // For a rename we discard both the new and the old path, + // so that the new file is removed and the old one is + // restored. + filePaths = append(filePaths, file.Names()...) return nil }) } @@ -465,7 +468,7 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm for _, node := range selectedNodes { err := node.ForEachFile(func(file *models.CommitFile) error { - return patchOperationFunction(file.Path) + return patchOperationFunction(file.Path, file.PreviousPath) }) if err != nil { return err @@ -610,11 +613,16 @@ func (self *CommitFilesController) pathsForDiff(node *filetree.CommitFileNode) [ if !node.IsFile() && self.context().IsFiltering() { var paths []string _ = node.ForEachFile(func(file *models.CommitFile) error { - paths = append(paths, file.Path) + // 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()} } diff --git a/pkg/gui/controllers/helpers/patch_building_helper.go b/pkg/gui/controllers/helpers/patch_building_helper.go index 9369cca93..ac79ee8d7 100644 --- a/pkg/gui/controllers/helpers/patch_building_helper.go +++ b/pkg/gui/controllers/helpers/patch_building_helper.go @@ -66,20 +66,21 @@ func (self *PatchBuildingHelper) RefreshPatchBuildingPanel(opts types.OnFocusOpt } // get diff from commit file that's currently selected - path := self.c.Contexts().CommitFiles.GetSelectedPath() - if path == "" { + file := self.c.Contexts().CommitFiles.GetSelectedFile() + if file == nil { return } from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) - diff, err := self.c.Git().WorkingTree.ShowFileDiff(from, to, reverse, path, true) + diff, err := self.c.Git().WorkingTree.ShowFileDiff(from, to, reverse, file.Path, file.PreviousPath, true) if err != nil { return } secondaryDiff := self.c.Git().Patch.PatchBuilder.RenderPatchForFile(patch.RenderPatchForFileOpts{ - Filename: path, + Filename: file.Path, + PreviousPath: file.PreviousPath, Plain: false, Reverse: false, TurnAddedFilesIntoDiffAgainstEmptyFile: true, diff --git a/pkg/gui/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go index dd8c89fff..5e4a17169 100644 --- a/pkg/gui/controllers/patch_building_controller.go +++ b/pkg/gui/controllers/patch_building_controller.go @@ -138,8 +138,8 @@ func (self *PatchBuildingController) toggleSelection() error { self.context().GetMutex().Lock() defer self.context().GetMutex().Unlock() - filename := self.c.Contexts().CommitFiles.GetSelectedPath() - if filename == "" { + file := self.c.Contexts().CommitFiles.GetSelectedFile() + if file == nil { return nil } @@ -152,7 +152,7 @@ func (self *PatchBuildingController) toggleSelection() error { return nil } - includedLineIndices, err := self.c.Git().Patch.PatchBuilder.GetFileIncLineIndices(filename) + includedLineIndices, err := self.c.Git().Patch.PatchBuilder.GetFileIncLineIndices(file.Path, file.PreviousPath) if err != nil { return err } @@ -164,7 +164,7 @@ func (self *PatchBuildingController) toggleSelection() error { } // add range of lines to those set for the file - if err := toggleFunc(filename, lineIndicesToToggle); err != nil { + if err := toggleFunc(file.Path, file.PreviousPath, lineIndicesToToggle); err != nil { // might actually want to return an error here self.c.Log.Error(err) } diff --git a/pkg/gui/controllers/rename_similarity_threshold_controller.go b/pkg/gui/controllers/rename_similarity_threshold_controller.go index 78b8bb7f4..e9bf13027 100644 --- a/pkg/gui/controllers/rename_similarity_threshold_controller.go +++ b/pkg/gui/controllers/rename_similarity_threshold_controller.go @@ -1,6 +1,7 @@ package controllers import ( + "errors" "fmt" "github.com/jesseduffield/lazygit/pkg/gui/context" @@ -49,6 +50,10 @@ func (self *RenameSimilarityThresholdController) Context() types.Context { } func (self *RenameSimilarityThresholdController) Increase() error { + if err := self.checkCanChangeThreshold(); err != nil { + return err + } + old_size := self.c.UserConfig().Git.RenameSimilarityThreshold if old_size < 100 { @@ -59,6 +64,10 @@ func (self *RenameSimilarityThresholdController) Increase() error { } func (self *RenameSimilarityThresholdController) Decrease() error { + if err := self.checkCanChangeThreshold(); err != nil { + return err + } + old_size := self.c.UserConfig().Git.RenameSimilarityThreshold if old_size > 5 { @@ -73,11 +82,23 @@ func (self *RenameSimilarityThresholdController) applyChange() error { currentContext := self.c.Context().CurrentSide() switch currentContext.GetKey() { - // we make an exception for our files context, because it actually need to refresh its state afterwards. + // we make an exception for the files and commit-files contexts, because + // they actually need to refresh their state afterwards: a changed threshold + // can turn a rename into a separate delete and add, or vice versa. case context.FILES_CONTEXT_KEY: self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) + case context.COMMIT_FILES_CONTEXT_KEY: + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMIT_FILES}}) default: currentContext.HandleRenderToMain() } return nil } + +func (self *RenameSimilarityThresholdController) checkCanChangeThreshold() error { + if self.c.Git().Patch.PatchBuilder.Active() { + return errors.New(self.c.Tr.CantChangeRenameThresholdError) + } + + return nil +} diff --git a/pkg/gui/presentation/files.go b/pkg/gui/presentation/files.go index cc0a93889..534229905 100644 --- a/pkg/gui/presentation/files.go +++ b/pkg/gui/presentation/files.go @@ -328,7 +328,8 @@ func fileNameAtDepth(node *filetree.Node[models.File], depth int, showRootItem b func commitFileNameAtDepth(node *filetree.Node[models.CommitFile], depth int) string { splitName := split(node.GetInternalPath()) - if depth == 0 && splitName[0] == "." { + showRootItem := splitName[0] == "." + if depth == 0 && showRootItem { if len(splitName) == 1 { return "/" } @@ -336,6 +337,20 @@ func commitFileNameAtDepth(node *filetree.Node[models.CommitFile], depth int) st } name := join(splitName[depth:]) + if node.File != nil && node.File.IsRename() { + splitPrevName := filetree.SplitFileTreePath(node.File.PreviousPath, showRootItem) + + prevName := node.File.PreviousPath + // if the file has just been renamed inside the same directory, we can shave off + // the prefix for the previous path too. Otherwise we'll keep it unchanged + sameParentDir := len(splitName) == len(splitPrevName) && join(splitName[0:depth]) == join(splitPrevName[0:depth]) + if sameParentDir { + prevName = join(splitPrevName[depth:]) + } + + return prevName + " → " + name + } + return name } diff --git a/pkg/gui/presentation/files_test.go b/pkg/gui/presentation/files_test.go index a5b01c156..c7e333682 100644 --- a/pkg/gui/presentation/files_test.go +++ b/pkg/gui/presentation/files_test.go @@ -151,6 +151,14 @@ func TestRenderCommitFileTree(t *testing.T) { showRootItem: true, expected: []string{"A test"}, }, + { + name: "renamed file", + files: []*models.CommitFile{ + {Path: "new.txt", PreviousPath: "old.txt", ChangeStatus: "R"}, + }, + showRootItem: false, + expected: []string{"R old.txt → new.txt"}, + }, { name: "big example", files: []*models.CommitFile{ @@ -219,7 +227,7 @@ M file1 } patchBuilder := patch.NewPatchBuilder( utils.NewDummyLog(), - func(from string, to string, reverse bool, filename string, plain bool) (string, error) { + func(from string, to string, reverse bool, filename string, previousPath string, plain bool) (string, error) { return "", nil }, ) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index be3886fbe..6f18842d5 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -819,6 +819,7 @@ type TranslationSet struct { SortCommits string SortCommitsTooltip string CantChangeContextSizeError string + CantChangeRenameThresholdError string OpenCommitInBrowser string ViewBisectOptions string ConfirmRevertCommit string @@ -1966,6 +1967,7 @@ func EnglishTranslationSet() *TranslationSet { SortCommits: "Commit sort order", SortCommitsTooltip: "Change the sort order of the commits in the commit log.\n\nThe default can be changed in the config file with the key 'git.log.sortOrder'.", CantChangeContextSizeError: "Cannot change context while in patch building mode because we were too lazy to support it when releasing the feature. If you really want it, please let us know!", + CantChangeRenameThresholdError: "Cannot change the rename similarity threshold while in patch building mode, because the custom patch can't cope with a rename turning into a delete and add underneath it.", OpenCommitInBrowser: "Open commit in browser", ViewBisectOptions: "View bisect options", ConfirmRevertCommit: "Are you sure you want to revert {{.selectedCommit}}?", diff --git a/pkg/integration/tests/commit/discard_renamed_file.go b/pkg/integration/tests/commit/discard_renamed_file.go new file mode 100644 index 000000000..01e729425 --- /dev/null +++ b/pkg/integration/tests/commit/discard_renamed_file.go @@ -0,0 +1,57 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var DiscardRenamedFile = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Discard a renamed file from an old commit; both the new and the old path are handled so the rename is undone", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("original", "line1\nline2\nline3\nline4\nline5\n") + shell.CreateFileAndAdd("other", "other content\n") + shell.Commit("first commit") + + shell.RenameFileInGit("original", "renamed") + shell.UpdateFileAndAdd("renamed", "line1\nline2 changed\nline3\nline4\nline5\n") + shell.Commit("rename with modification") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("rename with modification").IsSelected(), + Contains("first commit"), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("original → renamed").IsSelected(), + ). + Press(keys.Universal.Remove) + + t.ExpectPopup().Confirmation(). + Title(Equals("Discard file changes")). + Content(Contains("Are you sure you want to discard changes to the selected file(s) from this commit?")). + Confirm() + + // The rename is undone: the commit no longer touches any file. (If only + // the new path were discarded, the commit would still delete the old + // path and show "D original" here instead.) + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("(none)"), + ). + PressEscape() + + // The working tree is clean; the original file is back at HEAD. + t.Views().Files(). + IsEmpty() + }, +}) diff --git a/pkg/integration/tests/patch_building/copy_renamed_file_diff.go b/pkg/integration/tests/patch_building/copy_renamed_file_diff.go new file mode 100644 index 000000000..6343527e5 --- /dev/null +++ b/pkg/integration/tests/patch_building/copy_renamed_file_diff.go @@ -0,0 +1,60 @@ +package patch_building + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// note: this is required to simulate the clipboard during CI +func expectClipboard(t *TestDriver, matcher *TextMatcher) { + defer t.Shell().DeleteFile("clipboard") + + t.FileSystem().FileContent("clipboard", matcher) +} + +var CopyRenamedFileDiff = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Copy the diff of a renamed file to the clipboard; the diff shows the rename rather than a delete and add", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().OS.CopyToClipboardCmd = "printf '%s' {{text}} > clipboard" + }, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("original", "line1\nline2\nline3\nline4\nline5\n") + shell.Commit("first commit") + + shell.RenameFileInGit("original", "renamed") + shell.UpdateFileAndAdd("renamed", "line1\nline2 changed\nline3\nline4\nline5\n") + shell.Commit("rename with modification") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("rename with modification").IsSelected(), + Contains("first commit"), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("original → renamed").IsSelected(), + ). + Press(keys.Files.CopyFileInfoToClipboard) + + t.ExpectPopup().Menu(). + Title(Equals("Copy to clipboard")). + Select(Contains("Diff of selected file")). + Confirm() + + t.ExpectToast(Contains("File diff copied to clipboard")) + + expectClipboard(t, + Contains("rename from original"). + Contains("rename to renamed"). + Contains("-line2"). + Contains("+line2 changed"), + ) + }, +}) diff --git a/pkg/integration/tests/patch_building/rename_similarity_threshold_change.go b/pkg/integration/tests/patch_building/rename_similarity_threshold_change.go new file mode 100644 index 000000000..d36e0450b --- /dev/null +++ b/pkg/integration/tests/patch_building/rename_similarity_threshold_change.go @@ -0,0 +1,66 @@ +package patch_building + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RenameSimilarityThresholdChange = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Changing the rename similarity threshold refreshes the commit files panel, but is disabled while building a patch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("original", "one\ntwo\nthree\nfour\nfive\n") + shell.Commit("add original") + + shell.RenameFileInGit("original", "renamed") + shell.UpdateFileAndAdd("renamed", "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n") + shell.Commit("change name and contents") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("change name and contents").IsSelected(), + Contains("add original"), + ). + PressEnter() + + // At the default threshold of 50% the 50%-similar change is not detected + // as a rename. + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ /"), + Equals(" D original"), + Equals(" A renamed"), + ). + // Lowering the threshold turns it into a rename; the panel refreshes. + Press(keys.Universal.DecreaseRenameSimilarityThreshold). + Tap(func() { + t.ExpectToast(Equals("Changed rename similarity threshold to 45%")) + }). + Lines( + Equals("R original → renamed"), + ). + // Start building a patch from the renamed file. + PressPrimaryAction(). + Tap(func() { + t.Views().Information().Content(Contains("Building patch")) + + // Changing the threshold is now disabled: the patch builder + // can't cope with the rename turning into a delete and add. + t.Views().CommitFiles(). + Press(keys.Universal.IncreaseRenameSimilarityThreshold) + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("Cannot change the rename similarity threshold while in patch building mode")). + Confirm() + }). + // The file is unchanged: still a rename, still in the patch. + Lines( + Contains("original → renamed").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/patch_building/renamed_file_partial.go b/pkg/integration/tests/patch_building/renamed_file_partial.go new file mode 100644 index 000000000..3c37c13a2 --- /dev/null +++ b/pkg/integration/tests/patch_building/renamed_file_partial.go @@ -0,0 +1,73 @@ +package patch_building + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RenamedFilePartial = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Select part of a renamed file's changes into a custom patch and remove it from the commit, keeping the rename in place", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("original", "line1\nline2\nline3\nline4\nline5\n") + shell.Commit("first commit") + + shell.RenameFileInGit("original", "renamed") + shell.UpdateFileAndAdd("renamed", "line1\nline2 changed\nline3\nline4\nline5\n") + shell.Commit("rename with modification") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("rename with modification").IsSelected(), + Contains("first commit"), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("original → renamed").IsSelected(), + ). + PressEnter() + + // The main view shows the rename together with its content change. + t.Views().PatchBuilding(). + IsFocused(). + Content(Contains("rename from original").Contains("rename to renamed")). + ContainsLines( + Contains(" line1"), + Contains("-line2"), + Contains("+line2 changed"), + Contains(" line3"), + ). + // Add the hunk (a line selection, as opposed to adding the whole + // file), so this is a partial patch. + PressPrimaryAction() + + t.Views().Information().Content(Contains("Building patch")) + + t.Common().SelectPatchOption(Contains("Remove patch from original commit")) + + // The rename is preserved; only the content change is gone, so the file + // is still shown as a rename but now has no content change. + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("original → renamed").IsSelected(), + ) + + t.Views().Main(). + Content(DoesNotContain("line2 changed")) + + t.Views().Commits(). + Focus(). + Lines( + Contains("rename with modification").IsSelected(), + Contains("first commit"), + ) + }, +}) diff --git a/pkg/integration/tests/patch_building/renamed_file_whole.go b/pkg/integration/tests/patch_building/renamed_file_whole.go new file mode 100644 index 000000000..f4151a766 --- /dev/null +++ b/pkg/integration/tests/patch_building/renamed_file_whole.go @@ -0,0 +1,62 @@ +package patch_building + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var RenamedFileWhole = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Add a whole renamed file to a custom patch and remove it from the commit, taking the rename with it", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("original", "line1\nline2\nline3\nline4\nline5\n") + shell.Commit("first commit") + + shell.RenameFileInGit("original", "renamed") + shell.UpdateFileAndAdd("renamed", "line1\nline2 changed\nline3\nline4\nline5\n") + shell.Commit("rename with modification") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("rename with modification").IsSelected(), + Contains("first commit"), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("original → renamed").IsSelected(), + ). + PressPrimaryAction() + + t.Views().Information().Content(Contains("Building patch")) + + // The whole file is added, so the patch carries the rename itself. + t.Views().Secondary(). + ContainsLines( + Contains("rename from original"), + Contains("rename to renamed"), + ) + + t.Common().SelectPatchOption(Contains("Remove patch from original commit")) + + // The rename went with the patch, so the commit no longer touches the file. + t.Views().CommitFiles(). + IsFocused(). + Lines( + Contains("(none)"), + ) + + t.Views().Commits(). + Focus(). + Lines( + Contains("rename with modification").IsSelected(), + Contains("first commit"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 380aca2b6..abf13073e 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -128,6 +128,7 @@ var tests = []*components.IntegrationTest{ commit.CreateTag, commit.DisableCopyCommitMessageBody, commit.DiscardOldFileChanges, + commit.DiscardRenamedFile, commit.DiscardSubmoduleChanges, commit.DoNotShowBranchMarkerForHeadCommit, commit.FailHooksThenCommitNoHooks, @@ -351,6 +352,7 @@ var tests = []*components.IntegrationTest{ patch_building.ApplyInReverseWithConflict, patch_building.ApplyWithModifiedFileConflict, patch_building.ApplyWithModifiedFileNoConflict, + patch_building.CopyRenamedFileDiff, patch_building.DiscardLinesFromCommit, patch_building.EditLineInPatchBuildingPanel, patch_building.MoveRangeToIndex, @@ -373,6 +375,9 @@ var tests = []*components.IntegrationTest{ patch_building.MoveToNewCommitPartialHunk, patch_building.RemoveFromCommit, patch_building.RemovePartsOfAddedFile, + patch_building.RenameSimilarityThresholdChange, + patch_building.RenamedFilePartial, + patch_building.RenamedFileWhole, patch_building.ResetWithEscape, patch_building.SelectAllFiles, patch_building.SpecificSelection, From aa46a69f77bdc063edbac586b33f84e6382feefc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 4 Jul 2026 13:03:47 +0200 Subject: [PATCH 190/384] Remove unused function ExpectClipboard This can't be used because it wouldn't work on CI; delete it so that coding agents aren't tempted to use it. --- pkg/integration/components/test_driver.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 301ab3862..376b0f4d6 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -4,7 +4,6 @@ import ( "fmt" "time" - "github.com/atotto/clipboard" "github.com/jesseduffield/lazygit/pkg/config" integrationTypes "github.com/jesseduffield/lazygit/pkg/integration/types" ) @@ -125,17 +124,6 @@ func (self *TestDriver) ExpectToast(matcher *TextMatcher) *TestDriver { return self } -func (self *TestDriver) ExpectClipboard(matcher *TextMatcher) { - self.assertWithRetries(func() (bool, string) { - text, err := clipboard.ReadAll() - if err != nil { - return false, "Error occurred when reading from clipboard: " + err.Error() - } - ok, _ := matcher.test(text) - return ok, fmt.Sprintf("Expected clipboard to match %s, but got %s", matcher.name(), text) - }) -} - func (self *TestDriver) ExpectSearch() *SearchDriver { self.inSearch() From 8e36fba91e4c6c824ae0962ec853d7cd43797fc0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 4 Jul 2026 14:36:49 +0200 Subject: [PATCH 191/384] Update translations from Crowdin --- docs-master/keybindings/Keybindings_nl.md | 122 +++++++------- pkg/i18n/translations/ja.json | 8 - pkg/i18n/translations/ko.json | 1 - pkg/i18n/translations/nl.json | 193 +++++++++++++++++++++- pkg/i18n/translations/pl.json | 8 - pkg/i18n/translations/pt.json | 5 - pkg/i18n/translations/ru.json | 2 - pkg/i18n/translations/zh-CN.json | 11 +- pkg/i18n/translations/zh-TW.json | 8 - 9 files changed, 251 insertions(+), 107 deletions(-) diff --git a/docs-master/keybindings/Keybindings_nl.md b/docs-master/keybindings/Keybindings_nl.md index 7f1216b5f..0eb61729b 100644 --- a/docs-master/keybindings/Keybindings_nl.md +++ b/docs-master/keybindings/Keybindings_nl.md @@ -10,15 +10,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , 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. | -| `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | -| `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | +| `` 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. | | `` `` | Bekijk aangepaste patch opties | | -| `` m `` | Bekijk merge/rebase opties | View options to abort/continue/skip the current merge/rebase. | +| `` 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 | | @@ -28,10 +28,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` ? `` | 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, `` | Quit | | -| `` `` | Suspend the application | | +| `` 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'. | -| `` `` | Verander config bestand | Open file in external editor. | +| `` `` | 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. | @@ -47,8 +47,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Range select down | | | `` `` | Range select up | | | `` / `` | Start met zoeken | | -| `` H `` | Scroll left | | -| `` L `` | Scroll right | | +| `` H `` | Scroll naar links | | +| `` L `` | Scroll naar rechts | | | `` ] `` | Volgende tabblad | | | `` [ `` | Vorige tabblad | | @@ -58,15 +58,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Kopieer de bestandsnaam naar het klembord | | | `` `` | Toggle staged | Toggle staged for selected file. | -| `` `` | Filter files by status | | -| `` y `` | Copy to clipboard | | -| `` c `` | Commit veranderingen | Commit staged changes. | +| `` `` | Filter bestanden op status | | +| `` y `` | Kopieer naar klembord | | +| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. | | `` w `` | Commit veranderingen zonder pre-commit hook | | | `` A `` | Wijzig laatste commit | | | `` C `` | Commit veranderingen met de git editor | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | -| `` e `` | Edit | Open file in external editor. | -| `` o `` | Open bestand | Open file in default application. | +| `` `` | Find base commit for fixup | Vind de commit waar je huidige wijzigingen bovenop zijn gebouwd met als doel die commit te amenden/fixen. Hierdoor hoef je dit niet met de hand te doen. Zie: | +| `` e `` | Edit | Open bestand in externe editor. | +| `` o `` | Open bestand | Open bestand in standaardapplicatie. | | `` i `` | Ignore or exclude file | | | `` r `` | Refresh bestanden | | | `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. | @@ -75,13 +75,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Stage individuele hunks/lijnen | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. | | `` d `` | Bekijk 'veranderingen ongedaan maken' opties | View options for discarding changes to the selected file. | | `` g `` | Bekijk upstream reset opties | | -| `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | +| `` D `` | Resetten | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | -| `` `` | Open external diff tool (git difftool) | | -| `` M `` | View merge conflict options | View options for resolving merge conflicts. | +| `` `` | Open externe diff applicatie (git difftool) | | +| `` M `` | Bekijk merge conflict opties | Bekijk opties voor het oplossen van mergeconflicten. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | -| `` = `` | Expand all files | Expand all directories in the file tree | +| `` = `` | Vouw alle bestanden uit | Vouw alle mappen in de bestandsstructuur uit | | `` 0 `` | Focus main view | | | `` / `` | Filter the current view by text | | @@ -91,7 +91,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Bevestig | | | `` `` | Sluiten | | -| `` `` | Copy to clipboard | | +| `` `` | Kopieer naar klembord | | ## Branches @@ -99,19 +99,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Kopieer branch name naar klembord | | | `` i `` | Laat git-flow opties zien | | -| `` `` | Uitchecken | Checkout selected item. | +| `` `` | Uitchecken | Geselecteerd item uitchecken. | | `` n `` | Nieuwe branch | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Verplaats commits naar nieuwe branch | Maak een nieuwe branch en verplaats niet-gepushte commits van de huidige branch hier naar toe. Gebruik dit in het geval dat je deze commits eigenlijk op een nieuwe branch had willen maken.

Let op dat de selectie genegeerd wordt. De nieuwe branch komt ofwel bovenop de main branch, of bovenop de huidige branch (je kan kiezen). | | `` w `` | New worktree | | | `` o `` | Maak een pull-request | | | `` O `` | Bekijk opties voor pull-aanvraag | | | `` G `` | Open pull request in browser | | | `` `` | Kopieer de URL van het pull-verzoek naar het klembord | | | `` c `` | Uitchecken bij naam | Checkout by name. In the input box you can enter '-' to switch to the previous branch. | -| `` - `` | Checkout previous branch | | +| `` - `` | Vorige branch uitchecken | | | `` F `` | Forceer checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | -| `` d `` | Delete | View delete options for local/remote branch. | -| `` r `` | Rebase branch | Rebase the checked-out branch onto the selected branch. | +| `` d `` | Verwijderen | View delete options for local/remote branch. | +| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. | | `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. | | `` T `` | Creëer tag | | @@ -119,7 +119,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Bekijk reset opties | | | `` R `` | Hernoem branch | | | `` u `` | View upstream options | View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open externe diff applicatie (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | | `` / `` | Filter the current view by text | | @@ -136,18 +136,18 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopieer de bestandsnaam naar het klembord | | -| `` y `` | Copy to clipboard | | +| `` y `` | Kopieer naar klembord | | | `` c `` | Uitchecken | Bestand uitchecken | | `` d `` | Bekijk 'veranderingen ongedaan maken' opties | Uitsluit deze commit zijn veranderingen aan dit bestand | -| `` o `` | Open bestand | Open file in default application. | -| `` e `` | Edit | Open file in external editor. | -| `` `` | Open external diff tool (git difftool) | | +| `` o `` | Open bestand | Open bestand in standaardapplicatie. | +| `` e `` | Edit | Open bestand in externe editor. | +| `` `` | Open externe diff applicatie (git difftool) | | | `` `` | Toggle bestand inbegrepen in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Enter bestand om geselecteerde regels toe te voegen aan de patch | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | | `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` - `` | Collapse all files | Collapse all directories in the files tree | -| `` = `` | Expand all files | Expand all directories in the file tree | +| `` = `` | Vouw alle bestanden uit | Vouw alle mappen in de bestandsstructuur uit | | `` 0 `` | Focus main view | | | `` / `` | Filter the current view by text | | @@ -183,11 +183,11 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` 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 | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Verplaats commits naar nieuwe branch | Maak een nieuwe branch en verplaats niet-gepushte commits van de huidige branch hier naar toe. Gebruik dit in het geval dat je deze commits eigenlijk op een nieuwe branch had willen maken.

Let op dat de selectie genegeerd wordt. De nieuwe branch komt ofwel bovenop de main branch, of bovenop de huidige branch (je kan kiezen). | | `` w `` | New worktree | | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open externe diff applicatie (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | @@ -219,9 +219,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , h `` | Selecteer voorgaand conflict | | | `` , l `` | Selecteer volgende conflict | | | `` z `` | Ongedaan maken | Undo last merge conflict resolution. | -| `` e `` | Verander bestand | Open file in external editor. | -| `` o `` | Open bestand | Open file in default application. | -| `` M `` | View merge conflict options | View options for resolving merge conflicts. | +| `` e `` | Verander bestand | Open bestand in externe editor. | +| `` o `` | Open bestand | Open bestand in standaardapplicatie. | +| `` M `` | Bekijk merge conflict opties | Bekijk opties voor het oplossen van mergeconflicten. | | `` `` | Ga terug naar het bestanden paneel | | ## Normaal @@ -243,8 +243,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` v `` | Toggle drag selecteer | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` `` | Copy selected text to clipboard | | -| `` o `` | Open bestand | Open file in default application. | -| `` e `` | Verander bestand | Open file in external editor. | +| `` o `` | Open bestand | Open bestand in standaardapplicatie. | +| `` e `` | Verander bestand | Open bestand in externe editor. | | `` `` | Voeg toe/verwijder lijn(en) in patch | | | `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. | | `` `` | Sluit lijn-bij-lijn modus | | @@ -259,12 +259,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` 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 | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Verplaats commits naar nieuwe branch | Maak een nieuwe branch en verplaats niet-gepushte commits van de huidige branch hier naar toe. Gebruik dit in het geval dat je deze commits eigenlijk op een nieuwe branch had willen maken.

Let op dat de selectie genegeerd wordt. De nieuwe branch komt ofwel bovenop de main branch, of bovenop de huidige branch (je kan kiezen). | | `` w `` | New worktree | | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (gekopieerde) commits selectie | | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open externe diff applicatie (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | @@ -275,16 +275,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopieer branch name naar klembord | | -| `` `` | Uitchecken | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | +| `` `` | Uitchecken | Geselecteerde remote branch uitchecken als nieuwe locale branch of als detached head. | | `` n `` | Nieuwe branch | | | `` w `` | New worktree | | | `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) | -| `` r `` | Rebase branch | Rebase the checked-out branch onto the selected branch. | -| `` d `` | Delete | Delete the remote branch from the remote. | -| `` u `` | Set as upstream | Stel in als upstream van uitgecheckte branch | +| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. | +| `` d `` | Verwijderen | Delete the remote branch from the remote. | +| `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch | | `` s `` | Sort order | | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open externe diff applicatie (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | | `` / `` | Filter the current view by text | | @@ -293,9 +293,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` `` | View branches | | +| `` `` | Bekijk branches | | | `` n `` | Voeg een nieuwe remote toe | | -| `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | +| `` d `` | Verwijderen | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | Edit | Wijzig remote | | `` f `` | Fetch | Fetch remote | | `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. | @@ -320,15 +320,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 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. | -| `` o `` | Open bestand | Open file in default application. | -| `` e `` | Verander bestand | Open file in external editor. | +| `` o `` | Open bestand | Open bestand in standaardapplicatie. | +| `` e `` | Verander bestand | Open bestand in externe editor. | | `` `` | Ga terug naar het bestanden paneel | | | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` E `` | Edit hunk | Edit selected hunk in external editor. | -| `` c `` | Commit veranderingen | Commit staged changes. | +| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. | | `` w `` | Commit veranderingen zonder pre-commit hook | | | `` C `` | Commit veranderingen met de git editor | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | +| `` `` | Find base commit for fixup | Vind de commit waar je huidige wijzigingen bovenop zijn gebouwd met als doel die commit te amenden/fixen. Hierdoor hoef je dit niet met de hand te doen. Zie: | | `` / `` | Start met zoeken | | ## Stash @@ -340,7 +340,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` d `` | Laten vallen | Remove the stash entry from the stash list. | | `` n `` | Nieuwe branch | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | | `` w `` | New worktree | | -| `` r `` | Rename stash | | +| `` r `` | Hernoem stash | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | | `` / `` | Filter the current view by text | | @@ -349,7 +349,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` e `` | Verander config bestand | Open file in external editor. | +| `` e `` | Verander config bestand | Open bestand in externe editor. | | `` u `` | Check voor updates | | | `` `` | Wissel naar een recente repo | | | `` a `` | Show/cycle all branch logs | | @@ -365,12 +365,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` 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 | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Verplaats commits naar nieuwe branch | Maak een nieuwe branch en verplaats niet-gepushte commits van de huidige branch hier naar toe. Gebruik dit in het geval dat je deze commits eigenlijk op een nieuwe branch had willen maken.

Let op dat de selectie genegeerd wordt. De nieuwe branch komt ofwel bovenop de main branch, of bovenop de huidige branch (je kan kiezen). | | `` w `` | New worktree | | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (gekopieerde) commits selectie | | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open externe diff applicatie (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | @@ -382,7 +382,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Kopieer submodule naam naar klembord | | | `` `` | Enter | Enter submodule | -| `` d `` | Remove | Remove the selected submodule and its corresponding directory. | +| `` d `` | Verwijderen | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | Update selected submodule. | | `` n `` | Voeg nieuwe submodule toe | | | `` e `` | Update submodule URL | | @@ -395,13 +395,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy tag to clipboard | | -| `` `` | Uitchecken | Checkout the selected tag as a detached HEAD. | +| `` `` | 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. | | `` w `` | New worktree | | -| `` d `` | Delete | View delete options for local/remote tag. | +| `` 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. | -| `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | Open external diff tool (git difftool) | | +| `` g `` | Resetten | View reset options (soft/mixed/hard) for resetting onto selected item. | +| `` `` | Open externe diff applicatie (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | | `` / `` | Filter the current view by text | | @@ -412,6 +412,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` n `` | New worktree | | | `` `` | Switch | Switch to the selected worktree. | -| `` o `` | Open in editor | | -| `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. | +| `` o `` | Openen in editor | | +| `` d `` | Verwijderen | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. | | `` / `` | Filter the current view by text | | diff --git a/pkg/i18n/translations/ja.json b/pkg/i18n/translations/ja.json index ff9d804e5..593e253fb 100644 --- a/pkg/i18n/translations/ja.json +++ b/pkg/i18n/translations/ja.json @@ -207,7 +207,6 @@ "StashChanges": "変更をスタッシュ(一時保存)", "RenameStash": "スタッシュの名前を変更", "RenameStashPrompt": "スタッシュの名前を変更: {{.stashName}}", - "OpenConfig": "設定ファイルを開く", "EditConfig": "設定ファイルを編集", "ForcePush": "強制プッシュ", "ForcePushPrompt": "ローカルブランチはリモートブランチから分岐しています。キャンセルするには{{.cancelKey}}を、強制プッシュするには{{.confirmKey}}を押してください。", @@ -276,7 +275,6 @@ "ViewConflictsMenuItem": "コンフリクトを表示", "AbortMenuItem": "%s を中止", "PickHunk": "ハンクを選択", - "PickAllHunks": "すべてのハンクを選択", "ViewMergeRebaseOptions": "マージ/リベースオプションを表示", "ViewMergeRebaseOptionsTooltip": "現在のマージ/リベースを中止/継続/スキップするオプションを表示します。", "ViewMergeOptions": "マージオプションを表示", @@ -770,7 +768,6 @@ "DetachingWorktree": "ワークツリーをデタッチ中", "WorktreesTitle": "ワークツリー", "WorktreeTitle": "ワークツリー", - "RemoveWorktreePrompt": "ワークツリー '{{.worktreeName}}' を削除してよろしいですか?", "RemovingWorktree": "ワークツリーを削除中", "AddingWorktree": "ワークツリーを追加中", "CantDeleteCurrentWorktree": "現在のワークツリーは削除できません!", @@ -780,13 +777,8 @@ "MissingWorktree": "(見つかりません)", "NewWorktree": "新しいワークツリー", "NewWorktreePath": "新しいワークツリーのパス", - "NewWorktreeBase": "新しいワークツリーのベース参照", "RemoveWorktreeTooltip": "選択したワークツリーを削除します。これはワークツリーのディレクトリとワークツリーに関するメタデータの両方を.gitディレクトリから削除します。", "NewBranchName": "新しいブランチ名", - "NewBranchNameLeaveBlank": "新しいブランチ名({{.default}} をチェックアウトするには空白のままにしてください)", - "ViewWorktreeOptions": "ワークツリーオプションを表示", - "CreateWorktreeFrom": "{{.ref}} からワークツリーを作成", - "CreateWorktreeFromDetached": "{{.ref}} からワークツリーを作成(デタッチド)", "LcWorktree": "ワークツリー", "ChangingDirectoryTo": "ディレクトリを {{.path}} に変更中", "Name": "名前", diff --git a/pkg/i18n/translations/ko.json b/pkg/i18n/translations/ko.json index 1de57315e..dd02302ba 100644 --- a/pkg/i18n/translations/ko.json +++ b/pkg/i18n/translations/ko.json @@ -101,7 +101,6 @@ "StashApply": "Stash 적용", "SureApplyStashEntry": "정말로 Stash를 적용하시겠습니까?", "StashChanges": "변경을 Stash", - "OpenConfig": "설정 파일 열기", "EditConfig": "설정 파일 수정", "ForcePush": "강제 푸시", "ForcePushPrompt": "브랜치가 원격 브랜치에서 분기하고 있습니다. 'esc'를 눌러 취소하거나, 'enter'를 눌러 강제로 푸시하세요.", diff --git a/pkg/i18n/translations/nl.json b/pkg/i18n/translations/nl.json index 93a2dbe9e..e6369d85a 100644 --- a/pkg/i18n/translations/nl.json +++ b/pkg/i18n/translations/nl.json @@ -5,30 +5,81 @@ "BranchesTitle": "Branches", "CommitsTitle": "Commits", "StashTitle": "Stash", + "SnakeTitle": "Snake", + "EasterEgg": "Easter egg", "UnstagedChanges": "Unstaged wijzigingen", "StagedChanges": "Staged wijzigingen", "StagingTitle": "Staging", "MergingTitle": "Mergen", "NormalTitle": "Normaal", + "LogTitle": "Log", + "LogXOfYTitle": "Log (%d van %d)", "CommitSummary": "Commitbericht", "CredentialsUsername": "Gebruikersnaam", "CredentialsPassword": "Wachtwoord", "CredentialsPassphrase": "Voer een wachtwoordzin in voor de SSH-sleutel", + "CredentialsPIN": "Voer PIN voor SSH sleutel in", + "CredentialsToken": "Voer Token voor SSH sleutel in", "PassUnameWrong": "Wachtwoord en/of gebruikersnaam verkeerd", "Commit": "Commit veranderingen", + "CommitTooltip": "Commit gestagede wijzigingen.", "AmendLastCommit": "Wijzig laatste commit", "AmendLastCommitTitle": "Wijzig laatste commit", "SureToAmend": "Weet je zeker dat je de laatste commit wilt wijzigen? U kunt het commit-bericht wijzigen vanuit het commits-paneel.", "NoCommitToAmend": "Er is geen commits om te wijzigen.", "CommitChangesWithEditor": "Commit veranderingen met de git editor", + "FindBaseCommitForFixupTooltip": "Vind de commit waar je huidige wijzigingen bovenop zijn gebouwd met als doel die commit te amenden/fixen. Hierdoor hoef je dit niet met de hand te doen. Zie: ", + "BaseCommitIsNotInCurrentView": "Basis commit is niet zichtbaar", + "StatusTitle": "Status", "GlobalTitle": "Globale sneltoetsen", "Execute": "Uitvoeren", "Stage": "Toggle staged", "ToggleStagedAll": "Toggle staged alle", "ToggleTreeView": "Toggle bestandsboom weergave", + "OpenDiffTool": "Open externe diff applicatie (git difftool)", + "OpenMergeTool": "Open externe merge applicatie", "Refresh": "Verversen", + "Push": "Push", + "Pull": "Pull", + "PushTooltip": "Push de huidige branch naar de bijbehorende upstream-branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren.", + "PullTooltip": "Pull wijzigingen van de remote voor de huidige branch. Als er geen upstream is geconfigureerd wordt er gevraagd om een upstream-branch te configureren.", + "FileFilter": "Filter bestanden op status", + "CopyToClipboardMenu": "Kopieer naar klembord", + "CopyFileName": "Bestandsnaam", + "CopyRelativeFilePath": "Relatief pad", + "CopyAbsoluteFilePath": "Absoluut pad", + "CopyFileDiffTooltip": "Als er gestagede items zijn neemt dit commando alleen deze items mee. Zo niet, neemt het alle niet-gestagede items mee.", + "CopySelectedDiff": "Diff van geselecteerde bestand", + "CopyAllFilesDiff": "Diff van alle bestanden", + "CopyFileContent": "Inhoud van geselecteerde bestand", + "NoContentToCopyError": "Er is niks om te kopiëren", + "FileNameCopiedToast": "Bestandsnaam gekopieerd naar klembord", + "FilePathCopiedToast": "Bestandspad gekopieerd naar klembord", + "FileDiffCopiedToast": "Diff van bestand gekopieerd naar klembord", + "AllFilesDiffCopiedToast": "Diff van alle bestanden gekopieerd naar klembord", + "FileContentCopiedToast": "Bestandsinhoud gekopieerd naar klembord", + "FilterStagedFiles": "Toon alleen gestagede bestanden", + "FilterUnstagedFiles": "Toon alleen niet-gestagede bestanden", + "FilterTrackedFiles": "Toon alleen getrackte bestanden", + "FilterUntrackedFiles": "Toon alleen niet-getrackte bestanden", + "NoFilter": "Geen filter", + "FilterLabelStagedFiles": "(alleen gestaged)", + "FilterLabelUnstagedFiles": "(alleen niet-gestaged)", + "FilterLabelTrackedFiles": "(alleen getrackt)", + "FilterLabelUntrackedFiles": "(alleen niet-getrackt)", + "FilterLabelConflictingFiles": "(alleen conflicten)", "MergeConflictsTitle": "Merge conflicten", + "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:", + "MergeConflictPressEnterToResolve": "Druk op %s om op te lossen.", + "MergeConflictKeepFile": "Behoud bestanden", + "MergeConflictDeleteFile": "Bestand verwijderen", "Checkout": "Uitchecken", + "CheckoutTooltip": "Geselecteerd item uitchecken.", + "CantCheckoutBranchWhilePulling": "Je kan geen andere branch uitchecken tijdens het pullen van de huidige branch", + "TagCheckoutTooltip": "Geselecteerde tag uitchecken als detached HEAD.", + "RemoteBranchCheckoutTooltip": "Geselecteerde remote branch uitchecken als nieuwe locale branch of als detached head.", "NoChangedFiles": "Geen veranderde bestanden", "SoftReset": "Zacht reset", "AlreadyCheckedOutBranch": "Je hebt deze branch al uitgecheckt", @@ -37,54 +88,94 @@ "BranchName": "Branch naam", "NewBranchNameBranchOff": "Nieuw branch naam (Branch is afgeleid van '{{.branchName}}')", "CantDeleteCheckOutBranch": "Je kan een uitgecheckte branch niet verwijderen!", + "DeleteBranchTitle": "Verwijder branch '{{.selectedBranchName}}'?", + "DeleteBranchesTitle": "Geselecteerde branches verwijderen?", + "DeleteLocalBranch": "Lokale branch verwijderen", + "DeleteLocalBranches": "Lokale branches verwijderen", + "DeleteRemoteBranchPrompt": "Weet je zeker dat je de remote branch '{{.selectedBranchName}}' wilt verwijderen uit '{{.upstream}}'?", + "ForceDeleteBranchTitle": "Forceer verwijderen van branch", "ForceDeleteBranchMessage": "Weet je zeker dat je branch '{{.selectedBranchName}}' geforceerd wil verwijderen?", "RebaseBranch": "Rebase branch", + "RebaseBranchTooltip": "Rebase de uitgecheckte branch bovenop de geselecteerde branch.", "CantRebaseOntoSelf": "Je kan niet een branch rebasen op zichzelf", "CantMergeBranchIntoItself": "Je kan niet een branch in zichzelf mergen", "ForceCheckout": "Forceer checkout", "CheckoutByName": "Uitchecken bij naam", + "CheckoutPreviousBranch": "Vorige branch uitchecken", + "RemoteBranchCheckoutTitle": "{{.branchName}} uitchecken", + "RemoteBranchCheckoutPrompt": "Hoe wil je deze branch uitchecken?", + "CheckoutTypeNewBranch": "Nieuwe lokale branch", + "CheckoutTypeDetachedHead": "Detached head", "NewBranch": "Nieuwe branch", + "MoveCommitsToNewBranch": "Verplaats commits naar nieuwe branch", + "MoveCommitsToNewBranchTooltip": "Maak een nieuwe branch en verplaats niet-gepushte commits van de huidige branch hier naar toe. Gebruik dit in het geval dat je deze commits eigenlijk op een nieuwe branch had willen maken.\n\nLet op dat de selectie genegeerd wordt. De nieuwe branch komt ofwel bovenop de main branch, of bovenop de huidige branch (je kan kiezen).", + "CannotMoveCommitsFromDetachedHead": "Kan geen commits verplaatsen van een detached head", + "CannotMoveCommitsNoUpstream": "Kan geen commits verplaatsen vanaf een branch die geen upstream-branch heeft", + "CannotMoveCommitsBehindUpstream": "Kan geen commits verplaatsen van een branch die achterloopt zijn upstream-branch", "NoBranchesThisRepo": "Geen branches voor deze repo", "CommitWithoutMessageErr": "Je kan geen commit maken zonder commit bericht", "Close": "Sluiten", "CloseCancel": "Sluiten", "Confirm": "Bevestig", + "Quit": "Afsluiten", + "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)", "Reword": "Hernoem 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", "EditCommitTooltip": "Wijzig commit", "AmendCommitTooltip": "Wijzig commit met staged veranderingen", + "AddCoAuthor": "Voeg co-auteur toe", "RewordCommitEditor": "Hernoem commit met editor", "NoCommitsThisBranch": "Geen commits in deze branch", + "ExecCommandHere": "Voer het volgende commando hier uit:", "Error": "Foutmelding", "Undo": "Ongedaan maken", "UndoReflog": "Ongedaan maken (via reflog) (experimenteel)", "RedoReflog": "Redo (via reflog) (experimenteel)", + "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.", + "Pop": "Pop", "Drop": "Laten vallen", "Apply": "Toepassen", "NoStashEntries": "Geen stash items", "StashDrop": "Stash laten vallen", + "SureDropStashEntry": "Weet je het zeker dat je de geselecteerde stash(es) wilt verwijderen?", + "StashPop": "Stash poppen", "SurePopStashEntry": "Weet je zeker dat je deze stash entry wil poppen?", "StashApply": "Stash toepassen", "SureApplyStashEntry": "Weet je zeker dat je deze stash entry wil toepassen?", "NoTrackedStagedFilesStash": "Je hebt geen tracked/staged bestanden om te laten stashen", + "NoFilesToStash": "Je hebt geen bestanden om te stashen", "StashChanges": "Stash veranderingen", - "OpenConfig": "Open config bestand", + "RenameStash": "Hernoem stash", + "RenameStashPrompt": "Hernoem stash: {{.stashName}}", "EditConfig": "Verander config bestand", "ForcePush": "Forceer push", "ForcePushPrompt": "Je branch is afgeweken van de remote branch. Druk {{.cancelKey}} om te annuleren, of {{.confirmKey}} om geforceerd te pushen.", "CheckForUpdate": "Check voor updates", "CheckingForUpdates": "Zoeken naar updates...", + "UpdateAvailableTitle": "Update beschikbaar!", + "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.", "OpenFile": "Open bestand", + "OpenFileTooltip": "Open bestand in standaardapplicatie.", + "OpenInEditor": "Openen in editor", "IgnoreFile": "Voeg toe aan .gitignore", + "ExcludeFile": "Toevoegen aan .git/info/exclude", "RefreshFiles": "Refresh bestanden", "Merge": "Merge in met huidige checked out branch", "ConfirmQuit": "Weet je zeker dat je dit programma wil sluiten?", @@ -92,6 +183,8 @@ "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.", + "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)", @@ -100,26 +193,56 @@ "ReturnToFilesPanel": "Ga terug naar het bestanden paneel", "FastForward": "Fast-forward deze branch vanaf zijn upstream", "FoundConflictsTitle": "Conflicten!", + "ViewConflictsMenuItem": "Toon conflicten", + "AbortMenuItem": "Breek %s af", "PickHunk": "Kies stuk", - "PickAllHunks": "Kies beide stukken", "ViewMergeRebaseOptions": "Bekijk merge/rebase opties", + "ViewMergeRebaseOptionsTooltip": "Toon abort/continue/skip opties voor huidige merge/rebase.", + "ViewMergeOptions": "Toon merge opties", + "ViewRebaseOptions": "Toon rebase opties", + "ViewCherryPickOptions": "Toon cherry-pick opties", + "ViewRevertOptions": "Toon revert opties", "NotMergingOrRebasing": "Je bent momenteel niet aan het rebasen of mergen", + "AlreadyRebasing": "Deze actie kan niet worden uitgevoerd tijdens een rebase", + "NotMidRebase": "Deze actie werkt alleen tijdens een interactieve rebase", + "MustSelectFixupCommit": "Deze actie werkt alleen op fixup commits", "RecentRepos": "Recente repositories", "MergeOptionsTitle": "Merge opties", "RebaseOptionsTitle": "Rebase opties", + "CherryPickOptionsTitle": "Cherry-pick opties", + "RevertOptionsTitle": "Revert opties", "CommitSummaryTitle": "Commit bericht", + "CommitDescriptionTitle": "Commit beschrijving", "LocalBranchesTitle": "Branches", "SearchTitle": "Zoek", + "TagsTitle": "Tags", + "MenuTitle": "Menu", + "CommitMenuTitle": "Commit Menu", + "RemotesTitle": "Remotes", + "RemoteBranchesTitle": "Remote branches", "PatchBuildingTitle": "Patch bouwen", "InformationTitle": "Informatie", + "ReflogCommitsTitle": "Reflog", + "ConflictsResolved": "Alle mergeconflicten zijn opgelost. Doorgaan met de %s?", + "Continue": "Doorgaan", + "UnstagedFilesAfterConflictsResolved": "Er zijn bestanden gewijzigd nadat de mergeconflicten opgelost waren. Deze bestanden stagen en doorgaan?", + "RebasingTitle": "Rebase '{{.checkedOutBranch}}'", + "SimpleRebase": "Simpele rebase op '{{.ref}}'", + "InteractiveRebase": "Interactieve rebase op '{{.ref}}'", + "RebaseOntoBaseBranch": "Rebase op basis branch ({{.baseBranch}})", "FwdNoUpstream": "Kan niet de branch vooruitspoelen zonder upstream", "FwdCommitsToPush": "Je kan niet vooruitspoelen als de branch geen nieuwe commits heeft", "ErrorOccurred": "Er is iets fout gegaan! Zou je hier een issue aan willen maken", + "ConflictLabel": "CONFLICT", + "CommitsSectionHeader": "Commits", + "YouDied": "JE BENT DOOD!", "RewordNotSupported": "Herformatteren van commits in interactief rebasen is nog niet ondersteund", "CherryPickCopy": "Kopieer commit (cherry-pick)", "PasteCommits": "Plak commits (cherry-pick)", + "SureCherryPick": "Weet je zeker dat je de {{.numCommits}} gekopieerde commit(s) naar deze branch wilt cherry-picken?", "CherryPick": "Cherry-Pick", "Donate": "Doneer", + "AskQuestion": "Stel vraag", "PrevHunk": "Selecteer de vorige hunk", "NextHunk": "Selecteer de volgende hunk", "PrevConflict": "Selecteer voorgaand conflict", @@ -130,8 +253,10 @@ "ScrollUp": "Scroll omhoog", "ScrollUpMainWindow": "Scroll naar beneden vanaf hoofdpaneel", "ScrollDownMainWindow": "Scroll naar beneden vanaf hoofdpaneel", + "SuspendApp": "Pauzeer de applicatie", "AmendCommitTitle": "Commit wijzigen", "AmendCommitPrompt": "Weet je zeker dat je deze commit wil wijzigen met de vorige staged bestanden?", + "AmendCommitWithConflictsContinue": "Nee, doorgaan met rebase", "DropCommitTitle": "Verwijder commit", "DropCommitPrompt": "Weet je zeker dat je deze commit wil verwijderen?", "PullingStatus": "Pullen", @@ -145,12 +270,15 @@ "CherryPickingStatus": "Cherry-picken", "UndoingStatus": "Ongedaan maken", "CheckingOutStatus": "Uitchecken", + "RevertingStatus": "Bezig met reverten", "CommitFiles": "Commit bestanden", "ViewItemFiles": "Bekijk gecommite bestanden", "CommitFilesTitle": "Commit bestanden", "CheckoutCommitFileTooltip": "Bestand uitchecken", + "Remove": "Verwijderen", "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): ", "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", @@ -160,6 +288,8 @@ "DiscardAnyUnstagedChanges": "Gooi unstaged wijzigingen weg", "DiscardUntrackedFiles": "Negeer niet-gevonden bestanden", "HardReset": "Harde reset", + "Delete": "Verwijderen", + "Reset": "Resetten", "ViewResetOptions": "Bekijk reset opties", "CreateFixupCommit": "Creëer fixup commit", "CreateFixupCommitTooltip": "Creëer fixup commit", @@ -172,6 +302,8 @@ "StashAllChangesKeepIndex": "Stash staged wijzigingen", "StashOptions": "Stash opties", "NotARepository": "Fout: moet in een git repository uitgevoerd worden", + "ScrollLeft": "Scroll naar links", + "ScrollRight": "Scroll naar rechts", "DiscardPatch": "Patch weg gooien", "DiscardPatchConfirm": "Je kan alleen maar een patch bouwen van 1 commit. Huidige patch weggooien?", "CantPatchWhileRebasingError": "Je kan geen patch bouwen of patch commando uitvoeren wanneer je in een merging of rebasing state zit", @@ -185,12 +317,16 @@ "NewRemote": "Voeg een nieuwe remote toe", "NewRemoteName": "Nieuwe remote name:", "NewRemoteUrl": "Nieuwe remote url:", + "ViewBranches": "Bekijk branches", "EditRemoteName": "Enter updated remote naam voor {{.remoteName}}:", "EditRemoteUrl": "Enter updated remote url voor {{.remoteName}}:", "RemoveRemote": "Verwijder remote", "DeleteRemoteBranch": "Verwijder remote branch", + "SetAsUpstream": "Instellen als upstream", "SetAsUpstreamTooltip": "Stel in als upstream van uitgecheckte branch", "SetUpstream": "Stel in als upstream van uitgecheckte branch", + "DivergenceSectionHeaderLocal": "Lokaal", + "DivergenceSectionHeaderRemote": "Remote", "SetUpstreamTitle": "Stel in als upstream branch", "EditRemoteTooltip": "Wijzig remote", "TagNameTitle": "Tag naam:", @@ -207,6 +343,7 @@ "PrevScreenMode": "Vorige scherm modus", "StartSearch": "Start met zoeken", "Keybindings": "Sneltoetsen", + "KeybindingsMenuSectionGlobal": "Algemeen", "RenameBranch": "Hernoem branch", "NewGitFlowBranchPrompt": "Nieuwe '{{.branchType}}' naam:", "RenameBranchWarning": "Deze branch volgt een remote. Deze actie zal alleen de locale branch name wijzigen niet de naam van de remote branch. Verder gaan?", @@ -262,8 +399,54 @@ "CreatePullRequest": "Maak een pull-request", "ConfirmRevertCommit": "Weet u zeker dat u {{.selectedCommit}} ongedaan wilt maken?", "ToggleRangeSelect": "Toggle drag selecteer", - "Actions": {}, + "Actions": { + "CopyCommitAuthorToClipboard": "Kopieer commit auteur naar klembord", + "CopyCommitAttributeToClipboard": "Kopieer naar klembord", + "CopyCommitTagsToClipboard": "Kopieer commit tags naar klembord", + "CopyPatchToClipboard": "Kopieer patch naar klembord", + "Commit": "Commit", + "Push": "Push", + "Pull": "Pull", + "OpenFile": "Open bestand", + "CopyToClipboard": "Kopieer naar klembord", + "CopySelectedTextToClipboard": "Kopieer geselecteerde tekst naar klembord", + "AddRemote": "Voeg remote toe", + "RemoveSubmodule": "Verwijder submodule", + "ResetSubmodule": "Reset submodule", + "AddSubmodule": "Voeg submodule toe", + "UpdateSubmoduleUrl": "Update submodule URL", + "InitialiseSubmodule": "Initialiseer submodule", + "UpdateSubmodule": "Update submodule", + "NukeWorkingTree": "Blaas de working tree op met een kernbom", + "RemoveUntrackedFiles": "Verwijder niet-getrackte bestanden", + "SoftReset": "Soft reset", + "MixedReset": "Mixed reset", + "HardReset": "Hard reset", + "Undo": "Ongedaan maken", + "Redo": "Herhalen", + "OpenCommitInBrowser": "Open commit in browser", + "OpenPullRequest": "Open pull request in browser", + "AddWorktree": "Voeg worktree toe" + }, "Bisect": {}, - "Log": {}, - "BreakingChangesByVersion": {} + "Log": { + "RemoveFile": "Verwijder pad '{{.path}}'", + "RemoveEmptyDir": "Verwijder de lege map '{{.path}}'", + "CopyToClipboard": "Kopieer '{{.str}}' naar klembord", + "Remove": "Verwijder '{{.filename}}'", + "CreateFileWithContent": "Maak bestand '{{.path}}'", + "AppendingLineToFile": "Voeg {{.line}}' toe aan bestand '{{.filename}} '", + "EditRebaseFromBaseCommit": "Begin een interactieve rebase van '{{.baseCommit}}' op '{{.targetBranchName}}'", + "DroppingStash": "Verwijder stash %s", + "PoppingStash": "Pop stash %s", + "DeletingBranch": "Verwijder branch '{{.branchName}}' (was {{.hash}})" + }, + "BreakingChangesByVersion": {}, + "ViewMergeConflictOptions": "Bekijk merge conflict opties", + "ViewMergeConflictOptionsTooltip": "Bekijk opties voor het oplossen van mergeconflicten.", + "NoFilesWithMergeConflicts": "Er zijn geen files met mergeconflicten.", + "MergeConflictOptionsTitle": "Los mergeconflicten op", + "UseCurrentChanges": "Gebruik huidige wijzigingen", + "UseIncomingChanges": "Gebruik binnenkomende wijzigingen", + "UseBothChanges": "Gebruik beide" } diff --git a/pkg/i18n/translations/pl.json b/pkg/i18n/translations/pl.json index 560592524..60ecf6109 100644 --- a/pkg/i18n/translations/pl.json +++ b/pkg/i18n/translations/pl.json @@ -187,7 +187,6 @@ "StashChanges": "Schowaj zmiany", "RenameStash": "Zmień nazwę schowka", "RenameStashPrompt": "Zmień nazwę schowka: {{.stashName}}", - "OpenConfig": "Otwórz plik konfiguracyjny", "EditConfig": "Edytuj plik konfiguracyjny", "ForcePush": "Wymuś wysłanie", "ForcePushPrompt": "Twoja gałąź rozbiegła się z gałęzią zdalną. Naciśnij {{.cancelKey}}, aby anulować, lub {{.confirmKey}}, aby wymusić wysłanie.", @@ -244,7 +243,6 @@ "ViewConflictsMenuItem": "Pokaż konflikty", "AbortMenuItem": "Przerwij %s", "PickHunk": "Wybierz fragment", - "PickAllHunks": "Wybierz wszystkie fragmenty", "ViewMergeRebaseOptions": "Pokaż opcje scalania/rebase", "ViewMergeRebaseOptionsTooltip": "Pokaż opcje do przerwania/kontynuowania/pominięcia bieżącego scalania/rebase.", "ViewMergeOptions": "Pokaż opcje scalania", @@ -681,7 +679,6 @@ "DetachingWorktree": "Odłączanie drzewa pracy", "WorktreesTitle": "Drzewa pracy", "WorktreeTitle": "Drzewo pracy", - "RemoveWorktreePrompt": "Czy na pewno chcesz usunąć drzewo pracy '{{.worktreeName}}'?", "RemovingWorktree": "Usuwanie drzewa pracy", "AddingWorktree": "Dodawanie drzewa pracy", "CantDeleteCurrentWorktree": "Nie możesz usunąć bieżącego drzewa pracy!", @@ -691,13 +688,8 @@ "MissingWorktree": "(brakujące)", "NewWorktree": "Nowe drzewo pracy", "NewWorktreePath": "Nowa ścieżka drzewa pracy", - "NewWorktreeBase": "Nowa bazowa ref drzewa pracy", "RemoveWorktreeTooltip": "Usuń wybrane drzewo pracy. To usunie zarówno katalog drzewa pracy, jak i metadane o drzewie pracy w katalogu .git.", "NewBranchName": "Nowa nazwa brancha", - "NewBranchNameLeaveBlank": "Nowa nazwa brancha (pozostaw puste, aby przełączyć {{.default}})", - "ViewWorktreeOptions": "Zobacz opcje drzewa pracy", - "CreateWorktreeFrom": "Utwórz drzewo pracy z {{.ref}}", - "CreateWorktreeFromDetached": "Utwórz drzewo pracy z {{.ref}} (odłączone)", "LcWorktree": "drzewo pracy", "ChangingDirectoryTo": "Zmiana katalogu na {{.path}}", "Name": "Nazwa", diff --git a/pkg/i18n/translations/pt.json b/pkg/i18n/translations/pt.json index 6d9f83647..640ce2d65 100644 --- a/pkg/i18n/translations/pt.json +++ b/pkg/i18n/translations/pt.json @@ -206,7 +206,6 @@ "StashChanges": "Alterações preparadas", "RenameStash": "Renomear o stash", "RenameStashPrompt": "Renomear o estoque: {{.stashName}}", - "OpenConfig": "Abrir o ficheiro de config", "EditConfig": "Editar arquivo de configuração", "ForcePush": "Forçar push", "ForcePushPrompt": "Seu branch divergiu do branch remoto. Pressione {{.cancelKey}} para cancelar, ou {{.confirmKey}} para forçar a push.", @@ -279,7 +278,6 @@ "ViewConflictsMenuItem": "Visualizar conflitos", "AbortMenuItem": "Abortar %s", "PickHunk": "Escolha o local", - "PickAllHunks": "Pegar todos os pedaços", "ViewMergeRebaseOptions": "Ver opções de mesclar/rebase", "ViewMergeRebaseOptionsTooltip": "Ver opções para abortar/continuar/pular o merge/rebase atual.", "ViewMergeOptions": "Visualizar opções de merge", @@ -639,9 +637,6 @@ "MainWorktree": "(árvore de trabalho principal)", "NewWorktree": "Nova árvore de trabalho", "NewWorktreePath": "Caminho da nova árvore de trabalho", - "ViewWorktreeOptions": "Ver opções da árvore de trabalho", - "CreateWorktreeFrom": "Criar árvore de trabalho a partir de {{.ref}}", - "CreateWorktreeFromDetached": "Criar árvore de trabalho a partir de {{.ref}} (desanexado)", "LcWorktree": "árvore de trabalho", "Name": "Nome", "Path": "Caminho", diff --git a/pkg/i18n/translations/ru.json b/pkg/i18n/translations/ru.json index 89f09521e..34d7105fe 100644 --- a/pkg/i18n/translations/ru.json +++ b/pkg/i18n/translations/ru.json @@ -106,7 +106,6 @@ "StashChanges": "Припрятать изменения", "RenameStash": "Переименовать хранилище", "RenameStashPrompt": "Переименовать хранилище: {{.stashName}}", - "OpenConfig": "Открыть файл конфигурации", "EditConfig": "Редактировать файл конфигурации", "ForcePush": "Принудительная отправка изменении", "ForcePushPrompt": "Ветка отклонилась от удалённой ветки. Нажмите «esc», чтобы отменить, или «enter», чтобы начать принудительную отправку изменении.", @@ -152,7 +151,6 @@ "ViewConflictsMenuItem": "Просмотр конфликтов", "AbortMenuItem": "Прервать %s", "PickHunk": "Выбрать эту часть", - "PickAllHunks": "Выбрать все части", "ViewMergeRebaseOptions": "Просмотреть параметры слияния/перебазирования", "NotMergingOrRebasing": "В данный момент вы не выполняете ни перебазирования, ни слияние", "AlreadyRebasing": "Невозможно выполнить это действие во время перебазирования", diff --git a/pkg/i18n/translations/zh-CN.json b/pkg/i18n/translations/zh-CN.json index 0bbe1f117..6799fd0ae 100644 --- a/pkg/i18n/translations/zh-CN.json +++ b/pkg/i18n/translations/zh-CN.json @@ -219,7 +219,6 @@ "StashChanges": "贮藏变更", "RenameStash": "重命名贮藏", "RenameStashPrompt": "重命名贮藏: {{.stashName}}", - "OpenConfig": "打开配置文件", "EditConfig": "编辑配置文件", "ForcePush": "强制推送", "ForcePushPrompt": "您的分支已与远程分支不同。按‘esc’取消,或‘enter’强制推送.", @@ -305,7 +304,6 @@ "ViewConflictsMenuItem": "查看冲突", "AbortMenuItem": "中止 %s", "PickHunk": "选中区块", - "PickAllHunks": "选中所有区块", "ViewMergeRebaseOptions": "查看合并/变基选项", "ViewMergeRebaseOptionsTooltip": "查看当前合并或变基的中止、继续、跳过选项", "ViewMergeOptions": "查看合并选项", @@ -855,7 +853,6 @@ "DetachingWorktree": "正在分离工作树", "WorktreesTitle": "工作区", "WorktreeTitle": "工作区", - "RemoveWorktreePrompt": "您确定要删除工作树 {{.worktreeName}}' ?", "ForceRemoveWorktreePrompt": "'{{.worktreeName}}' 包含已修改或未跟踪的文件,或子模块(或包含所有这些)。确定要移除它吗?", "RemovingWorktree": "正在删除工作树", "AddingWorktree": "添加工作区", @@ -867,13 +864,8 @@ "MainWorktree": "(主工作树)", "NewWorktree": "新建工作树", "NewWorktreePath": "新建工作树路径", - "NewWorktreeBase": "新建工作树基于ref", "RemoveWorktreeTooltip": "删除选定的工作树。这将删除工作树的目录以及 .git 目录中有关工作树的元数据。", "NewBranchName": "新分支名称", - "NewBranchNameLeaveBlank": "新分支名称(为空则默认检出为 {{.default}})", - "ViewWorktreeOptions": "查看工作区选项", - "CreateWorktreeFrom": "从 {{.ref}} 创建工作树", - "CreateWorktreeFromDetached": "从 {{.ref}} 创建工作树(分离)", "LcWorktree": "工作区", "ChangingDirectoryTo": "将目录更改为 {{.path}}", "Name": "名称", @@ -1068,7 +1060,8 @@ "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.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": "查看合并冲突选项", "ViewMergeConflictOptionsTooltip": "查看用于解决合并冲突的选项。", diff --git a/pkg/i18n/translations/zh-TW.json b/pkg/i18n/translations/zh-TW.json index 7e47caec6..5c7b50e47 100644 --- a/pkg/i18n/translations/zh-TW.json +++ b/pkg/i18n/translations/zh-TW.json @@ -128,7 +128,6 @@ "StashChanges": "安置現有變更到收藏中", "RenameStash": "重新命名收藏", "RenameStashPrompt": "重新命名收藏:{{.stashName}}", - "OpenConfig": "開啟設定檔案", "EditConfig": "編輯設定檔案", "ForcePush": "強制推送", "ForcePushPrompt": "你的分支與遠端分支分岔。按 'ESC' 取消,或按 'Enter' 強制推送。", @@ -179,7 +178,6 @@ "ViewConflictsMenuItem": "檢視衝突", "AbortMenuItem": "中止%s", "PickHunk": "挑選程式碼片段", - "PickAllHunks": "挑選所有程式碼片段", "ViewMergeRebaseOptions": "查看合併/變基選項", "ViewRebaseOptions": "查看合併/變基選項", "NotMergingOrRebasing": "你當前既不在變基也不在合併中", @@ -528,7 +526,6 @@ "DetachingWorktree": "正在解除工作目錄連結", "WorktreesTitle": "工作目錄", "WorktreeTitle": "工作目錄", - "RemoveWorktreePrompt": "是否刪除 {{.worktreeName}} 工作目錄?", "RemovingWorktree": "正在刪除工作目錄", "AddingWorktree": "正在建立工作目錄", "CantDeleteCurrentWorktree": "無法刪除當前工作目錄!", @@ -537,12 +534,7 @@ "NoWorktreesThisRepo": "無工作目錄", "MissingWorktree": "(失蹤)", "NewWorktreePath": "工作目錄路徑", - "NewWorktreeBase": "工作目錄來源", "NewBranchName": "分支名稱", - "NewBranchNameLeaveBlank": "分支名稱(留空將檢出 {{.default}})", - "ViewWorktreeOptions": "檢視工作目錄選項", - "CreateWorktreeFrom": "從 {{.ref}} 建立工作目錄", - "CreateWorktreeFromDetached": "從 {{.ref}} 建立工作目錄(未連結)", "LcWorktree": "工作目錄", "ChangingDirectoryTo": "切換至 {{.path}}", "Name": "名稱", From 440f357319574a00aaa35c353485e8e9da5b9ff1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 4 Jul 2026 14:40:33 +0200 Subject: [PATCH 192/384] Update docs and schema for release --- docs/Config.md | 58 ++++++++++- docs/Custom_Pagers.md | 31 +----- docs/keybindings/Keybindings_en.md | 21 ++-- docs/keybindings/Keybindings_ja.md | 21 ++-- docs/keybindings/Keybindings_ko.md | 21 ++-- docs/keybindings/Keybindings_nl.md | 141 +++++++++++++------------- docs/keybindings/Keybindings_pl.md | 21 ++-- docs/keybindings/Keybindings_pt.md | 21 ++-- docs/keybindings/Keybindings_ru.md | 21 ++-- docs/keybindings/Keybindings_zh-CN.md | 19 ++-- docs/keybindings/Keybindings_zh-TW.md | 21 ++-- schema/config.json | 131 +++++++++++++++++++++--- 12 files changed, 332 insertions(+), 195 deletions(-) diff --git a/docs/Config.md b/docs/Config.md index 9f7921821..1d101be18 100644 --- a/docs/Config.md +++ b/docs/Config.md @@ -110,6 +110,26 @@ gui: # is true. expandedSidePanelWeight: 2 + # If true, don't give a side panel more height than it needs to show its + # content; when all panels fit, the leftover height is shared among them so that + # they still fill the screen. + shrinkSidePanelsToContent: false + + # The side panels, in the order they appear from top to bottom. + # Each entry is a list of one or more names that share a single panel as tabs + # (cycle through them with the next-tab/previous-tab keys). + # Omit a name to hide it; give a name its own one-element list to promote a tab + # to a top-level panel. + # Valid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', + # 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and + # 'commits' must always be included; they can't be hidden. + sidePanels: + - [status] + - [files, worktrees, submodules] + - [branches, remotes, tags] + - [commits, reflog] + - [stash] + # 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. @@ -342,6 +362,11 @@ gui: git: # Array of pagers. 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). + # 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' # colorArg: "always" @@ -361,6 +386,9 @@ git: # # 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: [] @@ -406,6 +434,11 @@ git: # 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 not "none", lazygit will automatically fast-forward local branches to match # their upstream after fetching. Applies to branches that are not the currently # checked out branch, and only to those that are strictly behind their upstream @@ -499,6 +532,15 @@ git: # to 40 to disable truncation. truncateCopiedCommitHashesTo: 12 +# Config relating to git worktrees +worktree: + # Default parent directory for new worktrees. It is offered as a candidate + # location alongside the parent directories of any worktrees you already have. + # A relative path is resolved against the repository's root directory, so + # "../worktrees" sits beside the repo and ".worktrees" sits inside it. + # A leading "~" is expanded to your home directory, so "~/worktrees" works. + defaultPath: "" + # Periodic update checks update: # One of: 'prompt' (default) | 'background' | 'never' @@ -517,6 +559,11 @@ refresher: # 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 @@ -648,6 +695,7 @@ keybinding: confirmInEditor: [, ] remove: d new: "n" + newWorktree: w edit: e openFile: o scrollUpMain: [, K, ] @@ -667,6 +715,7 @@ keybinding: nextScreenMode: + prevScreenMode: _ cyclePagers: '|' + cyclePagersReverse: \ undo: z redo: Z filteringMenu: @@ -681,6 +730,7 @@ keybinding: increaseRenameSimilarityThreshold: ) decreaseRenameSimilarityThreshold: ( openDiffTool: + editConfig: status: checkForUpdate: u recentRepos: @@ -726,8 +776,6 @@ keybinding: fetchRemote: f addForkRemote: F sortOrder: s - worktrees: - viewWorktreeOptions: w commits: squashDown: s renameCommit: r @@ -1059,6 +1107,12 @@ keybinding: edit: # disable 'edit file' ``` +### Overriding the platform for default keybindings + +A few keybindings have different defaults on macOS than on Linux and Windows (e.g. word-wise cursor movement in text inputs uses `alt` on macOS but `ctrl` elsewhere). Lazygit picks these based on the OS it's running on, but you can override that with the `LAZYGIT_KEYBINDING_PLATFORM` environment variable. Set it to `darwin`, `linux`, or `windows`; any other value is ignored and the actual OS is used. + +This is useful when running lazygit in a Linux container that you access over ssh from a Mac, where you'd rather use the macOS keybindings. + ### Example Keybindings For Colemak Users ```yaml diff --git a/docs/Custom_Pagers.md b/docs/Custom_Pagers.md index 903928d46..1b37766d0 100644 --- a/docs/Custom_Pagers.md +++ b/docs/Custom_Pagers.md @@ -2,8 +2,6 @@ Lazygit supports custom pagers, [configured](/docs/Config.md) in the config.yml file (which can be opened by pressing `e` in the Status panel). -Support does not extend to Windows users, because we're making use of a package which doesn't have Windows support. However, see [below](#emulating-custom-pagers-on-windows) for a workaround. - 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): @@ -71,7 +69,7 @@ git: - externalDiffCommand: difft --color=always ``` -The `colorArg` and `pager` options are not used in this case. +The `colorArg` option is not used in this case. You can add whatever extra arguments you prefer for your difftool; for instance @@ -91,29 +89,4 @@ git: 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. -## Emulating custom pagers on Windows - -There is a trick to emulate custom pagers on Windows using a Powershell script configured as an external diff command. It's not perfect, but certainly better than nothing. To do this, save the following script as `lazygit-pager.ps1` at a convenient place on your disk: - -```pwsh -#!/usr/bin/env pwsh - -$old = $args[1].Replace('\', '/') -$new = $args[4].Replace('\', '/') -$path = $args[0] -git diff --no-index --no-ext-diff $old $new - | %{ $_.Replace($old, $path).Replace($new, $path) } - | delta --width=$env:LAZYGIT_COLUMNS -``` - -Use the pager of your choice with the arguments you like in the last line of the script. Personally I wouldn't want to use lazygit anymore without delta's `--hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"` args, see [above](#delta). - -In your lazygit config, use - -```yml -git: - pagers: - - externalDiffCommand: "C:/wherever/lazygit-pager.ps1" -``` - -The main limitation of this approach compared to a "real" pager is that renames are not displayed correctly; they are shown as if they were modifications of the old file. (This affects only the hunk headers; the diff itself is always correct.) +`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/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md index d63058d82..4aa202740 100644 --- a/docs/keybindings/Keybindings_en.md +++ b/docs/keybindings/Keybindings_en.md @@ -22,7 +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 | Choose the next pager in the list of configured pagers. | +| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | | `` `` | Cancel | | | `` ? `` | Open keybindings menu | | | `` `` | View filter options | View options for filtering the commit log, so that only commits matching the filter are shown. | @@ -30,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | Quit | | | `` `` | Suspend the application | | | `` `` | 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'. | +| `` `` | Edit config file | Open file in external editor. | | `` z `` | Undo | 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 | 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. | @@ -110,13 +112,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Open commit in browser | | | `` n `` | Create new branch off of commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Copy (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View files | | -| `` w `` | View worktree options | | | `` / `` | Search the current view by text | | ## Confirmation panel @@ -176,6 +178,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Checkout | Checkout selected item. | | `` n `` | New branch | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` o `` | Create pull request | | | `` O `` | View create pull request options | | | `` G `` | Open pull request in browser | | @@ -195,7 +198,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Main panel (merging) @@ -203,7 +205,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Pick hunk | | -| `` b `` | Pick all hunks | | +| `` b `` | Pick both hunks | | | `` , k `` | Previous hunk | | | `` , j `` | Next hunk | | | `` , h `` | Previous conflict | | @@ -280,6 +282,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Open commit in browser | | | `` n `` | Create new branch off of commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Copy (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset copied (cherry-picked) commits selection | | @@ -287,7 +290,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Remote branches @@ -297,6 +299,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copy branch name to clipboard | | | `` `` | Checkout | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | New branch | | +| `` w `` | New worktree | | | `` M `` | Merge | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | Rebase | Rebase the checked-out branch onto the selected branch. | | `` d `` | Delete | Delete the remote branch from the remote. | @@ -306,7 +309,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Remotes @@ -337,17 +339,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Pop | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | Drop | Remove the stash entry from the stash list. | | `` n `` | New branch | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | +| `` w `` | New worktree | | | `` r `` | Rename stash | | | `` 0 `` | Focus main view | | | `` `` | View files | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Status | Key | Action | Info | |-----|--------|-------------| -| `` o `` | Open config file | Open file in default application. | | `` e `` | Edit config file | Open file in external editor. | | `` u `` | Check for update | | | `` `` | Switch to a recent repo | | @@ -365,6 +366,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Open commit in browser | | | `` n `` | Create new branch off of commit | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Copy (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset copied (cherry-picked) commits selection | | @@ -372,7 +374,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View files | | -| `` w `` | View worktree options | | | `` / `` | Search the current view by text | | ## Submodules @@ -396,13 +397,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copy tag to clipboard | | | `` `` | Checkout | Checkout the selected tag as a detached HEAD. | | `` n `` | New tag | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | +| `` w `` | New worktree | | | `` d `` | Delete | 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. | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | View commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Worktrees diff --git a/docs/keybindings/Keybindings_ja.md b/docs/keybindings/Keybindings_ja.md index d9b87d747..5b9c798a1 100644 --- a/docs/keybindings/Keybindings_ja.md +++ b/docs/keybindings/Keybindings_ja.md @@ -22,7 +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 | Choose the next pager in the list of configured pagers. | +| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | | `` `` | キャンセル | | | `` ? `` | キーバインディングメニューを開く | | | `` `` | フィルターオプションを表示 | コミットログのフィルタリングオプションを表示し、フィルタに一致するコミットのみを表示します。 | @@ -30,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | 終了 | | | `` `` | Suspend the application | | | `` `` | 空白表示の切り替え | 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'. | +| `` `` | 設定ファイルを編集 | 外部エディタでファイルを開きます。 | | `` z `` | 元に戻す | 最後のgitコマンドを元に戻すために実行するgitコマンドを決定するためにreflogが使用されます。これにはワーキングツリーへの変更は含まれません。コミットのみが考慮されます。 | | `` Z `` | やり直す | 最後のgitコマンドをやり直すために実行するgitコマンドを決定するためにreflogが使用されます。これにはワーキングツリーへの変更は含まれません。コミットのみが考慮されます。 | @@ -90,13 +92,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | ブラウザでコミットを開く | | | `` n `` | コミットから新しいブランチを作成 | | | `` N `` | コミットを新しいブランチに移動 | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | 新しいワークツリー | | | `` g `` | リセット | 選択した項目へのリセットオプション(ソフト/ミックス/ハード)を表示します。各リセットタイプの詳細は次の通りです:
- ソフトリセット:変更を保持し、ステージされた状態にします
- ミックスリセット:変更を保持し、ステージされていない状態にします
- ハードリセット:すべての変更を破棄します | | `` C `` | コピー(チェリーピック) | コミットをコピーとしてマークします。ローカルコミットビューで `V` を押すと、コピーしたコミットをチェックアウトしたブランチにペースト(チェリーピック)できます。いつでも `` を押して選択をキャンセルできます。 | | `` `` | 外部差分ツールを開く(git difftool) | | | `` * `` | 現在のブランチのコミットを選択 | | | `` 0 `` | メインビューにフォーカス | | | `` `` | ファイルを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストで検索 | | ## コミットファイル @@ -136,6 +138,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | ブラウザでコミットを開く | | | `` n `` | コミットから新しいブランチを作成 | | | `` N `` | コミットを新しいブランチに移動 | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | 新しいワークツリー | | | `` g `` | リセット | 選択した項目へのリセットオプション(ソフト/ミックス/ハード)を表示します。各リセットタイプの詳細は次の通りです:
- ソフトリセット:変更を保持し、ステージされた状態にします
- ミックスリセット:変更を保持し、ステージされていない状態にします
- ハードリセット:すべての変更を破棄します | | `` C `` | コピー(チェリーピック) | コミットをコピーとしてマークします。ローカルコミットビューで `V` を押すと、コピーしたコミットをチェックアウトしたブランチにペースト(チェリーピック)できます。いつでも `` を押して選択をキャンセルできます。 | | `` `` | コピーされた(チェリーピックされた)コミットの選択をリセット | | @@ -143,7 +146,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | 現在のブランチのコミットを選択 | | | `` 0 `` | メインビューにフォーカス | | | `` `` | ファイルを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストで検索 | | ## サブモジュール @@ -168,17 +170,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | ポップ | スタッシュエントリをワーキングディレクトリに適用し、スタッシュエントリを削除します。 | | `` d `` | 削除 | スタッシュリストからスタッシュエントリを削除します。 | | `` n `` | 新しいブランチ | 選択したスタッシュエントリから新しいブランチを作成します。これは、スタッシュエントリが作成されたコミットをgitがチェックアウトし、そのコミットから新しいブランチを作成した後、スタッシュエントリを追加のコミットとして新しいブランチに適用することで機能します。 | +| `` w `` | 新しいワークツリー | | | `` r `` | スタッシュの名前を変更 | | | `` 0 `` | メインビューにフォーカス | | | `` `` | ファイルを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストでフィルタリング | | ## ステータス | Key | Action | Info | |-----|--------|-------------| -| `` o `` | 設定ファイルを開く | デフォルトのアプリケーションでファイルを開きます。 | | `` e `` | 設定ファイルを編集 | 外部エディタでファイルを開きます。 | | `` u `` | 更新を確認 | | | `` `` | 最近のリポジトリをチェックアウト | | @@ -201,13 +202,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | タグをクリップボードにコピー | | | `` `` | チェックアウト(ブランチの切り替え) | 選択したタグをデタッチドHEADとしてチェックアウトします。 | | `` n `` | 新しいタグを作成 | 現在のコミットから新しいタグを作成します。タグ名とオプションの説明を入力するよう促されます。 | +| `` w `` | 新しいワークツリー | | | `` d `` | 削除 | ローカル/リモートタグの削除オプションを表示します。 | | `` P `` | タグをプッシュ | 選択したタグをリモートにプッシュします。リモートを選択するよう促されます。 | | `` g `` | リセット | 選択した項目へのリセットオプション(ソフト/ミックス/ハード)を表示します。各リセットタイプの詳細は次の通りです:
- ソフトリセット:変更を保持し、ステージされた状態にします
- ミックスリセット:変更を保持し、ステージされていない状態にします
- ハードリセット:すべての変更を破棄します | | `` `` | 外部差分ツールを開く(git difftool) | | | `` 0 `` | メインビューにフォーカス | | | `` `` | コミットを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストでフィルタリング | | ## ファイル @@ -286,7 +287,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | ハンクを選択 | | -| `` b `` | すべてのハンクを選択 | | +| `` b `` | Pick both hunks | | | `` , k `` | 前のハンク | | | `` , j `` | 次のハンク | | | `` , h `` | 前のコンフリクト | | @@ -325,6 +326,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | ブラウザでコミットを開く | | | `` n `` | コミットから新しいブランチを作成 | | | `` N `` | コミットを新しいブランチに移動 | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | 新しいワークツリー | | | `` g `` | リセット | 選択した項目へのリセットオプション(ソフト/ミックス/ハード)を表示します。各リセットタイプの詳細は次の通りです:
- ソフトリセット:変更を保持し、ステージされた状態にします
- ミックスリセット:変更を保持し、ステージされていない状態にします
- ハードリセット:すべての変更を破棄します | | `` C `` | コピー(チェリーピック) | コミットをコピーとしてマークします。ローカルコミットビューで `V` を押すと、コピーしたコミットをチェックアウトしたブランチにペースト(チェリーピック)できます。いつでも `` を押して選択をキャンセルできます。 | | `` `` | コピーされた(チェリーピックされた)コミットの選択をリセット | | @@ -332,7 +334,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | 現在のブランチのコミットを選択 | | | `` 0 `` | メインビューにフォーカス | | | `` `` | コミットを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストでフィルタリング | | ## リモート @@ -354,6 +355,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | ブランチ名をクリップボードにコピー | | | `` `` | チェックアウト(ブランチの切り替え) | 選択したリモートブランチに基づいて新しいローカルブランチをチェックアウトするか、リモートブランチをデタッチドヘッドとしてチェックアウトします。 | | `` n `` | 新しいブランチ | | +| `` w `` | 新しいワークツリー | | | `` M `` | マージ | 選択した項目を現在のブランチにマージするためのオプションを表示します(通常のマージ、スカッシュマージ) | | `` r `` | リベース | チェックアウトしたブランチを選択したブランチ上にリベースします。 | | `` d `` | 削除 | リモートからリモートブランチを削除します。 | @@ -363,7 +365,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 外部差分ツールを開く(git difftool) | | | `` 0 `` | メインビューにフォーカス | | | `` `` | コミットを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストでフィルタリング | | ## ローカルブランチ @@ -375,6 +376,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | チェックアウト(ブランチの切り替え) | 選択した項目をチェックアウトします。 | | `` n `` | 新しいブランチ | | | `` N `` | コミットを新しいブランチに移動 | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | 新しいワークツリー | | | `` o `` | プルリクエストを作成 | | | `` O `` | プルリクエスト作成オプションを表示 | | | `` G `` | Open pull request in browser | | @@ -394,7 +396,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 外部差分ツールを開く(git difftool) | | | `` 0 `` | メインビューにフォーカス | | | `` `` | コミットを表示 | | -| `` w `` | ワークツリーオプションを表示 | | | `` / `` | 現在のビューをテキストでフィルタリング | | ## ワークツリー diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md index 089543c5f..d1eb3afb1 100644 --- a/docs/keybindings/Keybindings_ko.md +++ b/docs/keybindings/Keybindings_ko.md @@ -22,7 +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 | Choose the next pager in the list of configured pagers. | +| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | | `` `` | 취소 | | | `` ? `` | 매뉴 열기 | | | `` `` | View filter-by-path options | View options for filtering the commit log, so that only commits matching the filter are shown. | @@ -30,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | 종료 | | | `` `` | Suspend the application | | | `` `` | 공백문자를 Diff 뷰에서 표시 여부 전환 | 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'. | +| `` `` | 설정 파일 수정 | Open file in external editor. | | `` z `` | 되돌리기 (reflog) (실험적) | 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 `` | 다시 실행 (reflog) (실험적) | 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. | @@ -67,6 +69,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 브라우저에서 커밋 열기 | | | `` n `` | 커밋에서 새 브랜치를 만듭니다. | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 커밋을 복사 (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (copied) commits selection | | @@ -74,7 +77,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Secondary @@ -93,10 +95,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Pop | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | Drop | Remove the stash entry from the stash list. | | `` n `` | 새 브랜치 생성 | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | +| `` w `` | New worktree | | | `` r `` | Rename stash | | | `` 0 `` | Focus main view | | | `` `` | View selected item's files | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Sub-commits @@ -109,6 +111,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 브라우저에서 커밋 열기 | | | `` n `` | 커밋에서 새 브랜치를 만듭니다. | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 커밋을 복사 (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (copied) commits selection | | @@ -116,7 +119,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View selected item's files | | -| `` w `` | View worktree options | | | `` / `` | 검색 시작 | | ## Worktrees @@ -142,7 +144,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Pick hunk | | -| `` b `` | Pick all hunks | | +| `` b `` | Pick both hunks | | | `` , k `` | 이전 hunk를 선택 | | | `` , j `` | 다음 hunk를 선택 | | | `` , h `` | 이전 충돌을 선택 | | @@ -210,6 +212,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 체크아웃 | Checkout selected item. | | `` n `` | 새 브랜치 생성 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` o `` | 풀 리퀘스트 생성 | | | `` O `` | 풀 리퀘스트 생성 옵션 | | | `` G `` | Open pull request in browser | | @@ -229,14 +232,12 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## 상태 | Key | Action | Info | |-----|--------|-------------| -| `` o `` | 설정 파일 열기 | Open file in default application. | | `` e `` | 설정 파일 수정 | Open file in external editor. | | `` u `` | 업데이트 확인 | | | `` `` | 최근에 사용한 저장소로 전환 | | @@ -277,6 +278,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 브랜치명을 클립보드에 복사 | | | `` `` | 체크아웃 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | 새 브랜치 생성 | | +| `` w `` | New worktree | | | `` M `` | 현재 브랜치에 병합 | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | 체크아웃된 브랜치를 이 브랜치에 리베이스 | Rebase the checked-out branch onto the selected branch. | | `` d `` | 삭제 | Delete the remote branch from the remote. | @@ -286,7 +288,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## 커밋 @@ -322,13 +323,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 브라우저에서 커밋 열기 | | | `` n `` | 커밋에서 새 브랜치를 만듭니다. | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | View reset options | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 커밋을 복사 (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | View selected item's files | | -| `` w `` | View worktree options | | | `` / `` | 검색 시작 | | ## 커밋 파일 @@ -365,13 +366,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copy tag to clipboard | | | `` `` | 체크아웃 | Checkout the selected tag as a detached HEAD. | | `` n `` | 태그를 생성 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | +| `` w `` | New worktree | | | `` d `` | 삭제 | View delete options for local/remote tag. | | `` P `` | 태그를 push | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | 초기화 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 커밋 보기 | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## 파일 diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md index 1715c597e..0eb61729b 100644 --- a/docs/keybindings/Keybindings_nl.md +++ b/docs/keybindings/Keybindings_nl.md @@ -10,26 +10,28 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` , 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. | -| `` P `` | Push | Push the current branch to its upstream branch. If no upstream is configured, you will be prompted to configure an upstream branch. | -| `` p `` | Pull | Pull changes from the remote for the current branch. If no upstream is configured, you will be prompted to configure an upstream branch. | +| `` 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. | | `` `` | Bekijk aangepaste patch opties | | -| `` m `` | Bekijk merge/rebase opties | View options to abort/continue/skip the current merge/rebase. | +| `` 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 | Choose the next pager in the list of configured pagers. | +| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | | `` `` | 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, `` | Quit | | -| `` `` | Suspend the application | | +| `` 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'. | +| `` `` | 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. | @@ -45,8 +47,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Range select down | | | `` `` | Range select up | | | `` / `` | Start met zoeken | | -| `` H `` | Scroll left | | -| `` L `` | Scroll right | | +| `` H `` | Scroll naar links | | +| `` L `` | Scroll naar rechts | | | `` ] `` | Volgende tabblad | | | `` [ `` | Vorige tabblad | | @@ -56,15 +58,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Kopieer de bestandsnaam naar het klembord | | | `` `` | Toggle staged | Toggle staged for selected file. | -| `` `` | Filter files by status | | -| `` y `` | Copy to clipboard | | -| `` c `` | Commit veranderingen | Commit staged changes. | +| `` `` | Filter bestanden op status | | +| `` y `` | Kopieer naar klembord | | +| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. | | `` w `` | Commit veranderingen zonder pre-commit hook | | | `` A `` | Wijzig laatste commit | | | `` C `` | Commit veranderingen met de git editor | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | -| `` e `` | Edit | Open file in external editor. | -| `` o `` | Open bestand | Open file in default application. | +| `` `` | Find base commit for fixup | Vind de commit waar je huidige wijzigingen bovenop zijn gebouwd met als doel die commit te amenden/fixen. Hierdoor hoef je dit niet met de hand te doen. Zie: | +| `` e `` | Edit | Open bestand in externe editor. | +| `` o `` | Open bestand | Open bestand in standaardapplicatie. | | `` i `` | Ignore or exclude file | | | `` r `` | Refresh bestanden | | | `` s `` | Stash | Stash all changes. For other variations of stashing, use the view stash options keybinding. | @@ -73,13 +75,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Stage individuele hunks/lijnen | If the selected item is a file, focus the staging view so you can stage individual hunks/lines. If the selected item is a directory, collapse/expand it. | | `` d `` | Bekijk 'veranderingen ongedaan maken' opties | View options for discarding changes to the selected file. | | `` g `` | Bekijk upstream reset opties | | -| `` D `` | Reset | View reset options for working tree (e.g. nuking the working tree). | +| `` D `` | Resetten | View reset options for working tree (e.g. nuking the working tree). | | `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | -| `` `` | Open external diff tool (git difftool) | | -| `` M `` | View merge conflict options | View options for resolving merge conflicts. | +| `` `` | Open externe diff applicatie (git difftool) | | +| `` M `` | Bekijk merge conflict opties | Bekijk opties voor het oplossen van mergeconflicten. | | `` f `` | Fetch | Fetch changes from remote. | | `` - `` | Collapse all files | Collapse all directories in the files tree | -| `` = `` | Expand all files | Expand all directories in the file tree | +| `` = `` | Vouw alle bestanden uit | Vouw alle mappen in de bestandsstructuur uit | | `` 0 `` | Focus main view | | | `` / `` | Filter the current view by text | | @@ -89,7 +91,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Bevestig | | | `` `` | Sluiten | | -| `` `` | Copy to clipboard | | +| `` `` | Kopieer naar klembord | | ## Branches @@ -97,18 +99,19 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Kopieer branch name naar klembord | | | `` i `` | Laat git-flow opties zien | | -| `` `` | Uitchecken | Checkout selected item. | +| `` `` | Uitchecken | Geselecteerd item uitchecken. | | `` n `` | Nieuwe branch | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Verplaats commits naar nieuwe branch | Maak een nieuwe branch en verplaats niet-gepushte commits van de huidige branch hier naar toe. Gebruik dit in het geval dat je deze commits eigenlijk op een nieuwe branch had willen maken.

Let op dat de selectie genegeerd wordt. De nieuwe branch komt ofwel bovenop de main branch, of bovenop de huidige branch (je kan kiezen). | +| `` w `` | New worktree | | | `` o `` | Maak een pull-request | | | `` O `` | Bekijk opties voor pull-aanvraag | | | `` G `` | Open pull request in browser | | | `` `` | Kopieer de URL van het pull-verzoek naar het klembord | | | `` c `` | Uitchecken bij naam | Checkout by name. In the input box you can enter '-' to switch to the previous branch. | -| `` - `` | Checkout previous branch | | +| `` - `` | Vorige branch uitchecken | | | `` F `` | Forceer checkout | Force checkout selected branch. This will discard all local changes in your working directory before checking out the selected branch. | -| `` d `` | Delete | View delete options for local/remote branch. | -| `` r `` | Rebase branch | Rebase the checked-out branch onto the selected branch. | +| `` d `` | Verwijderen | View delete options for local/remote branch. | +| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. | | `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` f `` | Fast-forward deze branch vanaf zijn upstream | Fast-forward selected branch from its upstream. | | `` T `` | Creëer tag | | @@ -116,10 +119,9 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Bekijk reset opties | | | `` R `` | Hernoem branch | | | `` u `` | View upstream options | View options relating to the branch's upstream e.g. setting/unsetting the upstream and resetting to the upstream. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open externe diff applicatie (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Commit bericht @@ -134,18 +136,18 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopieer de bestandsnaam naar het klembord | | -| `` y `` | Copy to clipboard | | +| `` y `` | Kopieer naar klembord | | | `` c `` | Uitchecken | Bestand uitchecken | | `` d `` | Bekijk 'veranderingen ongedaan maken' opties | Uitsluit deze commit zijn veranderingen aan dit bestand | -| `` o `` | Open bestand | Open file in default application. | -| `` e `` | Edit | Open file in external editor. | -| `` `` | Open external diff tool (git difftool) | | +| `` o `` | Open bestand | Open bestand in standaardapplicatie. | +| `` e `` | Edit | Open bestand in externe editor. | +| `` `` | Open externe diff applicatie (git difftool) | | | `` `` | Toggle bestand inbegrepen in patch | Toggle whether the file is included in the custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` a `` | Toggle all files | Add/remove all commit's files to custom patch. See https://github.com/jesseduffield/lazygit#rebase-magic-custom-patches. | | `` `` | Enter bestand om geselecteerde regels toe te voegen aan de patch | If a file is selected, enter the file so that you can add/remove individual lines to the custom patch. If a directory is selected, toggle the directory. | | `` ` `` | Toggle bestandsboom weergave | Toggle file view between flat and tree layout. Flat layout shows all file paths in a single list, tree layout groups files by directory.

The default can be changed in the config file with the key 'gui.showFileTree'. | | `` - `` | Collapse all files | Collapse all directories in the files tree | -| `` = `` | Expand all files | Expand all directories in the file tree | +| `` = `` | Vouw alle bestanden uit | Vouw alle mappen in de bestandsstructuur uit | | `` 0 `` | Focus main view | | | `` / `` | Filter the current view by text | | @@ -181,14 +183,14 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` 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 | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Verplaats commits naar nieuwe branch | Maak een nieuwe branch en verplaats niet-gepushte commits van de huidige branch hier naar toe. Gebruik dit in het geval dat je deze commits eigenlijk op een nieuwe branch had willen maken.

Let op dat de selectie genegeerd wordt. De nieuwe branch komt ofwel bovenop de main branch, of bovenop de huidige branch (je kan kiezen). | +| `` w `` | New worktree | | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open externe diff applicatie (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | -| `` w `` | View worktree options | | | `` / `` | Start met zoeken | | ## Input prompt @@ -211,15 +213,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Kies stuk | | -| `` b `` | Kies beide stukken | | +| `` b `` | Pick both hunks | | | `` , k `` | Selecteer bovenste hunk | | | `` , j `` | Selecteer onderste hunk | | | `` , h `` | Selecteer voorgaand conflict | | | `` , l `` | Selecteer volgende conflict | | | `` z `` | Ongedaan maken | Undo last merge conflict resolution. | -| `` e `` | Verander bestand | Open file in external editor. | -| `` o `` | Open bestand | Open file in default application. | -| `` M `` | View merge conflict options | View options for resolving merge conflicts. | +| `` e `` | Verander bestand | Open bestand in externe editor. | +| `` o `` | Open bestand | Open bestand in standaardapplicatie. | +| `` M `` | Bekijk merge conflict opties | Bekijk opties voor het oplossen van mergeconflicten. | | `` `` | Ga terug naar het bestanden paneel | | ## Normaal @@ -241,8 +243,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` v `` | Toggle drag selecteer | | | `` a `` | Toggle hunk selection | Toggle line-by-line vs. hunk selection mode. | | `` `` | Copy selected text to clipboard | | -| `` o `` | Open bestand | Open file in default application. | -| `` e `` | Verander bestand | Open file in external editor. | +| `` o `` | Open bestand | Open bestand in standaardapplicatie. | +| `` e `` | Verander bestand | Open bestand in externe editor. | | `` `` | Voeg toe/verwijder lijn(en) in patch | | | `` d `` | Remove lines from commit | Remove the selected lines from this commit. This runs an interactive rebase in the background, so you may get a merge conflict if a later commit also changes these lines. | | `` `` | Sluit lijn-bij-lijn modus | | @@ -257,15 +259,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` 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 | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Verplaats commits naar nieuwe branch | Maak een nieuwe branch en verplaats niet-gepushte commits van de huidige branch hier naar toe. Gebruik dit in het geval dat je deze commits eigenlijk op een nieuwe branch had willen maken.

Let op dat de selectie genegeerd wordt. De nieuwe branch komt ofwel bovenop de main branch, of bovenop de huidige branch (je kan kiezen). | +| `` w `` | New worktree | | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (gekopieerde) commits selectie | | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open externe diff applicatie (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Remote branches @@ -273,27 +275,27 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Kopieer branch name naar klembord | | -| `` `` | Uitchecken | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | +| `` `` | Uitchecken | Geselecteerde remote branch uitchecken als nieuwe locale branch of als detached head. | | `` n `` | Nieuwe branch | | +| `` w `` | New worktree | | | `` M `` | Merge in met huidige checked out branch | View options for merging the selected item into the current branch (regular merge, squash merge) | -| `` r `` | Rebase branch | Rebase the checked-out branch onto the selected branch. | -| `` d `` | Delete | Delete the remote branch from the remote. | -| `` u `` | Set as upstream | Stel in als upstream van uitgecheckte branch | +| `` r `` | Rebase branch | Rebase de uitgecheckte branch bovenop de geselecteerde branch. | +| `` d `` | Verwijderen | Delete the remote branch from the remote. | +| `` u `` | Instellen als upstream | Stel in als upstream van uitgecheckte branch | | `` s `` | Sort order | | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open externe diff applicatie (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Remotes | Key | Action | Info | |-----|--------|-------------| -| `` `` | View branches | | +| `` `` | Bekijk branches | | | `` n `` | Voeg een nieuwe remote toe | | -| `` d `` | Remove | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | +| `` d `` | Verwijderen | Remove the selected remote. Any local branches tracking a remote branch from the remote will be unaffected. | | `` e `` | Edit | Wijzig remote | | `` f `` | Fetch | Fetch remote | | `` F `` | Add fork remote | Quickly add a fork remote by replacing the owner in the origin URL and optionally check out a branch from new remote. | @@ -318,15 +320,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 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. | -| `` o `` | Open bestand | Open file in default application. | -| `` e `` | Verander bestand | Open file in external editor. | +| `` o `` | Open bestand | Open bestand in standaardapplicatie. | +| `` e `` | Verander bestand | Open bestand in externe editor. | | `` `` | Ga terug naar het bestanden paneel | | | `` `` | Ga naar een ander paneel | Switch to other view (staged/unstaged changes). | | `` E `` | Edit hunk | Edit selected hunk in external editor. | -| `` c `` | Commit veranderingen | Commit staged changes. | +| `` c `` | Commit veranderingen | Commit gestagede wijzigingen. | | `` w `` | Commit veranderingen zonder pre-commit hook | | | `` C `` | Commit veranderingen met de git editor | | -| `` `` | Find base commit for fixup | Find the commit that your current changes are building upon, for the sake of amending/fixing up the commit. This spares you from having to look through your branch's commits one-by-one to see which commit should be amended/fixed up. See docs: | +| `` `` | Find base commit for fixup | Vind de commit waar je huidige wijzigingen bovenop zijn gebouwd met als doel die commit te amenden/fixen. Hierdoor hoef je dit niet met de hand te doen. Zie: | | `` / `` | Start met zoeken | | ## Stash @@ -337,18 +339,17 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Pop | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | Laten vallen | Remove the stash entry from the stash list. | | `` n `` | Nieuwe branch | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | -| `` r `` | Rename stash | | +| `` w `` | New worktree | | +| `` r `` | Hernoem stash | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Status | Key | Action | Info | |-----|--------|-------------| -| `` o `` | Open config bestand | Open file in default application. | -| `` e `` | Verander config bestand | Open file in external editor. | +| `` e `` | Verander config bestand | Open bestand in externe editor. | | `` u `` | Check voor updates | | | `` `` | Wissel naar een recente repo | | | `` a `` | Show/cycle all branch logs | | @@ -364,15 +365,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` 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 | | -| `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` N `` | Verplaats commits naar nieuwe branch | Maak een nieuwe branch en verplaats niet-gepushte commits van de huidige branch hier naar toe. Gebruik dit in het geval dat je deze commits eigenlijk op een nieuwe branch had willen maken.

Let op dat de selectie genegeerd wordt. De nieuwe branch komt ofwel bovenop de main branch, of bovenop de huidige branch (je kan kiezen). | +| `` w `` | New worktree | | | `` g `` | Bekijk reset opties | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Kopieer commit (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Reset cherry-picked (gekopieerde) commits selectie | | -| `` `` | Open external diff tool (git difftool) | | +| `` `` | Open externe diff applicatie (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Bekijk gecommite bestanden | | -| `` w `` | View worktree options | | | `` / `` | Start met zoeken | | ## Submodules @@ -381,7 +382,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` `` | Kopieer submodule naam naar klembord | | | `` `` | Enter | Enter submodule | -| `` d `` | Remove | Remove the selected submodule and its corresponding directory. | +| `` d `` | Verwijderen | Remove the selected submodule and its corresponding directory. | | `` u `` | Update | Update selected submodule. | | `` n `` | Voeg nieuwe submodule toe | | | `` e `` | Update submodule URL | | @@ -394,15 +395,15 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Copy tag to clipboard | | -| `` `` | Uitchecken | Checkout the selected tag as a detached HEAD. | +| `` `` | 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. | -| `` d `` | Delete | View delete options for local/remote tag. | +| `` 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. | -| `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | -| `` `` | Open external diff tool (git difftool) | | +| `` g `` | Resetten | View reset options (soft/mixed/hard) for resetting onto selected item. | +| `` `` | Open externe diff applicatie (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Bekijk commits | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Worktrees @@ -411,6 +412,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct |-----|--------|-------------| | `` n `` | New worktree | | | `` `` | Switch | Switch to the selected worktree. | -| `` o `` | Open in editor | | -| `` d `` | Remove | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. | +| `` o `` | Openen in editor | | +| `` d `` | Verwijderen | Remove the selected worktree. This will both delete the worktree's directory, as well as metadata about the worktree in the .git directory. | | `` / `` | Filter the current view by text | | diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md index b032a6606..719a7e6c4 100644 --- a/docs/keybindings/Keybindings_pl.md +++ b/docs/keybindings/Keybindings_pl.md @@ -22,7 +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 | Choose the next pager in the list of configured pagers. | +| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | | `` `` | 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. | @@ -30,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | Wyjdź | | | `` `` | Suspend the application | | | `` `` | Przełącz białe znaki | 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'. | +| `` `` | Edytuj plik konfiguracyjny | Otwórz plik w zewnętrznym edytorze. | | `` z `` | Cofnij | Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby cofnąć ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity. | | `` Z `` | Ponów | Dziennik reflog zostanie użyty do określenia, jakie polecenie git należy uruchomić, aby ponowić ostatnie polecenie git. Nie obejmuje to zmian w drzewie roboczym; brane są pod uwagę tylko commity. | @@ -83,13 +85,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Otwórz commit w przeglądarce | | | `` n `` | Utwórz nową gałąź z commita | | | `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nowe drzewo pracy | | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Wyświetl pliki | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Dodatkowy @@ -120,6 +122,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Otwórz commit w przeglądarce | | | `` n `` | Utwórz nową gałąź z commita | | | `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nowe drzewo pracy | | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | | `` `` | Resetuj wybrane (cherry-picked) commity | | @@ -127,7 +130,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | ## Główny panel (budowanie łatki) @@ -162,6 +164,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Przełącz | Przełącz wybrany element. | | `` n `` | Nowa gałąź | | | `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nowe drzewo pracy | | | `` o `` | Utwórz żądanie ściągnięcia | | | `` O `` | Zobacz opcje tworzenia pull requesta | | | `` G `` | Otwórz żądanie ściągnięcia w przeglądarce | | @@ -181,7 +184,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | ## Menu @@ -207,7 +209,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Wybierz fragment | | -| `` b `` | Wybierz wszystkie fragmenty | | +| `` b `` | Pick both hunks | | | `` , k `` | Poprzedni fragment | | | `` , j `` | Następny fragment | | | `` , h `` | Poprzedni konflikt | | @@ -316,17 +318,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Wyciągnij | Zastosuj wpis schowka do katalogu roboczego i usuń wpis schowka. | | `` d `` | Usuń | Usuń wpis schowka z listy schowka. | | `` n `` | Nowa gałąź | Utwórz nową gałąź z wybranego wpisu schowka. Działa poprzez przełączenie git na commit, na którym wpis schowka został utworzony, tworzenie nowej gałęzi z tego commita, a następnie zastosowanie wpisu schowka do nowej gałęzi jako dodatkowego commita. | +| `` w `` | Nowe drzewo pracy | | | `` r `` | Zmień nazwę schowka | | | `` 0 `` | Focus main view | | | `` `` | Wyświetl pliki | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | ## Status | Key | Action | Info | |-----|--------|-------------| -| `` o `` | Otwórz plik konfiguracyjny | Otwórz plik w domyślnej aplikacji. | | `` e `` | Edytuj plik konfiguracyjny | Otwórz plik w zewnętrznym edytorze. | | `` u `` | Sprawdź aktualizacje | | | `` `` | Przełącz na ostatnie repozytorium | | @@ -344,6 +345,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Otwórz commit w przeglądarce | | | `` n `` | Utwórz nową gałąź z commita | | | `` N `` | Przenieś commity do nowej gałęzi | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nowe drzewo pracy | | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` C `` | Kopiuj (cherry-pick) | Oznacz commit jako skopiowany. Następnie, w widoku lokalnych commitów, możesz nacisnąć `V`, aby wkleić (cherry-pick) skopiowane commity do sprawdzonej gałęzi. W dowolnym momencie możesz nacisnąć ``, aby anulować zaznaczenie. | | `` `` | Resetuj wybrane (cherry-picked) commity | | @@ -351,7 +353,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Wyświetl pliki | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Szukaj w bieżącym widoku po tekście | | ## Submoduły @@ -375,13 +376,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Skopiuj tag do schowka | | | `` `` | Przełącz | Przełącz wybrany tag jako odłączoną głowę (detached HEAD). | | `` n `` | Nowy tag | Utwórz nowy tag z bieżącego commita. Zostaniesz poproszony o wprowadzenie nazwy tagu i opcjonalnego opisu. | +| `` w `` | Nowe drzewo pracy | | | `` d `` | Usuń | Wyświetl opcje usuwania lokalnego/odległego tagu. | | `` P `` | Wyślij tag | Wyślij wybrany tag do zdalnego. Zostaniesz poproszony o wybranie zdalnego. | | `` g `` | Reset | Wyświetl opcje resetu (miękki/mieszany/twardy) do wybranego elementu. | | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | ## Zdalne @@ -403,6 +404,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Kopiuj nazwę gałęzi do schowka | | | `` `` | Przełącz | Przełącz na nową lokalną gałąź na podstawie wybranej gałęzi zdalnej. Nowa gałąź będzie śledzić gałąź zdalną. | | `` n `` | Nowa gałąź | | +| `` w `` | Nowe drzewo pracy | | | `` M `` | Scal | Scal wybraną gałąź z aktualnie sprawdzoną gałęzią. | | `` r `` | Przebazuj | Przebazuj przełączoną gałąź na wybraną gałąź. | | `` d `` | Usuń | Usuń gałąź zdalną ze zdalnego. | @@ -412,5 +414,4 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Otwórz zewnętrzne narzędzie różnic (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Pokaż commity | | -| `` w `` | Zobacz opcje drzewa pracy | | | `` / `` | Filtruj bieżący widok po tekście | | diff --git a/docs/keybindings/Keybindings_pt.md b/docs/keybindings/Keybindings_pt.md index c19619191..5fcba2688 100644 --- a/docs/keybindings/Keybindings_pt.md +++ b/docs/keybindings/Keybindings_pt.md @@ -22,7 +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 | Choose the next pager in the list of configured pagers. | +| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | | `` `` | 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. | @@ -30,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | Sair | | | `` `` | Suspender a aplicação | | | `` `` | 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'. | +| `` `` | Editar arquivo de configuração | Abrir arquivo no editor externo. | | `` z `` | Desfazer | O reflog será usado para determinar qual comando git para executar para desfazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração. | | `` Z `` | Refazer | O reflog será usado para determinar qual comando git para executar para refazer o último comando git. Isto não inclui mudanças na árvore de trabalho; apenas compromissos são tidos em consideração. | @@ -92,6 +94,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Verificar | Checar item selecionado | | `` n `` | Nova branch | | | `` N `` | Mover commits para uma nova branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nova árvore de trabalho | | | `` o `` | Criar solicitação de pull | | | `` O `` | View create pull request options | | | `` G `` | Open pull request in browser | | @@ -111,7 +114,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver commits | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Filtrar a visualização atual por texto | | ## Branches remotos @@ -121,6 +123,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copiar nome da branch para área de transferência | | | `` `` | Verificar | Checar a nova branch baseada na brach remota selecionada, ou a branch remota como HEAD, desanexado | | `` n `` | Nova branch | | +| `` w `` | Nova árvore de trabalho | | | `` M `` | Mesclar | Ver opções para mesclar o item selecionado no branch atual (mesclar regularmente, mesclar squash) | | `` r `` | Refazer | Refazer a branch checada na branch selecionada | | `` d `` | Apagar | Excluir o branch remoto do controle remoto. | @@ -130,7 +133,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver commits | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Filtrar a visualização atual por texto | | ## Commit arquivos @@ -186,13 +188,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Abrir commit no navegador | | | `` n `` | Create new branch off of commit | | | `` N `` | Mover commits para uma nova branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nova árvore de trabalho | | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` C `` | Copiar (cherry-pick) | Marcar commit como copiado. Então, dentro da visualização local de commits, você pode pressionar `V` para colar (cherry-pick) o(s) commit(s) copiado(s) em seu branch de check-out. A qualquer momento você pode pressionar `` para cancelar a seleção. | | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver arquivos | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Pesquisar na visualização atual por texto | | ## Etiquetas @@ -202,13 +204,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copiar etiqueta para área de transferência | | | `` `` | Verificar | Checar a tag selecionada como um HEAD, desanexado | | `` n `` | Nova etiqueta | Crie uma nova etiqueta a partir do commit atual. Você será solicitado a digitar um nome e uma descrição opcional. | +| `` w `` | Nova árvore de trabalho | | | `` d `` | Apagar | Ver opções de exclusão para tag local/remoto. | | `` P `` | Empurrar etiqueta | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` `` | Abrir ferramenta de diff externa (git difftool) | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver commits | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Filtrar a visualização atual por texto | | ## Input prompt @@ -271,7 +273,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Escolha o local | | -| `` b `` | Pegar todos os pedaços | | +| `` b `` | Pick both hunks | | | `` , k `` | Trecho anterior | | | `` , j `` | Próximo trecho | | | `` , h `` | Conflito anterior | | @@ -308,6 +310,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Abrir commit no navegador | | | `` n `` | Create new branch off of commit | | | `` N `` | Mover commits para uma nova branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nova árvore de trabalho | | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` C `` | Copiar (cherry-pick) | Marcar commit como copiado. Então, dentro da visualização local de commits, você pode pressionar `V` para colar (cherry-pick) o(s) commit(s) copiado(s) em seu branch de check-out. A qualquer momento você pode pressionar `` para cancelar a seleção. | | `` `` | Reset copied (cherry-picked) commits selection | | @@ -315,7 +318,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver commits | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Filtrar a visualização atual por texto | | ## Remotes @@ -346,17 +348,16 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Pop | Aplique a entrada de stash no seu diretório de trabalho e remova a entrada de stash. | | `` d `` | Descartar | Remova a entrada do stash da lista de armazenamento. | | `` n `` | Nova branch | Criar um novo ramo a partir da entrada de lixo selecionada. Isso funciona verificando o commit do qual a entrada de lixo foi criada, criar um novo branch a partir desse commit e, em seguida, aplicar a entrada de lixo ao novo branch como um commit adicional. | +| `` w `` | Nova árvore de trabalho | | | `` r `` | Renomear o stash | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver arquivos | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Filtrar a visualização atual por texto | | ## Status | Key | Action | Info | |-----|--------|-------------| -| `` o `` | Abrir o ficheiro de config | Abrir arquivo no aplicativo padrão. | | `` e `` | Editar arquivo de configuração | Abrir arquivo no editor externo. | | `` u `` | Verificar atualização | | | `` `` | Mudar para um repositório recente | | @@ -374,6 +375,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Abrir commit no navegador | | | `` n `` | Create new branch off of commit | | | `` N `` | Mover commits para uma nova branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | Nova árvore de trabalho | | | `` g `` | Restaurar | Ver opções de redefinição (soft/mixed/hard) para redefinir para o item selecionado. | | `` C `` | Copiar (cherry-pick) | Marcar commit como copiado. Então, dentro da visualização local de commits, você pode pressionar `V` para colar (cherry-pick) o(s) commit(s) copiado(s) em seu branch de check-out. A qualquer momento você pode pressionar `` para cancelar a seleção. | | `` `` | Reset copied (cherry-picked) commits selection | | @@ -381,7 +383,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focar visualização principal | | | `` `` | Ver arquivos | | -| `` w `` | Ver opções da árvore de trabalho | | | `` / `` | Pesquisar na visualização atual por texto | | ## Submódulos diff --git a/docs/keybindings/Keybindings_ru.md b/docs/keybindings/Keybindings_ru.md index c802678b3..683135448 100644 --- a/docs/keybindings/Keybindings_ru.md +++ b/docs/keybindings/Keybindings_ru.md @@ -22,7 +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 | Choose the next pager in the list of configured pagers. | +| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | | `` `` | Отменить | | | `` ? `` | Открыть меню | | | `` `` | Просмотреть параметры фильтрации по пути | View options for filtering the commit log, so that only commits matching the filter are shown. | @@ -30,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | Выйти | | | `` `` | Suspend the application | | | `` `` | Переключить отображение изменении пробелов в просмотрщике сравнении | 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'. | +| `` `` | Редактировать файл конфигурации | Open file in external editor. | | `` z `` | Отменить (через reflog) (экспериментальный) | Журнал ссылок (reflog) будет использоваться для определения того, какую команду git запустить, чтобы отменить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты. | | `` Z `` | Повторить (через reflog) (экспериментальный) | Журнал ссылок (reflog) будет использоваться для определения того, какую команду git нужно запустить, чтобы повторить последнюю команду git. Сюда не входят изменения в рабочем дереве; учитываются только коммиты. | @@ -112,7 +114,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | Выбрать эту часть | | -| `` b `` | Выбрать все части | | +| `` b `` | Pick both hunks | | | `` , k `` | Выбрать предыдущую часть | | | `` , j `` | Выбрать следующую часть | | | `` , h `` | Выбрать предыдущий конфликт | | @@ -149,6 +151,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Открыть коммит в браузере | | | `` n `` | Создать новую ветку с этого коммита | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Скопировать отобранные коммит (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Сбросить отобранную (скопированную \| cherry-picked) выборку коммитов | | @@ -156,7 +159,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Коммиты @@ -192,13 +194,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Открыть коммит в браузере | | | `` n `` | Создать новую ветку с этого коммита | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Скопировать отобранные коммит (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Open external diff tool (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть файлы выбранного элемента | | -| `` w `` | View worktree options | | | `` / `` | Найти | | ## Локальные Ветки @@ -210,6 +212,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Переключить | Checkout selected item. | | `` n `` | Новая ветка | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` o `` | Создать запрос на принятие изменений | | | `` O `` | Создать параметры запроса принятие изменений | | | `` G `` | Open pull request in browser | | @@ -229,7 +232,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Меню @@ -258,6 +260,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | Открыть коммит в браузере | | | `` n `` | Создать новую ветку с этого коммита | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | Просмотреть параметры сброса | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | Скопировать отобранные коммит (cherry-pick) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | Сбросить отобранную (скопированную \| cherry-picked) выборку коммитов | | @@ -265,7 +268,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть файлы выбранного элемента | | -| `` w `` | View worktree options | | | `` / `` | Найти | | ## Подмодули @@ -313,7 +315,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` o `` | Открыть файл конфигурации | Open file in default application. | | `` e `` | Редактировать файл конфигурации | Open file in external editor. | | `` u `` | Проверить обновления | | | `` `` | Переключиться на последний репозиторий | | @@ -328,13 +329,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copy tag to clipboard | | | `` `` | Переключить | Checkout the selected tag as a detached HEAD. | | `` n `` | Создать тег | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | +| `` w `` | New worktree | | | `` d `` | Delete | View delete options for local/remote tag. | | `` P `` | Отправить тег | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | Reset | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Удалённые ветки @@ -344,6 +345,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Скопировать название ветки в буфер обмена | | | `` `` | Переключить | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | Новая ветка | | +| `` w `` | New worktree | | | `` M `` | Слияние с текущей переключённой веткой | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | Перебазировать переключённую ветку на эту ветку | Rebase the checked-out branch onto the selected branch. | | `` d `` | Delete | Delete the remote branch from the remote. | @@ -353,7 +355,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Open external diff tool (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть коммиты | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | ## Удалённые репозитории @@ -409,8 +410,8 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | Применить припрятанные изменения и тут же удалить их из хранилища | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | Удалить припрятанные изменения из хранилища | Remove the stash entry from the stash list. | | `` n `` | Новая ветка | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | +| `` w `` | New worktree | | | `` r `` | Переименовать хранилище | | | `` 0 `` | Focus main view | | | `` `` | Просмотреть файлы выбранного элемента | | -| `` w `` | View worktree options | | | `` / `` | Filter the current view by text | | diff --git a/docs/keybindings/Keybindings_zh-CN.md b/docs/keybindings/Keybindings_zh-CN.md index 9cb7d5186..b4f1134ec 100644 --- a/docs/keybindings/Keybindings_zh-CN.md +++ b/docs/keybindings/Keybindings_zh-CN.md @@ -23,6 +23,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` + `` | 下一屏模式(正常/半屏/全屏) | | | `` _ `` | 上一屏模式 | | | `` \| `` | 切换分页器 | 从已配置的分页器列表中选择下一个分页器 | +| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | | `` `` | 取消 | | | `` ? `` | 打开菜单 | | | `` `` | 查看按路径过滤选项 | 查看用于过滤提交日志的选项,以便仅显示与过滤器匹配的提交。 | @@ -30,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | 退出 | | | `` `` | 挂起应用程序 | | | `` `` | 切换是否在差异视图中显示空白字符差异 | 切换是否在差异视图中显示空白字符更改。

默认值可在配置文件中通过键 'git.ignoreWhitespaceInDiffView' 更改。 | +| `` `` | 编辑配置文件 | 使用外部编辑器打开文件 | | `` z `` | 撤销 | Reflog将用于确定运行哪个git命令来撤消最后一个git命令。这并不包括对工作树的更改,只考虑提交。 | | `` Z `` | 重做 | Reflog将用于确定运行哪个git命令来重做上一个git命令。这并不包括对工作树的更改,只考虑提交。 | @@ -60,6 +62,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 在浏览器中打开提交 | | | `` n `` | 从提交创建新分支 | | | `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。

请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 | +| `` w `` | 新建工作树 | | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` C `` | 复制提交(拣选) | 标记提交为已复制。然后,在本地提交视图中,您可以按 `V` (Cherry-Pick) 将已复制的提交粘贴到已检出的分支中。任何时候都可以按 `` 来取消选择。 | | `` `` | 重置已拣选(复制)的提交 | | @@ -67,7 +70,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | 选择当前分支的提交 | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交的文件 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 开始搜索 | | ## 子模块 @@ -104,6 +106,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 在浏览器中打开提交 | | | `` n `` | 从提交创建新分支 | | | `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。

请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 | +| `` w `` | 新建工作树 | | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` C `` | 复制提交(拣选) | 标记提交为已复制。然后,在本地提交视图中,您可以按 `V` (Cherry-Pick) 将已复制的提交粘贴到已检出的分支中。任何时候都可以按 `` 来取消选择。 | | `` `` | 重置已拣选(复制)的提交 | | @@ -111,7 +114,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | 选择当前分支的提交 | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | ## 提交 @@ -147,13 +149,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 在浏览器中打开提交 | | | `` n `` | 从提交创建新分支 | | | `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。

请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 | +| `` w `` | 新建工作树 | | | `` g `` | 查看重置选项 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` C `` | 复制提交(拣选) | 标记提交为已复制。然后,在本地提交视图中,您可以按 `V` (Cherry-Pick) 将已复制的提交粘贴到已检出的分支中。任何时候都可以按 `` 来取消选择。 | | `` `` | 使用外部差异比较工具(git difftool) | | | `` * `` | 选择当前分支的提交 | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交的文件 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 开始搜索 | | ## 提交信息 @@ -225,6 +227,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 检出 | 检出选中的项目 | | `` n `` | 新分支 | | | `` N `` | 移动提交至新分支 | 创建一个新分支,并将当前分支未推送的提交移动到该分支。如果您打算开始新工作但忘记先创建新分支,这会很有用。

请注意,此操作忽略选择,新分支总是从主分支创建或堆叠在当前分支之上(您可以选择哪种方式)。 | +| `` w `` | 新建工作树 | | | `` o `` | 创建拉取请求 | | | `` O `` | 创建拉取请求选项 | | | `` G `` | 在浏览器中打开拉取请求 | | @@ -244,7 +247,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 使用外部差异比较工具(git difftool) | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | ## 构建补丁中 @@ -270,13 +272,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 复制标签到剪贴板 | | | `` `` | 检出 | 检出选择的标签作为分离的HEAD | | `` n `` | 创建标签 | 基于当前提交创建一个新标签。您将在弹窗中输入标签名称和描述(可选)。 | +| `` w `` | 新建工作树 | | | `` d `` | 删除 | 查看本地/远程标签的删除选项 | | `` P `` | 推送标签 | 推送选择的标签到远端。您将在弹窗中选择一个远端。 | | `` g `` | 重置 | 查看重置选项 (soft/mixed/hard) 用于重置到选择项 | | `` `` | 使用外部差异比较工具(git difftool) | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | ## 次要 @@ -292,7 +294,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | 选中区块 | | -| `` b `` | 选中所有区块 | | +| `` b `` | Pick both hunks | | | `` , k `` | 选择顶部块 | | | `` , j `` | 选择底部块 | | | `` , h `` | 选择上一个冲突 | | @@ -339,7 +341,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` o `` | 打开配置文件 | 使用默认程序打开该文件 | | `` e `` | 编辑配置文件 | 使用外部编辑器打开文件 | | `` u `` | 检查更新 | | | `` `` | 切换到最近的仓库 | | @@ -371,10 +372,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | 应用并删除 | 将存储项应用到工作目录并删除存储项。 | | `` d `` | 删除 | 从贮藏列表中删除该贮藏项 | | `` n `` | 新分支 | 从选定的贮藏项创建一个新分支。这是通过 git 检查创建贮藏项的提交,从该提交创建一个新分支,然后将贮藏项作为附加提交应用到新分支来实现的。 | +| `` w `` | 新建工作树 | | | `` r `` | 重命名贮藏 | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交的文件 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | ## 输入提示 @@ -403,6 +404,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 复制分支名称到剪贴板 | | | `` `` | 检出 | 基于当前选中的远程分支检出一个新的本地分支,或者将远程分支作分离的HEAD。 | | `` n `` | 新分支 | | +| `` w `` | 新建工作树 | | | `` M `` | 合并到当前检出的分支 | 查看将选中项合并到当前分支的选项(正常合并,压缩合并) | | `` r `` | 变基 | 将检出的分支变基到所选的分支上。 | | `` d `` | 删除 | 从远程删除远程分支。 | @@ -412,5 +414,4 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 使用外部差异比较工具(git difftool) | | | `` 0 `` | 聚焦主视图 | | | `` `` | 查看提交 | | -| `` w `` | 查看工作区选项 | | | `` / `` | 通过文本过滤当前视图 | | diff --git a/docs/keybindings/Keybindings_zh-TW.md b/docs/keybindings/Keybindings_zh-TW.md index d6526b5b2..b50e25d35 100644 --- a/docs/keybindings/Keybindings_zh-TW.md +++ b/docs/keybindings/Keybindings_zh-TW.md @@ -22,7 +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 | Choose the next pager in the list of configured pagers. | +| `` \ `` | Cycle pagers (reverse) | Choose the previous pager in the list of configured pagers. | | `` `` | 取消 | | | `` ? `` | 開啟選單 | | | `` `` | 檢視篩選路徑選項 | View options for filtering the commit log, so that only commits matching the filter are shown. | @@ -30,6 +31,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` q, `` | 結束 | | | `` `` | Suspend the application | | | `` `` | 切換是否在差異檢視中顯示空格變更 | 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'. | +| `` `` | 編輯設定檔案 | 使用外部編輯器開啟 | | `` z `` | 復原 | 將使用 reflog 確任 git 指令以復原。這不包括工作區更改;只考慮提交。 | | `` Z `` | 取消復原 | 將使用 reflog 確任 git 指令以重作。這不包括工作區更改;只考慮提交。 | @@ -88,7 +90,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| | `` `` | 挑選程式碼片段 | | -| `` b `` | 挑選所有程式碼片段 | | +| `` b `` | Pick both hunks | | | `` , k `` | 選擇上一段 | | | `` , j `` | 選擇下一段 | | | `` , h `` | 選擇上一個衝突 | | @@ -139,6 +141,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 在瀏覽器中開啟提交 | | | `` n `` | 從提交建立新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | 重設選定的揀選 (複製) 提交 | | @@ -146,7 +149,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 檢視所選項目的檔案 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 子模組 @@ -206,13 +208,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 在瀏覽器中開啟提交 | | | `` n `` | 從提交建立新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | 開啟外部差異工具 (git difftool) | | | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 檢視所選項目的檔案 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 提交摘要 @@ -250,10 +252,10 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` g `` | 還原 | Apply the stash entry to your working directory and remove the stash entry. | | `` d `` | 捨棄 | Remove the stash entry from the stash list. | | `` n `` | 新分支 | Create a new branch from the selected stash entry. This works by git checking out the commit that the stash entry was created from, creating a new branch from that commit, then applying the stash entry to the new branch as an additional commit. | +| `` w `` | New worktree | | | `` r `` | 重新命名收藏 | | | `` 0 `` | Focus main view | | | `` `` | 檢視所選項目的檔案 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 日誌 @@ -266,6 +268,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` o `` | 在瀏覽器中開啟提交 | | | `` n `` | 從提交建立新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` g `` | 檢視重設選項 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` C `` | 複製提交 (揀選) | Mark commit as copied. Then, within the local commits view, you can press `V` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `` to cancel the selection. | | `` `` | 重設選定的揀選 (複製) 提交 | | @@ -273,7 +276,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` * `` | Select commits of current branch | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 本地分支 @@ -285,6 +287,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 檢出 | 檢出選定的項目。 | | `` n `` | 新分支 | | | `` N `` | Move commits to new branch | Create a new branch and move the unpushed commits of the current branch to it. Useful if you meant to start new work and forgot to create a new branch first.

Note that this disregards the selection, the new branch is always created either from the main branch or stacked on top of the current branch (you get to choose which). | +| `` w `` | New worktree | | | `` o `` | 建立拉取請求 | | | `` O `` | 建立拉取請求選項 | | | `` G `` | Open pull request in browser | | @@ -304,7 +307,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 開啟外部差異工具 (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 標籤 @@ -314,13 +316,13 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | Copy tag to clipboard | | | `` `` | 檢出 | Checkout the selected tag as a detached HEAD. | | `` n `` | 建立標籤 | Create new tag from current commit. You'll be prompted to enter a tag name and optional description. | +| `` w `` | New worktree | | | `` d `` | 刪除 | View delete options for local/remote tag. | | `` P `` | 推送標籤 | Push the selected tag to a remote. You'll be prompted to select a remote. | | `` g `` | 重設 | View reset options (soft/mixed/hard) for resetting onto selected item. | | `` `` | 開啟外部差異工具 (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | ## 檔案 @@ -368,7 +370,6 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | Key | Action | Info | |-----|--------|-------------| -| `` o `` | 開啟設定檔案 | 使用預設軟體開啟 | | `` e `` | 編輯設定檔案 | 使用外部編輯器開啟 | | `` u `` | 檢查更新 | | | `` `` | 切換到最近使用的版本庫 | | @@ -403,6 +404,7 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 複製分支名稱到剪貼簿 | | | `` `` | 檢出 | Checkout a new local branch based on the selected remote branch, or the remote branch as a detached head. | | `` n `` | 新分支 | | +| `` w `` | New worktree | | | `` M `` | 合併到當前檢出的分支 | View options for merging the selected item into the current branch (regular merge, squash merge) | | `` r `` | 將已檢出的分支變基至此分支 | Rebase the checked-out branch onto the selected branch. | | `` d `` | 刪除 | Delete the remote branch from the remote. | @@ -412,5 +414,4 @@ _This file is auto-generated. To update, make the changes in the pkg/i18n direct | `` `` | 開啟外部差異工具 (git difftool) | | | `` 0 `` | Focus main view | | | `` `` | 檢視提交 | | -| `` w `` | 檢視工作目錄選項 | | | `` / `` | 搜尋 | | diff --git a/schema/config.json b/schema/config.json index 2e968ba8f..82dbebb0b 100644 --- a/schema/config.json +++ b/schema/config.json @@ -321,7 +321,7 @@ "$ref": "#/$defs/PagingConfig" }, "type": "array", - "description": "Array of pagers. Each entry has the following format:\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\nSee https://github.com/jesseduffield/lazygit/blob/master/docs/Custom_Pagers.md for more information." + "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." }, "commit": { "$ref": "#/$defs/CommitConfig", @@ -358,6 +358,11 @@ "description": "If true, periodically refresh files and submodules", "default": true }, + "autoDetectExternalChanges": { + "type": "boolean", + "description": "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.", + "default": true + }, "autoForwardBranches": { "type": "string", "enum": [ @@ -585,6 +590,40 @@ "description": "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.", "default": 2 }, + "shrinkSidePanelsToContent": { + "type": "boolean", + "description": "If true, don't give a side panel more height than it needs to show its content; when all panels fit, the leftover height is shared among them so that they still fill the screen.", + "default": false + }, + "sidePanels": { + "items": { + "$ref": "#/$defs/SidePanel" + }, + "type": "array", + "description": "The side panels, in the order they appear from top to bottom.\nEach entry is a list of one or more names that share a single panel as tabs (cycle through them with the next-tab/previous-tab keys).\nOmit a name to hide it; give a name its own one-element list to promote a tab to a top-level panel.\nValid names are: 'status', 'files', 'worktrees', 'submodules', 'branches', 'remotes', 'tags', 'commits', 'reflog', 'stash'. 'files', 'branches', and 'commits' must always be included; they can't be hidden.", + "default": [ + [ + "status" + ], + [ + "files", + "worktrees", + "submodules" + ], + [ + "branches", + "remotes", + "tags" + ], + [ + "commits", + "reflog" + ], + [ + "stash" + ] + ] + }, "mainPanelSplitMode": { "type": "string", "enum": [ @@ -1624,9 +1663,6 @@ "branches": { "$ref": "#/$defs/KeybindingBranchesConfig" }, - "worktrees": { - "$ref": "#/$defs/KeybindingWorktreesConfig" - }, "commits": { "$ref": "#/$defs/KeybindingCommitsConfig" }, @@ -2847,6 +2883,20 @@ ], "default": "n" }, + "newWorktree": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "w" + }, "edit": { "oneOf": [ { @@ -3127,6 +3177,20 @@ ], "default": "|" }, + "cyclePagersReverse": { + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "default": "\\" + }, "undo": { "oneOf": [ { @@ -3340,14 +3404,8 @@ } ], "default": "\u003cctrl+t\u003e" - } - }, - "additionalProperties": false, - "type": "object" - }, - "KeybindingWorktreesConfig": { - "properties": { - "viewWorktreeOptions": { + }, + "editConfig": { "oneOf": [ { "type": "string" @@ -3359,7 +3417,7 @@ "type": "array" } ], - "default": "w" + "default": "\u003calt+shift+c\u003e" } }, "additionalProperties": false, @@ -3488,6 +3546,10 @@ }, "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": [ @@ -3521,21 +3583,45 @@ "properties": { "refreshInterval": { "type": "integer", - "minimum": 0, + "exclusiveMinimum": 0, "description": "File/submodule refresh interval in seconds.\nAuto-refresh can be disabled via option 'git.autoRefresh'.", "default": 10 }, "fetchInterval": { "type": "integer", - "minimum": 0, + "exclusiveMinimum": 0, "description": "Re-fetch interval in seconds.\nAuto-fetch can be disabled via option 'git.autoFetch'.", "default": 60 + }, + "externalChangeCheckInterval": { + "type": "integer", + "exclusiveMinimum": 0, + "description": "Interval in seconds at which lazygit polls for external ref changes (commits, branch updates, checkouts made outside lazygit).\nDetection can be disabled via option 'git.autoDetectExternalChanges'.", + "default": 2 } }, "additionalProperties": false, "type": "object", "description": "Background refreshes" }, + "SidePanel": { + "items": { + "type": "string", + "enum": [ + "status", + "files", + "worktrees", + "submodules", + "branches", + "remotes", + "tags", + "commits", + "reflog", + "stash" + ] + }, + "type": "array" + }, "SpinnerConfig": { "properties": { "frames": { @@ -3744,6 +3830,10 @@ "$ref": "#/$defs/GitConfig", "description": "Config relating to git" }, + "worktree": { + "$ref": "#/$defs/WorktreeConfig", + "description": "Config relating to git worktrees" + }, "update": { "$ref": "#/$defs/UpdateConfig", "description": "Periodic update checks" @@ -3809,6 +3899,17 @@ }, "additionalProperties": false, "type": "object" + }, + "WorktreeConfig": { + "properties": { + "defaultPath": { + "type": "string", + "description": "Default parent directory for new worktrees. It is offered as a candidate location alongside the parent directories of any worktrees you already have.\nA relative path is resolved against the repository's root directory, so \"../worktrees\" sits beside the repo and \".worktrees\" sits inside it.\nA leading \"~\" is expanded to your home directory, so \"~/worktrees\" works." + } + }, + "additionalProperties": false, + "type": "object", + "description": "Config relating to git worktrees" } } } From 44ba0d539fa1c059a7efd4770e973490b72a3fae Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 11:54:14 +0200 Subject: [PATCH 193/384] Show a waiting status while continuing a merge or rebase Continuing, skipping, or aborting a merge/rebase from the options menu ran the git command inline on the UI thread, freezing the UI with no spinner while it worked (a continue can replay many commits). Run the non-subprocess path on a worker with a waiting status instead, matching how the other merge/rebase entry points already behave. The auto-skip recursion in CheckMergeOrRebaseWithRefreshOptions must not start its own worker: it already runs on the caller's thread (the worker of the enclosing waiting status, or the UI thread for the synchronous callers). Route it through genericMergeCommandImpl with the waiting status suppressed so its behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/merge_and_rebase_helper.go | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index be1d7b3a9..6badaf2b4 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -79,6 +79,18 @@ func (self *MergeAndRebaseHelper) ContinueRebase() error { } func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { + return self.genericMergeCommandImpl(command, true) +} + +// genericMergeCommandImpl runs a merge/rebase continue/skip/abort and handles +// the result. Continuing can be slow (it may replay many commits), so the +// non-subprocess path runs on a worker with a waiting status. +// +// showWaitingStatus is false only for the recursive auto-skip in +// CheckMergeOrRebaseWithRefreshOptions: that call already runs on the caller's +// thread (the worker of the enclosing waiting status, or the UI thread for the +// synchronous callers), so it must not spin up a second one. +func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWaitingStatus bool) error { status := self.c.Git().Status.WorkingTreeState() if status.None() { @@ -123,12 +135,22 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { self.RecordWhetherMergeOrRebaseStartedInLazygit() return err } - result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) - return self.CheckMergeOrRebaseWithRefreshOptions(result, - types.RefreshOptions{ - Mode: types.ASYNC, - CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess), + + runAction := func() error { + result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) + return self.CheckMergeOrRebaseWithRefreshOptions(result, + types.RefreshOptions{ + Mode: types.ASYNC, + CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess), + }) + } + + if showWaitingStatus { + return self.c.WithWaitingStatus(status.Title(self.c.Tr), func(gocui.Task) error { + return runAction() }) + } + return runAction() } // commitSelectionAfterMerge maps whether a merge/rebase/pull created a new @@ -191,9 +213,9 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result er if result == nil { return nil } else if strings.Contains(result.Error(), "No changes - did you forget to use") { - return self.genericMergeCommand(REBASE_OPTION_SKIP) + return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false) } else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") { - return self.genericMergeCommand(REBASE_OPTION_SKIP) + return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false) } else if strings.Contains(result.Error(), "No rebase in progress?") { // assume in this case that we're already done return nil From 539ede2e1bb47ae466ad6ce2cc4bfa0b1dc2f352 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 11:54:38 +0200 Subject: [PATCH 194/384] Show a waiting status while merging a branch The regular and squash merges from the merge menu ran inline on the UI thread, freezing it with no spinner while git worked. Run them on a worker with a waiting status, like the rebase entry points already do. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/merge_and_rebase_helper.go | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 6badaf2b4..b00a4d41b 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -586,36 +586,42 @@ func (self *MergeAndRebaseHelper) MergeRefIntoCheckedOutBranch(refName string) e func (self *MergeAndRebaseHelper) RegularMerge(refName string, variant git_commands.MergeVariant) func() error { return func() error { self.c.LogAction(self.c.Tr.Actions.Merge) - err := self.c.Git().Branch.Merge(refName, variant) - return self.CheckMergeOrRebaseAndSelectHeadCommit(err) + return self.c.WithWaitingStatus(self.c.Tr.MergingStatus, func(gocui.Task) error { + err := self.c.Git().Branch.Merge(refName, variant) + return self.CheckMergeOrRebaseAndSelectHeadCommit(err) + }) } } func (self *MergeAndRebaseHelper) SquashMergeUncommitted(refName string) func() error { return func() error { self.c.LogAction(self.c.Tr.Actions.SquashMerge) - err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH) - return self.CheckMergeOrRebase(err) + return self.c.WithWaitingStatus(self.c.Tr.MergingStatus, func(gocui.Task) error { + err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH) + return self.CheckMergeOrRebase(err) + }) } } func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranchName string) func() error { return func() error { self.c.LogAction(self.c.Tr.Actions.SquashMerge) - err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH) - if err = self.CheckMergeOrRebase(err); err != nil { - return err - } - message := utils.ResolvePlaceholderString(self.c.UserConfig().Git.Merging.SquashMergeMessage, map[string]string{ - "selectedRef": refName, - "currentBranch": checkedOutBranchName, + return self.c.WithWaitingStatus(self.c.Tr.MergingStatus, func(gocui.Task) error { + err := self.c.Git().Branch.Merge(refName, git_commands.MERGE_VARIANT_SQUASH) + if err = self.CheckMergeOrRebase(err); err != nil { + return err + } + message := utils.ResolvePlaceholderString(self.c.UserConfig().Git.Merging.SquashMergeMessage, map[string]string{ + "selectedRef": refName, + "currentBranch": checkedOutBranchName, + }) + err = self.c.Git().Commit.CommitCmdObj(message, "", false).Run() + if err != nil { + return err + } + self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + return nil }) - err = self.c.Git().Commit.CommitCmdObj(message, "", false).Run() - if err != nil { - return err - } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) - return nil } } From 2bae29c6fd32b3477cb6ca82d031fe2c23b1a377 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 11:55:12 +0200 Subject: [PATCH 195/384] Show a waiting status when starting an interactive rebase onto a ref The interactive-rebase item in the rebase-onto-ref menu ran inline on the UI thread with no spinner, unlike its two siblings in the same menu (simple rebase and rebase onto base branch), which already run on a worker with a waiting status. Make it consistent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/merge_and_rebase_helper.go | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index b00a4d41b..4b25d1a62 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -408,21 +408,23 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { Tooltip: self.c.Tr.InteractiveRebaseTooltip, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RebaseBranch) - baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() - var err error - if baseCommit != "" { - err = self.c.Git().Rebase.EditRebaseFromBaseCommit(ref, baseCommit) - } else { - err = self.c.Git().Rebase.EditRebase(ref) - } - if err = self.CheckMergeOrRebase(err); err != nil { - return err - } - if err = self.ResetMarkedBaseCommit(); err != nil { - return err - } - self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) - return nil + return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error { + baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() + var err error + if baseCommit != "" { + err = self.c.Git().Rebase.EditRebaseFromBaseCommit(ref, baseCommit) + } else { + err = self.c.Git().Rebase.EditRebase(ref) + } + if err = self.CheckMergeOrRebase(err); err != nil { + return err + } + if err = self.ResetMarkedBaseCommit(); err != nil { + return err + } + self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + return nil + }) }, }, { From ccf49c8112407c2788aa96469a1aa61f299a5db2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 11:55:28 +0200 Subject: [PATCH 196/384] Show a waiting status when editing a commit Setting a single commit to "edit" ran the interactive rebase inline on the UI thread with no spinner, while its sibling startInteractiveRebaseWithEdit (used when editing multiple commits or quick-starting a rebase) already runs on a worker with a waiting status. Make the direct path match. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/local_commits_controller.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 80f01fc03..9f3acb1d2 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -589,9 +589,11 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start commits := self.c.Model().Commits if !commits[endIdx].IsMerge() { - err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "") - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( - err, types.RefreshOptions{Mode: types.BLOCK_UI}) + return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { + err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "") + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + err, types.RefreshOptions{Mode: types.BLOCK_UI}) + }) } return self.startInteractiveRebaseWithEdit(selectedCommits) From 6681ba7eda2694d107727f5279b7b55cd3f7dd43 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 11:56:07 +0200 Subject: [PATCH 197/384] Show a waiting status while resetting to a ref Resetting to a commit/branch/tag from the reset menu ran inline on the UI thread with no spinner; a hard reset to a distant commit can take a while and blocks the UI meanwhile. Run it on a worker with a waiting status. The undo/redo callers of ResetToRef already wrap it this way. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refs_helper.go | 4 +++- pkg/i18n/english.go | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 99e9f47ec..b90b9150b 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -288,7 +288,9 @@ func (self *RefsHelper) CreateGitResetMenu(name string, ref string) error { Prompt: self.c.Tr.ResetHardConfirmation, HandleConfirm: func() error { self.c.LogAction("Reset") - return self.ResetToRef(ref, row.strength, []string{}) + return self.c.WithWaitingStatus(self.c.Tr.ResettingStatus, func(gocui.Task) error { + return self.ResetToRef(ref, row.strength, []string{}) + }) }, }) }, diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 6f18842d5..0ee0a9e86 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -430,6 +430,7 @@ type TranslationSet struct { CommittingStatus string RewordingStatus string RevertingStatus string + ResettingStatus string CreatingFixupCommitStatus string MovingCommitsToNewBranchStatus string CommitFiles string @@ -1582,6 +1583,7 @@ func EnglishTranslationSet() *TranslationSet { CommittingStatus: "Committing", RewordingStatus: "Rewording", RevertingStatus: "Reverting", + ResettingStatus: "Resetting", CreatingFixupCommitStatus: "Creating fixup commit", MovingCommitsToNewBranchStatus: "Moving commits to new branch", CommitFiles: "Commit files", From 775ebc9e44828e7672590df8e04b033c20fbc201 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 17:16:45 +0200 Subject: [PATCH 198/384] Re-render to clear an inline status when its operation finishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operations that show an inline status ("Pushing", "Fast-forwarding", "Fetching", …) removed it by relying on the async refresh they trigger to redraw the view after the item operation had been cleared. That ordering was never guaranteed: the item operation is cleared on the worker once the operation's function returns, while the refresh redraws the item from the UI thread whenever its (asynchronous) git work happens to finish. If the refresh redrew before the clear, the status was left on screen with no later redraw to remove it, so the branch (or tag/remote) stayed stuck showing e.g. "Pushing" indefinitely even though the operation had completed. This is timing-dependent, which is why it surfaced as rare, hard-to-reproduce reports and as flaky CI failures. Fix it by re-rendering in stop() right after clearing the operation, and by making these refreshes synchronous rather than async. Because a synchronous refresh has already updated the model and queued its own redraw by the time stop() runs, and UI-thread callbacks run in order, the redraw we queue here runs last and draws the up-to-date model with the status removed. An async refresh couldn't give that guarantee: its model update might not have landed yet, so the redraw could briefly flash the pre-operation status. Pull refreshes through the shared CheckMergeOrRebaseAndSelectHeadCommit, so that helper becomes synchronous too; its only other caller, RegularMerge, thereby also refreshes synchronously, which is fine: a synchronous on-worker refresh is what we want anyway. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/branches_controller.go | 4 +-- .../helpers/inline_status_helper.go | 28 ++++++++----------- .../helpers/merge_and_rebase_helper.go | 2 +- pkg/gui/controllers/remotes_controller.go | 2 +- pkg/gui/controllers/sync_controller.go | 2 +- pkg/gui/controllers/tags_controller.go | 4 +-- 6 files changed, 19 insertions(+), 23 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 27bef4b66..45f98e9c5 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -734,7 +734,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { WorktreePath: worktreePath, }, ) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) return err } @@ -743,7 +743,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { err := self.c.Git().Sync.FastForward( task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch, ) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) + self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES}}) return err }) } diff --git a/pkg/gui/controllers/helpers/inline_status_helper.go b/pkg/gui/controllers/helpers/inline_status_helper.go index 0902c5bf2..f2ca7ec17 100644 --- a/pkg/gui/controllers/helpers/inline_status_helper.go +++ b/pkg/gui/controllers/helpers/inline_status_helper.go @@ -138,22 +138,18 @@ func (self *InlineStatusHelper) stop(opts InlineStatusOpts) { self.c.State().ClearItemOperation(opts.Item) - // When recording a demo we need to re-render the context again here to - // remove the inline status. In normal usage we don't want to do this - // because in the case of pushing a branch this would first reveal the ↑3↓7 - // status from before the push for a brief moment, to be replaced by a green - // checkmark a moment later when the async refresh is done. This looks - // jarring, so normally we rely on the async refresh to redraw with the - // status removed. (In some rare cases, where there's no refresh at all, we - // need to redraw manually in the controller; see TagsController.push() for - // an example.) - // - // In demos, however, we turn all async refreshes into sync ones, because - // this looks better in demos. In this case the refresh happens while the - // status is still set, so we need to render again after removing it. - if self.c.InDemo() { - self.renderContext(opts.ContextKey) - } + // Re-render the context to remove the inline status now that the operation + // finished. Any refresh it triggered must be synchronous, not async: by the + // time we get here a synchronous refresh has already updated the model and + // queued its own re-render, and since UI-thread callbacks run in order, the + // render we queue here runs after it and draws the up-to-date model without + // the inline status. An async refresh might not have updated the model yet, + // so this render could briefly show the stale, pre-operation model: when + // pushing a branch, for example, it would flash the old ↑3↓7 ahead/behind + // counts for a moment before the refresh replaced them with a green + // checkmark. (Operations that don't refresh at all are fine too: there's + // nothing stale to show, so this just drops the status.) + self.renderContext(opts.ContextKey) } func (self *InlineStatusHelper) renderContext(contextKey types.ContextKey) { diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 4b25d1a62..847b89893 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -233,7 +233,7 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error { // before the refresh. func (self *MergeAndRebaseHelper) CheckMergeOrRebaseAndSelectHeadCommit(result error) error { return self.CheckMergeOrRebaseWithRefreshOptions(result, - types.RefreshOptions{Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(result == nil)}) + types.RefreshOptions{Mode: types.SYNC, CommitSelection: commitSelectionAfterMerge(result == nil)}) } func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error { diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index dd5a171e4..f7b16e228 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -367,7 +367,7 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam } refreshOptions := types.RefreshOptions{ Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}, - Mode: types.ASYNC, + Mode: types.SYNC, } if branchName != "" { err = self.c.Git().Branch.New(branchName, remote.Name+"/"+branchName) diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index f1b794e97..0f754eb49 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -229,7 +229,7 @@ func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts) } return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) return nil }) } diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go index 879a73628..3cb5e0450 100644 --- a/pkg/gui/controllers/tags_controller.go +++ b/pkg/gui/controllers/tags_controller.go @@ -210,7 +210,7 @@ func (self *TagsController) remoteDelete(tag *models.Tag) error { return err } self.c.Toast(self.c.Tr.RemoteTagDeletedMessage) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return nil }) }, @@ -264,7 +264,7 @@ func (self *TagsController) localAndRemoteDelete(tag *models.Tag) error { if err := self.c.Git().Tag.LocalDelete(tag.Name); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return nil }) }, From 78dd678ce518910dcf486a8cb2396a389cc60228 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 17:20:38 +0200 Subject: [PATCH 199/384] Drop the now-redundant manual re-render in tag push Pushing a tag triggers no refresh, so it used to redraw the tags view by hand to remove the "Pushing" inline status. WithInlineStatus now always re-renders after clearing the operation, so this is redundant. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/tags_controller.go | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go index 3cb5e0450..fe2c4e80f 100644 --- a/pkg/gui/controllers/tags_controller.go +++ b/pkg/gui/controllers/tags_controller.go @@ -332,15 +332,7 @@ func (self *TagsController) push(tag *models.Tag) error { HandleConfirm: func(response string) error { return self.c.WithInlineStatus(tag, types.ItemOperationPushing, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.PushTag) - err := self.c.Git().Tag.Push(task, response, tag.Name) - - // Render again to remove the inline status: - self.c.OnUIThread(func() error { - self.c.Contexts().Tags.HandleRender() - return nil - }) - - return err + return self.c.Git().Tag.Push(task, response, tag.Name) }) }, }) From dd1576138a248b8d643921d7f6d6d5ad7c3fa238 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 22:44:07 +0200 Subject: [PATCH 200/384] AGENTS.md additions --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 46ad3e506..6aa8f7017 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,9 @@ while still being meaningful and self-contained. commits that leave the tree broken and rely on a follow-up to fix it. - **Every commit must be `gofumpt`-formatted.** Run `just format` before committing. +- **Every commit must be lint-clean.** Run `just lint` before committing — + don't introduce a lint warning in one commit and rely on a later commit + (or the user) to clean it up. - **Commit messages explain _why_, not _what_.** The diff already shows what changed; the message should capture the motivation, the constraint, or the bug being fixed. If the reason is obvious from a one-line subject, no body @@ -157,6 +160,16 @@ genuine forks — the ones where a reasonable person might pick differently, or where you'd be trading away something the plan assumed (scope, UX, performance, reload behavior, …). When in doubt, surface it. +This applies with equal force to unforeseen _discoveries_, not just to +decisions you set out to make. If you find something the plan didn't account +for — a latent bug, a race, a wrong assumption, a case that turns out +unhandled — stop and raise it before designing or writing a fix, even when the +fix seems obvious and even when it's "just correctness." Finding the problem is +itself the fork: whether to fix it here or in a separate change, how generally +to solve it, and whether it reshapes the current work are all calls for me to +make with you. Don't quietly fold a self-directed fix for a newly-found problem +into the branch and let me discover it in the diff. + ## Prefer the cleaner design over the smaller diff When a task could be implemented either by tacking onto existing code or by From 5350b6c37b20258f9024be038f5b3c488534c606 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 22:09:25 +0200 Subject: [PATCH 201/384] Remove unused CommitFileTreeViewModel.RWMutex --- pkg/gui/filetree/commit_file_tree_view_model.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go index c2e7e74e4..e33316788 100644 --- a/pkg/gui/filetree/commit_file_tree_view_model.go +++ b/pkg/gui/filetree/commit_file_tree_view_model.go @@ -2,7 +2,6 @@ package filetree import ( "strings" - "sync" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" @@ -26,7 +25,6 @@ type ICommitFileTreeViewModel interface { } type CommitFileTreeViewModel struct { - sync.RWMutex types.IListCursor ICommitFileTree From b54d4c369bb64415b79e89ad0c5c275a971fd8dc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 21:45:02 +0200 Subject: [PATCH 202/384] Remove unused IsRefreshingFiles state GetIsRefreshingFiles() is never called anywhere in the codebase, so the flag serves no purpose. Remove it from Gui, StateAccessor, and IStateAccessor, and drop the two SetIsRefreshingFiles calls in refreshFilesAndSubmodules that maintained it. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 6 +----- pkg/gui/gui.go | 10 ---------- pkg/gui/types/common.go | 2 -- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 3dbd19674..866ec90c3 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -735,11 +735,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { self.c.Mutexes().RefreshingFilesMutex.Lock() - self.c.State().SetIsRefreshingFiles(true) - defer func() { - self.c.State().SetIsRefreshingFiles(false) - self.c.Mutexes().RefreshingFilesMutex.Unlock() - }() + defer self.c.Mutexes().RefreshingFilesMutex.Unlock() if err := self.refreshStateSubmoduleConfigs(); err != nil { return err diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e23afd124..87a87f7b2 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -111,8 +111,6 @@ type Gui struct { PopupHandler types.IPopupHandler - IsRefreshingFiles bool - // we use this to decide whether we'll return to the original directory that // lazygit was opened in, or if we'll retain the one we're currently in. RetainOriginalDir bool @@ -175,14 +173,6 @@ func (self *StateAccessor) GetPagerConfig() *config.PagerConfig { return self.gui.pagerConfig } -func (self *StateAccessor) GetIsRefreshingFiles() bool { - return self.gui.IsRefreshingFiles -} - -func (self *StateAccessor) SetIsRefreshingFiles(value bool) { - self.gui.IsRefreshingFiles = value -} - func (self *StateAccessor) GetShowExtrasWindow() bool { return self.gui.ShowExtrasWindow } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 7621f8686..ad28972ae 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -377,8 +377,6 @@ type IStateAccessor interface { // tells us whether we're currently updating lazygit GetUpdating() bool SetUpdating(bool) - SetIsRefreshingFiles(bool) - GetIsRefreshingFiles() bool GetShowExtrasWindow() bool SetShowExtrasWindow(bool) GetRetainOriginalDir() bool From 717448f105589033f562e17ac195b1a9e4725110 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 22:33:33 +0200 Subject: [PATCH 203/384] Make RefreshOptions.Then a func() error, queue it via OnUIThread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is preparation for upcoming commits that will bounce refresh-scope model updates (e.g. Model.Files) onto the UI thread by enqueuing the write via OnUIThread instead of applying it directly on the worker goroutine. Once that lands, a Then callback that reads the model must run after that queued write has been processed, not synchronously at wg.Wait() time — at that point the workers have returned, but a bounce they queued may not have been processed yet. Queuing Then via OnUIThread here, ahead of that change, guarantees the right ordering once it lands: a bounce queued earlier in the same refresh is already sitting in the channel by the time wg.Wait() returns, so Then enqueued after it will always be processed after, and see the post-refresh model. The signature change to func() error lets Then propagate errors through gocui's normal error handler (the same path key-handler errors take). Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/bisect_controller.go | 7 +++++-- pkg/gui/controllers/filtering_menu_action.go | 3 ++- pkg/gui/controllers/helpers/mode_helper.go | 3 ++- pkg/gui/controllers/helpers/refresh_helper.go | 8 +++++++- pkg/gui/controllers/local_commits_controller.go | 8 +++----- pkg/gui/types/refresh.go | 2 +- 6 files changed, 20 insertions(+), 11 deletions(-) diff --git a/pkg/gui/controllers/bisect_controller.go b/pkg/gui/controllers/bisect_controller.go index 1066237c1..eb568240b 100644 --- a/pkg/gui/controllers/bisect_controller.go +++ b/pkg/gui/controllers/bisect_controller.go @@ -274,10 +274,11 @@ func (self *BisectController) afterMark(selectCurrent bool, waitToReselect bool) } func (self *BisectController) afterBisectMarkRefresh(selectCurrent bool, waitToReselect bool) error { - selectFn := func() { + selectFn := func() error { if selectCurrent { self.selectCurrentBisectCommit() } + return nil } if waitToReselect { @@ -285,7 +286,9 @@ func (self *BisectController) afterBisectMarkRefresh(selectCurrent bool, waitToR return nil } - selectFn() + if err := selectFn(); err != nil { + return err + } self.c.Helpers().Bisect.PostBisectCommandRefresh() return nil diff --git a/pkg/gui/controllers/filtering_menu_action.go b/pkg/gui/controllers/filtering_menu_action.go index 01a236f7a..7ae26c4ef 100644 --- a/pkg/gui/controllers/filtering_menu_action.go +++ b/pkg/gui/controllers/filtering_menu_action.go @@ -122,9 +122,10 @@ func (self *FilteringMenuAction) setFiltering() error { self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) - self.c.Refresh(types.RefreshOptions{Scope: helpers.ScopesToRefreshWhenFilteringModeChanges(), Then: func() { + self.c.Refresh(types.RefreshOptions{Scope: helpers.ScopesToRefreshWhenFilteringModeChanges(), Then: func() error { self.c.Contexts().LocalCommits.SetSelection(0) self.c.Contexts().LocalCommits.HandleFocus(types.OnFocusOpts{}) + return nil }}) return nil diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index e44d7b01b..4947e42d1 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -191,7 +191,7 @@ func (self *ModeHelper) ClearFiltering() error { self.c.Refresh(types.RefreshOptions{ Scope: ScopesToRefreshWhenFilteringModeChanges(), - Then: func() { + Then: func() error { // Find the commit that was last selected in filtering mode, and select it again after refreshing if !self.c.Contexts().LocalCommits.SelectCommitByHash(selectedCommitHash) { // If we couldn't find it (either because no commit was selected @@ -202,6 +202,7 @@ func (self *ModeHelper) ClearFiltering() error { } self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) + return nil }, }) return nil diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 866ec90c3..2933d9378 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -255,7 +255,13 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { wg.Wait() if options.Then != nil { - options.Then() + // Queue Then via OnUIThread so it runs *after* the refresh-scope + // functions' model-update bounces (which are already queued by + // now), not synchronously here — at this point the workers have + // returned but their bounces haven't been processed yet, so + // invoking Then synchronously would run it on a model that's + // still pre-refresh. + self.c.OnUIThread(options.Then) } } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 9f3acb1d2..5150e8fba 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -616,7 +616,7 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit( err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, - types.RefreshOptions{Mode: types.BLOCK_UI, Then: func() { + types.RefreshOptions{Mode: types.BLOCK_UI, Then: func() error { todos := make([]*models.Commit, 0, len(commitsToEdit)-1) for _, c := range commitsToEdit[:len(commitsToEdit)-1] { // Merge commits can't be set to "edit", so just skip them @@ -625,11 +625,9 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit( } } if len(todos) > 0 { - err := self.updateTodos(todo.Edit, todos) - if err != nil { - self.c.Log.Errorf("error when updating todos: %v", err) - } + return self.updateTodos(todo.Edit, todos) } + return nil }}) }) } diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index 8d9704d55..591aff5f3 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -56,7 +56,7 @@ const ( ) type RefreshOptions struct { - Then func() + Then func() error Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything Mode RefreshMode // one of SYNC (default), ASYNC, and BLOCK_UI From ea83f50dc35f5fac893a8bb641efe4b86995f2ee Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 22:01:39 +0200 Subject: [PATCH 204/384] Move post-FILES-refresh model reads into Then PromptToContinueRebase and WithEnsureCommittableFiles both read Model.Files right after a SYNC FILES refresh. This works today because the model write currently happens synchronously in the worker before Refresh's wg.Wait() returns, but an upcoming commit will bounce that write onto the UI thread instead, at which point wg.Wait() no longer guarantees it's been applied. Move both reads into Then ahead of that change. Then is already queued via OnUIThread (previous commit), so this is a behavior-preserving refactor on its own: the model is fully written by the time Then runs either way, whether that write is still synchronous or gets bounced later. As part of restructuring WithEnsureCommittableFiles, prepareFilesForCommit and syncRefresh are inlined into their single call sites. Co-Authored-By: Claude Sonnet 5 --- .../helpers/merge_and_rebase_helper.go | 41 ++++++++++--------- .../helpers/working_tree_helper.go | 39 +++++++----------- 2 files changed, 36 insertions(+), 44 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 847b89893..c25416a3b 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -309,27 +309,30 @@ func (self *MergeAndRebaseHelper) PromptToContinueRebase() error { // but this is not supported by all terminals or on all platforms. self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}, + Then: func() error { + unstagedFiles := GetUnstagedFilesExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) + if len(unstagedFiles) > 0 { + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.Continue, + Prompt: self.c.Tr.UnstagedFilesAfterConflictsResolved, + HandleConfirm: func() error { + self.c.LogAction(self.c.Tr.Actions.StageAllFiles) + if err := self.c.Git().WorkingTree.StageFiles(unstagedFiles, []string{}); err != nil { + return err + } + + return self.genericMergeCommand(REBASE_OPTION_CONTINUE) + }, + }) + + return nil + } + + return self.genericMergeCommand(REBASE_OPTION_CONTINUE) + }, }) - unstagedFiles := GetUnstagedFilesExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) - if len(unstagedFiles) > 0 { - self.c.Confirm(types.ConfirmOpts{ - Title: self.c.Tr.Continue, - Prompt: self.c.Tr.UnstagedFilesAfterConflictsResolved, - HandleConfirm: func() error { - self.c.LogAction(self.c.Tr.Actions.StageAllFiles) - if err := self.c.Git().WorkingTree.StageFiles(unstagedFiles, []string{}); err != nil { - return err - } - - return self.genericMergeCommand(REBASE_OPTION_CONTINUE) - }, - }) - - return nil - } - - return self.genericMergeCommand(REBASE_OPTION_CONTINUE) + return nil }, }) diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 7e070ba31..7e321854b 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -222,15 +222,24 @@ func (self *WorkingTreeHelper) HandleCommitPress() error { } func (self *WorkingTreeHelper) WithEnsureCommittableFiles(handler func() error) error { - if err := self.prepareFilesForCommit(); err != nil { - return err - } - if len(self.c.Model().Files) == 0 { return errors.New(self.c.Tr.NoFilesStagedTitle) } if !self.AnyStagedFiles() { + if self.c.UserConfig().Gui.SkipNoStagedFilesWarning { + self.c.LogAction(self.c.Tr.Actions.StageAllFiles) + if err := self.c.Git().WorkingTree.StageAll(false); err != nil { + return err + } + self.c.Refresh(types.RefreshOptions{ + Mode: types.SYNC, + Scope: []types.RefreshableView{types.FILES}, + Then: handler, + }) + return nil + } + return self.promptToStageAllAndRetry(handler) } @@ -246,7 +255,7 @@ func (self *WorkingTreeHelper) promptToStageAllAndRetry(retry func() error) erro if err := self.c.Git().WorkingTree.StageAll(false); err != nil { return err } - self.syncRefresh() + self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) return retry() }, @@ -255,26 +264,6 @@ func (self *WorkingTreeHelper) promptToStageAllAndRetry(retry func() error) erro return nil } -// for when you need to refetch files before continuing an action. Runs synchronously. -func (self *WorkingTreeHelper) syncRefresh() { - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) -} - -func (self *WorkingTreeHelper) prepareFilesForCommit() error { - noStagedFiles := !self.AnyStagedFiles() - if noStagedFiles && self.c.UserConfig().Gui.SkipNoStagedFilesWarning { - self.c.LogAction(self.c.Tr.Actions.StageAllFiles) - err := self.c.Git().WorkingTree.StageAll(false) - if err != nil { - return err - } - - self.syncRefresh() - } - - return nil -} - func (self *WorkingTreeHelper) commitPrefixConfigsForRepo() []config.CommitPrefixConfig { cfg, ok := self.c.UserConfig().Git.CommitPrefixes[self.c.Git().RepoPaths.RepoName()] if ok { From be897ce55e5466eec66e703d18abd2b213376188 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:01:00 +0200 Subject: [PATCH 205/384] Bounce FILES model updates onto the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshStateFiles now does its git work on the worker and enqueues a single OnUIThread closure that writes Model.Submodules, Model.Files, and the FileTreeViewModel state together, instead of writing them directly from the worker goroutine. refreshStateSubmoduleConfigs becomes a pure getter (returns the configs; no model write) so the result can be threaded into that same bounce. The STAGING handler wraps RefreshStagingPanel in OnUIThread after fileWg.Wait() so it sees the post-bounce file model rather than the stale pre-refresh one — without this it would race the files bounce queued just above it. Bouncing the write opens a hazard the old synchronous write didn't have: if the user switches repos while this refresh is in flight, the queued closure would fire after resetState has replaced the model with a fresh one for the new repo, silently overwriting it with the previous repo's files. Guard against this with a repo generation: resetState bumps a counter on every switch, refreshStateFiles captures it before its git work, and onUIThreadUnlessRepoChanged drops the bounce if the generation has moved on. This one helper is the general mechanism the remaining scopes' bounces will use too; the same guard covers the rebase-continue prompt, which reads Model.Files right after. A generation counter, not a comparison of the *Model pointer: switching away from and back to a repo reuses that repo's cached state (the same Model pointer), which a pointer comparison would wrongly accept even though the in-flight data is stale. PromptToContinueRebase's Then callback (previous commit) now gets an explanatory comment, since this is the commit that makes it necessary. The explicit locking around these writes (RefreshingFilesMutex in refreshFilesAndSubmodules, FileTreeViewModel.RWMutex around the write in refreshStateFiles) is left in place for now even though it's becoming redundant, to keep this commit focused on the bounce itself; it's removed next. Co-Authored-By: Claude Sonnet 5 --- .../helpers/merge_and_rebase_helper.go | 4 + pkg/gui/controllers/helpers/refresh_helper.go | 84 +++++++++++-------- pkg/gui/gui.go | 15 ++++ pkg/gui/types/common.go | 7 ++ 4 files changed, 77 insertions(+), 33 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index c25416a3b..51488a922 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -307,6 +307,10 @@ func (self *MergeAndRebaseHelper) PromptToContinueRebase() error { // Need to refresh the files to be really sure if this is the case. // We would otherwise be relying on lazygit's auto-refresh on focus, // but this is not supported by all terminals or on all platforms. + // + // The model.Files update is bounced onto the UI thread, so we have + // to read it in Then; reading it inline here would see the previous + // model. self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}, Then: func() error { diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 2933d9378..2f2210f49 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -238,7 +238,14 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if scopeSet.Includes(types.STAGING) { refresh("staging", func() { fileWg.Wait() - self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) + // Bounce onto the UI thread so this runs after the files + // scope's model-update bounce — RefreshStagingPanel reads + // Model.Files (via Files.GetSelected) and would otherwise + // see the pre-refresh model. + self.c.OnUIThread(func() error { + self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) + return nil + }) }) } @@ -667,15 +674,8 @@ func (self *RefreshHelper) refreshTags() error { return nil } -func (self *RefreshHelper) refreshStateSubmoduleConfigs() error { - configs, err := self.c.Git().Submodule.GetConfigs(nil) - if err != nil { - return err - } - - self.c.Model().Submodules = configs - - return nil +func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleConfig, error) { + return self.c.Git().Submodule.GetConfigs(nil) } // self.refreshStatus is called at the end of this because that's when we can @@ -743,25 +743,40 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { self.c.Mutexes().RefreshingFilesMutex.Lock() defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - if err := self.refreshStateSubmoduleConfigs(); err != nil { + configs, err := self.refreshStateSubmoduleConfigs() + if err != nil { return err } - if err := self.refreshStateFiles(background); err != nil { + if err := self.refreshStateFiles(background, configs); err != nil { return err } - self.c.OnUIThread(func() error { - self.refreshView(self.c.Contexts().Submodules) - self.refreshView(self.c.Contexts().Files) - return nil - }) + self.refreshView(self.c.Contexts().Submodules) + self.refreshView(self.c.Contexts().Files) return nil } -func (self *RefreshHelper) refreshStateFiles(background bool) error { +// onUIThreadUnlessRepoChanged bounces a refresh's model/view update onto the UI +// thread, but drops it if the repo was switched while the refresh was in flight. +// Refresh workers do their git work off the UI thread and enqueue their model +// writes here; a repo switch (which replaces the whole model and context tree) +// bumps the generation, so a write captured under the old generation must not +// clobber the new repo's state. Callers capture the generation with +// State().GetRepoGeneration() before doing their git work and pass it in. +func (self *RefreshHelper) onUIThreadUnlessRepoChanged(generation int, f func() error) { + self.c.OnUIThread(func() error { + if self.c.State().GetRepoGeneration() != generation { + return nil + } + return f() + }) +} + +func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs []*models.SubmoduleConfig) error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel + generation := self.c.State().GetRepoGeneration() prevConflictFileCount := 0 if self.c.UserConfig().Git.AutoStageResolvedConflicts { @@ -822,7 +837,9 @@ func (self *RefreshHelper) refreshStateFiles(background bool) error { // (e.g. in the user's editor). Offer to continue it. We only do this // for operations we started ourselves; prompting for one that was // started outside lazygit (e.g. by a coding agent) would be confusing. - self.c.OnUIThread(func() error { return self.mergeAndRebaseHelper.PromptToContinueRebase() }) + self.onUIThreadUnlessRepoChanged(generation, func() error { + return self.mergeAndRebaseHelper.PromptToContinueRebase() + }) } } else { // Either there's no operation in progress any more, or new conflicts have @@ -835,22 +852,23 @@ func (self *RefreshHelper) refreshStateFiles(background bool) error { }) } - fileTreeViewModel.RWMutex.Lock() - - // only taking over the filter if it hasn't already been set by the user. - if conflictFileCount > 0 && prevConflictFileCount == 0 { - if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { - fileTreeViewModel.SetStatusFilter(filetree.DisplayConflicted) - self.c.Contexts().Files.GetView().Subtitle = self.c.Tr.FilterLabelConflictingFiles + self.onUIThreadUnlessRepoChanged(generation, func() error { + // only taking over the filter if it hasn't already been set by the user. + if conflictFileCount > 0 && prevConflictFileCount == 0 { + if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { + 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) + self.c.Contexts().Files.GetView().Subtitle = "" } - } else if conflictFileCount == 0 && fileTreeViewModel.GetStatusFilter() == filetree.DisplayConflicted { - fileTreeViewModel.SetStatusFilter(filetree.DisplayAll) - self.c.Contexts().Files.GetView().Subtitle = "" - } - self.c.Model().Files = files - fileTreeViewModel.SetTree() - fileTreeViewModel.RWMutex.Unlock() + self.c.Model().Submodules = submoduleConfigs + self.c.Model().Files = files + fileTreeViewModel.SetTree() + return nil + }) return nil } diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 87a87f7b2..834cf1e2d 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -12,6 +12,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "github.com/jesseduffield/lazycore/pkg/boxlayout" @@ -111,6 +112,11 @@ type Gui struct { PopupHandler types.IPopupHandler + // Bumped every time we switch to a different repository (in resetState). + // Used to drop refresh results that were computed for a repo we've since + // navigated away from. See RefreshHelper.onUIThreadUnlessRepoChanged. + repoGeneration atomic.Int32 + // we use this to decide whether we'll return to the original directory that // lazygit was opened in, or if we'll retain the one we're currently in. RetainOriginalDir bool @@ -169,6 +175,10 @@ func (self *StateAccessor) GetRepoState() types.IRepoStateAccessor { return self.gui.State } +func (self *StateAccessor) GetRepoGeneration() int { + return int(self.gui.repoGeneration.Load()) +} + func (self *StateAccessor) GetPagerConfig() *config.PagerConfig { return self.gui.pagerConfig } @@ -575,6 +585,11 @@ func (gui *Gui) checkForChangedConfigsThatDontAutoReload(oldConfig *config.UserC // resetState reuses the repo state from our repo state map, if the repo was // open before; otherwise it creates a new one. func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { + // Bump the repo generation so that any refresh still in flight for the + // previous repo drops its model update instead of applying it here (see + // RefreshHelper.onUIThreadUnlessRepoChanged). + gui.repoGeneration.Add(1) + // Un-highlight the current view if there is one. The reason we do this is // that the repo we are switching to might have a different view focused, // and would then show an inactive highlight for the previous view. diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index ad28972ae..7fc75aee0 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -384,6 +384,13 @@ type IStateAccessor interface { GetItemOperation(item HasUrn) ItemOperation SetItemOperation(item HasUrn, operation ItemOperation) ClearItemOperation(item HasUrn) + + // A counter that is bumped every time we switch to a different repository + // (see Gui.resetState). Refresh workers capture it before doing their git + // work and pass it to onUIThreadUnlessRepoChanged, so that a model update + // computed for one repo can be dropped rather than applied to another if the + // user switched repos while the refresh was in flight. + GetRepoGeneration() int } type IRepoStateAccessor interface { From 2c139b6ac173842f88d03392017a3eecd5f05cc5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:01:45 +0200 Subject: [PATCH 206/384] Remove RefreshingFilesMutex/FileTreeViewModel.RWMutex, dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileTreeViewModel.RWMutex is removed along with the withFileTreeViewModelMutex wrapper in FilesController that RLocked it: every writer (the bounce closure, previous commit) and every reader (key handlers, disabled-reason callbacks) now runs on the UI thread, so the mutex is redundant. RefreshingFilesMutex is removed entirely, including its last use in repos_helper's DispatchSwitchTo. That use predates the bounce and was never about FilesController's optimistic-rendering concern; it serialized a repo switch's onNewRepo() against an in-flight FILES refresh for the repo being switched away from, so that a slow refresh from the old repo couldn't write into the freshly-reset model for the new one. Bouncing the write already broke that guarantee on its own terms — the mutex's critical section never covered the bounced closure's actual execution, only the (now-removed) code that enqueued it — so by this point it was only still locked here without protecting anything real; the previous commit's repo-generation guard is what now actually closes that race, making this lock fully redundant rather than just relocated. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/files_controller.go | 25 +++---------------- pkg/gui/controllers/helpers/refresh_helper.go | 3 --- pkg/gui/controllers/helpers/repos_helper.go | 3 --- pkg/gui/filetree/file_tree_view_model.go | 2 -- pkg/gui/types/common.go | 1 - 5 files changed, 4 insertions(+), 30 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index a63c6a15a..d7720ee34 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -44,7 +44,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types { Keys: opts.GetKeys(opts.Config.Universal.Select), Handler: self.withItems(self.press), - GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canStageSelection))), + GetDisabledReason: self.require(self.itemsSelected(self.canStageSelection)), Description: self.c.Tr.Stage, Tooltip: self.c.Tr.StageTooltip, DisplayOnScreen: true, @@ -91,7 +91,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types { Keys: opts.GetKeys(opts.Config.Universal.Edit), Handler: self.withItems(self.edit), - GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canEditFiles))), + GetDisabledReason: self.require(self.itemsSelected(self.canEditFiles)), Description: self.c.Tr.Edit, Tooltip: self.c.Tr.EditFileTooltip, DisplayOnScreen: true, @@ -145,7 +145,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types { Keys: opts.GetKeys(opts.Config.Universal.Remove), Handler: self.withItems(self.remove), - GetDisabledReason: self.withFileTreeViewModelMutex(self.require(self.itemsSelected(self.canRemove))), + GetDisabledReason: self.require(self.itemsSelected(self.canRemove)), Description: self.c.Tr.Discard, Tooltip: self.c.Tr.DiscardFileChangesTooltip, OpensMenu: true, @@ -182,7 +182,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types Handler: self.withItems(self.openMergeConflictMenu), Description: self.c.Tr.ViewMergeConflictOptions, Tooltip: self.c.Tr.ViewMergeConflictOptionsTooltip, - GetDisabledReason: self.require(self.withFileTreeViewModelMutex(self.itemsSelected(self.canOpenMergeConflictMenu))), + GetDisabledReason: self.require(self.itemsSelected(self.canOpenMergeConflictMenu)), OpensMenu: true, DisplayOnScreen: true, }, @@ -209,15 +209,6 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types } } -func (self *FilesController) withFileTreeViewModelMutex(callback func() *types.DisabledReason) func() *types.DisabledReason { - return func() *types.DisabledReason { - self.c.Contexts().Files.FileTreeViewModel.RWMutex.RLock() - defer self.c.Contexts().Files.FileTreeViewModel.RWMutex.RUnlock() - - return callback() - } -} - func (self *FilesController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { @@ -574,11 +565,6 @@ func (self *FilesController) toggleStaged( } func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) error { - // Obtaining this lock because optimistic rendering requires us to mutate - // the files in our model. - self.c.Mutexes().RefreshingFilesMutex.Lock() - defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - // When filtering, expand directory nodes to individual visible file paths // so that only filtered files are staged/unstaged. toPaths := func(nodes []*filetree.FileNode) []string { @@ -942,9 +928,6 @@ func (self *FilesController) toggleStagedAll() error { } func (self *FilesController) toggleStagedAllWithLock() error { - self.c.Mutexes().RefreshingFilesMutex.Lock() - defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - root := self.context().FileTreeViewModel.GetRoot() stage := func(unstagedNodes []*filetree.FileNode) error { diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 2f2210f49..875f55a30 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -740,9 +740,6 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele } func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { - self.c.Mutexes().RefreshingFilesMutex.Lock() - defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - configs, err := self.refreshStateSubmoduleConfigs() if err != nil { return err diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index bde1c47c6..94c9e4368 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -177,9 +177,6 @@ func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey self.c.Log.Errorf("error recording current directory: %v", err) } - self.c.Mutexes().RefreshingFilesMutex.Lock() - defer self.c.Mutexes().RefreshingFilesMutex.Unlock() - if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil { return err } diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index 741550c19..aabbbce7f 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -2,7 +2,6 @@ package filetree import ( "strings" - "sync" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/common" @@ -22,7 +21,6 @@ type IFileTreeViewModel interface { // which item is selected. It also contains logic for repositioning that cursor // after the files are refreshed type FileTreeViewModel struct { - sync.RWMutex types.IListCursor IFileTree searchHistory *utils.HistoryBuffer[string] diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 7fc75aee0..2b7dc2312 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -338,7 +338,6 @@ type Model struct { } type Mutexes struct { - RefreshingFilesMutex deadlock.Mutex RefreshingBranchesMutex deadlock.Mutex RefreshingStatusMutex deadlock.Mutex RefreshingPullRequestsMutex deadlock.Mutex From 6203a4e41119d04c738a5f2aa8fca7e2de71904d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 2 Jul 2026 22:07:16 +0200 Subject: [PATCH 207/384] Move post-COMMIT_FILES-refresh work into Then SwitchToDiffFilesController.enter calls SelectPath and Context.Push right after a (SYNC, by default) COMMIT_FILES refresh. This works today because the model write currently happens synchronously in the worker before Refresh's wg.Wait() returns, but an upcoming commit will bounce that write onto the UI thread instead, at which point wg.Wait() no longer guarantees it's been applied, and SelectPath would operate on a stale tree. Move both calls into Then ahead of that change, for the same reason as the earlier FILES-scope commit: Then is already queued via OnUIThread, so this is behavior-preserving on its own. Co-Authored-By: Claude Sonnet 5 --- .../switch_to_diff_files_controller.go | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index c2ff4d674..afdf92c80 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -90,18 +90,19 @@ func (self *SwitchToDiffFilesController) enter() error { self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.COMMIT_FILES}, + Then: func() error { + if filterPath := self.c.Modes().Filtering.GetPath(); filterPath != "" { + path, err := filepath.Rel(self.c.Git().RepoPaths.RepoPath(), filterPath) + if err != nil { + path = filterPath + } + commitFilesContext.CommitFileTreeViewModel.SelectPath( + filepath.ToSlash(path), self.c.UserConfig().Gui.ShowRootItemInFileTree) + } + self.c.Context().Push(commitFilesContext, types.OnFocusOpts{}) + return nil + }, }) - - if filterPath := self.c.Modes().Filtering.GetPath(); filterPath != "" { - path, err := filepath.Rel(self.c.Git().RepoPaths.RepoPath(), filterPath) - if err != nil { - path = filterPath - } - commitFilesContext.CommitFileTreeViewModel.SelectPath( - filepath.ToSlash(path), self.c.UserConfig().Gui.ShowRootItemInFileTree) - } - - self.c.Context().Push(commitFilesContext, types.OnFocusOpts{}) return nil } From b203ec57acdc8c1bd930c23c459f587626dcfee3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:04:44 +0200 Subject: [PATCH 208/384] Bounce COMMIT_FILES model updates onto the UI thread refreshCommitFilesContext now enqueues the Model.CommitFiles write and CommitFileTreeViewModel.SetTree() call via OnUIThread, instead of running them directly on the worker goroutine that drives async refreshes. This is what makes moving SwitchToDiffFilesController's post-refresh work into Then (previous commit) actually necessary, rather than just future-proofing. Same repo-switch hazard as the FILES bounce, closed the same way: it captures the repo generation before the git work and bounces through onUIThreadUnlessRepoChanged, so the write is dropped if the user switched repos while it was in flight. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 875f55a30..2fda422bc 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -635,14 +635,17 @@ func (self *RefreshHelper) RefreshAuthors(commits []*models.Commit) { func (self *RefreshHelper) refreshCommitFilesContext() error { from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) + generation := self.c.State().GetRepoGeneration() files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(from, to, reverse) if err != nil { return err } - self.c.Model().CommitFiles = files - self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree() - + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().CommitFiles = files + self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree() + return nil + }) self.refreshView(self.c.Contexts().CommitFiles) return nil } From 21f1dc336670da1c678369a1aa09fe867b2ffb48 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:21:31 +0200 Subject: [PATCH 209/384] Bounce TAGS model updates onto the UI thread refreshTags now captures the repo generation, loads the tags on the worker, and writes Model.Tags in an onUIThreadUnlessRepoChanged bounce rather than directly from the worker goroutine. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 2fda422bc..e610fef13 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -666,12 +666,17 @@ func (self *RefreshHelper) refreshRebaseCommits() error { } func (self *RefreshHelper) refreshTags() error { + generation := self.c.State().GetRepoGeneration() + tags, err := self.c.Git().Loaders.TagLoader.GetTags() if err != nil { return err } - self.c.Model().Tags = tags + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().Tags = tags + return nil + }) self.refreshView(self.c.Contexts().Tags) return nil From ff7ecf2d2a427f030da7b80e6e3307461ea975be Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:21:51 +0200 Subject: [PATCH 210/384] Bounce STASH model updates onto the UI thread refreshStashEntries now loads the stash entries on the worker and writes Model.StashEntries in an onUIThreadUnlessRepoChanged bounce. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index e610fef13..2076e45ee 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -979,9 +979,16 @@ func (self *RefreshHelper) refreshWorktrees() { } func (self *RefreshHelper) refreshStashEntries() { - self.c.Model().StashEntries = self.c.Git().Loaders.StashLoader. + generation := self.c.State().GetRepoGeneration() + + stashEntries := self.c.Git().Loaders.StashLoader. GetStashEntries(self.c.Modes().Filtering.GetPath()) + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().StashEntries = stashEntries + return nil + }) + self.refreshView(self.c.Contexts().Stash) } From d6f6d0ceba534cd904773b26aaba6c8e5d40fec4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:23:22 +0200 Subject: [PATCH 211/384] Bounce WORKTREES model updates onto the UI thread refreshWorktrees now writes Model.Worktrees in an onUIThreadUnlessRepoChanged bounce. loadWorktrees becomes a pure loader that returns the worktrees instead of writing them, since it's shared with refreshBranches; refreshWorktrees bounces the result, and the branches call site writes it directly for now (that write moves into refreshBranches's own bounce when that scope is migrated). Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 2076e45ee..d1c1091da 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -719,7 +719,10 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele self.rebuildPullRequestsMap() if refreshWorktrees { - self.loadWorktrees() + // TODO: this synchronous worker write goes away when refreshBranches is + // itself migrated to bouncing; for now it matches the rest of this + // not-yet-bounced function. + self.c.Model().Worktrees = self.loadWorktrees() self.refreshView(self.c.Contexts().Worktrees) } @@ -959,18 +962,24 @@ func (self *RefreshHelper) refreshRemotes() error { return nil } -func (self *RefreshHelper) loadWorktrees() { +func (self *RefreshHelper) loadWorktrees() []*models.Worktree { worktrees, err := self.c.Git().Loaders.Worktrees.GetWorktrees() if err != nil { self.c.Log.Error(err) - self.c.Model().Worktrees = []*models.Worktree{} - } else { - self.c.Model().Worktrees = worktrees + return []*models.Worktree{} } + return worktrees } func (self *RefreshHelper) refreshWorktrees() { - self.loadWorktrees() + generation := self.c.State().GetRepoGeneration() + + worktrees := self.loadWorktrees() + + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().Worktrees = worktrees + return nil + }) // need to refresh branches because the branches view shows worktrees against // branches From 559b4bf298268871a8fcd07a4bedc7fad9a6756e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:24:20 +0200 Subject: [PATCH 212/384] Bounce REBASE_COMMITS model updates onto the UI thread refreshRebaseCommits now computes the merged rebasing commits and working tree state on the worker and writes Model.Commits / WorkingTreeStateAtLastCommitRefresh in an onUIThreadUnlessRepoChanged bounce. LocalCommitsMutex is left in place for now; it's shared with the commits and branches refreshes and comes out once they're all bounced. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index d1c1091da..4d511ed78 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -654,12 +654,19 @@ func (self *RefreshHelper) refreshRebaseCommits() error { self.c.Mutexes().LocalCommitsMutex.Lock() defer self.c.Mutexes().LocalCommitsMutex.Unlock() + generation := self.c.State().GetRepoGeneration() + updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(self.c.Model().HashPool, self.c.Model().Commits) if err != nil { return err } - self.c.Model().Commits = updatedCommits - self.c.Model().WorkingTreeStateAtLastCommitRefresh = self.c.Git().Status.WorkingTreeState() + workingTreeState := self.c.Git().Status.WorkingTreeState() + + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().Commits = updatedCommits + self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState + return nil + }) self.refreshView(self.c.Contexts().LocalCommits) return nil From 0f85c2b2b4085d718ba5abd2d207a4f0f5a7eff9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:25:10 +0200 Subject: [PATCH 213/384] Bounce SUB_COMMITS model updates onto the UI thread refreshSubCommitsWithLimit now loads the sub-commits on the worker and writes Model.SubCommits (and folds their authors into Model.Authors via RefreshAuthors) inside an onUIThreadUnlessRepoChanged bounce. SubCommitsMutex and AuthorsMutex are left in place: the former is shared with setSubCommits, the latter with the commits refresh's RefreshAuthors call, so both come out only once those other writers are on the UI thread too. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 4d511ed78..67b8e86f1 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -594,6 +594,8 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit() error { self.c.Mutexes().SubCommitsMutex.Lock() defer self.c.Mutexes().SubCommitsMutex.Unlock() + generation := self.c.State().GetRepoGeneration() + commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: self.c.Contexts().SubCommits.GetLimitCommits(), @@ -610,8 +612,11 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit() error { if err != nil { return err } - self.c.Model().SubCommits = commits - self.RefreshAuthors(commits) + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().SubCommits = commits + self.RefreshAuthors(commits) + return nil + }) self.refreshView(self.c.Contexts().SubCommits) return nil From db5eb6fd3964cd5a5b9fad5d7fe59d9c49797407 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 14:27:35 +0200 Subject: [PATCH 214/384] Bounce REMOTES model updates onto the UI thread refreshRemotes now loads the remotes on the worker and writes Model.Remotes, rebuilds the pull-requests map, and updates the selected remote's RemoteBranches inside an onUIThreadUnlessRepoChanged bounce. RemotesController.addAndCheckoutRemote read Model.Remotes right after its SYNC REMOTES refresh to select the newly-added remote; since that write now bounces, the selection (and the follow-up fetch) move into Then so they run against the post-refresh model rather than the stale one. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 34 +++++++++++-------- pkg/gui/controllers/remotes_controller.go | 32 +++++++++-------- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 67b8e86f1..4811e026f 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -942,6 +942,7 @@ func (self *RefreshHelper) refreshReflogCommits() error { } func (self *RefreshHelper) refreshRemotes() error { + generation := self.c.State().GetRepoGeneration() prevSelectedRemote := self.c.Contexts().Remotes.GetSelected() remotes, err := self.c.Git().Loaders.RemoteLoader.GetRemotes() @@ -949,25 +950,28 @@ func (self *RefreshHelper) refreshRemotes() error { return err } - self.c.Model().Remotes = remotes + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().Remotes = remotes - hadPrs := len(self.c.Model().PullRequestsMap) != 0 - self.rebuildPullRequestsMap() - if !hadPrs && len(self.c.Model().PullRequestsMap) != 0 { - // if we didn't have PRs in the map before but now we do, we need to redraw the branches view - self.refreshView(self.c.Contexts().Branches) - } + hadPrs := len(self.c.Model().PullRequestsMap) != 0 + self.rebuildPullRequestsMap() + if !hadPrs && len(self.c.Model().PullRequestsMap) != 0 { + // if we didn't have PRs in the map before but now we do, we need to redraw the branches view + self.refreshView(self.c.Contexts().Branches) + } - // we need to ensure our selected remote branches aren't now outdated - if prevSelectedRemote != nil && self.c.Model().RemoteBranches != nil { - // find remote now - for _, remote := range remotes { - if remote.Name == prevSelectedRemote.Name { - self.c.Model().RemoteBranches = remote.Branches - break + // we need to ensure our selected remote branches aren't now outdated + if prevSelectedRemote != nil && self.c.Model().RemoteBranches != nil { + // find remote now + for _, remote := range remotes { + if remote.Name == prevSelectedRemote.Name { + self.c.Model().RemoteBranches = remote.Branches + break + } } } - } + return nil + }) self.refreshView(self.c.Contexts().Remotes) self.refreshView(self.c.Contexts().RemoteBranches) diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index f7b16e228..e4f606ca3 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -156,24 +156,28 @@ func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl return err } - // Do a sync refresh of the remotes so that we can select - // the new one. Loading remotes is not expensive, so we can - // afford it. + // Refresh the remotes so that we can select the new one. The remotes model + // update is bounced onto the UI thread, so the selection (which reads + // Model.Remotes) has to run in Then; reading it inline here would see the + // previous model. Loading remotes is not expensive, so a sync refresh is + // affordable. self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.REMOTES}, Mode: types.SYNC, + Then: func() error { + // Select the remote + for idx, remote := range self.c.Model().Remotes { + if remote.Name == remoteName { + self.c.Contexts().Remotes.SetSelection(idx) + break + } + } + + // Fetch the remote + return self.fetchAndCheckout(self.c.Contexts().Remotes.GetSelected(), branchToCheckout) + }, }) - - // Select the remote - for idx, remote := range self.c.Model().Remotes { - if remote.Name == remoteName { - self.c.Contexts().Remotes.SetSelection(idx) - break - } - } - - // Fetch the remote - return self.fetchAndCheckout(self.c.Contexts().Remotes.GetSelected(), branchToCheckout) + return nil } // Ensures the fork remote exists (matching the given URL). From bd47106d03e60f14c72fe001805a3882d921b601 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 15:17:43 +0200 Subject: [PATCH 215/384] Thread reflog commits explicitly into the branches load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BranchLoader.Load reads the reflog commits to sort branches by recency. Today it reads them straight from Model.ReflogCommits, which works because in the recency path the reflog refresh writes that field synchronously just before the branches refresh reads it (same goroutine, sequential). An upcoming commit bounces the reflog model write onto the UI thread, at which point Model.ReflogCommits wouldn't be updated yet when branches runs — branches would sort by the previous refresh's reflog. To decouple the branches load from *when* that write lands, pass the reflog commits to refreshBranches explicitly: refreshReflogCommits now returns the commits it loaded, and the recency path hands them straight to refreshBranches. The non-recency path (branches and reflog run concurrently, as before) keeps passing Model.ReflogCommits. Pure refactor: behavior is identical, since the value passed is exactly what Load read from the model before. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 4811e026f..7cd8b6a59 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -179,10 +179,13 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { } else { branchesAndRemotesWg.Add(1) refresh("branches", func() { - self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true) + // Not a recency sort, so branches doesn't depend on the reflog + // being fresh; it runs concurrently with the reflog refresh + // below and reads whatever's in the model, as it always has. + self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true, self.c.Model().ReflogCommits) branchesAndRemotesWg.Done() }) - refresh("reflog", func() { _ = self.refreshReflogCommits() }) + refresh("reflog", func() { _, _ = self.refreshReflogCommits() }) } } else if scopeSet.Includes(types.REBASE_COMMITS) { // the above block handles rebase commits so we only need to call this one @@ -378,27 +381,38 @@ func getModeName(mode types.RefreshMode) string { // on startup to sort the branches by recency. So we have two phases: INITIAL, and COMPLETE. // In the initial phase we don't get any reflog commits, but we asynchronously get them // and refresh the branches after that -func (self *RefreshHelper) refreshReflogCommitsConsideringStartup() { +// refreshReflogCommitsConsideringStartup returns the reflog commits that the +// caller should hand to refreshBranches for recency sorting. In the COMPLETE +// (normal) case that's the freshly-loaded reflog; in the INITIAL case the +// reflog is loaded asynchronously (and drives its own branches refresh once +// ready), so we return the current model value for the immediate, +// non-recency-sorted branches refresh the caller does in the meantime. +func (self *RefreshHelper) refreshReflogCommitsConsideringStartup() []*models.Commit { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: self.c.OnWorker(func(_ gocui.Task) error { - _ = self.refreshReflogCommits() - self.refreshBranches(false, true, true) + reflogCommits, _ := self.refreshReflogCommits() + self.refreshBranches(false, true, true, reflogCommits) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) + return self.c.Model().ReflogCommits + case types.COMPLETE: - _ = self.refreshReflogCommits() + reflogCommits, _ := self.refreshReflogCommits() + return reflogCommits } + + return self.c.Model().ReflogCommits } func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) { loadBehindCounts := self.c.State().GetRepoState().GetStartupStage() == types.COMPLETE - self.refreshReflogCommitsConsideringStartup() + reflogCommits := self.refreshReflogCommitsConsideringStartup() - self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, loadBehindCounts) + self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, loadBehindCounts, reflogCommits) } func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior) { @@ -700,12 +714,12 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // 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(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool) { +func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool, reflogCommits []*models.Commit) { self.c.Mutexes().RefreshingBranchesMutex.Lock() defer self.c.Mutexes().RefreshingBranchesMutex.Unlock() branches, err := self.c.Git().Loaders.BranchLoader.Load( - self.c.Model().ReflogCommits, + reflogCommits, self.c.Model().MainBranches, self.c.Model().Branches, loadBehindCounts, @@ -900,7 +914,10 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // This method also manages two things: ReflogCommits and FilteredReflogCommits. // FilteredReflogCommits are rendered in the reflogs panel, and ReflogCommits // are used by the branches panel to obtain recency values for sorting. -func (self *RefreshHelper) refreshReflogCommits() error { +// refreshReflogCommits returns the (non-filtered) ReflogCommits it loaded, so +// that a subsequent branches refresh can use them for recency sorting without +// having to read them back out of the model. +func (self *RefreshHelper) refreshReflogCommits() ([]*models.Commit, error) { // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception model := self.c.Model() @@ -926,19 +943,19 @@ func (self *RefreshHelper) refreshReflogCommits() error { } if err := refresh(&model.ReflogCommits, "", ""); err != nil { - return err + return nil, err } if self.c.Modes().Filtering.Active() { if err := refresh(&model.FilteredReflogCommits, self.c.Modes().Filtering.GetPath(), self.c.Modes().Filtering.GetAuthor()); err != nil { - return err + return nil, err } } else { model.FilteredReflogCommits = model.ReflogCommits } self.refreshView(self.c.Contexts().ReflogCommits) - return nil + return model.ReflogCommits, nil } func (self *RefreshHelper) refreshRemotes() error { From 063bba6b45ac0d8da36997f29d1c5599c0f6081b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 15:19:34 +0200 Subject: [PATCH 216/384] Bounce REFLOG model updates onto the UI thread refreshReflogCommits now does the git fetch on the worker and computes the new ReflogCommits / FilteredReflogCommits values (still reading the existing slices for the incremental prepend), then writes them in an onUIThreadUnlessRepoChanged bounce. The freshly-computed reflog is still returned for the branches load, so recency sorting is unaffected by the write now landing on the UI thread (see the previous commit). Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 7cd8b6a59..fc405c6bf 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -918,44 +918,53 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // that a subsequent branches refresh can use them for recency sorting without // having to read them back out of the model. func (self *RefreshHelper) refreshReflogCommits() ([]*models.Commit, error) { + generation := self.c.State().GetRepoGeneration() // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception model := self.c.Model() - refresh := func(stateCommits *[]*models.Commit, filterPath string, filterAuthor string) error { + // load does the git work on the worker and returns the new value for a + // reflog slice, reading the existing slice for the incremental fetch. The + // caller writes the result in the bounce. + load := func(existing []*models.Commit, filterPath string, filterAuthor string) ([]*models.Commit, error) { var lastReflogCommit *models.Commit - if filterPath == "" && filterAuthor == "" && len(*stateCommits) > 0 { - lastReflogCommit = (*stateCommits)[0] + if filterPath == "" && filterAuthor == "" && len(existing) > 0 { + lastReflogCommit = existing[0] } commits, onlyObtainedNewReflogCommits, err := self.c.Git().Loaders.ReflogCommitLoader. - GetReflogCommits(self.c.Model().HashPool, lastReflogCommit, filterPath, filterAuthor) + GetReflogCommits(model.HashPool, lastReflogCommit, filterPath, filterAuthor) if err != nil { - return err + return nil, err } if onlyObtainedNewReflogCommits { - *stateCommits = append(commits, *stateCommits...) - } else { - *stateCommits = commits + return append(commits, existing...), nil } - return nil + return commits, nil } - if err := refresh(&model.ReflogCommits, "", ""); err != nil { + reflogCommits, err := load(model.ReflogCommits, "", "") + if err != nil { return nil, err } + filteredReflogCommits := reflogCommits if self.c.Modes().Filtering.Active() { - if err := refresh(&model.FilteredReflogCommits, self.c.Modes().Filtering.GetPath(), self.c.Modes().Filtering.GetAuthor()); err != nil { + filteredReflogCommits, err = load(model.FilteredReflogCommits, self.c.Modes().Filtering.GetPath(), self.c.Modes().Filtering.GetAuthor()) + if err != nil { return nil, err } - } else { - model.FilteredReflogCommits = model.ReflogCommits } + self.onUIThreadUnlessRepoChanged(generation, func() error { + model.ReflogCommits = reflogCommits + model.FilteredReflogCommits = filteredReflogCommits + return nil + }) + self.refreshView(self.c.Contexts().ReflogCommits) - return model.ReflogCommits, nil + return reflogCommits, nil } func (self *RefreshHelper) refreshRemotes() error { From 4c9fdc42213eb4a7953c3e24c07140ec70c9e05f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 15:21:20 +0200 Subject: [PATCH 217/384] Bounce STATUS view update onto the UI thread refreshStatus computes the status line on the calling goroutine (as before) but now writes it to the status view in an onUIThreadUnlessRepoChanged bounce rather than calling SetViewContent directly from the worker. RefreshingStatusMutex is left in place for now; it only guards the compute phase between concurrent callers and comes out in the mutex cleanup. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index fc405c6bf..083907881 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1048,6 +1048,8 @@ func (self *RefreshHelper) refreshStatus() { self.c.Mutexes().RefreshingStatusMutex.Lock() defer self.c.Mutexes().RefreshingStatusMutex.Unlock() + generation := self.c.State().GetRepoGeneration() + currentBranch := self.refsHelper.GetCheckedOutRef() if currentBranch == nil { // need to wait for branches to refresh @@ -1061,7 +1063,10 @@ func (self *RefreshHelper) refreshStatus() { status := presentation.FormatStatus(repoName, currentBranch, types.ItemOperationNone, linkedWorktreeName, workingTreeState, self.c.Tr, self.c.UserConfig()) - self.c.SetViewContent(self.c.Views().Status, status) + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.SetViewContent(self.c.Views().Status, status) + return nil + }) } func (self *RefreshHelper) refForLog() string { From 4c3f8b51ea57787a407f104e110c35ee2093e5ce Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 15:25:04 +0200 Subject: [PATCH 218/384] Bounce PULL_REQUESTS model updates onto the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshGithubPullRequests and setGithubPullRequests now do their network work on the worker and write Model.PullRequests / PullRequestsMap in an onUIThreadUnlessRepoChanged bounce (the "no github remotes" and "no base remote" early-returns clear them the same way). rebuildPullRequestsMap moves into the bounce so the map is built from Model.Branches and Model.Remotes as they stand on the UI thread — after those scopes' refreshes have applied their own bounces — rather than from whatever the worker happened to see. The remaining worker-side reads of Model.Branches (to pick which upstream branches to query) are the same not-yet-addressed worker-read race that applies to the other bounced scopes. RefreshingPullRequestsMutex is left in place for the mutex cleanup. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 083907881..917a58ecf 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1115,17 +1115,25 @@ func (self *RefreshHelper) refreshGithubPullRequests() { self.c.Mutexes().RefreshingPullRequestsMutex.Lock() defer self.c.Mutexes().RefreshingPullRequestsMutex.Unlock() + generation := self.c.State().GetRepoGeneration() + + clearPullRequests := func() { + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().PullRequests = nil + self.c.Model().PullRequestsMap = nil + return nil + }) + } + githubRemotes := getAuthenticatedGithubRemotes(self.getGithubRemotes(), self.c.Git().GitHub.GetAuthToken) if len(githubRemotes) == 0 { - self.c.Model().PullRequests = nil - self.c.Model().PullRequestsMap = nil + clearPullRequests() return } baseInfo := getGithubBaseRemote(githubRemotes, self.c.Git().GitHub.ConfiguredBaseRemoteName()) if baseInfo == nil { - self.c.Model().PullRequests = nil - self.c.Model().PullRequestsMap = nil + clearPullRequests() if !self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] { self.promptForBaseGithubRepo(githubRemotes) @@ -1243,6 +1251,8 @@ func (self *RefreshHelper) rebuildPullRequestsMap() { } func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo) { + generation := self.c.State().GetRepoGeneration() + if len(self.c.Model().Branches) == 0 { return } @@ -1260,11 +1270,14 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo) { return } - self.c.Model().PullRequests = prs self.savePullRequestsToCache(prs) - self.rebuildPullRequestsMap() - self.c.OnUIThread(func() error { + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().PullRequests = prs + // Rebuilding here rather than on the worker means the map is built from + // the branches and remotes as they are on the UI thread, after their + // own refreshes' bounces have applied. + self.rebuildPullRequestsMap() self.c.PostRefreshUpdate(self.c.Contexts().Branches) return nil }) From 549df1727937e8ff706820a830d42904b9b469c0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 15:32:11 +0200 Subject: [PATCH 219/384] Bounce COMMITS model updates onto the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshCommitsWithLimit now loads the commits, working-tree state and bisect info on the worker and writes them all — Model.Commits, Model.BisectInfo, Model.WorkingTreeStateAtLastCommitRefresh, Model.CheckedOutBranch, the authors, and the restored commit selection — in a single onUIThreadUnlessRepoChanged bounce. The selection restore (SelectHeadCommit / KeepCommitSelectionByHash) has to run in the bounce because it reads the freshly-loaded commits; the FocusLine scroll is enqueued from within the bounce so it still runs after refreshView's re-render, as before. refForLog no longer writes Model.BisectInfo as a side effect; it returns the bisect info it read, and the bounce writes it, keeping that model write on the UI thread. No caller reads Model.BisectInfo synchronously after a refresh (the bisect controller reads Git().Bisect.GetInfo() directly), so this is safe. refreshCommitsAndCommitFiles's post-refresh re-init of the commit files context depends on that restored selection, so it reads the selection in a bounce and dispatches the commit-files git work back to a worker. LocalCommitsMutex / AuthorsMutex are left in place for the mutex cleanup. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 109 +++++++++++------- 1 file changed, 68 insertions(+), 41 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 917a58ecf..a86e91111 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -416,6 +416,7 @@ func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepB } func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior) { + generation := self.c.State().GetRepoGeneration() _ = self.refreshCommitsWithLimit(commitSelection) ctx := self.c.Contexts().CommitFiles.GetParentContext() if ctx != nil && ctx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY { @@ -425,12 +426,22 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.Co // Ideally we would know when to refresh the commit files context and when not to, // or perhaps we could just pop that context off the stack whenever cycling windows. // For now the awkwardness remains. - commit := self.c.Contexts().LocalCommits.GetSelected() - if commit != nil && commit.RefName() != "" { - refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() - self.c.Contexts().CommitFiles.ReInit(commit, refRange) - _ = self.refreshCommitFilesContext() - } + // + // The commit selection is restored in refreshCommitsWithLimit's bounce, + // so read it on the UI thread after that bounce; then load the commit + // files back on a worker (refreshCommitFilesContext does git work). + self.onUIThreadUnlessRepoChanged(generation, func() error { + commit := self.c.Contexts().LocalCommits.GetSelected() + if commit != nil && commit.RefName() != "" { + refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() + self.c.Contexts().CommitFiles.ReInit(commit, refRange) + self.c.OnWorker(func(gocui.Task) error { + _ = self.refreshCommitFilesContext() + return nil + }) + } + return nil + }) } } @@ -464,6 +475,8 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS self.c.Mutexes().LocalCommitsMutex.Lock() defer self.c.Mutexes().LocalCommitsMutex.Unlock() + generation := self.c.State().GetRepoGeneration() + var selectionRange *localCommitSelectionRange if commitSelection == types.KeepCommitSelectionByHash { selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() @@ -471,13 +484,14 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS } checkedOutRef := self.determineCheckedOutRef() + refName, bisectInfo := self.refForLog() commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: self.c.Contexts().LocalCommits.GetLimitCommits(), FilterPath: self.c.Modes().Filtering.GetPath(), FilterAuthor: self.c.Modes().Filtering.GetAuthor(), IncludeRebaseCommits: true, - RefName: self.refForLog(), + RefName: refName, RefForPushedStatus: checkedOutRef, All: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), MainBranches: self.c.Model().MainBranches, @@ -487,41 +501,51 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS if err != nil { return err } - self.c.Model().Commits = commits - self.RefreshAuthors(commits) - self.c.Model().WorkingTreeStateAtLastCommitRefresh = self.c.Git().Status.WorkingTreeState() - if checkedOutRef != nil { - self.c.Model().CheckedOutBranch = checkedOutRef.RefName() - } else { - self.c.Model().CheckedOutBranch = "" - } + workingTreeState := self.c.Git().Status.WorkingTreeState() - scrollSelectionIntoView := false - switch commitSelection { - case types.SelectHeadCommit: - if headCommitIdx := models.HeadCommitIdx(commits); headCommitIdx >= 0 { - self.c.Contexts().LocalCommits.SetSelection(headCommitIdx) - scrollSelectionIntoView = true + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().BisectInfo = bisectInfo + self.c.Model().Commits = commits + self.RefreshAuthors(commits) + self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState + if checkedOutRef != nil { + self.c.Model().CheckedOutBranch = checkedOutRef.RefName() + } else { + self.c.Model().CheckedOutBranch = "" } - case types.KeepCommitSelectionByHash: - if selectionRange != nil { - selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange) - if found { - self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode) - scrollSelectionIntoView = didMove + + 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 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. } - 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(generation, func() error { + self.c.Contexts().LocalCommits.FocusLine(true) + return nil + }) + } + return nil + }) self.refreshView(self.c.Contexts().LocalCommits) - if scrollSelectionIntoView { - self.c.OnUIThread(func() error { - self.c.Contexts().LocalCommits.FocusLine(true) - return nil - }) - } return nil } @@ -1069,20 +1093,23 @@ func (self *RefreshHelper) refreshStatus() { }) } -func (self *RefreshHelper) refForLog() string { +// refForLog returns the ref to log commits from, along with the bisect info it +// read to decide that. The caller writes the bisect info to the model (in its +// bounce) rather than refForLog doing it, so the model write stays on the UI +// thread. +func (self *RefreshHelper) refForLog() (string, *git_commands.BisectInfo) { bisectInfo := self.c.Git().Bisect.GetInfo() - self.c.Model().BisectInfo = bisectInfo if !bisectInfo.Started() { - return "HEAD" + return "HEAD", bisectInfo } // need to see if our bisect's current commit is reachable from our 'new' ref. if bisectInfo.Bisecting() && !self.c.Git().Bisect.ReachableFromStart(bisectInfo) { - return bisectInfo.GetNewHash() + return bisectInfo.GetNewHash(), bisectInfo } - return bisectInfo.GetStartHash() + return bisectInfo.GetStartHash(), bisectInfo } func (self *RefreshHelper) refreshView(context types.Context) { From f7a61443fa832d47bfec4bb9726a7cc52eb5f19f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 15:56:01 +0200 Subject: [PATCH 220/384] Bounce BRANCHES model updates onto the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refreshBranches now loads the branches (and worktrees) on the worker and writes Model.Branches, the pull-requests map, Model.Worktrees, and the restored branch selection in an onUIThreadUnlessRepoChanged bounce. The selection restore and rebuildPullRequestsMap run in the bounce so they see the branches we just wrote; the LocalCommits re-render (for branch head visualization) moves into the same bounce. refreshStatus is adjusted to read the checked-out branch and the linked worktree name inside its bounce rather than on the worker: both derive from models (Branches, Worktrees) that are now written via bounces, so reading them on the worker would format the status from stale values — which showed up as the status line dropping the "(worktree)" suffix right after entering a submodule or switching worktrees. The git work (WorkingTreeState) stays on the worker. Two callers that read the branches model right after a SYNC branches refresh move their reads into Then: - BranchesHelper.PostFetchRefresh: AutoForwardBranches reads Model.Branches, so it runs in Then (preserving that a fetch error is still returned to the caller and that background auto-forward errors aren't surfaced as a popup). - BranchesController rename: the re-select-by-name loop runs in Then. RefreshingBranchesMutex is left in place for the mutex cleanup. Co-Authored-By: Claude Sonnet 5 --- pkg/gui/controllers/branches_controller.go | 23 ++++--- .../controllers/helpers/branches_helper.go | 27 ++++++-- pkg/gui/controllers/helpers/refresh_helper.go | 68 +++++++++++-------- 3 files changed, 74 insertions(+), 44 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 45f98e9c5..131d19439 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -783,20 +783,25 @@ func (self *BranchesController) rename(branch *models.Branch) error { return err } - // need to find where the branch is now so that we can re-select it. That means we need to refetch the branches synchronously and then find our branch + // need to find where the branch is now so that we can re-select it. That means we need to + // refetch the branches and then find our branch. The branches model update is bounced + // onto the UI thread, so the re-selection (which reads Model.Branches) has to run in + // Then; reading it inline here would see the previous model. self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES, types.WORKTREES}, + Then: func() error { + // now that we've got our stuff again we need to find that branch and reselect it. + for i, newBranch := range self.c.Model().Branches { + if newBranch.Name == newBranchName { + self.context().SetSelection(i) + self.context().HandleRender() + } + } + return nil + }, }) - // now that we've got our stuff again we need to find that branch and reselect it. - for i, newBranch := range self.c.Model().Branches { - if newBranch.Name == newBranchName { - self.context().SetSelection(i) - self.context().HandleRender() - } - } - return nil }, }) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 4283bd29a..e5c1a07e4 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -387,11 +387,28 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er if self.c.UserConfig().Git.AutoForwardBranches != "none" { scope = append(scope, types.WORKTREES) } - self.c.Refresh(types.RefreshOptions{Scope: scope, Mode: types.SYNC, Background: background}) - if fetchErr != nil { - return fetchErr - } - return self.AutoForwardBranches() + // AutoForwardBranches reads Model.Branches, which the branches refresh writes + // via a bounce, so it has to run in Then rather than right after Refresh + // returns (where it would still see the previous branches). + self.c.Refresh(types.RefreshOptions{ + Scope: scope, + Mode: types.SYNC, + Background: background, + Then: func() error { + if fetchErr != nil { + return nil + } + err := self.AutoForwardBranches() + if background && err != nil { + // The background poller discards this return value, so surface + // the error in the log rather than as a popup for background work. + self.c.Log.Error(err) + return nil + } + return err + }, + }) + return fetchErr } func (self *BranchesHelper) AutoForwardBranches() error { diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index a86e91111..876ce0f30 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -742,6 +742,8 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele self.c.Mutexes().RefreshingBranchesMutex.Lock() defer self.c.Mutexes().RefreshingBranchesMutex.Unlock() + generation := self.c.State().GetRepoGeneration() + branches, err := self.c.Git().Loaders.BranchLoader.Load( reflogCommits, self.c.Model().MainBranches, @@ -753,7 +755,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele }) }, func() { - self.c.OnUIThread(func() error { + self.onUIThreadUnlessRepoChanged(generation, func() error { self.c.Contexts().Branches.HandleRender() self.refreshStatus() return nil @@ -765,38 +767,42 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele prevSelectedBranch := self.c.Contexts().Branches.GetSelected() - self.c.Model().Branches = branches - self.rebuildPullRequestsMap() - + var worktrees []*models.Worktree if refreshWorktrees { - // TODO: this synchronous worker write goes away when refreshBranches is - // itself migrated to bouncing; for now it matches the rest of this - // not-yet-bounced function. - self.c.Model().Worktrees = self.loadWorktrees() - self.refreshView(self.c.Contexts().Worktrees) + worktrees = self.loadWorktrees() } - if !keepBranchSelectionIndex && prevSelectedBranch != nil { - self.searchHelper.ReApplyFilter(self.c.Contexts().Branches) + self.onUIThreadUnlessRepoChanged(generation, func() error { + self.c.Model().Branches = branches + // Rebuilding here (rather than on the worker) means the map is built from + // the branches we just wrote, on the UI thread. + self.rebuildPullRequestsMap() - _, idx, found := lo.FindIndexOf(self.c.Contexts().Branches.GetItems(), - func(b *models.Branch) bool { return b.Name == prevSelectedBranch.Name }) - if found { - self.c.Contexts().Branches.SetSelectedLineIdx(idx) + if refreshWorktrees { + self.c.Model().Worktrees = worktrees + self.refreshView(self.c.Contexts().Worktrees) } - } - self.refreshView(self.c.Contexts().Branches) + if !keepBranchSelectionIndex && prevSelectedBranch != nil { + self.searchHelper.ReApplyFilter(self.c.Contexts().Branches) - // Need to re-render the commits view because the visualization of local - // branch heads might have changed - self.c.OnUIThread(func() error { + _, idx, found := lo.FindIndexOf(self.c.Contexts().Branches.GetItems(), + func(b *models.Branch) bool { return b.Name == prevSelectedBranch.Name }) + if found { + self.c.Contexts().Branches.SetSelectedLineIdx(idx) + } + } + + // Need to re-render the commits view because the visualization of local + // branch heads might have changed self.c.Mutexes().LocalCommitsMutex.Lock() self.c.Contexts().LocalCommits.HandleRender() self.c.Mutexes().LocalCommitsMutex.Unlock() return nil }) + self.refreshView(self.c.Contexts().Branches) + self.refreshStatus() } @@ -1074,20 +1080,22 @@ func (self *RefreshHelper) refreshStatus() { generation := self.c.State().GetRepoGeneration() - currentBranch := self.refsHelper.GetCheckedOutRef() - if currentBranch == nil { - // need to wait for branches to refresh - return - } - workingTreeState := self.c.Git().Status.WorkingTreeState() - linkedWorktreeName := self.worktreeHelper.GetLinkedWorktreeName() - repoName := self.c.Git().RepoPaths.RepoName() - status := presentation.FormatStatus(repoName, currentBranch, types.ItemOperationNone, linkedWorktreeName, workingTreeState, self.c.Tr, self.c.UserConfig()) - self.onUIThreadUnlessRepoChanged(generation, func() error { + // Read the checked-out branch and the linked worktree name here on the UI + // thread: both derive from models (Branches, Worktrees) that their + // refreshes now write via bounces, so reading them on the worker would + // see stale values from before those bounces applied. + currentBranch := self.refsHelper.GetCheckedOutRef() + if currentBranch == nil { + // need to wait for branches to refresh + return nil + } + linkedWorktreeName := self.worktreeHelper.GetLinkedWorktreeName() + + status := presentation.FormatStatus(repoName, currentBranch, types.ItemOperationNone, linkedWorktreeName, workingTreeState, self.c.Tr, self.c.UserConfig()) self.c.SetViewContent(self.c.Views().Status, status) return nil }) From 805738034f727e246bebfc8f35e3adc721c6873a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 16:51:55 +0200 Subject: [PATCH 221/384] Remove refresh mutexes made redundant by bouncing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that every refresh scope writes its model updates on the UI thread via onUIThreadUnlessRepoChanged, the per-scope mutexes that used to serialize concurrent worker-goroutine access are redundant: Model().Commits, .SubCommits, .Authors, the status view content, and .PullRequests/.PullRequestsMap are all now written only on the UI thread, and their readers already ran there. setSubCommits only existed to take the lock, so it's inlined to match refreshSubCommitsWithLimit, which writes Model().SubCommits directly. The worker phases still *read* some of these fields (the commit selection range, MergeRebasingCommits), but those reads race a concurrent refresh's bounced write regardless of the mutex — the write happens in the bounce, outside the locked region — so the mutex never protected them. That residual read race belongs to the broader -race effort, not to these locks. RefreshingBranchesMutex is deliberately kept. It is load-bearing for a reason unrelated to data races: at the INITIAL startup stage two refreshBranches run concurrently — an immediate one with an empty reflog (non-recency order) and an async one with the freshly-loaded reflog (recency order). The mutex serializes them so the recency write's bounce is enqueued last and wins. Without it the stale non-recency write can land last, reordering the branches list (caught by the recency-sort e2e tests: cherry_pick/*, branch/rebase_*). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 20 ------------------- .../controllers/helpers/sub_commits_helper.go | 9 +-------- pkg/gui/types/common.go | 13 ++++-------- 3 files changed, 5 insertions(+), 37 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 876ce0f30..75d5d2fd4 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -472,9 +472,6 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref { } func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitSelectionBehavior) error { - self.c.Mutexes().LocalCommitsMutex.Lock() - defer self.c.Mutexes().LocalCommitsMutex.Unlock() - generation := self.c.State().GetRepoGeneration() var selectionRange *localCommitSelectionRange @@ -629,9 +626,6 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit() error { return nil } - self.c.Mutexes().SubCommitsMutex.Lock() - defer self.c.Mutexes().SubCommitsMutex.Unlock() - generation := self.c.State().GetRepoGeneration() commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( @@ -661,9 +655,6 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit() error { } func (self *RefreshHelper) RefreshAuthors(commits []*models.Commit) { - self.c.Mutexes().AuthorsMutex.Lock() - defer self.c.Mutexes().AuthorsMutex.Unlock() - authors := self.c.Model().Authors for _, commit := range commits { if _, ok := authors[commit.AuthorEmail]; !ok { @@ -694,9 +685,6 @@ func (self *RefreshHelper) refreshCommitFilesContext() error { } func (self *RefreshHelper) refreshRebaseCommits() error { - self.c.Mutexes().LocalCommitsMutex.Lock() - defer self.c.Mutexes().LocalCommitsMutex.Unlock() - generation := self.c.State().GetRepoGeneration() updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(self.c.Model().HashPool, self.c.Model().Commits) @@ -795,9 +783,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele // Need to re-render the commits view because the visualization of local // branch heads might have changed - self.c.Mutexes().LocalCommitsMutex.Lock() self.c.Contexts().LocalCommits.HandleRender() - self.c.Mutexes().LocalCommitsMutex.Unlock() return nil }) @@ -1075,9 +1061,6 @@ func (self *RefreshHelper) refreshStashEntries() { // never call this on its own, it should only be called from within refreshCommits() func (self *RefreshHelper) refreshStatus() { - self.c.Mutexes().RefreshingStatusMutex.Lock() - defer self.c.Mutexes().RefreshingStatusMutex.Unlock() - generation := self.c.State().GetRepoGeneration() workingTreeState := self.c.Git().Status.WorkingTreeState() @@ -1147,9 +1130,6 @@ func (self *RefreshHelper) refreshView(context types.Context) { } func (self *RefreshHelper) refreshGithubPullRequests() { - self.c.Mutexes().RefreshingPullRequestsMutex.Lock() - defer self.c.Mutexes().RefreshingPullRequestsMutex.Unlock() - generation := self.c.State().GetRepoGeneration() clearPullRequests := func() { diff --git a/pkg/gui/controllers/helpers/sub_commits_helper.go b/pkg/gui/controllers/helpers/sub_commits_helper.go index fbf100e16..7bd928826 100644 --- a/pkg/gui/controllers/helpers/sub_commits_helper.go +++ b/pkg/gui/controllers/helpers/sub_commits_helper.go @@ -49,7 +49,7 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error { return err } - self.setSubCommits(commits) + self.c.Model().SubCommits = commits self.refreshHelper.RefreshAuthors(commits) subCommitsContext := self.c.Contexts().SubCommits @@ -71,10 +71,3 @@ func (self *SubCommitsHelper) ViewSubCommits(opts ViewSubCommitsOpts) error { self.c.Context().Push(self.c.Contexts().SubCommits, types.OnFocusOpts{}) return nil } - -func (self *SubCommitsHelper) setSubCommits(commits []*models.Commit) { - self.c.Mutexes().SubCommitsMutex.Lock() - defer self.c.Mutexes().SubCommitsMutex.Unlock() - - self.c.Model().SubCommits = commits -} diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 2b7dc2312..35f38d21e 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -338,15 +338,10 @@ type Model struct { } type Mutexes struct { - RefreshingBranchesMutex deadlock.Mutex - RefreshingStatusMutex deadlock.Mutex - RefreshingPullRequestsMutex deadlock.Mutex - LocalCommitsMutex deadlock.Mutex - SubCommitsMutex deadlock.Mutex - AuthorsMutex deadlock.Mutex - SubprocessMutex deadlock.Mutex - PopupMutex deadlock.Mutex - PtyMutex deadlock.Mutex + RefreshingBranchesMutex deadlock.Mutex + SubprocessMutex deadlock.Mutex + PopupMutex deadlock.Mutex + PtyMutex deadlock.Mutex } // A long-running operation associated with an item. For example, we'll show From 6d8ab1d0639f19227786a67103eca9582e219dd2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 17:35:44 +0200 Subject: [PATCH 222/384] Run the immediate startup branch refresh before spawning the async one At the INITIAL startup stage two branch refreshes happen: an immediate one sorted by whatever reflog we have (empty, so not by recency), and an async one that loads the reflog first and re-sorts by recency. Until now the async one was spawned first and the immediate one ran afterwards; this inverts that so the immediate refresh runs before the async one is spawned. With RefreshingBranchesMutex still in place this is behavior-preserving (the mutex serializes the two either way). It's a preparatory step for replacing that mutex with a branch-load sequence guard: running the immediate refresh first establishes a happens-before relation between the two loads' sequence numbers, so the recency-sorted one is guaranteed the higher sequence. This also lets refreshReflogCommitsConsideringStartup fold into refreshReflogAndBranches, whose two-phase logic is now all in one place. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 35 ++++++------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 75d5d2fd4..95c7fa4a7 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -377,19 +377,18 @@ func getModeName(mode types.RefreshMode) string { } } -// during startup, the bottleneck is fetching the reflog entries. We need these -// on startup to sort the branches by recency. So we have two phases: INITIAL, and COMPLETE. -// In the initial phase we don't get any reflog commits, but we asynchronously get them -// and refresh the branches after that -// refreshReflogCommitsConsideringStartup returns the reflog commits that the -// caller should hand to refreshBranches for recency sorting. In the COMPLETE -// (normal) case that's the freshly-loaded reflog; in the INITIAL case the -// reflog is loaded asynchronously (and drives its own branches refresh once -// ready), so we return the current model value for the immediate, -// non-recency-sorted branches refresh the caller does in the meantime. -func (self *RefreshHelper) refreshReflogCommitsConsideringStartup() []*models.Commit { +// During startup, the bottleneck is fetching the reflog entries, which we need +// in order to sort the branches by recency. So we have two phases: INITIAL and +// COMPLETE. In the INITIAL phase we don't have any reflog commits yet, so we +// show the branches right away sorted by whatever we have (typically nothing, +// i.e. not by recency), then load the reflog on a worker and refresh the +// branches again, this time recency-sorted. From then on we're in the COMPLETE +// phase and load the reflog synchronously before refreshing the branches. +func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: + self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, false, self.c.Model().ReflogCommits) + self.c.OnWorker(func(_ gocui.Task) error { reflogCommits, _ := self.refreshReflogCommits() self.refreshBranches(false, true, true, reflogCommits) @@ -397,22 +396,10 @@ func (self *RefreshHelper) refreshReflogCommitsConsideringStartup() []*models.Co return nil }) - return self.c.Model().ReflogCommits - case types.COMPLETE: reflogCommits, _ := self.refreshReflogCommits() - return reflogCommits + self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, true, reflogCommits) } - - return self.c.Model().ReflogCommits -} - -func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) { - loadBehindCounts := self.c.State().GetRepoState().GetStartupStage() == types.COMPLETE - - reflogCommits := self.refreshReflogCommitsConsideringStartup() - - self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, loadBehindCounts, reflogCommits) } func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior) { From 3103fe97ea9bb2653c4890dcbe8a1d67d1016aac Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 18:45:55 +0200 Subject: [PATCH 223/384] Replace RefreshingBranchesMutex with a branch-load sequence guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This removes the last refresh mutex. RefreshingBranchesMutex wasn't guarding a data race (Branch.BehindBaseBranch is atomic, and every model write is now bounced onto the UI thread); it was serializing the two branch loads that race at the INITIAL startup stage — an immediate one sorted without the reflog, and an async one that loads the reflog and sorts by recency — so that the recency-sorted write landed last and won. That serialization was never a real guarantee, only "very likely": it relied on the immediate load acquiring the lock before the async load, which had to load the reflog first. Instead, each branch load takes a monotonically increasing sequence number, and its bounce drops the write if a later-started load has already applied. Combined with the preceding commit (immediate load runs before the async one is spawned), this is an actual guarantee: the immediate non-recency load always has a lower sequence than its recency async partner, so the highest sequence number is always held by a recency-sorted load, and highest-wins converges on recency ordering — even if more refreshes fire during the INITIAL window, since each refresh's async out-sequences its own immediate. The guard also subsumes what the mutex gave post-startup: a slow, stale refresh's bounce can no longer clobber a newer refresh's branches. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 28 +++++++++++++++++-- pkg/gui/types/common.go | 7 ++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 95c7fa4a7..fe89bbad6 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -3,6 +3,7 @@ package helpers import ( "strings" "sync" + "sync/atomic" "time" "github.com/jesseduffield/generics/set" @@ -44,6 +45,15 @@ type RefreshHelper struct { // refresh that re-read refs/commits, read by the poller. refsSnapshotMutex deadlock.Mutex refsSnapshot string + + // branchLoadSeq hands out a monotonically increasing sequence number to + // each branch load (via Add, on the worker); appliedBranchLoadSeq is the + // highest sequence whose result has been written to the model (touched only + // on the UI thread, inside the bounce). Together they let a branch load's + // bounce drop its write if a later-started load has already applied, so + // concurrent branch loads don't clobber each other out of order. + branchLoadSeq atomic.Int64 + appliedBranchLoadSeq int64 } func NewRefreshHelper( @@ -384,6 +394,11 @@ func getModeName(mode types.RefreshMode) string { // i.e. not by recency), then load the reflog on a worker and refresh the // branches again, this time recency-sorted. From then on we're in the COMPLETE // phase and load the reflog synchronously before refreshing the branches. +// +// The immediate refresh must run before we spawn the async one, not after: that +// order gives the immediate (non-recency) load a lower branch-load sequence +// than the async (recency) load, so the sequence guard in refreshBranches keeps +// the recency-sorted result even if the two loads' bounces land out of order. func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: @@ -714,8 +729,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // 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(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool, reflogCommits []*models.Commit) { - self.c.Mutexes().RefreshingBranchesMutex.Lock() - defer self.c.Mutexes().RefreshingBranchesMutex.Unlock() + loadSeq := self.branchLoadSeq.Add(1) generation := self.c.State().GetRepoGeneration() @@ -748,6 +762,16 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele } self.onUIThreadUnlessRepoChanged(generation, func() error { + // Drop this write if a branch load that started later has already applied + // its result. At the INITIAL startup stage an immediate load (not + // recency-sorted) and an async recency-sorted load run concurrently; this + // makes the later-started (recency-sorted) one win regardless of which + // finishes first, so its result isn't clobbered by the stale immediate one. + if loadSeq < self.appliedBranchLoadSeq { + return nil + } + self.appliedBranchLoadSeq = loadSeq + self.c.Model().Branches = branches // Rebuilding here (rather than on the worker) means the map is built from // the branches we just wrote, on the UI thread. diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 35f38d21e..2ce07f9c7 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -338,10 +338,9 @@ type Model struct { } type Mutexes struct { - RefreshingBranchesMutex deadlock.Mutex - SubprocessMutex deadlock.Mutex - PopupMutex deadlock.Mutex - PtyMutex deadlock.Mutex + SubprocessMutex deadlock.Mutex + PopupMutex deadlock.Mutex + PtyMutex deadlock.Mutex } // A long-running operation associated with an item. For example, we'll show From cf7c3d82e6e962ce78494e57c77058796ea617f2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 20:46:51 +0200 Subject: [PATCH 224/384] Run the repo switch on the UI thread DispatchSwitchTo wrapped its whole body in WithWaitingStatus, so the switch ran on a worker: it chdirs, reassigns gui.git, and swaps gui.State (in resetState), all of which the UI thread also reads. The generation guard prevents the refresh-in-flight logical corruption but not this pointer data race on gui.State. Run the switch synchronously on the UI thread instead. Every caller is already a UI-thread handler except NewWorktreeCheckout, which must create the worktree (git work) on a worker first; it now dispatches only the switch via OnUIThread. The heavy data loading still happens asynchronously via the refresh that onNewRepo triggers, so the synchronous part is small (a couple of git rev-parse plus direnv). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/repos_helper.go | 72 +++++++++---------- .../controllers/helpers/worktree_helper.go | 8 ++- 2 files changed, 43 insertions(+), 37 deletions(-) diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 94c9e4368..6257327e9 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -13,7 +13,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/direnv" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/env" - "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" @@ -145,52 +144,53 @@ func (self *ReposHelper) DispatchSwitchToRepo(path string, contextKey types.Cont return self.DispatchSwitchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, contextKey) } +// DispatchSwitchTo switches lazygit to the repository (or worktree) at the +// given path. It runs synchronously on the UI thread: the switch swaps +// gui.State (in resetState) and reassigns gui.git and the process cwd, all of +// which the UI thread also reads, so doing it here rather than on a worker +// avoids racing those reads. The heavy data loading is still dispatched +// asynchronously by the refresh that onNewRepo kicks off. func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error { - return self.c.WithWaitingStatus(self.c.Tr.Switching, func(gocui.Task) error { - env.UnsetGitLocationEnvVars() - originalPath, err := os.Getwd() - if err != nil { - return nil + env.UnsetGitLocationEnvVars() + originalPath, err := os.Getwd() + if err != nil { + return nil + } + + msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path}) + self.c.LogCommand(msg, false) + + if err := os.Chdir(path); err != nil { + if os.IsNotExist(err) { + return errors.New(errMsg) } + return err + } - msg := utils.ResolvePlaceholderString(self.c.Tr.ChangingDirectoryTo, map[string]string{"path": path}) - self.c.LogCommand(msg, false) - - if err := os.Chdir(path); err != nil { - if os.IsNotExist(err) { - return errors.New(errMsg) - } + if err := commands.VerifyInGitRepo(self.c.OS()); err != nil { + if err := os.Chdir(originalPath); err != nil { return err } - if err := commands.VerifyInGitRepo(self.c.OS()); err != nil { - if err := os.Chdir(originalPath); err != nil { - return err - } + return err + } - return err - } + direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd)) - direnvResult := self.logDirenvResult(direnv.Load(self.c.OS().Cmd)) + if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil { + self.c.Log.Errorf("error recording current directory: %v", err) + } - if err := self.recordDirectoryHelper.RecordCurrentDirectory(); err != nil { - self.c.Log.Errorf("error recording current directory: %v", err) - } + if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil { + return err + } - if err := self.onNewRepo(appTypes.StartArgs{}, contextKey); err != nil { - return err - } + if direnvResult.Blocked { + self.promptDirenvApproval(direnvResult.EnvrcPath) + return nil + } - if direnvResult.Blocked { - self.c.OnUIThread(func() error { - self.promptDirenvApproval(direnvResult.EnvrcPath) - return nil - }) - return nil - } - - return direnvResult.Err - }) + return direnvResult.Err } // logDirenvResult writes whatever direnv emitted to the command log and the diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 7cec9f873..2a189a752 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -432,6 +432,12 @@ func (self *WorktreeHelper) createWorktree(opts git_commands.NewWorktreeOpts, co return err } - return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey) + // The switch swaps gui.State and must run on the UI thread, but + // we're on a worker here (creating the worktree is git work), so + // dispatch it rather than calling it directly. + self.c.OnUIThread(func() error { + return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey) + }) + return nil }) } From e352cafd43bea26fedefa6e0f6e45bcad8213d4f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 20:56:00 +0200 Subject: [PATCH 225/384] Add background tasks and a synchronous busy query to gocui Repo-switch safety needs to answer, synchronously on the UI thread, "is any foreground work in flight right now?" so it can refuse a switch that would run against a repo about to be swapped out. gocui already tracks a task per OnWorker/Update for the test idle-listener; extend that. Tasks gain a background flag: background tasks (the ongoing routines like auto-fetch, and the refreshes they trigger) don't count towards busy, because their model writes are already guarded against a concurrent switch by the repo generation. Add OnWorkerBackground, UpdateBackground and UpdateContentOnlyBackground (plus the gui-layer OnUIThreadBackground / OnUIThreadContentOnlyBackground / OnWorkerBackground on IGuiCommon) so the few background call sites can opt in without touching the hundreds of foreground callers. TaskManager.hasBusyForegroundTaskExcept answers the query; Gui.Busy() wraps it, excluding the event currently being processed (recorded as currentTask) so a handler asking the question doesn't count itself. Nothing gates on Busy() yet; this is the mechanism only. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 65 ++++++++++++++++++++++++++++++---- pkg/gocui/task.go | 18 +++++++++- pkg/gocui/task_manager.go | 26 ++++++++++++-- pkg/gocui/task_manager_test.go | 63 ++++++++++++++++++++++++++++++++ pkg/gui/gui.go | 16 +++++++++ pkg/gui/gui_common.go | 12 +++++++ pkg/gui/types/common.go | 9 +++++ 7 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 pkg/gocui/task_manager_test.go diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index ee1995911..ff113a2dd 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -193,6 +193,12 @@ type Gui struct { taskManager *TaskManager + // The task of the event currently being processed on the main goroutine, if + // any. Only touched from the main goroutine (in processEvent). It's excluded + // from the Busy() check so that an event handler asking "is anything else + // busy?" doesn't count itself. + currentTask Task + lastHoverView *View } @@ -273,7 +279,15 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { } func (g *Gui) NewTask() *TaskImpl { - return g.taskManager.NewTask() + return g.taskManager.NewTask(false) +} + +// Busy reports whether any foreground work is in flight, ignoring the event +// currently being processed on the main goroutine (see currentTask). Background +// routines (auto-fetch etc.) don't count. It's used to decide whether it's safe +// to switch repos. Must be called on the main goroutine. +func (g *Gui) Busy() bool { + return g.taskManager.hasBusyForegroundTaskExcept(g.currentTask) } // An idle listener listens for when the program is idle. This is useful for @@ -628,7 +642,18 @@ type userEvent struct { // never fire in practice; if it does, that's a signal to investigate, not // to grow the buffer reflexively. func (g *Gui) Update(f func(*Gui) error) { - task := g.NewTask() + g.update(f, false) +} + +// Like Update, but the enqueued work is a background routine (or triggered by +// one), so it doesn't count towards the program being busy for repo-switch +// safety. See TaskImpl.background. +func (g *Gui) UpdateBackground(f func(*Gui) error) { + g.update(f, true) +} + +func (g *Gui) update(f func(*Gui) error, background bool) { + task := g.taskManager.NewTask(background) select { case g.userEvents <- userEvent{f: f, task: task}: @@ -639,7 +664,16 @@ func (g *Gui) Update(f func(*Gui) error) { // Like Update, but signals that the callback only modifies content. func (g *Gui) UpdateContentOnly(f func(*Gui) error) { - task := g.NewTask() + g.updateContentOnly(f, false) +} + +// Like UpdateContentOnly, but for background work (see UpdateBackground). +func (g *Gui) UpdateContentOnlyBackground(f func(*Gui) error) { + g.updateContentOnly(f, true) +} + +func (g *Gui) updateContentOnly(f func(*Gui) error, background bool) { + task := g.taskManager.NewTask(background) g.userEvents <- userEvent{f: f, task: task, contentOnly: true} } @@ -650,7 +684,18 @@ func (g *Gui) UpdateContentOnly(f func(*Gui) error) { // background goroutines where you wouldn't want lazygit to be considered busy // (i.e. when you wouldn't want a loader to be shown to the user) func (g *Gui) OnWorker(f func(Task) error) { - task := g.NewTask() + g.onWorker(f, false) +} + +// Like OnWorker, but for a background routine (or work triggered by one), so it +// doesn't count towards the program being busy for repo-switch safety. See +// TaskImpl.background. +func (g *Gui) OnWorkerBackground(f func(Task) error) { + g.onWorker(f, true) +} + +func (g *Gui) onWorker(f func(Task) error, background bool) { + task := g.taskManager.NewTask(background) go func() { g.onWorkerAux(f, task) task.Done() @@ -758,17 +803,25 @@ func (g *Gui) handleError(err error) error { func (g *Gui) processEvent() error { contentOnly := false + // currentTask is the task of the event we're about to handle; recording it + // lets Busy() ignore it, so a handler asking "is anything else busy?" (the + // repo-switch guard does) doesn't count itself. Handlers of the remaining + // events drained below run with currentTask still set to this primary event; + // that's fine because the only Busy() callers are keybinding handlers, which + // are always the primary event here. select { case ev := <-g.gEvents: task := g.NewTask() - defer func() { task.Done() }() + g.currentTask = task + defer func() { g.currentTask = nil; task.Done() }() if err := g.handleError(g.handleEvent(&ev)); err != nil { return err } case ev := <-g.userEvents: contentOnly = ev.contentOnly - defer func() { ev.task.Done() }() + g.currentTask = ev.task + defer func() { g.currentTask = nil; ev.task.Done() }() if err := g.handleError(ev.f(g)); err != nil { return err diff --git a/pkg/gocui/task.go b/pkg/gocui/task.go index ace72f4a8..377781a4f 100644 --- a/pkg/gocui/task.go +++ b/pkg/gocui/task.go @@ -8,8 +8,9 @@ type Task interface { Done() Pause() Continue() - // not exporting because we don't need to + // not exporting these because we don't need to isBusy() bool + isBackground() bool } type TaskImpl struct { @@ -17,6 +18,13 @@ type TaskImpl struct { busy bool onDone func() withMutex func(func()) + // Background tasks don't count towards the program being "busy" for the + // purpose of deciding whether a repo switch is safe (see + // TaskManager.hasBusyForegroundTaskExcept). They're the ongoing background + // routines (auto-fetch, files refresh, external-change detection) and the + // refreshes they trigger, whose model writes are already guarded against a + // concurrent repo switch by the repo generation. + background bool } func (self *TaskImpl) Done() { @@ -39,6 +47,10 @@ func (self *TaskImpl) isBusy() bool { return self.busy } +func (self *TaskImpl) isBackground() bool { + return self.background +} + type TaskStatus int const ( @@ -73,6 +85,10 @@ func (self *FakeTask) isBusy() bool { return self.status == TaskStatusBusy } +func (self *FakeTask) isBackground() bool { + return false +} + func (self *FakeTask) Status() TaskStatus { return self.status } diff --git a/pkg/gocui/task_manager.go b/pkg/gocui/task_manager.go index e3c82b4d4..23ef0f77e 100644 --- a/pkg/gocui/task_manager.go +++ b/pkg/gocui/task_manager.go @@ -22,7 +22,7 @@ func newTaskManager() *TaskManager { } } -func (self *TaskManager) NewTask() *TaskImpl { +func (self *TaskManager) NewTask(background bool) *TaskImpl { self.mutex.Lock() defer self.mutex.Unlock() @@ -30,12 +30,34 @@ func (self *TaskManager) NewTask() *TaskImpl { taskId := self.nextId onDone := func() { self.delete(taskId) } - task := &TaskImpl{id: taskId, busy: true, onDone: onDone, withMutex: self.withMutex} + task := &TaskImpl{id: taskId, busy: true, background: background, onDone: onDone, withMutex: self.withMutex} self.tasks[taskId] = task return task } +// hasBusyForegroundTaskExcept reports whether any task other than `ignore` is +// currently busy and not a background task. It's used to decide whether a repo +// switch is safe: a foreground operation (or the refresh it triggers, or that +// refresh's follow-up callbacks) still in flight means the switch must wait, so +// it doesn't run against a repo that's about to be swapped out. +// +// `ignore` is the event currently being processed on the UI thread — the switch +// attempt itself — which is always busy and so must not count as a reason to +// refuse itself. +func (self *TaskManager) hasBusyForegroundTaskExcept(ignore Task) bool { + self.mutex.Lock() + defer self.mutex.Unlock() + + for _, task := range self.tasks { + if task != ignore && task.isBusy() && !task.isBackground() { + return true + } + } + + return false +} + func (self *TaskManager) addIdleListener(c chan struct{}) { self.idleListeners = append(self.idleListeners, c) } diff --git a/pkg/gocui/task_manager_test.go b/pkg/gocui/task_manager_test.go new file mode 100644 index 000000000..7fe706d7a --- /dev/null +++ b/pkg/gocui/task_manager_test.go @@ -0,0 +1,63 @@ +package gocui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTaskManagerHasBusyForegroundTaskExcept(t *testing.T) { + t.Run("no tasks", func(t *testing.T) { + tm := newTaskManager() + assert.False(t, tm.hasBusyForegroundTaskExcept(nil)) + }) + + t.Run("a busy foreground task counts", func(t *testing.T) { + tm := newTaskManager() + tm.NewTask(false) + assert.True(t, tm.hasBusyForegroundTaskExcept(nil)) + }) + + t.Run("a busy background task does not count", func(t *testing.T) { + tm := newTaskManager() + tm.NewTask(true) + assert.False(t, tm.hasBusyForegroundTaskExcept(nil)) + }) + + t.Run("a done foreground task does not count", func(t *testing.T) { + tm := newTaskManager() + task := tm.NewTask(false) + task.Done() + assert.False(t, tm.hasBusyForegroundTaskExcept(nil)) + }) + + t.Run("a paused foreground task does not count", func(t *testing.T) { + tm := newTaskManager() + task := tm.NewTask(false) + task.Pause() + assert.False(t, tm.hasBusyForegroundTaskExcept(nil)) + }) + + t.Run("the ignored task does not count", func(t *testing.T) { + tm := newTaskManager() + task := tm.NewTask(false) + assert.False(t, tm.hasBusyForegroundTaskExcept(task)) + }) + + t.Run("another foreground task counts even when one is ignored", func(t *testing.T) { + tm := newTaskManager() + ignored := tm.NewTask(false) + tm.NewTask(false) + assert.True(t, tm.hasBusyForegroundTaskExcept(ignored)) + }) + + t.Run("only a background task alongside the ignored current event", func(t *testing.T) { + // This is the repo-switch case: the switch is handled as the current + // event (ignored) while a background refresh is in flight; it must not + // be considered busy. + tm := newTaskManager() + current := tm.NewTask(false) + tm.NewTask(true) + assert.False(t, tm.hasBusyForegroundTaskExcept(current)) + }) +} diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 834cf1e2d..5aa8beaec 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -1194,16 +1194,32 @@ func (gui *Gui) onUIThread(f func() error) { }) } +func (gui *Gui) onUIThreadBackground(f func() error) { + gui.g.UpdateBackground(func(*gocui.Gui) error { + return f() + }) +} + func (gui *Gui) onUIThreadContentOnly(f func() error) { gui.g.UpdateContentOnly(func(*gocui.Gui) error { return f() }) } +func (gui *Gui) onUIThreadContentOnlyBackground(f func() error) { + gui.g.UpdateContentOnlyBackground(func(*gocui.Gui) error { + return f() + }) +} + func (gui *Gui) onWorker(f func(gocui.Task) error) { gui.g.OnWorker(f) } +func (gui *Gui) onWorkerBackground(f func(gocui.Task) error) { + gui.g.OnWorkerBackground(f) +} + func (gui *Gui) getWindowDimensions(informationStr string, appStatus string) map[string]boxlayout.Dimensions { return gui.helpers.WindowArrangement.GetWindowDimensions(informationStr, appStatus) } diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index c74a99a05..d13120508 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -124,14 +124,26 @@ func (self *guiCommon) OnUIThread(f func() error) { self.gui.onUIThread(f) } +func (self *guiCommon) OnUIThreadBackground(f func() error) { + self.gui.onUIThreadBackground(f) +} + func (self *guiCommon) OnUIThreadContentOnly(f func() error) { self.gui.onUIThreadContentOnly(f) } +func (self *guiCommon) OnUIThreadContentOnlyBackground(f func() error) { + self.gui.onUIThreadContentOnlyBackground(f) +} + func (self *guiCommon) OnWorker(f func(gocui.Task) error) { self.gui.onWorker(f) } +func (self *guiCommon) OnWorkerBackground(f func(gocui.Task) error) { + self.gui.onWorkerBackground(f) +} + func (self *guiCommon) RenderToMainViews(opts types.RefreshMainOpts) { self.gui.refreshMainViews(opts) } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 2ce07f9c7..766a5a757 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -75,13 +75,22 @@ type IGuiCommon interface { // Only necessary to call if you're not already on the UI thread i.e. you're inside a goroutine. // All controller handlers are executed on the UI thread. OnUIThread(f func() error) + // Like OnUIThread, but for work triggered by a background routine, so it + // doesn't count towards lazygit being busy (see the *Background methods on + // gocui.Gui and repo-switch safety). + OnUIThreadBackground(f func() error) // Like OnUIThread, but signals that the callback only modifies view // content (e.g. spinner), allows the event loop to skip // the expensive layout recalculation when only content changed. OnUIThreadContentOnly(f func() error) + // Like OnUIThreadContentOnly, but for background work (see OnUIThreadBackground). + OnUIThreadContentOnlyBackground(f func() error) // Runs a function in a goroutine. Use this whenever you want to run a goroutine and keep track of the fact // that lazygit is still busy. See docs/dev/Busy.md OnWorker(f func(gocui.Task) error) + // Like OnWorker, but for a background routine (or work it triggers), so it + // doesn't count towards lazygit being busy (see OnUIThreadBackground). + OnWorkerBackground(f func(gocui.Task) error) // Function to call at the end of our 'layout' function which renders views // For example, you may want a view's line to be focused only after that view is // resized, if in accordion mode. From d95900ccd08e64dddcee2606f4787c3224749130 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 3 Jul 2026 21:15:01 +0200 Subject: [PATCH 226/384] Tag background routines and their refreshes as background tasks For the busy query to be usable as a repo-switch guard it has to be false while the ongoing background routines run, or a switch would be refused every time a background fetch or files refresh happened to be in flight. Mark that work as background so it's excluded from the query. The background routine dispatch in goEvery becomes OnWorkerBackground, and the auto-fetch waiting status renders its spinner through the background variants. Within a refresh, the background flag (which Refresh already carries as options.Background, and which the files path already threaded) is now threaded through every place that enqueues a task: the async scope workers, the model-write bounces (onUIThreadUnlessRepoChanged), refreshView, the staging bounce, the Then dispatch, and the branch-loader's behind-count worker. Two single-caller chains reached by a background files refresh get the flag too: MergeConflictsHelper.EscapeMerge and BranchesHelper. AutoForwardBranches (whose follow-up refresh must stay background when triggered by the background fetch). Nothing gates on the busy query yet, so this is behavior-preserving; background tasks still count as busy for the test idle-listener, which looks at every task regardless of the background flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/background.go | 7 +- .../controllers/helpers/app_status_helper.go | 31 ++- .../controllers/helpers/branches_helper.go | 6 +- .../helpers/merge_conflicts_helper.go | 14 +- pkg/gui/controllers/helpers/refresh_helper.go | 189 ++++++++++-------- 5 files changed, 146 insertions(+), 101 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 94bf4f678..6215f0d45 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -114,7 +114,7 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() { if self.gui.UserConfig().Gui.ShowBottomLine || firstTimeOrRetriggered { return self.gui.helpers.AppStatus.WithWaitingStatusImpl(self.gui.Tr.FetchingStatus, func(gocui.Task) error { return self.backgroundFetch() - }, nil) + }, nil, true) } return self.backgroundFetch() @@ -198,7 +198,10 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru if self.backgroundRefreshesPaused() { return } - self.gui.c.OnWorker(func(gocui.Task) error { + // OnWorkerBackground, not OnWorker: these routines and the refreshes + // they trigger must not count towards lazygit being busy, or they'd + // spuriously block a repo switch every time one happens to be running. + self.gui.c.OnWorkerBackground(func(gocui.Task) error { _ = function(retriggered) done <- struct{}{} return nil diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index b691db4a4..bffd87866 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -34,7 +34,7 @@ func (self *AppStatusHelper) Toast(message string, kind types.ToastKind) { self.statusMgr().AddToastStatus(message, kind) - self.renderAppStatus() + self.renderAppStatus(false) } // A custom task for WithWaitingStatus calls; it wraps the original one and @@ -61,11 +61,14 @@ func (self appStatusHelperTask) Continue() { // WithWaitingStatus wraps a function and shows a waiting status while the function is still executing func (self *AppStatusHelper) WithWaitingStatus(message string, f func(gocui.Task) error) { self.c.OnWorker(func(task gocui.Task) error { - return self.WithWaitingStatusImpl(message, f, task) + return self.WithWaitingStatusImpl(message, f, task, false) }) } -func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task) error { +// background reports whether this waiting status belongs to a background routine +// (the auto-fetch poller); when it does, the spinner it drives must not count +// towards lazygit being busy, or it'd block repo switches while a fetch runs. +func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task, background bool) error { // A waiting status means lazygit is driving a git operation itself (often // one that internally runs a rebase and continues it). Pause the background // routines for its duration so they don't refresh from an intermediate @@ -73,7 +76,7 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui. self.c.PauseBackgroundRefreshes(true) defer self.c.PauseBackgroundRefreshes(false) - return self.statusMgr().WithWaitingStatus(message, self.renderAppStatus, func(waitingStatusHandle *status.WaitingStatusHandle) error { + return self.statusMgr().WithWaitingStatus(message, func() { self.renderAppStatus(background) }, func(waitingStatusHandle *status.WaitingStatusHandle) error { return f(appStatusHelperTask{task, waitingStatusHandle}) }) } @@ -100,21 +103,33 @@ func (self *AppStatusHelper) GetStatusString() string { return appStatus } -func (self *AppStatusHelper) renderAppStatus() { - self.c.OnWorker(func(_ gocui.Task) error { +func (self *AppStatusHelper) renderAppStatus(background bool) { + // A background waiting status (auto-fetch) must not count towards lazygit + // being busy, so its spinner worker and per-frame UI updates go through the + // background variants. + onWorker := self.c.OnWorker + onUIThread := self.c.OnUIThread + onUIThreadContentOnly := self.c.OnUIThreadContentOnly + if background { + onWorker = self.c.OnWorkerBackground + onUIThread = self.c.OnUIThreadBackground + onUIThreadContentOnly = self.c.OnUIThreadContentOnlyBackground + } + + onWorker(func(_ gocui.Task) error { ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate)) defer ticker.Stop() prevAppStatus := "" for range ticker.C { appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig()) - update := self.c.OnUIThreadContentOnly + update := onUIThreadContentOnly if utils.StringWidth(appStatus) != utils.StringWidth(prevAppStatus) { // Need a full layout whenever the width of the status string changes. This can't // happen during normal spinning because we validate that all spinner frames have // the same width, so typically this will only be triggered at the beginning and end // of a status, or if the status string changes midway for some reason. - update = self.c.OnUIThread + update = onUIThread } update(func() error { self.c.Views().AppStatus.FgColor = color diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index e5c1a07e4..053804760 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -398,7 +398,7 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er if fetchErr != nil { return nil } - err := self.AutoForwardBranches() + err := self.AutoForwardBranches(background) if background && err != nil { // The background poller discards this return value, so surface // the error in the log rather than as a popup for background work. @@ -411,7 +411,7 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er return fetchErr } -func (self *BranchesHelper) AutoForwardBranches() error { +func (self *BranchesHelper) AutoForwardBranches(background bool) error { if self.c.UserConfig().Git.AutoForwardBranches == "none" { return nil } @@ -443,7 +443,7 @@ func (self *BranchesHelper) AutoForwardBranches() error { self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false) err := self.c.Git().Branch.UpdateBranchRefs(updateCommands) - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Mode: types.SYNC}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Mode: types.SYNC, Background: background}) return err } diff --git a/pkg/gui/controllers/helpers/merge_conflicts_helper.go b/pkg/gui/controllers/helpers/merge_conflicts_helper.go index 6e6a01531..175bc3cc0 100644 --- a/pkg/gui/controllers/helpers/merge_conflicts_helper.go +++ b/pkg/gui/controllers/helpers/merge_conflicts_helper.go @@ -51,11 +51,17 @@ func (self *MergeConflictsHelper) resetMergeState() { self.context().GetState().Reset() } -func (self *MergeConflictsHelper) EscapeMerge() error { +func (self *MergeConflictsHelper) EscapeMerge(background bool) error { self.resetMergeState() // doing this in separate UI thread so that we're not still holding the lock by the time refresh the file - self.c.OnUIThread(func() error { + onUIThread := self.c.OnUIThread + if background { + // Reached from a background files refresh; keep it off the busy count + // (see the *Background dispatch methods) so it doesn't block a repo switch. + onUIThread = self.c.OnUIThreadBackground + } + onUIThread(func() error { // There is a race condition here: refreshing the files scope can trigger the // confirmation context to be pushed if all conflicts are resolved (prompting // to continue the merge/rebase. In that case, we don't want to then push the @@ -120,7 +126,7 @@ func (self *MergeConflictsHelper) Render() { }) } -func (self *MergeConflictsHelper) RefreshMergeState() error { +func (self *MergeConflictsHelper) RefreshMergeState(background bool) error { self.c.Contexts().MergeConflicts.GetMutex().Lock() defer self.c.Contexts().MergeConflicts.GetMutex().Unlock() @@ -134,7 +140,7 @@ func (self *MergeConflictsHelper) RefreshMergeState() error { } if !hasConflicts { - return self.EscapeMerge() + return self.EscapeMerge(background) } return nil diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index fe89bbad6..c0543d095 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -154,7 +154,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // everything happens fast and it's better to have everything update // in the one frame if !self.c.InDemo() && options.Mode == types.ASYNC { - self.c.OnWorker(func(t gocui.Task) error { + self.onWorker(options.Background, func(t gocui.Task) error { f() return nil }) @@ -176,14 +176,14 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // counts can change. Whenever we change branches we should also change commits // e.g. in the case of switching branches. refresh("commits and commit files", func() { - self.refreshCommitsAndCommitFiles(options.CommitSelection) + self.refreshCommitsAndCommitFiles(options.CommitSelection, options.Background) }) includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { branchesAndRemotesWg.Add(1) refresh("reflog and branches", func() { - self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex) + self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, options.Background) branchesAndRemotesWg.Done() }) } else { @@ -192,24 +192,24 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // Not a recency sort, so branches doesn't depend on the reflog // being fresh; it runs concurrently with the reflog refresh // below and reads whatever's in the model, as it always has. - self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true, self.c.Model().ReflogCommits) + self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true, self.c.Model().ReflogCommits, options.Background) branchesAndRemotesWg.Done() }) - refresh("reflog", func() { _, _ = self.refreshReflogCommits() }) + refresh("reflog", func() { _, _ = self.refreshReflogCommits(options.Background) }) } } 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 - refresh("rebase commits", func() { _ = self.refreshRebaseCommits() }) + refresh("rebase commits", func() { _ = self.refreshRebaseCommits(options.Background) }) } if scopeSet.Includes(types.SUB_COMMITS) { - refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit() }) + refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(options.Background) }) } // 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) { - refresh("commit files", func() { _ = self.refreshCommitFilesContext() }) + refresh("commit files", func() { _ = self.refreshCommitFilesContext(options.Background) }) } fileWg := sync.WaitGroup{} @@ -222,17 +222,17 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { } if scopeSet.Includes(types.STASH) { - refresh("stash", func() { self.refreshStashEntries() }) + refresh("stash", func() { self.refreshStashEntries(options.Background) }) } if scopeSet.Includes(types.TAGS) { - refresh("tags", func() { _ = self.refreshTags() }) + refresh("tags", func() { _ = self.refreshTags(options.Background) }) } if scopeSet.Includes(types.REMOTES) { branchesAndRemotesWg.Add(1) refresh("remotes", func() { - _ = self.refreshRemotes() + _ = self.refreshRemotes(options.Background) branchesAndRemotesWg.Done() }) } @@ -240,12 +240,12 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if scopeSet.Includes(types.PULL_REQUESTS) { refresh("pull requests", func() { branchesAndRemotesWg.Wait() - self.refreshGithubPullRequests() + self.refreshGithubPullRequests(options.Background) }) } if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches { - refresh("worktrees", func() { self.refreshWorktrees() }) + refresh("worktrees", func() { self.refreshWorktrees(options.Background) }) } if scopeSet.Includes(types.STAGING) { @@ -255,7 +255,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // scope's model-update bounce — RefreshStagingPanel reads // Model.Files (via Files.GetSelected) and would otherwise // see the pre-refresh model. - self.c.OnUIThread(func() error { + self.onUIThread(options.Background, func() error { self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) return nil }) @@ -267,10 +267,10 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { } if scopeSet.Includes(types.MERGE_CONFLICTS) { - refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState() }) + refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState(options.Background) }) } - self.refreshStatus() + self.refreshStatus(options.Background) wg.Wait() @@ -281,7 +281,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // returned but their bounces haven't been processed yet, so // invoking Then synchronously would run it on a model that's // still pre-refresh. - self.c.OnUIThread(options.Then) + self.onUIThread(options.Background, options.Then) } } @@ -399,27 +399,27 @@ func getModeName(mode types.RefreshMode) string { // order gives the immediate (non-recency) load a lower branch-load sequence // than the async (recency) load, so the sequence guard in refreshBranches keeps // the recency-sorted result even if the two loads' bounces land out of order. -func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool) { +func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, background bool) { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: - self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, false, self.c.Model().ReflogCommits) + self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, false, self.c.Model().ReflogCommits, background) - self.c.OnWorker(func(_ gocui.Task) error { - reflogCommits, _ := self.refreshReflogCommits() - self.refreshBranches(false, true, true, reflogCommits) + self.onWorker(background, func(_ gocui.Task) error { + reflogCommits, _ := self.refreshReflogCommits(background) + self.refreshBranches(false, true, true, reflogCommits, background) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) case types.COMPLETE: - reflogCommits, _ := self.refreshReflogCommits() - self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, true, reflogCommits) + reflogCommits, _ := self.refreshReflogCommits(background) + self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, true, reflogCommits, background) } } -func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior) { +func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior, background bool) { generation := self.c.State().GetRepoGeneration() - _ = self.refreshCommitsWithLimit(commitSelection) + _ = self.refreshCommitsWithLimit(commitSelection, background) ctx := self.c.Contexts().CommitFiles.GetParentContext() if ctx != nil && ctx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY { // This makes sense when we've e.g. just amended a commit, meaning we get a new commit hash at the same position. @@ -432,13 +432,13 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.Co // The commit selection is restored in refreshCommitsWithLimit's bounce, // so read it on the UI thread after that bounce; then load the commit // files back on a worker (refreshCommitFilesContext does git work). - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { commit := self.c.Contexts().LocalCommits.GetSelected() if commit != nil && commit.RefName() != "" { refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() self.c.Contexts().CommitFiles.ReInit(commit, refRange) - self.c.OnWorker(func(gocui.Task) error { - _ = self.refreshCommitFilesContext() + self.onWorker(background, func(gocui.Task) error { + _ = self.refreshCommitFilesContext(background) return nil }) } @@ -473,7 +473,7 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref { return nil } -func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitSelectionBehavior) error { +func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitSelectionBehavior, background bool) error { generation := self.c.State().GetRepoGeneration() var selectionRange *localCommitSelectionRange @@ -502,7 +502,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS } workingTreeState := self.c.Git().Status.WorkingTreeState() - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits self.RefreshAuthors(commits) @@ -536,7 +536,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS // 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(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Contexts().LocalCommits.FocusLine(true) return nil }) @@ -544,7 +544,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS return nil }) - self.refreshView(self.c.Contexts().LocalCommits) + self.refreshView(self.c.Contexts().LocalCommits, background) return nil } @@ -623,7 +623,7 @@ func hasRestorableCommitHash(commits []*models.Commit, idx int) bool { return idx >= 0 && idx < len(commits) && commits[idx].Hash() != "" } -func (self *RefreshHelper) refreshSubCommitsWithLimit() error { +func (self *RefreshHelper) refreshSubCommitsWithLimit(background bool) error { if self.c.Contexts().SubCommits.GetRef() == nil { return nil } @@ -646,13 +646,13 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit() error { if err != nil { return err } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().SubCommits = commits self.RefreshAuthors(commits) return nil }) - self.refreshView(self.c.Contexts().SubCommits) + self.refreshView(self.c.Contexts().SubCommits, background) return nil } @@ -668,7 +668,7 @@ func (self *RefreshHelper) RefreshAuthors(commits []*models.Commit) { } } -func (self *RefreshHelper) refreshCommitFilesContext() error { +func (self *RefreshHelper) refreshCommitFilesContext(background bool) error { from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) generation := self.c.State().GetRepoGeneration() @@ -677,16 +677,16 @@ func (self *RefreshHelper) refreshCommitFilesContext() error { if err != nil { return err } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().CommitFiles = files self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree() return nil }) - self.refreshView(self.c.Contexts().CommitFiles) + self.refreshView(self.c.Contexts().CommitFiles, background) return nil } -func (self *RefreshHelper) refreshRebaseCommits() error { +func (self *RefreshHelper) refreshRebaseCommits(background bool) error { generation := self.c.State().GetRepoGeneration() updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(self.c.Model().HashPool, self.c.Model().Commits) @@ -695,17 +695,17 @@ func (self *RefreshHelper) refreshRebaseCommits() error { } workingTreeState := self.c.Git().Status.WorkingTreeState() - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().Commits = updatedCommits self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState return nil }) - self.refreshView(self.c.Contexts().LocalCommits) + self.refreshView(self.c.Contexts().LocalCommits, background) return nil } -func (self *RefreshHelper) refreshTags() error { +func (self *RefreshHelper) refreshTags(background bool) error { generation := self.c.State().GetRepoGeneration() tags, err := self.c.Git().Loaders.TagLoader.GetTags() @@ -713,12 +713,12 @@ func (self *RefreshHelper) refreshTags() error { return err } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().Tags = tags return nil }) - self.refreshView(self.c.Contexts().Tags) + self.refreshView(self.c.Contexts().Tags, background) return nil } @@ -728,7 +728,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // 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(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool, reflogCommits []*models.Commit) { +func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) { loadSeq := self.branchLoadSeq.Add(1) generation := self.c.State().GetRepoGeneration() @@ -739,14 +739,14 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele self.c.Model().Branches, loadBehindCounts, func(f func() error) { - self.c.OnWorker(func(_ gocui.Task) error { + self.onWorker(background, func(_ gocui.Task) error { return f() }) }, func() { - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Contexts().Branches.HandleRender() - self.refreshStatus() + self.refreshStatus(background) return nil }) }) @@ -761,7 +761,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele worktrees = self.loadWorktrees() } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { // Drop this write if a branch load that started later has already applied // its result. At the INITIAL startup stage an immediate load (not // recency-sorted) and an async recency-sorted load run concurrently; this @@ -779,7 +779,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele if refreshWorktrees { self.c.Model().Worktrees = worktrees - self.refreshView(self.c.Contexts().Worktrees) + self.refreshView(self.c.Contexts().Worktrees, background) } if !keepBranchSelectionIndex && prevSelectedBranch != nil { @@ -798,9 +798,9 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele return nil }) - self.refreshView(self.c.Contexts().Branches) + self.refreshView(self.c.Contexts().Branches, background) - self.refreshStatus() + self.refreshStatus(background) } func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { @@ -813,8 +813,8 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { return err } - self.refreshView(self.c.Contexts().Submodules) - self.refreshView(self.c.Contexts().Files) + self.refreshView(self.c.Contexts().Submodules, background) + self.refreshView(self.c.Contexts().Files, background) return nil } @@ -826,8 +826,8 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { // bumps the generation, so a write captured under the old generation must not // clobber the new repo's state. Callers capture the generation with // State().GetRepoGeneration() before doing their git work and pass it in. -func (self *RefreshHelper) onUIThreadUnlessRepoChanged(generation int, f func() error) { - self.c.OnUIThread(func() error { +func (self *RefreshHelper) onUIThreadUnlessRepoChanged(generation int, background bool, f func() error) { + self.onUIThread(background, func() error { if self.c.State().GetRepoGeneration() != generation { return nil } @@ -835,6 +835,27 @@ func (self *RefreshHelper) onUIThreadUnlessRepoChanged(generation int, f func() }) } +// onWorker and onUIThread pick the foreground or background variant of the +// corresponding dispatch method depending on whether we're servicing a +// background refresh. Background refreshes (auto-fetch and friends) must not +// count towards lazygit being busy, or they'd spuriously block a repo switch; +// see the *Background methods on gocui.Gui. +func (self *RefreshHelper) onWorker(background bool, f func(gocui.Task) error) { + if background { + self.c.OnWorkerBackground(f) + } else { + self.c.OnWorker(f) + } +} + +func (self *RefreshHelper) onUIThread(background bool, f func() error) { + if background { + self.c.OnUIThreadBackground(f) + } else { + self.c.OnUIThread(f) + } +} + func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs []*models.SubmoduleConfig) error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel generation := self.c.State().GetRepoGeneration() @@ -898,7 +919,7 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // (e.g. in the user's editor). Offer to continue it. We only do this // for operations we started ourselves; prompting for one that was // started outside lazygit (e.g. by a coding agent) would be confusing. - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { return self.mergeAndRebaseHelper.PromptToContinueRebase() }) } @@ -913,7 +934,7 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ }) } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { // only taking over the filter if it hasn't already been set by the user. if conflictFileCount > 0 && prevConflictFileCount == 0 { if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { @@ -944,7 +965,7 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // refreshReflogCommits returns the (non-filtered) ReflogCommits it loaded, so // that a subsequent branches refresh can use them for recency sorting without // having to read them back out of the model. -func (self *RefreshHelper) refreshReflogCommits() ([]*models.Commit, error) { +func (self *RefreshHelper) refreshReflogCommits(background bool) ([]*models.Commit, error) { generation := self.c.State().GetRepoGeneration() // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception @@ -984,17 +1005,17 @@ func (self *RefreshHelper) refreshReflogCommits() ([]*models.Commit, error) { } } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { model.ReflogCommits = reflogCommits model.FilteredReflogCommits = filteredReflogCommits return nil }) - self.refreshView(self.c.Contexts().ReflogCommits) + self.refreshView(self.c.Contexts().ReflogCommits, background) return reflogCommits, nil } -func (self *RefreshHelper) refreshRemotes() error { +func (self *RefreshHelper) refreshRemotes(background bool) error { generation := self.c.State().GetRepoGeneration() prevSelectedRemote := self.c.Contexts().Remotes.GetSelected() @@ -1003,14 +1024,14 @@ func (self *RefreshHelper) refreshRemotes() error { return err } - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().Remotes = remotes hadPrs := len(self.c.Model().PullRequestsMap) != 0 self.rebuildPullRequestsMap() if !hadPrs && len(self.c.Model().PullRequestsMap) != 0 { // if we didn't have PRs in the map before but now we do, we need to redraw the branches view - self.refreshView(self.c.Contexts().Branches) + self.refreshView(self.c.Contexts().Branches, background) } // we need to ensure our selected remote branches aren't now outdated @@ -1026,8 +1047,8 @@ func (self *RefreshHelper) refreshRemotes() error { return nil }) - self.refreshView(self.c.Contexts().Remotes) - self.refreshView(self.c.Contexts().RemoteBranches) + self.refreshView(self.c.Contexts().Remotes, background) + self.refreshView(self.c.Contexts().RemoteBranches, background) return nil } @@ -1040,44 +1061,44 @@ func (self *RefreshHelper) loadWorktrees() []*models.Worktree { return worktrees } -func (self *RefreshHelper) refreshWorktrees() { +func (self *RefreshHelper) refreshWorktrees(background bool) { generation := self.c.State().GetRepoGeneration() worktrees := self.loadWorktrees() - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().Worktrees = worktrees return nil }) // need to refresh branches because the branches view shows worktrees against // branches - self.refreshView(self.c.Contexts().Branches) - self.refreshView(self.c.Contexts().Worktrees) + self.refreshView(self.c.Contexts().Branches, background) + self.refreshView(self.c.Contexts().Worktrees, background) } -func (self *RefreshHelper) refreshStashEntries() { +func (self *RefreshHelper) refreshStashEntries(background bool) { generation := self.c.State().GetRepoGeneration() stashEntries := self.c.Git().Loaders.StashLoader. GetStashEntries(self.c.Modes().Filtering.GetPath()) - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().StashEntries = stashEntries return nil }) - self.refreshView(self.c.Contexts().Stash) + self.refreshView(self.c.Contexts().Stash, background) } // never call this on its own, it should only be called from within refreshCommits() -func (self *RefreshHelper) refreshStatus() { +func (self *RefreshHelper) refreshStatus(background bool) { generation := self.c.State().GetRepoGeneration() workingTreeState := self.c.Git().Status.WorkingTreeState() repoName := self.c.Git().RepoPaths.RepoName() - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { // Read the checked-out branch and the linked worktree name here on the UI // thread: both derive from models (Branches, Worktrees) that their // refreshes now write via bounces, so reading them on the worker would @@ -1114,10 +1135,10 @@ func (self *RefreshHelper) refForLog() (string, *git_commands.BisectInfo) { return bisectInfo.GetStartHash(), bisectInfo } -func (self *RefreshHelper) refreshView(context types.Context) { +func (self *RefreshHelper) refreshView(context types.Context, background bool) { // refreshView is called from the worker goroutine that drives async // refreshes, so bounce to the UI thread before mutating view content. - self.c.OnUIThread(func() error { + self.onUIThread(background, func() error { // Re-applying the filter must be done before re-rendering the view, so that // the filtered list model is up to date for rendering. self.searchHelper.ReApplyFilter(context) @@ -1140,11 +1161,11 @@ func (self *RefreshHelper) refreshView(context types.Context) { }) } -func (self *RefreshHelper) refreshGithubPullRequests() { +func (self *RefreshHelper) refreshGithubPullRequests(background bool) { generation := self.c.State().GetRepoGeneration() clearPullRequests := func() { - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().PullRequests = nil self.c.Model().PullRequestsMap = nil return nil @@ -1167,7 +1188,7 @@ func (self *RefreshHelper) refreshGithubPullRequests() { return } - self.setGithubPullRequests(baseInfo) + self.setGithubPullRequests(baseInfo, background) } type githubRemoteInfo struct { @@ -1248,7 +1269,7 @@ func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteI self.c.Log.Error(err) } - self.setGithubPullRequests(&info) + self.setGithubPullRequests(&info, false) return nil }) }, @@ -1276,7 +1297,7 @@ func (self *RefreshHelper) rebuildPullRequestsMap() { ) } -func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo) { +func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, background bool) { generation := self.c.State().GetRepoGeneration() if len(self.c.Model().Branches) == 0 { @@ -1298,7 +1319,7 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo) { self.savePullRequestsToCache(prs) - self.onUIThreadUnlessRepoChanged(generation, func() error { + self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().PullRequests = prs // Rebuilding here rather than on the worker means the map is built from // the branches and remotes as they are on the UI thread, after their From 8655d3f5a59d35cfc4176d66defed263b62d64a5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 4 Jul 2026 07:49:04 +0200 Subject: [PATCH 227/384] Exclude view-buffer render tasks from the busy query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo-switch busy query must not count view-buffer content rendering: those tasks paint a view rather than drive a git operation, so leaving one running across a switch is harmless (the switch's own refresh re-renders). More importantly, they fire on nearly every focus/selection change — including the context activation that runs right before a menu/prompt confirmation handler (e.g. confirming worktree creation). A synchronous busy check in such a handler would otherwise see that render and make the very switch the handler is about to request refuse itself. Route ViewBufferManager's tasks through a new gocui NewBackgroundTask so they're tracked for idle detection but excluded from the busy query. The task "background" flag now covers two kinds of non-blocking work: the background routines (and their refreshes) tagged earlier, and view rendering. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 7 +++++++ pkg/gocui/task.go | 12 ++++++++---- pkg/gui/tasks_adapter.go | 9 ++++++++- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index ff113a2dd..a13744997 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -282,6 +282,13 @@ func (g *Gui) NewTask() *TaskImpl { return g.taskManager.NewTask(false) } +// NewBackgroundTask creates a task that is tracked for idle detection but does +// not count towards the program being busy for repo-switch safety. See +// TaskImpl.background. +func (g *Gui) NewBackgroundTask() *TaskImpl { + return g.taskManager.NewTask(true) +} + // Busy reports whether any foreground work is in flight, ignoring the event // currently being processed on the main goroutine (see currentTask). Background // routines (auto-fetch etc.) don't count. It's used to decide whether it's safe diff --git a/pkg/gocui/task.go b/pkg/gocui/task.go index 377781a4f..08a77463f 100644 --- a/pkg/gocui/task.go +++ b/pkg/gocui/task.go @@ -20,10 +20,14 @@ type TaskImpl struct { withMutex func(func()) // Background tasks don't count towards the program being "busy" for the // purpose of deciding whether a repo switch is safe (see - // TaskManager.hasBusyForegroundTaskExcept). They're the ongoing background - // routines (auto-fetch, files refresh, external-change detection) and the - // refreshes they trigger, whose model writes are already guarded against a - // concurrent repo switch by the repo generation. + // TaskManager.hasBusyForegroundTaskExcept). Two kinds of work are tagged + // this way: the ongoing background routines (auto-fetch, files refresh, + // external-change detection) and the refreshes they trigger, whose model + // writes are already guarded against a concurrent repo switch by the repo + // generation; and view-buffer content rendering, which only paints a view + // and so is harmless to leave running across a switch. What stays + // foreground is lazygit driving a git operation and applying its results + // to the model — exactly the work a repo switch must not run underneath. background bool } diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 09edd2d36..dd7999107 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -136,7 +136,14 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { view.SetOrigin(0, 0) }, func() gocui.Task { - return gui.c.GocuiGui().NewTask() + // A background task: rendering content into a view is display + // work, not lazygit driving a git operation, so it must not + // count towards being busy and block a repo switch. These + // renders fire on nearly every focus/selection change, including + // the context activation that happens right before a menu/prompt + // handler runs (e.g. confirming worktree creation), which would + // otherwise make the switch that handler triggers refuse itself. + return gui.c.GocuiGui().NewBackgroundTask() }, ) gui.viewBufferManagerMap[view.Name()] = manager From 56932abe0659edfa0bac2704366d6d8a431ab709 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 4 Jul 2026 07:49:15 +0200 Subject: [PATCH 228/384] Refuse a repo switch while a foreground operation is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching repos reassigns gui.git and the process cwd; doing it while a foreground git operation (rebase/commit/push/…) is mid-flight would run that operation's remaining commands against the wrong repo. The same applies while the refresh an operation triggers is still settling: its model writes are generation-guarded, but the client-side Then/OnUIThread callbacks that run after it aren't, and shouldn't run against a repo that changed underneath them. Refuse the switch (with a toast) whenever gocui reports a busy foreground task. DispatchSwitchTo carries the guard for the simple callers. The callers that do work before the switch check up front instead, so a refused switch doesn't leave that work half-done: worktree creation checks before creating (its own waiting-status spinner would otherwise make the query busy and refuse its own switch); submodule-enter and the recent-repos menu check before mutating the repo-path stack (pushing / clearing it); and escape-to-parent (SwitchToParentRepo) checks before popping it, so a refusal doesn't consume the entry and strand the user with nowhere to escape back to. All then call the unguarded switchTo, which is safe because their own operation is complete by then. --- pkg/gui/controllers/helpers/repos_helper.go | 65 ++++++++++++++++--- .../controllers/helpers/worktree_helper.go | 13 +++- pkg/gui/controllers/quit_actions.go | 6 +- pkg/i18n/english.go | 2 + 4 files changed, 70 insertions(+), 16 deletions(-) diff --git a/pkg/gui/controllers/helpers/repos_helper.go b/pkg/gui/controllers/helpers/repos_helper.go index 6257327e9..a61ad0013 100644 --- a/pkg/gui/controllers/helpers/repos_helper.go +++ b/pkg/gui/controllers/helpers/repos_helper.go @@ -43,13 +43,20 @@ func NewRecentReposHelper( } func (self *ReposHelper) EnterSubmodule(submodule *models.SubmoduleConfig) error { + // Check before pushing onto the repo-path stack, so a refused switch + // doesn't leave a stale entry there (which escape would later switch back + // to, needlessly reloading the current repo). + if self.switchRefusedBecauseBusy() { + return nil + } + wd, err := os.Getwd() if err != nil { return err } self.c.State().GetRepoPathStack().Push(wd) - return self.DispatchSwitchToRepo(submodule.FullPath(), context.NO_CONTEXT) + return self.switchTo(submodule.FullPath(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) } func (self *ReposHelper) getCurrentBranch(path string) string { @@ -129,10 +136,16 @@ func (self *ReposHelper) CreateRecentReposMenu() error { style.FgMagenta.Sprint(path), }, OnPress: func() error { + // Check before clearing the stack, so a refused switch doesn't + // forget the submodule breadcrumb (which would leave escape + // unable to return to the parent repo). + if self.switchRefusedBecauseBusy() { + return nil + } // if we were in a submodule, we want to forget about that stack of repos // so that hitting escape in the new repo does nothing self.c.State().GetRepoPathStack().Clear() - return self.DispatchSwitchToRepo(path, context.NO_CONTEXT) + return self.switchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) }, } }) @@ -140,17 +153,49 @@ func (self *ReposHelper) CreateRecentReposMenu() error { return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.RecentRepos, Items: menuItems}) } -func (self *ReposHelper) DispatchSwitchToRepo(path string, contextKey types.ContextKey) error { - return self.DispatchSwitchTo(path, self.c.Tr.ErrRepositoryMovedOrDeleted, contextKey) +// SwitchToParentRepo switches back to the repo the current submodule was +// entered from (the top of the repo-path stack). Like the other callers that do +// work before switching, it checks for an in-flight operation *before* popping +// the stack, so a refused switch leaves the stack intact — otherwise the entry +// would be consumed and escape would no longer return to the parent once the +// operation finished. The caller must only call this when the stack is +// non-empty. +func (self *ReposHelper) SwitchToParentRepo() error { + if self.switchRefusedBecauseBusy() { + return nil + } + return self.switchTo(self.c.State().GetRepoPathStack().Pop(), self.c.Tr.ErrRepositoryMovedOrDeleted, context.NO_CONTEXT) } -// DispatchSwitchTo switches lazygit to the repository (or worktree) at the -// given path. It runs synchronously on the UI thread: the switch swaps -// gui.State (in resetState) and reassigns gui.git and the process cwd, all of -// which the UI thread also reads, so doing it here rather than on a worker -// avoids racing those reads. The heavy data loading is still dispatched -// asynchronously by the refresh that onNewRepo kicks off. func (self *ReposHelper) DispatchSwitchTo(path string, errMsg string, contextKey types.ContextKey) error { + if self.switchRefusedBecauseBusy() { + return nil + } + return self.switchTo(path, errMsg, contextKey) +} + +// switchRefusedBecauseBusy reports (and shows a toast) whether a repo switch +// must be refused because a foreground git operation is in flight. Switching +// reassigns gui.git and the process cwd, so switching mid-operation would run +// the operation's remaining git commands against the wrong repo. Callers that +// do work before the switch (creating a worktree, recording the repo-path +// stack) check this up front, so they don't do that work only to have the +// switch refused; the switch itself (switchTo) is then unguarded. +func (self *ReposHelper) switchRefusedBecauseBusy() bool { + if self.c.GocuiGui().Busy() { + self.c.ErrorToast(self.c.Tr.CantSwitchWhileOperationInProgress) + return true + } + return false +} + +// switchTo switches lazygit to the repository (or worktree) at the given path. +// It runs synchronously on the UI thread: the switch swaps gui.State (in +// resetState) and reassigns gui.git and the process cwd, all of which the UI +// thread also reads, so doing it here rather than on a worker avoids racing +// those reads. The heavy data loading is still dispatched asynchronously by the +// refresh that onNewRepo kicks off. +func (self *ReposHelper) switchTo(path string, errMsg string, contextKey types.ContextKey) error { env.UnsetGitLocationEnvVars() originalPath, err := os.Getwd() if err != nil { diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 2a189a752..abfd1c0f6 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -426,6 +426,13 @@ func (self *WorktreeHelper) promptForWorktreeLocation(dirName string, prompt str } func (self *WorktreeHelper) createWorktree(opts git_commands.NewWorktreeOpts, contextKey types.ContextKey) error { + // Check now, before we create the worktree, rather than when we come to + // switch to it afterwards: by then this operation's own waiting-status + // spinner would make Busy() true and refuse our own switch. + if self.reposHelper.switchRefusedBecauseBusy() { + return nil + } + return self.c.WithWaitingStatus(self.c.Tr.AddingWorktree, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AddWorktree) if err := self.c.Git().Worktree.New(opts); err != nil { @@ -434,9 +441,11 @@ func (self *WorktreeHelper) createWorktree(opts git_commands.NewWorktreeOpts, co // The switch swaps gui.State and must run on the UI thread, but // we're on a worker here (creating the worktree is git work), so - // dispatch it rather than calling it directly. + // dispatch it. It's unguarded (switchTo, not DispatchSwitchTo) + // because we checked above and creating the worktree is now + // complete, so switching to it is safe. self.c.OnUIThread(func() error { - return self.reposHelper.DispatchSwitchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey) + return self.reposHelper.switchTo(opts.Path, self.c.Tr.ErrWorktreeMovedOrRemoved, contextKey) }) return nil }) diff --git a/pkg/gui/controllers/quit_actions.go b/pkg/gui/controllers/quit_actions.go index 40ad6f7e3..9a7082542 100644 --- a/pkg/gui/controllers/quit_actions.go +++ b/pkg/gui/controllers/quit_actions.go @@ -2,7 +2,6 @@ package controllers import ( "github.com/jesseduffield/lazygit/pkg/gocui" - "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -81,9 +80,8 @@ func (self *QuitActions) Escape() error { } } - repoPathStack := self.c.State().GetRepoPathStack() - if !repoPathStack.IsEmpty() { - return self.c.Helpers().Repos.DispatchSwitchToRepo(repoPathStack.Pop(), context.NO_CONTEXT) + if !self.c.State().GetRepoPathStack().IsEmpty() { + return self.c.Helpers().Repos.SwitchToParentRepo() } if self.c.UserConfig().QuitOnTopLevelReturn { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 0ee0a9e86..69ea7012f 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -769,6 +769,7 @@ type TranslationSet struct { ErrStageDirWithInlineMergeConflicts string ErrRepositoryMovedOrDeleted string ErrWorktreeMovedOrRemoved string + CantSwitchWhileOperationInProgress string CommandLog string ToggleShowCommandLog string FocusCommandLog string @@ -1921,6 +1922,7 @@ func EnglishTranslationSet() *TranslationSet { ErrRepositoryMovedOrDeleted: "Cannot find repo. It might have been moved or deleted ¯\\_(ツ)_/¯", CommandLog: "Command log", ErrWorktreeMovedOrRemoved: "Cannot find worktree. It might have been moved or removed ¯\\_(ツ)_/¯", + CantSwitchWhileOperationInProgress: "Can't switch repositories while an operation is in progress", ToggleShowCommandLog: "Toggle show/hide command log", FocusCommandLog: "Focus command log", CommandLogHeader: "You can hide/focus this panel by pressing '%s'\n", From 5414daf492901464734e145430bd6325026f73c9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 4 Jul 2026 12:28:13 +0200 Subject: [PATCH 229/384] Exclude toast rendering from the busy query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A toast keeps a foreground spinner task alive for its whole lifetime (~2-4s): showing one calls renderAppStatus, whose OnWorker loop runs until the status string clears. With the repo-switch guard in place that made the guard's own "can't switch, operation in progress" toast keep Busy() true, so the next escape/switch was refused until the toast faded — you had to wait it out. Render toasts in the background, like view-buffer content: a toast is a transient notification, not lazygit driving an operation, so a switch during one is fine. A real operation that shows a toast still keeps its own foreground task busy independently. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/app_status_helper.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index bffd87866..90b87b3b8 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -34,7 +34,12 @@ func (self *AppStatusHelper) Toast(message string, kind types.ToastKind) { self.statusMgr().AddToastStatus(message, kind) - self.renderAppStatus(false) + // Render the toast in the background: it's a transient notification, not + // lazygit driving an operation, so it must not count towards being busy — + // otherwise a toast (e.g. the "can't switch, operation in progress" one) + // would itself block a repo switch until it faded. A real operation showing + // a toast still keeps its own foreground task busy independently. + self.renderAppStatus(true) } // A custom task for WithWaitingStatus calls; it wraps the original one and From bd6081d601389a4814b5c964b94a950b53b8b266 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 4 Jul 2026 21:18:38 +0200 Subject: [PATCH 230/384] Select the checked-out branch via a refresh intent, not off-thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operations that check something out (checkout, create branch, move commits to a new branch, fetch-and-checkout) selected the newly checked-out branch by calling SelectFirstBranchAndFirstCommit() before the refresh and passing KeepBranchSelectionIndex so the refresh wouldn't override it. That set the selection directly, usually from a worker goroutine (WithWaitingStatus/WithInlineStatus). Now that the refresh's own selection write is bounced onto the UI thread, the two writes could land in either order, and under load the refresh's "restore the previously-selected branch" write would win — leaving the old branch selected instead of the new one (flaky move_commits_to_new_branch_from_base_branch). Replace it with declarative selection intents applied inside the refresh's own bounce, so the selection is set on the UI thread and atomically with the list write (no off-thread write, and no BLOCK_UI needed to avoid a flicker): - BranchSelection: SelectCheckedOutBranch selects the checked-out branch (top of the list). The default, KeepBranchSelectionByName, restores the previously-selected branch by name as before. This replaces the KeepBranchSelectionIndex bool. - CommitSelection: SelectHeadCommit (already existed) for the commit. - SelectTopReflogCommit selects the top reflog entry, since a checkout adds a new entry there (reflog/checkout relies on this). SelectFirstBranchAndFirstCommit is gone. The previously-selected branch is now read at the top of the branches bounce, before the list is overwritten, so that read moves onto the UI thread too. fetchAndCheckout's refresh changes from ASYNC to SYNC so its post-refresh focus switch can run in Then on the UI thread; SYNC keeps the inline fetch spinner spinning (only BLOCK_UI would freeze it). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/branches_controller.go | 8 +-- pkg/gui/controllers/helpers/refresh_helper.go | 59 +++++++++++++------ pkg/gui/controllers/helpers/refs_helper.go | 47 ++++++--------- pkg/gui/controllers/remotes_controller.go | 17 ++++-- pkg/gui/types/refresh.go | 31 ++++++++-- 5 files changed, 99 insertions(+), 63 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 131d19439..a5c55884b 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -599,11 +599,11 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er return err } - self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit() self.c.Refresh(types.RefreshOptions{ - Mode: types.ASYNC, - KeepBranchSelectionIndex: true, - CommitSelection: types.KeepCommitSelectionIndex, + Mode: types.ASYNC, + BranchSelection: types.SelectCheckedOutBranch, + CommitSelection: types.SelectHeadCommit, + SelectTopReflogCommit: true, }) return nil } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index c0543d095..cb1856aa9 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -183,7 +183,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { branchesAndRemotesWg.Add(1) refresh("reflog and branches", func() { - self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, options.Background) + self.refreshReflogAndBranches(includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, options.Background) branchesAndRemotesWg.Done() }) } else { @@ -192,10 +192,10 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // Not a recency sort, so branches doesn't depend on the reflog // being fresh; it runs concurrently with the reflog refresh // below and reads whatever's in the model, as it always has. - self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true, self.c.Model().ReflogCommits, options.Background) + self.refreshBranches(includeWorktreesWithBranches, options.BranchSelection, true, self.c.Model().ReflogCommits, options.Background) branchesAndRemotesWg.Done() }) - refresh("reflog", func() { _, _ = self.refreshReflogCommits(options.Background) }) + refresh("reflog", func() { _, _ = self.refreshReflogCommits(options.Background, options.SelectTopReflogCommit) }) } } else if scopeSet.Includes(types.REBASE_COMMITS) { // the above block handles rebase commits so we only need to call this one @@ -399,21 +399,21 @@ func getModeName(mode types.RefreshMode) string { // order gives the immediate (non-recency) load a lower branch-load sequence // than the async (recency) load, so the sequence guard in refreshBranches keeps // the recency-sorted result even if the two loads' bounces land out of order. -func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, keepBranchSelectionIndex bool, background bool) { +func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, background bool) { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: - self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, false, self.c.Model().ReflogCommits, background) + self.refreshBranches(refreshWorktrees, branchSelection, false, self.c.Model().ReflogCommits, background) self.onWorker(background, func(_ gocui.Task) error { - reflogCommits, _ := self.refreshReflogCommits(background) - self.refreshBranches(false, true, true, reflogCommits, background) + reflogCommits, _ := self.refreshReflogCommits(background, false) + self.refreshBranches(false, types.SelectCheckedOutBranch, true, reflogCommits, background) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) case types.COMPLETE: - reflogCommits, _ := self.refreshReflogCommits(background) - self.refreshBranches(refreshWorktrees, keepBranchSelectionIndex, true, reflogCommits, background) + reflogCommits, _ := self.refreshReflogCommits(background, selectTopReflogCommit) + self.refreshBranches(refreshWorktrees, branchSelection, true, reflogCommits, background) } } @@ -728,7 +728,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // 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(refreshWorktrees bool, keepBranchSelectionIndex bool, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) { +func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) { loadSeq := self.branchLoadSeq.Add(1) generation := self.c.State().GetRepoGeneration() @@ -754,8 +754,6 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele self.c.Log.Error(err) } - prevSelectedBranch := self.c.Contexts().Branches.GetSelected() - var worktrees []*models.Worktree if refreshWorktrees { worktrees = self.loadWorktrees() @@ -772,6 +770,11 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele } self.appliedBranchLoadSeq = loadSeq + // Read the currently-selected branch before overwriting the list, so we + // can restore it by name below. Reading it here in the bounce keeps it on + // the UI thread. + prevSelectedBranch := self.c.Contexts().Branches.GetSelected() + self.c.Model().Branches = branches // Rebuilding here (rather than on the worker) means the map is built from // the branches we just wrote, on the UI thread. @@ -782,14 +785,25 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele self.refreshView(self.c.Contexts().Worktrees, background) } - if !keepBranchSelectionIndex && prevSelectedBranch != nil { - self.searchHelper.ReApplyFilter(self.c.Contexts().Branches) + // 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. + switch branchSelection { + case types.KeepBranchSelectionByName: + if prevSelectedBranch != nil { + self.searchHelper.ReApplyFilter(self.c.Contexts().Branches) - _, idx, found := lo.FindIndexOf(self.c.Contexts().Branches.GetItems(), - func(b *models.Branch) bool { return b.Name == prevSelectedBranch.Name }) - if found { - self.c.Contexts().Branches.SetSelectedLineIdx(idx) + _, idx, found := lo.FindIndexOf(self.c.Contexts().Branches.GetItems(), + func(b *models.Branch) bool { return b.Name == prevSelectedBranch.Name }) + if found { + self.c.Contexts().Branches.SetSelectedLineIdx(idx) + } } + 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. + 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 @@ -965,7 +979,7 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // refreshReflogCommits returns the (non-filtered) ReflogCommits it loaded, so // that a subsequent branches refresh can use them for recency sorting without // having to read them back out of the model. -func (self *RefreshHelper) refreshReflogCommits(background bool) ([]*models.Commit, error) { +func (self *RefreshHelper) refreshReflogCommits(background bool, selectTopEntry bool) ([]*models.Commit, error) { generation := self.c.State().GetRepoGeneration() // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception @@ -1008,6 +1022,13 @@ func (self *RefreshHelper) refreshReflogCommits(background bool) ([]*models.Comm self.onUIThreadUnlessRepoChanged(generation, background, func() error { model.ReflogCommits = reflogCommits 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. + if selectTopEntry { + self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0) + self.c.Contexts().ReflogCommits.GetView().SetOriginY(0) + } return nil }) diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index b90b9150b..8d5e8397c 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -31,15 +31,6 @@ func NewRefsHelper( } } -func (self *RefsHelper) SelectFirstBranchAndFirstCommit() { - self.c.Contexts().Branches.SetSelection(0) - self.c.Contexts().ReflogCommits.SetSelection(0) - self.c.Contexts().LocalCommits.SetSelection(0) - self.c.Contexts().Branches.GetView().SetOriginY(0) - self.c.Contexts().ReflogCommits.GetView().SetOriginY(0) - self.c.Contexts().LocalCommits.GetView().SetOriginY(0) -} - func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions) error { waitingStatus := options.WaitingStatus if waitingStatus == "" { @@ -49,8 +40,6 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions cmdOptions := git_commands.CheckoutOptions{Force: false, EnvVars: options.EnvVars} refresh := func() { - self.SelectFirstBranchAndFirstCommit() - // loading a heap of commits is slow so we limit them whenever doing a reset self.c.Contexts().LocalCommits.SetLimitCommits(true) @@ -67,10 +56,11 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions scope = append(scope, types.PULL_REQUESTS) } self.c.Refresh(types.RefreshOptions{ - Mode: types.BLOCK_UI, - Scope: scope, - KeepBranchSelectionIndex: true, - CommitSelection: types.KeepCommitSelectionIndex, + Mode: types.BLOCK_UI, + Scope: scope, + BranchSelection: types.SelectCheckedOutBranch, + CommitSelection: types.SelectHeadCommit, + SelectTopReflogCommit: true, }) } @@ -375,12 +365,11 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) } - self.SelectFirstBranchAndFirstCommit() - self.c.Refresh(types.RefreshOptions{ - Mode: types.BLOCK_UI, - KeepBranchSelectionIndex: true, - CommitSelection: types.KeepCommitSelectionIndex, + Mode: types.BLOCK_UI, + BranchSelection: types.SelectCheckedOutBranch, + CommitSelection: types.SelectHeadCommit, + SelectTopReflogCommit: true, }) } @@ -534,12 +523,11 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa } } - self.SelectFirstBranchAndFirstCommit() - self.c.Refresh(types.RefreshOptions{ - Mode: types.BLOCK_UI, - KeepBranchSelectionIndex: true, - CommitSelection: types.KeepCommitSelectionIndex, + Mode: types.BLOCK_UI, + BranchSelection: types.SelectCheckedOutBranch, + CommitSelection: types.SelectHeadCommit, + SelectTopReflogCommit: true, }) return nil } @@ -576,12 +564,11 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri } } - self.SelectFirstBranchAndFirstCommit() - self.c.Refresh(types.RefreshOptions{ - Mode: types.BLOCK_UI, - KeepBranchSelectionIndex: true, - CommitSelection: types.KeepCommitSelectionIndex, + Mode: types.BLOCK_UI, + BranchSelection: types.SelectCheckedOutBranch, + CommitSelection: types.SelectHeadCommit, + SelectTopReflogCommit: true, }) return nil } diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index e4f606ca3..8bd19ad81 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -376,10 +376,19 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam if branchName != "" { err = self.c.Git().Branch.New(branchName, remote.Name+"/"+branchName) if err == nil { - self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) - self.c.Helpers().Refs.SelectFirstBranchAndFirstCommit() - refreshOptions.KeepBranchSelectionIndex = true - refreshOptions.CommitSelection = types.KeepCommitSelectionIndex + // Branch.New checks the new branch out, so HEAD moves: refresh the + // reflog (and, via scope expansion, the commits) as well, and select + // the newly checked-out branch and its head commit. + refreshOptions.Scope = append(refreshOptions.Scope, types.REFLOG) + refreshOptions.BranchSelection = types.SelectCheckedOutBranch + refreshOptions.CommitSelection = types.SelectHeadCommit + refreshOptions.SelectTopReflogCommit = true + // Focus the branches panel on the UI thread once the refresh has + // selected the newly checked-out branch. + refreshOptions.Then = func() error { + self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) + return nil + } } } self.c.Refresh(refreshOptions) diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index 591aff5f3..f4041bb2e 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -55,22 +55,41 @@ const ( SelectHeadCommit ) +// BranchSelectionBehavior controls which local branch is selected after the +// branches list is reloaded by a refresh. +type BranchSelectionBehavior int + +const ( + // Keep the same branch selected by name, restoring it at its new position if + // the order changed. This is the right default whenever the list reloads + // underneath a selection the user hasn't deliberately changed. + KeepBranchSelectionByName BranchSelectionBehavior = iota + + // Select the checked-out branch (the one at the top of the list). Used after + // operations that check something out - checkout, creating a branch, moving + // commits to a new branch - so the newly checked-out ref ends up selected. + SelectCheckedOutBranch +) + type RefreshOptions struct { Then func() error Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything Mode RefreshMode // one of SYNC (default), ASYNC, and BLOCK_UI - // Normally a refresh of the branches tries to keep the same branch selected - // (by name); this is usually important in case the order of branches - // changes. Passing true for KeepBranchSelectionIndex suppresses this and - // keeps the selection index the same. Useful after checking out a detached - // head, and selecting index 0. - KeepBranchSelectionIndex bool + // Controls which local branch is selected after the refresh. Defaults to + // KeepBranchSelectionByName. + BranchSelection BranchSelectionBehavior // Controls which local commit is selected after the refresh. Defaults to // KeepCommitSelectionByHash. CommitSelection CommitSelectionBehavior + // When true, select the top (most recent) reflog entry after the refresh. + // Used alongside SelectCheckedOutBranch by operations that check something + // out, since the checkout adds a new reflog entry at the top. Defaults to + // keeping the reflog selection where it is. + SelectTopReflogCommit bool + // When true, this refresh was initiated by a background routine rather than // by a user action. Every git command suppresses optional locks by default // so it can't contend for index.lock (see git_commands.OptionalLocksEnvVar); From 7c4d8045f9dbcab76c8f627d3c9735de56af37ed Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 5 Jul 2026 17:12:42 +0200 Subject: [PATCH 231/384] Clamp the commit-file tree selection when the tree is rebuilt CommitFileTreeViewModel embedded the low-level tree's SetTree, which rebuilds the node list without touching the cursor. So after a shrinking rebuild (e.g. moving a patch out into the index removes a file), the selection index could be left past the end of the tree. GetSelectedItems then indexes out of range and returns a nil node, which segfaults callers such as canEditFiles when the options map is rendered during layout. Override SetTree to ClampSelection after the rebuild. Unlike FileTreeViewModel we deliberately don't also re-find the selected node by path: that walk lands on the containing directory when a file is removed from a dir that then collapses, whereas keeping the clamped index lands on the sibling file (see discard_old_file_changes). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../filetree/commit_file_tree_view_model.go | 16 ++++++++ .../commit_file_tree_view_model_test.go | 40 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 pkg/gui/filetree/commit_file_tree_view_model_test.go diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go index e33316788..a58f7d93e 100644 --- a/pkg/gui/filetree/commit_file_tree_view_model.go +++ b/pkg/gui/filetree/commit_file_tree_view_model.go @@ -142,6 +142,22 @@ func (self *CommitFileTreeViewModel) GetSelectedPath() string { return node.GetPath() } +// SetTree rebuilds the tree and clamps the selection so it stays in range. The +// embedded tree's SetTree only rebuilds the node list and doesn't touch the +// cursor, so after a shrinking rebuild (e.g. moving a patch out into the index) +// the selection index could be left past the end of the tree; GetSelectedItems +// would then return a nil node and crash callers such as canEditFiles when the +// options map is rendered during layout. +// +// Unlike FileTreeViewModel.SetTree we don't re-find the selected node by path +// afterwards: that walk lands on the containing directory when a file is removed +// from a dir that then collapses, whereas keeping the (clamped) index lands on +// the sibling file, which is what we want here. +func (self *CommitFileTreeViewModel) SetTree() { + self.ICommitFileTree.SetTree() + self.ClampSelection() +} + // duplicated from file_tree_view_model.go. Generics will help here func (self *CommitFileTreeViewModel) ToggleShowTree() { selectedNode := self.GetSelected() diff --git a/pkg/gui/filetree/commit_file_tree_view_model_test.go b/pkg/gui/filetree/commit_file_tree_view_model_test.go new file mode 100644 index 000000000..c8862f6f9 --- /dev/null +++ b/pkg/gui/filetree/commit_file_tree_view_model_test.go @@ -0,0 +1,40 @@ +package filetree + +import ( + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/common" + "github.com/stretchr/testify/assert" +) + +// When the tree shrinks under the selection - e.g. moving a patch out into the +// index removes a file - SetTree must keep the selection in range. Otherwise +// GetSelectedItems returns a nil node, which crashes callers such as +// canEditFiles when the options map is rendered during layout. +func TestCommitFileTreeViewModelSetTreeClampsSelectionOnShrink(t *testing.T) { + files := []*models.CommitFile{ + {Path: "file1"}, + {Path: "file2"}, + {Path: "file3"}, + } + viewModel := NewCommitFileTreeViewModel( + func() []*models.CommitFile { return files }, + common.NewDummyCommon(), + false, // flat list + ) + viewModel.SetTree() + viewModel.SetSelectedLineIdx(viewModel.Len() - 1) + + // The file under the cursor goes away and the tree shrinks. + files = []*models.CommitFile{{Path: "file1"}} + viewModel.SetTree() + + assert.Less(t, viewModel.GetSelectedLineIdx(), viewModel.Len()) + assert.NotNil(t, viewModel.GetSelected()) + items, _, _ := viewModel.GetSelectedItems() + assert.NotEmpty(t, items) + for _, item := range items { + assert.NotNil(t, item) + } +} From 23cfa9b070900dc0ca785a1e8f2254e6ce7ae602 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 5 Jul 2026 20:50:33 +0200 Subject: [PATCH 232/384] Also refresh branches and remotes when refreshing pull requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pull-request fetch queries GitHub for the tracking branches' upstreams against the configured remotes. It therefore depends on the branches and remotes being up to date; a refresh that asks for pull requests but not for those (e.g. checking out a branch) would fetch against a stale branch/remote list — for instance missing the PR of the branch just checked out. Expand the scope so pull requests always co-refresh branches and remotes. This also sets up the next commit to hand the freshly-loaded branches and remotes straight to the fetch, instead of reading them back from the model (which, now that those writes are bounced onto the UI thread, would be stale on the worker). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index cb1856aa9..c23c01d80 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -131,6 +131,8 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // can move HEAD), so refresh commits + branches alongside // - submodules are refreshed as part of the files refresh // - merge conflicts are part of what the files refresh produces + // - pull requests are fetched for the tracking branches against the + // remotes, so refresh both alongside to fetch against fresh data if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { scopeSet.Add(types.COMMITS, types.BRANCHES) } @@ -140,6 +142,9 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if scopeSet.Includes(types.FILES) { scopeSet.Add(types.MERGE_CONFLICTS) } + if scopeSet.Includes(types.PULL_REQUESTS) { + scopeSet.Add(types.BRANCHES, types.REMOTES) + } // Capture the refs snapshot now, before we start reading git's state // below, rather than after. This is important to guard against the race From f0ea537956e938c633345c3a45139388daef46fa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 5 Jul 2026 20:55:13 +0200 Subject: [PATCH 233/384] Fetch pull requests using the freshly-loaded branches and remotes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR fetch needs the current branches (for their upstreams) and remotes to know what to query. It read them from Model().Branches / Model().Remotes on its own worker, after waiting on branchesAndRemotesWg for the branches and remotes refreshes to finish. That wait no longer guarantees fresh data: those refreshes now write the model in a bounce onto the UI thread, and Done() fires before the bounce has been processed. So the fetch read the pre-refresh lists — most visibly, checking out a branch that has a PR wouldn't show that PR until the next refresh, because the fetch queried the old branch set. Have refreshBranches / refreshReflogAndBranches / refreshRemotes return what they loaded, stash it in locals in Refresh, and hand it to the fetch. The wait on branchesAndRemotesWg orders the fetch after both loads have stored their slices, so it fetches against exactly the branches and remotes that were just loaded, with no model read on the worker. The previous commit guarantees both are always in scope when pull requests are, so no fallback is needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 69 ++++++++++++------- 1 file changed, 46 insertions(+), 23 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index c23c01d80..5e15e17c1 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -175,6 +175,14 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { } branchesAndRemotesWg := sync.WaitGroup{} + // The pull-request fetch (below) needs the just-loaded branches and + // remotes. Their model writes are bounced onto the UI thread, so the + // fetch worker can't read them back from the model without racing (and + // would see the pre-refresh values); instead the branches and remotes + // loads stash what they loaded here, and the wait on + // branchesAndRemotesWg gives the fetch the happens-before to read them. + var loadedBranches []*models.Branch + var loadedRemotes []*models.Remote includeWorktreesWithBranches := false if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { // whenever we change commits, we should update branches because the upstream/downstream @@ -188,7 +196,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { branchesAndRemotesWg.Add(1) refresh("reflog and branches", func() { - self.refreshReflogAndBranches(includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, options.Background) + loadedBranches = self.refreshReflogAndBranches(includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, options.Background) branchesAndRemotesWg.Done() }) } else { @@ -197,7 +205,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // Not a recency sort, so branches doesn't depend on the reflog // being fresh; it runs concurrently with the reflog refresh // below and reads whatever's in the model, as it always has. - self.refreshBranches(includeWorktreesWithBranches, options.BranchSelection, true, self.c.Model().ReflogCommits, options.Background) + loadedBranches = self.refreshBranches(includeWorktreesWithBranches, options.BranchSelection, true, self.c.Model().ReflogCommits, options.Background) branchesAndRemotesWg.Done() }) refresh("reflog", func() { _, _ = self.refreshReflogCommits(options.Background, options.SelectTopReflogCommit) }) @@ -237,7 +245,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if scopeSet.Includes(types.REMOTES) { branchesAndRemotesWg.Add(1) refresh("remotes", func() { - _ = self.refreshRemotes(options.Background) + loadedRemotes, _ = self.refreshRemotes(options.Background) branchesAndRemotesWg.Done() }) } @@ -245,7 +253,11 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { if scopeSet.Includes(types.PULL_REQUESTS) { refresh("pull requests", func() { branchesAndRemotesWg.Wait() - self.refreshGithubPullRequests(options.Background) + // Use the branches and remotes the loads above stashed, not + // Model().Branches/Remotes: those writes are bounced onto the + // UI thread and may not have landed on this worker yet. The + // wait above orders us after both loads have stashed theirs. + self.refreshGithubPullRequests(loadedBranches, loadedRemotes, options.Background) }) } @@ -404,10 +416,13 @@ func getModeName(mode types.RefreshMode) string { // order gives the immediate (non-recency) load a lower branch-load sequence // than the async (recency) load, so the sequence guard in refreshBranches keeps // the recency-sorted result even if the two loads' bounces land out of order. -func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, background bool) { +func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, background bool) []*models.Branch { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: - self.refreshBranches(refreshWorktrees, branchSelection, false, self.c.Model().ReflogCommits, background) + // 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(refreshWorktrees, branchSelection, false, self.c.Model().ReflogCommits, background) self.onWorker(background, func(_ gocui.Task) error { reflogCommits, _ := self.refreshReflogCommits(background, false) @@ -416,10 +431,14 @@ func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branc return nil }) + return branches + case types.COMPLETE: reflogCommits, _ := self.refreshReflogCommits(background, selectTopReflogCommit) - self.refreshBranches(refreshWorktrees, branchSelection, true, reflogCommits, background) + return self.refreshBranches(refreshWorktrees, branchSelection, true, reflogCommits, background) } + + return nil } func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior, background bool) { @@ -733,7 +752,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // 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(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) { +func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) []*models.Branch { loadSeq := self.branchLoadSeq.Add(1) generation := self.c.State().GetRepoGeneration() @@ -820,6 +839,10 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, branchSelectio self.refreshView(self.c.Contexts().Branches, background) self.refreshStatus(background) + + // Return the freshly-loaded branches so the caller can hand them to the PR + // fetch without reading them back from the (bounce-written) model. + return branches } func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { @@ -1041,13 +1064,13 @@ func (self *RefreshHelper) refreshReflogCommits(background bool, selectTopEntry return reflogCommits, nil } -func (self *RefreshHelper) refreshRemotes(background bool) error { +func (self *RefreshHelper) refreshRemotes(background bool) ([]*models.Remote, error) { generation := self.c.State().GetRepoGeneration() prevSelectedRemote := self.c.Contexts().Remotes.GetSelected() remotes, err := self.c.Git().Loaders.RemoteLoader.GetRemotes() if err != nil { - return err + return nil, err } self.onUIThreadUnlessRepoChanged(generation, background, func() error { @@ -1075,7 +1098,7 @@ func (self *RefreshHelper) refreshRemotes(background bool) error { self.refreshView(self.c.Contexts().Remotes, background) self.refreshView(self.c.Contexts().RemoteBranches, background) - return nil + return remotes, nil } func (self *RefreshHelper) loadWorktrees() []*models.Worktree { @@ -1187,7 +1210,7 @@ func (self *RefreshHelper) refreshView(context types.Context, background bool) { }) } -func (self *RefreshHelper) refreshGithubPullRequests(background bool) { +func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, remotes []*models.Remote, background bool) { generation := self.c.State().GetRepoGeneration() clearPullRequests := func() { @@ -1198,7 +1221,7 @@ func (self *RefreshHelper) refreshGithubPullRequests(background bool) { }) } - githubRemotes := getAuthenticatedGithubRemotes(self.getGithubRemotes(), self.c.Git().GitHub.GetAuthToken) + githubRemotes := getAuthenticatedGithubRemotes(self.getGithubRemotes(remotes), self.c.Git().GitHub.GetAuthToken) if len(githubRemotes) == 0 { clearPullRequests() return @@ -1209,12 +1232,12 @@ func (self *RefreshHelper) refreshGithubPullRequests(background bool) { clearPullRequests() if !self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] { - self.promptForBaseGithubRepo(githubRemotes) + self.promptForBaseGithubRepo(githubRemotes, branches) } return } - self.setGithubPullRequests(baseInfo, background) + self.setGithubPullRequests(baseInfo, branches, background) } type githubRemoteInfo struct { @@ -1223,8 +1246,8 @@ type githubRemoteInfo struct { authToken string } -func (self *RefreshHelper) getGithubRemotes() []githubRemoteInfo { - return lo.FilterMap(self.c.Model().Remotes, func(remote *models.Remote, _ int) (githubRemoteInfo, bool) { +func (self *RefreshHelper) getGithubRemotes(remotes []*models.Remote) []githubRemoteInfo { + return lo.FilterMap(remotes, func(remote *models.Remote, _ int) (githubRemoteInfo, bool) { if len(remote.Urls) == 0 { return githubRemoteInfo{}, false } @@ -1285,7 +1308,7 @@ func getGithubBaseRemote(githubRemotes []githubRemoteInfo, configuredRemoteName return nil } -func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteInfo) { +func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteInfo, branches []*models.Branch) { menuItems := lo.Map(githubRemotes, func(info githubRemoteInfo, _ int) *types.MenuItem { return &types.MenuItem{ LabelColumns: []string{info.remote.Name, style.FgCyan.Sprint(info.serviceInfo.RepoName)}, @@ -1295,7 +1318,7 @@ func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteI self.c.Log.Error(err) } - self.setGithubPullRequests(&info, false) + self.setGithubPullRequests(&info, branches, false) return nil }) }, @@ -1323,17 +1346,17 @@ func (self *RefreshHelper) rebuildPullRequestsMap() { ) } -func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, background bool) { +func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, branches []*models.Branch, background bool) { generation := self.c.State().GetRepoGeneration() - if len(self.c.Model().Branches) == 0 { + if len(branches) == 0 { return } - branches := lo.Filter(self.c.Model().Branches, func(branch *models.Branch, _ int) bool { + trackingBranches := lo.Filter(branches, func(branch *models.Branch, _ int) bool { return branch.IsTrackingRemote() }) - branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { + branchNames := lo.Map(trackingBranches, func(branch *models.Branch, _ int) string { return branch.UpstreamBranch }) From 1def541acb476be744f7a2a54e501300a9cf9493 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 14:44:23 +0200 Subject: [PATCH 234/384] Add IsUIThread and OnUIThreadAndWait to gocui The next commits move refresh workers to read UI-thread-owned state (the model, contexts, selection) on the UI thread rather than off it. Two primitives support that: - OnUIThreadAndWait runs a function on the main event loop and blocks the caller until it has run, so a worker can read that state without racing. OnUIThreadAndWaitBackground is the same for background routines, whose work must not count towards the program being busy. - IsUIThread reports whether the caller is on the main event loop, for a debug-only assertion that a refresh was issued from the thread it claims. It records the main loop's goroutine id in MainLoop and compares via goid, so it's promoted from an indirect to a direct dependency. goid is used only by that debug assertion, never to drive production control flow. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 2 +- pkg/gocui/gui.go | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 6820d941d..c10004176 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.0 github.com/mgutz/str v1.2.0 github.com/mitchellh/go-ps v1.0.0 + github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe github.com/rivo/uniseg v0.4.7 github.com/sahilm/fuzzy v0.1.3 github.com/samber/lo v1.53.0 @@ -62,7 +63,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/onsi/ginkgo v1.10.3 // indirect github.com/onsi/gomega v1.34.1 // indirect - github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect golang.org/x/mod v0.35.0 // indirect diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index a13744997..6002ebf9c 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -9,11 +9,13 @@ import ( "runtime" "strings" "sync" + "sync/atomic" "time" "github.com/gdamore/tcell/v3" "github.com/go-errors/errors" "github.com/jesseduffield/generics/set" + "github.com/petermattis/goid" "github.com/rivo/uniseg" "github.com/samber/lo" ) @@ -200,6 +202,11 @@ type Gui struct { currentTask Task lastHoverView *View + + // uiThreadID is the goroutine id of the main event loop, recorded when + // MainLoop starts. IsUIThread compares against it. Written once, read from + // worker goroutines, so it's atomic. + uiThreadID atomic.Int64 } type NewGuiOpts struct { @@ -684,6 +691,46 @@ func (g *Gui) updateContentOnly(f func(*Gui) error, background bool) { g.userEvents <- userEvent{f: f, task: task, contentOnly: true} } +// IsUIThread reports whether the caller is running on the main event-loop +// goroutine (the one running MainLoop). It calls goid.Get, so use it only for +// debug assertions, not to drive production control flow. +func (g *Gui) IsUIThread() bool { + return goid.Get() == g.uiThreadID.Load() +} + +// OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the +// caller until f has run, returning f's error. Use it to read UI-thread-owned +// state (the model, contexts) from a worker without racing the UI thread. +// +// It must be called from a worker goroutine, never from the UI thread itself: +// the UI thread would block waiting for a callback only it can run, which +// deadlocks. Callers arrange this by construction (see the refresh helper's +// RefreshFromWorker); a debug-only assertion there guards against getting it +// wrong. +func (g *Gui) OnUIThreadAndWait(f func() error) error { + return g.onUIThreadAndWait(f, false) +} + +// Like OnUIThreadAndWait, but the enqueued work belongs to a background routine, +// so it doesn't count towards the program being busy (see UpdateBackground). +func (g *Gui) OnUIThreadAndWaitBackground(f func() error) error { + return g.onUIThreadAndWait(f, true) +} + +func (g *Gui) onUIThreadAndWait(f func() error, background bool) error { + enqueue := g.Update + if background { + enqueue = g.UpdateBackground + } + + result := make(chan error, 1) + enqueue(func(*Gui) error { + result <- f() + return nil + }) + return <-result +} + // Calls a function in a goroutine. Handles panics gracefully and tracks // number of background tasks. // Always use this when you want to spawn a goroutine and you want lazygit to @@ -766,6 +813,8 @@ func (g *Gui) SetManagerFunc(manager func(*Gui) error) { // MainLoop runs the main loop until an error is returned. A successful // finish should return ErrQuit. func (g *Gui) MainLoop() error { + g.uiThreadID.Store(goid.Get()) + go func() { for { select { From 080542c9fb1e3eebbbcc1ea3fd5d8e6f064527db Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 14:45:03 +0200 Subject: [PATCH 235/384] Capture the commits refresh's inputs on the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A commits refresh does its git work on a worker and then reads the model, the contexts, and the modes for that work directly from there: LocalCommits.GetSelectionRangeAndMode/GetLimitCommits/GetShowWholeGitGraph, Model.Commits/MainBranches/HashPool, the filtering path/author. Those are owned by the UI thread, which is concurrently running the cursor and render code, so the reads race it — the dominant, confirmed source of the commits-scope flakes (the startup ClampSelection vs GetSelectionRangeAndMode race, for one). Gather them into an immutable capturedCommitState on the UI thread, before the git work is dispatched, and have refreshCommitsWithLimit compute from that snapshot. UI-thread callers capture inline; worker callers can't (a SYNC/BLOCK_UI refresh parks the UI thread at wg.Wait, so hopping from a scope sub-worker would deadlock), so the capture is lifted out of the scope worker into the refresh orchestration, and worker callers announce themselves with a new RefreshFromWorker entry point that hops the capture to the UI thread and blocks for it (OnUIThreadAndWait). BLOCK_UI runs the whole refresh on the UI thread regardless of the caller, so it captures inline too. Every refresh issued from a worker that reaches the commits (or branches, which pulls in commits) scope is converted: the fast-forward, branch/tag delete, worktree remove/detach, push, reword-via-rebase, author edits, custom-command, hard-reset-with-autostash, reset-to-ref, fetch-and-checkout, gpg-stream, post-fetch, and external-change-poller refreshes, plus the branch checkout and move-commits-to-new-branch refreshes. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/background.go | 2 +- pkg/gui/controllers/branches_controller.go | 4 +- .../controllers/helpers/branches_helper.go | 12 +- pkg/gui/controllers/helpers/gpg_helper.go | 4 +- pkg/gui/controllers/helpers/refresh_helper.go | 130 +++++++++++++++--- pkg/gui/controllers/helpers/refs_helper.go | 8 +- .../controllers/helpers/worktree_helper.go | 4 +- .../controllers/local_commits_controller.go | 8 +- pkg/gui/controllers/remotes_controller.go | 2 +- pkg/gui/controllers/sync_controller.go | 2 +- pkg/gui/controllers/tags_controller.go | 6 +- pkg/gui/controllers/undo_controller.go | 2 +- pkg/gui/gui_common.go | 4 + .../custom_commands/handler_creator.go | 2 +- pkg/gui/types/common.go | 6 + 15 files changed, 147 insertions(+), 49 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 6215f0d45..17f3677f6 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -184,7 +184,7 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() { // No need to update the stored snapshot here; Refresh does that. self.gui.c.Log.Info("External ref change detected — refreshing") - self.gui.c.Refresh(types.RefreshOptions{Background: true}) + self.gui.c.RefreshFromWorker(types.RefreshOptions{Background: true}) } // returns a channel that can be used to trigger the callback immediately diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index a5c55884b..9b9e9e546 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -734,7 +734,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { WorktreePath: worktreePath, }, ) - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC}) return err } @@ -743,7 +743,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { err := self.c.Git().Sync.FastForward( task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch, ) - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES}}) return err }) } diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 053804760..683d26db3 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -46,7 +46,7 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error } self.c.Contexts().Branches.CollapseRangeSelectionToTop() - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) return nil }) }) @@ -84,7 +84,7 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteB if err := self.deleteRemoteBranches(remoteBranches, task); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) if resetRemoteBranchesSelection { self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop() } @@ -152,7 +152,7 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branc } self.c.Contexts().Branches.CollapseRangeSelectionToTop() - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) return nil }) }, @@ -312,7 +312,7 @@ func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.B } self.c.Contexts().Branches.CollapseRangeSelectionToTop() - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}, }) @@ -330,7 +330,7 @@ func (self *BranchesHelper) deleteLocalAndRemoteBranchesContinuation(branches [] } self.c.Contexts().Branches.CollapseRangeSelectionToTop() - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.REMOTES, types.FILES}, }) @@ -390,7 +390,7 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er // AutoForwardBranches reads Model.Branches, which the branches refresh writes // via a bounce, so it has to run in Then rather than right after Refresh // returns (where it would still see the previous branches). - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Scope: scope, Mode: types.SYNC, Background: background, diff --git a/pkg/gui/controllers/helpers/gpg_helper.go b/pkg/gui/controllers/helpers/gpg_helper.go index fb8fae628..fd74a400b 100644 --- a/pkg/gui/controllers/helpers/gpg_helper.go +++ b/pkg/gui/controllers/helpers/gpg_helper.go @@ -88,7 +88,7 @@ func (self *GpgHelper) runAndStream( ) error { return self.c.WithWaitingStatus(waitingStatus, func(gocui.Task) error { if err := cmdObj.StreamOutput().Run(); err != nil { - self.c.Refresh(failureRefreshOptions) + self.c.RefreshFromWorker(failureRefreshOptions) return fmt.Errorf( self.c.Tr.GitCommandFailed, self.c.UserConfig().Keybinding.Universal.ExtrasMenu, ) @@ -100,7 +100,7 @@ func (self *GpgHelper) runAndStream( } } - self.c.Refresh(successRefreshOptions) + self.c.RefreshFromWorker(successRefreshOptions) return nil }) } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 5e15e17c1..48a6b6db8 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -79,6 +79,17 @@ func NewRefreshHelper( } func (self *RefreshHelper) Refresh(options types.RefreshOptions) { + self.performRefresh(options, false) +} + +// RefreshFromWorker is Refresh for callers already running on a worker +// goroutine (e.g. inside a WithWaitingStatus handler) rather than the UI +// thread. See IGuiCommon.RefreshFromWorker. +func (self *RefreshHelper) RefreshFromWorker(options types.RefreshOptions) { + self.performRefresh(options, true) +} + +func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) { if options.Mode == types.ASYNC && options.Then != nil { panic("RefreshOptions.Then doesn't work with mode ASYNC") } @@ -101,6 +112,13 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { ) } + // f runs on the UI thread when the refresh was initiated there, and also for + // BLOCK_UI, which dispatches f onto the UI thread regardless of the caller. + // Only a SYNC/ASYNC refresh initiated from a worker runs f on that worker. + // This, not calledFromWorker alone, is what decides whether a scope capture + // runs inline or has to hop (see captureOnUIThread). + fRunsOnUIThread := options.Mode == types.BLOCK_UI || !calledFromWorker + f := func() { var scopeSet *set.Set[types.RefreshableView] if len(options.Scope) == 0 { @@ -188,8 +206,16 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { // 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 refresh's model/context/mode inputs 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. + var capturedCommits capturedCommitState + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + capturedCommits = self.captureCommitsState(options.CommitSelection) + }) refresh("commits and commit files", func() { - self.refreshCommitsAndCommitFiles(options.CommitSelection, options.Background) + self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, options.Background) }) includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) @@ -441,11 +467,49 @@ func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branc return nil } -func (self *RefreshHelper) refreshCommitsAndCommitFiles(commitSelection types.CommitSelectionBehavior, background bool) { +// capturedCommitState holds everything the commits refresh reads from the +// model, contexts, and modes. It is gathered on the UI thread (see +// captureCommitsState) before the git work is dispatched to a worker, so the +// worker computes from an immutable snapshot rather than reading state the UI +// thread concurrently mutates. +type capturedCommitState struct { + selectionRange *localCommitSelectionRange + limitCommits bool + showWholeGitGraph bool + filterPath string + filterAuthor string + mainBranches *git_commands.MainBranches + hashPool *utils.StringPool + parentIsLocalCommits bool +} + +// captureCommitsState reads the commits refresh's model/context/mode inputs +// into an immutable snapshot. It must run on the UI thread. +func (self *RefreshHelper) captureCommitsState(commitSelection types.CommitSelectionBehavior) capturedCommitState { + var selectionRange *localCommitSelectionRange + if commitSelection == types.KeepCommitSelectionByHash { + selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() + selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) + } + + parentCtx := self.c.Contexts().CommitFiles.GetParentContext() + + return capturedCommitState{ + selectionRange: selectionRange, + limitCommits: self.c.Contexts().LocalCommits.GetLimitCommits(), + showWholeGitGraph: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), + filterPath: self.c.Modes().Filtering.GetPath(), + filterAuthor: self.c.Modes().Filtering.GetAuthor(), + mainBranches: self.c.Model().MainBranches, + hashPool: self.c.Model().HashPool, + parentIsLocalCommits: parentCtx != nil && parentCtx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY, + } +} + +func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, background bool) { generation := self.c.State().GetRepoGeneration() - _ = self.refreshCommitsWithLimit(commitSelection, background) - ctx := self.c.Contexts().CommitFiles.GetParentContext() - if ctx != nil && ctx.GetKey() == context.LOCAL_COMMITS_CONTEXT_KEY { + _ = self.refreshCommitsWithLimit(captured, commitSelection, background) + if captured.parentIsLocalCommits { // This makes sense when we've e.g. just amended a commit, meaning we get a new commit hash at the same position. // However if we've just added a brand new commit, it pushes the list down by one and so we would end up // showing the contents of a different commit than the one we initially entered. @@ -497,28 +561,22 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref { return nil } -func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitSelectionBehavior, background bool) error { +func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, background bool) error { generation := self.c.State().GetRepoGeneration() - var selectionRange *localCommitSelectionRange - if commitSelection == types.KeepCommitSelectionByHash { - selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() - selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) - } - checkedOutRef := self.determineCheckedOutRef() refName, bisectInfo := self.refForLog() commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ - Limit: self.c.Contexts().LocalCommits.GetLimitCommits(), - FilterPath: self.c.Modes().Filtering.GetPath(), - FilterAuthor: self.c.Modes().Filtering.GetAuthor(), + Limit: captured.limitCommits, + FilterPath: captured.filterPath, + FilterAuthor: captured.filterAuthor, IncludeRebaseCommits: true, RefName: refName, RefForPushedStatus: checkedOutRef, - All: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), - MainBranches: self.c.Model().MainBranches, - HashPool: self.c.Model().HashPool, + All: captured.showWholeGitGraph, + MainBranches: captured.mainBranches, + HashPool: captured.hashPool, }, ) if err != nil { @@ -545,10 +603,10 @@ func (self *RefreshHelper) refreshCommitsWithLimit(commitSelection types.CommitS scrollSelectionIntoView = true } case types.KeepCommitSelectionByHash: - if selectionRange != nil { - selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange) + if captured.selectionRange != nil { + selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, captured.selectionRange) if found { - self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode) + self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, captured.selectionRange.mode) scrollSelectionIntoView = didMove } } @@ -898,6 +956,36 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) { } } +// captureOnUIThread runs fn on the UI thread and returns once it has run. fn +// reads the model/context/mode state a refresh scope needs into locals, so the +// worker that follows computes from an immutable snapshot instead of reading +// state the UI thread concurrently mutates. When the enclosing refresh function +// runs on the UI thread (fRunsOnUIThread is true) fn runs inline; when it runs +// on a worker, fn is dispatched to the UI thread and we block for it. +// +// The inline case matters for correctness as much as the hop: a SYNC or +// BLOCK_UI refresh parks the UI thread in a wg.Wait while its scope workers +// run, so a scope worker that tried to hop to the UI thread there would +// deadlock. Capturing before those workers are spawned — inline, on the UI +// thread — avoids that entirely. This is why BLOCK_UI (which always runs on the +// UI thread, even from a worker caller) captures inline rather than hopping. +func (self *RefreshHelper) captureOnUIThread(fRunsOnUIThread bool, background bool, fn func()) { + if fRunsOnUIThread { + fn() + return + } + + wrapped := func() error { + fn() + return nil + } + if background { + _ = self.c.GocuiGui().OnUIThreadAndWaitBackground(wrapped) + } else { + _ = self.c.GocuiGui().OnUIThreadAndWait(wrapped) + } +} + func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs []*models.SubmoduleConfig) error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel generation := self.c.State().GetRepoGeneration() diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 8d5e8397c..70ff18593 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -55,7 +55,7 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions if options.RefreshPullRequests { scope = append(scope, types.PULL_REQUESTS) } - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.BLOCK_UI, Scope: scope, BranchSelection: types.SelectCheckedOutBranch, @@ -204,7 +204,7 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string // loading a heap of commits is slow so we limit them whenever doing a reset self.c.Contexts().LocalCommits.SetLimitCommits(true) - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}, CommitSelection: types.KeepCommitSelectionIndex}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}, CommitSelection: types.KeepCommitSelectionIndex}) return nil } @@ -523,7 +523,7 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa } } - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.BLOCK_UI, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, @@ -564,7 +564,7 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri } } - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.BLOCK_UI, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index abfd1c0f6..980d810ae 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -163,7 +163,7 @@ func (self *WorktreeHelper) remove(worktree *models.Worktree, force bool, then f return then(task) } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) return nil }) } @@ -181,7 +181,7 @@ func (self *WorktreeHelper) Detach(worktree *models.Worktree, then func(gocui.Ta return then(task) } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) return nil }) } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 5150e8fba..04c7fc290 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -487,7 +487,7 @@ func (self *LocalCommitsController) handleReword(summary string, description str if err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) return nil }) } @@ -854,7 +854,7 @@ func (self *LocalCommitsController) resetAuthor(start, end int) error { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) return nil }) } @@ -870,7 +870,7 @@ func (self *LocalCommitsController) setAuthor(start, end int) error { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) return nil }) }, @@ -889,7 +889,7 @@ func (self *LocalCommitsController) addCoAuthor(start, end int) error { if err := self.c.Git().Rebase.AddCommitCoAuthor(self.c.Model().Commits, start, end, value); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) return nil }) }, diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index 8bd19ad81..d4c838f7c 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -391,7 +391,7 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam } } } - self.c.Refresh(refreshOptions) + self.c.RefreshFromWorker(refreshOptions) return err }) } diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index 0f754eb49..fafd4e7dd 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -229,7 +229,7 @@ func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts) } return err } - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC}) return nil }) } diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go index fe2c4e80f..2a59af5ec 100644 --- a/pkg/gui/controllers/tags_controller.go +++ b/pkg/gui/controllers/tags_controller.go @@ -168,7 +168,7 @@ func (self *TagsController) localDelete(tag *models.Tag) error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag) err := self.c.Git().Tag.LocalDelete(tag.Name) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return err }) } @@ -210,7 +210,7 @@ func (self *TagsController) remoteDelete(tag *models.Tag) error { return err } self.c.Toast(self.c.Tr.RemoteTagDeletedMessage) - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return nil }) }, @@ -264,7 +264,7 @@ func (self *TagsController) localAndRemoteDelete(tag *models.Tag) error { if err := self.c.Git().Tag.LocalDelete(tag.Name); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return nil }) }, diff --git a/pkg/gui/controllers/undo_controller.go b/pkg/gui/controllers/undo_controller.go index 775e871a4..0954d66b0 100644 --- a/pkg/gui/controllers/undo_controller.go +++ b/pkg/gui/controllers/undo_controller.go @@ -271,7 +271,7 @@ func (self *UndoController) hardResetWithAutoStash(commitHash string, options ha if err != nil { return err } - self.c.Refresh(types.RefreshOptions{}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) } diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index d13120508..c8de7545d 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -30,6 +30,10 @@ func (self *guiCommon) Refresh(opts types.RefreshOptions) { self.gui.helpers.Refresh.Refresh(opts) } +func (self *guiCommon) RefreshFromWorker(opts types.RefreshOptions) { + self.gui.helpers.Refresh.RefreshFromWorker(opts) +} + func (self *guiCommon) PostRefreshUpdate(context types.Context) { self.gui.postRefreshUpdate(context) } diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index 1e321c2de..4eb762019 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -314,7 +314,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses } output, err := cmdObj.RunWithOutput() - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) if err != nil { if customCommand.After != nil && customCommand.After.CheckForConflicts { diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 766a5a757..0f8e99ee8 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -30,6 +30,12 @@ type IGuiCommon interface { LogCommand(cmdStr string, isCommandLine bool) // we call this when we want to refetch some models and render the result. Internally calls PostRefreshUpdate Refresh(RefreshOptions) + // Like Refresh, but for callers running on a worker goroutine (e.g. inside + // a WithWaitingStatus handler) rather than the UI thread. The refresh + // captures the model/context state it needs on the UI thread before doing + // its git work; knowing which thread the caller is on lets it capture + // inline (UI thread) or hop across (worker) without racing or deadlocking. + RefreshFromWorker(RefreshOptions) // we call this when we've changed something in the view model but not the actual model, // e.g. expanding or collapsing a folder in a file view. Calling 'Refresh' in this // case would be overkill, although refresh will internally call 'PostRefreshUpdate' From 558fd2c9d349b597932483c3b81c66a3c1c21281 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 14:46:32 +0200 Subject: [PATCH 236/384] Route merge/rebase result handling to the right refresh entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckMergeOrRebaseWithRefreshOptions refreshes after a merge/rebase step, and until now always via the UI-thread Refresh. Most of its callers are on a worker (the WithWaitingStatus/WithInlineStatus merge, squash-merge, rebase, pull, amend, drop, and patch-move handlers), so that refresh reads the commits scope off the UI thread — the race the previous commit addresses for everything else. Split it: the default is for worker callers and refreshes via RefreshFromWorker; a new CheckMergeOrRebaseWithRefreshOptionsFromUIThread is for the handlers that run the step synchronously on the UI thread (WithWaitingStatusSync, kept sync so rapid key presses batch): move up/down, revert, squash-fixups, cherry-pick paste, and patch-discard. The two share a private impl carrying which thread the caller is on, and the auto-skip recursion (genericMergeCommandImpl for an empty commit) threads it through so the follow-up step refreshes on the same thread. The merge-and-commit refresh in SquashMergeCommitted, also on a worker, moves to RefreshFromWorker to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/cherry_pick_helper.go | 2 +- .../helpers/merge_and_rebase_helper.go | 66 ++++++++++++++----- .../controllers/local_commits_controller.go | 8 +-- .../controllers/patch_building_controller.go | 2 +- 4 files changed, 56 insertions(+), 22 deletions(-) diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go index e2fe46545..673f657f5 100644 --- a/pkg/gui/controllers/helpers/cherry_pick_helper.go +++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go @@ -95,7 +95,7 @@ func (self *CherryPickHelper) Paste() error { cherryPickedCommits := self.getData().CherryPickedCommits result := self.c.Git().Rebase.CherryPickCommits(cherryPickedCommits) - err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.SYNC}) + err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{Mode: types.SYNC}) if err != nil { return result } diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 51488a922..6adf84712 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -79,7 +79,9 @@ func (self *MergeAndRebaseHelper) ContinueRebase() error { } func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { - return self.genericMergeCommandImpl(command, true) + // The menu/prompt/confirm handlers that reach here run on the UI thread and + // spin up a worker (via the waiting status below) to do the actual work. + return self.genericMergeCommandImpl(command, true, false) } // genericMergeCommandImpl runs a merge/rebase continue/skip/abort and handles @@ -87,10 +89,12 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { // non-subprocess path runs on a worker with a waiting status. // // showWaitingStatus is false only for the recursive auto-skip in -// CheckMergeOrRebaseWithRefreshOptions: that call already runs on the caller's -// thread (the worker of the enclosing waiting status, or the UI thread for the -// synchronous callers), so it must not spin up a second one. -func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWaitingStatus bool) error { +// checkMergeOrRebaseImpl: that call already runs on the caller's thread (the +// worker of the enclosing waiting status, or the UI thread for the synchronous +// callers), so it must not spin up a second one. calledFromWorker says which of +// those two the body runs on, so the post-action refresh picks Refresh vs +// RefreshFromWorker correctly. +func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWaitingStatus bool, calledFromWorker bool) error { status := self.c.Git().Status.WorkingTreeState() if status.None() { @@ -128,29 +132,30 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa if needsSubprocess { // TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction success, err := self.c.RunSubprocess(self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command)) - self.c.Refresh(types.RefreshOptions{ + self.refreshAfterMergeOrRebase(types.RefreshOptions{ Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(success && selectHeadCommitOnSuccess), - }) + }, calledFromWorker) self.RecordWhetherMergeOrRebaseStartedInLazygit() return err } - runAction := func() error { + runAction := func(calledFromWorker bool) error { result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) - return self.CheckMergeOrRebaseWithRefreshOptions(result, + return self.checkMergeOrRebaseImpl(result, types.RefreshOptions{ Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess), - }) + }, calledFromWorker) } if showWaitingStatus { return self.c.WithWaitingStatus(status.Title(self.c.Tr), func(gocui.Task) error { - return runAction() + // The waiting status ran runAction on a worker. + return runAction(true) }) } - return runAction() + return runAction(calledFromWorker) } // commitSelectionAfterMerge maps whether a merge/rebase/pull created a new @@ -205,17 +210,34 @@ func (self *MergeAndRebaseHelper) RecordWhetherMergeOrRebaseStartedInLazygit() { self.c.Git().Status.WorkingTreeState().Any()) } +// CheckMergeOrRebaseWithRefreshOptions handles the result of a merge/rebase +// step and refreshes. It's for callers running on a worker (the +// WithWaitingStatus / WithInlineStatus handlers), which is the large majority; +// UI-thread callers use CheckMergeOrRebaseWithRefreshOptionsFromUIThread. func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result error, refreshOptions types.RefreshOptions) error { - self.c.Refresh(refreshOptions) + return self.checkMergeOrRebaseImpl(result, refreshOptions, true) +} + +// CheckMergeOrRebaseWithRefreshOptionsFromUIThread is like +// CheckMergeOrRebaseWithRefreshOptions, but for the callers that run the +// merge/rebase synchronously on the UI thread (the WithWaitingStatusSync +// move/revert/squash-fixups/cherry-pick-paste/patch-discard handlers, kept sync +// so rapid key presses batch) rather than on a worker. +func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result error, refreshOptions types.RefreshOptions) error { + return self.checkMergeOrRebaseImpl(result, refreshOptions, false) +} + +func (self *MergeAndRebaseHelper) checkMergeOrRebaseImpl(result error, refreshOptions types.RefreshOptions, calledFromWorker bool) error { + self.refreshAfterMergeOrRebase(refreshOptions, calledFromWorker) self.RecordWhetherMergeOrRebaseStartedInLazygit() if result == nil { return nil } else if strings.Contains(result.Error(), "No changes - did you forget to use") { - return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false) + return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, calledFromWorker) } else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") { - return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false) + return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, calledFromWorker) } else if strings.Contains(result.Error(), "No rebase in progress?") { // assume in this case that we're already done return nil @@ -223,6 +245,18 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result er return self.CheckForConflicts(result) } +// refreshAfterMergeOrRebase issues the post-action refresh on the entry point +// that matches the thread the merge/rebase ran on: RefreshFromWorker for the +// worker callers, Refresh for the ones that stayed synchronously on the UI +// thread. +func (self *MergeAndRebaseHelper) refreshAfterMergeOrRebase(refreshOptions types.RefreshOptions, calledFromWorker bool) { + if calledFromWorker { + self.c.RefreshFromWorker(refreshOptions) + } else { + self.c.Refresh(refreshOptions) + } +} + func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error { return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.ASYNC}) } @@ -628,7 +662,7 @@ func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranch if err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) return nil }) } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 04c7fc290..12244d983 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -741,7 +741,7 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s self.context().MoveSelection(1) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex}) }) } @@ -769,7 +769,7 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta self.context().MoveSelection(-1) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex}) }) } @@ -927,7 +927,7 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end } result := self.c.Git().Commit.Revert(hashes, isMerge) - if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.SYNC}); err != nil { + if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{Mode: types.SYNC}); err != nil { return err } @@ -1127,7 +1127,7 @@ func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, reba self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits) err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit) self.context().MoveSelectedLine(-selectionOffset) - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( err, types.RefreshOptions{Mode: types.SYNC}) }) } diff --git a/pkg/gui/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go index 5e4a17169..d596c2ead 100644 --- a/pkg/gui/controllers/patch_building_controller.go +++ b/pkg/gui/controllers/patch_building_controller.go @@ -228,7 +228,7 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error { self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit) err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex) self.c.Helpers().PatchBuilding.Escape() - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( err, types.RefreshOptions{Mode: types.SYNC}) }) } From 988d04bda9983c6c8c6dfbb26e479315522e4e13 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 14:47:13 +0200 Subject: [PATCH 237/384] Assert a refresh uses the entry point matching its goroutine Now that every commits-reaching refresh issued from a worker goes through RefreshFromWorker, guard the choice: in debug builds, panic if a refresh was issued from the UI thread as RefreshFromWorker or from a worker as Refresh. The caller's own goroutine is recorded at the top of performRefresh, before a BLOCK_UI refresh dispatches onto the UI thread, so the check holds for every mode rather than being fooled by BLOCK_UI. It's scoped to the commits refresh for now, the only converted scope; once the rest are converted the guard can move up to cover every refresh unconditionally. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 48a6b6db8..1d8e17913 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -119,6 +119,15 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // runs inline or has to hop (see captureOnUIThread). fRunsOnUIThread := options.Mode == types.BLOCK_UI || !calledFromWorker + // Record the caller's own goroutine now, before a BLOCK_UI refresh dispatches + // f onto the UI thread, so the debug assertion below can verify the caller + // picked the entry point matching its thread regardless of the mode. Only + // read in debug (goid stays out of production control flow). + callerIsUIThread := false + if self.c.GetConfig().GetDebug() { + callerIsUIThread = self.c.GocuiGui().IsUIThread() + } + f := func() { var scopeSet *set.Set[types.RefreshableView] if len(options.Scope) == 0 { @@ -203,6 +212,18 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var loadedRemotes []*models.Remote includeWorktreesWithBranches := false if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { + // Debug-only guard: the caller must have picked the entry point that + // matches its goroutine — Refresh on the UI thread, RefreshFromWorker + // on a worker. We check the caller's own thread (captured above, + // before any BLOCK_UI dispatch), so it holds regardless of the mode. + // It's scoped to the commits refresh for now, the one scope whose + // worker reads have moved to the UI-thread capture below; once the + // other scopes are converted too it can move up to guard every + // refresh unconditionally. + if self.c.GetConfig().GetDebug() && callerIsUIThread == calledFromWorker { + panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread") + } + // 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. From 56989922e434f213638104c76877394dbd3b387e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 15:43:01 +0200 Subject: [PATCH 238/384] Capture the remotes/sub-commits/commit-files/rebase-commits inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These four refreshes each read model, context, and mode state directly on their worker — the same class of race the commits refresh had: - remotes reads the selected remote (Contexts().Remotes.GetSelected), needed to keep the remote-branches selection valid; - sub-commits reads the SubCommits ref/limit/divergence, the filtering path/author, and Model.MainBranches/HashPool; - commit-files reads the diff endpoints (CommitFiles from/to and the diffing args); - rebase-commits reads Model.HashPool/Commits. Give each the same treatment as commits: gather its inputs into an immutable snapshot on the UI thread (via captureOnUIThread, inline for a UI-thread refresh, hopped for a worker one) before dispatching the git work, and have the refresh compute from the snapshot. The commit-files re-init inside the commits refresh captures its endpoints in the bounce, right after ReInit sets them, before dispatching to the worker. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 111 ++++++++++++++---- 1 file changed, 90 insertions(+), 21 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 1d8e17913..66912b877 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -260,16 +260,29 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } 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 - refresh("rebase commits", func() { _ = self.refreshRebaseCommits(options.Background) }) + var rebaseHashPool *utils.StringPool + var rebaseCommits []*models.Commit + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + rebaseHashPool, rebaseCommits = self.captureRebaseCommitState() + }) + refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, options.Background) }) } if scopeSet.Includes(types.SUB_COMMITS) { - refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(options.Background) }) + var capturedSubCommits capturedSubCommitState + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + capturedSubCommits = self.captureSubCommitState() + }) + refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, options.Background) }) } // 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) { - refresh("commit files", func() { _ = self.refreshCommitFilesContext(options.Background) }) + var capturedCommitFiles capturedCommitFilesState + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + capturedCommitFiles = self.captureCommitFilesState() + }) + refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, options.Background) }) } fileWg := sync.WaitGroup{} @@ -290,9 +303,16 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.REMOTES) { + // Capture the previously-selected remote on the UI thread; the worker + // needs it to keep the remote-branches selection valid, and reading + // the Remotes context off the UI thread races its render. + var prevSelectedRemote *models.Remote + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + prevSelectedRemote = self.c.Contexts().Remotes.GetSelected() + }) branchesAndRemotesWg.Add(1) refresh("remotes", func() { - loadedRemotes, _ = self.refreshRemotes(options.Background) + loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, options.Background) branchesAndRemotesWg.Done() }) } @@ -546,8 +566,11 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS if commit != nil && commit.RefName() != "" { refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() self.c.Contexts().CommitFiles.ReInit(commit, refRange) + // Capture the diff endpoints here, on the UI thread and after + // ReInit has set them, before dispatching the git work. + capturedCommitFiles := self.captureCommitFilesState() self.onWorker(background, func(gocui.Task) error { - _ = self.refreshCommitFilesContext(background) + _ = self.refreshCommitFilesContext(capturedCommitFiles, background) return nil }) } @@ -726,8 +749,35 @@ func hasRestorableCommitHash(commits []*models.Commit, idx int) bool { return idx >= 0 && idx < len(commits) && commits[idx].Hash() != "" } -func (self *RefreshHelper) refreshSubCommitsWithLimit(background bool) error { - if self.c.Contexts().SubCommits.GetRef() == 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. +type capturedSubCommitState struct { + ref models.Ref + limitCommits bool + refToShowDivergenceFrom string + filterPath string + filterAuthor string + mainBranches *git_commands.MainBranches + hashPool *utils.StringPool +} + +// captureSubCommitState reads the sub-commits refresh's inputs into an immutable +// snapshot. It must run on the UI thread. +func (self *RefreshHelper) captureSubCommitState() capturedSubCommitState { + return capturedSubCommitState{ + ref: self.c.Contexts().SubCommits.GetRef(), + limitCommits: self.c.Contexts().SubCommits.GetLimitCommits(), + refToShowDivergenceFrom: self.c.Contexts().SubCommits.GetRefToShowDivergenceFrom(), + filterPath: self.c.Modes().Filtering.GetPath(), + filterAuthor: self.c.Modes().Filtering.GetAuthor(), + mainBranches: self.c.Model().MainBranches, + hashPool: self.c.Model().HashPool, + } +} + +func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommitState, background bool) error { + if captured.ref == nil { return nil } @@ -735,15 +785,15 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit(background bool) error { commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ - Limit: self.c.Contexts().SubCommits.GetLimitCommits(), - FilterPath: self.c.Modes().Filtering.GetPath(), - FilterAuthor: self.c.Modes().Filtering.GetAuthor(), + Limit: captured.limitCommits, + FilterPath: captured.filterPath, + FilterAuthor: captured.filterAuthor, IncludeRebaseCommits: false, - RefName: self.c.Contexts().SubCommits.GetRef().FullRefName(), - RefToShowDivergenceFrom: self.c.Contexts().SubCommits.GetRefToShowDivergenceFrom(), - RefForPushedStatus: self.c.Contexts().SubCommits.GetRef(), - MainBranches: self.c.Model().MainBranches, - HashPool: self.c.Model().HashPool, + RefName: captured.ref.FullRefName(), + RefToShowDivergenceFrom: captured.refToShowDivergenceFrom, + RefForPushedStatus: captured.ref, + MainBranches: captured.mainBranches, + HashPool: captured.hashPool, }, ) if err != nil { @@ -771,12 +821,26 @@ func (self *RefreshHelper) RefreshAuthors(commits []*models.Commit) { } } -func (self *RefreshHelper) refreshCommitFilesContext(background bool) error { +// capturedCommitFilesState holds the commit-files refresh's context/mode inputs +// (the diff endpoints), gathered on the UI thread before the git work runs. +type capturedCommitFilesState struct { + from string + to string + reverse bool +} + +// captureCommitFilesState reads the commit-files refresh's diff endpoints into +// an immutable snapshot. It must run on the UI thread. +func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState { from, to := self.c.Contexts().CommitFiles.GetFromAndToForDiff() from, reverse := self.c.Modes().Diffing.GetFromAndReverseArgsForDiff(from) + return capturedCommitFilesState{from: from, to: to, reverse: reverse} +} + +func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, background bool) error { generation := self.c.State().GetRepoGeneration() - files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(from, to, reverse) + files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse) if err != nil { return err } @@ -789,10 +853,16 @@ func (self *RefreshHelper) refreshCommitFilesContext(background bool) error { return nil } -func (self *RefreshHelper) refreshRebaseCommits(background bool) error { +// captureRebaseCommitState reads the rebase-commits refresh's model inputs into +// an immutable snapshot. It must run on the UI thread. +func (self *RefreshHelper) captureRebaseCommitState() (hashPool *utils.StringPool, commits []*models.Commit) { + return self.c.Model().HashPool, self.c.Model().Commits +} + +func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, commits []*models.Commit, background bool) error { generation := self.c.State().GetRepoGeneration() - updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(self.c.Model().HashPool, self.c.Model().Commits) + updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(hashPool, commits) if err != nil { return err } @@ -1173,9 +1243,8 @@ func (self *RefreshHelper) refreshReflogCommits(background bool, selectTopEntry return reflogCommits, nil } -func (self *RefreshHelper) refreshRemotes(background bool) ([]*models.Remote, error) { +func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, background bool) ([]*models.Remote, error) { generation := self.c.State().GetRepoGeneration() - prevSelectedRemote := self.c.Contexts().Remotes.GetSelected() remotes, err := self.c.Git().Loaders.RemoteLoader.GetRemotes() if err != nil { From 3e5c99e1e4580607292529c5314b12c716164176 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 16:19:30 +0200 Subject: [PATCH 239/384] Make the started-in-lazygit and startup-stage flags atomic GuiRepoState.mergeOrRebaseStartedInLazygit and StartupStage are plain fields, but they're written and read from worker goroutines: the former from both the files refresh and the merge/rebase result path (which runs on a worker for the async callers), the latter from the reflog/branches load as it transitions the startup stage. Those are data races. Make both atomic, like Branch.BehindBaseBranch. They're leaf flags, not mutexes guarding model or view state, so an atomic is the natural fit and keeps the merge/rebase result path out of this change. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/gui.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 5aa8beaec..d77673e9c 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -234,8 +234,11 @@ type GuiRepoState struct { SplitMainPanel bool - SearchState *types.SearchState - StartupStage types.StartupStage // Allows us to not load everything at once + SearchState *types.SearchState + // Lets us not load everything at once. Written and read from refresh + // workers (the reflog/branches load transitions it INITIAL->COMPLETE), so + // it's atomic. Holds a types.StartupStage. + startupStage atomic.Int32 ContextMgr *ContextMgr Contexts *context.ContextTree @@ -262,7 +265,11 @@ type GuiRepoState struct { // continue such an operation once its conflicts are resolved if we started // it ourselves; for an externally started one, popping up unbidden would be // confusing. Reset whenever we observe that no operation is in progress. - mergeOrRebaseStartedInLazygit bool + // + // Written from both the files refresh worker and the merge/rebase result + // path (which runs on a worker for the async callers), and read from the + // files refresh worker, so it's atomic. + mergeOrRebaseStartedInLazygit atomic.Bool } var _ types.IRepoStateAccessor = new(GuiRepoState) @@ -276,11 +283,11 @@ func (self *GuiRepoState) GetWindowViewNameMap() *utils.ThreadSafeMap[string, st } func (self *GuiRepoState) GetStartupStage() types.StartupStage { - return self.StartupStage + return types.StartupStage(self.startupStage.Load()) } func (self *GuiRepoState) SetStartupStage(value types.StartupStage) { - self.StartupStage = value + self.startupStage.Store(int32(value)) } func (self *GuiRepoState) GetCurrentPopupOpts() *types.CreatePopupPanelOpts { @@ -292,11 +299,11 @@ func (self *GuiRepoState) SetCurrentPopupOpts(value *types.CreatePopupPanelOpts) } func (self *GuiRepoState) GetMergeOrRebaseStartedInLazygit() bool { - return self.mergeOrRebaseStartedInLazygit + return self.mergeOrRebaseStartedInLazygit.Load() } func (self *GuiRepoState) SetMergeOrRebaseStartedInLazygit(value bool) { - self.mergeOrRebaseStartedInLazygit = value + self.mergeOrRebaseStartedInLazygit.Store(value) } func (self *GuiRepoState) GetScreenMode() types.ScreenMode { From fd6b20847acba3331d06a1a375ea38b57509f0e2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 16:19:42 +0200 Subject: [PATCH 240/384] Capture the files, reflog, branches and stash refresh inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining refresh scopes each still read model, context, and mode state directly on their worker, racing the UI thread — the same class of race the commits refresh had: - files reads Model.Files (to detect resolved conflicts and drive the auto-stage) and the Files context's ForceShowUntracked; - reflog reads the existing reflog slices (for the incremental fetch), Model.HashPool and the filtering path/author; - branches reads Model.MainBranches and the previous branches (for the BehindBaseBranch carry-over); - stash reads the filtering path. Gather each scope's inputs into an immutable snapshot on the UI thread (via captureOnUIThread) before dispatching the git work, and have the refresh compute from the snapshot — for branches, threaded through both the immediate and the recency-sorted startup loads, which share one snapshot (the BehindBaseBranch carry-over is identical either way). Status, tags and worktrees read nothing UI-owned, so they're left alone. For the snapshots to actually run on the UI thread, the worker callers that reach these scopes must announce themselves: convert the submodule operations, the submodule stash-and-reset, and the background files poller to RefreshFromWorker. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/background.go | 2 +- pkg/gui/controllers/files_controller.go | 2 +- pkg/gui/controllers/helpers/refresh_helper.go | 141 ++++++++++++++---- pkg/gui/controllers/submodules_controller.go | 16 +- 4 files changed, 118 insertions(+), 43 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 17f3677f6..8633f4624 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -133,7 +133,7 @@ func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() { userConfig := self.gui.UserConfig() self.goEvery(userConfig.Refresher.RefreshIntervalDuration(), self.gui.stopChan, func(_ bool) error { - self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Background: true}) + self.gui.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Background: true}) return nil }) } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index d7720ee34..fc35518e7 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1808,7 +1808,7 @@ func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) e return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}}) return nil }) } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 66912b877..cbf388d0a 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -227,13 +227,17 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // 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 refresh's model/context/mode inputs 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. + // Capture the commits, reflog and branches refresh inputs (model, + // contexts, modes) on the UI thread, before the git work is dispatched + // to a worker, so the workers compute from an immutable snapshot + // instead of reading state the UI thread concurrently mutates. var capturedCommits capturedCommitState + var capturedReflog capturedReflogState + var capturedBranches capturedBranchState self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { capturedCommits = self.captureCommitsState(options.CommitSelection) + capturedReflog = self.captureReflogState() + capturedBranches = self.captureBranchState() }) refresh("commits and commit files", func() { self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, options.Background) @@ -243,7 +247,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { branchesAndRemotesWg.Add(1) refresh("reflog and branches", func() { - loadedBranches = self.refreshReflogAndBranches(includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, options.Background) + loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, options.Background) branchesAndRemotesWg.Done() }) } else { @@ -251,11 +255,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr refresh("branches", func() { // Not a recency sort, so branches doesn't depend on the reflog // being fresh; it runs concurrently with the reflog refresh - // below and reads whatever's in the model, as it always has. - loadedBranches = self.refreshBranches(includeWorktreesWithBranches, options.BranchSelection, true, self.c.Model().ReflogCommits, options.Background) + // below and uses the reflog we captured up front, as it always has. + loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, options.Background) branchesAndRemotesWg.Done() }) - refresh("reflog", func() { _, _ = self.refreshReflogCommits(options.Background, options.SelectTopReflogCommit) }) + refresh("reflog", func() { + _, _ = self.refreshReflogCommits(capturedReflog, options.Background, options.SelectTopReflogCommit) + }) } } else if scopeSet.Includes(types.REBASE_COMMITS) { // the above block handles rebase commits so we only need to call this one @@ -287,15 +293,23 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr fileWg := sync.WaitGroup{} if scopeSet.Includes(types.FILES) { + var capturedFiles capturedFilesState + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + capturedFiles = self.captureFilesState() + }) fileWg.Add(1) refresh("files", func() { - _ = self.refreshFilesAndSubmodules(options.Background) + _ = self.refreshFilesAndSubmodules(capturedFiles, options.Background) fileWg.Done() }) } if scopeSet.Includes(types.STASH) { - refresh("stash", func() { self.refreshStashEntries(options.Background) }) + var stashFilterPath string + self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + stashFilterPath = self.c.Modes().Filtering.GetPath() + }) + refresh("stash", func() { self.refreshStashEntries(stashFilterPath, options.Background) }) } if scopeSet.Includes(types.TAGS) { @@ -483,17 +497,60 @@ func getModeName(mode types.RefreshMode) string { // order gives the immediate (non-recency) load a lower branch-load sequence // than the async (recency) load, so the sequence guard in refreshBranches keeps // the recency-sorted result even if the two loads' bounces land out of order. -func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, background bool) []*models.Branch { +// capturedReflogState holds the reflog refresh's model/mode inputs, gathered on +// the UI thread before the git work runs. The existing reflog slices feed the +// incremental fetch (we only load entries newer than the ones we already have). +type capturedReflogState struct { + reflogCommits []*models.Commit + filteredReflogCommits []*models.Commit + hashPool *utils.StringPool + filteringActive bool + filterPath string + filterAuthor string +} + +// captureReflogState reads the reflog refresh's inputs into an immutable +// snapshot. It must run on the UI thread. +func (self *RefreshHelper) captureReflogState() capturedReflogState { + return capturedReflogState{ + reflogCommits: self.c.Model().ReflogCommits, + filteredReflogCommits: self.c.Model().FilteredReflogCommits, + hashPool: self.c.Model().HashPool, + filteringActive: self.c.Modes().Filtering.Active(), + filterPath: self.c.Modes().Filtering.GetPath(), + filterAuthor: self.c.Modes().Filtering.GetAuthor(), + } +} + +// capturedBranchState holds the branches refresh's model inputs, gathered on the +// UI thread before the git work runs. oldBranches is used only to carry over the +// previous BehindBaseBranch values (to reduce flicker) — an atomic each, so a +// pre-refresh snapshot serves both the immediate and recency loads identically. +type capturedBranchState struct { + mainBranches *git_commands.MainBranches + oldBranches []*models.Branch +} + +// captureBranchState reads the branches refresh's model inputs into an immutable +// snapshot. It must run on the UI thread. +func (self *RefreshHelper) captureBranchState() capturedBranchState { + return capturedBranchState{ + mainBranches: self.c.Model().MainBranches, + oldBranches: self.c.Model().Branches, + } +} + +func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, background bool) []*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(refreshWorktrees, branchSelection, false, self.c.Model().ReflogCommits, background) + branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, background) self.onWorker(background, func(_ gocui.Task) error { - reflogCommits, _ := self.refreshReflogCommits(background, false) - self.refreshBranches(false, types.SelectCheckedOutBranch, true, reflogCommits, background) + reflogCommits, _ := self.refreshReflogCommits(capturedReflog, background, false) + self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, background) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) @@ -501,8 +558,8 @@ func (self *RefreshHelper) refreshReflogAndBranches(refreshWorktrees bool, branc return branches case types.COMPLETE: - reflogCommits, _ := self.refreshReflogCommits(background, selectTopReflogCommit) - return self.refreshBranches(refreshWorktrees, branchSelection, true, reflogCommits, background) + reflogCommits, _ := self.refreshReflogCommits(capturedReflog, background, selectTopReflogCommit) + return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, background) } return nil @@ -901,15 +958,15 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // 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(refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) []*models.Branch { +func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, background bool) []*models.Branch { loadSeq := self.branchLoadSeq.Add(1) generation := self.c.State().GetRepoGeneration() branches, err := self.c.Git().Loaders.BranchLoader.Load( reflogCommits, - self.c.Model().MainBranches, - self.c.Model().Branches, + captured.mainBranches, + captured.oldBranches, loadBehindCounts, func(f func() error) { self.onWorker(background, func(_ gocui.Task) error { @@ -994,13 +1051,13 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, branchSelectio return branches } -func (self *RefreshHelper) refreshFilesAndSubmodules(background bool) error { +func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState, background bool) error { configs, err := self.refreshStateSubmoduleConfigs() if err != nil { return err } - if err := self.refreshStateFiles(background, configs); err != nil { + if err := self.refreshStateFiles(captured, background, configs); err != nil { return err } @@ -1077,7 +1134,25 @@ func (self *RefreshHelper) captureOnUIThread(fRunsOnUIThread bool, background bo } } -func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs []*models.SubmoduleConfig) error { +// capturedFilesState holds the files refresh's context/model inputs, gathered +// on the UI thread before the git work runs: the previous files list (to detect +// resolved conflicts and drive the auto-stage), and whether untracked files are +// force-shown. +type capturedFilesState struct { + prevFiles []*models.File + forceShowUntracked bool +} + +// captureFilesState reads the files refresh's inputs into an immutable snapshot. +// It must run on the UI thread. +func (self *RefreshHelper) captureFilesState() capturedFilesState { + return capturedFilesState{ + prevFiles: self.c.Model().Files, + forceShowUntracked: self.c.Contexts().Files.ForceShowUntracked(), + } +} + +func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, background bool, submoduleConfigs []*models.SubmoduleConfig) error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel generation := self.c.State().GetRepoGeneration() @@ -1091,7 +1166,7 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // Although this also means that at startup we won't be staging anything until // we call git status again. pathsToStage := []string{} - for _, file := range self.c.Model().Files { + for _, file := range captured.prevFiles { if file.HasMergeConflicts { prevConflictFileCount++ } @@ -1115,7 +1190,7 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ files := self.c.Git().Loaders.FileLoader. GetStatusFiles(git_commands.GetStatusFileOptions{ - ForceShowUntracked: self.c.Contexts().Files.ForceShowUntracked(), + ForceShowUntracked: captured.forceShowUntracked, Background: background, }) @@ -1186,15 +1261,15 @@ func (self *RefreshHelper) refreshStateFiles(background bool, submoduleConfigs [ // refreshReflogCommits returns the (non-filtered) ReflogCommits it loaded, so // that a subsequent branches refresh can use them for recency sorting without // having to read them back out of the model. -func (self *RefreshHelper) refreshReflogCommits(background bool, selectTopEntry bool) ([]*models.Commit, error) { +func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, background bool, selectTopEntry bool) ([]*models.Commit, error) { generation := self.c.State().GetRepoGeneration() // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception model := self.c.Model() // load does the git work on the worker and returns the new value for a - // reflog slice, reading the existing slice for the incremental fetch. The - // caller writes the result in the bounce. + // reflog slice, reading the existing slice (captured on the UI thread) for + // the incremental fetch. The caller writes the result in the bounce. load := func(existing []*models.Commit, filterPath string, filterAuthor string) ([]*models.Commit, error) { var lastReflogCommit *models.Commit if filterPath == "" && filterAuthor == "" && len(existing) > 0 { @@ -1202,7 +1277,7 @@ func (self *RefreshHelper) refreshReflogCommits(background bool, selectTopEntry } commits, onlyObtainedNewReflogCommits, err := self.c.Git().Loaders.ReflogCommitLoader. - GetReflogCommits(model.HashPool, lastReflogCommit, filterPath, filterAuthor) + GetReflogCommits(captured.hashPool, lastReflogCommit, filterPath, filterAuthor) if err != nil { return nil, err } @@ -1213,14 +1288,14 @@ func (self *RefreshHelper) refreshReflogCommits(background bool, selectTopEntry return commits, nil } - reflogCommits, err := load(model.ReflogCommits, "", "") + reflogCommits, err := load(captured.reflogCommits, "", "") if err != nil { return nil, err } filteredReflogCommits := reflogCommits - if self.c.Modes().Filtering.Active() { - filteredReflogCommits, err = load(model.FilteredReflogCommits, self.c.Modes().Filtering.GetPath(), self.c.Modes().Filtering.GetAuthor()) + if captured.filteringActive { + filteredReflogCommits, err = load(captured.filteredReflogCommits, captured.filterPath, captured.filterAuthor) if err != nil { return nil, err } @@ -1304,11 +1379,11 @@ func (self *RefreshHelper) refreshWorktrees(background bool) { self.refreshView(self.c.Contexts().Worktrees, background) } -func (self *RefreshHelper) refreshStashEntries(background bool) { +func (self *RefreshHelper) refreshStashEntries(filterPath string, background bool) { generation := self.c.State().GetRepoGeneration() stashEntries := self.c.Git().Loaders.StashLoader. - GetStashEntries(self.c.Modes().Filtering.GetPath()) + GetStashEntries(filterPath) self.onUIThreadUnlessRepoChanged(generation, background, func() error { self.c.Model().StashEntries = stashEntries diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go index 97b7ff3dd..a2dd22ed3 100644 --- a/pkg/gui/controllers/submodules_controller.go +++ b/pkg/gui/controllers/submodules_controller.go @@ -164,7 +164,7 @@ func (self *SubmodulesController) add() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) }, @@ -193,7 +193,7 @@ func (self *SubmodulesController) editURL(submodule *models.SubmoduleConfig) err return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) }, @@ -210,7 +210,7 @@ func (self *SubmodulesController) init(submodule *models.SubmoduleConfig) error return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) } @@ -229,7 +229,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) }, @@ -244,7 +244,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) }, @@ -259,7 +259,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) }, @@ -274,7 +274,7 @@ func (self *SubmodulesController) openBulkActionsMenu() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) }, @@ -292,7 +292,7 @@ func (self *SubmodulesController) update(submodule *models.SubmoduleConfig) erro return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.SUBMODULES}}) return nil }) } From 5162a768eb4c23531acbdf89d0519f6918060d9d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 16:20:11 +0200 Subject: [PATCH 241/384] Guard every refresh's entry point, not just the commits scope With every scope's worker reads now captured on the UI thread and every worker caller on RefreshFromWorker, the debug entry-point assertion no longer needs to be scoped to the commits refresh. Move it to the top of performRefresh so it guards every refresh regardless of which scopes it touches, and drop the per-scope gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index cbf388d0a..65870ca20 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -119,13 +119,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // runs inline or has to hop (see captureOnUIThread). fRunsOnUIThread := options.Mode == types.BLOCK_UI || !calledFromWorker - // Record the caller's own goroutine now, before a BLOCK_UI refresh dispatches - // f onto the UI thread, so the debug assertion below can verify the caller - // picked the entry point matching its thread regardless of the mode. Only - // read in debug (goid stays out of production control flow). - callerIsUIThread := false - if self.c.GetConfig().GetDebug() { - callerIsUIThread = self.c.GocuiGui().IsUIThread() + // Debug-only guard: every refresh must be issued from the entry point that + // matches its goroutine — Refresh on the UI thread, RefreshFromWorker on a + // worker. We check the caller's own goroutine here, before a BLOCK_UI + // refresh dispatches f onto the UI thread, so it holds regardless of the + // mode. goid stays out of production control flow (debug only). + if self.c.GetConfig().GetDebug() && self.c.GocuiGui().IsUIThread() == calledFromWorker { + panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread") } f := func() { @@ -212,18 +212,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var loadedRemotes []*models.Remote includeWorktreesWithBranches := false if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { - // Debug-only guard: the caller must have picked the entry point that - // matches its goroutine — Refresh on the UI thread, RefreshFromWorker - // on a worker. We check the caller's own thread (captured above, - // before any BLOCK_UI dispatch), so it holds regardless of the mode. - // It's scoped to the commits refresh for now, the one scope whose - // worker reads have moved to the UI-thread capture below; once the - // other scopes are converted too it can move up to guard every - // refresh unconditionally. - if self.c.GetConfig().GetDebug() && callerIsUIThread == calledFromWorker { - panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread") - } - // 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. From eb95ae15f39bcee6a57c36f876d689e355cbca73 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 17:22:38 +0200 Subject: [PATCH 242/384] Capture the custom-patch handlers' commit reads on the UI thread These handlers dispatch their rebase to a worker via WithWaitingStatus but read Model().Commits (and, for move-to-selected-commit, the selected line index) from inside that worker, racing the UI thread's model writes. Read them on the UI thread before dispatching and close over the results. getPatchCommitIndex stays as-is: moving its call out of the worker makes its own Model().Commits read UI-thread-bound too, so the identical copy in patch_building_controller.go needs no matching signature change. The two pull-patch-into-new-commit handlers still push a context and close the commit-message panel from the worker; those writes are a separate concern, left for a follow-up. --- .../custom_patch_options_menu_action.go | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pkg/gui/controllers/custom_patch_options_menu_action.go b/pkg/gui/controllers/custom_patch_options_menu_action.go index cabba4739..20e36833e 100644 --- a/pkg/gui/controllers/custom_patch_options_menu_action.go +++ b/pkg/gui/controllers/custom_patch_options_menu_action.go @@ -132,10 +132,11 @@ func (self *CustomPatchOptionsMenuAction) returnFocusFromPatchExplorerIfNecessar func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error { self.returnFocusFromPatchExplorerIfNecessary() + commits := self.c.Model().Commits + commitIndex := self.getPatchCommitIndex() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { - commitIndex := self.getPatchCommitIndex() self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit) - err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex) + err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) } @@ -143,10 +144,12 @@ func (self *CustomPatchOptionsMenuAction) handleDeletePatchFromCommit() error { func (self *CustomPatchOptionsMenuAction) handleMovePatchToSelectedCommit() error { self.returnFocusFromPatchExplorerIfNecessary() + commits := self.c.Model().Commits + commitIndex := self.getPatchCommitIndex() + toCommitIndex := self.c.Contexts().LocalCommits.GetSelectedLineIdx() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { - commitIndex := self.getPatchCommitIndex() self.c.LogAction(self.c.Tr.Actions.MovePatchToSelectedCommit) - err := self.c.Git().Patch.MovePatchToSelectedCommit(self.c.Model().Commits, commitIndex, self.c.Contexts().LocalCommits.GetSelectedLineIdx()) + err := self.c.Git().Patch.MovePatchToSelectedCommit(commits, commitIndex, toCommitIndex) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) } @@ -159,10 +162,11 @@ func (self *CustomPatchOptionsMenuAction) handleMovePatchIntoWorkingTree() error Title: self.c.Tr.MustStashTitle, Prompt: self.c.Tr.MustStashWarning, HandleConfirm: func() error { + commits := self.c.Model().Commits + commitIndex := self.getPatchCommitIndex() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { - commitIndex := self.getPatchCommitIndex() self.c.LogAction(self.c.Tr.Actions.MovePatchIntoIndex) - err := self.c.Git().Patch.MovePatchIntoIndex(self.c.Model().Commits, commitIndex, mustStash) + err := self.c.Git().Patch.MovePatchIntoIndex(commits, commitIndex, mustStash) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) }, @@ -183,10 +187,11 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error { DescriptionTitle: self.c.Tr.CommitDescriptionTitle, PreserveMessage: false, OnConfirm: func(summary string, description string) error { + commits := self.c.Model().Commits return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { self.c.Helpers().Commits.CloseCommitMessagePanel() self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit) - err := self.c.Git().Patch.PullPatchIntoNewCommit(self.c.Model().Commits, commitIndex, summary, description) + err := self.c.Git().Patch.PullPatchIntoNewCommit(commits, commitIndex, summary, description) if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil { return err } @@ -214,10 +219,11 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() e DescriptionTitle: self.c.Tr.CommitDescriptionTitle, PreserveMessage: false, OnConfirm: func(summary string, description string) error { + commits := self.c.Model().Commits return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { self.c.Helpers().Commits.CloseCommitMessagePanel() self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit) - err := self.c.Git().Patch.PullPatchIntoNewCommitBefore(self.c.Model().Commits, commitIndex, summary, description) + err := self.c.Git().Patch.PullPatchIntoNewCommitBefore(commits, commitIndex, summary, description) if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil { return err } From b4a976834f9672bdf2bd2da0a7496d8851846efa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 17:24:32 +0200 Subject: [PATCH 243/384] Capture reword/amend/author commit reads on the UI thread handleReword, amendTo, and the reset/set/add-co-author handlers pass Model().Commits (and the selected line index) to a git rebase from inside the WithWaitingStatus worker, racing the UI thread's model writes. Read them on the UI thread before dispatching. The author handlers index the full commit list by absolute start/end, so the range sub-slice withItemsRange hands amendAttribute is not what they need; capture the full Model().Commits there and thread it through. --- .../controllers/local_commits_controller.go | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 12244d983..595918fb2 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -475,7 +475,9 @@ func (self *LocalCommitsController) switchFromCommitMessagePanelToEditor(filepat } func (self *LocalCommitsController) handleReword(summary string, description string) error { - if models.IsHeadCommit(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx()) { + commits := self.c.Model().Commits + selectedIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx() + if models.IsHeadCommit(commits, selectedIdx) { // we've selected the top commit so no rebase is required return self.c.Helpers().GPG.WithGpgHandling(self.c.Git().Commit.RewordLastCommit(summary, description), git_commands.CommitGpgSign, @@ -483,7 +485,7 @@ func (self *LocalCommitsController) handleReword(summary string, description str } return self.c.WithWaitingStatus(self.c.Tr.RewordingStatus, func(gocui.Task) error { - err := self.c.Git().Rebase.RewordCommit(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx(), summary, description) + err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description) if err != nil { return err } @@ -788,11 +790,13 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error { }) } } else { + commits := self.c.Model().Commits + selectedIdx := self.context().GetView().SelectedLineIdx() handleCommit = func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AmendCommit) - err := self.c.Git().Rebase.AmendTo(self.c.Model().Commits, self.context().GetView().SelectedLineIdx()) + err := self.c.Git().Rebase.AmendTo(commits, selectedIdx) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) }) }) @@ -820,26 +824,30 @@ func (self *LocalCommitsController) canAmend(_ *models.Commit) *types.DisabledRe return self.canAmendRange(self.c.Model().Commits, idx, idx) } -func (self *LocalCommitsController) amendAttribute(commits []*models.Commit, start, end int) error { +func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, end int) error { + // The author operations index into the full commit list by absolute + // start/end, so capture that here on the UI thread rather than reading + // Model().Commits from the worker the menu items dispatch to. + commits := self.c.Model().Commits opts := self.c.KeybindingsOpts() return self.c.Menu(types.CreateMenuOptions{ Title: "Amend commit attribute", Items: []*types.MenuItem{ { Label: self.c.Tr.ResetAuthor, - OnPress: func() error { return self.resetAuthor(start, end) }, + OnPress: func() error { return self.resetAuthor(commits, start, end) }, Keys: opts.GetKeys(opts.Config.AmendAttribute.ResetAuthor), Tooltip: self.c.Tr.ResetAuthorTooltip, }, { Label: self.c.Tr.SetAuthor, - OnPress: func() error { return self.setAuthor(start, end) }, + OnPress: func() error { return self.setAuthor(commits, start, end) }, Keys: opts.GetKeys(opts.Config.AmendAttribute.SetAuthor), Tooltip: self.c.Tr.SetAuthorTooltip, }, { Label: self.c.Tr.AddCoAuthor, - OnPress: func() error { return self.addCoAuthor(start, end) }, + OnPress: func() error { return self.addCoAuthor(commits, start, end) }, Keys: opts.GetKeys(opts.Config.AmendAttribute.AddCoAuthor), Tooltip: self.c.Tr.AddCoAuthorTooltip, }, @@ -847,10 +855,10 @@ func (self *LocalCommitsController) amendAttribute(commits []*models.Commit, sta }) } -func (self *LocalCommitsController) resetAuthor(start, end int) error { +func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error { return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor) - if err := self.c.Git().Rebase.ResetCommitAuthor(self.c.Model().Commits, start, end); err != nil { + if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil { return err } @@ -859,14 +867,14 @@ func (self *LocalCommitsController) resetAuthor(start, end int) error { }) } -func (self *LocalCommitsController) setAuthor(start, end int) error { +func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, end int) error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.SetAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor) - if err := self.c.Git().Rebase.SetCommitAuthor(self.c.Model().Commits, start, end, value); err != nil { + if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil { return err } @@ -879,14 +887,14 @@ func (self *LocalCommitsController) setAuthor(start, end int) error { return nil } -func (self *LocalCommitsController) addCoAuthor(start, end int) error { +func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start, end int) error { self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.AddCoAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor) - if err := self.c.Git().Rebase.AddCommitCoAuthor(self.c.Model().Commits, start, end, value); err != nil { + if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil { return err } self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) From fceba3121209658009109b98b2907db66e586e26 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 17:26:30 +0200 Subject: [PATCH 244/384] Capture moveCommitsToNewBranch's model reads on the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two move helpers run inside the WithWaitingStatus worker that withNewBranchNamePrompt dispatches to, but read Model().Files/Submodules (to decide whether to auto-stash) and Model().Commits (the unpushed commits to cherry-pick off the base branch) from there, racing the UI thread's model writes. Compute mustStash — needed by both paths — at the top, and the unpushed commits in the off-of-main menu item, on the UI thread, and pass them into the helpers. --- pkg/gui/controllers/helpers/refs_helper.go | 25 ++++++++++++---------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 70ff18593..61f3a232d 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -424,6 +424,8 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error { return err } + mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) + withNewBranchNamePrompt := func(baseBranchName string, f func(string) error) error { prompt := utils.ResolvePlaceholderString( self.c.Tr.NewBranchNameBranchOff, @@ -462,7 +464,9 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error { Title: self.c.Tr.MoveCommitsToNewBranch, Prompt: prompt, HandleConfirm: func() error { - return withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch) + return withNewBranchNamePrompt(currentBranch.Name, func(newBranchName string) error { + return self.moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName, mustStash) + }) }, }) return nil @@ -482,27 +486,31 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error { { Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, shortBaseBranchName), OnPress: func() error { + commitsToCherryPick := lo.Filter(self.c.Model().Commits, func(commit *models.Commit, _ int) bool { + return commit.Status == models.StatusUnpushed + }) return withNewBranchNamePrompt(shortBaseBranchName, func(newBranchName string) error { - return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, baseBranchRef) + return self.moveCommitsToNewBranchOffOfMainBranch(newBranchName, baseBranchRef, commitsToCherryPick, mustStash) }) }, }, { Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchStackedItem, currentBranch.Name), OnPress: func() error { - return withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch) + return withNewBranchNamePrompt(currentBranch.Name, func(newBranchName string) error { + return self.moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName, mustStash) + }) }, }, }, }) } -func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName string) error { +func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchName string, mustStash bool) error { if err := self.c.Git().Branch.NewWithoutCheckout(newBranchName, "HEAD"); err != nil { return err } - mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) if mustStash { if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil { return err @@ -532,12 +540,7 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa return nil } -func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName string, baseBranchRef string) error { - commitsToCherryPick := lo.Filter(self.c.Model().Commits, func(commit *models.Commit, _ int) bool { - return commit.Status == models.StatusUnpushed - }) - - mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) +func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName string, baseBranchRef string, commitsToCherryPick []*models.Commit, mustStash bool) error { if mustStash { if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil { return err From 462d75232bdd97d73981e0854ba04347e763f4b1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 17:27:07 +0200 Subject: [PATCH 245/384] Look up the submodule file and branch worktree on the UI thread ResetSubmodule and fastForward each call a helper that reads the model from inside their worker: FileForSubmodule reads Model().Files and worktreeForBranch reads Model().Worktrees, racing the UI thread's model writes. Hoist both lookups above the worker dispatch. --- pkg/gui/controllers/branches_controller.go | 2 +- pkg/gui/controllers/files_controller.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 9b9e9e546..a886a410b 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -710,9 +710,9 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { } action := self.c.Tr.Actions.FastForwardBranch + worktree, ok := self.worktreeForBranch(branch) return self.c.WithInlineStatus(branch, types.ItemOperationFastForwarding, context.LOCAL_BRANCHES_CONTEXT_KEY, func(task gocui.Task) error { - worktree, ok := self.worktreeForBranch(branch) if ok { self.c.LogAction(action) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index fc35518e7..b70b67ab7 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1791,10 +1791,10 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { } func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) error { + file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule) return self.c.WithWaitingStatus(self.c.Tr.ResettingSubmoduleStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.ResetSubmodule) - file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule) if file != nil { if err := self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil { return err From 2edfeac5382c2b9d4c107795d8e8cc7d0cccfbb0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 6 Jul 2026 17:32:19 +0200 Subject: [PATCH 246/384] Capture the commit-file discard and patch-toggle reads on the UI thread discard reads Model().Commits and the selected commit index from its WithWaitingStatus worker; read them in HandleConfirm instead. toggleForPatch reads the commit-files ref name from the worker, and its startPatchBuilder call reads the context's canRebase and diff range from there too. Capture the ref name and run startPatchBuilder in HandleConfirm before dispatching; PatchBuilder.Start only assigns fields, so moving it off the worker changes no timing. discard still collapses the range selection from the worker; that write is a separate concern, left for a follow-up. --- .../controllers/commits_files_controller.go | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index f9fda0b93..07979de5f 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -337,6 +337,8 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN Title: self.c.Tr.DiscardFileChangesTitle, Prompt: prompt, HandleConfirm: func() error { + commits := self.c.Model().Commits + selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { var filePaths []string selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes) @@ -356,7 +358,7 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN }) } - err := self.c.Git().Rebase.DiscardOldFileChanges(self.c.Model().Commits, self.c.Contexts().LocalCommits.GetSelectedLineIdx(), filePaths) + err := self.c.Git().Rebase.DiscardOldFileChanges(commits, selectedLineIdx, filePaths) if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil { return err } @@ -442,20 +444,16 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm self.c.UserConfig().Keybinding.Universal.IncreaseContextInDiffView) } + refName := self.context().GetRef().RefName() + toggle := func() error { return self.c.WithWaitingStatus(self.c.Tr.UpdatingPatch, func(gocui.Task) error { - if !self.c.Git().Patch.PatchBuilder.Active() { - if err := self.startPatchBuilder(); err != nil { - return err - } - } - selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes) // Find if any file in the selection is unselected or partially added adding := lo.SomeBy(selectedNodes, func(node *filetree.CommitFileNode) bool { return node.SomeFile(func(file *models.CommitFile) bool { - fileStatus := self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Path, self.context().GetRef().RefName()) + fileStatus := self.c.Git().Patch.PatchBuilder.GetFileStatus(file.Path, refName) return fileStatus == patch.PART || fileStatus == patch.UNSELECTED }) }) @@ -498,6 +496,12 @@ func (self *CommitFilesController) toggleForPatch(selectedNodes []*filetree.Comm self.c.Git().Patch.PatchBuilder.Reset() } + if !self.c.Git().Patch.PatchBuilder.Active() { + if err := self.startPatchBuilder(); err != nil { + return err + } + } + return toggle() }, }) From 6d21efb515f9e5850d30821fee35c71fda560ca2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 09:57:49 +0200 Subject: [PATCH 247/384] Make the local-commits limit-commits flag atomic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckoutRef and ResetToRef set this flag from their worker goroutine (to load fewer commits for speed) while the commits refresh reads it on the UI thread in captureCommitsState to decide how many to load — a data race. Make it an atomic.Bool so those writes are safe where they are, rather than routing the flag through a refresh intent. Precedent: Branch.BehindBaseBranch. --- pkg/gui/context/local_commits_context.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 056035cce..d929aca88 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "strings" + "sync/atomic" "time" "github.com/jesseduffield/lazygit/pkg/commands/models" @@ -142,7 +143,9 @@ type LocalCommitsViewModel struct { // If this is true we limit the amount of commits we load, for the sake of keeping things fast. // If the user attempts to scroll past the end of the list, we will load more commits. - limitCommits bool + // Atomic because a checkout or reset sets it from a worker goroutine while the + // commits refresh reads it on the UI thread to decide how many commits to load. + limitCommits atomic.Bool // If this is true we'll use git log --all when fetching the commits. showWholeGitGraph bool @@ -151,9 +154,9 @@ type LocalCommitsViewModel struct { func NewLocalCommitsViewModel(getModel func() []*models.Commit, c *ContextCommon) *LocalCommitsViewModel { self := &LocalCommitsViewModel{ ListViewModel: NewListViewModel(getModel), - limitCommits: true, showWholeGitGraph: c.UserConfig().Git.Log.ShowWholeGraph, } + self.limitCommits.Store(true) return self } @@ -225,11 +228,11 @@ func (self *LocalCommitsContext) ModelSearchResults(searchStr string, caseSensit } func (self *LocalCommitsViewModel) SetLimitCommits(value bool) { - self.limitCommits = value + self.limitCommits.Store(value) } func (self *LocalCommitsViewModel) GetLimitCommits() bool { - return self.limitCommits + return self.limitCommits.Load() } func (self *LocalCommitsViewModel) SetShowWholeGitGraph(value bool) { From 6c38ddc9a7e0fc5fbfb6a0c965534336e60c1aa1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:00:09 +0200 Subject: [PATCH 248/384] Set ResetToRef's post-reset selection via refresh intents ResetToRef ran on a worker and wrote the local-commits and reflog selection directly (SetSelection(0) on both) before its refresh, racing the UI thread. Fold those into the refresh's selection intents: SelectHeadCommit for the commits (after a reset HEAD is the top commit, and mid-interactive-rebase it correctly picks the real head over the first todo entry) and SelectTopReflogCommit for the reflog. The now-atomic SetLimitCommits stays where it is. --- pkg/gui/controllers/helpers/refs_helper.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 61f3a232d..5f07b8ea6 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -199,12 +199,14 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string return err } - self.c.Contexts().LocalCommits.SetSelection(0) - self.c.Contexts().ReflogCommits.SetSelection(0) // loading a heap of commits is slow so we limit them whenever doing a reset self.c.Contexts().LocalCommits.SetLimitCommits(true) - self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}, CommitSelection: types.KeepCommitSelectionIndex}) + self.c.RefreshFromWorker(types.RefreshOptions{ + Scope: []types.RefreshableView{types.FILES, types.BRANCHES, types.REFLOG, types.COMMITS}, + CommitSelection: types.SelectHeadCommit, + SelectTopReflogCommit: true, + }) return nil } From 5d8c89349779622903a06f2e06c10c31650eb2c7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:03:16 +0200 Subject: [PATCH 249/384] Capture commits and set selection on the UI thread for squash/fixup/drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interactiveRebaseWithFlag and dropMergeCommit ran inside the WithWaitingStatus worker but read Model().Commits and wrote the selection (SetSelection(startIdx)) there, racing the UI thread. Thread the commits slice in from each caller, and hoist the pre-rebase selection into a UI-thread helper (selectRebaseResultCommit) called before dispatching — squash/fixup unconditionally, drop only on the non-merge path, matching the previous action guard. --- .../controllers/local_commits_controller.go | 47 ++++++++++++------- 1 file changed, 30 insertions(+), 17 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 595918fb2..23e94adf7 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -340,9 +340,11 @@ func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, Title: self.c.Tr.Squash, Prompt: self.c.Tr.SureSquashThisCommit, HandleConfirm: func() error { + commits := self.c.Model().Commits + self.selectRebaseResultCommit(startIdx) return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashCommitDown) - return self.interactiveRebase(todo.Squash, startIdx, endIdx) + return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx) }) }, }) @@ -362,9 +364,11 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star Label: self.c.Tr.Fixup, Keys: menuKey('f'), OnPress: func() error { + commits := self.c.Model().Commits + self.selectRebaseResultCommit(startIdx) return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommit) - return self.interactiveRebase(todo.Fixup, startIdx, endIdx) + return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx) }) }, Tooltip: self.c.Tr.FixupTooltip, @@ -373,9 +377,11 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star Label: self.c.Tr.FixupKeepMessage, Keys: menuKey('c'), OnPress: func() error { + commits := self.c.Model().Commits + self.selectRebaseResultCommit(startIdx) return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage) - return self.interactiveRebaseWithFlag(todo.Fixup, startIdx, endIdx, "-C") + return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C") }) }, Tooltip: self.c.Tr.FixupKeepMessageTooltip, @@ -566,12 +572,16 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start Title: self.c.Tr.DropCommitTitle, Prompt: lo.Ternary(isMerge, self.c.Tr.DropMergeCommitPrompt, self.c.Tr.DropCommitPrompt), HandleConfirm: func() error { + commits := self.c.Model().Commits + if !isMerge { + self.selectRebaseResultCommit(startIdx) + } return self.c.WithWaitingStatus(self.c.Tr.DroppingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DropCommit) if isMerge { - return self.dropMergeCommit(startIdx) + return self.dropMergeCommit(commits, startIdx) } - return self.interactiveRebase(todo.Drop, startIdx, endIdx) + return self.interactiveRebase(commits, todo.Drop, startIdx, endIdx) }) }, }) @@ -579,8 +589,8 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start return nil } -func (self *LocalCommitsController) dropMergeCommit(commitIdx int) error { - err := self.c.Git().Rebase.DropMergeCommit(self.c.Model().Commits, commitIdx) +func (self *LocalCommitsController) dropMergeCommit(commits []*models.Commit, commitIdx int) error { + err := self.c.Git().Rebase.DropMergeCommit(commits, commitIdx) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) } @@ -658,22 +668,25 @@ func (self *LocalCommitsController) pick(selectedCommits []*models.Commit) error panic("should be disabled when not rebasing") } -func (self *LocalCommitsController) interactiveRebase(action todo.TodoCommand, startIdx int, endIdx int) error { - return self.interactiveRebaseWithFlag(action, startIdx, endIdx, "") +func (self *LocalCommitsController) interactiveRebase(commits []*models.Commit, action todo.TodoCommand, startIdx int, endIdx int) error { + return self.interactiveRebaseWithFlag(commits, action, startIdx, endIdx, "") } -func (self *LocalCommitsController) interactiveRebaseWithFlag(action todo.TodoCommand, startIdx int, endIdx int, flag string) error { - // When performing an action that will remove the selected commits, we need to select the - // next commit down (which will end up at the start index after the action is performed) - if action == todo.Drop || action == todo.Fixup || action == todo.Squash { - self.context().SetSelection(startIdx) - } - - err := self.c.Git().Rebase.InteractiveRebase(self.c.Model().Commits, startIdx, endIdx, action, flag) +func (self *LocalCommitsController) interactiveRebaseWithFlag(commits []*models.Commit, action todo.TodoCommand, startIdx int, endIdx int, flag string) error { + err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, action, flag) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) } +// selectRebaseResultCommit selects the commit that a drop/fixup/squash starting +// at startIdx will leave there. It must run on the UI thread before the rebase: +// the commit currently at startIdx is removed, so the refresh's +// keep-selection-by-hash can't restore it and falls back to the index, which by +// then holds the commit that shifted up into its place. +func (self *LocalCommitsController) selectRebaseResultCommit(startIdx int) { + self.context().SetSelection(startIdx) +} + // updateTodos sees if the selected commit is in fact a rebasing // commit meaning you are trying to edit the todo file rather than actually // begin a rebase. It then updates the todo file with that action From f07e94afe0cf236f52d75eef3b8177e119c70b67 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:12:13 +0200 Subject: [PATCH 250/384] Keep RebaseOntoRef's marked-base access on the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three rebase-onto menu items read Modes().MarkedBaseCommit.GetHash() (a bare string field) and, on success, cleared it via ResetMarkedBaseCommit and pushed the commits context — all from the WithWaitingStatus worker, racing the UI thread. Read the marked base hash before dispatching, and bounce the post-rebase reset and context push through OnUIThread, still guarded by the success check so they don't run on the conflict path. --- .../helpers/merge_and_rebase_helper.go | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 6adf84712..b0c53b831 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -424,8 +424,8 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { DisabledReason: disabledReason, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RebaseBranch) + baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error { - baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() var err error if baseCommit != "" { err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(ref, baseCommit) @@ -434,7 +434,9 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { } err = self.CheckMergeOrRebase(err) if err == nil { - return self.ResetMarkedBaseCommit() + self.c.OnUIThread(func() error { + return self.ResetMarkedBaseCommit() + }) } return err }) @@ -449,8 +451,8 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { Tooltip: self.c.Tr.InteractiveRebaseTooltip, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RebaseBranch) + baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error { - baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() var err error if baseCommit != "" { err = self.c.Git().Rebase.EditRebaseFromBaseCommit(ref, baseCommit) @@ -460,10 +462,13 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { if err = self.CheckMergeOrRebase(err); err != nil { return err } - if err = self.ResetMarkedBaseCommit(); err != nil { - return err - } - self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + self.c.OnUIThread(func() error { + if err := self.ResetMarkedBaseCommit(); err != nil { + return err + } + self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + return nil + }) return nil }) }, @@ -477,8 +482,8 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { Tooltip: self.c.Tr.RebaseOntoBaseBranchTooltip, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.RebaseBranch) + baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(task gocui.Task) error { - baseCommit := self.c.Modes().MarkedBaseCommit.GetHash() var err error if baseCommit != "" { err = self.c.Git().Rebase.RebaseBranchFromBaseCommit(baseBranch, baseCommit) @@ -487,7 +492,9 @@ func (self *MergeAndRebaseHelper) RebaseOntoRef(ref string) error { } err = self.CheckMergeOrRebase(err) if err == nil { - return self.ResetMarkedBaseCommit() + self.c.OnUIThread(func() error { + return self.ResetMarkedBaseCommit() + }) } return err }) From 67b0a6b1a46ef80399454f5954fd1f5c33ddacfa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:12:19 +0200 Subject: [PATCH 251/384] Move the pull-patch panel close and focus off the worker The pull-patch-into-new-commit handlers closed the commit-message panel and, on success, pushed the local-commits context from inside the WithWaitingStatus worker. Close the panel in OnConfirm before dispatching (UI thread), and bounce the post-rebase context push through OnUIThread, keeping it on the success path. --- .../custom_patch_options_menu_action.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/custom_patch_options_menu_action.go b/pkg/gui/controllers/custom_patch_options_menu_action.go index 20e36833e..3d15ce899 100644 --- a/pkg/gui/controllers/custom_patch_options_menu_action.go +++ b/pkg/gui/controllers/custom_patch_options_menu_action.go @@ -188,14 +188,17 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommit() error { PreserveMessage: false, OnConfirm: func(summary string, description string) error { commits := self.c.Model().Commits + self.c.Helpers().Commits.CloseCommitMessagePanel() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { - self.c.Helpers().Commits.CloseCommitMessagePanel() self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit) err := self.c.Git().Patch.PullPatchIntoNewCommit(commits, commitIndex, summary, description) if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil { return err } - self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + self.c.OnUIThread(func() error { + self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + return nil + }) return nil }) }, @@ -220,14 +223,17 @@ func (self *CustomPatchOptionsMenuAction) handlePullPatchIntoNewCommitBefore() e PreserveMessage: false, OnConfirm: func(summary string, description string) error { commits := self.c.Model().Commits + self.c.Helpers().Commits.CloseCommitMessagePanel() return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { - self.c.Helpers().Commits.CloseCommitMessagePanel() self.c.LogAction(self.c.Tr.Actions.MovePatchIntoNewCommit) err := self.c.Git().Patch.PullPatchIntoNewCommitBefore(commits, commitIndex, summary, description) if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err); err != nil { return err } - self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + self.c.OnUIThread(func() error { + self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + return nil + }) return nil }) }, From e7105a3138cebf767e15f6f5fd00d816f971609b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:17:33 +0200 Subject: [PATCH 252/384] Collapse the branch range selection on the UI thread after a delete The three branch-delete handlers and the two worktree-removal continuations collapsed the Branches/RemoteBranches range selection from their worker goroutine, racing the UI thread. Wrap each collapse in OnUIThread, keeping it in the same spot relative to the refresh (FIFO preserves the collapse-then-refresh order the name-restore depends on). --- .../controllers/helpers/branches_helper.go | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 683d26db3..5c72bacfd 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -45,7 +45,10 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error return err } - self.c.Contexts().Branches.CollapseRangeSelectionToTop() + self.c.OnUIThread(func() error { + self.c.Contexts().Branches.CollapseRangeSelectionToTop() + return nil + }) self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) return nil }) @@ -86,7 +89,10 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteB } self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) if resetRemoteBranchesSelection { - self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop() + self.c.OnUIThread(func() error { + self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop() + return nil + }) } return nil }) @@ -151,7 +157,10 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branc return err } - self.c.Contexts().Branches.CollapseRangeSelectionToTop() + self.c.OnUIThread(func() error { + self.c.Contexts().Branches.CollapseRangeSelectionToTop() + return nil + }) self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) return nil }) @@ -311,7 +320,10 @@ func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.B return err } - self.c.Contexts().Branches.CollapseRangeSelectionToTop() + self.c.OnUIThread(func() error { + self.c.Contexts().Branches.CollapseRangeSelectionToTop() + return nil + }) self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}, @@ -329,7 +341,10 @@ func (self *BranchesHelper) deleteLocalAndRemoteBranchesContinuation(branches [] return err } - self.c.Contexts().Branches.CollapseRangeSelectionToTop() + self.c.OnUIThread(func() error { + self.c.Contexts().Branches.CollapseRangeSelectionToTop() + return nil + }) self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.REMOTES, types.FILES}, From 6cd93de5b9ba4938cbe648eeab034fc813eb1243 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:18:52 +0200 Subject: [PATCH 253/384] Cancel the commit-file range selection on the UI thread after discard The discard handler cancelled the commit-files range selection from its WithWaitingStatus worker. Bounce it through OnUIThread, keeping it after the successful CheckMergeOrRebase as before. --- pkg/gui/controllers/commits_files_controller.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 07979de5f..b90e14b74 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -363,9 +363,12 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN return err } - if self.context().RangeSelectEnabled() { - self.context().GetList().CancelRangeSelect() - } + self.c.OnUIThread(func() error { + if self.context().RangeSelectEnabled() { + self.context().GetList().CancelRangeSelect() + } + return nil + }) return nil }) From 12757e2723c830d6831913892bb2cc5cde785873 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:20:14 +0200 Subject: [PATCH 254/384] Swap the file-path suggestions trie on the UI thread GetFilePathSuggestionsFunc builds the trie on a worker (the slow AllRepoFiles walk) and then assigned Model().FilesTrie and refreshed the suggestions panel from there, racing the UI thread that reads the trie. Keep the build on the worker but bounce just the model assignment and the refresh through OnUIThread. --- pkg/gui/controllers/helpers/suggestions_helper.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/helpers/suggestions_helper.go b/pkg/gui/controllers/helpers/suggestions_helper.go index d26f96f1c..8a5916816 100644 --- a/pkg/gui/controllers/helpers/suggestions_helper.go +++ b/pkg/gui/controllers/helpers/suggestions_helper.go @@ -137,10 +137,12 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type trie.Insert(patricia.Prefix(file), file) } - // cache the trie for future use - self.c.Model().FilesTrie = trie - - self.c.Contexts().Suggestions.RefreshSuggestions() + self.c.OnUIThread(func() error { + // cache the trie for future use + self.c.Model().FilesTrie = trie + self.c.Contexts().Suggestions.RefreshSuggestions() + return nil + }) return err }) From fefb3b632e79ea478d5883b5f5e25e64555c340d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 10:20:14 +0200 Subject: [PATCH 255/384] Clear the preserved commit message on the UI thread The commit's gpg onSuccess runs on a worker when the command output is streamed, so its ClearPreservedCommitMessage wrote commit-message context state off the UI thread. Bounce that write through OnUIThread. --- pkg/gui/controllers/helpers/working_tree_helper.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 7e321854b..36dfd2032 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -149,7 +149,12 @@ func (self *WorkingTreeHelper) handleCommit(summary string, description string, self.c.LogAction(self.c.Tr.Actions.Commit) return self.gpgHelper.WithGpgHandlingAndSelectHeadCommit(cmdObj, git_commands.CommitGpgSign, self.c.Tr.CommittingStatus, func() error { - self.commitsHelper.ClearPreservedCommitMessage() + // This runs on a worker when the commit output is streamed, so + // bounce the preserved-message write to the UI thread. + self.c.OnUIThread(func() error { + self.commitsHelper.ClearPreservedCommitMessage() + return nil + }) return nil }) } From 2c3a6acafaf511696d4dfb8f5c46a63c2463a65d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 12:36:02 +0200 Subject: [PATCH 256/384] Thread a refreshEnv through the refresh scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every refresh scope needs two ambient values to bounce its model and view updates back to the UI thread safely: the background flag (which picks the dispatch variant that doesn't count towards lazygit being busy) and the repo generation that guards the bounce against a repo switch. These were threaded separately — background as a parameter on every refreshXxx function, generation re-read from the model inside each one. Bundle them into a single refreshEnv passed through instead, so the guard has a home to grow into (the next commit needs the generation in refreshView, which currently has no access to it). Capturing the generation once, at the start of the refresh, is also more correct than the previous per-function re-read. The baseline should reflect the repo whose inputs the refresh snapshotted (all captured up front on the UI thread), not whenever each scope's worker happens to wake. With the per-function read, a background refresh whose worker woke after a repo switch would read the new generation and let its bounce through, writing data computed from the old repo's inputs into the new repo; capturing up front makes that bounce drop instead. No behavior change for foreground refreshes, where the UI thread is held for the whole refresh and the generation can't move under it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 245 +++++++++--------- pkg/gui/types/common.go | 8 +- 2 files changed, 123 insertions(+), 130 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 65870ca20..b1b3ef017 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -89,6 +89,15 @@ func (self *RefreshHelper) RefreshFromWorker(options types.RefreshOptions) { self.performRefresh(options, true) } +type refreshEnv struct { + // whether this is a background refresh (which selects the dispatch variant that + // doesn't count towards lazygit being busy) + background bool + + // the repo generation captured when the refresh started + generation int +} + func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) { if options.Mode == types.ASYNC && options.Then != nil { panic("RefreshOptions.Then doesn't work with mode ASYNC") @@ -129,6 +138,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } f := func() { + // Capture the repo generation once, here at the start, so every scope's + // bounce is guarded against the same baseline. + env := refreshEnv{ + background: options.Background, + generation: self.c.State().GetRepoGeneration(), + } + var scopeSet *set.Set[types.RefreshableView] if len(options.Scope) == 0 { // not refreshing staging/patch-building unless explicitly requested because we only need @@ -186,7 +202,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // everything happens fast and it's better to have everything update // in the one frame if !self.c.InDemo() && options.Mode == types.ASYNC { - self.onWorker(options.Background, func(t gocui.Task) error { + self.onWorker(env.background, func(t gocui.Task) error { f() return nil }) @@ -222,20 +238,20 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var capturedCommits capturedCommitState var capturedReflog capturedReflogState var capturedBranches capturedBranchState - self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { capturedCommits = self.captureCommitsState(options.CommitSelection) capturedReflog = self.captureReflogState() capturedBranches = self.captureBranchState() }) refresh("commits and commit files", func() { - self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, options.Background) + self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env) }) includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { branchesAndRemotesWg.Add(1) refresh("reflog and branches", func() { - loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, options.Background) + loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env) branchesAndRemotesWg.Done() }) } else { @@ -244,11 +260,11 @@ 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, options.Background) + loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, env) branchesAndRemotesWg.Done() }) refresh("reflog", func() { - _, _ = self.refreshReflogCommits(capturedReflog, options.Background, options.SelectTopReflogCommit) + _, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit) }) } } else if scopeSet.Includes(types.REBASE_COMMITS) { @@ -256,52 +272,52 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // if we've asked specifically for rebase commits and not those other things var rebaseHashPool *utils.StringPool var rebaseCommits []*models.Commit - self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { rebaseHashPool, rebaseCommits = self.captureRebaseCommitState() }) - refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, options.Background) }) + refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) }) } if scopeSet.Includes(types.SUB_COMMITS) { var capturedSubCommits capturedSubCommitState - self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { capturedSubCommits = self.captureSubCommitState() }) - refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, options.Background) }) + refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) }) } // reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) { var capturedCommitFiles capturedCommitFilesState - self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { capturedCommitFiles = self.captureCommitFilesState() }) - refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, options.Background) }) + refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) }) } fileWg := sync.WaitGroup{} if scopeSet.Includes(types.FILES) { var capturedFiles capturedFilesState - self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { capturedFiles = self.captureFilesState() }) fileWg.Add(1) refresh("files", func() { - _ = self.refreshFilesAndSubmodules(capturedFiles, options.Background) + _ = self.refreshFilesAndSubmodules(capturedFiles, env) fileWg.Done() }) } if scopeSet.Includes(types.STASH) { var stashFilterPath string - self.captureOnUIThread(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { stashFilterPath = self.c.Modes().Filtering.GetPath() }) - refresh("stash", func() { self.refreshStashEntries(stashFilterPath, options.Background) }) + refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) }) } if scopeSet.Includes(types.TAGS) { - refresh("tags", func() { _ = self.refreshTags(options.Background) }) + refresh("tags", func() { _ = self.refreshTags(env) }) } if scopeSet.Includes(types.REMOTES) { @@ -309,12 +325,12 @@ 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(fRunsOnUIThread, options.Background, func() { + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { prevSelectedRemote = self.c.Contexts().Remotes.GetSelected() }) branchesAndRemotesWg.Add(1) refresh("remotes", func() { - loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, options.Background) + loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env) branchesAndRemotesWg.Done() }) } @@ -326,12 +342,12 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // Model().Branches/Remotes: those writes are bounced onto the // UI thread and may not have landed on this worker yet. The // wait above orders us after both loads have stashed theirs. - self.refreshGithubPullRequests(loadedBranches, loadedRemotes, options.Background) + self.refreshGithubPullRequests(loadedBranches, loadedRemotes, env) }) } if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches { - refresh("worktrees", func() { self.refreshWorktrees(options.Background) }) + refresh("worktrees", func() { self.refreshWorktrees(env) }) } if scopeSet.Includes(types.STAGING) { @@ -341,7 +357,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // scope's model-update bounce — RefreshStagingPanel reads // Model.Files (via Files.GetSelected) and would otherwise // see the pre-refresh model. - self.onUIThread(options.Background, func() error { + self.onUIThread(env.background, func() error { self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) return nil }) @@ -353,10 +369,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.MERGE_CONFLICTS) { - refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState(options.Background) }) + refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState(env.background) }) } - self.refreshStatus(options.Background) + self.refreshStatus(env) wg.Wait() @@ -367,7 +383,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // returned but their bounces haven't been processed yet, so // invoking Then synchronously would run it on a model that's // still pre-refresh. - self.onUIThread(options.Background, options.Then) + self.onUIThread(env.background, options.Then) } } @@ -528,17 +544,17 @@ func (self *RefreshHelper) captureBranchState() capturedBranchState { } } -func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, background bool) []*models.Branch { +func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, 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, background) + branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, env) - self.onWorker(background, func(_ gocui.Task) error { - reflogCommits, _ := self.refreshReflogCommits(capturedReflog, background, false) - self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, background) + self.onWorker(env.background, func(_ gocui.Task) error { + reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, false) + self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, env) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) @@ -546,8 +562,8 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo return branches case types.COMPLETE: - reflogCommits, _ := self.refreshReflogCommits(capturedReflog, background, selectTopReflogCommit) - return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, background) + reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, selectTopReflogCommit) + return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, env) } return nil @@ -592,9 +608,8 @@ func (self *RefreshHelper) captureCommitsState(commitSelection types.CommitSelec } } -func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, background bool) { - generation := self.c.State().GetRepoGeneration() - _ = self.refreshCommitsWithLimit(captured, commitSelection, background) +func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, env refreshEnv) { + _ = self.refreshCommitsWithLimit(captured, commitSelection, env) if captured.parentIsLocalCommits { // This makes sense when we've e.g. just amended a commit, meaning we get a new commit hash at the same position. // However if we've just added a brand new commit, it pushes the list down by one and so we would end up @@ -606,7 +621,7 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS // The commit selection is restored in refreshCommitsWithLimit's bounce, // so read it on the UI thread after that bounce; then load the commit // files back on a worker (refreshCommitFilesContext does git work). - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { commit := self.c.Contexts().LocalCommits.GetSelected() if commit != nil && commit.RefName() != "" { refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() @@ -614,8 +629,8 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS // Capture the diff endpoints here, on the UI thread and after // ReInit has set them, before dispatching the git work. capturedCommitFiles := self.captureCommitFilesState() - self.onWorker(background, func(gocui.Task) error { - _ = self.refreshCommitFilesContext(capturedCommitFiles, background) + self.onWorker(env.background, func(gocui.Task) error { + _ = self.refreshCommitFilesContext(capturedCommitFiles, env) return nil }) } @@ -650,9 +665,7 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref { return nil } -func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, background bool) error { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, env refreshEnv) error { checkedOutRef := self.determineCheckedOutRef() refName, bisectInfo := self.refForLog() commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( @@ -673,7 +686,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, } workingTreeState := self.c.Git().Status.WorkingTreeState() - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits self.RefreshAuthors(commits) @@ -707,7 +720,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, // Enqueued from within this bounce so it runs after refreshView's // render below (which was enqueued first), matching the previous // ordering where FocusLine ran after the view was re-rendered. - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Contexts().LocalCommits.FocusLine(true) return nil }) @@ -715,7 +728,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, return nil }) - self.refreshView(self.c.Contexts().LocalCommits, background) + self.refreshView(self.c.Contexts().LocalCommits, env) return nil } @@ -821,13 +834,11 @@ func (self *RefreshHelper) captureSubCommitState() capturedSubCommitState { } } -func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommitState, background bool) error { +func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommitState, env refreshEnv) error { if captured.ref == nil { return nil } - generation := self.c.State().GetRepoGeneration() - commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: captured.limitCommits, @@ -844,13 +855,13 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommit if err != nil { return err } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().SubCommits = commits self.RefreshAuthors(commits) return nil }) - self.refreshView(self.c.Contexts().SubCommits, background) + self.refreshView(self.c.Contexts().SubCommits, env) return nil } @@ -882,19 +893,17 @@ func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState { return capturedCommitFilesState{from: from, to: to, reverse: reverse} } -func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, background bool) error { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, env refreshEnv) error { files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse) if err != nil { return err } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().CommitFiles = files self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree() return nil }) - self.refreshView(self.c.Contexts().CommitFiles, background) + self.refreshView(self.c.Contexts().CommitFiles, env) return nil } @@ -904,39 +913,35 @@ func (self *RefreshHelper) captureRebaseCommitState() (hashPool *utils.StringPoo return self.c.Model().HashPool, self.c.Model().Commits } -func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, commits []*models.Commit, background bool) error { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, commits []*models.Commit, env refreshEnv) error { updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(hashPool, commits) if err != nil { return err } workingTreeState := self.c.Git().Status.WorkingTreeState() - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().Commits = updatedCommits self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState return nil }) - self.refreshView(self.c.Contexts().LocalCommits, background) + self.refreshView(self.c.Contexts().LocalCommits, env) return nil } -func (self *RefreshHelper) refreshTags(background bool) error { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshTags(env refreshEnv) error { tags, err := self.c.Git().Loaders.TagLoader.GetTags() if err != nil { return err } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().Tags = tags return nil }) - self.refreshView(self.c.Contexts().Tags, background) + self.refreshView(self.c.Contexts().Tags, env) return nil } @@ -946,25 +951,23 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo // 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, background bool) []*models.Branch { +func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { loadSeq := self.branchLoadSeq.Add(1) - generation := self.c.State().GetRepoGeneration() - branches, err := self.c.Git().Loaders.BranchLoader.Load( reflogCommits, captured.mainBranches, captured.oldBranches, loadBehindCounts, func(f func() error) { - self.onWorker(background, func(_ gocui.Task) error { + self.onWorker(env.background, func(_ gocui.Task) error { return f() }) }, func() { - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Contexts().Branches.HandleRender() - self.refreshStatus(background) + self.refreshStatus(env) return nil }) }) @@ -977,7 +980,7 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh worktrees = self.loadWorktrees() } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { // Drop this write if a branch load that started later has already applied // its result. At the INITIAL startup stage an immediate load (not // recency-sorted) and an async recency-sorted load run concurrently; this @@ -1000,7 +1003,7 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh if refreshWorktrees { self.c.Model().Worktrees = worktrees - self.refreshView(self.c.Contexts().Worktrees, background) + self.refreshView(self.c.Contexts().Worktrees, env) } // Setting the selection here, in the same bounce that writes the list, @@ -1030,27 +1033,27 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh return nil }) - self.refreshView(self.c.Contexts().Branches, background) + self.refreshView(self.c.Contexts().Branches, env) - self.refreshStatus(background) + self.refreshStatus(env) // Return the freshly-loaded branches so the caller can hand them to the PR // fetch without reading them back from the (bounce-written) model. return branches } -func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState, background bool) error { +func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState, env refreshEnv) error { configs, err := self.refreshStateSubmoduleConfigs() if err != nil { return err } - if err := self.refreshStateFiles(captured, background, configs); err != nil { + if err := self.refreshStateFiles(captured, env, configs); err != nil { return err } - self.refreshView(self.c.Contexts().Submodules, background) - self.refreshView(self.c.Contexts().Files, background) + self.refreshView(self.c.Contexts().Submodules, env) + self.refreshView(self.c.Contexts().Files, env) return nil } @@ -1060,11 +1063,11 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState // Refresh workers do their git work off the UI thread and enqueue their model // writes here; a repo switch (which replaces the whole model and context tree) // bumps the generation, so a write captured under the old generation must not -// clobber the new repo's state. Callers capture the generation with -// State().GetRepoGeneration() before doing their git work and pass it in. -func (self *RefreshHelper) onUIThreadUnlessRepoChanged(generation int, background bool, f func() error) { - self.onUIThread(background, func() error { - if self.c.State().GetRepoGeneration() != generation { +// clobber the new repo's state. The generation is captured once at the start of +// the refresh and carried in env (see refreshEnv). +func (self *RefreshHelper) onUIThreadUnlessRepoChanged(env refreshEnv, f func() error) { + self.onUIThread(env.background, func() error { + if self.c.State().GetRepoGeneration() != env.generation { return nil } return f() @@ -1140,9 +1143,8 @@ func (self *RefreshHelper) captureFilesState() capturedFilesState { } } -func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, background bool, submoduleConfigs []*models.SubmoduleConfig) error { +func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env refreshEnv, submoduleConfigs []*models.SubmoduleConfig) error { fileTreeViewModel := self.c.Contexts().Files.FileTreeViewModel - generation := self.c.State().GetRepoGeneration() prevConflictFileCount := 0 if self.c.UserConfig().Git.AutoStageResolvedConflicts { @@ -1179,7 +1181,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, backgr files := self.c.Git().Loaders.FileLoader. GetStatusFiles(git_commands.GetStatusFileOptions{ ForceShowUntracked: captured.forceShowUntracked, - Background: background, + Background: env.background, }) conflictFileCount := 0 @@ -1203,7 +1205,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, backgr // (e.g. in the user's editor). Offer to continue it. We only do this // for operations we started ourselves; prompting for one that was // started outside lazygit (e.g. by a coding agent) would be confusing. - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { return self.mergeAndRebaseHelper.PromptToContinueRebase() }) } @@ -1218,7 +1220,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, backgr }) } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { // only taking over the filter if it hasn't already been set by the user. if conflictFileCount > 0 && prevConflictFileCount == 0 { if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { @@ -1249,8 +1251,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, backgr // refreshReflogCommits returns the (non-filtered) ReflogCommits it loaded, so // that a subsequent branches refresh can use them for recency sorting without // having to read them back out of the model. -func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, background bool, selectTopEntry bool) ([]*models.Commit, error) { - generation := self.c.State().GetRepoGeneration() +func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, env refreshEnv, selectTopEntry bool) ([]*models.Commit, error) { // pulling state into its own variable in case it gets swapped out for another state // and we get an out of bounds exception model := self.c.Model() @@ -1289,7 +1290,7 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, ba } } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { model.ReflogCommits = reflogCommits model.FilteredReflogCommits = filteredReflogCommits // Setting the selection here, in the same bounce that writes the list, @@ -1302,26 +1303,24 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, ba return nil }) - self.refreshView(self.c.Contexts().ReflogCommits, background) + self.refreshView(self.c.Contexts().ReflogCommits, env) return reflogCommits, nil } -func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, background bool) ([]*models.Remote, error) { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env refreshEnv) ([]*models.Remote, error) { remotes, err := self.c.Git().Loaders.RemoteLoader.GetRemotes() if err != nil { return nil, err } - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().Remotes = remotes hadPrs := len(self.c.Model().PullRequestsMap) != 0 self.rebuildPullRequestsMap() if !hadPrs && len(self.c.Model().PullRequestsMap) != 0 { // if we didn't have PRs in the map before but now we do, we need to redraw the branches view - self.refreshView(self.c.Contexts().Branches, background) + self.refreshView(self.c.Contexts().Branches, env) } // we need to ensure our selected remote branches aren't now outdated @@ -1337,8 +1336,8 @@ func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, bac return nil }) - self.refreshView(self.c.Contexts().Remotes, background) - self.refreshView(self.c.Contexts().RemoteBranches, background) + self.refreshView(self.c.Contexts().Remotes, env) + self.refreshView(self.c.Contexts().RemoteBranches, env) return remotes, nil } @@ -1351,44 +1350,38 @@ func (self *RefreshHelper) loadWorktrees() []*models.Worktree { return worktrees } -func (self *RefreshHelper) refreshWorktrees(background bool) { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshWorktrees(env refreshEnv) { worktrees := self.loadWorktrees() - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().Worktrees = worktrees return nil }) // need to refresh branches because the branches view shows worktrees against // branches - self.refreshView(self.c.Contexts().Branches, background) - self.refreshView(self.c.Contexts().Worktrees, background) + self.refreshView(self.c.Contexts().Branches, env) + self.refreshView(self.c.Contexts().Worktrees, env) } -func (self *RefreshHelper) refreshStashEntries(filterPath string, background bool) { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshStashEntries(filterPath string, env refreshEnv) { stashEntries := self.c.Git().Loaders.StashLoader. GetStashEntries(filterPath) - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().StashEntries = stashEntries return nil }) - self.refreshView(self.c.Contexts().Stash, background) + self.refreshView(self.c.Contexts().Stash, env) } // never call this on its own, it should only be called from within refreshCommits() -func (self *RefreshHelper) refreshStatus(background bool) { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshStatus(env refreshEnv) { workingTreeState := self.c.Git().Status.WorkingTreeState() repoName := self.c.Git().RepoPaths.RepoName() - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { // Read the checked-out branch and the linked worktree name here on the UI // thread: both derive from models (Branches, Worktrees) that their // refreshes now write via bounces, so reading them on the worker would @@ -1425,10 +1418,10 @@ func (self *RefreshHelper) refForLog() (string, *git_commands.BisectInfo) { return bisectInfo.GetStartHash(), bisectInfo } -func (self *RefreshHelper) refreshView(context types.Context, background bool) { +func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) { // refreshView is called from the worker goroutine that drives async // refreshes, so bounce to the UI thread before mutating view content. - self.onUIThread(background, func() error { + self.onUIThread(env.background, func() error { // Re-applying the filter must be done before re-rendering the view, so that // the filtered list model is up to date for rendering. self.searchHelper.ReApplyFilter(context) @@ -1451,11 +1444,9 @@ func (self *RefreshHelper) refreshView(context types.Context, background bool) { }) } -func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, remotes []*models.Remote, background bool) { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, remotes []*models.Remote, env refreshEnv) { clearPullRequests := func() { - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().PullRequests = nil self.c.Model().PullRequestsMap = nil return nil @@ -1478,7 +1469,7 @@ func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, return } - self.setGithubPullRequests(baseInfo, branches, background) + self.setGithubPullRequests(baseInfo, branches, env) } type githubRemoteInfo struct { @@ -1559,7 +1550,11 @@ func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteI self.c.Log.Error(err) } - self.setGithubPullRequests(&info, branches, false) + // This fetch runs on its own worker after the user picked a + // base remote, so it's not part of a performRefresh and has no + // ambient env; build a foreground one now, capturing the + // current generation as the guard baseline. + self.setGithubPullRequests(&info, branches, refreshEnv{generation: self.c.State().GetRepoGeneration()}) return nil }) }, @@ -1587,9 +1582,7 @@ func (self *RefreshHelper) rebuildPullRequestsMap() { ) } -func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, branches []*models.Branch, background bool) { - generation := self.c.State().GetRepoGeneration() - +func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, branches []*models.Branch, env refreshEnv) { if len(branches) == 0 { return } @@ -1609,7 +1602,7 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra self.savePullRequestsToCache(prs) - self.onUIThreadUnlessRepoChanged(generation, background, func() error { + self.onUIThreadUnlessRepoChanged(env, func() error { self.c.Model().PullRequests = prs // Rebuilding here rather than on the worker means the map is built from // the branches and remotes as they are on the UI thread, after their diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 0f8e99ee8..4ced8bd79 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -394,10 +394,10 @@ type IStateAccessor interface { ClearItemOperation(item HasUrn) // A counter that is bumped every time we switch to a different repository - // (see Gui.resetState). Refresh workers capture it before doing their git - // work and pass it to onUIThreadUnlessRepoChanged, so that a model update - // computed for one repo can be dropped rather than applied to another if the - // user switched repos while the refresh was in flight. + // (see Gui.resetState). A refresh captures it when it starts and carries it + // through to onUIThreadUnlessRepoChanged, so that a model update computed for + // one repo can be dropped rather than applied to another if the user switched + // repos while the refresh was in flight. GetRepoGeneration() int } From 19b34851ff5ffb31aef310686e74a4625404c2b3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 12:39:28 +0200 Subject: [PATCH 257/384] Guard the view-render and prompt-dismiss bounces on the generation The model-update bounces already drop themselves when the repo is switched mid-refresh (onUIThreadUnlessRepoChanged), but three bounces that touch the UI without writing the model did not: refreshView's render, the staging-panel refresh, and the stale continue-rebase prompt dismissal. All three ran unconditionally on the UI thread, so a background refresh in flight across a repo switch could render the old repo's data (through a context object belonging to the now-replaced context tree), or pop the new repo's popup based on the old repo's prompt state. Route them through onUIThreadUnlessRepoChanged too, so they're dropped alongside the model writes they accompany. This also fixes the dismiss bounce using the raw foreground OnUIThread, which ignored the background flag every other bounce in a background refresh respects. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index b1b3ef017..3c8524ffe 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -356,8 +356,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // Bounce onto the UI thread so this runs after the files // scope's model-update bounce — RefreshStagingPanel reads // Model.Files (via Files.GetSelected) and would otherwise - // see the pre-refresh model. - self.onUIThread(env.background, func() error { + // see the pre-refresh model. Guard on the generation so a + // repo switch mid-refresh drops it, like the model bounces. + self.onUIThreadUnlessRepoChanged(env, func() error { self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) return nil }) @@ -1214,7 +1215,10 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // appeared. Either way, a "continue?" prompt we're showing is now stale // (e.g. the operation was continued or aborted outside lazygit), so // dismiss it rather than leave the user with a prompt that would fail. - self.c.OnUIThread(func() error { + // Guard on the generation like the sibling PromptToContinueRebase + // bounce above: if the repo was switched while this refresh was in + // flight, a prompt showing now belongs to the new repo, so leave it be. + self.onUIThreadUnlessRepoChanged(env, func() error { self.mergeAndRebaseHelper.DismissContinueRebasePromptIfShowing() return nil }) @@ -1420,8 +1424,12 @@ func (self *RefreshHelper) refForLog() (string, *git_commands.BisectInfo) { func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) { // refreshView is called from the worker goroutine that drives async - // refreshes, so bounce to the UI thread before mutating view content. - self.onUIThread(env.background, func() error { + // refreshes, so bounce to the UI thread before mutating view content. Guard + // on the generation like the model-update bounces do: if the repo was + // switched while the refresh was in flight, its model write was already + // dropped, so there's nothing fresh to render — and the captured context + // belongs to the old repo's now-replaced context tree anyway. + self.onUIThreadUnlessRepoChanged(env, func() error { // Re-applying the filter must be done before re-rendering the view, so that // the filtered list model is up to date for rendering. self.searchHelper.ReApplyFilter(context) From 4d33d9df8be770c76df29bc29f3805985c47d130 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 15:37:25 +0200 Subject: [PATCH 258/384] Mention the `Then` rule in AGENTS.md --- AGENTS.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 6aa8f7017..947add510 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -256,6 +256,34 @@ Follow this even when the need for the refactor is only discovered in the middle of working on the branch; suggest to the user to rewrite the history to move the refactor to an earlier commit (but don't do it without asking first). +## Don't read model state right after a `Refresh` + +A `Refresh` (or `RefreshFromWorker`) does its git work on a worker and then +*enqueues* the model update onto the UI thread. So when `Refresh` returns, the +model is **not** updated yet — the write is still queued. Reading a field +synchronously right after refreshing its scope reads the stale, pre-refresh +value (and this is true even for SYNC refreshes): + +```go +self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) +files := self.c.Model().Files // BUG: still the pre-refresh value +``` + +Put the read in `RefreshOptions.Then` instead — it's queued after the scope's +model writes, so it sees the fresh value: + +```go +self.c.Refresh(types.RefreshOptions{ + Scope: []types.RefreshableView{types.FILES}, + Then: func() error { + files := self.c.Model().Files // fresh + return nil + }, +}) +``` + +`Then` is a `func() error` and works with any non-`ASYNC` mode. + ## Integration test conventions Don't bind views to local variables. Always chain method calls directly from From 2420fc7b76f52ee67c000b0a98248c3e50b88c2d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 18:24:18 +0200 Subject: [PATCH 259/384] Lock the status list when reading it GetStatusString and HasStatus read the statuses slice without holding the mutex that addStatus and removeStatus take when they mutate it. The readers run on the spinner-render worker (which polls GetStatusString every frame) while removeStatus fires from the waiting-status and toast-expiry goroutines, so the unguarded reads race the concurrent writes. Take the mutex in the readers too. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/status/status_manager.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/gui/status/status_manager.go b/pkg/gui/status/status_manager.go index 2f822c1ee..414568a69 100644 --- a/pkg/gui/status/status_manager.go +++ b/pkg/gui/status/status_manager.go @@ -70,6 +70,9 @@ func (self *StatusManager) AddToastStatus(message string, kind types.ToastKind) } func (self *StatusManager) GetStatusString(userConfig *config.UserConfig) (string, gocui.Attribute) { + self.mutex.Lock() + defer self.mutex.Unlock() + if len(self.statuses) == 0 { return "", gocui.ColorDefault } @@ -81,6 +84,9 @@ func (self *StatusManager) GetStatusString(userConfig *config.UserConfig) (strin } func (self *StatusManager) HasStatus() bool { + self.mutex.Lock() + defer self.mutex.Unlock() + return len(self.statuses) > 0 } From 1268a589d6996e3fac012907203efc59ab0936f1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 08:27:35 +0200 Subject: [PATCH 260/384] Write the command log on the UI thread LogAction and LogCommand are called from git worker goroutines (every command a worker runs logs itself, and controllers log an action before kicking off their worker), where they set the Extras view's Autoscroll flag and append to GuiLog while the UI thread reads both when it lays out and draws the view. Bounce the writes onto the UI thread instead. Use the background variant so the bounce doesn't count towards lazygit being busy: writing the command log is incidental display work, and a foreground task would let an in-flight log write refuse a concurrent repo switch (the same reason view-buffer renders and toasts are backgrounded). Ordering between successive log calls is preserved by the bounce FIFO. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/command_log_panel.go | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/pkg/gui/command_log_panel.go b/pkg/gui/command_log_panel.go index 8f2e06b98..6f7976c3e 100644 --- a/pkg/gui/command_log_panel.go +++ b/pkg/gui/command_log_panel.go @@ -27,10 +27,20 @@ func (gui *Gui) LogAction(action string) { return } - gui.Views.Extras.Autoscroll = true + // LogAction and LogCommand are called both from the UI thread and from git + // worker goroutines, so bounce the writes onto the UI thread: they touch the + // view's autoscroll flag and the GuiLog slice, which the layout/draw code + // reads. Ordering between successive log calls is preserved by the FIFO the + // bounce enqueues onto. It's a background bounce because writing the command + // log is incidental display work that must not count towards lazygit being + // busy (otherwise it could block a repo switch). + gui.onUIThreadBackground(func() error { + gui.Views.Extras.Autoscroll = true - gui.GuiLog = append(gui.GuiLog, action) - fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action)) + gui.GuiLog = append(gui.GuiLog, action) + fmt.Fprint(gui.Views.Extras, "\n"+style.FgYellow.Sprint(action)) + return nil + }) } func (gui *Gui) LogCommand(cmdStr string, commandLine bool) { @@ -38,17 +48,23 @@ func (gui *Gui) LogCommand(cmdStr string, commandLine bool) { return } - gui.Views.Extras.Autoscroll = true - textStyle := theme.DefaultTextColor if !commandLine { // if we're not dealing with a direct command that could be run on the command line, // we style it differently to communicate that textStyle = style.FgMagenta } - gui.GuiLog = append(gui.GuiLog, cmdStr) indentedCmdStr := " " + strings.ReplaceAll(cmdStr, "\n", "\n ") - fmt.Fprint(gui.Views.Extras, "\n"+textStyle.Sprint(indentedCmdStr)) + + // See the comment in LogAction: bounce onto the UI thread since we may be + // called from a git worker, in the background so it can't block a repo switch. + gui.onUIThreadBackground(func() error { + gui.Views.Extras.Autoscroll = true + + gui.GuiLog = append(gui.GuiLog, cmdStr) + fmt.Fprint(gui.Views.Extras, "\n"+textStyle.Sprint(indentedCmdStr)) + return nil + }) } func (gui *Gui) printCommandLogHeader() { From cbf220c49781bb78f96820b24b68b8e76a3d025b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 19:12:49 +0200 Subject: [PATCH 261/384] Read lines based on scroll position instead of a fixed per-notch delta When scrolling a lazy-loaded view (a diff in the main view, the command log, etc.), we top up the view's line buffer by reading more lines from the still-running task. This was driven by asking the task to read a fixed number of *additional* lines on every scroll event, which had two problems: - It was decoupled from the scroll position. Scrolling down, back up, and down again re-read lines that had already been read, so the buffer crept towards the end of the input regardless of where the user actually scrolled. - A single wheel notch only bought a single notch worth of runway, so fast scrolling constantly outran the reader and had to wait for the next read (and re-render) on every notch. Make ReadLines take an absolute target total instead of a delta: the task tracks how many lines it has read and only reads the shortfall, so requests are idempotent. Callers now ask to fill the viewport at the current scroll position plus a few screenfuls of read-ahead, which gives scrolling enough runway to stay smooth. The four call sites all wanted the same "fill this view" computation, so consolidate them into a single ReadLinesToFillView helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/vertical_scroll_controller.go | 8 ++----- .../controllers/view_selection_controller.go | 7 +----- pkg/gui/global_handlers.go | 8 ++----- pkg/gui/gui.go | 17 +++++++++++++ pkg/gui/gui_common.go | 4 ++++ pkg/gui/layout.go | 13 ++++++---- pkg/gui/types/common.go | 4 ++++ pkg/tasks/tasks.go | 24 +++++++++++++++---- 8 files changed, 58 insertions(+), 27 deletions(-) diff --git a/pkg/gui/controllers/vertical_scroll_controller.go b/pkg/gui/controllers/vertical_scroll_controller.go index 1db9bb76e..84e312c1d 100644 --- a/pkg/gui/controllers/vertical_scroll_controller.go +++ b/pkg/gui/controllers/vertical_scroll_controller.go @@ -66,12 +66,8 @@ func (self *VerticalScrollController) HandleScrollUp() error { } func (self *VerticalScrollController) HandleScrollDown() error { - scrollHeight := self.c.UserConfig().Gui.ScrollHeight - self.context.GetViewTrait().ScrollDown(scrollHeight) - - if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { - manager.ReadLines(scrollHeight) - } + self.context.GetViewTrait().ScrollDown(self.c.UserConfig().Gui.ScrollHeight) + self.c.ReadLinesToFillView(self.context.GetView()) return nil } diff --git a/pkg/gui/controllers/view_selection_controller.go b/pkg/gui/controllers/view_selection_controller.go index 31cbd3695..1a97a9a30 100644 --- a/pkg/gui/controllers/view_selection_controller.go +++ b/pkg/gui/controllers/view_selection_controller.go @@ -50,17 +50,12 @@ func (self *ViewSelectionController) GetMouseKeybindings(opts types.KeybindingsO } func (self *ViewSelectionController) handleLineChange(delta int) { - if delta > 0 { - if manager := self.c.GetViewBufferManagerForView(self.context.GetView()); manager != nil { - manager.ReadLines(delta) - } - } - v := self.Context().GetView() if delta < 0 { v.ScrollUp(-delta) } else { v.ScrollDown(delta) + self.c.ReadLinesToFillView(v) } } diff --git a/pkg/gui/global_handlers.go b/pkg/gui/global_handlers.go index 3c4896af4..a5e59a84e 100644 --- a/pkg/gui/global_handlers.go +++ b/pkg/gui/global_handlers.go @@ -17,12 +17,8 @@ func (gui *Gui) scrollUpView(view *gocui.View) { } func (gui *Gui) scrollDownView(view *gocui.View) { - scrollHeight := gui.c.UserConfig().Gui.ScrollHeight - view.ScrollDown(scrollHeight) - - if manager := gui.getViewBufferManagerForView(view); manager != nil { - manager.ReadLines(scrollHeight) - } + view.ScrollDown(gui.c.UserConfig().Gui.ScrollHeight) + gui.readLinesToFillView(view) } func (gui *Gui) scrollUpMain() error { diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index d77673e9c..1e6e8f9c3 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -691,6 +691,23 @@ func (gui *Gui) getViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferM return manager } +// When scrolling a lazy-loaded view, we read enough lines to fill the viewport +// plus this many extra screenfuls, so that further scrolling has some runway +// and doesn't have to block on reading (and re-rendering) more lines on every +// wheel notch. +const scrollReadAheadScreenfuls = 3 + +// readLinesToFillView reads enough lines into the view's buffer to cover +// everything currently scrolled into view, plus a few screenfuls of read-ahead. +// Reading is idempotent (see ViewBufferManager.ReadLines), so if the buffer +// already extends far enough this does nothing. +func (gui *Gui) readLinesToFillView(view *gocui.View) { + if manager := gui.getViewBufferManagerForView(view); manager != nil { + viewportBottom := view.OriginY() + view.InnerHeight() + manager.ReadLines(viewportBottom + scrollReadAheadScreenfuls*view.InnerHeight()) + } +} + func (gui *Gui) initialWindowViewNameMap(contextTree *context.ContextTree) *utils.ThreadSafeMap[string, string] { result := utils.NewThreadSafeMap[string, string]() diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index c8de7545d..69ec44781 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -165,6 +165,10 @@ func (self *guiCommon) GetViewBufferManagerForView(view *gocui.View) *tasks.View return self.gui.getViewBufferManagerForView(view) } +func (self *guiCommon) ReadLinesToFillView(view *gocui.View) { + self.gui.readLinesToFillView(view) +} + func (self *guiCommon) State() types.IStateAccessor { return self.gui.stateAccessor } diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index 6f7dc7187..de3bdbe9b 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -37,13 +37,18 @@ func (gui *Gui) layout(g *gocui.Gui) error { if prevMainView != nil { prevMainHeight := prevMainView.Height() newMainHeight := viewDimensions["main"].Y1 - viewDimensions["main"].Y0 + 1 - heightDiff := newMainHeight - prevMainHeight - if heightDiff > 0 { + if newMainHeight > prevMainHeight { + // The main views have grown taller, so make sure enough lines are + // loaded to fill them. The views haven't been resized yet at this + // point, so we can't rely on their current height; compute the target + // total from the new height instead. (Reading past the actual content + // is harmless: ReadLines stops at the end of input.) + linesToRead := prevMainView.OriginY() + newMainHeight if manager := gui.getViewBufferManagerForView(gui.Views.Main); manager != nil { - manager.ReadLines(heightDiff) + manager.ReadLines(linesToRead) } if manager := gui.getViewBufferManagerForView(gui.Views.Secondary); manager != nil { - manager.ReadLines(heightDiff) + manager.ReadLines(linesToRead) } } } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 4ced8bd79..08ff53bb0 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -58,6 +58,10 @@ type IGuiCommon interface { // return the view buffer manager for the given view, or nil if it doesn't have one GetViewBufferManagerForView(view *gocui.View) *tasks.ViewBufferManager + // read enough lines into the given view's buffer to fill it at its current + // scroll position, plus some read-ahead for smooth scrolling + ReadLinesToFillView(view *gocui.View) + // returns true if command completed successfully RunSubprocess(cmdObj *oscommands.CmdObj) (bool, error) RunSubprocessAndRefresh(*oscommands.CmdObj) error diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 26145c784..bc08013ed 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -82,7 +82,11 @@ type ViewBufferManager struct { } type LinesToRead struct { - // Total number of lines to read + // The total number of lines the task should have read once this request is + // satisfied. This is an absolute count from the start of the task, not a + // delta: the task keeps track of how many lines it has already read and only + // reads the shortfall, so a request for a total at or below what has already + // been read reads nothing. -1 means read all the way to the end. Total int // Number of lines after which we have read enough to fill the view, and can @@ -119,10 +123,14 @@ func NewViewBufferManager( } } -func (self *ViewBufferManager) ReadLines(n int) { +// ReadLines asks the task to ensure it has read at least totalLines lines in +// total. Because the count is absolute rather than a delta, repeated requests +// (e.g. as the user scrolls down, back up, and down again) don't re-read lines +// that have already been read: the task only ever reads the shortfall. +func (self *ViewBufferManager) ReadLines(totalLines int) { if self.readLines != nil { go utils.Safe(func() { - self.readLines <- LinesToRead{Total: n, InitialRefreshAfter: -1} + self.readLines <- LinesToRead{Total: totalLines, InitialRefreshAfter: -1} }) } } @@ -283,6 +291,11 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix } } + // The total number of lines we have read so far. Requests specify an + // absolute target total (see LinesToRead.Total), so we compare against + // this to work out how many more lines, if any, we still need to read. + linesRead := 0 + outer: for { if stopped() { @@ -297,7 +310,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix linesToRead.Then() } } - for i := 0; linesToRead.Total == -1 || i < linesToRead.Total; i++ { + for linesToRead.Total == -1 || linesRead < linesToRead.Total { if stopped() { callThen() break outer @@ -331,8 +344,9 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix } writeToView(append(line, '\n')) lineWrittenChan <- struct{}{} + linesRead++ - if i+1 == linesToRead.InitialRefreshAfter { + 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. From 73d7b443ec2a534aa14ada8092900d0a3d941c64 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 19:15:45 +0200 Subject: [PATCH 262/384] Render content-only when a task reads more lines into a view Reading more lines into a lazy-loaded view (e.g. a diff being scrolled) never changes the window layout, and after the first screenful it doesn't even change the visible content - the new lines land below the viewport, so the only thing that changes on screen is the scrollbar thumb. Yet each read triggered a full render: a layout pass plus a redraw of every view. On a slow terminal that full-screen repaint on every read is a big part of why scrolling through a not-yet-fully-read diff stutters. Route the task's refresh through a content-only render instead. It skips the layout pass and only redraws the views whose content changed, leaving tcell's cell-level dirty tracking to emit just the cells that actually differ (in the steady state, the scrollbar column). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/tasks_adapter.go | 7 ++++++- pkg/gui/view_helpers.go | 8 ++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index dd7999107..acad4fb75 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -118,7 +118,12 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { view.Reset() }, func() { - gui.render() + // As the task reads more lines, the only thing that changes is the + // view's content (and its scrollbar); the window layout doesn't. So a + // content-only render is enough, and it's much cheaper than a full + // layout-and-redraw on every read - which matters a lot when reading + // a long diff, where reads happen repeatedly as the user scrolls. + gui.renderContentOnly() }, func() { // Need to check if the content of the view is well past the origin. diff --git a/pkg/gui/view_helpers.go b/pkg/gui/view_helpers.go index 453ccd6c9..d139984fa 100644 --- a/pkg/gui/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -121,6 +121,14 @@ func (gui *Gui) render() { gui.c.OnUIThread(func() error { return nil }) } +// renderContentOnly triggers a re-render that skips the layout pass and only +// redraws the views whose content changed (relying on tcell's cell-level dirty +// tracking to emit just the cells that actually differ). Use it when only a +// view's content changed, not the window layout. +func (gui *Gui) renderContentOnly() { + gui.c.OnUIThreadContentOnly(func() error { return nil }) +} + // postRefreshUpdate is to be called on a context after the state that it depends on has been refreshed // if the context's view is set to another context we do nothing. // if the context's view is the current view we trigger a focus; re-selecting the current item. From 585c7f126d7fe48a9992f3a1d5ae928712186b9a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 22:29:31 +0200 Subject: [PATCH 263/384] Cache each line's wrapping so scrolling doesn't re-wrap the whole buffer refreshViewLinesIfNeeded re-wrapped every line of the buffer whenever the view was tainted. That's cheap for short content, but scrolling a long diff calls it constantly: adjustDownwardScrollAmount queries ViewLinesHeight on every scroll event, and each newly-read line taints the view, so every notch re-wrapped the entire buffer. Wrapping measures each cell's width (uniseg) and allocates per line, so once you'd scrolled far enough down the diff, scrolling turned sluggish - the cost grew with how much had been read. (A CPU profile of scrolling deep in a long diff put 77% of the time in lineWrap, reached almost entirely via ViewLinesHeight rather than draw.) Cache each line's wrapped result on the lineType, keyed by the width it was wrapped at, and only re-wrap lines that have actually changed since the last refresh. A firstDirtyLine index, updated in the same three places that set `tainted` (write, clearViewLines' callers, SetHighlight), marks the lowest line that might have changed; lines below it with a matching cached width reuse their cached wrapping. The cache lives on the line, so it's freed with the line when the view's content is replaced (e.g. selecting a different commit) - it doesn't accumulate across a session. The wrapping cost per scroll now scales with the number of lines just read, not with the total size of the buffer, so scrolling stays smooth no matter how far down you are. Co-Authored-By: Claude Opus 4.8 (1M context) --- cpu.out | Bin 0 -> 22717 bytes pkg/gocui/view.go | 120 ++++++++++++++++++++++++++++++---------------- 2 files changed, 79 insertions(+), 41 deletions(-) create mode 100644 cpu.out diff --git a/cpu.out b/cpu.out new file mode 100644 index 0000000000000000000000000000000000000000..24b4b40a40f2809d660809ebf24b5b82fa20ca33 GIT binary patch literal 22717 zcmV*WKv};ZiwFP!00004|Ga&7oK!{f_^4DjQP|%5k);k zJ;38U&)XYzAIr$>%s4YkxbVFtC{faqBuLIVM->16Yn>V`)#|*c3 ze<;20)vKFkjX#O@UyCQ~SKV2R zK>N;1dUCge5#IG4PO>X8!X4ny()7;vxU(3ALDy!9_mrP|P`s!7Hn>eo(>Fip&SDBI zXf<2BS9nLac&~53VDC&SDO@HfO1L@8Fjd z@!r8JK_#uSzEW{!aKe$$bO)h|>TqYVG${UTn1D~?dnJ4tuL4!H4E-C$oyBg0Ndu>g z_qXve^8IZ*6EZcfkH+^5PJSxiGv1wWlAX>N&w?zC>-F7P>~=UhYMl7|c77ILS7Ntw zCpa~izLvPNSUR-nCf}#?neu%)zXR^j?$iepcNVJz!}^RB-&f)%ZWF_)#P5Q;w7d0# zx4E-eWtcTwzOT$LRub>k`3O0BCw~wg z)ZBV6#O{K<1DE$f>@NO>!0zG?!9!YA{devxb_X0?HC}vu2j3>YzJosu4{O!*t;oAO zVNja|;{BccV|<^^?&QCMUun7?adUPT96j`Z4|It6x{ud@8d^=gH;VHwVBXKG#QR_HK~h|Q!M)(sYU+JZT~>ctnt_rK($sKEbA{scUsJ*khR?kx5IOj{`5Kft@o_Yd%= z;3@4%y#v12;J^im{};T!!f_fNgih_G5NLdtDo|?tX?R-G^qr_N+|X`}eE%RnD~I$T ze+Hh>H2sXj>9Y8he(!z1xc`?AX!k#eYTzL_wHdkOU~b+4=e81a^Jn2%Ek|#IomPcW zTds(4JjDCT_Yd)3!>_gH^gj6hVVHG5zOTwp$oEzG^YFa(oPHeNSA$tU&+UVf`!Js) z-#^S>fETpi=u=QKe+4JE<9n4=Xi{ztw)D=eV<&4khc__r8(l4RhSBFdU<$IkEkn2L{FTqP%Exk9s&xX@qebI(eoy_WuaKCRzFje+6FA>geZDN*;sFC*=D_`A_ow zqx@BPRjZ@_=y2X)vAohlRUfYQE4_O5Bac4j=F9`5+MsGx*<<{9G3LkkYw()(y8eWl zvmBV#TE6x0Ux~LK{yX@c_PYMCJB!tTC1VeZ`N-kN<%n|l8}NqqrhW`ZR1=mQ`Ch!Q z!7s=W)!=oZuJ)#W9_Pagm(F}A-q+-NW6W%YC%amG&(B#E%%5K|CTp`Mp~Z!m^+KT1e0c55MRH@x69sN%y$lCVtP@|q#QVs;U*ZK&pf%BZ;#9u^(@IW>rBj=K zB9~@u?gzj2p}q?ZZXGz#PQHJc&yxoEW!@B;Y9H#e@cpYWYtLEn{VV*cT>7u@00gu; z`uDfHv)F5Jse^o9hZoE5>+nJ-)avNF)7)9?bvS=nF3nf@nl!=tS9uVES{;27zW*I` z>bO;?hS&H8`Tc7=1R*V~cfE3*=i~SKw zK9@-Tz-LJ$f8cEiv=vCURdI5z@Zxr(GSY9WSHHo#e`xqecNTjOid!!eJvZdjkoi?u zL;f*=kBRmP(a{_H6I>fCy}dv3i#WO}?2o)1fp$c@TVI#%&SLMw^0>cvo^f1He}?-U%-kv~vqIDp;=H~1JIN44D{E7cU0Q`w}B+yX+-099@e}Q@XqD5@?iT=36aakAwMg#T;7d9H-xC?&SLq{sfPqe=WB^zmI0f$ zHAG$l6-s$cALR`yo5jr(Rvbn7&4;(P9K#Z zjrdv#(unsW&`W@Ie3)V}B0BZep@`!gLPTNMP853wE93*@DlX*12n-|IlX^cCu^5coCLx0S6A2OI!wC#Wh>sOF zXGPF|;o{z?5JTo1g!l*oBZxMV=uf#hYX*}xN`SDrw!(ZAfl)*oP4xTRoHd7Q+a)l?Xst%@rmL(ue^HFCIiEsciqJ;Cbvs!xflm7_2?=Y# zJIbkT!KV_Kijyy_L@NSaJ4+<*@!xXdcCyw4&c}Vc3#xe5hCrW9qr@_7$@`$QP=&SR z(+EsMwe=}_@@)xh?|_R)Wq;#^Vnl!A(+Nx`np^PtV*=Bzp#!I~zw_4wz~A`{0y7W* z9hOfB%<3uqzki5*YGw8hK9j&qqE*!&aywZ&0++r*3=Z~Bz8aZQh5eJyA~1_+)$~~WT_V)Mzj#Bzw}0_D1m+NJF42GQtZq%P z=E?cTKmYGv#iSMa;4itYNgL9Zd`vze?MQplf#4c;v5o|ar=uxPW5tAbkqWq&@OcF0 z5iL#cqPktI6M@rL=Jw?7G}emn6-ZSkw&oLDng~UffsHQ- zd?|qb;&!o53G5w%yTLTphVWj9DFa&z2rMAlLZauoovaIic|#D$!P*jzTlx%aEF!Q- zz`WpgvaSRUEtN1I6XAMhU}G_X#Y9^|^vB#T){Q{BwkrkMpAg;~JIln@QUXhfwv1q0 z#L2o7*nCxPNZJv8kFY@P2>*(}S43M*bZ|Ra4+0B*k}x%;Z(h@4vfhz&BAv;nqzmav zx{>ar2N90?^_X2k;OH1Mc4@3V;d?8KzS<*4VFlXtxs}~c)|0^bxM458ofEf<^&)U- zH?lU3bs%y#pNXxN1XdEXS5pzHH-XKgFZRZ1>PUERbV)O@wTi$hqOB%+*EF|_^&zl# zu7v7D_%R99iSRW9)(EJ{2-TNBo23$}GvQs(&}U+6ErGQH>eJiYF7_FLlSd`gr-ZM; zb(M*&bp+N4sO8M&%*t4rIcRN@YB3#i7Y^*1+o@g70Uc>EV{Ru2NF1_8Zg#TG^ zrYqqa32Y?VCZfOZcCi5jt}Wl$7suC)@Xm5RyAi&bz-FRtA$lhi!+`{r%t0sz>rRB$ z%)rK00$T;p!)_=0oItxF5~c^?_lw?o5WbDTHUV?D+rq&TDWM&4ob`sc0v^sj9G`EutA+UUx+)2D5Tv_Mimh@!5 zS3-J{UZgkaL;8}>NI%k_3?Ku^=VTBWOoos|m)XgN66n->uh3$>2!BM7-HY&D1a=W^ zH_@}*PBx6d-U+(}OmD*fDPVdNzK6gbqJ2&DpxecU6Igu`CnSyaA^cMuS|+x>A@Gfu zk#@+)5vYCTl=LOMn!wbT@Vx}~iYa;6?P4PdOd8QaFzGYG2Vz(=16%tD>?2w&y>BJA zi;W_%eK7`6(pW#jr=pZ(VC!1~-y+aN=612s1P-mk-qKiq!WZ2xy6aE)eggZ67SW(OgGBp|=-AtM0;6W{6)>M$ zis*B~zbEiL(GH;^aTmB#7UW#?sl>11eSawfkqPEUIL9I{3ilGA-Dw#&6lNpKg=3+AmT>J5ho~S5B z5q<^vl!2|&1Wps}b=*}r8JgX*G9NIS@IMKvM-zUAz!{>QC3=0glg%bjvU7}ZK*kWB zEnvnFevZI7qMau~Ps}0EZpJLp*;vA-fB+gx_yqzN(1Py1+wEj?2^@_lTgKfZl=?gZ zN57V)cO2nAN%0y-_(cL2@xy(%EanqvGf4uCC;WgM%6P(mCh#)??N^-l@FHH^{qzJi zJ#!|RMP`#ZWG=jquhKT2quL^hv%V zFzGl-R2rL(v&czyCbrs8XhXHORPT@6Sx%tSC4_RY8H7ubnnC!-6h5ZfCsfGY3IhGV zN0>A=lW-9^&%{|Zg+dnrbp@f;5jgO@gqlxeG&&PoT`6=GPzR~o z$<`B?H%Nwh>hb>RVsULCaI!BxOJiRU-VMi{AzBo=QSD!P*W2ArwvoWK!7_N$fbY!U z#97VKH}@|k%g9$`Iaxtgl2v3iSwq&6b!0u+KsJ(wZYSGBpi{{n;naLdcvwvKm*|^8 zcdGTEdcf^un+XgXA>EAb1b0c!Us?EnXsX~fLcg+FABY=)|=`j)a_*32yEYuP!6_;@Mi_FiwN&S zp%2yiQvFG{i)|;ccaL1%6A0f}nG>*#r%}{Bb;bh+s=(|}meVMhfSVs6j z3Ihex?{vG^UII%dAxs)h8irEcF1C-r(UF(LoPI_4NDQ20V(W7XpNnA+%5=Nfw*(Fh zl~BtGZ!Mvg6F!K-AOY1X)9qyY2`m{XUB(rJ*B5iLg7Co<22*qy>$#om0D)`k770;a zN%$)QW+mZ6C=8+6P^!P|cCv#6POg$LtB43+WME?$g<%5bX}61gN8tSD=LFrW37?6! zCIef;DGaAt9ep^On(ql*>MViQ5Pk%WNCvh>P#A$gAEOO9L||_TYV9<(mhi#S&s$6Q zND3nnXy9#b7duQ~Sa%6DfbdSYarCPxFW$BE_Vny;$lkcX?jzrl{p0{SNWLTAlSAZi zX+O`!ejqUYh?JlSgkM6@e~jPBi>GwYPp|re5c-?qI6~mq4`p#Uc=5MK?@q6JBsm;M z33MJFU-J9V;MqHcqX|p!rqmoGFuok7+j()Xv)Sp{$CAh7WXB2Y+k_J8K;gYpFxfPq z*~e31@(BW`Z2huLGC7?WFW#P$UiCzB3_3~RvaRjb;e6oJd7 zb*u9U{tI4wY|+OgJ^NJh{Bf}#2^?FC{7GZ$2%m*IBokYsD2$@oXsXY|CH)hDLH0WT z90`9sE~`J1pOPir$xahEXwS!8Vm_>iJ)O#YoFOp#$`oN=))T%3lRTN&8be`>u=y)+ z7krjL*NYNr1L0Ti682>S;bSR`6;Oxoa=X|$0(~Y(sEveg#YLTot#K5_38+E1s?QUc zH$pH&if(0I zJYc}H=~XW!C%Tsj4BjB+WD7=o1S7W)K9RyiAtx))@w-A`$yS6CtF1aG&L=tJ#fN)T zOMmn-xsvq!I-lX~6ka@U){2bu$1FcLx#2q5RRY6pZ{cZ9oH;y`7Y|$VYI?P+rW0@z zo;ley0!wWq&k7`FTCm!+WRVn87`{WYL;5Yw980HGE2h#Nxd|mM){4UDa@yUr6IEML z+&$bBM{5e5cOfBZY%AeiCan)#>|+X(%Z<(ynW`TrH@Z(KEVX5GB~tXHHCc~- zLX#Iew~Mu-u%jHVJ9u&1NxA8dwo46HdkP2bb+<{bJ5z|NwohhkPS$}!=l+Yt7Jj=G zSlmwd6be(QHkInQN$yDD&{`Rn-9dO~=|t}!d>Vym6w{EWacy>@FfN{v-i2GqyDUGj zN(b7JcA^c$=HQQRC+kdM@QnnwbFzZ_DTU6q(HMY6qoyVP`lrxFa&4=`iSuP^R-Z$g(Sw%ITeBKH zDHPkIM4jP$+CuH=nd&<4MPYt9|I*}q)n3W01sCg0p|kBil}Ldyap=916(}d`Lt#K! z={A>TwLUaSwcMA&_z}`!o=EsH+_pVpvB%w)F1VRoJK1LxPHjU)Dt5I`mNGG)B}+y> z3Ttc~vq9>ZO1${woF~$&_DhDZPS&5osV$O_WrRN@d@^%=W%s8^Z$zAI0ENl+>XMe% zoS$j~k{!`N3cEW^6OP?3!goq-wu|r?6lPFurU(hR*yj{>mq@7H@nFtw!e>#KC7@({KC`4|+(Y=XEU`D-L-=e8v#EBs{$&;>K`2a`D1p8vd<&MLWngO#g*gb+rwS%P zD70H@)i<}5X|16Yc8`pUz)vUw&sZXmlQxikP6yG!bO`N5hTdErTx=MHZnmDoG^O)N zi+zs{OHF$Wr?Ah~y+h?vQDh;HK0NuQGJ-27zzvQ&F6m9{0kCbrAT#!?tI>=B#wJVlI0|Pb;QAC%7E|w;3QHfC{A7-&aLOh| z`n2XcuQoneVkS^HW=kpN6%(AT38_e_lTD;B+m^0h3k_)Li;2k+G>O7!n;>)9nA~}E zQYr+w7<#%_WfpHQ;gh9+>?M32g?UulMD)q%Nll@!WN17ts&nGhEcv*5GMz%Nll^qw zZN&;Q?8u=w7~*78DRi@^Bqfd%S`JV7)6qaF}d=p0F7f~2C5zAlG*dfA?I7GPM5aC}@ z_=;-FsXoWycCy737Q~BS7o>CIyw?)9tS{)7bOBvR7tzIZgPF8gLg8>(hM6n9+LB}$ zwv@usqjG6YApElQm`trxb!l=ku#7@C+c)lpCgN#p>O9L*@{JQh0&-p7bk3dZD+>Fz zpg|B}7PoNN%sq4VS2Ssd&Bc~em|ku@SeAKtGQ4oI6%>w@ofMOu)m9`sDJv<=wkO35 zVLN&8*dy6-O<|$wn=d20hTyOjHeQ`Phn;K< zg?(kYV=}nfn&i0SWNRs0+BK#pcRSc&!ha)H++o63P*_1RkMD82*g6X9+szWo@(02v zVLl}TTPrE7#AP`FlP>EiOqwczju5^OHgak5<$ zHi$5Z7?{k*n7ql}mCC?&Q`lj1+1&V=-fY#~NpRWuxFuEDiRs~+IyZYLj4#It(+cT( zlI4VpeNAEhPMl&9lf^1F=hN2C-Sc%yHpJ~>-%yygUv6Sg68@9SHJ>DWJ%#lm26zf{ z&3h@#`d&hvB7B)N%clt6Kw*P`T7+hKABFxS5X!+S^R{Rp?Z8h5`VHMn_tB)I@!wMD zX)h_caWki>+PBFrsr?k%+B`RRKqeM_f08_R*0fBtZo9%ao$dn^dY0?j+`{PxlI+^; zVh1T4wx`<+r=`pMq32+#)BPQV)89$2??)^%73vpT6gHyQw*o!v?);$Y6_R^RNI=nIdT5N(jl(z==byx zJxtG%c&HIm(iFzqqwS0;*_NRnQXTCP3KvF8mu({9XV6i6#3I0bB$?RCQ3`vO$Y|;r z!Uy6(r%Y^Zp|C|nQ~PJQUF;ZzVPhrKM#6_;H1$O*QTFsvdh8Y>smCcC?j_NlC48tX zPdZEZRtj51=Y6p}=>&yN6D8C}!p~F^`@oYFPWHrG2-4U&Gwqgvt!)&xQ7v6xjNbhz z3fuchpz}lod$KdIwVlFt1nPrE@<$3?M@XOxgttfMJ_B1jDC|I>9SHOjg-c&bpo@g} z#5`pNwsun3i9l^JO?jHa@>K}rU@9LbLt$qqblN5>oqi^~JARmntz8s$QEfNXyW`QS zvlK=RL19x_E`L{?Y&b_@bNr;(S(#HkPht5Fcyuz2T_U`b^aw5yzK6mdsx`rLx-NEs z!l<1R=rZABMjOAMV|Vyr?1jI6Xnn zlU3v-Jw<<{Khe|l3_VNpE1je78|Uc-dXdJDg}d0#6xN@R3-Bu8vm`sO626zhUR;1P zv7+u0g-(ZLMcp;RgJN-ABYYo)eN_9F>VI;(*kuaqH%lMsDB)x8=fruZMN;LT=_PuZ z#vPq26xxkjCVaeN%KM{|&%oAx3j3*+q4!fU{YjxDeo;h6JWb_d*C-4tmejVQd@S-I z6I%x;9H81kDof9c6__`8t8g=0QyvxMwx;|$3g1!fd#Xp=PS#3+qeCZ%`fMkkg6rBo zkdt#@byly9Jr@-F55~dv$=gu>*ah$?&0rQ;O$=WMWauM%x zaIp52hs5cX_LQHXaDr;OsD$pIz_tAnqyrUI?p3gXD;Yr^z*8#SkKSSXR#WN?BFw>^M-uRglu^F=?QJ$O{ zpDJCHuF4ILnmAcE1v4oTb`13=X+icLk=|LOotedJhFIjY0WL zW8J8{vLFLn=P8`0np+olp_C{vuOkAPXQiCKv{u}m-IVT152ZvYebC6sdMa>q()`}s z?O@$4LF`WX1qv6ac99A@*-L?=I}j#~^`Lwn@-7owKU4Tw08POu>#ab$FTN0EW+jyO z!9|>jtxFUxQSCC-JD{i5M}Z}07YL}HlwXz#wI}6QC|sf1RjU7ts<^KL>yKR#RP~}< z9^dFi`85jHs8+1#4m{+d!1=aQL_u?JD#F>>nb>NjKr2OSt;lFxKLw`ko-7txAIckv zDeXgf8wJ`3pg&`tNP$Cx<_nm<)I4g`m-4m>v{kf^6&>6zHb8;?Gt3heR3s$vLQ8xl z1==H`jaZU1NP%%~ly^{|0|HG#puq}E+9ub?Kq?bT8QAKmKt}}HOcSRy;Yo?EHN%NMS;P`q&k>DcySd@oR3*6r0Nvq%Va{kQx%xJxu4Ln zLn#jktv8hNt_pNjv~G$XaJ$$v1=bJiCN%mm%4emE;QBDiyDQLL(RwKQq1!PRq(I3` z2{oMZk*FIpu~ni#iGXT_%5jDQqgHkixJFRxkm(4@dn(XVK=r_#SVha`6JYRk~k@AId z1QRL$T!GKUr}GeMkphS2NT^Aa&y!G-C?BN2AOSTOp%yDJX}W|OL-}w?+Y$v9*ep6K zA1+m(#3p4VK6J8W3Tz*YpF7xO%JHy%1~vvOFj(|~r=P!4V9Dms#0*cN{2NKg6v~Gv zFhtRYD*7%YWVr$-Hw+R`Qz`E)V@y*iAEv-CMH{Z@y)eeKLV*Q8pmlMuX_UVuBx)Mv zBNP~+V4}6I+r?HYFzF|0-KSH2L>A;vr+lOWBhgIlsEkp61$G~iKr<*`ErDiGK1zX6 z2s8(QRx5C5oCF#``3f`_Z(1v}_Ecq>GF_RW%v5G6vz0l@TxFgzU-?4$QdyuZROTs* zl*P&tMLt`qEK|NxmMbfimC7n*b=()oVrvvAE=G@4WessW7`9hnbKFKN48tX$yEY*C=+ z*tqfi1mop)5y_U+jPF(jifx)qV`)V{w%s9vAqh+zM-?dHx*}lp8{Ks$33<(sk(j1iPE)TYMV7~$f?PXqOFS0+$<%#Fv6j*p89v?`G$IhoMwcx=yPN|^(paN%ZIEsVG zj>5&hQ($3F+~B3LnUt@Td(@efk5*u`qTP*qR44mhf&Os|dmb&UeOJPF%J)k0_Uw=X zvu|jJ4kb_aVFeDC8@{QW^ux*H`9Xo{Wr`4O=i!G`Wc-K%-E4|X>tU`r{Ya7&IiKZb zQE?3{Z%Ik(JK0eMCJUV;DxKx3H>2*=jwU6Mn4D2yzP&NVD~uC3a!g5KV|-kJ!)K?6 zF#0UYXUIcgvvAJ>V-!48J{=E*olv094^jYUQ@#R2Gnv>LtH4+VPgRJ^VNNQrdl;rP z94uFO@J_n~__%UHIVs|;$sJvCu~Q06zM*D2mCCIDs6fwRImS7Zx5m(5Cbq^YFiwo| zDth5RDKKa~MwT4x9o_|<6#Ew3AC;dJ^Tt0HJFP(HF>wop_ns!WGpAFrP)>G6fy-s} zlNmItb|yJ#$YN&|xNsG#M$p~P5~kObsO+<;xYp+sm|d0_(}rii z1vTcH&pw}u3AmuZaC_C8CumJ_vM(e*|4w#Mfi-c>DE1z9eC%SfJC7{(vjS(XS!}Mu z@tmm{i+bc|WOH)+g_~VkasAWnmlT-x$;To}JeP930yF~~;}saMXcH8@24>F`=(HMP z(%8IsVrw4d6BU>!fX3iP?1}=1CTw=w{~h7i5JOSQ;OAA z>XI_IezaEMd_RfjOUl2(6Xv!M+yGDO>)>gl!ln6A6E2|qE2r43FQ9yy0@KhGk8rwO ztgQ;?=SrZ3ln;0y7nDmZJX-kLjs!bbwTEODOM&!RbtF%~oKxP;c!q1lC@KHbW)UEW(E% zBY$a`zB^^%LL0TM`my?n+D>h+ULXyfPK(jE*Q;-xhjXzGDjY6-GBQE^bx>1qkesZe z3Vk=khxZ02&KIpdpYCYUk>VL&C+nm_x3VVIR2pbirc4sx| z4ULn1s=~ptTGdpD)jmy*8W-!LLQi||VeVwiW#H+ernr;os=}al@_LV@l=pl{2=P+N z=O{2o(bDuz4>|9%Xe?7^*i{u}hTT-?W(&a}8BaIqd9+(!8U`QE)}=#o-BiU zsL;*!SqGx|wv!h<)FhJ^PFAAAmK&L`lH{k|#d@kx{IjeASw{ImYFEwdzskaLAhsxdCM8fyr^0trQ^;VNezx7e!uzet? z6>`u{k@Qi=q>>`(tHS7Vw%hcxv-_$kq<}tCVUO(v?nObfZ(H~*6-@n9*kMb^P#j-P zOUU(pNlJ+GSxd@mv`+mTYkw7Hmpk}ru9~X-)g+Q4E;c}g+2ve+i?svP6kPv-Dh%Ew z!-QLjEZnz8H&9JEkM+3WigLWN zJ_8%`6qqM6=XgigU=?#BVJIerhO02{8$4W@##T|jUnaCxQNB=tg<`9<5wF)9p+cvgm@9X%-}AG# zbCU4;5yRBs>Ik*LyLdmjlZ{lNZ~UnHPK4x^$lW&5gmSS_DxA6D;uw|u;xG+c4J_hJ zX=ChSqgCizZf|bxh4j&Ck{aB_#;7p3jA^pVEyt)SQZ-hEQ#Lld2O`1S8JmisbFpzM z4Br`FaAl16xKxe!colkiHNYP%`yQ9xO zQH9kT=ZJ8@8p^vqFFc1elrL6bF?tRs@Up*2Dh%2pf!0z!T>`D8e2D@}5NPW2c)U=B zVc*GGzjc(4|E0jRj`F1nEJdI*_hS}Wh2?7`&{4t{-^Ypb9!q-p1a+c1Nu8`tQR78{ zQ&s4D0L^I{J4tv~2Pe*_EcnN!s-jpsxosMzmQ@&R>oYURVhVWnw50W!+r_4&gU!`@6mD<#HBWI@;1;?o4AAyu*~_oRw3bKt{hTKXq5Tx^~S?Z?j+3uz#dU`a56&9(mef=U~j8_s79r0KkczB*VUv2n>`lY%+U8pWnZ+LgxViiu=7QhVp zn+r}~oTLRvQ1%^CRrX6%XuDo2=ygO!Fzt1^B$XAvRE5ho^z@b{vBVP64yemh*j2mO zG8HD5Q(UGc&0dzuFuqb@jjd0+p+2=Uw_izpnp`|Li!E1SL%b-UsfY`ju5b2oHR%EX z7h9o1ds~^BaX|~yisY54i>*{)bh+s^wQ2UsRQR+?g=6I;*Tkf+Qj_*wR;#eVbP=~v z-cD{Bw^6=UfwjU#`~>S^*Ql_!m$Y&-Dc^dVs19GN!mPpg(82!5Ps_st>r^;BJiaOX z0XK!VJg-*QsB6{tGu9=uIb5$o-!jgI?NqH#)pgmR!iG_Cy2NE6_GWd%P3cLfp0+do zH`4JNRhZmg>i9tff9oM(f3Z_4NL+=Ap0~b-P7{*&kz} zO&aS;`6Mh~a%!Px2kx*%rTYyu(4HvtqR@&f1lgQ_Np-I^SDU8%}Ihleyx6^?)^7nWnX72PjX*> zW}R%G3f&GY5rN-tD2Cv(GqABufo+PmUDlU>tHMcJJNH5D?6sIueV_WR)Xz8dt88(z zE_}K!Q3)#|@o?&OVcjmaUxh>Ky7l7jG`5%WZ_u~M#MTZ4b|~6TMgJO;ZU38~yi`7Q-^DcWvDe-Td$s&H*B{dC3GUp=56RKHWdS6?mtTMdU) zC@$@E+bla|I^EZ0gYyMzI-bD$$I9MWd9#_p)cGq5!!NCDl9-oi*SVSIxC*D1N@H=r3^!H5 z);9`#qiFxoKXSX+2^C6KNSK3^e~H>E16z9)*o!c8(CI#@!l19^*gp{70;bJX7&iH& zP;=i={u80F;_UN}Doi>lfi_USC9Tv2;m6ez>PZ!UDC``4KjW18<8`(# zKdI3ExQu1ZC47S-OvPyx7VMM{zo)zeY0kvfw+ei#;HC0Ckiau4wEI><9isA=!ZNY7 zUxEDssx_L5vnsS3EysVD@^)CUoq??b3LHR#dJ2z)ol{|ZD+%-i zRAJh5WOo`nM)|S~p{I$FGq8o#Hz+Af zk?EIJxHeBR{R9<`e0CrYXBRDq)csvAuxoj9&uQZK7l)T`6!{9ljH^QKTB<-YbODylTHGqH7Cf#YId+Zicr<$y^CB-Cm1lv5_QPAG6f zKy{?f`>mbj9SP@kuBpY2Rt|akueAf_m%CJf$n&vPTRV~p*0*uMiW@q*Z5%h9cl(&& z$#@0j^(~F<4VAu__4?5EVWskc+UtISNo@xlETgb(^4dDAJ!~vV*8MHH)L9+u7A3HvAl(w?h3rg9>+^Pt? z5k>7BFnpKXw@e`XD>OEC;H+J8BGKLf3+*7VdDVrvRrIuXq>{-^ICg|vuv8-33CU4= z>TYVYJ2;@Pea5d$lCi^0#TifgI^ayXaJo7AM>{%_4|61>s*q`^j2(Xk&jF|GNia)! z&9z;llOx5{my>mNz!uvhdcIT&yR##yiUSw>)B*cO#p}xOM#cnR?^8z#3D_*w#R2o< zwMsQgxq4k3DFj=(I-s*n(|OE!+5TtORA_RsZVp(uMw-vFly8yl!CA^rDR4?;wzi<9 z>h6G&^%81_c~b8#Yig==b98sucQdwwiGkyGgTPC#Y!yfvc7dpdeK8uoVdaSWn;9nurMsj;z>zgQZd;)?pr0T;^f*A5VU z=12vbGshZ6HEVZuQ_a-R0ULTv6;pDN@@cBD6&EQ#tH4=BJE!Q!(7);LfCc*`)X(zF zT6PAu&MR#OVTq%{}`drR;a+@CfVMGUv-yj3t<}Vo}Ebl>%e9_8a2VB14 zT{eT0u_r?uFlnTWC|#m_sq{rJQGQW@i;DKMqJM`vWhm+t>04elD-AQTbxDCs!na(9 zrG~>CFl)Ai>O}arxGA#lLU?a27SIoI40Q~1BzlR%9WdCgPuYzM(hji>cO+ZslJJpm zT9VopxLs_71J;yr%Ivbn5y?x{NCzw}Hw@D*XOB#J7+6W*fYBr3R_qWm#I_itQn46W zY_tQW$F0~SoFurBqaA-rCUl*U{*6~?e38Vjd^qiUXMR%Gl67P~!Fq~>X%a^&lEY;? zq@qr^RM_X=JEYb{o;Pwx$2j1yeU;NN{Mj?RfO3rERxfE9>wqnGg**P3cfuTwO<@l2 zbuq$=i--2)P+WX;0M{=o&k*nb^WGNq6@~*w)a%F#6$N8nB`;j$trYYHLs26y6wHr$ zt3--|F@K@qY3^%kguTDc358=hO+tl6qlnQwr?wFY_#!z4p`4l??^B*9asvKFMhhb^ zCmPAii4+B6{z4-s90}!l3PN62L_Y8ogra#(jQrON!vU|$!e0;yM+~Dd9Lp{Aa_eEx zXdaFE@|t>|yM=zmm!5)XF;srNUxfX^2#%=JYIpL)U*^?Lomm=Ov30y!q` z9A7L{=+E=$+3yCUzQ)EoApud{Q$H4p7;l9dzT|x)g+9f{<UqZ>P8mtqAGVR)6N=^-EesUx+J*Tj(D|XF z*sY*NEaHDi+xrFnSd*egp1e?D&WA=cYUCF+ZtOP#`8fgK-&z*#ib1vz=4!a`p# zKbjMY=7kCieZl;wN6*eH%zwMlhxLkrK_gP#Q?DpkE13UIB$Q`Fqi_0yM${Y1c{gfA zqB+r6)13OTra3Pg&2nBU@(1#xIRT%t_JBbwtc3`auEjK`vRkx(G;So0=^ z5qK=%E4UE_IeCToxuHfM=0=K;0s$8o=;s_w{gg)I(X$KuvD!BqMU$M0A4P#bmRp|u zcT*xir_^M~VN~~AXBz$+1F-wHrUCiiB((tuE|{X_(X;Crg`t@7W(Zekbx(m2lMh~s z_=0&&jOa@(<==1l3Jq`gzcnc)IXMwQOm09>gJVl^rTp8my=sju>S(t5640gbK37M16Dugi^Rk`Zy9dBYQ(%5jSFLF$KHtu zcP1t7HZF|igz-D1xDq0&FaB)ot(TO5#FtZ<<$%MEFLZybe#{puih6?;C(hUHSej*f zNjI#pF}!M+W59t$y}3ytE-3Qn`xyC+l#&J4dymhh! z4{Lf|(U#FXUm)O#_?nx|p!dZT)UD~L=R{jZbMt+X=KdfO#!Xxv^WUI1(`;E!yf3HF ziTSPdZ$d|Lv=PIXA25P;&y7QormwUx^1Q#jg^tj{&b6HXJIwB-k~ZCAZ)Pa!iK5oF z9;C9fHno3lI24r|48$gEfQ9YJ6sR__A>;3`+B&+ExX18CVrZzukgTWmih|xhB_%s? zC2V_(*V{Ir@9=g}EL;?eqP(Z5m6OKpeLE$hZ?HQ^AlCc)ln|DtG{Ij_L|MfW7h(ES z3Rak3=KRfUn1XHwOA10ZZsBftUrIqpOqABYap}HaxD*&635Nm!kG~)oiui*Cue|nIVO%m~K3zfS%8x;}sK%}^-peLT5uZRY_M zbc2eK!*J6AbQkbHWcI7KKuWD({%gKqe!vjhL(_Ap^&cZjk}bY)*rR9H3Wr5xq$1hk z3y1MPWXq#TjRr}%Xlz6pgfKi^KhGERzLtWSFwx``7UUKBB29A(PyxjZUxDyooaSp! zG|v}&TZVBnt!D*!FGqa-p!bDa?+(Z1E`zVGFVa+c>n|Jm{ybmI$oKxjhIy&L1`yKk z^*(!R1Yqa!L`}41G-edaG|FAq@6D#7&?w9cL`3vEY$hk7-deXpg+OGw6jASeR>xKt zT}0a73L*EscM9Wdx}?=TVSj!@pFft8IWqmxxQ&xBlQ_!eh_B2u(TB_Y&~jPD!}~2_ z^=y$@sqXOy{jt{!U-%_|EP88F(a0Yw^a&kMDouiG4D&Drkx=te{l?#jGzcyLJMmEH zYidLT{yZZkC&nC9j`$KKghylOf{8Fy{cyk^OKD{0$JW1yFvEQ25!l}H{xvE675DuM zFw|{ERdGH-g@yi@75gz-#3H`Dn0$!Qx;Op7|3PpU>G8)BB0(Qi;Fw-D%<-7PT~AH# zoBsiN3|C41NPOOk@-fl2BO@@IQ)-sPQbYU3lG;HPm& zZ>3OFkb_SINrE;lft}HC4>_!=9Lkmv1a9SQFBSByz6?@}jpmxCegkoTb*5QDLi zm;DjUx^O^6)XF` z>l$lQ!XF4l%Sib91tCwgi7#w;{K41<-uuhg+uEVRM*g6YFQYo%_fngVWFtBlEc(NL z2X%@PbkkUo_nrTM&g+JXyeS5sN||8{+P>BNdmUNT1PP}74%0*OM2vvpiyHNd@;Or* zuhet9k2gbkO}$P*Uc6uN?3omH0{FSz)gAaJrtTY>;}H0MohrUa^EmN$n+nQf&Ptt7 zq^UrfN(GgPBL~CySVel`x|EVpnlX9I1~EaWIB)uq5MXb}TDu#E^*E z=Jy{|AV-TNKg|TEFuX4(rKKf#1x0>zL_@*qIKV_A->+CA3yS=?sB=Ris**|w?(J~Q z9|}h6`oeV!L>f7mZ$!+86&Y4SR)%C>x#o5_IqM`jX;M_0OflmvV)na~MlO=55i#EK zh@MJemIb1KuVttxR-wze$e$yz;&M(UL0s1t^c5J9Iz_=e&zq92*ODENs5UTK#7v#g zAma1Kg!Bp?1!La4ij`a}ZZKNJgxboDVQ)Cvq(#H}-lJ!~>5obxTf|JNWlXuAnBt1s zmY{!*Gh@*m@JD00<{viwDaWqEp=iu3K6p2r?~55JiE@ITd8Ztr+?1w1*+LUkW~S>O zs@M)f!FOM8&;+-X?*;=#G+NIHhiW(R1q+OPv%@5-5^M`$qckxjxg4+*!b`!#c5okoWn7nJe{OzCRic_*ypi=f|3OO#SMSq`ztn$NPW(lOZIMlEBM? zkoU0!>I=#wUNMSj1Oir`Gh7s_sB+Nq9535<4kmw7BoIp)Z!(@nxG@nqO<4h1K^vtc z#$#tvQ%R1B960)QMj+tLtr%Gm+80-)%&_O?NBqqUk%dmhK3eW>Ucm50>J?!bdMX36 zJ{Jk?Bpn5$+0rE9yO$D#_r~F;Qr|VkG_z@j-wp;^+NqUrOz2CrL;1N;e?ia}NJw)T zg}&y#rbbaXrTC_~JIx6tmR-ejXTea+_z#fcRyG$2!)20Tb|tHw3hjeTXaib$@3dYQ zgv0|=g(sQv7K{)0gGR%MFYJ9IC9b25i&s|SADDTFzTWsP!^k)CQ=CHlzPbnhFR;93 z_zRlEk}Q!;a5y}A_DjB~A;h3Xth%Q`DCP@@zk)Fnr%{1x%`87f4-NGXN*Mlu)0SvF z{*46NR@iiAWi|Uf)~uG2T?}2?n`{)2XuvSSwn(7hcvC7%GW#(_F6<9B@(c3sFkc9i zSYB1n7i@aVm?OSm)7((w#>kk;iJ0xAww_(s#PEfyd&D86`e9%2mXde^33e0DAD2d4 z*B31E1rqYTMhwsDSb!|Bm>+7~*q&KEyC4svd(}OSd;woD z&#=>+W}4m3dcU3@FuaRt!t#vz3L6=boIuD^C*X_K^@Z(ta(0nF7<=Z?czih;ixlO> zw7)0jO8$*R3YE~*Xo=~HR6cn_e)3!@-$Y{(P3A^Z`m8XNpGq%vL;3#3ej}p&^8@dP z6|?%wy9MzHvGd3e6b1cxp?o8!DAxEH&&vk3(Vo5@l)hq01}71hL|70irR5|3W+8-% z8U>y@{zx=txuUfLMbVfMiT4`%Z?PbOg19S+RJs%7HUY-;m>Z%O?G5}dh>{L_LC7|m zL8CeDTuiO|+XO;ywn8j7Cu+oMH}PQ#-iX}HM~xTzy&DvoI`es7Q7j}5!(h$JZ%U;z z6FC(r3L276b#H-0U;c+h(b&tO=E3H^NPc~BIIvcsI46B8exXEw=#t$i+Gf&Zn;C&f zz~oDATqY)S8;;nFNX(z-3rJM8<7Yvto8b-lToBv5Vnj?h*`?{oX=YN9D^FhLV#Gtz zh|>09g^%r3BUTr;(Qg?vJN?@^lS!^kK{36B|3cg{qOi`l5 zhiDoDhKLw=^#ooeu7GeP6gDEUmX=bBdOxa|=`63w6S~bYyYT$0uXf&@CF@nCj zzLw@vj#rfhjpk^#ML|>?KJJ6~gZ`NCZEoP6n2IP~ju!t=ctxf3O9Gj3?!4lQ#%e|V zzMxbViMy}sf!8;Dk-R3h=kWgHABbf112>rj+XpQ$Vz1V&>x(u`DPv`NfZ`GWTp)2i z;jI8xb)7iNZu_Cgay*_c7#mMCMPp4H73DQGVz=fjh9e>2lg7Q>!gnH}#<_JRx8qL{ z%pWl1)A)wV%7VS=ixe2%hY}66C%>GruaBw8R}vWeZw!ps6cpImk%VRbb~qNboytOA zelvg6NZ?1y=(|xP|Lyv>bcm?XC^U~e;H<=}K24vX6t6$9dk~xQ zy0-spUZGLfdn+ZD5?j3LD84HohWL5y3Yb8bXZ%9@&3 zv9vbq%afs(xU6DOpv-=)@Eu>Isl9!R#zNsjn^%qf!Tfqf!Jw~Ez_3{(t{f=}7%`z6|Z=R@6-wlePNr8 z(UwuM65p<$K$Q8+ULB=MMC}zGS7=c~D8i_%>qI3ko`go-y3 zI?iQ0ax9+PedZhaMR~FM%?%@5J7VPfW4Ah|QAB~_oGDIuh%|S7QEz&Oe4xjVj$j7d z3uT|iPgKW)I!z4>l-nyIXvFXwP)f)1%!$r1zs+s>QISz36l-S^FJ!QQBPeS?Mf&&j$4BCjep7B@faF2@u7-<$=1_J4F1+-p~EHqHyi0$4vKPf%l}5*|1f z7y86Ki$bHYFw`uyqnf6cLU#ziji1#>?euG65oXjE^vC>BLzJQWf>u?hGh!4NEyC#A zz9g@3Fj6y3;`5wFzPzS*2tb4&@0Aa+qD;KS@S^$pI`8sB$g-lCk&;r9UC1NNSobXd zPUy0ZKbS9LTzDseFP7INHUC^tZgzt`JeSl0kvF1X-x#~huZ#T}QSW;dvr)u-zvv-> zuD>KjS6tyV2}NV#IDipp=Fc-c_2s`W8;x;a=nn{^F!EM3ns- z*_~W4=7z)4vtN?~HOD7Pr|^CcIo#KSjYI$8`GwN>tCvMyLAhU?@%SJUb@}ypmYzdC?%nrc*xmP#~+9pk&4U$lnHQIbjmxO zwEx7(NH)@c0}9NwgF=*C4j?{*Wdz?^eTeBlq{&wlYtkUp)Cii%3`{g6ry6cFO1#Re zG2U2nBYF5QT_q!B?wz7Qpq}wjkr9pkZ`=oOjfCx9{M@OzM~x@d@QnzbRq*XL<~=>u zEos8Z^?GW0f0wY3_R>L znmz9EM{}bXCa9>xF;*xuu_sTn$3>6fh`*UHW{6WkctvShT9B$-qtG9V8IfFH&=+X= zHzOAvD_^9dk5H8*&+Jtk&V8)ZTgj&Dk|wv!Rc{84xV`S{KK*}8P%)PF zMpGa=L))s~oS6D~K4kmD{~l8A_nO@dtk4&UHt_|#kK7bnQC_T>ko}t8YX2U%&=(H- zgE#M6MiiP|cbXC=Ky2%p-HbDZzOY5!O(ql}Z;nl{<>gSG_ulIawyu0#QNcTv z67L)?5SfeW9*la0?F)9}VNw~}6oFMqc08kmq1jrPZF;+LlGn4|*sG`;$~P>R0%fKs zYD6ol^b^JA20e=~lX*-8<^7NU;glo}<5eS8D=+46X5horkH`3fc(a`kcVG2m5pSc4 zTSCZ-1njwCU(gV%{n_M3FA8zY9Bq9ghLt19)^;a;iuIIrBECZ7mHYxDYWrDuQKCPJ zXNp7wy{27$ROXFo6XN%0mw9bRb@lAV{uTn6S;du7flEB=XoE2Z8=#?SL;DEfqtscTT1DOz(dnjjA2@lz)Ga>oNG^!%!kE2{76yoqk@R9k)6)LC()SU3)qjupO~C0l@2dkW?rVm9j|f4 zJ-ihfzYci3ItN#bD9eu1WtIumw1v%l;(a}-wH)(Bo8l%P>sw{fy?F?!x=0C$lGlp7 zidgg~|BtJvc2Y!2yDc^{SV=61Y7na94+N0xBu6TdB;>9!}M55*J5R<^x?KbTN|fY)ZmFjZlThd&rIA0>GZrmV8WlTvj;A*|<0WL#e2@s-45 z7ar>fizvFVvl5bu54?Y?7?Wc8kRmE1ZkxX$MRuWE;z^zxf}@eKj;)j zv%dWN)PlM4$1-xFv7$yo8h=?33iyHro=~J9rv)A~#;U~@9)Bn=7I-HX@&3JH`E|X{ za&CCTk8y=uf7`h!vb-wbc;e?X&PNN!OO%NgY5F;65+epv;EHyI}0r6}}& zeWdlVfAdx14TKRx3@v^+mo8GDuLtpXfK}2YZ+yB=Z2<91;kaaD{ zLk(|s7r!LSu9=QSTE1$;-fr}vt)$JW>9=e&1$nii=;PGF!l9Zrno`e&d*iirQmL1A zeYz|nkoe3hf_N@GuEsE3WOJdEX%w{s(q*ac5r5c}i$Ad}C7!lwR3M(+(lK>;?`DGp z?%z0gB@6|YQTmOthUqP%dF{gd2EJ%h!J(R-r#v;2JdlwRR$J}o`S(Z&M9Xkv`LK+m5zX_3jo0zFg2E9ahGDa? zFA_E04jL7iUlZNs$j*?+7m^ZU@s%mW)g@i82k}f}1zpD&@cpf2fj{>D0{{U3|6k=y Ir#|%n09n;AKL7v# literal 0 HcmV?d00001 diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index d93f84954..e4c5a5f98 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -50,6 +50,14 @@ type View struct { // tained is true if the viewLines must be updated tainted bool + // firstDirtyLine is the index of the lowest line in `lines` that has been + // written to or highlighted since viewLines was last refreshed, and whose + // cached wrapping (lineType.wrappedCells) may therefore be stale. Lines + // below it are unchanged and can reuse their cached wrapping instead of + // being re-wrapped, which keeps refreshViewLinesIfNeeded cheap while + // scrolling appends new lines to a long buffer. + firstDirtyLine int + // the last position that the mouse was hovering over; nil if the mouse is outside of // this view, or not hovering over a cell lastHoverPosition *pos @@ -457,6 +465,16 @@ type viewLine struct { type lineType struct { cells cells trailingFillAttributes *trailingFillAttributes + + // wrappedCells caches the result of wrapping `cells` to `wrappedColumns` + // columns, so that unchanged lines don't have to be re-wrapped on every + // refreshViewLinesIfNeeded (which runs on every scroll event, via + // ViewLinesHeight). Wrapping measures every cell's width and allocates, so + // for a long buffer that dominates the cost of scrolling. The cache is used + // only for lines below View.firstDirtyLine whose wrappedColumns still + // matches the current width; nil means nothing is cached yet. + wrappedCells [][]cell + wrappedColumns int } // trailingFillAttributes describes the fg/bg colors that draw() should @@ -815,6 +833,9 @@ func (v *View) Write(p []byte) (n int, err error) { func (v *View) write(p []byte) { v.tainted = true + // write only ever touches lines from v.wy onwards, so any cached wrapping + // below that stays valid. + v.firstDirtyLine = min(v.firstDirtyLine, v.wy) v.clearHover() // Fill with empty cells, if writing outside current view buffer @@ -1358,48 +1379,64 @@ func (v *View) draw() { } func (v *View) refreshViewLinesIfNeeded() { - if v.tainted { - maxX := v.InnerWidth() - lineIdx := 0 - lines := v.lines - for i, line := range lines { - wrap := 0 - if v.Wrap { - wrap = maxX - } - - ls := lineWrap(line.cells, wrap) - for j := range ls { - // Per-segment trailing fill. When the source line opted in - // via '\x1b[K', the LAST wrapped segment uses those colors - // directly; earlier segments use the colors of their own - // last cell, so the trailing area matches the bg active - // where that segment ended rather than bleeding the - // '\x1b[K' bg back across color changes in the line. - var attrs *trailingFillAttributes - if line.trailingFillAttributes != nil { - if j == len(ls)-1 { - attrs = line.trailingFillAttributes - } else if len(ls[j]) > 0 { - last := ls[j][len(ls[j])-1] - attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor} - } - } - vline := viewLine{ - linesX: j, linesY: i, line: ls[j], - trailingFillAttributes: attrs, - } - - if lineIdx > len(v.viewLines)-1 { - v.viewLines = append(v.viewLines, vline) - } else { - v.viewLines[lineIdx] = vline - } - lineIdx++ - } - } - v.tainted = false + if !v.tainted { + return } + + maxX := v.InnerWidth() + wrap := 0 + if v.Wrap { + wrap = maxX + } + + lineIdx := 0 + lines := v.lines + for i := range lines { + line := &lines[i] + + // Reuse the previously wrapped result for lines that haven't changed + // since the last refresh (i.e. below firstDirtyLine) and were wrapped at + // the current width. Wrapping is expensive and this loop runs on every + // scroll event, so only the lines that were actually just read (or + // re-highlighted) should be wrapped afresh. + if line.wrappedCells == nil || line.wrappedColumns != wrap || i >= v.firstDirtyLine { + line.wrappedCells = lineWrap(line.cells, wrap) + line.wrappedColumns = wrap + } + ls := line.wrappedCells + + for j := range ls { + // Per-segment trailing fill. When the source line opted in + // via '\x1b[K', the LAST wrapped segment uses those colors + // directly; earlier segments use the colors of their own + // last cell, so the trailing area matches the bg active + // where that segment ended rather than bleeding the + // '\x1b[K' bg back across color changes in the line. + var attrs *trailingFillAttributes + if line.trailingFillAttributes != nil { + if j == len(ls)-1 { + attrs = line.trailingFillAttributes + } else if len(ls[j]) > 0 { + last := ls[j][len(ls[j])-1] + attrs = &trailingFillAttributes{fg: last.fgColor, bg: last.bgColor} + } + } + vline := viewLine{ + linesX: j, linesY: i, line: ls[j], + trailingFillAttributes: attrs, + } + + if lineIdx > len(v.viewLines)-1 { + v.viewLines = append(v.viewLines, vline) + } else { + v.viewLines[lineIdx] = vline + } + lineIdx++ + } + } + + v.firstDirtyLine = len(lines) + v.tainted = false } // if autoscroll is enabled but we only have a single row of cells shown to the @@ -1599,6 +1636,7 @@ func (v *View) SetHighlight(y int, on bool) { cells = append(cells, c) } v.tainted = true + v.firstDirtyLine = min(v.firstDirtyLine, y) v.lines[y].cells = cells v.clearHover() } From d181615c315b682a2c26c89ef70ca141a0e66cc2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 11:46:19 +0200 Subject: [PATCH 264/384] Make integration tests using commits more robust Some tests assert that a specific commit subject does or doesn't occur in the main view; interactive_rebase/outside_rebase_range_select.go is an example for this, it asserts `t.Views().Main().Content( DoesNotContain("commit 06"))`. The problem with this kind of assertion and our test commit naming scheme is that the diff view begins with a "commit " line, and when that hash happens to start with "06" the assertion matched it and failed spuriously. This was usually masked by our MaxAttempts=2 that we currently use for integration tests (it's quite unlikely that the commit gets a hash beginning with "06" twice in a row). However, we want to get to a state where we can set MaxAttempts to 1, so make this more robust by changing our naming scheme. --- pkg/integration/components/shell.go | 2 +- pkg/integration/tests/bisect/basic.go | 16 +- pkg/integration/tests/bisect/choose_terms.go | 32 +-- .../tests/bisect/from_other_branch.go | 10 +- pkg/integration/tests/bisect/skip.go | 48 ++--- .../select_commits_of_current_branch.go | 26 +-- .../tests/commit/create_amend_commit.go | 24 +-- .../tests/custom_commands/selected_commit.go | 20 +- .../custom_commands/selected_commit_range.go | 10 +- .../tests/filter_by_author/select_author.go | 20 +- .../tests/filter_by_author/shared.go | 2 +- .../tests/filter_by_author/type_author.go | 8 +- .../interactive_rebase/amend_first_commit.go | 10 +- .../interactive_rebase/amend_fixup_commit.go | 26 +-- .../amend_head_commit_during_rebase.go | 20 +- .../amend_non_head_commit_during_rebase.go | 16 +- .../delete_update_ref_todo.go | 38 ++-- .../dont_show_branch_heads_for_todo_items.go | 38 ++-- ...commit_in_copied_branch_with_update_ref.go | 18 +- .../drop_todo_commit_with_update_ref.go | 46 ++-- .../drop_with_custom_comment_char.go | 6 +- .../interactive_rebase/edit_and_auto_amend.go | 20 +- .../interactive_rebase/edit_first_commit.go | 14 +- .../edit_last_commit_of_stacked_branch.go | 32 +-- .../edit_non_todo_commit_during_rebase.go | 10 +- ...nge_select_down_to_merge_outside_rebase.go | 8 +- .../interactive_rebase/fixup_first_commit.go | 10 +- .../interactive_rebase_of_copied_branch.go | 14 +- ...e_rebase_with_conflict_for_edit_command.go | 12 +- .../mid_rebase_range_select.go | 200 +++++++++--------- .../tests/interactive_rebase/move.go | 72 +++---- ...e_across_branch_boundary_outside_rebase.go | 22 +- .../interactive_rebase/move_in_rebase.go | 82 +++---- .../move_update_ref_todo.go | 38 ++-- .../move_with_custom_comment_char.go | 12 +- .../outside_rebase_range_select.go | 102 ++++----- .../quick_start_keep_selection.go | 30 +-- .../quick_start_keep_selection_range.go | 30 +-- ...vert_during_rebase_when_stopped_on_edit.go | 30 +-- .../reword_commit_with_editor_and_fail.go | 14 +- .../interactive_rebase/reword_first_commit.go | 10 +- .../interactive_rebase/reword_last_commit.go | 8 +- .../reword_last_commit_of_stacked_branch.go | 22 +- .../reword_you_are_here_commit.go | 20 +- .../reword_you_are_here_commit_with_editor.go | 18 +- .../interactive_rebase/show_exec_todos.go | 18 +- .../squash_down_first_commit.go | 10 +- .../squash_down_second_commit.go | 14 +- .../interactive_rebase/squash_fixups_above.go | 22 +- .../squash_fixups_above_first_commit.go | 12 +- .../squash_fixups_in_current_branch.go | 12 +- .../view_files_of_todo_entries.go | 6 +- ...commit_in_last_commit_of_stacked_branch.go | 28 +-- pkg/integration/tests/ui/accordion.go | 30 +-- .../mode_specific_keybinding_suggestions.go | 4 +- 55 files changed, 711 insertions(+), 711 deletions(-) diff --git a/pkg/integration/components/shell.go b/pkg/integration/components/shell.go index 70b12146a..72cc3d95c 100644 --- a/pkg/integration/components/shell.go +++ b/pkg/integration/components/shell.go @@ -256,7 +256,7 @@ func (self *Shell) CreateNCommitsStartingAt(n, startIndex int) *Shell { fmt.Sprintf("file%02d.txt", i), fmt.Sprintf("file%02d content", i), ). - Commit(fmt.Sprintf("commit %02d", i)) + Commit(fmt.Sprintf("commit-%02d", i)) } return self diff --git a/pkg/integration/tests/bisect/basic.go b/pkg/integration/tests/bisect/basic.go index dbce50969..fda3c11a0 100644 --- a/pkg/integration/tests/bisect/basic.go +++ b/pkg/integration/tests/bisect/basic.go @@ -34,29 +34,29 @@ var Basic = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). - SelectedLine(Contains("CI commit 10")). - NavigateToLine(Contains("CI commit 09")). + SelectedLine(Contains("CI commit-10")). + NavigateToLine(Contains("CI commit-09")). Tap(func() { markCommitAsBad() t.Views().Information().Content(Contains("Bisecting")) }). SelectedLine(Contains("<-- bad")). - NavigateToLine(Contains("CI commit 02")). + NavigateToLine(Contains("CI commit-02")). Tap(markCommitAsGood). - TopLines(Contains("CI commit 10")). + TopLines(Contains("CI commit-10")). // lazygit will land us in the commit between our good and bad commits. - SelectedLine(Contains("CI commit 05").Contains("<-- current")). + SelectedLine(Contains("CI commit-05").Contains("<-- current")). Tap(markCommitAsBad). - SelectedLine(Contains("CI commit 04").Contains("<-- current")). + SelectedLine(Contains("CI commit-04").Contains("<-- current")). Tap(func() { markCommitAsGood() // commit 5 is the culprit because we marked 4 as good and 5 as bad. - t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 05.*Do you want to reset")).Confirm() + t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit-05.*Do you want to reset")).Confirm() }). IsFocused(). - Content(Contains("CI commit 04")) + Content(Contains("CI commit-04")) t.Views().Information().Content(DoesNotContain("Bisecting")) }, diff --git a/pkg/integration/tests/bisect/choose_terms.go b/pkg/integration/tests/bisect/choose_terms.go index 51c9246ba..5e3b0ed27 100644 --- a/pkg/integration/tests/bisect/choose_terms.go +++ b/pkg/integration/tests/bisect/choose_terms.go @@ -34,40 +34,40 @@ var ChooseTerms = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). - SelectedLine(Contains("CI commit 10")). + SelectedLine(Contains("CI commit-10")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(Contains("Choose bisect terms")).Confirm() t.ExpectPopup().Prompt().Title(Equals("Term for old/good commit:")).Type("broken").Confirm() t.ExpectPopup().Prompt().Title(Equals("Term for new/bad commit:")).Type("fixed").Confirm() }). - NavigateToLine(Contains("CI commit 09")). + NavigateToLine(Contains("CI commit-09")). Tap(markCommitAsFixed). SelectedLine(Contains("<-- fixed")). - NavigateToLine(Contains("CI commit 02")). + NavigateToLine(Contains("CI commit-02")). Tap(markCommitAsBroken). Lines( - Contains("CI commit 10").DoesNotContain("<--"), - Contains("CI commit 09").Contains("<-- fixed"), - Contains("CI commit 08").DoesNotContain("<--"), - Contains("CI commit 07").DoesNotContain("<--"), - Contains("CI commit 06").DoesNotContain("<--"), - Contains("CI commit 05").Contains("<-- current").IsSelected(), - Contains("CI commit 04").DoesNotContain("<--"), - Contains("CI commit 03").DoesNotContain("<--"), - Contains("CI commit 02").Contains("<-- broken"), - Contains("CI commit 01").DoesNotContain("<--"), + Contains("CI commit-10").DoesNotContain("<--"), + Contains("CI commit-09").Contains("<-- fixed"), + Contains("CI commit-08").DoesNotContain("<--"), + Contains("CI commit-07").DoesNotContain("<--"), + Contains("CI commit-06").DoesNotContain("<--"), + Contains("CI commit-05").Contains("<-- current").IsSelected(), + Contains("CI commit-04").DoesNotContain("<--"), + Contains("CI commit-03").DoesNotContain("<--"), + Contains("CI commit-02").Contains("<-- broken"), + Contains("CI commit-01").DoesNotContain("<--"), ). Tap(markCommitAsFixed). - SelectedLine(Contains("CI commit 04").Contains("<-- current")). + SelectedLine(Contains("CI commit-04").Contains("<-- current")). Tap(func() { markCommitAsBroken() // commit 5 is the culprit because we marked 4 as broken and 5 as fixed. - t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 05.*Do you want to reset")).Confirm() + t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit-05.*Do you want to reset")).Confirm() }). IsFocused(). - Content(Contains("CI commit 04")) + Content(Contains("CI commit-04")) t.Views().Information().Content(DoesNotContain("Bisecting")) }, diff --git a/pkg/integration/tests/bisect/from_other_branch.go b/pkg/integration/tests/bisect/from_other_branch.go index 24e49104b..b65c88594 100644 --- a/pkg/integration/tests/bisect/from_other_branch.go +++ b/pkg/integration/tests/bisect/from_other_branch.go @@ -24,17 +24,17 @@ var FromOtherBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). TopLines( - MatchesRegexp(`<-- bad.*commit 08`), - MatchesRegexp(`<-- current.*commit 07`), - MatchesRegexp(`\?.*commit 06`), - MatchesRegexp(`<-- good.*commit 05`), + MatchesRegexp(`<-- bad.*commit-08`), + MatchesRegexp(`<-- current.*commit-07`), + MatchesRegexp(`\?.*commit-06`), + MatchesRegexp(`<-- good.*commit-05`), ). SelectNextItem(). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as good`)).Confirm() - t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit 08.*Do you want to reset")).Confirm() + t.ExpectPopup().Alert().Title(Equals("Bisect complete")).Content(MatchesRegexp("(?s)commit-08.*Do you want to reset")).Confirm() t.Views().Information().Content(DoesNotContain("Bisecting")) }). diff --git a/pkg/integration/tests/bisect/skip.go b/pkg/integration/tests/bisect/skip.go index c879cc408..7c9ef4aea 100644 --- a/pkg/integration/tests/bisect/skip.go +++ b/pkg/integration/tests/bisect/skip.go @@ -19,28 +19,28 @@ var Skip = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). - SelectedLine(Contains("commit 10")). + SelectedLine(Contains("commit-10")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as bad`)).Confirm() }). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")).Select(MatchesRegexp(`Mark .* as good`)).Confirm() t.Views().Information().Content(Contains("Bisecting")) }). Lines( - Contains("CI commit 10").Contains("<-- bad"), - Contains("CI commit 09").DoesNotContain("<--"), - Contains("CI commit 08").DoesNotContain("<--"), - Contains("CI commit 07").DoesNotContain("<--"), - Contains("CI commit 06").DoesNotContain("<--"), - Contains("CI commit 05").Contains("<-- current").IsSelected(), - Contains("CI commit 04").DoesNotContain("<--"), - Contains("CI commit 03").DoesNotContain("<--"), - Contains("CI commit 02").DoesNotContain("<--"), - Contains("CI commit 01").Contains("<-- good"), + Contains("CI commit-10").Contains("<-- bad"), + Contains("CI commit-09").DoesNotContain("<--"), + Contains("CI commit-08").DoesNotContain("<--"), + Contains("CI commit-07").DoesNotContain("<--"), + Contains("CI commit-06").DoesNotContain("<--"), + Contains("CI commit-05").Contains("<-- current").IsSelected(), + Contains("CI commit-04").DoesNotContain("<--"), + Contains("CI commit-03").DoesNotContain("<--"), + Contains("CI commit-02").DoesNotContain("<--"), + Contains("CI commit-01").Contains("<-- good"), ). Press(keys.Commits.ViewBisectOptions). Tap(func() { @@ -57,18 +57,18 @@ var Skip = NewIntegrationTest(NewIntegrationTestArgs{ }). // Skipping the current commit selects the new current commit: Lines( - Contains("CI commit 10").Contains("<-- bad"), - Contains("CI commit 09").DoesNotContain("<--"), - Contains("CI commit 08").DoesNotContain("<--"), - Contains("CI commit 07").DoesNotContain("<--"), - Contains("CI commit 06").Contains("<-- current").IsSelected(), - Contains("CI commit 05").Contains("<-- skipped"), - Contains("CI commit 04").DoesNotContain("<--"), - Contains("CI commit 03").DoesNotContain("<--"), - Contains("CI commit 02").DoesNotContain("<--"), - Contains("CI commit 01").Contains("<-- good"), + Contains("CI commit-10").Contains("<-- bad"), + Contains("CI commit-09").DoesNotContain("<--"), + Contains("CI commit-08").DoesNotContain("<--"), + Contains("CI commit-07").DoesNotContain("<--"), + Contains("CI commit-06").Contains("<-- current").IsSelected(), + Contains("CI commit-05").Contains("<-- skipped"), + Contains("CI commit-04").DoesNotContain("<--"), + Contains("CI commit-03").DoesNotContain("<--"), + Contains("CI commit-02").DoesNotContain("<--"), + Contains("CI commit-01").Contains("<-- good"), ). - NavigateToLine(Contains("commit 07")). + NavigateToLine(Contains("commit-07")). Press(keys.Commits.ViewBisectOptions). Tap(func() { t.ExpectPopup().Menu().Title(Equals("Bisect")). @@ -85,6 +85,6 @@ var Skip = NewIntegrationTest(NewIntegrationTestArgs{ }). // Skipping a selected, non-current commit keeps the selection // there: - SelectedLine(Contains("CI commit 07").Contains("<-- skipped")) + SelectedLine(Contains("CI commit-07").Contains("<-- skipped")) }, }) diff --git a/pkg/integration/tests/branch/select_commits_of_current_branch.go b/pkg/integration/tests/branch/select_commits_of_current_branch.go index 7b57455c3..6c1ee2a86 100644 --- a/pkg/integration/tests/branch/select_commits_of_current_branch.go +++ b/pkg/integration/tests/branch/select_commits_of_current_branch.go @@ -22,25 +22,25 @@ var SelectCommitsOfCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), Contains("master 02"), Contains("master 01"), ). Press(keys.Commits.SelectCommitsOfCurrentBranch). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02").IsSelected(), - Contains("commit 01").IsSelected(), + Contains("commit-03").IsSelected(), + Contains("commit-02").IsSelected(), + Contains("commit-01").IsSelected(), Contains("master 02"), Contains("master 01"), ). PressEscape(). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), Contains("master 02"), Contains("master 01"), ) @@ -58,15 +58,15 @@ var SelectCommitsOfCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().SubCommits(). IsFocused(). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), Contains("master 02"), Contains("master 01"), ). Press(keys.Commits.SelectCommitsOfCurrentBranch). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01").IsSelected(), + Contains("commit-02").IsSelected(), + Contains("commit-01").IsSelected(), Contains("master 02"), Contains("master 01"), ) diff --git a/pkg/integration/tests/commit/create_amend_commit.go b/pkg/integration/tests/commit/create_amend_commit.go index 474e24099..7d311a983 100644 --- a/pkg/integration/tests/commit/create_amend_commit.go +++ b/pkg/integration/tests/commit/create_amend_commit.go @@ -19,11 +19,11 @@ var CreateAmendCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Commits.CreateFixupCommit). Tap(func() { t.ExpectPopup().Menu(). @@ -31,14 +31,14 @@ var CreateAmendCommit = NewIntegrationTest(NewIntegrationTestArgs{ Select(Contains("amend! commit with changes")). Confirm() t.ExpectPopup().CommitMessagePanel(). - Content(Equals("commit 02")). + Content(Equals("commit-02")). Type(" amended").Confirm() }). Lines( - Contains("amend! commit 02"), - Contains("commit 03"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("amend! commit-02"), + Contains("commit-03"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.Views().Commits(). @@ -50,9 +50,9 @@ var CreateAmendCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 03"), - Contains("commit 02 amended").IsSelected(), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02 amended").IsSelected(), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/custom_commands/selected_commit.go b/pkg/integration/tests/custom_commands/selected_commit.go index 0265759a8..1add8b45a 100644 --- a/pkg/integration/tests/custom_commands/selected_commit.go +++ b/pkg/integration/tests/custom_commands/selected_commit.go @@ -24,44 +24,44 @@ var SelectedCommit = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { // Select different commits in each of the commit views t.Views().Commits().Focus(). - NavigateToLine(Contains("commit 01")) + NavigateToLine(Contains("commit-01")) t.Views().ReflogCommits().Focus(). - NavigateToLine(Contains("commit 02")) + NavigateToLine(Contains("commit-02")) t.Views().Branches().Focus(). Lines(Contains("master").IsSelected()). PressEnter() t.Views().SubCommits().IsFocused(). - NavigateToLine(Contains("commit 03")) + NavigateToLine(Contains("commit-03")) // SubCommits t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 03")) + t.FileSystem().FileContent("file.txt", Equals("commit-03")) t.Views().SubCommits().PressEnter() t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 03")) + t.FileSystem().FileContent("file.txt", Equals("commit-03")) // ReflogCommits t.Views().ReflogCommits().Focus() t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit: commit 02")) + t.FileSystem().FileContent("file.txt", Equals("commit: commit-02")) t.Views().ReflogCommits().PressEnter() t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit: commit 02")) + t.FileSystem().FileContent("file.txt", Equals("commit: commit-02")) // LocalCommits t.Views().Commits().Focus() t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 01")) + t.FileSystem().FileContent("file.txt", Equals("commit-01")) t.Views().Commits().PressEnter() t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 01")) + t.FileSystem().FileContent("file.txt", Equals("commit-01")) // None of these t.Views().Files().Focus() t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 01")) + t.FileSystem().FileContent("file.txt", Equals("commit-01")) }, }) diff --git a/pkg/integration/tests/custom_commands/selected_commit_range.go b/pkg/integration/tests/custom_commands/selected_commit_range.go index 6ef1305aa..662a28090 100644 --- a/pkg/integration/tests/custom_commands/selected_commit_range.go +++ b/pkg/integration/tests/custom_commands/selected_commit_range.go @@ -24,18 +24,18 @@ var SelectedCommitRange = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Focus(). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ) t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 03\n")) + t.FileSystem().FileContent("file.txt", Equals("commit-03\n")) t.Views().Commits().Focus(). Press(keys.Universal.RangeSelectDown) t.GlobalPress(config.Keybinding{"X"}) - t.FileSystem().FileContent("file.txt", Equals("commit 03\ncommit 02\n")) + t.FileSystem().FileContent("file.txt", Equals("commit-03\ncommit-02\n")) }, }) diff --git a/pkg/integration/tests/filter_by_author/select_author.go b/pkg/integration/tests/filter_by_author/select_author.go index 281034c12..3e7759ce8 100644 --- a/pkg/integration/tests/filter_by_author/select_author.go +++ b/pkg/integration/tests/filter_by_author/select_author.go @@ -29,14 +29,14 @@ var SelectAuthor = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). Lines( - Contains("commit 7"), - Contains("commit 6"), - Contains("commit 5"), - Contains("commit 4"), - Contains("commit 3"), - Contains("commit 2"), - Contains("commit 1"), - Contains("commit 0"), + Contains("commit-7"), + Contains("commit-6"), + Contains("commit-5"), + Contains("commit-4"), + Contains("commit-3"), + Contains("commit-2"), + Contains("commit-1"), + Contains("commit-0"), ) t.Views().Information().Content(Contains("Filtering by 'Paul Oberstein '")) @@ -51,7 +51,7 @@ var SelectAuthor = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). - NavigateToLine(Contains("SK commit 0")). + NavigateToLine(Contains("SK commit-0")). Press(keys.Universal.FilteringMenu) t.ExpectPopup().Menu(). @@ -62,7 +62,7 @@ var SelectAuthor = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). Lines( - Contains("commit 0"), + Contains("commit-0"), ) t.Views().Information().Content(Contains("Filtering by 'Siegfried Kircheis '")) diff --git a/pkg/integration/tests/filter_by_author/shared.go b/pkg/integration/tests/filter_by_author/shared.go index 22d08ad5c..33130db66 100644 --- a/pkg/integration/tests/filter_by_author/shared.go +++ b/pkg/integration/tests/filter_by_author/shared.go @@ -20,7 +20,7 @@ func commonSetup(shell *Shell) { for _, authorInfo := range authors { for i := range authorInfo.numberOfCommits { authorEmail := strings.ToLower(strings.ReplaceAll(authorInfo.name, " ", ".")) + "@email.com" - commitMessage := fmt.Sprintf("commit %d", i) + commitMessage := fmt.Sprintf("commit-%d", i) shell.SetAuthor(authorInfo.name, authorEmail) shell.EmptyCommitDaysAgo(commitMessage, repoStartDaysAgo-totalCommits) diff --git a/pkg/integration/tests/filter_by_author/type_author.go b/pkg/integration/tests/filter_by_author/type_author.go index cb84d5757..79750fab3 100644 --- a/pkg/integration/tests/filter_by_author/type_author.go +++ b/pkg/integration/tests/filter_by_author/type_author.go @@ -33,9 +33,9 @@ var TypeAuthor = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). Lines( - Contains("commit 2"), - Contains("commit 1"), - Contains("commit 0"), + Contains("commit-2"), + Contains("commit-1"), + Contains("commit-0"), ) t.Views().Information().Content(Contains("Filtering by 'Yang Wen-li '")) @@ -58,7 +58,7 @@ var TypeAuthor = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). Lines( - Contains("commit 0"), + Contains("commit-0"), ) t.Views().Information().Content(Contains("Filtering by 'Siegfried Kircheis '")) diff --git a/pkg/integration/tests/interactive_rebase/amend_first_commit.go b/pkg/integration/tests/interactive_rebase/amend_first_commit.go index 02ce4e112..b811a5638 100644 --- a/pkg/integration/tests/interactive_rebase/amend_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/amend_first_commit.go @@ -19,10 +19,10 @@ var AmendFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Commits.AmendToCommit). Tap(func() { t.ExpectPopup().Confirmation(). @@ -31,8 +31,8 @@ var AmendFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 02"), - Contains("commit 01").IsSelected(), + Contains("commit-02"), + Contains("commit-01").IsSelected(), ) t.Views().Main(). diff --git a/pkg/integration/tests/interactive_rebase/amend_fixup_commit.go b/pkg/integration/tests/interactive_rebase/amend_fixup_commit.go index 3140899be..8943f1580 100644 --- a/pkg/integration/tests/interactive_rebase/amend_fixup_commit.go +++ b/pkg/integration/tests/interactive_rebase/amend_fixup_commit.go @@ -13,22 +13,22 @@ var AmendFixupCommit = NewIntegrationTest(NewIntegrationTestArgs{ SetupRepo: func(shell *Shell) { shell. CreateNCommits(1). - CreateFileAndAdd("first-fixup-file", "").Commit("fixup! commit 01"). + CreateFileAndAdd("first-fixup-file", "").Commit("fixup! commit-01"). CreateNCommitsStartingAt(2, 2). - CreateFileAndAdd("unrelated-fixup-file", "fixup 03").Commit("fixup! commit 03"). + CreateFileAndAdd("unrelated-fixup-file", "fixup 03").Commit("fixup! commit-03"). CreateFileAndAdd("fixup-file", "fixup 01") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). Lines( - Contains("fixup! commit 03"), - Contains("commit 03"), - Contains("commit 02"), - Contains("fixup! commit 01"), - Contains("commit 01"), + Contains("fixup! commit-03"), + Contains("commit-03"), + Contains("commit-02"), + Contains("fixup! commit-01"), + Contains("commit-01"), ). - NavigateToLine(Contains("fixup! commit 01")). + NavigateToLine(Contains("fixup! commit-01")). Press(keys.Commits.AmendToCommit). Tap(func() { t.ExpectPopup().Confirmation(). @@ -37,11 +37,11 @@ var AmendFixupCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("fixup! commit 03"), - Contains("commit 03"), - Contains("commit 02"), - Contains("fixup! commit 01").IsSelected(), - Contains("commit 01"), + Contains("fixup! commit-03"), + Contains("commit-03"), + Contains("commit-02"), + Contains("fixup! commit-01").IsSelected(), + Contains("commit-01"), ) t.Views().Main(). 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 66be297f0..0ca3ffaaa 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 @@ -17,18 +17,18 @@ var AmendHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.Shell().CreateFile("fixup-file", "fixup content") @@ -51,10 +51,10 @@ var AmendHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.Views().Main(). 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 1216655e8..3e4df1404 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 @@ -17,21 +17,21 @@ var AmendNonHeadCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ) - for _, commit := range []string{"commit 01", "commit 03"} { + for _, commit := range []string{"commit-01", "commit-03"} { t.Views().Commits(). NavigateToLine(Contains(commit)). Press(keys.Commits.AmendToCommit) 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 3b7642bf6..86b2ed950 100644 --- a/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go +++ b/pkg/integration/tests/interactive_rebase/delete_update_ref_todo.go @@ -23,18 +23,18 @@ var DeleteUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( 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-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("pick").Contains("CI commit-03"), + Contains("pick").Contains("CI commit-02"), Contains("--- Commits ---"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-01"), ). NavigateToLine(Contains("update-ref")). Press(keys.Universal.Remove). @@ -46,25 +46,25 @@ var DeleteUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( 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("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("CI ○ commit 01"), + Contains("CI ○ commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Remove). Tap(func() { t.Common().ContinueRebase() }). Lines( - Contains("CI ○ commit 06"), - Contains("CI ○ commit 05"), - Contains("CI ○ commit 04"), - Contains("CI ○ commit 03"), // No star on this commit, so there's no branch head here - Contains("CI ○ commit 01"), + Contains("CI ○ commit-06"), + Contains("CI ○ commit-05"), + Contains("CI ○ commit-04"), + Contains("CI ○ commit-03"), // No star on this commit, so there's no branch head here + Contains("CI ○ commit-01"), ) t.Views().Branches(). 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 e5b43ee81..1b8674593 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 @@ -28,31 +28,31 @@ var DontShowBranchHeadsForTodoItems = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI commit 09"), - Contains("CI commit 08"), - Contains("CI commit 07"), - Contains("CI * commit 06"), - Contains("CI commit 05"), - Contains("CI commit 04"), - Contains("CI commit 03"), - Contains("CI * commit 02"), - Contains("CI commit 01"), + Contains("CI commit-09"), + Contains("CI commit-08"), + Contains("CI commit-07"), + Contains("CI * commit-06"), + Contains("CI commit-05"), + Contains("CI commit-04"), + Contains("CI commit-03"), + Contains("CI * commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 04")). + NavigateToLine(Contains("commit-04")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 09"), - Contains("pick").Contains("CI commit 08"), - Contains("pick").Contains("CI commit 07"), + 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("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("CI commit 04"), - Contains("CI commit 03"), - Contains("CI * commit 02"), // this star is fine though - Contains("CI commit 01"), + Contains("CI commit-04"), + Contains("CI commit-03"), + Contains("CI * commit-02"), // this star is fine though + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/drop_commit_in_copied_branch_with_update_ref.go b/pkg/integration/tests/interactive_rebase/drop_commit_in_copied_branch_with_update_ref.go index 81462296b..b66dcd2d7 100644 --- a/pkg/integration/tests/interactive_rebase/drop_commit_in_copied_branch_with_update_ref.go +++ b/pkg/integration/tests/interactive_rebase/drop_commit_in_copied_branch_with_update_ref.go @@ -25,11 +25,11 @@ var DropCommitInCopiedBranchWithUpdateRef = NewIntegrationTest(NewIntegrationTes t.Views().Commits(). Focus(). Lines( - Contains("CI * commit 03").IsSelected(), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI * commit-03").IsSelected(), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). @@ -38,8 +38,8 @@ var DropCommitInCopiedBranchWithUpdateRef = NewIntegrationTest(NewIntegrationTes Confirm() }). Lines( - Contains("CI commit 03"), // no start on this commit because branch1 is no longer pointing to it - Contains("CI commit 01"), + Contains("CI commit-03"), // no start on this commit because branch1 is no longer pointing to it + Contains("CI commit-01"), ) t.Views().Branches(). @@ -48,9 +48,9 @@ var DropCommitInCopiedBranchWithUpdateRef = NewIntegrationTest(NewIntegrationTes PressPrimaryAction() t.Views().Commits().Lines( - Contains("CI commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ) }, }) 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 ca481e986..9fb450afe 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 @@ -28,32 +28,32 @@ var DropTodoCommitWithUpdateRef = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI commit 07").IsSelected(), - Contains("CI commit 06"), - Contains("CI commit 05"), - Contains("CI * commit 04"), - Contains("CI commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-07").IsSelected(), + Contains("CI commit-06"), + Contains("CI commit-05"), + Contains("CI * commit-04"), + Contains("CI commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 07"), - Contains("pick").Contains("CI commit 06"), - Contains("pick").Contains("CI commit 05"), + 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("pick").Contains("CI commit-04"), + Contains("pick").Contains("CI commit-03"), Contains("--- Commits ---"), - Contains("CI commit 02").IsSelected(), - Contains("CI commit 01"), + Contains("CI commit-02").IsSelected(), + Contains("CI commit-01"), ). Tap(func() { - t.Views().Main().Content(Contains("commit 02")) + t.Views().Main().Content(Contains("commit-02")) }). - NavigateToLine(Contains("commit 06")). + NavigateToLine(Contains("commit-06")). Press(keys.Universal.Remove) t.Common().ContinueRebase() @@ -61,12 +61,12 @@ var DropTodoCommitWithUpdateRef = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). IsFocused(). Lines( - Contains("CI commit 07"), - Contains("CI commit 05"), - Contains("CI * commit 04"), - Contains("CI commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-07"), + Contains("CI commit-05"), + Contains("CI * commit-04"), + Contains("CI commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/drop_with_custom_comment_char.go b/pkg/integration/tests/interactive_rebase/drop_with_custom_comment_char.go index a6868e44f..734567d00 100644 --- a/pkg/integration/tests/interactive_rebase/drop_with_custom_comment_char.go +++ b/pkg/integration/tests/interactive_rebase/drop_with_custom_comment_char.go @@ -17,8 +17,8 @@ var DropWithCustomCommentChar = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Focus(). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Universal.Remove). Tap(func() { @@ -28,7 +28,7 @@ var DropWithCustomCommentChar = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 01").IsSelected(), + Contains("commit-01").IsSelected(), ) }, }) 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 2107c8a58..3045a5088 100644 --- a/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go +++ b/pkg/integration/tests/interactive_rebase/edit_and_auto_amend.go @@ -18,18 +18,18 @@ var EditAndAutoAmend = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.Shell().CreateFile("fixup-file", "fixup content") @@ -46,9 +46,9 @@ var EditAndAutoAmend = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.Views().Main(). diff --git a/pkg/integration/tests/interactive_rebase/edit_first_commit.go b/pkg/integration/tests/interactive_rebase/edit_first_commit.go index f09b7f27d..2ba657370 100644 --- a/pkg/integration/tests/interactive_rebase/edit_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/edit_first_commit.go @@ -18,23 +18,23 @@ var EditFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 02"), + Contains("commit-02"), Contains("--- Commits ---"), - Contains("commit 01").IsSelected(), + Contains("commit-01").IsSelected(), ). Tap(func() { t.Common().ContinueRebase() }). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ) }, }) 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 528afb7a4..7db9bb262 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 @@ -28,23 +28,23 @@ var EditLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI commit 05").IsSelected(), - Contains("CI commit 04"), - Contains("CI * commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-05").IsSelected(), + Contains("CI commit-04"), + Contains("CI * commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 03")). + NavigateToLine(Contains("commit-03")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 05"), - Contains("pick").Contains("CI commit 04"), + Contains("pick").Contains("CI commit-05"), + Contains("pick").Contains("CI commit-04"), Contains("update-ref").Contains("branch1"), Contains("--- Commits ---"), - Contains("CI * commit 03").IsSelected(), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI * commit-03").IsSelected(), + Contains("CI commit-02"), + Contains("CI commit-01"), ) t.Shell().CreateFile("fixup-file", "fixup content") @@ -66,11 +66,11 @@ var EditLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI commit 05"), - Contains("CI commit 04"), - Contains("CI * commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-05"), + Contains("CI commit-04"), + Contains("CI * commit-03"), + 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 6a21412de..00f77594e 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 @@ -18,17 +18,17 @@ var EditNonTodoCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), Contains("--- Commits ---"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit) t.ExpectToast(Contains("Disabled: When rebasing, this action only works on a selection of TODO commits.")) 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 832b99652..a57ab8acd 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 @@ -19,8 +19,8 @@ var EditRangeSelectDownToMergeOutsideRebase = NewIntegrationTest(NewIntegrationT t.Views().Commits(). Focus(). TopLines( - Contains("CI ○ commit 02").IsSelected(), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-02").IsSelected(), + Contains("CI ○ commit-01"), Contains("Merge branch 'second-change-branch' into first-change-branch"), ). Press(keys.Universal.RangeSelectDown). @@ -28,8 +28,8 @@ var EditRangeSelectDownToMergeOutsideRebase = NewIntegrationTest(NewIntegrationT Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("edit CI commit 02").IsSelected(), - Contains("edit CI commit 01").IsSelected(), + Contains("edit CI commit-02").IsSelected(), + Contains("edit CI commit-01").IsSelected(), Contains("--- Commits ---").IsSelected(), Contains(" CI ◎─╮ Merge branch 'second-change-branch' into first-change-branch").IsSelected(), Contains(" CI │ ○ * second-change-branch unrelated change"), diff --git a/pkg/integration/tests/interactive_rebase/fixup_first_commit.go b/pkg/integration/tests/interactive_rebase/fixup_first_commit.go index ff099d760..9dc90c5c7 100644 --- a/pkg/integration/tests/interactive_rebase/fixup_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/fixup_first_commit.go @@ -18,17 +18,17 @@ var FixupFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Commits.MarkCommitAsFixup). Tap(func() { t.ExpectToast(Equals("Disabled: There's no commit below to squash into")) }). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ) }, }) 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 73ace9105..de0bb28d0 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 @@ -25,19 +25,19 @@ var InteractiveRebaseOfCopiedBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI * commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI * commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( 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("pick").Contains("CI commit-03"), + Contains("pick").Contains("CI commit-02"), Contains("--- Commits ---"), - Contains("CI commit 01"), + 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 11596e758..5b341e61c 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 @@ -24,9 +24,9 @@ var InteractiveRebaseWithConflictForEditCommand = NewIntegrationTest(NewIntegrat Focus(). Lines( Contains("this will conflict").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), Contains("initial commit"), ) @@ -55,9 +55,9 @@ var InteractiveRebaseWithConflictForEditCommand = NewIntegrationTest(NewIntegrat Contains("--- Pending rebase todos ---"), Contains("edit").Contains("<-- CONFLICT --- this will conflict").IsSelected(), Contains("--- Commits ---"), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), Contains("master commit"), Contains("initial commit"), ) 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 cb96b8308..95dab3056 100644 --- a/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go +++ b/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go @@ -18,95 +18,95 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). TopLines( - Contains("commit 10").IsSelected(), + Contains("commit-10").IsSelected(), ). - NavigateToLine(Contains("commit 05")). + NavigateToLine(Contains("commit-05")). // Start a rebase Press(keys.Universal.Edit). TopLines( 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("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("commit 05").IsSelected(), - Contains("commit 04"), + Contains("commit-05").IsSelected(), + Contains("commit-04"), ). SelectPreviousItem(). // perform various actions on a range of commits Press(keys.Universal.RangeSelectUp). TopLines( 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("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("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.MarkCommitAsFixup). TopLines( 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("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("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.PickCommit). TopLines( 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("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("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Universal.Edit). TopLines( 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("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("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.SquashDown). TopLines( 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("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("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.MoveDownCommit). TopLines( 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("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("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) @@ -114,38 +114,38 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Press(keys.Commits.MoveUpCommit). TopLines( 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("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("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.MoveUpCommit). TopLines( 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("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("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.MoveUpCommit). TopLines( 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("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("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). Press(keys.Commits.MoveUpCommit). Tap(func() { @@ -153,29 +153,29 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ }). TopLines( 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("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("commit 05"), - Contains("commit 04"), + Contains("commit-05"), + Contains("commit-04"), ). // Verify we can't perform an action on a range that includes both // TODO and non-TODO commits - NavigateToLine(Contains("commit 08")). + NavigateToLine(Contains("commit-08")). Press(keys.Universal.RangeSelectDown). TopLines( 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("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("commit 05").IsSelected(), - Contains("commit 04"), + Contains("commit-05").IsSelected(), + Contains("commit-04"), ). Press(keys.Commits.MarkCommitAsFixup). Tap(func() { @@ -183,28 +183,28 @@ var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ }). TopLines( 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("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("commit 05").IsSelected(), - Contains("commit 04"), + Contains("commit-05").IsSelected(), + Contains("commit-04"), ). // continue the rebase Tap(func() { t.Common().ContinueRebase() }). TopLines( - Contains("commit 10"), - Contains("commit 09"), - Contains("commit 08"), - Contains("commit 05"), + Contains("commit-10"), + Contains("commit-09"), + Contains("commit-08"), + Contains("commit-05"), // selected indexes are retained, though we may want to clear it // in future (not sure what the best behaviour is right now) - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/move.go b/pkg/integration/tests/interactive_rebase/move.go index 3f1f23755..4f37f2c19 100644 --- a/pkg/integration/tests/interactive_rebase/move.go +++ b/pkg/integration/tests/interactive_rebase/move.go @@ -17,31 +17,31 @@ var Move = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 04").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( - Contains("commit 03"), - Contains("commit 04").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-04").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 04").IsSelected(), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-04").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), - Contains("commit 04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + Contains("commit-04").IsSelected(), ). // assert nothing happens upon trying to move beyond the last commit Press(keys.Commits.MoveDownCommit). @@ -49,31 +49,31 @@ var Move = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), - Contains("commit 04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + Contains("commit-04").IsSelected(), ). Press(keys.Commits.MoveUpCommit). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 04").IsSelected(), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-04").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.MoveUpCommit). Lines( - Contains("commit 03"), - Contains("commit 04").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-04").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ). Press(keys.Commits.MoveUpCommit). Lines( - Contains("commit 04").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). // assert nothing happens upon trying to move beyond the first commit Press(keys.Commits.MoveUpCommit). @@ -81,10 +81,10 @@ var Move = NewIntegrationTest(NewIntegrationTestArgs{ t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Lines( - Contains("commit 04").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/move_across_branch_boundary_outside_rebase.go b/pkg/integration/tests/interactive_rebase/move_across_branch_boundary_outside_rebase.go index 0f341d5b5..36f1f5312 100644 --- a/pkg/integration/tests/interactive_rebase/move_across_branch_boundary_outside_rebase.go +++ b/pkg/integration/tests/interactive_rebase/move_across_branch_boundary_outside_rebase.go @@ -28,20 +28,20 @@ var MoveAcrossBranchBoundaryOutsideRebase = NewIntegrationTest(NewIntegrationTes t.Views().Commits(). Focus(). Lines( - Contains("CI commit 05").IsSelected(), - Contains("CI commit 04"), - Contains("CI * commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-05").IsSelected(), + Contains("CI commit-04"), + Contains("CI * commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 04")). + NavigateToLine(Contains("commit-04")). Press(keys.Commits.MoveDownCommit). Lines( - Contains("CI commit 05"), - Contains("CI * commit 03"), - Contains("CI commit 04").IsSelected(), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-05"), + Contains("CI * commit-03"), + Contains("CI commit-04").IsSelected(), + Contains("CI commit-02"), + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/move_in_rebase.go b/pkg/integration/tests/interactive_rebase/move_in_rebase.go index 1cc9dd785..9138839b6 100644 --- a/pkg/integration/tests/interactive_rebase/move_in_rebase.go +++ b/pkg/integration/tests/interactive_rebase/move_in_rebase.go @@ -17,39 +17,39 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 04").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 04"), - Contains("commit 03"), - Contains("commit 02"), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), Contains("--- Commits ---"), - Contains("commit 01").IsSelected(), + Contains("commit-01").IsSelected(), ). SelectPreviousItem(). Press(keys.Commits.MoveUpCommit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 04"), - Contains("commit 02").IsSelected(), - Contains("commit 03"), + Contains("commit-04"), + Contains("commit-02").IsSelected(), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 01"), + Contains("commit-01"), ). Press(keys.Commits.MoveUpCommit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 02").IsSelected(), - Contains("commit 04"), - Contains("commit 03"), + Contains("commit-02").IsSelected(), + Contains("commit-04"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 01"), + Contains("commit-01"), ). // assert we can't move past the top Press(keys.Commits.MoveUpCommit). @@ -58,29 +58,29 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 02").IsSelected(), - Contains("commit 04"), - Contains("commit 03"), + Contains("commit-02").IsSelected(), + Contains("commit-04"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 01"), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 04"), - Contains("commit 02").IsSelected(), - Contains("commit 03"), + Contains("commit-04"), + Contains("commit-02").IsSelected(), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 01"), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 04"), - Contains("commit 03"), - Contains("commit 02").IsSelected(), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02").IsSelected(), Contains("--- Commits ---"), - Contains("commit 01"), + Contains("commit-01"), ). // assert we can't move past the bottom Press(keys.Commits.MoveDownCommit). @@ -89,30 +89,30 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 04"), - Contains("commit 03"), - Contains("commit 02").IsSelected(), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02").IsSelected(), Contains("--- Commits ---"), - Contains("commit 01"), + 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("commit 04"), - Contains("commit 02").IsSelected(), - Contains("commit 03"), + Contains("commit-04"), + Contains("commit-02").IsSelected(), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 01"), + Contains("commit-01"), ). Tap(func() { t.Common().ContinueRebase() }). Lines( - Contains("commit 04"), - Contains("commit 02").IsSelected(), - Contains("commit 03"), - Contains("commit 01"), + Contains("commit-04"), + Contains("commit-02").IsSelected(), + Contains("commit-03"), + Contains("commit-01"), ) }, }) 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 619efe7fb..c730fd995 100644 --- a/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go +++ b/pkg/integration/tests/interactive_rebase/move_update_ref_todo.go @@ -23,43 +23,43 @@ var MoveUpdateRefTodo = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Universal.Edit). Lines( 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-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("pick").Contains("CI commit-03"), + Contains("pick").Contains("CI commit-02"), Contains("--- Commits ---"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-01"), ). NavigateToLine(Contains("update-ref")). Press(keys.Commits.MoveUpCommit). Press(keys.Commits.MoveUpCommit). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 06"), + 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("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("CI ○ commit 01"), + Contains("CI ○ commit-01"), ). Tap(func() { t.Common().ContinueRebase() }). Lines( - Contains("CI ○ commit 06"), - Contains("CI ○ * commit 05"), - Contains("CI ○ commit 04"), - Contains("CI ○ commit 03"), - Contains("CI ○ commit 02"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-06"), + Contains("CI ○ * commit-05"), + Contains("CI ○ commit-04"), + Contains("CI ○ commit-03"), + Contains("CI ○ commit-02"), + Contains("CI ○ commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/move_with_custom_comment_char.go b/pkg/integration/tests/interactive_rebase/move_with_custom_comment_char.go index eefbcea33..db5177cff 100644 --- a/pkg/integration/tests/interactive_rebase/move_with_custom_comment_char.go +++ b/pkg/integration/tests/interactive_rebase/move_with_custom_comment_char.go @@ -17,18 +17,18 @@ var MoveWithCustomCommentChar = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits().Focus(). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). Lines( - Contains("commit 01"), - Contains("commit 02").IsSelected(), + Contains("commit-01"), + Contains("commit-02").IsSelected(), ). Press(keys.Commits.MoveUpCommit). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/outside_rebase_range_select.go b/pkg/integration/tests/interactive_rebase/outside_rebase_range_select.go index 4aeb28b28..fe9d2b762 100644 --- a/pkg/integration/tests/interactive_rebase/outside_rebase_range_select.go +++ b/pkg/integration/tests/interactive_rebase/outside_rebase_range_select.go @@ -18,13 +18,13 @@ var OutsideRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). TopLines( - Contains("commit 10").IsSelected(), + Contains("commit-10").IsSelected(), ). Press(keys.Universal.RangeSelectDown). TopLines( - Contains("commit 10").IsSelected(), - Contains("commit 09").IsSelected(), - Contains("commit 08"), + Contains("commit-10").IsSelected(), + Contains("commit-09").IsSelected(), + Contains("commit-08"), ). // Drop commits Press(keys.Universal.Remove). @@ -35,14 +35,14 @@ var OutsideRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). TopLines( - Contains("commit 08").IsSelected(), - Contains("commit 07"), + Contains("commit-08").IsSelected(), + Contains("commit-07"), ). Press(keys.Universal.RangeSelectDown). TopLines( - Contains("commit 08").IsSelected(), - Contains("commit 07").IsSelected(), - Contains("commit 06"), + Contains("commit-08").IsSelected(), + Contains("commit-07").IsSelected(), + Contains("commit-06"), ). // Squash commits Press(keys.Commits.SquashDown). @@ -53,27 +53,27 @@ var OutsideRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). TopLines( - Contains("commit 06").IsSelected(), - Contains("commit 05"), - Contains("commit 04"), + Contains("commit-06").IsSelected(), + Contains("commit-05"), + Contains("commit-04"), ). // Verify commit messages are concatenated Tap(func() { t.Views().Main(). ContainsLines( - Contains("commit 06"), + Contains("commit-06"), AnyString(), - Contains("commit 07"), + Contains("commit-07"), AnyString(), - Contains("commit 08"), + Contains("commit-08"), ) }). // Fixup commits Press(keys.Universal.RangeSelectDown). TopLines( - Contains("commit 06").IsSelected(), - Contains("commit 05").IsSelected(), - Contains("commit 04"), + Contains("commit-06").IsSelected(), + Contains("commit-05").IsSelected(), + Contains("commit-04"), ). Press(keys.Commits.MarkCommitAsFixup). Tap(func() { @@ -82,73 +82,73 @@ var OutsideRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). TopLines( - Contains("commit 04").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), ). // Verify commit messages are dropped Tap(func() { t.Views().Main(). Content( - Contains("commit 04"). - DoesNotContain("commit 06"). - DoesNotContain("commit 05"), + Contains("commit-04"). + DoesNotContain("commit-06"). + DoesNotContain("commit-05"), ) }). Press(keys.Universal.RangeSelectDown). TopLines( - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), - Contains("commit 02"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), + Contains("commit-02"), ). // Move commits Press(keys.Commits.MoveDownCommit). TopLines( - Contains("commit 02"), - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.MoveDownCommit). TopLines( - Contains("commit 02"), - Contains("commit 01"), - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), ). Press(keys.Commits.MoveDownCommit). TopLines( - Contains("commit 02"), - Contains("commit 01"), - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), ). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) }). Press(keys.Commits.MoveUpCommit). TopLines( - Contains("commit 02"), - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.MoveUpCommit). TopLines( - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ). Press(keys.Commits.MoveUpCommit). Tap(func() { t.ExpectToast(Contains("Disabled: Cannot move any further")) }). TopLines( - Contains("commit 04").IsSelected(), - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ) }, }) 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 55be5ea4a..4d045c8fc 100644 --- a/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go +++ b/pkg/integration/tests/interactive_rebase/quick_start_keep_selection.go @@ -28,27 +28,27 @@ var QuickStartKeepSelection = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI commit 07").IsSelected(), - Contains("CI commit 06"), - Contains("CI commit 05"), - Contains("CI * commit 04"), - Contains("CI commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-07").IsSelected(), + Contains("CI commit-06"), + Contains("CI commit-05"), + Contains("CI * commit-04"), + Contains("CI commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Commits.StartInteractiveRebase). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 07"), - Contains("pick").Contains("CI commit 06"), - Contains("pick").Contains("CI commit 05"), + Contains("pick").Contains("CI commit-07"), + Contains("pick").Contains("CI commit-06"), + Contains("pick").Contains("CI commit-05"), Contains("update-ref").Contains("branch1"), - Contains("pick").Contains("CI commit 04"), - Contains("pick").Contains("CI commit 03"), - Contains("CI commit 02").IsSelected(), + Contains("pick").Contains("CI commit-04"), + Contains("pick").Contains("CI commit-03"), + Contains("CI commit-02").IsSelected(), Contains("--- Commits ---"), - Contains("CI commit 01"), + 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 8ff8f1065..4d25a883c 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 @@ -29,31 +29,31 @@ var QuickStartKeepSelectionRange = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). Focus(). - NavigateToLine(Contains("commit 04")). + NavigateToLine(Contains("commit-04")). Press(keys.Universal.RangeSelectDown). Press(keys.Universal.RangeSelectDown). Lines( - Contains("CI commit 07"), - Contains("CI commit 06"), - Contains("CI * commit 05"), - Contains("CI commit 04").IsSelected(), - Contains("CI * commit 03").IsSelected(), - Contains("CI commit 02").IsSelected(), - Contains("CI commit 01"), + Contains("CI commit-07"), + Contains("CI commit-06"), + Contains("CI * commit-05"), + Contains("CI commit-04").IsSelected(), + Contains("CI * commit-03").IsSelected(), + Contains("CI commit-02").IsSelected(), + Contains("CI commit-01"), ). Press(keys.Commits.StartInteractiveRebase). Lines( Contains("--- Pending rebase todos ---"), - Contains("CI commit 07"), - Contains("CI commit 06"), + Contains("CI commit-07"), + Contains("CI commit-06"), Contains("update-ref").Contains("branch2"), - Contains("CI commit 05"), - Contains("CI commit 04").IsSelected(), + Contains("CI commit-05"), + Contains("CI commit-04").IsSelected(), Contains("update-ref").Contains("branch1").IsSelected(), - Contains("CI commit 03").IsSelected(), - Contains("CI commit 02").IsSelected(), + Contains("CI commit-03").IsSelected(), + Contains("CI commit-02").IsSelected(), Contains("--- Commits ---"), - Contains("CI commit 01"), + Contains("CI commit-01"), ) }, }) 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 16a2b8c25..44a39200b 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 @@ -20,22 +20,22 @@ var RevertDuringRebaseWhenStoppedOnEdit = NewIntegrationTest(NewIntegrationTestA t.Views().Commits(). Focus(). Lines( - Contains("commit 04").IsSelected(), - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), Contains("master commit 2"), Contains("master commit 1"), ). - NavigateToLine(Contains("commit 03")). + NavigateToLine(Contains("commit-03")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit 04"), + Contains("pick").Contains("commit-04"), Contains("--- Commits ---"), - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), Contains("master commit 2"), Contains("master commit 1"), ). @@ -50,13 +50,13 @@ var RevertDuringRebaseWhenStoppedOnEdit = NewIntegrationTest(NewIntegrationTestA }). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("commit 04"), + Contains("pick").Contains("commit-04"), Contains("--- Commits ---"), - Contains(`Revert "commit 01"`), - Contains(`Revert "commit 02"`), - Contains("commit 03"), - Contains("commit 02").IsSelected(), - Contains("commit 01").IsSelected(), + Contains(`Revert "commit-01"`), + Contains(`Revert "commit-02"`), + Contains("commit-03"), + Contains("commit-02").IsSelected(), + Contains("commit-01").IsSelected(), Contains("master commit 2"), Contains("master commit 1"), ) 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 df6486772..b8cf20ae8 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 @@ -20,11 +20,11 @@ var RewordCommitWithEditorAndFail = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Commits.RenameCommitWithEditor). Tap(func() { t.ExpectPopup().Confirmation(). @@ -34,10 +34,10 @@ var RewordCommitWithEditorAndFail = NewIntegrationTest(NewIntegrationTestArgs{ }). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.ExpectPopup().Alert(). diff --git a/pkg/integration/tests/interactive_rebase/reword_first_commit.go b/pkg/integration/tests/interactive_rebase/reword_first_commit.go index cb9afc3c4..b61ceb93a 100644 --- a/pkg/integration/tests/interactive_rebase/reword_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/reword_first_commit.go @@ -21,21 +21,21 @@ var RewordFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Commits.RenameCommit). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Reword commit")). - InitialText(Equals("commit 01")). + InitialText(Equals("commit-01")). Clear(). Type("renamed 01"). Confirm() }). Lines( - Contains("commit 02"), + Contains("commit-02"), Contains("renamed 01"), ) }, diff --git a/pkg/integration/tests/interactive_rebase/reword_last_commit.go b/pkg/integration/tests/interactive_rebase/reword_last_commit.go index 5d3038feb..80a57cc32 100644 --- a/pkg/integration/tests/interactive_rebase/reword_last_commit.go +++ b/pkg/integration/tests/interactive_rebase/reword_last_commit.go @@ -18,21 +18,21 @@ var RewordLastCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.RenameCommit). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Reword commit")). - InitialText(Equals("commit 02")). + InitialText(Equals("commit-02")). Clear(). Type("renamed 02"). Confirm() }). Lines( Contains("renamed 02"), - Contains("commit 01"), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/reword_last_commit_of_stacked_branch.go b/pkg/integration/tests/interactive_rebase/reword_last_commit_of_stacked_branch.go index e9cdc3a1a..b353e69cc 100644 --- a/pkg/integration/tests/interactive_rebase/reword_last_commit_of_stacked_branch.go +++ b/pkg/integration/tests/interactive_rebase/reword_last_commit_of_stacked_branch.go @@ -28,28 +28,28 @@ var RewordLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("CI commit 05").IsSelected(), - Contains("CI commit 04"), - Contains("CI * commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-05").IsSelected(), + Contains("CI commit-04"), + Contains("CI * commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 03")). + NavigateToLine(Contains("commit-03")). Press(keys.Commits.RenameCommit). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Reword commit")). - InitialText(Equals("commit 03")). + InitialText(Equals("commit-03")). Clear(). Type("renamed 03"). Confirm() }). Lines( - Contains("CI commit 05"), - Contains("CI commit 04"), + Contains("CI commit-05"), + Contains("CI commit-04"), Contains("CI * renamed 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-02"), + Contains("CI 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 92aaf1a43..bd58ab083 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 @@ -18,34 +18,34 @@ var RewordYouAreHereCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.RenameCommit). Tap(func() { t.ExpectPopup().CommitMessagePanel(). Title(Equals("Reword commit")). - InitialText(Equals("commit 02")). + InitialText(Equals("commit-02")). Clear(). Type("renamed 02"). Confirm() }). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), Contains("renamed 02").IsSelected(), - Contains("commit 01"), + 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 b927684fe..5406ed02b 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 @@ -20,18 +20,18 @@ var RewordYouAreHereCommitWithEditor = NewIntegrationTest(NewIntegrationTestArgs t.Views().Commits(). Focus(). Lines( - Contains("commit 03").IsSelected(), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03").IsSelected(), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Universal.Edit). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.RenameCommitWithEditor). Tap(func() { @@ -42,10 +42,10 @@ var RewordYouAreHereCommitWithEditor = NewIntegrationTest(NewIntegrationTestArgs }). Lines( Contains("--- Pending rebase todos ---"), - Contains("commit 03"), + Contains("commit-03"), Contains("--- Commits ---"), Contains("renamed 02").IsSelected(), - Contains("commit 01"), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/show_exec_todos.go b/pkg/integration/tests/interactive_rebase/show_exec_todos.go index 948bfb7d8..fad0e44e8 100644 --- a/pkg/integration/tests/interactive_rebase/show_exec_todos.go +++ b/pkg/integration/tests/interactive_rebase/show_exec_todos.go @@ -33,10 +33,10 @@ var ShowExecTodos = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("--- Pending rebase todos ---"), Contains("exec").Contains("false"), - Contains("pick").Contains("CI commit 03"), + Contains("pick").Contains("CI commit-03"), Contains("--- Commits ---"), - Contains("CI ○ commit 02"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-02"), + Contains("CI ○ commit-01"), ). Tap(func() { t.Common().ContinueRebase() @@ -45,17 +45,17 @@ var ShowExecTodos = NewIntegrationTest(NewIntegrationTestArgs{ Lines( Contains("--- Pending rebase todos ---"), Contains("--- Commits ---"), - Contains("CI ○ commit 03"), - Contains("CI ○ commit 02"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-03"), + Contains("CI ○ commit-02"), + Contains("CI ○ commit-01"), ). Tap(func() { t.Common().ContinueRebase() }). Lines( - Contains("CI ○ commit 03"), - Contains("CI ○ commit 02"), - Contains("CI ○ commit 01"), + Contains("CI ○ commit-03"), + Contains("CI ○ commit-02"), + Contains("CI ○ commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/squash_down_first_commit.go b/pkg/integration/tests/interactive_rebase/squash_down_first_commit.go index 65d6bfaa7..97f3f3567 100644 --- a/pkg/integration/tests/interactive_rebase/squash_down_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/squash_down_first_commit.go @@ -18,17 +18,17 @@ var SquashDownFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Commits.SquashDown). Tap(func() { t.ExpectToast(Equals("Disabled: There's no commit below to squash into")) }). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ) }, }) diff --git a/pkg/integration/tests/interactive_rebase/squash_down_second_commit.go b/pkg/integration/tests/interactive_rebase/squash_down_second_commit.go index 6ba313f7a..ba5f33705 100644 --- a/pkg/integration/tests/interactive_rebase/squash_down_second_commit.go +++ b/pkg/integration/tests/interactive_rebase/squash_down_second_commit.go @@ -18,11 +18,11 @@ var SquashDownSecondCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Commits.SquashDown). Tap(func() { t.ExpectPopup().Confirmation(). @@ -31,12 +31,12 @@ var SquashDownSecondCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 03"), - Contains("commit 01").IsSelected(), + Contains("commit-03"), + Contains("commit-01").IsSelected(), ) t.Views().Main(). - Content(Contains(" commit 01\n \n commit 02")). + Content(Contains(" commit-01\n \n commit-02")). Content(Contains("+file01 content")). Content(Contains("+file02 content")) }, diff --git a/pkg/integration/tests/interactive_rebase/squash_fixups_above.go b/pkg/integration/tests/interactive_rebase/squash_fixups_above.go index 467a66154..fdbcf7817 100644 --- a/pkg/integration/tests/interactive_rebase/squash_fixups_above.go +++ b/pkg/integration/tests/interactive_rebase/squash_fixups_above.go @@ -19,11 +19,11 @@ var SquashFixupsAbove = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 03"), - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 02")). + NavigateToLine(Contains("commit-02")). Press(keys.Commits.CreateFixupCommit). Tap(func() { t.ExpectPopup().Menu(). @@ -32,10 +32,10 @@ var SquashFixupsAbove = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("fixup! commit 02"), - Contains("commit 03"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("fixup! commit-02"), + Contains("commit-03"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Press(keys.Commits.SquashAboveCommits). Tap(func() { @@ -45,9 +45,9 @@ var SquashFixupsAbove = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 03"), - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-03"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ) t.Views().Main(). diff --git a/pkg/integration/tests/interactive_rebase/squash_fixups_above_first_commit.go b/pkg/integration/tests/interactive_rebase/squash_fixups_above_first_commit.go index 2d71093ba..4786dfd72 100644 --- a/pkg/integration/tests/interactive_rebase/squash_fixups_above_first_commit.go +++ b/pkg/integration/tests/interactive_rebase/squash_fixups_above_first_commit.go @@ -19,10 +19,10 @@ var SquashFixupsAboveFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). Lines( - Contains("commit 02"), - Contains("commit 01"), + Contains("commit-02"), + Contains("commit-01"), ). - NavigateToLine(Contains("commit 01")). + NavigateToLine(Contains("commit-01")). Press(keys.Commits.CreateFixupCommit). Tap(func() { t.ExpectPopup().Menu(). @@ -30,7 +30,7 @@ var SquashFixupsAboveFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ Select(Contains("fixup! commit")). Confirm() }). - NavigateToLine(Contains("commit 01").DoesNotContain("fixup!")). + NavigateToLine(Contains("commit-01").DoesNotContain("fixup!")). Press(keys.Commits.SquashAboveCommits). Tap(func() { t.ExpectPopup().Menu(). @@ -39,8 +39,8 @@ var SquashFixupsAboveFirstCommit = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 02"), - Contains("commit 01").IsSelected(), + Contains("commit-02"), + Contains("commit-01").IsSelected(), ) t.Views().Main(). diff --git a/pkg/integration/tests/interactive_rebase/squash_fixups_in_current_branch.go b/pkg/integration/tests/interactive_rebase/squash_fixups_in_current_branch.go index c6721d829..7e9caeebf 100644 --- a/pkg/integration/tests/interactive_rebase/squash_fixups_in_current_branch.go +++ b/pkg/integration/tests/interactive_rebase/squash_fixups_in_current_branch.go @@ -22,7 +22,7 @@ var SquashFixupsInCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{ Commit("fixup! master commit"). CreateNCommits(2). CreateFileAndAdd("fixup-file", "fixup content"). - Commit("fixup! commit 01") + Commit("fixup! commit-01") }, Run: func(t *TestDriver, keys config.KeybindingConfig) { t.Views().Commits(). @@ -30,9 +30,9 @@ var SquashFixupsInCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{ SelectNextItem(). SelectNextItem(). Lines( - Contains("fixup! commit 01"), - Contains("commit 02"), - Contains("commit 01").IsSelected(), + Contains("fixup! commit-01"), + Contains("commit-02"), + Contains("commit-01").IsSelected(), Contains("fixup! master commit"), Contains("master commit"), ). @@ -44,8 +44,8 @@ var SquashFixupsInCurrentBranch = NewIntegrationTest(NewIntegrationTestArgs{ Confirm() }). Lines( - Contains("commit 02"), - Contains("commit 01").IsSelected(), + Contains("commit-02"), + Contains("commit-01").IsSelected(), Contains("fixup! master commit"), Contains("master commit"), ) 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 f52e80703..3746633c7 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 @@ -29,11 +29,11 @@ var ViewFilesOfTodoEntries = NewIntegrationTest(NewIntegrationTestArgs{ Press(keys.Commits.StartInteractiveRebase). Lines( Contains("--- Pending rebase todos ---"), - Contains("pick").Contains("CI commit 03").IsSelected(), + Contains("pick").Contains("CI commit-03").IsSelected(), Contains("update-ref").Contains("branch1"), - Contains("pick").Contains("CI commit 02"), + Contains("pick").Contains("CI commit-02"), Contains("--- Commits ---"), - Contains("CI commit 01"), + Contains("CI commit-01"), ). Press(keys.Universal.GoInto) diff --git a/pkg/integration/tests/patch_building/move_to_new_commit_in_last_commit_of_stacked_branch.go b/pkg/integration/tests/patch_building/move_to_new_commit_in_last_commit_of_stacked_branch.go index c9fd80d0e..67170b35a 100644 --- a/pkg/integration/tests/patch_building/move_to_new_commit_in_last_commit_of_stacked_branch.go +++ b/pkg/integration/tests/patch_building/move_to_new_commit_in_last_commit_of_stacked_branch.go @@ -15,12 +15,12 @@ var MoveToNewCommitInLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrati }, SetupRepo: func(shell *Shell) { shell. - EmptyCommit("commit 01"). + EmptyCommit("commit-01"). NewBranch("branch1"). - EmptyCommit("commit 02"). + EmptyCommit("commit-02"). CreateFileAndAdd("file1", "file1 content"). CreateFileAndAdd("file2", "file2 content"). - Commit("commit 03"). + Commit("commit-03"). NewBranch("branch2"). CreateNCommitsStartingAt(2, 4) @@ -30,13 +30,13 @@ var MoveToNewCommitInLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrati t.Views().Commits(). Focus(). Lines( - Contains("CI commit 05").IsSelected(), - Contains("CI commit 04"), - Contains("CI * commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-05").IsSelected(), + Contains("CI commit-04"), + Contains("CI * commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ). - NavigateToLine(Contains("commit 03")). + NavigateToLine(Contains("commit-03")). PressEnter() t.Views().CommitFiles(). @@ -61,12 +61,12 @@ var MoveToNewCommitInLastCommitOfStackedBranch = NewIntegrationTest(NewIntegrati t.Views().Commits(). IsFocused(). Lines( - Contains("CI commit 05"), - Contains("CI commit 04"), + Contains("CI commit-05"), + Contains("CI commit-04"), Contains("CI * new commit").IsSelected(), - Contains("CI commit 03"), - Contains("CI commit 02"), - Contains("CI commit 01"), + Contains("CI commit-03"), + Contains("CI commit-02"), + Contains("CI commit-01"), ) }, }) diff --git a/pkg/integration/tests/ui/accordion.go b/pkg/integration/tests/ui/accordion.go index 1e2ed1480..0a18d2881 100644 --- a/pkg/integration/tests/ui/accordion.go +++ b/pkg/integration/tests/ui/accordion.go @@ -11,9 +11,9 @@ import ( // ╶─Files - Submodules──────0 of 0─╴│commit 6e56dd04b70e548976f7f2928c4d9c359574e2bc ▲ // ╶─Local branches - Remotes1 of 1─╴│Author: CI █ // ┌─Commits - Reflog───────────────┐│Date: Wed Jul 19 22:00:03 2023 +1000 │ -// │7fe02805 CI commit 12 ▲│ ▼ -// │6e56dd04 CI commit 11 █└────────────────────────────────────────────────────────────────┘ -// │a35c687d CI commit 10 ▼┌─Command log────────────────────────────────────────────────────┐ +// │7fe02805 CI commit-12 ▲│ ▼ +// │6e56dd04 CI commit-11 █└────────────────────────────────────────────────────────────────┘ +// │a35c687d CI commit-10 ▼┌─Command log────────────────────────────────────────────────────┐ // └───────────────────────10 of 20─┘│Random tip: To filter commits by path, press '' │ // ╶─Stash───────────────────0 of 0─╴└────────────────────────────────────────────────────────────────┘ // /: Scroll, : Cancel, q: Quit, ?: Keybindings, 1-Donate Ask Question unversioned @@ -32,18 +32,18 @@ var Accordion = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). VisibleLines( - Contains("commit 20").IsSelected(), - Contains("commit 19"), - Contains("commit 18"), + Contains("commit-20").IsSelected(), + Contains("commit-19"), + Contains("commit-18"), ). // go past commit 11, then come back, so that it ends up in the centre of the viewport - NavigateToLine(Contains("commit 11")). - NavigateToLine(Contains("commit 10")). - NavigateToLine(Contains("commit 11")). + NavigateToLine(Contains("commit-11")). + NavigateToLine(Contains("commit-10")). + NavigateToLine(Contains("commit-11")). VisibleLines( - Contains("commit 12"), - Contains("commit 11").IsSelected(), - Contains("commit 10"), + Contains("commit-12"), + Contains("commit-11").IsSelected(), + Contains("commit-10"), ) t.Views().Files(). @@ -53,9 +53,9 @@ var Accordion = NewIntegrationTest(NewIntegrationTestArgs{ t.Views().Commits(). Focus(). VisibleLines( - Contains("commit 12"), - Contains("commit 11").IsSelected(), - Contains("commit 10"), + Contains("commit-12"), + Contains("commit-11").IsSelected(), + Contains("commit-10"), ) }, }) diff --git a/pkg/integration/tests/ui/mode_specific_keybinding_suggestions.go b/pkg/integration/tests/ui/mode_specific_keybinding_suggestions.go index 73f09da3c..550cac2b5 100644 --- a/pkg/integration/tests/ui/mode_specific_keybinding_suggestions.go +++ b/pkg/integration/tests/ui/mode_specific_keybinding_suggestions.go @@ -27,8 +27,8 @@ var ModeSpecificKeybindingSuggestions = NewIntegrationTest(NewIntegrationTestArg t.Views().Commits(). Focus(). Lines( - Contains("commit 02").IsSelected(), - Contains("commit 01"), + Contains("commit-02").IsSelected(), + Contains("commit-01"), ). Tap(func() { // These suggestions are mode-specific so are not shown by default From c21ce617298f11e0ac4733a43d26917197612518 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 14:28:29 +0200 Subject: [PATCH 265/384] Synchronize ViewBufferManager.Close with a starting task Close read and called stopCurrentTask with no lock, while NewTask's goroutine assigns it (and constructs the sync.Once it closes over) under waitingMutex. On shutdown Close runs while a render task spawned by the last layout is still starting, so the two raced on the field and the once (three DATA RACE blocks under -race, e.g. cherry_pick). Read stopCurrentTask once under waitingMutex and call the captured value instead of re-reading the field, which establishes the happens-before the once needs. This can't deadlock: no task holds waitingMutex across a blocking UI-thread hop, so Close can always take it, and a task wedged in such a hop is still bounded by the existing 3s timeout. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index bc08013ed..9da12d40b 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -393,14 +393,21 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix // Close closes the task manager, killing whatever task may currently be running func (self *ViewBufferManager) Close() { - if self.stopCurrentTask == nil { + // stopCurrentTask is written by NewTask's goroutine under waitingMutex (and + // so is the sync.Once it closes over), so read it under the lock and call + // the captured value; a task starting on shutdown must not race us here. + self.waitingMutex.Lock() + stopCurrentTask := self.stopCurrentTask + self.waitingMutex.Unlock() + + if stopCurrentTask == nil { return } c := make(chan struct{}) go utils.Safe(func() { - self.stopCurrentTask() + stopCurrentTask() c <- struct{}{} }) From d4a606c68503cc7f8e0e9e5c4de0c3b344ca59b8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 14:19:29 +0200 Subject: [PATCH 266/384] Demonstrate that list index conversions depend on rendering ModelIndexToViewIndex and ViewIndexToModelIndex read conversion arrays that only renderLines populates. So converting an index before the list has been rendered ignores the non-model items (e.g. section headers) and returns a wrong result; the same staleness makes a conversion after the model has grown index a too-short array and panic (seen in cherry_pick under -race). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/context/list_renderer_test.go | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/pkg/gui/context/list_renderer_test.go b/pkg/gui/context/list_renderer_test.go index 08af680ff..24e4aa822 100644 --- a/pkg/gui/context/list_renderer_test.go +++ b/pkg/gui/context/list_renderer_test.go @@ -267,3 +267,31 @@ func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) { }) } } + +// The index conversions must not depend on the list having been rendered +// first. It used to be renderLines that populated the conversion arrays, so +// converting an index before the first render silently ignored the non-model +// items (and converting after the model changed used a stale snapshot). +func TestListRenderer_IndexConversionsAreRenderIndependent(t *testing.T) { + modelInts := lo.Map(lo.Range(3), func(i int, _ int) myint { return myint(i) }) + self := &ListRenderer{ + list: NewListViewModel(func() []myint { return modelInts }), + getDisplayStrings: func(startIdx int, endIdx int) [][]string { + return lo.Map(modelInts[startIdx:endIdx], + func(i myint, _ int) []string { return []string{fmt.Sprint(i)} }) + }, + // A section header sits at model index 1, so model item 1 is pushed down + // to view index 2, and view index 2 maps back to model item 1. + getNonModelItems: func() []*NonModelItem { + return []*NonModelItem{{Index: 1, Content: "--- header ---"}} + }, + } + + // Deliberately convert without rendering first. + /* EXPECTED: + assert.Equal(t, 2, self.ModelIndexToViewIndex(1)) + assert.Equal(t, 1, self.ViewIndexToModelIndex(2)) + ACTUAL: */ + assert.Equal(t, 1, self.ModelIndexToViewIndex(1)) + assert.Equal(t, 2, self.ViewIndexToModelIndex(2)) +} From 4e907c6b3e00d22fa954b10a9fd97bfb1f601c7b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 14:25:25 +0200 Subject: [PATCH 267/384] Compute list index conversions independently of rendering The model<->view index conversions were derived from arrays that only renderLines populated. That made them depend on the list having been rendered (so a conversion before the first render ignored the non-model items), and it made them go stale whenever the model changed after a render: converting an index then returned a wrong result, and once the model had grown past the last rendered length the conversion indexed a too-short array and panicked (seen in cherry_pick under -race). The conversion is a pure function of the current list length and the current non-model items, and needs none of the rendered display strings. Compute it directly and drop the cached arrays, so the result is always consistent with the current model and no longer depends on rendering. searchModelCommits converts every commit's index, and building the non-model items can be O(len) mid-rebase, so it would now be quadratic; snapshot the non-model items once via modelToViewIndexConverter instead of rebuilding them per index. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/context/list_renderer.go | 96 ++++++++++++++---------- pkg/gui/context/list_renderer_test.go | 7 -- pkg/gui/context/local_commits_context.go | 2 +- pkg/gui/context/sub_commits_context.go | 2 +- 4 files changed, 60 insertions(+), 47 deletions(-) diff --git a/pkg/gui/context/list_renderer.go b/pkg/gui/context/list_renderer.go index e863045e0..b8d036778 100644 --- a/pkg/gui/context/list_renderer.go +++ b/pkg/gui/context/list_renderer.go @@ -32,34 +32,76 @@ type ListRenderer struct { getNonModelItems func() []*NonModelItem // The remaining fields are private and shouldn't be initialized by clients - numNonModelItems int - viewIndicesByModelIndex []int - modelIndicesByViewIndex []int - columnPositions []int + columnPositions []int } func (self *ListRenderer) GetList() types.IList { return self.list } -func (self *ListRenderer) ModelIndexToViewIndex(modelIndex int) int { - modelIndex = lo.Clamp(modelIndex, 0, self.list.Len()) - if self.viewIndicesByModelIndex != nil { - return self.viewIndicesByModelIndex[modelIndex] +func (self *ListRenderer) getNonModelItemList() []*NonModelItem { + if self.getNonModelItems == nil { + return nil } + return self.getNonModelItems() +} - return modelIndex +func (self *ListRenderer) ModelIndexToViewIndex(modelIndex int) int { + return modelIndexToViewIndex(self.list.Len(), self.getNonModelItemList(), modelIndex) } func (self *ListRenderer) ViewIndexToModelIndex(viewIndex int) int { - viewIndex = lo.Clamp(viewIndex, 0, self.list.Len()+self.numNonModelItems) - if self.modelIndicesByViewIndex != nil { - return self.modelIndicesByViewIndex[viewIndex] - } + return viewIndexToModelIndex(self.list.Len(), self.getNonModelItemList(), viewIndex) +} +// modelToViewIndexConverter returns a model-to-view index conversion that +// reuses a single snapshot of the non-model items. Callers that convert many +// indices in a row (e.g. search, which converts every commit) should use this +// rather than calling ModelIndexToViewIndex per index, which would rebuild the +// non-model items each time. +func (self *ListRenderer) modelToViewIndexConverter() func(modelIndex int) int { + listLength := self.list.Len() + nonModelItems := self.getNonModelItemList() + return func(modelIndex int) int { + return modelIndexToViewIndex(listLength, nonModelItems, modelIndex) + } +} + +// The view shows the model items with the non-model items (e.g. section +// headers) inserted at their model indices. The two conversions below are +// computed directly from the current list length and non-model items, so they +// don't depend on the list having been rendered, and they can never be stale +// with respect to a model that changed since the last render (which used to +// cause both wrong results and index-out-of-range panics). +// +// The non-model items are assumed to be ordered by their Index, which is how +// all producers build them; the i-th one therefore ends up at view index +// Index+i. +func modelIndexToViewIndex(listLength int, nonModelItems []*NonModelItem, modelIndex int) int { + modelIndex = lo.Clamp(modelIndex, 0, listLength) + // Each non-model item inserted at or before this model item pushes it down + // by one row in the view. + viewIndex := modelIndex + for _, item := range nonModelItems { + if item.Index <= modelIndex { + viewIndex++ + } + } return viewIndex } +func viewIndexToModelIndex(listLength int, nonModelItems []*NonModelItem, viewIndex int) int { + viewIndex = lo.Clamp(viewIndex, 0, listLength+len(nonModelItems)) + // Subtract the non-model items that appear before this view index. + modelIndex := viewIndex + for i, item := range nonModelItems { + if item.Index+i < viewIndex { + modelIndex-- + } + } + return modelIndex +} + func (self *ListRenderer) ColumnPositions() []int { return self.columnPositions } @@ -71,23 +113,18 @@ func (self *ListRenderer) renderLines(startIdx int, endIdx int) string { if self.getColumnAlignments != nil { columnAlignments = self.getColumnAlignments() } - nonModelItems := []*NonModelItem{} - self.numNonModelItems = 0 - if self.getNonModelItems != nil { - nonModelItems = self.getNonModelItems() - self.prepareConversionArrays(nonModelItems) - } + nonModelItems := self.getNonModelItemList() startModelIdx := 0 if startIdx == -1 { startIdx = 0 } else { - startModelIdx = self.ViewIndexToModelIndex(startIdx) + startModelIdx = viewIndexToModelIndex(self.list.Len(), nonModelItems, startIdx) } endModelIdx := self.list.Len() if endIdx == -1 { endIdx = endModelIdx + len(nonModelItems) } else { - endModelIdx = self.ViewIndexToModelIndex(endIdx) + endModelIdx = viewIndexToModelIndex(self.list.Len(), nonModelItems, endIdx) } lines, columnPositions := utils.RenderDisplayStrings( self.getDisplayStrings(startModelIdx, endModelIdx), @@ -97,23 +134,6 @@ func (self *ListRenderer) renderLines(startIdx int, endIdx int) string { return strings.Join(lines, "\n") } -func (self *ListRenderer) prepareConversionArrays(nonModelItems []*NonModelItem) { - self.numNonModelItems = len(nonModelItems) - viewIndicesByModelIndex := lo.Range(self.list.Len() + 1) - modelIndicesByViewIndex := lo.Range(self.list.Len() + 1) - offset := 0 - for _, item := range nonModelItems { - for i := item.Index; i <= self.list.Len(); i++ { - viewIndicesByModelIndex[i]++ - } - modelIndicesByViewIndex = slices.Insert( - modelIndicesByViewIndex, item.Index+offset, modelIndicesByViewIndex[item.Index+offset]) - offset++ - } - self.viewIndicesByModelIndex = viewIndicesByModelIndex - self.modelIndicesByViewIndex = modelIndicesByViewIndex -} - func (self *ListRenderer) insertNonModelItems( nonModelItems []*NonModelItem, endIdx int, startIdx int, lines []string, columnPositions []int, ) []string { diff --git a/pkg/gui/context/list_renderer_test.go b/pkg/gui/context/list_renderer_test.go index 24e4aa822..11398a995 100644 --- a/pkg/gui/context/list_renderer_test.go +++ b/pkg/gui/context/list_renderer_test.go @@ -254,9 +254,6 @@ func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) { getNonModelItems: getNonModelItems, } - // Need to render first so that it knows the non-model items - self.renderLines(-1, -1) - for i := range len(s.modelIndices) { assert.Equal(t, s.expectedViewIndices[i], self.ModelIndexToViewIndex(s.modelIndices[i])) } @@ -288,10 +285,6 @@ func TestListRenderer_IndexConversionsAreRenderIndependent(t *testing.T) { } // Deliberately convert without rendering first. - /* EXPECTED: assert.Equal(t, 2, self.ModelIndexToViewIndex(1)) assert.Equal(t, 1, self.ViewIndexToModelIndex(2)) - ACTUAL: */ - assert.Equal(t, 1, self.ModelIndexToViewIndex(1)) - assert.Equal(t, 2, self.ViewIndexToModelIndex(2)) } diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index d929aca88..a66c720c9 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -224,7 +224,7 @@ func (self *LocalCommitsContext) RefForAdjustingLineNumberInDiff() string { } func (self *LocalCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition { - return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.ModelIndexToViewIndex, searchStr) + return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.modelToViewIndexConverter(), searchStr) } func (self *LocalCommitsViewModel) SetLimitCommits(value bool) { diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index fee5492ac..4e05c9594 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -223,7 +223,7 @@ func (self *SubCommitsContext) RefForAdjustingLineNumberInDiff() string { } func (self *SubCommitsContext) ModelSearchResults(searchStr string, caseSensitive bool) []gocui.SearchPosition { - return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.ModelIndexToViewIndex, searchStr) + return searchModelCommits(caseSensitive, self.GetCommits(), self.ColumnPositions(), self.modelToViewIndexConverter(), searchStr) } func (self *SubCommitsContext) IndexForGotoBottom() int { From c81c08071f8fbeaa755032b97204eac217dae4fa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 15:41:58 +0200 Subject: [PATCH 268/384] Add a hint about how to use diff --color-words or --word-diff in lazygit Since this frequently comes up as a feature request (but there are reasons why we don't want to add it), explain how to do this in lazygit today. --- docs-master/Custom_Pagers.md | 16 ++++++++++++++++ docs/Custom_Pagers.md | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/docs-master/Custom_Pagers.md b/docs-master/Custom_Pagers.md index 1b37766d0..f74005c19 100644 --- a/docs-master/Custom_Pagers.md +++ b/docs-master/Custom_Pagers.md @@ -79,6 +79,22 @@ git: - externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off ``` +This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`: + +```sh +#!/bin/sh + +git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5" +``` + +And then use it in your git config like so: + +```yaml +git: + pagers: + - externalDiffCommand: ~/bin/color-words.sh +``` + Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using ```yaml diff --git a/docs/Custom_Pagers.md b/docs/Custom_Pagers.md index 1b37766d0..f74005c19 100644 --- a/docs/Custom_Pagers.md +++ b/docs/Custom_Pagers.md @@ -79,6 +79,22 @@ git: - externalDiffCommand: difft --color=always --display=inline --syntax-highlight=off ``` +This can also be used for normal git diffs with custom parameters, such as `--color-words` or `--word-diff` which some people find useful. To do that, save a script like this to, say, `~/bin/color-words.sh`: + +```sh +#!/bin/sh + +git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5" +``` + +And then use it in your git config like so: + +```yaml +git: + pagers: + - externalDiffCommand: ~/bin/color-words.sh +``` + Instead of setting this command in lazygit's `externalDiffCommand` config, you can also tell lazygit to use the external diff command that is configured in git itself (`diff.external`), by using ```yaml From 58e121b9330836c3201f73f8246787ef67536345 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 16:48:42 +0200 Subject: [PATCH 269/384] Don't block the UI thread when triggering an immediate fetch on repo switch Switching repos triggers an immediate background fetch by sending on the goEvery retrigger channel. The send was blocking, but the goEvery loop only receives between callbacks: while a fetch is in flight, it waits for that fetch to finish before returning to its select. So a repo switch that landed while a fetch was in flight would stall the UI thread for the remainder of the fetch. Worse, since worker refreshes capture state on the UI thread with a blocking OnUIThreadAndWaitBackground call, the in-flight fetch's post-fetch refresh can itself be waiting for the UI thread, turning that stall into a deadlock cycle: UI thread: switchTo -> triggerImmediateFetch, blocking send goEvery loop: waiting for the in-flight fetch to finish fetch worker: PostFetchRefresh -> RefreshFromWorker, waiting for the UI thread Make the send non-blocking, and give the channel a buffer of one so that a trigger arriving while a fetch is in flight is latched rather than dropped; that fetch is fetching the previous repo, so we still need another one after it. The goEvery loop picks the trigger up as soon as it returns to its select, and concurrent triggers coalesce. Co-Authored-By: Claude Fable 5 --- pkg/gui/background.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 8633f4624..8b5e4b8d4 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -190,7 +190,11 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() { // returns a channel that can be used to trigger the callback immediately func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan struct{}, function func(bool) error) chan struct{} { done := make(chan struct{}) - retrigger := make(chan struct{}) + // Buffered so that a retrigger arriving while the callback is running is + // latched rather than lost: the loop below doesn't receive again until the + // callback has finished, and the callback (a fetch) may be for the wrong + // repo if the retrigger came from a repo switch. + retrigger := make(chan struct{}, 1) go utils.Safe(func() { ticker := time.NewTicker(interval) defer ticker.Stop() @@ -234,6 +238,16 @@ func (self *BackgroundRoutineMgr) backgroundFetch() (err error) { func (self *BackgroundRoutineMgr) triggerImmediateFetch() { if self.triggerFetch != nil { - self.triggerFetch <- struct{}{} + // This runs on the UI thread, which must never block waiting for a + // background routine; in particular, the goEvery loop only receives + // between callbacks, and an in-flight fetch can itself be waiting for + // the UI thread to perform its post-fetch refresh, so a blocking send + // here would deadlock. The channel has a buffer of one, so the trigger + // is latched even when the loop isn't currently receiving; if one is + // already pending, the two coalesce. + select { + case self.triggerFetch <- struct{}{}: + default: + } } } From 3a0ba6bf4d33ce355237497b560a19bfd4a855ba Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 17:16:46 +0200 Subject: [PATCH 270/384] Fix data race on the triggerFetch field startBackgroundFetch assigned the field from its own goroutine, and only after the initial fetch had completed, while the UI thread reads it in triggerImmediateFetch on every repo switch, with no synchronization. Create the channel in startBackgroundRoutines instead, which runs on the UI thread before the fetch goroutine is spawned; everything the UI thread does afterwards is ordered after the write, so the read is race-free without any locking. To make this possible, goEvery now takes the retrigger channel as a parameter instead of creating and returning it; callers that have no use for a retrigger channel pass nil, and a nil channel in a select is simply never ready. As a side effect, a repo switch that happens before the fetch loop has started (during the intro popup or the initial fetch) now latches a trigger and causes an immediate fetch once the loop is running, where previously it was silently dropped. Co-Authored-By: Claude Fable 5 --- pkg/gui/background.go | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 8b5e4b8d4..f9eff420b 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -43,6 +43,11 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() { if userConfig.Git.AutoFetch { fetchInterval := userConfig.Refresher.FetchInterval if fetchInterval > 0 { + // The channel must be created here, on the UI thread and before + // the fetch goroutine spawns, so that triggerImmediateFetch (also + // running on the UI thread) can read the field without racing the + // write. See triggerImmediateFetch for why it is buffered. + self.triggerFetch = make(chan struct{}, 1) go utils.Safe(self.startBackgroundFetch) } else { self.gui.c.Log.Errorf( @@ -74,7 +79,7 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() { } if self.gui.Config.GetDebug() { - self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, func(_ bool) error { + self.goEvery(time.Second*time.Duration(10), self.gui.stopChan, nil, func(_ bool) error { formatBytes := func(b uint64) string { const unit = 1000 if b < unit { @@ -125,14 +130,14 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() { _ = fetch(true) userConfig := self.gui.UserConfig() - self.triggerFetch = self.goEvery(userConfig.Refresher.FetchIntervalDuration(), self.gui.stopChan, fetch) + self.goEvery(userConfig.Refresher.FetchIntervalDuration(), self.gui.stopChan, self.triggerFetch, fetch) } func (self *BackgroundRoutineMgr) startBackgroundFilesRefresh() { self.gui.waitForIntro.Wait() userConfig := self.gui.UserConfig() - self.goEvery(userConfig.Refresher.RefreshIntervalDuration(), self.gui.stopChan, func(_ bool) error { + self.goEvery(userConfig.Refresher.RefreshIntervalDuration(), self.gui.stopChan, nil, func(_ bool) error { self.gui.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Background: true}) return nil }) @@ -151,6 +156,7 @@ func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() { self.goEvery( userConfig.Refresher.ExternalChangeCheckIntervalDuration(), self.gui.stopChan, + nil, func(_ bool) error { self.checkForExternalChanges() return nil @@ -187,14 +193,10 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() { self.gui.c.RefreshFromWorker(types.RefreshOptions{Background: true}) } -// returns a channel that can be used to trigger the callback immediately -func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan struct{}, function func(bool) error) chan struct{} { +// Runs function every interval until stop is closed. A send on retrigger (if +// non-nil) runs the callback immediately and restarts the interval. +func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop, retrigger chan struct{}, function func(bool) error) { done := make(chan struct{}) - // Buffered so that a retrigger arriving while the callback is running is - // latched rather than lost: the loop below doesn't receive again until the - // callback has finished, and the callback (a fetch) may be for the wrong - // repo if the retrigger came from a repo switch. - retrigger := make(chan struct{}, 1) go utils.Safe(func() { ticker := time.NewTicker(interval) defer ticker.Stop() @@ -227,7 +229,6 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru } } }) - return retrigger } func (self *BackgroundRoutineMgr) backgroundFetch() (err error) { From 76ad5a35521a6c972acf2d1802f5467e55bc5729 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 14:54:40 +0200 Subject: [PATCH 271/384] Clarify the contribution policy --- CONTRIBUTING.md | 277 ++++-------------------------------------------- 1 file changed, 19 insertions(+), 258 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd62a0eb7..49a9a625c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,273 +1,34 @@ # Contributing -This project does not accept pull requests. +## The short version -In todays world of agentic coding I have decided that it no longer makes sense for me to look at incoming pull requests. As far as I can tell, the vast majority of these is AI-generated these days, which in itself is not necessarily a bad thing; however, there's no way for me to tell whether the person posting the PR actually understands anything about the code that is being contributed or not, and I don't feel like spending time and energy on finding out whether they do. +This project does not accept pull requests. Don't bother making one, it won't be merged. -Now you might ask why this even matters; coding agents are capable of producing amazingly high-quality code, so why is it important that the person opening the PR understands it, as long as the code works and tests are green? It does actually matter very much to me. AI generated code needs to be carefully reviewed and iterated on, and it is the contributor's job to do that, not mine. And I have no idea to what extent the contributor has done this, or whether they are even capable of it. +However, there are other forms of contributions that are very welcome and encouraged; see below for what those are. -Every PR needs work and iterations until it is mergeable, whether manually coded or AI generated (even very good ones do), and if I don't know whether the person posting the PR will act on my review feedback themselves or just pass it on to their coding agent (which I guess is the much more likely case today), then it doesn't make sense for me to work with them. +## Why no PRs? -For this reason I will close incoming pull requests by default from now on, without comment. Sorry if this sounds hostile, but honestly I don't feel I have much of a choice if I want maintaining this project to still be enjoyable for me. +There are two main reasons for this, and I want to be very honest about them: -With that said, if you are indeed serious about contributing a high-quality PR to lazygit, and you are familiar with go, and you have learned enough about lazygit's code base to tell whether your changes are good, then do raise an issue and explain what you are planning to do, and somehow make it plausible that your PR will be worth my time reviewing it. In such a case I might make an exception from the default rule. +- I am maintaining lazygit for fun, as a hobby in my free time (which is quite limited). I'd like to spend my free time on things that I enjoy doing. I enjoy working on lazygit's code and improving it myself; I don't enjoy reviewing PRs. It's that simple, really. Reviewing PRs takes a lot of time; time that I would rather spend on developing lazygit myself. +- Even if I had the time and inclination to review PRs, this has become quite difficult today: most PRs nowadays are AI-generated to some extent (often completely), which in itself is not necessarily a bad thing; I heavily use AI myself these days, and I get great results from it. However, agentic coding needs to be guided by humans so that the results are good, and for contributed PRs I can't tell to what extent the human contributor did this, or is even capable of it; and I don't want to do the work of guiding a contributor's coding agent. If I post PR review feedback and have to suspect that the contributor simply passes it on to their coding agent, then that is a work mode that doesn't make sense to me, and I would rather just drive my own agent to do the work. -In the future I might also consider adopting a vouch system similar to [Ghostty's](https://github.com/ghostty-org/ghostty/blob/main/CONTRIBUTING.md#first-time-contributors), but right now I feel the effort needed to set this up and maintain is not justified given the rather low number of high-quality contributions I have seen in recent times. +### Why it might still make sense to post a PR -Even though we no longer accept pull requests, I find it important to emphasize that Lazygit is still a community project, and non-PR contributions are still very welcome. Do file issues for bug reports or feature requests, and help shape the future of lazygit by actively participating in discussing UX designs. Also, the localization system very much depends on everybody's help with translating texts (see https://crowdin.com/project/lazygit). +I can think of two such reasons: ---- +- You implemented a lazygit improvement that you want to use yourself; in this case it could make sense to let others merge this change into their forks if they find it useful too. And if enough people say they want the feature, this can persuade me to add it, so putting it out there to give it visibility can be helpful. +- You posted an issue for a feature request, and have a prototype that implements it; it could be useful to publish the branch as a draft PR to better illustrate how the feature works. -The remainder of this document is the old version from a time when contributing pull requests was still encouraged. Keeping it here in case I reconsider my policy in the future. +For this reason I usually don't close pull requests to give them more visibility. Just don't expect your PR to be merged. -## PR walkthrough +## So how can I contribute then? -[This video](https://www.youtube.com/watch?v=kNavnhzZHtk) walks through the process of adding a small feature to lazygit. If you have no idea where to start, watching that video is a good first step. +There are other forms of contributions to a project besides source code that are very welcome and encouraged; for instance: -## Design principles +- File issues for bugs that you find, and I'll do my best to take care of fixing them (if they are important enough). +- File feature requests for new functionality that you want to see in lazygit. I have a lot of ideas for future improvement myself, but I have also implemented a lot of feature ideas that weren't mine, and I'm grateful for those ideas. (Of course, there are also lots of feature requests that I don't implement, so don't be disappointed if I don't jump on yours.) +- Help make other people's bug reports reproducible. Sometimes people report bugs that they have only seen once, and in such a case it can be helpful to come up with reproducible scenarios. +- Help complete or improve the translation into other languages; join https://crowdin.com/project/lazygit for that. -See [here](./VISION.md) for a set of design principles that we want to consider when building a feature or making a change. - -## Codebase guide - -[This doc](./docs/dev/Codebase_Guide.md) explains: - -- what the different packages in the codebase are for -- where important files live -- important concepts in the code -- how the event loop works -- other useful information - -## All code changes happen through Pull Requests - -Pull requests are the best way to propose changes to the codebase. We actively -welcome your pull requests: - -1. Fork the repo and create your branch from `master`. -2. If you've added code that should be tested, add tests. -3. If you've added code that needs documentation, update the documentation. -4. Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). -5. Issue that pull request! - -Please do not raise pull request from your fork's master branch: make a feature branch instead. Lazygit maintainers will sometimes push changes to your branch when reviewing a PR and we often can't do this if you use your master branch. - -If you've never written Go in your life, then join the club! Lazygit was the maintainer's first Go program, and most contributors have never used Go before. Go is widely considered an easy-to-learn language, so if you're looking for an open source project to gain dev experience, you've come to the right place. - -## Commit history - -We value a clean and useful commit history, so please take some time to organize your commits so that they make sense. Don't assume that they will be squashed on merge anyway; we don't do that here. - -In particular: - -- Refactorings and behavior changes should be in separate commits. There are very few exceptions where this is not possible, but in my experience they are very rare. -- Strive for minimal commits; every change that is independent from other changes should be in a commit of its own (with a good commit message that explains why the change is made). -- When you need to iterate over your implementation during review (e.g. because you discovered a bug, or a maintainer requested changes), don't just pile new commits on top. Use fixup commits to make your changes transparent while still maintaining a good commit history. If you don't know what that means, [here's a brief introduction](docs/Fixup_Commits.md). - -## A note about AI - -It has become common recently to throw an issue at a coding agent and submit whatever comes out of it as a PR. This is not appreciated here, and I will close PRs where I can tell this was the case, or where I even suspect it was the case. - -Some of these PRs may actually be good and useful, but many are not, and it's not a good use of my time as a maintainer to look at generated PRs to decide. This is the job of the PR's contributor, and if you don't speak enough go or can't be bothered to get familiar enough with lazygit's codebase to tell, then don't contribute the PR. - -## Running in a VSCode dev container - -If you want to spare yourself the hassle of setting up your dev environment yourself (i.e. installing Go, extensions, and extra tools), you can run the Lazygit code in a VSCode dev container like so: - -![image](https://user-images.githubusercontent.com/8456633/201500508-0d55f99f-5035-4a6f-a0f8-eaea5c003e5d.png) - -This requires that: - -- you have docker installed -- you have the dev containers extension installed in VSCode - -See [here](https://code.visualstudio.com/docs/devcontainers/containers) for more info about dev containers. - -## Running in a Github Codespace - -If you want to start contributing to Lazygit with the click of a button, you can open the lazygit codebase in a Codespace. First fork the repo, then click to create a codespace: - -![image](https://user-images.githubusercontent.com/8456633/201500566-ffe9105d-6030-4cc7-a525-6570b0b413a2.png) - -To run lazygit from within the integrated terminal just go `go run main.go` - -This allows you to contribute to Lazygit without needing to install anything on your local machine. The Codespace has all the necessary tools and extensions pre-installed. - -## Using Nix for development - -If you use Nix, you can leverage the included flake to set up a complete development environment with all necessary dependencies: - -```sh -nix develop -``` - -This will drop you into a development shell that includes: - -- Latest Go toolchain -- golangci-lint for code linting -- git and make - -You can also build and run lazygit using nix: - -```sh -# Build lazygit -nix build - -# Run lazygit directly -nix run -``` - -The nix flake supports multiple architectures (x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin) and provides a consistent development environment across different systems. - -## Code of conduct - -Please note by participating in this project, you agree to abide by the [code of conduct]. - -[code of conduct]: https://github.com/jesseduffield/lazygit/blob/master/CODE-OF-CONDUCT.md - -## Any contributions you make will be under the MIT Software License - -In short, when you submit code changes, your submissions are understood to be -under the same [MIT License](http://choosealicense.com/licenses/mit/) that -covers the project. Feel free to contact the maintainers if that's a concern. - -## Report bugs using Github's [issues](https://github.com/jesseduffield/lazygit/issues) - -We use GitHub issues to track public bugs. Report a bug by [opening a new -issue](https://github.com/jesseduffield/lazygit/issues/new); it's that easy! - -## Go - -This project is written in Go. Go is an opinionated language with strict idioms, but some of those idioms are a little extreme. Some things we do differently: - -1. There is no shame in using `self` as a receiver name in a struct method. In fact we encourage it -2. There is no shame in prefixing an interface with 'I' instead of suffixing with 'er' when there are several methods on the interface. -3. If a struct implements an interface, we make it explicit with something like: - -```go -var _ MyInterface = &MyStruct{} -``` - -This makes the intent clearer and means that if we fail to satisfy the interface we'll get an error in the file that needs fixing. - -### Code Formatting - -To check code formatting [gofumpt](https://pkg.go.dev/mvdan.cc/gofumpt#section-readme) (which is a bit stricter than [gofmt](https://pkg.go.dev/cmd/gofmt)) is used. -VSCode will format the code correctly if you tell the Go extension to use `gofumpt` via your [`settings.json`](https://code.visualstudio.com/docs/getstarted/settings#_settingsjson) -by setting [`formatting.gofumpt`](https://github.com/golang/tools/blob/master/gopls/doc/settings.md#gofumpt-bool) to `true`: - -```jsonc -// .vscode/settings.json -{ - "gopls": { - "formatting.gofumpt": true - } -} -``` - -To run gofumpt from your terminal go: - -``` -go install mvdan.cc/gofumpt@latest && gofumpt -l -w . -``` - -## Programming Font - -Lazygit supports [Nerd Fonts](https://www.nerdfonts.com) to render certain icons. Sometimes we use some of these icons verbatim in string literals in the code (mainly in tests), so you need to set your development environment to use a nerd font to see these. - -## Internationalisation - -Boy that's a hard word to spell. Anyway, lazygit is translated into several languages within the pkg/i18n package. - -### For developers adding new text - -If you need to render text to the user, you should add a new field to the TranslationSet struct in `pkg/i18n/english.go` and add the actual content within the `EnglishTranslationSet()` method in the same file. Then you can access via `gui.Tr.YourNewText` (or `self.c.Tr.YourNewText`, etc). - -Note, we use 'Sentence case' for everything (so no 'Title Case' or 'whatever-it's-called-when-there's-no-capital-letters-case') - -### For translators - -Lazygit translations are managed through [Crowdin](https://crowdin.com/project/lazygit/). If you'd like to contribute translations: - -1. Join the Crowdin project at https://crowdin.com/project/lazygit/ -2. Select your target language and help translate missing strings -3. The translation files in `pkg/i18n/translations/` are managed by the maintainers - please don't edit them directly - -For detailed information about the translation process, including how maintainers sync translations, see `pkg/i18n/translations/README.md`. - -## Debugging - -The easiest way to debug lazygit is to have two terminal tabs open at once: one for running lazygit (via `go run main.go -debug` in the project root) and one for viewing lazygit's logs (which can be done via `go run main.go --logs` or just `lazygit --logs`). - -From most places in the codebase you have access to a logger e.g. `gui.Log.Warn("blah")` or `self.c.Log.Warn("blah")`. - -If you find that the existing logs are too noisy, you can set the log level with e.g. `LOG_LEVEL=warn go run main.go -debug` and then only use `Warn` logs yourself. - -If you need to log from code in the vendor directory (e.g. the `gocui` package), you won't have access to the logger, but you can easily add logging support by setting the `LAZYGIT_LOG_PATH` environment variable and using `logs.Global.Warn("blah")`. This is a global logger that's only intended for development purposes. - -If you keep having to do some setup steps to reproduce an issue, read the Testing section below to see how to create an integration test by recording a lazygit session. It's pretty easy! - -### VSCode debugger - -If you want to trigger a debug session from VSCode, you can use the following snippet. Note that the `console` key is, at the time of writing, still an experimental feature. - -```jsonc -// .vscode/launch.json -{ - "version": "0.2.0", - "configurations": [ - { - "name": "debug lazygit", - "type": "go", - "request": "launch", - "mode": "auto", - "program": "main.go", - "args": ["--debug"], - "console": "externalTerminal" // <-- you need this to actually see the lazygit UI in a window while debugging - } - ] -} -``` - -## Profiling - -If you want to investigate what's contributing to CPU or memory usage, see [this separate document](docs/dev/Profiling.md). - -## Testing - -Lazygit has two kinds of tests: unit tests and integration tests. Unit tests go in files that end in `_test.go`, and are written in Go. For integration tests, see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md) - -## Updating Gocui - -Sometimes you will need to make a change in the gocui fork (https://github.com/jesseduffield/gocui). Gocui is the package responsible for rendering windows and handling user input. Here's the typical process to follow: - -1. Make the changes in gocui inside lazygit's vendor directory so it's easy to test against lazygit -2. Copy the changes over to the actual gocui repo (clone it if you haven't already, and use the `awesome` branch, not `master`) -3. Raise a PR on the gocui repo with your changes -4. After that PR is merged, make a PR in lazygit bumping the gocui version. You can bump the version by running the following at the lazygit repo root: - -```sh -./scripts/bump_gocui.sh -``` - -5. Raise a PR in lazygit with those changes - -## Updating Lazycore - -[Lazycore](https://github.com/jesseduffield/lazycore) is a repo containing shared functionality between lazygit and lazydocker. Sometimes you will need to make a change to that repo and import the changes into lazygit. Similar to updating Gocui, here's what you do: - -1. Make the changes in lazycore inside lazygit's vendor directory so it's easy to test against lazygit -2. Copy the changes over to the actual lazycore repo (clone it if you haven't already, and use the `master` branch) -3. Raise a PR on the lazycore repo with your changes -4. After that PR is merged, make a PR in lazygit bumping the lazycore version. You can bump the version by running the following at the lazygit repo root: - -```sh -./scripts/bump_lazycore.sh -``` - -Or if you're using VSCode, there is a bump lazycore task you can find by going `cmd+shift+p` and typing 'Run task' - -5. Raise a PR in lazygit with those changes - -## Improvements - -If you can think of any way to improve these docs let us know. +Importantly, if you file issues (whether bug reports or feature requests), stay around to answer questions and discuss your issue. There are few things that I find more annoying than spending time on responding to someone's issue (sometimes even making a PR that addresses it), and to then never hear from the OP again. So please set up your Github notifications so that you see when there's activity on your issue, and continue to participate. From 50122e6886855a01f33a1ccb3e8b7d6de1f624d8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 14:54:56 +0200 Subject: [PATCH 272/384] Don't invite for contributions at startup --- pkg/i18n/english.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 69ea7012f..22b05e6c0 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -1130,12 +1130,7 @@ Thanks for using lazygit! Seriously you rock. Three things to share with you: 2) Be sure to read the latest release notes at: https://github.com/jesseduffield/lazygit/releases - 3) If you're using git, that makes you a programmer! With your help we can make - lazygit better, so consider becoming a contributor and joining the fun at - https://github.com/jesseduffield/lazygit - Or even just star the repo to share the love! - - 4) If lazygit has made your life easier, you can say thanks by clicking the + 3) If lazygit has made your life easier, you can say thanks by clicking the donate button at the bottom right. Donation does not grant priority support, but it is much appreciated. From e90daaf81287b4ab5d53166d69651d9aba75ec9e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 08:56:06 +0200 Subject: [PATCH 273/384] Unify the git command lock-retry loops RunWithOutput and RunWithOutputs each carried their own near-identical copy of the index.lock retry loop. Extract the loop into a single retryOnLockError helper so the retry policy lives in one place, ahead of changing that policy. Behavior is unchanged; the added tests characterize it (success and non-lock errors run once, a lock error in the output is retried). Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_cmd_obj_runner.go | 42 +++++------ pkg/commands/git_cmd_obj_runner_test.go | 92 +++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 pkg/commands/git_cmd_obj_runner_test.go diff --git a/pkg/commands/git_cmd_obj_runner.go b/pkg/commands/git_cmd_obj_runner.go index 668feef93..a72565b5c 100644 --- a/pkg/commands/git_cmd_obj_runner.go +++ b/pkg/commands/git_cmd_obj_runner.go @@ -33,11 +33,30 @@ func (self *gitCmdObjRunner) Run(cmdObj *oscommands.CmdObj) error { } func (self *gitCmdObjRunner) RunWithOutput(cmdObj *oscommands.CmdObj) (string, error) { + return self.retryOnLockError(func() (string, error) { + return self.innerRunner.RunWithOutput(cmdObj.Clone()) + }) +} + +func (self *gitCmdObjRunner) RunWithOutputs(cmdObj *oscommands.CmdObj) (string, string, error) { + var stdout, stderr string + _, err := self.retryOnLockError(func() (string, error) { + var runErr error + stdout, stderr, runErr = self.innerRunner.RunWithOutputs(cmdObj.Clone()) + return stdout + stderr, runErr + }) + return stdout, stderr, err +} + +// retryOnLockError runs the given function, retrying if it fails with a +// transient lock error (see isRetryableError). The string returned by run is +// the command output we inspect to classify the failure. We clone the command +// for each attempt (inside run) because an *exec.Cmd can only be run once. +func (self *gitCmdObjRunner) retryOnLockError(run func() (string, error)) (string, error) { var output string var err error for range RetryCount { - newCmdObj := cmdObj.Clone() - output, err = self.innerRunner.RunWithOutput(newCmdObj) + output, err = run() if err == nil || !isRetryableError(output) { return output, err @@ -51,25 +70,6 @@ func (self *gitCmdObjRunner) RunWithOutput(cmdObj *oscommands.CmdObj) (string, e return output, err } -func (self *gitCmdObjRunner) RunWithOutputs(cmdObj *oscommands.CmdObj) (string, string, error) { - var stdout, stderr string - var err error - for range RetryCount { - newCmdObj := cmdObj.Clone() - stdout, stderr, err = self.innerRunner.RunWithOutputs(newCmdObj) - - if err == nil || !isRetryableError(stdout+stderr) { - return stdout, stderr, err - } - - // if we have an error based on a lock, we should wait a bit and then retry - self.log.Warn("lock error prevented command from running. Retrying command after a small wait") - time.Sleep(WaitTime) - } - - return stdout, stderr, err -} - // Retry logic not implemented here, but these commands typically don't need to obtain a lock. func (self *gitCmdObjRunner) RunAndProcessLines(cmdObj *oscommands.CmdObj, onLine func(line string) (bool, error)) error { return self.innerRunner.RunAndProcessLines(cmdObj, onLine) diff --git a/pkg/commands/git_cmd_obj_runner_test.go b/pkg/commands/git_cmd_obj_runner_test.go new file mode 100644 index 000000000..e8c73727d --- /dev/null +++ b/pkg/commands/git_cmd_obj_runner_test.go @@ -0,0 +1,92 @@ +package commands + +import ( + "errors" + "testing" + + "github.com/jesseduffield/lazygit/pkg/commands/oscommands" + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stretchr/testify/assert" +) + +type runnerResult struct { + output string + err error +} + +// scriptedRunner is an ICmdObjRunner stub that returns a preconfigured result +// for each successive call, letting us drive the retry loop deterministically. +// It counts calls so tests can assert whether a command was retried. +type scriptedRunner struct { + results []runnerResult + calls int +} + +func (self *scriptedRunner) next() (string, error) { + result := self.results[self.calls] + self.calls++ + return result.output, result.err +} + +func (self *scriptedRunner) Run(*oscommands.CmdObj) error { + _, err := self.next() + return err +} + +func (self *scriptedRunner) RunWithOutput(*oscommands.CmdObj) (string, error) { + return self.next() +} + +func (self *scriptedRunner) RunWithOutputs(*oscommands.CmdObj) (string, string, error) { + output, err := self.next() + return output, "", err +} + +func (self *scriptedRunner) RunAndProcessLines(*oscommands.CmdObj, func(string) (bool, error)) error { + panic("not implemented") +} + +func newTestRunner(inner *scriptedRunner) *gitCmdObjRunner { + return &gitCmdObjRunner{ + log: utils.NewDummyLog(), + innerRunner: inner, + } +} + +// dummyCmdObj returns a throwaway command; only its clonability matters, since +// the scriptedRunner ignores it and returns preconfigured results. +func dummyCmdObj() *oscommands.CmdObj { + return oscommands.NewDummyCmdObjBuilder(nil).New([]string{"git", "status"}) +} + +func TestRunWithOutputReturnsSuccessWithoutRetrying(t *testing.T) { + inner := &scriptedRunner{results: []runnerResult{{output: "done", err: nil}}} + + output, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.NoError(t, err) + assert.Equal(t, "done", output) + assert.Equal(t, 1, inner.calls) +} + +func TestRunWithOutputDoesNotRetryNonLockError(t *testing.T) { + inner := &scriptedRunner{results: []runnerResult{{output: "boom", err: errors.New("boom")}}} + + _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.Error(t, err) + assert.Equal(t, 1, inner.calls) +} + +func TestRunWithOutputRetriesWhenLockErrorIsInOutput(t *testing.T) { + inner := &scriptedRunner{results: []runnerResult{ + {output: "fatal: Unable to create '/repo/.git/index.lock': File exists.", err: errors.New("exit status 128")}, + {output: "done", err: nil}, + }} + + output, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.NoError(t, err) + assert.Equal(t, "done", output) + assert.Equal(t, 2, inner.calls) +} From 0902c5c05878b97c17611cec804a99562694372f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 08:56:26 +0200 Subject: [PATCH 274/384] Demonstrate that a lock error in a streamed command isn't retried The gpg helper runs commands like amend with StreamOutput, so their output isn't captured and a failed run returns an empty output string; the index.lock message is carried by the error instead. isRetryableError only inspects the output, so the retry loop never fires for these commands. In practice this means a `shift-A` amend issued while a foreground `git status` refresh briefly holds index.lock fails outright instead of retrying. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_cmd_obj_runner_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pkg/commands/git_cmd_obj_runner_test.go b/pkg/commands/git_cmd_obj_runner_test.go index e8c73727d..2ff2795dc 100644 --- a/pkg/commands/git_cmd_obj_runner_test.go +++ b/pkg/commands/git_cmd_obj_runner_test.go @@ -90,3 +90,22 @@ func TestRunWithOutputRetriesWhenLockErrorIsInOutput(t *testing.T) { assert.Equal(t, "done", output) assert.Equal(t, 2, inner.calls) } + +func TestRunWithOutputRetriesWhenLockErrorIsOnlyInError(t *testing.T) { + // A streamed command (e.g. an amend run through the gpg helper) doesn't + // capture its output, so a lock failure surfaces only in the returned error + // with an empty output string. The retry logic must still recognize it. + inner := &scriptedRunner{results: []runnerResult{ + {output: "", err: errors.New("fatal: Unable to create '/repo/.git/index.lock': File exists.")}, + {output: "", err: nil}, + }} + + _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + /* EXPECTED: + assert.NoError(t, err) + assert.Equal(t, 2, inner.calls) + ACTUAL: */ + assert.Error(t, err) + assert.Equal(t, 1, inner.calls) +} From c1cd500fa776c54f61b4cf6dddf6de0d90c7aa1a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 08:56:56 +0200 Subject: [PATCH 275/384] Retry lock errors reported only through the command's error Have isRetryableError also inspect the returned error, not just the captured output. Streamed commands (amend, commit, and other operations run through the gpg helper) don't capture output, so their index.lock failures were slipping past the retry loop and surfacing to the user as a hard "Git command failed". Now they retry like every other command. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_cmd_obj_runner.go | 18 ++++++++++++------ pkg/commands/git_cmd_obj_runner_test.go | 4 ---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/pkg/commands/git_cmd_obj_runner.go b/pkg/commands/git_cmd_obj_runner.go index a72565b5c..f22a0f7aa 100644 --- a/pkg/commands/git_cmd_obj_runner.go +++ b/pkg/commands/git_cmd_obj_runner.go @@ -20,11 +20,17 @@ type gitCmdObjRunner struct { innerRunner oscommands.ICmdObjRunner } -// isRetryableError returns true if the error output indicates a transient -// lock-related error that may succeed on retry -func isRetryableError(output string) bool { - return strings.Contains(output, ".git/index.lock") || - strings.Contains(output, "cannot lock ref") +// isRetryableError returns true if a failed command hit a transient +// lock-related condition that may succeed on retry. The lock message can reach +// us either in the command's captured output or, for streamed commands whose +// output we don't capture, only in the returned error, so we check both. +func isRetryableError(output string, err error) bool { + text := output + if err != nil { + text += "\n" + err.Error() + } + return strings.Contains(text, ".git/index.lock") || + strings.Contains(text, "cannot lock ref") } func (self *gitCmdObjRunner) Run(cmdObj *oscommands.CmdObj) error { @@ -58,7 +64,7 @@ func (self *gitCmdObjRunner) retryOnLockError(run func() (string, error)) (strin for range RetryCount { output, err = run() - if err == nil || !isRetryableError(output) { + if err == nil || !isRetryableError(output, err) { return output, err } diff --git a/pkg/commands/git_cmd_obj_runner_test.go b/pkg/commands/git_cmd_obj_runner_test.go index 2ff2795dc..903c6a90b 100644 --- a/pkg/commands/git_cmd_obj_runner_test.go +++ b/pkg/commands/git_cmd_obj_runner_test.go @@ -102,10 +102,6 @@ func TestRunWithOutputRetriesWhenLockErrorIsOnlyInError(t *testing.T) { _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) - /* EXPECTED: assert.NoError(t, err) assert.Equal(t, 2, inner.calls) - ACTUAL: */ - assert.Error(t, err) - assert.Equal(t, 1, inner.calls) } From e3ecb77939e191281ef3a70509be1183acc2b41e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 08:57:26 +0200 Subject: [PATCH 276/384] Recognize index.lock contention in worktrees and submodules The retry check matched the literal ".git/index.lock", which only ever appears for the main worktree. A linked worktree's lock is at .git/worktrees//index.lock and a submodule's is under its own git dir, so contention there was never retried. Match the bare "index.lock" fragment instead, which covers all of them. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_cmd_obj_runner.go | 7 ++++++- pkg/commands/git_cmd_obj_runner_test.go | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/pkg/commands/git_cmd_obj_runner.go b/pkg/commands/git_cmd_obj_runner.go index f22a0f7aa..c4d2ec049 100644 --- a/pkg/commands/git_cmd_obj_runner.go +++ b/pkg/commands/git_cmd_obj_runner.go @@ -24,12 +24,17 @@ type gitCmdObjRunner struct { // lock-related condition that may succeed on retry. The lock message can reach // us either in the command's captured output or, for streamed commands whose // output we don't capture, only in the returned error, so we check both. +// +// We match the bare "index.lock" fragment rather than a fuller path or message +// so we catch the lock wherever git puts it: the main .git dir, a linked +// worktree's git dir (.git/worktrees//index.lock), or a submodule's git +// dir. func isRetryableError(output string, err error) bool { text := output if err != nil { text += "\n" + err.Error() } - return strings.Contains(text, ".git/index.lock") || + return strings.Contains(text, "index.lock") || strings.Contains(text, "cannot lock ref") } diff --git a/pkg/commands/git_cmd_obj_runner_test.go b/pkg/commands/git_cmd_obj_runner_test.go index 903c6a90b..2cde503f5 100644 --- a/pkg/commands/git_cmd_obj_runner_test.go +++ b/pkg/commands/git_cmd_obj_runner_test.go @@ -105,3 +105,18 @@ func TestRunWithOutputRetriesWhenLockErrorIsOnlyInError(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 2, inner.calls) } + +func TestRunWithOutputRetriesLockErrorInLinkedWorktree(t *testing.T) { + // In a linked worktree the lock lives at .git/worktrees//index.lock + // rather than .git/index.lock, so only matching the bare "index.lock" + // fragment lets the retry fire there too. + inner := &scriptedRunner{results: []runnerResult{ + {output: "", err: errors.New("fatal: Unable to create '/repo/.git/worktrees/feature/index.lock': File exists.")}, + {output: "", err: nil}, + }} + + _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.NoError(t, err) + assert.Equal(t, 2, inner.calls) +} From 4052057eee1073f2afe7c5cc1e3027995167c642 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 08:58:22 +0200 Subject: [PATCH 277/384] Back off exponentially between lock-error retries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry budget was five fixed 50ms waits (250ms total). A foreground `git status` refresh can hold index.lock for longer than that on a large repo, so the retries could be exhausted before the lock clears. Wait 20ms before the first retry and double each time, giving seven attempts over a bit more than a second — enough to outlast a slow refresh while keeping the common case (a lock that clears almost immediately) fast. The initial delay is now a runner field so tests can zero it out instead of sleeping. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/git_cmd_obj_builder.go | 5 +++-- pkg/commands/git_cmd_obj_runner.go | 28 ++++++++++++++++++------- pkg/commands/git_cmd_obj_runner_test.go | 15 +++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/pkg/commands/git_cmd_obj_builder.go b/pkg/commands/git_cmd_obj_builder.go index 6bc3b7d19..20d31c11c 100644 --- a/pkg/commands/git_cmd_obj_builder.go +++ b/pkg/commands/git_cmd_obj_builder.go @@ -25,8 +25,9 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild // the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase) updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner { return &gitCmdObjRunner{ - log: log, - innerRunner: runner, + log: log, + innerRunner: runner, + initialRetryDelay: defaultInitialRetryDelay, } }) diff --git a/pkg/commands/git_cmd_obj_runner.go b/pkg/commands/git_cmd_obj_runner.go index c4d2ec049..8112b0f30 100644 --- a/pkg/commands/git_cmd_obj_runner.go +++ b/pkg/commands/git_cmd_obj_runner.go @@ -11,13 +11,24 @@ import ( // here we're wrapping the default command runner in some git-specific stuff e.g. retry logic if we get an error due to the presence of .git/index.lock const ( - WaitTime = 50 * time.Millisecond - RetryCount = 5 + // defaultInitialRetryDelay is how long we wait before the first retry of a + // command that failed with a transient lock error. We double it before each + // subsequent retry (see retryOnLockError), so across maxRetries attempts we + // wait for a bit over a second in total. That's long enough to outlast the + // brief window during which another git process holds a lock we need — + // typically our own foreground `git status` refresh, which takes index.lock + // to persist its refreshed stat-cache. + defaultInitialRetryDelay = 20 * time.Millisecond + maxRetries = 7 ) type gitCmdObjRunner struct { log *logrus.Entry innerRunner oscommands.ICmdObjRunner + // initialRetryDelay is the wait before the first lock-error retry. It's a + // field rather than the constant directly so tests can set it to zero and + // not actually sleep. + initialRetryDelay time.Duration } // isRetryableError returns true if a failed command hit a transient @@ -64,18 +75,21 @@ func (self *gitCmdObjRunner) RunWithOutputs(cmdObj *oscommands.CmdObj) (string, // the command output we inspect to classify the failure. We clone the command // for each attempt (inside run) because an *exec.Cmd can only be run once. func (self *gitCmdObjRunner) retryOnLockError(run func() (string, error)) (string, error) { + delay := self.initialRetryDelay var output string var err error - for range RetryCount { + for attempt := range maxRetries { output, err = run() if err == nil || !isRetryableError(output, err) { - return output, err + break } - // if we have an error based on a lock, we should wait a bit and then retry - self.log.Warn("lock error prevented command from running. Retrying command after a small wait") - time.Sleep(WaitTime) + if attempt < maxRetries-1 { + self.log.Warnf("lock error prevented command from running; retrying in %s", delay) + time.Sleep(delay) + delay *= 2 + } } return output, err diff --git a/pkg/commands/git_cmd_obj_runner_test.go b/pkg/commands/git_cmd_obj_runner_test.go index 2cde503f5..bf938da54 100644 --- a/pkg/commands/git_cmd_obj_runner_test.go +++ b/pkg/commands/git_cmd_obj_runner_test.go @@ -50,6 +50,8 @@ func newTestRunner(inner *scriptedRunner) *gitCmdObjRunner { return &gitCmdObjRunner{ log: utils.NewDummyLog(), innerRunner: inner, + // don't actually sleep between retries + initialRetryDelay: 0, } } @@ -106,6 +108,19 @@ func TestRunWithOutputRetriesWhenLockErrorIsOnlyInError(t *testing.T) { assert.Equal(t, 2, inner.calls) } +func TestRunWithOutputGivesUpAfterMaxRetries(t *testing.T) { + results := make([]runnerResult, maxRetries) + for i := range results { + results[i] = runnerResult{err: errors.New("fatal: Unable to create '/repo/.git/index.lock': File exists.")} + } + inner := &scriptedRunner{results: results} + + _, err := newTestRunner(inner).RunWithOutput(dummyCmdObj()) + + assert.Error(t, err) + assert.Equal(t, maxRetries, inner.calls) +} + func TestRunWithOutputRetriesLockErrorInLinkedWorktree(t *testing.T) { // In a linked worktree the lock lives at .git/worktrees//index.lock // rather than .git/index.lock, so only matching the bare "index.lock" From 49eefbcf379935999132041f3e24d4b60f6e1d7d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 09:35:24 +0200 Subject: [PATCH 278/384] Make the user-event queue unbounded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update and friends enqueued onto a fixed 256-slot channel with a non-blocking send that panicked when the channel was full. That guard was firing in real use: - Toggling a directory of several hundred files into a custom patch (reliably): the operation runs on a worker behind a waiting status, whose spinner enqueues a content-only render on every tick, and over the long operation these outrun the UI loop and overflow the buffer. - Editing the config in an editor that suspends lazygit: the editor subprocess runs on the UI thread, so the loop drains nothing for the whole editing session, and the full refresh fired on resume fans out across every scope at once — a burst of updates that overflows before the just-resumed loop catches up. - Any time the UI thread blocks for a long time, the periodic refreshes keep enqueuing and eventually overflow. The 256-slot buffer was chosen deliberately, with the panic as a "should never happen" guard, to preserve two properties: FIFO ordering of same-goroutine Update calls (an earlier goroutine-per-Update design reordered them), and no self-deadlock (a blocking send from the UI thread would block against the loop that drains it). But a fixed channel can only offer those by crashing on overflow. Replace it with an unbounded, order-preserving queue: a mutex-guarded slice plus a buffered(1) doorbell channel that wakes the main loop's select. Enqueuing appends and rings the doorbell; the loop drains the slice to empty on each wake. This keeps FIFO order and never blocks the caller, so there is no self-deadlock and no overflow to panic on — under a stall the queue just grows and then drains. This also removes an inconsistency: updateContentOnly did a plain blocking send while update panicked, so the two paths disagreed on what happened when the queue was full. Both now share the same enqueue. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/flush_test.go | 10 +-- pkg/gocui/gui.go | 111 ++++++++++++++++++++++------- pkg/gocui/user_event_queue_test.go | 80 +++++++++++++++++++++ 3 files changed, 171 insertions(+), 30 deletions(-) create mode 100644 pkg/gocui/user_event_queue_test.go diff --git a/pkg/gocui/flush_test.go b/pkg/gocui/flush_test.go index 59bae427c..d4082fcf6 100644 --- a/pkg/gocui/flush_test.go +++ b/pkg/gocui/flush_test.go @@ -39,15 +39,15 @@ func setupViews(t *testing.T, g *Gui) (*View, *View) { return status, main } -// pushContentOnly pushes a content-only event directly to the channel -// (synchronous, deterministic — unlike Update which spawns a goroutine). +// pushContentOnly enqueues a content-only event directly, letting the test +// control the contentOnly flag (which Update/UpdateContentOnly hard-code). func pushContentOnly(g *Gui, f func(*Gui) error) { - g.userEvents <- userEvent{f: f, task: g.NewTask(), contentOnly: true} + g.userEvents.enqueue(userEvent{f: f, task: g.NewTask(), contentOnly: true}) } -// pushRegular pushes a regular event directly to the channel. +// pushRegular enqueues a regular (non-content-only) event directly. func pushRegular(g *Gui, f func(*Gui) error) { - g.userEvents <- userEvent{f: f, task: g.NewTask(), contentOnly: false} + g.userEvents.enqueue(userEvent{f: f, task: g.NewTask(), contentOnly: false}) } func TestFlushContentOnly_SkipsUntaintedViews(t *testing.T) { diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index ee1995911..103c90483 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -131,7 +131,7 @@ type Gui struct { viewMouseBindings []*ViewMouseBinding lastClick *clickInfo gEvents chan GocuiEvent - userEvents chan userEvent + userEvents *userEventQueue views []*View currentView *View managers []Manager @@ -238,12 +238,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { g.stop = make(chan struct{}) g.gEvents = make(chan GocuiEvent, 20) - // Update does a non-blocking send and panics on a full channel rather than - // blocking (which would deadlock the UI goroutine against itself) or - // silently reordering. The buffer is sized well above the peak occupancy we - // see in practice, so the panic stays unreachable in normal use; if it ever - // fires, that's a real anomaly to investigate, not a cue to grow the buffer. - g.userEvents = make(chan userEvent, 256) + g.userEvents = newUserEventQueue() g.taskManager = newTaskManager() if opts.PlayRecording { @@ -618,29 +613,83 @@ type userEvent struct { contentOnly bool } -// Update enqueues f on the user-events channel for the UI loop to run on its -// next iteration. Multiple Update calls from the same goroutine arrive in -// source order via the channel's FIFO. The send is non-blocking — if the -// channel is full we panic rather than block or silently reorder, since a -// blocked send from the UI goroutine would deadlock against itself and -// silently switching to inline execution would break the ordering guarantee -// callers rely on. The buffer is sized generously enough that this should -// never fire in practice; if it does, that's a signal to investigate, not -// to grow the buffer reflexively. -func (g *Gui) Update(f func(*Gui) error) { - task := g.NewTask() +// userEventQueue is an unbounded, order-preserving FIFO of work enqueued by +// Update and friends for the main loop to run. +// +// It's unbounded (rather than a fixed-size channel) because producers must +// never block or lose work. Update can be called from the UI goroutine itself, +// where a blocking send would deadlock against the loop that drains the queue; +// and it can be called from arbitrary worker goroutines that may enqueue faster +// than the loop drains. That happens while the loop is stalled — suspended for +// a subprocess (the editor runs on the UI thread), or hung in a long handler — +// and also when a long-running worker operation emits a steady stream of +// updates that outpaces the loop (e.g. the waiting-status spinner ticks while a +// large directory is toggled into a custom patch). A fixed channel forces a +// choice between blocking (deadlock), dropping or reordering, and panicking on +// overflow; an unbounded queue avoids all three while preserving FIFO order. +// +// enqueue appends under the mutex and rings the doorbell; the main loop selects +// on the doorbell to wake, then drains the slice to empty. The doorbell is +// buffered(1) and rung with a non-blocking send, so it's a coalescing "work +// pending" flag rather than a per-event signal: a burst of appends leaves at +// most one token, and the loop drains everything the token represents on a +// single wake. A token left over after a drain (because the drain happened to +// empty the slice after the ring) just causes one harmless empty wake. +type userEventQueue struct { + mutex sync.Mutex + events []userEvent + doorbell chan struct{} +} + +func newUserEventQueue() *userEventQueue { + return &userEventQueue{doorbell: make(chan struct{}, 1)} +} + +// enqueue appends an event and wakes the main loop. It never blocks. +func (q *userEventQueue) enqueue(ev userEvent) { + q.mutex.Lock() + q.events = append(q.events, ev) + q.mutex.Unlock() select { - case g.userEvents <- userEvent{f: f, task: task}: + case q.doorbell <- struct{}{}: default: - panic("gocui: userEvents channel full; refusing to block or reorder") } } +// dequeue pops the oldest event, reporting false when the queue is empty. +func (q *userEventQueue) dequeue() (userEvent, bool) { + q.mutex.Lock() + defer q.mutex.Unlock() + + if len(q.events) == 0 { + return userEvent{}, false + } + ev := q.events[0] + if len(q.events) == 1 { + // Release the backing array whenever the queue drains, so a one-off + // burst doesn't pin its peak size for the rest of the session. + q.events = nil + } else { + q.events[0] = userEvent{} + q.events = q.events[1:] + } + return ev, true +} + +// Update enqueues f for the UI loop to run on its next iteration. Multiple +// Update calls from the same goroutine arrive in source order (the queue is +// FIFO). The enqueue never blocks and never drops work; see userEventQueue for +// why the queue is unbounded. +func (g *Gui) Update(f func(*Gui) error) { + task := g.NewTask() + g.userEvents.enqueue(userEvent{f: f, task: task}) +} + // Like Update, but signals that the callback only modifies content. func (g *Gui) UpdateContentOnly(f func(*Gui) error) { task := g.NewTask() - g.userEvents <- userEvent{f: f, task: task, contentOnly: true} + g.userEvents.enqueue(userEvent{f: f, task: task, contentOnly: true}) } // Calls a function in a goroutine. Handles panics gracefully and tracks @@ -766,7 +815,14 @@ func (g *Gui) processEvent() error { if err := g.handleError(g.handleEvent(&ev)); err != nil { return err } - case ev := <-g.userEvents: + case <-g.userEvents.doorbell: + ev, ok := g.userEvents.dequeue() + if !ok { + // A leftover doorbell token whose events were already drained by a + // previous iteration's processRemainingEvents: nothing to run and + // nothing new to render. + return nil + } contentOnly = ev.contentOnly defer func() { ev.task.Done() }() @@ -798,15 +854,20 @@ func (g *Gui) processRemainingEvents() (bool, error) { if err := g.handleError(g.handleEvent(&ev)); err != nil { return false, err } - case ev := <-g.userEvents: + default: + // No gui event is pending; drain a queued user event instead. + // gui events take priority so input stays responsive, but they're + // bounded (buffer of 20), so this can't starve the user-event queue. + ev, ok := g.userEvents.dequeue() + if !ok { + return contentOnly, nil + } contentOnly = ev.contentOnly && contentOnly err := g.handleError(ev.f(g)) ev.task.Done() if err != nil { return false, err } - default: - return contentOnly, nil } } } diff --git a/pkg/gocui/user_event_queue_test.go b/pkg/gocui/user_event_queue_test.go new file mode 100644 index 000000000..10e86eef8 --- /dev/null +++ b/pkg/gocui/user_event_queue_test.go @@ -0,0 +1,80 @@ +package gocui + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Enqueuing far more events than the old fixed 256-slot buffer, without the +// main loop draining them, used to panic ("userEvents channel full"). It must +// not: producers can legitimately burst faster than a stalled UI loop drains +// (e.g. one command-log entry per git command when adding a large directory to +// a custom patch, or any producer while the loop is blocked in a subprocess). +// The events must also stay in FIFO order. +func TestUpdateIsUnboundedAndPreservesOrder(t *testing.T) { + g := newTestGui(t) + + const n = 1000 + var got []int + for i := range n { + g.Update(func(*Gui) error { + got = append(got, i) + return nil + }) + } + + // Drain the whole queue the way the main loop's inner drain does. + _, err := g.processRemainingEvents() + assert.NoError(t, err) + + want := make([]int, n) + for i := range want { + want[i] = i + } + assert.Equal(t, want, got) +} + +// Concurrent producers must be able to enqueue safely (run under -race). Only +// same-goroutine order is guaranteed, so we check that every event is delivered +// exactly once and that each producer's own events stay in order. +func TestUpdateConcurrentProducers(t *testing.T) { + g := newTestGui(t) + + const producers = 8 + const perProducer = 500 + + type item struct{ producer, seq int } + var got []item + + var wg sync.WaitGroup + for p := range producers { + wg.Add(1) + go func() { + defer wg.Done() + for seq := range perProducer { + g.Update(func(*Gui) error { + got = append(got, item{p, seq}) + return nil + }) + } + }() + } + // Update is a synchronous, non-blocking enqueue, so once every producer has + // returned, every event is in the queue and a single drain sees them all. + wg.Wait() + + _, err := g.processRemainingEvents() + assert.NoError(t, err) + + assert.Len(t, got, producers*perProducer) + lastSeq := make([]int, producers) + for p := range lastSeq { + lastSeq[p] = -1 + } + for _, it := range got { + assert.Equal(t, lastSeq[it.producer]+1, it.seq, "producer %d events out of order", it.producer) + lastSeq[it.producer] = it.seq + } +} From f0b139f3ab42f833b42792c10fd63f361de04a76 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 12:50:57 +0200 Subject: [PATCH 279/384] Log the user-event queue's high-water mark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the queue is unbounded, its depth is a useful signal for understanding how the event loop behaves under load — and we expect it to look very different across builds (e.g. master, which carries the bounce-state-updates-to-ui-thread work, versus the v0.63.0 release this fix ships in). Track the deepest the queue has ever been and log an Info line whenever that record is broken, so the numbers show up in the log for later reasoning. The mark is session-wide and doesn't reset when the queue drains. gocui has no logger of its own, so it exposes the new depth through a handler (matching the existing SetFocusHandler / SetOpenHyperlinkFunc pattern) that the gui registers to log via its own logger. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 32 ++++++++++++++++++++++++++++++ pkg/gocui/user_event_queue_test.go | 31 +++++++++++++++++++++++++++++ pkg/gui/gui.go | 4 ++++ 3 files changed, 67 insertions(+) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 103c90483..bb9fc9874 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -603,6 +603,13 @@ func (g *Gui) SetRenderSearchStatusFunc(renderSearchStatusFunc func(*View, int, g.renderSearchStatusFunc = renderSearchStatusFunc } +// SetUpdateQueueHighWaterMarkHandler registers a diagnostic callback invoked +// with the new depth whenever the queue of pending Update callbacks reaches a +// new maximum. It may be called from any goroutine. +func (g *Gui) SetUpdateQueueHighWaterMarkHandler(f func(depth int)) { + g.userEvents.setHighWaterMarkHandler(f) +} + // userEvent represents an event triggered by the user. type userEvent struct { f func(*Gui) error @@ -639,6 +646,13 @@ type userEventQueue struct { mutex sync.Mutex events []userEvent doorbell chan struct{} + + // highWaterMark is the deepest the queue has ever been, and + // onHighWaterMark (if set) is called with the new depth each time that + // record is broken. Purely diagnostic: it lets us see how deep the queue + // gets in practice (see SetUpdateQueueHighWaterMarkHandler). + highWaterMark int + onHighWaterMark func(int) } func newUserEventQueue() *userEventQueue { @@ -649,14 +663,32 @@ func newUserEventQueue() *userEventQueue { func (q *userEventQueue) enqueue(ev userEvent) { q.mutex.Lock() q.events = append(q.events, ev) + newHighWaterMark := 0 + if len(q.events) > q.highWaterMark { + q.highWaterMark = len(q.events) + newHighWaterMark = q.highWaterMark + } + onHighWaterMark := q.onHighWaterMark q.mutex.Unlock() + // Report outside the lock: the handler does I/O (logging) and must not + // stall other producers or the draining loop. + if newHighWaterMark > 0 && onHighWaterMark != nil { + onHighWaterMark(newHighWaterMark) + } + select { case q.doorbell <- struct{}{}: default: } } +func (q *userEventQueue) setHighWaterMarkHandler(f func(int)) { + q.mutex.Lock() + q.onHighWaterMark = f + q.mutex.Unlock() +} + // dequeue pops the oldest event, reporting false when the queue is empty. func (q *userEventQueue) dequeue() (userEvent, bool) { q.mutex.Lock() diff --git a/pkg/gocui/user_event_queue_test.go b/pkg/gocui/user_event_queue_test.go index 10e86eef8..e547debb4 100644 --- a/pkg/gocui/user_event_queue_test.go +++ b/pkg/gocui/user_event_queue_test.go @@ -36,6 +36,37 @@ func TestUpdateIsUnboundedAndPreservesOrder(t *testing.T) { assert.Equal(t, want, got) } +// The high-water-mark handler fires only when the queue reaches a new maximum +// depth, reporting that depth. It does not reset when the queue drains. +func TestUpdateQueueHighWaterMark(t *testing.T) { + g := newTestGui(t) + + var marks []int + g.SetUpdateQueueHighWaterMarkHandler(func(depth int) { marks = append(marks, depth) }) + + noop := func(*Gui) error { return nil } + + // Three enqueues with no drain: new highs 1, 2, 3. + g.Update(noop) + g.Update(noop) + g.Update(noop) + _, err := g.processRemainingEvents() + assert.NoError(t, err) + + // Two enqueues stay below the previous high of 3: no new marks. + g.Update(noop) + g.Update(noop) + _, err = g.processRemainingEvents() + assert.NoError(t, err) + + // Four enqueues with no drain: only depth 4 beats the previous high. + for range 4 { + g.Update(noop) + } + + assert.Equal(t, []int{1, 2, 3, 4}, marks) +} + // Concurrent producers must be able to enqueue safely (run under -race). Only // same-goroutine order is guaranteed, so we check that every event is delivered // exactly once and that each producer's own events stay in order. diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index e23afd124..61d4a90e2 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -414,6 +414,10 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context return nil }) + gui.g.SetUpdateQueueHighWaterMarkHandler(func(depth int) { + gui.c.Log.Infof("User-event queue reached a new high-water mark: %d", depth) + }) + gui.g.SetOnSelectSearchResultFunc(func(v *gocui.View, selectedLineIdx int) { ctx, ok := gui.helpers.View.ContextForView(v.Name()) if ok { From f116874f0a8c243642ee7ea3ae5caffa1e2ec782 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 10:07:15 +0200 Subject: [PATCH 280/384] Fix a deadlock when a Windows pty task is stopped mid-output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit winPty.Close could block indefinitely, and it is called while holding the global PtyMutex and while the task's onDone sync.Once is executing, so blocking there wedges the task's entire cleanup chain: the next NewTask call blocks on <-notifyStopped while holding waitingMutex, every later task for that view queues up behind it, and onResize blocks on PtyMutex — a full UI freeze. (Reported by a user via go-deadlock's 30s watchdog; a regression from the ConPTY support introduced for v0.63.0.) ClosePseudoConsole is what blocks; before Windows 11 24H2 it can do so in two ways. It flushes the client's pending output into the out pipe, but a stopped task's scanner goroutine has already quit draining, so with a client that's still producing output the flush never completes; this can also wedge the background waiter's closeHpc, which runs with the pipes deliberately left open. And it waits for the console host to exit, but closing only delivers CTRL_CLOSE_EVENT to the attached client without terminating it, so a client that keeps running (git still computing an expensive diff, a pager waiting for input) keeps the host alive arbitrarily long. Run the teardown on a background goroutine so Close returns immediately no matter which of these strikes, and within it close our pipe ends before the pseudoconsole, without taking p.mu: breaking the pipes fails a pending flush fast, which also unblocks a waiter already stuck in one. Co-Authored-By: Claude Fable 5 --- pkg/commands/oscommands/pty_windows.go | 53 +++++++++++++++----------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go index 0a4a06477..72ade5110 100644 --- a/pkg/commands/oscommands/pty_windows.go +++ b/pkg/commands/oscommands/pty_windows.go @@ -7,6 +7,7 @@ import ( "sync" "unsafe" + "github.com/jesseduffield/lazygit/pkg/utils" "golang.org/x/sys/windows" ) @@ -15,15 +16,13 @@ type winPty struct { inWrite *os.File outRead *os.File - // mu guards the teardown state below and serializes it against Resize. - // hpcClosed gates ClosePseudoConsole (it must run exactly once) and also - // keeps Resize from touching the HPCON once it's been freed: the - // background waiter in StartPty closes the pseudoconsole on child exit, - // which would otherwise race a concurrent onResize and hand - // ResizePseudoConsole a freed handle. + // mu guards hpcClosed, which gates ClosePseudoConsole (it must run + // exactly once) and also keeps Resize from touching the HPCON once it's + // been freed: the background waiter in StartPty closes the pseudoconsole + // on child exit, which would otherwise race a concurrent onResize and + // hand ResizePseudoConsole a freed handle. mu sync.Mutex hpcClosed bool - closed bool } func (p *winPty) Read(buf []byte) (int, error) { return p.outRead.Read(buf) } @@ -49,11 +48,6 @@ func (p *winPty) Resize(cols, rows uint16) error { func (p *winPty) closeHpc() { p.mu.Lock() defer p.mu.Unlock() - p.closeHpcLocked() -} - -// closeHpcLocked closes the pseudoconsole; the caller must hold p.mu. -func (p *winPty) closeHpcLocked() { if p.hpcClosed { return } @@ -61,18 +55,31 @@ func (p *winPty) closeHpcLocked() { windows.ClosePseudoConsole(p.hpc) } +// Close tears the pty down without waiting for it: the teardown runs on a +// background goroutine and Close returns immediately. +// +// It has to, because ClosePseudoConsole can block for a long time: before +// Windows 11 24H2 it waits for the console host to exit, and since closing +// only delivers CTRL_CLOSE_EVENT to the attached client without terminating +// it, a client that keeps running (git still computing an expensive diff, a +// pager waiting for input) keeps the host — and with it ClosePseudoConsole — +// alive arbitrarily long. Close is called while holding the global PtyMutex +// and while the task's onDone once is executing, where blocking wedges every +// subsequent task for the view (and with it the UI), so none of this may +// happen on the caller's thread. +// +// Within the teardown, the pipe ends must be closed before the +// pseudoconsole, and without holding p.mu: closing the pseudoconsole flushes +// the client's pending output into the out pipe, and with the task stopped +// nobody is reading anymore, so that flush can only complete once the pipe +// is broken. The background waiter's closeHpc may already be wedged in such +// a flush while holding p.mu; closing the pipes is what unblocks it. func (p *winPty) Close() error { - p.mu.Lock() - defer p.mu.Unlock() - if p.closed { - return nil - } - p.closed = true - // Closing the pseudoconsole breaks the pipes; the child's next write - // fails and it exits. Then we close our ends of the pipes. - p.closeHpcLocked() - p.inWrite.Close() - p.outRead.Close() + go utils.Safe(func() { + p.inWrite.Close() + p.outRead.Close() + p.closeHpc() + }) return nil } From a65d468cd3812b98c0acb2baf8ad3e548911be40 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 10:56:56 +0200 Subject: [PATCH 281/384] Determine the latest tag from the checked-out commit's history The Get Latest Tag step used to pick the most recently created tag in the entire repo, regardless of whether it is reachable from the commit being released. In preparation for supporting releases from branches other than master, use the nearest tag that is an ancestor of the checked-out commit instead. This way, a patch release cut from an older release branch bumps that branch's own latest tag even when master already carries a newer release, and the "changes since last release" check compares against the release that actually precedes this one in history. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b9815d44..84636424b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,7 +54,7 @@ jobs: - name: Get Latest Tag run: | - latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1) || echo "v0.0.0") + latest_tag=$(git describe --tags --abbrev=0 || echo "v0.0.0") if ! [[ $latest_tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Error: Tag format is invalid. Expected format: vX.X.X" From dda0af0f483b34c7105d64dceca0b088c2e7e55f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 13:16:21 +0200 Subject: [PATCH 282/384] Allow having branch and tag with the same name When creating a patch release from a branch called `v0.63.1`, the new tag would get the same name and pushing it would fail with `error: src refspec v0.63.1 matches more than one`. --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84636424b..a3a5f62c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -151,7 +151,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git tag "$NEW_TAG" -a -m "Release $NEW_TAG" - git push origin "$NEW_TAG" + git push origin "refs/tags/$NEW_TAG" - name: Setup Go uses: actions/setup-go@v6 From 1d99ba56fc768ae64ffc125c1f5d300060eed860 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 10:57:22 +0200 Subject: [PATCH 283/384] Allow releasing from a branch other than master This is useful for cutting a patch release for the previous version when master already contains work that shouldn't be released yet; for example, v0.63.1 had to be tagged and released by hand from a v0.63.1 branch off the v0.63.0 tag because the workflow could only release master. Scheduled runs are unaffected: with no input provided, the ref is empty and the checkout falls back to the default branch. --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3a5f62c2..1ef271d6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,11 @@ on: options: - minor - patch + branch: + description: 'Branch to release from' + type: string + required: true + default: 'master' ignore_blocks: description: 'Ignore blocking PRs/issues' type: boolean @@ -49,6 +54,7 @@ jobs: uses: actions/checkout@v7 with: repository: jesseduffield/lazygit + ref: ${{ inputs.branch }} token: ${{ secrets.LAZYGIT_RELEASE_PAT }} fetch-depth: 0 From bd8c06ddc0910bccd6522131a2ad9c0c7c143396 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 11:01:56 +0200 Subject: [PATCH 284/384] Rename version_bump options to be extra clear I keep getting slightly confused as to which is which, so make this extra clear. While at it, change the default to minor, this is the option that is more often used now that we don't have regular scheduled releases any more. --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ef271d6f..e3cf63bbf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,10 +13,10 @@ on: description: 'Version bump type' type: choice required: true - default: 'patch' + default: 'minor (normal)' options: - - minor - - patch + - minor (normal) + - patch (hotfix) branch: description: 'Branch to release from' type: string @@ -127,7 +127,7 @@ jobs: IFS='.' read -r major minor patch <<< "$LATEST_TAG" if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - if [[ "$VERSION_BUMP" == "patch" ]]; then + if [[ "$VERSION_BUMP" == "patch (hotfix)" ]]; then patch=$((patch + 1)) else minor=$((minor + 1)) From 7e1073a0ee1d8338bb1c3d39d3cbbd7eedbdacee Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 14:32:48 +0200 Subject: [PATCH 285/384] Extract the tcell-to-gocui event conversion out of pollEvent A following commit needs pollEvent to attach information from the replayed-event wrappers to the GocuiEvent it returns. With the conversion inlined there is no seam to do that in, because every branch of the type switch returns directly. Co-Authored-By: Claude Fable 5 --- pkg/gocui/tcell_driver.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index 226ee0580..857e35bfc 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -300,6 +300,10 @@ func (g *Gui) pollEvent() GocuiEvent { tev = <-Screen.EventQ() } + return gocuiEventFromTcellEvent(tev) +} + +func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent { switch tev := tev.(type) { case *tcell.EventInterrupt: return GocuiEvent{Type: eventInterrupt} From 664a65d5843765b6a0e89f2267c276c6e1bc1aab Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 14:45:52 +0200 Subject: [PATCH 286/384] Track replayed test input as busy from the moment it is submitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration tests synchronize with lazygit through the task manager: after submitting an input event, the test driver waits until the program goes idle before asserting. But a submitted event only got its task once the main loop picked it up from the events channel; while it was still in flight (handed to the poller goroutine, or sitting in the channel), no task existed for it, so the program could look idle even though input was still pending. The edge-triggered idle protocol mostly papers over this: each wait is satisfied by the *next* busy-to-idle transition, which in practice is the one produced by processing the submitted event. It only goes wrong when some other task (e.g. a background refresh) completes in that window, producing an edge the waiting test mistakes for its own — a rare source of test flakes. The next commit replaces that protocol with a level-triggered one, for which the window would be fatal rather than rare: a wait falling into the gap would return immediately. Close the gap by creating the task on the test goroutine before the event is submitted, and carrying it through the poller into the main loop, which uses it instead of creating its own. The new Replay* methods own this invariant, and the replayed-events channels are no longer exported, so tests can't submit an untracked event. Co-Authored-By: Claude Fable 5 --- pkg/gocui/gui.go | 46 ++++++++++++++++++++++++++++++++++----- pkg/gocui/tcell_driver.go | 28 +++++++++++++++++++----- pkg/gui/gui_driver.go | 16 +++++++------- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index ceb570c59..e5588e262 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -125,8 +125,11 @@ type clickInfo struct { // and keybindings. type Gui struct { RecordingConfig - // ReplayedEvents is for passing pre-recorded input events, for the purposes of testing - ReplayedEvents replayedEvents + // replayedEvents is for passing simulated input events, for the purposes + // of testing. Events must be submitted through the Replay* methods, which + // attach a task to each event; pushing into the channels directly would + // bypass the busy-tracking that integration tests rely on. + replayedEvents replayedEvents playRecording bool tabClickBindings []*tabClickBinding @@ -255,7 +258,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { g.taskManager = newTaskManager() if opts.PlayRecording { - g.ReplayedEvents = replayedEvents{ + g.replayedEvents = replayedEvents{ Keys: make(chan *TcellKeyEventWrapper), Resizes: make(chan *TcellResizeEventWrapper), MouseEvents: make(chan *TcellMouseEventWrapper), @@ -291,6 +294,30 @@ func (g *Gui) NewBackgroundTask() *TaskImpl { return g.taskManager.NewTask(true) } +// ReplayKeyEvent simulates a key press, as if the user had typed it. It's used +// by integration tests. The event carries a task, so that the program counts +// as busy from before the event is submitted until the main loop has fully +// processed it; the test driver relies on this when it waits for the program +// to go idle after submitting an event. (If the task were only created once +// the main loop picks the event up, there would be a window in which the event +// is still in flight but nothing counts as busy.) +func (g *Gui) ReplayKeyEvent(ev *TcellKeyEventWrapper) { + ev.task = g.NewTask() + g.replayedEvents.Keys <- ev +} + +// ReplayMouseEvent is like ReplayKeyEvent, but for mouse events. +func (g *Gui) ReplayMouseEvent(ev *TcellMouseEventWrapper) { + ev.task = g.NewTask() + g.replayedEvents.MouseEvents <- ev +} + +// ReplayFocusEvent is like ReplayKeyEvent, but for focus events. +func (g *Gui) ReplayFocusEvent(ev *TcellFocusEventWrapper) { + ev.task = g.NewTask() + g.replayedEvents.FocusEvents <- ev +} + // Busy reports whether any foreground work is in flight, ignoring the event // currently being processed on the main goroutine (see currentTask). Background // routines (auto-fetch etc.) don't count. It's used to decide whether it's safe @@ -948,7 +975,12 @@ func (g *Gui) processEvent() error { // are always the primary event here. select { case ev := <-g.gEvents: - task := g.NewTask() + // Replayed test events already carry their task (see ReplayKeyEvent); + // organic events get theirs here. + task := ev.task + if task == nil { + task = g.NewTask() + } g.currentTask = task defer func() { g.currentTask = nil; task.Done() }() @@ -992,7 +1024,11 @@ func (g *Gui) processRemainingEvents() (bool, error) { select { case ev := <-g.gEvents: contentOnly = false - if err := g.handleError(g.handleEvent(&ev)); err != nil { + err := g.handleError(g.handleEvent(&ev)) + if ev.task != nil { + ev.task.Done() + } + if err != nil { return false, err } default: diff --git a/pkg/gocui/tcell_driver.go b/pkg/gocui/tcell_driver.go index 857e35bfc..885bcbabb 100644 --- a/pkg/gocui/tcell_driver.go +++ b/pkg/gocui/tcell_driver.go @@ -172,6 +172,12 @@ type GocuiEvent struct { Focused bool Start bool N int + + // task tracks the processing of this event for idle detection. Events + // replayed by integration tests carry a task from the moment they are + // submitted (see Gui.ReplayKeyEvent); for organic events it is nil, and + // the main loop creates a task when it picks the event up. + task Task } // Event types. @@ -208,6 +214,8 @@ type TcellKeyEventWrapper struct { Mod tcell.ModMask Key tcell.Key Ch string + + task Task // see GocuiEvent.task } func NewTcellKeyEventWrapper(event *tcell.EventKey, timestamp int64) *TcellKeyEventWrapper { @@ -229,6 +237,8 @@ type TcellMouseEventWrapper struct { Y int ButtonMask tcell.ButtonMask ModMask tcell.ModMask + + task Task // see GocuiEvent.task } func NewTcellMouseEventWrapper(event *tcell.EventMouse, timestamp int64) *TcellMouseEventWrapper { @@ -269,6 +279,8 @@ func (wrapper TcellResizeEventWrapper) toTcellEvent() tcell.Event { type TcellFocusEventWrapper struct { Timestamp int64 Focused bool + + task Task // see GocuiEvent.task } func NewTcellFocusEventWrapper(event *tcell.EventFocus, timestamp int64) *TcellFocusEventWrapper { @@ -285,22 +297,28 @@ func (wrapper TcellFocusEventWrapper) toTcellEvent() tcell.Event { // pollEvent get tcell.Event and transform it into gocuiEvent func (g *Gui) pollEvent() GocuiEvent { var tev tcell.Event + var task Task if g.playRecording { select { - case ev := <-g.ReplayedEvents.Keys: + case ev := <-g.replayedEvents.Keys: tev = (ev).toTcellEvent() - case ev := <-g.ReplayedEvents.Resizes: + task = ev.task + case ev := <-g.replayedEvents.Resizes: tev = (ev).toTcellEvent() - case ev := <-g.ReplayedEvents.MouseEvents: + case ev := <-g.replayedEvents.MouseEvents: tev = (ev).toTcellEvent() - case ev := <-g.ReplayedEvents.FocusEvents: + task = ev.task + case ev := <-g.replayedEvents.FocusEvents: tev = (ev).toTcellEvent() + task = ev.task } } else { tev = <-Screen.EventQ() } - return gocuiEventFromTcellEvent(tev) + event := gocuiEventFromTcellEvent(tev) + event.task = task + return event } func gocuiEventFromTcellEvent(tev tcell.Event) GocuiEvent { diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index fef33bf66..a54530791 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -33,10 +33,10 @@ func (self *GuiDriver) PressKey(keyStr string) { self.Fail("Unrecognized key: " + keyStr) } - self.gui.g.ReplayedEvents.Keys <- gocui.NewTcellKeyEventWrapper( + self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper( tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())), 0, - ) + )) self.waitTillIdle() } @@ -44,15 +44,15 @@ func (self *GuiDriver) PressKey(keyStr string) { func (self *GuiDriver) Click(x, y int) { self.CheckAllToastsAcknowledged() - self.gui.g.ReplayedEvents.MouseEvents <- gocui.NewTcellMouseEventWrapper( + self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0), 0, - ) + )) self.waitTillIdle() - self.gui.g.ReplayedEvents.MouseEvents <- gocui.NewTcellMouseEventWrapper( + self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, tcell.ButtonNone, 0), 0, - ) + )) self.waitTillIdle() } @@ -60,10 +60,10 @@ func (self *GuiDriver) Click(x, y int) { // learns to reload changed config files. Tests use it to exercise the live // config-reload path. func (self *GuiDriver) FocusIn() { - self.gui.g.ReplayedEvents.FocusEvents <- gocui.NewTcellFocusEventWrapper( + self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( tcell.NewEventFocus(true), 0, - ) + )) self.waitTillIdle() } From 0ce857c717b391172261cb2f6498a07dccf6c076 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 14:56:27 +0200 Subject: [PATCH 287/384] Fix a deadlock between task.Done() and the integration test's idle wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the integration tests in a loop under the race detector eventually hung in demo/bisect. The goroutine dump shows the cycle: a background worker's task.Done() held the task manager's mutex while blocking on the unbuffered idle-listener channel send, and the test runner goroutine — the only reader of that channel — was itself blocked in NewTask on that same mutex, on its way to enqueueing a caption render (SetCaption -> Render -> OnUIThread). Neither side could proceed: the notification couldn't be delivered until the test goroutine got the mutex, and the mutex couldn't be released until the notification was delivered. The root problem is that the busy-to-idle notification is a blocking rendezvous performed while holding the mutex, so it needs the waiter's cooperation at a moment where the waiter may legitimately need the mutex first. Make the notification fire-and-forget instead: WaitUntilIdle waits on a condition variable and re-checks "is any task busy?" under the mutex, and the busy-to-idle transition broadcasts, which never blocks. Waiting is now level-triggered rather than edge-triggered, which is also more robust: a wait can no longer be satisfied by a stale idle transition produced by an unrelated background task, because the predicate is evaluated against the current state. This relies on the previous commit having made replayed input events carry their task from submission; without that, the wait could return in the window where an event is in flight but not yet picked up by the main loop. Co-Authored-By: Claude Fable 5 --- pkg/gocui/gui.go | 10 +++--- pkg/gocui/task_manager.go | 54 ++++++++++++++++++---------- pkg/gocui/task_manager_test.go | 66 ++++++++++++++++++++++++++++++++++ pkg/gui/gui_driver.go | 9 +++-- pkg/gui/test_mode.go | 8 ++--- 5 files changed, 112 insertions(+), 35 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index e5588e262..9da5225c0 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -326,11 +326,11 @@ func (g *Gui) Busy() bool { return g.taskManager.hasBusyForegroundTaskExcept(g.currentTask) } -// An idle listener listens for when the program is idle. This is useful for -// integration tests which can wait for the program to be idle before taking -// the next step in the test. -func (g *Gui) AddIdleListener(c chan struct{}) { - g.taskManager.addIdleListener(c) +// WaitUntilIdle blocks until the program is idle (no busy tasks). This is +// useful for integration tests which want to wait for the program to finish +// processing before taking the next step in the test. +func (g *Gui) WaitUntilIdle() { + g.taskManager.WaitUntilIdle() } // Close finalizes the library. It should be called after a successful diff --git a/pkg/gocui/task_manager.go b/pkg/gocui/task_manager.go index 23ef0f77e..8d6daaa20 100644 --- a/pkg/gocui/task_manager.go +++ b/pkg/gocui/task_manager.go @@ -6,20 +6,23 @@ import "sync" // the main goroutine or a worker goroutine). Used by integration tests // to wait until the program is idle before progressing. type TaskManager struct { - // each of these listeners will be notified when the program goes from busy to idle - idleListeners []chan struct{} - tasks map[int]Task + tasks map[int]Task // auto-incrementing id for new tasks nextId int mutex sync.Mutex + // signalled whenever the program transitions from busy to idle; used by + // WaitUntilIdle + idleCond *sync.Cond } func newTaskManager() *TaskManager { - return &TaskManager{ - tasks: make(map[int]Task), - idleListeners: []chan struct{}{}, + self := &TaskManager{ + tasks: make(map[int]Task), } + self.idleCond = sync.NewCond(&self.mutex) + + return self } func (self *TaskManager) NewTask(background bool) *TaskImpl { @@ -58,8 +61,26 @@ func (self *TaskManager) hasBusyForegroundTaskExcept(ignore Task) bool { return false } -func (self *TaskManager) addIdleListener(c chan struct{}) { - self.idleListeners = append(self.idleListeners, c) +// WaitUntilIdle blocks until no task is busy. Integration tests use it to wait +// for the program to finish processing before taking the next step. +func (self *TaskManager) WaitUntilIdle() { + self.mutex.Lock() + defer self.mutex.Unlock() + + for self.hasBusyTask() { + self.idleCond.Wait() + } +} + +// caller must hold self.mutex +func (self *TaskManager) hasBusyTask() bool { + for _, task := range self.tasks { + if task.isBusy() { + return true + } + } + + return false } func (self *TaskManager) withMutex(f func()) { @@ -68,17 +89,12 @@ func (self *TaskManager) withMutex(f func()) { f() - // Check if all tasks are done - for _, task := range self.tasks { - if task.isBusy() { - return - } - } - - // If we get here, all tasks are done, so - // notify listeners that the program is idle - for _, listener := range self.idleListeners { - listener <- struct{}{} + // Wake up any goroutine blocked in WaitUntilIdle. This must not block on + // the waiter (we hold the mutex, and the waiter may itself be trying to + // acquire it, e.g. by creating a task, before it next waits) — which is + // exactly what Broadcast guarantees. + if !self.hasBusyTask() { + self.idleCond.Broadcast() } } diff --git a/pkg/gocui/task_manager_test.go b/pkg/gocui/task_manager_test.go index 7fe706d7a..b83b678ea 100644 --- a/pkg/gocui/task_manager_test.go +++ b/pkg/gocui/task_manager_test.go @@ -2,6 +2,7 @@ package gocui import ( "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -61,3 +62,68 @@ func TestTaskManagerHasBusyForegroundTaskExcept(t *testing.T) { assert.False(t, tm.hasBusyForegroundTaskExcept(current)) }) } + +func TestTaskManagerWaitUntilIdle(t *testing.T) { + // returnsWithin reports whether f returns within the given duration. + returnsWithin := func(d time.Duration, f func()) bool { + done := make(chan struct{}) + go func() { + f() + close(done) + }() + select { + case <-done: + return true + case <-time.After(d): + return false + } + } + + t.Run("returns immediately when no task was ever created", func(t *testing.T) { + tm := newTaskManager() + assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle)) + }) + + t.Run("blocks while a task is busy", func(t *testing.T) { + tm := newTaskManager() + tm.NewTask(false) + assert.False(t, returnsWithin(50*time.Millisecond, tm.WaitUntilIdle)) + }) + + t.Run("wakes up when the last busy task completes", func(t *testing.T) { + tm := newTaskManager() + task := tm.NewTask(false) + go func() { + time.Sleep(10 * time.Millisecond) + task.Done() + }() + assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle)) + }) + + t.Run("a paused task counts as idle", func(t *testing.T) { + tm := newTaskManager() + task := tm.NewTask(false) + task.Pause() + assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle)) + }) + + t.Run("a task completing while nobody waits must not block", func(t *testing.T) { + // This is the deadlock case: the waiter (the integration-test runner) + // is between waits, and itself needs the task manager's mutex (it + // creates a task whenever it enqueues work) before it waits again. The + // idle notification must neither block the completing task while it + // holds the mutex, nor get lost. + tm := newTaskManager() + assert.True(t, returnsWithin(time.Second, func() { + // the program goes idle with nobody waiting... + tm.NewTask(true).Done() + + // ...and creating and completing more tasks afterwards must still + // be possible + task := tm.NewTask(false) + tm.NewTask(false).Done() + task.Done() + })) + assert.True(t, returnsWithin(time.Second, tm.WaitUntilIdle)) + }) +} diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index a54530791..31094b253 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -17,10 +17,9 @@ import ( // this gives our integration test a way of interacting with the gui for sending keypresses // and reading state. type GuiDriver struct { - gui *Gui - isIdleChan chan struct{} - toastChan chan string - headless bool + gui *Gui + toastChan chan string + headless bool } var _ integrationTypes.GuiDriver = &GuiDriver{} @@ -79,7 +78,7 @@ func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() { // wait until lazygit is idle (i.e. all processing is done) before continuing func (self *GuiDriver) waitTillIdle() { - <-self.isIdleChan + self.gui.g.WaitUntilIdle() } func (self *GuiDriver) CheckAllToastsAcknowledged() { diff --git a/pkg/gui/test_mode.go b/pkg/gui/test_mode.go index 2ba381078..2d5958fbb 100644 --- a/pkg/gui/test_mode.go +++ b/pkg/gui/test_mode.go @@ -23,12 +23,8 @@ func (gui *Gui) handleTestMode() { } if test != nil { - isIdleChan := make(chan struct{}) - - gui.c.GocuiGui().AddIdleListener(isIdleChan) - waitUntilIdle := func() { - <-isIdleChan + gui.c.GocuiGui().WaitUntilIdle() } go func() { @@ -38,7 +34,7 @@ func (gui *Gui) handleTestMode() { gui.PopupHandler.(*popup.PopupHandler).SetToastFunc( func(message string, kind types.ToastKind) { toastChan <- message }) - test.Run(&GuiDriver{gui: gui, isIdleChan: isIdleChan, toastChan: toastChan, headless: Headless()}) + test.Run(&GuiDriver{gui: gui, toastChan: toastChan, headless: Headless()}) gui.g.Update(func(*gocui.Gui) error { return gocui.ErrQuit From d786c9d79bbf7370705d2140acb7398287b3bcd2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 16 Jul 2026 09:11:16 +0200 Subject: [PATCH 288/384] Escape the merge conflicts view before prompting to continue the rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the last conflict of a file is resolved, a files refresh both offers to continue the rebase/merge (if we started it ourselves) and, via its merge-conflicts scope, escapes from the merge conflicts view back to the files context. The two race: the prompt is bounced onto the UI thread by the files worker, while the escape's context push is queued separately by EscapeMerge, and it deliberately refuses to push the files context over a popup. So if the prompt opens first, the escape does nothing, and closing the prompt lands the user in the stale merge conflicts view — usually already emptied by the escape's state reset — instead of the files panel. No later refresh rescues this. Fix this by escaping from the merge conflicts view right before opening the prompt. This runs on the UI thread and doesn't hold the merge conflicts mutex, so it can reset the state and push the files context synchronously; whichever side runs first, the prompt now always opens on top of the files context, and EscapeMerge's guarded push still does nothing only when that's the right thing to do. This is a timing race with no deterministic regression test; it showed up as a rare flake in tests that cancel the continue prompt (e.g. commit/amend_when_there_are_conflicts_and_continue) when looping the integration tests under the race detector. Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 3c8524ffe..21fdc8a7a 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1207,6 +1207,19 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // for operations we started ourselves; prompting for one that was // started outside lazygit (e.g. by a coding agent) would be confusing. self.onUIThreadUnlessRepoChanged(env, func() error { + // The merge-conflicts scope of this refresh also notices that + // the conflicts are gone and escapes from the merge conflicts + // view to the files context (see RefreshMergeState), but it + // runs concurrently with us, and its escape refuses to push + // the files context over a popup. So if our prompt opens + // first, the escape does nothing, and closing the prompt + // would land the user in the dead merge conflicts view. + // Escape it ourselves before opening the prompt, so that the + // prompt always opens on top of the files context. + if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) { + self.mergeConflictsHelper.ResetMergeState() + self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) + } return self.mergeAndRebaseHelper.PromptToContinueRebase() }) } From 14d717d77f7ac2e722656d5405cac61b29d54379 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 08:41:25 +0200 Subject: [PATCH 289/384] Bump tcell to an unreleased snapshot to fix a shutdown race tcell's filterEvents goroutine sends events into eventQ with a plain blocking send, while Fini (via finish/finalize) closes eventQ after closing the quit channel. The goroutine can have already committed to the ev = <-inQ select arm when quit is closed, so its send into eventQ races with the close; the race detector flags this (send and close on the same channel are unsynchronized), and if the close wins, the send panics with "send on closed channel". This was caught by the integration tests under the race detector, where every test drives a real tScreen over a MockTerm and tears it down via Fini, but it equally affects real-terminal shutdown. Upstream fixed it in 243630d2 ("Fix screen Init/Fini races") by tracking the filter goroutine in a WaitGroup that finalize waits for before closing eventQ, and guarding the send with a select on quit. That commit is not in a tagged release yet (latest is v3.4.0), so pin the pseudo-version; the delta over v3.4.0 is just this fix, a Windows key-release fix, a cell-rendering perf tweak, and dependency bumps. Co-Authored-By: Claude Fable 5 --- go.mod | 10 ++-- go.sum | 20 ++++---- vendor/github.com/gdamore/tcell/v3/cell.go | 18 ++++--- vendor/github.com/gdamore/tcell/v3/tscreen.go | 28 ++++++++++- .../gdamore/tcell/v3/tty/tty_win.go | 49 ++++++++++++++----- vendor/modules.txt | 10 ++-- 6 files changed, 97 insertions(+), 38 deletions(-) diff --git a/go.mod b/go.mod index c10004176..12af7493b 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/cli/go-gh/v2 v2.13.0 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 github.com/creack/pty v1.1.24 - github.com/gdamore/tcell/v3 v3.4.0 + github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 github.com/go-errors/errors v1.5.1 github.com/gookit/color v1.6.1 github.com/integrii/flaggy v1.8.0 @@ -65,11 +65,11 @@ require ( github.com/onsi/gomega v1.34.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect - golang.org/x/mod v0.35.0 // indirect + golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.55.0 // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.45.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect diff --git a/go.sum b/go.sum index 1ca3151b4..2f734de11 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v3 v3.4.0 h1:VUym1HQZiYodA5PGQrqLxF7QwqQndcAUwQD7G7XUy5E= -github.com/gdamore/tcell/v3 v3.4.0/go.mod h1:fjKxNiIFwbzTxDU+i+AAMz+xPOgXVaZq5tbShsKseHc= +github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 h1:kUXexBZYoVdAJIOIuP6uLgK3k0G7ClDIRO27Z3epgtU= +github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59/go.mod h1:Ev/2PFhL0QtVmu6XPZG9NEuITAZ6XH7i7/BF3wupBdw= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= @@ -139,8 +139,8 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -168,21 +168,21 @@ golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/vendor/github.com/gdamore/tcell/v3/cell.go b/vendor/github.com/gdamore/tcell/v3/cell.go index cbe2732de..b3be03b13 100644 --- a/vendor/github.com/gdamore/tcell/v3/cell.go +++ b/vendor/github.com/gdamore/tcell/v3/cell.go @@ -72,12 +72,18 @@ func (cb *CellBuffer) put(x int, y int, str string, style Style) (string, int) { if x >= 0 && y >= 0 && x < cb.w && y < cb.h { var cl string c := &cb.cells[(y*cb.w)+x] - g := textWidthOptions.StringGraphemes(str) - for width == 0 && g.Next() { - cluster := g.Value() - cl += cluster - width = g.Width() - str = str[len(cluster):] + if str == c.currStr && c.width > 0 { + // Identical re-Put (a full-screen redraw): the grapheme split is + // unchanged, so reuse the measured width instead of segmenting. + cl, width, str = str, c.width, "" + } else { + g := textWidthOptions.StringGraphemes(str) + for width == 0 && g.Next() { + cluster := g.Value() + cl += cluster + width = g.Width() + str = str[len(cluster):] + } } // Wide characters: we want to mark the "wide" cells diff --git a/vendor/github.com/gdamore/tcell/v3/tscreen.go b/vendor/github.com/gdamore/tcell/v3/tscreen.go index 2fa180f63..9fe3cf8fe 100644 --- a/vendor/github.com/gdamore/tcell/v3/tscreen.go +++ b/vendor/github.com/gdamore/tcell/v3/tscreen.go @@ -243,6 +243,7 @@ type tScreen struct { legacy bool hasClipboard bool // true if OSC 52 reported via DA1 finiOnce sync.Once + initFiniLock sync.Mutex enterUrl string exitUrl string setWinSize string @@ -258,6 +259,7 @@ type tScreen struct { running bool startTime time.Time wg sync.WaitGroup + eventWg sync.WaitGroup mouseFlags MouseFlags pasteEnabled bool focusEnabled bool @@ -355,6 +357,20 @@ func (t *tScreen) applyEnvironmentOverrides() { } func (t *tScreen) Init() error { + t.initFiniLock.Lock() + defer t.initFiniLock.Unlock() + + t.Lock() + if t.fini { + t.Unlock() + return errors.New("screen finalized") + } + if t.running { + t.Unlock() + return errors.New("already initialized") + } + t.Unlock() + if e := t.initialize(); e != nil { return e } @@ -525,7 +541,9 @@ func (t *tScreen) processInitQ() { func (t *tScreen) filterEvents() chan Event { inQ := make(chan Event, 128) + t.eventWg.Add(1) go func() { + defer t.eventWg.Done() for { var ev Event select { @@ -541,7 +559,11 @@ func (t *tScreen) filterEvents() chan Event { } default: - t.eventQ <- ev + select { + case t.eventQ <- ev: + case <-t.quit: + return + } } } }() @@ -596,6 +618,9 @@ func (t *tScreen) prepareCursorStyles() { } func (t *tScreen) Fini() { + t.initFiniLock.Lock() + defer t.initFiniLock.Unlock() + // Ensure that enough time passes for terminals to finish sending // their initial response (gnome-terminal sends terminal dimensions // asynchronously later than the response to primary DA for some reason.) @@ -1659,6 +1684,7 @@ func (t *tScreen) Beep() error { func (t *tScreen) finalize() { t.disengage() _ = t.tty.Close() + t.eventWg.Wait() close(t.eventQ) } diff --git a/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go b/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go index 853c1e136..e17cd7dab 100644 --- a/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go +++ b/vendor/github.com/gdamore/tcell/v3/tty/tty_win.go @@ -20,6 +20,7 @@ package tty import ( "encoding/binary" "errors" + "fmt" "sync" "syscall" "time" @@ -98,6 +99,38 @@ type inputRecord struct { data [16]byte } +func encodeWinKeyRecord(data [16]byte, surrogate *rune) []byte { + keyDown := binary.LittleEndian.Uint32(data[0:]) != 0 + repeat := binary.LittleEndian.Uint16(data[4:]) + virtualKey := binary.LittleEndian.Uint16(data[6:]) + scanCode := binary.LittleEndian.Uint16(data[8:]) + // we normally only expect to see ascii, but paste data may come in as UTF-16. + wc := rune(binary.LittleEndian.Uint16(data[10:])) + controlState := binary.LittleEndian.Uint32(data[12:]) + + if virtualKey != 0 || scanCode != 0 { + kd := 0 + if keyDown { + kd = 1 + } + return fmt.Appendf(nil, "\x1b[%d;%d;%d;%d;%d;%d_", + virtualKey, scanCode, wc, kd, controlState, max(1, repeat)) + } + + if !keyDown { + return nil + } + + var encoded []byte + decodedRunes := decodeUTF16Rune(surrogate, wc) + for range max(1, repeat) { + for _, decoded := range decodedRunes { + encoded = append(encoded, []byte(string(decoded))...) + } + } + return encoded +} + type winTty struct { buf chan byte out syscall.Handle @@ -207,17 +240,11 @@ func (w *winTty) getConsoleInput() error { ir := rec[i] switch ir.typ { case keyEvent: - // we normally only expect to see ascii, but paste data may come in as UTF-16. - wc := rune(binary.LittleEndian.Uint16(ir.data[10:])) - for _, decoded := range decodeUTF16Rune(&w.surrogate, wc) { - for _, chr := range []byte(string(decoded)) { - // We normally expect only to see ASCII (win32-input-mode), - // but apparently pasted data can arrive in UTF-16 here. - select { - case w.buf <- chr: - case <-w.stopQ: - break loop - } + for _, chr := range encodeWinKeyRecord(ir.data, &w.surrogate) { + select { + case w.buf <- chr: + case <-w.stopQ: + break loop } } diff --git a/vendor/modules.txt b/vendor/modules.txt index 3b50432bf..2c60d9282 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -48,7 +48,7 @@ github.com/fatih/color # github.com/gdamore/encoding v1.0.1 ## explicit; go 1.9 github.com/gdamore/encoding -# github.com/gdamore/tcell/v3 v3.4.0 +# github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 ## explicit; go 1.25.0 github.com/gdamore/tcell/v3 github.com/gdamore/tcell/v3/color @@ -175,7 +175,7 @@ github.com/xo/terminfo ## explicit; go 1.20 golang.org/x/exp/constraints golang.org/x/exp/slices -# golang.org/x/mod v0.35.0 +# golang.org/x/mod v0.36.0 ## explicit; go 1.25.0 golang.org/x/mod/internal/lazyregexp golang.org/x/mod/modfile @@ -192,10 +192,10 @@ golang.org/x/sync/semaphore golang.org/x/sys/plan9 golang.org/x/sys/unix golang.org/x/sys/windows -# golang.org/x/term v0.43.0 +# golang.org/x/term v0.44.0 ## explicit; go 1.25.0 golang.org/x/term -# golang.org/x/text v0.37.0 +# golang.org/x/text v0.38.0 ## explicit; go 1.25.0 golang.org/x/text/cases golang.org/x/text/encoding @@ -208,7 +208,7 @@ golang.org/x/text/language golang.org/x/text/runes golang.org/x/text/transform golang.org/x/text/unicode/norm -# golang.org/x/tools v0.44.0 +# golang.org/x/tools v0.45.0 ## explicit; go 1.25.0 golang.org/x/tools/go/ast/astutil # gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c From 38e1fe0493325283128519f36746ffdc7aa30894 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 11:56:47 +0200 Subject: [PATCH 290/384] Add tests showing ghost views when branches/commits are not their panel's first tab With gui.sidePanels, a panel's gocui window is named after its first tab. The transient contexts (remoteBranches, subCommits, commitFiles) initially point at the windows "branches" and "commits"; when the config gives no panel that name, their views end up visible at full screen size, covering every side panel below them in z-order (issue #5823). Co-Authored-By: Claude Fable 5 --- pkg/integration/tests/test_list.go | 2 ++ .../tests/ui/branches_not_first_tab.go | 34 +++++++++++++++++++ .../tests/ui/commits_not_first_tab.go | 32 +++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 pkg/integration/tests/ui/branches_not_first_tab.go create mode 100644 pkg/integration/tests/ui/commits_not_first_tab.go diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index abf13073e..8213d5159 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -484,6 +484,8 @@ var tests = []*components.IntegrationTest{ tag.Reset, tag.ResetToDuplicateNamedBranch, ui.Accordion, + ui.BranchesNotFirstTab, + ui.CommitsNotFirstTab, ui.DisableSwitchTabWithPanelJumpKeys, ui.EmptyMenu, ui.HideSidePanel, diff --git a/pkg/integration/tests/ui/branches_not_first_tab.go b/pkg/integration/tests/ui/branches_not_first_tab.go new file mode 100644 index 000000000..0e60b5ed9 --- /dev/null +++ b/pkg/integration/tests/ui/branches_not_first_tab.go @@ -0,0 +1,34 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var BranchesNotFirstTab = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "With gui.sidePanels grouping branches behind another tab, no ghost view must appear over the side panels", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{ + {"worktrees", "branches", "remotes"}, + {"files"}, + {"commits", "tags"}, + {"stash"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // The remote branches and sub-commits views are only shown after + // drilling into a remote or a branch; at startup both must be hidden, + // or they'd cover the side panels. + t.Views().RemoteBranches(). + /* EXPECTED: + IsInvisible() + ACTUAL: */ + IsVisible() + t.Views().SubCommits().IsInvisible() + }, +}) diff --git a/pkg/integration/tests/ui/commits_not_first_tab.go b/pkg/integration/tests/ui/commits_not_first_tab.go new file mode 100644 index 000000000..db50e4c08 --- /dev/null +++ b/pkg/integration/tests/ui/commits_not_first_tab.go @@ -0,0 +1,32 @@ +package ui + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var CommitsNotFirstTab = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "With gui.sidePanels grouping commits behind another tab, no ghost view must appear over the side panels", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + cfg.GetUserConfig().Gui.SidePanels = []config.SidePanel{ + {"branches", "worktrees", "remotes"}, + {"files"}, + {"tags", "commits"}, + {"stash"}, + } + }, + SetupRepo: func(shell *Shell) { + shell.EmptyCommit("one") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + // The commit files view is only shown after drilling into a commit; at + // startup it must be hidden, or it'd cover the side panels. + t.Views().CommitFiles(). + /* EXPECTED: + IsInvisible() + ACTUAL: */ + IsVisible() + }, +}) From bf4f5827e748574b9267520f48856e2c5ff7f049 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 12:00:00 +0200 Subject: [PATCH 291/384] Don't show a transient view whose window is not part of the layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With gui.sidePanels, a panel's gocui window is named after its first tab, so when branches is grouped behind, say, worktrees, there is no window called "branches" at all. The transient contexts (remoteBranches, subCommits, commitFiles) initially point at the windows "branches" and "commits", and layout() showed their views whenever the window-to-view map named them as their window's current view — without checking that the window exists in the layout. Since the map is seeded from the contexts themselves, a window that no panel owns keeps naming a transient view as its current view, and that view had just been parked at full screen size (the fallback for views in unlaid-out windows), so it covered every side panel below it in z-order. Only show a transient view if its window actually received dimensions in this layout. Fixes #5823. Co-Authored-By: Claude Fable 5 --- pkg/gui/layout.go | 9 ++++++++- pkg/integration/tests/ui/branches_not_first_tab.go | 3 --- pkg/integration/tests/ui/commits_not_first_tab.go | 3 --- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index de3bdbe9b..bcdc0edfc 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -154,7 +154,14 @@ func (gui *Gui) layout(g *gocui.Gui) error { if err != nil && !errors.Is(err, gocui.ErrUnknownView) { return err } - view.Visible = gui.helpers.Window.GetViewNameForWindow(context.GetWindowName()) == context.GetViewName() + // A transient view is visible if it is the view its window is currently + // showing — but only if that window is part of the layout at all. For a + // window without dimensions, setViewFromDimensions parks the view at full + // screen size in the background, so making it visible would cover all + // windows below it. + _, windowHasDimensions := viewDimensions[context.GetWindowName()] + view.Visible = windowHasDimensions && + gui.helpers.Window.GetViewNameForWindow(context.GetWindowName()) == context.GetViewName() } if gui.PrevLayout.Information != informationStr { diff --git a/pkg/integration/tests/ui/branches_not_first_tab.go b/pkg/integration/tests/ui/branches_not_first_tab.go index 0e60b5ed9..47e8a4fd5 100644 --- a/pkg/integration/tests/ui/branches_not_first_tab.go +++ b/pkg/integration/tests/ui/branches_not_first_tab.go @@ -25,10 +25,7 @@ var BranchesNotFirstTab = NewIntegrationTest(NewIntegrationTestArgs{ // drilling into a remote or a branch; at startup both must be hidden, // or they'd cover the side panels. t.Views().RemoteBranches(). - /* EXPECTED: IsInvisible() - ACTUAL: */ - IsVisible() t.Views().SubCommits().IsInvisible() }, }) diff --git a/pkg/integration/tests/ui/commits_not_first_tab.go b/pkg/integration/tests/ui/commits_not_first_tab.go index db50e4c08..505aa4307 100644 --- a/pkg/integration/tests/ui/commits_not_first_tab.go +++ b/pkg/integration/tests/ui/commits_not_first_tab.go @@ -24,9 +24,6 @@ var CommitsNotFirstTab = NewIntegrationTest(NewIntegrationTestArgs{ // The commit files view is only shown after drilling into a commit; at // startup it must be hidden, or it'd cover the side panels. t.Views().CommitFiles(). - /* EXPECTED: IsInvisible() - ACTUAL: */ - IsVisible() }, }) From 74a77e58be5f8c478481fe4ee3b1ce6de3351600 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 12:02:23 +0200 Subject: [PATCH 292/384] Assign the transient contexts' initial windows from the side panel config The transient contexts (remoteBranches, subCommits, commitFiles) take over the window of the context they are drilled into from, but until then they carry a hardcoded initial window ("branches" or "commits"). Under a gui.sidePanels config where those tabs aren't their panel's first, no window of that name exists, leaving the window-to-view map with entries for windows the layout never produces. The previous commit made such entries harmless, but there's no reason to have contexts point at nonexistent windows in the first place; assign them the window hosting branches or commits instead, which the config validation guarantees to exist. Co-Authored-By: Claude Fable 5 --- pkg/gui/side_panels.go | 9 +++++++++ pkg/gui/side_panels_test.go | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/pkg/gui/side_panels.go b/pkg/gui/side_panels.go index 361d54fb1..8b327b197 100644 --- a/pkg/gui/side_panels.go +++ b/pkg/gui/side_panels.go @@ -125,4 +125,13 @@ func (gui *Gui) assignSidePanelWindows(contextTree *context.ContextTree) { ctx.SetWindowName(name) } } + + // The transient contexts take over the window of the context they are + // drilled into from, but they need a valid initial window before their + // first use. Assign the window hosting branches or commits, respectively; + // unlike e.g. remotes, those tabs can't be hidden, so their windows are + // always part of the layout. + contextTree.RemoteBranches.SetWindowName(contextTree.Branches.GetWindowName()) + contextTree.SubCommits.SetWindowName(contextTree.Branches.GetWindowName()) + contextTree.CommitFiles.SetWindowName(contextTree.LocalCommits.GetWindowName()) } diff --git a/pkg/gui/side_panels_test.go b/pkg/gui/side_panels_test.go index b1240c357..24813b4ae 100644 --- a/pkg/gui/side_panels_test.go +++ b/pkg/gui/side_panels_test.go @@ -28,3 +28,23 @@ func TestSidePanelLookupsCoverAllValidTabs(t *testing.T) { assert.Equal(t, want, sortedKeys(gui.sidePanelTabTitles())) assert.Equal(t, want, sortedKeys(sidePanelContexts(gui.contextTree()))) } + +// The transient contexts must end up in windows that exist under the configured +// panel layout, or their views would be laid out for a window that is never +// shown. +func TestAssignSidePanelWindowsCoversTransientContexts(t *testing.T) { + gui := NewDummyGui() + gui.c.UserConfig().Gui.SidePanels = []config.SidePanel{ + {"worktrees", "branches", "remotes"}, + {"files"}, + {"tags", "commits"}, + {"stash"}, + } + + contextTree := gui.contextTree() + gui.assignSidePanelWindows(contextTree) + + assert.Equal(t, "worktrees", contextTree.RemoteBranches.GetWindowName()) + assert.Equal(t, "worktrees", contextTree.SubCommits.GetWindowName()) + assert.Equal(t, "tags", contextTree.CommitFiles.GetWindowName()) +} From d097519c05174ed2dd98251e9aafc4d38c6074f3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 19:13:33 +0200 Subject: [PATCH 293/384] Serialize concurrent writes to the streamed command's output writer runAndStreamAux funnels a command's stdout and stderr into a single cmdWriter (the command-log panel, or a buffer when output is suppressed) from two separate goroutines: stderr through the MultiWriter set on cmd.Stderr, and stdout through the onRun callback. Those goroutines wrote the shared writer without any synchronization, racing on the prefixWriter's prefixWritten flag and interleaving the two streams. Wrap the writer so its writes are serialized. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/oscommands/cmd_obj_runner.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/pkg/commands/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go index b70668431..74b7e721d 100644 --- a/pkg/commands/oscommands/cmd_obj_runner.go +++ b/pkg/commands/oscommands/cmd_obj_runner.go @@ -244,6 +244,10 @@ func (self *cmdObjRunner) runAndStreamAux( } else { cmdWriter = self.guiIO.newCmdWriterFn() } + // The command's stdout and stderr are streamed to cmdWriter concurrently + // from separate goroutines (stderr via the MultiWriter below, stdout via + // onRun), so it must be safe for concurrent writes. + cmdWriter = &synchronizedWriter{writer: cmdWriter} if cmdObj.ShouldLog() { self.logCmdObj(cmdObj) @@ -451,6 +455,20 @@ func (self *cmdObjRunner) getCheckForCredentialRequestFunc() func([]byte) (Crede } } +// synchronizedWriter serializes writes to its underlying writer so that it can +// be written from multiple goroutines at once (see runAndStreamAux, which +// streams a command's stdout and stderr to one writer from two goroutines). +type synchronizedWriter struct { + mutex deadlock.Mutex + writer io.Writer +} + +func (self *synchronizedWriter) Write(p []byte) (int, error) { + self.mutex.Lock() + defer self.mutex.Unlock() + return self.writer.Write(p) +} + type Buffer struct { b bytes.Buffer m deadlock.Mutex From a61be44e920948cfef3dbe5adf0e7893e1447dd3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 20:12:36 +0200 Subject: [PATCH 294/384] Wait for the streamed command's output goroutine before reading its buffers runAndStreamAux reads the stdout buffer (and, when output is suppressed, the combinedOutput buffer) for its error message after handler.wait() returns, but the goroutine that fills those buffers by draining the command's output isn't awaited, so the reads raced its final writes. Own the goroutine here rather than letting the onRun callbacks spawn it, and join it before reading the buffers. The pty reader reaches EOF on its own once the process exits, but the non-pty pipe never does, so its handler now closes the read end to unblock the reader; the pipe is synchronous, so by the time the command has exited all of its output has already been read and nothing is lost. This also plugs the goroutine that the non-pty streaming path previously leaked on every command. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/oscommands/cmd_obj_runner.go | 37 +++++++++++++++++------ 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/pkg/commands/oscommands/cmd_obj_runner.go b/pkg/commands/oscommands/cmd_obj_runner.go index 74b7e721d..6178ac816 100644 --- a/pkg/commands/oscommands/cmd_obj_runner.go +++ b/pkg/commands/oscommands/cmd_obj_runner.go @@ -227,9 +227,7 @@ type cmdHandler struct { func (self *cmdObjRunner) runAndStream(cmdObj *CmdObj) error { return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) { - go func() { - _, _ = io.Copy(cmdWriter, handler.stdoutPipe) - }() + _, _ = io.Copy(cmdWriter, handler.stdoutPipe) }) } @@ -280,10 +278,29 @@ func (self *cmdObjRunner) runAndStreamAux( t := time.Now() - onRun(handler, cmdWriter) + // Stream the command's output on a goroutine while it runs, but keep a + // handle on it: the buffers it fills (stdout, and combinedOutput when + // output is suppressed) must not be read below until it has finished. + streamingDone := make(chan struct{}) + go utils.Safe(func() { + defer close(streamingDone) + onRun(handler, cmdWriter) + }) err = handler.wait() + // The command has exited; wait for the streaming goroutine to drain the + // last of its output before reading those buffers. A pty reader reaches + // EOF on its own now the process is gone, but the non-pty pipe never does, + // so close it to unblock the reader — the pipe is synchronous, so all + // output has already been read by now and nothing is lost. + if !cmdObj.ShouldUsePty() { + if closeErr := handler.close(); closeErr != nil { + self.log.Error(closeErr) + } + } + <-streamingDone + self.log.Infof("%s (%s)", cmdObj.ToString(), time.Since(t)) if err != nil { @@ -358,10 +375,7 @@ func (self *cmdObjRunner) runAndDetectCredentialRequest( return self.runAndStreamAux(cmdObj, func(handler *cmdHandler, cmdWriter io.Writer) { tr := io.TeeReader(handler.stdoutPipe, cmdWriter) - - go utils.Safe(func() { - self.processOutput(tr, handler.stdinPipe, promptUserForCredential, handler.close, cmdObj) - }) + self.processOutput(tr, handler.stdinPipe, promptUserForCredential, handler.close, cmdObj) }) } @@ -500,8 +514,11 @@ func (self *cmdObjRunner) getCmdHandlerNonPty(cmd *exec.Cmd) (*cmdHandler, error return &cmdHandler{ stdoutPipe: stdoutReader, stdinPipe: buf, - close: func() error { return nil }, - wait: cmd.Wait, + // Closing the read end makes a blocked read on it return, which is how + // runAndStreamAux unblocks and joins the streaming goroutine once the + // command has finished (the pipe delivers no EOF of its own). + close: func() error { return stdoutReader.Close() }, + wait: cmd.Wait, }, nil } From 2765147b711826b54408b48b5e3850fa09d39e61 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 18:11:30 +0200 Subject: [PATCH 295/384] Note in AGENTS.md that gocui lives in-tree Agents (and humans new to the repo) repeatedly go looking for the gocui sources in go.mod, go.sum, or the module cache and hit a dead end, because gocui is a fork maintained in-tree under pkg/gocui rather than pulled in as a dependency. Record that in AGENTS.md so the dead end is avoided. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 947add510..88e818249 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -411,3 +411,12 @@ Never run `find` (or similar) from `/` or other paths outside the project. All third-party code we use is vendored under `vendor/`, so dependency sources are reachable from inside the working tree — search there instead of the host filesystem. + +## gocui is in-tree, not a dependency + +The `gocui` TUI library is a fork maintained directly in this repo under +`pkg/gocui` — it's an ordinary package, not a Go module dependency. Don't look +for it in `go.mod`/`go.sum` or the module cache (`$GOMODCACHE`); it isn't +there. When you need to read or change gocui internals (the task manager, the +event loop, worker/UI-thread dispatch, view rendering), edit `pkg/gocui` +directly. From 36f193a2e86bfd19c8e651d0bfa7f23524e125c4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 12:00:33 +0200 Subject: [PATCH 296/384] Remove return value from PromptToContinueRebase It always returned nil. --- pkg/gui/controllers/helpers/merge_and_rebase_helper.go | 4 +--- pkg/gui/controllers/helpers/refresh_helper.go | 3 ++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index b0c53b831..267453a86 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -321,7 +321,7 @@ func (self *MergeAndRebaseHelper) AbortMergeOrRebaseWithConfirm() error { } // PromptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress -func (self *MergeAndRebaseHelper) PromptToContinueRebase() error { +func (self *MergeAndRebaseHelper) PromptToContinueRebase() { self.continueRebasePromptShowing = true self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.Continue, @@ -373,8 +373,6 @@ func (self *MergeAndRebaseHelper) PromptToContinueRebase() error { return nil }, }) - - return nil } // DismissContinueRebasePromptIfShowing closes the "continue the rebase/merge?" diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 21fdc8a7a..dd6b87ae0 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1220,7 +1220,8 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re self.mergeConflictsHelper.ResetMergeState() self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) } - return self.mergeAndRebaseHelper.PromptToContinueRebase() + self.mergeAndRebaseHelper.PromptToContinueRebase() + return nil }) } } else { From 504e5b3f741d0149deda0ffdbbb7fa60be3e7669 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 12:01:26 +0200 Subject: [PATCH 297/384] Remove the error return value from the onUIThreadUnlessRepoChanged lambda All clients pass a function that returns nil. --- pkg/gui/controllers/helpers/refresh_helper.go | 72 +++++++------------ 1 file changed, 26 insertions(+), 46 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index dd6b87ae0..47cd51ff7 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -358,9 +358,8 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // Model.Files (via Files.GetSelected) and would otherwise // see the pre-refresh model. Guard on the generation so a // repo switch mid-refresh drops it, like the model bounces. - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) - return nil }) }) } @@ -622,7 +621,7 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS // The commit selection is restored in refreshCommitsWithLimit's bounce, // so read it on the UI thread after that bounce; then load the commit // files back on a worker (refreshCommitFilesContext does git work). - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { commit := self.c.Contexts().LocalCommits.GetSelected() if commit != nil && commit.RefName() != "" { refRange := self.c.Contexts().LocalCommits.GetSelectedRefRangeForDiffFiles() @@ -635,7 +634,6 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS return nil }) } - return nil }) } } @@ -687,7 +685,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, } workingTreeState := self.c.Git().Status.WorkingTreeState() - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits self.RefreshAuthors(commits) @@ -721,12 +719,10 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, // Enqueued from within this bounce so it runs after refreshView's // render below (which was enqueued first), matching the previous // ordering where FocusLine ran after the view was re-rendered. - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Contexts().LocalCommits.FocusLine(true) - return nil }) } - return nil }) self.refreshView(self.c.Contexts().LocalCommits, env) @@ -856,10 +852,9 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommit if err != nil { return err } - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().SubCommits = commits self.RefreshAuthors(commits) - return nil }) self.refreshView(self.c.Contexts().SubCommits, env) @@ -899,10 +894,9 @@ func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFile if err != nil { return err } - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().CommitFiles = files self.c.Contexts().CommitFiles.CommitFileTreeViewModel.SetTree() - return nil }) self.refreshView(self.c.Contexts().CommitFiles, env) return nil @@ -921,10 +915,9 @@ func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, comm } workingTreeState := self.c.Git().Status.WorkingTreeState() - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Commits = updatedCommits self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState - return nil }) self.refreshView(self.c.Contexts().LocalCommits, env) @@ -937,9 +930,8 @@ func (self *RefreshHelper) refreshTags(env refreshEnv) error { return err } - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Tags = tags - return nil }) self.refreshView(self.c.Contexts().Tags, env) @@ -966,10 +958,9 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh }) }, func() { - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Contexts().Branches.HandleRender() self.refreshStatus(env) - return nil }) }) if err != nil { @@ -981,14 +972,14 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh worktrees = self.loadWorktrees() } - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { // Drop this write if a branch load that started later has already applied // its result. At the INITIAL startup stage an immediate load (not // recency-sorted) and an async recency-sorted load run concurrently; this // makes the later-started (recency-sorted) one win regardless of which // finishes first, so its result isn't clobbered by the stale immediate one. if loadSeq < self.appliedBranchLoadSeq { - return nil + return } self.appliedBranchLoadSeq = loadSeq @@ -1031,7 +1022,6 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh // Need to re-render the commits view because the visualization of local // branch heads might have changed self.c.Contexts().LocalCommits.HandleRender() - return nil }) self.refreshView(self.c.Contexts().Branches, env) @@ -1066,12 +1056,13 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState // bumps the generation, so a write captured under the old generation must not // clobber the new repo's state. The generation is captured once at the start of // the refresh and carried in env (see refreshEnv). -func (self *RefreshHelper) onUIThreadUnlessRepoChanged(env refreshEnv, f func() error) { +func (self *RefreshHelper) onUIThreadUnlessRepoChanged(env refreshEnv, f func()) { self.onUIThread(env.background, func() error { if self.c.State().GetRepoGeneration() != env.generation { return nil } - return f() + f() + return nil }) } @@ -1206,7 +1197,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // (e.g. in the user's editor). Offer to continue it. We only do this // for operations we started ourselves; prompting for one that was // started outside lazygit (e.g. by a coding agent) would be confusing. - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { // The merge-conflicts scope of this refresh also notices that // the conflicts are gone and escapes from the merge conflicts // view to the files context (see RefreshMergeState), but it @@ -1221,7 +1212,6 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) } self.mergeAndRebaseHelper.PromptToContinueRebase() - return nil }) } } else { @@ -1232,13 +1222,12 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // Guard on the generation like the sibling PromptToContinueRebase // bounce above: if the repo was switched while this refresh was in // flight, a prompt showing now belongs to the new repo, so leave it be. - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.mergeAndRebaseHelper.DismissContinueRebasePromptIfShowing() - return nil }) } - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { // only taking over the filter if it hasn't already been set by the user. if conflictFileCount > 0 && prevConflictFileCount == 0 { if fileTreeViewModel.GetStatusFilter() == filetree.DisplayAll { @@ -1253,7 +1242,6 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re self.c.Model().Submodules = submoduleConfigs self.c.Model().Files = files fileTreeViewModel.SetTree() - return nil }) return nil @@ -1308,7 +1296,7 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en } } - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { model.ReflogCommits = reflogCommits model.FilteredReflogCommits = filteredReflogCommits // Setting the selection here, in the same bounce that writes the list, @@ -1318,7 +1306,6 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en self.c.Contexts().ReflogCommits.SetSelectedLineIdx(0) self.c.Contexts().ReflogCommits.GetView().SetOriginY(0) } - return nil }) self.refreshView(self.c.Contexts().ReflogCommits, env) @@ -1331,7 +1318,7 @@ func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env return nil, err } - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Remotes = remotes hadPrs := len(self.c.Model().PullRequestsMap) != 0 @@ -1351,7 +1338,6 @@ func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env } } } - return nil }) self.refreshView(self.c.Contexts().Remotes, env) @@ -1371,9 +1357,8 @@ func (self *RefreshHelper) loadWorktrees() []*models.Worktree { func (self *RefreshHelper) refreshWorktrees(env refreshEnv) { worktrees := self.loadWorktrees() - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Worktrees = worktrees - return nil }) // need to refresh branches because the branches view shows worktrees against @@ -1386,9 +1371,8 @@ func (self *RefreshHelper) refreshStashEntries(filterPath string, env refreshEnv stashEntries := self.c.Git().Loaders.StashLoader. GetStashEntries(filterPath) - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().StashEntries = stashEntries - return nil }) self.refreshView(self.c.Contexts().Stash, env) @@ -1399,7 +1383,7 @@ func (self *RefreshHelper) refreshStatus(env refreshEnv) { workingTreeState := self.c.Git().Status.WorkingTreeState() repoName := self.c.Git().RepoPaths.RepoName() - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { // Read the checked-out branch and the linked worktree name here on the UI // thread: both derive from models (Branches, Worktrees) that their // refreshes now write via bounces, so reading them on the worker would @@ -1407,13 +1391,12 @@ func (self *RefreshHelper) refreshStatus(env refreshEnv) { currentBranch := self.refsHelper.GetCheckedOutRef() if currentBranch == nil { // need to wait for branches to refresh - return nil + return } linkedWorktreeName := self.worktreeHelper.GetLinkedWorktreeName() status := presentation.FormatStatus(repoName, currentBranch, types.ItemOperationNone, linkedWorktreeName, workingTreeState, self.c.Tr, self.c.UserConfig()) self.c.SetViewContent(self.c.Views().Status, status) - return nil }) } @@ -1443,7 +1426,7 @@ func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) { // switched while the refresh was in flight, its model write was already // dropped, so there's nothing fresh to render — and the captured context // belongs to the old repo's now-replaced context tree anyway. - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { // Re-applying the filter must be done before re-rendering the view, so that // the filtered list model is up to date for rendering. self.searchHelper.ReApplyFilter(context) @@ -1462,16 +1445,14 @@ func (self *RefreshHelper) refreshView(context types.Context, env refreshEnv) { self.searchHelper.ReApplySearch(context) return nil }) - return nil }) } func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, remotes []*models.Remote, env refreshEnv) { clearPullRequests := func() { - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().PullRequests = nil self.c.Model().PullRequestsMap = nil - return nil }) } @@ -1624,14 +1605,13 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra self.savePullRequestsToCache(prs) - self.onUIThreadUnlessRepoChanged(env, func() error { + self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().PullRequests = prs // Rebuilding here rather than on the worker means the map is built from // the branches and remotes as they are on the UI thread, after their // own refreshes' bounces have applied. self.rebuildPullRequestsMap() self.c.PostRefreshUpdate(self.c.Contexts().Branches) - return nil }) } From 4acfc8806506b8abfac9140d0e25e170720c8d0e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 7 Jul 2026 14:02:04 +0200 Subject: [PATCH 298/384] Replace the BLOCK_UI refresh mode with a BatchUIUpdates flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOCK_UI ran the whole refresh on the UI thread and parked it in a wg.Wait for the duration, so the UI (and its spinner) froze while the git work ran. Blocking the UI was never the point — the point was to apply all the scopes' updates in one frame instead of a per-scope cascade — and if we genuinely wanted to block input it should span the whole operation, not just its refresh, which needs a gocui-level mechanism we don't have. So drop the mode and add a BatchUIUpdates option that achieves the "one frame" effect without blocking: each scope's UI-thread bounce is collected into a shared refreshBounceBatch during the refresh, and once every scope has finished they're all applied inside a single OnUIThread task. gocui drains every queued event before it redraws, so one task means one repaint. The refresh itself now runs SYNC — on a worker when issued from one (checkout, move-to-new-branch, the rebase-edit result handling), so the UI thread stays live and the spinner keeps animating. The batch needs a mutex because the scopes add concurrently from their worker goroutines, and a closed flag so that any bounces enqueued after the flush starts — the nested ones a flushed bounce produces in turn, e.g. scrolling the selection into view — are dispatched immediately as ordinary follow-ups rather than collected into a batch that nothing will drain. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 110 +++++++++++++----- pkg/gui/controllers/helpers/refs_helper.go | 12 +- .../controllers/local_commits_controller.go | 4 +- pkg/gui/types/refresh.go | 12 +- 4 files changed, 100 insertions(+), 38 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 47cd51ff7..2f0f10657 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -96,6 +96,49 @@ type refreshEnv struct { // the repo generation captured when the refresh started generation int + + // When non-nil, each scope's UI-thread bounce is collected here instead of + // being dispatched as it's produced, so they can all be applied in a single + // frame once the whole refresh is done (see RefreshOptions.BatchUIUpdates). + // Held by pointer so the copies of env that flow through the scope functions + // all share the one batch. + batch *refreshBounceBatch +} + +// refreshBounceBatch collects the UI-thread bounces of a batched refresh so they +// can be applied together in one frame rather than one scope at a time. The +// scopes run on separate worker goroutines and add concurrently, hence the +// mutex. Once the refresh starts flushing it closes the batch, so that any +// bounces enqueued afterwards — the nested ones a flushed bounce produces in +// turn, e.g. scrolling the selection into view — are dispatched immediately as +// ordinary follow-ups instead of being collected into a batch that nothing +// will drain. +type refreshBounceBatch struct { + mutex deadlock.Mutex + funcs []func() + closed bool +} + +// add collects f and returns true. Once the batch is closed it collects nothing +// and returns false, telling the caller to dispatch f immediately instead. +func (self *refreshBounceBatch) add(f func()) bool { + self.mutex.Lock() + defer self.mutex.Unlock() + + if self.closed { + return false + } + self.funcs = append(self.funcs, f) + return true +} + +// close marks the batch flushed and returns everything collected so far. +func (self *refreshBounceBatch) close() []func() { + self.mutex.Lock() + defer self.mutex.Unlock() + + self.closed = true + return self.funcs } func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) { @@ -121,18 +164,15 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr ) } - // f runs on the UI thread when the refresh was initiated there, and also for - // BLOCK_UI, which dispatches f onto the UI thread regardless of the caller. - // Only a SYNC/ASYNC refresh initiated from a worker runs f on that worker. - // This, not calledFromWorker alone, is what decides whether a scope capture - // runs inline or has to hop (see captureOnUIThread). - fRunsOnUIThread := options.Mode == types.BLOCK_UI || !calledFromWorker + // f runs on the UI thread when the refresh was initiated there (Refresh); a + // refresh initiated from a worker (RefreshFromWorker) runs f on that worker. + // This decides whether a scope capture runs inline or has to hop (see + // captureOnUIThread). + fRunsOnUIThread := !calledFromWorker // Debug-only guard: every refresh must be issued from the entry point that // matches its goroutine — Refresh on the UI thread, RefreshFromWorker on a - // worker. We check the caller's own goroutine here, before a BLOCK_UI - // refresh dispatches f onto the UI thread, so it holds regardless of the - // mode. goid stays out of production control flow (debug only). + // worker. goid stays out of production control flow (debug only). if self.c.GetConfig().GetDebug() && self.c.GocuiGui().IsUIThread() == calledFromWorker { panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread") } @@ -144,6 +184,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr background: options.Background, generation: self.c.State().GetRepoGeneration(), } + if options.BatchUIUpdates { + env.batch = &refreshBounceBatch{} + } var scopeSet *set.Set[types.RefreshableView] if len(options.Scope) == 0 { @@ -376,6 +419,20 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr wg.Wait() + if env.batch != nil { + // Apply all the scopes' collected bounces in a single UI-thread task, + // so they land in one frame: gocui drains every queued event before it + // redraws, so one task means one repaint. Bounces enqueued from within + // these (see refreshBounceBatch) run as ordinary follow-ups. + bounces := env.batch.close() + self.onUIThread(env.background, func() error { + for _, bounce := range bounces { + bounce() + } + return nil + }) + } + if options.Then != nil { // Queue Then via OnUIThread so it runs *after* the refresh-scope // functions' model-update bounces (which are already queued by @@ -387,14 +444,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } } - if options.Mode == types.BLOCK_UI { - self.c.OnUIThread(func() error { - f() - return nil - }) - return - } - f() } @@ -482,8 +531,6 @@ func getModeName(mode types.RefreshMode) string { return "sync" case types.ASYNC: return "async" - case types.BLOCK_UI: - return "block-ui" default: return "unknown mode" } @@ -1057,13 +1104,21 @@ func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState // clobber the new repo's state. The generation is captured once at the start of // the refresh and carried in env (see refreshEnv). func (self *RefreshHelper) onUIThreadUnlessRepoChanged(env refreshEnv, f func()) { - self.onUIThread(env.background, func() error { + wrapper := func() { if self.c.State().GetRepoGeneration() != env.generation { - return nil + return } f() - return nil - }) + } + + // A batched refresh collects its bounces and fires them together at the end + // (see refreshBounceBatch); add reports false once the batch is flushing, so + // bounces enqueued from within a flushed bounce dispatch immediately. + if env.batch != nil && env.batch.add(wrapper) { + return + } + + self.onUIThread(env.background, func() error { wrapper(); return nil }) } // onWorker and onUIThread pick the foreground or background variant of the @@ -1094,12 +1149,11 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) { // runs on the UI thread (fRunsOnUIThread is true) fn runs inline; when it runs // on a worker, fn is dispatched to the UI thread and we block for it. // -// The inline case matters for correctness as much as the hop: a SYNC or -// BLOCK_UI refresh parks the UI thread in a wg.Wait while its scope workers -// run, so a scope worker that tried to hop to the UI thread there would +// The inline case matters for correctness as much as the hop: a SYNC refresh +// initiated on the UI thread parks that thread in a wg.Wait while its scope +// workers run, so a scope worker that tried to hop to the UI thread there would // deadlock. Capturing before those workers are spawned — inline, on the UI -// thread — avoids that entirely. This is why BLOCK_UI (which always runs on the -// UI thread, even from a worker caller) captures inline rather than hopping. +// thread — avoids that entirely. func (self *RefreshHelper) captureOnUIThread(fRunsOnUIThread bool, background bool, fn func()) { if fRunsOnUIThread { fn() diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 5f07b8ea6..dda3d918a 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -56,7 +56,8 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions scope = append(scope, types.PULL_REQUESTS) } self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.BLOCK_UI, + Mode: types.SYNC, + BatchUIUpdates: true, Scope: scope, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, @@ -368,7 +369,8 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest } self.c.Refresh(types.RefreshOptions{ - Mode: types.BLOCK_UI, + Mode: types.SYNC, + BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, SelectTopReflogCommit: true, @@ -534,7 +536,8 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa } self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.BLOCK_UI, + Mode: types.SYNC, + BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, SelectTopReflogCommit: true, @@ -570,7 +573,8 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri } self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.BLOCK_UI, + Mode: types.SYNC, + BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, SelectTopReflogCommit: true, diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 23e94adf7..ade7d1a9a 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -604,7 +604,7 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "") return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( - err, types.RefreshOptions{Mode: types.BLOCK_UI}) + err, types.RefreshOptions{BatchUIUpdates: true}) }) } @@ -628,7 +628,7 @@ func (self *LocalCommitsController) startInteractiveRebaseWithEdit( err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, - types.RefreshOptions{Mode: types.BLOCK_UI, Then: func() error { + types.RefreshOptions{BatchUIUpdates: true, Then: func() error { todos := make([]*models.Commit, 0, len(commitsToEdit)-1) for _, c := range commitsToEdit[:len(commitsToEdit)-1] { // Merge commits can't be set to "edit", so just skip them diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index f4041bb2e..7b304b15d 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -28,9 +28,8 @@ const ( type RefreshMode int const ( - SYNC RefreshMode = iota // wait until everything is done before returning - ASYNC // return immediately, allowing each independent thing to update itself - BLOCK_UI // wrap code in an update call to ensure UI updates all at once and keybindings aren't executed till complete + SYNC RefreshMode = iota // wait until everything is done before returning + ASYNC // return immediately, allowing each independent thing to update itself ) // CommitSelectionBehavior controls which local commit is selected after the @@ -74,7 +73,12 @@ const ( type RefreshOptions struct { Then func() error Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything - Mode RefreshMode // one of SYNC (default), ASYNC, and BLOCK_UI + Mode RefreshMode // one of SYNC (default) and ASYNC + + // If true, hold off on updating the UI until all scopes have finished + // refreshing and then apply them together in a single frame, rather than + // letting each scope update the UI as soon as it's done. + BatchUIUpdates bool // Controls which local branch is selected after the refresh. Defaults to // KeepBranchSelectionByName. From d70d70aad2007df539b682767c47b5eba282b5e2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 14:31:01 +0200 Subject: [PATCH 299/384] Get rid of pointless f() indirection This was useful when there was a BLOCK_UI mode where f() was called differently, but now we no longer need it. I'm making this change as a separate commit because folding it into the previous one (which would conceptually have made sense) would have made that diff unreadable because of the indentation change. The variable `fRunsOnUIThread` and its comment no longer make sense now; we'll clean this up next. The diff is best viewed with --ignore-all-space. --- pkg/gui/controllers/helpers/refresh_helper.go | 514 +++++++++--------- 1 file changed, 255 insertions(+), 259 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 2f0f10657..0da2f08d0 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -177,274 +177,270 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread") } - f := func() { - // Capture the repo generation once, here at the start, so every scope's - // bounce is guarded against the same baseline. - env := refreshEnv{ - background: options.Background, - generation: self.c.State().GetRepoGeneration(), - } - if options.BatchUIUpdates { - env.batch = &refreshBounceBatch{} - } + // Capture the repo generation once, here at the start, so every scope's + // bounce is guarded against the same baseline. + env := refreshEnv{ + background: options.Background, + generation: self.c.State().GetRepoGeneration(), + } + if options.BatchUIUpdates { + env.batch = &refreshBounceBatch{} + } - var scopeSet *set.Set[types.RefreshableView] - if len(options.Scope) == 0 { - // not refreshing staging/patch-building unless explicitly requested because we only need - // to refresh those while focused. - scopeSet = set.NewFromSlice([]types.RefreshableView{ - types.COMMITS, - types.BRANCHES, - types.FILES, - types.STASH, - types.REFLOG, - types.TAGS, - types.REMOTES, - types.WORKTREES, - types.STATUS, - types.BISECT_INFO, - types.STAGING, - types.PULL_REQUESTS, - }) - } else { - scopeSet = set.NewFromSlice(options.Scope) - } + var scopeSet *set.Set[types.RefreshableView] + if len(options.Scope) == 0 { + // not refreshing staging/patch-building unless explicitly requested because we only need + // to refresh those while focused. + scopeSet = set.NewFromSlice([]types.RefreshableView{ + types.COMMITS, + types.BRANCHES, + types.FILES, + types.STASH, + types.REFLOG, + types.TAGS, + types.REMOTES, + types.WORKTREES, + types.STATUS, + types.BISECT_INFO, + types.STAGING, + types.PULL_REQUESTS, + }) + } else { + scopeSet = set.NewFromSlice(options.Scope) + } - // Expand co-refreshing scopes up front so downstream conditions can be - // simple single-scope checks. The relationships are: - // - whenever the reflog or bisect info changes, commits and branches - // can change too (e.g. switching branches updates the reflog and - // can move HEAD), so refresh commits + branches alongside - // - submodules are refreshed as part of the files refresh - // - merge conflicts are part of what the files refresh produces - // - pull requests are fetched for the tracking branches against the - // remotes, so refresh both alongside to fetch against fresh data - if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { - scopeSet.Add(types.COMMITS, types.BRANCHES) - } - if scopeSet.Includes(types.SUBMODULES) { - scopeSet.Add(types.FILES) - } - if scopeSet.Includes(types.FILES) { - scopeSet.Add(types.MERGE_CONFLICTS) - } - if scopeSet.Includes(types.PULL_REQUESTS) { - scopeSet.Add(types.BRANCHES, types.REMOTES) - } + // Expand co-refreshing scopes up front so downstream conditions can be + // simple single-scope checks. The relationships are: + // - whenever the reflog or bisect info changes, commits and branches + // can change too (e.g. switching branches updates the reflog and + // can move HEAD), so refresh commits + branches alongside + // - submodules are refreshed as part of the files refresh + // - merge conflicts are part of what the files refresh produces + // - pull requests are fetched for the tracking branches against the + // remotes, so refresh both alongside to fetch against fresh data + if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { + scopeSet.Add(types.COMMITS, types.BRANCHES) + } + if scopeSet.Includes(types.SUBMODULES) { + scopeSet.Add(types.FILES) + } + if scopeSet.Includes(types.FILES) { + scopeSet.Add(types.MERGE_CONFLICTS) + } + if scopeSet.Includes(types.PULL_REQUESTS) { + scopeSet.Add(types.BRANCHES, types.REMOTES) + } - // Capture the refs snapshot now, before we start reading git's state - // below, rather than after. This is important to guard against the race - // of git's state changing externally while (or right after) we are - // refreshing; the risk is one potential extra refresh, but capturing the - // snapshot at the end would risk missing one, which is worse. - self.updateRefsSnapshotIfRelevant(scopeSet) + // Capture the refs snapshot now, before we start reading git's state + // below, rather than after. This is important to guard against the race + // of git's state changing externally while (or right after) we are + // refreshing; the risk is one potential extra refresh, but capturing the + // snapshot at the end would risk missing one, which is worse. + self.updateRefsSnapshotIfRelevant(scopeSet) - wg := sync.WaitGroup{} - refresh := func(name string, f func()) { - // if we're in a demo we don't want any async refreshes because - // everything happens fast and it's better to have everything update - // in the one frame - if !self.c.InDemo() && options.Mode == types.ASYNC { - self.onWorker(env.background, func(t gocui.Task) error { - f() - return nil - }) - } else { - wg.Add(1) - go utils.Safe(func() { - t := time.Now() - defer wg.Done() - f() - self.c.Log.Infof("refreshed %s in %s", name, time.Since(t)) - }) - } - } - - branchesAndRemotesWg := sync.WaitGroup{} - // The pull-request fetch (below) needs the just-loaded branches and - // remotes. Their model writes are bounced onto the UI thread, so the - // fetch worker can't read them back from the model without racing (and - // would see the pre-refresh values); instead the branches and remotes - // loads stash what they loaded here, and the wait on - // branchesAndRemotesWg gives the fetch the happens-before to read them. - var loadedBranches []*models.Branch - var loadedRemotes []*models.Remote - includeWorktreesWithBranches := false - if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { - // whenever we change commits, we should update branches because the upstream/downstream - // counts can change. Whenever we change branches we should also change commits - // e.g. in the case of switching branches. - // Capture the commits, reflog and branches refresh inputs (model, - // contexts, modes) on the UI thread, before the git work is dispatched - // to a worker, so the workers compute from an immutable snapshot - // instead of reading state the UI thread concurrently mutates. - var capturedCommits capturedCommitState - var capturedReflog capturedReflogState - var capturedBranches capturedBranchState - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { - capturedCommits = self.captureCommitsState(options.CommitSelection) - capturedReflog = self.captureReflogState() - capturedBranches = self.captureBranchState() - }) - refresh("commits and commit files", func() { - self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env) - }) - - includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) - if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { - branchesAndRemotesWg.Add(1) - refresh("reflog and branches", func() { - loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env) - branchesAndRemotesWg.Done() - }) - } else { - branchesAndRemotesWg.Add(1) - refresh("branches", func() { - // Not a recency sort, so branches doesn't depend on the reflog - // being fresh; it runs concurrently with the reflog refresh - // below and uses the reflog we captured up front, as it always has. - loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, env) - branchesAndRemotesWg.Done() - }) - refresh("reflog", func() { - _, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit) - }) - } - } else if scopeSet.Includes(types.REBASE_COMMITS) { - // the above block handles rebase commits so we only need to call this one - // if we've asked specifically for rebase commits and not those other things - var rebaseHashPool *utils.StringPool - var rebaseCommits []*models.Commit - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { - rebaseHashPool, rebaseCommits = self.captureRebaseCommitState() - }) - refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) }) - } - - if scopeSet.Includes(types.SUB_COMMITS) { - var capturedSubCommits capturedSubCommitState - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { - capturedSubCommits = self.captureSubCommitState() - }) - refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) }) - } - - // reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway - if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) { - var capturedCommitFiles capturedCommitFilesState - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { - capturedCommitFiles = self.captureCommitFilesState() - }) - refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) }) - } - - fileWg := sync.WaitGroup{} - if scopeSet.Includes(types.FILES) { - var capturedFiles capturedFilesState - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { - capturedFiles = self.captureFilesState() - }) - fileWg.Add(1) - refresh("files", func() { - _ = self.refreshFilesAndSubmodules(capturedFiles, env) - fileWg.Done() - }) - } - - if scopeSet.Includes(types.STASH) { - var stashFilterPath string - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { - stashFilterPath = self.c.Modes().Filtering.GetPath() - }) - refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) }) - } - - if scopeSet.Includes(types.TAGS) { - refresh("tags", func() { _ = self.refreshTags(env) }) - } - - if scopeSet.Includes(types.REMOTES) { - // Capture the previously-selected remote on the UI thread; the worker - // needs it to keep the remote-branches selection valid, and reading - // the Remotes context off the UI thread races its render. - var prevSelectedRemote *models.Remote - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { - prevSelectedRemote = self.c.Contexts().Remotes.GetSelected() - }) - branchesAndRemotesWg.Add(1) - refresh("remotes", func() { - loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env) - branchesAndRemotesWg.Done() - }) - } - - if scopeSet.Includes(types.PULL_REQUESTS) { - refresh("pull requests", func() { - branchesAndRemotesWg.Wait() - // Use the branches and remotes the loads above stashed, not - // Model().Branches/Remotes: those writes are bounced onto the - // UI thread and may not have landed on this worker yet. The - // wait above orders us after both loads have stashed theirs. - self.refreshGithubPullRequests(loadedBranches, loadedRemotes, env) - }) - } - - if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches { - refresh("worktrees", func() { self.refreshWorktrees(env) }) - } - - if scopeSet.Includes(types.STAGING) { - refresh("staging", func() { - fileWg.Wait() - // Bounce onto the UI thread so this runs after the files - // scope's model-update bounce — RefreshStagingPanel reads - // Model.Files (via Files.GetSelected) and would otherwise - // see the pre-refresh model. Guard on the generation so a - // repo switch mid-refresh drops it, like the model bounces. - self.onUIThreadUnlessRepoChanged(env, func() { - self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) - }) - }) - } - - if scopeSet.Includes(types.PATCH_BUILDING) { - refresh("patch building", func() { self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) }) - } - - if scopeSet.Includes(types.MERGE_CONFLICTS) { - refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState(env.background) }) - } - - self.refreshStatus(env) - - wg.Wait() - - if env.batch != nil { - // Apply all the scopes' collected bounces in a single UI-thread task, - // so they land in one frame: gocui drains every queued event before it - // redraws, so one task means one repaint. Bounces enqueued from within - // these (see refreshBounceBatch) run as ordinary follow-ups. - bounces := env.batch.close() - self.onUIThread(env.background, func() error { - for _, bounce := range bounces { - bounce() - } + wg := sync.WaitGroup{} + refresh := func(name string, f func()) { + // if we're in a demo we don't want any async refreshes because + // everything happens fast and it's better to have everything update + // in the one frame + if !self.c.InDemo() && options.Mode == types.ASYNC { + self.onWorker(env.background, func(t gocui.Task) error { + f() return nil }) - } - - if options.Then != nil { - // Queue Then via OnUIThread so it runs *after* the refresh-scope - // functions' model-update bounces (which are already queued by - // now), not synchronously here — at this point the workers have - // returned but their bounces haven't been processed yet, so - // invoking Then synchronously would run it on a model that's - // still pre-refresh. - self.onUIThread(env.background, options.Then) + } else { + wg.Add(1) + go utils.Safe(func() { + t := time.Now() + defer wg.Done() + f() + self.c.Log.Infof("refreshed %s in %s", name, time.Since(t)) + }) } } - f() + branchesAndRemotesWg := sync.WaitGroup{} + // The pull-request fetch (below) needs the just-loaded branches and + // remotes. Their model writes are bounced onto the UI thread, so the + // fetch worker can't read them back from the model without racing (and + // would see the pre-refresh values); instead the branches and remotes + // loads stash what they loaded here, and the wait on + // branchesAndRemotesWg gives the fetch the happens-before to read them. + var loadedBranches []*models.Branch + var loadedRemotes []*models.Remote + includeWorktreesWithBranches := false + if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { + // whenever we change commits, we should update branches because the upstream/downstream + // counts can change. Whenever we change branches we should also change commits + // e.g. in the case of switching branches. + // Capture the commits, reflog and branches refresh inputs (model, + // contexts, modes) on the UI thread, before the git work is dispatched + // to a worker, so the workers compute from an immutable snapshot + // instead of reading state the UI thread concurrently mutates. + var capturedCommits capturedCommitState + var capturedReflog capturedReflogState + var capturedBranches capturedBranchState + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + capturedCommits = self.captureCommitsState(options.CommitSelection) + capturedReflog = self.captureReflogState() + capturedBranches = self.captureBranchState() + }) + refresh("commits and commit files", func() { + self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env) + }) + + includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) + if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { + branchesAndRemotesWg.Add(1) + refresh("reflog and branches", func() { + loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env) + branchesAndRemotesWg.Done() + }) + } else { + branchesAndRemotesWg.Add(1) + refresh("branches", func() { + // Not a recency sort, so branches doesn't depend on the reflog + // being fresh; it runs concurrently with the reflog refresh + // below and uses the reflog we captured up front, as it always has. + loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, env) + branchesAndRemotesWg.Done() + }) + refresh("reflog", func() { + _, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit) + }) + } + } else if scopeSet.Includes(types.REBASE_COMMITS) { + // the above block handles rebase commits so we only need to call this one + // if we've asked specifically for rebase commits and not those other things + var rebaseHashPool *utils.StringPool + var rebaseCommits []*models.Commit + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + rebaseHashPool, rebaseCommits = self.captureRebaseCommitState() + }) + refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) }) + } + + if scopeSet.Includes(types.SUB_COMMITS) { + var capturedSubCommits capturedSubCommitState + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + capturedSubCommits = self.captureSubCommitState() + }) + refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) }) + } + + // reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway + if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) { + var capturedCommitFiles capturedCommitFilesState + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + capturedCommitFiles = self.captureCommitFilesState() + }) + refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) }) + } + + fileWg := sync.WaitGroup{} + if scopeSet.Includes(types.FILES) { + var capturedFiles capturedFilesState + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + capturedFiles = self.captureFilesState() + }) + fileWg.Add(1) + refresh("files", func() { + _ = self.refreshFilesAndSubmodules(capturedFiles, env) + fileWg.Done() + }) + } + + if scopeSet.Includes(types.STASH) { + var stashFilterPath string + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + stashFilterPath = self.c.Modes().Filtering.GetPath() + }) + refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) }) + } + + if scopeSet.Includes(types.TAGS) { + refresh("tags", func() { _ = self.refreshTags(env) }) + } + + if scopeSet.Includes(types.REMOTES) { + // Capture the previously-selected remote on the UI thread; the worker + // needs it to keep the remote-branches selection valid, and reading + // the Remotes context off the UI thread races its render. + var prevSelectedRemote *models.Remote + self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + prevSelectedRemote = self.c.Contexts().Remotes.GetSelected() + }) + branchesAndRemotesWg.Add(1) + refresh("remotes", func() { + loadedRemotes, _ = self.refreshRemotes(prevSelectedRemote, env) + branchesAndRemotesWg.Done() + }) + } + + if scopeSet.Includes(types.PULL_REQUESTS) { + refresh("pull requests", func() { + branchesAndRemotesWg.Wait() + // Use the branches and remotes the loads above stashed, not + // Model().Branches/Remotes: those writes are bounced onto the + // UI thread and may not have landed on this worker yet. The + // wait above orders us after both loads have stashed theirs. + self.refreshGithubPullRequests(loadedBranches, loadedRemotes, env) + }) + } + + if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches { + refresh("worktrees", func() { self.refreshWorktrees(env) }) + } + + if scopeSet.Includes(types.STAGING) { + refresh("staging", func() { + fileWg.Wait() + // Bounce onto the UI thread so this runs after the files + // scope's model-update bounce — RefreshStagingPanel reads + // Model.Files (via Files.GetSelected) and would otherwise + // see the pre-refresh model. Guard on the generation so a + // repo switch mid-refresh drops it, like the model bounces. + self.onUIThreadUnlessRepoChanged(env, func() { + self.stagingHelper.RefreshStagingPanel(types.OnFocusOpts{}) + }) + }) + } + + if scopeSet.Includes(types.PATCH_BUILDING) { + refresh("patch building", func() { self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) }) + } + + if scopeSet.Includes(types.MERGE_CONFLICTS) { + refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState(env.background) }) + } + + self.refreshStatus(env) + + wg.Wait() + + if env.batch != nil { + // Apply all the scopes' collected bounces in a single UI-thread task, + // so they land in one frame: gocui drains every queued event before it + // redraws, so one task means one repaint. Bounces enqueued from within + // these (see refreshBounceBatch) run as ordinary follow-ups. + bounces := env.batch.close() + self.onUIThread(env.background, func() error { + for _, bounce := range bounces { + bounce() + } + return nil + }) + } + + if options.Then != nil { + // Queue Then via OnUIThread so it runs *after* the refresh-scope + // functions' model-update bounces (which are already queued by + // now), not synchronously here — at this point the workers have + // returned but their bounces haven't been processed yet, so + // invoking Then synchronously would run it on a model that's + // still pre-refresh. + self.onUIThread(env.background, options.Then) + } } // SetRefsSnapshot stores the given snapshot as the last observed refs state. From f319522d5b91a93170b5150cdaed296771a32444 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 14:37:16 +0200 Subject: [PATCH 300/384] Remove fRunsOnUIThread variable; use calledFromWorker directly There is no f() function any more, so a variable named "f runs on" doesn't make sense. And we also don't need it any more; it used to be necessary when its meaning was not exactly the same as `!calledFromWorker`, but also included the BLOCK_UI case, but that has changed several commits ago. --- pkg/gui/controllers/helpers/refresh_helper.go | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 0da2f08d0..002391aa1 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -164,12 +164,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr ) } - // f runs on the UI thread when the refresh was initiated there (Refresh); a - // refresh initiated from a worker (RefreshFromWorker) runs f on that worker. - // This decides whether a scope capture runs inline or has to hop (see - // captureOnUIThread). - fRunsOnUIThread := !calledFromWorker - // Debug-only guard: every refresh must be issued from the entry point that // matches its goroutine — Refresh on the UI thread, RefreshFromWorker on a // worker. goid stays out of production control flow (debug only). @@ -280,7 +274,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var capturedCommits capturedCommitState var capturedReflog capturedReflogState var capturedBranches capturedBranchState - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + self.captureOnUIThread(calledFromWorker, env.background, func() { capturedCommits = self.captureCommitsState(options.CommitSelection) capturedReflog = self.captureReflogState() capturedBranches = self.captureBranchState() @@ -314,7 +308,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // if we've asked specifically for rebase commits and not those other things var rebaseHashPool *utils.StringPool var rebaseCommits []*models.Commit - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + self.captureOnUIThread(calledFromWorker, env.background, func() { rebaseHashPool, rebaseCommits = self.captureRebaseCommitState() }) refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) }) @@ -322,7 +316,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr if scopeSet.Includes(types.SUB_COMMITS) { var capturedSubCommits capturedSubCommitState - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + self.captureOnUIThread(calledFromWorker, env.background, func() { capturedSubCommits = self.captureSubCommitState() }) refresh("sub commits", func() { _ = self.refreshSubCommitsWithLimit(capturedSubCommits, env) }) @@ -331,7 +325,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // reason we're not doing this if the COMMITS type is included is that if the COMMITS type _is_ included we will refresh the commit files context anyway if scopeSet.Includes(types.COMMIT_FILES) && !scopeSet.Includes(types.COMMITS) { var capturedCommitFiles capturedCommitFilesState - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + self.captureOnUIThread(calledFromWorker, env.background, func() { capturedCommitFiles = self.captureCommitFilesState() }) refresh("commit files", func() { _ = self.refreshCommitFilesContext(capturedCommitFiles, env) }) @@ -340,7 +334,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr fileWg := sync.WaitGroup{} if scopeSet.Includes(types.FILES) { var capturedFiles capturedFilesState - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + self.captureOnUIThread(calledFromWorker, env.background, func() { capturedFiles = self.captureFilesState() }) fileWg.Add(1) @@ -352,7 +346,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr if scopeSet.Includes(types.STASH) { var stashFilterPath string - self.captureOnUIThread(fRunsOnUIThread, env.background, func() { + self.captureOnUIThread(calledFromWorker, env.background, func() { stashFilterPath = self.c.Modes().Filtering.GetPath() }) refresh("stash", func() { self.refreshStashEntries(stashFilterPath, env) }) @@ -367,7 +361,7 @@ 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(fRunsOnUIThread, env.background, func() { + self.captureOnUIThread(calledFromWorker, env.background, func() { prevSelectedRemote = self.c.Contexts().Remotes.GetSelected() }) branchesAndRemotesWg.Add(1) @@ -1142,7 +1136,7 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) { // reads the model/context/mode state a refresh scope needs into locals, so the // worker that follows computes from an immutable snapshot instead of reading // state the UI thread concurrently mutates. When the enclosing refresh function -// runs on the UI thread (fRunsOnUIThread is true) fn runs inline; when it runs +// runs on the UI thread (calledFromWorker is false) fn runs inline; when it runs // on a worker, fn is dispatched to the UI thread and we block for it. // // The inline case matters for correctness as much as the hop: a SYNC refresh @@ -1150,8 +1144,8 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) { // workers run, so a scope worker that tried to hop to the UI thread there would // deadlock. Capturing before those workers are spawned — inline, on the UI // thread — avoids that entirely. -func (self *RefreshHelper) captureOnUIThread(fRunsOnUIThread bool, background bool, fn func()) { - if fRunsOnUIThread { +func (self *RefreshHelper) captureOnUIThread(calledFromWorker bool, background bool, fn func()) { + if !calledFromWorker { fn() return } From bfd3b7b47e57b423c11cdfba58f0a2a5938eaabb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 14:47:06 +0200 Subject: [PATCH 301/384] Allow Then and BatchUIUpdates to work with an async refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Then, and BatchUIUpdates, previously only worked for a SYNC refresh: the calling goroutine blocked in wg.Wait until every scope had finished, and only then flushed the batch and ran Then. An ASYNC refresh had no such join point — it dispatched each scope onto its own worker and returned right away — so Then was forbidden (it would have run before the scopes finished) and a batch would never be drained. Give the async path a join of its own. Both paths now register their scopes in the WaitGroup, and the finishing work — wg.Wait, the batch flush, and Then — moves into a closure. A SYNC refresh runs it inline as before; an ASYNC refresh dispatches it to a worker, so the caller still returns immediately but the batch and Then run once every scope is done. Besides lifting the restriction, this makes SYNC and ASYNC differ only in whether the finishing work blocks the caller, which is what lets a later commit drop the mode entirely and key the choice off the calling thread instead. --- pkg/gui/controllers/helpers/refresh_helper.go | 61 +++++++++++-------- 1 file changed, 35 insertions(+), 26 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 002391aa1..dd6d948f7 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -142,10 +142,6 @@ func (self *refreshBounceBatch) close() []func() { } func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) { - if options.Mode == types.ASYNC && options.Then != nil { - panic("RefreshOptions.Then doesn't work with mode ASYNC") - } - t := time.Now() defer func() { self.c.Log.Infof("Refresh took %s", time.Since(t)) @@ -234,16 +230,18 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr wg := sync.WaitGroup{} refresh := func(name string, f func()) { + wg.Add(1) + // if we're in a demo we don't want any async refreshes because // everything happens fast and it's better to have everything update // in the one frame if !self.c.InDemo() && options.Mode == types.ASYNC { self.onWorker(env.background, func(t gocui.Task) error { + defer wg.Done() f() return nil }) } else { - wg.Add(1) go utils.Safe(func() { t := time.Now() defer wg.Done() @@ -410,30 +408,41 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr self.refreshStatus(env) - wg.Wait() + waitAndFinalize := func() { + wg.Wait() - if env.batch != nil { - // Apply all the scopes' collected bounces in a single UI-thread task, - // so they land in one frame: gocui drains every queued event before it - // redraws, so one task means one repaint. Bounces enqueued from within - // these (see refreshBounceBatch) run as ordinary follow-ups. - bounces := env.batch.close() - self.onUIThread(env.background, func() error { - for _, bounce := range bounces { - bounce() - } - return nil - }) + if env.batch != nil { + // Apply all the scopes' collected bounces in a single UI-thread task, + // so they land in one frame: gocui drains every queued event before it + // redraws, so one task means one repaint. Bounces enqueued from within + // these (see refreshBounceBatch) run as ordinary follow-ups. + bounces := env.batch.close() + self.onUIThread(env.background, func() error { + for _, bounce := range bounces { + bounce() + } + return nil + }) + } + + if options.Then != nil { + // Queue Then via OnUIThread so it runs *after* the refresh-scope + // functions' model-update bounces (which are already queued by + // now), not synchronously here — at this point the workers have + // returned but their bounces haven't been processed yet, so + // invoking Then synchronously would run it on a model that's + // still pre-refresh. + self.onUIThread(env.background, options.Then) + } } - if options.Then != nil { - // Queue Then via OnUIThread so it runs *after* the refresh-scope - // functions' model-update bounces (which are already queued by - // now), not synchronously here — at this point the workers have - // returned but their bounces haven't been processed yet, so - // invoking Then synchronously would run it on a model that's - // still pre-refresh. - self.onUIThread(env.background, options.Then) + if options.Mode == types.SYNC { + waitAndFinalize() + } else { + self.onWorker(env.background, func(t gocui.Task) error { + waitAndFinalize() + return nil + }) } } From 63bd2d98c0fa56e5d6c852b933b59f283e64c63c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 15:46:57 +0200 Subject: [PATCH 302/384] Show a waiting status while creating a branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a branch checks it out, and checking out a distant ref (a tag or a commit far from HEAD) can take a noticeable while. NewBranch ran that synchronously in the prompt's confirm handler, on the UI thread, so the UI froze — no spinner, no repaint — until it finished. Move the branch creation (and the autostash path) onto a worker with a waiting status, mirroring CheckoutRef, and refresh from the worker so the UI thread stays live and the spinner keeps animating. Push the branches context from the refresh's Then rather than up front: the refresh already batches its UI updates, so switching panels there lands the switch in the same frame as the refreshed branch list instead of flashing the pre-refresh list while the checkout is still running. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refs_helper.go | 76 +++++++++++++--------- pkg/i18n/english.go | 2 + 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index dda3d918a..e91a6c500 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -364,16 +364,22 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest } refresh := func() { - if self.c.Context().Current() != self.c.Contexts().Branches { - self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) - } - - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Mode: types.SYNC, BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, SelectTopReflogCommit: true, + Then: func() error { + // Switch to the branches panel only now, in the same batched + // frame that applies the refreshed data, so the panel switch + // and the new branch appear together rather than flashing the + // old branch list while the checkout is still in progress. + if self.c.Context().Current() != self.c.Contexts().Branches { + self.c.Context().Push(self.c.Contexts().Branches, types.OnFocusOpts{}) + } + return nil + }, }) } @@ -387,34 +393,44 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest if newBranchName != suggestedBranchName { newBranchFunc = self.c.Git().Branch.NewWithoutTracking } - if err := newBranchFunc(newBranchName, from); err != nil { - if IsSwitchBranchUncommittedChangesError(err) { - // offer to autostash changes - self.c.Confirm(types.ConfirmOpts{ - Title: self.c.Tr.AutoStashTitle, - Prompt: self.c.Tr.AutoStashPrompt, - HandleConfirm: func() error { - if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil { - return err - } - if err := newBranchFunc(newBranchName, from); err != nil { - return err - } - err := self.c.Git().Stash.Pop(0) - // Branch switch successful so re-render the UI even if the pop operation failed (e.g. conflict). - refresh() - return err - }, - }) - return nil + // Creating the branch checks it out, which can take a while when + // the ref we're branching off is distant, so do it on a worker. + return self.c.WithWaitingStatus(self.c.Tr.CreatingBranchStatus, func(gocui.Task) error { + if err := newBranchFunc(newBranchName, from); err != nil { + if IsSwitchBranchUncommittedChangesError(err) { + // offer to autostash changes + self.c.OnUIThread(func() error { + self.c.Confirm(types.ConfirmOpts{ + Title: self.c.Tr.AutoStashTitle, + Prompt: self.c.Tr.AutoStashPrompt, + HandleConfirm: func() error { + return self.c.WithWaitingStatus(self.c.Tr.CreatingBranchStatus, func(gocui.Task) error { + if err := self.c.Git().Stash.Push(fmt.Sprintf(self.c.Tr.AutoStashForNewBranch, newBranchName)); err != nil { + return err + } + if err := newBranchFunc(newBranchName, from); err != nil { + return err + } + err := self.c.Git().Stash.Pop(0) + // Branch switch successful so re-render the UI even if the pop operation failed (e.g. conflict). + refresh() + return err + }) + }, + }) + return nil + }) + + return nil + } + + return err } - return err - } - - refresh() - return nil + refresh() + return nil + }) }, }) diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 22b05e6c0..2e83fed9b 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -427,6 +427,7 @@ type TranslationSet struct { UndoingStatus string RedoingStatus string CheckingOutStatus string + CreatingBranchStatus string CommittingStatus string RewordingStatus string RevertingStatus string @@ -1576,6 +1577,7 @@ func EnglishTranslationSet() *TranslationSet { UndoingStatus: "Undoing", RedoingStatus: "Redoing", CheckingOutStatus: "Checking out", + CreatingBranchStatus: "Creating branch", CommittingStatus: "Committing", RewordingStatus: "Rewording", RevertingStatus: "Reverting", From 8580c78cc020e79d4bfd6805f76eeadc973c4df5 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 15:57:07 +0200 Subject: [PATCH 303/384] Derive sync vs async refresh from the calling thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether a refresh should block or run in the background was controlled by the Mode field, but that always lined up with the calling thread: a UI-thread Refresh must not block the UI, while a RefreshFromWorker runs on a worker where blocking is exactly what we want. Now that Then and BatchUIUpdates work regardless of that choice, drop Mode from the decision and key it off calledFromWorker instead: - Refresh (UI thread) runs its scopes and the finishing step (wait, batch flush, Then) on workers, so the caller returns immediately — what ASYNC used to mean. - RefreshFromWorker runs them on the calling worker, blocking it until everything is done — what SYNC used to mean. Demos keep taking the blocking, inline path so everything still lands in one deterministic frame. In practice this flips the handful of RefreshFromWorker calls that passed ASYNC — they now block their worker until the refresh finishes, keeping the waiting-status spinner up until the UI actually updates — and the many UI-thread refreshes that defaulted to SYNC, which no longer freeze the UI thread while the git work runs. Mode now only feeds the log line; the next commit removes it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index dd6d948f7..2482e6015 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -232,10 +232,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr refresh := func(name string, f func()) { wg.Add(1) - // if we're in a demo we don't want any async refreshes because - // everything happens fast and it's better to have everything update - // in the one frame - if !self.c.InDemo() && options.Mode == types.ASYNC { + // A refresh issued from the UI thread must not block it, so its scopes + // run as their own worker tasks and the caller returns immediately (the + // finishing step below is dispatched to a worker too). A refresh issued + // from a worker blocks that worker instead, running its scopes as plain + // goroutines that it joins. In a demo we always take the blocking path + // so everything updates in a single, deterministic frame. + if !self.c.InDemo() && !calledFromWorker { self.onWorker(env.background, func(t gocui.Task) error { defer wg.Done() f() @@ -436,7 +439,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } } - if options.Mode == types.SYNC { + // waitAndFinalize blocks until every scope is done. Run it inline when we're + // already on a worker (or in a demo, for a deterministic single frame); when + // we're on the UI thread, dispatch it to a worker so it doesn't block the UI. + if calledFromWorker || self.c.InDemo() { waitAndFinalize() } else { self.onWorker(env.background, func(t gocui.Task) error { From 88811e6795c0bc15e50fea1a0488cbff561165fd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 16:10:01 +0200 Subject: [PATCH 304/384] Remove the RefreshMode field With sync vs async now derived from the calling thread, the Mode field and its SYNC/ASYNC constants no longer carry any information: Refresh is always async, RefreshFromWorker always sync. Drop the field, the type, and the Mode argument at every call site, and reduce the debug log's mode name to a plain sync/async derived from calledFromWorker. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/bisect_controller.go | 2 +- pkg/gui/controllers/branches_controller.go | 12 +++---- .../controllers/commits_files_controller.go | 2 +- .../custom_patch_options_menu_action.go | 2 +- pkg/gui/controllers/diffing_menu_action.go | 8 ++--- pkg/gui/controllers/files_controller.go | 12 +++---- pkg/gui/controllers/global_controller.go | 2 +- pkg/gui/controllers/helpers/bisect_helper.go | 2 +- .../controllers/helpers/branches_helper.go | 11 +++--- .../controllers/helpers/cherry_pick_helper.go | 2 +- .../controllers/helpers/credentials_helper.go | 2 +- pkg/gui/controllers/helpers/diff_helper.go | 2 +- pkg/gui/controllers/helpers/fixup_helper.go | 2 +- pkg/gui/controllers/helpers/gpg_helper.go | 6 ++-- .../helpers/merge_and_rebase_helper.go | 10 +++--- pkg/gui/controllers/helpers/refresh_helper.go | 26 +++++--------- pkg/gui/controllers/helpers/refs_helper.go | 7 +--- .../helpers/working_tree_helper.go | 5 ++- .../controllers/helpers/worktree_helper.go | 4 +-- .../controllers/local_commits_controller.go | 35 +++++++++---------- .../controllers/merge_conflicts_controller.go | 2 +- .../controllers/patch_building_controller.go | 2 +- .../controllers/remote_branches_controller.go | 2 +- pkg/gui/controllers/remotes_controller.go | 2 -- pkg/gui/controllers/sub_commits_controller.go | 2 +- pkg/gui/controllers/sync_controller.go | 2 +- pkg/gui/controllers/tags_controller.go | 6 ++-- .../controllers/workspace_reset_controller.go | 14 ++++---- pkg/gui/gui.go | 8 ++--- .../custom_commands/handler_creator.go | 2 +- pkg/gui/types/refresh.go | 8 ----- 31 files changed, 84 insertions(+), 120 deletions(-) diff --git a/pkg/gui/controllers/bisect_controller.go b/pkg/gui/controllers/bisect_controller.go index eb568240b..685f932b1 100644 --- a/pkg/gui/controllers/bisect_controller.go +++ b/pkg/gui/controllers/bisect_controller.go @@ -282,7 +282,7 @@ func (self *BisectController) afterBisectMarkRefresh(selectCurrent bool, waitToR } if waitToReselect { - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{}, Then: selectFn}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{}, Then: selectFn}) return nil } diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index a886a410b..a73ee3bc2 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -331,7 +331,6 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc return err } self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{ types.BRANCHES, types.COMMITS, @@ -355,7 +354,6 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc return err } self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{ types.BRANCHES, types.COMMITS, @@ -546,7 +544,7 @@ func (self *BranchesController) forceCheckout() error { if err := self.c.Git().Branch.Checkout(branch.Name, git_commands.CheckoutOptions{Force: true}); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, }) @@ -600,7 +598,6 @@ func (self *BranchesController) createNewBranchWithName(newBranchName string) er } self.c.Refresh(types.RefreshOptions{ - Mode: types.ASYNC, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, SelectTopReflogCommit: true, @@ -734,7 +731,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { WorktreePath: worktreePath, }, ) - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return err } @@ -743,7 +740,7 @@ func (self *BranchesController) fastForward(branch *models.Branch) error { err := self.c.Git().Sync.FastForward( task, branch.Name, branch.UpstreamRemote, branch.UpstreamBranch, ) - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}}) return err }) } @@ -760,7 +757,7 @@ func (self *BranchesController) createSortMenu() error { if self.c.UserConfig().Git.LocalBranchSortOrder != sortOrder { self.c.UserConfig().Git.LocalBranchSortOrder = sortOrder self.c.Contexts().Branches.SetSelection(0) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}}) return nil } return nil @@ -788,7 +785,6 @@ func (self *BranchesController) rename(branch *models.Branch) error { // onto the UI thread, so the re-selection (which reads Model.Branches) has to run in // Then; reading it inline here would see the previous model. self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES, types.WORKTREES}, Then: func() error { // now that we've got our stuff again we need to find that branch and reselect it. diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index b90e14b74..14e1c1a50 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -324,7 +324,7 @@ func (self *CommitFilesController) checkout(node *filetree.CommitFileNode) error return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil } diff --git a/pkg/gui/controllers/custom_patch_options_menu_action.go b/pkg/gui/controllers/custom_patch_options_menu_action.go index 3d15ce899..2882ab808 100644 --- a/pkg/gui/controllers/custom_patch_options_menu_action.go +++ b/pkg/gui/controllers/custom_patch_options_menu_action.go @@ -269,7 +269,7 @@ func (self *CustomPatchOptionsMenuAction) handleApplyPatch(reverse bool) error { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, }) diff --git a/pkg/gui/controllers/diffing_menu_action.go b/pkg/gui/controllers/diffing_menu_action.go index 3ae5903d9..8372d7919 100644 --- a/pkg/gui/controllers/diffing_menu_action.go +++ b/pkg/gui/controllers/diffing_menu_action.go @@ -22,7 +22,7 @@ func (self *DiffingMenuAction) Call() error { OnPress: func() error { self.c.Modes().Diffing.Ref = name // can scope this down based on current view but too lazy right now - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, }, @@ -38,7 +38,7 @@ func (self *DiffingMenuAction) Call() error { FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRefsSuggestionsFunc(), HandleConfirm: func(response string) error { self.c.Modes().Diffing.Ref = response - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, }) @@ -54,7 +54,7 @@ func (self *DiffingMenuAction) Call() error { Label: self.c.Tr.SwapDiff, OnPress: func() error { self.c.Modes().Diffing.Reverse = !self.c.Modes().Diffing.Reverse - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, }, @@ -62,7 +62,7 @@ func (self *DiffingMenuAction) Call() error { Label: self.c.Tr.ExitDiffMode, OnPress: func() error { self.c.Modes().Diffing = diffing.New() - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, }, diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index b70b67ab7..bf73d4c8b 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -636,7 +636,7 @@ func (self *FilesController) press(nodes []*filetree.FileNode) error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) self.context().HandleFocus(types.OnFocusOpts{}) return nil @@ -921,7 +921,7 @@ func (self *FilesController) toggleStagedAll() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) self.context().HandleFocus(types.OnFocusOpts{}) return nil @@ -1204,7 +1204,7 @@ func (self *FilesController) setStatusFiltering(filter filetree.FileTreeDisplayF // Whenever we switch between untracked and other filters, we need to refresh the files view // because the untracked files filter applies when running `git status`. if previousFilter != filter && (previousFilter == filetree.DisplayUntracked || filter == filetree.DisplayUntracked) { - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) } else { self.c.PostRefreshUpdate(self.context()) } @@ -1740,7 +1740,7 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) return nil }, Keys: self.c.KeybindingsOpts().GetKeys(self.c.UserConfig().Keybinding.Files.ConfirmDiscard), @@ -1766,7 +1766,7 @@ func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) return nil }, Keys: menuKey('u'), @@ -1808,7 +1808,7 @@ func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) e return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}}) return nil }) } diff --git a/pkg/gui/controllers/global_controller.go b/pkg/gui/controllers/global_controller.go index 8b9871294..77ef29070 100644 --- a/pkg/gui/controllers/global_controller.go +++ b/pkg/gui/controllers/global_controller.go @@ -158,7 +158,7 @@ func (self *GlobalController) createCustomPatchOptionsMenu() error { } func (self *GlobalController) refresh() error { - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil } diff --git a/pkg/gui/controllers/helpers/bisect_helper.go b/pkg/gui/controllers/helpers/bisect_helper.go index 6ce517dac..bc9548c4c 100644 --- a/pkg/gui/controllers/helpers/bisect_helper.go +++ b/pkg/gui/controllers/helpers/bisect_helper.go @@ -31,5 +31,5 @@ func (self *BisectHelper) Reset() error { } func (self *BisectHelper) PostBisectCommandRefresh() { - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{}}) } diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 5c72bacfd..83735d87d 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -49,7 +49,7 @@ func (self *BranchesHelper) ConfirmLocalDelete(branches []*models.Branch) error self.c.Contexts().Branches.CollapseRangeSelectionToTop() return nil }) - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}}) return nil }) }) @@ -87,7 +87,7 @@ func (self *BranchesHelper) ConfirmDeleteRemote(remoteBranches []*models.RemoteB if err := self.deleteRemoteBranches(remoteBranches, task); err != nil { return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) if resetRemoteBranchesSelection { self.c.OnUIThread(func() error { self.c.Contexts().RemoteBranches.CollapseRangeSelectionToTop() @@ -161,7 +161,7 @@ func (self *BranchesHelper) ConfirmLocalAndRemoteDelete(branches []*models.Branc self.c.Contexts().Branches.CollapseRangeSelectionToTop() return nil }) - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}}) return nil }) }, @@ -325,7 +325,6 @@ func (self *BranchesHelper) deleteLocalBranchesContinuation(branches []*models.B return nil }) self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}, }) return nil @@ -346,7 +345,6 @@ func (self *BranchesHelper) deleteLocalAndRemoteBranchesContinuation(branches [] return nil }) self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.REMOTES, types.FILES}, }) return nil @@ -407,7 +405,6 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er // returns (where it would still see the previous branches). self.c.RefreshFromWorker(types.RefreshOptions{ Scope: scope, - Mode: types.SYNC, Background: background, Then: func() error { if fetchErr != nil { @@ -458,7 +455,7 @@ func (self *BranchesHelper) AutoForwardBranches(background bool) error { self.c.LogCommand(strings.TrimRight(updateCommands, "\n"), false) err := self.c.Git().Branch.UpdateBranchRefs(updateCommands) - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Mode: types.SYNC, Background: background}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES}, Background: background}) return err } diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go index 673f657f5..b69b68514 100644 --- a/pkg/gui/controllers/helpers/cherry_pick_helper.go +++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go @@ -95,7 +95,7 @@ func (self *CherryPickHelper) Paste() error { cherryPickedCommits := self.getData().CherryPickedCommits result := self.c.Git().Rebase.CherryPickCommits(cherryPickedCommits) - err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{Mode: types.SYNC}) + err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{}) if err != nil { return result } diff --git a/pkg/gui/controllers/helpers/credentials_helper.go b/pkg/gui/controllers/helpers/credentials_helper.go index 9b2198ccb..7c765020e 100644 --- a/pkg/gui/controllers/helpers/credentials_helper.go +++ b/pkg/gui/controllers/helpers/credentials_helper.go @@ -33,7 +33,7 @@ func (self *CredentialsHelper) PromptUserForCredential(passOrUname oscommands.Cr HandleConfirm: func(input string) error { ch <- input + "\n" - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }, HandleClose: func() error { diff --git a/pkg/gui/controllers/helpers/diff_helper.go b/pkg/gui/controllers/helpers/diff_helper.go index 668ee916a..6af3b2b5c 100644 --- a/pkg/gui/controllers/helpers/diff_helper.go +++ b/pkg/gui/controllers/helpers/diff_helper.go @@ -94,7 +94,7 @@ func (self *DiffHelper) FilterPathsForCommit(commit *models.Commit) []string { func (self *DiffHelper) ExitDiffMode() error { self.c.Modes().Diffing = diffing.New() - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil } diff --git a/pkg/gui/controllers/helpers/fixup_helper.go b/pkg/gui/controllers/helpers/fixup_helper.go index dfde8365b..a958e58a8 100644 --- a/pkg/gui/controllers/helpers/fixup_helper.go +++ b/pkg/gui/controllers/helpers/fixup_helper.go @@ -137,7 +137,7 @@ func (self *FixupHelper) HandleFindBaseCommitForFixupPress() error { if err := self.c.Git().WorkingTree.StageAll(true); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) } self.c.Contexts().LocalCommits.SetSelection(index) diff --git a/pkg/gui/controllers/helpers/gpg_helper.go b/pkg/gui/controllers/helpers/gpg_helper.go index fd74a400b..9c7667a6d 100644 --- a/pkg/gui/controllers/helpers/gpg_helper.go +++ b/pkg/gui/controllers/helpers/gpg_helper.go @@ -26,7 +26,7 @@ func (self *GpgHelper) WithGpgHandling( onSuccess func() error, refreshScope []types.RefreshableView, ) error { - refreshOptions := types.RefreshOptions{Mode: types.ASYNC, Scope: refreshScope} + refreshOptions := types.RefreshOptions{Scope: refreshScope} return self.withGpgHandling( cmdObj, configKey, waitingStatus, onSuccess, refreshOptions, refreshOptions) } @@ -40,8 +40,8 @@ func (self *GpgHelper) WithGpgHandlingAndSelectHeadCommit( waitingStatus string, onSuccess func() error, ) error { - failureRefreshOptions := types.RefreshOptions{Mode: types.ASYNC} - successRefreshOptions := types.RefreshOptions{Mode: types.ASYNC, CommitSelection: types.SelectHeadCommit} + failureRefreshOptions := types.RefreshOptions{} + successRefreshOptions := types.RefreshOptions{CommitSelection: types.SelectHeadCommit} return self.withGpgHandling( cmdObj, configKey, waitingStatus, onSuccess, failureRefreshOptions, successRefreshOptions) } diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 267453a86..7f1a31188 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -133,7 +133,6 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa // TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction success, err := self.c.RunSubprocess(self.c.Git().Rebase.GenericMergeOrRebaseActionCmdObj(commandType, command)) self.refreshAfterMergeOrRebase(types.RefreshOptions{ - Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(success && selectHeadCommitOnSuccess), }, calledFromWorker) self.RecordWhetherMergeOrRebaseStartedInLazygit() @@ -144,7 +143,6 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) return self.checkMergeOrRebaseImpl(result, types.RefreshOptions{ - Mode: types.ASYNC, CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess), }, calledFromWorker) } @@ -258,7 +256,7 @@ func (self *MergeAndRebaseHelper) refreshAfterMergeOrRebase(refreshOptions types } func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error { - return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{Mode: types.ASYNC}) + return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{}) } // Like CheckMergeOrRebase, but for operations that create a new commit at HEAD @@ -267,7 +265,7 @@ func (self *MergeAndRebaseHelper) CheckMergeOrRebase(result error) error { // before the refresh. func (self *MergeAndRebaseHelper) CheckMergeOrRebaseAndSelectHeadCommit(result error) error { return self.CheckMergeOrRebaseWithRefreshOptions(result, - types.RefreshOptions{Mode: types.SYNC, CommitSelection: commitSelectionAfterMerge(result == nil)}) + types.RefreshOptions{CommitSelection: commitSelectionAfterMerge(result == nil)}) } func (self *MergeAndRebaseHelper) CheckForConflicts(result error) error { @@ -346,7 +344,7 @@ func (self *MergeAndRebaseHelper) PromptToContinueRebase() { // to read it in Then; reading it inline here would see the previous // model. self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}, + Scope: []types.RefreshableView{types.FILES}, Then: func() error { unstagedFiles := GetUnstagedFilesExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) if len(unstagedFiles) > 0 { @@ -667,7 +665,7 @@ func (self *MergeAndRebaseHelper) SquashMergeCommitted(refName, checkedOutBranch if err != nil { return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 2482e6015..fdb526549 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -147,15 +147,18 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr self.c.Log.Infof("Refresh took %s", time.Since(t)) }() + // A refresh from a worker blocks that worker until it's done; one from the + // UI thread returns immediately and finishes in the background. + syncOrAsync := "async" + if calledFromWorker { + syncOrAsync = "sync" + } if options.Scope == nil { - self.c.Log.Infof( - "refreshing all scopes in %s mode", - getModeName(options.Mode), - ) + self.c.Log.Infof("refreshing all scopes (%s)", syncOrAsync) } else { self.c.Log.Infof( - "refreshing the following scopes in %s mode: %s", - getModeName(options.Mode), + "refreshing the following scopes (%s): %s", + syncOrAsync, strings.Join(getScopeNames(options.Scope), ","), ) } @@ -530,17 +533,6 @@ func getScopeNames(scopes []types.RefreshableView) []string { }) } -func getModeName(mode types.RefreshMode) string { - switch mode { - case types.SYNC: - return "sync" - case types.ASYNC: - return "async" - default: - return "unknown mode" - } -} - // During startup, the bottleneck is fetching the reflog entries, which we need // in order to sort the branches by recency. So we have two phases: INITIAL and // COMPLETE. In the INITIAL phase we don't have any reflog commits yet, so we diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index e91a6c500..675c332a0 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -56,7 +56,6 @@ func (self *RefsHelper) CheckoutRef(ref string, options types.CheckoutRefOptions scope = append(scope, types.PULL_REQUESTS) } self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.SYNC, BatchUIUpdates: true, Scope: scope, BranchSelection: types.SelectCheckedOutBranch, @@ -161,7 +160,6 @@ func (self *RefsHelper) CheckoutRemoteBranch(fullBranchName string, localBranchN // Do a sync refresh to make sure the new branch is visible, // so that we see an inline status when checking it out self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.BRANCHES}, }) return checkout(localBranchName, true) @@ -365,7 +363,6 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest refresh := func() { self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.SYNC, BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, @@ -552,7 +549,6 @@ func (self *RefsHelper) moveCommitsToNewBranchStackedOnCurrentBranch(newBranchNa } self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.SYNC, BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, @@ -577,7 +573,7 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri } err := self.c.Git().Rebase.CherryPickCommits(commitsToCherryPick) - err = self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(err, types.RefreshOptions{Mode: types.SYNC}) + err = self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(err, types.RefreshOptions{}) if err != nil { return err } @@ -589,7 +585,6 @@ func (self *RefsHelper) moveCommitsToNewBranchOffOfMainBranch(newBranchName stri } self.c.RefreshFromWorker(types.RefreshOptions{ - Mode: types.SYNC, BatchUIUpdates: true, BranchSelection: types.SelectCheckedOutBranch, CommitSelection: types.SelectHeadCommit, diff --git a/pkg/gui/controllers/helpers/working_tree_helper.go b/pkg/gui/controllers/helpers/working_tree_helper.go index 36dfd2032..5f68cb822 100644 --- a/pkg/gui/controllers/helpers/working_tree_helper.go +++ b/pkg/gui/controllers/helpers/working_tree_helper.go @@ -238,7 +238,6 @@ func (self *WorkingTreeHelper) WithEnsureCommittableFiles(handler func() error) return err } self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}, Then: handler, }) @@ -260,7 +259,7 @@ func (self *WorkingTreeHelper) promptToStageAllAndRetry(retry func() error) erro if err := self.c.Git().WorkingTree.StageAll(false); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) return retry() }, @@ -360,7 +359,7 @@ func (self *WorkingTreeHelper) CreateMergeConflictMenu(selectedFilepaths []strin } err := self.c.Git().WorkingTree.StageFiles(selectedFilepaths, nil) - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.FILES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) return err } diff --git a/pkg/gui/controllers/helpers/worktree_helper.go b/pkg/gui/controllers/helpers/worktree_helper.go index 980d810ae..35d515d6e 100644 --- a/pkg/gui/controllers/helpers/worktree_helper.go +++ b/pkg/gui/controllers/helpers/worktree_helper.go @@ -163,7 +163,7 @@ func (self *WorktreeHelper) remove(worktree *models.Worktree, force bool, then f return then(task) } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) return nil }) } @@ -181,7 +181,7 @@ func (self *WorktreeHelper) Detach(worktree *models.Worktree, then func(gocui.Ta return then(task) } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.WORKTREES, types.BRANCHES, types.FILES}}) return nil }) } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index ade7d1a9a..19e0a7c17 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -476,7 +476,7 @@ func (self *LocalCommitsController) switchFromCommitMessagePanelToEditor(filepat return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil } @@ -495,7 +495,7 @@ func (self *LocalCommitsController) handleReword(summary string, description str if err != nil { return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) } @@ -700,7 +700,7 @@ func (self *LocalCommitsController) updateTodosWithFlag(action todo.TodoCommand, } self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, + Scope: []types.RefreshableView{types.REBASE_COMMITS}, }) return nil @@ -742,7 +742,6 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, CommitSelection: types.KeepCommitSelectionIndex, }) @@ -757,7 +756,7 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex}) + err, types.RefreshOptions{CommitSelection: types.KeepCommitSelectionIndex}) }) } @@ -770,7 +769,6 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) self.c.Refresh(types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, CommitSelection: types.KeepCommitSelectionIndex, }) @@ -785,7 +783,7 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{Mode: types.SYNC, CommitSelection: types.KeepCommitSelectionIndex}) + err, types.RefreshOptions{CommitSelection: types.KeepCommitSelectionIndex}) }) } @@ -798,7 +796,7 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error { if err := self.c.Helpers().AmendHelper.AmendHead(); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }) } @@ -875,7 +873,7 @@ func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) } @@ -891,7 +889,7 @@ func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, e return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) }, @@ -910,7 +908,7 @@ func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start, if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil { return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) }, @@ -948,7 +946,7 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end } result := self.c.Git().Commit.Revert(hashes, isMerge) - if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{Mode: types.SYNC}); err != nil { + if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{}); err != nil { return err } @@ -996,7 +994,7 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err return err } - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }) }) @@ -1096,7 +1094,7 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc return err } - self.c.Refresh(types.RefreshOptions{Mode: types.SYNC}) + self.c.Refresh(types.RefreshOptions{}) return nil }) }, @@ -1149,7 +1147,7 @@ func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, reba err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit) self.context().MoveSelectedLine(-selectionOffset) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{Mode: types.SYNC}) + err, types.RefreshOptions{}) }) } @@ -1196,7 +1194,7 @@ func (self *LocalCommitsController) openSearch() error { // we usually lazyload these commits but now that we're searching we need to load them now if self.context().GetLimitCommits() { self.context().SetLimitCommits(false) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}}) } return self.c.Helpers().Search.OpenSearchPrompt(self.context()) @@ -1217,7 +1215,7 @@ func (self *LocalCommitsController) handleOpenLogMenu() error { return self.c.WithWaitingStatus(self.c.Tr.LoadingCommits, func(gocui.Task) error { self.c.Refresh( - types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}}, ) return nil }) @@ -1271,7 +1269,6 @@ func (self *LocalCommitsController) handleOpenLogMenu() error { return self.c.WithWaitingStatus(self.c.Tr.LoadingCommits, func(gocui.Task) error { self.c.Refresh( types.RefreshOptions{ - Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS}, }, ) @@ -1316,7 +1313,7 @@ func (self *LocalCommitsController) GetOnFocus() func(types.OnFocusOpts) { context := self.context() if context.GetSelectedLineIdx() > COMMIT_THRESHOLD && context.GetLimitCommits() { context.SetLimitCommits(false) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}}) } } } diff --git a/pkg/gui/controllers/merge_conflicts_controller.go b/pkg/gui/controllers/merge_conflicts_controller.go index 898eb356b..1af53fded 100644 --- a/pkg/gui/controllers/merge_conflicts_controller.go +++ b/pkg/gui/controllers/merge_conflicts_controller.go @@ -302,7 +302,7 @@ func (self *MergeConflictsController) resolveConflict(selection mergeconflicts.S func (self *MergeConflictsController) onLastConflictResolved() { // as part of refreshing files, we handle the situation where a file has had // its merge conflicts resolved. - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) } func (self *MergeConflictsController) openMergeConflictMenu() error { diff --git a/pkg/gui/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go index d596c2ead..fe28ca603 100644 --- a/pkg/gui/controllers/patch_building_controller.go +++ b/pkg/gui/controllers/patch_building_controller.go @@ -229,7 +229,7 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error { err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex) self.c.Helpers().PatchBuilding.Escape() return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{Mode: types.SYNC}) + err, types.RefreshOptions{}) }) } diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go index f70145d7b..0d50068f3 100644 --- a/pkg/gui/controllers/remote_branches_controller.go +++ b/pkg/gui/controllers/remote_branches_controller.go @@ -158,7 +158,7 @@ func (self *RemoteBranchesController) createSortMenu() error { if self.c.UserConfig().Git.RemoteBranchSortOrder != sortOrder { self.c.UserConfig().Git.RemoteBranchSortOrder = sortOrder self.c.Contexts().RemoteBranches.SetSelection(0) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.REMOTES}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.REMOTES}}) } return nil }, diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index d4c838f7c..76bd16bb1 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -163,7 +163,6 @@ func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl // affordable. self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.REMOTES}, - Mode: types.SYNC, Then: func() error { // Select the remote for idx, remote := range self.c.Model().Remotes { @@ -371,7 +370,6 @@ func (self *RemotesController) fetchAndCheckout(remote *models.Remote, branchNam } refreshOptions := types.RefreshOptions{ Scope: []types.RefreshableView{types.BRANCHES, types.REMOTES}, - Mode: types.SYNC, } if branchName != "" { err = self.c.Git().Branch.New(branchName, remote.Name+"/"+branchName) diff --git a/pkg/gui/controllers/sub_commits_controller.go b/pkg/gui/controllers/sub_commits_controller.go index 8799cd3c6..d3d0c0b98 100644 --- a/pkg/gui/controllers/sub_commits_controller.go +++ b/pkg/gui/controllers/sub_commits_controller.go @@ -66,7 +66,7 @@ func (self *SubCommitsController) GetOnFocus() func(types.OnFocusOpts) { context := self.context() if context.GetSelectedLineIdx() > COMMIT_THRESHOLD && context.GetLimitCommits() { context.SetLimitCommits(false) - self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.SUB_COMMITS}}) + self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.SUB_COMMITS}}) } } } diff --git a/pkg/gui/controllers/sync_controller.go b/pkg/gui/controllers/sync_controller.go index fafd4e7dd..61b92747b 100644 --- a/pkg/gui/controllers/sync_controller.go +++ b/pkg/gui/controllers/sync_controller.go @@ -229,7 +229,7 @@ func (self *SyncController) pushAux(currentBranch *models.Branch, opts pushOpts) } return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) return nil }) } diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go index 2a59af5ec..a6c0e7e14 100644 --- a/pkg/gui/controllers/tags_controller.go +++ b/pkg/gui/controllers/tags_controller.go @@ -168,7 +168,7 @@ func (self *TagsController) localDelete(tag *models.Tag) error { return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag) err := self.c.Git().Tag.LocalDelete(tag.Name) - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return err }) } @@ -210,7 +210,7 @@ func (self *TagsController) remoteDelete(tag *models.Tag) error { return err } self.c.Toast(self.c.Tr.RemoteTagDeletedMessage) - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return nil }) }, @@ -264,7 +264,7 @@ func (self *TagsController) localAndRemoteDelete(tag *models.Tag) error { if err := self.c.Git().Tag.LocalDelete(tag.Name); err != nil { return err } - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}}) return nil }) }, diff --git a/pkg/gui/controllers/workspace_reset_controller.go b/pkg/gui/controllers/workspace_reset_controller.go index 9a9005254..27e736648 100644 --- a/pkg/gui/controllers/workspace_reset_controller.go +++ b/pkg/gui/controllers/workspace_reset_controller.go @@ -46,7 +46,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, @@ -68,7 +68,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, @@ -86,7 +86,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, @@ -111,7 +111,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, @@ -129,7 +129,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, @@ -147,7 +147,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, @@ -170,7 +170,7 @@ func (self *FilesController) createResetMenu() error { } self.c.Refresh( - types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES}}, + types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}, ) return nil }, diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 931b0909e..25e543abb 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -391,7 +391,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context } gui.c.Log.Info("Receiving focus - refreshing") - gui.helpers.Refresh.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + gui.helpers.Refresh.Refresh(types.RefreshOptions{}) return reloadErr } @@ -815,7 +815,7 @@ func NewGui( func(ctx goContext.Context, opts types.CreatePopupPanelOpts) { gui.helpers.Confirmation.CreatePopupPanel(ctx, opts) }, - func() error { gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}); return nil }, + func() error { gui.c.Refresh(types.RefreshOptions{}); return nil }, func() { gui.State.ContextMgr.Pop() }, func() types.Context { return gui.State.ContextMgr.Current() }, gui.createMenu, @@ -1023,7 +1023,7 @@ func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess *oscommands.CmdOb return err } - gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + gui.c.Refresh(types.RefreshOptions{}) return nil } @@ -1100,7 +1100,7 @@ func (gui *Gui) loadNewRepo() error { return err } - gui.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) + gui.c.Refresh(types.RefreshOptions{}) if err := gui.os.UpdateWindowTitle(); err != nil { return err diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index 4eb762019..6046d974a 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -314,7 +314,7 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses } output, err := cmdObj.RunWithOutput() - self.c.RefreshFromWorker(types.RefreshOptions{Mode: types.ASYNC}) + self.c.RefreshFromWorker(types.RefreshOptions{}) if err != nil { if customCommand.After != nil && customCommand.After.CheckForConflicts { diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index 7b304b15d..937c3a30e 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -25,13 +25,6 @@ const ( PULL_REQUESTS ) -type RefreshMode int - -const ( - SYNC RefreshMode = iota // wait until everything is done before returning - ASYNC // return immediately, allowing each independent thing to update itself -) - // CommitSelectionBehavior controls which local commit is selected after the // commits list is reloaded by a refresh. type CommitSelectionBehavior int @@ -73,7 +66,6 @@ const ( type RefreshOptions struct { Then func() error Scope []RefreshableView // e.g. []RefreshableView{COMMITS, BRANCHES}. Leave empty to refresh everything - Mode RefreshMode // one of SYNC (default) and ASYNC // If true, hold off on updating the UI until all scopes have finished // refreshing and then apply them together in a single frame, rather than From 6893d9a7590239c7a39aae3d2f3fd09a3e62f12d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 17:40:41 +0200 Subject: [PATCH 305/384] Add gocui primitives to block input during an operation Long-running operations that lazygit drives itself (rebases, and the commit surgery built on them) can be corrupted by keys the user presses while they run: pressing e to start an interactive rebase, then up+d before it finishes, must act on the resulting todo list, not race the rebase. WithWaitingStatusSync gets this today only as a side effect of freezing the UI thread, which the rest of this branch is moving away from. Add a nestable counter, BeginBlockingEvents/EndBlockingEvents, that withholds input at the event-dispatch layer without freezing anything: while blocked, key events are buffered and replayed in order once the count returns to zero (so they act on the now-current context), mouse clicks and hover are dropped (replaying them against a changed layout would target the wrong thing), and scrolling, resize, focus and all rendering keep flowing. These are the reusable core; a gui-level helper that brackets them around a worker operation follows. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/block_events_test.go | 98 ++++++++++++++++++++++++++++++++++ pkg/gocui/gui.go | 73 +++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 pkg/gocui/block_events_test.go diff --git a/pkg/gocui/block_events_test.go b/pkg/gocui/block_events_test.go new file mode 100644 index 000000000..277bac89a --- /dev/null +++ b/pkg/gocui/block_events_test.go @@ -0,0 +1,98 @@ +package gocui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEventWithheldWhileBlocking(t *testing.T) { + scenarios := []struct { + name string + event GocuiEvent + withheld bool + }{ + {"key", GocuiEvent{Type: eventKey, Key: NewKeyRune('x')}, true}, + {"mouse click", GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)}, true}, + {"mouse scroll", GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseWheelDown)}, false}, + {"mouse move", GocuiEvent{Type: eventMouseMove}, true}, + {"resize", GocuiEvent{Type: eventResize}, false}, + {"focus", GocuiEvent{Type: eventFocus}, false}, + {"paste", GocuiEvent{Type: eventPaste}, false}, + {"error", GocuiEvent{Type: eventError}, false}, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + assert.Equal(t, s.withheld, eventWithheldWhileBlocking(&s.event)) + }) + } +} + +// setupKeyRecorder wires a keybinding on a focused view that records each time +// it fires, and returns the key event that triggers it plus the record slice. +func setupKeyRecorder(t *testing.T, g *Gui) (GocuiEvent, *[]int) { + t.Helper() + + _, _ = g.SetView("main", 0, 0, 80, 22, 0) + _, err := g.SetCurrentView("main") + assert.NoError(t, err) + + fired := []int{} + callCount := 0 + key := NewKeyRune('x') + g.SetKeybinding("main", key, func(*Gui, *View) error { + callCount++ + fired = append(fired, callCount) + return nil + }) + + return GocuiEvent{Type: eventKey, Key: key}, &fired +} + +func TestBlockingEvents_KeysBufferedAndReplayed(t *testing.T) { + g := newTestGui(t) + keyEvent, fired := setupKeyRecorder(t, g) + + // Not blocking: the key dispatches immediately. + assert.NoError(t, g.handleEvent(&keyEvent)) + assert.Len(t, *fired, 1) + + // While blocking: the key is buffered, not dispatched. + g.BeginBlockingEvents() + assert.NoError(t, g.handleEvent(&keyEvent)) + assert.NoError(t, g.handleEvent(&keyEvent)) + assert.Len(t, *fired, 1, "buffered keys must not dispatch while blocking") + + // Unblocking replays the buffered keys. + assert.NoError(t, g.EndBlockingEvents()) + assert.Len(t, *fired, 3, "both buffered keys should replay on unblock") + assert.Empty(t, g.bufferedKeyEvents) +} + +func TestBlockingEvents_NestsWithCounter(t *testing.T) { + g := newTestGui(t) + keyEvent, fired := setupKeyRecorder(t, g) + + g.BeginBlockingEvents() + g.BeginBlockingEvents() + assert.NoError(t, g.handleEvent(&keyEvent)) + + // The inner block ending still leaves us blocked: no replay yet. + assert.NoError(t, g.EndBlockingEvents()) + assert.Empty(t, *fired) + + // Only the outermost block ending replays. + assert.NoError(t, g.EndBlockingEvents()) + assert.Len(t, *fired, 1) +} + +func TestBlockingEvents_MouseClicksDroppedNotBuffered(t *testing.T) { + g := newTestGui(t) + + g.BeginBlockingEvents() + click := GocuiEvent{Type: eventMouse, Key: NewKeyName(MouseLeft)} + assert.NoError(t, g.handleEvent(&click)) + assert.Empty(t, g.bufferedKeyEvents, "mouse clicks must be dropped, not buffered") + assert.NoError(t, g.EndBlockingEvents()) +} diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 9da5225c0..700c2b54c 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -210,6 +210,14 @@ type Gui struct { // MainLoop starts. IsUIThread compares against it. Written once, read from // worker goroutines, so it's atomic. uiThreadID atomic.Int64 + + // blockInputCount, when greater than zero, withholds keyboard input from + // the handlers: key events are buffered into bufferedKeyEvents and replayed + // once the count drops back to zero, while mouse clicks and hover are + // dropped outright. It's a counter so blocking can nest. Both fields are + // only touched on the UI thread. See BeginBlockingEvents. + blockInputCount int + bufferedKeyEvents []GocuiEvent } type NewGuiOpts struct { @@ -806,6 +814,42 @@ func (g *Gui) IsUIThread() bool { return goid.Get() == g.uiThreadID.Load() } +// BeginBlockingEvents starts withholding keyboard input from the handlers, so a +// long-running operation can't be disrupted by keys the user presses while it +// runs. Keys are buffered and replayed once EndBlockingEvents balances this +// call; mouse clicks and hover are dropped for the duration. Scrolling, +// resizing, focus changes and all rendering keep working throughout. It's a +// counter, so blocking nests; every call must be paired with EndBlockingEvents. +// +// Must be called on the UI thread. Callers arrange this by beginning the block +// synchronously from the keybinding handler, before dispatching the operation +// to a worker — beginning it from the worker would race the next queued +// keypress, which is exactly the input we mean to withhold. +func (g *Gui) BeginBlockingEvents() { + g.blockInputCount++ +} + +// EndBlockingEvents balances a BeginBlockingEvents call. When the last nested +// block ends, the keys buffered while blocked are replayed in order through the +// normal dispatch path, so they act on the now-current context (a key whose +// binding no longer exists is simply ignored, just as if it had been pressed +// now). Must be called on the UI thread. +func (g *Gui) EndBlockingEvents() error { + g.blockInputCount-- + if g.blockInputCount > 0 { + return nil + } + + buffered := g.bufferedKeyEvents + g.bufferedKeyEvents = nil + for i := range buffered { + if err := g.handleEvent(&buffered[i]); err != nil { + return err + } + } + return nil +} + // OnUIThreadAndWait runs f on the main event-loop goroutine and blocks the // caller until f has run, returning f's error. Use it to read UI-thread-owned // state (the model, contexts) from a worker without racing the UI thread. @@ -1052,6 +1096,17 @@ func (g *Gui) processRemainingEvents() (bool, error) { // handleEvent handles an event, based on its type (key-press, error, // etc.) func (g *Gui) handleEvent(ev *GocuiEvent) error { + if g.blockInputCount > 0 && eventWithheldWhileBlocking(ev) { + if ev.Type == eventKey { + // Buffer keys so they replay against fresh state on unblock. + g.bufferedKeyEvents = append(g.bufferedKeyEvents, *ev) + } + // Mouse clicks and hover fall through to here without being buffered: + // replaying them once the operation has changed the layout underneath + // them would target the wrong thing, so we drop them outright. + return nil + } + switch ev.Type { case eventKey, eventMouse, eventMouseMove: return g.onKey(ev) @@ -1070,6 +1125,24 @@ func (g *Gui) handleEvent(ev *GocuiEvent) error { } } +// eventWithheldWhileBlocking reports whether an event must not reach the +// handlers while input is blocked (see BeginBlockingEvents). Key events are +// withheld (buffered for replay); mouse clicks and hover are withheld (dropped). +// Everything else — mouse scrolling, resize, focus, paste, errors — flows +// through as usual. +func eventWithheldWhileBlocking(ev *GocuiEvent) bool { + switch ev.Type { + case eventKey: + return true + case eventMouse: + return !IsMouseScrollKey(ev.Key.KeyName()) + case eventMouseMove: + return true + default: + return false + } +} + func (g *Gui) onResize() { // not sure if we actually need this // g.screen.Sync() From 707b04a8c2b1b93908e41343abf19b877e9ef9a0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 17:43:06 +0200 Subject: [PATCH 306/384] Add a WithWaitingStatusBlockingInput helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bracket gocui's BeginBlockingEvents/EndBlockingEvents around a worker operation that shows a waiting status. The block is begun synchronously on the UI thread, before the operation is dispatched to a worker, so no keypress can slip through in between; it ends via OnUIThread once the operation and its refresh have applied their UI updates, so the replayed keys act on the refreshed state. This composes what the retiring WithWaitingStatusSync did — show a status and block input — but on a worker, so the UI keeps rendering (spinner animates, model updates land) instead of freezing. Callers follow in subsequent commits. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/app_status_helper.go | 24 +++++++++ pkg/gui/gui.go | 3 ++ pkg/gui/popup/popup_handler.go | 50 +++++++++++-------- pkg/gui/types/common.go | 1 + 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index 90b87b3b8..a366909f6 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -86,6 +86,30 @@ 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. +// +// Must be called on the UI thread: the block is begun synchronously here, before +// the operation is dispatched to a worker, so no keypress can slip through in +// between. +func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) { + self.c.GocuiGui().BeginBlockingEvents() + self.c.OnWorker(func(task gocui.Task) error { + // End the block 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. + defer self.c.OnUIThread(func() error { + return self.c.GocuiGui().EndBlockingEvents() + }) + return self.WithWaitingStatusImpl(message, f, task, false) + }) +} + func (self *AppStatusHelper) WithWaitingStatusSync(message string, f func() error) error { self.c.PauseBackgroundRefreshes(true) defer self.c.PauseBackgroundRefreshes(false) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 25e543abb..a6baaf373 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -820,6 +820,9 @@ func NewGui( func() types.Context { return gui.State.ContextMgr.Current() }, gui.createMenu, 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(message string, f func() error) error { return gui.helpers.AppStatus.WithWaitingStatusSync(message, f) }, diff --git a/pkg/gui/popup/popup_handler.go b/pkg/gui/popup/popup_handler.go index ab067410d..23084f9ce 100644 --- a/pkg/gui/popup/popup_handler.go +++ b/pkg/gui/popup/popup_handler.go @@ -13,16 +13,17 @@ import ( type PopupHandler struct { *common.Common - createPopupPanelFn func(context.Context, types.CreatePopupPanelOpts) - onErrorFn func() error - popContextFn func() - currentContextFn func() types.Context - createMenuFn func(types.CreateMenuOptions) error - withWaitingStatusFn func(message string, f func(gocui.Task) error) - withWaitingStatusSyncFn func(message string, f func() error) error - toastFn func(message string, kind types.ToastKind) - getPromptInputFn func() string - inDemo func() bool + createPopupPanelFn func(context.Context, types.CreatePopupPanelOpts) + onErrorFn func() error + popContextFn func() + currentContextFn func() types.Context + createMenuFn func(types.CreateMenuOptions) error + withWaitingStatusFn func(message string, f func(gocui.Task) error) + withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error) + withWaitingStatusSyncFn func(message string, f func() error) error + toastFn func(message string, kind types.ToastKind) + getPromptInputFn func() string + inDemo func() bool } var _ types.IPopupHandler = &PopupHandler{} @@ -35,23 +36,25 @@ 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), withWaitingStatusSyncFn func(message string, f func() error) error, toastFn func(message string, kind types.ToastKind), getPromptInputFn func() string, inDemo func() bool, ) *PopupHandler { return &PopupHandler{ - Common: common, - createPopupPanelFn: createPopupPanelFn, - onErrorFn: onErrorFn, - popContextFn: popContextFn, - currentContextFn: currentContextFn, - createMenuFn: createMenuFn, - withWaitingStatusFn: withWaitingStatusFn, - withWaitingStatusSyncFn: withWaitingStatusSyncFn, - toastFn: toastFn, - getPromptInputFn: getPromptInputFn, - inDemo: inDemo, + Common: common, + createPopupPanelFn: createPopupPanelFn, + onErrorFn: onErrorFn, + popContextFn: popContextFn, + currentContextFn: currentContextFn, + createMenuFn: createMenuFn, + withWaitingStatusFn: withWaitingStatusFn, + withWaitingStatusBlockingInputFn: withWaitingStatusBlockingInputFn, + withWaitingStatusSyncFn: withWaitingStatusSyncFn, + toastFn: toastFn, + getPromptInputFn: getPromptInputFn, + inDemo: inDemo, } } @@ -76,6 +79,11 @@ 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) + return nil +} + func (self *PopupHandler) WithWaitingStatusSync(message string, f func() error) error { return self.withWaitingStatusSyncFn(message, f) } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 08ff53bb0..964143c5a 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -161,6 +161,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 WithWaitingStatusSync(message string, f func() error) error Menu(opts CreateMenuOptions) error Toast(message string) From 62098ca6039bb7fd40c4fda2d266292e1de9996f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 17:53:44 +0200 Subject: [PATCH 307/384] Pass captured state to moveFixupCommitToOwnerStackedBranch It reads the selected index and the commits and branches models to decide where to move the fixup commit. Take those as parameters, captured on the UI thread by the callers, so the function can run its rebase on a worker without reading the model there. No behavior change; the callers still run on the UI thread for now. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/local_commits_controller.go | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 19e0a7c17..17547f6c8 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -985,12 +985,15 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err OnPress: func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit) + selectedIdx := self.context().GetSelectedLineIdx() + commits := self.c.Model().Commits + branches := self.c.Model().Branches return self.c.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error { if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil { return err } - if err := self.moveFixupCommitToOwnerStackedBranch(commit); err != nil { + if err := self.moveFixupCommitToOwnerStackedBranch(commit, selectedIdx, commits, branches); err != nil { return err } @@ -1023,7 +1026,12 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err }) } -func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(targetCommit *models.Commit) error { +// moveFixupCommitToOwnerStackedBranch takes state captured on the UI thread +// (the selected index and the commits and branches models) so that it can run +// its rebase on a worker without reading the model there. +func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch( + targetCommit *models.Commit, selectedIdx int, commits []*models.Commit, branches []*models.Branch, +) error { if self.c.Git().Version.IsOlderThan(2, 38, 0) { // Git 2.38.0 introduced the `rebase.updateRefs` config option. Don't // move the commit down with older versions, as it would break the stack. @@ -1051,9 +1059,9 @@ func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(targetCo } headOfOwnerBranchIdx := -1 - for i := self.context().GetSelectedLineIdx(); i > 0; i-- { - if lo.SomeBy(self.c.Model().Branches, func(b *models.Branch) bool { - return b.CommitHash == self.c.Model().Commits[i].Hash() + for i := selectedIdx; i > 0; i-- { + if lo.SomeBy(branches, func(b *models.Branch) bool { + return b.CommitHash == commits[i].Hash() }) { headOfOwnerBranchIdx = i break @@ -1064,7 +1072,7 @@ func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(targetCo return nil } - return self.c.Git().Rebase.MoveFixupCommitDown(self.c.Model().Commits, headOfOwnerBranchIdx) + return self.c.Git().Rebase.MoveFixupCommitDown(commits, headOfOwnerBranchIdx) } func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, includeFileChanges bool) error { @@ -1085,12 +1093,15 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc PreserveMessage: false, OnConfirm: func(summary string, description string) error { self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit) + selectedIdx := self.context().GetSelectedLineIdx() + commits := self.c.Model().Commits + branches := self.c.Model().Branches return self.c.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error { if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil { return err } - if err := self.moveFixupCommitToOwnerStackedBranch(commit); err != nil { + if err := self.moveFixupCommitToOwnerStackedBranch(commit, selectedIdx, commits, branches); err != nil { return err } From 352883c52b442e751ab5b12e2c87bcfac306f1f1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 17:57:26 +0200 Subject: [PATCH 308/384] Run the sync commit-surgery ops on a worker with input blocked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move, revert, squash-fixups, create-fixup and cherry-pick paste ran their rebase synchronously on the UI thread via WithWaitingStatusSync, which froze the UI for the duration but kept the user from disrupting the operation with a stray keypress. Switch them to WithWaitingStatusBlockingInput so the git work runs on a worker — the UI keeps rendering and the spinner animates — while input stays blocked for the whole operation, as before. discard-patch-from-commit also moves off WithWaitingStatusSync, but as a plain WithWaitingStatus: it's a custom-patch command, and those don't block input. The bodies now follow the worker conventions: model state they need is captured on the UI thread before dispatching, self.c.Refresh becomes RefreshFromWorker, and CheckMergeOrRebase uses the worker variant. An operation that moves the selection does so in the refresh's Then, so it lands in the same frame as the refreshed commit list; squash sets it as an absolute index there, because the shorter list would clamp a relative move. --- .../controllers/helpers/cherry_pick_helper.go | 22 +++-- .../controllers/local_commits_controller.go | 92 +++++++++++++------ .../controllers/patch_building_controller.go | 16 +++- 3 files changed, 88 insertions(+), 42 deletions(-) diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go index b69b68514..fc96b9d1b 100644 --- a/pkg/gui/controllers/helpers/cherry_pick_helper.go +++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go @@ -4,6 +4,7 @@ import ( "strconv" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/modes/cherrypicking" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" @@ -82,9 +83,9 @@ func (self *CherryPickHelper) Paste() error { "numCommits": strconv.Itoa(len(self.getData().CherryPickedCommits)), }), HandleConfirm: func() error { - return self.c.WithWaitingStatusSync(self.c.Tr.CherryPickingStatus, func() error { - mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) - + mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) + cherryPickedCommits := self.getData().CherryPickedCommits + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CherryPickingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.CherryPick) if mustStash { @@ -93,9 +94,9 @@ func (self *CherryPickHelper) Paste() error { } } - cherryPickedCommits := self.getData().CherryPickedCommits result := self.c.Git().Rebase.CherryPickCommits(cherryPickedCommits) - err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{}) + err := self.rebaseHelper.CheckMergeOrRebaseWithRefreshOptions(result, + types.RefreshOptions{BatchUIUpdates: true}) if err != nil { return result } @@ -109,14 +110,19 @@ func (self *CherryPickHelper) Paste() error { return result } if !isInCherryPick { - self.getData().DidPaste = true - self.rerender() + // DidPaste and the re-render touch mode state and contexts, + // so run them on the UI thread. + self.c.OnUIThread(func() error { + self.getData().DidPaste = true + self.rerender() + return nil + }) if mustStash { if err := self.c.Git().Stash.Pop(0); err != nil { return err } - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Scope: []types.RefreshableView{types.STASH, types.FILES}, }) } diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 17547f6c8..1234b80ec 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -748,15 +748,24 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s return nil } - return self.c.WithWaitingStatusSync(self.c.Tr.MovingStatus, func() error { + commits := self.c.Model().Commits + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) - err := self.c.Git().Rebase.MoveCommitsDown(self.c.Model().Commits, startIdx, endIdx) - if err == nil { - self.context().MoveSelection(1) - self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - } - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{CommitSelection: types.KeepCommitSelectionIndex}) + err := self.c.Git().Rebase.MoveCommitsDown(commits, startIdx, endIdx) + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + err, types.RefreshOptions{ + BatchUIUpdates: true, + CommitSelection: types.KeepCommitSelectionIndex, + // Move the selection to follow the moved commit, in Then so it + // lands in the same frame as the refreshed commit list. + Then: func() error { + if err == nil { + self.context().MoveSelection(1) + self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) + } + return nil + }, + }) }) } @@ -775,15 +784,24 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta return nil } - return self.c.WithWaitingStatusSync(self.c.Tr.MovingStatus, func() error { + commits := self.c.Model().Commits + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.MoveCommitUp) - err := self.c.Git().Rebase.MoveCommitsUp(self.c.Model().Commits, startIdx, endIdx) - if err == nil { - self.context().MoveSelection(-1) - self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - } - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{CommitSelection: types.KeepCommitSelectionIndex}) + err := self.c.Git().Rebase.MoveCommitsUp(commits, startIdx, endIdx) + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + err, types.RefreshOptions{ + BatchUIUpdates: true, + CommitSelection: types.KeepCommitSelectionIndex, + // Move the selection to follow the moved commit, in Then so it + // lands in the same frame as the refreshed commit list. + Then: func() error { + if err == nil { + self.context().MoveSelection(-1) + self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) + } + return nil + }, + }) }) } @@ -936,9 +954,8 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end Prompt: promptText, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.RevertCommit) - return self.c.WithWaitingStatusSync(self.c.Tr.RevertingStatus, func() error { - mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) - + mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RevertingStatus, func(gocui.Task) error { if mustStash { if err := self.c.Git().Stash.Push(self.c.Tr.AutoStashForReverting); err != nil { return err @@ -946,7 +963,8 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end } result := self.c.Git().Commit.Revert(hashes, isMerge) - if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result, types.RefreshOptions{}); err != nil { + if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(result, + types.RefreshOptions{BatchUIUpdates: true}); err != nil { return err } @@ -954,7 +972,7 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end if err := self.c.Git().Stash.Pop(0); err != nil { return err } - self.c.Refresh(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ Scope: []types.RefreshableView{types.STASH, types.FILES}, }) } @@ -988,7 +1006,7 @@ 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.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error { if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil { return err } @@ -997,7 +1015,7 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err return err } - self.c.Refresh(types.RefreshOptions{}) + self.c.RefreshFromWorker(types.RefreshOptions{BatchUIUpdates: true}) return nil }) }) @@ -1096,7 +1114,7 @@ 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.WithWaitingStatusSync(self.c.Tr.CreatingFixupCommitStatus, func() error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error { if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil { return err } @@ -1105,7 +1123,7 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc return err } - self.c.Refresh(types.RefreshOptions{}) + self.c.RefreshFromWorker(types.RefreshOptions{BatchUIUpdates: true}) return nil }) }, @@ -1153,12 +1171,28 @@ func (self *LocalCommitsController) squashAllFixupsInCurrentBranch() error { func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, rebaseStartIdx int) error { selectionOffset := countSquashableCommitsAbove(self.c.Model().Commits, self.context().GetSelectedLineIdx(), rebaseStartIdx) - return self.c.WithWaitingStatusSync(self.c.Tr.SquashingStatus, func() error { + // The squashed fixups above the selection are removed, so the selection moves + // up by that many rows to stay on the same commit. Compute the target as an + // absolute index now, on the current list. + targetIdx := self.context().GetSelectedLineIdx() - selectionOffset + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits) err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit) - self.context().MoveSelectedLine(-selectionOffset) - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( - err, types.RefreshOptions{}) + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( + err, types.RefreshOptions{ + BatchUIUpdates: true, + // Set the selection in Then so it lands in the same frame as the + // refreshed commit list. It has to be an absolute index: the new + // list is shorter, so a relative move from the (clamped) old index + // could overshoot. PostRefreshUpdate repaints the moved selection. + Then: func() error { + if err == nil { + self.context().SetSelectedLineIdx(targetIdx) + self.c.PostRefreshUpdate(self.context()) + } + return nil + }, + }) }) } diff --git a/pkg/gui/controllers/patch_building_controller.go b/pkg/gui/controllers/patch_building_controller.go index fe28ca603..f3e26e303 100644 --- a/pkg/gui/controllers/patch_building_controller.go +++ b/pkg/gui/controllers/patch_building_controller.go @@ -223,12 +223,18 @@ func (self *PatchBuildingController) discardSelectionFromCommit() error { return nil } - return self.c.WithWaitingStatusSync(self.c.Tr.RebasingStatus, func() error { - commitIndex := self.getPatchCommitIndex() + commits := self.c.Model().Commits + commitIndex := self.getPatchCommitIndex() + return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.RemovePatchFromCommit) - err := self.c.Git().Patch.DeletePatchesFromCommit(self.c.Model().Commits, commitIndex) - self.c.Helpers().PatchBuilding.Escape() - return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptionsFromUIThread( + err := self.c.Git().Patch.DeletePatchesFromCommit(commits, commitIndex) + // Escape pops the patch-building context, so run it on the UI thread + // before the refresh below. + _ = self.c.GocuiGui().OnUIThreadAndWait(func() error { + self.c.Helpers().PatchBuilding.Escape() + return nil + }) + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{}) }) } From d802cbdddf43868435fd6df997ba0fae214b41bd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 18:00:53 +0200 Subject: [PATCH 309/384] Block input during the worker commit-surgery ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit edit, quick-start rebase, drop, reword, squash, fixup, amend (including the amend-attribute author operations) and discard-file-from-commit all run a rebase on a worker. A key pressed while one is in flight could act on a stale commit or todo — pressing e to start an interactive rebase, then up+d before it finishes, is the motivating example. Switch them from WithWaitingStatus to WithWaitingStatusBlockingInput so input is held and replayed against the post-operation state, matching the commit-surgery ops that were already sync. Left alone: the custom-patch move/delete/pull-into-commit rebases (no need to block input while building and applying a patch), the loading-more-commits and patch-building toggle spinners (no rebase to disrupt), and fetches and other non-surgery operations where blocking navigation would only get in the way. --- .../controllers/commits_files_controller.go | 2 +- .../controllers/local_commits_controller.go | 22 +++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 14e1c1a50..d129b3f90 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -339,7 +339,7 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN HandleConfirm: func() error { commits := self.c.Model().Commits selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx() - return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { var filePaths []string selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 1234b80ec..2da502b79 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -342,7 +342,7 @@ func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, HandleConfirm: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashCommitDown) return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx) }) @@ -366,7 +366,7 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star OnPress: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommit) return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx) }) @@ -379,7 +379,7 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star OnPress: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage) return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C") }) @@ -490,7 +490,7 @@ func (self *LocalCommitsController) handleReword(summary string, description str self.c.Tr.RewordingStatus, nil, nil) } - return self.c.WithWaitingStatus(self.c.Tr.RewordingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RewordingStatus, func(gocui.Task) error { err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description) if err != nil { return err @@ -576,7 +576,7 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start if !isMerge { self.selectRebaseResultCommit(startIdx) } - return self.c.WithWaitingStatus(self.c.Tr.DroppingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.DroppingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DropCommit) if isMerge { return self.dropMergeCommit(commits, startIdx) @@ -601,7 +601,7 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start commits := self.c.Model().Commits if !commits[endIdx].IsMerge() { - return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "") return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{BatchUIUpdates: true}) @@ -623,7 +623,7 @@ func (self *LocalCommitsController) quickStartInteractiveRebase() error { func (self *LocalCommitsController) startInteractiveRebaseWithEdit( commitsToEdit []*models.Commit, ) error { - return self.c.WithWaitingStatus(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.EditCommit) err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( @@ -823,7 +823,7 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error { selectedIdx := self.context().GetView().SelectedLineIdx() handleCommit = func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { - return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AmendCommit) err := self.c.Git().Rebase.AmendTo(commits, selectedIdx) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) @@ -885,7 +885,7 @@ func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, en } func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error { - return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor) if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil { return err @@ -901,7 +901,7 @@ func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, e Title: self.c.Tr.SetAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { - return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor) if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil { return err @@ -921,7 +921,7 @@ func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start, Title: self.c.Tr.AddCoAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { - return self.c.WithWaitingStatus(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor) if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil { return err From a324f8aef181bd264a1c048ffd280619f6fe6e2d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 18:05:11 +0200 Subject: [PATCH 310/384] Drop the now-unused UI-thread CheckMergeOrRebase path With the last synchronous commit-surgery callers moved to workers, nothing runs CheckMergeOrRebase on the UI thread anymore, so CheckMergeOrRebaseWithRefreshOptionsFromUIThread has no callers. Remove it and fold the shared checkMergeOrRebaseImpl back into CheckMergeOrRebaseWithRefreshOptions, which is now always on a worker. The runAction closure loses its calledFromWorker parameter for the same reason. genericMergeCommandImpl keeps its calledFromWorker flag: the merge/rebase-continue subprocess path still runs on the UI thread when invoked straight from the menu, and on a worker for the recursive auto-skip. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/merge_and_rebase_helper.go | 52 +++++++------------ 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 7f1a31188..a8696a21c 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -89,11 +89,11 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { // non-subprocess path runs on a worker with a waiting status. // // showWaitingStatus is false only for the recursive auto-skip in -// checkMergeOrRebaseImpl: that call already runs on the caller's thread (the -// worker of the enclosing waiting status, or the UI thread for the synchronous -// callers), so it must not spin up a second one. calledFromWorker says which of -// those two the body runs on, so the post-action refresh picks Refresh vs -// RefreshFromWorker correctly. +// CheckMergeOrRebaseWithRefreshOptions, which already runs on a worker, so it +// must not spin up a second waiting status. calledFromWorker is used only by the +// subprocess path below: it's true for that recursive worker skip and false for +// genericMergeCommand's UI-thread invocation, so the post-action refresh picks +// RefreshFromWorker vs Refresh correctly. func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWaitingStatus bool, calledFromWorker bool) error { status := self.c.Git().Status.WorkingTreeState() @@ -139,21 +139,23 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa return err } - runAction := func(calledFromWorker bool) error { + // runAction always ends up on a worker: either the waiting status below spins + // one up, or we're the recursive auto-skip reached from + // CheckMergeOrRebaseWithRefreshOptions, which already runs on one. + runAction := func() error { result := self.c.Git().Rebase.GenericMergeOrRebaseAction(commandType, command) - return self.checkMergeOrRebaseImpl(result, + return self.CheckMergeOrRebaseWithRefreshOptions(result, types.RefreshOptions{ CommitSelection: commitSelectionAfterMerge(result == nil && selectHeadCommitOnSuccess), - }, calledFromWorker) + }) } if showWaitingStatus { return self.c.WithWaitingStatus(status.Title(self.c.Tr), func(gocui.Task) error { - // The waiting status ran runAction on a worker. - return runAction(true) + return runAction() }) } - return runAction(calledFromWorker) + return runAction() } // commitSelectionAfterMerge maps whether a merge/rebase/pull created a new @@ -209,33 +211,19 @@ func (self *MergeAndRebaseHelper) RecordWhetherMergeOrRebaseStartedInLazygit() { } // CheckMergeOrRebaseWithRefreshOptions handles the result of a merge/rebase -// step and refreshes. It's for callers running on a worker (the -// WithWaitingStatus / WithInlineStatus handlers), which is the large majority; -// UI-thread callers use CheckMergeOrRebaseWithRefreshOptionsFromUIThread. +// step and refreshes. It always runs on a worker (the WithWaitingStatus / +// WithWaitingStatusBlockingInput / WithInlineStatus handlers). func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptions(result error, refreshOptions types.RefreshOptions) error { - return self.checkMergeOrRebaseImpl(result, refreshOptions, true) -} - -// CheckMergeOrRebaseWithRefreshOptionsFromUIThread is like -// CheckMergeOrRebaseWithRefreshOptions, but for the callers that run the -// merge/rebase synchronously on the UI thread (the WithWaitingStatusSync -// move/revert/squash-fixups/cherry-pick-paste/patch-discard handlers, kept sync -// so rapid key presses batch) rather than on a worker. -func (self *MergeAndRebaseHelper) CheckMergeOrRebaseWithRefreshOptionsFromUIThread(result error, refreshOptions types.RefreshOptions) error { - return self.checkMergeOrRebaseImpl(result, refreshOptions, false) -} - -func (self *MergeAndRebaseHelper) checkMergeOrRebaseImpl(result error, refreshOptions types.RefreshOptions, calledFromWorker bool) error { - self.refreshAfterMergeOrRebase(refreshOptions, calledFromWorker) + self.refreshAfterMergeOrRebase(refreshOptions, true) self.RecordWhetherMergeOrRebaseStartedInLazygit() if result == nil { return nil } else if strings.Contains(result.Error(), "No changes - did you forget to use") { - return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, calledFromWorker) + return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, true) } else if strings.Contains(result.Error(), "The previous cherry-pick is now empty") { - return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, calledFromWorker) + return self.genericMergeCommandImpl(REBASE_OPTION_SKIP, false, true) } else if strings.Contains(result.Error(), "No rebase in progress?") { // assume in this case that we're already done return nil @@ -245,8 +233,8 @@ func (self *MergeAndRebaseHelper) checkMergeOrRebaseImpl(result error, refreshOp // refreshAfterMergeOrRebase issues the post-action refresh on the entry point // that matches the thread the merge/rebase ran on: RefreshFromWorker for the -// worker callers, Refresh for the ones that stayed synchronously on the UI -// thread. +// worker callers, Refresh for the merge/rebase-continue subprocess path that +// stays on the UI thread. func (self *MergeAndRebaseHelper) refreshAfterMergeOrRebase(refreshOptions types.RefreshOptions, calledFromWorker bool) { if calledFromWorker { self.c.RefreshFromWorker(refreshOptions) From a247dfd76d63c235f4e94dfc5ee31e57d0e4a07e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 19:01:46 +0200 Subject: [PATCH 311/384] Retire WithWaitingStatusSync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing calls it anymore now that the commit-surgery operations run on a worker with input blocked. Remove the helper, its bespoke synchronous spinner loop (renderAppStatusSync/setAppStatusContent), the popup-handler plumbing, and the interface method. That loop was also the only thing suppressing the yellow "Rebasing" mode indicator (and its reset button) while lazygit drives a rebase itself. Move that suppression to WithWaitingStatusBlockingInput so it applies to every input-blocking commit-surgery op — including the ones that already ran on a worker (edit, drop, and so on) and previously let the indicator flash on mid-operation. It's cleared after the refresh, so an operation that legitimately leaves a rebase in progress still shows the mode. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/app_status_helper.go | 81 +++---------------- pkg/gui/gui.go | 3 - pkg/gui/popup/popup_handler.go | 7 -- pkg/gui/types/common.go | 1 - 4 files changed, 10 insertions(+), 82 deletions(-) diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index a366909f6..69daa7d7a 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -98,31 +98,24 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui. // between. func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) { self.c.GocuiGui().BeginBlockingEvents() + // Hide the rebasing-mode indicator (and its reset button) while we drive the + // rebase ourselves; it reflects the transient on-disk state and would + // otherwise flash on for the duration of the operation. + self.modeHelper.SetSuppressRebasingMode(true) self.c.OnWorker(func(task gocui.Task) error { - // End the block 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. + // End the block and restore the mode indicator once the operation and its + // refresh have applied their UI updates: OnUIThread queues this after the + // refresh's model bounces and Then (which RefreshFromWorker has already + // enqueued by the time f returns), so the replayed keys act on the + // refreshed state and any resulting rebase state shows correctly. defer self.c.OnUIThread(func() error { + self.modeHelper.SetSuppressRebasingMode(false) return self.c.GocuiGui().EndBlockingEvents() }) return self.WithWaitingStatusImpl(message, f, task, false) }) } -func (self *AppStatusHelper) WithWaitingStatusSync(message string, f func() error) error { - self.c.PauseBackgroundRefreshes(true) - defer self.c.PauseBackgroundRefreshes(false) - - return self.statusMgr().WithWaitingStatus(message, func() {}, func(*status.WaitingStatusHandle) error { - stop := make(chan struct{}) - defer func() { close(stop) }() - self.renderAppStatusSync(stop) - - return f() - }) -} - func (self *AppStatusHelper) HasStatus() bool { return self.statusMgr().HasStatus() } @@ -174,57 +167,3 @@ func (self *AppStatusHelper) renderAppStatus(background bool) { return nil }) } - -func (self *AppStatusHelper) renderAppStatusSync(stop chan struct{}) { - go func() { - ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate)) - defer ticker.Stop() - - // Write the status into the view before the first layout below, so that - // layout (which sizes the bottom line based on the actual content of the - // AppStatus view) leaves room for it and it shows right away. The ticker - // only updates the spinner frame using ForceFlushViewsContentOnly, so this - // doesn't re-layout. - self.setAppStatusContent() - - // Forcing a re-layout and redraw after we added the waiting status; - // this is needed in case the gui.showBottomLine config is set to false, - // to make sure the bottom line appears. It's also useful for redrawing - // once after each of several consecutive keypresses, e.g. pressing - // ctrl-j to move a commit down several steps. - _ = self.c.GocuiGui().ForceLayoutAndRedraw() - - self.modeHelper.SetSuppressRebasingMode(true) - defer func() { self.modeHelper.SetSuppressRebasingMode(false) }() - - outer: - for { - select { - case <-ticker.C: - self.setAppStatusContent() - // Redraw all views of the bottom line: - bottomLineViews := []*gocui.View{ - self.c.Views().AppStatus, self.c.Views().Options, self.c.Views().Information, - self.c.Views().StatusSpacer1, self.c.Views().StatusSpacer2, - } - _ = self.c.GocuiGui().ForceFlushViewsContentOnly(bottomLineViews) - case <-stop: - // Clear the status from the view and re-layout, otherwise the - // stale content would keep layout reserving room for it forever. - // The UI thread is free again at this point, so we go through - // OnUIThread like the async renderAppStatus does. - self.c.OnUIThread(func() error { - self.c.SetViewContent(self.c.Views().AppStatus, "") - return nil - }) - break outer - } - } - }() -} - -func (self *AppStatusHelper) setAppStatusContent() { - appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig()) - self.c.Views().AppStatus.FgColor = color - self.c.SetViewContent(self.c.Views().AppStatus, appStatus) -} diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index a6baaf373..ce70cb2d5 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -823,9 +823,6 @@ func NewGui( func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatusBlockingInput(message, f) }, - func(message string, f func() error) error { - return gui.helpers.AppStatus.WithWaitingStatusSync(message, f) - }, func(message string, kind types.ToastKind) { gui.helpers.AppStatus.Toast(message, kind) }, func() string { return gui.Views.Prompt.TextArea.GetContent() }, func() bool { return gui.c.InDemo() }, diff --git a/pkg/gui/popup/popup_handler.go b/pkg/gui/popup/popup_handler.go index 23084f9ce..7c15c56ea 100644 --- a/pkg/gui/popup/popup_handler.go +++ b/pkg/gui/popup/popup_handler.go @@ -20,7 +20,6 @@ type PopupHandler struct { createMenuFn func(types.CreateMenuOptions) error withWaitingStatusFn func(message string, f func(gocui.Task) error) withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error) - withWaitingStatusSyncFn func(message string, f func() error) error toastFn func(message string, kind types.ToastKind) getPromptInputFn func() string inDemo func() bool @@ -37,7 +36,6 @@ func NewPopupHandler( createMenuFn func(types.CreateMenuOptions) error, withWaitingStatusFn func(message string, f func(gocui.Task) error), withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error), - withWaitingStatusSyncFn func(message string, f func() error) error, toastFn func(message string, kind types.ToastKind), getPromptInputFn func() string, inDemo func() bool, @@ -51,7 +49,6 @@ func NewPopupHandler( createMenuFn: createMenuFn, withWaitingStatusFn: withWaitingStatusFn, withWaitingStatusBlockingInputFn: withWaitingStatusBlockingInputFn, - withWaitingStatusSyncFn: withWaitingStatusSyncFn, toastFn: toastFn, getPromptInputFn: getPromptInputFn, inDemo: inDemo, @@ -84,10 +81,6 @@ func (self *PopupHandler) WithWaitingStatusBlockingInput(message string, f func( return nil } -func (self *PopupHandler) WithWaitingStatusSync(message string, f func() error) error { - return self.withWaitingStatusSyncFn(message, f) -} - func (self *PopupHandler) ErrorHandler(err error) error { var notHandledError *types.ErrKeybindingNotHandled if errors.As(err, ¬HandledError) { diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 964143c5a..5a256b434 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -162,7 +162,6 @@ type IPopupHandler interface { Prompt(opts PromptOpts) WithWaitingStatus(message string, f func(gocui.Task) error) error WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error - WithWaitingStatusSync(message string, f func() error) error Menu(opts CreateMenuOptions) error Toast(message string) ErrorToast(message string) From 9bb9fc8933315e72f096fd4141f602e89feb3a22 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 18:11:24 +0200 Subject: [PATCH 312/384] Run all refresh scopes on plain goroutines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two branches of the `refresh` closure ran the scope function identically; they differed only in that the UI-thread path registered each scope as its own gocui task while the worker/demo path used a bare goroutine (and only the latter logged per-scope timing). Those per-scope tasks were redundant. performRefresh always runs under a task that stays busy until the wg.Wait in waitAndFinalize joins every scope goroutine: the calling worker's own task when called from a worker, or the waitAndFinalize worker task when called from the UI thread — and that task is created (busy) before the triggering event's task goes Done, so there is no window in which nothing is busy. Repo-switch safety and the integration-test idle signal are therefore already covered without giving each scope its own task. Collapsing to the single goroutine path also means the timing log now fires for UI-thread refreshes too, not just worker ones. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index fdb526549..08a2e615c 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -234,27 +234,19 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr wg := sync.WaitGroup{} refresh := func(name string, f func()) { wg.Add(1) - - // A refresh issued from the UI thread must not block it, so its scopes - // run as their own worker tasks and the caller returns immediately (the - // finishing step below is dispatched to a worker too). A refresh issued - // from a worker blocks that worker instead, running its scopes as plain - // goroutines that it joins. In a demo we always take the blocking path - // so everything updates in a single, deterministic frame. - if !self.c.InDemo() && !calledFromWorker { - self.onWorker(env.background, func(t gocui.Task) error { - defer wg.Done() - f() - return nil - }) - } else { - go utils.Safe(func() { - t := time.Now() - defer wg.Done() - f() - self.c.Log.Infof("refreshed %s in %s", name, time.Since(t)) - }) - } + // Each scope runs on its own goroutine, joined by the wg.Wait in + // waitAndFinalize. They don't need to be registered as gocui tasks for + // repo-switch safety: performRefresh always runs under a task that stays + // busy until that wg.Wait returns — the calling worker's task when + // called from a worker, or the waitAndFinalize worker task when called + // from the UI thread (created before the triggering event's task ends, + // so there's no gap) — and that task already covers the whole refresh. + go utils.Safe(func() { + t := time.Now() + defer wg.Done() + f() + self.c.Log.Infof("refreshed %s in %s", name, time.Since(t)) + }) } branchesAndRemotesWg := sync.WaitGroup{} From 2f60280eb694a79921dd2eb980aa4ab519789cd1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 18:21:45 +0200 Subject: [PATCH 313/384] Log Refresh timing information for both sync/async For async refreshes (from UI thread) it would only log the time it took to schedule the refreshXxx calls, which is not useful. --- pkg/gui/controllers/helpers/refresh_helper.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 08a2e615c..5fa5ffbec 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -142,10 +142,7 @@ func (self *refreshBounceBatch) close() []func() { } func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) { - t := time.Now() - defer func() { - self.c.Log.Infof("Refresh took %s", time.Since(t)) - }() + startTime := time.Now() // A refresh from a worker blocks that worker until it's done; one from the // UI thread returns immediately and finishes in the background. @@ -432,6 +429,8 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // still pre-refresh. self.onUIThread(env.background, options.Then) } + + self.c.Log.Infof("Refresh took %s", time.Since(startTime)) } // waitAndFinalize blocks until every scope is done. Run it inline when we're From 4ff161b48d94b077920ca9a8b8d0f971072a021b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 18:28:59 +0200 Subject: [PATCH 314/384] Don't wait for pull requests to be fetched in refresh Fetching pull requests can take a long time, and we don't want to delay the refresh by it; in particular, for a WithWaitingStatusBlockingInput we want the UI thread to be unblocked again while pull requests are still fetching in the background. This is similar to how we fetch the behind values for branches in BranchLoader; this will update the UI without much flicker when done, and doesn't have to block anything. --- pkg/gui/controllers/helpers/refresh_helper.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 5fa5ffbec..768e7a618 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -365,13 +365,17 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.PULL_REQUESTS) { - refresh("pull requests", func() { + self.onWorker(env.background, func(gocui.Task) error { branchesAndRemotesWg.Wait() + + t := time.Now() // Use the branches and remotes the loads above stashed, not // Model().Branches/Remotes: those writes are bounced onto the // UI thread and may not have landed on this worker yet. The // wait above orders us after both loads have stashed theirs. self.refreshGithubPullRequests(loadedBranches, loadedRemotes, env) + self.c.Log.Infof("refreshed pull requests in %s", time.Since(t)) + return nil }) } From 303372d91789b0dd0614033b58122fa450ffa2bb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 07:53:14 +0200 Subject: [PATCH 315/384] Perform string-task view updates on the UI thread The closures that render static content to a main view (newStringTask and friends) ran on the ViewBufferManager's task goroutine, calling SetViewContent/SetOrigin/ResetViewOrigin directly on the view. Those touch view state (the line buffer, hover cells, the origin) that the UI thread concurrently reads and mutates while laying out and drawing, so they raced it -- e.g. a string task's SetContent clearing the view's lines while the UI thread's CopyContent read them, or its SetOrigin racing the layout's OriginY read. Bounce the whole closure onto the UI thread instead, so the view is only touched there. The bounce blocks (OnUIThreadAndWaitBackground) so the task still completes only once the content has actually been rendered, which the integration-test idle detection relies on; the background variant keeps it from counting towards the app being busy, matching the existing treatment of view rendering as work that must not block a repo switch. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/tasks_adapter.go | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index acad4fb75..808a4341d 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -59,8 +59,10 @@ func (gui *Gui) newStringTaskWithoutScroll(view *gocui.View, str string) error { manager := gui.getManager(view) f := func(tasks.TaskOpts) error { - gui.c.SetViewContent(view, str) - return nil + return gui.g.OnUIThreadAndWaitBackground(func() error { + gui.c.SetViewContent(view, str) + return nil + }) } if err := manager.NewTask(f, manager.GetTaskKey()); err != nil { @@ -74,9 +76,11 @@ func (gui *Gui) newStringTaskWithScroll(view *gocui.View, str string, originX in manager := gui.getManager(view) f := func(tasks.TaskOpts) error { - gui.c.SetViewContent(view, str) - view.SetOrigin(originX, originY) - return nil + return gui.g.OnUIThreadAndWaitBackground(func() error { + gui.c.SetViewContent(view, str) + view.SetOrigin(originX, originY) + return nil + }) } if err := manager.NewTask(f, manager.GetTaskKey()); err != nil { @@ -90,9 +94,11 @@ func (gui *Gui) newStringTaskWithKey(view *gocui.View, str string, key string) e manager := gui.getManager(view) f := func(tasks.TaskOpts) error { - gui.c.ResetViewOrigin(view) - gui.c.SetViewContent(view, str) - return nil + return gui.g.OnUIThreadAndWaitBackground(func() error { + gui.c.ResetViewOrigin(view) + gui.c.SetViewContent(view, str) + return nil + }) } if err := manager.NewTask(f, key); err != nil { From 99c1bcbf23ee9f08e8a694d53af08bc1b59394b7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 08:04:08 +0200 Subject: [PATCH 316/384] Reset the view origin for a new task on the UI thread When a task renders different content to a view (a new task key), the view's scroll origin is reset to the top via onNewKey. That ran on the task's own goroutine, racing the UI thread, which reads the origin (OriginY) while laying out and drawing the view -- the single largest source of view-render data races. Give ViewBufferManager a bounce primitive (onUIThread) that runs a function on the UI thread and waits for it, and reset the origin through it. This is the first use of the primitive; subsequent commits route the rest of the task's view mutations through it too, so that the view is only ever touched on the UI thread. It runs as background work (OnUIThreadAndWaitBackground) so rendering doesn't count towards the app being busy, matching how the render's gocui task is already created. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/tasks_adapter.go | 3 +++ pkg/tasks/tasks.go | 30 ++++++++++++++++++++++++------ pkg/tasks/tasks_test.go | 6 ++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 808a4341d..9f9488048 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -156,6 +156,9 @@ func (gui *Gui) getManager(view *gocui.View) *tasks.ViewBufferManager { // otherwise make the switch that handler triggers refuse itself. return gui.c.GocuiGui().NewBackgroundTask() }, + // Rendering is background work too (see above), so the view mutations + // it bounces onto the UI thread mustn't count towards being busy. + gui.g.OnUIThreadAndWaitBackground, ) gui.viewBufferManagerMap[view.Name()] = manager } diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 9da12d40b..c8f5b8244 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -74,6 +74,12 @@ type ViewBufferManager struct { // whereas the tasks in this file are about rendering content to a view. newGocuiTask func() gocui.Task + // Runs f on the UI thread and blocks until it has completed. All mutations + // of the view happen through this, so that the view is only ever touched on + // the UI thread (where it is also laid out and drawn), never on the task's + // own goroutine. + onUIThread func(f func() error) error + // if the user flicks through a heap of items, with each one // spawning a process to render something to the main view, // it can slow things down quite a bit. In these situations we @@ -110,6 +116,7 @@ func NewViewBufferManager( onEndOfInput func(), onNewKey func(), newGocuiTask func() gocui.Task, + onUIThread func(f func() error) error, ) *ViewBufferManager { return &ViewBufferManager{ Log: log, @@ -120,6 +127,7 @@ func NewViewBufferManager( readLines: nil, onNewKey: onNewKey, newGocuiTask: newGocuiTask, + onUIThread: onUIThread, } } @@ -460,21 +468,31 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error self.taskIDMutex.Lock() // Bail out before touching shared view state if a newer task has - // already been queued: if we ran onNewKey here we'd reset the view - // for a task that's about to exit, potentially wiping output the - // winning task has already written. + // already been queued: if we reset the view here we'd do it for a task + // that's about to exit, potentially wiping output the winning task has + // already written. if taskID < self.newTaskID { self.taskIDMutex.Unlock() return } - if self.GetTaskKey() != key && self.onNewKey != nil { - self.onNewKey() - } + resetOrigin := self.GetTaskKey() != key && self.onNewKey != nil self.taskKey = key self.taskIDMutex.Unlock() + if resetOrigin { + // onNewKey resets the view's scroll origin, which is view state the + // UI thread reads while laying out and drawing, so do it there. This + // must happen after releasing taskIDMutex: it blocks until the UI + // thread runs it, and a NewTask call on the UI thread takes + // taskIDMutex, so holding it here would deadlock. + _ = self.onUIThread(func() error { + self.onNewKey() + return nil + }) + } + self.waitingMutex.Lock() // Re-check staleness after acquiring waitingMutex: a newer task diff --git a/pkg/tasks/tasks_test.go b/pkg/tasks/tasks_test.go index c025e8e16..2cea139e8 100644 --- a/pkg/tasks/tasks_test.go +++ b/pkg/tasks/tasks_test.go @@ -39,6 +39,8 @@ func TestNewCmdTaskInstantStop(t *testing.T) { onEndOfInput, onNewKey, newTask, + // no UI thread in the test; run the view mutations inline + func(f func() error) error { return f() }, ) stop := make(chan struct{}) @@ -104,6 +106,8 @@ func TestNewCmdTask(t *testing.T) { onEndOfInput, onNewKey, newTask, + // no UI thread in the test; run the view mutations inline + func(f func() error) error { return f() }, ) stop := make(chan struct{}) @@ -237,6 +241,8 @@ func TestNewCmdTaskRefresh(t *testing.T) { func() {}, func() {}, newTask, + // no UI thread in the test; run the view mutations inline + func(f func() error) error { return f() }, ) stop := make(chan struct{}) From 40868a93895d8920cb89dc084644e9344d403d4d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 08:08:47 +0200 Subject: [PATCH 317/384] Hold the ViewBufferManager readLines channel in an atomic The readLines channel, by which a running task is told to read more lines as the user scrolls, is swapped out as tasks start and finish. It was a plain field written from the task goroutines (when a task starts, ends, or is replaced) and read from the UI thread in ReadLines/ ReadToEnd, so those accesses raced -- a longstanding data race (and a plausible cause of the occasional "main view stops updating" hang, since a torn read there could drop a scroll's read request). Make the field an atomic.Pointer and give the running task a captured local copy of the channel for its own send/receive, so the field itself is only ever loaded/stored atomically. No lock is involved, so there's nothing to untangle later. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index c8f5b8244..50a94a15c 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "sync" + "sync/atomic" "time" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" @@ -59,9 +60,13 @@ type ViewBufferManager struct { taskIDMutex deadlock.Mutex Log *logrus.Entry newTaskID int - readLines chan LinesToRead - taskKey string - onNewKey func() + // The channel by which the currently-running task is told to read more + // lines (e.g. as the user scrolls). Held in an atomic because it's swapped + // out as tasks come and go while ReadLines/ReadToEnd read it from the UI + // thread; nil when no task is running. + readLines atomic.Pointer[chan LinesToRead] + taskKey string + onNewKey func() // beforeStart is the function that is called before starting a new task beforeStart func() @@ -124,7 +129,6 @@ func NewViewBufferManager( beforeStart: beforeStart, refreshView: refreshView, onEndOfInput: onEndOfInput, - readLines: nil, onNewKey: onNewKey, newGocuiTask: newGocuiTask, onUIThread: onUIThread, @@ -136,17 +140,19 @@ func NewViewBufferManager( // (e.g. as the user scrolls down, back up, and down again) don't re-read lines // that have already been read: the task only ever reads the shortfall. func (self *ViewBufferManager) ReadLines(totalLines int) { - if self.readLines != nil { + if ch := self.readLines.Load(); ch != nil { + readLines := *ch go utils.Safe(func() { - self.readLines <- LinesToRead{Total: totalLines, InitialRefreshAfter: -1} + readLines <- LinesToRead{Total: totalLines, InitialRefreshAfter: -1} }) } } func (self *ViewBufferManager) ReadToEnd(then func()) { - if self.readLines != nil { + if ch := self.readLines.Load(); ch != nil { + readLines := *ch go utils.Safe(func() { - self.readLines <- LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: then} + readLines <- LinesToRead{Total: -1, InitialRefreshAfter: -1, Then: then} }) } else if then != nil { then() @@ -220,7 +226,8 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix loadingMutex := deadlock.Mutex{} - self.readLines = make(chan LinesToRead, 1024) + readLines := make(chan LinesToRead, 1024) + self.readLines.Store(&readLines) scanner := bufio.NewScanner(r) scanner.Split(utils.ScanLinesAndTruncateWhenLongerThanBuffer(bufio.MaxScanTokenSize)) @@ -312,7 +319,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix select { case <-opts.Stop: break outer - case linesToRead := <-self.readLines: + case linesToRead := <-readLines: callThen := func() { if linesToRead.Then != nil { linesToRead.Then() @@ -367,7 +374,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix } } - self.readLines = nil + self.readLines.Store(nil) refreshViewIfStale() @@ -391,7 +398,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix close(lineWrittenChan) }) - self.readLines <- linesToRead + readLines <- linesToRead <-done @@ -509,7 +516,7 @@ func (self *ViewBufferManager) NewTask(f func(TaskOpts) error, key string) error self.stopCurrentTask() } - self.readLines = nil + self.readLines.Store(nil) stop := make(chan struct{}) notifyStopped := make(chan struct{}) From e75688c101ce3e6b2a8c668a182f0e8c6c7aa1e8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 08:39:00 +0200 Subject: [PATCH 318/384] Make the ViewBufferManager throttle flag atomic The throttle flag is set from the goroutine that watches a task for being stopped, and read when the next task starts up -- two different goroutines, so the plain bool field was a data race. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index 50a94a15c..d58be3a92 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -88,8 +88,9 @@ type ViewBufferManager struct { // if the user flicks through a heap of items, with each one // spawning a process to render something to the main view, // it can slow things down quite a bit. In these situations we - // want to throttle the spawning of processes. - throttle bool + // want to throttle the spawning of processes. Atomic because it's set + // from one task's stop goroutine and read when the next task starts. + throttle atomic.Bool } type LinesToRead struct { @@ -177,7 +178,7 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix onFirstPageShown() } - if self.throttle { + if self.throttle.Load() { self.Log.Info("throttling task") time.Sleep(THROTTLE_TIME) } @@ -200,13 +201,13 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix case <-done: // The command finished and did not have to be preemptively stopped before the next command. // No need to throttle. - self.throttle = false + self.throttle.Store(false) case <-opts.Stop: // we use the time it took to start the program as a way of checking if things // are running slow at the moment. This is admittedly a crude estimate, but // the point is that we only want to throttle when things are running slow // and the user is flicking through a bunch of items. - self.throttle = time.Since(startTime) < THROTTLE_TIME && timeToStart > COMMAND_START_THRESHOLD + self.throttle.Store(time.Since(startTime) < THROTTLE_TIME && timeToStart > COMMAND_START_THRESHOLD) // Kill the still-running command. The only reason to do this is to save CPU usage // when flicking through several very long diffs when diff.algorithm = histogram is From f6eaed8cd4f2298fad4126ff56df77b71daed730 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 13:00:49 +0200 Subject: [PATCH 319/384] Snapshot the view width for command-task rendering on the UI thread A command task streams its output into a view from its own goroutine. To track soft-wraps (so cursor-positioning escapes from a pager land on the right line) the write path read the view's live InnerWidth, and the pty setup read its InnerSize -- both off the UI thread, racing the UI thread mutating the view's dimensions during layout. Capture the width on the UI thread instead and hand it to the task: the escape interpreter keeps a screenColMax it reads from, seeded in NewView and refreshed per render via View.SetContentWidth (called from newCmdTask/newPtyTask before the task's goroutine starts), and the pty size is computed in the after-layout callback rather than in the task's start func. The view's dimensions stay UI-thread-only; the task uses the snapshot rather than reading them live. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/escape.go | 14 +++++++++++--- pkg/gocui/view.go | 13 ++++++++++++- pkg/gui/pty.go | 11 ++++++++++- pkg/gui/tasks_adapter.go | 9 +++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/pkg/gocui/escape.go b/pkg/gocui/escape.go index ad862a596..7f3de9e6e 100644 --- a/pkg/gocui/escape.go +++ b/pkg/gocui/escape.go @@ -34,6 +34,14 @@ type escapeInterpreter struct { // modelled — we don't track the col argument of CUPs, and most // pager-style emitters use col 1 anyway. screenRow, screenCol int + + // The screen width that soft-wraps are counted against (see + // notifyCellsWritten). It's a snapshot of the view's InnerWidth taken on + // the UI thread (in NewView, and refreshed per render via + // View.SetContentWidth), rather than read live from the view's dimensions: + // a view's output is written from a task goroutine, and reading the live + // dimensions there would race the UI thread updating them during layout. + screenColMax int } type ( @@ -175,8 +183,8 @@ func (ei *escapeInterpreter) notifyColumnReset() { // columns; if that crosses the right edge of a `screenColMax`-wide pty // screen, the corresponding number of soft-wraps are added to screenRow // so subsequent CUPs land on the right line. -func (ei *escapeInterpreter) notifyCellsWritten(width, screenColMax int) { - if screenColMax <= 0 { +func (ei *escapeInterpreter) notifyCellsWritten(width int) { + if ei.screenColMax <= 0 { return } // One column at a time: matches ConPTY's "pending wrap" semantics @@ -185,7 +193,7 @@ func (ei *escapeInterpreter) notifyCellsWritten(width, screenColMax int) { // columns rather than doing the math in one shot so wide cells on a // row boundary still wrap cleanly. for range width { - if ei.screenCol > screenColMax { + if ei.screenCol > ei.screenColMax { ei.screenRow++ ei.screenCol = 1 } diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index e4c5a5f98..ae8aea880 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -536,9 +536,20 @@ func NewView(name string, x0, y0, x1, y1 int, mode OutputMode) *View { v.SelFgColor, v.SelBgColor = ColorDefault, ColorDefault v.InactiveViewSelBgColor = ColorDefault v.TitleColor, v.FrameColor = ColorDefault, ColorDefault + v.ei.screenColMax = v.InnerWidth() return v } +// SetContentWidth tells the view the screen width that content written to it +// should count soft-wraps against (see escapeInterpreter.notifyCellsWritten). +// Callers pass the view's InnerWidth; it's a separate call, made on the UI +// thread when a render starts, so that the task goroutine that streams the +// content can consult this snapshot instead of reading the view's live +// dimensions (which the UI thread mutates during layout). +func (v *View) SetContentWidth(width int) { + v.ei.screenColMax = width +} + // Dimensions returns the dimensions of the View func (v *View) Dimensions() (int, int, int, int) { return v.x0, v.y0, v.x1, v.y1 @@ -907,7 +918,7 @@ func (v *View) write(p []byte) { for _, c := range cells { totalWidth += c.width } - v.ei.notifyCellsWritten(totalWidth, v.InnerWidth()) + v.ei.notifyCellsWritten(totalWidth) } } } diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index 1a774fc3d..d4f739c6d 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -93,9 +93,18 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error manager := gui.getManager(view) + // Size the pty from the view's dimensions here, on the UI thread; the + // start func below runs on the task's goroutine, which must not read the + // view's live dimensions while the UI thread is laying it out. + cols, rows := gui.desiredPtySize(view) + var p oscommands.Pty start := func() (tasks.Cmd, io.Reader) { - cols, rows := gui.desiredPtySize(view) + // The pty (and pager) wrap to this width; apply it here, on the + // task's goroutine once the previous task has stopped, so it doesn't + // race that task's writes (see View.SetContentWidth). + view.SetContentWidth(width) + sp, err := oscommands.StartPty(cmd, cols, rows) if err != nil { gui.c.Log.Error(err) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 9f9488048..27aacf58b 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -18,8 +18,17 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error manager := gui.getManager(view) + // Snapshot the view width here, on the UI thread, so the task goroutine + // doesn't read the view's live dimensions while it streams output. It's + // applied inside start() below rather than now, because start() runs once + // the previous task has stopped -- applying it here would race that task's + // still-running writes (see View.SetContentWidth). + contentWidth := view.InnerWidth() + var r io.ReadCloser start := func() (tasks.Cmd, io.Reader) { + view.SetContentWidth(contentWidth) + var err error r, err = cmd.StdoutPipe() if err != nil { From 65cb439076b665d458609f7f3a769786200029e8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 13:01:01 +0200 Subject: [PATCH 320/384] Take the write mutex when clearing view lines and reading the buffer A view's line buffer, its viewLines/tainted flags, and its hover state are all written from the command-task goroutine (under writeMutex) as it renders. But three accessors reached that same state from the UI thread without the lock: SetView and the GUI-resize path cleared a view's lines directly, viewsToRedrawContentOnly read the tainted flag, and Buffer read the line buffer. Each raced a rendering task. Guard them with writeMutex, matching the view's other buffer accessors. These are reads/clears of state writeMutex already protects, not new callers of it -- the view's geometry stays outside the mutex. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 6 +++--- pkg/gocui/view.go | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 700c2b54c..936e1a310 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -385,7 +385,7 @@ func (g *Gui) SetView(name string, x0, y0, x1, y1 int, overlaps byte) (*View, er v.y1 = y1 if sizeChanged { - v.clearViewLines() + v.ClearViewLines() if v.Editable { cursorX, cursorY := v.TextArea.GetCursorXY() @@ -1461,7 +1461,7 @@ func (g *Gui) flush() error { // if GUI's size has changed, we need to redraw all views if maxX != g.maxX || maxY != g.maxY { for _, v := range g.views { - v.clearViewLines() + v.ClearViewLines() } } g.maxX, g.maxY = maxX, maxY @@ -1500,7 +1500,7 @@ func viewsToRedrawContentOnly(views []*View) []*View { redrawIndexes := set.New[int]() for i, v := range views { - if !v.tainted && !redrawIndexes.Includes(i) { + if !v.IsTainted() && !redrawIndexes.Includes(i) { continue } diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index ae8aea880..39f2d78a9 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -214,6 +214,16 @@ func (v *View) clearViewLines() { v.clearHover() } +// ClearViewLines is clearViewLines guarded by writeMutex. It's for callers on +// the UI thread (the layout pass) that touch a view whose content a task +// goroutine may be writing concurrently: viewLines/tainted/hover are all +// buffer state that writeMutex protects. +func (v *View) ClearViewLines() { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + v.clearViewLines() +} + type searcher struct { searchString string searchPositions []SearchPosition @@ -1287,6 +1297,8 @@ func (v *View) updateSearchPositions() { // IsTainted tells us if the view is tainted func (v *View) IsTainted() bool { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() return v.tainted } @@ -1535,6 +1547,9 @@ func (v *View) BufferLines() []string { // Buffer returns a string with the contents of the view's internal // buffer. func (v *View) Buffer() string { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + return linesToString(v.lines) } From eda215133072bfd135dae7c9001711358221e4b1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 13:01:09 +0200 Subject: [PATCH 321/384] Handle a command task's end-of-input on the UI thread When a command task reaches EOF it runs onEndOfInput, which reads the view's line height (and thus its dimensions) to decide whether to scroll, sets the view's origin, and flushes stale cells. Reading the dimensions and setting the origin are UI-thread-only, but this ran on the task's own goroutine, racing the UI thread. Bounce onEndOfInput onto the UI thread, as we already do for the new-task origin reset. It's once per render, so it doesn't add the per-line UI-thread churn that streaming the content would. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/tasks/tasks.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index d58be3a92..3a964c838 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -353,8 +353,14 @@ func (self *ViewBufferManager) NewCmdTask(start func() (Cmd, io.Reader), prefix if !ok { // if we're here then there's nothing left to scan from the source - // so we're at the EOF and can flush the stale content - self.onEndOfInput() + // so we're at the EOF and can flush the stale content. + // onEndOfInput reads the view's dimensions (to decide + // whether to scroll) and sets the origin, both of which + // are UI-thread-only, so run it there. + _ = self.onUIThread(func() error { + self.onEndOfInput() + return nil + }) callThen() break outer } From 9754a77b64439149a455ba91679ffe88efbebb3b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 15:08:07 +0200 Subject: [PATCH 322/384] Create popups and menus on the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raising a popup or menu pushes a context and mutates the popup views, so it must happen on the UI thread. But it can be triggered from a worker goroutine — for example a WithWaitingStatus handler that hits a merge conflict and calls PromptForConflictHandling, or a worker that shows a confirmation — where it raced the UI thread's layout and draw code. Bounce the creation onto the UI thread at the one point where the popup and menu producers are injected into the popup handler, so every caller stays oblivious to the threading. For a caller that is already on the UI thread this adds no delay: the main loop drains the enqueued closure in the same event-processing cycle, before it draws. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/gui.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index ce70cb2d5..993798e42 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -812,13 +812,25 @@ func NewGui( gui.PopupHandler = popup.NewPopupHandler( cmn, + // Raising a popup or menu pushes a context and mutates the popup views, + // and it can be triggered from a worker goroutine (e.g. a + // WithWaitingStatus handler that hits a merge conflict and asks the user + // how to proceed). Bounce the creation onto the UI thread so it can't + // race the layout/draw code. Doing it here, at the one point where these + // producers are injected, keeps every caller oblivious to the threading. func(ctx goContext.Context, opts types.CreatePopupPanelOpts) { - gui.helpers.Confirmation.CreatePopupPanel(ctx, opts) + gui.onUIThread(func() error { + gui.helpers.Confirmation.CreatePopupPanel(ctx, opts) + return nil + }) }, func() error { gui.c.Refresh(types.RefreshOptions{}); return nil }, func() { gui.State.ContextMgr.Pop() }, func() types.Context { return gui.State.ContextMgr.Current() }, - gui.createMenu, + func(opts types.CreateMenuOptions) error { + gui.onUIThread(func() error { return gui.createMenu(opts) }) + return nil + }, func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatus(message, f) }, func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatusBlockingInput(message, f) From 435e02efa83e8659e3befd2082852bc4df415a00 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 8 Jul 2026 15:44:54 +0200 Subject: [PATCH 323/384] Remove the now-dead PopupMutex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PopupMutex guarded CurrentPopupOpts against a popup being created on a worker goroutine while the UI thread deactivated it, or reset it on a repo switch. Now that popup and menu creation is bounced onto the UI thread, every access to CurrentPopupOpts — create, deactivate, and the reset-on-switch (which already runs on the UI thread) — happens on the one goroutine, so the mutex protects nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/confirmation_helper.go | 7 ------- pkg/gui/gui.go | 2 -- pkg/gui/types/common.go | 1 - 3 files changed, 10 deletions(-) diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go index 3663cd4ea..beffeb5e2 100644 --- a/pkg/gui/controllers/helpers/confirmation_helper.go +++ b/pkg/gui/controllers/helpers/confirmation_helper.go @@ -77,9 +77,7 @@ func (self *ConfirmationHelper) wrappedPromptConfirmationFunction( } func (self *ConfirmationHelper) DeactivateConfirmation() { - self.c.Mutexes().PopupMutex.Lock() self.c.State().GetRepoState().SetCurrentPopupOpts(nil) - self.c.Mutexes().PopupMutex.Unlock() self.c.Views().Confirmation.Visible = false @@ -87,9 +85,7 @@ func (self *ConfirmationHelper) DeactivateConfirmation() { } func (self *ConfirmationHelper) DeactivatePrompt() { - self.c.Mutexes().PopupMutex.Lock() self.c.State().GetRepoState().SetCurrentPopupOpts(nil) - self.c.Mutexes().PopupMutex.Unlock() self.c.Views().Prompt.Visible = false self.c.Views().Suggestions.Visible = false @@ -188,9 +184,6 @@ func characterForMask(mask bool) string { } func (self *ConfirmationHelper) CreatePopupPanel(ctx goContext.Context, opts types.CreatePopupPanelOpts) { - self.c.Mutexes().PopupMutex.Lock() - defer self.c.Mutexes().PopupMutex.Unlock() - _, cancel := goContext.WithCancel(ctx) // we don't allow interruptions of non-loader popups in case we get stuck somehow diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 993798e42..9b592ffae 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -620,9 +620,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { // setting this to nil so we don't get stuck based on a popup that was // previously opened - gui.Mutexes.PopupMutex.Lock() gui.State.CurrentPopupOpts = nil - gui.Mutexes.PopupMutex.Unlock() return gui.c.Context().Current() } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 5a256b434..6e7f72541 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -358,7 +358,6 @@ type Model struct { type Mutexes struct { SubprocessMutex deadlock.Mutex - PopupMutex deadlock.Mutex PtyMutex deadlock.Mutex } From 59ed1517bc1b3d42e91a5f65fc77ab427edbb5aa Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 10:25:13 +0200 Subject: [PATCH 324/384] Wait for the event loop to exit in integration tests The test harness enqueued ErrQuit after a test finished, waited for the program to go idle, then slept a fixed second and declared "gocui should have already exited" if it hadn't. That fixed grace is fragile: under the race detector the shutdown legitimately takes longer than a second, so nearly every test failed with that message even though nothing was wrong. Wait for the main loop to actually return instead. gocui now closes a loopExited channel when MainLoop exits, and the harness blocks on it; the existing 40s watchdog still fails a test whose loop genuinely never quits. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/gui.go | 12 ++++++++++++ pkg/gui/test_mode.go | 7 ++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 936e1a310..dbef57b59 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -148,6 +148,10 @@ type Gui struct { maxX, maxY int outputMode OutputMode stop chan struct{} + // loopExited is closed when MainLoop returns, so callers (e.g. the + // integration-test harness) can wait for the event loop to actually finish + // rather than polling or sleeping a fixed interval. + loopExited chan struct{} // BgColor and FgColor allow to configure the background and foreground // colors of the GUI. @@ -260,6 +264,7 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { g.outputMode = opts.OutputMode g.stop = make(chan struct{}) + g.loopExited = make(chan struct{}) g.gEvents = make(chan GocuiEvent, 20) g.userEvents = newUserEventQueue() @@ -348,6 +353,11 @@ func (g *Gui) Close() { Screen.Fini() } +// LoopExited returns a channel that is closed once MainLoop has returned. +func (g *Gui) LoopExited() <-chan struct{} { + return g.loopExited +} + // Size returns the terminal's size. func (g *Gui) Size() (x, y int) { return g.maxX, g.maxY @@ -965,6 +975,8 @@ func (g *Gui) SetManagerFunc(manager func(*Gui) error) { // MainLoop runs the main loop until an error is returned. A successful // finish should return ErrQuit. func (g *Gui) MainLoop() error { + defer close(g.loopExited) + g.uiThreadID.Store(goid.Get()) go func() { diff --git a/pkg/gui/test_mode.go b/pkg/gui/test_mode.go index 2d5958fbb..d6893c92d 100644 --- a/pkg/gui/test_mode.go +++ b/pkg/gui/test_mode.go @@ -40,11 +40,8 @@ func (gui *Gui) handleTestMode() { return gocui.ErrQuit }) - waitUntilIdle() - - time.Sleep(time.Second * 1) - - log.Fatal("gocui should have already exited") + // Wait for the event loop to actually exit. + <-gui.g.LoopExited() }() if os.Getenv(components.WAIT_FOR_DEBUGGER_ENV_VAR) == "" { From 33b8d497c22f131829b1b3181b9b19ce3800a80c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 10:25:22 +0200 Subject: [PATCH 325/384] Guard the patch builder against concurrent access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom-patch git operations (move/pull/delete patch, and their rebase continuations) run on worker goroutines and call PatchBuilder.Reset when they've consumed the patch, clearing To and the fileInfoMap. Meanwhile the UI thread reads that state every layout — the options bar and the mode indicator both call Active() — so the reset raced the render. Add a mutex. The map's entries are only ever touched on the UI thread, so the lock only has to serialize the To field and the fileInfoMap pointer: readers snapshot the pointer under the lock and iterate the local, and getFileInfo drops the lock across its git diff I/O rather than holding it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/commands/patch/patch_builder.go | 63 ++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/pkg/commands/patch/patch_builder.go b/pkg/commands/patch/patch_builder.go index b730d9f62..0d5ca34f8 100644 --- a/pkg/commands/patch/patch_builder.go +++ b/pkg/commands/patch/patch_builder.go @@ -6,6 +6,7 @@ import ( "github.com/jesseduffield/generics/maps" "github.com/samber/lo" + "github.com/sasha-s/go-deadlock" "github.com/sirupsen/logrus" ) @@ -50,6 +51,13 @@ type PatchBuilder struct { fileInfoMap map[string]*fileInfo Log *logrus.Entry + // mutex guards the fields that a git worker can mutate (via Reset, at the + // end of a patch-consuming operation) while the UI thread reads them to + // render — chiefly To and the fileInfoMap pointer. The map's *entries* are + // only ever touched on the UI thread, so we only hold the lock long enough + // to read or swap the fields, never across the git I/O in getFileInfo. + mutex deadlock.Mutex + // loadFileDiff loads the diff of a file, for a given to (typically a commit hash) loadFileDiff loadFileDiffFunc } @@ -62,6 +70,9 @@ func NewPatchBuilder(log *logrus.Entry, loadFileDiff loadFileDiffFunc) *PatchBui } func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) { + p.mutex.Lock() + defer p.mutex.Unlock() + p.To = to p.From = from p.reverse = reverse @@ -69,10 +80,21 @@ func (p *PatchBuilder) Start(from, to string, reverse bool, canRebase bool) { p.fileInfoMap = map[string]*fileInfo{} } +// snapshotFileInfoMap returns the current fileInfoMap under the lock. The map's +// entries are only mutated on the UI thread, so callers can read the returned +// map without holding the lock; the lock only serializes the pointer swap that +// Reset/Start do (potentially from a git worker) against these reads. +func (p *PatchBuilder) snapshotFileInfoMap() map[string]*fileInfo { + p.mutex.Lock() + defer p.mutex.Unlock() + + return p.fileInfoMap +} + func (p *PatchBuilder) PatchToApply(reverse bool, turnAddedFilesIntoDiffAgainstEmptyFile bool) string { var patch strings.Builder - for filename, info := range p.fileInfoMap { + for filename, info := range p.snapshotFileInfoMap() { if info.mode == UNSELECTED { continue } @@ -130,12 +152,17 @@ func (p *PatchBuilder) RemoveFile(filename string, previousPath string) error { } func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileInfo, error) { - info, ok := p.fileInfoMap[filename] + p.mutex.Lock() + fileInfoMap := p.fileInfoMap + from, to, reverse := p.From, p.To, p.reverse + p.mutex.Unlock() + + info, ok := fileInfoMap[filename] if ok { return info, nil } - diff, err := p.loadFileDiff(p.From, p.To, p.reverse, filename, previousPath, true) + diff, err := p.loadFileDiff(from, to, reverse, filename, previousPath, true) if err != nil { return nil, err } @@ -145,7 +172,7 @@ func (p *PatchBuilder) getFileInfo(filename string, previousPath string) (*fileI previousPath: previousPath, } - p.fileInfoMap[filename] = info + fileInfoMap[filename] = info return info, nil } @@ -220,14 +247,16 @@ func (p *PatchBuilder) RenderPatchForFile(opts RenderPatchForFileOpts) string { } func (p *PatchBuilder) renderEachFilePatch(plain bool) []string { + fileInfoMap := p.snapshotFileInfoMap() + // sort files by name then iterate through and render each patch - filenames := maps.Keys(p.fileInfoMap) + filenames := maps.Keys(fileInfoMap) sort.Strings(filenames) patches := lo.Map(filenames, func(filename string, _ int) string { return p.RenderPatchForFile(RenderPatchForFileOpts{ Filename: filename, - PreviousPath: p.fileInfoMap[filename].previousPath, + PreviousPath: fileInfoMap[filename].previousPath, Plain: plain, Reverse: false, TurnAddedFilesIntoDiffAgainstEmptyFile: true, @@ -245,11 +274,16 @@ func (p *PatchBuilder) RenderAggregatedPatch(plain bool) string { } func (p *PatchBuilder) GetFileStatus(filename string, parent string) PatchStatus { - if parent != p.To { + p.mutex.Lock() + to := p.To + fileInfoMap := p.fileInfoMap + p.mutex.Unlock() + + if parent != to { return UNSELECTED } - info, ok := p.fileInfoMap[filename] + info, ok := fileInfoMap[filename] if !ok { return UNSELECTED } @@ -267,16 +301,22 @@ func (p *PatchBuilder) GetFileIncLineIndices(filename string, previousPath strin // clears the patch func (p *PatchBuilder) Reset() { + p.mutex.Lock() + defer p.mutex.Unlock() + p.To = "" p.fileInfoMap = map[string]*fileInfo{} } func (p *PatchBuilder) Active() bool { + p.mutex.Lock() + defer p.mutex.Unlock() + return p.To != "" } func (p *PatchBuilder) IsEmpty() bool { - for _, fileInfo := range p.fileInfoMap { + for _, fileInfo := range p.snapshotFileInfoMap() { if fileInfo.mode == WHOLE || (fileInfo.mode == PART && len(fileInfo.includedLineIndices) > 0) { return false } @@ -287,9 +327,12 @@ func (p *PatchBuilder) IsEmpty() bool { // if any of these things change we'll need to reset and start a new patch func (p *PatchBuilder) NewPatchRequired(from string, to string, reverse bool) bool { + p.mutex.Lock() + defer p.mutex.Unlock() + return from != p.From || to != p.To || reverse != p.reverse } func (p *PatchBuilder) AllFilesInPatch() []string { - return lo.Keys(p.fileInfoMap) + return lo.Keys(p.snapshotFileInfoMap()) } From d48c8174d5d1a7af209c2a3935bc61719f610be1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 10:25:28 +0200 Subject: [PATCH 326/384] Refresh the patch-building panel on the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The patch-building scope ran RefreshPatchBuildingPanel directly on the refresh worker, where it read the commit-files selection and set the patch view's origin off the UI thread — the latter raced the UI thread's draw. Bounce it onto the UI thread, exactly as the staging panel just above already does, guarded on the generation so a repo switch drops it. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/helpers/refresh_helper.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 768e7a618..39a8c2fe1 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -398,7 +398,16 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.PATCH_BUILDING) { - refresh("patch building", func() { self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) }) + refresh("patch building", func() { + // Bounce onto the UI thread, like the staging panel above: + // RefreshPatchBuildingPanel reads the commit-files selection and + // sets the patch view's origin, neither of which may run off the UI + // thread. Guard on the generation so a repo switch mid-refresh drops + // it, like the model bounces. + self.onUIThreadUnlessRepoChanged(env, func() { + self.patchBuildingHelper.RefreshPatchBuildingPanel(types.OnFocusOpts{}) + }) + }) } if scopeSet.Includes(types.MERGE_CONFLICTS) { From 1efcfcc1484ee2d4bdb8abc0c04219f1fcfe2577 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 10:50:13 +0200 Subject: [PATCH 327/384] Don't share a live view's buffer when copying its content moveMainContextToTop copies the current top view's content into the view it's promoting, to avoid a flicker. The source can be a main view with a live streaming task (e.g. resolving a conflict promotes the merge-conflicts view over a main view that's mid-diff), and CopyContent both read and published that source's buffer unsafely: - it read the source's lines/viewLines while locking only the destination, racing the task's concurrent Write; and - it aliased the source's row slices into the destination, so the source's ongoing appends (growslice reading the shared array) and refreshViewLinesIfNeeded's in-place wrapping-cache writes (&lines[i]) kept racing this view's rendering after the copy. Lock the source for the read, and shallow-clone the row slices so the destination gets its own arrays. The per-row cell data is immutable once written, so it stays shared -- the clone cost is proportional to the number of rows, not their contents. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gocui/view.go | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 39f2d78a9..b106eb21f 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -7,6 +7,7 @@ package gocui import ( "fmt" "io" + "slices" "strings" "sync" "unicode" @@ -1146,10 +1147,25 @@ func (v *View) CopyContent(from *View) { v.writeMutex.Lock() defer v.writeMutex.Unlock() + // A background task may be streaming output into the source view's buffer + // via Write, so read it under its own lock. The source is always a + // different view than the destination (see the sole caller, + // moveMainContextToTop), and no other code holds two view write locks at + // once, so this can't deadlock. + from.writeMutex.Lock() + defer from.writeMutex.Unlock() + v.clear() - v.lines = from.lines - v.viewLines = from.viewLines + // Clone the row slices rather than sharing them: the source view stays + // live (its streaming task keeps appending rows, and refreshViewLinesIfNeeded + // fills each row's wrapping cache in place via &lines[i]), so sharing the + // backing arrays would race those writes against this view's own rendering. + // This is a shallow clone -- the per-row cell data is immutable once written + // and stays shared, so the cost is proportional to the number of rows, not + // their contents. + v.lines = slices.Clone(from.lines) + v.viewLines = slices.Clone(from.viewLines) v.ox = from.ox v.oy = from.oy v.cx = from.cx From 1b0cc02e1e5106308ec0e1e4ae233987f3d50795 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 12:21:45 +0200 Subject: [PATCH 328/384] Refresh once when dropping multiple stash entries Dropping a range of stashes ran a refresh after each drop. A refresh issued from the UI thread does its git work on a worker and applies the model update in the background, so firing one per iteration let the workers race: an earlier drop's refresh (which read a stash list that still contained a later-dropped entry) could apply its result last, leaving the stash view showing an entry that git had already removed. Refresh once, after all the drops, so a single worker reads the final stash list. The indices are captured up front and dropped highest-first, so the remaining lower indices stay valid without an intervening refresh. It's also cheaper: one `git stash list` instead of one per entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/controllers/stash_controller.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 06e6991c6..a2b7e9e97 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -170,11 +170,16 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) Prompt: self.c.Tr.SureDropStashEntry, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.DropStash) + // Refresh once at the end rather than after each drop: an async + // refresh from the UI thread finishes in the background, so firing + // one per iteration lets the workers race and an earlier, stale + // result can land last. The indices are captured up front and we + // drop highest-first, so the remaining lower indices stay valid + // without an intervening refresh. + defer self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) for i := len(stashEntries) - 1; i >= 0; i-- { self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.DroppingStash, stashEntries[i].Hash), false) - err := self.c.Git().Stash.Drop(stashEntries[i].Index) - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) - if err != nil { + if err := self.c.Git().Stash.Drop(stashEntries[i].Index); err != nil { return err } } From d36ce5155968b318d0ecd3d3b068699410deb332 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 21:40:51 +0200 Subject: [PATCH 329/384] Capture suggestions inputs on the UI thread RefreshSuggestions dispatched to an AsyncHandler worker that read State.FindSuggestions and the prompt's TextArea (via GetPromptInput) from the worker goroutine. The main thread rewrites both in preparePromptPanel when it (re)creates a prompt panel, so an in-flight suggestions worker races those writes -- two data races surfaced under -race (filter_by_path/reword_commit_in_filtering_mode). Capture both on the UI thread (RefreshSuggestions is only ever called from UI-thread handlers) before dispatching to the worker. This is also more correct: we search for the input as it was when dispatched, which is what this request's AsyncHandler id corresponds to. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/context/suggestions_context.go | 11 +++++++++-- pkg/gui/editors.go | 7 +++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/gui/context/suggestions_context.go b/pkg/gui/context/suggestions_context.go index fb69b34d9..6f0b3eae6 100644 --- a/pkg/gui/context/suggestions_context.go +++ b/pkg/gui/context/suggestions_context.go @@ -81,10 +81,17 @@ func (self *SuggestionsContext) SetSuggestions(suggestions []*types.Suggestion) } func (self *SuggestionsContext) RefreshSuggestions() { + // Capture the suggestions function and the prompt input here, on the UI + // thread, rather than inside the worker below: the main thread rewrites both + // (State.FindSuggestions and the prompt's TextArea) when it (re)creates a + // prompt panel, so reading them from the worker races those writes. It's + // also more correct -- we search for the input as it was when dispatched, + // which is what this request's AsyncHandler id corresponds to. + findSuggestionsFn := self.State.FindSuggestions + promptInput := self.c.GetPromptInput() self.State.AsyncHandler.Do(func() func() { - findSuggestionsFn := self.State.FindSuggestions if findSuggestionsFn != nil { - suggestions := findSuggestionsFn(self.c.GetPromptInput()) + suggestions := findSuggestionsFn(promptInput) return func() { self.SetSuggestions(suggestions) } } return func() {} diff --git a/pkg/gui/editors.go b/pkg/gui/editors.go index 7d3a93de3..37eacf416 100644 --- a/pkg/gui/editors.go +++ b/pkg/gui/editors.go @@ -35,10 +35,13 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key) bool { v.RenderTextArea() suggestionsContext := gui.State.Contexts.Suggestions - if suggestionsContext.State.FindSuggestions != nil { + // Capture the suggestions function and the input here, on the UI thread; the + // main thread rewrites State.FindSuggestions when it (re)creates a prompt + // panel, so reading it from the worker below would race that write. + if findSuggestions := suggestionsContext.State.FindSuggestions; findSuggestions != nil { input := v.TextArea.GetContent() suggestionsContext.State.AsyncHandler.Do(func() func() { - suggestions := suggestionsContext.State.FindSuggestions(input) + suggestions := findSuggestions(input) return func() { suggestionsContext.SetSuggestions(suggestions) } }) } From 2a7b74d3f310ed33bb4635290358c860e74171af Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 22:43:26 +0200 Subject: [PATCH 330/384] Don't access Model in refreshReflogCommits This was old code that was supposed to make a race less likely, but now that we capture model stuff on the UI thread we don't need it any more. --- pkg/gui/controllers/helpers/refresh_helper.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 39a8c2fe1..d0a0610e2 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1313,10 +1313,6 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re // that a subsequent branches refresh can use them for recency sorting without // having to read them back out of the model. func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, env refreshEnv, selectTopEntry bool) ([]*models.Commit, error) { - // pulling state into its own variable in case it gets swapped out for another state - // and we get an out of bounds exception - model := self.c.Model() - // load does the git work on the worker and returns the new value for a // reflog slice, reading the existing slice (captured on the UI thread) for // the incremental fetch. The caller writes the result in the bounce. @@ -1352,8 +1348,8 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en } self.onUIThreadUnlessRepoChanged(env, func() { - model.ReflogCommits = reflogCommits - model.FilteredReflogCommits = filteredReflogCommits + 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. From 25a3689c016955595a43a90410b235a775af1ae8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 13:19:03 +0200 Subject: [PATCH 331/384] Refresh the merge conflicts state on the UI thread The "merge conflicts" refresh scope ran on a worker like the others, but unlike them it does UI work rather than git work: RefreshMergeState reads the current context and renders (or escapes) the merge-conflicts view. Reading the context manager and rendering from a worker races the UI thread. Bounce it onto the UI thread with onUIThreadUnlessRepoChanged, exactly as the staging and patch-building scopes already do. Running on the UI thread also lets EscapeMerge push the files context directly instead of deferring the push to a separate UI task; it only needs to drop the merge-conflicts mutex first, because the push renders the newly focused file, which can take the mutex again. The deferred push could lose a race against the same refresh's prompt to continue the rebase/merge: if the prompt opened between RefreshMergeState and the deferred push, the push declined to cover the popup and was dropped, so closing the prompt landed the user in the emptied merge conflicts view. Co-Authored-By: Claude Opus 4.8 (1M context) Co-Authored-By: Claude Fable 5 --- .../helpers/merge_conflicts_helper.go | 49 ++++++++----------- pkg/gui/controllers/helpers/refresh_helper.go | 10 +++- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_conflicts_helper.go b/pkg/gui/controllers/helpers/merge_conflicts_helper.go index 175bc3cc0..34ae285f0 100644 --- a/pkg/gui/controllers/helpers/merge_conflicts_helper.go +++ b/pkg/gui/controllers/helpers/merge_conflicts_helper.go @@ -51,32 +51,28 @@ func (self *MergeConflictsHelper) resetMergeState() { self.context().GetState().Reset() } -func (self *MergeConflictsHelper) EscapeMerge(background bool) error { - self.resetMergeState() +// EscapeMerge returns from the merge conflicts view to the files context. It +// must be called on the UI thread, without the merge-conflicts mutex held: +// pushing the files context renders the newly focused file to the main view, +// which can take the mutex again (via SetMergeState). +func (self *MergeConflictsHelper) EscapeMerge() { + self.ResetMergeState() - // doing this in separate UI thread so that we're not still holding the lock by the time refresh the file - onUIThread := self.c.OnUIThread - if background { - // Reached from a background files refresh; keep it off the busy count - // (see the *Background dispatch methods) so it doesn't block a repo switch. - onUIThread = self.c.OnUIThreadBackground + // The files refresh may already have opened the prompt to continue the + // rebase/merge on top of us (if all conflicts are resolved); in that case + // don't push the files context over it. + if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) { + self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) } - onUIThread(func() error { - // There is a race condition here: refreshing the files scope can trigger the - // confirmation context to be pushed if all conflicts are resolved (prompting - // to continue the merge/rebase. In that case, we don't want to then push the - // files context over it. - // So long as both places call OnUIThread, we're fine. - if self.c.Context().IsCurrent(self.c.Contexts().MergeConflicts) { - self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) - } - return nil - }) - return nil } -func (self *MergeConflictsHelper) SetConflictsAndRender(path string) (bool, error) { - hasConflicts, err := self.setMergeStateWithoutLock(path) +// SetConflictsAndRender re-reads the file being merged and re-renders the +// merge conflicts view. Returns whether the file still has conflicts. +func (self *MergeConflictsHelper) SetConflictsAndRender() (bool, error) { + self.context().GetMutex().Lock() + defer self.context().GetMutex().Unlock() + + hasConflicts, err := self.setMergeStateWithoutLock(self.context().GetState().GetPath()) if err != nil { return false, err } @@ -126,21 +122,18 @@ func (self *MergeConflictsHelper) Render() { }) } -func (self *MergeConflictsHelper) RefreshMergeState(background bool) error { - self.c.Contexts().MergeConflicts.GetMutex().Lock() - defer self.c.Contexts().MergeConflicts.GetMutex().Unlock() - +func (self *MergeConflictsHelper) RefreshMergeState() error { if self.c.Context().Current().GetKey() != context.MERGE_CONFLICTS_CONTEXT_KEY { return nil } - hasConflicts, err := self.SetConflictsAndRender(self.c.Contexts().MergeConflicts.GetState().GetPath()) + hasConflicts, err := self.SetConflictsAndRender() if err != nil { return err } if !hasConflicts { - return self.EscapeMerge(background) + self.EscapeMerge() } return nil diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index d0a0610e2..4fedf9c7d 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -411,7 +411,15 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.MERGE_CONFLICTS) { - refresh("merge conflicts", func() { _ = self.mergeConflictsHelper.RefreshMergeState(env.background) }) + refresh("merge conflicts", func() { + // Bounce onto the UI thread, like the staging and patch-building + // panels above: RefreshMergeState reads the current context and + // renders (or escapes) the merge-conflicts view, none of which may + // run off the UI thread. + self.onUIThreadUnlessRepoChanged(env, func() { + _ = self.mergeConflictsHelper.RefreshMergeState() + }) + }) } self.refreshStatus(env) From 9f2886f96f75f0c127212562e05cc4d53e49b147 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 13:37:38 +0200 Subject: [PATCH 332/384] Hold the file-path suggestions trie outside the model The file-path suggestions trie is rebuilt asynchronously and then read by the suggestions search, which runs on an AsyncHandler worker. It lived in Model().FilesTrie, so that worker read the (UI-thread-only) model. Move it to an atomic pointer on the SuggestionsHelper instead: it's the only place that uses it, the helper is recreated per repo (so the cache still resets on a repo switch), and an atomic pointer is safe to store from the build and load from the search worker. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/helpers/suggestions_helper.go | 22 +++++++++++++------ pkg/gui/gui.go | 2 -- pkg/gui/types/common.go | 4 ---- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/pkg/gui/controllers/helpers/suggestions_helper.go b/pkg/gui/controllers/helpers/suggestions_helper.go index 8a5916816..8784d82fc 100644 --- a/pkg/gui/controllers/helpers/suggestions_helper.go +++ b/pkg/gui/controllers/helpers/suggestions_helper.go @@ -3,6 +3,7 @@ package helpers import ( "fmt" "strings" + "sync/atomic" "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -28,14 +29,20 @@ import ( type SuggestionsHelper struct { c *HelperCommon + + // filesTrie holds the repo's file paths for file-path suggestions. It's + // rebuilt asynchronously and read from the suggestions worker goroutine, so + // it lives here as an atomic pointer rather than in the (UI-thread-only) + // model. + filesTrie atomic.Pointer[patricia.Trie] } func NewSuggestionsHelper( c *HelperCommon, ) *SuggestionsHelper { - return &SuggestionsHelper{ - c: c, - } + self := &SuggestionsHelper{c: c} + self.filesTrie.Store(patricia.NewTrie()) + return self } func (self *SuggestionsHelper) getRemoteNames() []string { @@ -137,9 +144,9 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type trie.Insert(patricia.Prefix(file), file) } + // cache the trie for future use + self.filesTrie.Store(trie) self.c.OnUIThread(func() error { - // cache the trie for future use - self.c.Model().FilesTrie = trie self.c.Contexts().Suggestions.RefreshSuggestions() return nil }) @@ -148,9 +155,10 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type }) return func(input string) []*types.Suggestion { + filesTrie := self.filesTrie.Load() matchingNames := []string{} if self.c.UserConfig().Gui.UseFuzzySearch() { - _ = self.c.Model().FilesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error { + _ = filesTrie.VisitFuzzy(patricia.Prefix(input), true, func(prefix patricia.Prefix, item patricia.Item, skipped int) error { matchingNames = append(matchingNames, item.(string)) return nil }) @@ -159,7 +167,7 @@ func (self *SuggestionsHelper) GetFilePathSuggestionsFunc() func(string) []*type matchingNames = utils.FilterStrings(input, matchingNames, true) } else { substrings := strings.Fields(input) - _ = self.c.Model().FilesTrie.Visit(func(prefix patricia.Prefix, item patricia.Item) error { + _ = filesTrie.Visit(func(prefix patricia.Prefix, item patricia.Item) error { for _, sub := range substrings { if !utils.CaseAwareContains(item.(string), sub) { return nil diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 9b592ffae..533c01fbd 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -49,7 +49,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/sasha-s/go-deadlock" - "gopkg.in/ozeidan/fuzzy-patricia.v3/patricia" ) const StartupPopupVersion = 5 @@ -639,7 +638,6 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { FilteredReflogCommits: make([]*models.Commit, 0), ReflogCommits: make([]*models.Commit, 0), BisectInfo: git_commands.NewNullBisectInfo(), - FilesTrie: patricia.NewTrie(), Authors: map[string]*models.Author{}, MainBranches: git_commands.NewMainBranches(gui.c.Common, gui.os.Cmd), HashPool: &utils.StringPool{}, diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 6e7f72541..6d11e29db 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -11,7 +11,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/tasks" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/sasha-s/go-deadlock" - "gopkg.in/ozeidan/fuzzy-patricia.v3/patricia" ) type HelperCommon struct { @@ -348,9 +347,6 @@ type Model struct { MainBranches *git_commands.MainBranches - // for displaying suggestions while typing in a file name - FilesTrie *patricia.Trie - Authors map[string]*models.Author HashPool *utils.StringPool From 87ef96974e26ba31b34eae4acfc5d1365a86ddc1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 13:42:24 +0200 Subject: [PATCH 333/384] Check for exec todos on the UI thread hasExecTodos reads Model().Commits. genericMergeCommandImpl evaluates it when deciding whether to use a subprocess, and on the recursive auto-skip path that runs on a worker -- so the read raced the UI thread. Bounce it onto the UI thread there, keyed off the calledFromWorker flag the function already carries (on the UI-thread entry path the read stays inline). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../helpers/merge_and_rebase_helper.go | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index a8696a21c..7c7ab3e9a 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -127,7 +127,7 @@ func (self *MergeAndRebaseHelper) genericMergeCommandImpl(command string, showWa needsSubprocess := (effectiveStatus == models.WORKING_TREE_STATE_MERGING && command != REBASE_OPTION_ABORT && self.c.UserConfig().Git.Merging.ManualCommit) || // but we'll also use a subprocess if we have exec todos; those are likely to be lengthy build // tasks whose output the user will want to see in the terminal - (effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos()) + (effectiveStatus == models.WORKING_TREE_STATE_REBASING && command != REBASE_OPTION_ABORT && self.hasExecTodos(calledFromWorker)) if needsSubprocess { // TODO: see if we should be calling more of the code from self.Git.Rebase.GenericMergeOrRebaseAction @@ -168,16 +168,31 @@ func commitSelectionAfterMerge(createdNewCommit bool) types.CommitSelectionBehav return types.KeepCommitSelectionByHash } -func (self *MergeAndRebaseHelper) hasExecTodos() bool { - for _, commit := range self.c.Model().Commits { - if !commit.IsTODO() { - break - } - if commit.Action == todo.Exec { - return true +func (self *MergeAndRebaseHelper) hasExecTodos(calledFromWorker bool) bool { + check := func() bool { + for _, commit := range self.c.Model().Commits { + if !commit.IsTODO() { + break + } + if commit.Action == todo.Exec { + return true + } } + return false } - return false + + // This reads the model, which is only safe on the UI thread, so bounce there + // when we're being called from a worker. + if !calledFromWorker { + return check() + } + + result := false + _ = self.c.GocuiGui().OnUIThreadAndWait(func() error { + result = check() + return nil + }) + return result } var conflictStrings = []string{ From c23bcd6d9423f3fd51bd8b8d39a5b01dbc52ca65 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 15 Jul 2026 15:19:55 +0200 Subject: [PATCH 334/384] Store the UI thread ID earlier --- pkg/gocui/gui.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index dbef57b59..20beb2af7 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -293,6 +293,12 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { g.playRecording = opts.PlayRecording + // Record the UI thread here, at construction. This assumes NewGui is called + // on the same goroutine that will run MainLoop, which holds for all our + // callers -- and it means IsUIThread is already correct for the UI work that + // runs during startup, before we reach MainLoop. + g.uiThreadID.Store(goid.Get()) + return g, nil } @@ -977,8 +983,6 @@ func (g *Gui) SetManagerFunc(manager func(*Gui) error) { func (g *Gui) MainLoop() error { defer close(g.loopExited) - g.uiThreadID.Store(goid.Get()) - go func() { for { select { From e299de32700ab1a2d865652b6375a6c1131ec76c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 14 Jul 2026 13:11:07 +0200 Subject: [PATCH 335/384] Assert that Model() and Context() are only accessed on the UI thread The bounce model requires that a worker never touch UI-thread-owned state: it should capture what it needs on the UI thread and pass that in. Guard the two central accessors -- Model() (the git model) and Context() (the context manager, which owns the mutable current-context/stack) -- with a debug-only panic when they're called off the UI thread. Since the integration tests run with -debug, a stray worker access now fails deterministically and points at itself, rather than surfacing later as a probabilistic data race. One supporting change make the assertion usable: the integration test driver inspects gui state from the test goroutine, so GuiDriver.CurrentContext reads the context manager directly rather than through the now-guarded c.Context(). Contexts() (the registry of context objects) is deliberately left unguarded: workers legitimately fetch a context to grab its mutex or check identity, so a blanket assertion there would flag safe accesses. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/gui_common.go | 12 ++++++++++++ pkg/gui/gui_driver.go | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index 69ec44781..80b2b9ded 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -58,7 +58,18 @@ func (self *guiCommon) PauseBackgroundRefreshes(pause bool) { self.gui.BackgroundRoutineMgr.PauseBackgroundRefreshes(pause) } +// assertOnUIThread panics (in debug builds) if called from a worker goroutine. +// Use it to guard accessors for state that only the UI thread may touch, so +// that a stray worker access fails deterministically -- and points at itself -- +// rather than surfacing later as a probabilistic data race. +func (self *guiCommon) assertOnUIThread(accessor string) { + if self.GetConfig().GetDebug() && !self.GocuiGui().IsUIThread() { + panic(accessor + " accessed from a worker") + } +} + func (self *guiCommon) Context() types.IContextMgr { + self.assertOnUIThread("Context()") return self.gui.State.ContextMgr } @@ -113,6 +124,7 @@ func (self *guiCommon) Modes() *types.Modes { } func (self *guiCommon) Model() *types.Model { + self.assertOnUIThread("Model()") return self.gui.State.Model } diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 31094b253..7bd31d93d 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -92,7 +92,10 @@ func (self *GuiDriver) Keys() config.KeybindingConfig { } func (self *GuiDriver) CurrentContext() types.Context { - return self.gui.c.Context().Current() + // Read the context manager directly rather than through c.Context(): the + // driver runs on the test goroutine, not the UI thread, so it must bypass + // the UI-thread assertion that accessor carries. + return self.gui.State.ContextMgr.Current() } func (self *GuiDriver) ContextForView(viewName string) types.Context { From 5769ab219069daafbbdba09c2e8529cd17d250f1 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 08:16:25 +0200 Subject: [PATCH 336/384] Don't retry failed integration tests Now that we solved all known concurrency issues and our tests should be 100% deterministic, reduce MaxAttempts to 1 so that tests fail immediately. We don't want to paper over existing flakiness any more. --- pkg/integration/clients/go_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/integration/clients/go_test.go b/pkg/integration/clients/go_test.go index 211e73d28..11f6e754e 100644 --- a/pkg/integration/clients/go_test.go +++ b/pkg/integration/clients/go_test.go @@ -56,7 +56,7 @@ func TestIntegration(t *testing.T) { CodeCoverageDir: codeCoverageDir, InputDelay: 0, // Allow two attempts at each test to get around flakiness - MaxAttempts: 2, + MaxAttempts: 1, }) assert.NoError(t, err) From 5055c4fb654b1780330f941c27e828fbf44ebcd8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 13:37:23 +0200 Subject: [PATCH 337/384] Fetch GitHub pull requests as a background task Every full refresh includes the PULL_REQUESTS scope, and the worker it spawns inherited the refresh's foreground/background flag. Full foreground refreshes happen at startup, after switching repos or worktrees, and when the terminal regains focus, so the GitHub API request ran as a foreground task there, keeping Busy() true until it completed. On a healthy network that's a few hundred milliseconds and nobody notices; on a very slow one the request can stall for minutes, and every attempt to switch repos in that window was refused with "Can't switch repositories while an operation is in progress" even though lazygit looked completely idle. (The request has no visible status; at most, a background fetch hanging on the same bad network was showing its "Fetching..." spinner, pointing the blame at the wrong operation.) The switch-safety guard only needs to wait for operations whose remaining git commands would run against the wrong repo after a switch. The pull-request fetch runs no git commands at all, and its model writes are dropped when the repo generation has changed in the meantime, so there is no reason for it to block switching. Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 4fedf9c7d..a7c18aeb8 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -365,7 +365,17 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr } if scopeSet.Includes(types.PULL_REQUESTS) { - self.onWorker(env.background, func(gocui.Task) error { + // Fetching pull requests talks to the GitHub API over the network; on + // a bad connection that request can stall for a long time. It runs no + // git commands against the repo, and its model writes are guarded by + // the repo generation (a repo switch mid-fetch simply drops the + // result), so it is safe to run as a background task even when the + // enclosing refresh is a foreground one — a foreground task would + // block repo switching for as long as the request takes. The env copy + // makes the downstream UI-thread bounces background as well. + prEnv := env + prEnv.background = true + self.c.OnWorkerBackground(func(gocui.Task) error { branchesAndRemotesWg.Wait() t := time.Now() @@ -373,7 +383,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // Model().Branches/Remotes: those writes are bounced onto the // UI thread and may not have landed on this worker yet. The // wait above orders us after both loads have stashed theirs. - self.refreshGithubPullRequests(loadedBranches, loadedRemotes, env) + self.refreshGithubPullRequests(loadedBranches, loadedRemotes, prEnv) self.c.Log.Infof("refreshed pull requests in %s", time.Since(t)) return nil }) From 7360a8459d21be50acb135d8cc43c5e2be4f7b90 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 13:51:38 +0200 Subject: [PATCH 338/384] Give the GitHub GraphQL requests a timeout The http.Client used for fetching pull requests had no timeout, so on a network that silently drops packets a request could stay in flight until the OS-level TCP timeouts kick in, which can take many minutes. The fetch has no visible status, so nothing tells the user it is still running; bounding it keeps the refresh's worst case short, and the next refresh simply tries again. Co-Authored-By: Claude Fable 5 --- pkg/commands/git_commands/github.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/commands/git_commands/github.go b/pkg/commands/git_commands/github.go index e05472ef1..b74815301 100644 --- a/pkg/commands/git_commands/github.go +++ b/pkg/commands/git_commands/github.go @@ -210,7 +210,10 @@ func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string, req.Header.Set("Authorization", "token "+token) req.Header.Set("Content-Type", "application/json") - client := &http.Client{} + // Bound the request so that a dead or extremely slow network can't leave + // the pull-request refresh in flight for minutes. The data is auxiliary, + // so giving up and retrying on the next refresh beats waiting. + client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err From a1561a5e6967cf8a7f93c2f787afed4c773a9d61 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 15:55:01 +0200 Subject: [PATCH 339/384] Render the app status in a single background render loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each status used to start a spinner render loop of its own, running on a worker that inherited the foreground/background flavor of the status's owner, and exiting only once the entire status stack was empty. That shape had a real bug: a foreground operation's loop could be kept alive by someone else's status. Finish a quick operation with a waiting status while a background fetch's "Fetching..." status is still showing, and the operation's render loop — a foreground worker task — keeps ticking until the fetch ends. Busy() stays true for that whole time, so repo switching is refused even though nothing is in flight anymore; with a fetch hanging on a slow network, that means minutes. The shape was also wasteful: overlapping statuses were each drawn by their own loop (plus a duplicate whenever a task was paused and resumed while another status was showing), all redundantly redrawing the same top status. Replace the per-status loops with a single loop owned by the status stack as a whole: whoever shows the first status starts it, and it exits after drawing a final empty frame once the last status is removed. The claim/release methods on StatusManager keep the loop flag's transitions atomic with the stack under the one mutex, so a status added while the loop is about to exit starts a fresh loop instead of going unrendered. The loop always runs as a background task now: rendering issues no git commands, so it never needs to block repo switching, and a foreground operation's busy-ness is already carried by its own worker task. This is what fixes the bug above, and it retires the need to thread a foreground/background flag through the waiting-status helpers altogether. Co-Authored-By: Claude Fable 5 --- pkg/gui/background.go | 2 +- .../controllers/helpers/app_status_helper.go | 57 ++++++++++--------- pkg/gui/status/status_manager.go | 38 +++++++++++++ 3 files changed, 68 insertions(+), 29 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index f9eff420b..1e2db853f 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -119,7 +119,7 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() { if self.gui.UserConfig().Gui.ShowBottomLine || firstTimeOrRetriggered { return self.gui.helpers.AppStatus.WithWaitingStatusImpl(self.gui.Tr.FetchingStatus, func(gocui.Task) error { return self.backgroundFetch() - }, nil, true) + }, nil) } return self.backgroundFetch() diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index 69daa7d7a..d0bb03395 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -34,12 +34,7 @@ func (self *AppStatusHelper) Toast(message string, kind types.ToastKind) { self.statusMgr().AddToastStatus(message, kind) - // Render the toast in the background: it's a transient notification, not - // lazygit driving an operation, so it must not count towards being busy — - // otherwise a toast (e.g. the "can't switch, operation in progress" one) - // would itself block a repo switch until it faded. A real operation showing - // a toast still keeps its own foreground task busy independently. - self.renderAppStatus(true) + self.renderAppStatus() } // A custom task for WithWaitingStatus calls; it wraps the original one and @@ -66,14 +61,15 @@ func (self appStatusHelperTask) Continue() { // WithWaitingStatus wraps a function and shows a waiting status while the function is still executing func (self *AppStatusHelper) WithWaitingStatus(message string, f func(gocui.Task) error) { self.c.OnWorker(func(task gocui.Task) error { - return self.WithWaitingStatusImpl(message, f, task, false) + return self.WithWaitingStatusImpl(message, f, task) }) } -// background reports whether this waiting status belongs to a background routine -// (the auto-fetch poller); when it does, the spinner it drives must not count -// towards lazygit being busy, or it'd block repo switches while a fetch runs. -func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task, background bool) error { +// WithWaitingStatusImpl is WithWaitingStatus for callers that already run on a +// goroutine of their own (e.g. the auto-fetch poller) rather than wanting the +// work dispatched to a worker. task is used to hide the status while the task +// is paused; it may be nil for callers whose f ignores its task. +func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui.Task) error, task gocui.Task) error { // A waiting status means lazygit is driving a git operation itself (often // one that internally runs a rebase and continues it). Pause the background // routines for its duration so they don't refresh from an intermediate @@ -81,7 +77,7 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui. self.c.PauseBackgroundRefreshes(true) defer self.c.PauseBackgroundRefreshes(false) - return self.statusMgr().WithWaitingStatus(message, func() { self.renderAppStatus(background) }, func(waitingStatusHandle *status.WaitingStatusHandle) error { + return self.statusMgr().WithWaitingStatus(message, self.renderAppStatus, func(waitingStatusHandle *status.WaitingStatusHandle) error { return f(appStatusHelperTask{task, waitingStatusHandle}) }) } @@ -112,7 +108,7 @@ func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f fu self.modeHelper.SetSuppressRebasingMode(false) return self.c.GocuiGui().EndBlockingEvents() }) - return self.WithWaitingStatusImpl(message, f, task, false) + return self.WithWaitingStatusImpl(message, f, task) }) } @@ -125,33 +121,36 @@ func (self *AppStatusHelper) GetStatusString() string { return appStatus } -func (self *AppStatusHelper) renderAppStatus(background bool) { - // A background waiting status (auto-fetch) must not count towards lazygit - // being busy, so its spinner worker and per-frame UI updates go through the - // background variants. - onWorker := self.c.OnWorker - onUIThread := self.c.OnUIThread - onUIThreadContentOnly := self.c.OnUIThreadContentOnly - if background { - onWorker = self.c.OnWorkerBackground - onUIThread = self.c.OnUIThreadBackground - onUIThreadContentOnly = self.c.OnUIThreadContentOnlyBackground +// renderAppStatus ensures the render loop that keeps the app-status view up to +// date is running. There is one loop for the whole status stack, no matter how +// many statuses are showing: it draws whatever the top status currently is, +// and exits after drawing a final empty frame once the last status is removed. +// +// The loop always runs as a background task, regardless of what kind of +// operation owns a status: rendering runs no git commands, so it must never +// count towards lazygit being busy — otherwise it would block repo switching +// for as long as anything is showing (e.g. for the whole duration of a hung +// background fetch, or of a toast fading). A foreground operation's busy-ness +// is carried by its own worker task, not by the renderer. +func (self *AppStatusHelper) renderAppStatus() { + if !self.statusMgr().ClaimRenderLoop() { + return } - onWorker(func(_ gocui.Task) error { + self.c.OnWorkerBackground(func(_ gocui.Task) error { ticker := time.NewTicker(time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate)) defer ticker.Stop() prevAppStatus := "" for range ticker.C { appStatus, color := self.statusMgr().GetStatusString(self.c.UserConfig()) - update := onUIThreadContentOnly + update := self.c.OnUIThreadContentOnlyBackground if utils.StringWidth(appStatus) != utils.StringWidth(prevAppStatus) { // Need a full layout whenever the width of the status string changes. This can't // happen during normal spinning because we validate that all spinner frames have // the same width, so typically this will only be triggered at the beginning and end // of a status, or if the status string changes midway for some reason. - update = onUIThread + update = self.c.OnUIThreadBackground } update(func() error { self.c.Views().AppStatus.FgColor = color @@ -160,7 +159,9 @@ func (self *AppStatusHelper) renderAppStatus(background bool) { }) prevAppStatus = appStatus - if appStatus == "" { + // Checked after rendering, so that the frame which clears the view + // has already been drawn when we exit. + if self.statusMgr().ReleaseRenderLoopIfEmpty() { break } } diff --git a/pkg/gui/status/status_manager.go b/pkg/gui/status/status_manager.go index 414568a69..35e1b7746 100644 --- a/pkg/gui/status/status_manager.go +++ b/pkg/gui/status/status_manager.go @@ -17,6 +17,11 @@ type StatusManager struct { statuses []appStatus nextId int mutex deadlock.Mutex + + // Whether a render loop is currently drawing the statuses. Guarded by + // mutex, so that claiming and releasing the loop stay atomic with the + // changes to statuses; see ClaimRenderLoop and ReleaseRenderLoopIfEmpty. + renderLoopRunning bool } // Can be used to manipulate a waiting status while it is running (e.g. pause @@ -90,6 +95,39 @@ func (self *StatusManager) HasStatus() bool { return len(self.statuses) > 0 } +// ClaimRenderLoop is called by whoever just added a status; it reports whether +// they must start the render loop. When it returns false, a loop is already +// running and will pick the new status up on its next tick. +func (self *StatusManager) ClaimRenderLoop() bool { + self.mutex.Lock() + defer self.mutex.Unlock() + + if self.renderLoopRunning { + return false + } + + self.renderLoopRunning = true + return true +} + +// ReleaseRenderLoopIfEmpty is called by the render loop after each frame it +// draws; a true result releases the loop's claim and tells it to exit, because +// there are no statuses left to draw. The emptiness check and the release are +// atomic with respect to ClaimRenderLoop, so a status added around this moment +// either sees the still-running loop or starts a fresh one — it can't end up +// unrendered. +func (self *StatusManager) ReleaseRenderLoopIfEmpty() bool { + self.mutex.Lock() + defer self.mutex.Unlock() + + if len(self.statuses) > 0 { + return false + } + + self.renderLoopRunning = false + return true +} + func (self *StatusManager) addStatus(message string, statusType string, kind types.ToastKind) int { self.mutex.Lock() defer self.mutex.Unlock() From 33a2dc302efe6db7020b4bc838f8a204f7d782c4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Jul 2026 19:19:47 +0200 Subject: [PATCH 340/384] Suppress command logs for tag commands The command log is supposed to show only commands initiated by the user; these are commands that we run to get information for rendering, so they pollute the log and are confusing. --- pkg/commands/git_commands/tag.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/commands/git_commands/tag.go b/pkg/commands/git_commands/tag.go index 4d15027e1..c6fe2e807 100644 --- a/pkg/commands/git_commands/tag.go +++ b/pkg/commands/git_commands/tag.go @@ -43,7 +43,7 @@ func (self *TagCommands) HasTag(tagName string) bool { Arg("refs/tags/" + tagName). ToArgv() - return self.cmd.New(cmdArgs).Run() == nil + return self.cmd.New(cmdArgs).DontLog().Run() == nil } func (self *TagCommands) LocalDelete(tagName string) error { @@ -74,7 +74,7 @@ func (self *TagCommands) ShowAnnotationInfo(tagName string) (string, error) { Arg("refs/tags/" + tagName). ToArgv() - return self.cmd.New(cmdArgs).RunWithOutput() + return self.cmd.New(cmdArgs).DontLog().RunWithOutput() } func (self *TagCommands) IsTagAnnotated(tagName string) (bool, error) { @@ -83,6 +83,6 @@ func (self *TagCommands) IsTagAnnotated(tagName string) (bool, error) { Arg("refs/tags/" + tagName). ToArgv() - output, err := self.cmd.New(cmdArgs).RunWithOutput() + output, err := self.cmd.New(cmdArgs).DontLog().RunWithOutput() return strings.TrimSpace(output) == "tag", err } From 197916aafb3b9a7f6e7535263d44bf78b0b7638d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Jul 2026 19:22:48 +0200 Subject: [PATCH 341/384] Suppress command logs for git calls related to the ctrl+f command --- pkg/commands/git_commands/blame.go | 2 +- pkg/gui/controllers/helpers/fixup_helper.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/commands/git_commands/blame.go b/pkg/commands/git_commands/blame.go index aba1c63fe..e1a9469eb 100644 --- a/pkg/commands/git_commands/blame.go +++ b/pkg/commands/git_commands/blame.go @@ -29,5 +29,5 @@ func (self *BlameCommands) BlameLineRange(filename string, commit string, firstL Arg("--"). Arg(filename) - return self.cmd.New(cmdArgs.ToArgv()).RunWithOutput() + return self.cmd.New(cmdArgs.ToArgv()).DontLog().RunWithOutput() } diff --git a/pkg/gui/controllers/helpers/fixup_helper.go b/pkg/gui/controllers/helpers/fixup_helper.go index a958e58a8..e8fa43f2d 100644 --- a/pkg/gui/controllers/helpers/fixup_helper.go +++ b/pkg/gui/controllers/helpers/fixup_helper.go @@ -199,12 +199,12 @@ func (self *FixupHelper) getDiff() (string, bool, error) { // Try staged changes first hasStagedChanges := true - diff, err := self.c.Git().Diff.DiffIndexCmdObj(append([]string{"--cached"}, args...)...).RunWithOutput() + diff, err := self.c.Git().Diff.DiffIndexCmdObj(append([]string{"--cached"}, args...)...).DontLog().RunWithOutput() if err == nil && diff == "" { hasStagedChanges = false // If there are no staged changes, try unstaged changes - diff, err = self.c.Git().Diff.DiffIndexCmdObj(args...).RunWithOutput() + diff, err = self.c.Git().Diff.DiffIndexCmdObj(args...).DontLog().RunWithOutput() } return diff, hasStagedChanges, err From b37141156719332fa53f268bce91f33ad34d9ac3 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 22:52:47 +0200 Subject: [PATCH 342/384] Recommend the gopls MCP tools for symbol navigation in AGENTS.md Grep-based navigation needs manual filtering for the many colliding method names in this codebase, while gopls answers reference and implementation questions type-aware and exactly. Scope the guidance to the symbol tools and keep grep for textual searches: gopls' own MCP instructions prescribe running vulncheck at session start and go_file_context after every file read, which costs more than it helps here. The server is registered per user and machine, so sessions without it must just fall back to grep rather than try to set it up. --- AGENTS.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 88e818249..2cafd9d50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,24 @@ Windows box has only `just`). (most useful with `--sandbox` or `--slow`). - `just lint` — run golangci-lint. +## Prefer gopls MCP tools for Go symbol questions + +When the gopls MCP tools are available in the session, prefer them over grep +for type-aware questions about Go code: who calls a function or method +(`go_symbol_references`), finding a symbol by fuzzy name (`go_search`), or +inspecting a package's API (`go_package_api`). Method names in this codebase +collide a lot (`draw`, `Show`, `Refresh` exist on several types), and grep +needs manual filtering that gopls doesn't. This includes code under +`vendor/`, which gopls resolves as part of the module build. + +Grep remains the right tool for strings, comments, config keys, non-Go +files, and anything textual. Don't adopt the full workflow from +`gopls mcp -instructions` (vulncheck on session start, `go_file_context` +after every file read); that overhead isn't worth it here. + +If the tools aren't available in a session, fall back to grep silently — +don't try to install, register, or start the server. + ## When to commit Do not leave completed work uncommitted. Once a logical unit of work is done From 3ed6ce8f670caf023bf510f6c7b3592e472d3b27 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 13:55:34 +0200 Subject: [PATCH 343/384] Add test showing that resuming after a suspend schedules no redraw When lazygit is suspended with ctrl+z and brought back with fg, nothing deliberately triggers a redraw. The screen only repaints because the UI thread happens to have a flush pending from the suspend keybinding, and that flush races the SIGCONT handler's resume; when it loses in the right way, the terminal shows a blank screen until the next input event arrives (#5309). --- pkg/gocui/suspend_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 pkg/gocui/suspend_test.go diff --git a/pkg/gocui/suspend_test.go b/pkg/gocui/suspend_test.go new file mode 100644 index 000000000..25a15c921 --- /dev/null +++ b/pkg/gocui/suspend_test.go @@ -0,0 +1,27 @@ +package gocui + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestResumeSchedulesRedraw(t *testing.T) { + g := newTestGui(t) + + assert.NoError(t, g.Suspend()) + assert.NoError(t, g.Resume()) + + ev := GocuiEvent{Type: eventNone} + select { + case ev = <-g.gEvents: + case <-time.After(100 * time.Millisecond): + } + + /* EXPECTED: + assert.Equal(t, eventResize, ev.Type, + "resuming must schedule a redraw; without one the screen stays blank until the next event arrives") + ACTUAL: */ + assert.Equal(t, eventNone, ev.Type) +} From d887a41ad2c386ca8b43fab9262553525e57af72 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 13:58:32 +0200 Subject: [PATCH 344/384] Don't flush the screen while suspended When suspending with ctrl+z, the suspend keybinding handler disengages the screen and then sends SIGSTOP to the process group, so the UI thread freezes at the return from kill(2) with the handler's follow-up flush still pending. When fg continues the process, that pending flush races the SIGCONT handler's Resume. If the flush wins, Show() draws against the disengaged screen, whose cell buffer tcell has released to 0x0 while its width/height still hold the old size; drawCell() then reports width 0 for the out-of-range cell, the draw loop's 'x += width - 1' never advances, and the UI thread spins forever while holding the tcell screen lock. Resume in turn blocks forever on that lock, so the screen never re-engages and no input is ever read again: the hard stall of #5309, only recoverable by killing the process. Guard both flush paths with the suspended flag. For the flag to guarantee that the screen is engaged whenever it is false, Resume must clear it only after re-engaging (it used to clear it before); Suspend already sets it before disengaging. This also covers the pre-existing unsynchronized suspended check in draw(), which is subsumed by the guards and can go. The regression test cannot use the demonstrate-then-fix pattern: on unfixed code the flush goroutine spins holding the screen lock, which deadlocks any subsequent screen call including the test cleanup's Close(). --- pkg/gocui/gui.go | 42 ++++++++++++++++++++++++++++++------ pkg/gocui/suspend_test.go | 45 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 6 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 20beb2af7..2785f07d4 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1470,6 +1470,11 @@ func (g *Gui) drawListFooter(v *View, fgColor, bgColor Attribute) error { // flush updates the gui, re-drawing frames and buffers. func (g *Gui) flush() error { + // The screen must not be touched while suspended (see Suspend). + if g.isSuspended() { + return nil + } + // pretty sure we don't need this, but keeping it here in case we get weird visual artifacts // g.clear(g.FgColor, g.BgColor) @@ -1502,6 +1507,11 @@ func (g *Gui) flush() error { // actually-changed cells are emitted to the terminal. // Will also redraw any views that overlap tainted views func (g *Gui) flushContentOnly(views []*View) error { + // The screen must not be touched while suspended (see Suspend). + if g.isSuspended() { + return nil + } + for _, v := range viewsToRedrawContentOnly(views) { if err := g.draw(v); err != nil { return err @@ -1555,10 +1565,6 @@ func (g *Gui) ForceFlushViewsContentOnly(views []*View) error { // draw manages the cursor and calls the draw function of a view. func (g *Gui) draw(v *View) error { - if g.suspended { - return nil - } - if !v.Visible || v.y1 < v.y0 || v.x1 < v.x0 { return nil } @@ -1930,6 +1936,14 @@ func (g *Gui) onFocus(ev *GocuiEvent) error { return nil } +// While g.suspended is true, nothing must be drawn to the screen: tcell +// releases the screen's cell buffer when disengaging, and drawing to a +// disengaged screen spins forever inside tcell while holding the screen lock, +// which then blocks Resume (and with it all further input) forever. For the +// flag to guarantee that, it must only ever be false while the screen is +// engaged: Suspend sets it before disengaging, and Resume clears it only +// after re-engaging. + func (g *Gui) Suspend() error { g.suspendedMutex.Lock() defer g.suspendedMutex.Unlock() @@ -1940,7 +1954,12 @@ func (g *Gui) Suspend() error { g.suspended = true - return g.screen.Suspend() + if err := g.screen.Suspend(); err != nil { + g.suspended = false + return err + } + + return nil } func (g *Gui) Resume() error { @@ -1951,9 +1970,20 @@ func (g *Gui) Resume() error { return errors.New("Cannot resume because we are not suspended") } + if err := g.screen.Resume(); err != nil { + return err + } + g.suspended = false - return g.screen.Resume() + return nil +} + +func (g *Gui) isSuspended() bool { + g.suspendedMutex.Lock() + defer g.suspendedMutex.Unlock() + + return g.suspended } // matchView returns if the keybinding matches the current view (and the view's context) diff --git a/pkg/gocui/suspend_test.go b/pkg/gocui/suspend_test.go index 25a15c921..d5ea5a132 100644 --- a/pkg/gocui/suspend_test.go +++ b/pkg/gocui/suspend_test.go @@ -7,6 +7,51 @@ import ( "github.com/stretchr/testify/assert" ) +// A flush while suspended must return without touching the screen: tcell +// releases the screen's cell buffer when disengaging, and drawing to a +// disengaged screen spins forever inside tcell while holding the screen lock, +// blocking the resume triggered by fg (#5309). The flush runs in a goroutine +// so that a regression fails the test instead of hanging the suite. +func TestFlushIsNoOpWhileSuspended(t *testing.T) { + tests := []struct { + name string + flush func(g *Gui) error + }{ + {"flush", func(g *Gui) error { return g.flush() }}, + {"flushContentOnly", func(g *Gui) error { return g.flushContentOnly(g.views) }}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Deliberately not newTestGui: its cleanup closes the screen, + // which would deadlock on the screen lock if a regression makes + // the flush below spin. + g, err := NewGui(NewGuiOpts{ + OutputMode: OutputNormal, + Headless: true, + Width: 80, + Height: 24, + }) + assert.NoError(t, err) + + assert.NoError(t, g.Suspend()) + + flushReturned := make(chan error, 1) + go func() { flushReturned <- tc.flush(g) }() + + select { + case err := <-flushReturned: + assert.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("flush touched the suspended screen and got stuck") + } + + assert.NoError(t, g.Resume()) + g.Close() + }) + } +} + func TestResumeSchedulesRedraw(t *testing.T) { g := newTestGui(t) From 319f43e1660cfee71f8479626c8f48020f9642cc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 17 Jul 2026 14:00:06 +0200 Subject: [PATCH 345/384] Schedule a redraw when resuming from suspension Until now the repaint after fg was accidental: it only happened because the suspend keybinding handler still had a flush pending on the UI thread, and only if that flush happened to run after the SIGCONT handler had re-engaged the screen. Now that flushes are skipped while suspended, losing that race would leave the screen blank until the next input event arrives, so schedule a redraw explicitly (#5309). --- pkg/gocui/gui.go | 5 +++++ pkg/gocui/suspend_test.go | 3 --- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index 2785f07d4..57818960c 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -1976,6 +1976,11 @@ func (g *Gui) Resume() error { g.suspended = false + // Schedule a redraw of the whole screen. Nothing else guarantees one: + // flushes are skipped while suspended, and after re-engaging the screen + // the terminal shows nothing until we draw again. + go func() { g.gEvents <- GocuiEvent{Type: eventResize} }() + return nil } diff --git a/pkg/gocui/suspend_test.go b/pkg/gocui/suspend_test.go index d5ea5a132..ded220bea 100644 --- a/pkg/gocui/suspend_test.go +++ b/pkg/gocui/suspend_test.go @@ -64,9 +64,6 @@ func TestResumeSchedulesRedraw(t *testing.T) { case <-time.After(100 * time.Millisecond): } - /* EXPECTED: assert.Equal(t, eventResize, ev.Type, "resuming must schedule a redraw; without one the screen stays blank until the next event arrives") - ACTUAL: */ - assert.Equal(t, eventNone, ev.Type) } From 94e5f570f668a6332439970a46e2d52909178549 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Jul 2026 20:57:35 +0200 Subject: [PATCH 346/384] Bump tcell to v3.4.1 to fix drawing on a suspended screen The previous two commits stop gocui from flushing while suspended, but that guard cannot be fully airtight from gocui's side: it is a check-then-act on the suspended flag, so a flush racing the suspend itself (e.g. from a spinner goroutine) could still reach the screen just as it disengages, and tcell's disengageFinish mutates the cell buffer without holding the screen lock. Upstream now closes this at the source (gdamore/tcell#1139): draw() returns immediately on a disengaged screen, and the draw scan loop can no longer stall on the width-0 cells that a released cell buffer reports (#5309). The delta over the previously pinned snapshot is these two fixes, a CSI R input decode fix, a wasm packaging chore, and dependency bumps. Co-Authored-By: Claude Fable 5 --- go.mod | 15 ++--- go.sum | 32 ++++----- .../github.com/gdamore/tcell/v3/CHANGESv3.md | 10 ++- .../gdamore/tcell/v3/README-wasm.md | 2 + vendor/github.com/gdamore/tcell/v3/input.go | 1 + vendor/github.com/gdamore/tcell/v3/tscreen.go | 11 ++++ vendor/golang.org/x/mod/modfile/read.go | 8 +-- vendor/golang.org/x/mod/modfile/rule.go | 65 ++++++++++++++++--- .../golang.org/x/sync/semaphore/semaphore.go | 17 +++-- vendor/golang.org/x/sys/unix/syscall_linux.go | 1 + .../x/sys/unix/syscall_linux_386.go | 1 - .../x/sys/unix/syscall_linux_amd64.go | 1 - .../x/sys/unix/syscall_linux_arm.go | 1 - .../x/sys/unix/syscall_linux_arm64.go | 1 - .../x/sys/unix/syscall_linux_loong64.go | 1 - .../x/sys/unix/syscall_linux_mips64x.go | 1 - .../x/sys/unix/syscall_linux_mipsx.go | 1 - .../x/sys/unix/syscall_linux_ppc.go | 1 - .../x/sys/unix/syscall_linux_ppc64x.go | 1 - .../x/sys/unix/syscall_linux_riscv64.go | 1 - .../x/sys/unix/syscall_linux_s390x.go | 1 - .../x/sys/unix/syscall_linux_sparc64.go | 1 - vendor/golang.org/x/sys/unix/zerrors_linux.go | 8 ++- .../golang.org/x/sys/unix/zsyscall_linux.go | 17 +++++ .../x/sys/unix/zsyscall_linux_386.go | 17 ----- .../x/sys/unix/zsyscall_linux_amd64.go | 17 ----- .../x/sys/unix/zsyscall_linux_arm.go | 17 ----- .../x/sys/unix/zsyscall_linux_arm64.go | 17 ----- .../x/sys/unix/zsyscall_linux_loong64.go | 17 ----- .../x/sys/unix/zsyscall_linux_mips.go | 17 ----- .../x/sys/unix/zsyscall_linux_mips64.go | 17 ----- .../x/sys/unix/zsyscall_linux_mips64le.go | 17 ----- .../x/sys/unix/zsyscall_linux_mipsle.go | 17 ----- .../x/sys/unix/zsyscall_linux_ppc.go | 17 ----- .../x/sys/unix/zsyscall_linux_ppc64.go | 17 ----- .../x/sys/unix/zsyscall_linux_ppc64le.go | 17 ----- .../x/sys/unix/zsyscall_linux_riscv64.go | 17 ----- .../x/sys/unix/zsyscall_linux_s390x.go | 17 ----- .../x/sys/unix/zsyscall_linux_sparc64.go | 17 ----- .../x/sys/windows/security_windows.go | 36 ++++++++++ .../x/sys/windows/syscall_windows.go | 8 ++- .../golang.org/x/sys/windows/types_windows.go | 1 + vendor/golang.org/x/text/cases/context.go | 2 +- vendor/golang.org/x/text/cases/map.go | 4 +- .../x/text/unicode/norm/forminfo.go | 9 ++- vendor/golang.org/x/text/unicode/norm/iter.go | 8 +-- .../x/text/unicode/norm/normalize.go | 20 +++--- vendor/modules.txt | 16 ++--- 48 files changed, 216 insertions(+), 342 deletions(-) diff --git a/go.mod b/go.mod index 12af7493b..515bb8f1b 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/cli/go-gh/v2 v2.13.0 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 github.com/creack/pty v1.1.24 - github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 + github.com/gdamore/tcell/v3 v3.4.1 github.com/go-errors/errors v1.5.1 github.com/gookit/color v1.6.1 github.com/integrii/flaggy v1.8.0 @@ -38,8 +38,8 @@ require ( github.com/stretchr/testify v1.11.1 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 - golang.org/x/sync v0.21.0 - golang.org/x/sys v0.46.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 gopkg.in/ozeidan/fuzzy-patricia.v3 v3.0.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -65,11 +65,10 @@ 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.36.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/term v0.44.0 // indirect - golang.org/x/text v0.38.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.47.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect diff --git a/go.sum b/go.sum index 2f734de11..1b8cb7d66 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 h1:kUXexBZYoVdAJIOIuP6uLgK3k0G7ClDIRO27Z3epgtU= -github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59/go.mod h1:Ev/2PFhL0QtVmu6XPZG9NEuITAZ6XH7i7/BF3wupBdw= +github.com/gdamore/tcell/v3 v3.4.1 h1:22227t1EUwqxTlmCX9vw0RUE2IEPGw6oYcNan+bPe4w= +github.com/gdamore/tcell/v3 v3.4.1/go.mod h1:YWwuxZNi14VGQC5g2VGNEDRXpBraTwvVjMovRH6G6hw= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= @@ -139,19 +139,19 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20170407050850-f3918c30c5c2/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -163,26 +163,26 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md b/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md index 26a0c2e57..c3fcb3f4f 100644 --- a/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md +++ b/vendor/github.com/gdamore/tcell/v3/CHANGESv3.md @@ -100,7 +100,7 @@ These functions weren't reliable and served no useful purpose. `NewConsoleScreen` is removed as is support for Windows console mode. Instead this uses the more modern Windows VT modes. -As a consequence, this means that _Tcell_ on Windows requires at least Winows 10 build 1703 (the Creators Update). +As a consequence, this means that _Tcell_ on Windows requires at least Windows 10 build 1703 (the Creators Update). If you are using a version of Windows 10 older than that, you should really upgrade for _many_ reasons, not just because _Tcell_ doesn't support it anymore. @@ -108,3 +108,11 @@ because _Tcell_ doesn't support it anymore. This structure, and the associated `NewInputProcessor` function, were made public incorrectly. They are not part of our public API going forward, and are now private symbols. + +## SimulationScreen is Removed + +While never part of the public _Tcell_ API, some projects may have used the +`SimulationScreen` for their own tests. That facility was very limited, and +we implemented a much more complete emulation of a terminal in `MockScreen` +and `MockTerm`. (To be clear, those facilities are still intended for _Tcell_'s +own testing, and are still not part of the public API.) diff --git a/vendor/github.com/gdamore/tcell/v3/README-wasm.md b/vendor/github.com/gdamore/tcell/v3/README-wasm.md index 4e29a3dac..1a92a4e5c 100644 --- a/vendor/github.com/gdamore/tcell/v3/README-wasm.md +++ b/vendor/github.com/gdamore/tcell/v3/README-wasm.md @@ -24,6 +24,8 @@ cp -R webfiles/ghostty-web /path/to/dir/to/serve/ The vendored `ghostty-web.js` is intentionally browser-only. Its upstream Node `readFile` fallback import is removed so browser-oriented servers and bundlers such as Vite do not try to resolve a Node file-system shim; the bundled code loads `ghostty-vt.wasm` with `fetch`. +The vendored `ghostty-web.js` is also de-inlined: upstream embeds a base64 copy of `ghostty-vt.wasm` twice inside the JS (as default candidates for `Ghostty.load()`), which more than tripled the shipped bytes. Those inline `data:application/wasm;base64,...` defaults are removed; `tcell.js` passes an explicit URL to `Ghostty.load()`, and the `./ghostty-vt.wasm` / `/ghostty-vt.wasm` relative paths remain as no-argument fallbacks. The wasm is therefore shipped once, as the separate `ghostty-vt.wasm`. + For example: ```sh diff --git a/vendor/github.com/gdamore/tcell/v3/input.go b/vendor/github.com/gdamore/tcell/v3/input.go index 58c8a7e0d..74e234d1e 100644 --- a/vendor/github.com/gdamore/tcell/v3/input.go +++ b/vendor/github.com/gdamore/tcell/v3/input.go @@ -222,6 +222,7 @@ var csiAllKeys = map[csiParamMode]keyMap{ {M: 'L'}: {Key: KeyInsert}, {M: 'P'}: {Key: KeyF1}, // except for aixterm, where this is Delete {M: 'Q'}: {Key: KeyF2}, + {M: 'R'}: {Key: KeyF3}, {M: 'S'}: {Key: KeyF4}, {M: 'Z'}: {Key: KeyBacktab}, {M: 'a'}: {Key: KeyUp, Mod: ModShift}, diff --git a/vendor/github.com/gdamore/tcell/v3/tscreen.go b/vendor/github.com/gdamore/tcell/v3/tscreen.go index 9fe3cf8fe..d50101903 100644 --- a/vendor/github.com/gdamore/tcell/v3/tscreen.go +++ b/vendor/github.com/gdamore/tcell/v3/tscreen.go @@ -1009,6 +1009,13 @@ func (t *tScreen) hideCursor() { } func (t *tScreen) draw() { + if !t.running { + // While disengaged (e.g. suspended) the terminal belongs to some + // other application, so we must not emit anything; also the cell + // buffer is released, so there is nothing valid to draw from. + return + } + // clobber cursor position, because we're going to change it all t.cx = -1 t.cy = -1 @@ -1040,6 +1047,10 @@ func (t *tScreen) draw() { // actually will *draw* it. t.cells.SetDirty(x+1, y, true) } + } else if width < 1 { + // drawCell reports width 0 for coordinates outside the + // cell buffer; never let the scan stall + width = 1 } x += width - 1 } diff --git a/vendor/golang.org/x/mod/modfile/read.go b/vendor/golang.org/x/mod/modfile/read.go index 504a2f1df..5b528c718 100644 --- a/vendor/golang.org/x/mod/modfile/read.go +++ b/vendor/golang.org/x/mod/modfile/read.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "os" + "slices" "strconv" "strings" "unicode" @@ -105,8 +106,7 @@ func (x *FileSyntax) addLine(hint Expr, tokens ...string) *Line { if hint == nil { // If no hint given, add to the last statement of the given type. Loop: - for i := len(x.Stmt) - 1; i >= 0; i-- { - stmt := x.Stmt[i] + for _, stmt := range slices.Backward(x.Stmt) { switch stmt := stmt.(type) { case *Line: if stmt.Token != nil && stmt.Token[0] == tokens[0] { @@ -718,9 +718,7 @@ func (in *input) assignComments() { } // Assign suffix comments to syntax immediately before. - for i := len(in.post) - 1; i >= 0; i-- { - x := in.post[i] - + for _, x := range slices.Backward(in.post) { start, end := x.Span() if debug { fmt.Fprintf(os.Stderr, "post %T :%d:%d #%d :%d:%d #%d\n", x, start.Line, start.LineRune, start.Byte, end.Line, end.LineRune, end.Byte) diff --git a/vendor/golang.org/x/mod/modfile/rule.go b/vendor/golang.org/x/mod/modfile/rule.go index c5b8305de..9ab203b56 100644 --- a/vendor/golang.org/x/mod/modfile/rule.go +++ b/vendor/golang.org/x/mod/modfile/rule.go @@ -327,6 +327,7 @@ func parseToFile(file string, data []byte, fix VersionFixer, strict bool) (parse } var GoVersionRE = lazyregexp.New(`^([1-9][0-9]*)\.(0|[1-9][0-9]*)(\.(0|[1-9][0-9]*))?([a-z]+[0-9]+)?$`) + var laxGoVersionRE = lazyregexp.New(`^v?(([1-9][0-9]*)\.(0|[1-9][0-9]*))([^0-9].*)$`) // Toolchains must be named beginning with `go1`, @@ -1272,6 +1273,17 @@ func (f *File) SetRequire(req []*Require) { // SetRequireSeparateIndirect will split it into a direct-only and indirect-only // block. This aids in the transition to separate blocks. func (f *File) SetRequireSeparateIndirect(req []*Require) { + f.setRequireSeparateIndirect(req, false) +} + +// SetRequireAtMostTwo is like SetRequireSeparateIndirect but it aggressively +// consolidates all requirements into at most two blocks (one direct, one indirect). +// It ignores existing blocks and comments when deciding where to place requirements. +func (f *File) SetRequireAtMostTwo(req []*Require) { + f.setRequireSeparateIndirect(req, true) +} + +func (f *File) setRequireSeparateIndirect(req []*Require, simplify bool) { // hasComments returns whether a line or block has comments // other than "indirect". hasComments := func(c Comments) bool { @@ -1304,6 +1316,17 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { } // Examine existing require lines and blocks. + need := make(map[string]*Require) + for _, r := range req { + need[r.Mod.Path] = r + } + lineIndirect := make(map[*Line]bool) + for _, r := range f.Require { + if n := need[r.Mod.Path]; n != nil { + lineIndirect[r.Syntax] = n.Indirect + } + } + var ( // We may insert new requirements into the last uncommented // direct-only and indirect-only blocks. We may also move requirements @@ -1321,7 +1344,9 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { // Track the block each requirement belongs to (if any) so we can // move them later. - lineToBlock = make(map[*Line]*LineBlock) + lineToBlock = make(map[*Line]*LineBlock) + directBlockComments []Comment + indirectBlockComments []Comment ) for i, stmt := range f.Syntax.Stmt { switch stmt := stmt.(type) { @@ -1364,6 +1389,24 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { if allIndirect { lastIndirectIndex = i } + if simplify { + anyDirect := false + for _, line := range stmt.Line { + if ind, ok := lineIndirect[line]; ok && !ind { + anyDirect = true + break + } + } + target := &directBlockComments + if !anyDirect && len(stmt.Line) > 0 { + target = &indirectBlockComments + } + if len(*target) > 0 && len(stmt.Comments.Before) > 0 { + *target = append(*target, Comment{Token: "//"}) + } + *target = append(*target, stmt.Comments.Before...) + stmt.Comments.Before = nil + } } } @@ -1422,6 +1465,15 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { lastIndirectBlock = ensureBlock(lastIndirectIndex) } + if simplify { + if len(directBlockComments) > 0 { + lastDirectBlock.Comments.Before = append(lastDirectBlock.Comments.Before, directBlockComments...) + } + if len(indirectBlockComments) > 0 { + lastIndirectBlock.Comments.Before = append(lastIndirectBlock.Comments.Before, indirectBlockComments...) + } + } + // 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 @@ -1430,10 +1482,6 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { // correct block. // // Some blocks may be empty after this. Cleanup will remove them. - need := make(map[string]*Require) - for _, r := range req { - need[r.Mod.Path] = r - } have := make(map[string]*Require) for _, r := range f.Require { path := r.Mod.Path @@ -1446,10 +1494,10 @@ func (f *File) SetRequireSeparateIndirect(req []*Require) { r.setVersion(need[path].Mod.Version) r.setIndirect(need[path].Indirect) if need[path].Indirect && - (oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastDirectBlock) { + (simplify || oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastDirectBlock) { moveReq(r, lastIndirectBlock) } else if !need[path].Indirect && - (oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastIndirectBlock) { + (simplify || oneFlatUncommentedBlock || lineToBlock[r.Syntax] == lastIndirectBlock) { moveReq(r, lastDirectBlock) } } @@ -1736,8 +1784,7 @@ func removeDups(syntax *FileSyntax, exclude *[]*Exclude, replace *[]*Replace, to // Remove duplicate replacements. // Later replacements take priority over earlier ones. haveReplace := make(map[module.Version]bool) - for i := len(*replace) - 1; i >= 0; i-- { - x := (*replace)[i] + for _, x := range slices.Backward(*replace) { if haveReplace[x.Old] { kill[x.Syntax] = true continue diff --git a/vendor/golang.org/x/sync/semaphore/semaphore.go b/vendor/golang.org/x/sync/semaphore/semaphore.go index 040c5bc50..96a035aed 100644 --- a/vendor/golang.org/x/sync/semaphore/semaphore.go +++ b/vendor/golang.org/x/sync/semaphore/semaphore.go @@ -24,7 +24,7 @@ func NewWeighted(n int64) *Weighted { } // Weighted provides a way to bound concurrent access to a resource. -// The callers can request access with a given weight. +// The callers can request access with a given non-negative weight. type Weighted struct { size int64 cur int64 @@ -32,10 +32,13 @@ type Weighted struct { waiters list.List } -// Acquire acquires the semaphore with a weight of n, blocking until resources +// Acquire acquires the semaphore with a non-negative weight of n, blocking until resources // are available or ctx is done. On success, returns nil. On failure, returns // ctx.Err() and leaves the semaphore unchanged. func (s *Weighted) Acquire(ctx context.Context, n int64) error { + if n < 0 { + panic("semaphore: n < 0") + } done := ctx.Done() s.mu.Lock() @@ -106,9 +109,12 @@ func (s *Weighted) Acquire(ctx context.Context, n int64) error { } } -// TryAcquire acquires the semaphore with a weight of n without blocking. +// TryAcquire acquires the semaphore with a non-negative weight of n without blocking. // On success, returns true. On failure, returns false and leaves the semaphore unchanged. func (s *Weighted) TryAcquire(n int64) bool { + if n < 0 { + panic("semaphore: n < 0") + } s.mu.Lock() success := s.size-s.cur >= n && s.waiters.Len() == 0 if success { @@ -118,8 +124,11 @@ func (s *Weighted) TryAcquire(n int64) bool { return success } -// Release releases the semaphore with a weight of n. +// Release releases the semaphore with a non-negative weight of n. func (s *Weighted) Release(n int64) { + if n < 0 { + panic("semaphore: n < 0") + } s.mu.Lock() s.cur -= n if s.cur < 0 { diff --git a/vendor/golang.org/x/sys/unix/syscall_linux.go b/vendor/golang.org/x/sys/unix/syscall_linux.go index ce4d7ab1e..21e2bfa39 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux.go @@ -1874,6 +1874,7 @@ func Dup2(oldfd, newfd int) error { //sys Dup3(oldfd int, newfd int, flags int) (err error) //sysnb EpollCreate1(flag int) (fd int, err error) //sysnb EpollCtl(epfd int, op int, fd int, event *EpollEvent) (err error) +//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) = SYS_EPOLL_PWAIT //sys Eventfd(initval uint, flags int) (fd int, err error) = SYS_EVENTFD2 //sys Exit(code int) = SYS_EXIT_GROUP //sys Fallocate(fd int, mode uint32, off int64, len int64) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_386.go b/vendor/golang.org/x/sys/unix/syscall_linux_386.go index 506dafa7b..210d545c9 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_386.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_386.go @@ -20,7 +20,6 @@ func setTimeval(sec, usec int64) Timeval { // 64-bit file system and 32-bit uid calls // (386 default is 32-bit file system and 16-bit uid). -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64_64 //sys Fchown(fd int, uid int, gid int) (err error) = SYS_FCHOWN32 //sys Fstat(fd int, stat *Stat_t) (err error) = SYS_FSTAT64 diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_amd64.go b/vendor/golang.org/x/sys/unix/syscall_linux_amd64.go index d557cf8de..a9a52f231 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_amd64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_amd64.go @@ -6,7 +6,6 @@ package unix -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_arm.go b/vendor/golang.org/x/sys/unix/syscall_linux_arm.go index ecf92bfa2..54474c20f 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_arm.go @@ -44,7 +44,6 @@ func Seek(fd int, offset int64, whence int) (newoffset int64, err error) { // 64-bit file system and 32-bit uid calls // (16-bit uid calls are not always supported in newer kernels) -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fchown(fd int, uid int, gid int) (err error) = SYS_FCHOWN32 //sys Fstat(fd int, stat *Stat_t) (err error) = SYS_FSTAT64 //sys Fstatat(dirfd int, path string, stat *Stat_t, flags int) (err error) = SYS_FSTATAT64 diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go b/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go index 173738077..e9f30db97 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_arm64.go @@ -8,7 +8,6 @@ package unix import "unsafe" -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) = SYS_EPOLL_PWAIT //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go b/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go index a3fd1d0b8..6f09ca200 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_loong64.go @@ -8,7 +8,6 @@ package unix import "unsafe" -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) = SYS_EPOLL_PWAIT //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstatfs(fd int, buf *Statfs_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_mips64x.go b/vendor/golang.org/x/sys/unix/syscall_linux_mips64x.go index 70963a95a..ca3b56597 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_mips64x.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_mips64x.go @@ -6,7 +6,6 @@ package unix -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstatfs(fd int, buf *Statfs_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_mipsx.go b/vendor/golang.org/x/sys/unix/syscall_linux_mipsx.go index c218ebd28..54ba667b1 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_mipsx.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_mipsx.go @@ -13,7 +13,6 @@ import ( func Syscall9(trap, a1, a2, a3, a4, a5, a6, a7, a8, a9 uintptr) (r1, r2 uintptr, err syscall.Errno) -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Ftruncate(fd int, length int64) (err error) = SYS_FTRUNCATE64 diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_ppc.go b/vendor/golang.org/x/sys/unix/syscall_linux_ppc.go index e6c48500c..ce4628590 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_ppc.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_ppc.go @@ -11,7 +11,6 @@ import ( "unsafe" ) -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) = SYS_FSTAT64 //sys Fstatat(dirfd int, path string, stat *Stat_t, flags int) (err error) = SYS_FSTATAT64 diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_ppc64x.go b/vendor/golang.org/x/sys/unix/syscall_linux_ppc64x.go index 7286a9aa8..33f7af380 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_ppc64x.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_ppc64x.go @@ -6,7 +6,6 @@ package unix -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go b/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go index fc5543c5f..c658871e3 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_riscv64.go @@ -8,7 +8,6 @@ package unix import "unsafe" -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) = SYS_EPOLL_PWAIT //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_s390x.go b/vendor/golang.org/x/sys/unix/syscall_linux_s390x.go index 66f31210d..2c8587691 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_s390x.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_s390x.go @@ -10,7 +10,6 @@ import ( "unsafe" ) -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/syscall_linux_sparc64.go b/vendor/golang.org/x/sys/unix/syscall_linux_sparc64.go index 11d1f1698..4964119af 100644 --- a/vendor/golang.org/x/sys/unix/syscall_linux_sparc64.go +++ b/vendor/golang.org/x/sys/unix/syscall_linux_sparc64.go @@ -6,7 +6,6 @@ package unix -//sys EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) //sys Fadvise(fd int, offset int64, length int64, advice int) (err error) = SYS_FADVISE64 //sys Fchown(fd int, uid int, gid int) (err error) //sys Fstat(fd int, stat *Stat_t) (err error) diff --git a/vendor/golang.org/x/sys/unix/zerrors_linux.go b/vendor/golang.org/x/sys/unix/zerrors_linux.go index 9d72a6b73..5bb51d7ae 100644 --- a/vendor/golang.org/x/sys/unix/zerrors_linux.go +++ b/vendor/golang.org/x/sys/unix/zerrors_linux.go @@ -1359,6 +1359,7 @@ const ( FAN_UNLIMITED_MARKS = 0x20 FAN_UNLIMITED_QUEUE = 0x10 FD_CLOEXEC = 0x1 + FD_PIDFS_ROOT = -0x2712 FD_SETSIZE = 0x400 FF0 = 0x0 FIB_RULE_DEV_DETACHED = 0x8 @@ -1970,6 +1971,8 @@ const ( MADV_DONTNEED = 0x4 MADV_DONTNEED_LOCKED = 0x18 MADV_FREE = 0x8 + MADV_GUARD_INSTALL = 0x66 + MADV_GUARD_REMOVE = 0x67 MADV_HUGEPAGE = 0xe MADV_HWPOISON = 0x64 MADV_KEEPONFORK = 0x13 @@ -2114,7 +2117,7 @@ const ( MS_NOSEC = 0x10000000 MS_NOSUID = 0x2 MS_NOSYMFOLLOW = 0x100 - MS_NOUSER = -0x80000000 + MS_NOUSER = 0x80000000 MS_POSIXACL = 0x10000 MS_PRIVATE = 0x40000 MS_RDONLY = 0x1 @@ -3786,6 +3789,9 @@ const ( TCPOPT_TIMESTAMP = 0x8 TCPOPT_TSTAMP_HDR = 0x101080a TCPOPT_WINDOW = 0x3 + TCP_AO_KEYF_EXCLUDE_OPT = 0x2 + TCP_AO_KEYF_IFINDEX = 0x1 + TCP_AO_MAXKEYLEN = 0x50 TCP_CC_INFO = 0x1a TCP_CM_INQ = 0x24 TCP_CONGESTION = 0xd diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux.go b/vendor/golang.org/x/sys/unix/zsyscall_linux.go index 80f40e401..5788c2a58 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux.go @@ -700,6 +700,23 @@ func EpollCtl(epfd int, op int, fd int, event *EpollEvent) (err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT +func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { + var _p0 unsafe.Pointer + if len(events) > 0 { + _p0 = unsafe.Pointer(&events[0]) + } else { + _p0 = unsafe.Pointer(&_zero) + } + r0, _, e1 := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) + n = int(r0) + if e1 != 0 { + err = errnoErr(e1) + } + return +} + +// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT + func Eventfd(initval uint, flags int) (fd int, err error) { r0, _, e1 := Syscall(SYS_EVENTFD2, uintptr(initval), uintptr(flags), 0) fd = int(r0) diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_386.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_386.go index 4def3e9fc..254f33988 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_386.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_386.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64_64, uintptr(fd), uintptr(offset), uintptr(offset>>32), uintptr(length), uintptr(length>>32), uintptr(advice)) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_amd64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_amd64.go index fef2bc8ba..27c05db1a 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_amd64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_amd64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_arm.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_arm.go index a9fd76a88..840d85bfc 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_arm.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_arm.go @@ -213,23 +213,6 @@ func sendmsg(s int, msg *Msghdr, flags int) (n int, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fchown(fd int, uid int, gid int) (err error) { _, _, e1 := Syscall(SYS_FCHOWN32, uintptr(fd), uintptr(uid), uintptr(gid)) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_arm64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_arm64.go index 460065028..fe414498b 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_arm64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_arm64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_loong64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_loong64.go index c8987d264..eb358ce05 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_loong64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_loong64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips.go index 921f43061..c437622f1 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall9(SYS_FADVISE64, uintptr(fd), 0, uintptr(offset>>32), uintptr(offset), uintptr(length>>32), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64.go index 44f067829..bc4ca2558 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64le.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64le.go index e7fa0abf0..5051435ce 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64le.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_mips64le.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_mipsle.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_mipsle.go index 8c5125675..33aa5418a 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_mipsle.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_mipsle.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall9(SYS_FADVISE64, uintptr(fd), 0, uintptr(offset), uintptr(offset>>32), uintptr(length), uintptr(length>>32), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc.go index 7392fd45e..3bef8ef1d 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fchown(fd int, uid int, gid int) (err error) { _, _, e1 := Syscall(SYS_FCHOWN, uintptr(fd), uintptr(uid), uintptr(gid)) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64.go index 41180434e..fc1bd4e2c 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64le.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64le.go index 40c6ce7ae..d78fe7dab 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64le.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_ppc64le.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_riscv64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_riscv64.go index 2cfe34adb..76dcf87d0 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_riscv64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_riscv64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_PWAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_s390x.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_s390x.go index 61e6f0709..2cf020f2b 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_s390x.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_s390x.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/unix/zsyscall_linux_sparc64.go b/vendor/golang.org/x/sys/unix/zsyscall_linux_sparc64.go index 834b84204..527637623 100644 --- a/vendor/golang.org/x/sys/unix/zsyscall_linux_sparc64.go +++ b/vendor/golang.org/x/sys/unix/zsyscall_linux_sparc64.go @@ -45,23 +45,6 @@ func Tee(rfd int, wfd int, len int, flags int) (n int64, err error) { // THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT -func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error) { - var _p0 unsafe.Pointer - if len(events) > 0 { - _p0 = unsafe.Pointer(&events[0]) - } else { - _p0 = unsafe.Pointer(&_zero) - } - r0, _, e1 := Syscall6(SYS_EPOLL_WAIT, uintptr(epfd), uintptr(_p0), uintptr(len(events)), uintptr(msec), 0, 0) - n = int(r0) - if e1 != 0 { - err = errnoErr(e1) - } - return -} - -// THIS FILE IS GENERATED BY THE COMMAND AT THE TOP; DO NOT EDIT - func Fadvise(fd int, offset int64, length int64, advice int) (err error) { _, _, e1 := Syscall6(SYS_FADVISE64, uintptr(fd), uintptr(offset), uintptr(length), uintptr(advice), 0, 0) if e1 != 0 { diff --git a/vendor/golang.org/x/sys/windows/security_windows.go b/vendor/golang.org/x/sys/windows/security_windows.go index 6c955cea1..783621561 100644 --- a/vendor/golang.org/x/sys/windows/security_windows.go +++ b/vendor/golang.org/x/sys/windows/security_windows.go @@ -1109,17 +1109,53 @@ const ( ) // This type is the union inside of TRUSTEE and must be created using one of the TrusteeValueFrom* functions. +// +// Go pointers stored in a TrusteeValue must be pinned using [runtime.Pinner] +// for the lifetime of the TrusteeValue. type TrusteeValue uintptr +// TrusteeValueFromString is unsafe and should not be used. +// +// It returns a uintptr containing a reference to newly-allocated memory +// which will be freed by the garbage collector. +// There is no way for the caller to safely reference this memory. +// +// To create a [TrusteeValue] from a string, use: +// +// p, err := windows.UTF16PtrFromString(s) +// if err != nil { +// // handle error +// } +// +// // Pin the string for as long as it is used. +// var pinner runtime.Pinner +// pinner.Pin(p) +// defer pinner.Unpin() +// +// tv := TrusteeValue(unsafe.Pointer(p)) +// +// Deprecated: TrusteeValueFromString is unsafe and should not be used. func TrusteeValueFromString(str string) TrusteeValue { return TrusteeValue(unsafe.Pointer(StringToUTF16Ptr(str))) } + +// TrusteeValueFromSID returns a [TrusteeValue] referencing sid. +// +// The caller must pin sid using a [runtime.Pinner] for the lifetime of the TrusteeValue. func TrusteeValueFromSID(sid *SID) TrusteeValue { return TrusteeValue(unsafe.Pointer(sid)) } + +// TrusteeValueFromObjectsAndSid returns a [TrusteeValue] referencing objectsAndSid. +// +// The caller must pin objectsAndSid using a [runtime.Pinner] for the lifetime of the TrusteeValue. func TrusteeValueFromObjectsAndSid(objectsAndSid *OBJECTS_AND_SID) TrusteeValue { return TrusteeValue(unsafe.Pointer(objectsAndSid)) } + +// TrusteeValueFromObjectsAndName returns a [TrusteeValue] referencing objectsAndName. +// +// The caller must pin objectsAndName using a [runtime.Pinner] for the lifetime of the TrusteeValue. func TrusteeValueFromObjectsAndName(objectsAndName *OBJECTS_AND_NAME) TrusteeValue { return TrusteeValue(unsafe.Pointer(objectsAndName)) } diff --git a/vendor/golang.org/x/sys/windows/syscall_windows.go b/vendor/golang.org/x/sys/windows/syscall_windows.go index 9755bca9f..e6966b4c3 100644 --- a/vendor/golang.org/x/sys/windows/syscall_windows.go +++ b/vendor/golang.org/x/sys/windows/syscall_windows.go @@ -1728,11 +1728,15 @@ func (s *NTUnicodeString) String() string { // the more common *uint16 string type. func NewNTString(s string) (*NTString, error) { var nts NTString - s8, err := BytePtrFromString(s) + s8, err := ByteSliceFromString(s) if err != nil { return nil, err } - RtlInitString(&nts, s8) + // The source string plus its terminating NUL must fit within MAX_USHORT. + if len(s8) > MAX_USHORT { + return nil, syscall.EINVAL + } + RtlInitString(&nts, &s8[0]) return &nts, nil } diff --git a/vendor/golang.org/x/sys/windows/types_windows.go b/vendor/golang.org/x/sys/windows/types_windows.go index d2574a73e..75a50b316 100644 --- a/vendor/golang.org/x/sys/windows/types_windows.go +++ b/vendor/golang.org/x/sys/windows/types_windows.go @@ -169,6 +169,7 @@ const ( FORMAT_MESSAGE_ARGUMENT_ARRAY = 8192 FORMAT_MESSAGE_MAX_WIDTH_MASK = 255 + MAX_USHORT = 0xffff MAX_PATH = 260 MAX_LONG_PATH = 32768 diff --git a/vendor/golang.org/x/text/cases/context.go b/vendor/golang.org/x/text/cases/context.go index e9aa9e193..a28f45d7b 100644 --- a/vendor/golang.org/x/text/cases/context.go +++ b/vendor/golang.org/x/text/cases/context.go @@ -249,7 +249,7 @@ func upper(c *context) bool { return c.copy() } -// isUpper writes the isUppercase version of the current rune to dst. +// isUpper reports whether the current rune is in upper case. func isUpper(c *context) bool { ct := c.caseType() if c.info&hasMappingMask == 0 || ct == cUpper { diff --git a/vendor/golang.org/x/text/cases/map.go b/vendor/golang.org/x/text/cases/map.go index 0f7c6a14b..51a683092 100644 --- a/vendor/golang.org/x/text/cases/map.go +++ b/vendor/golang.org/x/text/cases/map.go @@ -774,7 +774,7 @@ func nlTitle(c *context) bool { // From CLDR: // # Special titlecasing for Dutch initial "ij". // ::Any-Title(); - // # Fix up Ij at the beginning of a "word" (per Any-Title, notUAX #29) + // # Fix up Ij at the beginning of a "word" (per Any-Title, not UAX #29) // [:^WB=ALetter:] [:WB=Extend:]* [[:WB=MidLetter:][:WB=MidNumLet:]]? { Ij } → IJ ; if c.src[c.pSrc] != 'I' && c.src[c.pSrc] != 'i' { return title(c) @@ -794,7 +794,7 @@ func nlTitleSpan(c *context) bool { // From CLDR: // # Special titlecasing for Dutch initial "ij". // ::Any-Title(); - // # Fix up Ij at the beginning of a "word" (per Any-Title, notUAX #29) + // # Fix up Ij at the beginning of a "word" (per Any-Title, not UAX #29) // [:^WB=ALetter:] [:WB=Extend:]* [[:WB=MidLetter:][:WB=MidNumLet:]]? { Ij } → IJ ; if c.src[c.pSrc] != 'I' { return isTitle(c) diff --git a/vendor/golang.org/x/text/unicode/norm/forminfo.go b/vendor/golang.org/x/text/unicode/norm/forminfo.go index f3a234e5f..b3cf5d9bd 100644 --- a/vendor/golang.org/x/text/unicode/norm/forminfo.go +++ b/vendor/golang.org/x/text/unicode/norm/forminfo.go @@ -121,8 +121,12 @@ func (p Properties) BoundaryAfter() bool { // // When all 6 bits are zero, the character is inert, meaning it is never // influenced by normalization. +// +// We set flags to 0x80 (high bit 7 unused in quick check data) to indicate an invalid rune. type qcInfo uint8 +func (p Properties) isInvalid() bool { return p.flags == 0x80 } + func (p Properties) isYesC() bool { return p.flags&0x10 == 0 } func (p Properties) isYesD() bool { return p.flags&0x4 == 0 } @@ -247,6 +251,9 @@ func (f Form) PropertiesString(s string) Properties { // to a Properties. See the comment at the top of the file // for more information on the format. func compInfo(v uint16, sz int) Properties { + if sz == 0 { + return Properties{flags: 0x80, size: 1} + } if v == 0 { return Properties{size: uint8(sz)} } else if v >= 0x8000 { @@ -254,7 +261,7 @@ func compInfo(v uint16, sz int) Properties { size: uint8(sz), ccc: uint8(v), tccc: uint8(v), - flags: qcInfo(v >> 8), + flags: qcInfo(v>>8) & 0x3f, } if p.ccc > 0 || p.combinesBackward() { p.nLead = uint8(p.flags & 0x3) diff --git a/vendor/golang.org/x/text/unicode/norm/iter.go b/vendor/golang.org/x/text/unicode/norm/iter.go index 417c6b268..3cc059224 100644 --- a/vendor/golang.org/x/text/unicode/norm/iter.go +++ b/vendor/golang.org/x/text/unicode/norm/iter.go @@ -376,16 +376,12 @@ func nextComposed(i *Iter) []byte { goto doNorm } prevCC = i.info.tccc - sz := int(i.info.size) - if sz == 0 { - sz = 1 // illegal rune: copy byte-by-byte - } - p := outp + sz + p := outp + int(i.info.size) if p > len(i.buf) { break } outp = p - i.p += sz + i.p += int(i.info.size) if i.p >= i.rb.nsrc { i.setDone() break diff --git a/vendor/golang.org/x/text/unicode/norm/normalize.go b/vendor/golang.org/x/text/unicode/norm/normalize.go index 4747ad07a..60b1511ca 100644 --- a/vendor/golang.org/x/text/unicode/norm/normalize.go +++ b/vendor/golang.org/x/text/unicode/norm/normalize.go @@ -148,7 +148,7 @@ func (f Form) IsNormalString(s string) bool { // patched buffer and whether the decomposition is still in progress. func patchTail(rb *reorderBuffer) bool { info, p := lastRuneStart(&rb.f, rb.out) - if p == -1 || info.size == 0 { + if p == -1 || info.isInvalid() { return true } end := p + int(info.size) @@ -225,7 +225,7 @@ func doAppend(rb *reorderBuffer, out []byte, p int) []byte { } fd := &rb.f if doMerge { - var info Properties + info := Properties{flags: 0x80, size: 1} // invalid rune if p < n { info = fd.info(src, p) if !info.BoundaryBefore() || info.nLeadingNonStarters() > 0 { @@ -235,7 +235,7 @@ func doAppend(rb *reorderBuffer, out []byte, p int) []byte { p = decomposeSegment(rb, p, true) } } - if info.size == 0 { + if info.isInvalid() { rb.doFlush() // Append incomplete UTF-8 encoding. return src.appendSlice(rb.out, p, n) @@ -314,7 +314,7 @@ func (f *formInfo) quickSpan(src input, i, end int, atEOF bool) (n int, ok bool) continue } info := f.info(src, i) - if info.size == 0 { + if info.isInvalid() { if atEOF { // include incomplete runes return n, true @@ -379,7 +379,7 @@ func (f Form) firstBoundary(src input, nsrc int) int { // CGJ insertion points correctly. Luckily it doesn't have to. for { info := fd.info(src, i) - if info.size == 0 { + if info.isInvalid() { return -1 } if s := ss.next(info); s != ssSuccess { @@ -424,7 +424,7 @@ func (f Form) nextBoundary(src input, nsrc int, atEOF bool) int { } fd := formTable[f] info := fd.info(src, 0) - if info.size == 0 { + if info.isInvalid() { if atEOF { return 1 } @@ -435,7 +435,7 @@ func (f Form) nextBoundary(src input, nsrc int, atEOF bool) int { for i := int(info.size); i < nsrc; i += int(info.size) { info = fd.info(src, i) - if info.size == 0 { + if info.isInvalid() { if atEOF { return i } @@ -465,7 +465,7 @@ func lastBoundary(fd *formInfo, b []byte) int { if p == -1 { return -1 } - if info.size == 0 { // ends with incomplete rune + if info.isInvalid() { // ends with incomplete rune if p == 0 { // starts with incomplete rune return -1 } @@ -504,7 +504,7 @@ func lastBoundary(fd *formInfo, b []byte) int { func decomposeSegment(rb *reorderBuffer, sp int, atEOF bool) int { // Force one character to be consumed. info := rb.f.info(rb.src, sp) - if info.size == 0 { + if info.isInvalid() { return 0 } if s := rb.ss.next(info); s == ssStarter { @@ -528,7 +528,7 @@ func decomposeSegment(rb *reorderBuffer, sp int, atEOF bool) int { break } info = rb.f.info(rb.src, sp) - if info.size == 0 { + if info.isInvalid() { if !atEOF { return int(iShortSrc) } diff --git a/vendor/modules.txt b/vendor/modules.txt index 2c60d9282..005d79a73 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -48,7 +48,7 @@ github.com/fatih/color # github.com/gdamore/encoding v1.0.1 ## explicit; go 1.9 github.com/gdamore/encoding -# github.com/gdamore/tcell/v3 v3.4.1-0.20260703153331-243630d2fb59 +# github.com/gdamore/tcell/v3 v3.4.1 ## explicit; go 1.25.0 github.com/gdamore/tcell/v3 github.com/gdamore/tcell/v3/color @@ -175,27 +175,25 @@ github.com/xo/terminfo ## explicit; go 1.20 golang.org/x/exp/constraints golang.org/x/exp/slices -# golang.org/x/mod v0.36.0 +# golang.org/x/mod v0.37.0 ## explicit; go 1.25.0 golang.org/x/mod/internal/lazyregexp golang.org/x/mod/modfile golang.org/x/mod/module golang.org/x/mod/semver -# golang.org/x/net v0.55.0 -## explicit; go 1.25.0 -# golang.org/x/sync v0.21.0 +# golang.org/x/sync v0.22.0 ## explicit; go 1.25.0 golang.org/x/sync/errgroup golang.org/x/sync/semaphore -# golang.org/x/sys v0.46.0 +# golang.org/x/sys v0.47.0 ## explicit; go 1.25.0 golang.org/x/sys/plan9 golang.org/x/sys/unix golang.org/x/sys/windows -# golang.org/x/term v0.44.0 +# golang.org/x/term v0.45.0 ## explicit; go 1.25.0 golang.org/x/term -# golang.org/x/text v0.38.0 +# golang.org/x/text v0.40.0 ## explicit; go 1.25.0 golang.org/x/text/cases golang.org/x/text/encoding @@ -208,7 +206,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.45.0 +# golang.org/x/tools v0.47.0 ## explicit; go 1.25.0 golang.org/x/tools/go/ast/astutil # gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c From 46c1fa7db28d1b9399dfb6df2dcce76ab1865755 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 9 Jul 2026 19:11:33 +0200 Subject: [PATCH 347/384] Don't let a leaked subprocess wedge the headless test runner When lazygit exits but leaves behind a subprocess that inherited its stderr pipe and has detached from the pty, cmd.Wait() blocks in awaitGoroutines waiting for that pipe to reach EOF -- which never happens while the straggler is alive. With no WaitDelay set, that wait is unbounded, so a single leaked process hangs the whole test binary until the 10-minute global timeout fires and panics. Worse, the timeout discards whatever lazygit wrote to stderr before exiting (a panic, a -race report), which is exactly the output needed to diagnose the failure. This surfaces under -race, where lazygit runs slow enough to widen the window for a spawned command to still be alive when lazygit quits, and it's a blocker for enabling the race detector on CI. Bound the wait with cmd.WaitDelay so Wait force-closes the pipe and returns ErrWaitDelay instead of hanging, surface the captured stderr as the error (falling back to the wait error when nothing was printed), and kill the child's process group on failure so a straggler can't linger into a later test or pile up across a run. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/integration/clients/go_test.go | 39 +++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/pkg/integration/clients/go_test.go b/pkg/integration/clients/go_test.go index 11f6e754e..d3f15b88a 100644 --- a/pkg/integration/clients/go_test.go +++ b/pkg/integration/clients/go_test.go @@ -11,7 +11,9 @@ import ( "io" "os" "os/exec" + "syscall" "testing" + "time" "github.com/creack/pty" "github.com/jesseduffield/lazycore/pkg/utils" @@ -75,6 +77,17 @@ func runCmdHeadless(cmd *exec.Cmd) (int, error) { stderr := new(bytes.Buffer) cmd.Stderr = stderr + // If lazygit exits but leaves behind a subprocess that inherited its stderr + // pipe, cmd.Wait blocks waiting for that pipe to reach EOF for as long as the + // subprocess stays alive. Unbounded, that hangs the whole test binary until + // its global timeout fires, and the timeout throws away whatever lazygit + // wrote to stderr before exiting (a panic, a -race report) -- the very output + // needed to diagnose the failure. WaitDelay caps the wait: once the process + // has exited, Wait gives the stderr goroutine at most this long to drain, + // then closes the pipe and returns ErrWaitDelay, so the captured stderr + // surfaces as the test error instead of being lost. + cmd.WaitDelay = 5 * time.Second + // these rows and columns are ignored because internally we use tcell's // simulation screen. However we still need the pty for the sake of // running other commands in a pty. @@ -83,12 +96,32 @@ func runCmdHeadless(cmd *exec.Cmd) (int, error) { return -1, err } + // pty.StartWithSize starts lazygit in its own process group, so we can signal + // the whole group at once. Capture the id now, while the process is alive: + // once Wait has reaped it we can no longer look it up. + pgid, pgidErr := syscall.Getpgid(cmd.Process.Pid) + _, _ = io.Copy(io.Discard, f) - if cmd.Wait() != nil { + waitErr := cmd.Wait() + + // On any failure -- including a WaitDelay expiry caused by a leaked + // subprocess -- kill the whole process group so a straggler can't linger and + // wedge a later test or pile up across a CI run. Best effort: usually the + // group is already gone (ESRCH), and a subprocess that called setsid to + // detach into its own group is out of reach, but WaitDelay still unblocks us. + if waitErr != nil && pgidErr == nil { + _ = syscall.Kill(-pgid, syscall.SIGKILL) + } + + if waitErr != nil { _ = f.Close() - // return an error with the stderr output - return cmd.Process.Pid, errors.New(stderr.String()) + // Prefer lazygit's own stderr as the error; fall back to the wait error + // itself (e.g. ErrWaitDelay) when it exited without printing anything. + if stderr.Len() > 0 { + return cmd.Process.Pid, errors.New(stderr.String()) + } + return cmd.Process.Pid, waitErr } return cmd.Process.Pid, f.Close() From 7fce58b09bc7b9bb61cbd8c3950579121a0bb832 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 12:55:36 +0200 Subject: [PATCH 348/384] Scale the integration test watchdog up under the race detector The integration test watchdog fails a test if its recording takes longer than 40 seconds. Under the race detector everything runs several times slower, so legitimately slow tests (e.g. a conflicting interactive rebase) blow that budget and fail even though nothing is actually stuck. Key the timeout off a build-tag constant: the `race` tag is set automatically when the binary is built with -race, so a race build gets a 5x-longer budget while a normal build is unchanged, and the two can't drift apart the way a runtime flag would. The base 40s stays in one place; only the multiplier varies by build. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/test_mode.go | 5 +++-- pkg/gui/test_timeout_norace.go | 5 +++++ pkg/gui/test_timeout_race.go | 10 ++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 pkg/gui/test_timeout_norace.go create mode 100644 pkg/gui/test_timeout_race.go diff --git a/pkg/gui/test_mode.go b/pkg/gui/test_mode.go index d6893c92d..0b24bb8cd 100644 --- a/pkg/gui/test_mode.go +++ b/pkg/gui/test_mode.go @@ -45,9 +45,10 @@ func (gui *Gui) handleTestMode() { }() if os.Getenv(components.WAIT_FOR_DEBUGGER_ENV_VAR) == "" { + timeout := 40 * time.Second * testTimeoutMultiplier go utils.Safe(func() { - time.Sleep(time.Second * 40) - log.Fatal("40 seconds is up, lazygit recording took too long to complete") + time.Sleep(timeout) + log.Fatalf("%v is up, lazygit integration test took too long to complete", timeout) }) } } diff --git a/pkg/gui/test_timeout_norace.go b/pkg/gui/test_timeout_norace.go new file mode 100644 index 000000000..7f924ea71 --- /dev/null +++ b/pkg/gui/test_timeout_norace.go @@ -0,0 +1,5 @@ +//go:build !race + +package gui + +const testTimeoutMultiplier = 1 diff --git a/pkg/gui/test_timeout_race.go b/pkg/gui/test_timeout_race.go new file mode 100644 index 000000000..7d633def3 --- /dev/null +++ b/pkg/gui/test_timeout_race.go @@ -0,0 +1,10 @@ +//go:build race + +package gui + +// The race detector makes everything run several times slower, so the +// recording watchdog needs a correspondingly longer timeout; otherwise it +// fires on tests that are merely slow under -race rather than actually stuck. +// The `race` build tag is set automatically when the binary is built with +// -race, so this can't drift out of sync with the actual build. +const testTimeoutMultiplier = 4 From 334daccfab6e080df95b94678d33e607b106a850 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 15:29:28 +0200 Subject: [PATCH 349/384] Increase integration test timeout to 30 minutes Go's default 10-minute timeout was enough for running integration tests normally (both locally and on CI), but with race detection turned on they can take much longer to run. Increase the timeout unconditionally to 30 minutes; we don't bother making a distinction between race vs. normal, because a longer timeout doesn't hurt (I can't recall having hit the global timeout ever; and we still have the per-test watchdog that kills an individual test after 40s). --- justfile | 2 +- scripts/run_integration_tests.sh | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/justfile b/justfile index 64d9d1ee2..0851179b9 100644 --- a/justfile +++ b/justfile @@ -40,7 +40,7 @@ lint: ./scripts/gofumpt-check.sh ./scripts/golangci-lint-shim.sh run -e2e-test-command := "go test pkg/integration/clients/*.go" +e2e-test-command := "go test -timeout 30m pkg/integration/clients/*.go" # Run integration tests headlessly: no args runs all tests, a test name (or path) runs just that one. Use e2e-cli for a visible UI. e2e *args: diff --git a/scripts/run_integration_tests.sh b/scripts/run_integration_tests.sh index 579e6d77c..1dadeec97 100755 --- a/scripts/run_integration_tests.sh +++ b/scripts/run_integration_tests.sh @@ -19,7 +19,7 @@ if [ -n "$LAZYGIT_GOCOVERDIR" ]; then # hacky. To capture the coverage data for the test runner we pass the test.gocoverdir positional # arg, but if we do that then the GOCOVERDIR env var (which you typically pass to the test binary) will be overwritten by the test runner. So we're passing LAZYGIT_COCOVERDIR instead # and then internally passing that to the test binary as GOCOVERDIR. - go test -cover -coverpkg=github.com/jesseduffield/lazygit/pkg/... pkg/integration/clients/*.go -args -test.gocoverdir="/tmp/code_coverage" + go test -timeout 30m -cover -coverpkg=github.com/jesseduffield/lazygit/pkg/... pkg/integration/clients/*.go -args -test.gocoverdir="/tmp/code_coverage" EXITCODE=$? # We're merging the coverage data for the sake of having fewer artefacts to upload. @@ -29,7 +29,7 @@ if [ -n "$LAZYGIT_GOCOVERDIR" ]; then rm -rf /tmp/code_coverage mv /tmp/code_coverage_merged /tmp/code_coverage else - go test pkg/integration/clients/*.go + go test -timeout 30m pkg/integration/clients/*.go EXITCODE=$? fi From 840a66e7333ae412003b33cd9ff3799a251e92f9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 13:09:40 +0200 Subject: [PATCH 350/384] Run the integration tests under the race detector on CI Add one extra integration-tests job that runs the whole suite under the race detector. A `race` matrix dimension (default false) plus an include entry adds a single git-latest job with LAZYGIT_RACE_DETECTOR set; races live in lazygit's own Go code rather than in git, so one git version is enough, and using latest skips the git-build steps. The race job skips coverage collection: it's redundant with the non-race latest job and would only slow the -race build down further. --- .github/workflows/ci.yml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e7538b0d..d14778483 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,8 +53,16 @@ jobs: - 2.38.2 # first version that supports the rebase.updateRefs config - 2.44.0 - latest # We rely on github to have the latest version installed on their VMs + race: + - false + # Additionally run the whole suite once under the race detector. Data + # races live in lazygit's own Go code rather than in git, so a single + # git version is enough; use the latest to skip the git-build steps. + include: + - git-version: latest + race: true runs-on: ubuntu-latest - name: "Integration Tests - git ${{matrix.git-version}}" + name: "Integration Tests - git ${{matrix.git-version}}${{ matrix.race && ' (race)' || '' }}" env: GOFLAGS: -mod=vendor steps: @@ -92,12 +100,20 @@ jobs: run: git --version - name: Test code env: - # See https://go.dev/blog/integration-test-coverage - LAZYGIT_GOCOVERDIR: /tmp/code_coverage + # See https://go.dev/blog/integration-test-coverage. The race variant + # skips coverage: it's redundant with the non-race latest job and + # would only slow the -race build down further. Leaving the dir unset + # makes run_integration_tests.sh take its non-coverage path. + LAZYGIT_GOCOVERDIR: ${{ !matrix.race && '/tmp/code_coverage' || '' }} + # Only set for the race variant. The race detector needs cgo; it's on + # by default on the Linux runner, but we set it explicitly to be safe. + LAZYGIT_RACE_DETECTOR: ${{ matrix.race && '1' || '' }} + CGO_ENABLED: ${{ matrix.race && '1' || '' }} run: | mkdir -p /tmp/code_coverage ./scripts/run_integration_tests.sh - name: Upload code coverage artifacts + if: ${{ !matrix.race }} uses: actions/upload-artifact@v7 with: name: coverage-integration-${{ matrix.git-version }}-${{ github.run_id }} From 03a914c04c713df8b7dbb504f2154185047675cb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 17:28:19 +0200 Subject: [PATCH 351/384] Dump goroutine stacks when the test watchdog fires The watchdog only log.Fatal'd with a message, so a hung test told us that it timed out but not where it was stuck -- useless for diagnosing an intermittent deadlock under the race detector. Dump all goroutine stacks to stderr first (the harness surfaces this process's stderr on failure), turning a bare timeout into an actionable stack trace. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/gui/test_mode.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/gui/test_mode.go b/pkg/gui/test_mode.go index 0b24bb8cd..2644e989b 100644 --- a/pkg/gui/test_mode.go +++ b/pkg/gui/test_mode.go @@ -3,6 +3,7 @@ package gui import ( "log" "os" + "runtime/pprof" "time" "github.com/jesseduffield/lazygit/pkg/gocui" @@ -48,6 +49,10 @@ func (gui *Gui) handleTestMode() { timeout := 40 * time.Second * testTimeoutMultiplier go utils.Safe(func() { time.Sleep(timeout) + // Dump all goroutine stacks before dying, so a hung test shows + // where it got stuck rather than just that it timed out. The + // test harness surfaces this process's stderr on failure. + _ = pprof.Lookup("goroutine").WriteTo(os.Stderr, 2) log.Fatalf("%v is up, lazygit integration test took too long to complete", timeout) }) } From b6b5436d571d377af1e487dee871fad101a46a25 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 10 Jul 2026 17:28:19 +0200 Subject: [PATCH 352/384] Log per-test durations during integration tests To spot slow or anomalous tests across CI runs, record each test's run duration when LAZYGIT_TEST_TIMING is set (to a file path); run_integration_tests.sh prints them at the end, sorted by slowest first. CI sets it for all integration jobs. The harness appends to a file rather than writing to stdout/stderr because `go test` captures those and only surfaces them with -v, which would drown the signal in every test's verbose logs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 3 +++ pkg/integration/clients/go_test.go | 4 +++- pkg/integration/components/runner.go | 34 ++++++++++++++++++++++++++++ scripts/run_integration_tests.sh | 8 +++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d14778483..c6d2fdf6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,9 @@ jobs: # by default on the Linux runner, but we set it explicitly to be safe. LAZYGIT_RACE_DETECTOR: ${{ matrix.race && '1' || '' }} CGO_ENABLED: ${{ matrix.race && '1' || '' }} + # Append each test's duration to this file; run_integration_tests.sh + # prints the slowest at the end, to spot slow/anomalous tests. + LAZYGIT_TEST_TIMING: /tmp/test_timings.txt run: | mkdir -p /tmp/code_coverage ./scripts/run_integration_tests.sh diff --git a/pkg/integration/clients/go_test.go b/pkg/integration/clients/go_test.go index d3f15b88a..4c1faa557 100644 --- a/pkg/integration/clients/go_test.go +++ b/pkg/integration/clients/go_test.go @@ -30,6 +30,7 @@ func TestIntegration(t *testing.T) { parallelTotal := tryConvert(os.Getenv("PARALLEL_TOTAL"), 1) parallelIndex := tryConvert(os.Getenv("PARALLEL_INDEX"), 0) raceDetector := os.Getenv("LAZYGIT_RACE_DETECTOR") != "" + logTimingsPath := os.Getenv("LAZYGIT_TEST_TIMING") // LAZYGIT_GOCOVERDIR is the directory where we write coverage files to. If this directory // is defined, go binaries built with the -cover flag will write coverage files to // to it. @@ -58,7 +59,8 @@ func TestIntegration(t *testing.T) { CodeCoverageDir: codeCoverageDir, InputDelay: 0, // Allow two attempts at each test to get around flakiness - MaxAttempts: 1, + MaxAttempts: 1, + LogTimingsPath: logTimingsPath, }) assert.NoError(t, err) diff --git a/pkg/integration/components/runner.go b/pkg/integration/components/runner.go index 5640c3e70..78cb5439f 100644 --- a/pkg/integration/components/runner.go +++ b/pkg/integration/components/runner.go @@ -5,6 +5,8 @@ import ( "os" "os/exec" "path/filepath" + "sync" + "time" lazycoreUtils "github.com/jesseduffield/lazycore/pkg/utils" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -24,6 +26,12 @@ type RunTestArgs struct { CodeCoverageDir string InputDelay int MaxAttempts int + // If set, each test's run duration is appended to this file (as + // " "). run_integration_tests.sh prints the slowest at + // the end, so slow or anomalous tests can be spotted across CI runs. We + // write to a file rather than stdout/stderr because `go test` captures + // those and only shows them with -v. Empty disables it. + LogTimingsPath string } // This function lets you run tests either from within `go test` or from a regular binary. @@ -47,6 +55,11 @@ func RunTests(args RunTestArgs) error { return err } + // Start each run with a fresh timings file (see RunTestArgs.LogTimingsPath). + if args.LogTimingsPath != "" { + _ = os.Remove(args.LogTimingsPath) + } + for _, test := range args.Tests { args.TestWrapper(test, func() error { paths := NewPaths( @@ -99,7 +112,11 @@ func runTest( return err } + start := time.Now() pid, err := args.RunCmd(cmd) + if args.LogTimingsPath != "" { + logTestTiming(args.LogTimingsPath, test.Name(), time.Since(start)) + } // Print race detector log regardless of the command's exit status if args.RaceDetector { @@ -112,6 +129,23 @@ func runTest( return err } +// timingsMutex serializes appends to the timings file, since tests run in +// parallel. +var timingsMutex sync.Mutex + +func logTestTiming(path, name string, duration time.Duration) { + timingsMutex.Lock() + defer timingsMutex.Unlock() + + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + + fmt.Fprintf(f, "%.2f %s\n", duration.Seconds(), name) +} + func prepareTestDir( test *IntegrationTest, paths Paths, diff --git a/scripts/run_integration_tests.sh b/scripts/run_integration_tests.sh index 1dadeec97..2bf010f19 100755 --- a/scripts/run_integration_tests.sh +++ b/scripts/run_integration_tests.sh @@ -37,4 +37,12 @@ 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. +if [ -n "$LAZYGIT_TEST_TIMING" ] && [ -f "$LAZYGIT_TEST_TIMING" ]; then + echo "Test timings (seconds):" + sort -rn "$LAZYGIT_TEST_TIMING" +fi + exit $EXITCODE From 132f656480671ac83ade3528fcb475422d551ee4 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Jul 2026 22:54:12 +0200 Subject: [PATCH 353/384] Run nested-submodule commands in the parent module's directory explicitly Deleting a nested submodule (and updating its URL) chdir'd the whole process into the parent module, ran its git commands there, and chdir'd back. Only those commands need to run there, and a process-wide chdir leaks the parent module's directory into any command another goroutine spawns during that window (e.g. a background refresh's). Set the directory on the commands themselves instead. Co-Authored-By: Claude Fable 5 --- pkg/commands/git_commands/submodule.go | 62 ++++++++++---------------- 1 file changed, 24 insertions(+), 38 deletions(-) diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index 3b88e4fbb..3eb081701 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -213,51 +213,51 @@ func (self *SubmoduleCommands) UpdateAll() error { return self.cmd.New(cmdArgs).Run() } +// runInParentModule runs the given command in the submodule's parent module's +// directory when the submodule is nested: its path arguments (and the +// .gitmodules file the config commands touch) are relative to the parent +// module. The directory is set on the command itself rather than by +// temporarily chdir-ing the process there, which would leak the parent +// module's directory into whatever other commands run concurrently (e.g. a +// background refresh's). +func (self *SubmoduleCommands) runInParentModule(submodule *models.SubmoduleConfig, cmdObj *oscommands.CmdObj) error { + if submodule.ParentModule != nil { + cmdObj.SetWd(submodule.ParentModule.FullPath()) + } + return cmdObj.Run() +} + func (self *SubmoduleCommands) Delete(submodule *models.SubmoduleConfig) error { // based on https://gist.github.com/myusuf3/7f645819ded92bda6677 - if submodule.ParentModule != nil { - wd, err := os.Getwd() - if err != nil { - return err - } - - err = os.Chdir(submodule.ParentModule.FullPath()) - if err != nil { - return err - } - - defer func() { _ = os.Chdir(wd) }() - } - - if err := self.cmd.New( + if err := self.runInParentModule(submodule, self.cmd.New( NewGitCmd("submodule"). Arg("deinit", "--force", "--", submodule.Path).ToArgv(), - ).Run(); err != nil { + )); err != nil { if !strings.Contains(err.Error(), "did not match any file(s) known to git") { return err } - if err := self.cmd.New( + if err := self.runInParentModule(submodule, self.cmd.New( NewGitCmd("config"). Arg("--file", ".gitmodules", "--remove-section", "submodule."+submodule.Path). ToArgv(), - ).Run(); err != nil { + )); err != nil { return err } - if err := self.cmd.New( + if err := self.runInParentModule(submodule, self.cmd.New( NewGitCmd("config"). Arg("--remove-section", "submodule."+submodule.Path). ToArgv(), - ).Run(); err != nil { + )); err != nil { return err } } - if err := self.cmd.New( + if err := self.runInParentModule(submodule, self.cmd.New( NewGitCmd("rm").Arg("--force", "-r", submodule.Path).ToArgv(), - ).Run(); err != nil { + )); err != nil { // if the directory isn't there then that's fine self.Log.Error(err) } @@ -282,20 +282,6 @@ func (self *SubmoduleCommands) Add(name string, path string, url string) error { } func (self *SubmoduleCommands) UpdateUrl(submodule *models.SubmoduleConfig, newUrl string) error { - if submodule.ParentModule != nil { - wd, err := os.Getwd() - if err != nil { - return err - } - - err = os.Chdir(submodule.ParentModule.FullPath()) - if err != nil { - return err - } - - defer func() { _ = os.Chdir(wd) }() - } - setUrlCmdStr := NewGitCmd("config"). Arg( "--file", ".gitmodules", "submodule."+submodule.Name+".url", newUrl, @@ -303,14 +289,14 @@ func (self *SubmoduleCommands) UpdateUrl(submodule *models.SubmoduleConfig, newU ToArgv() // the set-url command is only for later git versions so we're doing it manually here - if err := self.cmd.New(setUrlCmdStr).Run(); err != nil { + if err := self.runInParentModule(submodule, self.cmd.New(setUrlCmdStr)); err != nil { return err } syncCmdStr := NewGitCmd("submodule").Arg("sync", "--", submodule.Path). ToArgv() - if err := self.cmd.New(syncCmdStr).Run(); err != nil { + if err := self.runInParentModule(submodule, self.cmd.New(syncCmdStr)); err != nil { return err } From 096a710761ad2210466423ad869306afa741f135 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 12:08:42 +0200 Subject: [PATCH 354/384] Refresh pull requests through the regular refresh after picking a base remote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user picks a base remote in the "select remote repository" prompt, we called setGithubPullRequests directly, bypassing the refresh machinery — which meant hand-rolling the refresh env that call needs (with a comment explaining why), and fetching against the branches captured when the prompt was created. Issue a PULL_REQUESTS-scoped refresh instead: it re-reads branches and remotes (both fast even in large repos), fetches against those fresh values, and gets the refresh machinery's guarantees without any special-casing. The config write is re-read by the refresh from git config, so it is guaranteed to be picked up. The waiting status now covers the config write and the branches/remotes reload, while the GitHub request itself continues as a background task — which is how every other pull-request fetch behaves. Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index a7c18aeb8..497eca28a 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1536,7 +1536,7 @@ func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, clearPullRequests() if !self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] { - self.promptForBaseGithubRepo(githubRemotes, branches) + self.promptForBaseGithubRepo(githubRemotes) } return } @@ -1612,7 +1612,7 @@ func getGithubBaseRemote(githubRemotes []githubRemoteInfo, configuredRemoteName return nil } -func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteInfo, branches []*models.Branch) { +func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteInfo) { menuItems := lo.Map(githubRemotes, func(info githubRemoteInfo, _ int) *types.MenuItem { return &types.MenuItem{ LabelColumns: []string{info.remote.Name, style.FgCyan.Sprint(info.serviceInfo.RepoName)}, @@ -1622,11 +1622,7 @@ func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteI self.c.Log.Error(err) } - // This fetch runs on its own worker after the user picked a - // base remote, so it's not part of a performRefresh and has no - // ambient env; build a foreground one now, capturing the - // current generation as the guard baseline. - self.setGithubPullRequests(&info, branches, refreshEnv{generation: self.c.State().GetRepoGeneration()}) + self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.PULL_REQUESTS}}) return nil }) }, From 527124d0e0e41c41edb7829a243f64399f82e807 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Jul 2026 22:14:08 +0200 Subject: [PATCH 355/384] Pin git commands to the repo they were created for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lazygit changes the process working directory when switching repos, but work that is still in flight for the previous repo can keep spawning git commands after the switch — most notably a background refresh. Its model writes are already dropped by the repo generation guard, but its git commands would now run against the new repo. That is wasted work at best; at worst it surfaces spurious error popups (the behind-base- branch computation failing with "no such ref" when the old repo's main branch doesn't exist in the new one) and pollutes caches belonging to the old repo's reusable state (e.g. MainBranches' existing-branches cache), which the user sees when switching back. Give the git command builder the directory of the repo it was created for, and pin every command it produces to that directory. The pinned directory and the process cwd are identical until a switch happens (NewGitCommand chdirs to the worktree path right before creating the builder), so nothing changes in the steady state; the pin only takes effect for commands built through a previous repo's GitCommand instance after a switch, which now keep addressing the repo they were built for. Co-Authored-By: Claude Fable 5 --- pkg/commands/git.go | 2 +- pkg/commands/git_cmd_obj_builder.go | 16 +++++++++++++--- pkg/commands/git_cmd_obj_builder_test.go | 17 +++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/pkg/commands/git.go b/pkg/commands/git.go index d9add2790..7ba2dba66 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -90,7 +90,7 @@ func NewGitCommandAux( repoPaths *git_commands.RepoPaths, pagerConfig *config.PagerConfig, ) *GitCommand { - cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd) + cmd := NewGitCmdObjBuilder(cmn.Log, osCommand.Cmd, repoPaths.WorktreePath()) // here we're doing a bunch of dependency injection for each of our commands structs. // This is admittedly messy, but allows us to test each command struct in isolation, diff --git a/pkg/commands/git_cmd_obj_builder.go b/pkg/commands/git_cmd_obj_builder.go index 20d31c11c..9c3cd50d4 100644 --- a/pkg/commands/git_cmd_obj_builder.go +++ b/pkg/commands/git_cmd_obj_builder.go @@ -11,6 +11,15 @@ import ( type gitCmdObjBuilder struct { innerBuilder *oscommands.CmdObjBuilder + + // The directory of the repo (or worktree) this builder was created for; + // every command we produce runs there, regardless of the process's current + // working directory. The two are the same until the user switches to + // another repo: lazygit chdirs on a switch, but work still in flight for + // the previous repo (e.g. a background refresh spawning commands through + // the old builder) must keep running its commands against the repo it + // started in, not whichever one the process has since moved to. + repoDir string } var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{} @@ -21,7 +30,7 @@ var _ oscommands.ICmdObjBuilder = &gitCmdObjBuilder{} // only the foreground files refresh) opt back in via CmdObj.RemoveEnvVar. var defaultEnvVar = git_commands.OptionalLocksEnvVar + "=0" -func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder) *gitCmdObjBuilder { +func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuilder, repoDir string) *gitCmdObjBuilder { // the price of having a convenient interface where we can say .New(...).Run() is that our builder now depends on our runner, so when we want to wrap the default builder/runner in new functionality we need to jump through some hoops. We could avoid the use of a decorator function here by just exporting the runner field on the default builder but that would be misleading because we don't want anybody using that to run commands (i.e. we want there to be a single API used across the codebase) updatedBuilder := innerBuilder.CloneWithNewRunner(func(runner oscommands.ICmdObjRunner) oscommands.ICmdObjRunner { return &gitCmdObjRunner{ @@ -33,15 +42,16 @@ func NewGitCmdObjBuilder(log *logrus.Entry, innerBuilder *oscommands.CmdObjBuild return &gitCmdObjBuilder{ innerBuilder: updatedBuilder, + repoDir: repoDir, } } func (self *gitCmdObjBuilder) New(args []string) *oscommands.CmdObj { - return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar) + return self.innerBuilder.New(args).AddEnvVars(defaultEnvVar).SetWd(self.repoDir) } func (self *gitCmdObjBuilder) NewShell(cmdStr string, shellFunctionsFile string) *oscommands.CmdObj { - return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar) + return self.innerBuilder.NewShell(cmdStr, shellFunctionsFile).AddEnvVars(defaultEnvVar).SetWd(self.repoDir) } func (self *gitCmdObjBuilder) Quote(str string) string { diff --git a/pkg/commands/git_cmd_obj_builder_test.go b/pkg/commands/git_cmd_obj_builder_test.go index ca7e54cfe..28e21501c 100644 --- a/pkg/commands/git_cmd_obj_builder_test.go +++ b/pkg/commands/git_cmd_obj_builder_test.go @@ -17,8 +17,25 @@ func TestGitCmdObjBuilderDisablesOptionalLocksByDefault(t *testing.T) { builder := NewGitCmdObjBuilder( utils.NewDummyLog(), oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), + "/path/to/repo", ) assert.Contains(t, builder.New([]string{"git", "status"}).GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0") assert.Contains(t, builder.NewShell("git status", "").GetEnvVars(), git_commands.OptionalLocksEnvVar+"=0") } + +// Every command the builder produces runs in the directory of the repo the +// builder was created for, not in the process's current directory: lazygit +// chdirs when switching repos, and commands built for the previous repo after +// that (e.g. by a background refresh still in flight) must keep addressing the +// repo they were built for. +func TestGitCmdObjBuilderPinsCommandsToRepoDir(t *testing.T) { + builder := NewGitCmdObjBuilder( + utils.NewDummyLog(), + oscommands.NewDummyCmdObjBuilder(oscommands.NewFakeRunner(t)), + "/path/to/repo", + ) + + assert.Equal(t, "/path/to/repo", builder.New([]string{"git", "status"}).GetCmd().Dir) + assert.Equal(t, "/path/to/repo", builder.NewShell("git status", "").GetCmd().Dir) +} From 7c0fa9fe33d8a065c7a33d6f5cfe2dda0a51bd72 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Jul 2026 22:31:48 +0200 Subject: [PATCH 356/384] Run a refresh's git commands through the instance captured at its start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A background refresh's model writes are dropped by the generation guard when the repo is switched mid-flight, but its git commands kept running — and because the refresh read the live git instance at each step, any command issued after the switch ran against the new repo. Now that git commands are pinned to the directory of the instance they were built from, capture the instance once when the refresh starts and run every scope's git work through it, so a switch-crossing refresh keeps addressing the repo it was started for. The instance is captured together with the repo generation, on the UI thread (where repo switches run), so the pair can't straddle a switch: an old instance paired with the new generation would compute data from the old repo and write it into the new repo's model unguarded. This also removes the refresh workers' unsynchronized reads of the live instance pointer, which raced its reassignment on the UI thread when a background refresh crossed a repo switch (foreground refreshes can't cross one: they keep Busy() true, which refuses the switch). Two reads keyed app-state by the live instance's repo path on a worker and now use the captured instance, fixing which repo they file under when crossing a switch: the pull-request cache, and the "user dismissed the base-remote prompt" flag. The base-remote menu's handlers keep reading the live instance: a switch dismisses any open popup, so they can't run against the wrong repo (and the OnPress body runs under a foreground task, which blocks switching anyway). Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 117 +++++++++++------- 1 file changed, 70 insertions(+), 47 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 497eca28a..caf14ed2a 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -7,6 +7,7 @@ import ( "time" "github.com/jesseduffield/generics/set" + "github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/hosting_service" "github.com/jesseduffield/lazygit/pkg/commands/models" @@ -97,6 +98,13 @@ type refreshEnv struct { // the repo generation captured when the refresh started generation int + // the git command instance captured when the refresh started. The refresh + // workers run their git commands through this rather than reading the live + // instance: a repo switch mid-refresh replaces the live instance (and the + // process cwd), while this one keeps addressing the repo the refresh was + // started for (its commands are pinned to that repo's directory). + git *commands.GitCommand + // When non-nil, each scope's UI-thread bounce is collected here instead of // being dispatched as it's produced, so they can all be applied in a single // frame once the whole refresh is done (see RefreshOptions.BatchUIUpdates). @@ -167,12 +175,23 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread") } - // Capture the repo generation once, here at the start, so every scope's - // bounce is guarded against the same baseline. + // Capture the refresh's baseline once, here at the start: the repo + // generation that every scope's bounce is guarded against, and the git + // command instance the scopes run their commands through. The two are + // captured together on the UI thread so that they can't straddle a repo + // switch (which runs on the UI thread): pairing the old repo's instance + // with the new repo's generation would let a refresh compute data from + // the old repo and write it into the new repo's model unguarded. With a + // consistent pair, a switch-crossing refresh keeps running its commands + // against the repo it started in, and the generation guard drops its + // writes. env := refreshEnv{ background: options.Background, - generation: self.c.State().GetRepoGeneration(), } + self.captureOnUIThread(calledFromWorker, options.Background, func() { + env.generation = self.c.State().GetRepoGeneration() + env.git = self.c.Git() + }) if options.BatchUIUpdates { env.batch = &refreshBounceBatch{} } @@ -226,7 +245,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // of git's state changing externally while (or right after) we are // refreshing; the risk is one potential extra refresh, but capturing the // snapshot at the end would risk missing one, which is worse. - self.updateRefsSnapshotIfRelevant(scopeSet) + self.updateRefsSnapshotIfRelevant(scopeSet, env) wg := sync.WaitGroup{} refresh := func(name string, f func()) { @@ -515,12 +534,12 @@ func (self *RefreshHelper) RefsSnapshotChangedSince(snapshot string) bool { // We check just COMMITS and BRANCHES because the scope-expansion step at the // top of Refresh has already added these whenever REFLOG or BISECT_INFO are // in scope, and whenever a nil scope was passed. -func (self *RefreshHelper) updateRefsSnapshotIfRelevant(scopeSet *set.Set[types.RefreshableView]) { +func (self *RefreshHelper) updateRefsSnapshotIfRelevant(scopeSet *set.Set[types.RefreshableView], env refreshEnv) { if !scopeSet.Includes(types.COMMITS) && !scopeSet.Includes(types.BRANCHES) { return } - snapshot, err := self.c.Git().Status.RefsSnapshot() + snapshot, err := env.git.Status.RefsSnapshot() if err != nil { self.c.Log.Warnf("RefsSnapshot failed during refresh: %v", err) return @@ -704,15 +723,15 @@ func (self *RefreshHelper) refreshCommitsAndCommitFiles(captured capturedCommitS } } -func (self *RefreshHelper) determineCheckedOutRef() models.Ref { - if rebasedBranch := self.c.Git().Status.BranchBeingRebased(); rebasedBranch != "" { +func (self *RefreshHelper) determineCheckedOutRef(env refreshEnv) models.Ref { + if rebasedBranch := env.git.Status.BranchBeingRebased(); rebasedBranch != "" { // During a rebase we're on a detached head, so cannot determine the // branch name in the usual way. We need to read it from the // ".git/rebase-merge/head-name" file instead. return &models.Branch{Name: strings.TrimPrefix(rebasedBranch, "refs/heads/")} } - if bisectInfo := self.c.Git().Bisect.GetInfo(); bisectInfo.Bisecting() && bisectInfo.GetStartHash() != "" { + if bisectInfo := env.git.Bisect.GetInfo(); bisectInfo.Bisecting() && bisectInfo.GetStartHash() != "" { // Likewise, when we're bisecting we're on a detached head as well. In // this case we read the branch name from the ".git/BISECT_START" file. return &models.Branch{Name: bisectInfo.GetStartHash()} @@ -722,7 +741,7 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref { // checked out. Note that if we're on a detached head (for reasons other // than rebasing or bisecting, i.e. it was explicitly checked out), then // this will return an empty string. - if branchName, err := self.c.Git().Branch.CurrentBranchName(); err == nil && branchName != "" { + if branchName, err := env.git.Branch.CurrentBranchName(); err == nil && branchName != "" { return &models.Branch{Name: branchName} } @@ -731,9 +750,9 @@ func (self *RefreshHelper) determineCheckedOutRef() models.Ref { } func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, commitSelection types.CommitSelectionBehavior, env refreshEnv) error { - checkedOutRef := self.determineCheckedOutRef() - refName, bisectInfo := self.refForLog() - commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( + checkedOutRef := self.determineCheckedOutRef(env) + refName, bisectInfo := self.refForLog(env) + commits, err := env.git.Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: captured.limitCommits, FilterPath: captured.filterPath, @@ -749,7 +768,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, if err != nil { return err } - workingTreeState := self.c.Git().Status.WorkingTreeState() + workingTreeState := env.git.Status.WorkingTreeState() self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().BisectInfo = bisectInfo @@ -902,7 +921,7 @@ func (self *RefreshHelper) refreshSubCommitsWithLimit(captured capturedSubCommit return nil } - commits, err := self.c.Git().Loaders.CommitLoader.GetCommits( + commits, err := env.git.Loaders.CommitLoader.GetCommits( git_commands.GetCommitsOptions{ Limit: captured.limitCommits, FilterPath: captured.filterPath, @@ -956,7 +975,7 @@ func (self *RefreshHelper) captureCommitFilesState() capturedCommitFilesState { } func (self *RefreshHelper) refreshCommitFilesContext(captured capturedCommitFilesState, env refreshEnv) error { - files, err := self.c.Git().Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse) + files, err := env.git.Loaders.CommitFileLoader.GetFilesInDiff(captured.from, captured.to, captured.reverse) if err != nil { return err } @@ -975,11 +994,11 @@ func (self *RefreshHelper) captureRebaseCommitState() (hashPool *utils.StringPoo } func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, commits []*models.Commit, env refreshEnv) error { - updatedCommits, err := self.c.Git().Loaders.CommitLoader.MergeRebasingCommits(hashPool, commits) + updatedCommits, err := env.git.Loaders.CommitLoader.MergeRebasingCommits(hashPool, commits) if err != nil { return err } - workingTreeState := self.c.Git().Status.WorkingTreeState() + workingTreeState := env.git.Status.WorkingTreeState() self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Commits = updatedCommits @@ -991,7 +1010,7 @@ func (self *RefreshHelper) refreshRebaseCommits(hashPool *utils.StringPool, comm } func (self *RefreshHelper) refreshTags(env refreshEnv) error { - tags, err := self.c.Git().Loaders.TagLoader.GetTags() + tags, err := env.git.Loaders.TagLoader.GetTags() if err != nil { return err } @@ -1004,8 +1023,8 @@ func (self *RefreshHelper) refreshTags(env refreshEnv) error { return nil } -func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleConfig, error) { - return self.c.Git().Submodule.GetConfigs(nil) +func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*models.SubmoduleConfig, error) { + return env.git.Submodule.GetConfigs(nil) } // self.refreshStatus is called at the end of this because that's when we can @@ -1013,7 +1032,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs() ([]*models.SubmoduleCo func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { loadSeq := self.branchLoadSeq.Add(1) - branches, err := self.c.Git().Loaders.BranchLoader.Load( + branches, err := env.git.Loaders.BranchLoader.Load( reflogCommits, captured.mainBranches, captured.oldBranches, @@ -1035,7 +1054,7 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh var worktrees []*models.Worktree if refreshWorktrees { - worktrees = self.loadWorktrees() + worktrees = self.loadWorktrees(env) } self.onUIThreadUnlessRepoChanged(env, func() { @@ -1100,7 +1119,7 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh } func (self *RefreshHelper) refreshFilesAndSubmodules(captured capturedFilesState, env refreshEnv) error { - configs, err := self.refreshStateSubmoduleConfigs() + configs, err := self.refreshStateSubmoduleConfigs(env) if err != nil { return err } @@ -1237,13 +1256,13 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re if len(pathsToStage) > 0 { self.c.LogAction(self.c.Tr.Actions.StageResolvedFiles) - if err := self.c.Git().WorkingTree.StageFiles(pathsToStage, nil); err != nil { + if err := env.git.WorkingTree.StageFiles(pathsToStage, nil); err != nil { return err } } } - files := self.c.Git().Loaders.FileLoader. + files := env.git.Loaders.FileLoader. GetStatusFiles(git_commands.GetStatusFileOptions{ ForceShowUntracked: captured.forceShowUntracked, Background: env.background, @@ -1257,7 +1276,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re } repoState := self.c.State().GetRepoState() - workingTreeState := self.c.Git().Status.WorkingTreeState() + workingTreeState := env.git.Status.WorkingTreeState() if workingTreeState.None() { // No operation is in progress (any more), so forget that we started one. // This also covers an operation that was finished or aborted externally. @@ -1340,7 +1359,7 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en lastReflogCommit = existing[0] } - commits, onlyObtainedNewReflogCommits, err := self.c.Git().Loaders.ReflogCommitLoader. + commits, onlyObtainedNewReflogCommits, err := env.git.Loaders.ReflogCommitLoader. GetReflogCommits(captured.hashPool, lastReflogCommit, filterPath, filterAuthor) if err != nil { return nil, err @@ -1382,7 +1401,7 @@ func (self *RefreshHelper) refreshReflogCommits(captured capturedReflogState, en } func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env refreshEnv) ([]*models.Remote, error) { - remotes, err := self.c.Git().Loaders.RemoteLoader.GetRemotes() + remotes, err := env.git.Loaders.RemoteLoader.GetRemotes() if err != nil { return nil, err } @@ -1414,8 +1433,8 @@ func (self *RefreshHelper) refreshRemotes(prevSelectedRemote *models.Remote, env return remotes, nil } -func (self *RefreshHelper) loadWorktrees() []*models.Worktree { - worktrees, err := self.c.Git().Loaders.Worktrees.GetWorktrees() +func (self *RefreshHelper) loadWorktrees(env refreshEnv) []*models.Worktree { + worktrees, err := env.git.Loaders.Worktrees.GetWorktrees() if err != nil { self.c.Log.Error(err) return []*models.Worktree{} @@ -1424,7 +1443,7 @@ func (self *RefreshHelper) loadWorktrees() []*models.Worktree { } func (self *RefreshHelper) refreshWorktrees(env refreshEnv) { - worktrees := self.loadWorktrees() + worktrees := self.loadWorktrees(env) self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Worktrees = worktrees @@ -1437,7 +1456,7 @@ func (self *RefreshHelper) refreshWorktrees(env refreshEnv) { } func (self *RefreshHelper) refreshStashEntries(filterPath string, env refreshEnv) { - stashEntries := self.c.Git().Loaders.StashLoader. + stashEntries := env.git.Loaders.StashLoader. GetStashEntries(filterPath) self.onUIThreadUnlessRepoChanged(env, func() { @@ -1449,8 +1468,8 @@ func (self *RefreshHelper) refreshStashEntries(filterPath string, env refreshEnv // never call this on its own, it should only be called from within refreshCommits() func (self *RefreshHelper) refreshStatus(env refreshEnv) { - workingTreeState := self.c.Git().Status.WorkingTreeState() - repoName := self.c.Git().RepoPaths.RepoName() + workingTreeState := env.git.Status.WorkingTreeState() + repoName := env.git.RepoPaths.RepoName() self.onUIThreadUnlessRepoChanged(env, func() { // Read the checked-out branch and the linked worktree name here on the UI @@ -1473,15 +1492,15 @@ func (self *RefreshHelper) refreshStatus(env refreshEnv) { // read to decide that. The caller writes the bisect info to the model (in its // bounce) rather than refForLog doing it, so the model write stays on the UI // thread. -func (self *RefreshHelper) refForLog() (string, *git_commands.BisectInfo) { - bisectInfo := self.c.Git().Bisect.GetInfo() +func (self *RefreshHelper) refForLog(env refreshEnv) (string, *git_commands.BisectInfo) { + bisectInfo := env.git.Bisect.GetInfo() if !bisectInfo.Started() { return "HEAD", bisectInfo } // need to see if our bisect's current commit is reachable from our 'new' ref. - if bisectInfo.Bisecting() && !self.c.Git().Bisect.ReachableFromStart(bisectInfo) { + if bisectInfo.Bisecting() && !env.git.Bisect.ReachableFromStart(bisectInfo) { return bisectInfo.GetNewHash(), bisectInfo } @@ -1525,17 +1544,17 @@ func (self *RefreshHelper) refreshGithubPullRequests(branches []*models.Branch, }) } - githubRemotes := getAuthenticatedGithubRemotes(self.getGithubRemotes(remotes), self.c.Git().GitHub.GetAuthToken) + githubRemotes := getAuthenticatedGithubRemotes(self.getGithubRemotes(remotes, env), env.git.GitHub.GetAuthToken) if len(githubRemotes) == 0 { clearPullRequests() return } - baseInfo := getGithubBaseRemote(githubRemotes, self.c.Git().GitHub.ConfiguredBaseRemoteName()) + baseInfo := getGithubBaseRemote(githubRemotes, env.git.GitHub.ConfiguredBaseRemoteName()) if baseInfo == nil { clearPullRequests() - if !self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] { + if !self.githubBaseRemotePromptDismissed[env.git.RepoPaths.RepoPath()] { self.promptForBaseGithubRepo(githubRemotes) } return @@ -1550,12 +1569,12 @@ type githubRemoteInfo struct { authToken string } -func (self *RefreshHelper) getGithubRemotes(remotes []*models.Remote) []githubRemoteInfo { +func (self *RefreshHelper) getGithubRemotes(remotes []*models.Remote, env refreshEnv) []githubRemoteInfo { return lo.FilterMap(remotes, func(remote *models.Remote, _ int) (githubRemoteInfo, bool) { if len(remote.Urls) == 0 { return githubRemoteInfo{}, false } - serviceInfo, err := self.c.Git().HostingService.GetServiceInfo(remote.Urls[0]) + serviceInfo, err := env.git.HostingService.GetServiceInfo(remote.Urls[0]) if err != nil || serviceInfo.Provider != "github" { return githubRemoteInfo{}, false } @@ -1662,13 +1681,13 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra return branch.UpstreamBranch }) - prs, err := self.c.Git().GitHub.FetchRecentPRs(branchNames, &baseInfo.serviceInfo, baseInfo.authToken) + prs, err := env.git.GitHub.FetchRecentPRs(branchNames, &baseInfo.serviceInfo, baseInfo.authToken) if err != nil { self.c.Log.Error("error fetching pull requests from GitHub: " + err.Error()) return } - self.savePullRequestsToCache(prs) + self.savePullRequestsToCache(prs, env) self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().PullRequests = prs @@ -1680,8 +1699,12 @@ func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo, bra }) } -func (self *RefreshHelper) savePullRequestsToCache(prs []*models.GithubPullRequest) { - repoPath := self.c.Git().RepoPaths.RepoPath() +func (self *RefreshHelper) savePullRequestsToCache(prs []*models.GithubPullRequest, env refreshEnv) { + // Key the cache by the repo the refresh was started for, not the live one: + // this runs on a worker, and if the user switched repos while the fetch was + // in flight, the live instance would file the old repo's pull requests + // under the new repo's path. + repoPath := env.git.RepoPaths.RepoPath() cached := lo.Map(prs, func(pr *models.GithubPullRequest, _ int) config.CachedPullRequest { return config.CachedPullRequest{ HeadRefName: pr.HeadRefName, From 8e045653bef18ef356d989b17e3eac39b42e463e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Jul 2026 22:34:08 +0200 Subject: [PATCH 357/384] Don't pop up errors from a refresh worker once the repo was switched MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An error returned from a gocui worker is shown to the user in an error popup. For the branch loader's behind-counts worker that used to be the "no such ref" popup when a background refresh crossed a repo switch: the old repo's main branch didn't exist in the new repo. The previous commits fix that scenario properly — the command now runs against the repo the refresh was started for — but a stale worker can still fail legitimately, most plausibly because that repo was deleted after switching away from it (e.g. removing a worktree). Its results are dropped anyway, so log the error instead of alarming the user about a repo they already left. Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index caf14ed2a..7ae7b4119 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1039,7 +1039,18 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh loadBehindCounts, func(f func() error) { self.onWorker(env.background, func(_ gocui.Task) error { - return f() + err := f() + if err != nil && self.c.State().GetRepoGeneration() != env.generation { + // An error returned from a worker is shown in a popup. Don't + // do that if the repo was switched while this worker was in + // flight: its results are dropped anyway, and the error + // concerns a repo the user has already left — e.g. failing to + // compute the behind-counts for a worktree that was deleted + // after switching away from it. + self.c.Log.Warnf("dropping error from a stale refresh worker after a repo switch: %v", err) + return nil + } + return err }) }, func() { From ae095f276b0f46041f76ce1d68d29adb6f5ff30e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sun, 19 Jul 2026 22:43:34 +0200 Subject: [PATCH 358/384] Don't refuse a repo switch during a pure refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refreshes on focus-in, right after a repo switch, and after returning from a subprocess are full foreground refreshes, so their tasks kept Busy() true for as long as the slowest scope took — and any switch attempt in that window was refused with the "can't switch" toast. The focus-in one is particularly annoying: focusing lazygit is often precisely what the user does in order to switch repos, and right after regaining focus is when a refresh takes longest. Blocking the switch bought nothing there. The refusal exists for user operations, whose follow-up work (e.g. a Then callback reading the model) isn't covered by the switch-safety guards; but these refreshes merely reload state, and a refresh by itself is now switch-safe: its git commands run against the repo it was started for, and the generation guard drops its updates when the repo changed. We can't just mark them Background, because that flag also decides whether the files refresh lets git take optional locks to persist its refreshed stat cache — worth doing for an attended refresh, and the focus-in refresh (typically running right after external changes) is the case that profits most. So split the two meanings: a new DontBlockRepoSwitch option dispatches the refresh's tasks as background tasks (excluded from Busy()) while keeping the attended optional-locks behavior. Combining it with Then panics, since Then is not generation-guarded. Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/helpers/refresh_helper.go | 27 +++++++++++++++---- pkg/gui/gui.go | 6 ++--- pkg/gui/types/refresh.go | 15 +++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 7ae7b4119..27f465f69 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -91,10 +91,19 @@ func (self *RefreshHelper) RefreshFromWorker(options types.RefreshOptions) { } type refreshEnv struct { - // whether this is a background refresh (which selects the dispatch variant that - // doesn't count towards lazygit being busy) + // Whether everything this refresh dispatches uses the background task + // variants, which don't count towards lazygit being busy — so the refresh + // doesn't block switching repos. Set for refreshes initiated by a + // background routine, and for foreground ones that opted in via + // RefreshOptions.DontBlockRepoSwitch. background bool + // Whether the refresh was initiated by an unattended background routine + // (RefreshOptions.Background) rather than by user activity. The files + // refresh uses this to decide whether git may take optional locks and + // persist its refreshed stat cache. + backgroundRoutine bool + // the repo generation captured when the refresh started generation int @@ -175,6 +184,13 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr panic("Refresh called from a worker, or RefreshFromWorker called from the UI thread") } + if options.Then != nil && options.DontBlockRepoSwitch { + // Then is not generation-guarded, so if a switch crossed the refresh it + // would run against the newly switched-to repo. A refresh carrying a + // Then must keep blocking switches. + panic("a refresh with a Then callback must not set DontBlockRepoSwitch") + } + // Capture the refresh's baseline once, here at the start: the repo // generation that every scope's bounce is guarded against, and the git // command instance the scopes run their commands through. The two are @@ -186,9 +202,10 @@ 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, + background: options.Background || options.DontBlockRepoSwitch, + backgroundRoutine: options.Background, } - self.captureOnUIThread(calledFromWorker, options.Background, func() { + self.captureOnUIThread(calledFromWorker, env.background, func() { env.generation = self.c.State().GetRepoGeneration() env.git = self.c.Git() }) @@ -1276,7 +1293,7 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re files := env.git.Loaders.FileLoader. GetStatusFiles(git_commands.GetStatusFileOptions{ ForceShowUntracked: captured.forceShowUntracked, - Background: env.background, + Background: env.backgroundRoutine, }) conflictFileCount := 0 diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 533c01fbd..8776040e7 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -390,7 +390,7 @@ func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, contextKey types.Context } gui.c.Log.Info("Receiving focus - refreshing") - gui.helpers.Refresh.Refresh(types.RefreshOptions{}) + gui.helpers.Refresh.Refresh(types.RefreshOptions{DontBlockRepoSwitch: true}) return reloadErr } @@ -1031,7 +1031,7 @@ func (gui *Gui) runSubprocessWithSuspenseAndRefresh(subprocess *oscommands.CmdOb return err } - gui.c.Refresh(types.RefreshOptions{}) + gui.c.Refresh(types.RefreshOptions{DontBlockRepoSwitch: true}) return nil } @@ -1108,7 +1108,7 @@ func (gui *Gui) loadNewRepo() error { return err } - gui.c.Refresh(types.RefreshOptions{}) + gui.c.Refresh(types.RefreshOptions{DontBlockRepoSwitch: true}) if err := gui.os.UpdateWindowTitle(); err != nil { return err diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index 937c3a30e..c733e589e 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -94,4 +94,19 @@ type RefreshOptions struct { // fast. Background refreshes leave the suppression in place: not persisting // the stat-cache is the right trade-off for unattended work. Background bool + + // When true, this foreground refresh does not block switching repos while + // it is in flight. A refresh is switch-safe by construction — its git + // commands run against the repo it was started for, and the generation + // guard drops its model/view updates if the repo changed — but a refresh + // triggered by a user operation still blocks switching (its tasks count + // towards Busy()), because the operation's follow-up work isn't covered + // by those guards. A refresh that merely reloads state (on focus, after a + // repo switch, after returning from a subprocess) has no such follow-up, + // so it opts in here and a repo switch during it is allowed rather than + // refused with a toast. + // + // Must not be combined with Then: Then is not generation-guarded, so it + // would run against the newly switched-to repo. + DontBlockRepoSwitch bool } From e0b8dbf48c0dfd1039f2df743952f4032b5fc21b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:24:03 +0200 Subject: [PATCH 359/384] Resolve the refresh's file reads against its repo root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh workers read a few files at paths relative to the process working directory: the submodule config read of .gitmodules, the files refresh's check for conflict markers, and the submodule stash's existence check. Git commands are pinned to the repo their instance was created for, but these Go file reads still followed the cwd, so a background refresh crossing a repo switch would read the new repo's files while computing data for the old one. Join them with the worktree root of the instance they belong to. (Most git-state file reads — working tree state, rebase todos, bisect info — already resolve against RepoPaths and need no change.) This also fixes the submodule stash's existence check for nested submodules: it stat'ed submodule.Path, which is relative to the parent module, against the repo root — now it uses the submodule's full path, matching the stash command right below it. Co-Authored-By: Claude Fable 5 --- pkg/commands/git_commands/submodule.go | 11 ++++++++--- pkg/gui/controllers/helpers/refresh_helper.go | 7 ++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/pkg/commands/git_commands/submodule.go b/pkg/commands/git_commands/submodule.go index 3eb081701..d8c1208bc 100644 --- a/pkg/commands/git_commands/submodule.go +++ b/pkg/commands/git_commands/submodule.go @@ -28,10 +28,15 @@ func NewSubmoduleCommands(gitCommon *GitCommon) *SubmoduleCommands { } func (self *SubmoduleCommands) GetConfigs(parentModule *models.SubmoduleConfig) ([]*models.SubmoduleConfig, error) { - gitModulesPath := ".gitmodules" + // Resolve the path against the repo this commands object was created for + // rather than the process working directory, so that a read from a + // still-running refresh keeps addressing that repo after the user + // switched to another one. + dir := self.repoPaths.WorktreePath() if parentModule != nil { - gitModulesPath = filepath.Join(parentModule.FullPath(), gitModulesPath) + dir = filepath.Join(dir, parentModule.FullPath()) } + gitModulesPath := filepath.Join(dir, ".gitmodules") file, err := os.Open(gitModulesPath) if err != nil { if os.IsNotExist(err) { @@ -180,7 +185,7 @@ func (self *SubmoduleCommands) ConflictSideLog(path string, side string, otherSi func (self *SubmoduleCommands) Stash(submodule *models.SubmoduleConfig) error { // if the path does not exist then it hasn't yet been initialized so we'll swallow the error // because the intention here is to have no dirty worktree state - if _, err := os.Stat(submodule.Path); os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(self.repoPaths.WorktreePath(), submodule.FullPath())); os.IsNotExist(err) { self.Log.Infof("submodule path %s does not exist, returning", submodule.FullPath()) return nil } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 27f465f69..8ae62d9dd 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1,6 +1,7 @@ package helpers import ( + "path/filepath" "strings" "sync" "sync/atomic" @@ -1273,7 +1274,11 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re prevConflictFileCount++ } if file.HasInlineMergeConflicts { - hasConflicts, err := mergeconflicts.FileHasConflictMarkers(file.Path) + // Join with the refresh's repo root rather than relying on the + // 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)) if err != nil { self.c.Log.Error(err) } else if !hasConflicts { From 568a4276d7580b7285ef9bd21555eebcf160a0f2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:34:07 +0200 Subject: [PATCH 360/384] Pin the cached git config's commands to the repo directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cached git config runs its `git config` reads through raw exec.Command calls, outside the pinned git command builder, so they followed the process working directory. A cache miss on a stale instance — one still in use by a refresh that crossed a repo switch — would therefore read the new repo's local config while computing data for the old one. Give the cache a directory, set once by NewGitCommand right after it determines the repo paths (the object is created fresh for every repo switch, so no cross-repo cache invalidation is needed), and run every config command there. Co-Authored-By: Claude Fable 5 --- pkg/commands/git.go | 4 ++++ pkg/commands/git_config/cached_git_config.go | 17 +++++++++++++++++ .../git_config/cached_git_config_test.go | 17 +++++++++++++++++ pkg/commands/git_config/fake_git_config.go | 3 +++ 4 files changed, 41 insertions(+) diff --git a/pkg/commands/git.go b/pkg/commands/git.go index 7ba2dba66..ba6e5a033 100644 --- a/pkg/commands/git.go +++ b/pkg/commands/git.go @@ -72,6 +72,10 @@ func NewGitCommand( return nil, utils.WrapError(err) } + // Pin the config reads to the repo directory like all other git commands + // (see NewGitCmdObjBuilder); the config commands run outside that builder. + gitConfig.SetDir(repoPaths.WorktreePath()) + return NewGitCommandAux( cmn, version, diff --git a/pkg/commands/git_config/cached_git_config.go b/pkg/commands/git_config/cached_git_config.go index 256cd325b..17152ef9e 100644 --- a/pkg/commands/git_config/cached_git_config.go +++ b/pkg/commands/git_config/cached_git_config.go @@ -16,11 +16,19 @@ type IGitConfig interface { // this is for when you want to pass 'mykey' and check if the result is truthy GetBool(string) bool + // SetDir pins the config commands to the given repo directory, so that + // they keep reading that repo's local config even if the process working + // directory changes later (i.e. the user switches repos while this + // instance is still in use by in-flight work). Called once, before the + // first read. + SetDir(string) + DropCache() } type CachedGitConfig struct { cache map[string]string + dir string runGitConfigCmd func(*exec.Cmd) (string, error) log *logrus.Entry mutex sync.Mutex @@ -39,6 +47,13 @@ func NewCachedGitConfig(runGitConfigCmd func(*exec.Cmd) (string, error), log *lo } } +func (self *CachedGitConfig) SetDir(dir string) { + self.mutex.Lock() + defer self.mutex.Unlock() + + self.dir = dir +} + func (self *CachedGitConfig) Get(key string) string { self.mutex.Lock() defer self.mutex.Unlock() @@ -69,6 +84,7 @@ func (self *CachedGitConfig) GetGeneral(args string) string { func (self *CachedGitConfig) getGeneralAux(args string) string { cmd := getGitConfigGeneralCmd(args) + cmd.Dir = self.dir value, err := self.runGitConfigCmd(cmd) if err != nil { self.log.Debugf("Error getting git config value for args: %s. Error: %v", args, err.Error()) @@ -79,6 +95,7 @@ func (self *CachedGitConfig) getGeneralAux(args string) string { func (self *CachedGitConfig) getAux(key string) string { cmd := getGitConfigCmd(key) + cmd.Dir = self.dir value, err := self.runGitConfigCmd(cmd) if err != nil { self.log.Debugf("Error getting git config value for key: %s. Error: %v", key, err.Error()) diff --git a/pkg/commands/git_config/cached_git_config_test.go b/pkg/commands/git_config/cached_git_config_test.go index fd884df65..7b92eed1e 100644 --- a/pkg/commands/git_config/cached_git_config_test.go +++ b/pkg/commands/git_config/cached_git_config_test.go @@ -116,3 +116,20 @@ func TestGet(t *testing.T) { assert.Equal(t, "blah", result) assert.Equal(t, 1, count) } + +// The config commands run in the directory set by SetDir rather than in the +// process's current directory: lazygit chdirs when switching repos, and config +// reads issued for the previous repo after that must keep addressing the repo +// they were created for. +func TestSetDirPinsCommandsToDirectory(t *testing.T) { + real := NewCachedGitConfig( + func(cmd *exec.Cmd) (string, error) { + assert.Equal(t, "/path/to/repo", cmd.Dir) + return "blah", nil + }, + utils.NewDummyLog(), + ) + real.SetDir("/path/to/repo") + real.Get("commit.gpgsign") + real.GetGeneral("--local --get-regexp foo") +} diff --git a/pkg/commands/git_config/fake_git_config.go b/pkg/commands/git_config/fake_git_config.go index e82efcd1b..442c18644 100644 --- a/pkg/commands/git_config/fake_git_config.go +++ b/pkg/commands/git_config/fake_git_config.go @@ -28,5 +28,8 @@ func (self *FakeGitConfig) GetBool(key string) bool { return isTruthy(self.Get(key)) } +func (self *FakeGitConfig) SetDir(dir string) { +} + func (self *FakeGitConfig) DropCache() { } From efed9d040721faf57bc8d9b6a1e57f31ba2219c7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:39:36 +0200 Subject: [PATCH 361/384] Don't auto-forward branches when the repo was switched during the fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostFetchRefresh's refresh is the only background refresh carrying a Then callback, and Then callbacks are not generation-guarded: when the background fetch's refresh crossed a repo switch, the callback still ran — in the new repo — and auto-forwarded the new repo's branches because the old repo's fetch had completed. That was harmless in practice (the update-ref call compares against the expected old value, and it only does what the next fetch's auto-forward would do anyway), but mutating refs in a repo whose fetch never happened is not an action the user took. Skip the auto-forward when the repo generation changed since the fetch started. The generation is captured by the fetch's callers before the fetch runs, not by PostFetchRefresh itself: the background fetch doesn't block repo switching and is a network call, so by the time PostFetchRefresh runs a switch may already have happened — a capture there (or the one the refresh itself takes) would compare against the new repo's generation and let the auto-forward through. For the manual fetch the capture point makes no difference, since a foreground operation blocks repo switching for its entire duration. This deliberately guards only this call site rather than making Then callbacks generation-guarded in general: a Then is an arbitrary callback, and whether it is safe to skip on a repo switch is a decision for the author of the call site. Co-Authored-By: Claude Fable 5 --- pkg/gui/background.go | 7 ++++++- pkg/gui/controllers/files_controller.go | 3 ++- pkg/gui/controllers/helpers/branches_helper.go | 12 +++++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index 1e2db853f..d39dfd84c 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -232,9 +232,14 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop, retrigge } func (self *BackgroundRoutineMgr) backgroundFetch() (err error) { + // Captured before the fetch, not after: the fetch is a network call during + // which the user may switch repos, and the post-fetch refresh needs to be + // able to tell (see PostFetchRefresh). + fetchGeneration := self.gui.c.State().GetRepoGeneration() + err = self.gui.git.Sync.FetchBackground() - return self.gui.helpers.BranchesHelper.PostFetchRefresh(err, true) + return self.gui.helpers.BranchesHelper.PostFetchRefresh(err, true, fetchGeneration) } func (self *BackgroundRoutineMgr) triggerImmediateFetch() { diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index bf73d4c8b..656da1bf5 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1527,6 +1527,7 @@ func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error } func (self *FilesController) fetch() error { + fetchGeneration := self.c.State().GetRepoGeneration() return self.c.WithWaitingStatus(self.c.Tr.FetchingStatus, func(task gocui.Task) error { self.c.LogAction("Fetch") err := self.c.Git().Sync.Fetch(task) @@ -1535,7 +1536,7 @@ func (self *FilesController) fetch() error { return errors.New(self.c.Tr.PassUnameWrong) } - return self.c.Helpers().BranchesHelper.PostFetchRefresh(err, false) + return self.c.Helpers().BranchesHelper.PostFetchRefresh(err, false, fetchGeneration) }) } diff --git a/pkg/gui/controllers/helpers/branches_helper.go b/pkg/gui/controllers/helpers/branches_helper.go index 83735d87d..e87edb460 100644 --- a/pkg/gui/controllers/helpers/branches_helper.go +++ b/pkg/gui/controllers/helpers/branches_helper.go @@ -392,7 +392,11 @@ func (self *BranchesHelper) deleteRemoteBranches(remoteBranches []*models.Remote return nil } -func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) error { +// fetchGeneration must be the repo generation from when the fetch started, +// captured by the caller before running the fetch: the background fetch +// doesn't block repo switching and is a network call, so the window in which +// the user can switch repos spans the whole fetch, not just this refresh. +func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool, fetchGeneration int) error { scope := []types.RefreshableView{ types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS, } @@ -410,6 +414,12 @@ func (self *BranchesHelper) PostFetchRefresh(fetchErr error, background bool) er if fetchErr != nil { return nil } + // Then callbacks are not generation-guarded, so check explicitly: + // if the repo was switched since the fetch started, don't forward + // this repo's branches on the strength of another repo's fetch. + if self.c.State().GetRepoGeneration() != fetchGeneration { + return nil + } err := self.AutoForwardBranches(background) if background && err != nil { // The background poller discards this return value, so surface From 5fc678dde6ea9dad188f95293de75da3be9779bc Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 15:59:36 +0200 Subject: [PATCH 362/384] Read the gui's per-repo pointers on the UI thread in background routines gui.git, gui.helpers and gui.State are all replaced on a repo switch, which runs on the UI thread. The background fetch and the external- change poller read them from their own goroutines, racing the reassignment. This race can't show up in the integration suite, which doesn't enable the background routines, so no -race run will ever flag it; it can only bite real users who switch repos while a background fetch or poll is in flight. Capture the objects a routine iteration needs in a single blocking UI-thread hop before using them, the same pattern the refresh's input capture uses. For the fetch this has two welcome side effects: the fetch, the post-fetch refresh's generation baseline, and the recorded fetch time now all refer to the same repo (the old comment documented the timestamp's mismatch as a known, unguarded race), and the git instance the fetch runs through is pinned to that repo's directory, so a switch mid-fetch can no longer direct in-flight work at the new repo. Co-Authored-By: Claude Fable 5 --- pkg/gui/background.go | 66 ++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/pkg/gui/background.go b/pkg/gui/background.go index d39dfd84c..180b2d445 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -6,7 +6,9 @@ import ( "sync/atomic" "time" + "github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/gocui" + "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" ) @@ -106,23 +108,35 @@ func (self *BackgroundRoutineMgr) startBackgroundFetch() { self.gui.waitForIntro.Wait() fetch := func(firstTimeOrRetriggered bool) error { - // Do this on the UI thread so that we don't have to deal with synchronization around the - // access of the repo state. - self.gui.onUIThread(func() error { - // There's a race here, where we might be recording the time stamp for a different repo - // than where the fetch actually ran. It's not very likely though, and not harmful if it - // does happen; guarding against it would be more effort than it's worth. + // Capture what the fetch needs from the gui's per-repo state in a + // single UI-thread hop: gui.git, gui.helpers and gui.State are all + // replaced on a repo switch (which runs on the UI thread), so reading + // them from this background goroutine would race the reassignment. + // Capturing them together also ties the fetch, the post-fetch + // refresh's generation baseline, and the recorded fetch time to the + // same repo. + var git *commands.GitCommand + var appStatusHelper *helpers.AppStatusHelper + var branchesHelper *helpers.BranchesHelper + var fetchGeneration int + if err := self.gui.g.OnUIThreadAndWaitBackground(func() error { + git = self.gui.git + appStatusHelper = self.gui.helpers.AppStatus + branchesHelper = self.gui.helpers.BranchesHelper + fetchGeneration = self.gui.c.State().GetRepoGeneration() self.gui.State.LastBackgroundFetchTime = time.Now() return nil - }) + }); err != nil { + return err + } if self.gui.UserConfig().Gui.ShowBottomLine || firstTimeOrRetriggered { - return self.gui.helpers.AppStatus.WithWaitingStatusImpl(self.gui.Tr.FetchingStatus, func(gocui.Task) error { - return self.backgroundFetch() + return appStatusHelper.WithWaitingStatusImpl(self.gui.Tr.FetchingStatus, func(gocui.Task) error { + return self.backgroundFetch(git, branchesHelper, fetchGeneration) }, nil) } - return self.backgroundFetch() + return self.backgroundFetch(git, branchesHelper, fetchGeneration) } // We want an immediate fetch at startup, and since goEvery starts by @@ -165,7 +179,20 @@ func (self *BackgroundRoutineMgr) startBackgroundExternalChangeDetection() { } func (self *BackgroundRoutineMgr) checkForExternalChanges() { - current, err := self.gui.git.Status.RefsSnapshot() + // Capture the per-repo objects in a UI-thread hop, like the background + // fetch does: gui.git and gui.helpers are replaced on a repo switch, so + // reading them from this background goroutine would race the reassignment. + var git *commands.GitCommand + var refreshHelper *helpers.RefreshHelper + if err := self.gui.g.OnUIThreadAndWaitBackground(func() error { + git = self.gui.git + refreshHelper = self.gui.helpers.Refresh + return nil + }); err != nil { + return + } + + current, err := git.Status.RefsSnapshot() if err != nil { // Transient error (e.g. git process couldn't start). Don't update the // stored snapshot; we'll retry next tick. @@ -173,7 +200,7 @@ func (self *BackgroundRoutineMgr) checkForExternalChanges() { return } - if !self.gui.helpers.Refresh.RefsSnapshotChangedSince(current) { + if !refreshHelper.RefsSnapshotChangedSince(current) { return } @@ -231,15 +258,14 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop, retrigge }) } -func (self *BackgroundRoutineMgr) backgroundFetch() (err error) { - // Captured before the fetch, not after: the fetch is a network call during - // which the user may switch repos, and the post-fetch refresh needs to be - // able to tell (see PostFetchRefresh). - fetchGeneration := self.gui.c.State().GetRepoGeneration() +// The parameters are captured by the caller before the fetch starts, not read +// here after it: the fetch is a network call during which the user may switch +// repos, and the post-fetch refresh needs to be able to tell (see +// PostFetchRefresh). +func (self *BackgroundRoutineMgr) backgroundFetch(git *commands.GitCommand, branchesHelper *helpers.BranchesHelper, fetchGeneration int) error { + err := git.Sync.FetchBackground() - err = self.gui.git.Sync.FetchBackground() - - return self.gui.helpers.BranchesHelper.PostFetchRefresh(err, true, fetchGeneration) + return branchesHelper.PostFetchRefresh(err, true, fetchGeneration) } func (self *BackgroundRoutineMgr) triggerImmediateFetch() { From 00cef799ce36268cd198f0ae8ed22e6922fb4203 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 13:07:03 +0200 Subject: [PATCH 363/384] Show the inline status again when checking out a newly created remote branch Checking out a remote branch that has no local counterpart creates the local branch, refreshes, and then checks it out. The refresh exists so that CheckoutRef finds the new branch in the model and attaches an inline status to the branch item instead of showing a global waiting status. But since UI-thread refreshes stopped blocking, the checkout started before the refreshed branches had landed in the model, so the lookup failed and we always got the waiting status. Run the checkout from the refresh's Then, which is queued behind the model update. Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/helpers/refs_helper.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index 675c332a0..0fcbeace3 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -157,12 +157,17 @@ func (self *RefsHelper) CheckoutRemoteBranch(fullBranchName string, localBranchN if err := self.c.Git().Branch.CreateWithUpstream(localBranchName, fullBranchName); err != nil { return err } - // Do a sync refresh to make sure the new branch is visible, - // so that we see an inline status when checking it out + // Refresh the branches and check out from Then, so that the + // new branch is already in the model when CheckoutRef looks + // it up; that's what makes it show an inline status on the + // branch rather than a global waiting status. self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.BRANCHES}, + Then: func() error { + return checkout(localBranchName, true) + }, }) - return checkout(localBranchName, true) + return nil }, }, { From 2e653ceebaa69e3fd8d02843ba65166ac50760f2 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 13:08:56 +0200 Subject: [PATCH 364/384] Update comments that still describe the removed blocking refresh mode A few comments still reasoned in terms of SYNC vs ASYNC refreshes, a distinction that no longer exists: sync vs async is now derived from the calling thread. Restate them in terms of the current mechanisms (RefreshFromWorker blocking its worker, model updates being enqueued on the UI thread) without changing any behavior. Co-Authored-By: Claude Fable 5 --- .../helpers/inline_status_helper.go | 21 ++++++++++--------- pkg/gui/controllers/helpers/refresh_helper.go | 10 ++++----- pkg/gui/controllers/remotes_controller.go | 3 +-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/pkg/gui/controllers/helpers/inline_status_helper.go b/pkg/gui/controllers/helpers/inline_status_helper.go index f2ca7ec17..814a11406 100644 --- a/pkg/gui/controllers/helpers/inline_status_helper.go +++ b/pkg/gui/controllers/helpers/inline_status_helper.go @@ -139,16 +139,17 @@ func (self *InlineStatusHelper) stop(opts InlineStatusOpts) { self.c.State().ClearItemOperation(opts.Item) // Re-render the context to remove the inline status now that the operation - // finished. Any refresh it triggered must be synchronous, not async: by the - // time we get here a synchronous refresh has already updated the model and - // queued its own re-render, and since UI-thread callbacks run in order, the - // render we queue here runs after it and draws the up-to-date model without - // the inline status. An async refresh might not have updated the model yet, - // so this render could briefly show the stale, pre-operation model: when - // pushing a branch, for example, it would flash the old ↑3↓7 ahead/behind - // counts for a moment before the refresh replaced them with a green - // checkmark. (Operations that don't refresh at all are fine too: there's - // nothing stale to show, so this just drops the status.) + // finished. The operation must trigger its refresh via RefreshFromWorker + // before we get here: that call returns only once the refresh's model + // updates have been enqueued on the UI thread, and since UI-thread + // callbacks run in order, the render we queue here runs after them and + // draws the up-to-date model without the inline status. A refresh whose + // model updates aren't enqueued yet by this point would make this render + // briefly show the stale, pre-operation model: when pushing a branch, for + // example, it would flash the old ↑3↓7 ahead/behind counts for a moment + // before the refresh replaced them with a green checkmark. (Operations + // that don't refresh at all are fine too: there's nothing stale to show, + // so this just drops the status.) self.renderContext(opts.ContextKey) } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 8ae62d9dd..a85c58cf2 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1216,11 +1216,11 @@ func (self *RefreshHelper) onUIThread(background bool, f func() error) { // runs on the UI thread (calledFromWorker is false) fn runs inline; when it runs // on a worker, fn is dispatched to the UI thread and we block for it. // -// The inline case matters for correctness as much as the hop: a SYNC refresh -// initiated on the UI thread parks that thread in a wg.Wait while its scope -// workers run, so a scope worker that tried to hop to the UI thread there would -// deadlock. Capturing before those workers are spawned — inline, on the UI -// thread — avoids that entirely. +// The inline case matters for correctness as much as the hop: OnUIThreadAndWait +// must not be called from the UI thread itself (it would park the thread +// 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()) { if !calledFromWorker { fn() diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index 76bd16bb1..cd05e5ff9 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -159,8 +159,7 @@ func (self *RemotesController) addAndCheckoutRemote(remoteName string, remoteUrl // Refresh the remotes so that we can select the new one. The remotes model // update is bounced onto the UI thread, so the selection (which reads // Model.Remotes) has to run in Then; reading it inline here would see the - // previous model. Loading remotes is not expensive, so a sync refresh is - // affordable. + // previous model. self.c.Refresh(types.RefreshOptions{ Scope: []types.RefreshableView{types.REMOTES}, Then: func() error { From 7a902b56cc49dc908e25a76b894fc38cb1923590 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 12:49:36 +0200 Subject: [PATCH 365/384] Add a way for integration tests to press keys in rapid succession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test driver waits for lazygit to become idle after every keypress, so tests could never exercise what happens when a key arrives while the previous key's processing is still in flight — for example while the refresh triggered by the previous key hasn't updated the model yet. Real users type faster than that all the time. PressRapidly injects all its keys back to back and waits for idle only once at the end, so the second and later keys are queued before the first one's processing has finished. The next commit uses this to demonstrate a bug in exactly that scenario. Co-Authored-By: Claude Fable 5 --- pkg/gui/gui_driver.go | 26 ++++++++++++++++------- pkg/integration/components/test_driver.go | 10 +++++++++ pkg/integration/components/test_test.go | 4 ++++ pkg/integration/components/view_driver.go | 13 ++++++++++++ pkg/integration/types/types.go | 4 ++++ 5 files changed, 49 insertions(+), 8 deletions(-) diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 7bd31d93d..74a8109a7 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -25,17 +25,27 @@ type GuiDriver struct { var _ integrationTypes.GuiDriver = &GuiDriver{} func (self *GuiDriver) PressKey(keyStr string) { + self.PressKeysRapidly(keyStr) +} + +// PressKeysRapidly presses the given keys in immediate succession, waiting for +// lazygit to become idle only after the last one. Keys pressed this way can +// arrive while the previous key's processing is still in flight, like a user +// typing faster than lazygit handles the input. +func (self *GuiDriver) PressKeysRapidly(keyStrs ...string) { self.CheckAllToastsAcknowledged() - key, ok := config.KeyFromLabel(keyStr) - if !ok { - self.Fail("Unrecognized key: " + keyStr) - } + for _, keyStr := range keyStrs { + key, ok := config.KeyFromLabel(keyStr) + if !ok { + self.Fail("Unrecognized key: " + keyStr) + } - self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper( - tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())), - 0, - )) + self.gui.g.ReplayKeyEvent(gocui.NewTcellKeyEventWrapper( + tcell.NewEventKey(tcell.Key(key.KeyName()), key.Str(), tcell.ModMask(key.Mod())), + 0, + )) + } self.waitTillIdle() } diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 376b0f4d6..42ce8ac35 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -2,6 +2,7 @@ package components import ( "fmt" + "strings" "time" "github.com/jesseduffield/lazygit/pkg/config" @@ -42,6 +43,15 @@ func (self *TestDriver) pressFast(keyStr string) { self.Wait(self.inputDelay / 5) } +// presses the keys in immediate succession, without waiting for lazygit to +// become idle in between, to simulate a user typing faster than lazygit +// processes the input +func (self *TestDriver) pressRapidly(keyStrs []string) { + self.SetCaption(fmt.Sprintf("Pressing %s", strings.Join(keyStrs, ", "))) + self.gui.PressKeysRapidly(keyStrs...) + self.Wait(self.inputDelay) +} + func (self *TestDriver) click(x, y int) { self.SetCaption(fmt.Sprintf("Clicking %d, %d", x, y)) self.gui.Click(x, y) diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index e7fd0b66a..8fd4417ea 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -30,6 +30,10 @@ func (self *fakeGuiDriver) PressKey(key string) { self.pressedKeys = append(self.pressedKeys, key) } +func (self *fakeGuiDriver) PressKeysRapidly(keys ...string) { + self.pressedKeys = append(self.pressedKeys, keys...) +} + func (self *fakeGuiDriver) Click(x, y int) { self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index df4b9d7d8..920c610be 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -454,6 +454,19 @@ func (self *ViewDriver) PressFast(key config.Keybinding) *ViewDriver { return self } +// Presses the given keys in immediate succession, without waiting for lazygit +// to become idle in between (Press waits after every key). Use this to +// simulate a user typing faster than lazygit processes the input. +func (self *ViewDriver) PressRapidly(keys ...config.Keybinding) *ViewDriver { + self.IsFocused() + + self.t.pressRapidly(lo.Map(keys, func(key config.Keybinding, _ int) string { + return key[0] + })) + + return self +} + func (self *ViewDriver) Click(x, y int) *ViewDriver { offsetX, offsetY, _, _ := self.getView().Dimensions() diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 34ce499cc..12009315a 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -23,6 +23,10 @@ type IntegrationTest interface { // this is the interface through which our integration tests interact with the lazygit gui type GuiDriver interface { PressKey(string) + // Like PressKey, but presses several keys in immediate succession, waiting + // for lazygit to become idle only after the last one. Use it to simulate a + // user typing faster than lazygit processes the input. + PressKeysRapidly(...string) Click(int, int) // Simulate the terminal window regaining focus (which triggers a reload of // changed config files) From 963db76ab62700ddd32ddfd14e0e16efe30601be Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 12:54:24 +0200 Subject: [PATCH 366/384] Add test showing that a rapid second keypress acts on a stale staging panel Pressing space twice in quick succession in the staging panel is supposed to stage two hunks: the refresh triggered by the first press rebuilds the panel's diff and moves the selection to the next stageable hunk, and the second press stages that. Since we made UI-thread refreshes non-blocking, the second press is handled as soon as it arrives, while that refresh is still in flight. It then reads the stale pre-refresh diff, builds the first hunk's patch again, and git apply fails with 'patch does not apply' because those lines are already in the index. The test documents this currently broken behavior; the fix comes next. Co-Authored-By: Claude Fable 5 --- .../stage_hunks_with_rapid_keypresses.go | 69 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 70 insertions(+) create mode 100644 pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go diff --git a/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go b/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go new file mode 100644 index 000000000..74dbb4b6f --- /dev/null +++ b/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go @@ -0,0 +1,69 @@ +package staging + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// The second space is pressed before the refresh triggered by the first one +// has updated the staging panel. That refresh is what moves the selection to +// the next hunk, so the second press must not be handled until it has landed; +// handling it earlier would try to stage the first hunk a second time. +var StageHunksWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Stage two hunks with two space presses in rapid succession", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.UseHunkModeInStagingView = true + }, + SetupRepo: func(shell *Shell) { + // Use 7 context lines between the two change blocks so that git creates + // two separate hunks. + shell.CreateFileAndAdd("file1", "1\n2\na\nb\nc\nd\ne\nf\ng\n3\n4\n") + shell.Commit("one") + + shell.UpdateFile("file1", "1b\n2b\na\nb\nc\nd\ne\nf\ng\n3b\n4b\n") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Contains("file1").IsSelected(), + ). + PressEnter() + + t.Views().Staging(). + IsFocused(). + PressRapidly(keys.Universal.Select, keys.Universal.Select) + + /* EXPECTED: + t.Views().StagingSecondary(). + IsFocused(). + ContainsLines( + Contains("+1b"), + Contains("+2b"), + ). + ContainsLines( + Contains("+3b"), + Contains("+4b"), + ) + ACTUAL: */ + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("patch does not apply")). + Confirm() + + t.Views().Staging(). + IsFocused(). + ContainsLines( + Contains("+3b"), + Contains("+4b"), + ) + + t.Views().StagingSecondary(). + ContainsLines( + Contains("+1b"), + Contains("+2b"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 8213d5159..2189d3506 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -403,6 +403,7 @@ var tests = []*components.IntegrationTest{ staging.SelectNextLineAfterStagingInTwoHunkDiff, staging.SelectNextLineAfterStagingIsolatedAddedLine, staging.StageHunks, + staging.StageHunksWithRapidKeypresses, staging.StageLines, staging.StagePartialBlockOfChangesFirstLines, staging.StagePartialBlockOfChangesLastLines, From b96b8a97534d513dd774d2826782ee833b2cfa36 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 13:02:36 +0200 Subject: [PATCH 367/384] Add RefreshBlockingInput to buffer keypresses until a refresh has landed A refresh from the UI thread returns immediately and applies its model and view updates as queued UI-thread callbacks. A key pressed before those have run is handled against the stale, pre-refresh state. For most keys that's harmless, but some handlers turn that state into git commands: pressing space twice in quick succession in the staging panel builds the second patch from the already-applied diff and fails with 'patch does not apply', because the refresh after the first press is what moves the selection to the next stageable hunk. Notably, this is not just a regression of the recent change that made UI-thread refreshes non-blocking; the window was merely much narrower before. A blocking refresh parked the UI thread while the scopes' bounces were queued, and the event loop drains pending keyboard input with priority over queued user events, so a key pressed during the blocked window still beat the queued state updates. The guarantee that the next keypress sees post-refresh state had already ended when the scopes' state updates moved from worker-side mutex-guarded writes to UI-thread bounces. Fix it with the input-blocking mechanism we already use for commit surgery, exposed as a new RefreshBlockingInput entry point: it begins blocking events synchronously in the calling handler, and ends the block from a callback that the finishing step queues behind the refresh's own updates. Keys pressed while the refresh is in flight are buffered and replayed, in order, against the fully refreshed state; since a replayed key's handler re-enters this same path, a burst of keypresses applies sequentially, each one seeing the previous one's refresh. Unlike the old blocking refreshes, this doesn't freeze the UI thread: rendering, spinners, resizing, and mouse scrolling keep working while input is withheld. Blocking input is opt-in per call site rather than the default for all UI-thread refreshes, because most refreshes (the focus-in and startup refreshes, say) don't produce state that the next keypress depends on, and blocking on them would delay typing for no reason. It should also be limited to quick, narrow-scoped refreshes: a full refresh, or any scope that pulls in COMMITS, can take very long in large repos and should usually not hold up input. The staging panel's stage/discard/edit-hunk refreshes use it now. --- pkg/gui/controllers/helpers/refresh_helper.go | 32 +++++++++++++++++-- pkg/gui/controllers/staging_controller.go | 9 ++++-- pkg/gui/gui_common.go | 4 +++ pkg/gui/types/common.go | 11 +++++++ .../stage_hunks_with_rapid_keypresses.go | 19 ----------- 5 files changed, 51 insertions(+), 24 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index a85c58cf2..cf097b67e 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -81,14 +81,20 @@ func NewRefreshHelper( } func (self *RefreshHelper) Refresh(options types.RefreshOptions) { - self.performRefresh(options, false) + self.performRefresh(options, false, false) +} + +// RefreshBlockingInput is Refresh for handlers whose next keypress may depend +// on the state the refresh produces. See IGuiCommon.RefreshBlockingInput. +func (self *RefreshHelper) RefreshBlockingInput(options types.RefreshOptions) { + self.performRefresh(options, false, true) } // RefreshFromWorker is Refresh for callers already running on a worker // goroutine (e.g. inside a WithWaitingStatus handler) rather than the UI // thread. See IGuiCommon.RefreshFromWorker. func (self *RefreshHelper) RefreshFromWorker(options types.RefreshOptions) { - self.performRefresh(options, true) + self.performRefresh(options, true, false) } type refreshEnv struct { @@ -159,7 +165,7 @@ func (self *refreshBounceBatch) close() []func() { return self.funcs } -func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool) { +func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFromWorker bool, blockInput bool) { startTime := time.Now() // A refresh from a worker blocks that worker until it's done; one from the @@ -192,6 +198,17 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr panic("a refresh with a Then callback must not set DontBlockRepoSwitch") } + // A RefreshBlockingInput caller wants keyboard input withheld until the + // refreshed state is in place (see IGuiCommon.RefreshBlockingInput). Begin + // the block synchronously here in the calling handler, so that no keypress + // can slip through before it; the finishing step ends it from a callback + // queued behind the refresh's own updates (see waitAndFinalize). Demos + // take the blocking inline path below and need none of this. + blockInputUntilDone := blockInput && !self.c.InDemo() + if blockInputUntilDone { + self.c.GocuiGui().BeginBlockingEvents() + } + // Capture the refresh's baseline once, here at the start: the repo // generation that every scope's bounce is guarded against, and the git // command instance the scopes run their commands through. The two are @@ -498,6 +515,15 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr self.onUIThread(env.background, options.Then) } + if blockInputUntilDone { + // Queued after the scopes' model bounces and Then, so by the time + // this runs — and the keys buffered during the refresh replay — + // the refreshed state is in place. + self.c.OnUIThread(func() error { + return self.c.GocuiGui().EndBlockingEvents() + }) + } + self.c.Log.Infof("Refresh took %s", time.Since(startTime)) } diff --git a/pkg/gui/controllers/staging_controller.go b/pkg/gui/controllers/staging_controller.go index 8d876acda..505a07fc4 100644 --- a/pkg/gui/controllers/staging_controller.go +++ b/pkg/gui/controllers/staging_controller.go @@ -229,7 +229,10 @@ func (self *StagingController) applySelectionAndRefresh(reverse bool) error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) + // Block input until the refresh has landed: it rebuilds the staging panel + // and moves the selection to the next stageable change, and a quick second + // keypress must act on that, not on the stale pre-refresh diff. + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) return nil } @@ -284,7 +287,9 @@ func (self *StagingController) EditHunkAndRefresh() error { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) + // Block input like applySelectionAndRefresh does; the refresh rebuilds the + // staging panel from the post-edit diff. + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES, types.STAGING}}) return nil } diff --git a/pkg/gui/gui_common.go b/pkg/gui/gui_common.go index 80b2b9ded..e7b14ba04 100644 --- a/pkg/gui/gui_common.go +++ b/pkg/gui/gui_common.go @@ -30,6 +30,10 @@ func (self *guiCommon) Refresh(opts types.RefreshOptions) { self.gui.helpers.Refresh.Refresh(opts) } +func (self *guiCommon) RefreshBlockingInput(opts types.RefreshOptions) { + self.gui.helpers.Refresh.RefreshBlockingInput(opts) +} + func (self *guiCommon) RefreshFromWorker(opts types.RefreshOptions) { self.gui.helpers.Refresh.RefreshFromWorker(opts) } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 6d11e29db..87bd9ef50 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -29,6 +29,17 @@ type IGuiCommon interface { LogCommand(cmdStr string, isCommandLine bool) // we call this when we want to refetch some models and render the result. Internally calls PostRefreshUpdate Refresh(RefreshOptions) + // Like Refresh, but withholds keyboard input until the refreshed state is + // in place: keys pressed while the refresh is in flight are buffered and + // replayed once its model and view updates have run, instead of being + // handled against the stale, pre-refresh state. Use it when the very next + // keypress may depend on what the refresh produces — e.g. staging a hunk, + // where the refresh moves the selection to the next stageable hunk that + // the next press is meant to stage. Keep it to quick, narrow-scoped + // refreshes: one that includes COMMITS (or refreshes everything) can take + // very long in large repos and should usually not block input unless + // there's a very good reason (switching repos is one such example). + RefreshBlockingInput(RefreshOptions) // Like Refresh, but for callers running on a worker goroutine (e.g. inside // a WithWaitingStatus handler) rather than the UI thread. The refresh // captures the model/context state it needs on the UI thread before doing diff --git a/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go b/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go index 74dbb4b6f..5b41073e2 100644 --- a/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go +++ b/pkg/integration/tests/staging/stage_hunks_with_rapid_keypresses.go @@ -36,7 +36,6 @@ var StageHunksWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ IsFocused(). PressRapidly(keys.Universal.Select, keys.Universal.Select) - /* EXPECTED: t.Views().StagingSecondary(). IsFocused(). ContainsLines( @@ -47,23 +46,5 @@ var StageHunksWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ Contains("+3b"), Contains("+4b"), ) - ACTUAL: */ - t.ExpectPopup().Alert(). - Title(Equals("Error")). - Content(Contains("patch does not apply")). - Confirm() - - t.Views().Staging(). - IsFocused(). - ContainsLines( - Contains("+3b"), - Contains("+4b"), - ) - - t.Views().StagingSecondary(). - ContainsLines( - Contains("+1b"), - Contains("+2b"), - ) }, }) From 200042a57cc9637d2ae756e9aa0a07673faf52a7 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 19:29:47 +0200 Subject: [PATCH 368/384] Add test showing that rapidly moving a rebase todo twice moves the wrong todo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving a todo up or down rewrites the todo file and advances the selection synchronously, but the commits model is only rebuilt by the refresh, which finishes in the background. A second keypress arriving before that reads the pre-move model at the advanced selection index — that's the todo the first move swapped with, so the second press moves that one back instead of moving the selected todo further. Two rapid presses (e.g. from holding the key down) thus amount to a net no-op. The two presses also spawn two racing refreshes whose model updates can land in either order, so the todo list can even end up disagreeing with the todo file. That's why the test continues the rebase and asserts the resulting commit order instead of the displayed list: the rebase replays the file, which is deterministic. The test documents this currently broken behavior; the fix comes next. Co-Authored-By: Claude Fable 5 --- .../move_todo_down_with_rapid_keypresses.go | 68 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 69 insertions(+) create mode 100644 pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go 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 new file mode 100644 index 000000000..548d924f4 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/move_todo_down_with_rapid_keypresses.go @@ -0,0 +1,68 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +// The second keypress arrives before the refresh triggered by the first one +// has rebuilt the commits model. The handler reads the selected todo from the +// model at the already-advanced selection index, so with the stale, pre-move +// model it grabs the todo the first move swapped with and moves that one back +// down — turning the two presses into a net no-op instead of moving the +// selected todo down two slots. This is what happens when holding down the +// move-down key to move a todo several slots. +// +// We continue the rebase and assert the resulting commit order rather than +// asserting the todo list, because the two presses also spawn two racing +// refreshes whose updates can land in either order, so what the todo list +// shows in the broken state is not deterministic (it can even disagree with +// the todo file). The rebase replays what's in the file. +var MoveTodoDownWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Move a todo down two slots with two keypresses in rapid succession", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(4) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("commit-04").IsSelected(), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ). + NavigateToLine(Contains("commit-01")). + Press(keys.Universal.Edit). + Lines( + Contains("--- Pending rebase todos ---"), + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), + Contains("--- Commits ---"), + Contains("commit-01").IsSelected(), + ). + NavigateToLine(Contains("commit-04")). + PressRapidly(keys.Commits.MoveDownCommit, keys.Commits.MoveDownCommit). + Tap(func() { + t.Common().ContinueRebase() + }). + /* EXPECTED: + Lines( + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-04"), + Contains("commit-01"), + ) + ACTUAL: */ + Lines( + Contains("commit-04"), + Contains("commit-03"), + Contains("commit-02"), + Contains("commit-01"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 2189d3506..1bb06741f 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -310,6 +310,7 @@ var tests = []*components.IntegrationTest{ interactive_rebase.Move, interactive_rebase.MoveAcrossBranchBoundaryOutsideRebase, interactive_rebase.MoveInRebase, + interactive_rebase.MoveTodoDownWithRapidKeypresses, interactive_rebase.MoveUpdateRefTodo, interactive_rebase.MoveWithCustomCommentChar, interactive_rebase.OutsideRebaseRangeSelect, From 055184f99772dc360b4dacb23edf0113dfadb86d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 19:31:36 +0200 Subject: [PATCH 369/384] Block input while the refresh after moving a rebase todo is in flight Moving a todo rewrites the todo file and advances the selection synchronously, but the commits model is only rebuilt by the refresh. A second press arriving before that grabs the swapped-with todo from the stale model at the advanced index and moves it back, so holding the key to move a todo several slots misbehaved. Use RefreshBlockingInput so the second press is buffered and replayed once the moved todo list is in place. Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/local_commits_controller.go | 8 ++++++-- .../move_todo_down_with_rapid_keypresses.go | 8 -------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 2da502b79..708f8fc28 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -741,7 +741,10 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s self.context().MoveSelection(1) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - self.c.Refresh(types.RefreshOptions{ + // Block input until the refresh has landed: a quick second press must + // read the moved todo from the refreshed model, not grab whatever the + // advanced selection index points at in the stale one. + self.c.RefreshBlockingInput(types.RefreshOptions{ Scope: []types.RefreshableView{types.REBASE_COMMITS}, CommitSelection: types.KeepCommitSelectionIndex, }) @@ -777,7 +780,8 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta self.context().MoveSelection(-1) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) - self.c.Refresh(types.RefreshOptions{ + // Block input for the same reason as in moveDown. + self.c.RefreshBlockingInput(types.RefreshOptions{ Scope: []types.RefreshableView{types.REBASE_COMMITS}, CommitSelection: types.KeepCommitSelectionIndex, }) 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 548d924f4..16e58e18e 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 @@ -50,19 +50,11 @@ var MoveTodoDownWithRapidKeypresses = NewIntegrationTest(NewIntegrationTestArgs{ Tap(func() { t.Common().ContinueRebase() }). - /* EXPECTED: Lines( Contains("commit-03"), Contains("commit-02"), Contains("commit-04"), Contains("commit-01"), ) - ACTUAL: */ - Lines( - Contains("commit-04"), - Contains("commit-03"), - Contains("commit-02"), - Contains("commit-01"), - ) }, }) From fc975f32a8d84ab1083a1e526dcdedcd961974fd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 19:33:39 +0200 Subject: [PATCH 370/384] Block input while the refresh after a stash operation is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Popping or dropping a stash shifts the indices of the entries below it, and renaming re-creates the stash at the top, shifting all the others. The stash model is only rebuilt by the refresh, which finishes in the background, so acting on the next entry in quick succession — pressing the key, confirming the popup, and pressing again right away — reads the stale pre-operation indices and targets the wrong stash. Note that the confirmation popup is no protection here: the race starts when the confirm handler runs, and the next keypress can easily beat the refresh. Use RefreshBlockingInput so a quick follow-up keypress is buffered and replayed once the refreshed stash list is in place. Co-Authored-By: Claude Fable 5 --- pkg/gui/controllers/stash_controller.go | 30 ++++++++++++++++--------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index a2b7e9e97..d01fc8dbf 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -170,13 +170,16 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) Prompt: self.c.Tr.SureDropStashEntry, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.DropStash) - // Refresh once at the end rather than after each drop: an async - // refresh from the UI thread finishes in the background, so firing - // one per iteration lets the workers race and an earlier, stale - // result can land last. The indices are captured up front and we - // drop highest-first, so the remaining lower indices stay valid - // without an intervening refresh. - defer self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) + // Refresh once at the end rather than after each drop: a refresh + // from the UI thread finishes in the background, so firing one per + // iteration lets the workers race and an earlier, stale result can + // land last. The indices are captured up front and we drop + // highest-first, so the remaining lower indices stay valid without + // an intervening refresh. 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}}) 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 { @@ -192,7 +195,11 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) } func (self *StashController) postStashRefresh() { - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}}) + // 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}}) } func (self *StashController) handleNewBranchOffStashEntry(stashEntry *models.StashEntry) error { @@ -214,12 +221,15 @@ func (self *StashController) handleRenameStashEntry(stashEntry *models.StashEntr self.c.LogAction(self.c.Tr.Actions.RenameStash) err := self.c.Git().Stash.Rename(stashEntry.Index, response) if err != nil { - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) return err } self.context().SetSelection(0) // Select the renamed stash self.context().FocusLine(true) - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) + // 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. + self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) return nil }, AllowEmptyInput: true, From 975da9b8a9bd8186ca353e9cddc4bd4cd83c4ecd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 20:38:41 +0200 Subject: [PATCH 371/384] Block input and batch UI updates when switching repos On startup we don't want to block input during the initial refresh (it should be possible to press, say, `4` to jump to the commits panel right after startup without a delay), and we also want panels to show their contents as soon as possible; it doesn't matter so much that it's not in sync, we go from empty to populated here. However, when switching repos it can be confusing that some panels that are slow to update still show the old repo's data while others already show the new one's data, so update the UI only when everything is ready, and also block input to prevent accidentally trying to act on the old, stale data. --- pkg/gui/gui.go | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 8776040e7..912d46567 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -1103,12 +1103,31 @@ func (gui *Gui) runSubprocess(cmdObj *oscommands.CmdObj) error { return err } +var isFirstRefreshAfterStartup = true + func (gui *Gui) loadNewRepo() error { if err := gui.updateRecentRepoList(); err != nil { return err } - gui.c.Refresh(types.RefreshOptions{DontBlockRepoSwitch: true}) + // On startup we don't want to block input during the initial refresh (it + // should be possible to press, say, `4` to jump to the commits panel right + // after startup without a delay), and we also want panels to show their + // contents as soon as possible; it doesn't matter so much that it's not in + // sync, we go from empty to populated here. However, when switching repos + // it can be confusing that some panels that are slow to update still show + // the old repo's data while others already show the new one's data, so + // update the UI only when everything is ready, and also block input to + // prevent accidentally trying to act on the old, stale data. + options := types.RefreshOptions{DontBlockRepoSwitch: true} + refresh := gui.c.Refresh + if isFirstRefreshAfterStartup { + isFirstRefreshAfterStartup = false + } else { + options.BatchUIUpdates = true + refresh = gui.c.RefreshBlockingInput + } + refresh(options) if err := gui.os.UpdateWindowTitle(); err != nil { return err From 87d9537de9b6bc5a0d17a2121c9bced9cafa3268 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 21 Jul 2026 11:22:04 +0200 Subject: [PATCH 372/384] Support a `{{diffContext}}` template variable in external diff command Useful for passing the context size to external diff commands like difftastic. --- docs-master/Custom_Pagers.md | 10 +++++----- pkg/commands/git_commands/commit.go | 2 +- pkg/commands/git_commands/diff.go | 5 +++-- pkg/commands/git_commands/stash.go | 5 +++-- pkg/commands/git_commands/working_tree.go | 4 ++-- pkg/config/pager_config.go | 9 +++++++-- pkg/gui/pty.go | 3 ++- 7 files changed, 23 insertions(+), 15 deletions(-) diff --git a/docs-master/Custom_Pagers.md b/docs-master/Custom_Pagers.md index f74005c19..8bdcf164d 100644 --- a/docs-master/Custom_Pagers.md +++ b/docs-master/Custom_Pagers.md @@ -66,17 +66,17 @@ These can be used in lazygit by using the `externalDiffCommand` config; in the c ```yaml git: pagers: - - externalDiffCommand: difft --color=always + - externalDiffCommand: difft --color=always --context={{diffContext}} ``` -The `colorArg` option is not used in this case. +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 --display=inline --syntax-highlight=off + - 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`: @@ -84,7 +84,7 @@ This can also be used for normal git diffs with custom parameters, such as `--co ```sh #!/bin/sh -git diff --color-words --no-index --color=always --no-ext-diff "$2" "$5" +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: @@ -92,7 +92,7 @@ And then use it in your git config like so: ```yaml git: pagers: - - externalDiffCommand: ~/bin/color-words.sh + - 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 diff --git a/pkg/commands/git_commands/commit.go b/pkg/commands/git_commands/commit.go index 6abf272b3..953717ec0 100644 --- a/pkg/commands/git_commands/commit.go +++ b/pkg/commands/git_commands/commit.go @@ -243,7 +243,7 @@ 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() + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() cmdArgs := NewGitCmd("show"). Config("diff.noprefix=false"). diff --git a/pkg/commands/git_commands/diff.go b/pkg/commands/git_commands/diff.go index 3074e653a..f4ecb5f53 100644 --- a/pkg/commands/git_commands/diff.go +++ b/pkg/commands/git_commands/diff.go @@ -19,7 +19,8 @@ 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. func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj { - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + contextSize := self.UserConfig().Git.DiffContextSize + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiff := extDiffCmd != "" useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() ignoreWhitespace := self.UserConfig().Git.IgnoreWhitespaceInDiffView @@ -32,7 +33,7 @@ func (self *DiffCommands) DiffCmdObj(diffArgs []string) *oscommands.CmdObj { Arg("--submodule"). Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())). ArgIf(ignoreWhitespace, "--ignore-all-space"). - Arg(fmt.Sprintf("--unified=%d", self.UserConfig().Git.DiffContextSize)). + Arg(fmt.Sprintf("--unified=%d", contextSize)). Arg(diffArgs...). Dir(self.repoPaths.worktreePath). ToArgv(), diff --git a/pkg/commands/git_commands/stash.go b/pkg/commands/git_commands/stash.go index 0e5eb299d..9bd960ed5 100644 --- a/pkg/commands/git_commands/stash.go +++ b/pkg/commands/git_commands/stash.go @@ -81,7 +81,8 @@ func (self *StashCommands) Hash(index int) (string, error) { } func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj { - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + 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 @@ -92,7 +93,7 @@ func (self *StashCommands) ShowStashEntryCmdObj(index int) *oscommands.CmdObj { ConfigIf(extDiffCmd != "", "diff.external="+extDiffCmd). ArgIfElse(extDiffCmd != "" || useExtDiffGitConfig, "--ext-diff", "--no-ext-diff"). Arg(fmt.Sprintf("--color=%s", self.pagerConfig.GetColorArg())). - Arg(fmt.Sprintf("--unified=%d", self.UserConfig().Git.DiffContextSize)). + 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("refs/stash@{%d}", index)). diff --git a/pkg/commands/git_commands/working_tree.go b/pkg/commands/git_commands/working_tree.go index 8625158ab..d296858f6 100644 --- a/pkg/commands/git_commands/working_tree.go +++ b/pkg/commands/git_commands/working_tree.go @@ -401,7 +401,7 @@ func (self *WorkingTreeCommands) WorktreeFileDiffCmdObj(node models.IFile, plain contextSize := self.UserConfig().Git.DiffContextSize prevPath := node.GetPreviousPath() noIndex := !node.GetIsTracked() && !node.GetHasStagedChanges() && !cached && node.GetIsFile() - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiff := extDiffCmd != "" && !plain useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain @@ -450,7 +450,7 @@ func (self *WorkingTreeCommands) ShowFileDiffCmdObj(from string, to string, reve colorArg = "never" } - extDiffCmd := self.pagerConfig.GetExternalDiffCommand() + extDiffCmd := self.pagerConfig.GetExternalDiffCommand(contextSize) useExtDiff := extDiffCmd != "" && !plain useExtDiffGitConfig := self.pagerConfig.GetUseExternalDiffGitConfig() && !plain diff --git a/pkg/config/pager_config.go b/pkg/config/pager_config.go index d243a01b2..01f92f584 100644 --- a/pkg/config/pager_config.go +++ b/pkg/config/pager_config.go @@ -58,12 +58,17 @@ func (self *PagerConfig) GetColorArg() string { return colorArg } -func (self *PagerConfig) GetExternalDiffCommand() string { +func (self *PagerConfig) GetExternalDiffCommand(diffContext uint64) string { currentPagerConfig := self.currentPagerConfig() if currentPagerConfig == nil { return "" } - return currentPagerConfig.ExternalDiffCommand + + templateValues := map[string]string{ + "diffContext": strconv.Itoa(int(diffContext)), + } + + return utils.ResolvePlaceholderString(currentPagerConfig.ExternalDiffCommand, templateValues) } func (self *PagerConfig) GetUseExternalDiffGitConfig() bool { diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index d4f739c6d..dbf968048 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -58,6 +58,7 @@ func (p ptyCmd) GetProcess() *os.Process { return p.process } // 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 @@ -65,7 +66,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error cmd.Env = append(cmd.Env, fmt.Sprintf("LAZYGIT_COLUMNS=%d", width)) pager := gui.stateAccessor.GetPagerConfig().GetPagerCommand(width) - externalDiffCommand := gui.stateAccessor.GetPagerConfig().GetExternalDiffCommand() + externalDiffCommand := gui.stateAccessor.GetPagerConfig().GetExternalDiffCommand(diffContext) useExtDiffGitConfig := gui.stateAccessor.GetPagerConfig().GetUseExternalDiffGitConfig() if pager == "" && externalDiffCommand == "" && !useExtDiffGitConfig { From 5209294a56e752beaf9b9e175c9f795bb5df869f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:32:33 +0200 Subject: [PATCH 373/384] Extract helper for starting a command with piped output The fallback path in newPtyTask (taken when StartPty fails) needs the same start-the-command-with-a-pipe logic that newCmdTask uses, so pull it out into a helper that both can share. No behavior change. Co-Authored-By: Claude Fable 5 --- pkg/gui/tasks_adapter.go | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 27aacf58b..3eb446a90 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -7,6 +7,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/tasks" + "github.com/sirupsen/logrus" ) func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error { @@ -29,19 +30,9 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error start := func() (tasks.Cmd, io.Reader) { view.SetContentWidth(contentWidth) - var err error - r, err = cmd.StdoutPipe() - if err != nil { - gui.c.Log.Error(err) - r = nil - } - cmd.Stderr = cmd.Stdout - - if err := cmd.Start(); err != nil { - gui.c.Log.Error(err) - } - - return tasks.ExecCmd{Cmd: cmd}, r + execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log) + r = pipe + return execCmd, pipe } onClose := func() { @@ -59,6 +50,24 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error return nil } +// startCmdWithPipe starts cmd with its stdout and stderr going to a single +// pipe, and returns the command along with the pipe's read end, in the shape +// that NewCmdTask expects from its start func. +func startCmdWithPipe(cmd *exec.Cmd, log *logrus.Entry) (tasks.Cmd, io.ReadCloser) { + r, err := cmd.StdoutPipe() + if err != nil { + log.Error(err) + r = nil + } + cmd.Stderr = cmd.Stdout + + if err := cmd.Start(); err != nil { + log.Error(err) + } + + return tasks.ExecCmd{Cmd: cmd}, r +} + func (gui *Gui) newStringTask(view *gocui.View, str string) error { // using str so that if rendering the exact same thing we don't reset the origin return gui.newStringTaskWithKey(view, str, str) From 400faea60ceffc183d0fe43fb91fd2fd2d65eceb Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:38:13 +0200 Subject: [PATCH 374/384] Add test showing startCmdWithPipe returns a nil reader on pipe failure NewCmdTask feeds the reader returned by its start func straight into a bufio.Scanner, whose Scan panics on a nil reader with a nil pointer dereference. startCmdWithPipe returns exactly that when the pipe cannot be created. Co-Authored-By: Claude Fable 5 --- pkg/gui/tasks_adapter_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 pkg/gui/tasks_adapter_test.go diff --git a/pkg/gui/tasks_adapter_test.go b/pkg/gui/tasks_adapter_test.go new file mode 100644 index 000000000..7821f835a --- /dev/null +++ b/pkg/gui/tasks_adapter_test.go @@ -0,0 +1,27 @@ +package gui + +import ( + "bytes" + "os/exec" + "testing" + + "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func TestStartCmdWithPipeWhenPipeCannotBeCreated(t *testing.T) { + cmd := exec.Command("non-existent-command") + // Assigning stdout up front makes cmd.StdoutPipe fail. This happens in + // practice on the Unix pty fallback path: a failed pty start can leave + // the tty assigned to the command's stdout. + cmd.Stdout = &bytes.Buffer{} + + _, r := startCmdWithPipe(cmd, utils.NewDummyLog()) + + // NewCmdTask's scanner panics on a nil reader, so startCmdWithPipe must + // not return one even when it can't create the pipe. + /* EXPECTED: + assert.NotNil(t, r) + ACTUAL: */ + assert.Nil(t, r) +} From f000ce9f1ce6a55cb23d8e377775a4a2ded9b852 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:50:00 +0200 Subject: [PATCH 375/384] Never hand NewCmdTask a nil reader when a command fails to start NewCmdTask feeds the reader returned by its start func into a bufio.Scanner, and Scanner.Scan panics with a nil pointer dereference when that reader is nil. Two start funcs could produce one: - newPtyTask's fallback for a failed StartPty returned a literal nil reader, alongside an ExecCmd that was never started, so the intended "fall back to a plain cmd task" never worked. This crashed lazygit on Windows when using a custom pager with the main view zero-sized, e.g. after pressing + twice to enter full-screen mode with a side panel focused: ConPTY rejects zero dimensions, making StartPty fail. - startCmdWithPipe returned nil when the pipe couldn't be created, which the Unix pty fallback path can trigger, since a failed pty start can leave the tty assigned to the command's stdout. Make startCmdWithPipe never return a nil reader: when the pipe can't be created, don't start the command at all and return an empty reader so the task shuts down cleanly with the error in the log. Then route newPtyTask's fallback through it, so a StartPty failure degrades to running the command without a pty: the pager is lost, but the command's output still renders. Co-Authored-By: Claude Fable 5 --- pkg/gui/pty.go | 11 ++++++++++- pkg/gui/tasks_adapter.go | 7 +++++-- pkg/gui/tasks_adapter_test.go | 3 --- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/pkg/gui/pty.go b/pkg/gui/pty.go index dbf968048..e10ea8ec7 100644 --- a/pkg/gui/pty.go +++ b/pkg/gui/pty.go @@ -100,6 +100,7 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error cols, rows := gui.desiredPtySize(view) 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 // task's goroutine once the previous task has stopped, so it doesn't @@ -109,7 +110,11 @@ 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) - return tasks.ExecCmd{Cmd: cmd}, nil + // Fall back to running the command without a pty: the pager is + // lost, but the command's output still renders. + execCmd, pipe := startCmdWithPipe(cmd, gui.c.Log) + fallbackPipe = pipe + return execCmd, pipe } p = sp.Pty @@ -125,6 +130,10 @@ func (gui *Gui) newPtyTask(view *gocui.View, cmd *exec.Cmd, prefix string) error if p != nil { p.Close() } + if fallbackPipe != nil { + fallbackPipe.Close() + fallbackPipe = nil + } delete(gui.viewPtmxMap, view.Name()) gui.Mutexes.PtyMutex.Unlock() } diff --git a/pkg/gui/tasks_adapter.go b/pkg/gui/tasks_adapter.go index 3eb446a90..3dce93874 100644 --- a/pkg/gui/tasks_adapter.go +++ b/pkg/gui/tasks_adapter.go @@ -52,12 +52,15 @@ func (gui *Gui) newCmdTask(view *gocui.View, cmd *exec.Cmd, prefix string) error // startCmdWithPipe starts cmd with its stdout and stderr going to a single // pipe, and returns the command along with the pipe's read end, in the shape -// that NewCmdTask expects from its start func. +// that NewCmdTask expects from its start func. It never returns a nil reader, +// because NewCmdTask's scanner panics on one: when the pipe can't be created +// the command isn't started at all, and an empty reader is returned so that +// the task shuts down cleanly with the error in the log. func startCmdWithPipe(cmd *exec.Cmd, log *logrus.Entry) (tasks.Cmd, io.ReadCloser) { r, err := cmd.StdoutPipe() if err != nil { log.Error(err) - r = nil + return tasks.ExecCmd{Cmd: cmd}, io.NopCloser(strings.NewReader("")) } cmd.Stderr = cmd.Stdout diff --git a/pkg/gui/tasks_adapter_test.go b/pkg/gui/tasks_adapter_test.go index 7821f835a..48c1bb45f 100644 --- a/pkg/gui/tasks_adapter_test.go +++ b/pkg/gui/tasks_adapter_test.go @@ -20,8 +20,5 @@ func TestStartCmdWithPipeWhenPipeCannotBeCreated(t *testing.T) { // NewCmdTask's scanner panics on a nil reader, so startCmdWithPipe must // not return one even when it can't create the pipe. - /* EXPECTED: assert.NotNil(t, r) - ACTUAL: */ - assert.Nil(t, r) } From c217084c90b78ff9127ec5ff2c6e6f8c7bf7545a Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 14:59:35 +0200 Subject: [PATCH 376/384] Add test showing StartPty fails on Windows when given a zero size CreatePseudoConsole rejects zero dimensions with E_INVALIDARG, so starting a pty sized after a hidden (and thus zero-sized) view fails. Co-Authored-By: Claude Fable 5 --- pkg/commands/oscommands/pty_windows_test.go | 28 +++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 pkg/commands/oscommands/pty_windows_test.go diff --git a/pkg/commands/oscommands/pty_windows_test.go b/pkg/commands/oscommands/pty_windows_test.go new file mode 100644 index 000000000..d16d3b295 --- /dev/null +++ b/pkg/commands/oscommands/pty_windows_test.go @@ -0,0 +1,28 @@ +package oscommands + +import ( + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" +) + +// The requested size can legitimately be zero: the pty inherits the main +// view's dimensions, and that view is zero-sized while hidden, e.g. in +// full-screen mode with a side panel focused. +func TestStartPtyWithZeroSize(t *testing.T) { + // The command deliberately produces no output: go test runs with + // redirected std handles, which CreateProcess duplicates into the child + // in place of handles to the attached pseudoconsole, so command output + // would bypass the pty and pollute the test log. + sp, err := StartPty(exec.Command("cmd", "/c", "exit 0"), 0, 0) + /* EXPECTED: + assert.NoError(t, err) + ACTUAL: */ + assert.Error(t, err) + + if err == nil { + _ = sp.Wait() + _ = sp.Pty.Close() + } +} From 02c8ba3073200f652d0152c2bdfb7da5a8af97f0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Mon, 20 Jul 2026 15:22:30 +0200 Subject: [PATCH 377/384] Clamp ConPTY sizes to the 1x1 minimum that Windows accepts CreatePseudoConsole and ResizePseudoConsole reject zero dimensions with E_INVALIDARG, but we legitimately request them: the pty is sized after the main view, and that view is zero-sized while hidden, e.g. in full-screen mode with a side panel focused. Entering that mode while a custom pager is configured therefore made StartPty fail (degrading to unpaged output now that the fallback works), and resizing a live pty from onResize would fail layout. The Unix pty accepts zero sizes, so the clamp lives in the Windows implementation only. Co-Authored-By: Claude Fable 5 --- pkg/commands/oscommands/pty_windows.go | 13 +++++++++++-- pkg/commands/oscommands/pty_windows_test.go | 3 --- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/pkg/commands/oscommands/pty_windows.go b/pkg/commands/oscommands/pty_windows.go index 72ade5110..eaa762ed6 100644 --- a/pkg/commands/oscommands/pty_windows.go +++ b/pkg/commands/oscommands/pty_windows.go @@ -36,7 +36,16 @@ func (p *winPty) Resize(cols, rows uint16) error { // there is nothing left to resize. return nil } - return windows.ResizePseudoConsole(p.hpc, windows.Coord{X: int16(cols), Y: int16(rows)}) + return windows.ResizePseudoConsole(p.hpc, clampPtySize(cols, rows)) +} + +// clampPtySize clamps a requested pty size to the minimum that ConPTY +// accepts: CreatePseudoConsole and ResizePseudoConsole reject zero +// dimensions with E_INVALIDARG, but callers legitimately request them — the +// pty is sized after the main view, which is zero-sized while hidden, e.g. +// in full-screen mode with a side panel focused. +func clampPtySize(cols, rows uint16) windows.Coord { + return windows.Coord{X: int16(max(cols, 1)), Y: int16(max(rows, 1))} } // closeHpc closes the pseudoconsole exactly once. Safe to call from multiple @@ -140,7 +149,7 @@ func StartPty(cmd *exec.Cmd, cols, rows uint16) (sp StartedPty, err error) { // CreatePseudoConsole dupes the handles it needs internally; we release // our references to the child-side ends immediately after. var hpc windows.Handle - size := windows.Coord{X: int16(cols), Y: int16(rows)} + size := clampPtySize(cols, rows) if err = windows.CreatePseudoConsole(size, inRead, outWrite, 0, &hpc); err != nil { _ = windows.CloseHandle(inRead) _ = windows.CloseHandle(outWrite) diff --git a/pkg/commands/oscommands/pty_windows_test.go b/pkg/commands/oscommands/pty_windows_test.go index d16d3b295..0b4173561 100644 --- a/pkg/commands/oscommands/pty_windows_test.go +++ b/pkg/commands/oscommands/pty_windows_test.go @@ -16,10 +16,7 @@ func TestStartPtyWithZeroSize(t *testing.T) { // in place of handles to the attached pseudoconsole, so command output // would bypass the pty and pollute the test log. sp, err := StartPty(exec.Command("cmd", "/c", "exit 0"), 0, 0) - /* EXPECTED: assert.NoError(t, err) - ACTUAL: */ - assert.Error(t, err) if err == nil { _ = sp.Wait() From 5aa003612c9c1dba1f73173a175194b44e4dc938 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:45:45 +0200 Subject: [PATCH 378/384] Demonstrate stale focus refresh overwriting a click When clicking in the commits view of lazygit running in an unfocused VS Code window, VS Code first sends us the focus-in event and then the mouse-click. The focus-in refresh captures the selection when it starts, then we handle the mouse click and you briefly see the clicked row getting selected, but then the selection flashes back to the original row as the refresh restores it when done. --- pkg/gui/gui_driver.go | 19 ++++++++++++ pkg/integration/components/test_driver.go | 6 ++++ pkg/integration/components/test_test.go | 4 +++ pkg/integration/components/view_driver.go | 8 +++++ ..._clicked_commit_selected_after_focus_in.go | 29 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + pkg/integration/types/types.go | 3 ++ 7 files changed, 70 insertions(+) create mode 100644 pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 74a8109a7..9e06f483b 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -77,6 +77,25 @@ func (self *GuiDriver) FocusIn() { self.waitTillIdle() } +func (self *GuiDriver) FocusInAndClick(x, y int) { + self.CheckAllToastsAcknowledged() + + self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( + tcell.NewEventFocus(true), + 0, + )) + self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( + tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0), + 0, + )) + self.waitTillIdle() + self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( + tcell.NewEventMouse(x, y, tcell.ButtonNone, 0), + 0, + )) + self.waitTillIdle() +} + func (self *GuiDriver) PretendMergeOrRebaseStartedInLazygit() { self.gui.onUIThread(func() error { self.gui.State.SetMergeOrRebaseStartedInLazygit(true) diff --git a/pkg/integration/components/test_driver.go b/pkg/integration/components/test_driver.go index 42ce8ac35..19219707a 100644 --- a/pkg/integration/components/test_driver.go +++ b/pkg/integration/components/test_driver.go @@ -73,6 +73,12 @@ func (self *TestDriver) FocusIn() { self.Wait(self.inputDelay) } +func (self *TestDriver) focusInAndClick(x, y int) { + self.SetCaption(fmt.Sprintf("Focusing window and clicking %d, %d", x, y)) + self.gui.FocusInAndClick(x, y) + self.Wait(self.inputDelay) +} + func (self *TestDriver) typeContent(content string) { for _, char := range content { self.pressFast(string(char)) diff --git a/pkg/integration/components/test_test.go b/pkg/integration/components/test_test.go index 8fd4417ea..7196779eb 100644 --- a/pkg/integration/components/test_test.go +++ b/pkg/integration/components/test_test.go @@ -41,6 +41,10 @@ func (self *fakeGuiDriver) Click(x, y int) { func (self *fakeGuiDriver) FocusIn() { } +func (self *fakeGuiDriver) FocusInAndClick(x, y int) { + self.clickedCoordinates = append(self.clickedCoordinates, coordinate{x: x, y: y}) +} + func (self *fakeGuiDriver) Keys() config.KeybindingConfig { return config.KeybindingConfig{} } diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 920c610be..2cfaba338 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -475,6 +475,14 @@ func (self *ViewDriver) Click(x, y int) *ViewDriver { return self } +func (self *ViewDriver) FocusInAndClick(x, y int) *ViewDriver { + offsetX, offsetY, _, _ := self.getView().Dimensions() + + self.t.focusInAndClick(offsetX+1+x, offsetY+1+y) + + 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/commit/keep_clicked_commit_selected_after_focus_in.go b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go new file mode 100644 index 000000000..c951ea125 --- /dev/null +++ b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go @@ -0,0 +1,29 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var KeepClickedCommitSelectedAfterFocusIn = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Keep a clicked commit selected when focus-in immediately precedes the click", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(2) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("commit-02").IsSelected(), + Contains("commit-01"), + ). + FocusInAndClick(1, 1). + /* EXPECTED: + SelectedLine(Contains("commit-01")) + ACTUAL: */ + SelectedLine(Contains("commit-02")) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 1bb06741f..07a12e2be 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -140,6 +140,7 @@ var tests = []*components.IntegrationTest{ commit.Highlight, commit.History, commit.HistoryComplex, + commit.KeepClickedCommitSelectedAfterFocusIn, commit.KeepSelectedCommitAfterExternalCommit, commit.NewBranch, commit.PasteCommitMessage, diff --git a/pkg/integration/types/types.go b/pkg/integration/types/types.go index 12009315a..ea76c45be 100644 --- a/pkg/integration/types/types.go +++ b/pkg/integration/types/types.go @@ -31,6 +31,9 @@ type GuiDriver interface { // Simulate the terminal window regaining focus (which triggers a reload of // changed config files) FocusIn() + // Simulate a terminal dispatching focus-in immediately followed by a click, + // without waiting for the focus refresh to finish in between. + FocusInAndClick(int, int) Keys() config.KeybindingConfig CurrentContext() types.Context ContextForView(viewName string) types.Context From 3d9318e2a777cadf1c1e7718682ace1e8f69a606 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:48:06 +0200 Subject: [PATCH 379/384] Preserve commit clicks during focus refreshes This fixes the problem described in the previous commit; we no longer capture the selection at the start of the refresh. There's no reason to do that (we don't do it for branches either). It is enough to capture the selection in the final bounce, before we assign the new model slice. --- pkg/gui/controllers/helpers/refresh_helper.go | 26 +++++++++---------- ..._clicked_commit_selected_after_focus_in.go | 3 --- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index cf097b67e..29784b5ff 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -322,7 +322,7 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr var capturedReflog capturedReflogState var capturedBranches capturedBranchState self.captureOnUIThread(calledFromWorker, env.background, func() { - capturedCommits = self.captureCommitsState(options.CommitSelection) + capturedCommits = self.captureCommitsState() capturedReflog = self.captureReflogState() capturedBranches = self.captureBranchState() }) @@ -704,7 +704,6 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo // worker computes from an immutable snapshot rather than reading state the UI // thread concurrently mutates. type capturedCommitState struct { - selectionRange *localCommitSelectionRange limitCommits bool showWholeGitGraph bool filterPath string @@ -716,17 +715,12 @@ type capturedCommitState struct { // captureCommitsState reads the commits refresh's model/context/mode inputs // into an immutable snapshot. It must run on the UI thread. -func (self *RefreshHelper) captureCommitsState(commitSelection types.CommitSelectionBehavior) capturedCommitState { - var selectionRange *localCommitSelectionRange - if commitSelection == types.KeepCommitSelectionByHash { - selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() - selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) - } - +// The selection is captured later, when applying the refresh, so user input +// received while the git work is in flight is not overwritten. +func (self *RefreshHelper) captureCommitsState() capturedCommitState { parentCtx := self.c.Contexts().CommitFiles.GetParentContext() return capturedCommitState{ - selectionRange: selectionRange, limitCommits: self.c.Contexts().LocalCommits.GetLimitCommits(), showWholeGitGraph: self.c.Contexts().LocalCommits.GetShowWholeGitGraph(), filterPath: self.c.Modes().Filtering.GetPath(), @@ -815,6 +809,12 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, workingTreeState := env.git.Status.WorkingTreeState() self.onUIThreadUnlessRepoChanged(env, func() { + var selectionRange *localCommitSelectionRange + if commitSelection == types.KeepCommitSelectionByHash { + selectedIdx, rangeStartIdx, rangeSelectMode := self.c.Contexts().LocalCommits.GetSelectionRangeAndMode() + selectionRange = captureLocalCommitSelectionRange(self.c.Model().Commits, selectedIdx, rangeStartIdx, rangeSelectMode) + } + self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits self.RefreshAuthors(commits) @@ -833,10 +833,10 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, scrollSelectionIntoView = true } case types.KeepCommitSelectionByHash: - if captured.selectionRange != nil { - selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, captured.selectionRange) + if selectionRange != nil { + selectedIdx, rangeStartIdx, didMove, found := findLocalCommitSelectionRange(commits, selectionRange) if found { - self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, captured.selectionRange.mode) + self.c.Contexts().LocalCommits.SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, selectionRange.mode) scrollSelectionIntoView = didMove } } diff --git a/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go index c951ea125..cfd4e23c5 100644 --- a/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go +++ b/pkg/integration/tests/commit/keep_clicked_commit_selected_after_focus_in.go @@ -21,9 +21,6 @@ var KeepClickedCommitSelectedAfterFocusIn = NewIntegrationTest(NewIntegrationTes Contains("commit-01"), ). FocusInAndClick(1, 1). - /* EXPECTED: SelectedLine(Contains("commit-01")) - ACTUAL: */ - SelectedLine(Contains("commit-02")) }, }) From 9d7ca51ee7ccf70cec0a91704996b566e3259f24 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 25 Jul 2026 18:36:28 +0200 Subject: [PATCH 380/384] Small addition to CONTRIBUTING.md --- CONTRIBUTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49a9a625c..04095ba77 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,5 +30,6 @@ There are other forms of contributions to a project besides source code that are - File feature requests for new functionality that you want to see in lazygit. I have a lot of ideas for future improvement myself, but I have also implemented a lot of feature ideas that weren't mine, and I'm grateful for those ideas. (Of course, there are also lots of feature requests that I don't implement, so don't be disappointed if I don't jump on yours.) - Help make other people's bug reports reproducible. Sometimes people report bugs that they have only seen once, and in such a case it can be helpful to come up with reproducible scenarios. - Help complete or improve the translation into other languages; join https://crowdin.com/project/lazygit for that. +- Run a master build! This is probably the most valuable way to help me. Test the latest master not just by occasionally trying it, but by actually using it for your daily work; report any issues that you find. This will help prevent having to release hotfix updates for regressions that are only noticed by users updating to a new release. Importantly, if you file issues (whether bug reports or feature requests), stay around to answer questions and discuss your issue. There are few things that I find more annoying than spending time on responding to someone's issue (sometimes even making a PR that addresses it), and to then never hear from the OP again. So please set up your Github notifications so that you see when there's activity on your issue, and continue to participate. From 6a022241d2aecc852af7be4faa640dee5e9472f9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 28 Jul 2026 17:57:24 +0200 Subject: [PATCH 381/384] Add test to demonstrate a problem with custom patches and directories sharing a prefix We had the same bug in the files panel, and fixed it in a5eec48b4b8, but forgot to make the equivalent change to the commit files panel. --- .../select_direcories_sharing_prefix.go | 59 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 60 insertions(+) create mode 100644 pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go diff --git a/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go b/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go new file mode 100644 index 000000000..2c0059dd0 --- /dev/null +++ b/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go @@ -0,0 +1,59 @@ +package patch_building + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SelectDirecoriesSharingPrefix = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Select directories sharing a prefix in the commit files view and add them to a custom patch", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateFileAndAdd("foo/file", "file1 content") + shell.CreateFileAndAdd("foobar/file", "file2 content") + shell.Commit("first commit") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + Lines( + Contains("first commit").IsSelected(), + ). + PressEnter() + + t.Views().CommitFiles(). + IsFocused(). + Lines( + Equals("▼ /").IsSelected(), + Equals(" ▼ foo"), + Equals(" A file"), + Equals(" ▼ foobar"), + Equals(" A file"), + ). + SelectNextItem(). + Press(keys.Universal.ToggleRangeSelect). + NavigateToLine(Contains("foobar")). + PressPrimaryAction(). + Lines( + Equals("▼ /"), + Equals(" ▼ foo").IsSelected(), + Equals(" ● file").IsSelected(), + Equals(" ▼ foobar").IsSelected(), + /* EXPECTED: + Equals(" ● file"), + ACTUAL: */ + Equals(" A file"), + ) + + t.Views().Information().Content(Contains("Building patch")) + + t.Views().Secondary().Content( + /* EXPECTED: + Contains("foo/file").Contains("foobar/file"), + ACTUAL: */ + Contains("foo/file").DoesNotContain("foobar/file"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 07a12e2be..77e54e265 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -382,6 +382,7 @@ var tests = []*components.IntegrationTest{ patch_building.RenamedFileWhole, patch_building.ResetWithEscape, patch_building.SelectAllFiles, + patch_building.SelectDirecoriesSharingPrefix, patch_building.SpecificSelection, patch_building.StartNewPatch, patch_building.ToggleDirectory, From 8fefe2b9335cb0879b24327eff4296543b474ac8 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 28 Jul 2026 17:37:30 +0200 Subject: [PATCH 382/384] Cleanup: move variable assignment out of the loop It never changes inside this function, so there's no need to recompute it with every loop iteration. Equivalent to the change that was made to isDescendentOfSelectedNodes in files_controller.go in d0c6e27fee9b4. --- pkg/gui/controllers/commits_files_controller.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index d129b3f90..5d4377fe1 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -642,9 +642,10 @@ func normalisedSelectedCommitFileNodes(selectedNodes []*filetree.CommitFileNode) } func isDescendentOfSelectedCommitFileNodes(node *filetree.CommitFileNode, selectedNodes []*filetree.CommitFileNode) bool { + nodePath := node.GetPath() + for _, selectedNode := range selectedNodes { selectedNodePath := selectedNode.GetPath() - nodePath := node.GetPath() if strings.HasPrefix(nodePath, selectedNodePath) && nodePath != selectedNodePath { return true From c7acf383990273b03cea012e4d14ab940c73333e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 28 Jul 2026 17:40:03 +0200 Subject: [PATCH 383/384] Make isDescendentOfSelectedCommitFileNodes work for the root item The root item's path is ".", and the path of a file at top level is "./file". When using GetPath, this gives us "." and "file", respectively, and isDescendentOfSelectedCommitFileNodes would return false for these. Working with the internal paths (i.e. without stripping the leading "./") fixes this. There is no known breakage that is caused by this, that's why I'm not adding an integration test that demonstrates a bug. Equivalent to the change that was made to isDescendentOfSelectedNodes in files_controller.go in 302b621b681. --- pkg/gui/controllers/commits_files_controller.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 5d4377fe1..da24b155c 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -642,10 +642,10 @@ func normalisedSelectedCommitFileNodes(selectedNodes []*filetree.CommitFileNode) } func isDescendentOfSelectedCommitFileNodes(node *filetree.CommitFileNode, selectedNodes []*filetree.CommitFileNode) bool { - nodePath := node.GetPath() + nodePath := node.GetInternalPath() for _, selectedNode := range selectedNodes { - selectedNodePath := selectedNode.GetPath() + selectedNodePath := selectedNode.GetInternalPath() if strings.HasPrefix(nodePath, selectedNodePath) && nodePath != selectedNodePath { return true From 1d107721f2b0c063913fc80b3f1bedd00fa01e50 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 28 Jul 2026 17:45:40 +0200 Subject: [PATCH 384/384] Fix multi-selection of files with common prefix not working in commit files panel Equivalent to the change that was made to isDescendentOfSelectedNodes in files_controller.go in a5eec48b4b8. --- pkg/gui/controllers/commits_files_controller.go | 6 +++++- pkg/gui/controllers/files_controller.go | 2 ++ .../patch_building/select_direcories_sharing_prefix.go | 6 ------ 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index da24b155c..6c8f49e1f 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -645,9 +645,13 @@ func isDescendentOfSelectedCommitFileNodes(node *filetree.CommitFileNode, select nodePath := node.GetInternalPath() for _, selectedNode := range selectedNodes { + if selectedNode.IsFile() { + continue + } + selectedNodePath := selectedNode.GetInternalPath() - if strings.HasPrefix(nodePath, selectedNodePath) && nodePath != selectedNodePath { + if strings.HasPrefix(nodePath, selectedNodePath+"/") { return true } } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 656da1bf5..567b0b6e5 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1552,6 +1552,8 @@ func normalisedSelectedNodes(selectedNodes []*filetree.FileNode) []*filetree.Fil }) } +// NOTE: there's a duplicate of this function in commits_files_controller.go; if you make +// changes here, make them there, too. (We should unify them using generics.) func isDescendentOfSelectedNodes(node *filetree.FileNode, selectedNodes []*filetree.FileNode) bool { nodePath := node.GetInternalPath() diff --git a/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go b/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go index 2c0059dd0..bd20e4ff5 100644 --- a/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go +++ b/pkg/integration/tests/patch_building/select_direcories_sharing_prefix.go @@ -41,19 +41,13 @@ var SelectDirecoriesSharingPrefix = NewIntegrationTest(NewIntegrationTestArgs{ Equals(" ▼ foo").IsSelected(), Equals(" ● file").IsSelected(), Equals(" ▼ foobar").IsSelected(), - /* EXPECTED: Equals(" ● file"), - ACTUAL: */ - Equals(" A file"), ) t.Views().Information().Content(Contains("Building patch")) t.Views().Secondary().Content( - /* EXPECTED: Contains("foo/file").Contains("foobar/file"), - ACTUAL: */ - Contains("foo/file").DoesNotContain("foobar/file"), ) }, })