Improve group deletion and status banner UX

This commit is contained in:
Joe Julian
2026-03-29 16:17:22 -07:00
parent fe3fa854bb
commit 1c4fb59960
4 changed files with 236 additions and 10 deletions
+91 -7
View File
@@ -12,6 +12,7 @@ import (
"path/filepath"
"slices"
"strings"
"time"
"gioui.org/app"
"gioui.org/gesture"
@@ -38,7 +39,10 @@ const (
productName = "KeePassGO"
)
const maxAttachmentBytes = 10 << 20
const (
maxAttachmentBytes = 10 << 20
statusBannerDuration = 4 * time.Second
)
type bannerKind string
@@ -138,6 +142,8 @@ type ui struct {
createGroup widget.Clickable
renameGroup widget.Clickable
deleteGroup widget.Clickable
confirmDeleteGroup widget.Clickable
cancelDeleteGroup widget.Clickable
togglePasswordInline widget.Clickable
showEntries widget.Clickable
showTemplates widget.Clickable
@@ -173,6 +179,9 @@ type ui struct {
recentVaultsPath string
editingEntry bool
recentVaults []string
deleteGroupPath []string
statusExpiresAt time.Time
now func() time.Time
}
var (
@@ -253,6 +262,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
lifecycleMode: "local",
defaultSaveAsPath: paths.DefaultSaveAsPath,
recentVaultsPath: paths.RecentVaultsPath,
now: time.Now,
}
u.state.Session = sess
u.phoneSplit.Value = 0.46
@@ -692,21 +702,76 @@ func (u *ui) displayEntryPath(path []string) []string {
return append([]string(nil), path[1:]...)
}
func pathHasPrefix(path, prefix []string) bool {
if len(prefix) > len(path) {
return false
}
return slices.Equal(path[:len(prefix)], prefix)
}
func (u *ui) currentGroupDeletionState() (bool, string) {
u.syncCurrentPath()
if u.state.Section != appstate.SectionEntries || len(u.displayPath()) == 0 || u.state.Session == nil {
return false, ""
}
model, err := u.state.Session.Current()
if err != nil {
return false, ""
}
path := append([]string(nil), u.currentPath...)
if len(model.ChildGroups(path)) > 0 {
return false, "This group contains child groups. Move or delete them before removing the group."
}
for _, item := range model.Entries {
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
return false, "This group contains entries. Move or delete them before removing the group."
}
}
for _, item := range model.Templates {
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) {
return false, "This group contains templates. Move or delete them before removing the group."
}
}
return true, "Deleting this empty group will not remove any entries."
}
func (u *ui) deleteGroupPendingConfirmation() bool {
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
}
func (u *ui) clearDeleteGroupConfirmation() {
u.deleteGroupPath = nil
}
func (u *ui) armDeleteCurrentGroupAction() {
if deletable, _ := u.currentGroupDeletionState(); !deletable {
return
}
u.syncCurrentPath()
u.deleteGroupPath = append([]string(nil), u.currentPath...)
u.state.ErrorMessage = ""
u.state.StatusMessage = fmt.Sprintf("Confirm deleting empty group %q.", strings.Join(u.displayPath(), " / "))
u.statusExpiresAt = u.now().Add(statusBannerDuration)
}
func (u *ui) runAction(label string, action func() error) {
u.loadingMessage = actionLoadingLabel(label)
if err := action(); err != nil {
u.loadingMessage = ""
u.state.ErrorMessage = u.describeActionError(label, err)
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
return
}
u.loadingMessage = ""
u.state.ErrorMessage = ""
if suppressStatusMessage(label) {
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
return
}
u.state.StatusMessage = label + " complete"
u.statusExpiresAt = u.now().Add(statusBannerDuration)
}
func suppressStatusMessage(label string) bool {
@@ -748,6 +813,11 @@ func (u *ui) bannerSurface() uiBanner {
case strings.TrimSpace(u.state.ErrorMessage) != "":
return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.state.ErrorMessage)}
case strings.TrimSpace(u.state.StatusMessage) != "":
if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) {
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
return uiBanner{}
}
return uiBanner{Kind: bannerStatus, Message: strings.TrimSpace(u.state.StatusMessage)}
default:
return uiBanner{}
@@ -816,7 +886,7 @@ func (u *ui) listEmptyMessage() string {
}
switch u.state.Section {
case appstate.SectionTemplates:
return "No templates yet. Save a reusable entry as a template."
return "Templates are not available in this build."
case appstate.SectionRecycleBin:
return "Recycle Bin is empty."
default:
@@ -854,6 +924,7 @@ func (u *ui) setCurrentPath(path []string) {
u.currentPath = append([]string(nil), path...)
u.state.NavigateToPath(path)
u.syncedPath = append([]string(nil), path...)
u.clearDeleteGroupConfirmation()
}
func (u *ui) syncCurrentPath() {
@@ -866,6 +937,9 @@ func (u *ui) syncCurrentPath() {
u.state.CurrentPath = append([]string(nil), u.currentPath...)
}
u.syncedPath = append([]string(nil), u.currentPath...)
if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) {
u.clearDeleteGroupConfirmation()
}
}
func (u *ui) layout(gtx layout.Context) layout.Dimensions {
@@ -895,12 +969,15 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
u.runAction("unlock vault", u.unlockAction)
}
for u.showEntries.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showEntriesSection()
}
for u.showTemplates.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showTemplatesSection()
}
for u.showRecycle.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.showRecycleBinSection()
}
for u.showLocalLifecycle.Clicked(gtx) {
@@ -988,13 +1065,24 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
u.runAction("restore history", u.restoreSelectedHistoryAction)
}
for u.createGroup.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.runAction("create group", u.createGroupAction)
}
for u.renameGroup.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.runAction("rename group", u.renameGroupAction)
}
for u.deleteGroup.Clicked(gtx) {
u.armDeleteCurrentGroupAction()
}
for u.confirmDeleteGroup.Clicked(gtx) {
u.runAction("delete group", u.deleteCurrentGroupAction)
u.clearDeleteGroupConfirmation()
}
for u.cancelDeleteGroup.Clicked(gtx) {
u.clearDeleteGroupConfirmation()
u.state.StatusMessage = ""
u.statusExpiresAt = time.Time{}
}
for u.togglePassword.Clicked(gtx) {
u.showPassword = !u.showPassword
@@ -1186,7 +1274,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
}
label := "Add Entry"
if u.mode == "phone" {
label = "+ Add Entry"
label = "+ " + label
}
btn := material.Button(u.theme, &u.addEntry, label)
return btn.Layout(gtx)
@@ -1214,10 +1302,6 @@ func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showTemplates, "Templates")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
}),
+96
View File
@@ -10,6 +10,7 @@ import (
"slices"
"strings"
"testing"
"time"
"gioui.org/io/key"
"gioui.org/unit"
@@ -774,6 +775,7 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) {
u.state.NavigateToPath([]string{"Root", "Budget"})
u.filter()
u.armDeleteCurrentGroupAction()
if err := u.deleteCurrentGroupAction(); err != nil {
t.Fatalf("deleteCurrentGroupAction() error = %v", err)
}
@@ -1667,6 +1669,28 @@ func TestUIBannerSurfacePrefersLoadingThenErrorThenStatus(t *testing.T) {
}
}
func TestUIStatusBannerExpiresAfterTimeout(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC)
u := newUIWithModel("desktop", vault.Model{})
u.now = func() time.Time { return now }
u.state.StatusMessage = "synchronize vault complete"
u.statusExpiresAt = now.Add(statusBannerDuration)
if got := u.bannerSurface(); got.Kind != bannerStatus || got.Message != "synchronize vault complete" {
t.Fatalf("bannerSurface() before expiry = %#v, want visible status banner", got)
}
now = now.Add(statusBannerDuration + time.Millisecond)
if got := u.bannerSurface(); got.Kind != bannerNone {
t.Fatalf("bannerSurface() after expiry = %#v, want no banner", got)
}
if got := u.state.StatusMessage; got != "" {
t.Fatalf("state.StatusMessage after expiry = %q, want empty", got)
}
}
func TestUIRunActionNormalizesRemoteSaveConflictsForDisplay(t *testing.T) {
t.Parallel()
@@ -2144,6 +2168,78 @@ func TestUILocalLifecycleActionsUpdateVisibleStatusMessages(t *testing.T) {
}
}
func TestUIGroupDeletionOnlyAllowsEmptyGroupsAndRequiresConfirmation(t *testing.T) {
t.Parallel()
t.Run("non-empty group cannot be deleted", func(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
Groups: [][]string{{"Root"}, {"Root", "Internet"}},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
if deletable, reason := u.currentGroupDeletionState(); deletable {
t.Fatal("currentGroupDeletionState() deletable = true, want false for non-empty group")
} else if !strings.Contains(reason, "contains entries") {
t.Fatalf("currentGroupDeletionState() reason = %q, want contains entries guidance", reason)
}
})
t.Run("empty group requires confirmation before deletion", func(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Groups: [][]string{{"Root"}, {"Root", "Archive"}},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Archive"})
u.filter()
if deletable, reason := u.currentGroupDeletionState(); !deletable {
t.Fatalf("currentGroupDeletionState() = false, want true for empty group: %q", reason)
}
u.armDeleteCurrentGroupAction()
if !u.deleteGroupPendingConfirmation() {
t.Fatal("deleteGroupPendingConfirmation() = false, want true after arming delete")
}
if got := u.state.StatusMessage; !strings.Contains(got, "Confirm deleting empty group") {
t.Fatalf("StatusMessage after arming delete = %q, want confirmation guidance", got)
}
if err := u.deleteCurrentGroupAction(); err != nil {
t.Fatalf("deleteCurrentGroupAction() error = %v", err)
}
if u.deleteGroupPendingConfirmation() {
t.Fatal("deleteGroupPendingConfirmation() = true, want false after deletion")
}
if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after delete = %v, want [Root]", got)
}
if got := u.childGroups(); len(got) != 0 {
t.Fatalf("childGroups() after delete = %v, want empty", got)
}
})
}
func TestUITemplateSectionEmptyStateStaysProductSpecific(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.showTemplatesSection()
got := u.listEmptyMessage()
if got != "Templates are not available in this build." {
t.Fatalf("listEmptyMessage() = %q, want templates unavailable copy", got)
}
}
func TestUILocalLifecycleActionErrorsAreVisibleAndSpecific(t *testing.T) {
t.Parallel()
+8
View File
@@ -180,17 +180,25 @@ func (u *ui) deleteSelectedTemplateAction() error {
}
func (u *ui) createGroupAction() error {
u.clearDeleteGroupConfirmation()
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
}
func (u *ui) renameGroupAction() error {
u.clearDeleteGroupConfirmation()
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
}
func (u *ui) deleteCurrentGroupAction() error {
if !u.deleteGroupPendingConfirmation() {
return fmt.Errorf("confirm deleting the empty group first")
}
if err := u.state.DeleteCurrentGroup(); err != nil {
return err
}
u.clearDeleteGroupConfirmation()
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
u.filter()
return nil
}
+41 -3
View File
@@ -130,6 +130,7 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
if u.state.Section != appstate.SectionEntries {
return layout.Dimensions{}
}
deletable, deleteReason := u.currentGroupDeletionState()
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(labeledEditor(u.theme, "Group Name", &u.groupName, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
@@ -145,16 +146,53 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Group")
return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Current Group")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteGroup, "Delete Group")
if !deletable || u.deleteGroupPendingConfirmation() {
return layout.Dimensions{}
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.deleteGroup, "Delete Empty Group")
}),
)
}),
)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(u.displayPath()) == 0 {
return layout.Dimensions{}
}
if u.deleteGroupPendingConfirmation() {
lbl := material.Label(u.theme, unit.Sp(12), fmt.Sprintf("Delete %q? This group is empty, and the deletion cannot be undone.", strings.Join(u.displayPath(), " / ")))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
if deletable || strings.TrimSpace(deleteReason) == "" {
return layout.Dimensions{}
}
lbl := material.Label(u.theme, unit.Sp(12), deleteReason)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !u.deleteGroupPendingConfirmation() {
return layout.Dimensions{}
}
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.confirmDeleteGroup, "Confirm Delete")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.cancelDeleteGroup, "Cancel")
}),
)
}),
)
}