Compare commits

...

5 Commits

Author SHA1 Message Date
Joe Julian 17d52e1e45 Route Android back through Gio settings flow 2026-04-03 11:21:17 -07:00
Joe Julian efd1b48df0 Add API policy pickers and contextual search labels 2026-04-03 10:55:17 -07:00
Joe Julian a1ed86ff88 Remove redundant recycle bin notice 2026-04-03 10:45:47 -07:00
Joe Julian e160b01bb0 Fix group tools disclosure icon 2026-04-03 10:43:07 -07:00
Joe Julian baa03cb63e Harden phone entry click state 2026-04-03 10:38:14 -07:00
5 changed files with 233 additions and 34 deletions
+41 -28
View File
@@ -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
@@ -396,6 +399,7 @@ type ui struct {
copyIcon *widget.Icon
expandMoreIcon *widget.Icon
expandLessIcon *widget.Icon
chevronRightIcon *widget.Icon
chevronDownIcon *widget.Icon
settingsIcon *widget.Icon
menuIcon *widget.Icon
@@ -615,6 +619,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
u.expandMoreIcon, _ = widget.NewIcon(icons.NavigationExpandMore)
u.expandLessIcon, _ = widget.NewIcon(icons.NavigationExpandLess)
u.chevronRightIcon, _ = widget.NewIcon(icons.NavigationChevronRight)
u.chevronDownIcon, _ = widget.NewIcon(icons.NavigationArrowDropDown)
u.settingsIcon, _ = widget.NewIcon(icons.ActionSettings)
u.menuIcon, _ = widget.NewIcon(icons.NavigationMenu)
@@ -645,13 +650,21 @@ func (u *ui) filter() {
return
}
u.visible = visible
if len(u.entryClicks) < len(u.visible) {
u.entryClicks = make([]widget.Clickable, len(u.visible))
u.ensureEntryClicks()
}
func (u *ui) ensureEntryClicks() {
if len(u.entryClicks) >= len(u.visible) {
return
}
next := make([]widget.Clickable, len(u.visible))
copy(next, u.entryClicks)
u.entryClicks = next
}
func (u *ui) visibleEntrySnapshot() ([]entry, []*widget.Clickable) {
visible := append([]entry(nil), u.visible...)
u.ensureEntryClicks()
clicks := make([]*widget.Clickable, len(visible))
for i := range visible {
clicks[i] = &u.entryClicks[i]
@@ -659,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 == "" {
@@ -2582,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{
@@ -2654,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:
@@ -2986,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
@@ -4159,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)
@@ -4176,12 +4211,6 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
})
}
}
if !u.isVaultLocked() && u.state.Section == appstate.SectionRecycleBin {
rows = append(rows, u.recycleBinSectionNotice)
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
return layout.Spacer{Height: spacing}.Layout(gtx)
})
}
if !u.isVaultLocked() && (u.state.Section == appstate.SectionEntries || u.state.Section == appstate.SectionRecycleBin) {
rows = append(rows, u.pathBar)
rows = append(rows, func(gtx layout.Context) layout.Dimensions {
@@ -4245,18 +4274,6 @@ 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.isVaultLocked() || u.state.Section != appstate.SectionRecycleBin {
return layout.Dimensions{}
}
return u.recycleBinSectionNotice(gtx)
}),
layout.Rigid(layout.Spacer{Height: func() unit.Dp {
if u.isVaultLocked() || u.state.Section != appstate.SectionRecycleBin {
return 0
}
return spacing
}()}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) {
return layout.Dimensions{}
@@ -4283,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)
@@ -5741,10 +5758,6 @@ func recyclePathCard(gtx layout.Context, th *material.Theme, title, body string)
})
}
func (u *ui) recycleBinSectionNotice(gtx layout.Context) layout.Dimensions {
return recyclePathCard(gtx, u.theme, "Recycle Bin", "Deleted entries are separated from normal browsing so you can review or restore them safely.")
}
func recycleDetailTitle(gtx layout.Context, th *material.Theme, title string) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, A: 255}), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
+101 -1
View File
@@ -849,6 +849,28 @@ func TestUIVisibleEntrySnapshotIsStableAfterVisibleMutation(t *testing.T) {
}
}
func TestUIVisibleEntrySnapshotRegrowsClickableState(t *testing.T) {
t.Parallel()
u := newUIWithModel("phone", vault.Model{
Entries: []vault.Entry{
{ID: "1", Title: "Alpha", Path: []string{"Joe", "Internet"}},
{ID: "2", Title: "Beta", Path: []string{"Joe", "Internet"}},
},
})
u.state.NavigateToPath([]string{"Joe", "Internet"})
u.filter()
u.entryClicks = u.entryClicks[:1]
visible, clicks := u.visibleEntrySnapshot()
if len(visible) != 2 || len(clicks) != 2 {
t.Fatalf("snapshot lengths = (%d, %d), want (2, 2)", len(visible), len(clicks))
}
if clicks[1] == nil {
t.Fatal("regrown click pointer = nil, want usable clickable state")
}
}
func TestUIPhoneBackReturnsFromSubscreenToEntries(t *testing.T) {
t.Parallel()
@@ -865,6 +887,20 @@ func TestUIPhoneBackReturnsFromSubscreenToEntries(t *testing.T) {
}
}
func TestUIPhoneBackClosesSettingsDialog(t *testing.T) {
t.Parallel()
u := newUIWithModel("phone", vault.Model{})
u.securityDialogOpen = true
if !u.handlePhoneBack() {
t.Fatal("handlePhoneBack() = false, want true for open settings dialog")
}
if u.securityDialogOpen {
t.Fatal("securityDialogOpen = true after back, want false")
}
}
func TestUISecurityDialogContentDoesNotPanicWithSmallViewport(t *testing.T) {
t.Parallel()
@@ -951,7 +987,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)
}
@@ -5195,6 +5231,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()
+1 -1
View File
@@ -471,7 +471,7 @@ func exeAndroid(tmpDir string, tools *androidTools, bi *buildInfo, extraJars, pe
<uses-sdk android:minSdkVersion="{{.MinSDK}}" android:targetSdkVersion="{{.TargetSDK}}" />
{{range .Permissions}} <uses-permission android:name="{{.}}"/>
{{end}}{{range .Features}} <uses-feature android:{{.}} android:required="false"/>
{{end}}{{.ManifestSnip}} <application {{.IconSnip}} android:label="{{.AppName}}">
{{end}}{{.ManifestSnip}} <application {{.IconSnip}} android:label="{{.AppName}}" android:enableOnBackInvokedCallback="false">
{{.AppSnip}}
<activity android:name="org.gioui.GioActivity"
android:label="{{.AppName}}"
+89 -3
View File
@@ -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")
+1 -1
View File
@@ -909,7 +909,7 @@ func (u *ui) groupControlsDisclosure(gtx layout.Context) layout.Dimensions {
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
icon := u.expandLessIcon
if u.groupControlsHidden {
icon = u.expandMoreIcon
icon = u.chevronRightIcon
}
if icon == nil {
lbl := material.Label(u.theme, unit.Sp(16), ">")