diff --git a/main.go b/main.go index ab11aff..63af43b 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/main_test.go b/main_test.go index 756e4b6..ae11fa3 100644 --- a/main_test.go +++ b/main_test.go @@ -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()