From 51f2a0121a9a05e5e2523cc1e16a232cd7f19b19 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Tue, 28 Apr 2026 21:31:40 -0700 Subject: [PATCH] Fix extra string actions --- internal/appui/app.go | 128 ++++++++++++++++++++++++++++- internal/appui/entry_editor.go | 44 ++++++++++ internal/appui/lifecycle_forms.go | 1 + internal/appui/main_test.go | 85 +++++++++++++++++++ internal/clipboard/service.go | 16 ++++ internal/clipboard/service_test.go | 24 ++++++ 6 files changed, 297 insertions(+), 1 deletion(-) diff --git a/internal/appui/app.go b/internal/appui/app.go index a705b57..e46de0d 100644 --- a/internal/appui/app.go +++ b/internal/appui/app.go @@ -249,6 +249,7 @@ type ui struct { entryFields widget.Editor customFieldKeys []widget.Editor customFieldValues []widget.Editor + copyCustomFields []widget.Clickable historyIndex widget.Editor groupName widget.Editor groupParentPath widget.Editor @@ -402,6 +403,8 @@ type ui struct { vaultRemoteCredentialClicks []widget.Clickable syncRemoteCredentialClicks []widget.Clickable removeCustomFields []widget.Clickable + toggleCustomFields []widget.Clickable + revealedCustomFields map[string]bool state appstate.State visible []entry currentPath []string @@ -662,6 +665,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) pendingSharedLookupPath: paths.PendingSharedLookupPath, recentVaultGroups: map[string][]string{}, recentVaultUsedAt: map[string]time.Time{}, + revealedCustomFields: map[string]bool{}, lifecycleAdvancedHidden: true, historyHidden: true, statusBannerTTL: statusBannerDuration, @@ -940,6 +944,7 @@ func (u *ui) handlePhoneBack() bool { func (u *ui) resetPasswordPeek() { u.showPassword = false + u.revealedCustomFields = map[string]bool{} } func (u *ui) childGroups() []string { @@ -2153,6 +2158,12 @@ type detailViewMetrics struct { cardGap unit.Dp } +type extraStringView struct { + Key string + Value string + Revealed bool +} + func (u *ui) detailViewContent(gtx layout.Context, item entry) layout.Dimensions { rows := u.detailViewRows(item) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, @@ -2180,6 +2191,8 @@ func (u *ui) detailViewRows(item entry) []layout.Widget { layout.Spacer{Height: unit.Dp(8)}.Layout, u.detailNotesCard(item), layout.Spacer{Height: metrics.cardGap}.Layout, + u.detailExtraStringsCard, + layout.Spacer{Height: metrics.cardGap}.Layout, u.attachmentSummaryPanel, layout.Spacer{Height: metrics.cardGap}.Layout, u.historyPanel, @@ -2339,6 +2352,115 @@ func (u *ui) detailNotesCard(item entry) layout.Widget { } } +func (u *ui) detailExtraStringsCard(gtx layout.Context) layout.Dimensions { + fields := u.detailExtraStrings() + u.ensureExtraStringClickables(len(fields)) + if len(fields) == 0 { + return layout.Dimensions{} + } + + return compactCard(gtx, func(gtx layout.Context) layout.Dimensions { + children := []layout.FlexChild{ + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "EXTRA STRINGS") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout), + } + for i, field := range fields { + index := i + item := field + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.detailExtraStringRow(gtx, index, item) + })) + if i < len(fields)-1 { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + } + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) +} + +func (u *ui) detailExtraStringRow(gtx layout.Context, index int, field extraStringView) layout.Dimensions { + for u.toggleCustomFields[index].Clicked(gtx) { + u.toggleExtraStringReveal(field.Key) + } + for u.copyCustomFields[index].Clicked(gtx) { + key := field.Key + u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction(key) }) + } + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, 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(u.theme, unit.Sp(12), strings.ToUpper(field.Key)) + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(15), field.Value) + return lbl.Layout(gtx) + }), + ) + }), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return u.inlinePasswordToggle(gtx, &u.toggleCustomFields[index], field.Revealed) + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + btn := material.IconButton(u.theme, &u.copyCustomFields[index], u.copyIcon, "Copy extra string") + btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255} + btn.Color = accentColor + btn.Size = unit.Dp(18) + btn.Inset = layout.UniformInset(unit.Dp(8)) + return btn.Layout(gtx) + }), + ) +} + +func (u *ui) detailExtraStrings() []extraStringView { + item, ok := u.selectedEntry() + if !ok || len(item.Fields) == 0 { + return nil + } + keys := make([]string, 0, len(item.Fields)) + for key := range item.Fields { + keys = append(keys, key) + } + slices.Sort(keys) + out := make([]extraStringView, 0, len(keys)) + for _, key := range keys { + value := item.Fields[key] + revealed := u.revealedCustomFields[key] + if !revealed { + value = maskedSecretValue(value) + } + out = append(out, extraStringView{Key: key, Value: value, Revealed: revealed}) + } + return out +} + +func (u *ui) toggleExtraStringReveal(key string) { + if u.revealedCustomFields == nil { + u.revealedCustomFields = map[string]bool{} + } + u.revealedCustomFields[key] = !u.revealedCustomFields[key] +} + +func (u *ui) ensureExtraStringClickables(count int) { + if len(u.copyCustomFields) < count { + clicks := make([]widget.Clickable, count) + copy(clicks, u.copyCustomFields) + u.copyCustomFields = clicks + } + if len(u.toggleCustomFields) < count { + clicks := make([]widget.Clickable, count) + copy(clicks, u.toggleCustomFields) + u.toggleCustomFields = clicks + } +} + func (u *ui) detailActionRow(gtx layout.Context) layout.Dimensions { switch u.state.Section { case appstate.SectionTemplates: @@ -2996,7 +3118,11 @@ func (u *ui) detailPasswordValue() string { if u.showPassword { return item.Password } - return strings.Repeat("•", max(8, len(item.Password))) + return maskedSecretValue(item.Password) +} + +func maskedSecretValue(value string) string { + return strings.Repeat("•", max(8, len(value))) } func card(gtx layout.Context, w layout.Widget) layout.Dimensions { diff --git a/internal/appui/entry_editor.go b/internal/appui/entry_editor.go index 9e84b31..7414b76 100644 --- a/internal/appui/entry_editor.go +++ b/internal/appui/entry_editor.go @@ -82,6 +82,8 @@ func (u *ui) setCustomFieldRows(fields map[string]string) { u.customFieldKeys = nil u.customFieldValues = nil u.removeCustomFields = nil + u.copyCustomFields = nil + u.toggleCustomFields = nil if len(fields) == 0 { u.appendCustomFieldRow("", "") return @@ -104,21 +106,53 @@ func (u *ui) appendCustomFieldRow(key, value string) { u.customFieldKeys = append(u.customFieldKeys, keyEditor) u.customFieldValues = append(u.customFieldValues, valueEditor) u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{}) + u.copyCustomFields = append(u.copyCustomFields, widget.Clickable{}) + u.toggleCustomFields = append(u.toggleCustomFields, widget.Clickable{}) } func (u *ui) removeCustomFieldRow(index int) { + u.ensureCustomFieldRowControls() 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:]...) + u.copyCustomFields = append(u.copyCustomFields[:index], u.copyCustomFields[index+1:]...) + u.toggleCustomFields = append(u.toggleCustomFields[:index], u.toggleCustomFields[index+1:]...) if len(u.customFieldKeys) == 0 { u.appendCustomFieldRow("", "") } } +func (u *ui) ensureCustomFieldRowControls() { + if len(u.customFieldValues) < len(u.customFieldKeys) { + values := make([]widget.Editor, len(u.customFieldKeys)) + copy(values, u.customFieldValues) + for i := len(u.customFieldValues); i < len(values); i++ { + values[i] = widget.Editor{SingleLine: true, Submit: false} + } + u.customFieldValues = values + } + if len(u.removeCustomFields) < len(u.customFieldKeys) { + clicks := make([]widget.Clickable, len(u.customFieldKeys)) + copy(clicks, u.removeCustomFields) + u.removeCustomFields = clicks + } + if len(u.copyCustomFields) < len(u.customFieldKeys) { + clicks := make([]widget.Clickable, len(u.customFieldKeys)) + copy(clicks, u.copyCustomFields) + u.copyCustomFields = clicks + } + if len(u.toggleCustomFields) < len(u.customFieldKeys) { + clicks := make([]widget.Clickable, len(u.customFieldKeys)) + copy(clicks, u.toggleCustomFields) + u.toggleCustomFields = clicks + } +} + func (u *ui) currentCustomFields() (map[string]string, error) { + u.ensureCustomFieldRowControls() fields := map[string]string{} for i := range u.customFieldKeys { key := strings.TrimSpace(u.customFieldKeys[i].Text()) @@ -399,6 +433,16 @@ func (u *ui) copySelectedFieldAction(target clipboard.Target) error { return service.Copy(model, u.state.SelectedEntryID, target) } +func (u *ui) copySelectedCustomFieldAction(key string) error { + model, err := u.state.Session.Current() + if err != nil { + return err + } + + service := clipboard.Service{Writer: u.clipboardWriter} + return service.CopyCustomField(model, u.state.SelectedEntryID, key) +} + func (u *ui) generatePasswordAction() error { profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text()) if err != nil { diff --git a/internal/appui/lifecycle_forms.go b/internal/appui/lifecycle_forms.go index f41d44f..36db402 100644 --- a/internal/appui/lifecycle_forms.go +++ b/internal/appui/lifecycle_forms.go @@ -667,6 +667,7 @@ func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions { if len(u.customFieldKeys) == 0 { u.setCustomFieldRows(nil) } + u.ensureCustomFieldRowControls() return sectionCard(gtx, u.theme, "CUSTOM FIELDS", "Add key/value pairs. Changes are only saved when you save the entry.", func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { diff --git a/internal/appui/main_test.go b/internal/appui/main_test.go index 9082337..6634bf2 100644 --- a/internal/appui/main_test.go +++ b/internal/appui/main_test.go @@ -3830,6 +3830,21 @@ func TestUILoadSelectedEntryIntoEditorPopulatesStructuredCustomFields(t *testing } } +func TestUIRemoveCustomFieldRowToleratesMissingClickables(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{}) + u.customFieldKeys = []widget.Editor{{SingleLine: true}} + u.customFieldValues = []widget.Editor{{SingleLine: true}} + u.removeCustomFields = nil + + u.removeCustomFieldRow(0) + + if len(u.customFieldKeys) != 1 || len(u.customFieldValues) != 1 || len(u.removeCustomFields) != 1 { + t.Fatalf("custom field rows after remove with missing clickables = %d/%d/%d, want one blank row", len(u.customFieldKeys), len(u.customFieldValues), len(u.removeCustomFields)) + } +} + func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) { t.Parallel() @@ -9126,6 +9141,76 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) { } } +func TestUIExtraStringValuesAreMaskedUntilRevealed(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Path: []string{"Root", "Internet"}, + Fields: map[string]string{ + "OTPSeed": "green-light", + }, + }, + }, + }) + u.showEntriesSection() + u.state.NavigateToPath([]string{"Root", "Internet"}) + u.filter() + u.state.SelectedEntryID = "vault-console" + + fields := u.detailExtraStrings() + if len(fields) != 1 { + t.Fatalf("len(detailExtraStrings()) = %d, want 1", len(fields)) + } + if fields[0].Value != strings.Repeat("•", len("green-light")) { + t.Fatalf("detailExtraStrings()[0].Value hidden = %q, want masked value", fields[0].Value) + } + + u.toggleExtraStringReveal("OTPSeed") + fields = u.detailExtraStrings() + if fields[0].Value != "green-light" { + t.Fatalf("detailExtraStrings()[0].Value revealed = %q, want green-light", fields[0].Value) + } +} + +func TestUICopyExtraStringWritesClipboardWithoutLeakingStatus(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Path: []string{"Root", "Internet"}, + Fields: map[string]string{ + "OTPSeed": "green-light", + }, + }, + }, + }) + writer := &memoryClipboardWriter{} + u.clipboardWriter = writer + u.showEntriesSection() + u.state.NavigateToPath([]string{"Root", "Internet"}) + u.filter() + u.state.SelectedEntryID = "vault-console" + + u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction("OTPSeed") }) + + if writer.content != "green-light" { + t.Fatalf("clipboard content = %q, want green-light", writer.content) + } + if u.state.StatusMessage != "copy extra string complete" { + t.Fatalf("state.StatusMessage = %q, want copy extra string complete", u.state.StatusMessage) + } + if strings.Contains(u.state.StatusMessage, "green-light") { + t.Fatalf("state.StatusMessage = %q, must not contain copied extra string value", u.state.StatusMessage) + } +} + func TestUIPasswordTogglePresentationMatchesVisibility(t *testing.T) { t.Parallel() diff --git a/internal/clipboard/service.go b/internal/clipboard/service.go index 639d67d..365f3b3 100644 --- a/internal/clipboard/service.go +++ b/internal/clipboard/service.go @@ -45,6 +45,22 @@ func (s Service) Copy(model vault.Model, entryID string, target Target) error { return nil } +func (s Service) CopyCustomField(model vault.Model, entryID, key string) error { + entry, err := findEntry(model, entryID) + if err != nil { + return err + } + + content, ok := entry.Fields[key] + if !ok { + return ErrUnsupportedTarget + } + if err := s.writer().WriteText(content); err != nil { + return writeError{err: err} + } + return nil +} + func (s Service) writer() Writer { if s.Writer != nil { return s.Writer diff --git a/internal/clipboard/service_test.go b/internal/clipboard/service_test.go index a601bb1..91ec05c 100644 --- a/internal/clipboard/service_test.go +++ b/internal/clipboard/service_test.go @@ -48,6 +48,30 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) { } } +func TestServiceCopiesCustomField(t *testing.T) { + t.Parallel() + + var writer memoryWriter + service := Service{Writer: &writer} + model := vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Fields: map[string]string{ + "OTPSeed": "green-light", + }, + }, + }, + } + + if err := service.CopyCustomField(model, "vault-console", "OTPSeed"); err != nil { + t.Fatalf("CopyCustomField(vault-console, OTPSeed) error = %v", err) + } + if writer.content != "green-light" { + t.Fatalf("clipboard content = %q, want green-light", writer.content) + } +} + func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) { t.Parallel()