Restore per-vault groups and scroll edit forms

This commit is contained in:
Joe Julian
2026-03-29 16:32:54 -07:00
parent 20d5b3ba3d
commit d7026d5c0e
2 changed files with 199 additions and 13 deletions
+116 -13
View File
@@ -80,6 +80,11 @@ type statePaths struct {
RecentVaultsPath string
}
type recentVaultRecord struct {
Path string `json:"path"`
LastGroup []string `json:"lastGroup,omitempty"`
}
type ui struct {
mode string
theme *material.Theme
@@ -179,6 +184,7 @@ type ui struct {
recentVaultsPath string
editingEntry bool
recentVaults []string
recentVaultGroups map[string][]string
deleteGroupPath []string
statusExpiresAt time.Time
now func() time.Time
@@ -262,6 +268,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
lifecycleMode: "local",
defaultSaveAsPath: paths.DefaultSaveAsPath,
recentVaultsPath: paths.RecentVaultsPath,
recentVaultGroups: map[string][]string{},
now: time.Now,
}
u.state.Session = sess
@@ -491,7 +498,7 @@ func (u *ui) openVaultAction() error {
}
u.noteRecentVault(path)
u.currentPath = append([]string(nil), u.state.CurrentPath...)
u.enterHiddenVaultRoot()
u.restoreRecentVaultGroup(path)
u.editingEntry = false
u.filter()
return nil
@@ -597,6 +604,14 @@ func (u *ui) noteRecentVault(path string) {
if path == "" {
return
}
if u.recentVaultGroups == nil {
u.recentVaultGroups = map[string][]string{}
}
if len(u.currentPath) > 0 {
u.recentVaultGroups[path] = append([]string(nil), u.currentPath...)
} else if _, ok := u.recentVaultGroups[path]; !ok {
u.recentVaultGroups[path] = nil
}
next := []string{path}
for _, existing := range u.recentVaults {
if existing == path {
@@ -622,19 +637,40 @@ func (u *ui) loadRecentVaults() {
if err != nil {
return
}
var paths []string
if err := json.Unmarshal(content, &paths); err != nil {
u.recentVaults = nil
u.recentVaultGroups = map[string][]string{}
var records []recentVaultRecord
switch {
case json.Unmarshal(content, &records) == nil:
u.applyRecentVaultRecords(records)
return
default:
var paths []string
if err := json.Unmarshal(content, &paths); err != nil {
return
}
records = make([]recentVaultRecord, 0, len(paths))
for _, path := range paths {
records = append(records, recentVaultRecord{Path: path})
}
u.applyRecentVaultRecords(records)
}
filtered := make([]string, 0, len(paths))
}
func (u *ui) applyRecentVaultRecords(records []recentVaultRecord) {
filtered := make([]string, 0, len(records))
seen := map[string]bool{}
for _, path := range paths {
path = strings.TrimSpace(path)
for _, record := range records {
path := strings.TrimSpace(record.Path)
if path == "" || seen[path] {
continue
}
seen[path] = true
filtered = append(filtered, path)
if u.recentVaultGroups == nil {
u.recentVaultGroups = map[string][]string{}
}
u.recentVaultGroups[path] = append([]string(nil), record.LastGroup...)
if len(filtered) == 6 {
break
}
@@ -652,13 +688,27 @@ func (u *ui) saveRecentVaults() {
if err := os.MkdirAll(filepath.Dir(u.recentVaultsPath), 0o700); err != nil {
return
}
content, err := json.MarshalIndent(u.recentVaults, "", " ")
records := make([]recentVaultRecord, 0, len(u.recentVaults))
for _, path := range u.recentVaults {
records = append(records, recentVaultRecord{
Path: path,
LastGroup: append([]string(nil), u.recentVaultGroups[path]...),
})
}
content, err := json.MarshalIndent(records, "", " ")
if err != nil {
return
}
_ = os.WriteFile(u.recentVaultsPath, content, 0o600)
}
func (u *ui) recentVaultGroup(path string) []string {
if u.recentVaultGroups == nil {
return nil
}
return append([]string(nil), u.recentVaultGroups[strings.TrimSpace(path)]...)
}
func (u *ui) hiddenVaultRoot() string {
if u.state.Section != appstate.SectionEntries {
return ""
@@ -685,6 +735,29 @@ func (u *ui) enterHiddenVaultRoot() {
u.setCurrentPath([]string{root})
}
func (u *ui) restoreRecentVaultGroup(path string) {
saved := u.recentVaultGroup(path)
if len(saved) == 0 {
u.enterHiddenVaultRoot()
return
}
model, err := u.state.Session.Current()
if err != nil {
u.enterHiddenVaultRoot()
return
}
root := u.hiddenVaultRoot()
if len(saved) == 1 && root != "" && saved[0] == root {
u.setCurrentPath(saved)
return
}
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) {
u.setCurrentPath(saved)
return
}
u.enterHiddenVaultRoot()
}
func (u *ui) displayPath() []string {
path := append([]string(nil), u.currentPath...)
root := u.hiddenVaultRoot()
@@ -709,6 +782,15 @@ func pathHasPrefix(path, prefix []string) bool {
return slices.Equal(path[:len(prefix)], prefix)
}
func hasExactGroup(model vault.Model, path []string) bool {
for _, group := range model.Groups {
if slices.Equal(group, path) {
return true
}
}
return false
}
func (u *ui) currentGroupDeletionState() (bool, string) {
u.syncCurrentPath()
if u.state.Section != appstate.SectionEntries || len(u.displayPath()) == 0 || u.state.Session == nil {
@@ -924,6 +1006,7 @@ func (u *ui) setCurrentPath(path []string) {
u.currentPath = append([]string(nil), path...)
u.state.NavigateToPath(path)
u.syncedPath = append([]string(nil), path...)
u.noteCurrentVaultPath()
u.clearDeleteGroupConfirmation()
}
@@ -937,11 +1020,28 @@ func (u *ui) syncCurrentPath() {
u.state.CurrentPath = append([]string(nil), u.currentPath...)
}
u.syncedPath = append([]string(nil), u.currentPath...)
u.noteCurrentVaultPath()
if len(u.deleteGroupPath) > 0 && !slices.Equal(u.deleteGroupPath, u.currentPath) {
u.clearDeleteGroupConfirmation()
}
}
func (u *ui) noteCurrentVaultPath() {
status, ok := u.state.Session.(sessionStatus)
if !ok || status.IsRemote() || status.IsLocked() {
return
}
path := strings.TrimSpace(u.vaultPath.Text())
if path == "" {
return
}
if u.recentVaultGroups == nil {
u.recentVaultGroups = map[string][]string{}
}
u.recentVaultGroups[path] = append([]string(nil), u.currentPath...)
u.saveRecentVaults()
}
func (u *ui) layout(gtx layout.Context) layout.Dimensions {
u.processShortcuts(gtx)
for u.createVault.Clicked(gtx) {
@@ -1479,8 +1579,8 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
)
}
if u.editingEntry {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions {
title := "New Entry"
if ok {
title = "Edit Entry"
@@ -1488,10 +1588,13 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(18), title)
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(u.entryEditorPanel),
)
},
layout.Spacer{Height: unit.Dp(8)}.Layout,
u.entryEditorPanel,
}
return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions {
return rows[i](gtx)
})
}
password := u.detailPasswordValue()
titleSize := unit.Sp(26)
+83
View File
@@ -1822,6 +1822,89 @@ func TestUILoadsRecentVaultsFromPersistedConfig(t *testing.T) {
}
}
func TestUIRecentVaultsPersistLastOpenedGroupPerVault(t *testing.T) {
t.Parallel()
configPath := filepath.Join(t.TempDir(), "recent-vaults.json")
first := newUIWithSession("desktop", &session.Manager{})
first.recentVaultsPath = configPath
first.recentVaults = nil
first.currentPath = []string{"Root", "Internet"}
first.syncedPath = []string{"Root", "Internet"}
first.noteRecentVault("/tmp/one.kdbx")
first.currentPath = []string{"Root", "Home Assistant"}
first.syncedPath = []string{"Root", "Home Assistant"}
first.noteRecentVault("/tmp/two.kdbx")
first.currentPath = []string{"Root", "Finance"}
first.syncedPath = []string{"Root", "Finance"}
first.noteRecentVault("/tmp/one.kdbx")
second := newUIWithSession("desktop", &session.Manager{})
second.recentVaultsPath = configPath
second.recentVaults = nil
second.loadRecentVaults()
if got := second.recentVaults; !slices.Equal(got, []string{"/tmp/one.kdbx", "/tmp/two.kdbx"}) {
t.Fatalf("recentVaults after reload = %v, want [/tmp/one.kdbx /tmp/two.kdbx]", got)
}
if got := second.recentVaultGroup("/tmp/one.kdbx"); !slices.Equal(got, []string{"Root", "Finance"}) {
t.Fatalf("recentVaultGroup(one) = %v, want [Root Finance]", got)
}
if got := second.recentVaultGroup("/tmp/two.kdbx"); !slices.Equal(got, []string{"Root", "Home Assistant"}) {
t.Fatalf("recentVaultGroup(two) = %v, want [Root Home Assistant]", got)
}
}
func TestUIOpenVaultRestoresLastOpenedGroupForThatVault(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "keepass.kdbx")
statePath := filepath.Join(dir, "recent-vaults.json")
u := newUIWithSession("desktop", &session.Manager{})
u.recentVaultsPath = statePath
u.masterPassword.SetText("correct horse battery staple")
if err := u.createVaultAction(); err != nil {
t.Fatalf("createVaultAction() error = %v", err)
}
if err := u.state.UpsertEntry(vault.Entry{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "token-1",
URL: "https://vault.crew.example.invalid",
Path: []string{"Root", "Internet"},
}); err != nil {
t.Fatalf("UpsertEntry() error = %v", err)
}
u.state.NavigateToPath([]string{"Root", "Internet"})
u.currentPath = []string{"Root", "Internet"}
u.syncedPath = []string{"Root", "Internet"}
u.saveAsPath.SetText(path)
if err := u.saveAsAction(); err != nil {
t.Fatalf("saveAsAction() error = %v", err)
}
reopened := newUIWithSession("desktop", &session.Manager{})
reopened.recentVaultsPath = statePath
reopened.recentVaults = nil
reopened.loadRecentVaults()
reopened.masterPassword.SetText("correct horse battery staple")
reopened.vaultPath.SetText(path)
if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
if got := reopened.displayPath(); !slices.Equal(got, []string{"Internet"}) {
t.Fatalf("displayPath() after reopen = %v, want [Internet]", got)
}
if got := reopened.state.CurrentPath; !slices.Equal(got, []string{"Root", "Internet"}) {
t.Fatalf("state.CurrentPath after reopen = %v, want [Root Internet]", got)
}
}
func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
t.Parallel()