From 43fc278fd114ac46535fb2a2154c101ef56ff69f Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:21:38 -0700 Subject: [PATCH] Make group navigation controller-driven --- appstate/state.go | 24 +++++++ appstate/state_test.go | 32 +++++++++ main.go | 28 ++++---- main_test.go | 145 ++++++++++++++++++++++++++++++++++------- ui_editor.go | 20 +----- ui_shortcuts.go | 2 +- 6 files changed, 191 insertions(+), 60 deletions(-) diff --git a/appstate/state.go b/appstate/state.go index e963534..6285dd8 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -597,6 +597,30 @@ func (s *State) MoveSelectedEntry(path []string) error { return nil } +func (s *State) DeleteCurrentGroup() error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + if err := model.DeleteGroup(s.CurrentPath); err != nil { + return err + } + + session.Replace(model) + if len(s.CurrentPath) > 0 { + s.CurrentPath = append([]string(nil), s.CurrentPath[:len(s.CurrentPath)-1]...) + } + s.SelectedEntryID = "" + s.Dirty = true + return nil +} + func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error { session, ok := s.Session.(MutableSession) if !ok { diff --git a/appstate/state_test.go b/appstate/state_test.go index e4b194d..ef864cf 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -967,6 +967,38 @@ func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) { } } +func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T) { + t.Parallel() + + model := testVaultModel() + model.CreateGroup([]string{"Root"}, "Finance") + sess := &mutableStubSession{model: model} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Finance"}, + } + + if err := state.DeleteCurrentGroup(); err != nil { + t.Fatalf("DeleteCurrentGroup() error = %v", err) + } + + if !slices.Equal(state.CurrentPath, []string{"Root"}) { + t.Fatalf("CurrentPath = %v, want [Root]", state.CurrentPath) + } + + got, err := state.ChildGroups() + if err != nil { + t.Fatalf("ChildGroups() error = %v", err) + } + + if !slices.Equal(got, []string{"Home Assistant", "Internet"}) { + t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got) + } + if !state.Dirty { + t.Fatal("Dirty = false, want true after DeleteCurrentGroup") + } +} + func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) { t.Parallel() diff --git a/main.go b/main.go index 05f0a7d..621eddd 100644 --- a/main.go +++ b/main.go @@ -124,7 +124,6 @@ type ui struct { state appstate.State masterKeyMode vault.MasterKeyMode visible []entry - currentPath []string selectedHistoryIndex int showPassword bool togglePassword widget.Clickable @@ -224,7 +223,6 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui { func (u *ui) filter() { u.state.SearchQuery = u.search.Text() - u.state.CurrentPath = append([]string(nil), u.currentPath...) visible, err := u.state.VisibleEntries() if err != nil { u.visible = nil @@ -238,25 +236,24 @@ func (u *ui) filter() { func (u *ui) showEntriesSection() { u.state.Section = appstate.SectionEntries - u.currentPath = nil + u.state.NavigateToPath(nil) u.filter() } func (u *ui) showTemplatesSection() { u.state.Section = appstate.SectionTemplates - u.currentPath = nil + u.state.NavigateToPath(nil) u.filter() } func (u *ui) showRecycleBinSection() { u.state.Section = appstate.SectionRecycleBin - u.currentPath = nil + u.state.NavigateToPath(nil) u.filter() } func (u *ui) childGroups() []string { u.state.SearchQuery = u.search.Text() - u.state.CurrentPath = append([]string(nil), u.currentPath...) groups, err := u.state.ChildGroups() if err != nil { return nil @@ -364,7 +361,6 @@ func (u *ui) createVaultAction() error { if err := u.state.CreateVault(key); err != nil { return err } - u.currentPath = nil u.filter() return nil } @@ -377,7 +373,6 @@ func (u *ui) openVaultAction() error { if err := u.state.OpenVault(strings.TrimSpace(u.vaultPath.Text()), key); err != nil { return err } - u.currentPath = nil u.filter() return nil } @@ -411,7 +406,6 @@ func (u *ui) openRemoteAction() error { if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil { return err } - u.currentPath = nil u.filter() return nil } @@ -553,8 +547,8 @@ func (u *ui) detailPlaceholderMessage() string { } func (u *ui) ensureNavClickables() { - if len(u.breadcrumbs) < len(u.currentPath)+1 { - u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1) + if len(u.breadcrumbs) < len(u.state.CurrentPath)+1 { + u.breadcrumbs = make([]widget.Clickable, len(u.state.CurrentPath)+1) } } @@ -605,7 +599,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.addEntry.Clicked(gtx) { u.state.SelectedEntryID = "" u.loadSelectedEntryIntoEditor() - u.entryPath.SetText(strings.Join(u.currentPath, " / ")) + u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / ")) u.statusMessage = "new entry form ready" u.errorMessage = "" } @@ -1229,9 +1223,9 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { return lbl.Layout(gtx) } - crumbs := append([]string{"Vault"}, append([]string{}, u.currentPath...)...) + crumbs := append([]string{"Vault"}, append([]string{}, u.state.CurrentPath...)...) if u.state.Section == appstate.SectionTemplates { - crumbs = append([]string{"Templates"}, append([]string{}, u.currentPath...)...) + crumbs = append([]string{"Templates"}, append([]string{}, u.state.CurrentPath...)...) } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, func() []layout.FlexChild { children := make([]layout.FlexChild, 0, len(crumbs)*2) @@ -1241,9 +1235,9 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { for u.breadcrumbs[index].Clicked(gtx) { if index == 0 { - u.currentPath = nil + u.state.NavigateToPath(nil) } else { - u.currentPath = append([]string{}, crumbs[1:index+1]...) + u.state.NavigateToPath(crumbs[1 : index+1]) } u.filter() } @@ -1280,7 +1274,7 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { name := group children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { for u.groupClicks[idx].Clicked(gtx) { - u.currentPath = append(append([]string{}, u.currentPath...), name) + u.state.EnterGroup(name) u.filter() } btn := material.Button(u.theme, &u.groupClicks[idx], "Folder: "+name) diff --git a/main_test.go b/main_test.go index bc43936..f8d93ae 100644 --- a/main_test.go +++ b/main_test.go @@ -31,7 +31,7 @@ func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { }, }) - u.currentPath = []string{"Crew", "Internet"} + u.state.NavigateToPath([]string{"Crew", "Internet"}) u.filter() if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio", "Vault Console"}) { t.Fatalf("filteredTitles() = %v, want [Bellagio Vault Console]", got) @@ -137,7 +137,7 @@ func TestUIChildGroupsComeFromVaultModel(t *testing.T) { }, }) - u.currentPath = []string{"Crew"} + u.state.NavigateToPath([]string{"Crew"}) if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) { t.Fatalf("childGroups() = %v, want [Home Assistant Internet]", got) } @@ -153,7 +153,7 @@ func TestUISelectedEntryFollowsApplicationStateSelection(t *testing.T) { }, }) - u.currentPath = []string{"Crew", "Internet"} + u.state.NavigateToPath([]string{"Crew", "Internet"}) u.filter() u.state.SelectedEntryID = "2" @@ -215,7 +215,7 @@ func TestUILifecycleActionsCreateSaveOpenLockAndUnlockLocalVault(t *testing.T) { if err := u.lockAction(); err != nil { t.Fatalf("lockAction() error = %v", err) } - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() if got := u.filteredTitles(); len(got) != 0 { t.Fatalf("filteredTitles() = %v, want empty while locked", got) @@ -235,7 +235,7 @@ func TestUILifecycleActionsCreateSaveOpenLockAndUnlockLocalVault(t *testing.T) { if err := reopened.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } - reopened.currentPath = []string{"Root", "Internet"} + reopened.state.NavigateToPath([]string{"Root", "Internet"}) reopened.filter() if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got) @@ -322,7 +322,7 @@ func TestUIMasterKeyModesCreateOpenAndUnlockLocalVault(t *testing.T) { if err := reopened.openVaultAction(); err != nil { t.Fatalf("openVaultAction() error = %v", err) } - reopened.currentPath = []string{"Root", "Internet"} + reopened.state.NavigateToPath([]string{"Root", "Internet"}) reopened.filter() if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got) @@ -395,7 +395,7 @@ func TestUIChangeMasterKeyModeForExistingVault(t *testing.T) { if err := reopened.openVaultAction(); err != nil { t.Fatalf("openVaultAction() with updated key error = %v", err) } - reopened.currentPath = []string{"Root", "Internet"} + reopened.state.NavigateToPath([]string{"Root", "Internet"}) reopened.filter() if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { t.Fatalf("reopened filteredTitles() = %v, want [Vault Console]", got) @@ -706,13 +706,110 @@ func TestUISectionNavigationShowsTemplatesAndRecycleBin(t *testing.T) { } u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) { t.Fatalf("entry filteredTitles() = %v, want [Vault Console]", got) } } +func TestUIGroupManagementAndPathNavigationAreControllerDriven(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + {ID: "entry-2", Title: "Home Assistant", Path: []string{"Root", "Home Assistant"}}, + }, + }) + u.showEntriesSection() + u.state.NavigateToPath([]string{"Root"}) + u.filter() + + u.groupName.SetText("Finance") + if err := u.createGroupAction(); err != nil { + t.Fatalf("createGroupAction() error = %v", err) + } + if got := u.childGroups(); !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) { + t.Fatalf("childGroups() after create = %v, want [Finance Home Assistant Internet]", got) + } + + u.state.EnterGroup("Finance") + u.filter() + u.groupName.SetText("Budget") + if err := u.renameGroupAction(); err != nil { + t.Fatalf("renameGroupAction() error = %v", err) + } + if !slices.Equal(u.state.CurrentPath, []string{"Root", "Budget"}) { + t.Fatalf("state.CurrentPath after rename = %v, want [Root Budget]", u.state.CurrentPath) + } + + u.state.NavigateToPath([]string{"Root"}) + u.filter() + if got := u.childGroups(); !slices.Equal(got, []string{"Budget", "Home Assistant", "Internet"}) { + t.Fatalf("childGroups() after rename = %v, want [Budget Home Assistant Internet]", got) + } + + u.state.NavigateToPath([]string{"Root", "Budget"}) + u.filter() + if err := u.deleteCurrentGroupAction(); err != nil { + t.Fatalf("deleteCurrentGroupAction() error = %v", err) + } + if !slices.Equal(u.state.CurrentPath, []string{"Root"}) { + t.Fatalf("state.CurrentPath after delete = %v, want [Root]", u.state.CurrentPath) + } + if got := u.childGroups(); !slices.Equal(got, []string{"Home Assistant", "Internet"}) { + t.Fatalf("childGroups() after delete = %v, want [Home Assistant Internet]", got) + } +} + +func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + { + ID: "ha", + Title: "Home Assistant", + Username: "rustyryan", + Password: "token-2", + URL: "https://ha.example.test", + Path: []string{"Root", "Home Assistant"}, + }, + }, + }) + u.showEntriesSection() + u.state.NavigateToPath([]string{"Root", "Internet"}) + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + u.entryPath.SetText("Root / Home Assistant") + + if err := u.saveEntryAction(); err != nil { + t.Fatalf("saveEntryAction() error = %v", err) + } + + u.state.NavigateToPath([]string{"Root", "Internet"}) + u.filter() + if got := u.filteredTitles(); len(got) != 0 { + t.Fatalf("filteredTitles() in source group = %v, want empty after move", got) + } + + u.state.NavigateToPath([]string{"Root", "Home Assistant"}) + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Home Assistant", "Vault Console"}) { + t.Fatalf("filteredTitles() in destination group = %v, want [Vault Console Home Assistant]", got) + } +} + func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) { t.Parallel() @@ -729,7 +826,7 @@ func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) { }, }) u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() @@ -764,7 +861,7 @@ func TestUISavesDuplicatesDeletesAndRestoresEntriesFromTheEditor(t *testing.T) { t.Fatalf("restoreSelectedRecycleEntryAction() error = %v", err) } u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() if got := u.filteredTitles(); !slices.Equal(got, []string{"Vault Console", "Vault Console (Copy)"}) { t.Fatalf("filteredTitles() after restore = %v, want restored copy", got) @@ -900,7 +997,7 @@ func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) { } u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "entry-1" u.loadSelectedEntryIntoEditor() @@ -973,7 +1070,7 @@ func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) { }, }) u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() @@ -1038,7 +1135,7 @@ func TestUISelectingEntryHistoryVersionTracksSelectedVersion(t *testing.T) { }, }) u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() @@ -1080,7 +1177,7 @@ func TestUIKeyboardShortcutActionsDispatchExpectedCommands(t *testing.T) { }, }) u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() @@ -1124,7 +1221,7 @@ func TestUIKeyboardNavigationMovesAcrossBreadcrumbsListAndDetail(t *testing.T) { }, }) u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() if got := u.keyboardFocus; got != focusSearch { @@ -1171,7 +1268,7 @@ func TestUIKeyboardNavigationActivatesBreadcrumbs(t *testing.T) { }, }) u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.keyboardFocus = breadcrumbFocusID(0) @@ -1181,8 +1278,8 @@ func TestUIKeyboardNavigationActivatesBreadcrumbs(t *testing.T) { } u.handleKeyPress(key.NameReturn, 0) - if got := u.currentPath; !slices.Equal(got, []string{"Root"}) { - t.Fatalf("currentPath after breadcrumb activation = %v, want [Root]", got) + if got := u.state.CurrentPath; !slices.Equal(got, []string{"Root"}) { + t.Fatalf("state.CurrentPath after breadcrumb activation = %v, want [Root]", got) } } @@ -1202,7 +1299,7 @@ func TestUIKeyboardShortcutsMoveFocusForSearchAndNewEntry(t *testing.T) { }, }) u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" u.loadSelectedEntryIntoEditor() @@ -1235,7 +1332,7 @@ func TestUIAccessibilityLabelsDescribeFocusableControls(t *testing.T) { }, }) u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() if got := u.accessibilityLabel(focusSearch); got != "Search vault" { @@ -1294,7 +1391,7 @@ func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) { }, }) u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) }) @@ -1444,7 +1541,7 @@ func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *test writer := &memoryClipboardWriter{} u.clipboardWriter = writer u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" @@ -1483,7 +1580,7 @@ func TestUICopyActionSanitizesClipboardBackendErrors(t *testing.T) { }) u.clipboardWriter = failingClipboardWriter{err: os.ErrPermission} u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" @@ -1515,7 +1612,7 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) { }, }) u.showEntriesSection() - u.currentPath = []string{"Root", "Internet"} + u.state.NavigateToPath([]string{"Root", "Internet"}) u.filter() u.state.SelectedEntryID = "vault-console" diff --git a/ui_editor.go b/ui_editor.go index 4a90f84..65a08f9 100644 --- a/ui_editor.go +++ b/ui_editor.go @@ -6,7 +6,6 @@ import ( "strconv" "strings" - "git.julianfamily.org/keepassgo/appstate" "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/vault" @@ -25,7 +24,7 @@ func (u *ui) loadSelectedEntryIntoEditor() { u.entryURL.SetText("") u.entryNotes.SetText("") u.entryTags.SetText("") - u.entryPath.SetText(strings.Join(u.currentPath, " / ")) + u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / ")) u.entryFields.SetText("") u.attachmentName.SetText("") u.attachmentPath.SetText("") @@ -161,24 +160,9 @@ func (u *ui) renameGroupAction() error { } func (u *ui) deleteCurrentGroupAction() error { - session, ok := u.state.Session.(appstate.MutableSession) - if !ok { - return fmt.Errorf("session is not mutable") - } - - model, err := session.Current() - if err != nil { + if err := u.state.DeleteCurrentGroup(); err != nil { return err } - if err := model.DeleteGroup(u.currentPath); err != nil { - return err - } - session.Replace(model) - if len(u.currentPath) > 0 { - u.currentPath = append([]string(nil), u.currentPath[:len(u.currentPath)-1]...) - u.state.CurrentPath = append([]string(nil), u.currentPath...) - } - u.state.Dirty = true u.filter() return nil } diff --git a/ui_shortcuts.go b/ui_shortcuts.go index 711e578..31af9d0 100644 --- a/ui_shortcuts.go +++ b/ui_shortcuts.go @@ -63,7 +63,7 @@ func (u *ui) performShortcut(name string) error { case shortcutNewEntry: u.state.SelectedEntryID = "" u.loadSelectedEntryIntoEditor() - u.entryPath.SetText(strings.Join(u.currentPath, " / ")) + u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / ")) u.keyboardFocus = detailFocusID(detailFieldTitle) return nil case shortcutCopyUser: