From 422de535afdf12ae5200c64844037de6d2a179fa Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:21:46 -0700 Subject: [PATCH] Complete UI state and error surfaces --- README.md | 2 +- main.go | 182 +++++++++++++++++++++++++++++++++++++++++++++++++-- main_test.go | 110 +++++++++++++++++++++++++++++++ ui_forms.go | 12 +++- 4 files changed, 296 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 757da0e..c1e9784 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # KeePassGO -KeePassGO is a Go-based KeePass-compatible password manager prototype targeting desktop first, with future Android support. +KeePassGO is a Go-based KeePass-compatible password manager targeting desktop first, with future Android support. ## Current Capabilities diff --git a/main.go b/main.go index a52d8df..ec00a42 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "flag" "fmt" "image" @@ -28,6 +29,31 @@ import ( type entry = vault.Entry +const ( + productName = "KeePassGO" + desktopSubtitle = "KeePass-compatible password management for desktop-first workflows" +) + +type bannerKind string + +const ( + bannerNone bannerKind = "" + bannerLoading bannerKind = "loading" + bannerError bannerKind = "error" + bannerStatus bannerKind = "status" +) + +type uiBanner struct { + Kind bannerKind + Message string +} + +type uiSurface struct { + Title string + Message string + Locked bool +} + type ui struct { mode string theme *material.Theme @@ -109,6 +135,7 @@ type ui struct { eyeIcon *widget.Icon eyeOffIcon *widget.Icon copyIcon *widget.Icon + loadingMessage string statusMessage string errorMessage string } @@ -399,15 +426,109 @@ func (u *ui) changeMasterKeyAction() error { } func (u *ui) runAction(label string, action func() error) { + u.loadingMessage = actionLoadingLabel(label) if err := action(); err != nil { - u.errorMessage = err.Error() + u.loadingMessage = "" + u.errorMessage = u.describeActionError(label, err) u.statusMessage = "" return } + u.loadingMessage = "" u.errorMessage = "" u.statusMessage = label + " complete" } +func actionLoadingLabel(label string) string { + label = strings.TrimSpace(label) + if label == "" { + return "Working..." + } + runes := []rune(label) + runes[0] = []rune(strings.ToUpper(string(runes[0])))[0] + return string(runes) + "..." +} + +func (u *ui) describeActionError(label string, err error) string { + if err == nil { + return "" + } + if errors.Is(err, webdav.ErrConflict) || strings.Contains(err.Error(), webdav.ErrConflict.Error()) { + return "Save conflict: the remote vault changed. Reopen it and retry the save." + } + return err.Error() +} + +func (u *ui) bannerSurface() uiBanner { + switch { + case strings.TrimSpace(u.loadingMessage) != "": + return uiBanner{Kind: bannerLoading, Message: strings.TrimSpace(u.loadingMessage)} + case strings.TrimSpace(u.errorMessage) != "": + return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.errorMessage)} + case strings.TrimSpace(u.statusMessage) != "": + return uiBanner{Kind: bannerStatus, Message: strings.TrimSpace(u.statusMessage)} + default: + return uiBanner{} + } +} + +func (u *ui) sessionSurface() uiSurface { + if u.state.Session == nil { + return uiSurface{} + } + if _, err := u.state.Session.Current(); errors.Is(err, session.ErrLocked) { + return uiSurface{ + Title: "Vault locked", + Message: "Enter a master password, choose a key file, or provide both to unlock the vault.", + Locked: true, + } + } + return uiSurface{} +} + +func (u *ui) listEmptyMessage() string { + if surface := u.sessionSurface(); surface.Locked { + return "Unlock the vault to browse entries and groups." + } + query := strings.TrimSpace(u.search.Text()) + if query != "" { + switch u.state.Section { + case appstate.SectionTemplates: + return fmt.Sprintf("No templates match %q. Clear or refine the search.", query) + case appstate.SectionRecycleBin: + return fmt.Sprintf("No recycle-bin entries match %q. Clear or refine the search.", query) + default: + return fmt.Sprintf("No entries match %q. Clear or refine the search.", query) + } + } + switch u.state.Section { + case appstate.SectionTemplates: + return "No templates yet. Save a reusable entry as a template." + case appstate.SectionRecycleBin: + return "Recycle Bin is empty." + default: + return "Create or open a vault, then add an entry to get started." + } +} + +func (u *ui) detailPlaceholderMessage() string { + if surface := u.sessionSurface(); surface.Locked { + return "Unlock the vault to inspect entries, attachments, and history." + } + if strings.TrimSpace(u.entryTitle.Text()) != "" || strings.TrimSpace(u.entryUsername.Text()) != "" || + strings.TrimSpace(u.entryPassword.Text()) != "" || strings.TrimSpace(u.entryURL.Text()) != "" || + strings.TrimSpace(u.entryNotes.Text()) != "" || strings.TrimSpace(u.entryFields.Text()) != "" { + return "Complete the form to create a new item or update the current selection." + } + switch u.state.Section { + case appstate.SectionTemplates: + return "Select a template or start a reusable entry." + case appstate.SectionRecycleBin: + return "Select a recycle-bin entry to review or restore it." + default: + return "Select an entry or start a new one." + } +} + func (u *ui) ensureNavClickables() { if len(u.breadcrumbs) < len(u.currentPath)+1 { u.breadcrumbs = make([]widget.Clickable, len(u.currentPath)+1) @@ -533,7 +654,16 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(u.header), - layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.bannerSurface().Kind == bannerNone { + return layout.Dimensions{} + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + layout.Rigid(u.banner), + layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout), + ) + }), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { if u.mode == "phone" || gtx.Constraints.Max.X < gtx.Dp(unit.Dp(720)) { u.phoneSpan = gtx.Constraints.Max.Y @@ -574,7 +704,7 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(20), "Vault") + lbl := material.Label(u.theme, unit.Sp(20), productName) lbl.Color = accentColor return lbl.Layout(gtx) }), @@ -597,11 +727,12 @@ func (u *ui) header(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(24), "Vault") + lbl.Text = productName lbl.Color = accentColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(13), "A single app concept for desktop and Android") + lbl := material.Label(u.theme, unit.Sp(13), desktopSubtitle) lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -660,7 +791,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { if len(u.visible) == 0 { - lbl := material.Label(u.theme, unit.Sp(16), "No entries match.") + lbl := material.Label(u.theme, unit.Sp(16), u.listEmptyMessage()) lbl.Color = mutedColor return lbl.Layout(gtx) } @@ -825,7 +956,18 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { if !ok { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(16), "Edit the current form to create or update an item") + surface := u.sessionSurface() + title := surface.Title + if title == "" { + title = "Entry details" + } + lbl := material.Label(u.theme, unit.Sp(18), title) + 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(16), u.detailPlaceholderMessage()) lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -910,6 +1052,32 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions { }) } +func (u *ui) banner(gtx layout.Context) layout.Dimensions { + banner := u.bannerSurface() + if banner.Kind == bannerNone { + return layout.Dimensions{} + } + + bg := color.NRGBA{R: 232, G: 239, B: 235, A: 255} + fg := accentColor + switch banner.Kind { + case bannerLoading: + bg = color.NRGBA{R: 234, G: 232, B: 227, A: 255} + fg = color.NRGBA{R: 92, G: 76, B: 34, A: 255} + case bannerError: + bg = color.NRGBA{R: 248, G: 228, B: 225, A: 255} + fg = color.NRGBA{R: 130, G: 36, B: 25, A: 255} + } + + return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(14), banner.Message) + lbl.Color = fg + return lbl.Layout(gtx) + }) + }) +} + func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionRecycleBin { lbl := material.Label(u.theme, unit.Sp(13), "Recycle Bin") @@ -1116,7 +1284,7 @@ func main() { go func() { w := new(app.Window) w.Option( - app.Title("Vault Mock"), + app.Title(productName), app.Size(width, height), ) if err := run(w, strings.ToLower(*mode)); err != nil { diff --git a/main_test.go b/main_test.go index 39b18e3..d2a5b55 100644 --- a/main_test.go +++ b/main_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "errors" "net/http" "net/http/httptest" "os" @@ -12,6 +13,7 @@ import ( "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/session" "git.julianfamily.org/keepassgo/vault" + "git.julianfamily.org/keepassgo/webdav" ) func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { @@ -781,3 +783,111 @@ func TestUIActionErrorsAndStatusMessagesAreCapturedForDisplay(t *testing.T) { t.Fatal("statusMessage = empty, want visible success status") } } + +func TestUILockSurfacePromptsForMasterKeyMaterial(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + + if err := u.createVaultAction(); err != nil { + t.Fatalf("createVaultAction() error = %v", err) + } + if err := u.lockAction(); err != nil { + t.Fatalf("lockAction() error = %v", err) + } + + got := u.sessionSurface() + if !got.Locked { + t.Fatal("sessionSurface().Locked = false, want true") + } + if got.Title != "Vault locked" { + t.Fatalf("sessionSurface().Title = %q, want %q", got.Title, "Vault locked") + } + if got.Message != "Enter a master password, choose a key file, or provide both to unlock the vault." { + t.Fatalf("sessionSurface().Message = %q, want unlock prompt", got.Message) + } + + if msg := u.listEmptyMessage(); msg != "Unlock the vault to browse entries and groups." { + t.Fatalf("listEmptyMessage() = %q, want locked list prompt", msg) + } + if msg := u.detailPlaceholderMessage(); msg != "Unlock the vault to inspect entries, attachments, and history." { + t.Fatalf("detailPlaceholderMessage() = %q, want locked detail prompt", msg) + } +} + +func TestUIEmptyStatesExplainCurrentSectionAndSearch(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + + if msg := u.listEmptyMessage(); msg != "Create or open a vault, then add an entry to get started." { + t.Fatalf("listEmptyMessage() = %q, want empty entries guidance", msg) + } + + u.search.SetText("bellagio") + u.filter() + if msg := u.listEmptyMessage(); msg != `No entries match "bellagio". Clear or refine the search.` { + t.Fatalf("search listEmptyMessage() = %q, want search guidance", msg) + } + + u.search.SetText("") + u.showTemplatesSection() + if msg := u.listEmptyMessage(); msg != "No templates yet. Save a reusable entry as a template." { + t.Fatalf("template listEmptyMessage() = %q, want template empty guidance", msg) + } + + u.showRecycleBinSection() + if msg := u.listEmptyMessage(); msg != "Recycle Bin is empty." { + t.Fatalf("recycle listEmptyMessage() = %q, want recycle empty guidance", msg) + } +} + +func TestUIBannerSurfacePrefersLoadingThenErrorThenStatus(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.loadingMessage = "Opening vault..." + if got := u.bannerSurface(); got.Kind != bannerLoading || got.Message != "Opening vault..." { + t.Fatalf("bannerSurface() with loading = %#v, want loading banner", got) + } + + u.loadingMessage = "" + u.errorMessage = "save failed" + if got := u.bannerSurface(); got.Kind != bannerError || got.Message != "save failed" { + t.Fatalf("bannerSurface() with error = %#v, want error banner", got) + } + + u.errorMessage = "" + u.statusMessage = "save complete" + if got := u.bannerSurface(); got.Kind != bannerStatus || got.Message != "save complete" { + t.Fatalf("bannerSurface() with status = %#v, want status banner", got) + } +} + +func TestUIRunActionNormalizesRemoteSaveConflictsForDisplay(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.runAction("save vault", func() error { + return errors.New("save remote vaults/main.kdbx: " + webdav.ErrConflict.Error()) + }) + + if got := u.errorMessage; got != "Save conflict: the remote vault changed. Reopen it and retry the save." { + t.Fatalf("errorMessage = %q, want normalized save conflict guidance", got) + } + if got := u.statusMessage; got != "" { + t.Fatalf("statusMessage = %q, want empty on conflict", got) + } +} + +func TestUIUsesKeePassGOProductCopy(t *testing.T) { + t.Parallel() + + if productName != "KeePassGO" { + t.Fatalf("productName = %q, want %q", productName, "KeePassGO") + } + if desktopSubtitle != "KeePass-compatible password management for desktop-first workflows" { + t.Fatalf("desktopSubtitle = %q, want updated product subtitle", desktopSubtitle) + } +} diff --git a/ui_forms.go b/ui_forms.go index fd9f417..d0222f9 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -11,9 +11,13 @@ import ( ) func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { + surface := u.sessionSurface() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, unit.Sp(12), "MASTER KEY MODE") + if surface.Locked { + lbl.Text += " • " + strings.ToUpper(surface.Message) + } lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -52,9 +56,13 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(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.createVault, "New") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.createVault, "New Vault") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openVault, "Open") }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.openVault, "Open Vault") + }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.saveVault, "Save") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),