From 87f479d521962303bd2e23880a421854929eb891 Mon Sep 17 00:00:00 2001 From: drew Date: Wed, 3 Jun 2026 12:28:07 +0400 Subject: [PATCH] feat: patch view in email Co-authored-by: Steve Evans Signed-off-by: drew --- config/default_keybinds.json | 3 +- config/keybinds.go | 2 + tui/email_view.go | 39 ++++++++++-- tui/patch_view.go | 119 +++++++++++++++++++++++++++++++++++ tui/patch_view_test.go | 83 ++++++++++++++++++++++++ 5 files changed, 241 insertions(+), 5 deletions(-) create mode 100644 tui/patch_view.go create mode 100644 tui/patch_view_test.go diff --git a/config/default_keybinds.json b/config/default_keybinds.json index 2e4e8385..c4fe8080 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -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", diff --git a/config/keybinds.go b/config/keybinds.go index bb53d139..4b108d1a 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -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 { @@ -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, diff --git a/tui/email_view.go b/tui/email_view.go index e764d6aa..27ada5e8 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -11,6 +11,7 @@ import ( 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" @@ -75,6 +76,10 @@ type EmailView struct { 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 { @@ -157,8 +162,15 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma 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, @@ -179,6 +191,9 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma calendarEvent: calendarEvent, originalICSData: originalICSData, isPreviewMode: false, + isPatch: isPatch, + patch: patch, + patchRaw: patchRaw, } } @@ -312,6 +327,11 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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: @@ -334,8 +354,12 @@ func (m *EmailView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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) @@ -389,6 +413,9 @@ func (m *EmailView) View() tea.View { 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) @@ -399,6 +426,10 @@ func (m *EmailView) View() tea.View { shortcuts.WriteString(" • ") shortcuts.WriteString(m.pluginStatus) } + if m.patchStatus != "" { + shortcuts.WriteString(" • ") + shortcuts.WriteString(m.patchStatus) + } help = helpStyle.Render(shortcuts.String()) } diff --git a/tui/patch_view.go b/tui/patch_view.go new file mode 100644 index 00000000..ab1be7f1 --- /dev/null +++ b/tui/patch_view.go @@ -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) +} diff --git a/tui/patch_view_test.go b/tui/patch_view_test.go new file mode 100644 index 00000000..ee54f8b4 --- /dev/null +++ b/tui/patch_view_test.go @@ -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 ", + 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 ", + 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) + } + } +}