Improve entries navigation readability

This commit is contained in:
Joe Julian
2026-04-01 17:12:13 -07:00
parent e364630b77
commit 4fea8b360a
3 changed files with 255 additions and 69 deletions
+174 -62
View File
@@ -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}
+80
View File
@@ -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()
+1 -7
View File
@@ -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])