Refine recycle bin and sync UX

This commit is contained in:
Joe Julian
2026-04-01 17:09:51 -07:00
parent a1cbec85da
commit 468b2dc933
2 changed files with 229 additions and 43 deletions
+218 -35
View File
@@ -1704,22 +1704,26 @@ func (u *ui) bannerSurface() uiBanner {
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),
Dismissable: true,
}
default:
return uiBanner{}
}
}
func (u *ui) statusToastSurface() uiBanner {
if strings.TrimSpace(u.state.StatusMessage) == "" {
return uiBanner{}
}
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),
}
}
func (u *ui) loadingDetailMessage() string {
if !u.shouldShowLifecycleSetup() {
return ""
@@ -2360,6 +2364,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
}
return u.approvalDialog(gtx)
}),
layout.Stacked(u.statusToast),
)
}
@@ -2546,41 +2551,49 @@ func (u *ui) syncDialogContent(gtx layout.Context) layout.Dimensions {
return material.List(u.theme, &u.lifecycleList).Layout(gtx, 1, func(gtx layout.Context, _ int) 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(20), "Synchronize...")
lbl := material.Label(u.theme, unit.Sp(20), "Advanced Sync")
lbl.Color = accentColor
return lbl.Layout(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(14), "Choose another source and whether to pull it into the current vault or push the current vault to it.")
lbl := material.Label(u.theme, unit.Sp(14), "Pick direction, choose the other vault, and then run the merge. The quick sync button keeps using the current source.")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(syncDialogSectionLabel(u.theme, "Direction")),
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,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showSyncPull, "Pull From Source")
return syncChoiceButton(gtx, u.theme, &u.showSyncPull, "Pull Into Current Vault", u.syncDirection == syncDirectionPull)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showSyncPush, "Push To Source")
return syncChoiceButton(gtx, u.theme, &u.showSyncPush, "Push Current Vault Out", u.syncDirection == syncDirectionPush)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(syncDialogSectionLabel(u.theme, "Other Source")),
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,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showSyncLocal, "Local File")
return syncChoiceButton(gtx, u.theme, &u.showSyncLocal, "Local File", u.syncSourceMode == syncSourceLocal)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return tonedButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV")
return syncChoiceButton(gtx, u.theme, &u.showSyncRemote, "Remote WebDAV", u.syncSourceMode == syncSourceRemote)
}),
)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return syncDialogSummaryCard(gtx, u.theme, u.syncSourceMode, u.syncDirection)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.syncSourceMode == syncSourceRemote {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
@@ -2760,21 +2773,14 @@ func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
}
func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions {
label := "Synchronize"
label := "Sync"
spacing := unit.Dp(4)
if u.mode == "phone" {
label = "Sync"
spacing = unit.Dp(3)
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.Button(u.theme, &u.synchronizeVault, label)
btn.CornerRadius = unit.Dp(10)
if u.mode == "phone" {
btn.TextSize = unit.Sp(13)
btn.Inset = layout.Inset{Top: 8, Bottom: 8, Left: 12, Right: 12}
}
return btn.Layout(gtx)
return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, label, u.mode == "phone")
}),
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
@@ -2791,8 +2797,8 @@ func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions {
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.toggleSyncMenu, u.chevronDownIcon, "More synchronize actions")
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
btn.Color = accentColor
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
if u.mode == "phone" {
@@ -2806,7 +2812,13 @@ func (u *ui) syncMenu(gtx layout.Context) 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 {
return tonedButton(gtx, u.theme, &u.openAdvancedSync, "Synchronize...")
lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?")
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.openAdvancedSync, "Open Advanced Sync")
}),
)
})
@@ -2829,6 +2841,18 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
return u.navigationHeader(gtx)
}),
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() || u.state.Section != appstate.SectionRecycleBin {
return layout.Dimensions{}
}
return u.recycleBinSectionNotice(gtx)
}),
layout.Rigid(layout.Spacer{Height: func() unit.Dp {
if u.isVaultLocked() || u.state.Section != appstate.SectionRecycleBin {
return 0
}
return spacing
}()}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) {
return layout.Dimensions{}
@@ -3073,7 +3097,11 @@ func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item
}),
)
}
return layout.Background{}.Layout(gtx, fill(panelColor), func(gtx layout.Context) layout.Dimensions {
bg := panelColor
if u.state.Section == appstate.SectionRecycleBin {
bg = color.NRGBA{R: 249, G: 242, B: 236, A: 255}
}
return layout.Background{}.Layout(gtx, fill(bg), func(gtx layout.Context) layout.Dimensions {
return row(gtx)
})
})
@@ -3246,13 +3274,26 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
func(gtx layout.Context) layout.Dimensions {
title := item.Title
if u.state.Section == appstate.SectionRecycleBin {
title = "Deleted: " + title
title = "Recycle Bin Entry"
}
lbl := material.Label(u.theme, titleSize, title)
lbl.Color = accentColor
return lbl.Layout(gtx)
},
layout.Spacer{Height: titlePad}.Layout,
func(gtx layout.Context) layout.Dimensions {
if u.state.Section != appstate.SectionRecycleBin {
lbl := material.Label(u.theme, unit.Sp(16), item.Title)
return lbl.Layout(gtx)
}
return recycleDetailTitle(gtx, u.theme, item.Title)
},
layout.Spacer{Height: func() unit.Dp {
if u.state.Section == appstate.SectionRecycleBin {
return unit.Dp(10)
}
return 0
}()}.Layout,
func(gtx layout.Context) layout.Dimensions {
if u.state.Section != appstate.SectionRecycleBin {
return layout.Dimensions{}
@@ -3432,6 +3473,32 @@ func (u *ui) banner(gtx layout.Context) layout.Dimensions {
})
}
func (u *ui) statusToast(gtx layout.Context) layout.Dimensions {
status := u.statusToastSurface()
if status.Kind == bannerNone {
return layout.Dimensions{}
}
max := gtx.Constraints.Max
gtx.Constraints.Min = max
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, gtx.Constraints.Max.Y)}
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Alignment: layout.End}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Dimensions{}
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return statusToastCard(gtx, u.theme, status.Message)
}),
)
}),
)
})
}
func (u *ui) historyPanel(gtx layout.Context) layout.Dimensions {
history := u.visibleHistory()
u.ensureHistoryClickables()
@@ -3610,9 +3677,7 @@ func (u *ui) historyRow(gtx layout.Context, click *widget.Clickable, index int,
func (u *ui) pathBar(gtx layout.Context) layout.Dimensions {
if u.state.Section == appstate.SectionRecycleBin {
lbl := material.Label(u.theme, unit.Sp(13), "Recycle Bin / Deleted entries")
lbl.Color = mutedColor
return lbl.Layout(gtx)
return recyclePathCard(gtx, u.theme, "Recycle Bin", "Deleted entries stay here until you restore them.")
}
u.syncCurrentPath()
@@ -3885,6 +3950,124 @@ func tonedButton(gtx layout.Context, th *material.Theme, click *widget.Clickable
return btn.Layout(gtx)
}
func syncPrimaryButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, compact bool) layout.Dimensions {
btn := material.Button(th, click, label)
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
btn.Color = accentColor
btn.CornerRadius = unit.Dp(10)
btn.TextSize = unit.Sp(14)
btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 12, Right: 12}
if compact {
btn.TextSize = unit.Sp(13)
btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 10, Right: 10}
}
return btn.Layout(gtx)
}
func syncChoiceButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, active bool) layout.Dimensions {
btn := material.Button(th, click, label)
btn.CornerRadius = unit.Dp(10)
btn.TextSize = unit.Sp(14)
btn.Inset = layout.Inset{Top: 7, Bottom: 7, Left: 11, Right: 11}
if active {
btn.Background = accentColor
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
} else {
btn.Background = color.NRGBA{R: 231, G: 236, B: 232, A: 255}
btn.Color = accentColor
}
return btn.Layout(gtx)
}
func syncDialogSectionLabel(th *material.Theme, text string) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), strings.ToUpper(text))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}
}
func syncDialogSummaryCard(gtx layout.Context, th *material.Theme, source syncSourceMode, direction syncDirection) layout.Dimensions {
sourceLabel := "another local vault file"
if source == syncSourceRemote {
sourceLabel = "another WebDAV-backed vault"
}
action := "Pull changes from"
if direction == syncDirectionPush {
action = "Push the current vault into"
}
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 242, G: 245, B: 240, A: 255}), 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(th, unit.Sp(12), "SYNC PLAN")
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(th, unit.Sp(14), action+" "+sourceLabel+".")
lbl.Color = th.Palette.Fg
return lbl.Layout(gtx)
}),
)
})
})
}
func recyclePathCard(gtx layout.Context, th *material.Theme, title, body string) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, A: 255}), 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(th, unit.Sp(12), strings.ToUpper(title))
lbl.Color = color.NRGBA{R: 144, G: 74, B: 49, A: 255}
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(13), body)
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
)
})
})
}
func (u *ui) recycleBinSectionNotice(gtx layout.Context) layout.Dimensions {
return recyclePathCard(gtx, u.theme, "Recycle Bin", "Deleted entries are separated from normal browsing so you can review or restore them safely.")
}
func recycleDetailTitle(gtx layout.Context, th *material.Theme, title string) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 247, G: 239, B: 231, A: 255}), 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(th, unit.Sp(12), "DELETED ENTRY")
lbl.Color = color.NRGBA{R: 144, G: 74, B: 49, A: 255}
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(3)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(16), title)
return lbl.Layout(gtx)
}),
)
})
})
}
func statusToastCard(gtx layout.Context, th *material.Theme, message string) layout.Dimensions {
return layout.Background{}.Layout(gtx, fill(color.NRGBA{R: 27, G: 58, B: 47, A: 235}), func(gtx layout.Context) layout.Dimensions {
return layout.UniformInset(unit.Dp(12)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(13), message)
lbl.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
return lbl.Layout(gtx)
})
})
}
func sectionTabButton(gtx layout.Context, th *material.Theme, click *widget.Clickable, label string, active bool) layout.Dimensions {
btn := material.Button(th, click, label)
btn.CornerRadius = unit.Dp(10)
+11 -8
View File
@@ -2385,7 +2385,7 @@ func TestUIGeneratedPasswordFlowsIntoCreateEntryForm(t *testing.T) {
}
}
func TestUIBannerSurfacePrefersLoadingThenErrorThenStatus(t *testing.T) {
func TestUIBannerSurfacePrefersLoadingThenError(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
@@ -2402,12 +2402,15 @@ func TestUIBannerSurfacePrefersLoadingThenErrorThenStatus(t *testing.T) {
u.state.ErrorMessage = ""
u.state.StatusMessage = "save complete"
if got := u.bannerSurface(); got.Kind != bannerStatus || got.Message != "save complete" {
t.Fatalf("bannerSurface() with status = %#v, want status banner", got)
if got := u.bannerSurface(); got.Kind != bannerNone {
t.Fatalf("bannerSurface() with status = %#v, want no status banner", got)
}
if got := u.statusToastSurface(); got.Kind != bannerStatus || got.Message != "save complete" {
t.Fatalf("statusToastSurface() with status = %#v, want status toast", got)
}
}
func TestUIStatusBannerExpiresAfterTimeout(t *testing.T) {
func TestUIStatusToastExpiresAfterTimeout(t *testing.T) {
t.Parallel()
now := time.Date(2026, time.March, 29, 12, 0, 0, 0, time.UTC)
@@ -2419,13 +2422,13 @@ func TestUIStatusBannerExpiresAfterTimeout(t *testing.T) {
}
u.statusExpiresAt = now.Add(statusBannerDuration)
if got := u.bannerSurface(); got.Kind != bannerStatus || got.Message != "synchronize vault complete" {
t.Fatalf("bannerSurface() before expiry = %#v, want visible status banner", got)
if got := u.statusToastSurface(); got.Kind != bannerStatus || got.Message != "synchronize vault complete" {
t.Fatalf("statusToastSurface() before expiry = %#v, want visible status toast", got)
}
now = now.Add(statusBannerDuration + time.Millisecond)
if got := u.bannerSurface(); got.Kind != bannerNone {
t.Fatalf("bannerSurface() after expiry = %#v, want no banner", got)
if got := u.statusToastSurface(); got.Kind != bannerNone {
t.Fatalf("statusToastSurface() after expiry = %#v, want no toast", got)
}
if got := u.state.StatusMessage; got != "" {
t.Fatalf("state.StatusMessage after expiry = %q, want empty", got)