Fix entry and group navigation workflows
This commit is contained in:
+22
-1
@@ -302,7 +302,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.Section == SectionEntries {
|
if s.Section == SectionEntries {
|
||||||
return model.EntriesInPath(s.CurrentPath), nil
|
return model.EntriesUnderPath(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
|
||||||
@@ -837,6 +837,27 @@ func (s *State) CreateGroup(name string) error {
|
|||||||
return nil
|
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 {
|
func (s *State) RenameCurrentGroup(newName string) error {
|
||||||
session, ok := s.Session.(MutableSession)
|
session, ok := s.Session.(MutableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -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: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||||
|
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
||||||
|
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CurrentPath: []string{"Joe"},
|
||||||
|
}
|
||||||
|
|
||||||
|
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{"Dynadot", "Git Server", "Home Assistant (Codex)"}) {
|
||||||
|
t.Fatalf("visible titles = %v, want descendant entries from Joe", titles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPendingApprovalsReturnsManagerRequests(t *testing.T) {
|
func TestPendingApprovalsReturnsManagerRequests(t *testing.T) {
|
||||||
t.Parallel()
|
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: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||||
|
{ID: "git-server", Title: "Git Server", URL: "https://git.julianfamily.org", Path: []string{"Joe", "Internet"}},
|
||||||
|
{ID: "ha-codex", Title: "Home Assistant (Codex)", URL: "https://lights.julianfamily.org", Path: []string{"Joe", "Home Assistant"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CurrentPath: []string{"Joe"},
|
||||||
|
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) {
|
func TestVisibleEntriesUsesTemplateSection(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
func TestRenameCurrentGroupUpdatesPathAndMarksDirty(t *testing.T) {
|
||||||
t.Parallel()
|
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: "git-server", Title: "Git Server", 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", "Joe"}); err != nil {
|
||||||
|
t.Fatalf("MoveCurrentGroup() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(state.CurrentPath, []string{"Root", "Joe", "Internet"}) {
|
||||||
|
t.Fatalf("CurrentPath = %v, want [Root Joe Internet]", state.CurrentPath)
|
||||||
|
}
|
||||||
|
if got := session.model.EntriesInPath([]string{"Root", "Joe", "Internet"}); len(got) != 1 || got[0].ID != "git-server" {
|
||||||
|
t.Fatalf("EntriesInPath(Root/Joe/Internet) = %#v, want moved entry", got)
|
||||||
|
}
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after MoveCurrentGroup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ type ui struct {
|
|||||||
customFieldValues []widget.Editor
|
customFieldValues []widget.Editor
|
||||||
historyIndex widget.Editor
|
historyIndex widget.Editor
|
||||||
groupName widget.Editor
|
groupName widget.Editor
|
||||||
|
groupParentPath widget.Editor
|
||||||
passwordProfile widget.Editor
|
passwordProfile widget.Editor
|
||||||
attachmentName widget.Editor
|
attachmentName widget.Editor
|
||||||
attachmentPath widget.Editor
|
attachmentPath widget.Editor
|
||||||
@@ -212,6 +213,7 @@ type ui struct {
|
|||||||
restoreHistory widget.Clickable
|
restoreHistory widget.Clickable
|
||||||
generatePassword widget.Clickable
|
generatePassword widget.Clickable
|
||||||
createGroup widget.Clickable
|
createGroup widget.Clickable
|
||||||
|
moveGroup widget.Clickable
|
||||||
renameGroup widget.Clickable
|
renameGroup widget.Clickable
|
||||||
deleteGroup widget.Clickable
|
deleteGroup widget.Clickable
|
||||||
confirmDeleteGroup 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},
|
entryFields: widget.Editor{SingleLine: false, Submit: false},
|
||||||
historyIndex: widget.Editor{SingleLine: true, Submit: false},
|
historyIndex: widget.Editor{SingleLine: true, Submit: false},
|
||||||
groupName: 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},
|
passwordProfile: widget.Editor{SingleLine: true, Submit: false},
|
||||||
attachmentName: widget.Editor{SingleLine: true, Submit: false},
|
attachmentName: widget.Editor{SingleLine: true, Submit: false},
|
||||||
attachmentPath: 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.clearDeleteGroupConfirmation()
|
||||||
u.runAction("create group", u.createGroupAction)
|
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) {
|
for u.toggleGroupControls.Clicked(gtx) {
|
||||||
u.groupControlsHidden = !u.groupControlsHidden
|
u.groupControlsHidden = !u.groupControlsHidden
|
||||||
u.saveUIPreferences()
|
u.saveUIPreferences()
|
||||||
|
|||||||
@@ -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: "dynadot", Title: "Dynadot", Path: []string{"Joe", "Internet"}},
|
||||||
|
{ID: "git-server", Title: "Git Server", Path: []string{"Joe", "Internet"}},
|
||||||
|
{ID: "ha-codex", Title: "Home Assistant (Codex)", Path: []string{"Joe", "Home Assistant"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
u.showEntriesSection()
|
||||||
|
u.state.NavigateToPath([]string{"Joe"})
|
||||||
|
u.filter()
|
||||||
|
|
||||||
|
if got := u.filteredTitles(); !slices.Equal(got, []string{"Dynadot", "Git Server", "Home Assistant (Codex)"}) {
|
||||||
|
t.Fatalf("filteredTitles() = %v, want descendant entries under Joe", 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: "git-server", Title: "Git Server", 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 / Joe")
|
||||||
|
|
||||||
|
if err := u.moveCurrentGroupAction(); err != nil {
|
||||||
|
t.Fatalf("moveCurrentGroupAction() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Equal(u.state.CurrentPath, []string{"Root", "Joe", "Internet"}) {
|
||||||
|
t.Fatalf("state.CurrentPath = %v, want [Root Joe Internet]", u.state.CurrentPath)
|
||||||
|
}
|
||||||
|
got := u.state.Session.(*uiSession).model.EntriesInPath([]string{"Root", "Joe", "Internet"})
|
||||||
|
if len(got) != 1 || got[0].ID != "git-server" {
|
||||||
|
t.Fatalf("EntriesInPath(Root/Joe/Internet) = %#v, want moved git-server entry", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) {
|
func TestUISavingEntryWithDifferentPathMovesItBetweenGroups(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -251,6 +251,14 @@ func (u *ui) createGroupAction() error {
|
|||||||
return u.state.CreateGroup(strings.TrimSpace(u.groupName.Text()))
|
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 {
|
func (u *ui) renameGroupAction() error {
|
||||||
u.clearDeleteGroupConfirmation()
|
u.clearDeleteGroupConfirmation()
|
||||||
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
|
return u.state.RenameCurrentGroup(strings.TrimSpace(u.groupName.Text()))
|
||||||
|
|||||||
+25
-2
@@ -242,12 +242,12 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
deletable, deleteReason := u.currentGroupDeletionState()
|
deletable, deleteReason := u.currentGroupDeletionState()
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
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(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
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 {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if len(u.displayPath()) == 0 {
|
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 {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.renameGroup, "Rename Current Group")
|
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 {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if !deletable || u.deleteGroupPendingConfirmation() {
|
if !deletable || u.deleteGroupPendingConfirmation() {
|
||||||
return layout.Dimensions{}
|
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 {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if len(u.displayPath()) == 0 {
|
if len(u.displayPath()) == 0 {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
|
|||||||
+89
-5
@@ -96,6 +96,27 @@ func (m Model) EntriesInPath(path []string) []Entry {
|
|||||||
return entries
|
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 {
|
func (m Model) Search(query string) []SearchResult {
|
||||||
query = strings.TrimSpace(strings.ToLower(query))
|
query = strings.TrimSpace(strings.ToLower(query))
|
||||||
if query == "" {
|
if query == "" {
|
||||||
@@ -256,13 +277,14 @@ func (m *Model) RestoreEntryVersion(id string, historyIndex int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) CreateGroup(parent []string, name string) {
|
func (m *Model) CreateGroup(parent []string, name string) {
|
||||||
groupPath := append(append([]string(nil), parent...), name)
|
groupPath := append([]string(nil), parent...)
|
||||||
for _, existing := range m.Groups {
|
for _, part := range splitGroupPath(name) {
|
||||||
if slices.Equal(existing, groupPath) {
|
groupPath = append(groupPath, part)
|
||||||
return
|
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 {
|
func (m *Model) RenameGroup(path []string, newName string) error {
|
||||||
@@ -310,6 +332,47 @@ func (m *Model) MoveEntry(id string, path []string) error {
|
|||||||
return ErrEntryNotFound
|
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 {
|
func (m *Model) MoveTemplate(id string, path []string) error {
|
||||||
for i := range m.Templates {
|
for i := range m.Templates {
|
||||||
if m.Templates[i].ID != id {
|
if m.Templates[i].ID != id {
|
||||||
@@ -349,6 +412,27 @@ func hasPathPrefix(path, prefix []string) bool {
|
|||||||
return slices.Equal(path[:len(prefix)], prefix)
|
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 {
|
func mergeEntryTemplate(template, overrides Entry) Entry {
|
||||||
entry := cloneEntry(template)
|
entry := cloneEntry(template)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ func TestEntriesInPathReturnsOnlyDirectEntries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEntriesUnderPathReturnsDescendantEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := testModel()
|
||||||
|
got := model.EntriesUnderPath([]string{"Joe"})
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("len(EntriesUnderPath(Joe)) = %d, want 3", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Title != "Dynadot" || got[1].Title != "Git Server" || got[2].Title != "Home Assistant (Codex)" {
|
||||||
|
t.Fatalf("EntriesUnderPath(Joe) titles = %q, %q, %q", got[0].Title, got[1].Title, got[2].Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
|
func TestSearchReturnsMatchesWithFullPathContext(t *testing.T) {
|
||||||
model := testModel()
|
model := testModel()
|
||||||
|
|
||||||
@@ -246,6 +259,22 @@ func TestCreateGroupMakesItVisibleAsChildGroup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateGroupSupportsNestedRelativePath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := testModel()
|
||||||
|
model.CreateGroup([]string{"Joe"}, "Infrastructure / Prod")
|
||||||
|
|
||||||
|
got := model.ChildGroups([]string{"Joe"})
|
||||||
|
if !slices.Equal(got, []string{"Home Assistant", "Infrastructure", "Internet"}) {
|
||||||
|
t.Fatalf("ChildGroups(Joe) = %v, want [Home Assistant Infrastructure Internet]", got)
|
||||||
|
}
|
||||||
|
got = model.ChildGroups([]string{"Joe", "Infrastructure"})
|
||||||
|
if !slices.Equal(got, []string{"Prod"}) {
|
||||||
|
t.Fatalf("ChildGroups(Joe/Infrastructure) = %v, want [Prod]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
|
func TestRenameGroupMovesEntriesAndKeepsHierarchy(t *testing.T) {
|
||||||
model := testModel()
|
model := testModel()
|
||||||
|
|
||||||
@@ -276,6 +305,28 @@ func TestMoveEntryChangesItsPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMoveGroupMovesEntriesAndNestedGroups(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := testModel()
|
||||||
|
model.CreateGroup([]string{"Joe", "Internet"}, "Infrastructure")
|
||||||
|
if err := model.MoveGroup([]string{"Joe", "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{"Joe", "Internet"})) != 0 {
|
||||||
|
t.Fatal("EntriesInPath(Joe/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) {
|
func TestDeleteEmptyGroupRemovesItFromNavigation(t *testing.T) {
|
||||||
model := testModel()
|
model := testModel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user