From cd21cc26c952a864178ab71dff21e3fc652c724c Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 15:18:14 -0700 Subject: [PATCH] Improve KeePassGO vault navigation UX --- main.go | 129 +++++++++++++++++++++++++++++++++++++++------------ main_test.go | 47 +++++++++++++++++++ ui_editor.go | 7 ++- ui_forms.go | 37 ++++++++++++++- 4 files changed, 187 insertions(+), 33 deletions(-) diff --git a/main.go b/main.go index 5d3a502..43801c0 100644 --- a/main.go +++ b/main.go @@ -143,6 +143,7 @@ type ui struct { attachmentClicks []widget.Clickable breadcrumbs []widget.Clickable groupClicks []widget.Clickable + recentVaultClicks []widget.Clickable state appstate.State visible []entry currentPath []string @@ -164,6 +165,7 @@ type ui struct { keyboardFocus focusID defaultSaveAsPath string editingEntry bool + recentVaults []string } var ( @@ -425,6 +427,7 @@ func (u *ui) createVaultAction() error { return err } u.vaultPath.SetText(u.saveAsTargetPath()) + u.noteRecentVault(u.saveAsTargetPath()) } u.currentPath = append([]string(nil), u.state.CurrentPath...) u.editingEntry = false @@ -444,7 +447,9 @@ func (u *ui) openVaultAction() error { if err := u.state.OpenVault(path, key); err != nil { return err } + u.noteRecentVault(path) u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.enterHiddenVaultRoot() u.editingEntry = false u.filter() return nil @@ -464,6 +469,7 @@ func (u *ui) saveAsAction() error { return err } u.vaultPath.SetText(path) + u.noteRecentVault(path) u.filter() return nil } @@ -481,6 +487,7 @@ func (u *ui) openRemoteAction() error { if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil { return err } + u.enterHiddenVaultRoot() u.editingEntry = false u.filter() return nil @@ -535,6 +542,70 @@ func (u *ui) saveAsTargetPath() string { return u.defaultSaveAsPath } +func (u *ui) noteRecentVault(path string) { + path = strings.TrimSpace(path) + if path == "" { + return + } + next := []string{path} + for _, existing := range u.recentVaults { + if existing == path { + continue + } + next = append(next, existing) + if len(next) == 6 { + break + } + } + u.recentVaults = next + if len(u.recentVaultClicks) < len(u.recentVaults) { + u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults)) + } +} + +func (u *ui) hiddenVaultRoot() string { + if u.state.Section != appstate.SectionEntries { + return "" + } + model, err := u.state.Session.Current() + if err != nil { + return "" + } + if len(model.EntriesInPath(nil)) != 0 { + return "" + } + groups := model.ChildGroups(nil) + if len(groups) != 1 { + return "" + } + return groups[0] +} + +func (u *ui) enterHiddenVaultRoot() { + root := u.hiddenVaultRoot() + if root == "" { + return + } + u.setCurrentPath([]string{root}) +} + +func (u *ui) displayPath() []string { + path := append([]string(nil), u.currentPath...) + root := u.hiddenVaultRoot() + if root == "" || len(path) == 0 || path[0] != root { + return path + } + return append([]string(nil), path[1:]...) +} + +func (u *ui) displayEntryPath(path []string) []string { + root := u.hiddenVaultRoot() + if root == "" || len(path) == 0 || path[0] != root { + return append([]string(nil), path...) + } + return append([]string(nil), path[1:]...) +} + func (u *ui) runAction(label string, action func() error) { u.loadingMessage = actionLoadingLabel(label) if err := action(); err != nil { @@ -756,10 +827,17 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.pickKeyFile.Clicked(gtx) { u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) }) } + for i := range u.recentVaultClicks { + for u.recentVaultClicks[i].Clicked(gtx) { + if i < len(u.recentVaults) { + u.vaultPath.SetText(u.recentVaults[i]) + } + } + } for u.addEntry.Clicked(gtx) { u.state.BeginNewEntry() u.loadSelectedEntryIntoEditor() - u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / ")) + u.entryPath.SetText(strings.Join(u.displayPath(), " / ")) u.editingEntry = true } for u.saveEntry.Clicked(gtx) { @@ -903,7 +981,6 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), ) }), - layout.Rigid(u.feedbackBanner), ) }) } @@ -929,7 +1006,6 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), ) }), - layout.Rigid(u.feedbackBanner), ) }) } @@ -1250,7 +1326,7 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { return lbl.Layout(gtx) }, layout.Spacer{Height: titlePad}.Layout, - detailLine(u.theme, "Path", strings.Join(item.Path, " / ")), + detailLine(u.theme, "Path", strings.Join(u.displayEntryPath(item.Path), " / ")), layout.Spacer{Height: sectionGap}.Layout, detailLine(u.theme, "Username", item.Username), layout.Spacer{Height: sectionGap}.Layout, @@ -1489,7 +1565,8 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { } u.syncCurrentPath() - crumbs := append([]string{"All Entries"}, append([]string{}, u.currentPath...)...) + displayPath := u.displayPath() + crumbs := append([]string{"/"}, append([]string{}, displayPath...)...) if u.state.Section == appstate.SectionTemplates { crumbs = append([]string{"Templates"}, append([]string{}, u.currentPath...)...) } @@ -1501,9 +1578,19 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { for u.breadcrumbs[index].Clicked(gtx) { if index == 0 { - u.setCurrentPath(nil) + root := u.hiddenVaultRoot() + if root == "" { + u.setCurrentPath(nil) + } else { + u.setCurrentPath([]string{root}) + } } else { - u.setCurrentPath(crumbs[1 : index+1]) + nextPath := crumbs[1 : index+1] + root := u.hiddenVaultRoot() + if root != "" { + nextPath = append([]string{root}, nextPath...) + } + u.setCurrentPath(nextPath) } u.filter() } @@ -1533,8 +1620,8 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { if len(groups) == 0 { return layout.Dimensions{} } - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, func() []layout.FlexChild { - children := make([]layout.FlexChild, 0, len(groups)) + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { + children := make([]layout.FlexChild, 0, len(groups)*2) for i, group := range groups { idx := i name := group @@ -1550,6 +1637,9 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { btn.TextSize = unit.Sp(12) return btn.Layout(gtx) })) + if i < len(groups)-1 { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + } } return children }()...) @@ -1571,27 +1661,6 @@ func detailLine(th *material.Theme, label, value string) layout.Widget { } } -func (u *ui) feedbackBanner(gtx layout.Context) layout.Dimensions { - message := u.state.StatusMessage - tone := color.NRGBA{R: 231, G: 239, B: 235, A: 255} - textColor := accentColor - if u.state.ErrorMessage != "" { - message = u.state.ErrorMessage - tone = color.NRGBA{R: 248, G: 226, B: 223, A: 255} - textColor = color.NRGBA{R: 140, G: 46, B: 34, A: 255} - } - if message == "" { - return layout.Dimensions{} - } - return layout.Background{}.Layout(gtx, fill(tone), func(gtx layout.Context) layout.Dimensions { - return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(13), message) - lbl.Color = textColor - return lbl.Layout(gtx) - }) - }) -} - func (u *ui) passwordLine(label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { icon := u.eyeIcon diff --git a/main_test.go b/main_test.go index f5be8ce..b721843 100644 --- a/main_test.go +++ b/main_test.go @@ -1694,6 +1694,53 @@ func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) { } } +func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "keepass.kdbx") + var encoded bytes.Buffer + if err := vault.SaveKDBX(&encoded, vault.Model{ + Entries: []vault.Entry{ + {ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}}, + }, + }, "correct horse battery staple"); err != nil { + t.Fatalf("SaveKDBX() error = %v", err) + } + if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil { + t.Fatalf("WriteFile(keepass.kdbx) error = %v", err) + } + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + u.vaultPath.SetText(path) + if err := u.openVaultAction(); err != nil { + 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.displayPath(); len(got) != 0 { + t.Fatalf("displayPath() = %v, want root slash path", got) + } + if got := u.childGroups(); !slices.Equal(got, []string{"Crew"}) { + t.Fatalf("childGroups() = %v, want [Crew]", got) + } +} + +func TestUINoteRecentVaultDeduplicatesAndOrdersMostRecentFirst(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.noteRecentVault("/tmp/one.kdbx") + u.noteRecentVault("/tmp/two.kdbx") + u.noteRecentVault("/tmp/one.kdbx") + + if got := u.recentVaults; !slices.Equal(got, []string{"/tmp/one.kdbx", "/tmp/two.kdbx"}) { + t.Fatalf("recentVaults = %v, want [/tmp/one.kdbx /tmp/two.kdbx]", got) + } +} + func TestUICopyActionsWriteExpectedClipboardContentsAndSanitizedFeedback(t *testing.T) { t.Parallel() diff --git a/ui_editor.go b/ui_editor.go index c264dfe..7730eeb 100644 --- a/ui_editor.go +++ b/ui_editor.go @@ -50,7 +50,7 @@ func (u *ui) loadSelectedEntryIntoEditor() { u.entryURL.SetText("") u.entryNotes.SetText("") u.entryTags.SetText("") - u.entryPath.SetText(strings.Join(u.state.CurrentPath, " / ")) + u.entryPath.SetText(strings.Join(u.displayPath(), " / ")) u.entryFields.SetText("") u.attachmentName.SetText("") u.attachmentPath.SetText("") @@ -65,7 +65,7 @@ func (u *ui) loadSelectedEntryIntoEditor() { u.entryURL.SetText(item.URL) u.entryNotes.SetText(item.Notes) u.entryTags.SetText(strings.Join(item.Tags, ", ")) - u.entryPath.SetText(strings.Join(item.Path, " / ")) + u.entryPath.SetText(strings.Join(u.displayEntryPath(item.Path), " / ")) u.entryFields.SetText(marshalFields(item.Fields)) u.attachmentName.SetText("") u.attachmentPath.SetText("") @@ -330,6 +330,9 @@ func (u *ui) generatePasswordAction() error { func (u *ui) editorEntry() (vault.Entry, error) { path := parsePath(u.entryPath.Text()) + if root := u.hiddenVaultRoot(); root != "" && (len(path) == 0 || path[0] != root) { + path = append([]string{root}, path...) + } fields, err := parseFields(u.entryFields.Text()) if err != nil { return vault.Entry{}, err diff --git a/ui_forms.go b/ui_forms.go index 608e854..6d7be00 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -43,6 +43,8 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(selectorEditorHelp(u.theme, "Vault Path", "Choose the existing .kdbx file to open.", &u.vaultPath, &u.pickVaultPath, "Choose File", false)), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(u.recentVaultList), ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), @@ -63,6 +65,39 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { ) } +func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions { + if len(u.recentVaults) == 0 { + return layout.Dimensions{} + } + if len(u.recentVaultClicks) < len(u.recentVaults) { + u.recentVaultClicks = make([]widget.Clickable, len(u.recentVaults)) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "RECENTLY OPENED") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { + children := make([]layout.FlexChild, 0, len(u.recentVaults)*2) + for i, path := range u.recentVaults { + index := i + label := path + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.recentVaultClicks[index], label) + })) + if i < len(u.recentVaults)-1 { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + } + } + return children + }()...) + }), + ) +} + func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions { items := u.selectedAttachmentItems() if len(items) == 0 { @@ -104,7 +139,7 @@ func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.createGroup, "Create Group") }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if len(u.currentPath) == 0 { + if len(u.displayPath()) == 0 { return layout.Dimensions{} } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,