Add attachment replace workflow UI

This commit is contained in:
Joe Julian
2026-03-29 11:22:13 -07:00
parent fc90e91174
commit 00717f32ce
6 changed files with 322 additions and 11 deletions
+39
View File
@@ -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
+86
View File
@@ -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
+48
View File
@@ -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)
}
+61 -2
View File
@@ -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()
+47 -9
View File
@@ -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
+41
View File
@@ -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")
}),