Improve group deletion and status banner UX
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gioui.org/app"
|
"gioui.org/app"
|
||||||
"gioui.org/gesture"
|
"gioui.org/gesture"
|
||||||
@@ -38,7 +39,10 @@ const (
|
|||||||
productName = "KeePassGO"
|
productName = "KeePassGO"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxAttachmentBytes = 10 << 20
|
const (
|
||||||
|
maxAttachmentBytes = 10 << 20
|
||||||
|
statusBannerDuration = 4 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type bannerKind string
|
type bannerKind string
|
||||||
|
|
||||||
@@ -138,6 +142,8 @@ type ui struct {
|
|||||||
createGroup widget.Clickable
|
createGroup widget.Clickable
|
||||||
renameGroup widget.Clickable
|
renameGroup widget.Clickable
|
||||||
deleteGroup widget.Clickable
|
deleteGroup widget.Clickable
|
||||||
|
confirmDeleteGroup widget.Clickable
|
||||||
|
cancelDeleteGroup widget.Clickable
|
||||||
togglePasswordInline widget.Clickable
|
togglePasswordInline widget.Clickable
|
||||||
showEntries widget.Clickable
|
showEntries widget.Clickable
|
||||||
showTemplates widget.Clickable
|
showTemplates widget.Clickable
|
||||||
@@ -173,6 +179,9 @@ type ui struct {
|
|||||||
recentVaultsPath string
|
recentVaultsPath string
|
||||||
editingEntry bool
|
editingEntry bool
|
||||||
recentVaults []string
|
recentVaults []string
|
||||||
|
deleteGroupPath []string
|
||||||
|
statusExpiresAt time.Time
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -253,6 +262,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
lifecycleMode: "local",
|
lifecycleMode: "local",
|
||||||
defaultSaveAsPath: paths.DefaultSaveAsPath,
|
defaultSaveAsPath: paths.DefaultSaveAsPath,
|
||||||
recentVaultsPath: paths.RecentVaultsPath,
|
recentVaultsPath: paths.RecentVaultsPath,
|
||||||
|
now: time.Now,
|
||||||
}
|
}
|
||||||
u.state.Session = sess
|
u.state.Session = sess
|
||||||
u.phoneSplit.Value = 0.46
|
u.phoneSplit.Value = 0.46
|
||||||
@@ -692,21 +702,76 @@ func (u *ui) displayEntryPath(path []string) []string {
|
|||||||
return append([]string(nil), path[1:]...)
|
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) {
|
func (u *ui) runAction(label string, action func() error) {
|
||||||
u.loadingMessage = actionLoadingLabel(label)
|
u.loadingMessage = actionLoadingLabel(label)
|
||||||
if err := action(); err != nil {
|
if err := action(); err != nil {
|
||||||
u.loadingMessage = ""
|
u.loadingMessage = ""
|
||||||
u.state.ErrorMessage = u.describeActionError(label, err)
|
u.state.ErrorMessage = u.describeActionError(label, err)
|
||||||
u.state.StatusMessage = ""
|
u.state.StatusMessage = ""
|
||||||
|
u.statusExpiresAt = time.Time{}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.loadingMessage = ""
|
u.loadingMessage = ""
|
||||||
u.state.ErrorMessage = ""
|
u.state.ErrorMessage = ""
|
||||||
if suppressStatusMessage(label) {
|
if suppressStatusMessage(label) {
|
||||||
u.state.StatusMessage = ""
|
u.state.StatusMessage = ""
|
||||||
|
u.statusExpiresAt = time.Time{}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.state.StatusMessage = label + " complete"
|
u.state.StatusMessage = label + " complete"
|
||||||
|
u.statusExpiresAt = u.now().Add(statusBannerDuration)
|
||||||
}
|
}
|
||||||
|
|
||||||
func suppressStatusMessage(label string) bool {
|
func suppressStatusMessage(label string) bool {
|
||||||
@@ -748,6 +813,11 @@ func (u *ui) bannerSurface() uiBanner {
|
|||||||
case strings.TrimSpace(u.state.ErrorMessage) != "":
|
case strings.TrimSpace(u.state.ErrorMessage) != "":
|
||||||
return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.state.ErrorMessage)}
|
return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.state.ErrorMessage)}
|
||||||
case strings.TrimSpace(u.state.StatusMessage) != "":
|
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)}
|
return uiBanner{Kind: bannerStatus, Message: strings.TrimSpace(u.state.StatusMessage)}
|
||||||
default:
|
default:
|
||||||
return uiBanner{}
|
return uiBanner{}
|
||||||
@@ -816,7 +886,7 @@ func (u *ui) listEmptyMessage() string {
|
|||||||
}
|
}
|
||||||
switch u.state.Section {
|
switch u.state.Section {
|
||||||
case appstate.SectionTemplates:
|
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:
|
case appstate.SectionRecycleBin:
|
||||||
return "Recycle Bin is empty."
|
return "Recycle Bin is empty."
|
||||||
default:
|
default:
|
||||||
@@ -854,6 +924,7 @@ func (u *ui) setCurrentPath(path []string) {
|
|||||||
u.currentPath = append([]string(nil), path...)
|
u.currentPath = append([]string(nil), path...)
|
||||||
u.state.NavigateToPath(path)
|
u.state.NavigateToPath(path)
|
||||||
u.syncedPath = append([]string(nil), path...)
|
u.syncedPath = append([]string(nil), path...)
|
||||||
|
u.clearDeleteGroupConfirmation()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) syncCurrentPath() {
|
func (u *ui) syncCurrentPath() {
|
||||||
@@ -866,6 +937,9 @@ func (u *ui) syncCurrentPath() {
|
|||||||
u.state.CurrentPath = append([]string(nil), u.currentPath...)
|
u.state.CurrentPath = append([]string(nil), u.currentPath...)
|
||||||
}
|
}
|
||||||
u.syncedPath = 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 {
|
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)
|
u.runAction("unlock vault", u.unlockAction)
|
||||||
}
|
}
|
||||||
for u.showEntries.Clicked(gtx) {
|
for u.showEntries.Clicked(gtx) {
|
||||||
|
u.clearDeleteGroupConfirmation()
|
||||||
u.showEntriesSection()
|
u.showEntriesSection()
|
||||||
}
|
}
|
||||||
for u.showTemplates.Clicked(gtx) {
|
for u.showTemplates.Clicked(gtx) {
|
||||||
|
u.clearDeleteGroupConfirmation()
|
||||||
u.showTemplatesSection()
|
u.showTemplatesSection()
|
||||||
}
|
}
|
||||||
for u.showRecycle.Clicked(gtx) {
|
for u.showRecycle.Clicked(gtx) {
|
||||||
|
u.clearDeleteGroupConfirmation()
|
||||||
u.showRecycleBinSection()
|
u.showRecycleBinSection()
|
||||||
}
|
}
|
||||||
for u.showLocalLifecycle.Clicked(gtx) {
|
for u.showLocalLifecycle.Clicked(gtx) {
|
||||||
@@ -988,13 +1065,24 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
u.runAction("restore history", u.restoreSelectedHistoryAction)
|
u.runAction("restore history", u.restoreSelectedHistoryAction)
|
||||||
}
|
}
|
||||||
for u.createGroup.Clicked(gtx) {
|
for u.createGroup.Clicked(gtx) {
|
||||||
|
u.clearDeleteGroupConfirmation()
|
||||||
u.runAction("create group", u.createGroupAction)
|
u.runAction("create group", u.createGroupAction)
|
||||||
}
|
}
|
||||||
for u.renameGroup.Clicked(gtx) {
|
for u.renameGroup.Clicked(gtx) {
|
||||||
|
u.clearDeleteGroupConfirmation()
|
||||||
u.runAction("rename group", u.renameGroupAction)
|
u.runAction("rename group", u.renameGroupAction)
|
||||||
}
|
}
|
||||||
for u.deleteGroup.Clicked(gtx) {
|
for u.deleteGroup.Clicked(gtx) {
|
||||||
|
u.armDeleteCurrentGroupAction()
|
||||||
|
}
|
||||||
|
for u.confirmDeleteGroup.Clicked(gtx) {
|
||||||
u.runAction("delete group", u.deleteCurrentGroupAction)
|
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) {
|
for u.togglePassword.Clicked(gtx) {
|
||||||
u.showPassword = !u.showPassword
|
u.showPassword = !u.showPassword
|
||||||
@@ -1186,7 +1274,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
label := "Add Entry"
|
label := "Add Entry"
|
||||||
if u.mode == "phone" {
|
if u.mode == "phone" {
|
||||||
label = "+ Add Entry"
|
label = "+ " + label
|
||||||
}
|
}
|
||||||
btn := material.Button(u.theme, &u.addEntry, label)
|
btn := material.Button(u.theme, &u.addEntry, label)
|
||||||
return btn.Layout(gtx)
|
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")
|
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
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 {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gioui.org/io/key"
|
"gioui.org/io/key"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
@@ -774,6 +775,7 @@ func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) {
|
|||||||
|
|
||||||
u.state.NavigateToPath([]string{"Root", "Budget"})
|
u.state.NavigateToPath([]string{"Root", "Budget"})
|
||||||
u.filter()
|
u.filter()
|
||||||
|
u.armDeleteCurrentGroupAction()
|
||||||
if err := u.deleteCurrentGroupAction(); err != nil {
|
if err := u.deleteCurrentGroupAction(); err != nil {
|
||||||
t.Fatalf("deleteCurrentGroupAction() error = %v", err)
|
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) {
|
func TestUIRunActionNormalizesRemoteSaveConflictsForDisplay(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestUILocalLifecycleActionErrorsAreVisibleAndSpecific(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -180,17 +180,25 @@ func (u *ui) deleteSelectedTemplateAction() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) createGroupAction() error {
|
func (u *ui) createGroupAction() error {
|
||||||
|
u.clearDeleteGroupConfirmation()
|
||||||
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
|
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) renameGroupAction() error {
|
func (u *ui) renameGroupAction() error {
|
||||||
|
u.clearDeleteGroupConfirmation()
|
||||||
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
|
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) deleteCurrentGroupAction() error {
|
func (u *ui) deleteCurrentGroupAction() error {
|
||||||
|
if !u.deleteGroupPendingConfirmation() {
|
||||||
|
return fmt.Errorf("confirm deleting the empty group first")
|
||||||
|
}
|
||||||
if err := u.state.DeleteCurrentGroup(); err != nil {
|
if err := u.state.DeleteCurrentGroup(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
u.clearDeleteGroupConfirmation()
|
||||||
|
u.currentPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
|
u.syncedPath = append([]string(nil), u.state.CurrentPath...)
|
||||||
u.filter()
|
u.filter()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-3
@@ -130,6 +130,7 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
|
|||||||
if u.state.Section != appstate.SectionEntries {
|
if u.state.Section != appstate.SectionEntries {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
|
deletable, deleteReason := u.currentGroupDeletionState()
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(labeledEditor(u.theme, "Group Name", &u.groupName, false)),
|
layout.Rigid(labeledEditor(u.theme, "Group Name", &u.groupName, false)),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
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,
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
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 {
|
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