diff --git a/main.go b/main.go index 63af43b..1ea95bd 100644 --- a/main.go +++ b/main.go @@ -106,6 +106,8 @@ type ui struct { entryTags widget.Editor entryPath widget.Editor entryFields widget.Editor + customFieldKeys []widget.Editor + customFieldValues []widget.Editor historyIndex widget.Editor groupName widget.Editor passwordProfile widget.Editor @@ -149,6 +151,7 @@ type ui struct { deleteGroup widget.Clickable confirmDeleteGroup widget.Clickable cancelDeleteGroup widget.Clickable + addCustomField widget.Clickable togglePasswordInline widget.Clickable showEntries widget.Clickable showTemplates widget.Clickable @@ -161,6 +164,7 @@ type ui struct { breadcrumbs []widget.Clickable groupClicks []widget.Clickable recentVaultClicks []widget.Clickable + removeCustomFields []widget.Clickable state appstate.State visible []entry currentPath []string @@ -278,6 +282,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.copyIcon, _ = widget.NewIcon(icons.ContentContentCopy) u.passwordProfile.SetText("strong") u.keyboardFocus = focusSearch + u.setCustomFieldRows(nil) u.loadRecentVaults() u.filter() return u diff --git a/main_test.go b/main_test.go index 7ee923c..42b9668 100644 --- a/main_test.go +++ b/main_test.go @@ -908,7 +908,10 @@ func TestUICreatesEntryWithAllSupportedEditorFields(t *testing.T) { u.entryNotes.SetText("Registrar account") u.entryTags.SetText("dns, registrar") 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 { 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) { t.Parallel() @@ -1145,7 +1183,7 @@ func TestUITemplatesCanBeBrowsedCreatedEditedDeletedAndInstantiated(t *testing.T u.state.SelectedEntryID = "tpl-web" u.loadSelectedEntryIntoEditor() u.entryTitle.SetText("Website Login Updated") - u.entryFields.SetText("Environment=prod") + u.setCustomFieldRows(map[string]string{"Environment": "prod"}) if err := u.saveTemplateAction(); err != nil { t.Fatalf("saveTemplateAction(edit) error = %v", err) } diff --git a/ui_editor.go b/ui_editor.go index 4dc22ec..2a1ba6f 100644 --- a/ui_editor.go +++ b/ui_editor.go @@ -3,9 +3,11 @@ package main import ( "fmt" "os" + "slices" "strconv" "strings" + "gioui.org/widget" "git.julianfamily.org/keepassgo/clipboard" "git.julianfamily.org/keepassgo/passwords" "git.julianfamily.org/keepassgo/vault" @@ -52,6 +54,7 @@ func (u *ui) loadSelectedEntryIntoEditor() { u.entryTags.SetText("") u.entryPath.SetText(strings.Join(u.displayPath(), " / ")) u.entryFields.SetText("") + u.setCustomFieldRows(nil) u.attachmentName.SetText("") u.attachmentPath.SetText("") u.exportAttachmentPath.SetText("") @@ -67,11 +70,74 @@ func (u *ui) loadSelectedEntryIntoEditor() { u.entryTags.SetText(strings.Join(item.Tags, ", ")) u.entryPath.SetText(strings.Join(u.displayEntryPath(item.Path), " / ")) u.entryFields.SetText(marshalFields(item.Fields)) + u.setCustomFieldRows(item.Fields) u.attachmentName.SetText("") u.attachmentPath.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 { item, ok := u.selectedEntry() 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) { path = append([]string{root}, path...) } - fields, err := parseFields(u.entryFields.Text()) + fields, err := u.currentCustomFields() if err != nil { return vault.Entry{}, err } diff --git a/ui_forms.go b/ui_forms.go index 52dbde5..876b179 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -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 { if u.state.Section != appstate.SectionEntries { return layout.Dimensions{} @@ -218,9 +278,9 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { return lbl.Layout(gtx) }), 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(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(labeledEditorWithFocus(u.theme, "History Index", &u.historyIndex, false, u.isFocused(detailFocusID(detailFieldHistoryIndex)))), 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) + }) + }), + ) + } +}