Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config/default_keybinds.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"rsvp_accept": "1",
"rsvp_decline": "2",
"rsvp_tentative": "3",
"focus_attachments": "tab"
"focus_attachments": "tab",
"apply_patch": "p"
},
"composer": {
"external_editor": "ctrl+e",
Expand Down
2 changes: 2 additions & 0 deletions config/keybinds.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type EmailKeys struct {
RsvpDecline string `json:"rsvp_decline"`
RsvpTentative string `json:"rsvp_tentative"`
FocusAttachments string `json:"focus_attachments"`
ApplyPatch string `json:"apply_patch"`
}

type ComposerKeys struct {
Expand Down Expand Up @@ -134,6 +135,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string {
"rsvp_decline": kb.Email.RsvpDecline,
"rsvp_tentative": kb.Email.RsvpTentative,
"focus_attachments": kb.Email.FocusAttachments,
"apply_patch": kb.Email.ApplyPatch,
},
"composer": {
"external_editor": kb.Composer.ExternalEditor,
Expand Down
39 changes: 35 additions & 4 deletions tui/email_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
calendar "github.com/floatpane/go-icalendar"
mailpatch "github.com/floatpane/go-mailpatch"
"github.com/floatpane/matcha/config"
"github.com/floatpane/matcha/fetcher"
"github.com/floatpane/matcha/theme"
Expand Down Expand Up @@ -75,6 +76,10 @@
originalICSData []byte
isPreviewMode bool
columnOffset int // horizontal offset for image rendering in split pane
isPatch bool
patch *mailpatch.Patch
patchRaw []byte
patchStatus string
}

func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox MailboxKind, disableImages bool) *EmailView {
Expand Down Expand Up @@ -157,8 +162,15 @@
vp := viewport.New()
vp.SetWidth(width)
vp.SetHeight(height - headerHeight - attachmentHeight - calendarHeight)
wrapped := wrapBodyToWidth(body, vp.Width())
vp.SetContent(wrapped + "\n")

// If this email is a git format-patch, render the diff in place of the
// plain body so the reviewer sees a colored diff.
patch, patchRaw, isPatch := detectPatch(email)
if isPatch {
vp.SetContent(renderPatch(patch) + "\n")
} else {
vp.SetContent(wrapBodyToWidth(body, vp.Width()) + "\n")
}

return &EmailView{
viewport: vp,
Expand All @@ -179,6 +191,9 @@
calendarEvent: calendarEvent,
originalICSData: originalICSData,
isPreviewMode: false,
isPatch: isPatch,
patch: patch,
patchRaw: patchRaw,
}
}

Expand All @@ -194,7 +209,7 @@
return nil
}

func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

Check failure on line 212 in tui/email_view.go

View workflow job for this annotation

GitHub Actions / lint

cyclomatic complexity 33 of func `(*EmailView).Update` is high (> 30) (gocyclo)
var cmd tea.Cmd
cmds := make([]tea.Cmd, 0, 1)

Expand Down Expand Up @@ -312,6 +327,11 @@
if len(m.email.Attachments) > 0 {
m.focusOnAttachments = true
}
case kb.Email.ApplyPatch:
if m.isPatch {
m.patchStatus = applyOpenPatch(m.patchRaw)
return m, nil
}
}
}
case tea.WindowSizeMsg:
Expand All @@ -334,8 +354,12 @@
}
body = applyBodyTransform(body, m.email)
m.imagePlacements = placements
wrapped := wrapBodyToWidth(body, m.viewport.Width())
m.viewport.SetContent(wrapped + "\n")
if m.isPatch {
m.viewport.SetContent(renderPatch(m.patch) + "\n")
} else {
wrapped := wrapBodyToWidth(body, m.viewport.Width())
m.viewport.SetContent(wrapped + "\n")
}
}

m.viewport, cmd = m.viewport.Update(msg)
Expand Down Expand Up @@ -389,6 +413,9 @@
if view.ImageProtocolSupported() {
shortcuts.WriteString("• \uf03e i: toggle images")
}
if m.isPatch {
shortcuts.WriteString(" • p: apply patch")
}
for _, pk := range m.pluginKeyBindings {
shortcuts.WriteString(" • ")
shortcuts.WriteString(pk.Key)
Expand All @@ -399,6 +426,10 @@
shortcuts.WriteString(" • ")
shortcuts.WriteString(m.pluginStatus)
}
if m.patchStatus != "" {
shortcuts.WriteString(" • ")
shortcuts.WriteString(m.patchStatus)
}
help = helpStyle.Render(shortcuts.String())
}

Expand Down
119 changes: 119 additions & 0 deletions tui/patch_view.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package tui

import (
"fmt"
"os"
"strings"

"charm.land/lipgloss/v2"
mailpatch "github.com/floatpane/go-mailpatch"
"github.com/floatpane/matcha/fetcher"
"github.com/floatpane/matcha/gitmail"
"github.com/floatpane/matcha/theme"
)

// detectPatch reconstructs a minimal RFC 5322 message from a plain-text email
// and parses it as a git format-patch. It returns the parsed patch and the raw
// bytes (for applying) when the message carries a diff; otherwise ok is false.
func detectPatch(e fetcher.Email) (patch *mailpatch.Patch, raw []byte, ok bool) {
if e.BodyMIMEType == "text/html" {
return nil, nil, false
}
raw = reconstructPatchMessage(e)
parsed, err := mailpatch.ParseBytes(raw)
if err != nil || !parsed.HasDiff() {
return nil, nil, false
}
return parsed, raw, true
}

