From 9aeb98da58f12d30acd04108f7a6aeb3a1818f6a Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 5 Apr 2026 23:42:53 -0700 Subject: [PATCH] Add About section --- appstate/state.go | 3 +- main.go | 157 ++++++++++++++++++++++++++++++++++++++++++---- main_test.go | 30 +++++++++ 3 files changed, 176 insertions(+), 14 deletions(-) diff --git a/appstate/state.go b/appstate/state.go index c57fa47..45dcc1a 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -26,6 +26,7 @@ const ( SectionRecycleBin Section = "recycle-bin" SectionAPITokens Section = "api-tokens" SectionAPIAudit Section = "api-audit" + SectionAbout Section = "about" ) type CurrentSession interface { @@ -376,7 +377,7 @@ func (s *State) entriesForSection(model vault.Model) []vault.Entry { return slices.Clone(model.Templates) case SectionRecycleBin: return slices.Clone(model.RecycleBin) - case SectionAPITokens, SectionAPIAudit: + case SectionAPITokens, SectionAPIAudit, SectionAbout: return nil default: return slices.Clone(model.Entries) diff --git a/main.go b/main.go index cfe6962..9d9b9d5 100644 --- a/main.go +++ b/main.go @@ -334,6 +334,7 @@ type ui struct { showRecycle widget.Clickable showAPITokens widget.Clickable showAPIAudit widget.Clickable + showAbout widget.Clickable showLocalLifecycle widget.Clickable showRemoteLifecycle widget.Clickable showSyncLocal widget.Clickable @@ -685,6 +686,8 @@ func (u *ui) searchPlaceholder() string { return "Search audit log" case appstate.SectionRecycleBin: return "Search recycle bin" + case appstate.SectionAbout: + return "Search disabled on About" default: return "Search vault" } @@ -814,6 +817,14 @@ func (u *ui) showAPIAuditSection() { u.filter() } +func (u *ui) showAboutSection() { + u.resetPasswordPeek() + u.rememberEntriesSectionState() + u.state.ShowSection(appstate.SectionAbout) + u.mainMenuOpen = false + u.filter() +} + func (u *ui) returnToMainEntries() { u.clearDeleteGroupConfirmation() u.showEntriesSection() @@ -2752,6 +2763,11 @@ func (u *ui) listEmptyState() emptyState { Title: "No API audit events yet", Body: "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity.", } + case appstate.SectionAbout: + return emptyState{ + Title: "About KeePassGO", + Body: "Product details, compatibility notes, and platform targets appear in the detail pane.", + } case appstate.SectionTemplates: return emptyState{ Title: "Templates unavailable", @@ -2790,6 +2806,8 @@ func (u *ui) detailPlaceholderMessage() string { return "Select an API token, issue a new one, or search to narrow the list." case appstate.SectionAPIAudit: return "Select an audit event to inspect it, or use Search audit log or the quick filters above." + case appstate.SectionAbout: + return "Review the product overview, platform support, and compatibility goals." case appstate.SectionTemplates: return "Select a template or start a reusable entry." case appstate.SectionRecycleBin: @@ -3007,6 +3025,10 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.clearDeleteGroupConfirmation() u.showAPIAuditSection() } + for u.showAbout.Clicked(gtx) { + u.clearDeleteGroupConfirmation() + u.showAboutSection() + } for u.showLocalLifecycle.Clicked(gtx) { if u.lifecycleBusy() { continue @@ -4191,6 +4213,10 @@ func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit") }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.showAbout, "About") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings") }), @@ -4198,6 +4224,70 @@ func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions { }) } +func (u *ui) aboutDetailPanel(gtx layout.Context) layout.Dimensions { + rows := []layout.Widget{ + func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(22), "KeePassGO") + lbl.Color = accentColor + return lbl.Layout(gtx) + }, + func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(15), "A KeePass-compatible password manager built in Go.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }, + layout.Spacer{Height: unit.Dp(14)}.Layout, + aboutFact(u.theme, "Compatibility", "KeePass and KDBX interoperability", "Designed to coexist with desktop KeePass and KeePass2Android workflows."), + layout.Spacer{Height: unit.Dp(10)}.Layout, + aboutFact(u.theme, "Platforms", "Windows and Linux first, Android supported", "Desktop remains the primary product surface while Android stays compatible."), + layout.Spacer{Height: unit.Dp(10)}.Layout, + aboutFact(u.theme, "Sync", "Local files and direct WebDAV", "Remote-file workflows are first-class and avoid browser-stack dependencies."), + layout.Spacer{Height: unit.Dp(10)}.Layout, + aboutFact(u.theme, "Programmatic Access", "Secure local gRPC API", "Built for trusted clients such as browser extensions and automation."), + layout.Spacer{Height: unit.Dp(14)}.Layout, + func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "Runtime") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }, + func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(14), fmt.Sprintf("%s on %s/%s", runtime.Version(), runtime.GOOS, runtime.GOARCH)) + lbl.Color = u.theme.Palette.Fg + return lbl.Layout(gtx) + }, + } + return material.List(u.theme, &u.detailList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { + return rows[i](gtx) + }) +} + +func aboutFact(theme *material.Theme, title, primary, secondary string) layout.Widget { + return func(gtx layout.Context) layout.Dimensions { + return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(theme, unit.Sp(12), strings.ToUpper(title)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(theme, unit.Sp(16), primary) + lbl.Color = theme.Palette.Fg + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(theme, unit.Sp(13), secondary) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + }) + }) + } +} + func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions { label := "Sync" spacing := unit.Dp(4) @@ -4308,21 +4398,23 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { return panel(gtx, func(gtx layout.Context) layout.Dimensions { visibleEntries, entryClicks := u.visibleEntrySnapshot() rows := make([]layout.Widget, 0, 16+len(visibleEntries)) - rows = append(rows, func(gtx layout.Context) layout.Dimensions { - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { - editor := material.Editor(u.theme, &u.search, u.searchPlaceholder()) - editor.Color = u.theme.Palette.Fg - editor.HintColor = mutedColor - return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) + if u.state.Section != appstate.SectionAbout { + rows = append(rows, func(gtx layout.Context) layout.Dimensions { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { + editor := material.Editor(u.theme, &u.search, u.searchPlaceholder()) + editor.Color = u.theme.Palette.Fg + editor.HintColor = mutedColor + return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) + }) }) - }) - rows = append(rows, func(gtx layout.Context) layout.Dimensions { - return layout.Spacer{Height: spacing}.Layout(gtx) - }) + rows = append(rows, func(gtx layout.Context) layout.Dimensions { + return layout.Spacer{Height: spacing}.Layout(gtx) + }) + } if !u.isVaultLocked() { rows = append(rows, u.navigationHeader) - if u.state.Section == appstate.SectionEntries { + if u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionAbout { rows = append(rows, func(gtx layout.Context) layout.Dimensions { return layout.Spacer{Height: spacing}.Layout(gtx) }) @@ -4352,6 +4444,8 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { return btn.Layout(gtx) case appstate.SectionAPITokens: return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token") + case appstate.SectionAbout: + return emptyStatePanel(gtx, u.theme, u.listEmptyState()) default: return layout.Dimensions{} } @@ -4365,6 +4459,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { rows = append(rows, u.apiTokenListPanel) case u.state.Section == appstate.SectionAPIAudit: rows = append(rows, u.apiAuditListPanel) + case u.state.Section == appstate.SectionAbout: case len(visibleEntries) == 0: rows = append(rows, func(gtx layout.Context) layout.Dimensions { return emptyStatePanel(gtx, u.theme, u.listEmptyState()) @@ -4391,6 +4486,18 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { return u.navigationHeader(gtx) }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.state.Section == appstate.SectionAbout { + return emptyStatePanel(gtx, u.theme, u.listEmptyState()) + } + return layout.Dimensions{} + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.state.Section == appstate.SectionAbout { + return layout.Dimensions{} + } + return layout.Spacer{Height: spacing}.Layout(gtx) + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) { return layout.Dimensions{} @@ -4413,6 +4520,9 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.state.Section == appstate.SectionAbout { + return layout.Dimensions{} + } if u.mode == "phone" { gtx.Constraints.Min.X = gtx.Constraints.Max.X } @@ -4425,6 +4535,9 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: spacing}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.state.Section == appstate.SectionAbout { + return layout.Dimensions{} + } if u.isVaultLocked() { return layout.Dimensions{} } @@ -4450,6 +4563,9 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionAPIAudit { return u.apiAuditListPanel(gtx) } + if u.state.Section == appstate.SectionAbout { + return layout.Dimensions{} + } if len(u.visible) == 0 { return emptyStatePanel(gtx, u.theme, u.listEmptyState()) } @@ -4465,13 +4581,23 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { - if u.state.Section != appstate.SectionEntries { + if u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionAbout { return layout.Dimensions{} } + if u.state.Section == appstate.SectionAbout { + lbl := material.Label(u.theme, unit.Sp(18), "About") + lbl.Color = accentColor + return lbl.Layout(gtx) + } return u.groupControlsDisclosure(gtx) } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + if u.state.Section == appstate.SectionAbout { + lbl := material.Label(u.theme, unit.Sp(18), "About") + lbl.Color = accentColor + return lbl.Layout(gtx) + } return u.sectionBar(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { @@ -4772,6 +4898,11 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions { layout.Flexed(1, u.apiAuditDetailPanel), } } + if u.state.Section == appstate.SectionAbout { + return []layout.FlexChild{ + layout.Flexed(1, u.aboutDetailPanel), + } + } item, ok := u.selectedEntry() if !ok && !u.editingEntry { return []layout.FlexChild{ diff --git a/main_test.go b/main_test.go index 18cccfb..449d32b 100644 --- a/main_test.go +++ b/main_test.go @@ -5485,6 +5485,36 @@ func TestUISearchPlaceholderIsContextual(t *testing.T) { if got := u.searchPlaceholder(); got != "Search audit log" { t.Fatalf("api audit searchPlaceholder() = %q, want %q", got, "Search audit log") } + u.showAboutSection() + if got := u.searchPlaceholder(); got != "Search disabled on About" { + t.Fatalf("about searchPlaceholder() = %q, want %q", got, "Search disabled on About") + } +} + +func TestShowAboutSection(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{{ID: "entry-1", Title: "Bellagio", Path: []string{"Crew"}}}, + }) + u.mainMenuOpen = true + u.state.CurrentPath = []string{"Crew"} + u.state.SelectedEntryID = "entry-1" + + u.showAboutSection() + + if got := u.state.Section; got != appstate.SectionAbout { + t.Fatalf("state.Section = %q, want %q", got, appstate.SectionAbout) + } + if u.mainMenuOpen { + t.Fatal("mainMenuOpen = true, want false") + } + if len(u.state.CurrentPath) != 0 { + t.Fatalf("state.CurrentPath = %v, want empty", u.state.CurrentPath) + } + if got := u.state.SelectedEntryID; got != "" { + t.Fatalf("state.SelectedEntryID = %q, want empty", got) + } } func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {