Add persisted UI settings preferences

This commit is contained in:
Joe Julian
2026-04-01 17:38:02 -07:00
parent 2677f7db17
commit 2f13b0d42f
2 changed files with 426 additions and 318 deletions
+344 -318
View File
@@ -141,6 +141,7 @@ type uiPreferences struct {
GroupControlsHidden bool `json:"groupControlsHidden"` GroupControlsHidden bool `json:"groupControlsHidden"`
LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"` LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"`
HistoryHidden bool `json:"historyHidden"` HistoryHidden bool `json:"historyHidden"`
DenseLayout bool `json:"denseLayout"`
} }
type entriesSectionState struct { type entriesSectionState struct {
@@ -165,217 +166,219 @@ const (
) )
type ui struct { type ui struct {
mode string mode string
theme *material.Theme theme *material.Theme
logoHorizontal paint.ImageOp logoHorizontal paint.ImageOp
splashSquare paint.ImageOp splashSquare paint.ImageOp
search widget.Editor search widget.Editor
vaultPath widget.Editor vaultPath widget.Editor
saveAsPath widget.Editor saveAsPath widget.Editor
remoteBaseURL widget.Editor remoteBaseURL widget.Editor
remotePath widget.Editor remotePath widget.Editor
remoteUsername widget.Editor remoteUsername widget.Editor
remotePassword widget.Editor remotePassword widget.Editor
masterPassword widget.Editor masterPassword widget.Editor
keyFilePath widget.Editor keyFilePath widget.Editor
apiTokenName widget.Editor apiTokenName widget.Editor
apiTokenClientName widget.Editor apiTokenClientName widget.Editor
apiTokenExpiresAt widget.Editor apiTokenExpiresAt widget.Editor
apiPolicyOperation widget.Editor apiPolicyOperation widget.Editor
apiPolicyPath widget.Editor apiPolicyPath widget.Editor
apiPolicyEntryID widget.Editor apiPolicyEntryID widget.Editor
securityCipher widget.Editor securityCipher widget.Editor
securityKDF widget.Editor securityKDF widget.Editor
entryID widget.Editor entryID widget.Editor
entryTitle widget.Editor entryTitle widget.Editor
entryUsername widget.Editor entryUsername widget.Editor
entryPassword widget.Editor entryPassword widget.Editor
entryURL widget.Editor entryURL widget.Editor
entryNotes widget.Editor entryNotes widget.Editor
entryTags widget.Editor entryTags widget.Editor
entryPath widget.Editor entryPath widget.Editor
entryFields widget.Editor entryFields widget.Editor
customFieldKeys []widget.Editor customFieldKeys []widget.Editor
customFieldValues []widget.Editor customFieldValues []widget.Editor
historyIndex widget.Editor historyIndex widget.Editor
groupName widget.Editor groupName widget.Editor
groupParentPath widget.Editor groupParentPath widget.Editor
passwordProfile widget.Editor passwordProfile widget.Editor
attachmentName widget.Editor attachmentName widget.Editor
attachmentPath widget.Editor attachmentPath widget.Editor
exportAttachmentPath widget.Editor exportAttachmentPath widget.Editor
list widget.List list widget.List
groupList widget.List groupList widget.List
detailList widget.List detailList widget.List
apiPolicyList widget.List apiPolicyList widget.List
lifecycleList widget.List lifecycleList widget.List
copyUser widget.Clickable copyUser widget.Clickable
copyPass widget.Clickable copyPass widget.Clickable
copyURL widget.Clickable copyURL widget.Clickable
lockVault widget.Clickable lockVault widget.Clickable
unlockVault widget.Clickable unlockVault widget.Clickable
createVault widget.Clickable createVault widget.Clickable
openVault widget.Clickable openVault widget.Clickable
saveVault widget.Clickable saveVault widget.Clickable
saveAsVault widget.Clickable saveAsVault widget.Clickable
openRemote widget.Clickable openRemote widget.Clickable
changeMasterKey widget.Clickable changeMasterKey widget.Clickable
synchronizeVault widget.Clickable synchronizeVault widget.Clickable
toggleSyncMenu widget.Clickable toggleSyncMenu widget.Clickable
openAdvancedSync widget.Clickable openAdvancedSync widget.Clickable
openSecuritySettings widget.Clickable openSecuritySettings widget.Clickable
openRemotePrefsHelp widget.Clickable closeAdvancedSync widget.Clickable
closeAdvancedSync widget.Clickable closeSecuritySettings widget.Clickable
closeSecuritySettings widget.Clickable runAdvancedSync widget.Clickable
closeRemotePrefsHelp widget.Clickable saveSecuritySettings widget.Clickable
runAdvancedSync widget.Clickable editEntry widget.Clickable
saveSecuritySettings widget.Clickable cancelEdit widget.Clickable
editEntry widget.Clickable pickVaultPath widget.Clickable
cancelEdit widget.Clickable pickKeyFile widget.Clickable
pickVaultPath widget.Clickable pickSyncLocalPath widget.Clickable
pickKeyFile widget.Clickable clearVaultSelection widget.Clickable
pickSyncLocalPath widget.Clickable clearRemoteSelection widget.Clickable
clearVaultSelection widget.Clickable dismissBanner widget.Clickable
clearRemoteSelection widget.Clickable addEntry widget.Clickable
dismissBanner widget.Clickable saveEntry widget.Clickable
addEntry widget.Clickable duplicateEntry widget.Clickable
saveEntry widget.Clickable deleteEntry widget.Clickable
duplicateEntry widget.Clickable restoreEntry widget.Clickable
deleteEntry widget.Clickable saveTemplate widget.Clickable
restoreEntry widget.Clickable deleteTemplate widget.Clickable
saveTemplate widget.Clickable instantiateTemplate widget.Clickable
deleteTemplate widget.Clickable addAttachment widget.Clickable
instantiateTemplate widget.Clickable replaceAttachment widget.Clickable
addAttachment widget.Clickable removeAttachment widget.Clickable
replaceAttachment widget.Clickable exportAttachment widget.Clickable
removeAttachment widget.Clickable restoreHistory widget.Clickable
exportAttachment widget.Clickable generatePassword widget.Clickable
restoreHistory widget.Clickable goToRootGroup widget.Clickable
generatePassword widget.Clickable goToParentGroup widget.Clickable
goToRootGroup widget.Clickable createGroup widget.Clickable
goToParentGroup widget.Clickable moveGroup widget.Clickable
createGroup widget.Clickable renameGroup widget.Clickable
moveGroup widget.Clickable deleteGroup widget.Clickable
renameGroup widget.Clickable confirmDeleteGroup widget.Clickable
deleteGroup widget.Clickable cancelDeleteGroup widget.Clickable
confirmDeleteGroup widget.Clickable addCustomField widget.Clickable
cancelDeleteGroup widget.Clickable toggleGroupControls widget.Clickable
addCustomField widget.Clickable toggleLifecycleAdvanced widget.Clickable
toggleGroupControls widget.Clickable toggleHistory widget.Clickable
toggleLifecycleAdvanced widget.Clickable togglePasswordInline widget.Clickable
toggleHistory widget.Clickable toggleSyncPassword widget.Clickable
togglePasswordInline widget.Clickable showEntries widget.Clickable
toggleSyncPassword widget.Clickable showTemplates widget.Clickable
showEntries widget.Clickable showRecycle widget.Clickable
showTemplates widget.Clickable showAPITokens widget.Clickable
showRecycle widget.Clickable showAPIAudit widget.Clickable
showAPITokens widget.Clickable showLocalLifecycle widget.Clickable
showAPIAudit widget.Clickable showRemoteLifecycle widget.Clickable
showLocalLifecycle widget.Clickable showSyncLocal widget.Clickable
showRemoteLifecycle widget.Clickable showSyncRemote widget.Clickable
showSyncLocal widget.Clickable showSyncPull widget.Clickable
showSyncRemote widget.Clickable showSyncPush widget.Clickable
showSyncPull widget.Clickable allowApproval widget.Clickable
showSyncPush widget.Clickable denyApproval widget.Clickable
allowApproval widget.Clickable cancelApproval widget.Clickable
denyApproval widget.Clickable cancelLifecycleProgress widget.Clickable
cancelApproval widget.Clickable retryLifecycleOpen widget.Clickable
cancelLifecycleProgress widget.Clickable approvalPermanent widget.Bool
retryLifecycleOpen widget.Clickable rememberRemoteAuth widget.Bool
approvalPermanent widget.Bool apiPolicyAllow widget.Bool
rememberRemoteAuth widget.Bool apiPolicyGroupScopeW widget.Bool
apiPolicyAllow widget.Bool apiTokenDisabled widget.Bool
apiPolicyGroupScopeW widget.Bool settingsGroupControls widget.Bool
apiTokenDisabled widget.Bool settingsLifecycleAdvanced widget.Bool
entryClicks []widget.Clickable settingsHistory widget.Bool
apiTokenClicks []widget.Clickable settingsDenseLayout widget.Bool
apiPolicyRemoves []widget.Clickable entryClicks []widget.Clickable
apiAuditClicks []widget.Clickable apiTokenClicks []widget.Clickable
apiAuditTokenFilters []widget.Clickable apiPolicyRemoves []widget.Clickable
apiAuditDecisionFilters []widget.Clickable apiAuditClicks []widget.Clickable
apiAuditOperationFilters []widget.Clickable apiAuditTokenFilters []widget.Clickable
clearAPIAuditFilters widget.Clickable apiAuditDecisionFilters []widget.Clickable
historyClicks []widget.Clickable apiAuditOperationFilters []widget.Clickable
attachmentClicks []widget.Clickable clearAPIAuditFilters widget.Clickable
breadcrumbs []widget.Clickable historyClicks []widget.Clickable
groupClicks []widget.Clickable attachmentClicks []widget.Clickable
recentVaultClicks []widget.Clickable breadcrumbs []widget.Clickable
recentRemoteClicks []widget.Clickable groupClicks []widget.Clickable
removeCustomFields []widget.Clickable recentVaultClicks []widget.Clickable
state appstate.State recentRemoteClicks []widget.Clickable
visible []entry removeCustomFields []widget.Clickable
currentPath []string state appstate.State
syncedPath []string visible []entry
selectedHistoryIndex int currentPath []string
showPassword bool syncedPath []string
generatedPasswordDraft bool selectedHistoryIndex int
togglePassword widget.Clickable showPassword bool
copyAPITokenSecret widget.Clickable generatedPasswordDraft bool
issueAPIToken widget.Clickable togglePassword widget.Clickable
saveAPIToken widget.Clickable copyAPITokenSecret widget.Clickable
rotateAPIToken widget.Clickable issueAPIToken widget.Clickable
disableAPIToken widget.Clickable saveAPIToken widget.Clickable
revokeAPIToken widget.Clickable rotateAPIToken widget.Clickable
deleteAPIToken widget.Clickable disableAPIToken widget.Clickable
addAPIPolicyRule widget.Clickable revokeAPIToken widget.Clickable
phoneSplit widget.Float deleteAPIToken widget.Clickable
splitDrag gesture.Drag addAPIPolicyRule widget.Clickable
splitBase float32 phoneSplit widget.Float
splitStartY float32 splitDrag gesture.Drag
phoneSpan int splitBase float32
eyeIcon *widget.Icon splitStartY float32
eyeOffIcon *widget.Icon phoneSpan int
copyIcon *widget.Icon eyeIcon *widget.Icon
expandMoreIcon *widget.Icon eyeOffIcon *widget.Icon
expandLessIcon *widget.Icon copyIcon *widget.Icon
chevronDownIcon *widget.Icon expandMoreIcon *widget.Icon
settingsIcon *widget.Icon expandLessIcon *widget.Icon
clipboardWriter clipboard.Writer chevronDownIcon *widget.Icon
loadingMessage string settingsIcon *widget.Icon
loadingActionLabel string clipboardWriter clipboard.Writer
lifecycleMode string loadingMessage string
syncSourceMode syncSourceMode loadingActionLabel string
syncDirection syncDirection lifecycleMode string
syncLocalPath widget.Editor syncSourceMode syncSourceMode
syncRemoteBaseURL widget.Editor syncDirection syncDirection
syncRemotePath widget.Editor syncLocalPath widget.Editor
syncRemoteUsername widget.Editor syncRemoteBaseURL widget.Editor
syncRemotePassword widget.Editor syncRemotePath widget.Editor
syncDialogOpen bool syncRemoteUsername widget.Editor
syncMenuOpen bool syncRemotePassword widget.Editor
securityDialogOpen bool syncDialogOpen bool
remotePrefsDialogOpen bool syncMenuOpen bool
showSyncPassword bool securityDialogOpen bool
keyboardFocus focusID showSyncPassword bool
defaultSaveAsPath string keyboardFocus focusID
recentVaultsPath string defaultSaveAsPath string
uiPreferencesPath string recentVaultsPath string
recentRemotesPath string uiPreferencesPath string
autofillCachePath string recentRemotesPath string
editingEntry bool autofillCachePath string
groupControlsHidden bool editingEntry bool
lifecycleAdvancedHidden bool groupControlsHidden bool
historyHidden bool lifecycleAdvancedHidden bool
recentVaults []string historyHidden bool
recentRemotes []recentRemoteRecord denseLayout bool
recentVaultGroups map[string][]string recentVaults []string
recentVaultUsedAt map[string]time.Time recentRemotes []recentRemoteRecord
entriesState entriesSectionState recentVaultGroups map[string][]string
deleteGroupPath []string recentVaultUsedAt map[string]time.Time
apiPolicyGroupScope bool entriesState entriesSectionState
apiTokenSecret string deleteGroupPath []string
selectedAuditIndex int apiPolicyGroupScope bool
statusExpiresAt time.Time apiTokenSecret string
now func() time.Time selectedAuditIndex int
apiHost *api.Host statusExpiresAt time.Time
auditLog *apiaudit.Log now func() time.Time
grpcAddress string apiHost *api.Host
backgroundResults chan backgroundActionResult auditLog *apiaudit.Log
backgroundActionSerial int grpcAddress string
activeBackgroundAction int backgroundResults chan backgroundActionResult
lastLifecycleAction string backgroundActionSerial int
requestMasterPassFocus bool activeBackgroundAction int
invalidate func() lastLifecycleAction string
requestMasterPassFocus bool
invalidate func()
} }
type backgroundActionResult struct { type backgroundActionResult struct {
@@ -519,6 +522,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.loadRecentRemotes() u.loadRecentRemotes()
u.restoreStartupLifecycleTarget() u.restoreStartupLifecycleTarget()
u.loadUIPreferences() u.loadUIPreferences()
u.loadSettingsFormFromPreferences()
u.filter() u.filter()
u.syncAutofillCache() u.syncAutofillCache()
return u return u
@@ -1016,6 +1020,8 @@ func (u *ui) saveSecuritySettingsAction() error {
if err := u.state.ConfigureSecurity(settings); err != nil { if err := u.state.ConfigureSecurity(settings); err != nil {
return err return err
} }
u.applySettingsFormToPreferences()
u.saveUIPreferences()
u.securityDialogOpen = false u.securityDialogOpen = false
return nil return nil
} }
@@ -1292,6 +1298,7 @@ func (u *ui) loadUIPreferences() {
u.groupControlsHidden = prefs.GroupControlsHidden u.groupControlsHidden = prefs.GroupControlsHidden
u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden
u.historyHidden = prefs.HistoryHidden u.historyHidden = prefs.HistoryHidden
u.denseLayout = prefs.DenseLayout
} }
func (u *ui) saveUIPreferences() { func (u *ui) saveUIPreferences() {
@@ -1305,6 +1312,7 @@ func (u *ui) saveUIPreferences() {
GroupControlsHidden: u.groupControlsHidden, GroupControlsHidden: u.groupControlsHidden,
LifecycleAdvancedHidden: u.lifecycleAdvancedHidden, LifecycleAdvancedHidden: u.lifecycleAdvancedHidden,
HistoryHidden: u.historyHidden, HistoryHidden: u.historyHidden,
DenseLayout: u.denseLayout,
}, "", " ") }, "", " ")
if err != nil { if err != nil {
return return
@@ -1312,6 +1320,20 @@ func (u *ui) saveUIPreferences() {
_ = os.WriteFile(u.uiPreferencesPath, content, 0o600) _ = os.WriteFile(u.uiPreferencesPath, content, 0o600)
} }
func (u *ui) loadSettingsFormFromPreferences() {
u.settingsGroupControls.Value = u.groupControlsHidden
u.settingsLifecycleAdvanced.Value = u.lifecycleAdvancedHidden
u.settingsHistory.Value = u.historyHidden
u.settingsDenseLayout.Value = u.denseLayout
}
func (u *ui) applySettingsFormToPreferences() {
u.groupControlsHidden = u.settingsGroupControls.Value
u.lifecycleAdvancedHidden = u.settingsLifecycleAdvanced.Value
u.historyHidden = u.settingsHistory.Value
u.denseLayout = u.settingsDenseLayout.Value
}
func (u *ui) noteRecentRemote(baseURL, path, username, password string, rememberAuth bool) { func (u *ui) noteRecentRemote(baseURL, path, username, password string, rememberAuth bool) {
baseURL = strings.TrimSpace(baseURL) baseURL = strings.TrimSpace(baseURL)
path = strings.TrimSpace(path) path = strings.TrimSpace(path)
@@ -1428,28 +1450,20 @@ func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) {
u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != "" u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != ""
} }
func (u *ui) remotePreferencesCurrentSummary() string { func (u *ui) remoteAuthStatusMessage() string {
selected, hasSelected := u.selectedRecentRemoteRecord() selected, hasSelected := u.selectedRecentRemoteRecord()
switch { switch {
case !u.rememberRemoteAuth.Value: case !u.rememberRemoteAuth.Value:
return "Current choice: KeePassGO will remember only the WebDAV location for this connection." return "Only the location will be saved in Recent Connections."
case hasSelected && (strings.TrimSpace(selected.Username) != "" || selected.Password != ""): case hasSelected && (strings.TrimSpace(selected.Username) != "" || selected.Password != ""):
return "Current choice: a successful open will update the saved sign-in for this connection on this device." return "Saved sign-in will be updated for this connection."
case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "": case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "":
return "Current choice: a successful open will save the entered sign-in for this connection on this device." return "This sign-in will be saved in Recent Connections after a successful open."
default: default:
return "Current choice: sign-in retention is enabled, but no username or password is entered yet." return "Enter a username or password to save sign-in details for this connection."
} }
} }
func (u *ui) remotePreferencesAlwaysSavedSummary() string {
return "Recent Connections always stores the WebDAV base URL, remote path, and the last group you opened for that connection."
}
func (u *ui) remotePreferencesRetentionSummary() string {
return "KeePassGO keeps up to six recent connections. Turning off Remember sign-in and reopening rewrites that connection without the saved username or password."
}
func (u *ui) noteCurrentRemotePath() { func (u *ui) noteCurrentRemotePath() {
status, ok := u.state.Session.(sessionStatus) status, ok := u.state.Session.(sessionStatus)
if !ok || !status.IsRemote() || status.IsLocked() { if !ok || !status.IsRemote() || status.IsLocked() {
@@ -2450,11 +2464,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
} }
for u.openSecuritySettings.Clicked(gtx) { for u.openSecuritySettings.Clicked(gtx) {
u.loadSecuritySettingsFromSession() u.loadSecuritySettingsFromSession()
u.loadSettingsFormFromPreferences()
u.securityDialogOpen = true u.securityDialogOpen = true
} }
for u.openRemotePrefsHelp.Clicked(gtx) {
u.remotePrefsDialogOpen = true
}
for u.closeAdvancedSync.Clicked(gtx) { for u.closeAdvancedSync.Clicked(gtx) {
u.syncDialogOpen = false u.syncDialogOpen = false
u.showSyncPassword = false u.showSyncPassword = false
@@ -2462,9 +2474,6 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
for u.closeSecuritySettings.Clicked(gtx) { for u.closeSecuritySettings.Clicked(gtx) {
u.securityDialogOpen = false u.securityDialogOpen = false
} }
for u.closeRemotePrefsHelp.Clicked(gtx) {
u.remotePrefsDialogOpen = false
}
for u.runAdvancedSync.Clicked(gtx) { for u.runAdvancedSync.Clicked(gtx) {
u.runAction("advanced synchronize vault", u.advancedSyncAction) u.runAction("advanced synchronize vault", u.advancedSyncAction)
} }
@@ -2870,12 +2879,6 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
} }
return u.securityDialog(gtx) return u.securityDialog(gtx)
}), }),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
if !u.remotePrefsDialogOpen {
return layout.Dimensions{}
}
return u.remotePrefsDialog(gtx)
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions { layout.Stacked(func(gtx layout.Context) layout.Dimensions {
if _, ok := u.pendingApproval(); !ok { if _, ok := u.pendingApproval(); !ok {
return layout.Dimensions{} return layout.Dimensions{}
@@ -2958,43 +2961,55 @@ func (u *ui) securityDialog(gtx layout.Context) layout.Dimensions {
) )
} }
func (u *ui) remotePrefsDialog(gtx layout.Context) layout.Dimensions {
return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
paint.FillShape(gtx.Ops, color.NRGBA{A: 90}, clip.Rect{Max: gtx.Constraints.Max}.Op())
return layout.Dimensions{Size: gtx.Constraints.Max}
}),
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
width := gtx.Dp(unit.Dp(660))
if width > gtx.Constraints.Max.X {
width = gtx.Constraints.Max.X - gtx.Dp(unit.Dp(24))
}
if width < 1 {
width = gtx.Constraints.Max.X
}
gtx.Constraints.Min.X = width
gtx.Constraints.Max.X = width
return card(gtx, u.remotePrefsDialogContent)
})
}),
)
}
func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions { func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), "Security Settings") lbl := material.Label(u.theme, unit.Sp(20), "Settings")
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "Choose the KDBX cipher and KDF family KeePassGO should use for new or future saves.") lbl := material.Label(u.theme, unit.Sp(14), "Choose how KeePassGO remembers UI layout behavior and which KDBX security settings it should use for new or future saves.")
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), "UI Preferences")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.settingsGroupControls, "Keep Group Tools collapsed")
return check.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.settingsLifecycleAdvanced, "Keep advanced lifecycle controls collapsed")
return check.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.settingsHistory, "Keep entry history collapsed")
return check.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
check := material.CheckBox(u.theme, &u.settingsDenseLayout, "Use dense entry layout")
return check.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "Dense layout reduces list and detail spacing. Leave it unchecked for the default comfortable spacing.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(16), "Vault Security")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Cipher", "Supported values: "+strings.Join([]string{vault.CipherAES256, vault.CipherChaCha20}, ", "), &u.securityCipher, false)), layout.Rigid(labeledEditorHelp(u.theme, "Cipher", "Supported values: "+strings.Join([]string{vault.CipherAES256, vault.CipherChaCha20}, ", "), &u.securityCipher, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "KDF", "Supported values: "+strings.Join([]string{vault.KDFAES, vault.KDFArgon2}, ", "), &u.securityKDF, false)), layout.Rigid(labeledEditorHelp(u.theme, "KDF", "Supported values: "+strings.Join([]string{vault.KDFAES, vault.KDFArgon2}, ", "), &u.securityKDF, false)),
@@ -3006,49 +3021,13 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions {
}), }),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.saveSecuritySettings, "Save Security Settings") return tonedButton(gtx, u.theme, &u.saveSecuritySettings, "Save Settings")
}), }),
) )
}), }),
) )
} }
func (u *ui) remotePrefsDialogContent(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(20), "Remote Connection Settings & Help")
lbl.Color = accentColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(14), "Use Recent Connections to reopen WebDAV-backed vaults quickly without cluttering the main open flow.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return approvalFact(u.theme, "Current", u.remotePreferencesCurrentSummary(), "")(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return approvalFact(u.theme, "Always Saved", u.remotePreferencesAlwaysSavedSummary(), "")(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return approvalFact(u.theme, "Retention", u.remotePreferencesRetentionSummary(), "")(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return approvalFact(u.theme, "When Sign-in Saves", "Username and password or app token are only stored after a successful remote open when Remember sign-in is enabled.", "")(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.closeRemotePrefsHelp, "Done")
}),
)
}
func (u *ui) approvalDialog(gtx layout.Context) layout.Dimensions { func (u *ui) approvalDialog(gtx layout.Context) layout.Dimensions {
return layout.Stack{}.Layout(gtx, return layout.Stack{}.Layout(gtx,
layout.Expanded(func(gtx layout.Context) layout.Dimensions { layout.Expanded(func(gtx layout.Context) layout.Dimensions {
@@ -3355,7 +3334,7 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
layout.Rigid(u.syncButtonGroup), layout.Rigid(u.syncButtonGroup),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.openSecuritySettings, u.settingsIcon, "Vault settings") btn := material.IconButton(u.theme, &u.openSecuritySettings, u.settingsIcon, "Settings")
btn.Background = selectedColor btn.Background = selectedColor
btn.Color = accentColor btn.Color = accentColor
btn.Size = unit.Dp(18) btn.Size = unit.Dp(18)
@@ -3422,12 +3401,58 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
}) })
} }
func (u *ui) sectionSpacing() unit.Dp {
if u.mode == "phone" {
if u.denseLayout {
return unit.Dp(4)
}
return unit.Dp(6)
}
if u.denseLayout {
return unit.Dp(8)
}
return unit.Dp(12)
}
func (u *ui) entryRowMetrics() (unit.Dp, unit.Sp, unit.Sp, unit.Sp, unit.Sp, unit.Dp) {
inset := unit.Dp(12)
titleSize := unit.Sp(17)
metaSize := unit.Sp(14)
urlSize := unit.Sp(12)
pathSize := unit.Sp(11)
dividerGap := unit.Dp(7)
if u.denseLayout {
inset = unit.Dp(9)
titleSize = unit.Sp(15)
metaSize = unit.Sp(12)
urlSize = unit.Sp(11)
pathSize = unit.Sp(10)
dividerGap = unit.Dp(5)
}
if u.mode == "phone" {
inset = unit.Dp(9)
titleSize = unit.Sp(15)
metaSize = unit.Sp(12)
urlSize = unit.Sp(11)
pathSize = unit.Sp(10)
dividerGap = unit.Dp(5)
if u.denseLayout {
inset = unit.Dp(8)
titleSize = unit.Sp(14)
metaSize = unit.Sp(11)
urlSize = unit.Sp(10)
pathSize = unit.Sp(9)
dividerGap = unit.Dp(4)
}
}
return inset, titleSize, metaSize, urlSize, pathSize, dividerGap
}
func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
panel := card panel := card
spacing := unit.Dp(12) spacing := u.sectionSpacing()
if u.mode == "phone" { if u.mode == "phone" {
panel = compactCard panel = compactCard
spacing = unit.Dp(6)
} }
u.ensureNavClickables() u.ensureNavClickables()
return panel(gtx, func(gtx layout.Context) layout.Dimensions { return panel(gtx, func(gtx layout.Context) layout.Dimensions {
@@ -3614,18 +3639,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item
u.loadSelectedEntryIntoEditor() u.loadSelectedEntryIntoEditor()
} }
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
inset := unit.Dp(12) inset, titleSize, metaSize, urlSize, pathSize, dividerGap := u.entryRowMetrics()
titleSize := unit.Sp(17)
metaSize := unit.Sp(14)
urlSize := unit.Sp(12)
pathSize := unit.Sp(11)
if u.mode == "phone" {
inset = unit.Dp(9)
titleSize = unit.Sp(15)
metaSize = unit.Sp(12)
urlSize = unit.Sp(11)
pathSize = unit.Sp(10)
}
selected := item.ID == u.state.SelectedEntryID selected := item.ID == u.state.SelectedEntryID
focused := u.isFocused(listFocusID(idx)) focused := u.isFocused(listFocusID(idx))
titleColor := accentColor titleColor := accentColor
@@ -3696,7 +3710,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item
lbl.Color = secondaryColor lbl.Color = secondaryColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(7)}.Layout), layout.Rigid(layout.Spacer{Height: dividerGap}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
w := gtx.Constraints.Max.X w := gtx.Constraints.Max.X
if w < 1 { if w < 1 {
@@ -3808,7 +3822,7 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(u.syncButtonGroup), layout.Rigid(u.syncButtonGroup),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.openSecuritySettings, u.settingsIcon, "Vault settings") btn := material.IconButton(u.theme, &u.openSecuritySettings, u.settingsIcon, "Settings")
btn.Background = selectedColor btn.Background = selectedColor
btn.Color = accentColor btn.Color = accentColor
btn.Size = unit.Dp(18) btn.Size = unit.Dp(18)
@@ -3947,10 +3961,22 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
titleSize := unit.Sp(26) titleSize := unit.Sp(26)
titlePad := unit.Dp(10) titlePad := unit.Dp(10)
sectionGap := unit.Dp(6) sectionGap := unit.Dp(6)
cardGap := unit.Dp(8)
if u.denseLayout {
titlePad = unit.Dp(6)
sectionGap = unit.Dp(4)
cardGap = unit.Dp(6)
}
if u.mode == "phone" { if u.mode == "phone" {
titleSize = unit.Sp(18) titleSize = unit.Sp(18)
titlePad = unit.Dp(4) titlePad = unit.Dp(4)
sectionGap = unit.Dp(4) sectionGap = unit.Dp(4)
cardGap = unit.Dp(6)
if u.denseLayout {
titlePad = unit.Dp(3)
sectionGap = unit.Dp(3)
cardGap = unit.Dp(4)
}
} }
rows := []layout.Widget{ rows := []layout.Widget{
func(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions {
@@ -4068,11 +4094,11 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
) )
}) })
}, },
layout.Spacer{Height: unit.Dp(8)}.Layout, layout.Spacer{Height: cardGap}.Layout,
u.attachmentSummaryPanel, u.attachmentSummaryPanel,
layout.Spacer{Height: unit.Dp(8)}.Layout, layout.Spacer{Height: cardGap}.Layout,
u.historyPanel, u.historyPanel,
layout.Spacer{Height: unit.Dp(8)}.Layout, layout.Spacer{Height: cardGap}.Layout,
func(gtx layout.Context) layout.Dimensions { func(gtx layout.Context) layout.Dimensions {
switch u.state.Section { switch u.state.Section {
case appstate.SectionTemplates: case appstate.SectionTemplates:
+82
View File
@@ -865,6 +865,48 @@ func TestUISaveSecuritySettingsUpdatesExistingVault(t *testing.T) {
} }
} }
func TestUISaveSettingsPersistsUIPreferences(t *testing.T) {
t.Parallel()
dir := t.TempDir()
configPath := filepath.Join(dir, "ui-prefs.json")
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
UIPreferencesPath: configPath,
})
u.settingsGroupControls.Value = true
u.settingsLifecycleAdvanced.Value = false
u.settingsHistory.Value = false
u.settingsDenseLayout.Value = true
if err := u.saveSecuritySettingsAction(); err != nil {
t.Fatalf("saveSecuritySettingsAction() error = %v", err)
}
reloaded := newUIWithSession("desktop", &session.Manager{}, statePaths{
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
UIPreferencesPath: configPath,
})
if !reloaded.groupControlsHidden {
t.Fatal("groupControlsHidden after reload = false, want true")
}
if reloaded.lifecycleAdvancedHidden {
t.Fatal("lifecycleAdvancedHidden after reload = true, want false")
}
if reloaded.historyHidden {
t.Fatal("historyHidden after reload = true, want false")
}
if !reloaded.denseLayout {
t.Fatal("denseLayout after reload = false, want true")
}
}
func TestUILockAndUnlockClearMasterPasswordField(t *testing.T) { func TestUILockAndUnlockClearMasterPasswordField(t *testing.T) {
t.Parallel() t.Parallel()
@@ -3420,6 +3462,46 @@ func TestUIGroupToolsDisclosureStatePersists(t *testing.T) {
} }
} }
func TestUIDenseLayoutPreferencePersists(t *testing.T) {
t.Parallel()
configPath := filepath.Join(t.TempDir(), "ui-prefs.json")
first := newUIWithSession("desktop", &session.Manager{})
first.uiPreferencesPath = configPath
first.denseLayout = true
first.saveUIPreferences()
second := newUIWithSession("desktop", &session.Manager{})
second.uiPreferencesPath = configPath
second.denseLayout = false
second.loadUIPreferences()
if !second.denseLayout {
t.Fatal("denseLayout = false after reload, want true")
}
}
func TestUIEntryRowMetricsUseDenseLayout(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
comfortableInset, comfortableTitle, _, _, _, comfortableGap := u.entryRowMetrics()
u.denseLayout = true
denseInset, denseTitle, _, _, _, denseGap := u.entryRowMetrics()
if denseInset >= comfortableInset {
t.Fatalf("dense inset = %v, want smaller than comfortable inset %v", denseInset, comfortableInset)
}
if denseTitle >= comfortableTitle {
t.Fatalf("dense title size = %v, want smaller than comfortable title size %v", denseTitle, comfortableTitle)
}
if denseGap >= comfortableGap {
t.Fatalf("dense divider gap = %v, want smaller than comfortable divider gap %v", denseGap, comfortableGap)
}
}
func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) { func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) {
t.Parallel() t.Parallel()