Improve group deletion and status banner UX

This commit is contained in:
Joe Julian
2026-03-29 16:17:22 -07:00
parent 75dc6453f7
commit 71b383114d
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")
}),