diff --git a/main.go b/main.go index 9959034..ab11aff 100644 --- a/main.go +++ b/main.go @@ -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") }), diff --git a/main_test.go b/main_test.go index 15771d9..756e4b6 100644 --- a/main_test.go +++ b/main_test.go @@ -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() diff --git a/ui_editor.go b/ui_editor.go index 7730eeb..4dc22ec 100644 --- a/ui_editor.go +++ b/ui_editor.go @@ -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 } diff --git a/ui_forms.go b/ui_forms.go index ffd363a..52dbde5 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -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") + }), + ) + }), ) }