diff --git a/main.go b/main.go index a81889a..5d84d85 100644 --- a/main.go +++ b/main.go @@ -78,6 +78,7 @@ type attachmentItem struct { type statePaths struct { DefaultSaveAsPath string RecentVaultsPath string + RecentRemotesPath string } type recentVaultRecord struct { @@ -85,6 +86,13 @@ type recentVaultRecord struct { LastGroup []string `json:"lastGroup,omitempty"` } +type recentRemoteRecord struct { + BaseURL string `json:"baseUrl"` + Path string `json:"path"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` +} + type ui struct { mode string theme *material.Theme @@ -159,12 +167,14 @@ type ui struct { showRecycle widget.Clickable showLocalLifecycle widget.Clickable showRemoteLifecycle widget.Clickable + rememberRemoteAuth widget.Bool entryClicks []widget.Clickable historyClicks []widget.Clickable attachmentClicks []widget.Clickable breadcrumbs []widget.Clickable groupClicks []widget.Clickable recentVaultClicks []widget.Clickable + recentRemoteClicks []widget.Clickable removeCustomFields []widget.Clickable state appstate.State visible []entry @@ -189,9 +199,11 @@ type ui struct { keyboardFocus focusID defaultSaveAsPath string recentVaultsPath string + recentRemotesPath string editingEntry bool groupControlsHidden bool recentVaults []string + recentRemotes []recentRemoteRecord recentVaultGroups map[string][]string deleteGroupPath []string statusExpiresAt time.Time @@ -276,6 +288,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) lifecycleMode: "local", defaultSaveAsPath: paths.DefaultSaveAsPath, recentVaultsPath: paths.RecentVaultsPath, + recentRemotesPath: paths.RecentRemotesPath, recentVaultGroups: map[string][]string{}, now: time.Now, } @@ -290,6 +303,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.keyboardFocus = focusSearch u.setCustomFieldRows(nil) u.loadRecentVaults() + u.loadRecentRemotes() u.filter() return u } @@ -322,6 +336,7 @@ func defaultStatePaths(stateDir string) statePaths { return statePaths{ DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"), RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"), } } @@ -551,6 +566,13 @@ func (u *ui) openRemoteAction() error { if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil { return err } + u.noteRecentRemote( + strings.TrimSpace(u.remoteBaseURL.Text()), + strings.TrimSpace(u.remotePath.Text()), + strings.TrimSpace(u.remoteUsername.Text()), + u.remotePassword.Text(), + u.rememberRemoteAuth.Value, + ) u.enterHiddenVaultRoot() u.editingEntry = false u.filter() @@ -695,6 +717,42 @@ func (u *ui) applyRecentVaultRecords(records []recentVaultRecord) { } } +func (u *ui) loadRecentRemotes() { + if strings.TrimSpace(u.recentRemotesPath) == "" { + return + } + content, err := os.ReadFile(u.recentRemotesPath) + if err != nil { + return + } + var records []recentRemoteRecord + if err := json.Unmarshal(content, &records); err != nil { + return + } + filtered := make([]recentRemoteRecord, 0, len(records)) + seen := map[string]bool{} + for _, record := range records { + record.BaseURL = strings.TrimSpace(record.BaseURL) + record.Path = strings.TrimSpace(record.Path) + if record.BaseURL == "" || record.Path == "" { + continue + } + key := record.BaseURL + "|" + record.Path + if seen[key] { + continue + } + seen[key] = true + filtered = append(filtered, record) + if len(filtered) == 6 { + break + } + } + u.recentRemotes = filtered + if len(u.recentRemoteClicks) < len(u.recentRemotes) { + u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes)) + } +} + func (u *ui) saveRecentVaults() { if strings.TrimSpace(u.recentVaultsPath) == "" { return @@ -716,6 +774,51 @@ func (u *ui) saveRecentVaults() { _ = os.WriteFile(u.recentVaultsPath, content, 0o600) } +func (u *ui) saveRecentRemotes() { + if strings.TrimSpace(u.recentRemotesPath) == "" { + return + } + if err := os.MkdirAll(filepath.Dir(u.recentRemotesPath), 0o700); err != nil { + return + } + content, err := json.MarshalIndent(u.recentRemotes, "", " ") + if err != nil { + return + } + _ = os.WriteFile(u.recentRemotesPath, content, 0o600) +} + +func (u *ui) noteRecentRemote(baseURL, path, username, password string, rememberAuth bool) { + baseURL = strings.TrimSpace(baseURL) + path = strings.TrimSpace(path) + if baseURL == "" || path == "" { + return + } + record := recentRemoteRecord{ + BaseURL: baseURL, + Path: path, + } + if rememberAuth { + record.Username = strings.TrimSpace(username) + record.Password = password + } + next := []recentRemoteRecord{record} + for _, existing := range u.recentRemotes { + if existing.BaseURL == baseURL && existing.Path == path { + continue + } + next = append(next, existing) + if len(next) == 6 { + break + } + } + u.recentRemotes = next + if len(u.recentRemoteClicks) < len(u.recentRemotes) { + u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes)) + } + u.saveRecentRemotes() +} + func (u *ui) recentVaultGroup(path string) []string { if u.recentVaultGroups == nil { return nil @@ -1128,6 +1231,18 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { } } } + for i := range u.recentRemoteClicks { + for u.recentRemoteClicks[i].Clicked(gtx) { + if i < len(u.recentRemotes) { + record := u.recentRemotes[i] + u.remoteBaseURL.SetText(record.BaseURL) + u.remotePath.SetText(record.Path) + u.remoteUsername.SetText(record.Username) + u.remotePassword.SetText(record.Password) + u.rememberRemoteAuth.Value = strings.TrimSpace(record.Username) != "" || record.Password != "" + } + } + } for u.addEntry.Clicked(gtx) { u.state.BeginNewEntry() u.loadSelectedEntryIntoEditor() diff --git a/main_test.go b/main_test.go index 5a396fb..6ab2ba0 100644 --- a/main_test.go +++ b/main_test.go @@ -1964,6 +1964,34 @@ func TestUIOpenVaultRestoresLastOpenedGroupForThatVault(t *testing.T) { } } +func TestUIRecentRemoteConnectionsPersistAndReload(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "recent-remotes.json") + + first := newUIWithSession("desktop", &session.Manager{}) + first.recentRemotesPath = configPath + first.recentRemotes = nil + first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-1", true) + first.noteRecentRemote("https://dav.example.com", "vaults/team.kdbx", "bob", "secret-2", false) + first.noteRecentRemote("https://dav.example.com", "vaults/home.kdbx", "alice", "secret-3", true) + + second := newUIWithSession("desktop", &session.Manager{}) + second.recentRemotesPath = configPath + second.recentRemotes = nil + second.loadRecentRemotes() + + if got := len(second.recentRemotes); got != 2 { + t.Fatalf("len(recentRemotes) = %d, want 2", got) + } + 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[1]; got.Username != "" || got.Password != "" { + t.Fatalf("recentRemotes[1] = %#v, want credentials omitted when remember disabled", got) + } +} + func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) { t.Parallel() @@ -1976,6 +2004,9 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) { if got := paths.RecentVaultsPath; got != filepath.Join(base, "recent-vaults.json") { t.Fatalf("RecentVaultsPath = %q, want %q", got, filepath.Join(base, "recent-vaults.json")) } + if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") { + t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json")) + } } func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) { @@ -1990,6 +2021,9 @@ func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) { if got := paths.RecentVaultsPath; got != filepath.Join(base, "recent-vaults.json") { t.Fatalf("RecentVaultsPath = %q, want %q", got, filepath.Join(base, "recent-vaults.json")) } + if got := paths.RecentRemotesPath; got != filepath.Join(base, "recent-remotes.json") { + t.Fatalf("RecentRemotesPath = %q, want %q", got, filepath.Join(base, "recent-remotes.json")) + } } func TestResolveFlagOrEnvPrefersFlagThenEnvThenFallback(t *testing.T) { diff --git a/ui_forms.go b/ui_forms.go index 5741676..6352b95 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -40,6 +40,14 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { 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(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") + box.Color = accentColor + return box.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(u.recentRemoteList), ) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -99,6 +107,39 @@ func (u *ui) recentVaultList(gtx layout.Context) layout.Dimensions { ) } +func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions { + if len(u.recentRemotes) == 0 { + return layout.Dimensions{} + } + if len(u.recentRemoteClicks) < len(u.recentRemotes) { + u.recentRemoteClicks = make([]widget.Clickable, len(u.recentRemotes)) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "RECENT CONNECTIONS") + 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 layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { + children := make([]layout.FlexChild, 0, len(u.recentRemotes)*2) + for i, record := range u.recentRemotes { + index := i + label := record.BaseURL + " / " + record.Path + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.recentRemoteClicks[index], label) + })) + if i < len(u.recentRemotes)-1 { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + } + } + return children + }()...) + }), + ) +} + func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions { items := u.selectedAttachmentItems() if len(items) == 0 {