diff --git a/appstate/state.go b/appstate/state.go index cd15381..e963534 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -161,6 +161,19 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry { } } +func (s State) SearchPathContext(entry vault.Entry) string { + path := slices.Clone(entry.Path) + switch s.Section { + case SectionTemplates: + if len(path) == 0 || path[0] != "Templates" { + path = append([]string{"Templates"}, path...) + } + case SectionRecycleBin: + path = append([]string{"Recycle Bin"}, path...) + } + return strings.Join(path, " / ") +} + func entriesInPath(entries []vault.Entry, path []string) []vault.Entry { var out []vault.Entry for _, entry := range entries { diff --git a/appstate/state_test.go b/appstate/state_test.go index 5f3af72..e4b194d 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -117,6 +117,142 @@ func TestVisibleEntriesUsesRecycleBinSection(t *testing.T) { } } +func TestVisibleEntriesUsesGlobalSearchWithinTemplateSection(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}}, + }, + }, + }, + Section: SectionTemplates, + CurrentPath: []string{"Templates", "Web"}, + SearchQuery: "infra", + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + + if len(got) != 1 || got[0].ID != "tpl-2" { + t.Fatalf("VisibleEntries() = %#v, want global template search result tpl-2", got) + } +} + +func TestVisibleEntriesResetToCurrentTemplatePathAfterClearingSearch(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}}, + }, + }, + }, + Section: SectionTemplates, + CurrentPath: []string{"Templates", "Web"}, + SearchQuery: "ssh", + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() with search error = %v", err) + } + if len(got) != 1 || got[0].ID != "tpl-3" { + t.Fatalf("VisibleEntries() with search = %#v, want tpl-3", got) + } + + state.SearchQuery = "" + got, err = state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() after clearing search error = %v", err) + } + + if len(got) != 2 { + t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 2", len(got)) + } + if titles := []string{got[0].Title, got[1].Title}; !slices.Equal(titles, []string{"Email Login", "Website Login"}) { + t.Fatalf("VisibleEntries() after clearing search titles = %v, want [Email Login Website Login]", titles) + } +} + +func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + RecycleBin: []vault.Entry{ + {ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}}, + {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + }, + }, + }, + Section: SectionRecycleBin, + CurrentPath: []string{"Root", "Internet"}, + SearchQuery: "climate", + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + + if len(got) != 1 || got[0].ID != "deleted-2" { + t.Fatalf("VisibleEntries() = %#v, want global recycle-bin search result deleted-2", got) + } +} + +func TestSearchPathContextIncludesSectionRoots(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + section Section + entry vault.Entry + want string + }{ + { + name: "entries use direct path", + section: SectionEntries, + entry: vault.Entry{Path: []string{"Root", "Internet"}}, + want: "Root / Internet", + }, + { + name: "templates retain templates root", + section: SectionTemplates, + entry: vault.Entry{Path: []string{"Templates", "Web"}}, + want: "Templates / Web", + }, + { + name: "recycle bin prefixes root label", + section: SectionRecycleBin, + entry: vault.Entry{Path: []string{"Root", "Internet"}}, + want: "Recycle Bin / Root / Internet", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + state := State{Section: tt.section} + if got := state.SearchPathContext(tt.entry); got != tt.want { + t.Fatalf("SearchPathContext(%v) = %q, want %q", tt.entry.Path, got, tt.want) + } + }) + } +} + func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) { t.Parallel() diff --git a/main.go b/main.go index 056a420..a4abfe6 100644 --- a/main.go +++ b/main.go @@ -269,6 +269,14 @@ 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 { @@ -862,7 +870,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), strings.Join(item.Path, " / ")) + lbl := material.Label(u.theme, unit.Sp(11), u.state.SearchPathContext(item)) lbl.Color = mutedColor return lbl.Layout(gtx) }), diff --git a/main_test.go b/main_test.go index d0e4136..b97ce50 100644 --- a/main_test.go +++ b/main_test.go @@ -43,6 +43,88 @@ func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { } } +func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) { + t.Parallel() + + modes := []string{"desktop", "phone"} + for _, mode := range modes { + mode := mode + t.Run(mode, func(t *testing.T) { + t.Parallel() + + u := newUIWithModel(mode, vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Root", "Internet"}}, + {ID: "entry-2", Title: "HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + }, + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", URL: "https://accounts.example.com", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}}, + }, + RecycleBin: []vault.Entry{ + {ID: "deleted-1", Title: "Deleted Bellagio", URL: "https://bellagio.example.com", Path: []string{"Root", "Internet"}}, + {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, + }, + }) + + u.showEntriesSection() + u.currentPath = []string{"Root", "Internet"} + u.search.SetText("climate") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"HVAC"}) { + t.Fatalf("entries filteredTitles() = %v, want [HVAC]", got) + } + + u.showTemplatesSection() + u.currentPath = []string{"Templates", "Web"} + u.search.SetText("infra") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) { + t.Fatalf("templates filteredTitles() = %v, want [SSH Login]", got) + } + if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Templates / Infra"}) { + t.Fatalf("templates visiblePathContexts() = %v, want [Templates / Infra]", got) + } + + u.showRecycleBinSection() + u.search.SetText("climate") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Deleted HVAC"}) { + t.Fatalf("recycle filteredTitles() = %v, want [Deleted HVAC]", got) + } + if got := u.visiblePathContexts(); !slices.Equal(got, []string{"Recycle Bin / Root / Home"}) { + t.Fatalf("recycle visiblePathContexts() = %v, want [Recycle Bin / Root / Home]", got) + } + }) + } +} + +func TestUIClearingSearchResetsToCurrentSectionListing(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Templates: []vault.Entry{ + {ID: "tpl-1", Title: "Website Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-2", Title: "Email Login", Path: []string{"Templates", "Web"}}, + {ID: "tpl-3", Title: "SSH Login", Path: []string{"Templates", "Infra"}}, + }, + }) + + u.showTemplatesSection() + u.currentPath = []string{"Templates", "Web"} + u.search.SetText("ssh") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"SSH Login"}) { + t.Fatalf("filteredTitles() with search = %v, want [SSH Login]", got) + } + + u.search.SetText("") + u.filter() + if got := u.filteredTitles(); !slices.Equal(got, []string{"Email Login", "Website Login"}) { + t.Fatalf("filteredTitles() after clearing search = %v, want [Email Login Website Login]", got) + } +} + func TestUIChildGroupsComeFromVaultModel(t *testing.T) { t.Parallel()