Detailed changes
@@ -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.
@@ -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)
@@ -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
@@ -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()))
}
@@ -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 {