Complete UI state and error surfaces

This commit is contained in:
Joe Julian
2026-03-29 11:21:46 -07:00
parent 44bba18149
commit 422de535af
4 changed files with 296 additions and 10 deletions
+1 -1
View File
@@ -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
+175 -7
View File
@@ -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 {
+110
View File
@@ -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)
}
}
+10 -2
View File
@@ -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),