Use vault views for entry and recycle-bin state

This commit is contained in:
Joe Julian
2026-04-13 07:12:32 -07:00
parent ea30775eb7
commit 59cd01f8e7
7 changed files with 338 additions and 63 deletions
-4
View File
@@ -8,10 +8,6 @@ The product is not complete until the global exit criteria at the end of this fi
## Priority Bugs ## Priority Bugs
- Vault root view bug:
update `internal/appstate` and entries/recycle-bin UI plumbing to use
`VaultRoot` and `VaultRecycleBin` instead of raw datastore paths, removing
the hidden-root heuristic from entries browsing.
- Vault root view bug: - Vault root view bug:
update gRPC/API-facing datastore reads and writes to use logical `VaultRoot` update gRPC/API-facing datastore reads and writes to use logical `VaultRoot`
paths while keeping authorization on canonical `Vault` paths. paths while keeping authorization on canonical `Vault` paths.
+133 -16
View File
@@ -11,6 +11,7 @@ import (
"git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav" "git.julianfamily.org/keepassgo/internal/webdav"
) )
@@ -30,6 +31,8 @@ const (
SectionAbout Section = "about" SectionAbout Section = "about"
) )
const entriesRootLabel = "Root"
type CurrentSession interface { type CurrentSession interface {
Current() (vault.Model, error) Current() (vault.Model, error)
} }
@@ -375,7 +378,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
} }
if s.Section == SectionEntries { if s.Section == SectionEntries {
return entriesInPath(model.Entries, s.CurrentPath), nil return entriesInPath(entries, logicalEntriesPathForModel(model, s.CurrentPath)), nil
} }
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 { if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil return entries, nil
@@ -401,7 +404,7 @@ func (s *State) ChildGroups() ([]string, error) {
return childGroups(s.entriesForSection(model), s.CurrentPath), nil return childGroups(s.entriesForSection(model), s.CurrentPath), nil
} }
return model.ChildGroups(s.CurrentPath), nil return vaultview.VaultRoot(model).ChildGroups(entriesViewPathForModel(model, s.CurrentPath)), nil
} }
func (s *State) SelectVisibleIndex(index int) error { func (s *State) SelectVisibleIndex(index int) error {
@@ -447,11 +450,11 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry {
case SectionTemplates: case SectionTemplates:
return slices.Clone(model.Templates) return slices.Clone(model.Templates)
case SectionRecycleBin: case SectionRecycleBin:
return slices.Clone(model.RecycleBin) return logicalEntries(vaultview.VaultRecycleBin(model).EntriesUnderPath(nil))
case SectionAPITokens, SectionAPIAudit, SectionAbout: case SectionAPITokens, SectionAPIAudit, SectionAbout:
return nil return nil
default: default:
return slices.Clone(model.Entries) return logicalEntries(vaultview.VaultRoot(model).EntriesUnderPath(nil))
} }
} }
@@ -463,7 +466,9 @@ func (s State) SearchPathContext(entry vault.Entry) string {
path = append([]string{"Templates"}, path...) path = append([]string{"Templates"}, path...)
} }
case SectionRecycleBin: case SectionRecycleBin:
path = append([]string{"Recycle Bin"}, path...) path = append([]string{"Recycle Bin"}, logicalEntriesPath(path)...)
case SectionEntries:
path = logicalEntriesPath(path)
} }
return strings.Join(path, " / ") return strings.Join(path, " / ")
} }
@@ -520,6 +525,116 @@ func filterEntries(entries []vault.Entry, query string) []vault.Entry {
return out return out
} }
func logicalEntriesPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return []string{entriesRootLabel}
}
if path[0] == entriesRootLabel {
return append([]string(nil), path...)
}
if usesPhysicalEntriesRoot(model) && path[0] == vaultview.KeepassRoot {
path = path[1:]
}
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
}
func logicalEntriesPath(path []string) []string {
if len(path) == 0 {
return []string{entriesRootLabel}
}
if path[0] == entriesRootLabel {
return append([]string(nil), path...)
}
if path[0] == vaultview.KeepassRoot {
path = path[1:]
}
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
}
func entriesViewPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
switch {
case usesPhysicalEntriesRoot(model) && path[0] == entriesRootLabel:
return append([]string(nil), path[1:]...)
case usesLogicalEntriesRoot(model):
return append([]string(nil), path...)
case path[0] == entriesRootLabel:
return append([]string(nil), path[1:]...)
default:
return append([]string(nil), path...)
}
}
func logicalEntry(entry vault.Entry) vault.Entry {
entry.Path = logicalEntriesPath(entry.Path)
for i := range entry.History {
entry.History[i] = logicalEntry(entry.History[i])
}
return entry
}
func logicalEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = logicalEntry(entries[i])
}
return out
}
func entryForModel(model vault.Model, entry vault.Entry) vault.Entry {
entry.Path = entriesViewPathForModel(model, entry.Path)
for i := range entry.History {
entry.History[i] = entryForModel(model, entry.History[i])
}
return entry
}
func usesPhysicalEntriesRoot(model vault.Model) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return true
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func usesLogicalEntriesRoot(model vault.Model) bool {
for _, group := range model.Groups {
if len(group) > 0 && group[0] == entriesRootLabel {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
return true
}
}
return false
}
func childGroups(entries []vault.Entry, path []string) []string { func childGroups(entries []vault.Entry, path []string) []string {
seen := map[string]bool{} seen := map[string]bool{}
var groups []string var groups []string
@@ -594,7 +709,7 @@ func (s *State) UpsertEntry(entry vault.Entry) error {
return err return err
} }
model.UpsertEntry(entry) model.UpsertEntry(vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, entry)))
session.Replace(model) session.Replace(model)
s.SelectedEntryID = entry.ID s.SelectedEntryID = entry.ID
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
@@ -628,7 +743,7 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
return vault.Entry{}, err return vault.Entry{}, err
} }
entry, err := model.InstantiateTemplate(templateID, overrides) entry, err := model.InstantiateTemplate(templateID, vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, overrides)))
if err != nil { if err != nil {
return vault.Entry{}, err return vault.Entry{}, err
} }
@@ -638,7 +753,7 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
if err := s.markDirtyAndAutoSave(); err != nil { if err := s.markDirtyAndAutoSave(); err != nil {
return vault.Entry{}, err return vault.Entry{}, err
} }
return entry, nil return logicalEntry(entry), nil
} }
func (s *State) DeleteTemplate(id string) error { func (s *State) DeleteTemplate(id string) error {
@@ -993,7 +1108,7 @@ func (s *State) CreateGroup(name string) error {
return err return err
} }
model.CreateGroup(s.CurrentPath, name) model.CreateGroup(vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, s.CurrentPath)), name)
session.Replace(model) session.Replace(model)
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
} }
@@ -1007,13 +1122,15 @@ func (s *State) MoveCurrentGroup(parent []string) error {
if err != nil { if err != nil {
return err return err
} }
current := append([]string(nil), s.CurrentPath...) current := logicalEntriesPathForModel(model, s.CurrentPath)
if err := model.MoveGroup(current, parent); err != nil { currentViewPath := entriesViewPathForModel(model, current)
parentViewPath := entriesViewPathForModel(model, parent)
if err := model.MoveGroup(vaultview.VaultRoot(model).ToPhysicalPath(currentViewPath), vaultview.VaultRoot(model).ToPhysicalPath(parentViewPath)); err != nil {
return err return err
} }
session.Replace(model) session.Replace(model)
if len(current) > 0 { if len(currentViewPath) > 0 {
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1]) s.CurrentPath = logicalEntriesPathForModel(model, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1]))
} }
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
} }
@@ -1029,7 +1146,7 @@ func (s *State) RenameCurrentGroup(newName string) error {
return err return err
} }
if err := model.RenameGroup(s.CurrentPath, newName); err != nil { if err := model.RenameGroup(vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, s.CurrentPath)), newName); err != nil {
return err return err
} }
@@ -1051,7 +1168,7 @@ func (s *State) MoveSelectedEntry(path []string) error {
return err return err
} }
if err := model.MoveEntry(s.SelectedEntryID, path); err != nil { if err := model.MoveEntry(s.SelectedEntryID, vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, path))); err != nil {
return err return err
} }
@@ -1070,7 +1187,7 @@ func (s *State) DeleteCurrentGroup() error {
return err return err
} }
if err := model.DeleteGroup(s.CurrentPath); err != nil { if err := model.DeleteGroup(vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, s.CurrentPath))); err != nil {
return err return err
} }
+74 -5
View File
@@ -27,7 +27,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
}, },
}, },
}, },
CurrentPath: []string{"Crew", "Internet"}, CurrentPath: []string{"Root", "Crew", "Internet"},
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -583,6 +583,75 @@ func TestSearchPathContextIncludesSectionRoots(t *testing.T) {
} }
} }
func TestVisibleEntriesUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security Office"},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
titles := make([]string, 0, len(got))
for _, entry := range got {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("VisibleEntries() titles = %v, want [Bellagio Vault Console]", titles)
}
if !slices.Equal(got[0].Path, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("VisibleEntries()[0].Path = %v, want [Root Crew Internet]", got[0].Path)
}
}
func TestChildGroupsUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security Office"},
},
},
},
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("ChildGroups() = %v, want [Crew]", got)
}
}
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) { func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1634,11 +1703,11 @@ func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
t.Fatalf("CreateGroup() error = %v", err) t.Fatalf("CreateGroup() error = %v", err)
} }
if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) { if got := session.model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got) t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
} }
if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { if got := session.model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got) t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
} }
} }
+11 -2
View File
@@ -24,6 +24,7 @@ import (
"git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
) )
func (u *ui) bannerSurface() uiBanner { func (u *ui) bannerSurface() uiBanner {
@@ -558,6 +559,11 @@ func copyPath(path []string) []string {
} }
func pathExistsInModel(model vault.Model, path []string) bool { func pathExistsInModel(model vault.Model, path []string) bool {
if len(path) > 0 && path[0] == "Root" {
view := vaultview.VaultRoot(model)
viewPath := entriesViewPathForModel(model, path)
return len(view.EntriesInPath(viewPath)) > 0 || len(view.ChildGroups(viewPath)) > 0 || hasExactGroup(model, view.ToPhysicalPath(viewPath))
}
return len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) return len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path)
} }
@@ -569,9 +575,12 @@ func normalizeEntriesPathWithoutModel(path []string, root string) []string {
return []string{root} return []string{root}
} }
if path[0] == "Root" { if path[0] == "Root" {
return copyPath(path)
}
if path[0] == vaultview.KeepassRoot {
return append([]string{root}, path[1:]...) return append([]string{root}, path[1:]...)
} }
return copyPath(path) return append([]string{root}, copyPath(path)...)
} }
func (u *ui) normalizedEntriesPath(path []string) []string { func (u *ui) normalizedEntriesPath(path []string) []string {
@@ -590,7 +599,7 @@ func (u *ui) normalizedEntriesPath(path []string) []string {
return []string{root} return []string{root}
} }
if path[0] == "Root" && root != "" { if path[0] == "Root" && root != "" {
candidate := append([]string{root}, path[1:]...) candidate := copyPath(path)
if pathExistsInModel(model, candidate) { if pathExistsInModel(model, candidate) {
return candidate return candidate
} }
+21 -21
View File
@@ -3606,11 +3606,11 @@ func TestUICreateGroupActionSupportsNestedSubgroups(t *testing.T) {
t.Fatalf("createGroupAction() error = %v", err) t.Fatalf("createGroupAction() error = %v", err)
} }
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) { if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got) t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
} }
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got) t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
} }
} }
@@ -5125,8 +5125,8 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
t.Fatalf("openVaultAction() error = %v", err) t.Fatalf("openVaultAction() error = %v", err)
} }
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath = %v, want [keepass]", got) t.Fatalf("currentPath = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() = %v, want root slash path", got) t.Fatalf("displayPath() = %v, want root slash path", got)
@@ -5152,8 +5152,8 @@ func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) {
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath = %v, want [keepass]", got) t.Fatalf("currentPath = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() = %v, want root slash path", got) t.Fatalf("displayPath() = %v, want root slash path", got)
@@ -5174,15 +5174,15 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T)
}) })
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after initial entries section = %v, want [keepass]", got) t.Fatalf("currentPath after initial entries section = %v, want [Root]", got)
} }
u.showAPITokensSection() u.showAPITokensSection()
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after returning to entries = %v, want [keepass]", got) t.Fatalf("currentPath after returning to entries = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got) t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got)
@@ -5215,8 +5215,8 @@ func TestUISyncCurrentPathNormalizesHiddenRootAfterSectionSwitch(t *testing.T) {
u.syncCurrentPath() u.syncCurrentPath()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after syncCurrentPath() = %v, want [keepass]", got) t.Fatalf("currentPath after syncCurrentPath() = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() after syncCurrentPath() = %v, want root slash path", got) t.Fatalf("displayPath() after syncCurrentPath() = %v, want root slash path", got)
@@ -5235,7 +5235,7 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
}) })
u.showEntriesSection() u.showEntriesSection()
u.setCurrentPath([]string{"keepass", "Crew", "Internet"}) u.setCurrentPath([]string{"Root", "Crew", "Internet"})
u.search.SetText("amazon") u.search.SetText("amazon")
u.filter() u.filter()
u.state.SelectedEntryID = "amazon" u.state.SelectedEntryID = "amazon"
@@ -5245,8 +5245,8 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
u.showAPITokensSection() u.showAPITokensSection()
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("currentPath after returning to entries = %v, want [keepass Crew Internet]", got) t.Fatalf("currentPath after returning to entries = %v, want [Root Crew Internet]", got)
} }
if got := u.search.Text(); got != "amazon" { if got := u.search.Text(); got != "amazon" {
t.Fatalf("search text after returning to entries = %q, want amazon", got) t.Fatalf("search text after returning to entries = %q, want amazon", got)
@@ -8073,7 +8073,7 @@ func TestUISelectedRemoteCardUsesLocalCacheSummaryForBoundRemote(t *testing.T) {
wantDetails := []string{ wantDetails := []string{
"/vaults/cache", "/vaults/cache",
"Sync target: home.kdbx · dav.example.invalid", "Sync target: home.kdbx · dav.example.invalid",
"Last group: Root / Internet", "Last group: Internet",
} }
if !slices.Equal(gotDetails, wantDetails) { if !slices.Equal(gotDetails, wantDetails) {
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
@@ -8105,7 +8105,7 @@ func TestUISelectedRemoteCardUsesConnectionSummaryWithoutLocalCache(t *testing.T
wantDetails := []string{ wantDetails := []string{
"Path: vaults/home.kdbx", "Path: vaults/home.kdbx",
"Server: https://dav.example.invalid", "Server: https://dav.example.invalid",
"Last group: Root / Internet", "Last group: Internet",
} }
if !slices.Equal(gotDetails, wantDetails) { if !slices.Equal(gotDetails, wantDetails) {
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
@@ -9327,8 +9327,8 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
if err := u.useCurrentGroupForPolicyAction(); err != nil { if err := u.useCurrentGroupForPolicyAction(); err != nil {
t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err) t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err)
} }
if got := u.apiPolicyPath.Text(); got != "bashertarr" { if got := u.apiPolicyPath.Text(); got != "Crew / bashertarr" {
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "bashertarr") t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "Crew / bashertarr")
} }
if !u.apiPolicyGroupScopeW.Value { if !u.apiPolicyGroupScopeW.Value {
t.Fatal("apiPolicyGroupScopeW.Value = false, want true") t.Fatal("apiPolicyGroupScopeW.Value = false, want true")
+68 -13
View File
@@ -1260,14 +1260,10 @@ func (u *ui) recentVaultGroup(path string) []string {
} }
func (u *ui) hiddenVaultRoot() string { func (u *ui) hiddenVaultRoot() string {
if u.state.Section != appstate.SectionEntries { if u.state.Section == appstate.SectionEntries {
return "" return "Root"
} }
model, err := u.state.Session.Current() return ""
if err != nil {
return ""
}
return vaultview.HiddenRoot(model)
} }
func (u *ui) enterHiddenVaultRoot() { func (u *ui) enterHiddenVaultRoot() {
@@ -1294,7 +1290,7 @@ func (u *ui) restoreRecentVaultGroup(path string) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { if pathExistsInModel(model, saved) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
@@ -1317,7 +1313,7 @@ func (u *ui) restoreRecentRemoteGroup(baseURL, path string) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { if pathExistsInModel(model, saved) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
@@ -1339,7 +1335,7 @@ func (u *ui) restoreEntriesPath(path []string) {
u.setCurrentPath(path) u.setCurrentPath(path)
return return
} }
if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) { if pathExistsInModel(model, path) {
u.setCurrentPath(path) u.setCurrentPath(path)
return return
} }
@@ -1415,6 +1411,22 @@ func pathHasPrefix(path, prefix []string) bool {
return slices.Equal(path[:len(prefix)], prefix) return slices.Equal(path[:len(prefix)], prefix)
} }
func entriesViewPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
switch {
case usesPhysicalEntriesRoot(model) && path[0] == "Root":
return append([]string(nil), path[1:]...)
case usesLogicalEntriesRoot(model):
return append([]string(nil), path...)
case path[0] == "Root":
return append([]string(nil), path[1:]...)
default:
return append([]string(nil), path...)
}
}
func hasExactGroup(model vault.Model, path []string) bool { func hasExactGroup(model vault.Model, path []string) bool {
for _, group := range model.Groups { for _, group := range model.Groups {
if slices.Equal(group, path) { if slices.Equal(group, path) {
@@ -1433,12 +1445,14 @@ func (u *ui) currentGroupDeletionState() (bool, string) {
if err != nil { if err != nil {
return false, "" return false, ""
} }
path := append([]string(nil), u.currentPath...) view := vaultview.VaultRoot(model)
if len(model.ChildGroups(path)) > 0 { path := entriesViewPathForModel(model, u.currentPath)
physicalPath := view.ToPhysicalPath(path)
if len(model.ChildGroups(physicalPath)) > 0 {
return false, "This group contains child groups. Move or delete them before removing the group." return false, "This group contains child groups. Move or delete them before removing the group."
} }
for _, item := range model.Entries { for _, item := range model.Entries {
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) { if slices.Equal(item.Path, physicalPath) || pathHasPrefix(item.Path, physicalPath) {
return false, "This group contains entries. Move or delete them before removing the group." return false, "This group contains entries. Move or delete them before removing the group."
} }
} }
@@ -1450,6 +1464,47 @@ func (u *ui) currentGroupDeletionState() (bool, string) {
return true, "Deleting this empty group will not remove any entries." return true, "Deleting this empty group will not remove any entries."
} }
func usesPhysicalEntriesRoot(model vault.Model) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return true
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func usesLogicalEntriesRoot(model vault.Model) bool {
for _, group := range model.Groups {
if len(group) > 0 && group[0] == "Root" {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == "Root" {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == "Root" {
return true
}
}
return false
}
func (u *ui) deleteGroupPendingConfirmation() bool { func (u *ui) deleteGroupPendingConfirmation() bool {
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath) return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
} }
+31 -2
View File
@@ -28,7 +28,7 @@ func Vault(model vault.Model) View {
// VaultRoot returns the logical main-vault view rooted at the physical // VaultRoot returns the logical main-vault view rooted at the physical
// keepass storage group. // keepass storage group.
func VaultRoot(model vault.Model) View { func VaultRoot(model vault.Model) View {
return rootView{model: model} return rootView{model: model, rooted: usesKeepassRoot(model)}
} }
// VaultRecycleBin returns the logical recycle-bin view. // VaultRecycleBin returns the logical recycle-bin view.
@@ -69,7 +69,8 @@ func (v physicalView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
} }
type rootView struct { type rootView struct {
model vault.Model model vault.Model
rooted bool
} }
func (v rootView) ChildGroups(path []string) []string { func (v rootView) ChildGroups(path []string) []string {
@@ -85,6 +86,9 @@ func (v rootView) EntriesUnderPath(path []string) []vault.Entry {
} }
func (v rootView) ToPhysicalPath(path []string) []string { func (v rootView) ToPhysicalPath(path []string) []string {
if !v.rooted {
return clonePath(path)
}
if len(path) == 0 { if len(path) == 0 {
return []string{KeepassRoot} return []string{KeepassRoot}
} }
@@ -92,6 +96,9 @@ func (v rootView) ToPhysicalPath(path []string) []string {
} }
func (v rootView) FromPhysicalPath(path []string) []string { func (v rootView) FromPhysicalPath(path []string) []string {
if !v.rooted {
return clonePath(path)
}
if len(path) == 0 { if len(path) == 0 {
return nil return nil
} }
@@ -267,3 +274,25 @@ func clonePath(path []string) []string {
} }
return slices.Clone(path) return slices.Clone(path)
} }
func usesKeepassRoot(model vault.Model) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return true
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == KeepassRoot {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == KeepassRoot {
return true
}
}
return false
}