mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-10-06 22:16:22 -04:00
Allow deleting a range selection of tags
Tags can only be deleted one at a time. If many of them have piled up, for example backup tags made before rewriting a branch, deleting them takes a lot of keypresses. Let the delete menu work on a range selection of tags. It deletes the selected tags with one git command for the local tags and one for the remote tags, and asks for a single remote for all of them. After deleting local tags, collapse the range selection to its first line; otherwise it would select the tags that moved up into the place of the deleted ones. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2ca2be6025
commit
1b4757be89
@@ -7,6 +7,7 @@ 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/controllers/helpers"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
||||
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
||||
"github.com/jesseduffield/lazygit/pkg/utils"
|
||||
@@ -61,9 +62,9 @@ func (self *TagsController) GetKeybindings(opts types.KeybindingsOpts) []*types.
|
||||
},
|
||||
{
|
||||
Keys: opts.GetKeys(opts.Config.Universal.Remove),
|
||||
Handler: self.withItem(self.delete),
|
||||
Handler: self.withItems(self.delete),
|
||||
Description: self.c.Tr.Delete,
|
||||
GetDisabledReason: self.require(self.singleItemSelected()),
|
||||
GetDisabledReason: self.require(self.itemsSelected()),
|
||||
Tooltip: self.c.Tr.TagDeleteTooltip,
|
||||
OpensMenu: true,
|
||||
DisplayOnScreen: true,
|
||||
@@ -164,83 +165,104 @@ func (self *TagsController) checkout(tag *models.Tag) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *TagsController) localDelete(tag *models.Tag) error {
|
||||
func (self *TagsController) localDelete(tags []*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([]string{tag.Name})
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
|
||||
err := self.c.Git().Tag.LocalDelete(tagNames(tags))
|
||||
self.refreshAfterLocalDelete()
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (self *TagsController) remoteDelete(tag *models.Tag) error {
|
||||
return self.confirmRemoteDelete(tag, self.c.Tr.DeleteRemoteTagPrompt, func(task gocui.Task, upstream string) error {
|
||||
func (self *TagsController) remoteDelete(tags []*models.Tag) error {
|
||||
confirmPromptTemplate := lo.Ternary(len(tags) > 1, self.c.Tr.DeleteRemoteTagsPrompt, self.c.Tr.DeleteRemoteTagPrompt)
|
||||
return self.confirmRemoteDelete(tags, confirmPromptTemplate, func(task gocui.Task, upstream string) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag)
|
||||
if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, []string{tag.Name}); err != nil {
|
||||
if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tagNames(tags)); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.Toast(self.c.Tr.RemoteTagDeletedMessage)
|
||||
self.c.Toast(lo.Ternary(len(tags) > 1, self.c.Tr.RemoteTagsDeletedMessage, self.c.Tr.RemoteTagDeletedMessage))
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (self *TagsController) localAndRemoteDelete(tag *models.Tag) error {
|
||||
return self.confirmRemoteDelete(tag, self.c.Tr.DeleteLocalAndRemoteTagPrompt, func(task gocui.Task, upstream string) error {
|
||||
func (self *TagsController) localAndRemoteDelete(tags []*models.Tag) error {
|
||||
confirmPromptTemplate := lo.Ternary(len(tags) > 1, self.c.Tr.DeleteLocalAndRemoteTagsPrompt, self.c.Tr.DeleteLocalAndRemoteTagPrompt)
|
||||
return self.confirmRemoteDelete(tags, confirmPromptTemplate, func(task gocui.Task, upstream string) error {
|
||||
self.c.LogAction(self.c.Tr.Actions.DeleteRemoteTag)
|
||||
if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, []string{tag.Name}); err != nil {
|
||||
if err := self.c.Git().Remote.DeleteRemoteTag(task, upstream, tagNames(tags)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
self.c.LogAction(self.c.Tr.Actions.DeleteLocalTag)
|
||||
if err := self.c.Git().Tag.LocalDelete([]string{tag.Name}); err != nil {
|
||||
if err := self.c.Git().Tag.LocalDelete(tagNames(tags)); err != nil {
|
||||
return err
|
||||
}
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS, types.TAGS}})
|
||||
self.refreshAfterLocalDelete()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Asks for the remote to delete the tag from and for a confirmation, and then
|
||||
// runs deleteTag on a worker with the tag shown as being deleted.
|
||||
// confirmPromptTemplate can use the placeholders tagName and upstream.
|
||||
func (self *TagsController) confirmRemoteDelete(
|
||||
tag *models.Tag,
|
||||
confirmPromptTemplate string,
|
||||
deleteTag func(task gocui.Task, upstream string) error,
|
||||
) error {
|
||||
title := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.SelectRemoteTagUpstream,
|
||||
map[string]string{
|
||||
"tagName": tag.Name,
|
||||
// Refreshes after deleting local tags. If the tags were a range selection,
|
||||
// this also collapses it to its first line; otherwise it would select the tags
|
||||
// that moved up into the place of the deleted ones. Collapsing it in the Then
|
||||
// of a batched refresh draws the shorter list and the new selection in the
|
||||
// same frame.
|
||||
func (self *TagsController) refreshAfterLocalDelete() {
|
||||
self.c.RefreshFromWorker(types.RefreshOptions{
|
||||
Scope: []types.RefreshableView{types.COMMITS, types.TAGS},
|
||||
BatchUIUpdates: true,
|
||||
Then: func() error {
|
||||
if self.context().IsSelectingRange() {
|
||||
self.context().CollapseRangeSelectionToTop()
|
||||
self.c.PostRefreshUpdate(self.context())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Asks for the remote to delete the tags from and for a confirmation, and then
|
||||
// runs deleteTags on a worker with the tags shown as being deleted.
|
||||
// confirmPromptTemplate can use the placeholder upstream, and tagName if there
|
||||
// is only one tag.
|
||||
func (self *TagsController) confirmRemoteDelete(
|
||||
tags []*models.Tag,
|
||||
confirmPromptTemplate string,
|
||||
deleteTags func(task gocui.Task, upstream string) error,
|
||||
) error {
|
||||
var title string
|
||||
if len(tags) == 1 {
|
||||
title = utils.ResolvePlaceholderString(
|
||||
self.c.Tr.SelectRemoteTagUpstream,
|
||||
map[string]string{
|
||||
"tagName": tags[0].Name,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
title = self.c.Tr.SelectRemoteTagsUpstream
|
||||
}
|
||||
|
||||
self.c.Prompt(types.PromptOpts{
|
||||
Title: title,
|
||||
InitialContent: "origin",
|
||||
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetRemoteSuggestionsFunc(),
|
||||
HandleConfirm: func(upstream string) error {
|
||||
confirmTitle := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.DeleteTagTitle,
|
||||
map[string]string{
|
||||
"tagName": tag.Name,
|
||||
},
|
||||
)
|
||||
confirmPrompt := utils.ResolvePlaceholderString(
|
||||
confirmPromptTemplate,
|
||||
map[string]string{
|
||||
"tagName": tag.Name,
|
||||
"tagName": tags[0].Name,
|
||||
"upstream": upstream,
|
||||
},
|
||||
)
|
||||
|
||||
self.c.Confirm(types.ConfirmOpts{
|
||||
Title: confirmTitle,
|
||||
Title: self.deleteTagsTitle(tags),
|
||||
Prompt: confirmPrompt,
|
||||
HandleConfirm: func() error {
|
||||
return self.c.WithInlineStatus(tag, types.ItemOperationDeleting, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
return deleteTag(task, upstream)
|
||||
return helpers.WithInlineStatusOnItems(self.c.HelperCommon, tags, types.ItemOperationDeleting, context.TAGS_CONTEXT_KEY, func(task gocui.Task) error {
|
||||
return deleteTags(task, upstream)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -252,46 +274,52 @@ func (self *TagsController) confirmRemoteDelete(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (self *TagsController) delete(tag *models.Tag) error {
|
||||
menuTitle := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.DeleteTagTitle,
|
||||
map[string]string{
|
||||
"tagName": tag.Name,
|
||||
},
|
||||
)
|
||||
|
||||
func (self *TagsController) delete(tags []*models.Tag) error {
|
||||
menuItems := []*types.MenuItem{
|
||||
{
|
||||
Label: self.c.Tr.DeleteLocalTag,
|
||||
Label: lo.Ternary(len(tags) > 1, self.c.Tr.DeleteLocalTags, self.c.Tr.DeleteLocalTag),
|
||||
Keys: menuKey('c'),
|
||||
OnPress: func() error {
|
||||
return self.localDelete(tag)
|
||||
return self.localDelete(tags)
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: self.c.Tr.DeleteRemoteTag,
|
||||
Label: lo.Ternary(len(tags) > 1, self.c.Tr.DeleteRemoteTags, self.c.Tr.DeleteRemoteTag),
|
||||
Keys: menuKey('r'),
|
||||
OpensMenu: true,
|
||||
OnPress: func() error {
|
||||
return self.remoteDelete(tag)
|
||||
return self.remoteDelete(tags)
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: self.c.Tr.DeleteLocalAndRemoteTag,
|
||||
Label: lo.Ternary(len(tags) > 1, self.c.Tr.DeleteLocalAndRemoteTags, self.c.Tr.DeleteLocalAndRemoteTag),
|
||||
Keys: menuKey('b'),
|
||||
OpensMenu: true,
|
||||
OnPress: func() error {
|
||||
return self.localAndRemoteDelete(tag)
|
||||
return self.localAndRemoteDelete(tags)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return self.c.Menu(types.CreateMenuOptions{
|
||||
Title: menuTitle,
|
||||
Title: self.deleteTagsTitle(tags),
|
||||
Items: menuItems,
|
||||
})
|
||||
}
|
||||
|
||||
func (self *TagsController) deleteTagsTitle(tags []*models.Tag) string {
|
||||
if len(tags) > 1 {
|
||||
return self.c.Tr.DeleteTagsTitle
|
||||
}
|
||||
|
||||
return utils.ResolvePlaceholderString(
|
||||
self.c.Tr.DeleteTagTitle,
|
||||
map[string]string{
|
||||
"tagName": tags[0].Name,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (self *TagsController) push(tag *models.Tag) error {
|
||||
title := utils.ResolvePlaceholderString(
|
||||
self.c.Tr.PushTagTitle,
|
||||
@@ -329,3 +357,7 @@ func (self *TagsController) create() error {
|
||||
func (self *TagsController) context() *context.TagsContext {
|
||||
return self.c.Contexts().Tags
|
||||
}
|
||||
|
||||
func tagNames(tags []*models.Tag) []string {
|
||||
return lo.Map(tags, func(tag *models.Tag, _ int) string { return tag.Name })
|
||||
}
|
||||
|
||||
@@ -609,13 +609,21 @@ type TranslationSet struct {
|
||||
LightweightTag string
|
||||
AnnotatedTag string
|
||||
DeleteTagTitle string
|
||||
DeleteTagsTitle string
|
||||
DeleteLocalTag string
|
||||
DeleteLocalTags string
|
||||
DeleteRemoteTag string
|
||||
DeleteRemoteTags string
|
||||
DeleteLocalAndRemoteTag string
|
||||
DeleteLocalAndRemoteTags string
|
||||
SelectRemoteTagUpstream string
|
||||
SelectRemoteTagsUpstream string
|
||||
DeleteRemoteTagPrompt string
|
||||
DeleteRemoteTagsPrompt string
|
||||
DeleteLocalAndRemoteTagPrompt string
|
||||
DeleteLocalAndRemoteTagsPrompt string
|
||||
RemoteTagDeletedMessage string
|
||||
RemoteTagsDeletedMessage string
|
||||
PushTagTitle string
|
||||
PushTag string
|
||||
PushTagTooltip string
|
||||
@@ -1789,13 +1797,21 @@ func EnglishTranslationSet() *TranslationSet {
|
||||
AnnotatedTag: "Annotated tag",
|
||||
LightweightTag: "Lightweight tag",
|
||||
DeleteTagTitle: "Delete tag '{{.tagName}}'?",
|
||||
DeleteTagsTitle: "Delete selected tags?",
|
||||
DeleteLocalTag: "Delete local tag",
|
||||
DeleteLocalTags: "Delete local tags",
|
||||
DeleteRemoteTag: "Delete remote tag",
|
||||
DeleteRemoteTags: "Delete remote tags",
|
||||
DeleteLocalAndRemoteTag: "Delete local and remote tag",
|
||||
DeleteLocalAndRemoteTags: "Delete local and remote tags",
|
||||
RemoteTagDeletedMessage: "Remote tag deleted",
|
||||
RemoteTagsDeletedMessage: "Remote tags deleted",
|
||||
SelectRemoteTagUpstream: "Remote from which to remove tag '{{.tagName}}':",
|
||||
SelectRemoteTagsUpstream: "Remote from which to remove the selected tags:",
|
||||
DeleteRemoteTagPrompt: "Are you sure you want to delete the remote tag '{{.tagName}}' from '{{.upstream}}'?",
|
||||
DeleteRemoteTagsPrompt: "Are you sure you want to delete the selected tags from '{{.upstream}}'?",
|
||||
DeleteLocalAndRemoteTagPrompt: "Are you sure you want to delete '{{.tagName}}' from both your machine and from '{{.upstream}}'?",
|
||||
DeleteLocalAndRemoteTagsPrompt: "Are you sure you want to delete the selected tags from both your machine and from '{{.upstream}}'?",
|
||||
PushTagTitle: "Remote to push tag '{{.tagName}}' to:",
|
||||
// Using 'push tag' rather than just 'push' to disambiguate from a global push
|
||||
PushTag: "Push tag",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
. "github.com/jesseduffield/lazygit/pkg/integration/components"
|
||||
)
|
||||
|
||||
var DeleteMultiple = NewIntegrationTest(NewIntegrationTestArgs{
|
||||
Description: "Delete a range selection of tags remotely, locally and remotely, and locally",
|
||||
ExtraCmdArgs: []string{},
|
||||
Skip: false,
|
||||
SetupConfig: func(config *config.AppConfig) {},
|
||||
SetupRepo: func(shell *Shell) {
|
||||
shell.EmptyCommit("initial commit")
|
||||
shell.CloneIntoRemote("origin")
|
||||
|
||||
// The tags are sorted by date, newest first. Give each tag's commit a
|
||||
// date of its own so that the order is fixed.
|
||||
for i := 1; i <= 8; i++ {
|
||||
shell.EmptyCommitWithDate(fmt.Sprintf("commit %02d", i), fmt.Sprintf("2024-01-%02d 10:00:00", i))
|
||||
shell.CreateLightweightTag(fmt.Sprintf("tag-%02d", i), "HEAD")
|
||||
}
|
||||
|
||||
for _, tag := range []string{"tag-03", "tag-04", "tag-05", "tag-06"} {
|
||||
shell.PushBranch("origin", "refs/tags/"+tag) // abusing PushBranch to push a tag
|
||||
}
|
||||
},
|
||||
Run: func(t *TestDriver, keys config.KeybindingConfig) {
|
||||
t.Views().Tags().
|
||||
Focus().
|
||||
Lines(
|
||||
Contains("tag-08").IsSelected(),
|
||||
Contains("tag-07"),
|
||||
Contains("tag-06"),
|
||||
Contains("tag-05"),
|
||||
Contains("tag-04"),
|
||||
Contains("tag-03"),
|
||||
Contains("tag-02"),
|
||||
Contains("tag-01"),
|
||||
).
|
||||
NavigateToLine(Contains("tag-06")).
|
||||
Press(keys.Universal.RangeSelectDown).
|
||||
Press(keys.Universal.Remove).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().
|
||||
Menu().
|
||||
Title(Equals("Delete selected tags?")).
|
||||
Select(Contains("Delete remote tags")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().Prompt().
|
||||
Title(Equals("Remote from which to remove the selected tags:")).
|
||||
InitialText(Equals("origin")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().
|
||||
Confirmation().
|
||||
Title(Equals("Delete selected tags?")).
|
||||
Content(Equals("Are you sure you want to delete the selected tags from 'origin'?")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectToast(Equals("Remote tags deleted"))
|
||||
}).
|
||||
// The local tags are still there, so the selection stays as it is
|
||||
Lines(
|
||||
Contains("tag-08"),
|
||||
Contains("tag-07"),
|
||||
Contains("tag-06").IsSelected(),
|
||||
Contains("tag-05").IsSelected(),
|
||||
Contains("tag-04"),
|
||||
Contains("tag-03"),
|
||||
Contains("tag-02"),
|
||||
Contains("tag-01"),
|
||||
).
|
||||
Tap(func() {
|
||||
t.Git().
|
||||
RemoteTagDeleted("origin", "tag-06").
|
||||
RemoteTagDeleted("origin", "tag-05")
|
||||
}).
|
||||
NavigateToLine(Contains("tag-04")).
|
||||
Press(keys.Universal.RangeSelectDown).
|
||||
Press(keys.Universal.Remove).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().
|
||||
Menu().
|
||||
Title(Equals("Delete selected tags?")).
|
||||
Select(Contains("Delete local and remote tags")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().Prompt().
|
||||
Title(Equals("Remote from which to remove the selected tags:")).
|
||||
InitialText(Equals("origin")).
|
||||
Confirm()
|
||||
|
||||
t.ExpectPopup().
|
||||
Confirmation().
|
||||
Title(Equals("Delete selected tags?")).
|
||||
Content(Equals("Are you sure you want to delete the selected tags from both your machine and from 'origin'?")).
|
||||
Confirm()
|
||||
}).
|
||||
Lines(
|
||||
Contains("tag-08"),
|
||||
Contains("tag-07"),
|
||||
Contains("tag-06"),
|
||||
Contains("tag-05"),
|
||||
Contains("tag-02").IsSelected(),
|
||||
Contains("tag-01"),
|
||||
).
|
||||
Tap(func() {
|
||||
t.Git().
|
||||
RemoteTagDeleted("origin", "tag-04").
|
||||
RemoteTagDeleted("origin", "tag-03")
|
||||
}).
|
||||
NavigateToLine(Contains("tag-07")).
|
||||
Press(keys.Universal.RangeSelectDown).
|
||||
Press(keys.Universal.Remove).
|
||||
Tap(func() {
|
||||
t.ExpectPopup().
|
||||
Menu().
|
||||
Title(Equals("Delete selected tags?")).
|
||||
Select(Contains("Delete local tags")).
|
||||
Confirm()
|
||||
}).
|
||||
Lines(
|
||||
Contains("tag-08"),
|
||||
Contains("tag-05").IsSelected(),
|
||||
Contains("tag-02"),
|
||||
Contains("tag-01"),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -533,6 +533,7 @@ var tests = []*components.IntegrationTest{
|
||||
tag.CrudAnnotated,
|
||||
tag.CrudLightweight,
|
||||
tag.DeleteLocalAndRemote,
|
||||
tag.DeleteMultiple,
|
||||
tag.DeleteRemoteTagWhenBranchWithSameNameExists,
|
||||
tag.ForceTagAnnotated,
|
||||
tag.ForceTagLightweight,
|
||||
|
||||
Reference in New Issue
Block a user