Remember recent remote vault connections
This commit is contained in:
@@ -78,6 +78,7 @@ type attachmentItem struct {
|
|||||||
type statePaths struct {
|
type statePaths struct {
|
||||||
DefaultSaveAsPath string
|
DefaultSaveAsPath string
|
||||||
RecentVaultsPath string
|
RecentVaultsPath string
|
||||||
|
RecentRemotesPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
type recentVaultRecord struct {
|
type recentVaultRecord struct {
|
||||||
@@ -85,6 +86,13 @@ type recentVaultRecord struct {
|
|||||||
LastGroup []string `json:"lastGroup,omitempty"`
|
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 {
|
type ui struct {
|
||||||
mode string
|
mode string
|
||||||
theme *material.Theme
|
theme *material.Theme
|
||||||
@@ -159,12 +167,14 @@ type ui struct {
|
|||||||
showRecycle widget.Clickable
|
showRecycle widget.Clickable
|
||||||
showLocalLifecycle widget.Clickable
|
showLocalLifecycle widget.Clickable
|
||||||
showRemoteLifecycle widget.Clickable
|
showRemoteLifecycle widget.Clickable
|
||||||
|
rememberRemoteAuth widget.Bool
|
||||||
entryClicks []widget.Clickable
|
entryClicks []widget.Clickable
|
||||||
historyClicks []widget.Clickable
|
historyClicks []widget.Clickable
|
||||||
attachmentClicks []widget.Clickable
|
attachmentClicks []widget.Clickable
|
||||||
breadcrumbs []widget.Clickable
|
breadcrumbs []widget.Clickable
|
||||||
groupClicks []widget.Clickable
|
groupClicks []widget.Clickable
|
||||||
recentVaultClicks []widget.Clickable
|
recentVaultClicks []widget.Clickable
|
||||||
|
recentRemoteClicks []widget.Clickable
|
||||||
removeCustomFields []widget.Clickable
|
removeCustomFields []widget.Clickable
|
||||||
state appstate.State
|
state appstate.State
|
||||||
visible []entry
|
visible []entry
|
||||||
@@ -189,9 +199,11 @@ type ui struct {
|
|||||||
keyboardFocus focusID
|
keyboardFocus focusID
|
||||||
defaultSaveAsPath string
|
defaultSaveAsPath string
|
||||||
recentVaultsPath string
|
recentVaultsPath string
|
||||||
|
recentRemotesPath string
|
||||||
editingEntry bool
|
editingEntry bool
|
||||||
groupControlsHidden bool
|
groupControlsHidden bool
|
||||||
recentVaults []string
|
recentVaults []string
|
||||||
|
recentRemotes []recentRemoteRecord
|
||||||
recentVaultGroups map[string][]string
|
recentVaultGroups map[string][]string
|
||||||
deleteGroupPath []string
|
deleteGroupPath []string
|
||||||
statusExpiresAt time.Time
|
statusExpiresAt time.Time
|
||||||
@@ -276,6 +288,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
lifecycleMode: "local",
|
lifecycleMode: "local",
|
||||||
defaultSaveAsPath: paths.DefaultSaveAsPath,
|
defaultSaveAsPath: paths.DefaultSaveAsPath,
|
||||||
recentVaultsPath: paths.RecentVaultsPath,
|
recentVaultsPath: paths.RecentVaultsPath,
|
||||||
|
recentRemotesPath: paths.RecentRemotesPath,
|
||||||
recentVaultGroups: map[string][]string{},
|
recentVaultGroups: map[string][]string{},
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
}
|
}
|
||||||
@@ -290,6 +303,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
u.keyboardFocus = focusSearch
|
u.keyboardFocus = focusSearch
|
||||||
u.setCustomFieldRows(nil)
|
u.setCustomFieldRows(nil)
|
||||||
u.loadRecentVaults()
|
u.loadRecentVaults()
|
||||||
|
u.loadRecentRemotes()
|
||||||
u.filter()
|
u.filter()
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
@@ -322,6 +336,7 @@ func defaultStatePaths(stateDir string) statePaths {
|
|||||||
return statePaths{
|
return statePaths{
|
||||||
DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"),
|
DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"),
|
||||||
RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"),
|
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 {
|
if err := u.state.OpenRemoteVault(client, strings.TrimSpace(u.remotePath.Text()), key); err != nil {
|
||||||
return err
|
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.enterHiddenVaultRoot()
|
||||||
u.editingEntry = false
|
u.editingEntry = false
|
||||||
u.filter()
|
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() {
|
func (u *ui) saveRecentVaults() {
|
||||||
if strings.TrimSpace(u.recentVaultsPath) == "" {
|
if strings.TrimSpace(u.recentVaultsPath) == "" {
|
||||||
return
|
return
|
||||||
@@ -716,6 +774,51 @@ func (u *ui) saveRecentVaults() {
|
|||||||
_ = os.WriteFile(u.recentVaultsPath, content, 0o600)
|
_ = 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 {
|
func (u *ui) recentVaultGroup(path string) []string {
|
||||||
if u.recentVaultGroups == nil {
|
if u.recentVaultGroups == nil {
|
||||||
return 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) {
|
for u.addEntry.Clicked(gtx) {
|
||||||
u.state.BeginNewEntry()
|
u.state.BeginNewEntry()
|
||||||
u.loadSelectedEntryIntoEditor()
|
u.loadSelectedEntryIntoEditor()
|
||||||
|
|||||||
@@ -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) {
|
func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1976,6 +2004,9 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
|
|||||||
if got := paths.RecentVaultsPath; got != filepath.Join(base, "recent-vaults.json") {
|
if got := paths.RecentVaultsPath; got != filepath.Join(base, "recent-vaults.json") {
|
||||||
t.Fatalf("RecentVaultsPath = %q, want %q", 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) {
|
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") {
|
if got := paths.RecentVaultsPath; got != filepath.Join(base, "recent-vaults.json") {
|
||||||
t.Fatalf("RecentVaultsPath = %q, want %q", 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) {
|
func TestResolveFlagOrEnvPrefersFlagThenEnvThenFallback(t *testing.T) {
|
||||||
|
|||||||
+41
@@ -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(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(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,
|
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 {
|
func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions {
|
||||||
items := u.selectedAttachmentItems()
|
items := u.selectedAttachmentItems()
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user