Add persisted UI settings preferences

This commit is contained in:
Joe Julian
2026-04-01 17:38:02 -07:00
parent 7ee969fb81
commit 6f2b4d49b3
2 changed files with 426 additions and 318 deletions
+136 -110
View File
@@ -141,6 +141,7 @@ type uiPreferences struct {
GroupControlsHidden bool `json:"groupControlsHidden"`
LifecycleAdvancedHidden bool `json:"lifecycleAdvancedHidden"`
HistoryHidden bool `json:"historyHidden"`
DenseLayout bool `json:"denseLayout"`
}
type entriesSectionState struct {
@@ -224,10 +225,8 @@ type ui struct {
toggleSyncMenu widget.Clickable
openAdvancedSync widget.Clickable
openSecuritySettings widget.Clickable
openRemotePrefsHelp widget.Clickable
closeAdvancedSync widget.Clickable
closeSecuritySettings widget.Clickable
closeRemotePrefsHelp widget.Clickable
runAdvancedSync widget.Clickable
saveSecuritySettings widget.Clickable
editEntry widget.Clickable
@@ -287,6 +286,10 @@ type ui struct {
apiPolicyAllow widget.Bool
apiPolicyGroupScopeW widget.Bool
apiTokenDisabled widget.Bool
settingsGroupControls widget.Bool
settingsLifecycleAdvanced widget.Bool
settingsHistory widget.Bool
settingsDenseLayout widget.Bool
entryClicks []widget.Clickable
apiTokenClicks []widget.Clickable
apiPolicyRemoves []widget.Clickable
@@ -344,7 +347,6 @@ type ui struct {
syncDialogOpen bool
syncMenuOpen bool
securityDialogOpen bool
remotePrefsDialogOpen bool
showSyncPassword bool
keyboardFocus focusID
defaultSaveAsPath string
@@ -356,6 +358,7 @@ type ui struct {
groupControlsHidden bool
lifecycleAdvancedHidden bool
historyHidden bool
denseLayout bool
recentVaults []string
recentRemotes []recentRemoteRecord
recentVaultGroups map[string][]string
@@ -519,6 +522,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.loadRecentRemotes()
u.restoreStartupLifecycleTarget()
u.loadUIPreferences()
u.loadSettingsFormFromPreferences()
u.filter()
u.syncAutofillCache()
return u
@@ -1016,6 +1020,8 @@ func (u *ui) saveSecuritySettingsAction() error {
if err := u.state.ConfigureSecurity(settings); err != nil {
return err
}
u.applySettingsFormToPreferences()
u.saveUIPreferences()
u.securityDialogOpen = false
return nil
}
@@ -1292,6 +1298,7 @@ func (u *ui) loadUIPreferences() {
u.groupControlsHidden = prefs.GroupControlsHidden
u.lifecycleAdvancedHidden = prefs.LifecycleAdvancedHidden
u.historyHidden = prefs.HistoryHidden
u.denseLayout = prefs.DenseLayout
}
func (u *ui) saveUIPreferences() {
@@ -1305,6 +1312,7 @@ func (u *ui) saveUIPreferences() {
GroupControlsHidden: u.groupControlsHidden,
LifecycleAdvancedHidden: u.lifecycleAdvancedHidden,
HistoryHidden: u.historyHidden,
DenseLayout: u.denseLayout,
}, "", " ")
if err != nil {
return
@@ -1312,6 +1320,20 @@ func (u *ui) saveUIPreferences() {
_ = 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) {
baseURL = strings.TrimSpace(baseURL)
path = strings.TrimSpace(path)
@@ -1428,28 +1450,20 @@ func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) {
u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != ""
}
func (u *ui) remotePreferencesCurrentSummary() string {
func (u *ui) remoteAuthStatusMessage() string {
selected, hasSelected := u.selectedRecentRemoteRecord()
switch {
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 != ""):
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() != "":
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:
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() {
status, ok := u.state.Session.(sessionStatus)
if !ok || !status.IsRemote() || status.IsLocked() {
@@ -2450,11 +2464,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
}
for u.openSecuritySettings.Clicked(gtx) {
u.loadSecuritySettingsFromSession()
u.loadSettingsFormFromPreferences()
u.securityDialogOpen = true
}
for u.openRemotePrefsHelp.Clicked(gtx) {
u.remotePrefsDialogOpen = true
}
for u.closeAdvancedSync.Clicked(gtx) {
u.syncDialogOpen = false
u.showSyncPassword = false
@@ -2462,9 +2474,6 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
for u.closeSecuritySettings.Clicked(gtx) {
u.securityDialogOpen = false
}
for u.closeRemotePrefsHelp.Clicked(gtx) {
u.remotePrefsDialogOpen = false
}
for u.runAdvancedSync.Clicked(gtx) {
u.runAction("advanced synchronize vault", u.advancedSyncAction)
}
@@ -2870,12 +2879,6 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
}
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 {
if _, ok := u.pendingApproval(); !ok {
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 {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
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
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), "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
return lbl.Layout(gtx)
}),
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(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)),
@@ -3006,49 +3021,13 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions {
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
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 {
return layout.Stack{}.Layout(gtx,
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(layout.Spacer{Width: unit.Dp(8)}.Layout),
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.Color = accentColor
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 {
panel := card
spacing := unit.Dp(12)
spacing := u.sectionSpacing()
if u.mode == "phone" {
panel = compactCard
spacing = unit.Dp(6)
}
u.ensureNavClickables()
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()
}
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
inset := unit.Dp(12)
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)
}
inset, titleSize, metaSize, urlSize, pathSize, dividerGap := u.entryRowMetrics()
selected := item.ID == u.state.SelectedEntryID
focused := u.isFocused(listFocusID(idx))
titleColor := accentColor
@@ -3696,7 +3710,7 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item
lbl.Color = secondaryColor
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 {
w := gtx.Constraints.Max.X
if w < 1 {
@@ -3808,7 +3822,7 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(u.syncButtonGroup),
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
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.Color = accentColor
btn.Size = unit.Dp(18)
@@ -3947,10 +3961,22 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
titleSize := unit.Sp(26)
titlePad := unit.Dp(10)
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" {
titleSize = unit.Sp(18)
titlePad = 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{
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,
layout.Spacer{Height: unit.Dp(8)}.Layout,
layout.Spacer{Height: cardGap}.Layout,
u.historyPanel,
layout.Spacer{Height: unit.Dp(8)}.Layout,
layout.Spacer{Height: cardGap}.Layout,
func(gtx layout.Context) layout.Dimensions {
switch u.state.Section {
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) {
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) {
t.Parallel()