From efd1b48df0e30cfe978a61994f04b27a16833e38 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Fri, 3 Apr 2026 10:55:17 -0700 Subject: [PATCH] Add API policy pickers and contextual search labels --- main.go | 33 ++++++++++++++++--- main_test.go | 66 ++++++++++++++++++++++++++++++++++++- ui_api.go | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 183 insertions(+), 8 deletions(-) diff --git a/main.go b/main.go index 156db78..d9edee7 100644 --- a/main.go +++ b/main.go @@ -384,6 +384,9 @@ type ui struct { disableAPIToken widget.Clickable revokeAPIToken widget.Clickable deleteAPIToken widget.Clickable + useCurrentGroupForPolicy widget.Clickable + useSelectedEntryForPolicy widget.Clickable + clearAPIPolicyTarget widget.Clickable addAPIPolicyRule widget.Clickable phoneSplit widget.Float splitDrag gesture.Drag @@ -669,6 +672,19 @@ func (u *ui) visibleEntrySnapshot() ([]entry, []*widget.Clickable) { 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 { baseDir := strings.TrimSpace(stateDir) if baseDir == "" { @@ -2592,7 +2608,7 @@ func (u *ui) listEmptyState() emptyState { case appstate.SectionAPITokens: 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), + 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: return emptyState{ @@ -2664,7 +2680,7 @@ func (u *ui) detailPlaceholderMessage() string { case appstate.SectionAPITokens: 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 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: return "Select a template or start a reusable entry." case appstate.SectionRecycleBin: @@ -2996,6 +3012,15 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.addAPIPolicyRule.Clicked(gtx) { 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 u.apiPolicyRemoves[i].Clicked(gtx) { index := i @@ -4169,7 +4194,7 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions { 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, "Search vault") + 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) @@ -4275,7 +4300,7 @@ func (u *ui) listPanel(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, "Search vault") + 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) diff --git a/main_test.go b/main_test.go index 54e09fc..18dcbaf 100644 --- a/main_test.go +++ b/main_test.go @@ -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." { 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) } @@ -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{"Joe", "codex"}}, + }, + }) + u.state.NavigateToPath([]string{"Joe", "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) { t.Parallel() diff --git a/ui_api.go b/ui_api.go index 921b6a6..178bb53 100644 --- a/ui_api.go +++ b/ui_api.go @@ -369,6 +369,57 @@ func (u *ui) addAPIPolicyRuleAction() error { 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 { token, ok := u.selectedAPIToken() 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(labeledEditorHelp(u.theme, "Operation", "Valid operations: "+strings.Join(stringOps(apiOperations()), ", "), &u.apiPolicyOperation, false)), 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(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(labeledEditorHelp(u.theme, "Entry ID", "Used when group scope is disabled.", &u.apiPolicyEntryID, false)), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if u.apiPolicyGroupScopeW.Value { + 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(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")