From a2f153902736096385025297174493788f8e7733 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:22:17 -0700 Subject: [PATCH] Centralize app state ownership in controller --- appstate/state.go | 51 +++++++++++++++++++ appstate/state_test.go | 112 +++++++++++++++++++++++++++++++++++++++++ main.go | 53 +++++++------------ main_test.go | 8 +-- ui_shortcuts.go | 2 +- 5 files changed, 186 insertions(+), 40 deletions(-) diff --git a/appstate/state.go b/appstate/state.go index 880416b..343dacf 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -75,6 +75,35 @@ type State struct { SearchQuery string SelectedEntryID string Dirty bool + StatusMessage string + ErrorMessage string +} + +func (s *State) ShowSection(section Section) { + s.Section = section + s.CurrentPath = nil + s.SelectedEntryID = "" +} + +func (s *State) SetSearchQuery(query string) { + s.SearchQuery = query +} + +func (s *State) BeginNewEntry() { + s.SelectedEntryID = "" + s.StatusMessage = "new entry form ready" + s.ErrorMessage = "" +} + +func (s *State) SetActionResult(label string, err error) { + if err != nil { + s.ErrorMessage = err.Error() + s.StatusMessage = "" + return + } + + s.ErrorMessage = "" + s.StatusMessage = label + " complete" } func (s *State) VisibleEntries() ([]vault.Entry, error) { @@ -467,6 +496,28 @@ func (s *State) NavigateToPath(path []string) { s.SelectedEntryID = "" } +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.Dirty = true + return nil +} + func (s *State) Save() error { session, ok := s.Session.(SaveableSession) if !ok { diff --git a/appstate/state_test.go b/appstate/state_test.go index dcfe94f..e7a598c 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -884,6 +884,87 @@ func TestChangeMasterKeyMarksStateDirty(t *testing.T) { } } +func TestShowSectionResetsPathAndSelection(t *testing.T) { + t.Parallel() + + state := State{ + Section: SectionEntries, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "vault-console", + SearchQuery: "git", + } + + state.ShowSection(SectionTemplates) + + if state.Section != SectionTemplates { + t.Fatalf("Section = %q, want %q", state.Section, SectionTemplates) + } + if len(state.CurrentPath) != 0 { + t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath) + } + if state.SelectedEntryID != "" { + t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID) + } + if state.SearchQuery != "git" { + t.Fatalf("SearchQuery = %q, want search preserved", state.SearchQuery) + } +} + +func TestSetSearchQueryUpdatesControllerSearchState(t *testing.T) { + t.Parallel() + + state := State{} + + state.SetSearchQuery("lights") + + if state.SearchQuery != "lights" { + t.Fatalf("SearchQuery = %q, want %q", state.SearchQuery, "lights") + } +} + +func TestBeginNewEntryClearsSelectionAndSetsStatus(t *testing.T) { + t.Parallel() + + state := State{ + SelectedEntryID: "vault-console", + ErrorMessage: "previous error", + } + + state.BeginNewEntry() + + if state.SelectedEntryID != "" { + t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID) + } + if state.StatusMessage != "new entry form ready" { + t.Fatalf("StatusMessage = %q, want new entry form ready", state.StatusMessage) + } + if state.ErrorMessage != "" { + t.Fatalf("ErrorMessage = %q, want empty", state.ErrorMessage) + } +} + +func TestSetActionResultTracksSuccessAndFailureMessages(t *testing.T) { + t.Parallel() + + state := State{} + + state.SetActionResult("save vault", nil) + if state.StatusMessage != "save vault complete" { + t.Fatalf("StatusMessage = %q, want save vault complete", state.StatusMessage) + } + if state.ErrorMessage != "" { + t.Fatalf("ErrorMessage = %q, want empty on success", state.ErrorMessage) + } + + state.SetActionResult("save vault", errors.New("disk full")) + if state.StatusMessage != "" { + t.Fatalf("StatusMessage = %q, want empty on failure", state.StatusMessage) + } + if state.ErrorMessage != "disk full" { + t.Fatalf("ErrorMessage = %q, want disk full", state.ErrorMessage) + } +} + func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) { t.Parallel() @@ -920,6 +1001,37 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) { } } +func TestDeleteCurrentGroupMovesToParentAndMarksDirty(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) + } + if !state.Dirty { + t.Fatal("Dirty = false, want true after DeleteCurrentGroup") + } + + 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) + } +} + func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) { t.Parallel() diff --git a/main.go b/main.go index 51786c9..fe92c51 100644 --- a/main.go +++ b/main.go @@ -150,8 +150,6 @@ type ui struct { copyIcon *widget.Icon clipboardWriter clipboard.Writer loadingMessage string - statusMessage string - errorMessage string keyboardFocus focusID } @@ -240,8 +238,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui { } func (u *ui) filter() { - u.state.SearchQuery = u.search.Text() - u.syncCurrentPath() + u.state.SetSearchQuery(u.search.Text()) visible, err := u.state.VisibleEntries() if err != nil { u.visible = nil @@ -289,26 +286,22 @@ func (u *ui) selectedAttachmentNames() []string { } func (u *ui) showEntriesSection() { - u.state.Section = appstate.SectionEntries - u.setCurrentPath(nil) + u.state.ShowSection(appstate.SectionEntries) u.filter() } func (u *ui) showTemplatesSection() { - u.state.Section = appstate.SectionTemplates - u.setCurrentPath(nil) + u.state.ShowSection(appstate.SectionTemplates) u.filter() } func (u *ui) showRecycleBinSection() { - u.state.Section = appstate.SectionRecycleBin - u.setCurrentPath(nil) + u.state.ShowSection(appstate.SectionRecycleBin) u.filter() } func (u *ui) childGroups() []string { - u.state.SearchQuery = u.search.Text() - u.syncCurrentPath() + u.state.SetSearchQuery(u.search.Text()) groups, err := u.state.ChildGroups() if err != nil { return nil @@ -328,14 +321,6 @@ func (u *ui) filteredTitles() []string { return titles } -func (u *ui) visiblePathContexts() []string { - paths := make([]string, 0, len(u.visible)) - for _, item := range u.visible { - paths = append(paths, u.state.SearchPathContext(item)) - } - return paths -} - func (u *ui) selectedEntry() (entry, bool) { for _, item := range u.visible { if item.ID == u.state.SelectedEntryID { @@ -517,13 +502,13 @@ func (u *ui) runAction(label string, action func() error) { u.loadingMessage = actionLoadingLabel(label) if err := action(); err != nil { u.loadingMessage = "" - u.errorMessage = u.describeActionError(label, err) - u.statusMessage = "" + u.state.ErrorMessage = u.describeActionError(label, err) + u.state.StatusMessage = "" return } u.loadingMessage = "" - u.errorMessage = "" - u.statusMessage = label + " complete" + u.state.ErrorMessage = "" + u.state.StatusMessage = label + " complete" } func actionLoadingLabel(label string) string { @@ -553,10 +538,10 @@ func (u *ui) bannerSurface() uiBanner { switch { case strings.TrimSpace(u.loadingMessage) != "": return uiBanner{Kind: bannerLoading, Message: strings.TrimSpace(u.loadingMessage)} - case strings.TrimSpace(u.errorMessage) != "": - return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.errorMessage)} - case strings.TrimSpace(u.statusMessage) != "": - return uiBanner{Kind: bannerStatus, Message: strings.TrimSpace(u.statusMessage)} + case strings.TrimSpace(u.state.ErrorMessage) != "": + return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.state.ErrorMessage)} + case strings.TrimSpace(u.state.StatusMessage) != "": + return uiBanner{Kind: bannerStatus, Message: strings.TrimSpace(u.state.StatusMessage)} default: return uiBanner{} } @@ -690,11 +675,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.runAction("lock vault", u.lockAction) } for u.addEntry.Clicked(gtx) { - u.state.SelectedEntryID = "" + u.state.BeginNewEntry() u.loadSelectedEntryIntoEditor() u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / ")) - u.statusMessage = "new entry form ready" - u.errorMessage = "" } for u.saveEntry.Clicked(gtx) { u.runAction("save entry", u.saveEntryAction) @@ -977,7 +960,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item if strings.TrimSpace(u.search.Text()) == "" { return layout.Dimensions{} } - lbl := material.Label(u.theme, unit.Sp(11), u.state.SearchPathContext(item)) + lbl := material.Label(u.theme, unit.Sp(11), strings.Join(item.Path, " / ")) lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -1403,11 +1386,11 @@ func detailLine(th *material.Theme, label, value string) layout.Widget { } func (u *ui) feedbackBanner(gtx layout.Context) layout.Dimensions { - message := u.statusMessage + message := u.state.StatusMessage tone := color.NRGBA{R: 231, G: 239, B: 235, A: 255} textColor := accentColor - if u.errorMessage != "" { - message = u.errorMessage + if u.state.ErrorMessage != "" { + message = u.state.ErrorMessage tone = color.NRGBA{R: 248, G: 226, B: 223, A: 255} textColor = color.NRGBA{R: 140, G: 46, B: 34, A: 255} } diff --git a/main_test.go b/main_test.go index 5306d51..7f47c5f 100644 --- a/main_test.go +++ b/main_test.go @@ -1549,8 +1549,8 @@ func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) { u.masterPassword.SetText("correct horse battery staple") u.runAction("open vault", u.openVaultAction) - if u.errorMessage == "" { - t.Fatal("errorMessage = empty, want visible action error") + if u.state.ErrorMessage == "" { + t.Fatal("state.ErrorMessage = empty, want visible action error") } u = newUIWithModel("desktop", vault.Model{ @@ -1563,8 +1563,8 @@ func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) { u.filter() u.state.SelectedEntryID = "vault-console" u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) }) - if u.statusMessage == "" { - t.Fatal("statusMessage = empty, want visible success status") + if u.state.StatusMessage == "" { + t.Fatal("state.StatusMessage = empty, want visible success status") } } diff --git a/ui_shortcuts.go b/ui_shortcuts.go index 31af9d0..b52d581 100644 --- a/ui_shortcuts.go +++ b/ui_shortcuts.go @@ -61,7 +61,7 @@ func (u *ui) performShortcut(name string) error { case shortcutLock: return u.lockAction() case shortcutNewEntry: - u.state.SelectedEntryID = "" + u.state.BeginNewEntry() u.loadSelectedEntryIntoEditor() u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / ")) u.keyboardFocus = detailFocusID(detailFieldTitle)