diff --git a/main.go b/main.go index db05724..15b1e53 100644 --- a/main.go +++ b/main.go @@ -26,8 +26,8 @@ import ( "gioui.org/widget" "gioui.org/widget/material" "git.julianfamily.org/keepassgo/api" - "git.julianfamily.org/keepassgo/apiaudit" "git.julianfamily.org/keepassgo/apiapproval" + "git.julianfamily.org/keepassgo/apiaudit" "git.julianfamily.org/keepassgo/apitokens" "git.julianfamily.org/keepassgo/appstate" "git.julianfamily.org/keepassgo/clipboard" @@ -119,181 +119,182 @@ const ( ) type ui struct { - mode string - theme *material.Theme - search widget.Editor - vaultPath widget.Editor - saveAsPath widget.Editor - remoteBaseURL widget.Editor - remotePath widget.Editor - remoteUsername widget.Editor - remotePassword widget.Editor - masterPassword widget.Editor - keyFilePath widget.Editor - apiTokenName widget.Editor - apiTokenClientName widget.Editor - apiTokenExpiresAt widget.Editor - apiPolicyOperation widget.Editor - apiPolicyPath widget.Editor - apiPolicyEntryID widget.Editor - securityCipher widget.Editor - securityKDF widget.Editor - entryID widget.Editor - entryTitle widget.Editor - entryUsername widget.Editor - entryPassword widget.Editor - entryURL widget.Editor - entryNotes widget.Editor - entryTags widget.Editor - entryPath widget.Editor - entryFields widget.Editor - customFieldKeys []widget.Editor - customFieldValues []widget.Editor - historyIndex widget.Editor - groupName widget.Editor - passwordProfile widget.Editor - attachmentName widget.Editor - attachmentPath widget.Editor - exportAttachmentPath widget.Editor - list widget.List - detailList widget.List - lifecycleList widget.List - copyUser widget.Clickable - copyPass widget.Clickable - copyURL widget.Clickable - lockVault widget.Clickable - unlockVault widget.Clickable - createVault widget.Clickable - openVault widget.Clickable - saveVault widget.Clickable - saveAsVault widget.Clickable - openRemote widget.Clickable - changeMasterKey widget.Clickable - synchronizeVault widget.Clickable - toggleSyncMenu widget.Clickable - openAdvancedSync widget.Clickable - openSecuritySettings widget.Clickable - closeAdvancedSync widget.Clickable + mode string + theme *material.Theme + search widget.Editor + vaultPath widget.Editor + saveAsPath widget.Editor + remoteBaseURL widget.Editor + remotePath widget.Editor + remoteUsername widget.Editor + remotePassword widget.Editor + masterPassword widget.Editor + keyFilePath widget.Editor + apiTokenName widget.Editor + apiTokenClientName widget.Editor + apiTokenExpiresAt widget.Editor + apiPolicyOperation widget.Editor + apiPolicyPath widget.Editor + apiPolicyEntryID widget.Editor + securityCipher widget.Editor + securityKDF widget.Editor + entryID widget.Editor + entryTitle widget.Editor + entryUsername widget.Editor + entryPassword widget.Editor + entryURL widget.Editor + entryNotes widget.Editor + entryTags widget.Editor + entryPath widget.Editor + entryFields widget.Editor + customFieldKeys []widget.Editor + customFieldValues []widget.Editor + historyIndex widget.Editor + groupName widget.Editor + passwordProfile widget.Editor + attachmentName widget.Editor + attachmentPath widget.Editor + exportAttachmentPath widget.Editor + list widget.List + groupList widget.List + detailList widget.List + lifecycleList widget.List + copyUser widget.Clickable + copyPass widget.Clickable + copyURL widget.Clickable + lockVault widget.Clickable + unlockVault widget.Clickable + createVault widget.Clickable + openVault widget.Clickable + saveVault widget.Clickable + saveAsVault widget.Clickable + openRemote widget.Clickable + changeMasterKey widget.Clickable + synchronizeVault widget.Clickable + toggleSyncMenu widget.Clickable + openAdvancedSync widget.Clickable + openSecuritySettings widget.Clickable + closeAdvancedSync widget.Clickable closeSecuritySettings widget.Clickable - runAdvancedSync widget.Clickable - saveSecuritySettings widget.Clickable - editEntry widget.Clickable - cancelEdit widget.Clickable - pickVaultPath widget.Clickable - pickKeyFile widget.Clickable - pickSyncLocalPath widget.Clickable - addEntry widget.Clickable - saveEntry widget.Clickable - duplicateEntry widget.Clickable - deleteEntry widget.Clickable - restoreEntry widget.Clickable - saveTemplate widget.Clickable - deleteTemplate widget.Clickable - instantiateTemplate widget.Clickable - addAttachment widget.Clickable - replaceAttachment widget.Clickable - removeAttachment widget.Clickable - exportAttachment widget.Clickable - restoreHistory widget.Clickable - generatePassword widget.Clickable - createGroup widget.Clickable - renameGroup widget.Clickable - deleteGroup widget.Clickable - confirmDeleteGroup widget.Clickable - cancelDeleteGroup widget.Clickable - addCustomField widget.Clickable - toggleGroupControls widget.Clickable - togglePasswordInline widget.Clickable - toggleSyncPassword widget.Clickable - showEntries widget.Clickable - showTemplates widget.Clickable - showRecycle widget.Clickable - showAPITokens widget.Clickable - showAPIAudit widget.Clickable - showLocalLifecycle widget.Clickable - showRemoteLifecycle widget.Clickable - showSyncLocal widget.Clickable - showSyncRemote widget.Clickable - showSyncPull widget.Clickable - showSyncPush widget.Clickable - allowApproval widget.Clickable - denyApproval widget.Clickable - cancelApproval widget.Clickable - approvalPermanent widget.Bool - rememberRemoteAuth widget.Bool - apiPolicyAllow widget.Bool - apiPolicyGroupScopeW widget.Bool - apiTokenDisabled widget.Bool - entryClicks []widget.Clickable - apiTokenClicks []widget.Clickable - apiPolicyRemoves []widget.Clickable - apiAuditClicks []widget.Clickable - historyClicks []widget.Clickable - attachmentClicks []widget.Clickable - breadcrumbs []widget.Clickable - groupClicks []widget.Clickable - recentVaultClicks []widget.Clickable - recentRemoteClicks []widget.Clickable - removeCustomFields []widget.Clickable - state appstate.State - visible []entry - currentPath []string - syncedPath []string - selectedHistoryIndex int - showPassword bool - togglePassword widget.Clickable - copyAPITokenSecret widget.Clickable - issueAPIToken widget.Clickable - saveAPIToken widget.Clickable - rotateAPIToken widget.Clickable - disableAPIToken widget.Clickable - revokeAPIToken widget.Clickable - deleteAPIToken widget.Clickable - addAPIPolicyRule widget.Clickable - phoneSplit widget.Float - splitDrag gesture.Drag - splitBase float32 - splitStartY float32 - phoneSpan int - eyeIcon *widget.Icon - eyeOffIcon *widget.Icon - copyIcon *widget.Icon - expandMoreIcon *widget.Icon - expandLessIcon *widget.Icon - chevronDownIcon *widget.Icon - clipboardWriter clipboard.Writer - loadingMessage string - lifecycleMode string - syncSourceMode syncSourceMode - syncDirection syncDirection - syncLocalPath widget.Editor - syncRemoteBaseURL widget.Editor - syncRemotePath widget.Editor - syncRemoteUsername widget.Editor - syncRemotePassword widget.Editor - syncDialogOpen bool - syncMenuOpen bool - securityDialogOpen bool - showSyncPassword bool - keyboardFocus focusID - defaultSaveAsPath string - recentVaultsPath string - uiPreferencesPath string - recentRemotesPath string - editingEntry bool - groupControlsHidden bool - recentVaults []string - recentRemotes []recentRemoteRecord - recentVaultGroups map[string][]string - deleteGroupPath []string - apiPolicyGroupScope bool - apiTokenSecret string - selectedAuditIndex int - statusExpiresAt time.Time - now func() time.Time - apiHost *api.Host - auditLog *apiaudit.Log - grpcAddress string + runAdvancedSync widget.Clickable + saveSecuritySettings widget.Clickable + editEntry widget.Clickable + cancelEdit widget.Clickable + pickVaultPath widget.Clickable + pickKeyFile widget.Clickable + pickSyncLocalPath widget.Clickable + addEntry widget.Clickable + saveEntry widget.Clickable + duplicateEntry widget.Clickable + deleteEntry widget.Clickable + restoreEntry widget.Clickable + saveTemplate widget.Clickable + deleteTemplate widget.Clickable + instantiateTemplate widget.Clickable + addAttachment widget.Clickable + replaceAttachment widget.Clickable + removeAttachment widget.Clickable + exportAttachment widget.Clickable + restoreHistory widget.Clickable + generatePassword widget.Clickable + createGroup widget.Clickable + renameGroup widget.Clickable + deleteGroup widget.Clickable + confirmDeleteGroup widget.Clickable + cancelDeleteGroup widget.Clickable + addCustomField widget.Clickable + toggleGroupControls widget.Clickable + togglePasswordInline widget.Clickable + toggleSyncPassword widget.Clickable + showEntries widget.Clickable + showTemplates widget.Clickable + showRecycle widget.Clickable + showAPITokens widget.Clickable + showAPIAudit widget.Clickable + showLocalLifecycle widget.Clickable + showRemoteLifecycle widget.Clickable + showSyncLocal widget.Clickable + showSyncRemote widget.Clickable + showSyncPull widget.Clickable + showSyncPush widget.Clickable + allowApproval widget.Clickable + denyApproval widget.Clickable + cancelApproval widget.Clickable + approvalPermanent widget.Bool + rememberRemoteAuth widget.Bool + apiPolicyAllow widget.Bool + apiPolicyGroupScopeW widget.Bool + apiTokenDisabled widget.Bool + entryClicks []widget.Clickable + apiTokenClicks []widget.Clickable + apiPolicyRemoves []widget.Clickable + apiAuditClicks []widget.Clickable + historyClicks []widget.Clickable + attachmentClicks []widget.Clickable + breadcrumbs []widget.Clickable + groupClicks []widget.Clickable + recentVaultClicks []widget.Clickable + recentRemoteClicks []widget.Clickable + removeCustomFields []widget.Clickable + state appstate.State + visible []entry + currentPath []string + syncedPath []string + selectedHistoryIndex int + showPassword bool + togglePassword widget.Clickable + copyAPITokenSecret widget.Clickable + issueAPIToken widget.Clickable + saveAPIToken widget.Clickable + rotateAPIToken widget.Clickable + disableAPIToken widget.Clickable + revokeAPIToken widget.Clickable + deleteAPIToken widget.Clickable + addAPIPolicyRule widget.Clickable + phoneSplit widget.Float + splitDrag gesture.Drag + splitBase float32 + splitStartY float32 + phoneSpan int + eyeIcon *widget.Icon + eyeOffIcon *widget.Icon + copyIcon *widget.Icon + expandMoreIcon *widget.Icon + expandLessIcon *widget.Icon + chevronDownIcon *widget.Icon + clipboardWriter clipboard.Writer + loadingMessage string + lifecycleMode string + syncSourceMode syncSourceMode + syncDirection syncDirection + syncLocalPath widget.Editor + syncRemoteBaseURL widget.Editor + syncRemotePath widget.Editor + syncRemoteUsername widget.Editor + syncRemotePassword widget.Editor + syncDialogOpen bool + syncMenuOpen bool + securityDialogOpen bool + showSyncPassword bool + keyboardFocus focusID + defaultSaveAsPath string + recentVaultsPath string + uiPreferencesPath string + recentRemotesPath string + editingEntry bool + groupControlsHidden bool + recentVaults []string + recentRemotes []recentRemoteRecord + recentVaultGroups map[string][]string + deleteGroupPath []string + apiPolicyGroupScope bool + apiTokenSecret string + selectedAuditIndex int + statusExpiresAt time.Time + now func() time.Time + apiHost *api.Host + auditLog *apiaudit.Log + grpcAddress string } var ( @@ -375,6 +376,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) list: widget.List{ List: layout.List{Axis: layout.Vertical}, }, + groupList: widget.List{ + List: layout.List{Axis: layout.Vertical}, + }, detailList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, @@ -508,7 +512,9 @@ 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.filter() } @@ -1213,6 +1219,28 @@ func (u *ui) restoreRecentRemoteGroup(baseURL, path string) { u.enterHiddenVaultRoot() } +func (u *ui) restoreEntriesPath(path []string) { + if len(path) == 0 { + u.enterHiddenVaultRoot() + return + } + model, err := u.state.Session.Current() + if err != nil { + u.enterHiddenVaultRoot() + return + } + root := u.hiddenVaultRoot() + if len(path) == 1 && root != "" && path[0] == root { + u.setCurrentPath(path) + return + } + if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) { + u.setCurrentPath(path) + return + } + u.enterHiddenVaultRoot() +} + func (u *ui) displayPath() []string { path := append([]string(nil), u.currentPath...) root := u.hiddenVaultRoot() @@ -3033,21 +3061,28 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), } - for i, group := range groups { - idx := i - name := group - children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - for u.groupClicks[idx].Clicked(gtx) { - u.state.EnterGroup(name) - u.currentPath = append([]string(nil), u.state.CurrentPath...) - u.filter() - } - return tonedButton(gtx, u.theme, &u.groupClicks[idx], name) - })) - if i < len(groups)-1 { - children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + const maxGroupListHeight = 160 + maxY := gtx.Dp(unit.Dp(maxGroupListHeight)) + if gtx.Constraints.Max.Y > maxY { + gtx.Constraints.Max.Y = maxY } - } + if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y { + gtx.Constraints.Min.Y = gtx.Constraints.Max.Y + } + return material.List(u.theme, &u.groupList).Layout(gtx, len(groups), func(gtx layout.Context, i int) layout.Dimensions { + idx := i + name := groups[i] + return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + for u.groupClicks[idx].Clicked(gtx) { + u.state.EnterGroup(name) + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.filter() + } + return tonedButton(gtx, u.theme, &u.groupClicks[idx], name) + }) + }) + })) return children }()...) } diff --git a/main_test.go b/main_test.go index 64ba303..5529722 100644 --- a/main_test.go +++ b/main_test.go @@ -2198,6 +2198,35 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) { } } +func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "1", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}}, + {ID: "2", Title: "Home Assistant", Path: []string{"keepass", "Crew", "Home"}}, + }, + }) + + u.showEntriesSection() + if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { + t.Fatalf("currentPath after initial entries section = %v, want [keepass]", got) + } + + u.showAPITokensSection() + u.showEntriesSection() + + if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { + t.Fatalf("currentPath after returning to entries = %v, want [keepass]", got) + } + if got := u.displayPath(); len(got) != 0 { + t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got) + } + if got := u.childGroups(); !slices.Equal(got, []string{"Crew"}) { + t.Fatalf("childGroups() after returning to entries = %v, want [Crew]", got) + } +} + func TestUINoteRecentVaultDeduplicatesAndOrdersMostRecentFirst(t *testing.T) { t.Parallel()