Remember recent remote vault connections

This commit is contained in:
Joe Julian
2026-03-29 17:05:42 -07:00
parent daecb5ff32
commit 4a3e52ec1c
3 changed files with 190 additions and 0 deletions
+115
View File
@@ -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()
+34
View File
@@ -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) {
+41
View File
@@ -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 {