Add API policy pickers and contextual search labels

This commit is contained in:
Joe Julian
2026-04-03 10:55:17 -07:00
parent eebb270158
commit 05f43aa7bb
3 changed files with 183 additions and 8 deletions
+29 -4
View File
@@ -384,6 +384,9 @@ type ui struct {
disableAPIToken widget.Clickable disableAPIToken widget.Clickable
revokeAPIToken widget.Clickable revokeAPIToken widget.Clickable
deleteAPIToken widget.Clickable deleteAPIToken widget.Clickable
useCurrentGroupForPolicy widget.Clickable
useSelectedEntryForPolicy widget.Clickable
clearAPIPolicyTarget widget.Clickable
addAPIPolicyRule widget.Clickable addAPIPolicyRule widget.Clickable
phoneSplit widget.Float phoneSplit widget.Float
splitDrag gesture.Drag splitDrag gesture.Drag
@@ -669,6 +672,19 @@ func (u *ui) visibleEntrySnapshot() ([]entry, []*widget.Clickable) {
return visible, clicks return visible, clicks
} }
func (u *ui) searchPlaceholder() string {
switch u.state.Section {
case appstate.SectionAPITokens:
return "Search API tokens"
case appstate.SectionAPIAudit:
return "Search audit log"
case appstate.SectionRecycleBin:
return "Search recycle bin"
default:
return "Search vault"
}
}
func defaultStatePaths(stateDir string) statePaths { func defaultStatePaths(stateDir string) statePaths {
baseDir := strings.TrimSpace(stateDir) baseDir := strings.TrimSpace(stateDir)
if baseDir == "" { if baseDir == "" {
@@ -2592,7 +2608,7 @@ func (u *ui) listEmptyState() emptyState {
case appstate.SectionAPITokens: case appstate.SectionAPITokens:
return emptyState{ return emptyState{
Title: "No matching API tokens", 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), Body: fmt.Sprintf("No API tokens match %q. Clear or refine Search API tokens to find a token by name, client, or expiration.", query),
} }
case appstate.SectionAPIAudit: case appstate.SectionAPIAudit:
return emptyState{ return emptyState{
@@ -2664,7 +2680,7 @@ func (u *ui) detailPlaceholderMessage() string {
case appstate.SectionAPITokens: case appstate.SectionAPITokens:
return "Select an API token, issue a new one, or search to narrow the list." return "Select an API token, issue a new one, or search to narrow the list."
case appstate.SectionAPIAudit: case appstate.SectionAPIAudit:
return "Select an audit event to inspect it, or use Search vault or the quick filters above." return "Select an audit event to inspect it, or use Search audit log or the quick filters above."
case appstate.SectionTemplates: case appstate.SectionTemplates:
return "Select a template or start a reusable entry." return "Select a template or start a reusable entry."
case appstate.SectionRecycleBin: case appstate.SectionRecycleBin:
@@ -2996,6 +3012,15 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
for u.addAPIPolicyRule.Clicked(gtx) { for u.addAPIPolicyRule.Clicked(gtx) {
u.runAction("add API policy rule", u.addAPIPolicyRuleAction) u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
} }
for u.useCurrentGroupForPolicy.Clicked(gtx) {
u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction)
}
for u.useSelectedEntryForPolicy.Clicked(gtx) {
u.runAction("use selected entry for API policy", u.useSelectedEntryForPolicyAction)
}
for u.clearAPIPolicyTarget.Clicked(gtx) {
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
}
for i := range u.apiPolicyRemoves { for i := range u.apiPolicyRemoves {
for u.apiPolicyRemoves[i].Clicked(gtx) { for u.apiPolicyRemoves[i].Clicked(gtx) {
index := i index := i
@@ -4169,7 +4194,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
rows = append(rows, func(gtx layout.Context) layout.Dimensions { rows = append(rows, func(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X gtx.Constraints.Min.X = gtx.Constraints.Max.X
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.search, "Search vault") editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
editor.Color = u.theme.Palette.Fg editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
@@ -4275,7 +4300,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
gtx.Constraints.Min.X = gtx.Constraints.Max.X gtx.Constraints.Min.X = gtx.Constraints.Max.X
} }
return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions { return u.outlinedFieldState(gtx, u.isFocused(focusSearch), func(gtx layout.Context) layout.Dimensions {
editor := material.Editor(u.theme, &u.search, "Search vault") editor := material.Editor(u.theme, &u.search, u.searchPlaceholder())
editor.Color = u.theme.Palette.Fg editor.Color = u.theme.Palette.Fg
editor.HintColor = mutedColor editor.HintColor = mutedColor
return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout) return layout.UniformInset(unit.Dp(10)).Layout(gtx, editor.Layout)
+65 -1
View File
@@ -973,7 +973,7 @@ func TestUIAPIAuditMessagesGuideQuickFilters(t *testing.T) {
if got := u.listEmptyState(); got.Title != "No API audit events yet" || got.Body != "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity." { if got := u.listEmptyState(); got.Title != "No API audit events yet" || got.Body != "Connect a trusted client, respond to approval prompts, or issue a token to start recording activity." {
t.Fatalf("listEmptyState() = %#v, want updated API audit guidance", got) t.Fatalf("listEmptyState() = %#v, want updated API audit guidance", got)
} }
if got := u.detailPlaceholderMessage(); got != "Select an audit event to inspect it, or use Search vault or the quick filters above." { if got := u.detailPlaceholderMessage(); got != "Select an audit event to inspect it, or use Search audit log or the quick filters above." {
t.Fatalf("detailPlaceholderMessage() = %q, want quick-filter guidance", got) t.Fatalf("detailPlaceholderMessage() = %q, want quick-filter guidance", got)
} }
@@ -5217,6 +5217,70 @@ func TestUIListEmptyStateProvidesSectionSpecificGuidance(t *testing.T) {
}) })
} }
func TestUISearchPlaceholderIsContextual(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
if got := u.searchPlaceholder(); got != "Search vault" {
t.Fatalf("default searchPlaceholder() = %q, want %q", got, "Search vault")
}
u.showRecycleBinSection()
if got := u.searchPlaceholder(); got != "Search recycle bin" {
t.Fatalf("recycle searchPlaceholder() = %q, want %q", got, "Search recycle bin")
}
u.showAPITokensSection()
if got := u.searchPlaceholder(); got != "Search API tokens" {
t.Fatalf("api token searchPlaceholder() = %q, want %q", got, "Search API tokens")
}
u.showAPIAuditSection()
if got := u.searchPlaceholder(); got != "Search audit log" {
t.Fatalf("api audit searchPlaceholder() = %q, want %q", got, "Search audit log")
}
}
func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{ID: "lights", Title: "Home Assistant", Path: []string{"Crew", "codex"}},
},
})
u.state.NavigateToPath([]string{"Crew", "codex"})
u.filter()
u.state.SelectedEntryID = "lights"
if err := u.useCurrentGroupForPolicyAction(); err != nil {
t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err)
}
if got := u.apiPolicyPath.Text(); got != "codex" {
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "codex")
}
if !u.apiPolicyGroupScopeW.Value {
t.Fatal("apiPolicyGroupScopeW.Value = false, want true")
}
if err := u.useSelectedEntryForPolicyAction(); err != nil {
t.Fatalf("useSelectedEntryForPolicyAction() error = %v", err)
}
if got := u.apiPolicyEntryID.Text(); got != "lights" {
t.Fatalf("apiPolicyEntryID.Text() = %q, want %q", got, "lights")
}
if u.apiPolicyGroupScopeW.Value {
t.Fatal("apiPolicyGroupScopeW.Value = true, want false")
}
if err := u.clearAPIPolicyTargetAction(); err != nil {
t.Fatalf("clearAPIPolicyTargetAction() error = %v", err)
}
if got := u.apiPolicyPath.Text(); got != "" {
t.Fatalf("apiPolicyPath.Text() = %q, want empty", got)
}
if got := u.apiPolicyEntryID.Text(); got != "" {
t.Fatalf("apiPolicyEntryID.Text() = %q, want empty", got)
}
}
func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) { func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) {
t.Parallel() t.Parallel()
+89 -3
View File
@@ -369,6 +369,57 @@ func (u *ui) addAPIPolicyRuleAction() error {
return nil return nil
} }
func (u *ui) apiPolicyGroupPathSummary() string {
path := parsePath(u.apiPolicyPath.Text())
if len(path) == 0 {
return "No group selected"
}
return strings.Join(path, " / ")
}
func (u *ui) apiPolicyEntrySummary() string {
id := strings.TrimSpace(u.apiPolicyEntryID.Text())
if id == "" {
return "No entry selected"
}
if item, ok := u.selectedEntry(); ok && item.ID == id {
if strings.TrimSpace(item.Title) != "" {
return item.Title + " (" + id + ")"
}
}
return id
}
func (u *ui) useCurrentGroupForPolicyAction() error {
u.syncCurrentPath()
path := u.displayPath()
if len(path) == 0 {
return fmt.Errorf("navigate to a group first")
}
u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true
u.apiPolicyPath.SetText(strings.Join(path, " / "))
u.apiPolicyEntryID.SetText("")
return nil
}
func (u *ui) useSelectedEntryForPolicyAction() error {
item, ok := u.selectedEntry()
if !ok || strings.TrimSpace(item.ID) == "" {
return fmt.Errorf("select an entry first")
}
u.apiPolicyGroupScope = false
u.apiPolicyGroupScopeW.Value = false
u.apiPolicyEntryID.SetText(item.ID)
return nil
}
func (u *ui) clearAPIPolicyTargetAction() error {
u.apiPolicyPath.SetText("")
u.apiPolicyEntryID.SetText("")
return nil
}
func (u *ui) removeAPIPolicyRuleAction(index int) error { func (u *ui) removeAPIPolicyRuleAction(index int) error {
token, ok := u.selectedAPIToken() token, ok := u.selectedAPIToken()
if !ok { if !ok {
@@ -1002,9 +1053,44 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)), layout.Rigid(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Group Path", "Used when group scope is enabled.", &u.apiPolicyPath, false)), layout.Rigid(func(gtx layout.Context) layout.Dimensions {
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), if u.apiPolicyGroupScopeW.Value {
layout.Rigid(labeledEditorHelp(u.theme, "Entry ID", "Used when group scope is disabled.", &u.apiPolicyEntryID, false)), return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(detailLine(u.theme, "Group Path", u.apiPolicyGroupPathSummary())),
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.useCurrentGroupForPolicy, "Use Current Group")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
}),
)
}),
)
})
}
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(detailLine(u.theme, "Entry", u.apiPolicyEntrySummary())),
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.useSelectedEntryForPolicy, "Use Selected Entry")
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.clearAPIPolicyTarget, "Clear")
}),
)
}),
)
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule") return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")