diff --git a/main.go b/main.go index 03f07a4..ad14a8e 100644 --- a/main.go +++ b/main.go @@ -61,8 +61,10 @@ const ( ) type uiBanner struct { - Kind bannerKind - Message string + Kind bannerKind + Message string + Detail string + Dismissable bool } type uiSurface struct { @@ -200,6 +202,9 @@ type ui struct { pickVaultPath widget.Clickable pickKeyFile widget.Clickable pickSyncLocalPath widget.Clickable + clearVaultSelection widget.Clickable + clearRemoteSelection widget.Clickable + dismissBanner widget.Clickable addEntry widget.Clickable saveEntry widget.Clickable duplicateEntry widget.Clickable @@ -1688,21 +1693,60 @@ func (u *ui) describeActionError(label string, err error) string { func (u *ui) bannerSurface() uiBanner { switch { case strings.TrimSpace(u.loadingMessage) != "": - return uiBanner{Kind: bannerLoading, Message: strings.TrimSpace(u.loadingMessage)} + return uiBanner{ + Kind: bannerLoading, + Message: strings.TrimSpace(u.loadingMessage), + Detail: u.loadingDetailMessage(), + } case strings.TrimSpace(u.state.ErrorMessage) != "": - return uiBanner{Kind: bannerError, Message: strings.TrimSpace(u.state.ErrorMessage)} + return uiBanner{ + Kind: bannerError, + Message: strings.TrimSpace(u.state.ErrorMessage), + Dismissable: true, + } case strings.TrimSpace(u.state.StatusMessage) != "": if !u.statusExpiresAt.IsZero() && !u.now().Before(u.statusExpiresAt) { u.state.StatusMessage = "" u.statusExpiresAt = time.Time{} return uiBanner{} } - return uiBanner{Kind: bannerStatus, Message: strings.TrimSpace(u.state.StatusMessage)} + return uiBanner{ + Kind: bannerStatus, + Message: strings.TrimSpace(u.state.StatusMessage), + Dismissable: true, + } default: return uiBanner{} } } +func (u *ui) loadingDetailMessage() string { + if !u.shouldShowLifecycleSetup() { + return "" + } + if u.lifecycleMode == "remote" { + baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) + path := strings.TrimSpace(u.remotePath.Text()) + switch { + case baseURL != "" && path != "": + return fmt.Sprintf( + "Target: %s (%s)", + friendlyRecentRemoteLabel(recentRemoteRecord{BaseURL: baseURL, Path: path}), + path, + ) + case baseURL != "": + return "Target: " + baseURL + default: + return "Preparing remote vault access" + } + } + path := strings.TrimSpace(u.vaultPath.Text()) + if path == "" { + return "Preparing local vault access" + } + return "Target: " + path +} + func (u *ui) sessionSurface() uiSurface { if u.state.Session == nil { return uiSurface{} @@ -1739,6 +1783,10 @@ func (u *ui) shouldShowLifecycleSetup() bool { return !u.hasOpenVault() } +func (u *ui) lifecycleBusy() bool { + return u.shouldShowLifecycleSetup() && strings.TrimSpace(u.loadingMessage) != "" +} + func (u *ui) shouldUseLockedSinglePane() bool { return u.isVaultLocked() && !u.shouldShowLifecycleSetup() } @@ -1958,12 +2006,21 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.showAPIAuditSection() } for u.showLocalLifecycle.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } u.lifecycleMode = "local" } for u.showRemoteLifecycle.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } u.lifecycleMode = "remote" } for u.toggleLifecycleAdvanced.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } u.lifecycleAdvancedHidden = !u.lifecycleAdvancedHidden u.saveUIPreferences() } @@ -2059,9 +2116,15 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.loadSelectedEntryIntoEditor() } for u.pickVaultPath.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) }) } for u.pickKeyFile.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) }) } for u.pickSyncLocalPath.Clicked(gtx) { @@ -2069,18 +2132,50 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } for i := range u.recentVaultClicks { for u.recentVaultClicks[i].Clicked(gtx) { + if u.lifecycleBusy() { + continue + } if i < len(u.recentVaults) { + u.lifecycleMode = "local" u.vaultPath.SetText(u.recentVaults[i]) } } } for i := range u.recentRemoteClicks { for u.recentRemoteClicks[i].Clicked(gtx) { + if u.lifecycleBusy() { + continue + } if i < len(u.recentRemotes) { + u.lifecycleMode = "remote" u.applyRecentRemoteRecord(u.recentRemotes[i]) } } } + for u.clearVaultSelection.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } + u.vaultPath.SetText("") + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + } + for u.clearRemoteSelection.Clicked(gtx) { + if u.lifecycleBusy() { + continue + } + u.remoteBaseURL.SetText("") + u.remotePath.SetText("") + u.remoteUsername.SetText("") + u.remotePassword.SetText("") + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + } + for u.dismissBanner.Clicked(gtx) { + u.state.ErrorMessage = "" + u.state.StatusMessage = "" + u.statusExpiresAt = time.Time{} + } for u.addEntry.Clicked(gtx) { u.state.BeginNewEntry() u.loadSelectedEntryIntoEditor() @@ -3304,9 +3399,35 @@ func (u *ui) banner(gtx layout.Context) layout.Dimensions { 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 { - lbl := material.Label(u.theme, unit.Sp(14), banner.Message) - lbl.Color = fg - return lbl.Layout(gtx) + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, 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), banner.Message) + lbl.Color = fg + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if strings.TrimSpace(banner.Detail) == "" { + return layout.Dimensions{} + } + return layout.Inset{Top: unit.Dp(2)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), banner.Detail) + lbl.Color = fg + return lbl.Layout(gtx) + }) + }), + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !banner.Dismissable { + 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") + }) + }), + ) }) }) } diff --git a/main_test.go b/main_test.go index eda1f82..93785ae 100644 --- a/main_test.go +++ b/main_test.go @@ -2905,6 +2905,89 @@ func TestSelectingRecentRemoteConnectionKeepsPasswordMasked(t *testing.T) { } } +func TestSelectingRecentVaultSwitchesToLocalMode(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + u.recentVaults = []string{"/tmp/example.kdbx"} + u.recentVaultClicks = make([]widget.Clickable, 1) + u.recentVaultClicks[0].Click() + + gtx := layout.Context{} + for u.recentVaultClicks[0].Clicked(gtx) { + if 0 < len(u.recentVaults) { + u.lifecycleMode = "local" + u.vaultPath.SetText(u.recentVaults[0]) + } + } + + if got := u.lifecycleMode; got != "local" { + t.Fatalf("lifecycleMode after recent vault click = %q, want local", got) + } + if got := u.vaultPath.Text(); got != "/tmp/example.kdbx" { + t.Fatalf("vaultPath after recent vault click = %q, want /tmp/example.kdbx", got) + } +} + +func TestSelectingRecentRemoteSwitchesToRemoteMode(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "local" + u.recentRemotes = []recentRemoteRecord{{ + BaseURL: "https://dav.example.com", + Path: "vaults/home.kdbx", + }} + u.recentRemoteClicks = make([]widget.Clickable, 1) + u.recentRemoteClicks[0].Click() + + gtx := layout.Context{} + for u.recentRemoteClicks[0].Clicked(gtx) { + if 0 < len(u.recentRemotes) { + u.lifecycleMode = "remote" + u.applyRecentRemoteRecord(u.recentRemotes[0]) + } + } + + if got := u.lifecycleMode; got != "remote" { + t.Fatalf("lifecycleMode after recent remote click = %q, want remote", got) + } + if got := u.remoteBaseURL.Text(); got != "https://dav.example.com" { + t.Fatalf("remoteBaseURL after recent remote click = %q, want https://dav.example.com", got) + } +} + +func TestUILoadingDetailMessageUsesSelectedVault(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.vaultPath.SetText("/home/julian/vaults/main.kdbx") + u.loadingMessage = "Open vault..." + + got := u.loadingDetailMessage() + want := "Target: /home/julian/vaults/main.kdbx" + if got != want { + t.Fatalf("loadingDetailMessage() = %q, want %q", got, want) + } +} + +func TestUILoadingDetailMessageUsesSelectedRemote(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + u.remoteBaseURL.SetText("https://dav.example.com") + u.remotePath.SetText("vaults/home.kdbx") + u.loadingMessage = "Open remote vault..." + + got := u.loadingDetailMessage() + want := "Target: dav.example.com ยท vaults/home.kdbx (vaults/home.kdbx)" + if got != want { + t.Fatalf("loadingDetailMessage() = %q, want %q", got, want) + } +} + func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) { t.Parallel() diff --git a/ui_forms.go b/ui_forms.go index 073221b..eff6e2f 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -17,6 +17,7 @@ import ( ) 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") @@ -27,10 +28,16 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy { + return passiveSectionTab(gtx, u.theme, "Local Vault", u.lifecycleMode == "local") + } return sectionTabButton(gtx, u.theme, &u.showLocalLifecycle, "Local Vault", u.lifecycleMode == "local") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy { + return passiveSectionTab(gtx, u.theme, "Remote Vault", u.lifecycleMode == "remote") + } return sectionTabButton(gtx, u.theme, &u.showRemoteLifecycle, "Remote Vault", u.lifecycleMode == "remote") }), ) @@ -40,7 +47,12 @@ func (u *ui) lifecycleControls(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(selectorEditorHelp(u.theme, "Key File", "Optional path to a KeePass-compatible key file.", &u.keyFilePath, &u.pickKeyFile, "Choose File", false)), + 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" { @@ -91,6 +103,13 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { 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.clearRemoteSelection, "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), record.BaseURL) @@ -116,7 +135,12 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { return box.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(u.recentRemoteList), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy { + return layout.Dimensions{} + } + return u.recentRemoteList(gtx) + }), ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -126,7 +150,12 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { return lbl.Layout(gtx) }), layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), - layout.Rigid(selectorEditorHelp(u.theme, "Vault Path", "Choose the existing .kdbx file to open.", &u.vaultPath, &u.pickVaultPath, "Choose File", false)), + 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()) == "" { @@ -146,6 +175,13 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { 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()) @@ -157,14 +193,34 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { }) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(u.recentVaultList), + 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(u.lifecycleAdvancedDisclosure), - layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if u.lifecycleAdvancedHidden { + 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{} } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -176,14 +232,31 @@ 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 { if u.lifecycleMode == "remote" { - return tonedButton(gtx, u.theme, &u.openRemote, "Open Remote Vault") + label := "Open Remote Vault" + if busy { + label = "Opening Remote Vault..." + } + if busy { + return passiveTonedButton(gtx, u.theme, label) + } + return tonedButton(gtx, u.theme, &u.openRemote, label) } return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.openVault, "Open Vault") + 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(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if busy { + return passiveTonedButton(gtx, u.theme, "New Vault") + } return tonedButton(gtx, u.theme, &u.createVault, "New Vault") }), ) @@ -368,6 +441,16 @@ func recentSelectionCard(gtx layout.Context, selected bool, w layout.Widget) lay ) } +func passiveSectionTab(gtx layout.Context, th *material.Theme, label string, active bool) layout.Dimensions { + click := new(widget.Clickable) + return sectionTabButton(gtx, th, click, label, active) +} + +func passiveTonedButton(gtx layout.Context, th *material.Theme, label string) layout.Dimensions { + click := new(widget.Clickable) + return tonedButton(gtx, th, click, label) +} + func friendlyRecentVaultLabel(path string) string { value := strings.TrimSpace(path) if value == "" {