Add attachment replace workflow UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user