Refine remote open lifecycle flow

This commit is contained in:
Joe Julian
2026-04-01 17:10:23 -07:00
parent 6eee43c1df
commit fddf864c42
3 changed files with 248 additions and 43 deletions
+52
View File
@@ -1339,6 +1339,28 @@ func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) {
return recentRemoteRecord{}, false, time.Time{} return recentRemoteRecord{}, false, time.Time{}
} }
func (u *ui) currentRemoteRecord() recentRemoteRecord {
return recentRemoteRecord{
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Path: strings.TrimSpace(u.remotePath.Text()),
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}
}
func (u *ui) selectedRecentRemoteRecord() (recentRemoteRecord, bool) {
record := u.currentRemoteRecord()
if record.BaseURL == "" || record.Path == "" {
return recentRemoteRecord{}, false
}
for _, existing := range u.recentRemotes {
if existing.BaseURL == record.BaseURL && existing.Path == record.Path {
return existing, true
}
}
return recentRemoteRecord{}, false
}
func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) { func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) {
u.remoteBaseURL.SetText(record.BaseURL) u.remoteBaseURL.SetText(record.BaseURL)
u.remotePath.SetText(record.Path) u.remotePath.SetText(record.Path)
@@ -1348,6 +1370,20 @@ func (u *ui) applyRecentRemoteRecord(record recentRemoteRecord) {
u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != "" u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != ""
} }
func (u *ui) remoteAuthStatusMessage() string {
selected, hasSelected := u.selectedRecentRemoteRecord()
switch {
case !u.rememberRemoteAuth.Value:
return "Only the location will be saved in Recent Connections."
case hasSelected && (strings.TrimSpace(selected.Username) != "" || selected.Password != ""):
return "Saved sign-in will be updated for this connection."
case strings.TrimSpace(u.remoteUsername.Text()) != "" || u.remotePassword.Text() != "":
return "This sign-in will be saved in Recent Connections after a successful open."
default:
return "Enter a username or password to save sign-in details for this connection."
}
}
func (u *ui) noteCurrentRemotePath() { func (u *ui) noteCurrentRemotePath() {
status, ok := u.state.Session.(sessionStatus) status, ok := u.state.Session.(sessionStatus)
if !ok || !status.IsRemote() || status.IsLocked() { if !ok || !status.IsRemote() || status.IsLocked() {
@@ -1715,6 +1751,21 @@ func (u *ui) describeActionError(label string, err error) string {
return err.Error() return err.Error()
} }
func (u *ui) remoteOpenRetryAvailable() bool {
return u.lifecycleMode == "remote" && strings.HasPrefix(strings.TrimSpace(u.state.ErrorMessage), "open remote vault failed:")
}
func (u *ui) remoteOpenButtonLabel() string {
switch {
case u.lifecycleBusy():
return "Opening Remote Vault..."
case u.remoteOpenRetryAvailable():
return "Retry Remote Vault"
default:
return "Open Remote Vault"
}
}
func (u *ui) bannerSurface() uiBanner { func (u *ui) bannerSurface() uiBanner {
switch { switch {
case strings.TrimSpace(u.loadingMessage) != "": case strings.TrimSpace(u.loadingMessage) != "":
@@ -2197,6 +2248,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
u.remotePath.SetText("") u.remotePath.SetText("")
u.remoteUsername.SetText("") u.remoteUsername.SetText("")
u.remotePassword.SetText("") u.remotePassword.SetText("")
u.rememberRemoteAuth.Value = false
u.state.ErrorMessage = "" u.state.ErrorMessage = ""
u.state.StatusMessage = "" u.state.StatusMessage = ""
} }
+104 -1
View File
@@ -3017,12 +3017,115 @@ func TestUILoadingDetailMessageUsesSelectedRemote(t *testing.T) {
u.loadingMessage = "Open remote vault..." u.loadingMessage = "Open remote vault..."
got := u.loadingDetailMessage() got := u.loadingDetailMessage()
want := "Target: dav.example.com · vaults/home.kdbx (vaults/home.kdbx)" want := "Target: home.kdbx · dav.example.com (vaults/home.kdbx)"
if got != want { if got != want {
t.Fatalf("loadingDetailMessage() = %q, want %q", got, want) t.Fatalf("loadingDetailMessage() = %q, want %q", got, want)
} }
} }
func TestFriendlyRecentRemoteLabelUsesVaultNameBeforeHost(t *testing.T) {
t.Parallel()
got := friendlyRecentRemoteLabel(recentRemoteRecord{
BaseURL: "https://dav.example.com/remote.php/webdav/",
Path: "vaults/family/home.kdbx",
})
want := "home.kdbx · dav.example.com"
if got != want {
t.Fatalf("friendlyRecentRemoteLabel() = %q, want %q", got, want)
}
}
func TestRecentRemoteStoredAuthSummaryDescribesSavedCredentialState(t *testing.T) {
t.Parallel()
tests := []struct {
name string
record recentRemoteRecord
want string
}{
{
name: "location_only",
record: recentRemoteRecord{},
want: "location only",
},
{
name: "username_only",
record: recentRemoteRecord{Username: "alice"},
want: "saved username",
},
{
name: "password_only",
record: recentRemoteRecord{Password: "token-1"},
want: "saved password",
},
{
name: "full_sign_in",
record: recentRemoteRecord{Username: "alice", Password: "token-1"},
want: "saved username and password",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := recentRemoteStoredAuthSummary(tt.record); got != tt.want {
t.Fatalf("recentRemoteStoredAuthSummary(%+v) = %q, want %q", tt.record, got, tt.want)
}
})
}
}
func TestUIRemoteAuthStatusMessageExplainsWhatWillBeRemembered(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.remoteBaseURL.SetText("https://dav.example.com")
u.remotePath.SetText("vaults/home.kdbx")
if got := u.remoteAuthStatusMessage(); got != "Only the location will be saved in Recent Connections." {
t.Fatalf("remoteAuthStatusMessage() = %q, want location-only guidance", got)
}
u.rememberRemoteAuth.Value = true
if got := u.remoteAuthStatusMessage(); got != "Enter a username or password to save sign-in details for this connection." {
t.Fatalf("remoteAuthStatusMessage() = %q, want empty-sign-in guidance", got)
}
u.remoteUsername.SetText("alice")
if got := u.remoteAuthStatusMessage(); got != "This sign-in will be saved in Recent Connections after a successful open." {
t.Fatalf("remoteAuthStatusMessage() = %q, want pending-save guidance", got)
}
u.recentRemotes = []recentRemoteRecord{{
BaseURL: "https://dav.example.com",
Path: "vaults/home.kdbx",
Username: "alice",
Password: "secret-1",
}}
if got := u.remoteAuthStatusMessage(); got != "Saved sign-in will be updated for this connection." {
t.Fatalf("remoteAuthStatusMessage() = %q, want saved-sign-in guidance", got)
}
}
func TestUIRemoteOpenButtonLabelOffersRetryAfterFailure(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{})
u.lifecycleMode = "remote"
if got := u.remoteOpenButtonLabel(); got != "Open Remote Vault" {
t.Fatalf("remoteOpenButtonLabel() = %q, want %q", got, "Open Remote Vault")
}
u.state.ErrorMessage = "open remote vault failed: dial tcp timeout"
if got := u.remoteOpenButtonLabel(); got != "Retry Remote Vault" {
t.Fatalf("remoteOpenButtonLabel() after error = %q, want %q", got, "Retry Remote Vault")
}
}
func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) { func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) {
t.Parallel() t.Parallel()
+92 -42
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"image" "image"
"image/color" "image/color"
"net/url"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -66,28 +67,12 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)), layout.Rigid(labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)), layout.Rigid(labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)),
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), "AUTHENTICATION")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if strings.TrimSpace(u.remoteBaseURL.Text()) == "" || strings.TrimSpace(u.remotePath.Text()) == "" { if strings.TrimSpace(u.remoteBaseURL.Text()) == "" || strings.TrimSpace(u.remotePath.Text()) == "" {
return layout.Dimensions{} return layout.Dimensions{}
} }
record := recentRemoteRecord{ record := u.currentRemoteRecord()
BaseURL: strings.TrimSpace(u.remoteBaseURL.Text()),
Path: strings.TrimSpace(u.remotePath.Text()),
}
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path) lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
return compactCard(gtx, func(gtx layout.Context) 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.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
@@ -103,16 +88,22 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
lbl.Color = accentColor lbl.Color = accentColor
return lbl.Layout(gtx) 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(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), record.BaseURL) lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{
Username: strings.TrimSpace(u.remoteUsername.Text()),
Password: u.remotePassword.Text(),
}))
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
@@ -124,16 +115,17 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) 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(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember username and password")
box.Color = accentColor
return box.Layout(gtx)
}),
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 busy { if busy {
@@ -141,6 +133,31 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
} }
return u.recentRemoteList(gtx) return u.recentRemoteList(gtx)
}), }),
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), "AUTHENTICATION")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
layout.Rigid(labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device")
box.Color = accentColor
return box.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), u.remoteAuthStatusMessage())
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
) )
} }
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
@@ -232,10 +249,7 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
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.lifecycleMode == "remote" { if u.lifecycleMode == "remote" {
label := "Open Remote Vault" label := u.remoteOpenButtonLabel()
if busy {
label = "Opening Remote Vault..."
}
if busy { if busy {
return passiveTonedButton(gtx, u.theme, label) return passiveTonedButton(gtx, u.theme, label)
} }
@@ -396,7 +410,17 @@ func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions {
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), strings.TrimSpace(record.BaseURL)) lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+normalizedRemoteHost(record.BaseURL))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(record))
lbl.Color = mutedColor lbl.Color = mutedColor
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
@@ -469,15 +493,41 @@ func friendlyRecentRemoteLabel(record recentRemoteRecord) string {
if baseURL == "" && path == "" { if baseURL == "" && path == "" {
return "" return ""
} }
host := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://")) host := normalizedRemoteHost(baseURL)
host = strings.TrimSuffix(host, "/") name := friendlyRecentVaultLabel(path)
switch { switch {
case host == "": case name != "" && host != "":
return path return name + " · " + host
case path == "": case name != "":
return name
case host != "":
return host return host
default: default:
return host + " · " + path return path
}
}
func normalizedRemoteHost(baseURL string) string {
baseURL = strings.TrimSpace(baseURL)
if parsed, err := url.Parse(baseURL); err == nil && strings.TrimSpace(parsed.Host) != "" {
return strings.TrimSpace(parsed.Host)
}
host := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://"))
return strings.TrimSuffix(host, "/")
}
func recentRemoteStoredAuthSummary(record recentRemoteRecord) string {
username := strings.TrimSpace(record.Username)
hasPassword := record.Password != ""
switch {
case username != "" && hasPassword:
return "saved username and password"
case username != "":
return "saved username"
case hasPassword:
return "saved password"
default:
return "location only"
} }
} }