Scroll lifecycle screen and restore remote groups
This commit is contained in:
@@ -91,6 +91,7 @@ type recentRemoteRecord struct {
|
|||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
Password string `json:"password,omitempty"`
|
Password string `json:"password,omitempty"`
|
||||||
|
LastGroup []string `json:"lastGroup,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ui struct {
|
type ui struct {
|
||||||
@@ -124,6 +125,7 @@ type ui struct {
|
|||||||
exportAttachmentPath widget.Editor
|
exportAttachmentPath widget.Editor
|
||||||
list widget.List
|
list widget.List
|
||||||
detailList widget.List
|
detailList widget.List
|
||||||
|
lifecycleList widget.List
|
||||||
copyUser widget.Clickable
|
copyUser widget.Clickable
|
||||||
copyPass widget.Clickable
|
copyPass widget.Clickable
|
||||||
copyURL widget.Clickable
|
copyURL widget.Clickable
|
||||||
@@ -283,6 +285,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
detailList: widget.List{
|
detailList: widget.List{
|
||||||
List: layout.List{Axis: layout.Vertical},
|
List: layout.List{Axis: layout.Vertical},
|
||||||
},
|
},
|
||||||
|
lifecycleList: widget.List{
|
||||||
|
List: layout.List{Axis: layout.Vertical},
|
||||||
|
},
|
||||||
state: appstate.State{},
|
state: appstate.State{},
|
||||||
selectedHistoryIndex: -1,
|
selectedHistoryIndex: -1,
|
||||||
lifecycleMode: "local",
|
lifecycleMode: "local",
|
||||||
@@ -583,7 +588,7 @@ func (u *ui) openRemoteAction() error {
|
|||||||
u.rememberRemoteAuth.Value,
|
u.rememberRemoteAuth.Value,
|
||||||
)
|
)
|
||||||
u.resetPasswordPeek()
|
u.resetPasswordPeek()
|
||||||
u.enterHiddenVaultRoot()
|
u.restoreRecentRemoteGroup(strings.TrimSpace(u.remoteBaseURL.Text()), strings.TrimSpace(u.remotePath.Text()))
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
u.filter()
|
u.filter()
|
||||||
return nil
|
return nil
|
||||||
@@ -753,6 +758,7 @@ func (u *ui) loadRecentRemotes() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
|
record.LastGroup = append([]string(nil), record.LastGroup...)
|
||||||
filtered = append(filtered, record)
|
filtered = append(filtered, record)
|
||||||
if len(filtered) == 6 {
|
if len(filtered) == 6 {
|
||||||
break
|
break
|
||||||
@@ -806,8 +812,12 @@ func (u *ui) noteRecentRemote(baseURL, path, username, password string, remember
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
record := recentRemoteRecord{
|
record := recentRemoteRecord{
|
||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
Path: path,
|
Path: path,
|
||||||
|
LastGroup: append([]string(nil), u.currentPath...),
|
||||||
|
}
|
||||||
|
if len(record.LastGroup) == 0 {
|
||||||
|
record.LastGroup = u.recentRemoteGroup(baseURL, path)
|
||||||
}
|
}
|
||||||
if rememberAuth {
|
if rememberAuth {
|
||||||
record.Username = strings.TrimSpace(username)
|
record.Username = strings.TrimSpace(username)
|
||||||
@@ -830,6 +840,37 @@ func (u *ui) noteRecentRemote(baseURL, path, username, password string, remember
|
|||||||
u.saveRecentRemotes()
|
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 {
|
func (u *ui) recentVaultGroup(path string) []string {
|
||||||
if u.recentVaultGroups == nil {
|
if u.recentVaultGroups == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -886,6 +927,29 @@ func (u *ui) restoreRecentVaultGroup(path string) {
|
|||||||
u.enterHiddenVaultRoot()
|
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 {
|
func (u *ui) displayPath() []string {
|
||||||
path := append([]string(nil), u.currentPath...)
|
path := append([]string(nil), u.currentPath...)
|
||||||
root := u.hiddenVaultRoot()
|
root := u.hiddenVaultRoot()
|
||||||
@@ -1175,7 +1239,11 @@ func (u *ui) syncCurrentPath() {
|
|||||||
|
|
||||||
func (u *ui) noteCurrentVaultPath() {
|
func (u *ui) noteCurrentVaultPath() {
|
||||||
status, ok := u.state.Session.(sessionStatus)
|
status, ok := u.state.Session.(sessionStatus)
|
||||||
if !ok || status.IsRemote() || status.IsLocked() {
|
if !ok || status.IsLocked() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if status.IsRemote() {
|
||||||
|
u.noteCurrentRemotePath()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
path := strings.TrimSpace(u.vaultPath.Text())
|
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 {
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
if u.shouldShowLifecycleSetup() {
|
if u.shouldShowLifecycleSetup() {
|
||||||
return layout.Dimensions{}
|
return u.lifecycleScreen(gtx)
|
||||||
}
|
}
|
||||||
if u.shouldUseLockedSinglePane() {
|
if u.shouldUseLockedSinglePane() {
|
||||||
return u.detailPanel(gtx)
|
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 {
|
func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
||||||
if u.mode == "phone" {
|
if u.mode == "phone" {
|
||||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
lbl := material.Label(u.theme, unit.Sp(20), productName)
|
||||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
lbl.Color = accentColor
|
||||||
lbl := material.Label(u.theme, unit.Sp(20), productName)
|
return lbl.Layout(gtx)
|
||||||
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),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
|
layout.Rigid(u.headerActions),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1445,27 +1514,13 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
|||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
lbl := material.Label(u.theme, unit.Sp(24), productName)
|
||||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
lbl.Color = accentColor
|
||||||
lbl := material.Label(u.theme, unit.Sp(24), productName)
|
return lbl.Layout(gtx)
|
||||||
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),
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
|
layout.Rigid(u.headerActions),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1972,8 +1972,11 @@ func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) {
|
|||||||
first := newUIWithSession("desktop", &session.Manager{})
|
first := newUIWithSession("desktop", &session.Manager{})
|
||||||
first.recentRemotesPath = configPath
|
first.recentRemotesPath = configPath
|
||||||
first.recentRemotes = nil
|
first.recentRemotes = nil
|
||||||
|
first.currentPath = []string{"Root", "Internet"}
|
||||||
first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-1", true)
|
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.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)
|
first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-3", true)
|
||||||
|
|
||||||
second := newUIWithSession("desktop", &session.Manager{})
|
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" {
|
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)
|
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 != "" {
|
if got := second.recentRemotes[1]; got.Username != "" || got.Password != "" {
|
||||||
t.Fatalf("recentRemotes[1] = %#v, want credentials omitted when remember disabled", got)
|
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: "Vault Console", 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) {
|
func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
|
||||||
|
|||||||
+3
-1
@@ -41,7 +41,9 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
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(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(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(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember username and password")
|
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember username and password")
|
||||||
|
|||||||
Reference in New Issue
Block a user