diff --git a/config/cache.go b/config/cache.go index f3df4d07c44eb1b51a9b72022b33575da2428912..890eac8845f25fccc6eabb009a6d56811aee2cea 100644 --- a/config/cache.go +++ b/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. diff --git a/main.go b/main.go index b51692e27d63a502622572541a350b932e99036e..56a45c77311a629c2179c39d5c1d6e807d28a692 100644 --- a/main.go +++ b/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) diff --git a/tui/composer.go b/tui/composer.go index cc1e0f329d43fbc2d4d19305de9d29d0235d5daf..0ba2084149fec62537edb44a1584a7ee77f3543a 100644 --- a/tui/composer.go +++ b/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 diff --git a/tui/filepicker.go b/tui/filepicker.go index 9aa2a33c599c5d0dbf25ca31c1330ee20883e5ea..4892342bf923db2af5ab426886abdf61d852770c 100644 --- a/tui/filepicker.go +++ b/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())) } diff --git a/tui/messages.go b/tui/messages.go index 2212953d309ae97812312d7d000916a83153f012..413fd0fc2ad4a21634ba48176a692ad575127a44 100644 --- a/tui/messages.go +++ b/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 {