From d05830335fa43d93cfccc3519b41f8ce584fc701 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:20:46 -0700 Subject: [PATCH 1/2] Add WebDAV lifecycle UI feedback --- main.go | 29 ++++++++++++++-- main_test.go | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index cb96e96..42307d2 100644 --- a/main.go +++ b/main.go @@ -465,9 +465,9 @@ func (u *ui) describeActionError(label string, err error) string { return "" } if errors.Is(err, webdav.ErrConflict) || strings.Contains(err.Error(), webdav.ErrConflict.Error()) { - return "Save conflict: the remote vault changed. Reopen it and retry the save." + return fmt.Sprintf("%s failed: remote vault changed on the server; reopen the remote vault and retry", label) } - return err.Error() + return fmt.Sprintf("%s failed: %v", label, err) } func (u *ui) bannerSurface() uiBanner { @@ -728,6 +728,8 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(u.lifecycleControls), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(u.feedbackBanner), ) }) } @@ -758,6 +760,8 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), layout.Rigid(u.lifecycleControls), + layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), + layout.Rigid(u.feedbackBanner), ) }) } @@ -1295,6 +1299,27 @@ func detailLine(th *material.Theme, label, value string) layout.Widget { } } +func (u *ui) feedbackBanner(gtx layout.Context) layout.Dimensions { + message := u.statusMessage + tone := color.NRGBA{R: 231, G: 239, B: 235, A: 255} + textColor := accentColor + if u.errorMessage != "" { + message = u.errorMessage + tone = color.NRGBA{R: 248, G: 226, B: 223, A: 255} + textColor = color.NRGBA{R: 140, G: 46, B: 34, A: 255} + } + if message == "" { + return layout.Dimensions{} + } + return layout.Background{}.Layout(gtx, fill(tone), func(gtx layout.Context) layout.Dimensions { + return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(13), message) + lbl.Color = textColor + return lbl.Layout(gtx) + }) + }) +} + func (u *ui) passwordLine(label, value string) layout.Widget { return func(gtx layout.Context) layout.Dimensions { icon := u.eyeIcon diff --git a/main_test.go b/main_test.go index a179da1..aa76ab6 100644 --- a/main_test.go +++ b/main_test.go @@ -478,6 +478,99 @@ func TestUIOpenRemoteAndSaveThroughConfiguredWebDAVTarget(t *testing.T) { } } +func TestUIOpenRemoteReportsTransportFailure(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := server.URL + server.Close() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + u.remoteBaseURL.SetText(url) + u.remotePath.SetText("vaults/main.kdbx") + + u.runAction("open remote vault", u.openRemoteAction) + + if got := u.errorMessage; !strings.Contains(got, "open remote vault failed:") { + t.Fatalf("errorMessage = %q, want open remote vault failure", got) + } + if got := u.statusMessage; got != "" { + t.Fatalf("statusMessage = %q, want empty on remote open failure", got) + } +} + +func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-1", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }, + }, + } + + var putCount int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, model, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + w.Header().Set("ETag", "\"v1\"") + _, _ = w.Write(encoded.Bytes()) + case http.MethodPut: + putCount++ + w.WriteHeader(http.StatusPreconditionFailed) + default: + t.Fatalf("unexpected method %s", r.Method) + } + })) + defer server.Close() + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText("correct horse battery staple") + u.remoteBaseURL.SetText(server.URL) + u.remotePath.SetText("vaults/main.kdbx") + + if err := u.openRemoteAction(); err != nil { + t.Fatalf("openRemoteAction() error = %v", err) + } + if err := u.state.UpsertEntry(vault.Entry{ + ID: "vault-console", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-2", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }); err != nil { + t.Fatalf("UpsertEntry() error = %v", err) + } + + u.runAction("save vault", u.saveAction) + + if got := u.errorMessage; !strings.Contains(got, "remote vault changed on the server") { + t.Fatalf("errorMessage = %q, want visible remote conflict guidance", got) + } + if got := u.statusMessage; got != "" { + t.Fatalf("statusMessage = %q, want empty after remote save conflict", got) + } + if !u.state.Dirty { + t.Fatal("Dirty = false, want true after remote save conflict") + } + if putCount != 1 { + t.Fatalf("remote PUT count = %d, want 1", putCount) + } +} + func TestUIMasterKeyInputSupportsKeyFileAndCompositeKeys(t *testing.T) { t.Parallel() From 17ff374be796d73640d31d0c9ee33a2c75a80f7f Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 13:35:07 -0700 Subject: [PATCH 2/2] Resolve remote lifecycle landing conflicts --- main.go | 7 +++++-- main_test.go | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index 42307d2..76569d4 100644 --- a/main.go +++ b/main.go @@ -465,9 +465,12 @@ func (u *ui) describeActionError(label string, err error) string { return "" } if errors.Is(err, webdav.ErrConflict) || strings.Contains(err.Error(), webdav.ErrConflict.Error()) { - return fmt.Sprintf("%s failed: remote vault changed on the server; reopen the remote vault and retry", label) + return "Save conflict: the remote vault changed. Reopen it and retry the save." } - return fmt.Sprintf("%s failed: %v", label, err) + if label == "open remote vault" { + return fmt.Sprintf("%s failed: %v", label, err) + } + return err.Error() } func (u *ui) bannerSurface() uiBanner { diff --git a/main_test.go b/main_test.go index aa76ab6..9bde448 100644 --- a/main_test.go +++ b/main_test.go @@ -557,8 +557,8 @@ func TestUIRemoteSaveConflictShowsVisibleErrorAndKeepsDirtyState(t *testing.T) { u.runAction("save vault", u.saveAction) - if got := u.errorMessage; !strings.Contains(got, "remote vault changed on the server") { - t.Fatalf("errorMessage = %q, want visible remote conflict guidance", got) + if got := u.errorMessage; got != "Save conflict: the remote vault changed. Reopen it and retry the save." { + t.Fatalf("errorMessage = %q, want normalized save conflict guidance", got) } if got := u.statusMessage; got != "" { t.Fatalf("statusMessage = %q, want empty after remote save conflict", got)