Centralize app state ownership in controller
This commit is contained in:
@@ -75,6 +75,35 @@ type State struct {
|
|||||||
SearchQuery string
|
SearchQuery string
|
||||||
SelectedEntryID string
|
SelectedEntryID string
|
||||||
Dirty bool
|
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) {
|
func (s *State) VisibleEntries() ([]vault.Entry, error) {
|
||||||
@@ -467,6 +496,28 @@ func (s *State) NavigateToPath(path []string) {
|
|||||||
s.SelectedEntryID = ""
|
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 {
|
func (s *State) Save() error {
|
||||||
session, ok := s.Session.(SaveableSession)
|
session, ok := s.Session.(SaveableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -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: "git-server",
|
||||||
|
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: "git-server",
|
||||||
|
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) {
|
func TestEnterGroupAppendsPathAndClearsSelection(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -150,8 +150,6 @@ type ui struct {
|
|||||||
copyIcon *widget.Icon
|
copyIcon *widget.Icon
|
||||||
clipboardWriter clipboard.Writer
|
clipboardWriter clipboard.Writer
|
||||||
loadingMessage string
|
loadingMessage string
|
||||||
statusMessage string
|
|
||||||
errorMessage string
|
|
||||||
keyboardFocus focusID
|
keyboardFocus focusID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,8 +238,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession) *ui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) filter() {
|
func (u *ui) filter() {
|
||||||
u.state.SearchQuery = u.search.Text()
|
u.state.SetSearchQuery(u.search.Text())
|
||||||
u.syncCurrentPath()
|
|
||||||
visible, err := u.state.VisibleEntries()
|
visible, err := u.state.VisibleEntries()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.visible = nil
|
u.visible = nil
|
||||||
@@ -289,26 +286,22 @@ func (u *ui) selectedAttachmentNames() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) showEntriesSection() {
|
func (u *ui) showEntriesSection() {
|
||||||
u.state.Section = appstate.SectionEntries
|
u.state.ShowSection(appstate.SectionEntries)
|
||||||
u.setCurrentPath(nil)
|
|
||||||
u.filter()
|
u.filter()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) showTemplatesSection() {
|
func (u *ui) showTemplatesSection() {
|
||||||
u.state.Section = appstate.SectionTemplates
|
u.state.ShowSection(appstate.SectionTemplates)
|
||||||
u.setCurrentPath(nil)
|
|
||||||
u.filter()
|
u.filter()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) showRecycleBinSection() {
|
func (u *ui) showRecycleBinSection() {
|
||||||
u.state.Section = appstate.SectionRecycleBin
|
u.state.ShowSection(appstate.SectionRecycleBin)
|
||||||
u.setCurrentPath(nil)
|
|
||||||
u.filter()
|
u.filter()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) childGroups() []string {
|
func (u *ui) childGroups() []string {
|
||||||
u.state.SearchQuery = u.search.Text()
|
u.state.SetSearchQuery(u.search.Text())
|
||||||
u.syncCurrentPath()
|
|
||||||
groups, err := u.state.ChildGroups()
|
groups, err := u.state.ChildGroups()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -328,14 +321,6 @@ func (u *ui) filteredTitles() []string {
|
|||||||
return titles
|
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) {
|
func (u *ui) selectedEntry() (entry, bool) {
|
||||||
for _, item := range u.visible {
|
for _, item := range u.visible {
|
||||||
if item.ID == u.state.SelectedEntryID {
|
if item.ID == u.state.SelectedEntryID {
|
||||||
@@ -517,13 +502,13 @@ 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.errorMessage = u.describeActionError(label, err)
|
u.state.ErrorMessage = u.describeActionError(label, err)
|
||||||
u.statusMessage = ""
|
u.state.StatusMessage = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
u.loadingMessage = ""
|
u.loadingMessage = ""
|
||||||
u.errorMessage = ""
|
u.state.ErrorMessage = ""
|
||||||
u.statusMessage = label + " complete"
|
u.state.StatusMessage = label + " complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
func actionLoadingLabel(label string) string {
|
func actionLoadingLabel(label string) string {
|
||||||
@@ -553,10 +538,10 @@ func (u *ui) bannerSurface() uiBanner {
|
|||||||
switch {
|
switch {
|
||||||
case strings.TrimSpace(u.loadingMessage) != "":
|
case strings.TrimSpace(u.loadingMessage) != "":
|
||||||
return uiBanner{Kind: bannerLoading, Message: strings.TrimSpace(u.loadingMessage)}
|
return uiBanner{Kind: bannerLoading, Message: strings.TrimSpace(u.loadingMessage)}
|
||||||
case strings.TrimSpace(u.errorMessage) != "":
|
case strings.TrimSpace(u.state.ErrorMessage) != "":
|
||||||
return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.errorMessage)}
|
return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.state.ErrorMessage)}
|
||||||
case strings.TrimSpace(u.statusMessage) != "":
|
case strings.TrimSpace(u.state.StatusMessage) != "":
|
||||||
return uiBanner{Kind: bannerStatus, Message: strings.TrimSpace(u.statusMessage)}
|
return uiBanner{Kind: bannerStatus, Message: strings.TrimSpace(u.state.StatusMessage)}
|
||||||
default:
|
default:
|
||||||
return uiBanner{}
|
return uiBanner{}
|
||||||
}
|
}
|
||||||
@@ -690,11 +675,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
u.runAction("lock vault", u.lockAction)
|
u.runAction("lock vault", u.lockAction)
|
||||||
}
|
}
|
||||||
for u.addEntry.Clicked(gtx) {
|
for u.addEntry.Clicked(gtx) {
|
||||||
u.state.SelectedEntryID = ""
|
u.state.BeginNewEntry()
|
||||||
u.loadSelectedEntryIntoEditor()
|
u.loadSelectedEntryIntoEditor()
|
||||||
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
|
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
|
||||||
u.statusMessage = "new entry form ready"
|
|
||||||
u.errorMessage = ""
|
|
||||||
}
|
}
|
||||||
for u.saveEntry.Clicked(gtx) {
|
for u.saveEntry.Clicked(gtx) {
|
||||||
u.runAction("save entry", u.saveEntryAction)
|
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()) == "" {
|
if strings.TrimSpace(u.search.Text()) == "" {
|
||||||
return layout.Dimensions{}
|
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
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
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 {
|
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}
|
tone := color.NRGBA{R: 231, G: 239, B: 235, A: 255}
|
||||||
textColor := accentColor
|
textColor := accentColor
|
||||||
if u.errorMessage != "" {
|
if u.state.ErrorMessage != "" {
|
||||||
message = u.errorMessage
|
message = u.state.ErrorMessage
|
||||||
tone = color.NRGBA{R: 248, G: 226, B: 223, A: 255}
|
tone = color.NRGBA{R: 248, G: 226, B: 223, A: 255}
|
||||||
textColor = color.NRGBA{R: 140, G: 46, B: 34, A: 255}
|
textColor = color.NRGBA{R: 140, G: 46, B: 34, A: 255}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1549,8 +1549,8 @@ func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) {
|
|||||||
u.masterPassword.SetText("correct horse battery staple")
|
u.masterPassword.SetText("correct horse battery staple")
|
||||||
|
|
||||||
u.runAction("open vault", u.openVaultAction)
|
u.runAction("open vault", u.openVaultAction)
|
||||||
if u.errorMessage == "" {
|
if u.state.ErrorMessage == "" {
|
||||||
t.Fatal("errorMessage = empty, want visible action error")
|
t.Fatal("state.ErrorMessage = empty, want visible action error")
|
||||||
}
|
}
|
||||||
|
|
||||||
u = newUIWithModel("desktop", vault.Model{
|
u = newUIWithModel("desktop", vault.Model{
|
||||||
@@ -1563,8 +1563,8 @@ func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) {
|
|||||||
u.filter()
|
u.filter()
|
||||||
u.state.SelectedEntryID = "git-server"
|
u.state.SelectedEntryID = "git-server"
|
||||||
u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) })
|
u.runAction("copy username", func() error { return u.copySelectedFieldAction(clipboard.TargetUsername) })
|
||||||
if u.statusMessage == "" {
|
if u.state.StatusMessage == "" {
|
||||||
t.Fatal("statusMessage = empty, want visible success status")
|
t.Fatal("state.StatusMessage = empty, want visible success status")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@ func (u *ui) performShortcut(name string) error {
|
|||||||
case shortcutLock:
|
case shortcutLock:
|
||||||
return u.lockAction()
|
return u.lockAction()
|
||||||
case shortcutNewEntry:
|
case shortcutNewEntry:
|
||||||
u.state.SelectedEntryID = ""
|
u.state.BeginNewEntry()
|
||||||
u.loadSelectedEntryIntoEditor()
|
u.loadSelectedEntryIntoEditor()
|
||||||
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
|
u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / "))
|
||||||
u.keyboardFocus = detailFocusID(detailFieldTitle)
|
u.keyboardFocus = detailFocusID(detailFieldTitle)
|
||||||
|
|||||||
Reference in New Issue
Block a user