From 4dfa8a8e52fd82916927ad1d2c20f5c6e5936669 Mon Sep 17 00:00:00 2001 From: drew Date: Tue, 29 Jul 2025 16:03:46 +0400 Subject: [PATCH] feat: attachment viewer + esc confirmation (#28, #26) --- fetcher/fetcher.go | 71 ++++++++++++------ main.go | 174 ++++++++++++++++++++++++++++----------------- tui/choice.go | 57 +++++++-------- tui/composer.go | 57 ++++++++++++--- tui/email_view.go | 105 ++++++++++++++++++++++----- tui/messages.go | 31 ++++++-- 6 files changed, 349 insertions(+), 146 deletions(-) diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index f4b80f5927687fe3aecb75d1ed4a2fbbaa1755da..8b3683ac2d4674930e3e4e3c960d4d0a1c4b9153 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -1,6 +1,7 @@ package fetcher import ( + "encoding/base64" "fmt" "io" "io/ioutil" @@ -17,15 +18,22 @@ import ( "golang.org/x/text/transform" ) +// Attachment holds data for an email attachment. +type Attachment struct { + Filename string + Data []byte +} + type Email struct { - UID uint32 // Added UID to uniquely identify emails - From string - To []string - Subject string - Body string - Date time.Time - MessageID string - References []string + UID uint32 + From string + To []string + Subject string + Body string + Date time.Time + MessageID string + References []string + Attachments []Attachment } func decodePart(reader io.Reader, header mail.PartHeader) (string, error) { @@ -74,7 +82,6 @@ func decodeHeader(header string) string { return decoded } -// connect initializes a connection to the IMAP server. func connect(cfg *config.Config) (*client.Client, error) { var imapServer string var imapPort int @@ -176,6 +183,7 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { } var body string + var attachments []Attachment for { p, err := mr.NextPart() if err == io.EOF { @@ -185,25 +193,47 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { break } + // Correctly parse Content-Disposition + cdHeader := p.Header.Get("Content-Disposition") + if cdHeader != "" { + disposition, params, err := mime.ParseMediaType(cdHeader) + if err == nil && (disposition == "attachment" || disposition == "inline") { + filename := params["filename"] + if filename != "" { + partBody, _ := ioutil.ReadAll(p.Body) + encoding := p.Header.Get("Content-Transfer-Encoding") + if strings.ToLower(encoding) == "base64" { + decoded, decodeErr := base64.StdEncoding.DecodeString(string(partBody)) + if decodeErr == nil { + partBody = decoded + } + } + attachments = append(attachments, Attachment{Filename: filename, Data: partBody}) + continue // Skip to next part + } + } + } + + // Process body part if not an attachment mediaType, _, _ := mime.ParseMediaType(p.Header.Get("Content-Type")) - if mediaType == "text/plain" || mediaType == "text/html" { + if (mediaType == "text/plain" || mediaType == "text/html") && body == "" { decodedPart, decodeErr := decodePart(p.Body, p.Header) if decodeErr == nil { body = decodedPart - break } } } emails = append(emails, Email{ - UID: msg.Uid, - From: fromAddr, - To: toAddrList, - Subject: subject, - Body: body, - Date: date, - MessageID: messageID, - References: strings.Fields(references), + UID: msg.Uid, + From: fromAddr, + To: toAddrList, + Subject: subject, + Body: body, + Date: date, + MessageID: messageID, + References: strings.Fields(references), + Attachments: attachments, }) } @@ -218,7 +248,6 @@ func FetchEmails(cfg *config.Config, limit, offset uint32) ([]Email, error) { return emails, nil } -// moveEmail moves an email to a specified destination mailbox. func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error { c, err := connect(cfg) if err != nil { @@ -236,7 +265,6 @@ func moveEmail(cfg *config.Config, uid uint32, destMailbox string) error { return c.UidMove(seqSet, destMailbox) } -// DeleteEmail moves an email to the trash folder. func DeleteEmail(cfg *config.Config, uid uint32) error { var trashMailbox string switch cfg.ServiceProvider { @@ -248,7 +276,6 @@ func DeleteEmail(cfg *config.Config, uid uint32) error { return moveEmail(cfg, uid, trashMailbox) } -// ArchiveEmail moves an email to the archive folder. func ArchiveEmail(cfg *config.Config, uid uint32) error { var archiveMailbox string switch cfg.ServiceProvider { diff --git a/main.go b/main.go index d3a5ea80f0d88f33bae192960bc179b9485afb1a..db47509032aabfa7c0bb18877aa782271a50921d 100644 --- a/main.go +++ b/main.go @@ -27,26 +27,28 @@ const ( ) type mainModel struct { - current tea.Model - previousModel tea.Model - config *config.Config - emails []fetcher.Email - inbox *tui.Inbox - width int - height int - err error + current tea.Model + previousModel tea.Model + cachedComposer *tui.Composer // To cache a discarded draft + config *config.Config + emails []fetcher.Email + inbox *tui.Inbox + width int + height int + err error } func newInitialModel(cfg *config.Config) *mainModel { + // Determine if there is a cached composer to pass to the initial choice view + hasCache := false + initialModel := &mainModel{} if cfg == nil { - return &mainModel{ - current: tui.NewLogin(), - } - } - return &mainModel{ - current: tui.NewChoice(), - config: cfg, + initialModel.current = tui.NewLogin() + } else { + initialModel.current = tui.NewChoice(hasCache) + initialModel.config = cfg } + return initialModel } func (m *mainModel) Init() tea.Cmd { @@ -57,13 +59,14 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd + m.current, cmd = m.current.Update(msg) + cmds = append(cmds, cmd) + switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.current, cmd = m.current.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) + return m, nil case tea.KeyMsg: if msg.String() == "ctrl+c" { @@ -71,17 +74,35 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if msg.String() == "esc" { switch m.current.(type) { - case *tui.EmailView: - m.current = m.inbox - return m, nil case *tui.FilePicker: return m, func() tea.Msg { return tui.CancelFilePickerMsg{} } - case *tui.Inbox, *tui.Composer, *tui.Login: - m.current = tui.NewChoice() + case *tui.Inbox, *tui.Login: + m.current = tui.NewChoice(m.cachedComposer != nil) return m, m.current.Init() } } + case tui.BackToInboxMsg: + if m.inbox != nil { + m.current = m.inbox + } else { + m.current = tui.NewChoice(m.cachedComposer != nil) + } + return m, nil + + case tui.DiscardDraftMsg: + m.cachedComposer = msg.ComposerState + m.current = tui.NewChoice(true) // Now there is a cached draft + return m, m.current.Init() + + case tui.RestoreDraftMsg: + if m.cachedComposer != nil { + m.current = m.cachedComposer + m.cachedComposer.ResetConfirmation() + m.cachedComposer = nil // Clear cache after restoring + return m, m.current.Init() + } + case tui.Credentials: cfg := &config.Config{ ServiceProvider: msg.Provider, @@ -94,8 +115,8 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } m.config = cfg - m.current = tui.NewChoice() - cmds = append(cmds, m.current.Init()) + m.current = tui.NewChoice(m.cachedComposer != nil) + return m, m.current.Init() case tui.GoToInboxMsg: m.current = tui.NewStatus("Fetching emails...") @@ -106,33 +127,34 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.inbox = tui.NewInbox(m.emails) m.current = m.inbox m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - cmds = append(cmds, m.current.Init()) + return m, m.current.Init() case tui.FetchMoreEmailsMsg: - cmds = append(cmds, func() tea.Msg { return tui.FetchingMoreEmailsMsg{} }) - cmds = append(cmds, fetchEmails(m.config, paginationLimit, msg.Offset)) - return m, tea.Batch(cmds...) + return m, tea.Batch( + func() tea.Msg { return tui.FetchingMoreEmailsMsg{} }, + fetchEmails(m.config, paginationLimit, msg.Offset), + ) case tui.EmailsAppendedMsg: m.emails = append(m.emails, msg.Emails...) - m.current, cmd = m.current.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) + return m, nil case tui.GoToSendMsg: + // When composing a new email, we discard any previously cached draft. + m.cachedComposer = nil m.current = tui.NewComposer(m.config.Email, msg.To, msg.Subject, msg.Body) m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - cmds = append(cmds, m.current.Init()) + return m, m.current.Init() case tui.GoToSettingsMsg: m.current = tui.NewLogin() m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - cmds = append(cmds, m.current.Init()) + return m, m.current.Init() case tui.ViewEmailMsg: emailView := tui.NewEmailView(m.emails[msg.Index], m.width, m.height) m.current = emailView - cmds = append(cmds, m.current.Init()) + return m, m.current.Init() case tui.ReplyToEmailMsg: to := msg.Email.From @@ -140,7 +162,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { body := fmt.Sprintf("\n\nOn %s, %s wrote:\n> %s", msg.Email.Date.Format("Jan 2, 2006 at 3:04 PM"), msg.Email.From, strings.ReplaceAll(msg.Email.Body, "\n", "\n> ")) m.current = tui.NewComposer(m.config.Email, to, subject, body) m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) - cmds = append(cmds, m.current.Init()) + return m, m.current.Init() case tui.GoToFilePickerMsg: m.previousModel = m.current @@ -149,16 +171,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() - case tui.FileSelectedMsg: - if m.previousModel != nil { - m.previousModel, cmd = m.previousModel.Update(msg) - cmds = append(cmds, cmd) - m.current = m.previousModel - m.previousModel = nil - } - return m, tea.Batch(cmds...) - - case tui.CancelFilePickerMsg: + case tui.FileSelectedMsg, tui.CancelFilePickerMsg: if m.previousModel != nil { m.current = m.previousModel m.previousModel = nil @@ -166,30 +179,30 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tui.SendEmailMsg: + m.cachedComposer = nil // Clear cache on successful send m.current = tui.NewStatus("Sending email...") - cmds = append(cmds, m.current.Init(), sendEmail(m.config, msg)) + return m, tea.Batch(m.current.Init(), sendEmail(m.config, msg)) case tui.EmailResultMsg: - m.current = tui.NewChoice() - cmds = append(cmds, m.current.Init()) + m.current = tui.NewChoice(m.cachedComposer != nil) + return m, m.current.Init() case tui.DeleteEmailMsg: + m.previousModel = m.current m.current = tui.NewStatus("Deleting email...") - cmds = append(cmds, m.current.Init(), deleteEmailCmd(m.config, msg.UID)) + return m, tea.Batch(m.current.Init(), deleteEmailCmd(m.config, msg.UID)) case tui.ArchiveEmailMsg: + m.previousModel = m.current m.current = tui.NewStatus("Archiving email...") - cmds = append(cmds, m.current.Init(), archiveEmailCmd(m.config, msg.UID)) + return m, tea.Batch(m.current.Init(), archiveEmailCmd(m.config, msg.UID)) case tui.EmailActionDoneMsg: if msg.Err != nil { - // In a real app, you might show an error message. - // For now, we'll just go back to the inbox. log.Printf("Action failed: %v", msg.Err) m.current = m.inbox return m, nil } - // Remove the email from the local cache var updatedEmails []fetcher.Email for _, email := range m.emails { if email.UID != msg.UID { @@ -197,15 +210,35 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } m.emails = updatedEmails - // Refresh the inbox view m.inbox = tui.NewInbox(m.emails) m.current = m.inbox m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() - } - m.current, cmd = m.current.Update(msg) - cmds = append(cmds, cmd) + case tui.DownloadAttachmentMsg: + m.previousModel = m.current + m.current = tui.NewStatus(fmt.Sprintf("Downloading %s...", msg.Filename)) + return m, tea.Batch(m.current.Init(), downloadAttachmentCmd(msg)) + + case tui.AttachmentDownloadedMsg: + var statusMsg string + if msg.Err != nil { + statusMsg = fmt.Sprintf("Error downloading: %v", msg.Err) + } else { + statusMsg = fmt.Sprintf("Saved to %s", msg.Path) + } + m.current = tui.NewStatus(statusMsg) + return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + return tui.RestoreViewMsg{} + }) + + case tui.RestoreViewMsg: + if m.previousModel != nil { + m.current = m.previousModel + m.previousModel = nil + } + return m, nil + } return m, tea.Batch(cmds...) } @@ -216,11 +249,7 @@ func (m *mainModel) View() string { func markdownToHTML(md []byte) []byte { var buf bytes.Buffer - p := goldmark.New( - goldmark.WithRendererOptions( - html.WithUnsafe(), - ), - ) + p := goldmark.New(goldmark.WithRendererOptions(html.WithUnsafe())) if err := p.Convert(md, &buf); err != nil { return md } @@ -266,7 +295,6 @@ func sendEmail(cfg *config.Config, msg tui.SendEmailMsg) tea.Cmd { log.Printf("Failed to send email: %v", err) return tui.EmailResultMsg{Err: err} } - time.Sleep(1 * time.Second) return tui.EmailResultMsg{} } } @@ -298,6 +326,24 @@ func archiveEmailCmd(cfg *config.Config, uid uint32) tea.Cmd { } } +func downloadAttachmentCmd(msg tui.DownloadAttachmentMsg) tea.Cmd { + return func() tea.Msg { + homeDir, err := os.UserHomeDir() + if err != nil { + return tui.AttachmentDownloadedMsg{Err: err} + } + downloadsPath := filepath.Join(homeDir, "Downloads") + if _, err := os.Stat(downloadsPath); os.IsNotExist(err) { + if mkErr := os.MkdirAll(downloadsPath, 0755); mkErr != nil { + return tui.AttachmentDownloadedMsg{Err: mkErr} + } + } + filePath := filepath.Join(downloadsPath, msg.Filename) + err = os.WriteFile(filePath, msg.Data, 0644) + return tui.AttachmentDownloadedMsg{Path: filePath, Err: err} + } +} + func main() { cfg, err := config.LoadConfig() var initialModel *mainModel diff --git a/tui/choice.go b/tui/choice.go index 83bb378962ef19b06137dd0b6c9f4478963b2f3e..58fc537f8b5caad3034d5d1ff8cd22b599c0094c 100644 --- a/tui/choice.go +++ b/tui/choice.go @@ -8,34 +8,30 @@ import ( "github.com/charmbracelet/lipgloss" ) +// Styles defined locally to avoid import issues. var ( - // A more beautiful style for the main menu - docStyle = lipgloss.NewStyle().Margin(1, 2) - - titleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFFDF5")). - Background(lipgloss.Color("#25A065")). - Padding(0, 1) - - helpStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")) - - // Styling for the choice list - listHeader = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). - PaddingBottom(1) - - // Custom item styles + docStyle = lipgloss.NewStyle().Margin(1, 2) + titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFDF5")).Background(lipgloss.Color("#25A065")).Padding(0, 1) + listHeader = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).PaddingBottom(1) itemStyle = lipgloss.NewStyle().PaddingLeft(2) selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("205")) ) type Choice struct { - cursor int + cursor int + choices []string + hasCachedDraft bool } -func NewChoice() Choice { - return Choice{cursor: 0} +func NewChoice(hasCachedDraft bool) Choice { + choices := []string{"View Inbox", "Compose Email", "Settings"} + if hasCachedDraft { + choices = append(choices, "Restore Draft") + } + return Choice{ + choices: choices, + hasCachedDraft: hasCachedDraft, + } } func (m Choice) Init() tea.Cmd { @@ -51,17 +47,20 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cursor-- } case "down", "j": - if m.cursor < 2 { // We have three choices now + if m.cursor < len(m.choices)-1 { m.cursor++ } case "enter": - switch m.cursor { - case 0: + selectedChoice := m.choices[m.cursor] + switch selectedChoice { + case "View Inbox": return m, func() tea.Msg { return GoToInboxMsg{} } - case 1: + case "Compose Email": return m, func() tea.Msg { return GoToSendMsg{} } - case 2: + case "Settings": return m, func() tea.Msg { return GoToSettingsMsg{} } + case "Restore Draft": + return m, func() tea.Msg { return RestoreDraftMsg{} } } } } @@ -71,16 +70,11 @@ func (m Choice) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Choice) View() string { var b strings.Builder - // Title b.WriteString(titleStyle.Render("Email CLI") + "\n\n") - - // Header b.WriteString(listHeader.Render("What would you like to do?")) b.WriteString("\n\n") - // Choices - choices := []string{"View Inbox", "Compose Email", "Settings"} - for i, choice := range choices { + for i, choice := range m.choices { if m.cursor == i { b.WriteString(selectedItemStyle.Render(fmt.Sprintf("> %s", choice))) } else { @@ -89,7 +83,6 @@ func (m Choice) View() string { b.WriteString("\n") } - // Help b.WriteString("\n\n") b.WriteString(helpStyle.Render("Use ↑/↓ to navigate, enter to select, and q to quit.")) diff --git a/tui/composer.go b/tui/composer.go index ac44371a45dc87dbde0d9d3dc1403a317a747753..0f8b14993164ebeb9b0701424b51135010128a21 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "strings" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" @@ -15,10 +16,11 @@ var ( blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) cursorStyle = focusedStyle.Copy() noStyle = lipgloss.NewStyle() + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) focusedButton = focusedStyle.Copy().Render("[ Send ]") blurredButton = blurredStyle.Copy().Render("[ Send ]") emailRecipientStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) - attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240")) + attachmentStyle = lipgloss.NewStyle().PaddingLeft(4).Foreground(lipgloss.Color("240")) // This was the missing style ) // Composer model holds the state of the email composition UI. @@ -29,6 +31,9 @@ type Composer struct { bodyInput textarea.Model attachmentPath string fromAddr string + width int + height int + confirmingExit bool } // NewComposer initializes a new composer model. @@ -61,6 +66,11 @@ func NewComposer(from, to, subject, body string) *Composer { return m } +// ResetConfirmation ensures a restored draft isn't stuck in the exit prompt. +func (m *Composer) ResetConfirmation() { + m.confirmingExit = false +} + func (m *Composer) Init() tea.Cmd { return textinput.Blink } @@ -71,6 +81,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height inputWidth := msg.Width - 6 m.toInput.Width = inputWidth m.subjectInput.Width = inputWidth @@ -85,9 +97,24 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: + if m.confirmingExit { + switch msg.String() { + case "y", "Y": + return m, func() tea.Msg { return DiscardDraftMsg{ComposerState: m} } + case "n", "N", "esc": + m.confirmingExit = false + return m, nil + default: + return m, nil + } + } + switch msg.Type { case tea.KeyCtrlC: return m, tea.Quit + case tea.KeyEsc: + m.confirmingExit = true + return m, nil case tea.KeyTab, tea.KeyShiftTab: if msg.Type == tea.KeyShiftTab { @@ -96,7 +123,7 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focusIndex++ } - if m.focusIndex > 4 { // 4 is the Send button + if m.focusIndex > 4 { m.focusIndex = 0 } else if m.focusIndex < 0 { m.focusIndex = 4 @@ -118,10 +145,10 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) case tea.KeyEnter: - if m.focusIndex == 3 { // Attachment field focused + if m.focusIndex == 3 { return m, func() tea.Msg { return GoToFilePickerMsg{} } } - if m.focusIndex == 4 { // Send button focused + if m.focusIndex == 4 { return m, func() tea.Msg { return SendEmailMsg{ To: m.toInput.Value(), @@ -150,8 +177,10 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *Composer) View() string { + var composerView strings.Builder var button string - if m.focusIndex == 4 { // Send button + + if m.focusIndex == 4 { button = focusedButton } else { button = blurredButton @@ -163,13 +192,13 @@ func (m *Composer) View() string { attachmentText = m.attachmentPath } - if m.focusIndex == 3 { // Attachment field + if m.focusIndex == 3 { attachmentField = focusedStyle.Render(fmt.Sprintf("> Attachment: %s", attachmentText)) } else { attachmentField = blurredStyle.Render(fmt.Sprintf(" Attachment: %s", attachmentText)) } - return lipgloss.JoinVertical(lipgloss.Left, + composerView.WriteString(lipgloss.JoinVertical(lipgloss.Left, "Compose New Email", "From: "+emailRecipientStyle.Render(m.fromAddr), m.toInput.View(), @@ -178,5 +207,17 @@ func (m *Composer) View() string { attachmentStyle.Render(attachmentField), button, helpStyle.Render("Markdown/HTML • tab: next field • esc: back to menu"), - ) + )) + + if m.confirmingExit { + dialog := DialogBoxStyle.Render( + lipgloss.JoinVertical(lipgloss.Center, + "Discard draft?", + HelpStyle.Render("\n(y/n)"), + ), + ) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) + } + + return composerView.String() } \ No newline at end of file diff --git a/tui/email_view.go b/tui/email_view.go index 626b75ea5246da8806cd8e07a4b319c11a7b5ebc..97834e25bcbc0967350a8ae6c677135edfd83ec4 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "strings" "github.com/andrinoff/email-cli/fetcher" "github.com/andrinoff/email-cli/view" @@ -11,15 +12,15 @@ import ( ) var ( - emailHeaderStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.NormalBorder()). - BorderBottom(true). - Padding(0, 1) + emailHeaderStyle = lipgloss.NewStyle().BorderStyle(lipgloss.NormalBorder()).BorderBottom(true).Padding(0, 1) + attachmentBoxStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).PaddingLeft(2).MarginTop(1) ) type EmailView struct { - viewport viewport.Model - email fetcher.Email + viewport viewport.Model + email fetcher.Email + attachmentCursor int + focusOnAttachments bool } func NewEmailView(email fetcher.Email, width, height int) *EmailView { @@ -29,9 +30,15 @@ func NewEmailView(email fetcher.Email, width, height int) *EmailView { } header := fmt.Sprintf("From: %s\nSubject: %s", email.From, email.Subject) - headerHeight := lipgloss.Height(header) + 2 // Account for padding and border + headerHeight := lipgloss.Height(header) + 2 - vp := viewport.New(width, height-headerHeight) + // Calculate height for attachments if they exist + attachmentHeight := 0 + if len(email.Attachments) > 0 { + attachmentHeight = len(email.Attachments) + 2 // +2 for title and border + } + + vp := viewport.New(width, height-headerHeight-attachmentHeight) vp.SetContent(body) return &EmailView{ @@ -46,27 +53,93 @@ func (m *EmailView) Init() tea.Cmd { func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { case tea.KeyMsg: - switch msg.String() { - case "r": - return m, func() tea.Msg { - return ReplyToEmailMsg{Email: m.email} + // Handle 'esc' key locally + if msg.Type == tea.KeyEsc { + if m.focusOnAttachments { + m.focusOnAttachments = false + return m, nil + } + return m, func() tea.Msg { return BackToInboxMsg{} } + } + + if m.focusOnAttachments { + switch msg.String() { + case "up", "k": + if m.attachmentCursor > 0 { + m.attachmentCursor-- + } + case "down", "j": + if m.attachmentCursor < len(m.email.Attachments)-1 { + m.attachmentCursor++ + } + case "enter": + if len(m.email.Attachments) > 0 { + selected := m.email.Attachments[m.attachmentCursor] + return m, func() tea.Msg { + return DownloadAttachmentMsg{Filename: selected.Filename, Data: selected.Data} + } + } + case "tab": + m.focusOnAttachments = false + } + } else { + switch msg.String() { + case "r": + return m, func() tea.Msg { return ReplyToEmailMsg{Email: m.email} } + case "tab": + if len(m.email.Attachments) > 0 { + m.focusOnAttachments = true + } } } case tea.WindowSizeMsg: header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject) headerHeight := lipgloss.Height(header) + 2 + attachmentHeight := 0 + if len(m.email.Attachments) > 0 { + attachmentHeight = len(m.email.Attachments) + 2 + } m.viewport.Width = msg.Width - m.viewport.Height = msg.Height - headerHeight + m.viewport.Height = msg.Height - headerHeight - attachmentHeight } + m.viewport, cmd = m.viewport.Update(msg) - return m, cmd + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) } func (m *EmailView) View() string { header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject) styledHeader := emailHeaderStyle.Width(m.viewport.Width).Render(header) - help := helpStyle.Render("r: reply • esc: back to inbox") - return fmt.Sprintf("%s\n%s\n%s", styledHeader, m.viewport.View(), help) + + var help string + if m.focusOnAttachments { + help = helpStyle.Render("↑/↓: navigate • enter: download • esc/tab: back to email body") + } else { + help = helpStyle.Render("r: reply • tab: focus attachments • esc: back to inbox") + } + + var attachmentView string + if len(m.email.Attachments) > 0 { + var b strings.Builder + b.WriteString("Attachments:\n") + for i, attachment := range m.email.Attachments { + cursor := " " + style := itemStyle + if m.focusOnAttachments && i == m.attachmentCursor { + cursor = "> " + style = selectedItemStyle + } + b.WriteString(style.Render(fmt.Sprintf("%s%s", cursor, attachment.Filename))) + b.WriteString("\n") + } + attachmentView = attachmentBoxStyle.Render(b.String()) + } + + return fmt.Sprintf("%s\n%s\n%s\n%s", styledHeader, m.viewport.View(), attachmentView, help) } \ No newline at end of file diff --git a/tui/messages.go b/tui/messages.go index fc423ebd57715285b4fa84f56106f37a56d94505..8da7fe7fe192e56d6e5b41ff3d4f5070a001332f 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -72,8 +72,6 @@ type FileSelectedMsg struct { type CancelFilePickerMsg struct{} -// --- Email Action Messages --- - type DeleteEmailMsg struct { UID uint32 } @@ -82,8 +80,33 @@ type ArchiveEmailMsg struct { UID uint32 } -// EmailActionDoneMsg reports the result of an action like delete or archive. type EmailActionDoneMsg struct { UID uint32 Err error -} \ No newline at end of file +} + +type GoToChoiceMenuMsg struct{} + +type DownloadAttachmentMsg struct { + Filename string + Data []byte +} + +type AttachmentDownloadedMsg struct { + Path string + Err error +} + +type RestoreViewMsg struct{} + +type BackToInboxMsg struct{} + +// --- Draft Messages --- + +// DiscardDraftMsg signals that a draft should be cached. +type DiscardDraftMsg struct { + ComposerState *Composer +} + +// RestoreDraftMsg signals that the cached draft should be restored. +type RestoreDraftMsg struct{} \ No newline at end of file