diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b516bf78d703baa9a9a229b7df74d6cfbe2b0f9..305f87237596a6367d9cd6b35f324b0d61be449e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,5 @@ jobs: - name: Tidy modules run: go mod tidy - - name: Build - run: go build -v ./... - - name: Test run: go test -v ./... diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml deleted file mode 100644 index 76c531859856b10f43181dc24f1e023468cc859d..0000000000000000000000000000000000000000 --- a/.github/workflows/labeler.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Issue Labeler -on: - issues: - types: [opened, edited] - -jobs: - label_issues: - runs-on: ubuntu-latest - permissions: - issues: write - steps: - - name: Label issues - # Update the action to the latest version - uses: github/issue-labeler@v5 - with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" - # Correct the path to your configuration file - configuration-path: .github/labeler-config.yml - not-before: "2023-01-01T00:00:00Z" # Optional: only run on new issues diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4937152eb3b92e78c99904827dd2f4957a957ccc..2a60a3d46c8abff0dcfc296eb2f3e538884ba61c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,9 @@ name: Release on: - push: + workflow_run: + workflows: ["Go CI"] + types: [completed] branches: - master diff --git a/tui/email_view.go b/tui/email_view.go index d7eeb34c23e888bf2e6782cd4037230e5bfae758..a1c75594383d7037175a99de0aeaa6dff752f6cf 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -114,7 +114,7 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m *EmailView) View() string { - header := fmt.Sprintf("From: %s\nSubject: %s", m.email.From, m.email.Subject) + header := fmt.Sprintf("From: %s | Subject: %s", m.email.From, m.email.Subject) styledHeader := emailHeaderStyle.Width(m.viewport.Width).Render(header) var help string diff --git a/tui/email_view_test.go b/tui/email_view_test.go new file mode 100644 index 0000000000000000000000000000000000000000..67b4ec5d39ff0a005c35bd4ba176c967e0c4956e --- /dev/null +++ b/tui/email_view_test.go @@ -0,0 +1,140 @@ +package tui + +import ( + "testing" + "time" + + "github.com/andrinoff/email-cli/fetcher" + tea "github.com/charmbracelet/bubbletea" +) + +func TestEmailViewUpdate(t *testing.T) { + emailWithAttachments := fetcher.Email{ + From: "test@example.com", + Subject: "Test Email with Attachments", + Body: "This is the body.", + Date: time.Now(), + Attachments: []fetcher.Attachment{ + {Filename: "attachment1.txt", Data: []byte("attachment1")}, + {Filename: "attachment2.txt", Data: []byte("attachment2")}, + }, + } + + emailWithoutAttachments := fetcher.Email{ + From: "test@example.com", + Subject: "Test Email without Attachments", + Body: "This is the body.", + Date: time.Now(), + } + + t.Run("Focus on attachments", func(t *testing.T) { + emailView := NewEmailView(emailWithAttachments, 80, 24) + if emailView.focusOnAttachments { + t.Error("focusOnAttachments should be initially false") + } + + // Tab to focus on attachments + model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab}) + emailView = model.(*EmailView) + + if !emailView.focusOnAttachments { + t.Error("focusOnAttachments should be true after tabbing") + } + + // Tab back to body + model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyTab}) + emailView = model.(*EmailView) + if emailView.focusOnAttachments { + t.Error("focusOnAttachments should be false after tabbing again") + } + }) + + t.Run("No focus on attachments when there are none", func(t *testing.T) { + emailView := NewEmailView(emailWithoutAttachments, 80, 24) + if emailView.focusOnAttachments { + t.Error("focusOnAttachments should be initially false") + } + // Tab + model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab}) + emailView = model.(*EmailView) + if emailView.focusOnAttachments { + t.Error("focusOnAttachments should remain false when there are no attachments") + } + }) + + t.Run("Navigate attachments", func(t *testing.T) { + emailView := NewEmailView(emailWithAttachments, 80, 24) + // Focus on attachments + model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab}) + emailView = model.(*EmailView) + + if emailView.attachmentCursor != 0 { + t.Errorf("Initial attachmentCursor should be 0, got %d", emailView.attachmentCursor) + } + + // Move down + model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyDown}) + emailView = model.(*EmailView) + if emailView.attachmentCursor != 1 { + t.Errorf("After one down arrow, attachmentCursor should be 1, got %d", emailView.attachmentCursor) + } + + // Move down again (should not go past the end) + model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyDown}) + emailView = model.(*EmailView) + if emailView.attachmentCursor != 1 { + t.Errorf("attachmentCursor should not go past the end of the list, got %d", emailView.attachmentCursor) + } + + // Move up + model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyUp}) + emailView = model.(*EmailView) + if emailView.attachmentCursor != 0 { + t.Errorf("After one up arrow, attachmentCursor should be 0, got %d", emailView.attachmentCursor) + } + }) + + t.Run("Download attachment", func(t *testing.T) { + emailView := NewEmailView(emailWithAttachments, 80, 24) + // Focus on attachments + model, _ := emailView.Update(tea.KeyMsg{Type: tea.KeyTab}) + emailView = model.(*EmailView) + + // Move to the second attachment + model, _ = emailView.Update(tea.KeyMsg{Type: tea.KeyDown}) + emailView = model.(*EmailView) + + // Press enter + _, cmd := emailView.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if cmd == nil { + t.Fatal("Expected a command, but got nil") + } + + msg := cmd() + downloadMsg, ok := msg.(DownloadAttachmentMsg) + if !ok { + t.Fatalf("Expected a DownloadAttachmentMsg, but got %T", msg) + } + if downloadMsg.Filename != "attachment2.txt" { + t.Errorf("Expected to download 'attachment2.txt', but got '%s'", downloadMsg.Filename) + } + }) + + t.Run("Reply to email", func(t *testing.T) { + emailView := NewEmailView(emailWithAttachments, 80, 24) + + _, cmd := emailView.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")}) + if cmd == nil { + t.Fatal("Expected a command, but got nil") + } + + msg := cmd() + replyMsg, ok := msg.(ReplyToEmailMsg) + if !ok { + t.Fatalf("Expected a ReplyToEmailMsg, but got %T", msg) + } + if replyMsg.Email.Subject != emailWithAttachments.Subject { + t.Errorf("Expected reply to have subject '%s', but got '%s'", emailWithAttachments.Subject, replyMsg.Email.Subject) + } + }) +} diff --git a/view/html.go b/view/html.go index 1cdf4afb572916be422f6132d5959d5a8f67625f..436ddff54a7edea1392ba2a59df4314f4ac609ae 100644 --- a/view/html.go +++ b/view/html.go @@ -62,7 +62,8 @@ func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (st doc.Find("style, script").Remove() - // Style headers + // Style headers by setting their text content. + // We use SetText so the h1/h2 tags remain in the document for spacing logic. doc.Find("h1").Each(func(i int, s *goquery.Selection) { s.SetText(h1Style.Render(s.Text())) }) @@ -71,8 +72,9 @@ func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (st s.SetText(h2Style.Render(s.Text())) }) - // Add newlines after block elements for better spacing - doc.Find("p, div").Each(func(i int, s *goquery.Selection) { + // Add newlines after block elements for better spacing. + // THIS IS THE KEY FIX: Include h1 and h2 in the selector. + doc.Find("p, div, h1, h2").Each(func(i int, s *goquery.Selection) { s.After("\n\n") }) @@ -106,5 +108,8 @@ func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style) (st re := regexp.MustCompile(`\n{3,}`) text = re.ReplaceAllString(text, "\n\n") + + text = strings.TrimSpace(text) + return bodyStyle.Render(text), nil } diff --git a/view/html_test.go b/view/html_test.go new file mode 100644 index 0000000000000000000000000000000000000000..b0d427c5c5ba427cad747d4b6e8a5f25e8239490 --- /dev/null +++ b/view/html_test.go @@ -0,0 +1,139 @@ +package view + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" +) + +func TestDecodeQuotedPrintable(t *testing.T) { + testCases := []struct { + name string + input string + expected string + }{ + { + name: "Simple case", + input: "Hello=2C world=21", + expected: "Hello, world!", + }, + { + name: "With soft line break", + input: "This is a long line that gets wrapped=\r\n and continues here.", + expected: "This is a long line that gets wrapped and continues here.", + }, + { + name: "No encoding", + input: "Just a plain string.", + expected: "Just a plain string.", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + decoded, err := decodeQuotedPrintable(tc.input) + if err != nil { + t.Fatalf("decodeQuotedPrintable() failed: %v", err) + } + if decoded != tc.expected { + t.Errorf("Expected %q, got %q", tc.expected, decoded) + } + }) + } +} + +func TestMarkdownToHTML(t *testing.T) { + testCases := []struct { + name string + input string + expected string + }{ + { + name: "Heading", + input: "# Hello", + expected: "
bold text
", + }, + { + name: "Link", + input: "[link](http://example.com)", + expected: ``, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + html := markdownToHTML([]byte(tc.input)) + // Trim newlines for consistent comparison + if strings.TrimSpace(string(html)) != tc.expected { + t.Errorf("Expected %s, got %s", tc.expected, html) + } + }) + } +} + +func TestProcessBody(t *testing.T) { + h1Style := lipgloss.NewStyle().SetString("H1") + h2Style := lipgloss.NewStyle().SetString("H2") + bodyStyle := lipgloss.NewStyle().SetString("BODY") + + testCases := []struct { + name string + input string + expected string + }{ + { + name: "Simple HTML", + input: "Hello, world!
", + expected: "Hello, world!", + }, + { + name: "With link HTML", + input: `Click here`, + expected: "Click here", + }, + { + name: "With image HTML", + input: `
`,
+ expected: "[Click here to view image: alt text]",
+ },
+ {
+ name: "With headers HTML",
+ input: "