From 00717f32ce1742b16856e1b00ca02c854e279d60 Mon Sep 17 00:00:00 2001 From: Joe Julian Date: Sun, 29 Mar 2026 11:22:13 -0700 Subject: [PATCH] Add attachment replace workflow UI --- appstate/state.go | 39 +++++++++++++++++++ appstate/state_test.go | 86 ++++++++++++++++++++++++++++++++++++++++++ main.go | 48 +++++++++++++++++++++++ main_test.go | 63 ++++++++++++++++++++++++++++++- ui_editor.go | 56 ++++++++++++++++++++++----- ui_forms.go | 41 ++++++++++++++++++++ 6 files changed, 322 insertions(+), 11 deletions(-) diff --git a/appstate/state.go b/appstate/state.go index 6285dd8..880416b 100644 --- a/appstate/state.go +++ b/appstate/state.go @@ -1,6 +1,7 @@ package appstate import ( + "errors" "fmt" "slices" "strings" @@ -11,6 +12,11 @@ import ( type Section string +var ( + ErrAttachmentAlreadyExists = errors.New("attachment already exists") + ErrAttachmentNotFound = errors.New("attachment not found") +) + const ( SectionEntries Section = "" SectionTemplates Section = "templates" @@ -639,6 +645,36 @@ func (s *State) AddAttachmentToSelectedEntry(name string, content []byte) error if model.Entries[i].Attachments == nil { model.Entries[i].Attachments = map[string][]byte{} } + if _, exists := model.Entries[i].Attachments[name]; exists { + return ErrAttachmentAlreadyExists + } + model.Entries[i].Attachments[name] = append([]byte(nil), content...) + session.Replace(model) + s.Dirty = true + return nil + } + + return vault.ErrEntryNotFound +} + +func (s *State) ReplaceAttachmentOnSelectedEntry(name string, content []byte) error { + session, ok := s.Session.(MutableSession) + if !ok { + return fmt.Errorf("session is not mutable") + } + + model, err := session.Current() + if err != nil { + return err + } + + for i := range model.Entries { + if model.Entries[i].ID != s.SelectedEntryID { + continue + } + if _, exists := model.Entries[i].Attachments[name]; !exists { + return ErrAttachmentNotFound + } model.Entries[i].Attachments[name] = append([]byte(nil), content...) session.Replace(model) s.Dirty = true @@ -663,6 +699,9 @@ func (s *State) DeleteAttachmentFromSelectedEntry(name string) error { if model.Entries[i].ID != s.SelectedEntryID { continue } + if _, exists := model.Entries[i].Attachments[name]; !exists { + return ErrAttachmentNotFound + } delete(model.Entries[i].Attachments, name) if len(model.Entries[i].Attachments) == 0 { model.Entries[i].Attachments = nil diff --git a/appstate/state_test.go b/appstate/state_test.go index ef864cf..dcfe94f 100644 --- a/appstate/state_test.go +++ b/appstate/state_test.go @@ -1056,6 +1056,76 @@ func TestAddAttachmentToSelectedEntryPersistsAndMarksDirty(t *testing.T) { } } +func TestAddAttachmentToSelectedEntryRejectsDuplicateNames(t *testing.T) { + t.Parallel() + + model := testVaultModel() + model.Entries[0].Attachments = map[string][]byte{"token.txt": []byte("secret")} + sess := &mutableStubSession{model: model} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "bellagio", + } + + err := state.AddAttachmentToSelectedEntry("token.txt", []byte("replacement")) + if !errors.Is(err, ErrAttachmentAlreadyExists) { + t.Fatalf("AddAttachmentToSelectedEntry() error = %v, want ErrAttachmentAlreadyExists", err) + } + + got, currentErr := sess.Current() + if currentErr != nil { + t.Fatalf("Current() error = %v", currentErr) + } + if string(got.Entries[0].Attachments["token.txt"]) != "secret" { + t.Fatalf("attachment content = %q, want %q", got.Entries[0].Attachments["token.txt"], "secret") + } +} + +func TestReplaceAttachmentOnSelectedEntryPersistsAndMarksDirty(t *testing.T) { + t.Parallel() + + model := testVaultModel() + model.Entries[0].Attachments = map[string][]byte{"token.txt": []byte("secret")} + sess := &mutableStubSession{model: model} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "bellagio", + } + + if err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")); err != nil { + t.Fatalf("ReplaceAttachmentOnSelectedEntry() error = %v", err) + } + + got, err := state.VisibleEntries() + if err != nil { + t.Fatalf("VisibleEntries() error = %v", err) + } + if string(got[0].Attachments["token.txt"]) != "replacement" { + t.Fatalf("attachment content = %q, want %q", got[0].Attachments["token.txt"], "replacement") + } + if !state.Dirty { + t.Fatal("Dirty = false, want true after ReplaceAttachmentOnSelectedEntry") + } +} + +func TestReplaceAttachmentOnSelectedEntryRequiresExistingAttachment(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: testVaultModel()} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "bellagio", + } + + err := state.ReplaceAttachmentOnSelectedEntry("token.txt", []byte("replacement")) + if !errors.Is(err, ErrAttachmentNotFound) { + t.Fatalf("ReplaceAttachmentOnSelectedEntry() error = %v, want ErrAttachmentNotFound", err) + } +} + func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) { t.Parallel() @@ -1084,6 +1154,22 @@ func TestDeleteAttachmentFromSelectedEntryPersistsAndMarksDirty(t *testing.T) { } } +func TestDeleteAttachmentFromSelectedEntryRequiresExistingAttachment(t *testing.T) { + t.Parallel() + + sess := &mutableStubSession{model: testVaultModel()} + state := State{ + Session: sess, + CurrentPath: []string{"Root", "Internet"}, + SelectedEntryID: "bellagio", + } + + err := state.DeleteAttachmentFromSelectedEntry("token.txt") + if !errors.Is(err, ErrAttachmentNotFound) { + t.Fatalf("DeleteAttachmentFromSelectedEntry() error = %v, want ErrAttachmentNotFound", err) + } +} + type mutableStubSession struct { model vault.Model err error diff --git a/main.go b/main.go index 621eddd..1ec8d61 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "image" "image/color" "os" + "slices" "strings" "gioui.org/app" @@ -34,6 +35,8 @@ const ( desktopSubtitle = "KeePass-compatible password management for desktop-first workflows" ) +const maxAttachmentBytes = 10 << 20 + type bannerKind string const ( @@ -54,6 +57,11 @@ type uiSurface struct { Locked bool } +type attachmentItem struct { + Name string + Size int +} + type ui struct { mode string theme *material.Theme @@ -103,6 +111,7 @@ type ui struct { deleteTemplate widget.Clickable instantiateTemplate widget.Clickable addAttachment widget.Clickable + replaceAttachment widget.Clickable removeAttachment widget.Clickable exportAttachment widget.Clickable restoreHistory widget.Clickable @@ -119,6 +128,7 @@ type ui struct { masterKeyComposite widget.Clickable entryClicks []widget.Clickable historyClicks []widget.Clickable + attachmentClicks []widget.Clickable breadcrumbs []widget.Clickable groupClicks []widget.Clickable state appstate.State @@ -234,6 +244,41 @@ func (u *ui) filter() { } } +func (u *ui) selectedAttachmentItems() []attachmentItem { + item, ok := u.selectedEntry() + if !ok || len(item.Attachments) == 0 { + return nil + } + + items := make([]attachmentItem, 0, len(item.Attachments)) + for name, content := range item.Attachments { + items = append(items, attachmentItem{Name: name, Size: len(content)}) + } + slices.SortFunc(items, func(a, b attachmentItem) int { + switch { + case a.Name < b.Name: + return -1 + case a.Name > b.Name: + return 1 + default: + return 0 + } + }) + if len(u.attachmentClicks) < len(items) { + u.attachmentClicks = make([]widget.Clickable, len(items)) + } + return items +} + +func (u *ui) selectedAttachmentNames() []string { + items := u.selectedAttachmentItems() + names := make([]string, 0, len(items)) + for _, item := range items { + names = append(names, item.Name) + } + return names +} + func (u *ui) showEntriesSection() { u.state.Section = appstate.SectionEntries u.state.NavigateToPath(nil) @@ -627,6 +672,9 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions { for u.addAttachment.Clicked(gtx) { u.runAction("add attachment", u.addAttachmentAction) } + for u.replaceAttachment.Clicked(gtx) { + u.runAction("replace attachment", u.replaceAttachmentAction) + } for u.removeAttachment.Clicked(gtx) { u.runAction("remove attachment", u.removeAttachmentAction) } diff --git a/main_test.go b/main_test.go index a819866..e0a95ee 100644 --- a/main_test.go +++ b/main_test.go @@ -1015,6 +1015,19 @@ func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) { if err := u.addAttachmentAction(); err != nil { t.Fatalf("addAttachmentAction() error = %v", err) } + if got := u.selectedAttachmentNames(); !slices.Equal(got, []string{"token.txt"}) { + t.Fatalf("selectedAttachmentNames() = %v, want [token.txt]", got) + } + + replacementPath := filepath.Join(t.TempDir(), "token-replacement.txt") + replacement := []byte("attachment-replacement") + if err := os.WriteFile(replacementPath, replacement, 0o600); err != nil { + t.Fatalf("WriteFile(replacementPath) error = %v", err) + } + u.attachmentPath.SetText(replacementPath) + if err := u.replaceAttachmentAction(); err != nil { + t.Fatalf("replaceAttachmentAction() error = %v", err) + } u.exportAttachmentPath.SetText(attachmentExportPath) if err := u.exportAttachmentAction(); err != nil { @@ -1025,8 +1038,8 @@ func TestUITemplateAndAttachmentActionsWorkThroughEditor(t *testing.T) { if err != nil { t.Fatalf("ReadFile(exportAttachmentPath) error = %v", err) } - if !bytes.Equal(exported, content) { - t.Fatalf("exported attachment = %q, want %q", exported, content) + if !bytes.Equal(exported, replacement) { + t.Fatalf("exported attachment = %q, want %q", exported, replacement) } if err := u.removeAttachmentAction(); err != nil { @@ -1152,6 +1165,52 @@ func TestUITemplatesCanBeBrowsedCreatedEditedDeletedAndInstantiated(t *testing.T } } +func TestUIAttachmentActionsRejectDuplicateMissingAndOversizeCases(t *testing.T) { + t.Parallel() + + u := newUIWithModel("desktop", vault.Model{ + Entries: []vault.Entry{ + { + ID: "vault-console", + Title: "Vault Console", + Attachments: map[string][]byte{"token.txt": []byte("original")}, + Path: []string{"Root", "Internet"}, + }, + }, + }) + u.showEntriesSection() + u.state.NavigateToPath([]string{"Root", "Internet"}) + u.filter() + u.state.SelectedEntryID = "vault-console" + u.loadSelectedEntryIntoEditor() + + addPath := filepath.Join(t.TempDir(), "token.txt") + if err := os.WriteFile(addPath, []byte("duplicate"), 0o600); err != nil { + t.Fatalf("WriteFile(addPath) error = %v", err) + } + u.attachmentName.SetText("token.txt") + u.attachmentPath.SetText(addPath) + if err := u.addAttachmentAction(); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("addAttachmentAction() error = %v, want duplicate-name failure", err) + } + + u.attachmentName.SetText("missing.txt") + if err := u.replaceAttachmentAction(); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("replaceAttachmentAction() error = %v, want missing-attachment failure", err) + } + + oversizePath := filepath.Join(t.TempDir(), "oversize.bin") + oversizeContent := bytes.Repeat([]byte("a"), maxAttachmentBytes+1) + if err := os.WriteFile(oversizePath, oversizeContent, 0o600); err != nil { + t.Fatalf("WriteFile(oversizePath) error = %v", err) + } + u.attachmentName.SetText("oversize.bin") + u.attachmentPath.SetText(oversizePath) + if err := u.addAttachmentAction(); err == nil || !strings.Contains(err.Error(), "too large") { + t.Fatalf("addAttachmentAction() oversize error = %v, want size failure", err) + } +} + func TestUIRestoresSelectedEntryHistoryVersion(t *testing.T) { t.Parallel() diff --git a/ui_editor.go b/ui_editor.go index 65a08f9..5d83bbd 100644 --- a/ui_editor.go +++ b/ui_editor.go @@ -11,6 +11,32 @@ import ( "git.julianfamily.org/keepassgo/vault" ) +func (u *ui) attachmentInput() (string, []byte, error) { + name := strings.TrimSpace(u.attachmentName.Text()) + if name == "" { + return "", nil, fmt.Errorf("attachment name is required") + } + + path := strings.TrimSpace(u.attachmentPath.Text()) + if path == "" { + return "", nil, fmt.Errorf("attachment path is required") + } + + info, err := os.Stat(path) + if err != nil { + return "", nil, fmt.Errorf("stat attachment: %w", err) + } + if info.Size() > maxAttachmentBytes { + return "", nil, fmt.Errorf("attachment too large: %d bytes exceeds %d byte limit", info.Size(), maxAttachmentBytes) + } + + content, err := os.ReadFile(path) + if err != nil { + return "", nil, fmt.Errorf("read attachment: %w", err) + } + return name, content, nil +} + func (u *ui) loadSelectedEntryIntoEditor() { u.selectedHistoryIndex = -1 u.historyIndex.SetText("") @@ -185,16 +211,10 @@ func (u *ui) instantiateSelectedTemplateAction() error { } func (u *ui) addAttachmentAction() error { - content, err := os.ReadFile(strings.TrimSpace(u.attachmentPath.Text())) + name, content, err := u.attachmentInput() if err != nil { - return fmt.Errorf("read attachment: %w", err) + return err } - - name := strings.TrimSpace(u.attachmentName.Text()) - if name == "" { - return fmt.Errorf("attachment name is required") - } - if err := u.state.AddAttachmentToSelectedEntry(name, content); err != nil { return err } @@ -204,6 +224,20 @@ func (u *ui) addAttachmentAction() error { return nil } +func (u *ui) replaceAttachmentAction() error { + name, content, err := u.attachmentInput() + if err != nil { + return err + } + if err := u.state.ReplaceAttachmentOnSelectedEntry(name, content); err != nil { + return err + } + u.loadSelectedEntryIntoEditor() + u.attachmentName.SetText(name) + u.filter() + return nil +} + func (u *ui) exportAttachmentAction() error { item, ok := u.selectedEntry() if !ok { @@ -216,7 +250,11 @@ func (u *ui) exportAttachmentAction() error { return fmt.Errorf("attachment not found") } - if err := os.WriteFile(strings.TrimSpace(u.exportAttachmentPath.Text()), content, 0o600); err != nil { + exportPath := strings.TrimSpace(u.exportAttachmentPath.Text()) + if exportPath == "" { + return fmt.Errorf("export attachment path is required") + } + if err := os.WriteFile(exportPath, content, 0o600); err != nil { return fmt.Errorf("write attachment export: %w", err) } return nil diff --git a/ui_forms.go b/ui_forms.go index 3ca93e4..f30b673 100644 --- a/ui_forms.go +++ b/ui_forms.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "strings" "gioui.org/layout" @@ -84,6 +85,34 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions { ) } +func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions { + items := u.selectedAttachmentItems() + if len(items) == 0 { + lbl := material.Label(u.theme, unit.Sp(13), "No attachments on this entry.") + lbl.Color = mutedColor + return lbl.Layout(gtx) + } + + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, func() []layout.FlexChild { + children := make([]layout.FlexChild, 0, len(items)*2) + for i, item := range items { + index := i + itemName := item.Name + label := fmt.Sprintf("%s (%d B)", itemName, item.Size) + children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions { + for u.attachmentClicks[index].Clicked(gtx) { + u.attachmentName.SetText(itemName) + } + return tonedButton(gtx, u.theme, &u.attachmentClicks[index], label) + })) + if i < len(items)-1 { + children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout)) + } + } + return children + }()...) +} + func (u *ui) groupControls(gtx layout.Context) layout.Dimensions { if u.state.Section == appstate.SectionRecycleBin { return layout.Dimensions{} @@ -186,6 +215,14 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { ) }), layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + lbl := material.Label(u.theme, unit.Sp(12), "ATTACHMENTS") + lbl.Color = mutedColor + return lbl.Layout(gtx) + }), + layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), + layout.Rigid(u.attachmentList), + layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout), layout.Rigid(labeledEditor(u.theme, "Attachment Name", &u.attachmentName, false)), layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout), layout.Rigid(labeledEditor(u.theme, "Attachment Path", &u.attachmentPath, false)), @@ -198,6 +235,10 @@ func (u *ui) entryEditorPanel(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.addAttachment, "Add Attachment") }), layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), + layout.Rigid(func(gtx layout.Context) layout.Dimensions { + return tonedButton(gtx, u.theme, &u.replaceAttachment, "Replace Attachment") + }), + layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.removeAttachment, "Remove Attachment") }),