diff --git a/main.go b/main.go index 4f1b75d..9b9d762 100644 --- a/main.go +++ b/main.go @@ -245,6 +245,8 @@ type ui struct { detailList widget.List apiPolicyList widget.List lifecycleList widget.List + recentVaultListState widget.List + recentRemoteListState widget.List copyUser widget.Clickable copyPass widget.Clickable copyURL widget.Clickable @@ -552,6 +554,12 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) lifecycleList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, + recentVaultListState: widget.List{ + List: layout.List{Axis: layout.Vertical}, + }, + recentRemoteListState: widget.List{ + List: layout.List{Axis: layout.Vertical}, + }, state: appstate.State{}, selectedHistoryIndex: -1, selectedAuditIndex: -1, @@ -5167,21 +5175,78 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { displayPath := u.displayPath() atRoot := len(displayPath) == 0 return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + if u.mode == "phone" { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "GROUPS") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if atRoot { + return layout.Dimensions{} + } + return layout.Inset{Top: unit.Dp(8)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + for u.goToRootGroup.Clicked(gtx) { + root := u.hiddenVaultRoot() + if root == "" { + u.setCurrentPath(nil) + } else { + u.setCurrentPath([]string{root}) + } + u.filter() + } + return tonedButton(gtx, u.theme, &u.goToRootGroup, "Root") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + for u.goToParentGroup.Clicked(gtx) { + u.setCurrentPath(u.currentPath[:len(u.currentPath)-1]) + u.filter() + } + return tonedButton(gtx, u.theme, &u.goToParentGroup, "Up") + }), + ) + }) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if len(groups) == 0 { + lbl := material.Label(u.theme, unit.Sp(12), "No subgroups here.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + } + maxY := gtx.Dp(unit.Dp(168)) + if gtx.Constraints.Max.Y > maxY { + gtx.Constraints.Max.Y = maxY + } + if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y { + gtx.Constraints.Min.Y = gtx.Constraints.Max.Y + } + return material.List(u.theme, &u.groupList).Layout(gtx, len(groups), func(gtx layout.Context, i int) layout.Dimensions { + idx := i + name := groups[i] + return layout.Inset{Bottom: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + for u.groupClicks[idx].Clicked(gtx) { + u.state.EnterGroup(name) + u.currentPath = append([]string(nil), u.state.CurrentPath...) + u.filter() + } + return tonedButton(gtx, u.theme, &u.groupClicks[idx], name) + }) + }) + }), + ) + } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "BROWSE GROUPS") + lbl := material.Label(u.theme, unit.Sp(12), "GROUPS") lbl.Color = mutedColor return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "Root anchors the vault, current group sets the listing, and child groups open deeper paths.") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(detailLine(u.theme, "Root", "Vault root (/)")), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(detailLine(u.theme, "Current Group", u.currentGroupDisplayName())), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if atRoot { @@ -5261,7 +5326,7 @@ func (u *ui) groupBar(gtx layout.Context) layout.Dimensions { u.currentPath = append([]string(nil), u.state.CurrentPath...) u.filter() } - return tonedButton(gtx, u.theme, &u.groupClicks[idx], "Open "+name) + return tonedButton(gtx, u.theme, &u.groupClicks[idx], name) }) }) }), diff --git a/main_test.go b/main_test.go index deb3703..458e225 100644 --- a/main_test.go +++ b/main_test.go @@ -615,6 +615,125 @@ func TestUIAPITokenDetailPanelResizesPolicyRemoveClickablesAcrossTokenSelection( _ = u.apiTokenDetailPanel(gtx) } +func TestUILifecycleScreenWithSelectedRecentVaultDoesNotPanic(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + paths := statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), + SettingsPath: filepath.Join(dir, "settings.json"), + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + AutofillCachePath: filepath.Join(dir, "autofill-cache.json"), + } + + first := newUIWithSession("phone", &session.Manager{}, paths) + first.noteRecentVault("/sdcard/Download/sample-vault.kdbx") + + u := newUIWithSession("phone", &session.Manager{}, paths) + ops := new(op.Ops) + gtx := layout.Context{ + Ops: ops, + Constraints: layout.Exact(image.Pt(1080, 2400)), + } + defer func() { + if r := recover(); r != nil { + t.Fatalf("layout() panicked with selected startup vault: %v", r) + } + }() + + _ = u.layout(gtx) +} + +func TestUILifecycleControlsWithSelectedRecentVaultDoesNotPanic(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + paths := statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), + SettingsPath: filepath.Join(dir, "settings.json"), + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + AutofillCachePath: filepath.Join(dir, "autofill-cache.json"), + } + + first := newUIWithSession("phone", &session.Manager{}, paths) + first.noteRecentVault("/sdcard/Download/sample-vault.kdbx") + + u := newUIWithSession("phone", &session.Manager{}, paths) + ops := new(op.Ops) + gtx := layout.Context{ + Ops: ops, + Constraints: layout.Exact(image.Pt(1080, 2000)), + } + defer func() { + if r := recover(); r != nil { + t.Fatalf("lifecycleControls() panicked with selected startup vault: %v", r) + } + }() + + _ = u.lifecycleControls(gtx) +} + +func TestUIRecentVaultListWithSelectedRecentVaultDoesNotPanic(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + paths := statePaths{ + DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"), + SettingsPath: filepath.Join(dir, "settings.json"), + UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"), + AutofillCachePath: filepath.Join(dir, "autofill-cache.json"), + } + + first := newUIWithSession("phone", &session.Manager{}, paths) + first.noteRecentVault("/sdcard/Download/sample-vault.kdbx") + + u := newUIWithSession("phone", &session.Manager{}, paths) + ops := new(op.Ops) + gtx := layout.Context{ + Ops: ops, + Constraints: layout.Exact(image.Pt(1080, 800)), + } + defer func() { + if r := recover(); r != nil { + t.Fatalf("recentVaultList() panicked with selected startup vault: %v", r) + } + }() + + _ = u.recentVaultList(gtx) +} + +func TestUIPhoneGroupBarWithChildGroupsDoesNotPanic(t *testing.T) { + t.Parallel() + + u := newUIWithModel("phone", vault.Model{ + Groups: [][]string{ + {"Joe"}, + {"Joe", "Internet"}, + {"Joe", "eMail"}, + }, + }) + u.setCurrentPath([]string{"Joe"}) + + ops := new(op.Ops) + gtx := layout.Context{ + Ops: ops, + Constraints: layout.Exact(image.Pt(1080, 700)), + } + defer func() { + if r := recover(); r != nil { + t.Fatalf("groupBar() panicked on phone with child groups: %v", r) + } + }() + + _ = u.groupBar(gtx) +} + func TestUIAPIAuditSectionShowsRecordedEvents(t *testing.T) { t.Parallel() diff --git a/ui_forms.go b/ui_forms.go index 1a4ae26..6b702e7 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -386,7 +386,7 @@ func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions { if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y { gtx.Constraints.Min.Y = gtx.Constraints.Max.Y } - return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(u.recentVaults), func(gtx layout.Context, i int) layout.Dimensions { + return material.List(u.theme, &u.recentVaultListState).Layout(gtx, len(u.recentVaults), func(gtx layout.Context, i int) layout.Dimensions { path := u.recentVaults[i] label := path if friendly := friendlyRecentVaultLabel(path); friendly != "" { @@ -467,7 +467,7 @@ func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions { if gtx.Constraints.Min.Y > gtx.Constraints.Max.Y { gtx.Constraints.Min.Y = gtx.Constraints.Max.Y } - return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(u.recentRemotes), func(gtx layout.Context, i int) layout.Dimensions { + return material.List(u.theme, &u.recentRemoteListState).Layout(gtx, len(u.recentRemotes), func(gtx layout.Context, i int) layout.Dimensions { record := u.recentRemotes[i] label := friendlyRecentRemoteLabel(record) selected := strings.TrimSpace(u.remoteBaseURL.Text()) == record.BaseURL && strings.TrimSpace(u.remotePath.Text()) == record.Path