From f47682f3a1f269f60c8a48da71574af348eb618f Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 17:46:48 -0700 Subject: [PATCH] Scroll lifecycle screen and restore remote groups --- main.go | 145 +++++++++++++++++++++++++++++++++++---------------- main_test.go | 67 ++++++++++++++++++++++++ ui_forms.go | 4 +- 3 files changed, 170 insertions(+), 46 deletions(-) diff --git a/main.go b/main.go index 281ad44..a45cada 100644 --- a/main.go +++ b/main.go @@ -91,6 +91,7 @@ type recentRemoteRecord struct { Path string `json:"path"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` + LastGroup []string `json:"lastGroup,omitempty"` } type ui struct { @@ -124,6 +125,7 @@ type ui struct { exportAttachmentPath widget.Editor list widget.List detailList widget.List + lifecycleList widget.List copyUser widget.Clickable copyPass widget.Clickable copyURL widget.Clickable @@ -283,6 +285,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) detailList: widget.List{ List: layout.List{Axis: layout.Vertical}, }, + lifecycleList: widget.List{ + List: layout.List{Axis: layout.Vertical}, + }, state: appstate.State{}, selectedHistoryIndex: -1, lifecycleMode: "local", @@ -583,7 +588,7 @@ func (u *ui) openRemoteAction() error { u.rememberRemoteAuth.Value, ) u.resetPasswordPeek() - u.enterHiddenVaultRoot() + u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text())) u.editingEntry = false u.filter() return nil @@ -753,6 +758,7 @@ func (u *ui) loadRecentRemotes() { continue } seen[key] = true + record.LastGroup = append([]string(nil), record.LastGroup...) filtered = append(filtered, record) if len(filtered) == 6 { break @@ -806,8 +812,12 @@ func (u *ui) noteRecentRemote(baseURL, path, username, password string, remember return } record := recentRemoteRecord{ - BaseURL: baseURL, - Path: path, + BaseURL: baseURL, + Path: path, + LastGroup: append([]string(nil), u.currentPath...), + } + if len(record.LastGroup) == 0 { + record.LastGroup = u.recentRemoteGroup(baseURL, path) } if rememberAuth { record.Username = strings.TrimSpace(username) @@ -830,6 +840,37 @@ func (u *ui) noteRecentRemote(baseURL, path, username, password string, remember u.saveRecentRemotes() } +func (u *ui) recentRemoteGroup(baseURL, path string) []string { + baseURL = strings.TrimSpace(baseURL) + path = strings.TrimSpace(path) + for _, record := range u.recentRemotes { + if record.BaseURL == baseURL && record.Path == path { + return append([]string(nil), record.LastGroup...) + } + } + return nil +} + +func (u *ui) noteCurrentRemotePath() { + status, ok := u.state.Session.(sessionStatus) + if !ok || !status.IsRemote() || status.IsLocked() { + return + } + baseURL := strings.TrimSpace(u.remoteBaseURL.Text()) + path := strings.TrimSpace(u.remotePath.Text()) + if baseURL == "" || path == "" { + return + } + for i := range u.recentRemotes { + if u.recentRemotes[i].BaseURL != baseURL || u.recentRemotes[i].Path != path { + continue + } + u.recentRemotes[i].LastGroup = append([]string(nil), u.currentPath...) + u.saveRecentRemotes() + return + } +} + func (u *ui) recentVaultGroup(path string) []string { if u.recentVaultGroups == nil { return nil @@ -886,6 +927,29 @@ func (u *ui) restoreRecentVaultGroup(path string) { u.enterHiddenVaultRoot() } +func (u *ui) restoreRecentRemoteGroup(baseURL, path string) { + saved := u.recentRemoteGroup(baseURL, path) + if len(saved) == 0 { + u.enterHiddenVaultRoot() + return + } + model, err := u.state.Session.Current() + if err != nil { + u.enterHiddenVaultRoot() + return + } + root := u.hiddenVaultRoot() + if len(saved) == 1 && root != "" && saved[0] == root { + u.setCurrentPath(saved) + return + } + if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { + u.setCurrentPath(saved) + return + } + u.enterHiddenVaultRoot() +} + func (u *ui) displayPath() []string { path := append([]string(nil), u.currentPath...) root := u.hiddenVaultRoot() @@ -1175,7 +1239,11 @@ func (u *ui) syncCurrentPath() { func (u *ui) noteCurrentVaultPath() { status, ok := u.state.Session.(sessionStatus) - if !ok || status.IsRemote() || status.IsLocked() { + if !ok || status.IsLocked() { + return + } + if status.IsRemote() { + u.noteCurrentRemotePath() return } path := strings.TrimSpace(u.vaultPath.Text()) @@ -1372,7 +1440,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { }), layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { if u.shouldShowLifecycleSetup() { - return layout.Dimensions{} + return u.lifecycleScreen(gtx) } if u.shouldUseLockedSinglePane() { return u.detailPanel(gtx) @@ -1414,30 +1482,31 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { }) } +func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions { + panel := card + if u.mode == "phone" { + panel = compactCard + } + return panel(gtx, func(gtx layout.Context) layout.Dimensions { + rows := []layout.Widget{ + u.lifecycleControls, + } + return material.List(u.theme, &u.lifecycleList).Layout(gtx, len(rows), func(gtx layout.Context, i int) layout.Dimensions { + return rows[i](gtx) + }) + }) +} + func (u *ui) header(gtx layout.Context) layout.Dimensions { if u.mode == "phone" { 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 layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(20), productName) - lbl.Color = accentColor - return lbl.Layout(gtx) - }), - layout.Rigid(u.headerActions), - ) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !u.shouldShowLifecycleSetup() { - return layout.Dimensions{} - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - layout.Rigid(u.lifecycleControls), - layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), - ) + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(20), productName) + lbl.Color = accentColor + return lbl.Layout(gtx) }), + layout.Rigid(u.headerActions), ) }) } @@ -1445,27 +1514,13 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions { return layout.Dimensions{} } return card(gtx, func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - lbl := material.Label(u.theme, unit.Sp(24), productName) - lbl.Color = accentColor - return lbl.Layout(gtx) - }), - layout.Rigid(u.headerActions), - ) - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - if !u.shouldShowLifecycleSetup() { - return layout.Dimensions{} - } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - layout.Rigid(u.lifecycleControls), - layout.Rigid(layout.Spacer{Height: unit.Dp(10)}.Layout), - ) + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(24), productName) + lbl.Color = accentColor + return lbl.Layout(gtx) }), + layout.Rigid(u.headerActions), ) }) } diff --git a/main_test.go b/main_test.go index 9afdf1a..3e437d7 100644 --- a/main_test.go +++ b/main_test.go @@ -1972,8 +1972,11 @@ func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) { first := newUIWithSession("desktop", &session.Manager{}) first.recentRemotesPath = configPath first.recentRemotes = nil + first.currentPath = []string{"Root", "Internet"} first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-1", true) + first.currentPath = []string{"Root", "Home"} first.noteRecentRemote("https://dav.example.com", "vaults/team.kdbx", "bob", "secret-2", false) + first.currentPath = []string{"Root", "Finance"} first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-3", true) second := newUIWithSession("desktop", &session.Manager{}) @@ -1987,9 +1990,73 @@ func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) { if got := second.recentRemotes[0]; got.BaseURL != "https://dav.example.com" || got.Path != "vaults/home.kdbx" || got.Username != "alice" || got.Password != "secret-3" { t.Fatalf("recentRemotes[0] = %#v, want updated remembered credentials", got) } + if got := second.recentRemotes[0].LastGroup; !slices.Equal(got, []string{"Root", "Finance"}) { + t.Fatalf("recentRemotes[0].LastGroup = %v, want [Root Finance]", got) + } if got := second.recentRemotes[1]; got.Username != "" || got.Password != "" { t.Fatalf("recentRemotes[1] = %#v, want credentials omitted when remember disabled", got) } + if got := second.recentRemotes[1].LastGroup; !slices.Equal(got, []string{"Root", "Home"}) { + t.Fatalf("recentRemotes[1].LastGroup = %v, want [Root Home]", got) + } +} + +func TestUIOpenRemoteVaultRestoresLastOpenedGroupForThatConnection(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + statePath := filepath.Join(dir, "recent-remotes.json") + masterKey := vault.MasterKey{Password: "correct horse battery staple"} + + var encoded bytes.Buffer + if err := vault.SaveKDBXWithKey(&encoded, vault.Model{ + Entries: []vault.Entry{ + {ID: "entry-1", Title: "Git Server", Path: []string{"Root", "Internet"}}, + }, + }, masterKey); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + _, _ = w.Write(encoded.Bytes()) + default: + t.Fatalf("unexpected method = %s", r.Method) + } + })) + defer server.Close() + + first := newUIWithSession("desktop", &session.Manager{}) + first.recentRemotesPath = statePath + first.recentRemotes = nil + first.lifecycleMode = "remote" + first.masterPassword.SetText("correct horse battery staple") + first.remoteBaseURL.SetText(server.URL) + first.remotePath.SetText("vault.kdbx") + if err := first.openRemoteAction(); err != nil { + t.Fatalf("openRemoteAction() error = %v", err) + } + first.state.NavigateToPath([]string{"Root", "Internet"}) + first.currentPath = []string{"Root", "Internet"} + first.syncedPath = []string{"Root", "Internet"} + first.noteCurrentRemotePath() + + reopened := newUIWithSession("desktop", &session.Manager{}) + reopened.recentRemotesPath = statePath + reopened.recentRemotes = nil + reopened.loadRecentRemotes() + reopened.lifecycleMode = "remote" + reopened.masterPassword.SetText("correct horse battery staple") + reopened.remoteBaseURL.SetText(server.URL) + reopened.remotePath.SetText("vault.kdbx") + if err := reopened.openRemoteAction(); err != nil { + t.Fatalf("openRemoteAction() error = %v", err) + } + + if got := reopened.state.CurrentPath; !slices.Equal(got, []string{"Root", "Internet"}) { + t.Fatalf("state.CurrentPath after reopen = %v, want [Root Internet]", got) + } } func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) { diff --git a/ui_forms.go b/ui_forms.go index 00d144e..a3b18d0 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -41,7 +41,9 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { 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(labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)), + 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 username and password")