diff --git a/appstate/state.go b/appstate/state.go index a02c693..c57fa47 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -61,6 +61,7 @@ type SynchronizableSession interface { type AdvancedSynchronizableSession interface { CurrentSession SynchronizeFromLocal(string) error + SynchronizeFromLocalBytes(string, []byte) error SynchronizeToLocal(string) error SynchronizeFromRemote(webdav.Client, string) error SynchronizeToRemote(webdav.Client, string) error @@ -722,6 +723,18 @@ func (s *State) SynchronizeFromLocal(path string) error { return nil } +func (s *State) SynchronizeFromLocalBytes(name string, content []byte) error { + session, ok := s.Session.(AdvancedSynchronizableSession) + if !ok { + return fmt.Errorf("session is not advanced-synchronizable") + } + if err := session.SynchronizeFromLocalBytes(name, content); err != nil { + return err + } + s.Dirty = false + return nil +} + func (s *State) SynchronizeToLocal(path string) error { session, ok := s.Session.(AdvancedSynchronizableSession) if !ok { diff --git a/appstate/state_test.go b/appstate/state_test.go index 8bd5cc2..270d5f3 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -1584,6 +1584,26 @@ func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault. return nil } +func (s *lifecycleStubSession) SynchronizeFromLocal(string) error { + return nil +} + +func (s *lifecycleStubSession) SynchronizeFromLocalBytes(string, []byte) error { + return nil +} + +func (s *lifecycleStubSession) SynchronizeToLocal(string) error { + return nil +} + +func (s *lifecycleStubSession) SynchronizeFromRemote(webdav.Client, string) error { + return nil +} + +func (s *lifecycleStubSession) SynchronizeToRemote(webdav.Client, string) error { + return nil +} + func (s *lifecycleStubSession) ChangeMasterKey(key vault.MasterKey) error { s.changedKey = key return nil diff --git a/docs/accessibility.md b/docs/accessibility.md index eea8c9b..76e935d 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -15,7 +15,7 @@ KeePassGO currently targets keyboard-first desktop use on Linux and Windows. - list navigation - search focus - new-entry focus transitions -- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/home/rustyryan/dev/go/src/git.julianfamily.org/keepassgo/ui_accessibility.go). +- Controls that participate in keyboard navigation have intent-revealing accessibility labels through `accessibilityLabel` in [`ui_accessibility.go`](/workspace/keepassgo/ui_accessibility.go). ## Current screen-reader boundary diff --git a/go.mod b/go.mod index 2cc9d4b..a51d457 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26 replace gioui.org/cmd => ./third_party/gioui-cmd require ( - gioui.org v0.8.0 + gioui.org v0.9.0 github.com/atotto/clipboard v0.1.4 github.com/tobischo/gokeepasslib/v3 v3.6.2 golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 @@ -18,6 +18,8 @@ require ( 4d63.com/gochecknoglobals v0.2.2 // indirect gioui.org/cmd v0.8.0 // indirect gioui.org/shader v1.0.8 // indirect + gioui.org/x v0.9.0 // indirect + git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect github.com/4meepo/tagalign v1.4.2 // indirect github.com/Abirdcfly/dupword v0.1.3 // indirect github.com/Antonboom/errname v1.0.0 // indirect @@ -74,6 +76,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/gobwas/glob v0.2.3 // indirect + github.com/godbus/dbus/v5 v5.0.6 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect @@ -192,7 +195,7 @@ require ( go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.24.0 // indirect golang.org/x/crypto v0.48.0 // indirect - golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect golang.org/x/image v0.37.0 // indirect golang.org/x/mod v0.33.0 // indirect diff --git a/go.sum b/go.sum index 50ce7f1..78308eb 100644 --- a/go.sum +++ b/go.sum @@ -39,9 +39,15 @@ eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKw eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA= gioui.org v0.8.0 h1:QV5p5JvsmSmGiIXVYOKn6d9YDliTfjtLlVf5J+BZ9Pg= gioui.org v0.8.0/go.mod h1:vEMmpxMOd/iwJhXvGVIzWEbxMWhnMQ9aByOGQdlQ8rc= +gioui.org v0.9.0 h1:4u7XZwnb5kzQW91Nz/vR0wKD6LdW9CaVF96r3rfy4kc= +gioui.org v0.9.0/go.mod h1:CjNig0wAhLt9WZxOPAusgFD8x8IRvqt26LdDBa3Jvao= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ= gioui.org/shader v1.0.8 h1:6ks0o/A+b0ne7RzEqRZK5f4Gboz2CfG+mVliciy6+qA= gioui.org/shader v1.0.8/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM= +gioui.org/x v0.9.0 h1:JUAP3okDXTEmN5WiDpaHbitVWajXKCXyyI5H8qt7KOQ= +gioui.org/x v0.9.0/go.mod h1:IWhEs8zCwiAUM1sfrdacHvcdUagoaKqcodF/N2D3pss= +git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 h1:bGG/g4ypjrCJoSvFrP5hafr9PPB5aw8SjcOWWila7ZI= +git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo= github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E= github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI= github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE= @@ -224,6 +230,8 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/godbus/dbus/v5 v5.0.6 h1:mkgN1ofwASrYnJ5W6U/BxG15eXXXjirgZc7CLqkcaro= +github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -671,6 +679,8 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs= golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= diff --git a/main.go b/main.go index 7448e19..a7ae279 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "fmt" "image" "image/color" + "io" "os" "os/exec" "path/filepath" @@ -26,6 +27,7 @@ import ( "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" + "gioui.org/x/explorer" "git.julianfamily.org/keepassgo/api" "git.julianfamily.org/keepassgo/apiapproval" "git.julianfamily.org/keepassgo/apiaudit" @@ -200,6 +202,7 @@ const ( type ui struct { mode string theme *material.Theme + fileExplorer *explorer.Explorer logoHorizontal paint.ImageOp splashSquare paint.ImageOp search widget.Editor @@ -409,6 +412,8 @@ type ui struct { lifecycleMode string syncSourceMode syncSourceMode syncDirection syncDirection + syncLocalImportName string + syncLocalImportContent []byte syncLocalPath widget.Editor syncRemoteBaseURL widget.Editor syncRemotePath widget.Editor @@ -1216,6 +1221,19 @@ func (u *ui) openAdvancedSyncDialog() { } } +func (u *ui) clearSyncLocalImport() { + u.syncLocalImportName = "" + u.syncLocalImportContent = nil +} + +func (u *ui) selectedSyncLocalImport() (string, []byte, bool) { + name := strings.TrimSpace(u.syncLocalImportName) + if name == "" || name != strings.TrimSpace(u.syncLocalPath.Text()) || len(u.syncLocalImportContent) == 0 { + return "", nil, false + } + return name, append([]byte(nil), u.syncLocalImportContent...), true +} + func sanitizeSyncSourceMode(mode syncSourceMode) syncSourceMode { switch mode { case syncSourceRemote: @@ -1255,6 +1273,12 @@ func (u *ui) advancedSyncFromAction() error { return err } default: + if name, content, ok := u.selectedSyncLocalImport(); ok { + if err := u.state.SynchronizeFromLocalBytes(name, content); err != nil { + return err + } + break + } path := strings.TrimSpace(u.syncLocalPath.Text()) if path == "" { return errors.New(errVaultPathRequired) @@ -1269,6 +1293,37 @@ func (u *ui) advancedSyncFromAction() error { return nil } +func (u *ui) startChooseSyncLocalSourceAction() { + if runtime.GOOS != "android" || u.fileExplorer == nil { + u.runAction("choose sync path", func() error { + u.clearSyncLocalImport() + return u.chooseExistingFileAction(&u.syncLocalPath) + }) + return + } + u.runBackgroundAction("choose sync file", func() (func() error, error) { + file, err := u.fileExplorer.ChooseFile(".kdbx") + if err != nil { + if errors.Is(err, explorer.ErrUserDecline) { + return func() error { return nil }, nil + } + return nil, err + } + defer file.Close() + content, err := io.ReadAll(file) + if err != nil { + return nil, err + } + label := "Selected Android vault" + return func() error { + u.syncLocalImportName = label + u.syncLocalImportContent = append([]byte(nil), content...) + u.syncLocalPath.SetText(label) + return nil + }, nil + }) +} + func (u *ui) advancedSyncToAction() error { switch u.syncSourceMode { case syncSourceRemote: @@ -1713,6 +1768,14 @@ func (u *ui) latestRecentVault() (string, time.Time) { return "", time.Time{} } +func (u *ui) hasSelectedVaultPath() bool { + return strings.TrimSpace(u.vaultPath.Text()) != "" +} + +func (u *ui) showLocalVaultChooser() bool { + return u.lifecycleMode != "local" || !u.hasSelectedVaultPath() +} + func (u *ui) latestRecentRemote() (recentRemoteRecord, bool, time.Time) { for _, record := range u.recentRemotes { if strings.TrimSpace(record.BaseURL) == "" || strings.TrimSpace(record.Path) == "" { @@ -3060,7 +3123,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { u.runAction("choose key file", func() error { return u.chooseExistingFileAction(&u.keyFilePath) }) } for u.pickSyncLocalPath.Clicked(gtx) { - u.runAction("choose sync path", func() error { return u.chooseExistingFileAction(&u.syncLocalPath) }) + u.startChooseSyncLocalSourceAction() } for i := range u.recentVaultClicks { for u.recentVaultClicks[i].Clicked(gtx) { @@ -5863,6 +5926,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { var ops op.Ops manager := &session.Manager{} ui := newUIWithSession(mode, manager, paths) + ui.fileExplorer = explorer.NewExplorer(w) ui.invalidate = w.Invalidate ui.clipboardWriter = newPlatformClipboardWriter(runtime.GOOS, w.Invalidate) host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty }) @@ -5877,6 +5941,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error { } for { e := w.Event() + ui.fileExplorer.ListenEvents(e) switch e := e.(type) { case app.DestroyEvent: return e.Err diff --git a/main_test.go b/main_test.go index daf30ac..94c7eed 100644 --- a/main_test.go +++ b/main_test.go @@ -135,7 +135,7 @@ func TestUISearchBehaviorIsConsistentAcrossDesktopAndPhoneLayouts(t *testing.T) {ID: "tpl-2", Title: "SSH Login", URL: "ssh://infra.internal", Path: []string{"Templates", "Infra"}}, }, RecycleBin: []vault.Entry{ - {ID: "deleted-1", Title: "Deleted Bellagio", URL: "https://bellagio.example.com", Path: []string{"Root", "Internet"}}, + {ID: "deleted-1", Title: "Deleted Bellagio", URL: "https://bellagio.example.invalid", Path: []string{"Root", "Internet"}}, {ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}}, }, }) @@ -1754,6 +1754,67 @@ func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) { } } +func TestUIAdvancedSynchronizeFromImportedLocalVaultMergesIntoCurrentVault(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + currentPath := filepath.Join(t.TempDir(), "current.kdbx") + + writeKDBXMainTestFile(t, currentPath, vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-current", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-current", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + }, key) + + var other bytes.Buffer + if err := vault.SaveKDBX(&other, vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-other", + Title: "Bellagio", + Username: "rustyryan", + Password: "token-other", + URL: "https://bellagio.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + }, key.Password); err != nil { + t.Fatalf("SaveKDBX(other) error = %v", err) + } + + u := newUIWithSession("desktop", &session.Manager{}) + u.masterPassword.SetText(key.Password) + u.vaultPath.SetText(currentPath) + if err := u.openVaultAction(); err != nil { + t.Fatalf("openVaultAction() error = %v", err) + } + + u.openAdvancedSyncDialog() + u.syncDirection = syncDirectionPull + u.syncSourceMode = syncSourceLocal + u.syncLocalImportName = "Selected Android vault" + u.syncLocalImportContent = other.Bytes() + u.syncLocalPath.SetText("Selected Android vault") + if err := u.advancedSyncAction(); err != nil { + t.Fatalf("advancedSyncAction() error = %v", err) + } + + var reopened session.Manager + if err := reopened.Open(currentPath, key); err != nil { + t.Fatalf("reopen Open(current) error = %v", err) + } + model, err := reopened.Current() + if err != nil { + t.Fatalf("reopened Current() error = %v", err) + } + if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { + t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) + } +} + func TestUIStartOpenVaultActionAppliesResultOnMainThread(t *testing.T) { t.Parallel() @@ -4275,6 +4336,47 @@ func TestSelectingRecentVaultSwitchesToLocalMode(t *testing.T) { } } +func TestRestoreStartupLifecycleTargetSelectsMostRecentLocalVault(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "remote" + u.vaultPath.SetText("") + u.recentVaults = []string{"/tmp/example.kdbx"} + u.recentVaultUsedAt["/tmp/example.kdbx"] = time.Date(2026, time.April, 5, 1, 2, 3, 0, time.UTC) + u.recentRemotes = nil + + u.restoreStartupLifecycleTarget() + + if got := u.lifecycleMode; got != "local" { + t.Fatalf("lifecycleMode after restore = %q, want local", got) + } + if got := u.vaultPath.Text(); got != "/tmp/example.kdbx" { + t.Fatalf("vaultPath after restore = %q, want /tmp/example.kdbx", got) + } +} + +func TestShowLocalVaultChooser(t *testing.T) { + t.Parallel() + + u := newUIWithSession("desktop", &session.Manager{}) + u.lifecycleMode = "local" + u.vaultPath.SetText("") + if got := u.showLocalVaultChooser(); !got { + t.Fatal("showLocalVaultChooser() = false, want true when no local vault is selected") + } + + u.vaultPath.SetText("/tmp/example.kdbx") + if got := u.showLocalVaultChooser(); got { + t.Fatal("showLocalVaultChooser() = true, want false when a local vault is selected") + } + + u.lifecycleMode = "remote" + if got := u.showLocalVaultChooser(); !got { + t.Fatal("showLocalVaultChooser() = false, want true outside local lifecycle mode") + } +} + func TestSelectingRecentRemoteSwitchesToRemoteMode(t *testing.T) { t.Parallel() diff --git a/session/session.go b/session/session.go index 00fd383..8711559 100644 --- a/session/session.go +++ b/session/session.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "io" "os" "path/filepath" "reflect" @@ -177,6 +178,18 @@ func (m *Manager) SynchronizeFromLocal(path string) error { return m.persistMergedToCurrentSource(merged) } +func (m *Manager) SynchronizeFromLocalBytes(name string, content []byte) error { + other, _, err := loadLocalSourceBytes(name, content, m.key) + if err != nil { + return err + } + merged, err := m.mergedWithPeer(other) + if err != nil { + return err + } + return m.persistMergedToCurrentSource(merged) +} + func (m *Manager) SynchronizeToLocal(path string) error { other, config, err := loadLocalSourceOrEmpty(path, m.key) if err != nil { @@ -908,6 +921,17 @@ func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBX return model, config, nil } +func loadLocalSourceBytes(name string, content []byte, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) { + if len(content) == 0 { + return vault.Model{}, nil, fmt.Errorf("open %s for synchronize: %w", name, io.EOF) + } + model, config, err := vault.LoadKDBXWithConfig(bytes.NewReader(content), key) + if err != nil { + return vault.Model{}, nil, fmt.Errorf("decode %s for synchronize: %w", name, err) + } + return model, config, nil +} + func loadLocalSourceOrEmpty(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) { model, config, err := loadLocalSource(path, key) if err == nil { diff --git a/session/session_test.go b/session/session_test.go index ce8be44..bfda523 100644 --- a/session/session_test.go +++ b/session/session_test.go @@ -953,6 +953,63 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) { } } +func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T) { + t.Parallel() + + key := vault.MasterKey{Password: "correct horse battery staple"} + currentPath := filepath.Join(t.TempDir(), "current.kdbx") + + currentModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-current", + Title: "Vault Console", + Username: "dannyocean", + Password: "token-current", + URL: "https://vault.crew.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + } + otherModel := vault.Model{ + Entries: []vault.Entry{{ + ID: "entry-other", + Title: "Bellagio", + Username: "rustyryan", + Password: "token-other", + URL: "https://bellagio.example.invalid", + Path: []string{"Root", "Internet"}, + }}, + } + + writeKDBXTestFile(t, currentPath, currentModel, key) + var other bytes.Buffer + if err := vault.SaveKDBX(&other, otherModel, key.Password); err != nil { + t.Fatalf("SaveKDBX(other) error = %v", err) + } + + var sess Manager + if err := sess.Open(currentPath, key); err != nil { + t.Fatalf("Open(current) error = %v", err) + } + + if err := sess.SynchronizeFromLocalBytes("picked-other.kdbx", other.Bytes()); err != nil { + t.Fatalf("SynchronizeFromLocalBytes() error = %v", err) + } + + var reopened Manager + if err := reopened.Open(currentPath, key); err != nil { + t.Fatalf("reopen Open(current) error = %v", err) + } + current, err := reopened.Current() + if err != nil { + t.Fatalf("reopened Current() error = %v", err) + } + + got := current.EntriesInPath([]string{"Root", "Internet"}) + if len(got) != 2 { + t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) + } +} + func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) { t.Parallel() diff --git a/ui_forms.go b/ui_forms.go index b4977bb..a6d0773 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -20,6 +20,7 @@ import ( func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { busy := u.lifecycleBusy() + showLocalChooser := u.showLocalVaultChooser() 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 A VAULT") @@ -161,24 +162,45 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showLocalChooser { + return layout.Dimensions{} + } lbl := material.Label(u.theme, unit.Sp(12), "RECENT VAULTS") lbl.Color = mutedColor return lbl.Layout(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if busy { + if !showLocalChooser { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showLocalChooser || busy { return layout.Dimensions{} } return u.recentVaultList(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showLocalChooser { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showLocalChooser { + return layout.Dimensions{} + } lbl := material.Label(u.theme, unit.Sp(12), "VAULT FILE") lbl.Color = mutedColor return lbl.Layout(gtx) }), - layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + if !showLocalChooser { + return layout.Dimensions{} + } + return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx) + }), layout.Rigid(func(gtx layout.Context) layout.Dimensions { selectedPath := strings.TrimSpace(u.vaultPath.Text()) switch { @@ -221,7 +243,7 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { }), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Change...") + return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Open Different Vault") }), ) })