diff --git a/appstate/state.go b/appstate/state.go index e187a9b..4e1e1b2 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -302,7 +302,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) { } if s.Section == SectionEntries { - return model.EntriesInPath(s.CurrentPath), nil + return model.EntriesUnderPath(s.CurrentPath), nil } if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 { return entries, nil @@ -837,6 +837,27 @@ func (s *State) CreateGroup(name string) error { return nil } +func (s *State) MoveCurrentGroup(parent []string) error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + model, err := session.Current() + if err != nil { + return err + } + current := append([]string(nil), s.CurrentPath...) + if err := model.MoveGroup(current, parent); err != nil { + return err + } + session.Replace(model) + if len(current) > 0 { + s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1]) + } + s.Dirty = true + return nil +} + func (s *State) RenameCurrentGroup(newName string) error { session, ok := s.Session.(MutableSession) if !ok { diff --git a/appstate/state_test.go b/appstate/state_test.go index 3d22623..7e6a044 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -44,6 +44,36 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) { } } +func TestVisibleEntriesIncludesDescendantEntriesAtParentGroup(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + }, + }, + }, + CurrentPath: []string{"Crew"}, + } + + 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", "Surveillance Console"}) { + t.Fatalf("visible titles = %v, want descendant entries from Crew", titles) + } +} + func TestPendingApprovalsReturnsManagerRequests(t *testing.T) { t.Parallel() @@ -160,6 +190,41 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) { } } +func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) { + t.Parallel() + + state := State{ + Session: stubSession{ + model: vault.Model{ + Entries: []vault.Entry{ + {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}}, + {ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}}, + }, + }, + }, + CurrentPath: []string{"Crew"}, + SearchQuery: "missing", + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() with search error = %v", err) + } + if len(got) != 0 { + t.Fatalf("VisibleEntries() with missing search = %#v, want empty", got) + } + + state.SearchQuery = "" + got, err = state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() after clearing search error = %v", err) + } + if len(got) != 3 { + t.Fatalf("len(VisibleEntries()) after clearing search = %d, want 3 descendant entries", len(got)) + } +} + func TestVisibleEntriesUsesTemplateSection(t *testing.T) { t.Parallel() @@ -1150,6 +1215,27 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) { } } +func TestCreateGroupSupportsNestedGroupPath(t *testing.T) { + t.Parallel() + + session := &mutableStubSession{model: vault.Model{}} + state := State{ + Session: session, + CurrentPath: []string{"Root"}, + } + + if err := state.CreateGroup("Infrastructure / Prod"); err != nil { + 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{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { + t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got) + } +} + func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) { t.Parallel() @@ -1234,6 +1320,37 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) { } } +func TestMoveCurrentGroupMovesHierarchyAndMarksDirty(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + } + model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure") + + session := &mutableStubSession{model: model} + state := State{ + Session: session, + CurrentPath: []string{"Root", "Internet"}, + } + + if err := state.MoveCurrentGroup([]string{"Root", "Crew"}); err != nil { + t.Fatalf("MoveCurrentGroup() error = %v", err) + } + + if !slices.Equal(state.CurrentPath, []string{"Root", "Crew", "Internet"}) { + t.Fatalf("CurrentPath = %v, want [Root Crew Internet]", state.CurrentPath) + } + if got := session.model.EntriesInPath([]string{"Root", "Crew", "Internet"}); len(got) != 1 || got[0].ID != "vault-console" { + t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved entry", got) + } + if !state.Dirty { + t.Fatal("Dirty = false, want true after MoveCurrentGroup") + } +} + func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) { t.Parallel() diff --git a/main.go b/main.go index 85cbd78..439bc4a 100644 --- a/main.go +++ b/main.go @@ -165,6 +165,7 @@ type ui struct { customFieldValues []widget.Editor historyIndex widget.Editor groupName widget.Editor + groupParentPath widget.Editor passwordProfile widget.Editor attachmentName widget.Editor attachmentPath widget.Editor @@ -212,6 +213,7 @@ type ui struct { restoreHistory widget.Clickable generatePassword widget.Clickable createGroup widget.Clickable + moveGroup widget.Clickable renameGroup widget.Clickable deleteGroup widget.Clickable confirmDeleteGroup widget.Clickable @@ -388,6 +390,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) entryFields: widget.Editor{SingleLine: false, Submit: false}, historyIndex: widget.Editor{SingleLine: true, Submit: false}, groupName: widget.Editor{SingleLine: true, Submit: false}, + groupParentPath: widget.Editor{SingleLine: true, Submit: false}, passwordProfile: widget.Editor{SingleLine: true, Submit: false}, attachmentName: widget.Editor{SingleLine: true, Submit: false}, attachmentPath: widget.Editor{SingleLine: true, Submit: false}, @@ -1930,6 +1933,13 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.clearDeleteGroupConfirmation() u.runAction("create group", u.createGroupAction) } + for u.moveGroup.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.runAction("move group", u.moveCurrentGroupAction) + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.syncedPath = append([]string(nil), u.state.CurrentPath...) + u.filter() + } for u.toggleGroupControls.Clicked(gtx) { u.groupControlsHidden = !u.groupControlsHidden u.saveUIPreferences() diff --git a/main_test.go b/main_test.go index 2be676c..e4fdeb3 100644 --- a/main_test.go +++ b/main_test.go @@ -1231,6 +1231,73 @@ func TestUIGroupControlsCanBeCollapsed(t *testing.T) { } } +func TestUIParentGroupShowsDescendantEntries(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}}, + {ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}}, + {ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}}, + }, + }) + u.showEntriesSection() + u.state.NavigateToPath([]string{"Crew"}) + u.filter() + + if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio", "Vault Console", "Surveillance Console"}) { + t.Fatalf("filteredTitles() = %v, want descendant entries under Crew", got) + } +} + +func TestUICreateGroupActionSupportsNestedSubgroups(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.showEntriesSection() + u.state.NavigateToPath([]string{"Root"}) + u.groupName.SetText("Infrastructure / Prod") + + if err := u.createGroupAction(); err != nil { + 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{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { + t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got) + } +} + +func TestUIMoveCurrentGroupActionMovesHierarchy(t *testing.T) { + t.Parallel() + + model := vault.Model{ + Entries: []vault.Entry{ + {ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Internet"}}, + }, + } + model.CreateGroup([]string{"Root", "Internet"}, "Infrastructure") + + u := newUIWithModel("desktop", model) + u.showEntriesSection() + u.setCurrentPath([]string{"Root", "Internet"}) + u.groupParentPath.SetText("Root / Crew") + + if err := u.moveCurrentGroupAction(); err != nil { + t.Fatalf("moveCurrentGroupAction() error = %v", err) + } + + if !slices.Equal(u.state.CurrentPath, []string{"Root", "Crew", "Internet"}) { + t.Fatalf("state.CurrentPath = %v, want [Root Crew Internet]", u.state.CurrentPath) + } + got := u.state.Session.(*uiSession).model.EntriesInPath([]string{"Root", "Crew", "Internet"}) + if len(got) != 1 || got[0].ID != "vault-console" { + t.Fatalf("EntriesInPath(Root/Crew/Internet) = %#v, want moved vault-console entry", got) + } +} + func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) { t.Parallel() diff --git a/ui_editor.go b/ui_editor.go index f5cbab8..6428237 100644 --- a/ui_editor.go +++ b/ui_editor.go @@ -251,6 +251,14 @@ func (u *ui) createGroupAction() error { return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text())) } +func (u *ui) moveCurrentGroupAction() error { + u.clearDeleteGroupConfirmation() + if len(u.displayPath()) == 0 { + return fmt.Errorf("no current group selected") + } + return u.state.MoveCurrentGroup(parsePath(u.groupParentPath.Text())) +} + func (u *ui) renameGroupAction() error { u.clearDeleteGroupConfirmation() return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text())) diff --git a/ui_forms.go b/ui_forms.go index 37500d6..e8b2b89 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -242,12 +242,12 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { } deletable, deleteReason := u.currentGroupDeletionState() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(labeledEditor(u.theme, "Group Name", &u.groupName, false)), + layout.Rigid(labeledEditor(u.theme, "Create Group / Subgroup", &u.groupName, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.createGroup, "Create Group") + return tonedButton(gtx, u.theme, &u.createGroup, "Create") }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(u.displayPath()) == 0 { @@ -258,6 +258,14 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Current Group") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.moveGroup, "Move Current Group") + }), + ) + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !deletable || u.deleteGroupPendingConfirmation() { return layout.Dimensions{} @@ -273,6 +281,21 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { }), ) }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if len(u.displayPath()) == 0 { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(labeledEditorHelp( + u.theme, + "Move Current Group To", + "Enter the destination parent path. Use / for the root.", + &u.groupParentPath, + false, + )), + ) + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if len(u.displayPath()) == 0 { return layout.Dimensions{} diff --git a/vault/model.go b/vault/model.go index 9295bb9..e2d12c9 100644 --- a/vault/model.go +++ b/vault/model.go @@ -96,6 +96,27 @@ func (m Model) EntriesInPath(path []string) []Entry { return entries } +func (m Model) EntriesUnderPath(path []string) []Entry { + var entries []Entry + for _, entry := range m.Entries { + if !hasPathPrefix(entry.Path, path) { + continue + } + entries = append(entries, entry) + } + slices.SortFunc(entries, func(a, b Entry) int { + switch { + case a.Title < b.Title: + return -1 + case a.Title > b.Title: + return 1 + default: + return 0 + } + }) + return entries +} + func (m Model) Search(query string) []SearchResult { query = strings.TrimSpace(strings.ToLower(query)) if query == "" { @@ -256,13 +277,14 @@ func (m *Model) RestoreEntryVersion(id string, historyIndex int) error { } func (m *Model) CreateGroup(parent []string, name string) { - groupPath := append(append([]string(nil), parent...), name) - for _, existing := range m.Groups { - if slices.Equal(existing, groupPath) { - return + groupPath := append([]string(nil), parent...) + for _, part := range splitGroupPath(name) { + groupPath = append(groupPath, part) + if groupPathExists(m.Groups, groupPath) { + continue } + m.Groups = append(m.Groups, append([]string(nil), groupPath...)) } - m.Groups = append(m.Groups, groupPath) } func (m *Model) RenameGroup(path []string, newName string) error { @@ -310,6 +332,47 @@ func (m *Model) MoveEntry(id string, path []string) error { return ErrEntryNotFound } +func (m *Model) MoveGroup(path, parent []string) error { + if len(path) == 0 { + return ErrEntryNotFound + } + if hasPathPrefix(parent, path) { + return ErrEntryNotFound + } + + groupName := path[len(path)-1] + newPath := append(append([]string(nil), parent...), groupName) + moved := false + for i := range m.Entries { + if !hasPathPrefix(m.Entries[i].Path, path) { + continue + } + m.Entries[i].Path = append(append([]string(nil), newPath...), m.Entries[i].Path[len(path):]...) + moved = true + } + for i := range m.Templates { + if !hasPathPrefix(m.Templates[i].Path, path) { + continue + } + m.Templates[i].Path = append(append([]string(nil), newPath...), m.Templates[i].Path[len(path):]...) + moved = true + } + for i := range m.Groups { + if !hasPathPrefix(m.Groups[i], path) { + continue + } + m.Groups[i] = append(append([]string(nil), newPath...), m.Groups[i][len(path):]...) + moved = true + } + if !moved { + return ErrEntryNotFound + } + if !groupPathExists(m.Groups, newPath) { + m.Groups = append(m.Groups, append([]string(nil), newPath...)) + } + return nil +} + func (m *Model) MoveTemplate(id string, path []string) error { for i := range m.Templates { if m.Templates[i].ID != id { @@ -349,6 +412,27 @@ func hasPathPrefix(path, prefix []string) bool { return slices.Equal(path[:len(prefix)], prefix) } +func splitGroupPath(name string) []string { + var parts []string + for _, part := range strings.Split(name, "/") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + parts = append(parts, part) + } + return parts +} + +func groupPathExists(groups [][]string, path []string) bool { + for _, existing := range groups { + if slices.Equal(existing, path) { + return true + } + } + return false +} + func mergeEntryTemplate(template, overrides Entry) Entry { entry := cloneEntry(template) diff --git a/vault/model_test.go b/vault/model_test.go index e6666e4..81d773e 100644 --- a/vault/model_test.go +++ b/vault/model_test.go @@ -41,6 +41,19 @@ func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) { } } +func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) { + t.Parallel() + + model := testModel() + got := model.EntriesUnderPath([]string{"Crew"}) + if len(got) != 3 { + t.Fatalf("len(EntriesUnderPath(Crew)) = %d, want 3", len(got)) + } + if got[0].Title != "Bellagio" || got[1].Title != "Surveillance Console" || got[2].Title != "Vault Console" { + t.Fatalf("EntriesUnderPath(Crew) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title) + } +} + func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) { model := testModel() @@ -246,6 +259,22 @@ func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) { } } +func TestCreateGroupSupportsNestedRelativePath(t *testing.T) { + t.Parallel() + + model := testModel() + model.CreateGroup([]string{"Crew"}, "Infrastructure / Prod") + + got := model.ChildGroups([]string{"Crew"}) + if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) { + t.Fatalf("ChildGroups(Crew) = %v, want [Home Assistant Infrastructure Internet]", got) + } + got = model.ChildGroups([]string{"Crew", "Infrastructure"}) + if !slices.Equal(got, []string{"Prod"}) { + t.Fatalf("ChildGroups(Crew/Infrastructure) = %v, want [Prod]", got) + } +} + func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) { model := testModel() @@ -276,6 +305,28 @@ func TestMoveEntryChangesItsPath(t *testing.T) { } } +func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) { + t.Parallel() + + model := testModel() + model.CreateGroup([]string{"Crew", "Internet"}, "Infrastructure") + if err := model.MoveGroup([]string{"Crew", "Internet"}, []string{"Tricia"}); err != nil { + t.Fatalf("MoveGroup() error = %v", err) + } + + got := model.EntriesInPath([]string{"Tricia", "Internet"}) + if len(got) != 2 { + t.Fatalf("len(EntriesInPath(Tricia/Internet)) = %d, want 2", len(got)) + } + if len(model.EntriesInPath([]string{"Crew", "Internet"})) != 0 { + t.Fatal("EntriesInPath(Crew/Internet) should be empty after move") + } + gotGroups := model.ChildGroups([]string{"Tricia", "Internet"}) + if !slices.Equal(gotGroups, []string{"Infrastructure"}) { + t.Fatalf("ChildGroups(Tricia/Internet) = %v, want [Infrastructure]", gotGroups) + } +} + func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) { model := testModel()