feat: select composer attachments (#1259)

FromSi created

## What?

Added selectable attachment navigation in the composer. Attachments are
now shown as a focused list with a cursor, and `backspace`, `delete`, or
`d` removes the selected attachment instead of always removing the last
one.

## Why?

Previously, when multiple files were attached, the composer only allowed
removing the most recently added attachment. This makes it possible to
choose and remove any specific attachment without deleting newer ones
first.

Closes #543

Change summary

config/default_keybinds.json |   3 
config/keybinds.go           |   4 +
tui/composer.go              | 108 ++++++++++++++++++++++++++++---------
tui/composer_test.go         |  42 ++++++++++++++
4 files changed, 128 insertions(+), 29 deletions(-)

Detailed changes

config/default_keybinds.json 🔗

@@ -31,7 +31,8 @@
   "composer": {
     "external_editor": "ctrl+e",
     "next_field": "tab",
-    "prev_field": "shift+tab"
+    "prev_field": "shift+tab",
+    "delete": "d"
   },
   "folder": {
     "next_folder": "tab",

config/keybinds.go 🔗

@@ -61,6 +61,7 @@ type ComposerKeys struct {
 	ExternalEditor string `json:"external_editor"`
 	NextField      string `json:"next_field"`
 	PrevField      string `json:"prev_field"`
+	Delete         string `json:"delete"`
 }
 
 type FolderKeys struct {
@@ -105,7 +106,7 @@ func LoadKeybindsFromDir(cfgDir string) error {
 		return nil
 	}
 
-	var kb KeybindsConfig
+	kb := defaultKeybinds()
 	if err := json.Unmarshal(data, &kb); err != nil {
 		return fmt.Errorf("keybinds: parse %s: %w", path, err)
 	}
@@ -167,6 +168,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string {
 		"external_editor": kb.Composer.ExternalEditor,
 		"next_field":      kb.Composer.NextField,
 		"prev_field":      kb.Composer.PrevField,
+		"delete":          kb.Composer.Delete,
 	})
 	check("folder", map[string]string{
 		"next_folder":   kb.Folder.NextFolder,

tui/composer.go 🔗

@@ -47,20 +47,21 @@ const (
 
 // Composer model holds the state of the email composition UI.
 type Composer struct {
-	focusIndex      int
-	toInput         textinput.Model
-	ccInput         textinput.Model
-	bccInput        textinput.Model
-	subjectInput    textinput.Model
-	bodyInput       textarea.Model
-	signatureInput  textarea.Model
-	attachmentPaths []string
-	attachmentNames map[string]string
-	encryptSMIME    bool
-	width           int
-	height          int
-	confirmingExit  bool
-	hideTips        bool
+	focusIndex       int
+	toInput          textinput.Model
+	ccInput          textinput.Model
+	bccInput         textinput.Model
+	subjectInput     textinput.Model
+	bodyInput        textarea.Model
+	signatureInput   textarea.Model
+	attachmentPaths  []string
+	attachmentNames  map[string]string
+	attachmentCursor int
+	encryptSMIME     bool
+	width            int
+	height           int
+	confirmingExit   bool
+	hideTips         bool
 
 	// Multi-account support
 	accounts           []config.Account
@@ -249,6 +250,31 @@ func (m *Composer) attachmentDisplayName(path string) string {
 	return filepath.Base(path)
 }
 
+func (m *Composer) clampAttachmentCursor() {
+	if len(m.attachmentPaths) == 0 {
+		m.attachmentCursor = 0
+		return
+	}
+	if m.attachmentCursor < 0 {
+		m.attachmentCursor = 0
+	}
+	if m.attachmentCursor >= len(m.attachmentPaths) {
+		m.attachmentCursor = len(m.attachmentPaths) - 1
+	}
+}
+
+func (m *Composer) removeSelectedAttachment() {
+	if len(m.attachmentPaths) == 0 {
+		return
+	}
+
+	m.clampAttachmentCursor()
+	idx := m.attachmentCursor
+	delete(m.attachmentNames, m.attachmentPaths[idx])
+	m.attachmentPaths = append(m.attachmentPaths[:idx], m.attachmentPaths[idx+1:]...)
+	m.clampAttachmentCursor()
+}
+
 func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
 	var cmd tea.Cmd
@@ -299,6 +325,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.attachmentNames[newPath] = formatAttachmentName(newPath)
 			}
 		}
+		m.clampAttachmentCursor()
 		return m, nil
 
 	case tea.KeyPressMsg:
@@ -413,6 +440,18 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 		kb := config.Keybinds
+		attachmentPathSize := len(m.attachmentPaths)
+		if m.focusIndex == focusAttachment && attachmentPathSize > 0 {
+			switch msg.String() {
+			case "up", kb.Global.NavUp:
+				m.attachmentCursor = (m.attachmentCursor - 1 + attachmentPathSize) % attachmentPathSize
+				return m, nil
+			case "down", kb.Global.NavDown:
+				m.attachmentCursor = (m.attachmentCursor + 1) % attachmentPathSize
+				return m, nil
+			}
+		}
+
 		switch msg.String() {
 		case kb.Global.Quit:
 			return m, tea.Quit
@@ -470,10 +509,9 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 			return m, tea.Batch(cmds...)
 
-		case "backspace", "delete", "d":
+		case kb.Composer.Delete:
 			if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
-				delete(m.attachmentNames, m.attachmentPaths[len(m.attachmentPaths)-1])
-				m.attachmentPaths = m.attachmentPaths[:len(m.attachmentPaths)-1]
+				m.removeSelectedAttachment()
 				return m, nil
 			}
 
@@ -584,6 +622,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 func (m *Composer) View() tea.View {
 	var composerView strings.Builder
 	var button string
+	ck := config.Keybinds.Composer
 
 	if m.focusIndex == focusSend {
 		button = focusedStyle.Copy().Render("[ " + t("composer.send") + " ]")
@@ -626,16 +665,25 @@ func (m *Composer) View() tea.View {
 			attachmentField = blurredStyle.Render(fmt.Sprintf("  %s %s", t("composer.attachments"), attachmentText))
 		}
 	} else {
-		var names []string
-		for _, p := range m.attachmentPaths {
-			names = append(names, m.attachmentDisplayName(p))
-		}
-		attachmentText := strings.Join(names, ", ")
+		var b strings.Builder
+		headerPrefix := "  "
+		headerStyle := blurredStyle
 		if m.focusIndex == focusAttachment {
-			attachmentField = focusedStyle.Render(fmt.Sprintf("> %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
-		} else {
-			attachmentField = blurredStyle.Render(fmt.Sprintf("  %s (%d): %s", t("composer.attachments"), len(m.attachmentPaths), attachmentText))
+			headerPrefix = "> "
+			headerStyle = focusedStyle
+		}
+		b.WriteString(headerStyle.Render(fmt.Sprintf("%s%s (%d):", headerPrefix, t("composer.attachments"), len(m.attachmentPaths))))
+		for i, p := range m.attachmentPaths {
+			cursor := "    "
+			style := blurredStyle
+			if m.focusIndex == focusAttachment && i == m.attachmentCursor {
+				cursor = "  > "
+				style = focusedStyle
+			}
+			b.WriteString("\n")
+			b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, m.attachmentDisplayName(p))))
 		}
+		attachmentField = b.String()
 	}
 
 	encToggle := "[ ]"
@@ -690,7 +738,7 @@ func (m *Composer) View() tea.View {
 	case focusSignature:
 		tip = "Your email signature. This will be appended to the end of the email."
 	case focusAttachment:
-		tip = "Enter: add file • backspace/d: remove last attachment"
+		tip = fmt.Sprintf("Enter: add file • up/down: select attachment • %s: remove selected", ck.Delete)
 	case focusEncryptSMIME:
 		tip = "Press Space or Enter to toggle S/MIME encryption on or off."
 	case focusSend:
@@ -708,10 +756,15 @@ func (m *Composer) View() tea.View {
 		signatureLabel,
 		m.signatureInput.View(),
 		attachmentStyle.Render(attachmentField),
+	}
+	if len(m.attachmentPaths) > 0 {
+		composerViewElements = append(composerViewElements, "")
+	}
+	composerViewElements = append(composerViewElements,
 		smimeToggleStyle.Render(encField),
 		button,
 		"",
-	}
+	)
 
 	if !m.hideTips && tip != "" {
 		composerViewElements = append(composerViewElements, TipStyle.Render("Tip: "+tip))
@@ -971,6 +1024,7 @@ func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTip
 	for _, path := range m.attachmentPaths {
 		m.attachmentNames[path] = formatAttachmentName(path)
 	}
+	m.clampAttachmentCursor()
 	if m.isCatchAllAccount() && draft.FromOverride != "" {
 		m.fromInput.SetValue(draft.FromOverride)
 	}

tui/composer_test.go 🔗

@@ -249,6 +249,48 @@ func TestFormatAttachmentNameMissingFile(t *testing.T) {
 	}
 }
 
+func TestComposerAttachmentSelectionAndRemoval(t *testing.T) {
+	composer := NewComposer("", "", "", "", false)
+	composer.focusIndex = focusAttachment
+	composer.attachmentPaths = []string{"/tmp/a.txt", "/tmp/b.txt", "/tmp/c.txt"}
+	composer.attachmentNames = map[string]string{
+		"/tmp/a.txt": "a.txt",
+		"/tmp/b.txt": "b.txt",
+		"/tmp/c.txt": "c.txt",
+	}
+
+	model, _ := composer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+	composer = model.(*Composer)
+	if composer.attachmentCursor != 1 {
+		t.Fatalf("Expected attachmentCursor 1 after Down, got %d", composer.attachmentCursor)
+	}
+
+	model, _ = composer.Update(tea.KeyPressMsg{Code: 'd', Text: "d"})
+	composer = model.(*Composer)
+
+	want := []string{"/tmp/a.txt", "/tmp/c.txt"}
+	if len(composer.attachmentPaths) != len(want) {
+		t.Fatalf("Expected %d attachments after removal, got %d", len(want), len(composer.attachmentPaths))
+	}
+	for i, path := range want {
+		if composer.attachmentPaths[i] != path {
+			t.Fatalf("attachmentPaths[%d] = %q, want %q", i, composer.attachmentPaths[i], path)
+		}
+	}
+	if _, ok := composer.attachmentNames["/tmp/b.txt"]; ok {
+		t.Fatal("Expected removed attachment display name to be deleted")
+	}
+	if composer.attachmentCursor != 1 {
+		t.Fatalf("Expected cursor to stay on the next attachment, got %d", composer.attachmentCursor)
+	}
+
+	model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyDown})
+	composer = model.(*Composer)
+	if composer.attachmentCursor != 0 {
+		t.Fatalf("Expected attachmentCursor to wrap to 0 after Down, got %d", composer.attachmentCursor)
+	}
+}
+
 // TestComposerGetFromAddress verifies the from address formatting.
 func TestComposerGetFromAddress(t *testing.T) {
 	t.Run("With name", func(t *testing.T) {