// reconstructPatchMessage rebuilds the headers go-mailpatch needs (From,
// Subject) around the raw body, so the subject's "[PATCH n/m]" prefix and the
// author are parsed alongside the diff.
func reconstructPatchMessage(e fetcher.Email) []byte {
var b strings.Builder
b.WriteString("From: ")
b.WriteString(e.From)
b.WriteString("\nSubject: ")
b.WriteString(e.Subject)
b.WriteString("\n\n")
b.WriteString(e.Body)
return []byte(b.String())
}

// renderPatch produces a colored, scrollable rendering of a parsed patch: a
// summary banner, the commit message, then each file's hunks with additions in
// green and deletions in red.
func renderPatch(p *mailpatch.Patch) string {
add := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Tip)
del := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger)
hunkStyle := lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent)
fileHdr := lipgloss.NewStyle().Bold(true).Foreground(theme.ActiveTheme.Accent)
meta := lipgloss.NewStyle().Foreground(theme.ActiveTheme.MutedText)

var b strings.Builder

if p.Series.Total > 0 {
fmt.Fprintf(&b, "%s\n", meta.Render(
fmt.Sprintf("Patch %d/%d (v%d)", p.Series.Index, p.Series.Total, p.Series.Version)))
}
fmt.Fprintf(&b, "%s\n\n", meta.Render(
fmt.Sprintf("%d file(s) changed, +%d -%d", p.Stat.FilesChanged, p.Stat.Additions, p.Stat.Deletions)))

if msg := strings.TrimSpace(p.Body); msg != "" {
b.WriteString(msg)
b.WriteString("\n\n")
}

for _, f := range p.Files {
fmt.Fprintf(&b, "%s\n", fileHdr.Render(fileHeading(f)))
if f.IsBinary {
b.WriteString(meta.Render(" (binary file)"))
b.WriteString("\n\n")
continue
}
for _, h := range f.Hunks {
head := strings.TrimRight(fmt.Sprintf("@@ -%d,%d +%d,%d @@ %s",
h.OldStart, h.OldLines, h.NewStart, h.NewLines, h.Section), " ")
fmt.Fprintf(&b, "%s\n", hunkStyle.Render(head))
for _, ln := range h.Lines {
switch ln.Kind {
case mailpatch.Add:
b.WriteString(add.Render("+" + ln.Text))
case mailpatch.Delete:
b.WriteString(del.Render("-" + ln.Text))
case mailpatch.Context:
b.WriteString(" " + ln.Text)
}
b.WriteByte('\n')
}
}
b.WriteByte('\n')
}
return b.String()
}

func fileHeading(f mailpatch.FileChange) string {
switch f.Type {
case mailpatch.Renamed, mailpatch.Copied:
return fmt.Sprintf("%s: %s -> %s", f.Type, f.OldPath, f.NewPath)
case mailpatch.Added, mailpatch.Deleted, mailpatch.Modified:
return fmt.Sprintf("%s: %s", f.Type, f.Path())
default:
return fmt.Sprintf("%s: %s", f.Type, f.Path())
}
}

// applyOpenPatch applies the patch to the current working directory and returns
// a human-readable status line for the help bar.
func applyOpenPatch(raw []byte) string {
wd, err := os.Getwd()
if err != nil {
wd = "."
}
summary, err := gitmail.Apply(raw, ".", gitmail.Options{})
if err != nil {
return "✗ apply failed: " + err.Error()
}
return fmt.Sprintf("✓ applied %d file(s) into %s", len(summary.Files), wd)
}
83 changes: 83 additions & 0 deletions tui/patch_view_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package tui

import (
"strings"
"testing"

"github.com/floatpane/matcha/fetcher"
)

const patchBody = `commit message here

---
greet.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/greet.txt b/greet.txt
index 111..222 100644
--- a/greet.txt
+++ b/greet.txt
@@ -1,3 +1,3 @@
hello
-world
+there
bye
`

func patchEmail() fetcher.Email {
return fetcher.Email{
From: "Ada <ada@example.com>",
Subject: "[PATCH 2/3] greet: change the world",
Body: patchBody,
BodyMIMEType: "text/plain",
}
}

func TestDetectPatch(t *testing.T) {
p, raw, ok := detectPatch(patchEmail())
if !ok {
t.Fatal("detectPatch = false for a patch email")
}
if p.Subject != "greet: change the world" {
t.Errorf("Subject = %q", p.Subject)
}
if p.Series.Index != 2 || p.Series.Total != 3 {
t.Errorf("Series = %+v", p.Series)
}
if len(raw) == 0 {
t.Error("raw is empty")
}
}

func TestDetectPatchRejectsHTML(t *testing.T) {
e := patchEmail()
e.BodyMIMEType = "text/html"
if _, _, ok := detectPatch(e); ok {
t.Error("detectPatch should reject HTML bodies")
}
}

func TestDetectPatchRejectsPlainEmail(t *testing.T) {
e := fetcher.Email{
From: "Bob <bob@example.com>",
Subject: "lunch?",
Body: "want to grab lunch tomorrow?\n",
BodyMIMEType: "text/plain",
}
if _, _, ok := detectPatch(e); ok {
t.Error("detectPatch should reject a non-patch email")
}
}

func TestRenderPatch(t *testing.T) {
p, _, ok := detectPatch(patchEmail())
if !ok {
t.Fatal("detectPatch failed")
}
out := renderPatch(p)
for _, want := range []string{"greet.txt", "world", "there", "Patch 2/3"} {
if !strings.Contains(out, want) {
t.Errorf("rendered patch missing %q", want)
}
}
}
Loading