From 59cd01f8e76166b06b103f259464414ad750757e Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 13 Apr 2026 07:12:32 -0700 Subject: [PATCH] Use vault views for entry and recycle-bin state --- TODO.md | 4 - internal/appstate/state.go | 149 ++++++++++++++++++++++++++++---- internal/appstate/state_test.go | 79 +++++++++++++++-- internal/appui/frame.go | 13 ++- internal/appui/main_test.go | 42 ++++----- internal/appui/recent_state.go | 81 ++++++++++++++--- internal/vaultview/view.go | 33 ++++++- 7 files changed, 338 insertions(+), 63 deletions(-) diff --git a/TODO.md b/TODO.md index c43ae90..3aa7188 100644 --- a/TODO.md +++ b/TODO.md @@ -8,10 +8,6 @@ The product is not complete until the global exit criteria at the end of this fi ## 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: update gRPC/API-facing datastore reads and writes to use logical `VaultRoot` paths while keeping authorization on canonical `Vault` paths. diff --git a/internal/appstate/state.go b/internal/appstate/state.go index 1b7d52f..78319bf 100644 --- a/internal/appstate/state.go +++ b/internal/appstate/state.go @@ -11,6 +11,7 @@ import ( "git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/vaultview" "git.julianfamily.org/keepassgo/internal/webdav" ) @@ -30,6 +31,8 @@ const ( SectionAbout Section = "about" ) +const entriesRootLabel = "Root" + type CurrentSession interface { Current() (vault.Model, error) } @@ -375,7 +378,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) { } 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 { return entries, nil @@ -401,7 +404,7 @@ func (s *State) ChildGroups() ([]string, error) { 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 { @@ -447,11 +450,11 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry { case SectionTemplates: return slices.Clone(model.Templates) case SectionRecycleBin: - return slices.Clone(model.RecycleBin) + return logicalEntries(vaultview.VaultRecycleBin(model).EntriesUnderPath(nil)) case SectionAPITokens, SectionAPIAudit, SectionAbout: return nil 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...) } case SectionRecycleBin: - path = append([]string{"Recycle Bin"}, path...) + path = append([]string{"Recycle Bin"}, logicalEntriesPath(path)...) + case SectionEntries: + path = logicalEntriesPath(path) } return strings.Join(path, " / ") } @@ -520,6 +525,116 @@ func filterEntries(entries []vault.Entry, query string) []vault.Entry { 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 { seen := map[string]bool{} var groups []string @@ -594,7 +709,7 @@ func (s *State) UpsertEntry(entry vault.Entry) error { return err } - model.UpsertEntry(entry) + model.UpsertEntry(vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, entry))) session.Replace(model) s.SelectedEntryID = entry.ID return s.markDirtyAndAutoSave() @@ -628,7 +743,7 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v 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 { return vault.Entry{}, err } @@ -638,7 +753,7 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v if err := s.markDirtyAndAutoSave(); err != nil { return vault.Entry{}, err } - return entry, nil + return logicalEntry(entry), nil } func (s *State) DeleteTemplate(id string) error { @@ -993,7 +1108,7 @@ func (s *State) CreateGroup(name string) error { return err } - model.CreateGroup(s.CurrentPath, name) + model.CreateGroup(vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, s.CurrentPath)), name) session.Replace(model) return s.markDirtyAndAutoSave() } @@ -1007,13 +1122,15 @@ func (s *State) MoveCurrentGroup(parent []string) error { if err != nil { return err } - current := append([]string(nil), s.CurrentPath...) - if err := model.MoveGroup(current, parent); err != nil { + current := logicalEntriesPathForModel(model, s.CurrentPath) + 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 } session.Replace(model) - if len(current) > 0 { - s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1]) + if len(currentViewPath) > 0 { + s.CurrentPath = logicalEntriesPathForModel(model, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1])) } return s.markDirtyAndAutoSave() } @@ -1029,7 +1146,7 @@ func (s *State) RenameCurrentGroup(newName string) error { 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 } @@ -1051,7 +1168,7 @@ func (s *State) MoveSelectedEntry(path []string) error { 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 } @@ -1070,7 +1187,7 @@ func (s *State) DeleteCurrentGroup() error { 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 } diff --git a/internal/appstate/state_test.go b/internal/appstate/state_test.go index 5888209..18279e2 100644 --- a/internal/appstate/state_test.go +++ b/internal/appstate/state_test.go @@ -27,7 +27,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) { }, }, }, - CurrentPath: []string{"Crew", "Internet"}, + CurrentPath: []string{"Root", "Crew", "Internet"}, } 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) { t.Parallel() @@ -1634,11 +1703,11 @@ func TestCreateGroupSupportsNestedGroupPath(t *testing.T) { t.Fatalf("CreateGroup() error = %v", err) } - if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) { - t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got) + if got := session.model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) { + t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got) } - if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { - t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got) + if got := session.model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { + t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got) } } diff --git a/internal/appui/frame.go b/internal/appui/frame.go index 78d4b0d..c8b04fe 100644 --- a/internal/appui/frame.go +++ b/internal/appui/frame.go @@ -24,6 +24,7 @@ import ( "git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/vault" + "git.julianfamily.org/keepassgo/internal/vaultview" ) func (u *ui) bannerSurface() uiBanner { @@ -558,6 +559,11 @@ func copyPath(path []string) []string { } 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) } @@ -569,9 +575,12 @@ func normalizeEntriesPathWithoutModel(path []string, root string) []string { return []string{root} } if path[0] == "Root" { + return copyPath(path) + } + if path[0] == vaultview.KeepassRoot { return append([]string{root}, path[1:]...) } - return copyPath(path) + return append([]string{root}, copyPath(path)...) } func (u *ui) normalizedEntriesPath(path []string) []string { @@ -590,7 +599,7 @@ func (u *ui) normalizedEntriesPath(path []string) []string { return []string{root} } if path[0] == "Root" && root != "" { - candidate := append([]string{root}, path[1:]...) + candidate := copyPath(path) if pathExistsInModel(model, candidate) { return candidate } diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index 7b4579f..62bada1 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -3606,11 +3606,11 @@ func TestUICreateGroupActionSupportsNestedSubgroups(t *testing.T) { t.Fatalf("createGroupAction() error = %v", err) } - if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) { - t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got) + if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) { + t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got) } - if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { - t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got) + if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { + t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got) } } @@ -5125,8 +5125,8 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) { t.Fatalf("openVaultAction() error = %v", err) } - if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { - t.Fatalf("currentPath = %v, want [keepass]", got) + if got := u.currentPath; !slices.Equal(got, []string{"Root"}) { + t.Fatalf("currentPath = %v, want [Root]", got) } if got := u.displayPath(); len(got) != 0 { t.Fatalf("displayPath() = %v, want root slash path", got) @@ -5152,8 +5152,8 @@ func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) { u.showEntriesSection() - if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { - t.Fatalf("currentPath = %v, want [keepass]", got) + if got := u.currentPath; !slices.Equal(got, []string{"Root"}) { + t.Fatalf("currentPath = %v, want [Root]", got) } if got := u.displayPath(); len(got) != 0 { t.Fatalf("displayPath() = %v, want root slash path", got) @@ -5174,15 +5174,15 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T) }) u.showEntriesSection() - if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { - t.Fatalf("currentPath after initial entries section = %v, want [keepass]", got) + if got := u.currentPath; !slices.Equal(got, []string{"Root"}) { + t.Fatalf("currentPath after initial entries section = %v, want [Root]", got) } u.showAPITokensSection() u.showEntriesSection() - if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { - t.Fatalf("currentPath after returning to entries = %v, want [keepass]", got) + if got := u.currentPath; !slices.Equal(got, []string{"Root"}) { + t.Fatalf("currentPath after returning to entries = %v, want [Root]", got) } if got := u.displayPath(); len(got) != 0 { t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got) @@ -5215,8 +5215,8 @@ func TestUISyncCurrentPathNormalizesHiddenRootAfterSectionSwitch(t *testing.T) { u.syncCurrentPath() - if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { - t.Fatalf("currentPath after syncCurrentPath() = %v, want [keepass]", got) + if got := u.currentPath; !slices.Equal(got, []string{"Root"}) { + t.Fatalf("currentPath after syncCurrentPath() = %v, want [Root]", got) } if got := u.displayPath(); len(got) != 0 { t.Fatalf("displayPath() after syncCurrentPath() = %v, want root slash path", got) @@ -5235,7 +5235,7 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) { }) u.showEntriesSection() - u.setCurrentPath([]string{"keepass", "Crew", "Internet"}) + u.setCurrentPath([]string{"Root", "Crew", "Internet"}) u.search.SetText("amazon") u.filter() u.state.SelectedEntryID = "amazon" @@ -5245,8 +5245,8 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) { u.showAPITokensSection() u.showEntriesSection() - if got := u.currentPath; !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) { - t.Fatalf("currentPath after returning to entries = %v, want [keepass Crew Internet]", got) + if got := u.currentPath; !slices.Equal(got, []string{"Root", "Crew", "Internet"}) { + t.Fatalf("currentPath after returning to entries = %v, want [Root Crew Internet]", got) } if got := u.search.Text(); got != "amazon" { t.Fatalf("search text after returning to entries = %q, want amazon", got) @@ -8073,7 +8073,7 @@ func TestUISelectedRemoteCardUsesLocalCacheSummaryForBoundRemote(t *testing.T) { wantDetails := []string{ "/vaults/cache", "Sync target: home.kdbx ยท dav.example.invalid", - "Last group: Root / Internet", + "Last group: Internet", } if !slices.Equal(gotDetails, wantDetails) { t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) @@ -8105,7 +8105,7 @@ func TestUISelectedRemoteCardUsesConnectionSummaryWithoutLocalCache(t *testing.T wantDetails := []string{ "Path: vaults/home.kdbx", "Server: https://dav.example.invalid", - "Last group: Root / Internet", + "Last group: Internet", } if !slices.Equal(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 { t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err) } - if got := u.apiPolicyPath.Text(); got != "bashertarr" { - t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "bashertarr") + if got := u.apiPolicyPath.Text(); got != "Crew / bashertarr" { + t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "Crew / bashertarr") } if !u.apiPolicyGroupScopeW.Value { t.Fatal("apiPolicyGroupScopeW.Value = false, want true") diff --git a/internal/appui/recent_state.go b/internal/appui/recent_state.go index 8cb49b4..2936f36 100644 --- a/internal/appui/recent_state.go +++ b/internal/appui/recent_state.go @@ -1260,14 +1260,10 @@ func (u *ui) recentVaultGroup(path string) []string { } func (u *ui) hiddenVaultRoot() string { - if u.state.Section != appstate.SectionEntries { - return "" + if u.state.Section == appstate.SectionEntries { + return "Root" } - model, err := u.state.Session.Current() - if err != nil { - return "" - } - return vaultview.HiddenRoot(model) + return "" } func (u *ui) enterHiddenVaultRoot() { @@ -1294,7 +1290,7 @@ func (u *ui) restoreRecentVaultGroup(path string) { u.setCurrentPath(saved) return } - if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { + if pathExistsInModel(model, saved) { u.setCurrentPath(saved) return } @@ -1317,7 +1313,7 @@ func (u *ui) restoreRecentRemoteGroup(baseURL, path string) { u.setCurrentPath(saved) return } - if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { + if pathExistsInModel(model, saved) { u.setCurrentPath(saved) return } @@ -1339,7 +1335,7 @@ func (u *ui) restoreEntriesPath(path []string) { u.setCurrentPath(path) return } - if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) { + if pathExistsInModel(model, path) { u.setCurrentPath(path) return } @@ -1415,6 +1411,22 @@ func pathHasPrefix(path, prefix []string) bool { 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 { for _, group := range model.Groups { if slices.Equal(group, path) { @@ -1433,12 +1445,14 @@ func (u *ui) currentGroupDeletionState() (bool, string) { if err != nil { return false, "" } - path := append([]string(nil), u.currentPath...) - if len(model.ChildGroups(path)) > 0 { + view := vaultview.VaultRoot(model) + 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." } 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." } } @@ -1450,6 +1464,47 @@ func (u *ui) currentGroupDeletionState() (bool, string) { 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 { return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath) } diff --git a/internal/vaultview/view.go b/internal/vaultview/view.go index 23e82b3..b9500d1 100644 --- a/internal/vaultview/view.go +++ b/internal/vaultview/view.go @@ -28,7 +28,7 @@ func Vault(model vault.Model) View { // VaultRoot returns the logical main-vault view rooted at the physical // keepass storage group. func VaultRoot(model vault.Model) View { - return rootView{model: model} + return rootView{model: model, rooted: usesKeepassRoot(model)} } // VaultRecycleBin returns the logical recycle-bin view. @@ -69,7 +69,8 @@ func (v physicalView) FromPhysicalEntry(entry vault.Entry) vault.Entry { } type rootView struct { - model vault.Model + model vault.Model + rooted bool } 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 { + if !v.rooted { + return clonePath(path) + } if len(path) == 0 { return []string{KeepassRoot} } @@ -92,6 +96,9 @@ func (v rootView) ToPhysicalPath(path []string) []string { } func (v rootView) FromPhysicalPath(path []string) []string { + if !v.rooted { + return clonePath(path) + } if len(path) == 0 { return nil } @@ -267,3 +274,25 @@ func clonePath(path []string) []string { } 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 +}