Compare commits

...
Author SHA1 Message Date
Stefan Haller aa331e52b8 Fix assigning custom key to pullFiles command in the Commits panel (#4617)
- **PR Description**

Improve the dispatching of key bindings so that remapping "pullFiles" to
a different key works correctly in the Commits panel.

Fixes #4614.
2025-06-05 13:24:34 +02:00
Stefan Haller c752f3529b Remove the pick vs. pull hack
Previously we would call pullFiles() from the pick() handler if we were not in a
rebase, assuming that the default keybinding for both is "p". This needn't be
the case of course, if the user has remapped one or the other.

The consequence of this was that swapping the keybindings for "pullFiles" and
"pushFiles" would work in all panels except the Commits panel (unless "pick" was
also remapped in the same way).

Fix this by using the new AllowFurtherDispatching mechanism of DisabledReasons
to pass the keybinding on to the next handler.
2025-06-05 13:20:39 +02:00
Stefan Haller 3e26be9845 Optionally pass disabled commands on to next handler
If a DisabledReason has its AllowFurtherDispatching flag set, it is returned as
a ErrKeybindingNotHandled error, instead of shown as a toast right away. This
allows gocui to continue to dispatch the keybinding, and we can unwrap the error
at the other end (in our global ErrorHandler) and display it then.

This allows having keybindings for the same key at the local and global levels,
and they will continue to be dispatched even if the first one returns a
DisabledReason. It is opt-in, so we only use it for cases where we know that a
local and a global handler share the same (default) keybinding.
2025-06-05 13:20:38 +02:00
Stefan Haller 37b118f4fb Cleanup: restructure code for clarity
There was no reason to declare a variable for disabledReason, assign it inside
the "if binding.GetDisabledReason != nil" statement, and then check its value
again after that if statement. Move all that code inside the first if statement
to make the control flow easier to understand.
2025-06-05 13:20:38 +02:00
Stefan Haller 9e64f7dd66 Bump gocui and adapt lazygit code
Adaptions are for this gocui commit:

Cleanup: remove Is* error functions

- Use errors.Is instead of quality comparisons. This is better because it
  matches wrapped errors as well, which we will need later in this branch.
- Inline the errors.Is calls at the call sites. This is idiomatic go, we don't
  need helper functions for this.

See https://go.dev/blog/go1.13-errors for more about this.
2025-06-05 13:20:38 +02:00
Stefan Haller ac0c3db472 Fix wrong inactive highlight when switching between repos (#4621)
- **PR Description**

When switching between repos, each repo might have a different focused
panel; in this case, the previously focused panel would show the
"inactive" highlight. By default this is only bold text, so it's barely
noticeable, but it becomes more pronounced when setting e.g.

```yml
gui:
  theme:
    inactiveViewSelectedLineBgColor:
      - "#666666"
```

I noticed this especially when entering or leaving submodules; for
example, enter a submodule by pressing enter in the Files panel, then
switch to the Commits panel in the submodule, then press Esc to go back
to the parent repo. This would put the focus back into the Files panel,
but keep the inactive highlight in the Commits panel.
2025-06-05 13:00:54 +02:00
Stefan Haller f185e1a5e3 Fix wrong inactive highlight when switching between repos
When switching between repos, each repo might have a different focused panel; in
this case, the previously focused panel would show the "inactive" highlight. By
default this is only bold text, so it's barely noticeable, but it becomes more
pronounced when setting e.g.

gui:
  theme:
    inactiveViewSelectedLineBgColor:
      - "#666666"

I noticed this especially when entering or leaving submodules; for example,
enter a submodule by pressing enter in the Files panel, then switch to the
Commits panel in the submodule, then press Esc to go back to the parent repo.
This would put the focus back into the Files panel, but keep the inactive
highlight in the Commits panel.
2025-06-05 12:58:38 +02:00
Stefan Haller ac7de7e8de Fix resetting to a branch when a tag shares the same name, or vice versa (#4571)
- **PR Description**

Allows the reset menu to have a different name that is displayed, and a
fully qualified name that git will unambiguously know what it refers
about. We could totally squash this back down to 1 input, and display to
the user the _precise_ full ref name that we are resetting to, but I
think the context they are in (branches tab versus tag tab), means that
we don't need to do that, and can continue to just show the branch name
and the tag name to the end users.

Fixes https://github.com/jesseduffield/lazygit/issues/4569
2025-06-04 20:48:32 +02:00
Chris McDonnell 706891e92b Add integration test for resetting to upstream branch with duplicate name 2025-06-04 20:43:01 +02:00
Chris McDonnell fa238809ae Use full refname instead of short to prevent disambiguation with tag
In the unlikely scenario that you have a remote branch on `origin` called
`foo`, and a local tag called `origin/foo`, git changes the behavior of
the previous command such that it produces

```
$ git for-each-ref --sort=refname --format=%(refname:short) refs/remotes

origin/branch1
remotes/origin/foo
```

with `remotes/` prepended. Presumably this is to disambiguate it from
the local tag `origin/foo`. Unfortunately, this breaks the existing
behavior of this function, so the remote branch is never shown.

By changing the command, we now get
```
$ git for-each-ref --sort=refname --format=%(refname) refs/remotes

refs/remotes/origin/branch1
refs/remotes/origin/foo
```

This allows easy parsing based on the `/`, and none of the code outside
this function has to change.

----

We previously were not showing remote HEADs for modern git versions
based on how they were formatted from "%(refname:short)".
We have decided that this is a feature, not a bug, so we are building
that into the code here.
2025-06-04 20:43:01 +02:00
Chris McDonnell 737a99b1c8 Add integration tests showing resetting to duplicate named tags and branches 2025-06-04 20:43:01 +02:00
Chris McDonnell 122d6e5f0d Add FullRefName to all reset menus 2025-06-04 20:43:01 +02:00
Stefan Haller 36de219eb7 Show default option when prompting to create a new git repo (#4596) 2025-06-04 08:25:53 +02:00
Joshua Hollander bb6c883761 Print default option when prompting to create a new git repo 2025-06-04 08:15:21 +02:00
Stefan Haller 1e5d104524 Improve PR template wrt release notes (#4607)
Mention in the PR template that PR titles will be used in release notes.
This came up in #4571.
2025-06-01 14:38:09 +02:00
Stefan Haller 946f5f130c Make PR template mention that PR titles will be used in release notes 2025-06-01 14:34:12 +02:00
Stefan Haller a8c73f1ff2 Replace literal with ConfigFilename constant (#4613)
A tiny clean-up where I assume it is a good idea to re-use the common
specification of the `"config.yml"` name.
2025-05-31 17:34:06 +02:00
Mateusz Łoskot d60fe437ef Replace literal with ConfigFilename constant
Signed-off-by: Mateusz Łoskot <mateusz@loskot.net>
2025-05-31 02:28:39 +02:00
Stefan Haller 8280fdedb7 Use branchPrefix when moving commits to new branch (#4604)
- **PR Description**

When pressing N to move new commits to a new branch we get greeted with
an empty prompt, this PR makes it so we fill the empty prompt with a
suggestion taken from branchPrefix, similar to the good old create a new
branch.
2025-05-29 22:42:37 +02:00
Elias Assaf fdf9726c37 Use branchPrefix on moving commits to a new branch
Signed-off-by: Elias Assaf <elyas51000@gmail.com>
2025-05-29 22:39:26 +02:00
Elias Assaf 816d0c0820 Add a function to suggest a branch name based on branchPrefix
Moving the getter of the suggested branch name to a separate function
allows us to reuse it in situations where we are not calling the regular
create new branch function, such as move commits to a new branch

Signed-off-by: Elias Assaf <elyas51000@gmail.com>
2025-05-29 21:07:22 +03:00
Stefan Haller 5b4d009f55 Fix branch selection jumping back on background fetch (#4597)
- **PR Description**

When refreshing the branches list, we have code to keep the same branch
selected even when the refresh changes the sort order; this code
remembers the selected branch before the refresh, and then tries to
select it again afterwards (looking it up by name) if it is still there.

However, we stored the previously selected branch too early, before even
obtaining the branches list; if the user moved the selection between
that point and the end of the refresh, it would jump back. Fix this by
remembering the previous selection only at the last moment, right before
assigning the new branches slice.

We still have a race condition here between the UI code that manages the
selection as the user presses arrow keys, and the background thread
doing the refresh that reads and restores the selection; however, the
race was there before, and we make it neither better nor worse with this
PR. It doesn't seem to be a problem in practice.

Fixes #4116.
2025-05-29 14:45:09 +02:00
Stefan Haller fce4816a0d Fix branch selection jumping back on background fetch
When refreshing the branches list, we have code to keep the same branch selected
even when the refresh changes the sort order; this code remembers the selected
branch before the refresh, and then tries to select it again afterwards (looking
it up by name) if it is still there.

However, we stored the previously selected branch too early, before even
obtaining the branches list; if the user moved the selection between that point
and the end of the refresh, it would jump back. Fix this by remembering the
previous selection only at the last moment, right before assigning the new
branches slice.

We still have a race condition here between the UI code that manages the
selection as the user presses arrow keys, and the background thread doing the
refresh that reads and restores the selection; however, the race was there
before, and we make it neither better nor worse with this PR. It doesn't seem to
be a problem in practice.
2025-05-29 14:41:59 +02:00
Stefan Haller 676b5c2287 Kill background fetch when it requests a passphrase (#4588)
Previously we would enter a newline at the password prompt, which would
cause the fetch to fail. The problem with this was that if you have many
remotes, the fetch would sometimes hang for some reason; I don't totally
understand how that happened, but I guess the many ssh processes
requesting passwords would somehow interfere with each other. Avoid this
by simply killing the git fetch process the moment it requests the first
password.
2025-05-29 14:39:39 +02:00
Stefan Haller d5bd30474c Kill background fetch when it requests a passphrase
Previously we would enter a newline at the password prompt, which would cause
the fetch to fail. The problem with this was that if you have many remotes, the
fetch would sometimes hang for some reason; I don't totally understand how that
happened, but I guess the many ssh processes requesting passwords would somehow
interfere with each other. Avoid this by simply killing the git fetch process
the moment it requests the first password.
2025-05-29 14:36:01 +02:00
Stefan Haller 75a8c0c73e Pass cmdObj instead of task to processOutput
This is a preparation for the next commit, where we will need more from the
cmdObj in processOutput.
2025-05-29 14:36:01 +02:00
Stefan Haller c4bfdaeaf3 Fix selecting large hunks, and fix problems editing very long commit descriptions (#4589)
See https://github.com/jesseduffield/gocui/pull/80 for details.

Fixes #4470.
2025-05-29 14:35:35 +02:00
Stefan Haller 5dbd91038a Bump gocui
See https://github.com/jesseduffield/gocui/pull/80.

This fixes selecting hunks in the staging view that are longer than the screen.
2025-05-29 14:33:17 +02:00
Stefan Haller 21b8b2827e Add user config for hiding the root item in the file tree (#4593)
- **PR Description**

In #4346 we added a `/` root item in the Files and CommitFiles panels
whenever there is more than one top-level item. We made it
unconditional, but I promised to add a config as soon as users ask for
being able to disable it. For a while I was able to convince users who
asked for it that it is useful and they don't want to turn it off, but
now there's a [stronger
request](https://github.com/jesseduffield/lazygit/discussions/4590#discussioncomment-13254924)
from someone who refuses to upgrade to the current version, and we don't
want that.

So, add a config option `gui.showRootItemInFileTree` that is true by
default.
2025-05-26 21:18:19 +02:00
Stefan Haller 3cff48437e Add user config gui.addRootItemInFileTree 2025-05-24 18:17:25 +02:00
Stefan Haller ffb8586795 Pass common.Common to file trees instead of just the Log
We will need a user config in the file tree in the next commit, and passing the
entire common is the easiest way to do that while ensuring hot-reloading when
users change the config while lazygit is running.
2025-05-24 17:56:43 +02:00
46 changed files with 739 additions and 201 deletions
+3 -1
View File
@@ -11,6 +11,8 @@
* [ ] You've read through your own file changes for silly mistakes etc
<!--
Be sure to name your PR with an imperative e.g. 'Add worktrees view'
Be sure to name your PR with an imperative e.g. 'Add worktrees view', and make sure the title
is suitable to be included as a bullet point in release notes (i.e. phrased from a user's point
of view).
see https://github.com/jesseduffield/lazygit/releases/tag/v0.40.0 for examples
-->
+3
View File
@@ -190,6 +190,9 @@ gui:
# This can be toggled from within Lazygit with the '`' key, but that will not change the default.
showFileTree: true
# If true, add a "/" root item in the file tree representing the root of the repository. It is only added when necessary, i.e. when there is more than one item at top level.
showRootItemInFileTree: true
# If true, show the number of lines changed per file in the Files view
showNumstatInFilesView: false
+1 -1
View File
@@ -15,7 +15,7 @@ require (
github.com/integrii/flaggy v1.4.0
github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c
github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd
github.com/jesseduffield/gocui v0.3.1-0.20250522064656-07eb9218e08e
github.com/jesseduffield/gocui v0.3.1-0.20250605111917-fc5387961412
github.com/jesseduffield/kill v0.0.0-20250101124109-e216ddbe133a
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5
github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e
+2 -2
View File
@@ -194,8 +194,8 @@ github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c h1:tC2Paiis
github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c/go.mod h1:F2fEBk0ddf6ixrBrJjY7phfQ3hL9rXG0uSjvwYe50bE=
github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd h1:ViKj6qth8FgcIWizn9KiACWwPemWSymx62OPN0tHT+Q=
github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd/go.mod h1:lRhCiBr6XjQrvcQVa+UYsy/99d3wMXn/a0nSQlhnhlA=
github.com/jesseduffield/gocui v0.3.1-0.20250522064656-07eb9218e08e h1:NuSXZHAUSo+dSAYArlaDpmKgxVpojuKvNcRkYJcQxkY=
github.com/jesseduffield/gocui v0.3.1-0.20250522064656-07eb9218e08e/go.mod h1:sLIyZ2J42R6idGdtemZzsiR3xY5EF0KsvYEGh3dQv3s=
github.com/jesseduffield/gocui v0.3.1-0.20250605111917-fc5387961412 h1:8z1CpdCy9nzdj47lSLbDbCVmR5MgXsknYsuuHpzYk5M=
github.com/jesseduffield/gocui v0.3.1-0.20250605111917-fc5387961412/go.mod h1:sLIyZ2J42R6idGdtemZzsiR3xY5EF0KsvYEGh3dQv3s=
github.com/jesseduffield/kill v0.0.0-20250101124109-e216ddbe133a h1:UDeJ3EBk04bXDLOPvuqM3on8HvyJfISw0+UMqW+0a4g=
github.com/jesseduffield/kill v0.0.0-20250101124109-e216ddbe133a/go.mod h1:FSWDLKT0NQpntbDd1H3lbz51fhCVlMzy/J0S6nM727Q=
github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY=
+9 -5
View File
@@ -96,19 +96,23 @@ func (self *RemoteLoader) getRemoteBranchesByRemoteName() (map[string][]*models.
cmdArgs := NewGitCmd("for-each-ref").
Arg(fmt.Sprintf("--sort=%s", sortOrder)).
Arg("--format=%(refname:short)").
Arg("--format=%(refname)").
Arg("refs/remotes").
ToArgv()
err := self.cmd.New(cmdArgs).DontLog().RunAndProcessLines(func(line string) (bool, error) {
line = strings.TrimSpace(line)
split := strings.SplitN(line, "/", 2)
if len(split) != 2 {
split := strings.SplitN(line, "/", 4)
if len(split) != 4 {
return false, nil
}
remoteName := split[2]
name := split[3]
if name == "HEAD" {
return false, nil
}
remoteName := split[0]
name := split[1]
_, ok := remoteBranchesByRemoteName[remoteName]
if !ok {
+26 -20
View File
@@ -10,7 +10,6 @@ import (
"time"
"github.com/go-errors/errors"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/sasha-s/go-deadlock"
"github.com/sirupsen/logrus"
@@ -295,14 +294,10 @@ const (
Token
)
// Whenever we're asked for a password we just enter a newline, which will
// eventually cause the command to fail.
// Whenever we're asked for a password we return a nil channel to tell the
// caller to kill the process.
var failPromptFn = func(CredentialType) <-chan string {
ch := make(chan string)
go func() {
ch <- "\n"
}()
return ch
return nil
}
func (self *cmdObjRunner) runWithCredentialHandling(cmdObj *CmdObj) error {
@@ -340,7 +335,7 @@ func (self *cmdObjRunner) runAndDetectCredentialRequest(
tr := io.TeeReader(handler.stdoutPipe, cmdWriter)
go utils.Safe(func() {
self.processOutput(tr, handler.stdinPipe, promptUserForCredential, cmdObj.GetTask())
self.processOutput(tr, handler.stdinPipe, promptUserForCredential, cmdObj)
})
})
}
@@ -349,9 +344,10 @@ func (self *cmdObjRunner) processOutput(
reader io.Reader,
writer io.Writer,
promptUserForCredential func(CredentialType) <-chan string,
task gocui.Task,
cmdObj *CmdObj,
) {
checkForCredentialRequest := self.getCheckForCredentialRequestFunc()
task := cmdObj.GetTask()
scanner := bufio.NewScanner(reader)
scanner.Split(bufio.ScanBytes)
@@ -360,16 +356,26 @@ func (self *cmdObjRunner) processOutput(
askFor, ok := checkForCredentialRequest(newBytes)
if ok {
responseChan := promptUserForCredential(askFor)
if task != nil {
task.Pause()
}
toInput := <-responseChan
if task != nil {
task.Continue()
}
// If the return data is empty we don't write anything to stdin
if toInput != "" {
_, _ = writer.Write([]byte(toInput))
if responseChan == nil {
// Returning a nil channel means we should kill the process.
// Note that we don't break the loop after this, because we
// still need to drain the output, otherwise the Wait() call
// later might block.
if err := Kill(cmdObj.GetCmd()); err != nil {
self.log.Error(err)
}
} else {
if task != nil {
task.Pause()
}
toInput := <-responseChan
if task != nil {
task.Continue()
}
// If the return data is empty we don't write anything to stdin
if toInput != "" {
_, _ = writer.Write([]byte(toInput))
}
}
}
}
@@ -120,8 +120,8 @@ func TestProcessOutput(t *testing.T) {
reader := strings.NewReader(scenario.output)
writer := &strings.Builder{}
task := gocui.NewFakeTask()
runner.processOutput(reader, writer, toChanFn(scenario.promptUserForCredential), task)
cmdObj := &CmdObj{task: gocui.NewFakeTask()}
runner.processOutput(reader, writer, toChanFn(scenario.promptUserForCredential), cmdObj)
if writer.String() != scenario.expectedToWrite {
t.Errorf("expected to write '%s' but got '%s'", scenario.expectedToWrite, writer.String())
+1 -1
View File
@@ -136,7 +136,7 @@ func NewAppConfig(
}
func ConfigDir() string {
_, filePath := findConfigFile("config.yml")
_, filePath := findConfigFile(ConfigFilename)
return filepath.Dir(filePath)
}
+3
View File
@@ -123,6 +123,8 @@ type GuiConfig struct {
// If true, display the files in the file views as a tree. If false, display the files as a flat list.
// This can be toggled from within Lazygit with the '`' key, but that will not change the default.
ShowFileTree bool `yaml:"showFileTree"`
// If true, add a "/" root item in the file tree representing the root of the repository. It is only added when necessary, i.e. when there is more than one item at top level.
ShowRootItemInFileTree bool `yaml:"showRootItemInFileTree"`
// If true, show the number of lines changed per file in the Files view
ShowNumstatInFilesView bool `yaml:"showNumstatInFilesView"`
// If true, show a random tip in the command log when Lazygit starts
@@ -764,6 +766,7 @@ func GetDefaultConfig() *UserConfig {
ShowBottomLine: true,
ShowPanelJumps: true,
ShowFileTree: true,
ShowRootItemInFileTree: true,
ShowNumstatInFilesView: false,
ShowRandomTip: true,
ShowIcons: false,
+1 -1
View File
@@ -29,7 +29,7 @@ var (
func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext {
viewModel := filetree.NewCommitFileTreeViewModel(
func() []*models.CommitFile { return c.Model().CommitFiles },
c.Log,
c.Common,
c.UserConfig().Gui.ShowFileTree,
)
+2 -2
View File
@@ -24,14 +24,14 @@ var (
func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
viewModel := filetree.NewFileTreeViewModel(
func() []*models.File { return c.Model().Files },
c.Log,
c.Common,
c.UserConfig().Gui.ShowFileTree,
)
getDisplayStrings := func(_ int, _ int) [][]string {
showFileIcons := icons.IsIconEnabled() && c.UserConfig().Gui.ShowFileIcons
showNumstat := c.UserConfig().Gui.ShowNumstatInFilesView
lines := presentation.RenderFileTree(viewModel, c.Model().Submodules, showFileIcons, showNumstat, &c.UserConfig().Gui.CustomIcons)
lines := presentation.RenderFileTree(viewModel, c.Model().Submodules, showFileIcons, showNumstat, &c.UserConfig().Gui.CustomIcons, c.UserConfig().Gui.ShowRootItemInFileTree)
return lo.Map(lines, func(line string, _ int) []string {
return []string{line}
})
@@ -361,7 +361,7 @@ func (self *BasicCommitsController) newBranch(commit *models.Commit) error {
}
func (self *BasicCommitsController) createResetMenu(commit *models.Commit) error {
return self.c.Helpers().Refs.CreateGitResetMenu(commit.Hash())
return self.c.Helpers().Refs.CreateGitResetMenu(commit.Hash(), commit.Hash())
}
func (self *BasicCommitsController) checkout(commit *models.Commit) error {
+3 -2
View File
@@ -326,7 +326,8 @@ func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branc
LabelColumns: []string{upstreamResetOptions},
OpensMenu: true,
OnPress: func() error {
err := self.c.Helpers().Refs.CreateGitResetMenu(upstream)
// We only can invoke this when the remote branch is stored locally, so using the selectedBranch here is fine.
err := self.c.Helpers().Refs.CreateGitResetMenu(upstream, selectedBranch.FullUpstreamRefName())
if err != nil {
return err
}
@@ -686,7 +687,7 @@ func (self *BranchesController) createSortMenu() error {
}
func (self *BranchesController) createResetMenu(selectedBranch *models.Branch) error {
return self.c.Helpers().Refs.CreateGitResetMenu(selectedBranch.Name)
return self.c.Helpers().Refs.CreateGitResetMenu(selectedBranch.Name, selectedBranch.FullRefName())
}
func (self *BranchesController) rename(branch *models.Branch) error {
+1 -1
View File
@@ -1144,7 +1144,7 @@ func (self *FilesController) stash() error {
}
func (self *FilesController) createResetToUpstreamMenu() error {
return self.c.Helpers().Refs.CreateGitResetMenu("@{upstream}")
return self.c.Helpers().Refs.CreateGitResetMenu("@{upstream}", "@{upstream}")
}
func (self *FilesController) handleToggleDirCollapsed() error {
@@ -448,8 +448,6 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele
self.c.Mutexes().RefreshingBranchesMutex.Lock()
defer self.c.Mutexes().RefreshingBranchesMutex.Unlock()
prevSelectedBranch := self.c.Contexts().Branches.GetSelected()
reflogCommits := self.c.Model().FilteredReflogCommits
if self.c.Modes().Filtering.Active() && self.c.AppState.LocalBranchSortOrder == "recency" {
// in filter mode we filter our reflog commits to just those containing the path
@@ -484,6 +482,8 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele
self.c.Log.Error(err)
}
prevSelectedBranch := self.c.Contexts().Branches.GetSelected()
self.c.Model().Branches = branches
if refreshWorktrees {
+26 -15
View File
@@ -231,7 +231,7 @@ func (self *RefsHelper) CreateSortOrderMenu(sortOptionsOrder []string, onSelecte
})
}
func (self *RefsHelper) CreateGitResetMenu(ref string) error {
func (self *RefsHelper) CreateGitResetMenu(name string, ref string) error {
type strengthWithKey struct {
strength string
label string
@@ -249,7 +249,7 @@ func (self *RefsHelper) CreateGitResetMenu(ref string) error {
return &types.MenuItem{
LabelColumns: []string{
row.label,
style.FgRed.Sprintf("reset --%s %s", row.strength, ref),
style.FgRed.Sprintf("reset --%s %s", row.strength, name),
},
OnPress: func() error {
self.c.LogAction("Reset")
@@ -261,7 +261,7 @@ func (self *RefsHelper) CreateGitResetMenu(ref string) error {
})
return self.c.Menu(types.CreateMenuOptions{
Title: fmt.Sprintf("%s %s", self.c.Tr.ResetTo, ref),
Title: fmt.Sprintf("%s %s", self.c.Tr.ResetTo, name),
Items: menuItems,
})
}
@@ -325,13 +325,10 @@ func (self *RefsHelper) NewBranch(from string, fromFormattedName string, suggest
if suggestedBranchName == "" {
var err error
suggestedBranchName, err = utils.ResolveTemplate(self.c.UserConfig().Git.BranchPrefix, nil, template.FuncMap{
"runCommand": self.c.Git().Custom.TemplateFunctionRunCommand,
})
suggestedBranchName, err = self.getSuggestedBranchName()
if err != nil {
return err
}
suggestedBranchName = strings.ReplaceAll(suggestedBranchName, "\t", " ")
}
refresh := func() error {
@@ -399,16 +396,21 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error {
return err
}
withNewBranchNamePrompt := func(baseBranchName string, f func(string, string) error) {
withNewBranchNamePrompt := func(baseBranchName string, f func(string, string) error) error {
prompt := utils.ResolvePlaceholderString(
self.c.Tr.NewBranchNameBranchOff,
map[string]string{
"branchName": baseBranchName,
},
)
suggestedBranchName, err := self.getSuggestedBranchName()
if err != nil {
return err
}
self.c.Prompt(types.PromptOpts{
Title: prompt,
Title: prompt,
InitialContent: suggestedBranchName,
HandleConfirm: func(response string) error {
self.c.LogAction(self.c.Tr.MoveCommitsToNewBranch)
newBranchName := SanitizedBranchName(response)
@@ -417,6 +419,7 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error {
})
},
})
return nil
}
isMainBranch := lo.Contains(self.c.UserConfig().Git.MainBranches, currentBranch.Name)
@@ -431,8 +434,7 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error {
Title: self.c.Tr.MoveCommitsToNewBranch,
Prompt: prompt,
HandleConfirm: func() error {
withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch)
return nil
return withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch)
},
})
return nil
@@ -452,17 +454,15 @@ func (self *RefsHelper) MoveCommitsToNewBranch() error {
{
Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchFromBaseItem, shortBaseBranchName),
OnPress: func() error {
withNewBranchNamePrompt(shortBaseBranchName, func(currentBranch string, newBranchName string) error {
return withNewBranchNamePrompt(shortBaseBranchName, func(currentBranch string, newBranchName string) error {
return self.moveCommitsToNewBranchOffOfMainBranch(currentBranch, newBranchName, baseBranchRef)
})
return nil
},
},
{
Label: fmt.Sprintf(self.c.Tr.MoveCommitsToNewBranchStackedItem, currentBranch.Name),
OnPress: func() error {
withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch)
return nil
return withNewBranchNamePrompt(currentBranch.Name, self.moveCommitsToNewBranchStackedOnCurrentBranch)
},
},
},
@@ -587,3 +587,14 @@ func (self *RefsHelper) ParseRemoteBranchName(fullBranchName string) (string, st
func IsSwitchBranchUncommittedChangesError(err error) bool {
return strings.Contains(err.Error(), "Please commit your changes or stash them before you switch branch")
}
func (self *RefsHelper) getSuggestedBranchName() (string, error) {
suggestedBranchName, err := utils.ResolveTemplate(self.c.UserConfig().Git.BranchPrefix, nil, template.FuncMap{
"runCommand": self.c.Git().Custom.TemplateFunctionRunCommand,
})
if err != nil {
return suggestedBranchName, err
}
suggestedBranchName = strings.ReplaceAll(suggestedBranchName, "\t", " ")
return suggestedBranchName, nil
}
@@ -144,14 +144,6 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [
),
Description: self.c.Tr.Pick,
Tooltip: self.c.Tr.PickCommitTooltip,
// Not displaying this because we only want to display it when a TODO commit
// is selected. A keybinding is displayed in the options view if Display is true,
// and if it's not disabled, but if we disable it whenever a non-TODO commit is
// selected, we'll be preventing pulls from happening within the commits view
// (given they both use the 'p' key). Some approaches that come to mind:
// * Allow a disabled keybinding to conditionally fallback to a global keybinding
// * Allow a separate way of deciding whether a keybinding is displayed in the options view
DisplayOnScreen: false,
},
{
Key: opts.GetKey(opts.Config.Commits.CreateFixupCommit),
@@ -623,9 +615,7 @@ func (self *LocalCommitsController) pick(selectedCommits []*models.Commit) error
return self.updateTodos(todo.Pick, selectedCommits)
}
// at this point we aren't actually rebasing so we will interpret this as an
// attempt to pull. We might revoke this later after enabling configurable keybindings
return self.pullFiles()
panic("should be disabled when not rebasing")
}
func (self *LocalCommitsController) interactiveRebase(action todo.TodoCommand, startIdx int, endIdx int) error {
@@ -1476,8 +1466,7 @@ func (self *LocalCommitsController) pickEnabled(selectedCommits []*models.Commit
}
if !self.isRebasing() {
// if not rebasing, we're going to do a pull so we don't care about the selection
return nil
return &types.DisabledReason{Text: self.c.Tr.PickIsOnlyAllowedDuringRebase, AllowFurtherDispatching: true}
}
return self.midRebaseCommandEnabled(selectedCommits, startIdx, endIdx)
@@ -158,7 +158,7 @@ func (self *RemoteBranchesController) createSortMenu() error {
}
func (self *RemoteBranchesController) createResetMenu(selectedBranch *models.RemoteBranch) error {
return self.c.Helpers().Refs.CreateGitResetMenu(selectedBranch.FullName())
return self.c.Helpers().Refs.CreateGitResetMenu(selectedBranch.FullName(), selectedBranch.FullRefName())
}
func (self *RemoteBranchesController) setAsUpstream(selectedBranch *models.RemoteBranch) error {
+1 -1
View File
@@ -302,7 +302,7 @@ func (self *TagsController) push(tag *models.Tag) error {
}
func (self *TagsController) createResetMenu(tag *models.Tag) error {
return self.c.Helpers().Refs.CreateGitResetMenu(tag.Name)
return self.c.Helpers().Refs.CreateGitResetMenu(tag.Name, tag.FullRefName())
}
func (self *TagsController) create() error {
+16 -8
View File
@@ -7,14 +7,14 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
)
func BuildTreeFromFiles(files []*models.File) *Node[models.File] {
func BuildTreeFromFiles(files []*models.File, showRootItem bool) *Node[models.File] {
root := &Node[models.File]{}
childrenMapsByNode := make(map[*Node[models.File]]map[string]*Node[models.File])
var curr *Node[models.File]
for _, file := range files {
splitPath := split("./" + file.Path)
splitPath := SplitFileTreePath(file.Path, showRootItem)
curr = root
outer:
for i := range splitPath {
@@ -63,19 +63,19 @@ func BuildTreeFromFiles(files []*models.File) *Node[models.File] {
return root
}
func BuildFlatTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFile] {
rootAux := BuildTreeFromCommitFiles(files)
func BuildFlatTreeFromCommitFiles(files []*models.CommitFile, showRootItem bool) *Node[models.CommitFile] {
rootAux := BuildTreeFromCommitFiles(files, showRootItem)
sortedFiles := rootAux.GetLeaves()
return &Node[models.CommitFile]{Children: sortedFiles}
}
func BuildTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFile] {
func BuildTreeFromCommitFiles(files []*models.CommitFile, showRootItem bool) *Node[models.CommitFile] {
root := &Node[models.CommitFile]{}
var curr *Node[models.CommitFile]
for _, file := range files {
splitPath := split("./" + file.Path)
splitPath := SplitFileTreePath(file.Path, showRootItem)
curr = root
outer:
for i := range splitPath {
@@ -115,8 +115,8 @@ func BuildTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFil
return root
}
func BuildFlatTreeFromFiles(files []*models.File) *Node[models.File] {
rootAux := BuildTreeFromFiles(files)
func BuildFlatTreeFromFiles(files []*models.File, showRootItem bool) *Node[models.File] {
rootAux := BuildTreeFromFiles(files, showRootItem)
sortedFiles := rootAux.GetLeaves()
// from top down we have merge conflict files, then tracked file, then untracked
@@ -160,3 +160,11 @@ func split(str string) []string {
func join(strs []string) string {
return strings.Join(strs, "/")
}
func SplitFileTreePath(path string, showRootItem bool) []string {
if showRootItem {
return split("./" + path)
}
return split(path)
}
+251 -16
View File
@@ -9,9 +9,10 @@ import (
func TestBuildTreeFromFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.File
expected *Node[models.File]
name string
files []*models.File
showRootItem bool
expected *Node[models.File]
}{
{
name: "no files",
@@ -31,6 +32,7 @@ func TestBuildTreeFromFiles(t *testing.T) {
Path: "dir1/b",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -51,6 +53,37 @@ func TestBuildTreeFromFiles(t *testing.T) {
},
},
},
{
name: "files in same directory, not root item",
files: []*models.File{
{
Path: "dir1/a",
},
{
Path: "dir1/b",
},
},
showRootItem: false,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
{
path: "dir1",
CompressionLevel: 0,
Children: []*Node[models.File]{
{
File: &models.File{Path: "dir1/a"},
path: "dir1/a",
},
{
File: &models.File{Path: "dir1/b"},
path: "dir1/b",
},
},
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.File{
@@ -61,6 +94,7 @@ func TestBuildTreeFromFiles(t *testing.T) {
Path: "dir2/dir4/b",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -92,6 +126,43 @@ func TestBuildTreeFromFiles(t *testing.T) {
},
},
},
{
name: "paths that can be compressed, no root item",
files: []*models.File{
{
Path: "dir1/dir3/a",
},
{
Path: "dir2/dir4/b",
},
},
showRootItem: false,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
{
path: "dir1/dir3",
Children: []*Node[models.File]{
{
File: &models.File{Path: "dir1/dir3/a"},
path: "dir1/dir3/a",
},
},
CompressionLevel: 1,
},
{
path: "dir2/dir4",
Children: []*Node[models.File]{
{
File: &models.File{Path: "dir2/dir4/b"},
path: "dir2/dir4/b",
},
},
CompressionLevel: 1,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.File{
@@ -102,6 +173,7 @@ func TestBuildTreeFromFiles(t *testing.T) {
Path: "a",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -135,6 +207,7 @@ func TestBuildTreeFromFiles(t *testing.T) {
Path: "a",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -164,7 +237,7 @@ func TestBuildTreeFromFiles(t *testing.T) {
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := BuildTreeFromFiles(s.files)
result := BuildTreeFromFiles(s.files, s.showRootItem)
assert.EqualValues(t, s.expected, result)
})
}
@@ -172,9 +245,10 @@ func TestBuildTreeFromFiles(t *testing.T) {
func TestBuildFlatTreeFromFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.File
expected *Node[models.File]
name string
files []*models.File
showRootItem bool
expected *Node[models.File]
}{
{
name: "no files",
@@ -194,6 +268,7 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
Path: "dir1/b",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -210,6 +285,33 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
},
},
},
{
name: "files in same directory, not root item",
files: []*models.File{
{
Path: "dir1/a",
},
{
Path: "dir1/b",
},
},
showRootItem: false,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
{
File: &models.File{Path: "dir1/a"},
path: "dir1/a",
CompressionLevel: 0,
},
{
File: &models.File{Path: "dir1/b"},
path: "dir1/b",
CompressionLevel: 0,
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.File{
@@ -220,6 +322,7 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
Path: "dir2/b",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -236,6 +339,33 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
},
},
},
{
name: "paths that can be compressed, no root item",
files: []*models.File{
{
Path: "dir1/a",
},
{
Path: "dir2/b",
},
},
showRootItem: false,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
{
File: &models.File{Path: "dir1/a"},
path: "dir1/a",
CompressionLevel: 0,
},
{
File: &models.File{Path: "dir2/b"},
path: "dir2/b",
CompressionLevel: 0,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.File{
@@ -246,6 +376,7 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
Path: "a",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -288,6 +419,7 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
Tracked: true,
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -322,7 +454,7 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := BuildFlatTreeFromFiles(s.files)
result := BuildFlatTreeFromFiles(s.files, s.showRootItem)
assert.EqualValues(t, s.expected, result)
})
}
@@ -330,9 +462,10 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
func TestBuildTreeFromCommitFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.CommitFile
expected *Node[models.CommitFile]
name string
files []*models.CommitFile
showRootItem bool
expected *Node[models.CommitFile]
}{
{
name: "no files",
@@ -352,6 +485,7 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
Path: "dir1/b",
},
},
showRootItem: true,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
@@ -372,6 +506,37 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
},
},
},
{
name: "files in same directory, not root item",
files: []*models.CommitFile{
{
Path: "dir1/a",
},
{
Path: "dir1/b",
},
},
showRootItem: false,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
{
path: "dir1",
CompressionLevel: 0,
Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Path: "dir1/a"},
path: "dir1/a",
},
{
File: &models.CommitFile{Path: "dir1/b"},
path: "dir1/b",
},
},
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.CommitFile{
@@ -382,6 +547,7 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
Path: "dir2/dir4/b",
},
},
showRootItem: true,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
@@ -413,6 +579,43 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
},
},
},
{
name: "paths that can be compressed, no root item",
files: []*models.CommitFile{
{
Path: "dir1/dir3/a",
},
{
Path: "dir2/dir4/b",
},
},
showRootItem: false,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
{
path: "dir1/dir3",
Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Path: "dir1/dir3/a"},
path: "dir1/dir3/a",
},
},
CompressionLevel: 1,
},
{
path: "dir2/dir4",
Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Path: "dir2/dir4/b"},
path: "dir2/dir4/b",
},
},
CompressionLevel: 1,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.CommitFile{
@@ -423,6 +626,7 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
Path: "a",
},
},
showRootItem: true,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
@@ -446,7 +650,7 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := BuildTreeFromCommitFiles(s.files)
result := BuildTreeFromCommitFiles(s.files, s.showRootItem)
assert.EqualValues(t, s.expected, result)
})
}
@@ -454,9 +658,10 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.CommitFile
expected *Node[models.CommitFile]
name string
files []*models.CommitFile
showRootItem bool
expected *Node[models.CommitFile]
}{
{
name: "no files",
@@ -476,6 +681,7 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
Path: "dir1/b",
},
},
showRootItem: true,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
@@ -492,6 +698,33 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
},
},
},
{
name: "files in same directory, not root item",
files: []*models.CommitFile{
{
Path: "dir1/a",
},
{
Path: "dir1/b",
},
},
showRootItem: false,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Path: "dir1/a"},
path: "dir1/a",
CompressionLevel: 0,
},
{
File: &models.CommitFile{Path: "dir1/b"},
path: "dir1/b",
CompressionLevel: 0,
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.CommitFile{
@@ -502,6 +735,7 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
Path: "dir2/b",
},
},
showRootItem: true,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
@@ -528,6 +762,7 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
Path: "a",
},
},
showRootItem: true,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
@@ -546,7 +781,7 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := BuildFlatTreeFromCommitFiles(s.files)
result := BuildFlatTreeFromCommitFiles(s.files, s.showRootItem)
assert.EqualValues(t, s.expected, result)
})
}
+7 -6
View File
@@ -2,9 +2,9 @@ package filetree
import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
)
type ICommitFileTree interface {
@@ -21,7 +21,7 @@ type CommitFileTree struct {
getFiles func() []*models.CommitFile
tree *Node[models.CommitFile]
showTree bool
log *logrus.Entry
common *common.Common
collapsedPaths *CollapsedPaths
}
@@ -41,10 +41,10 @@ func (self *CommitFileTree) ExpandAll() {
var _ ICommitFileTree = &CommitFileTree{}
func NewCommitFileTree(getFiles func() []*models.CommitFile, log *logrus.Entry, showTree bool) *CommitFileTree {
func NewCommitFileTree(getFiles func() []*models.CommitFile, common *common.Common, showTree bool) *CommitFileTree {
return &CommitFileTree{
getFiles: getFiles,
log: log,
common: common,
showTree: showTree,
collapsedPaths: NewCollapsedPaths(),
}
@@ -94,10 +94,11 @@ func (self *CommitFileTree) GetAllFiles() []*models.CommitFile {
}
func (self *CommitFileTree) SetTree() {
showRootItem := self.common.UserConfig().Gui.ShowRootItemInFileTree
if self.showTree {
self.tree = BuildTreeFromCommitFiles(self.getFiles())
self.tree = BuildTreeFromCommitFiles(self.getFiles(), showRootItem)
} else {
self.tree = BuildFlatTreeFromCommitFiles(self.getFiles())
self.tree = BuildFlatTreeFromCommitFiles(self.getFiles(), showRootItem)
}
}
@@ -5,10 +5,10 @@ import (
"sync"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
)
type ICommitFileTreeViewModel interface {
@@ -43,8 +43,8 @@ type CommitFileTreeViewModel struct {
var _ ICommitFileTreeViewModel = &CommitFileTreeViewModel{}
func NewCommitFileTreeViewModel(getFiles func() []*models.CommitFile, log *logrus.Entry, showTree bool) *CommitFileTreeViewModel {
fileTree := NewCommitFileTree(getFiles, log, showTree)
func NewCommitFileTreeViewModel(getFiles func() []*models.CommitFile, common *common.Common, showTree bool) *CommitFileTreeViewModel {
fileTree := NewCommitFileTree(getFiles, common, showTree)
listCursor := traits.NewListCursor(fileTree.Len)
return &CommitFileTreeViewModel{
ICommitFileTree: fileTree,
+7 -6
View File
@@ -4,9 +4,9 @@ import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
)
type FileTreeDisplayFilter int
@@ -54,17 +54,17 @@ type FileTree struct {
getFiles func() []*models.File
tree *Node[models.File]
showTree bool
log *logrus.Entry
common *common.Common
filter FileTreeDisplayFilter
collapsedPaths *CollapsedPaths
}
var _ IFileTree = &FileTree{}
func NewFileTree(getFiles func() []*models.File, log *logrus.Entry, showTree bool) *FileTree {
func NewFileTree(getFiles func() []*models.File, common *common.Common, showTree bool) *FileTree {
return &FileTree{
getFiles: getFiles,
log: log,
common: common,
showTree: showTree,
filter: DisplayAll,
collapsedPaths: NewCollapsedPaths(),
@@ -168,10 +168,11 @@ func (self *FileTree) GetAllFiles() []*models.File {
func (self *FileTree) SetTree() {
filesForDisplay := self.getFilesForDisplay()
showRootItem := self.common.UserConfig().Gui.ShowRootItemInFileTree
if self.showTree {
self.tree = BuildTreeFromFiles(filesForDisplay)
self.tree = BuildTreeFromFiles(filesForDisplay, showRootItem)
} else {
self.tree = BuildFlatTreeFromFiles(filesForDisplay)
self.tree = BuildFlatTreeFromFiles(filesForDisplay, showRootItem)
}
}
+3 -3
View File
@@ -5,11 +5,11 @@ import (
"sync"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
)
type IFileTreeViewModel interface {
@@ -28,8 +28,8 @@ type FileTreeViewModel struct {
var _ IFileTreeViewModel = &FileTreeViewModel{}
func NewFileTreeViewModel(getFiles func() []*models.File, log *logrus.Entry, showTree bool) *FileTreeViewModel {
fileTree := NewFileTree(getFiles, log, showTree)
func NewFileTreeViewModel(getFiles func() []*models.File, common *common.Common, showTree bool) *FileTreeViewModel {
fileTree := NewFileTree(getFiles, common, showTree)
listCursor := traits.NewListCursor(fileTree.Len)
return &FileTreeViewModel{
IFileTree: fileTree,
+7
View File
@@ -523,6 +523,13 @@ 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 {
// 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.
if oldCurrentView := gui.g.CurrentView(); oldCurrentView != nil {
oldCurrentView.Highlight = false
}
worktreePath := gui.git.RepoPaths.WorktreePath()
if state := gui.RepoStateMap[Repo(worktreePath)]; state != nil {
+13 -10
View File
@@ -523,19 +523,22 @@ func (gui *Gui) SetMouseKeybinding(binding *gocui.ViewMouseBinding) error {
}
func (gui *Gui) callKeybindingHandler(binding *types.Binding) error {
var disabledReason *types.DisabledReason
if binding.GetDisabledReason != nil {
disabledReason = binding.GetDisabledReason()
}
if disabledReason != nil {
if disabledReason.ShowErrorInPanel {
return errors.New(disabledReason.Text)
}
if disabledReason := binding.GetDisabledReason(); disabledReason != nil {
if disabledReason.AllowFurtherDispatching {
return &types.ErrKeybindingNotHandled{DisabledReason: disabledReason}
}
if len(disabledReason.Text) > 0 {
gui.c.ErrorToast(gui.Tr.DisabledMenuItemPrefix + disabledReason.Text)
if disabledReason.ShowErrorInPanel {
return errors.New(disabledReason.Text)
}
if len(disabledReason.Text) > 0 {
gui.c.ErrorToast(gui.Tr.DisabledMenuItemPrefix + disabledReason.Text)
}
return nil
}
return nil
}
return binding.Handler()
}
+4 -2
View File
@@ -1,6 +1,8 @@
package gui
import (
"errors"
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
@@ -121,7 +123,7 @@ func (gui *Gui) layout(g *gocui.Gui) error {
}
_, err := setViewFromDimensions(context)
if err != nil && !gocui.IsUnknownView(err) {
if err != nil && !errors.Is(err, gocui.ErrUnknownView) {
return err
}
}
@@ -134,7 +136,7 @@ func (gui *Gui) layout(g *gocui.Gui) error {
for _, context := range gui.transientContexts() {
view, err := gui.g.View(context.GetViewName())
if err != nil && !gocui.IsUnknownView(err) {
if err != nil && !errors.Is(err, gocui.ErrUnknownView) {
return err
}
view.Visible = gui.helpers.Window.GetViewNameForWindow(context.GetWindowName()) == context.GetViewName()
+11
View File
@@ -2,6 +2,7 @@ package popup
import (
"context"
"errors"
"strings"
"github.com/jesseduffield/gocui"
@@ -80,6 +81,16 @@ func (self *PopupHandler) WithWaitingStatusSync(message string, f func() error)
}
func (self *PopupHandler) ErrorHandler(err error) error {
var notHandledError *types.ErrKeybindingNotHandled
if errors.As(err, &notHandledError) {
if !notHandledError.DisabledReason.ShowErrorInPanel {
if msg := notHandledError.DisabledReason.Text; len(msg) > 0 {
self.ErrorToast(self.Tr.DisabledMenuItemPrefix + msg)
}
return nil
}
}
// Need to set bold here explicitly; otherwise it gets cancelled by the red colouring.
coloredMessage := style.FgRed.SetBold().Sprint(strings.TrimSpace(err.Error()))
if err := self.onErrorFn(); err != nil {
+6 -4
View File
@@ -25,12 +25,13 @@ func RenderFileTree(
showFileIcons bool,
showNumstat bool,
customIconsConfig *config.CustomIconsConfig,
showRootItem bool,
) []string {
collapsedPaths := tree.CollapsedPaths()
return renderAux(tree.GetRoot().Raw(), collapsedPaths, -1, -1, func(node *filetree.Node[models.File], treeDepth int, visualDepth int, isCollapsed bool) string {
fileNode := filetree.NewFileNode(node)
return getFileLine(isCollapsed, fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), treeDepth, visualDepth, showNumstat, showFileIcons, submoduleConfigs, node, customIconsConfig)
return getFileLine(isCollapsed, fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), treeDepth, visualDepth, showNumstat, showFileIcons, submoduleConfigs, node, customIconsConfig, showRootItem)
})
}
@@ -120,8 +121,9 @@ func getFileLine(
submoduleConfigs []*models.SubmoduleConfig,
node *filetree.Node[models.File],
customIconsConfig *config.CustomIconsConfig,
showRootItem bool,
) string {
name := fileNameAtDepth(node, treeDepth)
name := fileNameAtDepth(node, treeDepth, showRootItem)
output := ""
var nameColor style.TextStyle
@@ -297,7 +299,7 @@ func getColorForChangeStatus(changeStatus string) style.TextStyle {
}
}
func fileNameAtDepth(node *filetree.Node[models.File], depth int) string {
func fileNameAtDepth(node *filetree.Node[models.File], depth int, showRootItem bool) string {
splitName := split(node.GetInternalPath())
if depth == 0 && splitName[0] == "." {
if len(splitName) == 1 {
@@ -308,7 +310,7 @@ func fileNameAtDepth(node *filetree.Node[models.File], depth int) string {
name := join(splitName[depth:])
if node.File != nil && node.File.IsRename() {
splitPrevName := split("./" + node.File.PreviousPath)
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
+65 -5
View File
@@ -7,6 +7,7 @@ import (
"github.com/gookit/color"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/commands/patch"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/utils"
@@ -25,6 +26,7 @@ func TestRenderFileTree(t *testing.T) {
files []*models.File
collapsedPaths []string
showLineChanges bool
showRootItem bool
expected []string
}{
{
@@ -37,7 +39,8 @@ func TestRenderFileTree(t *testing.T) {
files: []*models.File{
{Path: "test", ShortStatus: " M", HasStagedChanges: true},
},
expected: []string{" M test"},
showRootItem: true,
expected: []string{" M test"},
},
{
name: "numstat",
@@ -48,6 +51,7 @@ func TestRenderFileTree(t *testing.T) {
{Path: "test4", ShortStatus: " M", HasStagedChanges: true, LinesAdded: 0, LinesDeleted: 0},
},
showLineChanges: true,
showRootItem: true,
expected: []string{
"▼ /",
" M test +1 -1",
@@ -66,6 +70,7 @@ func TestRenderFileTree(t *testing.T) {
{Path: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
{Path: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
},
showRootItem: true,
expected: toStringSlice(
`
▼ /
@@ -80,6 +85,30 @@ func TestRenderFileTree(t *testing.T) {
),
collapsedPaths: []string{"./dir1"},
},
{
name: "big example without root item",
files: []*models.File{
{Path: "dir1/file2", ShortStatus: "M ", HasUnstagedChanges: true},
{Path: "dir1/file3", ShortStatus: "M ", HasUnstagedChanges: true},
{Path: "dir2/dir2/file3", ShortStatus: " M", HasStagedChanges: true},
{Path: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
{Path: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
{Path: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
},
showRootItem: false,
expected: toStringSlice(
`
▶ dir1
▼ dir2
▼ dir2
M file3
M file4
M file5
M file1
`,
),
collapsedPaths: []string{"dir1"},
},
}
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone)
@@ -87,12 +116,14 @@ func TestRenderFileTree(t *testing.T) {
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
viewModel := filetree.NewFileTree(func() []*models.File { return s.files }, utils.NewDummyLog(), true)
common := common.NewDummyCommon()
common.UserConfig().Gui.ShowRootItemInFileTree = s.showRootItem
viewModel := filetree.NewFileTree(func() []*models.File { return s.files }, common, true)
viewModel.SetTree()
for _, path := range s.collapsedPaths {
viewModel.ToggleCollapsed(path)
}
result := RenderFileTree(viewModel, nil, false, s.showLineChanges, &config.CustomIconsConfig{})
result := RenderFileTree(viewModel, nil, false, s.showLineChanges, &config.CustomIconsConfig{}, s.showRootItem)
assert.EqualValues(t, s.expected, result)
})
}
@@ -104,6 +135,7 @@ func TestRenderCommitFileTree(t *testing.T) {
root *filetree.FileNode
files []*models.CommitFile
collapsedPaths []string
showRootItem bool
expected []string
}{
{
@@ -116,7 +148,8 @@ func TestRenderCommitFileTree(t *testing.T) {
files: []*models.CommitFile{
{Path: "test", ChangeStatus: "A"},
},
expected: []string{"A test"},
showRootItem: true,
expected: []string{"A test"},
},
{
name: "big example",
@@ -128,6 +161,7 @@ func TestRenderCommitFileTree(t *testing.T) {
{Path: "dir2/file5", ChangeStatus: "M"},
{Path: "file1", ChangeStatus: "M"},
},
showRootItem: true,
expected: toStringSlice(
`
▼ /
@@ -142,6 +176,30 @@ func TestRenderCommitFileTree(t *testing.T) {
),
collapsedPaths: []string{"./dir1"},
},
{
name: "big example without root item",
files: []*models.CommitFile{
{Path: "dir1/file2", ChangeStatus: "M"},
{Path: "dir1/file3", ChangeStatus: "A"},
{Path: "dir2/dir2/file3", ChangeStatus: "D"},
{Path: "dir2/dir2/file4", ChangeStatus: "M"},
{Path: "dir2/file5", ChangeStatus: "M"},
{Path: "file1", ChangeStatus: "M"},
},
showRootItem: false,
expected: toStringSlice(
`
▶ dir1
▼ dir2
▼ dir2
D file3
M file4
M file5
M file1
`,
),
collapsedPaths: []string{"dir1"},
},
}
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone)
@@ -151,7 +209,9 @@ func TestRenderCommitFileTree(t *testing.T) {
t.Run(s.name, func(t *testing.T) {
hashPool := &utils.StringPool{}
viewModel := filetree.NewCommitFileTreeViewModel(func() []*models.CommitFile { return s.files }, utils.NewDummyLog(), true)
common := common.NewDummyCommon()
common.UserConfig().Gui.ShowRootItemInFileTree = s.showRootItem
viewModel := filetree.NewCommitFileTreeViewModel(func() []*models.CommitFile { return s.files }, common, true)
viewModel.SetRef(models.NewCommit(hashPool, models.NewCommitOpts{Hash: "1234"}))
viewModel.SetTree()
for _, path := range s.collapsedPaths {
+4
View File
@@ -202,6 +202,10 @@ type DisabledReason struct {
// error panel instead. This is useful if the text is very long, or if it is
// important enough to show it more prominently, or both.
ShowErrorInPanel bool
// If true, the keybinding dispatch mechanism will continue to look for
// other handlers for the keypress.
AllowFurtherDispatching bool
}
type MenuWidget int
+12
View File
@@ -55,3 +55,15 @@ type KeybindingGuards struct {
OutsideFilterMode Guard
NoPopupPanel Guard
}
type ErrKeybindingNotHandled struct {
DisabledReason *DisabledReason
}
func (e ErrKeybindingNotHandled) Error() string {
return e.DisabledReason.Text
}
func (e ErrKeybindingNotHandled) Unwrap() error {
return gocui.ErrKeybindingNotHandled
}
+2 -1
View File
@@ -1,6 +1,7 @@
package gui
import (
"errors"
"fmt"
"github.com/jesseduffield/gocui"
@@ -78,7 +79,7 @@ func (gui *Gui) createAllViews() error {
var err error
for _, mapping := range gui.orderedViewNameMappings() {
*mapping.viewPtr, err = gui.prepareView(mapping.name)
if err != nil && !gocui.IsUnknownView(err) {
if err != nil && !errors.Is(err, gocui.ErrUnknownView) {
return err
}
}
+3 -1
View File
@@ -369,6 +369,7 @@ type TranslationSet struct {
RewordNotSupported string
ChangingThisActionIsNotAllowed string
NotAllowedMidCherryPickOrRevert string
PickIsOnlyAllowedDuringRebase string
DroppingMergeRequiresSingleSelection string
CherryPickCopy string
CherryPickCopyTooltip string
@@ -1459,6 +1460,7 @@ func EnglishTranslationSet() *TranslationSet {
RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported",
ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed",
NotAllowedMidCherryPickOrRevert: "This action is not allowed while cherry-picking or reverting",
PickIsOnlyAllowedDuringRebase: "This action is only allowed while rebasing",
DroppingMergeRequiresSingleSelection: "Dropping a merge commit requires a single selected item",
CherryPickCopy: "Copy (cherry-pick)",
CherryPickCopyTooltip: "Mark commit as copied. Then, within the local commits view, you can press `{{.paste}}` to paste (cherry-pick) the copied commit(s) into your checked out branch. At any time you can press `{{.escape}}` to cancel the selection.",
@@ -1529,7 +1531,7 @@ func EnglishTranslationSet() *TranslationSet {
DiscardFileChangesTitle: "Discard file changes",
DiscardFileChangesPrompt: "Are you sure you want to remove changes to the selected file(s) from this commit?\n\nThis action will start a rebase, reverting these file changes. Be aware that if subsequent commits depend on these changes, you may need to resolve conflicts.\nNote: This will also reset any active custom patches.",
DisabledForGPG: "Feature not available for users using GPG.\n\nIf you are using a passphrase agent (e.g. gpg-agent) so that you don't have to type your passphrase when signing, you can enable this feature by adding\n\ngit:\n overrideGpg: true\n\nto your lazygit config file.",
CreateRepo: "Not in a git repository. Create a new git repository? (y/n): ",
CreateRepo: "Not in a git repository. Create a new git repository? (y/N): ",
BareRepo: "You've attempted to open Lazygit in a bare repo but Lazygit does not yet support bare repos. Open most recent repo? (y/n) ",
InitialBranch: "Branch name? (leave empty for git's default): ",
NoRecentRepositories: "Must open lazygit in a git repository. No valid recent repositories. Exiting.",
+5 -4
View File
@@ -1,6 +1,7 @@
package clients
import (
"errors"
"fmt"
"log"
"os"
@@ -314,7 +315,7 @@ func (self *app) layout(g *gocui.Gui) error {
g.FgColor = gocui.ColorGreen
listView, err := g.SetView("list", 0, 0, maxX-1, maxY-descriptionViewHeight-keybindingsViewHeight-editorViewHeight-1, 0)
if err != nil {
if !gocui.IsUnknownView(err) {
if !errors.Is(err, gocui.ErrUnknownView) {
return err
}
@@ -334,7 +335,7 @@ func (self *app) layout(g *gocui.Gui) error {
descriptionView, err := g.SetViewBeneath("description", "list", descriptionViewHeight)
if err != nil {
if !gocui.IsUnknownView(err) {
if !errors.Is(err, gocui.ErrUnknownView) {
return err
}
descriptionView.Title = "Test description"
@@ -344,7 +345,7 @@ func (self *app) layout(g *gocui.Gui) error {
keybindingsView, err := g.SetViewBeneath("keybindings", "description", keybindingsViewHeight)
if err != nil {
if !gocui.IsUnknownView(err) {
if !errors.Is(err, gocui.ErrUnknownView) {
return err
}
keybindingsView.Title = "Keybindings"
@@ -355,7 +356,7 @@ func (self *app) layout(g *gocui.Gui) error {
editorView, err := g.SetViewBeneath("editor", "keybindings", editorViewHeight)
if err != nil {
if !gocui.IsUnknownView(err) {
if !errors.Is(err, gocui.ErrUnknownView) {
return err
}
@@ -9,7 +9,9 @@ var MoveCommitsToNewBranchKeepStacked = NewIntegrationTest(NewIntegrationTestArg
Description: "Create a new branch from the commits that you accidentally made on the wrong branch; choosing stacked on current branch",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Git.BranchPrefix = "myprefix/"
},
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("initial commit")
shell.CloneIntoRemote("origin")
@@ -42,12 +44,13 @@ var MoveCommitsToNewBranchKeepStacked = NewIntegrationTest(NewIntegrationTestArg
t.ExpectPopup().Prompt().
Title(Equals("New branch name (branch is off of 'feature')")).
InitialText(Equals("myprefix/")).
Type("new branch").
Confirm()
t.Views().Branches().
Lines(
Contains("new-branch").DoesNotContain("↑").IsSelected(),
Contains("myprefix/new-branch").DoesNotContain("↑").IsSelected(),
Contains("feature ✓"),
Contains("master ✓"),
)
@@ -0,0 +1,52 @@
package branch
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ResetToDuplicateNamedTag = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Hard reset to a branch when a tag shares the same name",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.NewBranch("current-branch")
shell.EmptyCommit("other-branch-tag commit")
shell.CreateLightweightTag("other-branch", "HEAD")
shell.EmptyCommit("other-branch commit")
shell.NewBranch("other-branch")
shell.Checkout("current-branch")
shell.EmptyCommit("current-branch commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().Lines(
Contains("current-branch commit"),
Contains("other-branch commit"),
Contains("other-branch-tag commit"),
)
t.Views().Branches().
Focus().
Lines(
Contains("current-branch").IsSelected(),
Contains("other-branch"),
).
SelectNextItem().
Press(keys.Commits.ViewResetOptions)
t.ExpectPopup().Menu().
Title(Contains("Reset to other-branch")).
Select(Contains("Hard reset")).
Confirm()
t.Views().Commits().
Lines(
Contains("other-branch commit"),
Contains("other-branch-tag commit"),
)
},
})
@@ -0,0 +1,57 @@
package branch
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ResetToDuplicateNamedUpstream = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Hard reset the current branch to an upstream branch when there is a competing tag name",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.
CloneIntoRemote("origin").
NewBranch("foo").
EmptyCommit("commit 1").
PushBranchAndSetUpstream("origin", "foo").
EmptyCommit("commit 2").
CreateLightweightTag("origin/foo", "HEAD")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().Lines(
Contains("commit 2"),
Contains("commit 1"),
)
t.Views().Tags().Focus().Lines(Contains("origin/foo"))
t.Views().Remotes().Focus().
Lines(Contains("origin")).
PressEnter()
t.Views().RemoteBranches().IsFocused().
Lines(Contains("foo")).
Press(keys.Commits.ViewResetOptions)
t.ExpectPopup().Menu().
Title(Contains("Reset to origin/foo")).
Select(Contains("Hard reset")).
Confirm()
t.Views().Commits().Lines(
Contains("commit 1"),
)
t.Views().Tags().Focus().
Lines(Contains("origin/foo")).
Press(keys.Commits.ViewResetOptions)
t.ExpectPopup().Menu().
Title(Contains("Reset to origin/foo")).
Select(Contains("Hard reset")).
Confirm()
t.Views().Commits().Lines(
Contains("commit 2"),
Contains("commit 1"),
)
},
})
@@ -0,0 +1,36 @@
package file
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var RenamedFilesNoRootItem = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Regression test for the display of renamed files in the file tree, when the root item is disabled",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {
config.GetUserConfig().Gui.ShowRootItemInFileTree = false
},
SetupRepo: func(shell *Shell) {
shell.CreateDir("dir")
shell.CreateDir("dir/nested")
shell.CreateFileAndAdd("file1", "file1 content\n")
shell.CreateFileAndAdd("dir/file2", "file2 content\n")
shell.CreateFileAndAdd("dir/nested/file3", "file3 content\n")
shell.Commit("initial commit")
shell.RunCommand([]string{"git", "mv", "file1", "dir/file1"})
shell.RunCommand([]string{"git", "mv", "dir/file2", "dir/file2-renamed"})
shell.RunCommand([]string{"git", "mv", "dir/nested/file3", "file3"})
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Lines(
Equals("▼ dir"),
Equals(" R file1 → file1"),
Equals(" R file2 → file2-renamed"),
Equals("R dir/nested/file3 → file3"),
)
},
})
@@ -0,0 +1,48 @@
package tag
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ResetToDuplicateNamedBranch = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Hard reset to a tag when a branch shares the same name",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.NewBranch("current-branch")
shell.EmptyCommit("other-branch-tag commit")
shell.CreateLightweightTag("other-branch", "HEAD")
shell.EmptyCommit("other-branch commit")
shell.NewBranch("other-branch")
shell.Checkout("current-branch")
shell.EmptyCommit("current-branch commit")
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().Lines(
Contains("current-branch commit"),
Contains("other-branch commit"),
Contains("other-branch-tag commit"),
)
t.Views().Tags().
Focus().
Lines(
Contains("other-branch").IsSelected(),
).
Press(keys.Commits.ViewResetOptions)
t.ExpectPopup().Menu().
Title(Contains("Reset to other-branch")).
Select(Contains("Hard reset")).
Confirm()
t.Views().Commits().Lines(
Contains("other-branch-tag commit"),
)
},
})
+4
View File
@@ -71,6 +71,8 @@ var tests = []*components.IntegrationTest{
branch.RebaseToUpstream,
branch.Rename,
branch.Reset,
branch.ResetToDuplicateNamedTag,
branch.ResetToDuplicateNamedUpstream,
branch.ResetToUpstream,
branch.SelectCommitsOfCurrentBranch,
branch.SetUpstream,
@@ -207,6 +209,7 @@ var tests = []*components.IntegrationTest{
file.RememberCommitMessageAfterFail,
file.RenameSimilarityThresholdChange,
file.RenamedFiles,
file.RenamedFilesNoRootItem,
file.StageChildrenRangeSelect,
file.StageDeletedRangeSelect,
file.StageRangeSelect,
@@ -405,6 +408,7 @@ var tests = []*components.IntegrationTest{
tag.ForceTagAnnotated,
tag.ForceTagLightweight,
tag.Reset,
tag.ResetToDuplicateNamedBranch,
ui.Accordion,
ui.DisableSwitchTabWithPanelJumpKeys,
ui.EmptyMenu,
+5
View File
@@ -569,6 +569,11 @@
"description": "If true, display the files in the file views as a tree. If false, display the files as a flat list.\nThis can be toggled from within Lazygit with the '`' key, but that will not change the default.",
"default": true
},
"showRootItemInFileTree": {
"type": "boolean",
"description": "If true, add a \"/\" root item in the file tree representing the root of the repository. It is only added when necessary, i.e. when there is more than one item at top level.",
"default": true
},
"showNumstatInFilesView": {
"type": "boolean",
"description": "If true, show the number of lines changed per file in the Files view",
+15 -42
View File
@@ -786,7 +786,7 @@ func (g *Gui) MainLoop() error {
}
func (g *Gui) handleError(err error) error {
if err != nil && !IsQuit(err) && g.ErrorHandler != nil {
if err != nil && !standardErrors.Is(err, ErrQuit) && g.ErrorHandler != nil {
return g.ErrorHandler(err)
}
@@ -1232,24 +1232,9 @@ func (g *Gui) draw(v *View) error {
if g.Cursor {
if curview := g.currentView; curview != nil {
vMaxX, vMaxY := curview.Size()
if curview.cx < 0 {
curview.cx = 0
} else if curview.cx >= vMaxX {
curview.cx = vMaxX - 1
}
if curview.cy < 0 {
curview.cy = 0
} else if curview.cy >= vMaxY {
curview.cy = vMaxY - 1
}
gMaxX, gMaxY := g.Size()
cx, cy := curview.x0+curview.cx+1, curview.y0+curview.cy+1
// This test probably doesn't need to be here.
// tcell is hiding cursor by setting coordinates outside of screen.
// Keeping it here for now, as I'm not 100% sure :)
if cx >= 0 && cx < gMaxX && cy >= 0 && cy < gMaxY {
vMaxX, vMaxY := curview.InnerSize()
if curview.cx >= 0 && curview.cx < vMaxX && curview.cy >= 0 && curview.cy < vMaxY {
cx, cy := curview.x0+curview.cx+1, curview.y0+curview.cy+1
Screen.ShowCursor(cx, cy)
} else {
Screen.HideCursor()
@@ -1513,6 +1498,8 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
}
}
var err error
for _, kb := range g.keybindings {
if kb.handler == nil {
continue
@@ -1521,13 +1508,13 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
continue
}
if g.matchView(v, kb) {
err := g.execKeybinding(v, kb)
if IsKeybindingNotHandled(err) {
matchingParentViewKb = nil
break
} else {
err = g.execKeybinding(v, kb)
if !errors.Is(err, ErrKeybindingNotHandled) {
return err
}
matchingParentViewKb = nil
break
}
if v != nil && g.matchView(v.ParentView, kb) {
matchingParentViewKb = kb
@@ -1537,8 +1524,8 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
}
}
if matchingParentViewKb != nil {
err := g.execKeybinding(v.ParentView, matchingParentViewKb)
if !IsKeybindingNotHandled(err) {
err = g.execKeybinding(v.ParentView, matchingParentViewKb)
if !errors.Is(err, ErrKeybindingNotHandled) {
return err
}
}
@@ -1551,9 +1538,9 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) error {
}
if globalKb != nil {
return g.execKeybinding(v, globalKb)
err = g.execKeybinding(v, globalKb)
}
return nil
return err
}
// execKeybinding executes a given keybinding
@@ -1617,20 +1604,6 @@ func (g *Gui) isBlacklisted(k Key) bool {
return false
}
// IsUnknownView reports whether the contents of an error is "unknown view".
func IsUnknownView(err error) bool {
return err != nil && err.Error() == ErrUnknownView.Error()
}
// IsQuit reports whether the contents of an error is "quit".
func IsQuit(err error) bool {
return err != nil && err.Error() == ErrQuit.Error()
}
func IsKeybindingNotHandled(err error) bool {
return err != nil && err.Error() == ErrKeybindingNotHandled.Error()
}
func (g *Gui) Suspend() error {
g.suspendedMutex.Lock()
defer g.suspendedMutex.Unlock()
+7 -16
View File
@@ -562,29 +562,19 @@ func max(a, b int) int {
}
// SetCursor sets the cursor position of the view at the given point,
// relative to the view. It checks if the position is valid.
// relative to the view. It is allowed to set the position to a point outside
// the visible portion of the view, or even outside the content of the view.
// Clients are responsible for clamping to valid positions.
func (v *View) SetCursor(x, y int) {
maxX, maxY := v.InnerSize()
if x < 0 || x >= maxX || y < 0 || y >= maxY {
return
}
v.cx = x
v.cy = y
}
func (v *View) SetCursorX(x int) {
maxX := v.InnerWidth()
if x < 0 || x >= maxX {
return
}
v.cx = x
}
func (v *View) SetCursorY(y int) {
maxY := v.InnerHeight()
if y < 0 || y >= maxY {
return
}
v.cy = y
}
@@ -1685,10 +1675,11 @@ func (v *View) RenderTextArea() {
func updatedCursorAndOrigin(prevOrigin int, size int, cursor int) (int, int) {
var newViewCursor int
newOrigin := prevOrigin
usableSize := size - 1
if cursor > prevOrigin+size {
newOrigin = cursor - size
newViewCursor = size
if cursor > prevOrigin+usableSize {
newOrigin = cursor - usableSize
newViewCursor = usableSize
} else if cursor < prevOrigin {
newOrigin = cursor
newViewCursor = 0
+1 -1
View File
@@ -227,7 +227,7 @@ github.com/jesseduffield/go-git/v5/utils/merkletrie/internal/frame
github.com/jesseduffield/go-git/v5/utils/merkletrie/noder
github.com/jesseduffield/go-git/v5/utils/sync
github.com/jesseduffield/go-git/v5/utils/trace
# github.com/jesseduffield/gocui v0.3.1-0.20250522064656-07eb9218e08e
# github.com/jesseduffield/gocui v0.3.1-0.20250605111917-fc5387961412
## explicit; go 1.12
github.com/jesseduffield/gocui
# github.com/jesseduffield/kill v0.0.0-20250101124109-e216ddbe133a