Use structured custom fields in entry editor

This commit is contained in:
Joe Julian
2026-03-29 16:37:20 -07:00
parent d7026d5c0e
commit db06fcd385
4 changed files with 208 additions and 5 deletions
+5
View File
@@ -106,6 +106,8 @@ type ui struct {
entryTags widget.Editor entryTags widget.Editor
entryPath widget.Editor entryPath widget.Editor
entryFields widget.Editor entryFields widget.Editor
customFieldKeys []widget.Editor
customFieldValues []widget.Editor
historyIndex widget.Editor historyIndex widget.Editor
groupName widget.Editor groupName widget.Editor
passwordProfile widget.Editor passwordProfile widget.Editor
@@ -149,6 +151,7 @@ type ui struct {
deleteGroup widget.Clickable deleteGroup widget.Clickable
confirmDeleteGroup widget.Clickable confirmDeleteGroup widget.Clickable
cancelDeleteGroup widget.Clickable cancelDeleteGroup widget.Clickable
addCustomField widget.Clickable
togglePasswordInline widget.Clickable togglePasswordInline widget.Clickable
showEntries widget.Clickable showEntries widget.Clickable
showTemplates widget.Clickable showTemplates widget.Clickable
@@ -161,6 +164,7 @@ type ui struct {
breadcrumbs []widget.Clickable breadcrumbs []widget.Clickable
groupClicks []widget.Clickable groupClicks []widget.Clickable
recentVaultClicks []widget.Clickable recentVaultClicks []widget.Clickable
removeCustomFields []widget.Clickable
state appstate.State state appstate.State
visible []entry visible []entry
currentPath []string currentPath []string
@@ -278,6 +282,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy) u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy)
u.passwordProfile.SetText("strong") u.passwordProfile.SetText("strong")
u.keyboardFocus = focusSearch u.keyboardFocus = focusSearch
u.setCustomFieldRows(nil)
u.loadRecentVaults() u.loadRecentVaults()
u.filter() u.filter()
return u return u
+40 -2
View File
@@ -908,7 +908,10 @@ func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) {
u.entryNotes.SetText("Registrar account") u.entryNotes.SetText("Registrar account")
u.entryTags.SetText("dns, registrar") u.entryTags.SetText("dns, registrar")
u.entryPath.SetText("Root / Internet") u.entryPath.SetText("Root / Internet")
u.entryFields.SetText("Environment=prod\nAccount ID=12345") u.setCustomFieldRows(map[string]string{
"Environment": "prod",
"Account ID": "12345",
})
if err := u.saveEntryAction(); err != nil { if err := u.saveEntryAction(); err != nil {
t.Fatalf("saveEntryAction() create error = %v", err) t.Fatalf("saveEntryAction() create error = %v", err)
@@ -937,6 +940,41 @@ func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) {
} }
} }
func TestUILoadSelectedEntryIntoEditorPopulatesStructuredCustomFields(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "gitlab",
Title: "Gitlab",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"AndroidApp1": "androidapp://com.gitlab.android",
"OTP": "123456",
},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "gitlab"
u.loadSelectedEntryIntoEditor()
if len(u.customFieldKeys) != 2 || len(u.customFieldValues) != 2 {
t.Fatalf("custom field rows = %d/%d, want 2 rows", len(u.customFieldKeys), len(u.customFieldValues))
}
got := map[string]string{}
for i := range u.customFieldKeys {
got[u.customFieldKeys[i].Text()] = u.customFieldValues[i].Text()
}
if got["AndroidApp1"] != "androidapp://com.gitlab.android" || got["OTP"] != "123456" {
t.Fatalf("custom field rows = %#v, want AndroidApp1 and OTP values", got)
}
}
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) { func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1145,7 +1183,7 @@ func TestUITemplatesCanBeBrowsedCreatedEditedDeletedAndInstantiated(t *testing.T
u.state.SelectedEntryID = "tpl-web" u.state.SelectedEntryID = "tpl-web"
u.loadSelectedEntryIntoEditor() u.loadSelectedEntryIntoEditor()
u.entryTitle.SetText("Website Login Updated") u.entryTitle.SetText("Website Login Updated")
u.entryFields.SetText("Environment=prod") u.setCustomFieldRows(map[string]string{"Environment": "prod"})
if err := u.saveTemplateAction(); err != nil { if err := u.saveTemplateAction(); err != nil {
t.Fatalf("saveTemplateAction(edit) error = %v", err) t.Fatalf("saveTemplateAction(edit) error = %v", err)
} }
+67 -1
View File
@@ -3,9 +3,11 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"slices"
"strconv" "strconv"
"strings" "strings"
"gioui.org/widget"
"git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/clipboard"
"git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/passwords"
"git.julianfamily.org/keepassgo/vault" "git.julianfamily.org/keepassgo/vault"
@@ -52,6 +54,7 @@ func (u *ui) loadSelectedEntryIntoEditor() {
u.entryTags.SetText("") u.entryTags.SetText("")
u.entryPath.SetText(strings.Join(u.displayPath(), " / ")) u.entryPath.SetText(strings.Join(u.displayPath(), " / "))
u.entryFields.SetText("") u.entryFields.SetText("")
u.setCustomFieldRows(nil)
u.attachmentName.SetText("") u.attachmentName.SetText("")
u.attachmentPath.SetText("") u.attachmentPath.SetText("")
u.exportAttachmentPath.SetText("") u.exportAttachmentPath.SetText("")
@@ -67,11 +70,74 @@ func (u *ui) loadSelectedEntryIntoEditor() {
u.entryTags.SetText(strings.Join(item.Tags, ", ")) u.entryTags.SetText(strings.Join(item.Tags, ", "))
u.entryPath.SetText(strings.Join(u.displayEntryPath(item.Path), " / ")) u.entryPath.SetText(strings.Join(u.displayEntryPath(item.Path), " / "))
u.entryFields.SetText(marshalFields(item.Fields)) u.entryFields.SetText(marshalFields(item.Fields))
u.setCustomFieldRows(item.Fields)
u.attachmentName.SetText("") u.attachmentName.SetText("")
u.attachmentPath.SetText("") u.attachmentPath.SetText("")
u.exportAttachmentPath.SetText("") u.exportAttachmentPath.SetText("")
} }
func (u *ui) setCustomFieldRows(fields map[string]string) {
u.customFieldKeys = nil
u.customFieldValues = nil
u.removeCustomFields = nil
if len(fields) == 0 {
u.appendCustomFieldRow("", "")
return
}
keys := make([]string, 0, len(fields))
for key := range fields {
keys = append(keys, key)
}
slices.Sort(keys)
for _, key := range keys {
u.appendCustomFieldRow(key, fields[key])
}
}
func (u *ui) appendCustomFieldRow(key, value string) {
keyEditor := widget.Editor{SingleLine: true, Submit: false}
keyEditor.SetText(key)
valueEditor := widget.Editor{SingleLine: true, Submit: false}
valueEditor.SetText(value)
u.customFieldKeys = append(u.customFieldKeys, keyEditor)
u.customFieldValues = append(u.customFieldValues, valueEditor)
u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{})
}
func (u *ui) removeCustomFieldRow(index int) {
if index < 0 || index >= len(u.customFieldKeys) {
return
}
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...)
if len(u.customFieldKeys) == 0 {
u.appendCustomFieldRow("", "")
}
}
func (u *ui) currentCustomFields() (map[string]string, error) {
fields := map[string]string{}
for i := range u.customFieldKeys {
key := strings.TrimSpace(u.customFieldKeys[i].Text())
value := strings.TrimSpace(u.customFieldValues[i].Text())
if key == "" && value == "" {
continue
}
if key == "" {
return nil, fmt.Errorf("custom field name is required")
}
fields[key] = value
}
if len(fields) == 0 && strings.TrimSpace(u.entryFields.Text()) != "" {
return parseFields(u.entryFields.Text())
}
if len(fields) == 0 {
return nil, nil
}
return fields, nil
}
func (u *ui) visibleHistory() []vault.Entry { func (u *ui) visibleHistory() []vault.Entry {
item, ok := u.selectedEntry() item, ok := u.selectedEntry()
if !ok || len(item.History) == 0 { if !ok || len(item.History) == 0 {
@@ -341,7 +407,7 @@ func (u *ui) editorEntry() (vault.Entry, error) {
if root := u.hiddenVaultRoot(); root != "" && (len(path) == 0 || path[0] != root) { if root := u.hiddenVaultRoot(); root != "" && (len(path) == 0 || path[0] != root) {
path = append([]string{root}, path...) path = append([]string{root}, path...)
} }
fields, err := parseFields(u.entryFields.Text()) fields, err := u.currentCustomFields()
if err != nil { if err != nil {
return vault.Entry{}, err return vault.Entry{}, err
} }
+96 -2
View File
@@ -126,6 +126,66 @@ func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions {
}()...) }()...)
} }
func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions {
if len(u.customFieldKeys) == 0 {
u.setCustomFieldRows(nil)
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "CUSTOM FIELDS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(11), "Add key/value pairs. Changes are only saved when you save the entry.")
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.customFieldKeys)*2)
for i := range u.customFieldKeys {
index := i
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
for u.removeCustomFields[index].Clicked(gtx) {
u.removeCustomFieldRow(index)
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(0.38, func(gtx layout.Context) layout.Dimensions {
return labeledEditor(u.theme, "Name", &u.customFieldKeys[index], false)(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Flexed(0.52, func(gtx layout.Context) layout.Dimensions {
return labeledEditor(u.theme, "Value", &u.customFieldValues[index], false)(gtx)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if len(u.customFieldKeys) == 1 && (strings.TrimSpace(u.customFieldKeys[index].Text()) != "" || strings.TrimSpace(u.customFieldValues[index].Text()) != "") {
return layout.Dimensions{}
}
return tonedButton(gtx, u.theme, &u.removeCustomFields[index], "-")
}),
)
}))
if i < len(u.customFieldKeys)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return children
}()...)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
for u.addCustomField.Clicked(gtx) {
u.appendCustomFieldRow("", "")
}
return tonedButton(gtx, u.theme, &u.addCustomField, "+")
}),
)
}
func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { func (u *ui) groupControls(gtx layout.Context) layout.Dimensions {
if u.state.Section != appstate.SectionEntries { if u.state.Section != appstate.SectionEntries {
return layout.Dimensions{} return layout.Dimensions{}
@@ -218,9 +278,9 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions {
return lbl.Layout(gtx) return lbl.Layout(gtx)
}), }),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)))), layout.Rigid(labeledMultilineEditorWithFocus(u.theme, "Notes", &u.entryNotes, false, u.isFocused(detailFocusID(detailFieldNotes)), unit.Dp(120))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
layout.Rigid(labeledEditorHelpFocus(u.theme, "Custom Fields", "One key=value pair per line. These fields are only saved when you save the entry.", &u.entryFields, false, u.isFocused(detailFocusID(detailFieldFields)))), layout.Rigid(u.customFieldEditorPanel),
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))), layout.Rigid(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))),
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
@@ -385,3 +445,37 @@ func labeledEditorWithFocus(
) )
} }
} }
func labeledMultilineEditorWithFocus(
th *material.Theme,
label string,
editor *widget.Editor,
sensitive bool,
focused bool,
minHeight unit.Dp,
) layout.Widget {
return func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(th, unit.Sp(12), strings.ToUpper(label))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return outlinedFieldState(gtx, focused, func(gtx layout.Context) layout.Dimensions {
mask := editor.Mask
if sensitive {
editor.Mask = '•'
}
defer func() { editor.Mask = mask }()
gtx.Constraints.Min.X = gtx.Constraints.Max.X
if min := gtx.Dp(minHeight); gtx.Constraints.Min.Y < min {
gtx.Constraints.Min.Y = min
}
ed := material.Editor(th, editor, label)
return layout.UniformInset(unit.Dp(8)).Layout(gtx, ed.Layout)
})
}),
)
}
}