Improve group deletion and status banner UX
This commit is contained in:
@@ -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")
|
||||
}),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user