diff --git a/main.go b/main.go index 909b9ee..ae2b6f5 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "gioui.org/app" "gioui.org/gesture" + "gioui.org/io/key" "gioui.org/io/pointer" "gioui.org/layout" "gioui.org/op" @@ -277,6 +278,8 @@ type ui struct { allowApproval widget.Clickable denyApproval widget.Clickable cancelApproval widget.Clickable + cancelLifecycleProgress widget.Clickable + retryLifecycleOpen widget.Clickable approvalPermanent widget.Bool rememberRemoteAuth widget.Bool apiPolicyAllow widget.Bool @@ -323,6 +326,7 @@ type ui struct { settingsIcon *widget.Icon clipboardWriter clipboard.Writer loadingMessage string + loadingActionLabel string lifecycleMode string syncSourceMode syncSourceMode syncDirection syncDirection @@ -360,6 +364,10 @@ type ui struct { auditLog *apiaudit.Log grpcAddress string backgroundResults chan backgroundActionResult + backgroundActionSerial int + activeBackgroundAction int + lastLifecycleAction string + requestMasterPassFocus bool invalidate func() } @@ -367,6 +375,7 @@ type backgroundActionResult struct { label string apply func() error err error + id int } var ( @@ -416,13 +425,13 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) remoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, remotePath: widget.Editor{SingleLine: true, Submit: false}, remoteUsername: widget.Editor{SingleLine: true, Submit: false}, - remotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•'}, + remotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•', InputHint: key.HintPassword}, syncLocalPath: widget.Editor{SingleLine: true, Submit: false}, syncRemoteBaseURL: widget.Editor{SingleLine: true, Submit: false}, syncRemotePath: widget.Editor{SingleLine: true, Submit: false}, syncRemoteUsername: widget.Editor{SingleLine: true, Submit: false}, - syncRemotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•'}, - masterPassword: widget.Editor{SingleLine: true, Submit: false}, + syncRemotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•', InputHint: key.HintPassword}, + masterPassword: widget.Editor{SingleLine: true, Submit: false, InputHint: key.HintPassword}, keyFilePath: widget.Editor{SingleLine: true, Submit: false}, apiTokenName: widget.Editor{SingleLine: true, Submit: false}, apiTokenClientName: widget.Editor{SingleLine: true, Submit: false}, @@ -481,6 +490,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) syncDirection: syncDirectionPull, apiPolicyGroupScope: true, backgroundResults: make(chan backgroundActionResult, 8), + requestMasterPassFocus: true, } u.apiPolicyAllow.Value = true u.apiPolicyGroupScopeW.Value = true @@ -791,13 +801,16 @@ func (u *ui) startOpenVaultAction() { u.clearMasterPassword() if err != nil { u.state.ErrorMessage = u.describeActionError("open vault", err) + u.requestMasterPassFocus = true return } path := strings.TrimSpace(u.vaultPath.Text()) if path == "" { u.state.ErrorMessage = u.describeActionError("open vault", errors.New(errVaultPathRequired)) + u.requestMasterPassFocus = true return } + u.lastLifecycleAction = "open vault" u.runBackgroundAction("open vault", func() (func() error, error) { prepared, err := session.PrepareLocalOpen(path, key) if err != nil { @@ -875,6 +888,7 @@ func (u *ui) startOpenRemoteAction() { u.clearMasterPassword() if err != nil { u.state.ErrorMessage = u.describeActionError("open remote vault", err) + u.requestMasterPassFocus = true return } client := webdav.Client{ @@ -883,6 +897,7 @@ func (u *ui) startOpenRemoteAction() { Password: u.remotePassword.Text(), } remotePath := strings.TrimSpace(u.remotePath.Text()) + u.lastLifecycleAction = "open remote vault" u.runBackgroundAction("open remote vault", func() (func() error, error) { prepared, err := session.PrepareRemoteOpen(client, remotePath, key) if err != nil { @@ -912,6 +927,7 @@ func (u *ui) lockAction() error { if err := u.state.Lock(); err != nil { return err } + u.requestMasterPassFocus = true u.currentPath = append([]string(nil), u.state.CurrentPath...) u.resetPasswordPeek() u.editingEntry = false @@ -946,6 +962,7 @@ func (u *ui) startUnlockAction() { u.clearMasterPassword() if err != nil { u.state.ErrorMessage = u.describeActionError("unlock vault", err) + u.requestMasterPassFocus = true return } encoded := append([]byte(nil), manager.EncodedBytes()...) @@ -1667,14 +1684,17 @@ func (u *ui) runAction(label string, action func() error) { return } u.loadingMessage = actionLoadingLabel(label) + u.loadingActionLabel = strings.TrimSpace(label) if err := action(); err != nil { u.loadingMessage = "" + u.loadingActionLabel = "" u.state.ErrorMessage = u.describeActionError(label, err) u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return } u.loadingMessage = "" + u.loadingActionLabel = "" u.syncAutofillCache() u.state.ErrorMessage = "" if suppressStatusMessage(label) { @@ -1690,13 +1710,17 @@ func (u *ui) runBackgroundAction(label string, prepare func() (func() error, err if strings.TrimSpace(u.loadingMessage) != "" { return } + u.backgroundActionSerial++ + actionID := u.backgroundActionSerial + u.activeBackgroundAction = actionID u.loadingMessage = actionLoadingLabel(label) + u.loadingActionLabel = strings.TrimSpace(label) u.state.ErrorMessage = "" u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} go func() { apply, err := prepare() - u.backgroundResults <- backgroundActionResult{label: label, apply: apply, err: err} + u.backgroundResults <- backgroundActionResult{label: label, apply: apply, err: err, id: actionID} if u.invalidate != nil { u.invalidate() } @@ -1704,9 +1728,17 @@ func (u *ui) runBackgroundAction(label string, prepare func() (func() error, err } func (u *ui) applyBackgroundResult(result backgroundActionResult) { + if result.id != 0 && result.id != u.activeBackgroundAction { + return + } + u.activeBackgroundAction = 0 u.loadingMessage = "" + u.loadingActionLabel = "" if result.err != nil { u.state.ErrorMessage = u.describeActionError(result.label, result.err) + if strings.HasPrefix(result.label, "open ") { + u.requestMasterPassFocus = true + } u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return @@ -1714,6 +1746,9 @@ func (u *ui) applyBackgroundResult(result backgroundActionResult) { if result.apply != nil { if err := result.apply(); err != nil { u.state.ErrorMessage = u.describeActionError(result.label, err) + if strings.HasPrefix(result.label, "open ") { + u.requestMasterPassFocus = true + } u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return @@ -1730,6 +1765,40 @@ func (u *ui) applyBackgroundResult(result backgroundActionResult) { u.statusExpiresAt = u.now().Add(statusBannerDuration) } +func (u *ui) cancelLifecycleBusyState() { + if !u.lifecycleBusy() { + return + } + u.activeBackgroundAction = 0 + u.loadingMessage = "" + u.loadingActionLabel = "" + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + u.requestMasterPassFocus = true +} + +func (u *ui) retryLastLifecycleOpen() { + switch strings.TrimSpace(u.lastLifecycleAction) { + case "open vault": + u.startOpenVaultAction() + case "open remote vault": + u.startOpenRemoteAction() + } +} + +func (u *ui) canRetryLifecycleOpen() bool { + if !u.shouldShowLifecycleSetup() || u.lifecycleBusy() || strings.TrimSpace(u.state.ErrorMessage) == "" { + return false + } + switch strings.TrimSpace(u.lastLifecycleAction) { + case "open vault", "open remote vault": + return true + default: + return false + } +} + func (u *ui) processBackgroundActions() { for { select { @@ -1980,6 +2049,29 @@ func isAutofillOperation(operation apitokens.Operation) bool { } } +func (u *ui) bannerActionLabels(banner uiBanner) (primary, secondary string) { + if !u.shouldShowLifecycleSetup() { + if banner.Dismissable { + return "", "Dismiss" + } + return "", "" + } + switch banner.Kind { + case bannerLoading: + if strings.HasPrefix(u.loadingActionLabel, "open ") { + return "Cancel", "" + } + case bannerError: + if u.canRetryLifecycleOpen() { + return "Retry", "Dismiss" + } + if banner.Dismissable { + return "", "Dismiss" + } + } + return "", "" +} + func (u *ui) loadingDetailMessage() string { if !u.shouldShowLifecycleSetup() { return "" @@ -2048,6 +2140,41 @@ func (u *ui) vaultResumeContext(path []string) string { return "Resume in: " + strings.Join(displayPath, " / ") } +func compactPathDirectorySummary(path string) string { + cleaned := filepath.Clean(strings.TrimSpace(path)) + if cleaned == "." || cleaned == "" { + return "" + } + dir := filepath.Dir(cleaned) + if dir == "." || dir == cleaned { + return "" + } + if dir == string(filepath.Separator) { + return dir + } + parts := strings.Split(filepath.ToSlash(dir), "/") + filtered := parts[:0] + for _, part := range parts { + if strings.TrimSpace(part) != "" { + filtered = append(filtered, part) + } + } + parts = filtered + if len(parts) <= 2 { + return filepath.ToSlash(dir) + } + return parts[0] + "/.../" + parts[len(parts)-1] +} + +func (u *ui) requestMasterPasswordFocusIfNeeded(gtx layout.Context) { + if !u.requestMasterPassFocus { + return + } + gtx.Execute(key.FocusCmd{Tag: &u.masterPassword}) + gtx.Execute(op.InvalidateCmd{}) + u.requestMasterPassFocus = false +} + func (u *ui) sessionSurface() uiSurface { if u.state.Session == nil { return uiSurface{} @@ -2326,6 +2453,13 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.unlockVault.Clicked(gtx) { u.startUnlockAction() } + for u.cancelLifecycleProgress.Clicked(gtx) { + u.cancelLifecycleBusyState() + } + for u.retryLifecycleOpen.Clicked(gtx) { + u.state.ErrorMessage = "" + u.retryLastLifecycleOpen() + } for u.showEntries.Clicked(gtx) { u.clearDeleteGroupConfirmation() u.showEntriesSection() @@ -2351,12 +2485,14 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { continue } u.lifecycleMode = "local" + u.requestMasterPassFocus = true } for u.showRemoteLifecycle.Clicked(gtx) { if u.lifecycleBusy() { continue } u.lifecycleMode = "remote" + u.requestMasterPassFocus = true } for u.toggleLifecycleAdvanced.Clicked(gtx) { if u.lifecycleBusy() { @@ -2479,6 +2615,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { if i < len(u.recentVaults) { u.lifecycleMode = "local" u.vaultPath.SetText(u.recentVaults[i]) + u.requestMasterPassFocus = true } } } @@ -2490,6 +2627,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { if i < len(u.recentRemotes) { u.lifecycleMode = "remote" u.applyRecentRemoteRecord(u.recentRemotes[i]) + u.requestMasterPassFocus = true } } } @@ -2500,6 +2638,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.vaultPath.SetText("") u.state.ErrorMessage = "" u.state.StatusMessage = "" + u.requestMasterPassFocus = true } for u.clearRemoteSelection.Clicked(gtx) { if u.lifecycleBusy() { @@ -2512,6 +2651,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.rememberRemoteAuth.Value = false u.state.ErrorMessage = "" u.state.StatusMessage = "" + u.requestMasterPassFocus = true } for u.dismissBanner.Clicked(gtx) { u.state.ErrorMessage = "" @@ -3908,6 +4048,7 @@ func (u *ui) banner(gtx layout.Context) layout.Dimensions { bg = color.NRGBA{R: 248, G: 228, B: 225, A: 255} fg = color.NRGBA{R: 130, G: 36, B: 25, A: 255} } + primaryAction, secondaryAction := u.bannerActionLabels(banner) return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { @@ -3932,11 +4073,34 @@ func (u *ui) banner(gtx layout.Context) layout.Dimensions { ) }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !banner.Dismissable { + if primaryAction == "" && secondaryAction == "" { return layout.Dimensions{} } return layout.Inset{Left: unit.Dp(10)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.dismissBanner, "Dismiss") + return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if primaryAction == "" { + return layout.Dimensions{} + } + click := &u.cancelLifecycleProgress + if primaryAction == "Retry" { + click = &u.retryLifecycleOpen + } + return tonedButton(gtx, u.theme, click, primaryAction) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if primaryAction == "" || secondaryAction == "" { + return layout.Dimensions{} + } + return layout.Spacer{Width: unit.Dp(6)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if secondaryAction == "" { + return layout.Dimensions{} + } + return tonedButton(gtx, u.theme, &u.dismissBanner, secondaryAction) + }), + ) }) }), ) diff --git a/main_test.go b/main_test.go index 6a1cac6..69e2ae0 100644 --- a/main_test.go +++ b/main_test.go @@ -105,6 +105,15 @@ func TestUIFiltersUsingVaultModelPathsAndSearch(t *testing.T) { } } +func TestUIMasterPasswordUsesPasswordInputHint(t *testing.T) { + t.Parallel() + + u := newUIWithSession("phone", &session.Manager{}) + if got := u.masterPassword.InputHint; got != key.HintPassword { + t.Fatalf("masterPassword.InputHint = %v, want %v", got, key.HintPassword) + } +} + func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) { t.Parallel() @@ -268,6 +277,48 @@ func TestUIRunBackgroundActionIgnoresDuplicateWhileLoading(t *testing.T) { } } +func TestUICancelLifecycleBusyStateIgnoresLateResultAndKeepsRetryAvailable(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.vaultPath.SetText("/tmp/example.kdbx") + u.lastLifecycleAction = "open vault" + + started := make(chan struct{}) + release := make(chan struct{}) + u.runBackgroundAction("open vault", func() (func() error, error) { + close(started) + <-release + return func() error { + u.state.StatusMessage = "should not apply" + return nil + }, nil + }) + <-started + + u.cancelLifecycleBusyState() + if got := u.loadingMessage; got != "" { + t.Fatalf("loadingMessage after cancel = %q, want empty", got) + } + if got := u.activeBackgroundAction; got != 0 { + t.Fatalf("activeBackgroundAction after cancel = %d, want 0", got) + } + if !u.requestMasterPassFocus { + t.Fatal("requestMasterPassFocus after cancel = false, want true") + } + + close(release) + result := waitForBackgroundResult(t, u) + u.applyBackgroundResult(result) + + if got := u.state.StatusMessage; got != "" { + t.Fatalf("StatusMessage after stale apply = %q, want empty", got) + } + if got := u.lastLifecycleAction; got != "open vault" { + t.Fatalf("lastLifecycleAction after cancel = %q, want open vault", got) + } +} + func TestUIChildGroupsComeFromVaultModel(t *testing.T) { t.Parallel() @@ -2695,6 +2746,43 @@ func TestUIBannerSurfacePrefersLoadingThenErrorThenStatus(t *testing.T) { } } +func TestUIBannerActionLabelsExposeCancelAndRetryForLifecycleOpen(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.loadingMessage = "Open vault..." + u.loadingActionLabel = "open vault" + + primary, secondary := u.bannerActionLabels(u.bannerSurface()) + if primary != "Cancel" || secondary != "" { + t.Fatalf("bannerActionLabels(loading) = %q/%q, want Cancel/empty", primary, secondary) + } + + u.loadingMessage = "" + u.loadingActionLabel = "" + u.lastLifecycleAction = "open vault" + u.state.ErrorMessage = "open failed" + + primary, secondary = u.bannerActionLabels(u.bannerSurface()) + if primary != "Retry" || secondary != "Dismiss" { + t.Fatalf("bannerActionLabels(error) = %q/%q, want Retry/Dismiss", primary, secondary) + } +} + +func TestCompactPathDirectorySummaryCollapsesLongPaths(t *testing.T) { + t.Parallel() + + got := compactPathDirectorySummary("/home/julian/vaults/family/main.kdbx") + if got != "home/.../family" { + t.Fatalf("compactPathDirectorySummary() = %q, want %q", got, "home/.../family") + } + + short := compactPathDirectorySummary("/tmp/main.kdbx") + if short != "/tmp" { + t.Fatalf("compactPathDirectorySummary(short) = %q, want %q", short, "/tmp") + } +} + func TestUIStatusToastExpiresAfterTimeout(t *testing.T) { t.Parallel() @@ -3304,6 +3392,7 @@ func TestSelectingRecentVaultSwitchesToLocalMode(t *testing.T) { u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "remote" + u.requestMasterPassFocus = false u.recentVaults = []string{"/tmp/example.kdbx"} u.recentVaultClicks = make([]widget.Clickable, 1) u.recentVaultClicks[0].Click() @@ -3313,6 +3402,7 @@ func TestSelectingRecentVaultSwitchesToLocalMode(t *testing.T) { if 0 < len(u.recentVaults) { u.lifecycleMode = "local" u.vaultPath.SetText(u.recentVaults[0]) + u.requestMasterPassFocus = true } } @@ -3322,6 +3412,9 @@ func TestSelectingRecentVaultSwitchesToLocalMode(t *testing.T) { if got := u.vaultPath.Text(); got != "/tmp/example.kdbx" { t.Fatalf("vaultPath after recent vault click = %q, want /tmp/example.kdbx", got) } + if !u.requestMasterPassFocus { + t.Fatal("requestMasterPassFocus after recent vault click = false, want true") + } } func TestSelectingRecentRemoteSwitchesToRemoteMode(t *testing.T) { @@ -3329,6 +3422,7 @@ func TestSelectingRecentRemoteSwitchesToRemoteMode(t *testing.T) { u := newUIWithSession("desktop", &session.Manager{}) u.lifecycleMode = "local" + u.requestMasterPassFocus = false u.recentRemotes = []recentRemoteRecord{{ BaseURL: "https://dav.example.com", Path: "vaults/home.kdbx", @@ -3341,6 +3435,7 @@ func TestSelectingRecentRemoteSwitchesToRemoteMode(t *testing.T) { if 0 < len(u.recentRemotes) { u.lifecycleMode = "remote" u.applyRecentRemoteRecord(u.recentRemotes[0]) + u.requestMasterPassFocus = true } } @@ -3350,6 +3445,9 @@ func TestSelectingRecentRemoteSwitchesToRemoteMode(t *testing.T) { if got := u.remoteBaseURL.Text(); got != "https://dav.example.com" { t.Fatalf("remoteBaseURL after recent remote click = %q, want https://dav.example.com", got) } + if !u.requestMasterPassFocus { + t.Fatal("requestMasterPassFocus after recent remote click = false, want true") + } } func TestUILoadingDetailMessageUsesSelectedVault(t *testing.T) { diff --git a/ui_forms.go b/ui_forms.go index e098357..e9ef73f 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -21,10 +21,20 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { busy := u.lifecycleBusy() return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(12), "OPEN OR CREATE VAULT") + lbl := material.Label(u.theme, unit.Sp(12), "OPEN A VAULT") lbl.Color = mutedColor return lbl.Layout(gtx) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + message := "Choose a recent vault or pick a `.kdbx` file, then unlock it." + if u.lifecycleMode == "remote" { + message = "Connect to a remote vault, then unlock it with the KeePass master key." + } + lbl := material.Label(u.theme, unit.Sp(14), message) + lbl.Color = accentColor + return lbl.Layout(gtx) + }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, @@ -44,17 +54,6 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return u.masterPasswordField(gtx, "Leave blank if this vault is protected by key file only.") - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return labeledEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, false)(gtx) - } - return selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if u.lifecycleMode == "remote" { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -162,62 +161,92 @@ func (u *ui) lifecycleControls(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(12), "VAULT FILE") + lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS") 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 labeledEditorHelp(u.theme, "Vault Path", "Choose the existing .kdbx file to open.", &u.vaultPath, false)(gtx) - } - return selectorEditorHelp(u.theme, "Vault Path", "Choose the existing .kdbx file to open.", &u.vaultPath, &u.pickVaultPath, "Choose File", false)(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if strings.TrimSpace(u.vaultPath.Text()) == "" { - return layout.Dimensions{} - } - return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.UniformInset(unit.Dp(10)).Layout(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(12), "SELECTED VAULT") - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(14), friendlyRecentVaultLabel(u.vaultPath.Text())) - lbl.Color = accentColor - return lbl.Layout(gtx) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { - return layout.Dimensions{} - } - return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Change...") - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), u.vaultPath.Text()) - lbl.Color = mutedColor - return lbl.Layout(gtx) - }), - ) - }) - }) - }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy { return layout.Dimensions{} } return u.recentVaultList(gtx) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + selectedPath := strings.TrimSpace(u.vaultPath.Text()) + switch { + case busy: + return labeledEditorHelp(u.theme, "Vault Path", "Choose the existing .kdbx file to open.", &u.vaultPath, false)(gtx) + case selectedPath == "": + return selectorEditorHelp(u.theme, "Vault Path", "Choose the existing .kdbx file to open.", &u.vaultPath, &u.pickVaultPath, "Choose File", false)(gtx) + default: + lastGroup := u.recentVaultGroup(selectedPath) + return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(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(12), "SELECTED VAULT") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(16), friendlyRecentVaultLabel(selectedPath)) + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + dir := compactPathDirectorySummary(selectedPath) + if dir == "" { + return layout.Dimensions{} + } + lbl := material.Label(u.theme, unit.Sp(11), dir) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if len(lastGroup) == 0 { + return layout.Dimensions{} + } + lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / ")) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Change...") + }), + ) + }) + }) + } + }), ) }), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "UNLOCK") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.masterPasswordField(gtx, "Leave blank if this vault is protected by key file only.") + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy { + return labeledEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, false)(gtx) + } + return selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)(gtx) + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { if busy { return layout.Dimensions{} @@ -255,7 +284,7 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } return tonedButton(gtx, u.theme, &u.openRemote, label) } - return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { label := "Open Vault" if busy { @@ -266,12 +295,18 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } return tonedButton(gtx, u.theme, &u.openVault, label) }), - layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + 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 passiveTonedButton(gtx, u.theme, "New Vault") + return passiveSectionTab(gtx, u.theme, "Create New Vault", false) } - return tonedButton(gtx, u.theme, &u.createVault, "New Vault") + return sectionTabButton(gtx, u.theme, &u.createVault, "Create New Vault", false) }), ) }), @@ -317,7 +352,7 @@ func (u *ui) recentVaultList(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(12), "RECENTLY OPENED") + lbl := material.Label(u.theme, unit.Sp(12), "TAP TO SELECT") lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -344,13 +379,30 @@ func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions { return layout.UniformInset(unit.Dp(10)).Layout(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(14), label) - lbl.Color = accentColor - return lbl.Layout(gtx) + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(15), label) + lbl.Color = accentColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + badge := "Tap to use" + if selected { + badge = "Selected" + } + lbl := material.Label(u.theme, unit.Sp(11), badge) + if selected { + lbl.Color = accentColor + } else { + lbl.Color = mutedColor + } + return lbl.Layout(gtx) + }), + ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(11), path) + lbl := material.Label(u.theme, unit.Sp(11), compactPathDirectorySummary(path)) lbl.Color = mutedColor return lbl.Layout(gtx) }), @@ -1213,7 +1265,9 @@ func (u *ui) masterPasswordField(gtx layout.Context, help string) layout.Dimensi defer func() { u.masterPassword.Mask = restore }() gtx.Constraints.Min.X = gtx.Constraints.Max.X ed := material.Editor(u.theme, &u.masterPassword, "Master Password") - return ed.Layout(gtx) + dims := ed.Layout(gtx) + u.requestMasterPasswordFocusIfNeeded(gtx) + return dims }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions {