From 4fea8b360a88704f746717ea25496b63dd07b09d Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Wed, 1 Apr 2026 17:12:13 -0700 Subject: [PATCH] Improve entries navigation readability --- main.go | 236 +++++++++++++++++++++++++++++++++++++-------------- main_test.go | 80 +++++++++++++++++ ui_api.go | 8 +- 3 files changed, 255 insertions(+), 69 deletions(-) diff --git a/main.go b/main.go index a2ade7d..381d47b 100644 --- a/main.go +++ b/main.go @@ -73,6 +73,11 @@ type uiSurface struct { Locked bool } +type emptyState struct { + Title string + Body string +} + type sessionStatus interface { HasVault() bool IsLocked() bool @@ -1896,38 +1901,78 @@ func (u *ui) chooseExistingFileAction(target *widget.Editor) error { } func (u *ui) listEmptyMessage() string { + return u.listEmptyState().Body +} + +func (u *ui) listEmptyState() emptyState { if surface := u.sessionSurface(); surface.Locked { - return "Unlock the vault to browse entries and groups." + return emptyState{ + Title: "Vault locked", + Body: "Unlock the vault to browse entries and groups.", + } } query := strings.TrimSpace(u.search.Text()) if query != "" { switch u.state.Section { case appstate.SectionAPITokens: - return fmt.Sprintf("No API tokens match %q. Clear or refine the search.", query) + return emptyState{ + Title: "No matching API tokens", + Body: fmt.Sprintf("No API tokens match %q. Clear or refine Search vault to find a token by name, client, or expiration.", query), + } case appstate.SectionAPIAudit: - return fmt.Sprintf("No audit events match %q. Clear or refine the search.", query) + return emptyState{ + Title: "No matching audit events", + Body: fmt.Sprintf("No audit events match %q. Clear or refine Search vault to filter by token, decision, operation, or resource.", query), + } case appstate.SectionTemplates: - return fmt.Sprintf("No templates match %q. Clear or refine the search.", query) + return emptyState{ + Title: "No matching templates", + Body: fmt.Sprintf("No templates match %q. Clear or refine Search vault.", query), + } case appstate.SectionRecycleBin: - return fmt.Sprintf("No recycle-bin entries match %q. Clear or refine the search.", query) + return emptyState{ + Title: "No matching deleted entries", + Body: fmt.Sprintf("No recycle-bin entries match %q. Clear or refine Search vault to look across deleted titles, usernames, URLs, and paths.", query), + } default: - return fmt.Sprintf("No entries match %q. Clear or refine the search.", query) + return emptyState{ + Title: "No matching entries", + Body: fmt.Sprintf("No entries match %q in this view. Clear Search vault, broaden the query, or move to another group.", query), + } } } switch u.state.Section { case appstate.SectionAPITokens: - return "No API tokens yet. Issue one to grant scoped gRPC access to an external tool." + return emptyState{ + Title: "No API tokens yet", + Body: "Issue a token to grant scoped gRPC access to an external tool.", + } case appstate.SectionAPIAudit: - return "No API audit events yet. Approval prompts, denials, and token actions will appear here." + return emptyState{ + Title: "No API audit events yet", + Body: "Approval prompts, denials, and token actions will appear here.", + } case appstate.SectionTemplates: - return "Templates are not available in this build." + return emptyState{ + Title: "Templates unavailable", + Body: "Templates are not available in this build.", + } case appstate.SectionRecycleBin: - return "Recycle Bin is empty. Deleted entries will appear here until restored." + return emptyState{ + Title: "Recycle Bin is empty", + Body: "Deleted entries will appear here until restored.", + } default: if len(u.displayPath()) > 0 { - return "No entries in this group yet. Add one, search below this point, or open a subgroup." + return emptyState{ + Title: "This group is empty", + Body: "Add an entry here, search below this point, or open a subgroup.", + } + } + return emptyState{ + Title: "No entries yet", + Body: "Create or open a vault, then add an entry to get started.", } - return "Create or open a vault, then add an entry to get started." } } @@ -2990,9 +3035,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { return u.apiAuditListPanel(gtx) } if len(u.visible) == 0 { - lbl := material.Label(u.theme, unit.Sp(16), u.listEmptyMessage()) - lbl.Color = mutedColor - return lbl.Layout(gtx) + return emptyStatePanel(gtx, u.theme, u.listEmptyState()) } return material.List(u.theme, &u.list).Layout(gtx, len(u.visible), func(gtx layout.Context, i int) layout.Dimensions { item := u.visible[i] @@ -3010,12 +3053,11 @@ func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return u.sectionBar(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.state.Section != appstate.SectionEntries { return layout.Dimensions{} } - return u.groupControlsDisclosure(gtx) + return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, u.groupControlsDisclosure) }), ) } @@ -3034,37 +3076,38 @@ func (u *ui) navigationHeader(gtx layout.Context) layout.Dimensions { func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions { tabs := []struct { - click *widget.Clickable - label string - active bool + click *widget.Clickable + label string + compact string + active bool }{ - {click: &u.showEntries, label: "Entries", active: u.state.Section == appstate.SectionEntries}, - {click: &u.showRecycle, label: "Recycle Bin", active: u.state.Section == appstate.SectionRecycleBin}, - {click: &u.showAPITokens, label: "API Tokens", active: u.state.Section == appstate.SectionAPITokens}, - {click: &u.showAPIAudit, label: "API Audit", active: u.state.Section == appstate.SectionAPIAudit}, + {click: &u.showEntries, label: "Entries", compact: "Entries", active: u.state.Section == appstate.SectionEntries}, + {click: &u.showRecycle, label: "Recycle Bin", compact: "Recycle", active: u.state.Section == appstate.SectionRecycleBin}, + {click: &u.showAPITokens, label: "API Tokens", compact: "Tokens", active: u.state.Section == appstate.SectionAPITokens}, + {click: &u.showAPIAudit, label: "API Audit", compact: "Audit", active: u.state.Section == appstate.SectionAPIAudit}, } if u.mode == "phone" { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 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 sectionTabButton(gtx, u.theme, tabs[0].click, tabs[0].label, tabs[0].active) + return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return sectionTabButton(gtx, u.theme, tabs[0].click, tabs[0].compact, tabs[0].active) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return sectionTabButton(gtx, u.theme, tabs[1].click, tabs[1].label, tabs[1].active) + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return sectionTabButton(gtx, u.theme, tabs[1].click, tabs[1].compact, tabs[1].active) }), ) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.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 sectionTabButton(gtx, u.theme, tabs[2].click, tabs[2].label, tabs[2].active) + return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return sectionTabButton(gtx, u.theme, tabs[2].click, tabs[2].compact, tabs[2].active) }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return sectionTabButton(gtx, u.theme, tabs[3].click, tabs[3].label, tabs[3].active) + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + return sectionTabButton(gtx, u.theme, tabs[3].click, tabs[3].compact, tabs[3].active) }), ) }), @@ -3096,56 +3139,100 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item } return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions { inset := unit.Dp(12) - titleSize := unit.Sp(18) + titleSize := unit.Sp(17) metaSize := unit.Sp(14) - urlSize := unit.Sp(13) + urlSize := unit.Sp(12) + pathSize := unit.Sp(11) if u.mode == "phone" { - inset = unit.Dp(10) - titleSize = unit.Sp(16) - metaSize = unit.Sp(13) - urlSize = unit.Sp(12) + inset = unit.Dp(9) + titleSize = unit.Sp(15) + metaSize = unit.Sp(12) + urlSize = unit.Sp(11) + pathSize = unit.Sp(10) + } + selected := item.ID == u.state.SelectedEntryID + focused := u.isFocused(listFocusID(idx)) + titleColor := accentColor + metaColor := color.NRGBA{R: 61, G: 60, B: 56, A: 255} + secondaryColor := mutedColor + dividerColor := color.NRGBA{R: 225, G: 219, B: 210, A: 255} + if selected { + titleColor = color.NRGBA{R: 19, G: 57, B: 43, A: 255} + metaColor = color.NRGBA{R: 31, G: 53, B: 44, A: 255} + secondaryColor = color.NRGBA{R: 72, G: 88, B: 80, A: 255} + dividerColor = color.NRGBA{R: 173, G: 196, B: 184, A: 255} + } else if focused { + metaColor = color.NRGBA{R: 49, G: 74, B: 63, A: 255} + secondaryColor = color.NRGBA{R: 86, G: 102, B: 95, A: 255} + dividerColor = color.NRGBA{R: 190, G: 208, B: 199, A: 255} } row := func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(inset).Layout(gtx, func(gtx layout.Context) layout.Dimensions { showPath := strings.TrimSpace(u.search.Text()) != "" || len(u.displayPath()) == 0 || u.state.Section == appstate.SectionRecycleBin + hasUsername := strings.TrimSpace(item.Username) != "" + hasURL := strings.TrimSpace(item.URL) != "" + pathText := strings.Join(u.displayEntryPath(item.Path), " / ") return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { lbl := material.Label(u.theme, titleSize, item.Title) - lbl.Color = accentColor + lbl.Color = titleColor return lbl.Layout(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !hasUsername { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(3)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !hasUsername { + return layout.Dimensions{} + } lbl := material.Label(u.theme, metaSize, item.Username) - lbl.Color = mutedColor + lbl.Color = metaColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !hasURL { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(2)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !hasURL { + return layout.Dimensions{} + } lbl := material.Label(u.theme, urlSize, item.URL) - lbl.Color = mutedColor + lbl.Color = secondaryColor return lbl.Layout(gtx) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if !showPath { return layout.Dimensions{} } - lbl := material.Label(u.theme, unit.Sp(11), strings.Join(u.displayEntryPath(item.Path), " / ")) - lbl.Color = mutedColor + return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showPath { + return layout.Dimensions{} + } + lbl := material.Label(u.theme, pathSize, pathText) + lbl.Color = secondaryColor return lbl.Layout(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(layout.Spacer{Height: unit.Dp(7)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { w := gtx.Constraints.Max.X if w < 1 { w = 1 } - paint.FillShape(gtx.Ops, color.NRGBA{R: 232, G: 227, B: 219, A: 255}, clip.Rect{Max: image.Pt(w, 1)}.Op()) + paint.FillShape(gtx.Ops, dividerColor, clip.Rect{Max: image.Pt(w, 1)}.Op()) return layout.Dimensions{Size: image.Pt(w, 1)} }), ) }) } - if item.ID == u.state.SelectedEntryID || u.isFocused(listFocusID(idx)) { + if selected || focused { return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx layout.Context) layout.Dimensions { size := gtx.Constraints.Min @@ -3155,18 +3242,18 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item if size.Y == 0 { size.Y = gtx.Constraints.Max.Y } - fillColor := selectedColor - edgeColor := selectedEdge + fillColor := color.NRGBA{R: 212, G: 228, B: 220, A: 255} + edgeColor := color.NRGBA{R: 46, G: 106, B: 82, A: 255} if u.state.Section == appstate.SectionRecycleBin { - fillColor = color.NRGBA{R: 245, G: 234, B: 226, A: 255} - edgeColor = color.NRGBA{R: 144, G: 74, B: 49, A: 255} + fillColor = color.NRGBA{R: 244, G: 229, B: 219, A: 255} + edgeColor = color.NRGBA{R: 133, G: 65, B: 41, A: 255} } - if u.isFocused(listFocusID(idx)) && item.ID != u.state.SelectedEntryID { - fillColor = color.NRGBA{R: 235, G: 241, B: 238, A: 255} - edgeColor = accentColor + if focused && !selected { + fillColor = color.NRGBA{R: 231, G: 239, B: 235, A: 255} + edgeColor = color.NRGBA{R: 69, G: 118, B: 97, A: 255} } paint.FillShape(gtx.Ops, fillColor, clip.Rect{Max: size}.Op()) - paint.FillShape(gtx.Ops, edgeColor, clip.Rect{Max: image.Pt(4, size.Y)}.Op()) + paint.FillShape(gtx.Ops, edgeColor, clip.Rect{Max: image.Pt(5, size.Y)}.Op()) return layout.Dimensions{Size: size} }), layout.Stacked(func(gtx layout.Context) layout.Dimensions { @@ -3794,7 +3881,7 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions { btn.TextSize = unit.Sp(11) if u.mode == "phone" { btn.TextSize = unit.Sp(10) - btn.Inset = layout.Inset{Top: 4, Bottom: 4, Left: 7, Right: 7} + btn.Inset = layout.Inset{Top: 3, Bottom: 3, Left: 6, Right: 6} } else { btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9} } @@ -3823,7 +3910,7 @@ func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) { return indices }() } - if u.mode != "phone" || len(displayPath) <= 2 { + if u.mode != "phone" || len(displayPath) <= 1 { crumbs := append([]string{"/"}, append([]string{}, displayPath...)...) indices := make([]int, 0, len(crumbs)) indices = append(indices, 0) @@ -3832,8 +3919,11 @@ func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) { } return crumbs, indices } - crumbs := []string{"/", "…", displayPath[len(displayPath)-2], displayPath[len(displayPath)-1]} - indices := []int{0, len(displayPath) - 2, len(displayPath) - 1, len(displayPath)} + if len(displayPath) == 2 { + return []string{"/", displayPath[len(displayPath)-1]}, []int{0, len(displayPath)} + } + crumbs := []string{"/", "…", displayPath[len(displayPath)-1]} + indices := []int{0, len(displayPath) - 1, len(displayPath)} return crumbs, indices } @@ -4032,6 +4122,24 @@ func compactCard(gtx layout.Context, w layout.Widget) layout.Dimensions { }) } +func emptyStatePanel(gtx layout.Context, th *material.Theme, state emptyState) layout.Dimensions { + return compactCard(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(th, unit.Sp(15), state.Title) + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(th, unit.Sp(13), state.Body) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + ) + }) +} + func outlinedFieldState(gtx layout.Context, focused bool, w layout.Widget) layout.Dimensions { appearance := fieldFocusAppearance(gtx.Metric, focused) size := gtx.Constraints.Min @@ -4214,6 +4322,10 @@ func sectionTabButton(gtx layout.Context, th *material.Theme, click *widget.Clic btn.TextSize = unit.Sp(10) btn.Inset = layout.Inset{Top: 4, Bottom: 4, Left: 8, Right: 8} } + if gtx.Constraints.Max.X <= gtx.Dp(unit.Dp(220)) { + btn.TextSize = unit.Sp(9) + btn.Inset = layout.Inset{Top: 4, Bottom: 4, Left: 6, Right: 6} + } if active { btn.Background = accentColor btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255} diff --git a/main_test.go b/main_test.go index 4062805..87f37dc 100644 --- a/main_test.go +++ b/main_test.go @@ -3805,6 +3805,86 @@ func TestUITemplateSectionEmptyStateStaysProductSpecific(t *testing.T) { } } +func TestUIListEmptyStateProvidesSectionSpecificGuidance(t *testing.T) { + t.Parallel() + + t.Run("empty group", func(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Root Entry", Path: []string{"Root"}}, + }, + }) + u.showEntriesSection() + u.setCurrentPath([]string{"Root", "Empty Group"}) + + got := u.listEmptyState() + want := emptyState{ + Title: "This group is empty", + Body: "Add an entry here, search below this point, or open a subgroup.", + } + if got != want { + t.Fatalf("listEmptyState() = %#v, want %#v", got, want) + } + }) + + t.Run("recycle search", func(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.showRecycleBinSection() + u.search.SetText("orphaned") + + got := u.listEmptyState() + want := emptyState{ + Title: "No matching deleted entries", + Body: `No recycle-bin entries match "orphaned". Clear or refine Search vault to look across deleted titles, usernames, URLs, and paths.`, + } + if got != want { + t.Fatalf("listEmptyState() = %#v, want %#v", got, want) + } + }) + + t.Run("api tokens", func(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.showAPITokensSection() + + got := u.listEmptyState() + want := emptyState{ + Title: "No API tokens yet", + Body: "Issue a token to grant scoped gRPC access to an external tool.", + } + if got != want { + t.Fatalf("listEmptyState() = %#v, want %#v", got, want) + } + }) +} + +func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) { + t.Parallel() + + u := newUIWithModel("phone", vault.Model{}) + + gotCrumbs, gotIndices := u.visibleBreadcrumbs([]string{"Root", "Infrastructure"}) + if !slices.Equal(gotCrumbs, []string{"/", "Infrastructure"}) { + t.Fatalf("visibleBreadcrumbs() crumbs = %v, want [\"/\" Infrastructure]", gotCrumbs) + } + if !slices.Equal(gotIndices, []int{0, 2}) { + t.Fatalf("visibleBreadcrumbs() indices = %v, want [0 2]", gotIndices) + } + + gotCrumbs, gotIndices = u.visibleBreadcrumbs([]string{"Root", "Infrastructure", "SSH"}) + if !slices.Equal(gotCrumbs, []string{"/", "…", "SSH"}) { + t.Fatalf("visibleBreadcrumbs() deep crumbs = %v, want [\"/\" \"…\" SSH]", gotCrumbs) + } + if !slices.Equal(gotIndices, []int{0, 2, 3}) { + t.Fatalf("visibleBreadcrumbs() deep indices = %v, want [0 2 3]", gotIndices) + } +} + func TestUILocalLifecycleActionErrorsAreVisibleAndSpecific(t *testing.T) { t.Parallel() diff --git a/ui_api.go b/ui_api.go index 2560e93..07ecccc 100644 --- a/ui_api.go +++ b/ui_api.go @@ -434,13 +434,7 @@ func (u *ui) apiTokenListPanel(gtx layout.Context) layout.Dimensions { layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { if len(tokens) == 0 { - text := "No API tokens yet." - if strings.TrimSpace(u.search.Text()) != "" { - text = "No API tokens match the current filter." - } - lbl := material.Label(u.theme, unit.Sp(14), text) - lbl.Color = mutedColor - return lbl.Layout(gtx) + return emptyStatePanel(gtx, u.theme, u.listEmptyState()) } return material.List(u.theme, &u.list).Layout(gtx, len(tokens), func(gtx layout.Context, i int) layout.Dimensions { return u.apiTokenRow(gtx, &u.apiTokenClicks[i], i, tokens[i])