From 59905ccd985cf9d553641a22e37c9314d69d9012 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 21:16:38 -0700 Subject: [PATCH] Tighten banner and navigation behavior --- appstate/state.go | 2 +- appstate/state_test.go | 6 ++--- main.go | 57 +++++++++++++++++++++++++++++++++++++++--- main_test.go | 29 +++++++++++++++++++++ 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/appstate/state.go b/appstate/state.go index 138150e..b468eb5 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -96,7 +96,7 @@ func (s *State) SetSearchQuery(query string) { func (s *State) BeginNewEntry() { s.SelectedEntryID = "" - s.StatusMessage = "new entry form ready" + s.StatusMessage = "" s.ErrorMessage = "" } diff --git a/appstate/state_test.go b/appstate/state_test.go index e7a598c..625da48 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -922,7 +922,7 @@ func TestSetSearchQueryUpdatesControllerSearchState(t *testing.T) { } } -func TestBeginNewEntryClearsSelectionAndSetsStatus(t *testing.T) { +func TestBeginNewEntryClearsSelectionAndStatus(t *testing.T) { t.Parallel() state := State{ @@ -935,8 +935,8 @@ func TestBeginNewEntryClearsSelectionAndSetsStatus(t *testing.T) { if state.SelectedEntryID != "" { t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID) } - if state.StatusMessage != "new entry form ready" { - t.Fatalf("StatusMessage = %q, want new entry form ready", state.StatusMessage) + if state.StatusMessage != "" { + t.Fatalf("StatusMessage = %q, want empty", state.StatusMessage) } if state.ErrorMessage != "" { t.Fatalf("ErrorMessage = %q, want empty", state.ErrorMessage) diff --git a/main.go b/main.go index 542db53..3a5f95d 100644 --- a/main.go +++ b/main.go @@ -41,7 +41,7 @@ const ( const ( maxAttachmentBytes = 10 << 20 - statusBannerDuration = 4 * time.Second + statusBannerDuration = 2600 * time.Millisecond ) type bannerKind string @@ -79,6 +79,7 @@ type statePaths struct { DefaultSaveAsPath string RecentVaultsPath string RecentRemotesPath string + UIPreferencesPath string } type recentVaultRecord struct { @@ -94,6 +95,10 @@ type recentRemoteRecord struct { LastGroup []string `json:"lastGroup,omitempty"` } +type uiPreferences struct { + GroupControlsHidden bool `json:"groupControlsHidden"` +} + type ui struct { mode string theme *material.Theme @@ -201,6 +206,7 @@ type ui struct { keyboardFocus focusID defaultSaveAsPath string recentVaultsPath string + uiPreferencesPath string recentRemotesPath string editingEntry bool groupControlsHidden bool @@ -293,6 +299,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) lifecycleMode: "local", defaultSaveAsPath: paths.DefaultSaveAsPath, recentVaultsPath: paths.RecentVaultsPath, + uiPreferencesPath: paths.UIPreferencesPath, recentRemotesPath: paths.RecentRemotesPath, recentVaultGroups: map[string][]string{}, now: time.Now, @@ -309,6 +316,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.setCustomFieldRows(nil) u.loadRecentVaults() u.loadRecentRemotes() + u.loadUIPreferences() u.filter() return u } @@ -342,6 +350,7 @@ func defaultStatePaths(stateDir string) statePaths { DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"), RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"), RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"), + UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"), } } @@ -805,6 +814,37 @@ func (u *ui) saveRecentRemotes() { _ = os.WriteFile(u.recentRemotesPath, content, 0o600) } +func (u *ui) loadUIPreferences() { + if strings.TrimSpace(u.uiPreferencesPath) == "" { + return + } + content, err := os.ReadFile(u.uiPreferencesPath) + if err != nil { + return + } + var prefs uiPreferences + if err := json.Unmarshal(content, &prefs); err != nil { + return + } + u.groupControlsHidden = prefs.GroupControlsHidden +} + +func (u *ui) saveUIPreferences() { + if strings.TrimSpace(u.uiPreferencesPath) == "" { + return + } + if err := os.MkdirAll(filepath.Dir(u.uiPreferencesPath), 0o700); err != nil { + return + } + content, err := json.MarshalIndent(uiPreferences{ + GroupControlsHidden: u.groupControlsHidden, + }, "", " ") + if err != nil { + return + } + _ = os.WriteFile(u.uiPreferencesPath, content, 0o600) +} + func (u *ui) noteRecentRemote(baseURL, path, username, password string, rememberAuth bool) { baseURL = strings.TrimSpace(baseURL) path = strings.TrimSpace(path) @@ -1398,6 +1438,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } for u.toggleGroupControls.Clicked(gtx) { u.groupControlsHidden = !u.groupControlsHidden + u.saveUIPreferences() } for u.renameGroup.Clicked(gtx) { u.clearDeleteGroupConfirmation() @@ -1644,11 +1685,21 @@ func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions { func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showEntries, "Entries") + btn := material.Button(u.theme, &u.showEntries, "Entries") + btn.Background = accentColor + btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + btn.TextSize = unit.Sp(11) + btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} + return btn.Layout(gtx) }), layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin") + btn := material.Button(u.theme, &u.showRecycle, "Recycle Bin") + btn.Background = accentColor + btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} + btn.TextSize = unit.Sp(11) + btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} + return btn.Layout(gtx) }), ) } diff --git a/main_test.go b/main_test.go index 60fdf94..d0d5d17 100644 --- a/main_test.go +++ b/main_test.go @@ -1737,6 +1737,9 @@ func TestUIStatusBannerExpiresAfterTimeout(t *testing.T) { u := newUIWithModel("desktop", vault.Model{}) u.now = func() time.Time { return now } u.state.StatusMessage = "synchronize vault complete" + if statusBannerDuration != 2600*time.Millisecond { + t.Fatalf("statusBannerDuration = %v, want 2.6s", statusBannerDuration) + } u.statusExpiresAt = now.Add(statusBannerDuration) if got := u.bannerSurface(); got.Kind != bannerStatus || got.Message != "synchronize vault complete" { @@ -2003,6 +2006,26 @@ func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) { } } +func TestUIGroupToolsDisclosureStatePersists(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "ui-prefs.json") + + first := newUIWithSession("desktop", &session.Manager{}) + first.uiPreferencesPath = configPath + first.groupControlsHidden = true + first.saveUIPreferences() + + second := newUIWithSession("desktop", &session.Manager{}) + second.uiPreferencesPath = configPath + second.groupControlsHidden = false + second.loadUIPreferences() + + if !second.groupControlsHidden { + t.Fatal("groupControlsHidden = false after reload, want true") + } +} + func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) { t.Parallel() @@ -2107,6 +2130,9 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) { if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") { t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json")) } + if got := paths.UIPreferencesPath; got != filepath.Join(base, "ui-prefs.json") { + t.Fatalf("UIPreferencesPath = %q, want %q", got, filepath.Join(base, "ui-prefs.json")) + } } func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) { @@ -2124,6 +2150,9 @@ func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) { if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") { t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json")) } + if got := paths.UIPreferencesPath; got != filepath.Join(base, "ui-prefs.json") { + t.Fatalf("UIPreferencesPath = %q, want %q", got, filepath.Join(base, "ui-prefs.json")) + } } func TestResolveFlagOrEnvPrefersFlagThenEnvThenFallback(t *testing.T) {