From 27fdd77aa17fd60deba5f53f48d02d40a9615868 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Mon, 30 Mar 2026 14:31:06 -0700 Subject: [PATCH] Restore entries tab state and preload recent vault --- main.go | 110 ++++++++++++++++++++++++++++++++++++++++++++++----- main_test.go | 106 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 9 deletions(-) diff --git a/main.go b/main.go index 15b1e53..6277026 100644 --- a/main.go +++ b/main.go @@ -90,6 +90,7 @@ type statePaths struct { type recentVaultRecord struct { Path string `json:"path"` LastGroup []string `json:"lastGroup,omitempty"` + UsedAt string `json:"usedAt,omitempty"` } type recentRemoteRecord struct { @@ -98,12 +99,20 @@ type recentRemoteRecord struct { Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` LastGroup []string `json:"lastGroup,omitempty"` + UsedAt string `json:"usedAt,omitempty"` } type uiPreferences struct { GroupControlsHidden bool `json:"groupControlsHidden"` } +type entriesSectionState struct { + Path []string + SearchQuery string + SelectedEntryID string + Editing bool +} + type syncSourceMode string const ( @@ -286,6 +295,8 @@ type ui struct { recentVaults []string recentRemotes []recentRemoteRecord recentVaultGroups map[string][]string + recentVaultUsedAt map[string]time.Time + entriesState entriesSectionState deleteGroupPath []string apiPolicyGroupScope bool apiTokenSecret string @@ -394,6 +405,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) uiPreferencesPath: paths.UIPreferencesPath, recentRemotesPath: paths.RecentRemotesPath, recentVaultGroups: map[string][]string{}, + recentVaultUsedAt: map[string]time.Time{}, now: time.Now, syncSourceMode: syncSourceLocal, syncDirection: syncDirectionPull, @@ -416,6 +428,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.setCustomFieldRows(nil) u.loadRecentVaults() u.loadRecentRemotes() + u.restoreStartupLifecycleTarget() u.loadUIPreferences() u.filter() return u @@ -512,26 +525,28 @@ func (u *ui) selectedAttachmentNames() []string { func (u *ui) showEntriesSection() { u.resetPasswordPeek() - preservedPath := append([]string(nil), u.currentPath...) u.state.ShowSection(appstate.SectionEntries) - u.restoreEntriesPath(preservedPath) + u.restoreEntriesSectionState() u.filter() } func (u *ui) showTemplatesSection() { u.resetPasswordPeek() + u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionTemplates) u.filter() } func (u *ui) showRecycleBinSection() { u.resetPasswordPeek() + u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionRecycleBin) u.filter() } func (u *ui) showAPITokensSection() { u.resetPasswordPeek() + u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionAPITokens) u.loadSelectedAPITokenIntoEditor() u.filter() @@ -539,6 +554,7 @@ func (u *ui) showAPITokensSection() { func (u *ui) showAPIAuditSection() { u.resetPasswordPeek() + u.rememberEntriesSectionState() u.state.ShowSection(appstate.SectionAPIAudit) u.selectedAuditIndex = -1 u.filter() @@ -897,11 +913,15 @@ func (u *ui) noteRecentVault(path string) { if u.recentVaultGroups == nil { u.recentVaultGroups = map[string][]string{} } + if u.recentVaultUsedAt == nil { + u.recentVaultUsedAt = map[string]time.Time{} + } if len(u.currentPath) > 0 { u.recentVaultGroups[path] = append([]string(nil), u.currentPath...) } else if _, ok := u.recentVaultGroups[path]; !ok { u.recentVaultGroups[path] = nil } + u.recentVaultUsedAt[path] = u.now() next := []string{path} for _, existing := range u.recentVaults { if existing == path { @@ -929,6 +949,7 @@ func (u *ui) loadRecentVaults() { } u.recentVaults = nil u.recentVaultGroups = map[string][]string{} + u.recentVaultUsedAt = map[string]time.Time{} var records []recentVaultRecord switch { case json.Unmarshal(content, &records) == nil: @@ -960,7 +981,13 @@ func (u *ui) applyRecentVaultRecords(records []recentVaultRecord) { if u.recentVaultGroups == nil { u.recentVaultGroups = map[string][]string{} } + if u.recentVaultUsedAt == nil { + u.recentVaultUsedAt = map[string]time.Time{} + } u.recentVaultGroups[path] = append([]string(nil), record.LastGroup...) + if usedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(record.UsedAt)); err == nil { + u.recentVaultUsedAt[path] = usedAt + } if len(filtered) == 6 { break } @@ -1020,6 +1047,7 @@ func (u *ui) saveRecentVaults() { records = append(records, recentVaultRecord{ Path: path, LastGroup: append([]string(nil), u.recentVaultGroups[path]...), + UsedAt: u.recentVaultUsedAt[path].Format(time.RFC3339Nano), }) } content, err := json.MarshalIndent(records, "", " ") @@ -1084,6 +1112,7 @@ func (u *ui) noteRecentRemote(baseURL, path, username, password string, remember BaseURL: baseURL, Path: path, LastGroup: append([]string(nil), u.currentPath...), + UsedAt: u.now().Format(time.RFC3339Nano), } if len(record.LastGroup) == 0 { record.LastGroup = u.recentRemoteGroup(baseURL, path) @@ -1120,6 +1149,53 @@ func (u *ui) recentRemoteGroup(baseURL, path string) []string { return nil } +func (u *ui) restoreStartupLifecycleTarget() { + localPath, localUsedAt := u.latestRecentVault() + remoteRecord, hasRemote, remoteUsedAt := u.latestRecentRemote() + + switch { + case hasRemote && (localPath == "" || remoteUsedAt.After(localUsedAt)): + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(remoteRecord) + case localPath != "": + u.lifecycleMode = "local" + u.vaultPath.SetText(localPath) + } +} + +func (u *ui) latestRecentVault() (string, time.Time) { + for _, path := range u.recentVaults { + if strings.TrimSpace(path) == "" { + continue + } + return path, u.recentVaultUsedAt[path] + } + return "", time.Time{} +} + +func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) { + for _, record := range u.recentRemotes { + if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" { + continue + } + usedAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(record.UsedAt)) + if err != nil { + usedAt = time.Time{} + } + return record, true, usedAt + } + return recentRemoteRecord{}, false, time.Time{} +} + +func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) { + u.remoteBaseURL.SetText(record.BaseURL) + u.remotePath.SetText(record.Path) + u.remoteUsername.SetText(record.Username) + u.remotePassword.SetText(record.Password) + u.remotePassword.Mask = '•' + u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != "" +} + func (u *ui) noteCurrentRemotePath() { status, ok := u.state.Session.(sessionStatus) if !ok || !status.IsRemote() || status.IsLocked() { @@ -1241,6 +1317,28 @@ func (u *ui) restoreEntriesPath(path []string) { u.enterHiddenVaultRoot() } +func (u *ui) rememberEntriesSectionState() { + if u.state.Section != appstate.SectionEntries { + return + } + u.entriesState = entriesSectionState{ + Path: append([]string(nil), u.currentPath...), + SearchQuery: u.search.Text(), + SelectedEntryID: u.state.SelectedEntryID, + Editing: u.editingEntry, + } +} + +func (u *ui) restoreEntriesSectionState() { + u.search.SetText(u.entriesState.SearchQuery) + u.restoreEntriesPath(u.entriesState.Path) + u.state.SelectedEntryID = u.entriesState.SelectedEntryID + u.editingEntry = u.entriesState.Editing && strings.TrimSpace(u.entriesState.SelectedEntryID) != "" + if u.editingEntry || strings.TrimSpace(u.state.SelectedEntryID) != "" { + u.loadSelectedEntryIntoEditor() + } +} + func (u *ui) displayPath() []string { path := append([]string(nil), u.currentPath...) root := u.hiddenVaultRoot() @@ -1743,13 +1841,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for i := range u.recentRemoteClicks { for u.recentRemoteClicks[i].Clicked(gtx) { if i < len(u.recentRemotes) { - record := u.recentRemotes[i] - u.remoteBaseURL.SetText(record.BaseURL) - u.remotePath.SetText(record.Path) - u.remoteUsername.SetText(record.Username) - u.remotePassword.SetText(record.Password) - u.remotePassword.Mask = '•' - u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != "" + u.applyRecentRemoteRecord(u.recentRemotes[i]) } } } diff --git a/main_test.go b/main_test.go index 5529722..1b381b6 100644 --- a/main_test.go +++ b/main_test.go @@ -2227,6 +2227,45 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T) } } +func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "amazon", Title: "Amazon", Username: "danny@crew.example.invalid", Path: []string{"keepass", "Crew", "Internet"}}, + {ID: "aws", Title: "Amazon AWS", Username: "danny@crew.example.invalid", Path: []string{"keepass", "Crew", "Internet"}}, + {ID: "git", Title: "Vault Console", Username: "dannyocean", Path: []string{"keepass", "Crew", "Internet"}}, + }, + }) + + u.showEntriesSection() + u.setCurrentPath([]string{"keepass", "Crew", "Internet"}) + u.search.SetText("amazon") + u.filter() + u.state.SelectedEntryID = "amazon" + u.editingEntry = true + u.loadSelectedEntryIntoEditor() + + u.showAPITokensSection() + u.showEntriesSection() + + if got := u.currentPath; !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) { + t.Fatalf("currentPath after returning to entries = %v, want [keepass Crew Internet]", got) + } + if got := u.search.Text(); got != "amazon" { + t.Fatalf("search text after returning to entries = %q, want amazon", got) + } + if got := u.state.SelectedEntryID; got != "amazon" { + t.Fatalf("SelectedEntryID after returning to entries = %q, want amazon", got) + } + if !u.editingEntry { + t.Fatal("editingEntry = false, want true after returning to entries") + } + if got := u.filteredTitles(); !slices.Equal(got, []string{"Amazon", "Amazon AWS"}) { + t.Fatalf("filteredTitles() after returning to entries = %v, want [Amazon Amazon AWS]", got) + } +} + func TestUINoteRecentVaultDeduplicatesAndOrdersMostRecentFirst(t *testing.T) { t.Parallel() @@ -2262,6 +2301,34 @@ func TestUILoadsRecentVaultsFromPersistedConfig(t *testing.T) { } } +func TestUIStartupPreselectsMostRecentLocalVault(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.recentVaultUsedAt = map[string]time.Time{} + first.now = func() time.Time { return time.Date(2026, 3, 30, 12, 0, 0, 0, time.UTC) } + first.noteRecentVault("/tmp/older.kdbx") + first.now = func() time.Time { return time.Date(2026, 3, 30, 13, 0, 0, 0, time.UTC) } + first.noteRecentVault("/tmp/newer.kdbx") + + second := newUIWithSession("desktop", &session.Manager{}, statePaths{ + DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"), + RecentVaultsPath: configPath, + RecentRemotesPath: filepath.Join(t.TempDir(), "recent-remotes.json"), + UIPreferencesPath: filepath.Join(t.TempDir(), "ui-prefs.json"), + }) + + if got := second.lifecycleMode; got != "local" { + t.Fatalf("lifecycleMode = %q, want local", got) + } + if got := second.vaultPath.Text(); got != "/tmp/newer.kdbx" { + t.Fatalf("vaultPath = %q, want /tmp/newer.kdbx", got) + } +} + func TestUIRecentVaultsPersistLastOpenedGroupPerVault(t *testing.T) { t.Parallel() @@ -2382,6 +2449,44 @@ func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) { } } +func TestUIStartupPreselectsNewestTargetAcrossLocalAndRemote(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + vaultsPath := filepath.Join(dir, "recent-vaults.json") + remotesPath := filepath.Join(dir, "recent-remotes.json") + paths := statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: vaultsPath, + RecentRemotesPath: remotesPath, + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + } + + first := newUIWithSession("desktop", &session.Manager{}, paths) + first.now = func() time.Time { return time.Date(2026, 3, 30, 12, 0, 0, 0, time.UTC) } + first.noteRecentVault("/tmp/local.kdbx") + first.now = func() time.Time { return time.Date(2026, 3, 30, 13, 0, 0, 0, time.UTC) } + first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-1", true) + + second := newUIWithSession("desktop", &session.Manager{}, paths) + + if got := second.lifecycleMode; got != "remote" { + t.Fatalf("lifecycleMode = %q, want remote", got) + } + if got := second.remoteBaseURL.Text(); got != "https://dav.example.com" { + t.Fatalf("remoteBaseURL = %q, want https://dav.example.com", got) + } + if got := second.remotePath.Text(); got != "vaults/home.kdbx" { + t.Fatalf("remotePath = %q, want vaults/home.kdbx", got) + } + if got := second.remoteUsername.Text(); got != "alice" { + t.Fatalf("remoteUsername = %q, want alice", got) + } + if got := second.remotePassword.Text(); got != "secret-1" { + t.Fatalf("remotePassword = %q, want secret-1", got) + } +} + func TestUIGroupToolsDisclosureStatePersists(t *testing.T) { t.Parallel() @@ -3106,6 +3211,7 @@ func TestUILocalLifecycleActionErrorsAreVisibleAndSpecific(t *testing.T) { u := newUIWithSession("desktop", &session.Manager{}) u.masterPassword.SetText("correct horse battery staple") + u.vaultPath.SetText("") u.runAction("open vault", u.openVaultAction)