From 1b4757be89c267772eb80fc97719466674eff94e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 26 Sep 2026 18:56:11 +0200 Subject: [PATCH] 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) --- pkg/gui/controllers/tags_controller.go | 136 ++++++++++++------- pkg/i18n/english.go | 16 +++ pkg/integration/tests/tag/delete_multiple.go | 133 ++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 4 files changed, 234 insertions(+), 52 deletions(-) create mode 100644 pkg/integration/tests/tag/delete_multiple.go diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go index 38c9f471b..edd74e890 100644 --- a/pkg/gui/controllers/tags_controller.go +++ b/pkg/gui/controllers/tags_controller.go @@ -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 }) +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 8e0bcb017..5f5de6914 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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", diff --git a/pkg/integration/tests/tag/delete_multiple.go b/pkg/integration/tests/tag/delete_multiple.go new file mode 100644 index 000000000..0cf3acd3a --- /dev/null +++ b/pkg/integration/tests/tag/delete_multiple.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 7ac9055ab..dd12a4e59 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -533,6 +533,7 @@ var tests = []*components.IntegrationTest{ tag.CrudAnnotated, tag.CrudLightweight, tag.DeleteLocalAndRemote, + tag.DeleteMultiple, tag.DeleteRemoteTagWhenBranchWithSameNameExists, tag.ForceTagAnnotated, tag.ForceTagLightweight,