Compare commits
5 Commits
cb6fbd05a3
...
a867ac4664
| Author | SHA1 | Date | |
|---|---|---|---|
| a867ac4664 | |||
| 1aab5367a8 | |||
| 5d435f1f1f | |||
| 7868a77c8a | |||
| 43ef58936b |
@@ -95,6 +95,17 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
- Phone should optimize for low tap count, not purity of mobile patterns.
|
- Phone should optimize for low tap count, not purity of mobile patterns.
|
||||||
- The stacked phone layout is the current preferred phone direction.
|
- The stacked phone layout is the current preferred phone direction.
|
||||||
- Do not reintroduce the abandoned phone flow mode unless explicitly requested.
|
- Do not reintroduce the abandoned phone flow mode unless explicitly requested.
|
||||||
|
- Keep the product feeling like the same application on desktop, Android phone,
|
||||||
|
and Android tablet.
|
||||||
|
- Platform adaptation is allowed for layout and spacing, not for changing the
|
||||||
|
user's mental model of the workflow.
|
||||||
|
- Use the same action names, the same primary next steps, and the same workflow
|
||||||
|
order across platforms unless there is a hard platform constraint.
|
||||||
|
- Treat workflow prominence and reachability as product behavior, not visual
|
||||||
|
polish. A feature is not parity-complete if it technically exists but is
|
||||||
|
harder to discover or reach on one platform.
|
||||||
|
- Prefer shared workflow decisions with platform-specific presentation, rather
|
||||||
|
than platform-specific workflow branches.
|
||||||
- Make all test strings `Heist Movie` themed. Use characters, crews, casinos,
|
- Make all test strings `Heist Movie` themed. Use characters, crews, casinos,
|
||||||
vaults, and locations from heist movies so test fixtures stay obviously fake
|
vaults, and locations from heist movies so test fixtures stay obviously fake
|
||||||
and consistent with the product theme.
|
and consistent with the product theme.
|
||||||
@@ -117,6 +128,14 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
implement the minimum code to satisfy them,
|
implement the minimum code to satisfy them,
|
||||||
verify with `go test ./...` and relevant lint checks,
|
verify with `go test ./...` and relevant lint checks,
|
||||||
and commit each completed behavior.
|
and commit each completed behavior.
|
||||||
|
- For cross-platform UI work, behavior tests must cover workflow parity, not
|
||||||
|
just feature or label parity.
|
||||||
|
- For lifecycle, open, unlock, sync, and other primary flows, tests should
|
||||||
|
assert the same conceptual next step across desktop, phone, and tablet
|
||||||
|
layouts.
|
||||||
|
- When Android or phone UX is part of the slice, verify real reachability on an
|
||||||
|
emulator or device for the exact flow being changed. Do not count “the same
|
||||||
|
buttons exist somewhere on screen” as sufficient validation.
|
||||||
- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo.
|
- Only stop before the requirements are satisfied if the work is genuinely blocked by a missing decision, missing external dependency, or a hard technical constraint that cannot be resolved within the repo.
|
||||||
- If blocked, state the blocker concretely and stop only at that point.
|
- If blocked, state the blocker concretely and stop only at that point.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,36 @@ The product is not complete until the global exit criteria at the end of this fi
|
|||||||
These items came from a hands-on emulator and desktop walkthrough.
|
These items came from a hands-on emulator and desktop walkthrough.
|
||||||
They should be treated as usability work, not just polish.
|
They should be treated as usability work, not just polish.
|
||||||
|
|
||||||
|
### Cross-Platform Workflow Parity
|
||||||
|
|
||||||
|
These items are required to keep desktop, Android phone, and Android tablet
|
||||||
|
feeling like the same application rather than three related UIs.
|
||||||
|
|
||||||
|
- Workflow parity:
|
||||||
|
define canonical workflows for open, unlock, set up remote sync, use remote
|
||||||
|
sync, browse entries, and edit entries.
|
||||||
|
- Workflow parity:
|
||||||
|
ensure desktop, phone, and tablet use the same action names and the same
|
||||||
|
primary next steps for those workflows.
|
||||||
|
- Workflow parity:
|
||||||
|
remove or reduce platform-specific workflow exceptions where the same user
|
||||||
|
intent currently takes a different route on different form factors.
|
||||||
|
- Testing:
|
||||||
|
add cross-mode behavior tests that assert workflow order and action
|
||||||
|
prominence, not just label presence.
|
||||||
|
- Testing:
|
||||||
|
add explicit lifecycle/open-screen tests for reachability of the primary
|
||||||
|
action on desktop, phone, and tablet layouts.
|
||||||
|
- Testing:
|
||||||
|
add explicit remote-sync workflow tests that prove setup, settings, use, and
|
||||||
|
removal are reachable from the same primary affordance family across modes.
|
||||||
|
- Android verification:
|
||||||
|
validate changed lifecycle/open/sync workflows on the emulator or a device,
|
||||||
|
including with the on-screen keyboard visible.
|
||||||
|
- Android verification:
|
||||||
|
treat “present but below the fold or behind an unexpected branch” as a parity
|
||||||
|
failure, not as acceptable platform variation.
|
||||||
|
|
||||||
### Primary Workflow Changes
|
### Primary Workflow Changes
|
||||||
|
|
||||||
These should remain in the main user flow rather than being hidden behind a settings gear.
|
These should remain in the main user flow rather than being hidden behind a settings gear.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -281,6 +282,7 @@ type ui struct {
|
|||||||
detailList widget.List
|
detailList widget.List
|
||||||
apiPolicyList widget.List
|
apiPolicyList widget.List
|
||||||
lifecycleList widget.List
|
lifecycleList widget.List
|
||||||
|
syncDialogList widget.List
|
||||||
phonePanelList widget.List
|
phonePanelList widget.List
|
||||||
securityDialogList widget.List
|
securityDialogList widget.List
|
||||||
remotePrefsDialogList widget.List
|
remotePrefsDialogList widget.List
|
||||||
@@ -628,6 +630,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
lifecycleList: widget.List{
|
lifecycleList: widget.List{
|
||||||
List: layout.List{Axis: layout.Vertical},
|
List: layout.List{Axis: layout.Vertical},
|
||||||
},
|
},
|
||||||
|
syncDialogList: widget.List{
|
||||||
|
List: layout.List{Axis: layout.Vertical},
|
||||||
|
},
|
||||||
phonePanelList: widget.List{
|
phonePanelList: widget.List{
|
||||||
List: layout.List{Axis: layout.Vertical},
|
List: layout.List{Axis: layout.Vertical},
|
||||||
},
|
},
|
||||||
@@ -1421,6 +1426,7 @@ func (u *ui) openAdvancedSyncDialog() {
|
|||||||
u.syncDialogOpen = true
|
u.syncDialogOpen = true
|
||||||
u.syncMenuOpen = false
|
u.syncMenuOpen = false
|
||||||
u.showSyncPassword = false
|
u.showSyncPassword = false
|
||||||
|
u.syncDialogList.Position = layout.Position{}
|
||||||
u.syncDialogPurpose = syncDialogPurposeAdvanced
|
u.syncDialogPurpose = syncDialogPurposeAdvanced
|
||||||
u.syncSourceMode = u.syncDefaultSourceMode
|
u.syncSourceMode = u.syncDefaultSourceMode
|
||||||
u.syncDirection = u.syncDefaultDirection
|
u.syncDirection = u.syncDefaultDirection
|
||||||
@@ -1435,6 +1441,7 @@ func (u *ui) openRemoteSyncSetupDialog() {
|
|||||||
u.syncDialogOpen = true
|
u.syncDialogOpen = true
|
||||||
u.syncMenuOpen = false
|
u.syncMenuOpen = false
|
||||||
u.showSyncPassword = false
|
u.showSyncPassword = false
|
||||||
|
u.syncDialogList.Position = layout.Position{}
|
||||||
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
|
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
|
||||||
u.syncSourceMode = syncSourceRemote
|
u.syncSourceMode = syncSourceRemote
|
||||||
u.syncDirection = syncDirectionPush
|
u.syncDirection = syncDirectionPush
|
||||||
@@ -2319,6 +2326,34 @@ func normalizeRemoteCredentialURL(raw string) string {
|
|||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func remoteCredentialURLMatches(candidate, target string) bool {
|
||||||
|
candidate = normalizeRemoteCredentialURL(candidate)
|
||||||
|
target = normalizeRemoteCredentialURL(target)
|
||||||
|
if candidate == "" || target == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if candidate == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
candidateURL, err := url.Parse(candidate)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
targetURL, err := url.Parse(target)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(candidateURL.Hostname(), targetURL.Hostname()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
candidatePath := strings.TrimRight(candidateURL.EscapedPath(), "/")
|
||||||
|
targetPath := strings.TrimRight(targetURL.EscapedPath(), "/")
|
||||||
|
if candidatePath == "" || candidatePath == "/" || targetPath == "" || targetPath == "/" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(targetPath, candidatePath) || strings.HasPrefix(candidatePath, targetPath)
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry {
|
func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry {
|
||||||
if sanitizeSyncSourceMode(u.syncSourceMode) != syncSourceRemote {
|
if sanitizeSyncSourceMode(u.syncSourceMode) != syncSourceRemote {
|
||||||
return nil
|
return nil
|
||||||
@@ -2346,7 +2381,7 @@ func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry {
|
|||||||
matches = append(matches, entry)
|
matches = append(matches, entry)
|
||||||
}
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if normalizeRemoteCredentialURL(entry.URL) != baseURL {
|
if !remoteCredentialURLMatches(entry.URL, baseURL) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
appendMatch(entry)
|
appendMatch(entry)
|
||||||
@@ -2364,7 +2399,7 @@ func (u *ui) matchingAdvancedSyncRemoteCredentialEntries() []vault.Entry {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if normalizeRemoteCredentialURL(profile.BaseURL) != baseURL {
|
if !remoteCredentialURLMatches(profile.BaseURL, baseURL) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if remotePath != "" && strings.TrimSpace(profile.Path) != remotePath && strings.TrimSpace(record.Path) != remotePath {
|
if remotePath != "" && strings.TrimSpace(profile.Path) != remotePath && strings.TrimSpace(record.Path) != remotePath {
|
||||||
@@ -2729,6 +2764,23 @@ func (u *ui) remoteSyncSetupShortcutLabel() string {
|
|||||||
return "Set Up Remote Sync"
|
return "Set Up Remote Sync"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) syncMenuActionLabels() []string {
|
||||||
|
labels := []string{"Open Advanced Sync"}
|
||||||
|
if u.shouldShowRemoteSyncSetupShortcut() {
|
||||||
|
labels = append(labels, u.remoteSyncSetupShortcutLabel())
|
||||||
|
}
|
||||||
|
if u.shouldShowDirectRemoteSyncShortcut() {
|
||||||
|
labels = append(labels, u.directRemoteSyncShortcutLabel())
|
||||||
|
}
|
||||||
|
if u.shouldShowRemoteSyncSettingsShortcut() {
|
||||||
|
labels = append(labels, u.remoteSyncSettingsShortcutLabel())
|
||||||
|
}
|
||||||
|
if u.shouldShowRemoveRemoteSyncShortcut() {
|
||||||
|
labels = append(labels, u.removeRemoteSyncShortcutLabel())
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
func remoteBindingSuffix(baseURL, path, username string) string {
|
func remoteBindingSuffix(baseURL, path, username string) string {
|
||||||
sum := sha256.Sum256([]byte(strings.TrimSpace(baseURL) + "\n" + strings.TrimSpace(path) + "\n" + strings.TrimSpace(username)))
|
sum := sha256.Sum256([]byte(strings.TrimSpace(baseURL) + "\n" + strings.TrimSpace(path) + "\n" + strings.TrimSpace(username)))
|
||||||
return hex.EncodeToString(sum[:8])
|
return hex.EncodeToString(sum[:8])
|
||||||
@@ -5152,7 +5204,7 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
|
|||||||
if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) {
|
if len(u.syncRemoteCredentialClicks) < len(matchingCredentials) {
|
||||||
u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials))
|
u.syncRemoteCredentialClicks = make([]widget.Clickable, len(matchingCredentials))
|
||||||
}
|
}
|
||||||
return material.List(u.theme, &u.lifecycleList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
return material.List(u.theme, &u.syncDialogList).Layout(gtx, 1, func(gtx layout.Context, _ int) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(20), u.syncDialogTitle())
|
lbl := material.Label(u.theme, unit.Sp(20), u.syncDialogTitle())
|
||||||
@@ -5628,6 +5680,38 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
|
|||||||
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
|
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Open Advanced Sync")
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
if u.shouldShowRemoteSyncSetupShortcut() {
|
||||||
|
rows = append(rows,
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if u.shouldShowDirectRemoteSyncShortcut() {
|
||||||
|
rows = append(rows,
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if u.shouldShowRemoteSyncSettingsShortcut() {
|
||||||
|
rows = append(rows,
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if u.shouldShowRemoveRemoteSyncShortcut() {
|
||||||
|
rows = append(rows,
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
if u.hasOpenVault() && len(profiles) > 0 && len(credentials) > 0 {
|
if u.hasOpenVault() && len(profiles) > 0 && len(credentials) > 0 {
|
||||||
rows = append(rows,
|
rows = append(rows,
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout),
|
||||||
@@ -6986,44 +7070,8 @@ func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
|
|||||||
return children
|
return children
|
||||||
}()...)
|
}()...)
|
||||||
}
|
}
|
||||||
if !u.shouldShowDirectRemoteSyncShortcut() && !u.shouldShowRemoteSyncSetupShortcut() && !u.shouldShowRemoteSyncSettingsShortcut() && !u.shouldShowRemoveRemoteSyncShortcut() {
|
|
||||||
return crumbBar(gtx)
|
return crumbBar(gtx)
|
||||||
}
|
}
|
||||||
children := []layout.FlexChild{
|
|
||||||
layout.Rigid(crumbBar),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
}
|
|
||||||
if u.shouldShowDirectRemoteSyncShortcut() {
|
|
||||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openSelectedVaultRemote, u.directRemoteSyncShortcutLabel())
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
if u.shouldShowRemoteSyncSetupShortcut() {
|
|
||||||
if u.shouldShowDirectRemoteSyncShortcut() {
|
|
||||||
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
}
|
|
||||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSetupShortcutLabel())
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
if u.shouldShowRemoteSyncSettingsShortcut() {
|
|
||||||
if u.shouldShowDirectRemoteSyncShortcut() || u.shouldShowRemoteSyncSetupShortcut() {
|
|
||||||
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
}
|
|
||||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.useSavedAdvancedSyncRemote, u.remoteSyncSettingsShortcutLabel())
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
if u.shouldShowRemoveRemoteSyncShortcut() {
|
|
||||||
if u.shouldShowDirectRemoteSyncShortcut() || u.shouldShowRemoteSyncSetupShortcut() || u.shouldShowRemoteSyncSettingsShortcut() {
|
|
||||||
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
|
||||||
}
|
|
||||||
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.removeSelectedRemoteBinding, u.removeRemoteSyncShortcutLabel())
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) {
|
func (u *ui) visibleBreadcrumbs(displayPath []string) ([]string, []int) {
|
||||||
if u.state.Section == appstate.SectionTemplates {
|
if u.state.Section == appstate.SectionTemplates {
|
||||||
|
|||||||
+132
-1
@@ -746,6 +746,20 @@ func TestUILifecycleControlsWithSelectedRecentVaultDoesNotPanic(t *testing.T) {
|
|||||||
_ = u.lifecycleControls(gtx)
|
_ = u.lifecycleControls(gtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIShouldPrioritizeLifecyclePrimaryActionsOnPhone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
phone := newUIWithSession("phone", &session.Manager{})
|
||||||
|
if !phone.shouldPrioritizeLifecyclePrimaryActions() {
|
||||||
|
t.Fatal("phone.shouldPrioritizeLifecyclePrimaryActions() = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
desktop := newUIWithSession("desktop", &session.Manager{})
|
||||||
|
if desktop.shouldPrioritizeLifecyclePrimaryActions() {
|
||||||
|
t.Fatal("desktop.shouldPrioritizeLifecyclePrimaryActions() = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIRecentVaultListWithSelectedRecentVaultDoesNotPanic(t *testing.T) {
|
func TestUIRecentVaultListWithSelectedRecentVaultDoesNotPanic(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -5953,6 +5967,55 @@ func TestUIRemoteSetupShortcutHasParityAcrossModes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUISyncMenuActionLabelsIncludeRemoteSetupForUnboundVault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "entry-1",
|
||||||
|
Title: "Mint Console",
|
||||||
|
Path: []string{"Crew", "Signals"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
u.state.Section = appstate.SectionEntries
|
||||||
|
|
||||||
|
got := u.syncMenuActionLabels()
|
||||||
|
if !slices.Contains(got, "Set Up Remote Sync") {
|
||||||
|
t.Fatalf("syncMenuActionLabels() = %v, want Set Up Remote Sync", got)
|
||||||
|
}
|
||||||
|
if slices.Contains(got, "Use Remote Sync") {
|
||||||
|
t.Fatalf("syncMenuActionLabels() = %v, want no Use Remote Sync without saved binding", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUISyncMenuActionLabelsIncludeSavedRemoteActionsForBoundVault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "remote-creds-1",
|
||||||
|
Title: "Bellagio WebDAV Sign-In",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Path: []string{"Crew", "Signals"},
|
||||||
|
}},
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{{
|
||||||
|
ID: "bellagio-webdav",
|
||||||
|
Name: "Bellagio Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/bellagio/keepass.kdbx",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
u.state.Section = appstate.SectionEntries
|
||||||
|
|
||||||
|
got := u.syncMenuActionLabels()
|
||||||
|
for _, want := range []string{"Use Remote Sync", "Remote Sync Settings", "Stop Using Remote Sync"} {
|
||||||
|
if !slices.Contains(got, want) {
|
||||||
|
t.Fatalf("syncMenuActionLabels() = %v, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIShouldHideRemoteSyncSetupShortcutWhenSavedBindingExists(t *testing.T) {
|
func TestUIShouldHideRemoteSyncSetupShortcutWhenSavedBindingExists(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -5991,7 +6054,13 @@ func TestUIRemoteSyncSetupShortcutLabelUsesClearLanguage(t *testing.T) {
|
|||||||
func TestUILifecycleRemoteSyncActionLabelUsesSetupLanguageWithoutSavedBinding(t *testing.T) {
|
func TestUILifecycleRemoteSyncActionLabelUsesSetupLanguageWithoutSavedBinding(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
u := newUIWithSession("desktop", &session.Manager{})
|
dir := t.TempDir()
|
||||||
|
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
||||||
|
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
|
||||||
|
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
|
||||||
|
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
|
||||||
|
})
|
||||||
u.vaultPath.SetText("/vaults/bellagio.kdbx")
|
u.vaultPath.SetText("/vaults/bellagio.kdbx")
|
||||||
|
|
||||||
if !u.shouldShowLifecycleRemoteSyncAction() {
|
if !u.shouldShowLifecycleRemoteSyncAction() {
|
||||||
@@ -6732,6 +6801,68 @@ func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesBaseURL(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesMatchingHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "entry-1", Title: "Mint WebDAV", Username: "charliecroker", URL: "https://dav.example.invalid", Path: []string{"Crew", "Signals"}},
|
||||||
|
{ID: "entry-2", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", URL: "https://dav.example.invalid/remote.php/dav", Path: []string{"Crew", "Signals"}},
|
||||||
|
{ID: "entry-3", Title: "Bank Console", Username: "stevefrezelli", URL: "https://insidejob.example.invalid", Path: []string{"Crew", "Signals"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
u.syncSourceMode = syncSourceRemote
|
||||||
|
u.syncRemoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav")
|
||||||
|
|
||||||
|
got := u.matchingAdvancedSyncRemoteCredentialEntries()
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(matchingAdvancedSyncRemoteCredentialEntries()) = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
gotIDs := []string{got[0].ID, got[1].ID}
|
||||||
|
slices.Sort(gotIDs)
|
||||||
|
if !slices.Equal(gotIDs, []string{"entry-1", "entry-2"}) {
|
||||||
|
t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() ids = %v, want [entry-1 entry-2]", gotIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIRemoteSyncSetupMatchingCredentialsUsesMatchingHost(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "entry-1", Title: "Mint WebDAV", Username: "charliecroker", URL: "https://dav.example.invalid", Path: []string{"Crew", "Signals"}},
|
||||||
|
{ID: "entry-2", Title: "Bellagio WebDAV Sign-In", Username: "linuscaldwell", URL: "https://dav.example.invalid/remote.php/dav", Path: []string{"Crew", "Signals"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
u.syncDialogPurpose = syncDialogPurposeRemoteSetup
|
||||||
|
u.syncSourceMode = syncSourceRemote
|
||||||
|
u.syncDirection = syncDirectionPush
|
||||||
|
u.syncRemoteBaseURL.SetText("https://dav.example.invalid/remote.php/dav")
|
||||||
|
|
||||||
|
got := u.matchingAdvancedSyncRemoteCredentialEntries()
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(matchingAdvancedSyncRemoteCredentialEntries()) = %d, want 2 in remote setup flow", len(got))
|
||||||
|
}
|
||||||
|
gotIDs := []string{got[0].ID, got[1].ID}
|
||||||
|
slices.Sort(gotIDs)
|
||||||
|
if !slices.Equal(gotIDs, []string{"entry-1", "entry-2"}) {
|
||||||
|
t.Fatalf("matchingAdvancedSyncRemoteCredentialEntries() ids = %v, want [entry-1 entry-2] in remote setup flow", gotIDs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIOpenRemoteSyncSetupDialogResetsDialogScrollPosition(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{})
|
||||||
|
u.syncDialogList.Position = layout.Position{First: 2, Offset: 48, BeforeEnd: true}
|
||||||
|
|
||||||
|
u.openRemoteSyncSetupDialog()
|
||||||
|
|
||||||
|
if got := u.syncDialogList.Position; got != (layout.Position{}) {
|
||||||
|
t.Fatalf("syncDialogList.Position = %#v, want zero position after opening setup dialog", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesSavedBindingForCurrentVault(t *testing.T) {
|
func TestUIAdvancedSyncMatchingRemoteCredentialEntriesUsesSavedBindingForCurrentVault(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+101
-91
@@ -22,6 +22,92 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
busy := u.lifecycleBusy()
|
busy := u.lifecycleBusy()
|
||||||
showLocalChooser := u.showLocalVaultChooser()
|
showLocalChooser := u.showLocalVaultChooser()
|
||||||
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
|
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
|
||||||
|
advancedSection := func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if busy {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(u.lifecycleAdvancedDisclosure),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.lifecycleAdvancedHidden {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
if u.lifecycleMode == "remote" {
|
||||||
|
return 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(u.theme, unit.Sp(13), "Vault settings")
|
||||||
|
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(u.theme, unit.Sp(12), u.lifecycleSecuritySettingsSummary())
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Open Vault Settings")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
primaryActionsSection := func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
label := "Open Vault"
|
||||||
|
if busy {
|
||||||
|
label = "Opening Vault..."
|
||||||
|
}
|
||||||
|
if busy {
|
||||||
|
return passiveTonedButton(gtx, u.theme, label)
|
||||||
|
}
|
||||||
|
return tonedButton(gtx, u.theme, &u.openVault, label)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel())
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?")
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if busy {
|
||||||
|
return passiveSectionTab(gtx, u.theme, "Create New Vault", false)
|
||||||
|
}
|
||||||
|
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
selectedVaultSection := func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if busy || selectedLocalPath == "" {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return u.selectedLocalVaultCard(gtx, selectedLocalPath)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT")
|
lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT")
|
||||||
@@ -119,106 +205,30 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
return keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)(gtx)
|
return keyFileSelector(u.theme, &u.keyFilePath, &u.pickKeyFile)(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return u.lifecycleAdvancedDisclosure(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy || u.lifecycleAdvancedHidden {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
if u.lifecycleMode == "remote" {
|
|
||||||
return 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(u.theme, unit.Sp(13), "Vault settings")
|
|
||||||
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(u.theme, unit.Sp(12), u.lifecycleSecuritySettingsSummary())
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Open Vault Settings")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
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 {
|
||||||
|
if u.shouldPrioritizeLifecyclePrimaryActions() {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(primaryActionsSection),
|
||||||
label := "Open Vault"
|
layout.Rigid(selectedVaultSection),
|
||||||
if busy {
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
label = "Opening Vault..."
|
layout.Rigid(advancedSection),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if busy {
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
return passiveTonedButton(gtx, u.theme, label)
|
layout.Rigid(advancedSection),
|
||||||
}
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
return tonedButton(gtx, u.theme, &u.openVault, label)
|
layout.Rigid(primaryActionsSection),
|
||||||
}),
|
layout.Rigid(selectedVaultSection),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy || !u.shouldShowLifecycleRemoteSyncAction() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return tonedButton(gtx, u.theme, &u.lifecycleRemoteSyncAction, u.lifecycleRemoteSyncActionLabel())
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Need a fresh database instead?")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy {
|
|
||||||
return passiveSectionTab(gtx, u.theme, "Create New Vault", false)
|
|
||||||
}
|
|
||||||
return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy || selectedLocalPath == "" {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy || selectedLocalPath == "" {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return u.selectedLocalVaultCard(gtx, selectedLocalPath)
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) shouldPrioritizeLifecyclePrimaryActions() bool {
|
||||||
|
return u.mode == "phone"
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions {
|
func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions {
|
||||||
heading := u.selectedRemoteCardHeading()
|
heading := u.selectedRemoteCardHeading()
|
||||||
primary := u.selectedRemoteCardPrimaryText()
|
primary := u.selectedRemoteCardPrimaryText()
|
||||||
|
|||||||
Reference in New Issue
Block a user