Clarify lifecycle loading and target selection

This commit is contained in:
Joe Julian
2026-04-01 17:00:24 -07:00
parent 7de55d1041
commit a1cbec85da
3 changed files with 305 additions and 18 deletions
+129 -8
View File
@@ -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")
})
}),
)
})
})
}
+83
View File
@@ -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()
+93 -10
View File
@@ -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 == "" {