feat: multiple attachments and better filepicker (#227)

Drew Smirnoff created

Signed-off-by: drew <me@andrinoff.com>

Change summary

config/cache.go   |  26 ++++----
main.go           |  12 +-
tui/composer.go   | 124 ++++++++++++++++++++++++----------------
tui/filepicker.go | 148 +++++++++++++++++++++++++++++++++++++++++++++---
tui/messages.go   |  26 ++++----
5 files changed, 243 insertions(+), 93 deletions(-)

Detailed changes

config/cache.go 🔗

@@ -246,19 +246,19 @@ func SearchContacts(query string) []Contact {
 
 // Draft stores a saved email draft.
 type Draft struct {
-	ID             string    `json:"id"`
-	To             string    `json:"to"`
-	Cc             string    `json:"cc,omitempty"`
-	Bcc            string    `json:"bcc,omitempty"`
-	Subject        string    `json:"subject"`
-	Body           string    `json:"body"`
-	AttachmentPath string    `json:"attachment_path,omitempty"`
-	AccountID      string    `json:"account_id"`
-	InReplyTo      string    `json:"in_reply_to,omitempty"`
-	References     []string  `json:"references,omitempty"`
-	QuotedText     string    `json:"quoted_text,omitempty"`
-	CreatedAt      time.Time `json:"created_at"`
-	UpdatedAt      time.Time `json:"updated_at"`
+	ID              string    `json:"id"`
+	To              string    `json:"to"`
+	Cc              string    `json:"cc,omitempty"`
+	Bcc             string    `json:"bcc,omitempty"`
+	Subject         string    `json:"subject"`
+	Body            string    `json:"body"`
+	AttachmentPaths []string  `json:"attachment_paths,omitempty"`
+	AccountID       string    `json:"account_id"`
+	InReplyTo       string    `json:"in_reply_to,omitempty"`
+	References      []string  `json:"references,omitempty"`
+	QuotedText      string    `json:"quoted_text,omitempty"`
+	CreatedAt       time.Time `json:"created_at"`
+	UpdatedAt       time.Time `json:"updated_at"`
 }
 
 // DraftsCache stores all saved drafts.

main.go 🔗

@@ -1451,14 +1451,14 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd {
 
 		htmlBody := markdownToHTML([]byte(body))
 
-		if msg.AttachmentPath != "" {
-			fileData, err := os.ReadFile(msg.AttachmentPath)
+		for _, attachPath := range msg.AttachmentPaths {
+			fileData, err := os.ReadFile(attachPath)
 			if err != nil {
-				log.Printf("Could not read attachment file %s: %v", msg.AttachmentPath, err)
-			} else {
-				_, filename := filepath.Split(msg.AttachmentPath)
-				attachments[filename] = fileData
+				log.Printf("Could not read attachment file %s: %v", attachPath, err)
+				continue
 			}
+			_, filename := filepath.Split(attachPath)
+			attachments[filename] = fileData
 		}
 
 		err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME)

tui/composer.go 🔗

@@ -2,6 +2,7 @@ package tui
 
 import (
 	"fmt"
+	"path/filepath"
 	"strings"
 
 	"charm.land/bubbles/v2/textarea"
@@ -47,19 +48,19 @@ 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
-	attachmentPath 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
+	encryptSMIME    bool
+	width           int
+	height          int
+	confirmingExit  bool
+	hideTips        bool
 
 	// Multi-account support
 	accounts           []config.Account
@@ -194,7 +195,13 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.signatureInput.SetWidth(inputWidth)
 
 	case FileSelectedMsg:
-		m.attachmentPath = msg.Path
+		// Avoid duplicates
+		for _, p := range m.attachmentPaths {
+			if p == msg.Path {
+				return m, nil
+			}
+		}
+		m.attachmentPaths = append(m.attachmentPaths, msg.Path)
 		return m, nil
 
 	case tea.KeyPressMsg:
@@ -340,6 +347,12 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 			return m, tea.Batch(cmds...)
 
+		case "backspace", "delete", "d":
+			if m.focusIndex == focusAttachment && len(m.attachmentPaths) > 0 {
+				m.attachmentPaths = m.attachmentPaths[:len(m.attachmentPaths)-1]
+				return m, nil
+			}
+
 		case "enter", " ":
 			switch m.focusIndex {
 			case focusFrom:
@@ -365,19 +378,19 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 					}
 					return m, func() tea.Msg {
 						return SendEmailMsg{
-							To:             m.toInput.Value(),
-							Cc:             m.ccInput.Value(),
-							Bcc:            m.bccInput.Value(),
-							Subject:        m.subjectInput.Value(),
-							Body:           m.bodyInput.Value(),
-							AttachmentPath: m.attachmentPath,
-							AccountID:      accountID,
-							QuotedText:     m.quotedText,
-							InReplyTo:      m.inReplyTo,
-							References:     m.references,
-							Signature:      m.signatureInput.Value(),
-							SignSMIME:      acc != nil && acc.SMIMESignByDefault,
-							EncryptSMIME:   m.encryptSMIME,
+							To:              m.toInput.Value(),
+							Cc:              m.ccInput.Value(),
+							Bcc:             m.bccInput.Value(),
+							Subject:         m.subjectInput.Value(),
+							Body:            m.bodyInput.Value(),
+							AttachmentPaths: m.attachmentPaths,
+							AccountID:       accountID,
+							QuotedText:      m.quotedText,
+							InReplyTo:       m.inReplyTo,
+							References:      m.references,
+							Signature:       m.signatureInput.Value(),
+							SignSMIME:       acc != nil && acc.SMIMESignByDefault,
+							EncryptSMIME:    m.encryptSMIME,
 						}
 					}
 				}
@@ -454,15 +467,24 @@ func (m *Composer) View() tea.View {
 	}
 
 	var attachmentField string
-	attachmentText := "None (Press Enter to select)"
-	if m.attachmentPath != "" {
-		attachmentText = m.attachmentPath
-	}
-
-	if m.focusIndex == focusAttachment {
-		attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachment: %s", attachmentText))
+	if len(m.attachmentPaths) == 0 {
+		attachmentText := "None (Enter to add)"
+		if m.focusIndex == focusAttachment {
+			attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachments: %s", attachmentText))
+		} else {
+			attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachments: %s", attachmentText))
+		}
 	} else {
-		attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachment: %s", attachmentText))
+		var names []string
+		for _, p := range m.attachmentPaths {
+			names = append(names, filepath.Base(p))
+		}
+		attachmentText := strings.Join(names, ", ")
+		if m.focusIndex == focusAttachment {
+			attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachments (%d): %s", len(m.attachmentPaths), attachmentText))
+		} else {
+			attachmentField = blurredStyle.Render(fmt.Sprintf("  Attachments (%d): %s", len(m.attachmentPaths), attachmentText))
+		}
 	}
 
 	encToggle := "[ ]"
@@ -517,7 +539,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 = "Press Enter to select a file to attach to this email."
+		tip = "Enter: add file • backspace/d: remove last attachment"
 	case focusEncryptSMIME:
 		tip = "Press Space or Enter to toggle S/MIME encryption on or off."
 	case focusSend:
@@ -649,9 +671,9 @@ func (m *Composer) GetBody() string {
 	return m.bodyInput.Value()
 }
 
-// GetAttachmentPath returns the current attachment path.
-func (m *Composer) GetAttachmentPath() string {
-	return m.attachmentPath
+// GetAttachmentPaths returns the current attachment paths.
+func (m *Composer) GetAttachmentPaths() []string {
+	return m.attachmentPaths
 }
 
 // GetSignature returns the current signature value.
@@ -688,17 +710,17 @@ func (m *Composer) GetReferences() []string {
 // ToDraft converts the composer state to a Draft for saving.
 func (m *Composer) ToDraft() config.Draft {
 	return config.Draft{
-		ID:             m.draftID,
-		To:             m.toInput.Value(),
-		Cc:             m.ccInput.Value(),
-		Bcc:            m.bccInput.Value(),
-		Subject:        m.subjectInput.Value(),
-		Body:           m.bodyInput.Value(),
-		AttachmentPath: m.attachmentPath,
-		AccountID:      m.GetSelectedAccountID(),
-		InReplyTo:      m.inReplyTo,
-		References:     m.references,
-		QuotedText:     m.quotedText,
+		ID:              m.draftID,
+		To:              m.toInput.Value(),
+		Cc:              m.ccInput.Value(),
+		Bcc:             m.bccInput.Value(),
+		Subject:         m.subjectInput.Value(),
+		Body:            m.bodyInput.Value(),
+		AttachmentPaths: m.attachmentPaths,
+		AccountID:       m.GetSelectedAccountID(),
+		InReplyTo:       m.inReplyTo,
+		References:      m.references,
+		QuotedText:      m.quotedText,
 	}
 }
 
@@ -708,7 +730,7 @@ func NewComposerFromDraft(draft config.Draft, accounts []config.Account, hideTip
 	m.ccInput.SetValue(draft.Cc)
 	m.bccInput.SetValue(draft.Bcc)
 	m.draftID = draft.ID
-	m.attachmentPath = draft.AttachmentPath
+	m.attachmentPaths = draft.AttachmentPaths
 	m.inReplyTo = draft.InReplyTo
 	m.references = draft.References
 	m.quotedText = draft.QuotedText

tui/filepicker.go 🔗

@@ -7,6 +7,7 @@ import (
 	"path/filepath"
 	"strings"
 
+	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 )
@@ -15,6 +16,7 @@ var (
 	filePickerItemStyle         = lipgloss.NewStyle().PaddingLeft(2)
 	filePickerSelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("42"))
 	directoryStyle              = lipgloss.NewStyle().Foreground(lipgloss.Color("34"))
+	fileSizeStyle               = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
 )
 
 type FilePicker struct {
@@ -23,10 +25,21 @@ type FilePicker struct {
 	items       []fs.DirEntry
 	width       int
 	height      int
+	showHidden  bool
+	pathInput   textinput.Model
+	editingPath bool
 }
 
 func NewFilePicker(startPath string) *FilePicker {
-	fp := &FilePicker{currentPath: startPath}
+	pi := textinput.New()
+	pi.Placeholder = "Type a path and press Enter..."
+	pi.Prompt = "Go to: "
+	pi.CharLimit = 512
+
+	fp := &FilePicker{
+		currentPath: startPath,
+		pathInput:   pi,
+	}
 	fp.readDir()
 	return fp
 }
@@ -34,12 +47,20 @@ func NewFilePicker(startPath string) *FilePicker {
 func (m *FilePicker) readDir() {
 	files, err := os.ReadDir(m.currentPath)
 	if err != nil {
-		// Handle error, maybe show a message
 		m.items = []fs.DirEntry{}
 		return
 	}
+	if !m.showHidden {
+		filtered := files[:0]
+		for _, f := range files {
+			if !strings.HasPrefix(f.Name(), ".") {
+				filtered = append(filtered, f)
+			}
+		}
+		files = filtered
+	}
 	m.items = files
-	m.cursor = 0 // Reset cursor
+	m.cursor = 0
 }
 
 func (m *FilePicker) Init() tea.Cmd {
@@ -53,6 +74,49 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.height = msg.Height
 
 	case tea.KeyPressMsg:
+		// Path input mode
+		if m.editingPath {
+			switch msg.String() {
+			case "enter":
+				path := m.pathInput.Value()
+				if path == "" {
+					m.editingPath = false
+					m.pathInput.Blur()
+					return m, nil
+				}
+				// Expand ~ to home dir
+				if strings.HasPrefix(path, "~") {
+					if home, err := os.UserHomeDir(); err == nil {
+						path = filepath.Join(home, path[1:])
+					}
+				}
+				info, err := os.Stat(path)
+				if err == nil {
+					if info.IsDir() {
+						m.currentPath = path
+						m.readDir()
+					} else {
+						// It's a file — navigate to its parent and select it
+						m.currentPath = filepath.Dir(path)
+						m.readDir()
+					}
+				}
+				m.editingPath = false
+				m.pathInput.Blur()
+				m.pathInput.SetValue("")
+				return m, nil
+			case "esc":
+				m.editingPath = false
+				m.pathInput.Blur()
+				m.pathInput.SetValue("")
+				return m, nil
+			}
+			var cmd tea.Cmd
+			m.pathInput, cmd = m.pathInput.Update(msg)
+			return m, cmd
+		}
+
+		// Normal browsing mode
 		switch msg.String() {
 		case "up", "k":
 			if m.cursor > 0 {
@@ -62,6 +126,18 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			if m.cursor < len(m.items)-1 {
 				m.cursor++
 			}
+		case "/":
+			m.editingPath = true
+			m.pathInput.Focus()
+			return m, nil
+		case "~":
+			if home, err := os.UserHomeDir(); err == nil {
+				m.currentPath = home
+				m.readDir()
+			}
+		case "h":
+			m.showHidden = !m.showHidden
+			m.readDir()
 		case "enter":
 			if len(m.items) == 0 {
 				return m, nil
@@ -73,15 +149,13 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				m.currentPath = newPath
 				m.readDir()
 			} else {
-				// It's a file, send a message with the path
 				return m, func() tea.Msg {
 					return FileSelectedMsg{Path: newPath}
 				}
 			}
 		case "backspace":
-			// Go up one directory
 			parentDir := filepath.Dir(m.currentPath)
-			if parentDir != m.currentPath { // Avoid getting stuck at root
+			if parentDir != m.currentPath {
 				m.currentPath = parentDir
 				m.readDir()
 			}
@@ -92,24 +166,70 @@ func (m *FilePicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return m, nil
 }
 
+func formatFileSize(size int64) string {
+	switch {
+	case size < 1024:
+		return fmt.Sprintf("%dB", size)
+	case size < 1024*1024:
+		return fmt.Sprintf("%.1fK", float64(size)/1024)
+	case size < 1024*1024*1024:
+		return fmt.Sprintf("%.1fM", float64(size)/(1024*1024))
+	default:
+		return fmt.Sprintf("%.1fG", float64(size)/(1024*1024*1024))
+	}
+}
+
 func (m *FilePicker) View() tea.View {
 	var b strings.Builder
 
 	b.WriteString(titleStyle.Render("Select a File") + "\n")
-	b.WriteString(fmt.Sprintf("Current Path: %s\n\n", m.currentPath))
+	b.WriteString(fmt.Sprintf("  %s\n", m.currentPath))
+
+	if m.editingPath {
+		b.WriteString(m.pathInput.View() + "\n")
+	}
+
+	b.WriteString("\n")
 
-	for i, item := range m.items {
+	// Calculate how many items we can show (reserve lines for header + help)
+	headerLines := 3
+	if m.editingPath {
+		headerLines++
+	}
+	helpLines := 2
+	visibleItems := m.height - headerLines - helpLines
+	if visibleItems < 3 {
+		visibleItems = 3
+	}
+
+	// Calculate scroll window
+	start := 0
+	if m.cursor >= visibleItems {
+		start = m.cursor - visibleItems + 1
+	}
+	end := start + visibleItems
+	if end > len(m.items) {
+		end = len(m.items)
+	}
+
+	for i := start; i < end; i++ {
+		item := m.items[i]
 		cursor := "  "
 		if m.cursor == i {
 			cursor = "> "
 		}
 
 		itemName := item.Name()
+		sizeStr := ""
 		if item.IsDir() {
 			itemName = directoryStyle.Render(itemName + "/")
+		} else {
+			if info, err := item.Info(); err == nil {
+				sizeStr = fileSizeStyle.Render("  " + formatFileSize(info.Size()))
+			}
 		}
 
-		line := fmt.Sprintf("%s%s", cursor, itemName)
+		line := fmt.Sprintf("%s%s%s", cursor, itemName, sizeStr)
 
 		if m.cursor == i {
 			b.WriteString(filePickerSelectedItemStyle.Render(line))
@@ -119,7 +239,15 @@ func (m *FilePicker) View() tea.View {
 		b.WriteString("\n")
 	}
 
-	b.WriteString("\n" + helpStyle.Render("↑/↓: navigate • enter: select • backspace: up • esc: cancel"))
+	if len(m.items) == 0 {
+		b.WriteString(fileSizeStyle.Render("  (empty directory)") + "\n")
+	}
+
+	hiddenLabel := "show"
+	if m.showHidden {
+		hiddenLabel = "hide"
+	}
+	b.WriteString("\n" + helpStyle.Render(fmt.Sprintf("↑/↓: navigate • enter: select • backspace: up • /: go to path • ~: home • h: %s hidden • esc: cancel", hiddenLabel)))
 
 	return tea.NewView(docStyle.Render(b.String()))
 }

tui/messages.go 🔗

@@ -22,19 +22,19 @@ type ViewEmailMsg struct {
 }
 
 type SendEmailMsg struct {
-	To             string
-	Cc             string // Cc recipient(s)
-	Bcc            string // Bcc recipient(s)
-	Subject        string
-	Body           string
-	AttachmentPath string
-	InReplyTo      string
-	References     []string
-	AccountID      string // ID of the account to send from
-	QuotedText     string // Hidden quoted text appended when sending
-	Signature      string // Signature to append to email body
-	SignSMIME      bool   // Whether to sign the email using S/MIME
-	EncryptSMIME   bool   // Whether to encrypt the email using S/MIME
+	To              string
+	Cc              string // Cc recipient(s)
+	Bcc             string // Bcc recipient(s)
+	Subject         string
+	Body            string
+	AttachmentPaths []string
+	InReplyTo       string
+	References      []string
+	AccountID       string // ID of the account to send from
+	QuotedText      string // Hidden quoted text appended when sending
+	Signature       string // Signature to append to email body
+	SignSMIME       bool   // Whether to sign the email using S/MIME
+	EncryptSMIME    bool   // Whether to encrypt the email using S/MIME
 }
 
 type Credentials struct {