feat: support for external editors (#411)

Drew Smirnoff created

## What?

<!-- Describe what this PR changes. Keep it concise — what code was
added, removed, or modified? -->

Adds external editor support (`$EDITOR`) to the email composer via
`ctrl+e`.

## Why?

<!-- Explain the motivation behind this change. What problem does it
solve, or what addition does it enable? Link related issues if
applicable. -->

Closes #398

---------

Signed-off-by: drew <me@andrinoff.com>

Change summary

main.go         | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++
tui/composer.go |  9 ++++++
tui/messages.go | 11 +++++++++
3 files changed, 81 insertions(+), 1 deletion(-)

Detailed changes

main.go 🔗

@@ -839,6 +839,23 @@ 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.OpenEditorMsg:
+		composer, ok := m.current.(*tui.Composer)
+		if !ok {
+			return m, nil
+		}
+		return m, openExternalEditor(composer.GetBody())
+
+	case tui.EditorFinishedMsg:
+		if msg.Err != nil {
+			log.Printf("Editor error: %v", msg.Err)
+			return m, nil
+		}
+		if composer, ok := m.current.(*tui.Composer); ok {
+			composer.SetBody(msg.Body)
+		}
+		return m, nil
+
 	case tui.GoToFilePickerMsg:
 		m.previousModel = m.current
 		wd, _ := os.Getwd()
@@ -1559,6 +1576,51 @@ func archiveEmailCmd(account *config.Account, uid uint32, accountID string, mail
 	}
 }
 
+// --- External editor command ---
+
+// openExternalEditor writes the body to a temp file, opens $EDITOR, and reads back the result.
+func openExternalEditor(body string) tea.Cmd {
+	editor := os.Getenv("EDITOR")
+	if editor == "" {
+		editor = os.Getenv("VISUAL")
+	}
+	if editor == "" {
+		editor = "vi"
+	}
+
+	tmpFile, err := os.CreateTemp("", "matcha-*.md")
+	if err != nil {
+		return func() tea.Msg {
+			return tui.EditorFinishedMsg{Err: fmt.Errorf("creating temp file: %w", err)}
+		}
+	}
+	tmpPath := tmpFile.Name()
+
+	if _, err := tmpFile.WriteString(body); err != nil {
+		tmpFile.Close()
+		os.Remove(tmpPath)
+		return func() tea.Msg {
+			return tui.EditorFinishedMsg{Err: fmt.Errorf("writing temp file: %w", err)}
+		}
+	}
+	tmpFile.Close()
+
+	parts := strings.Fields(editor)
+	args := append(parts[1:], tmpPath)
+	c := exec.Command(parts[0], args...)
+	return tea.ExecProcess(c, func(err error) tea.Msg {
+		defer os.Remove(tmpPath)
+		if err != nil {
+			return tui.EditorFinishedMsg{Err: err}
+		}
+		content, readErr := os.ReadFile(tmpPath)
+		if readErr != nil {
+			return tui.EditorFinishedMsg{Err: readErr}
+		}
+		return tui.EditorFinishedMsg{Body: string(content)}
+	})
+}
+
 // --- IDLE command ---
 
 // listenForIdleUpdates blocks until an IDLE update arrives, then returns it as a tea.Msg.

tui/composer.go 🔗

@@ -331,6 +331,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch msg.String() {
 		case "ctrl+c":
 			return m, tea.Quit
+		case "ctrl+e":
+			return m, func() tea.Msg { return OpenEditorMsg{} }
 		case "esc":
 			m.confirmingExit = true
 			return m, nil
@@ -598,7 +600,7 @@ func (m *Composer) View() tea.View {
 	}
 
 	mainContent := lipgloss.JoinVertical(lipgloss.Left, composerViewElements...)
-	helpText := "Markdown/HTML • tab/shift+tab: navigate • esc: save draft & exit"
+	helpText := "Markdown/HTML • tab/shift+tab: navigate • ctrl+e: $EDITOR • esc: save draft & exit"
 	if m.pluginStatus != "" {
 		helpText += " • " + m.pluginStatus
 	}
@@ -706,6 +708,11 @@ func (m *Composer) GetBody() string {
 	return m.bodyInput.Value()
 }
 
+// SetBody updates the Body field with new content.
+func (m *Composer) SetBody(body string) {
+	m.bodyInput.SetValue(body)
+}
+
 // GetAttachmentPaths returns the current attachment paths.
 func (m *Composer) GetAttachmentPaths() []string {
 	return m.attachmentPaths

tui/messages.go 🔗

@@ -391,6 +391,17 @@ type FetchFolderMoreEmailsMsg struct {
 	Limit      uint32
 }
 
+// --- External Editor Messages ---
+
+// OpenEditorMsg signals that the composer body should be opened in $EDITOR.
+type OpenEditorMsg struct{}
+
+// EditorFinishedMsg signals that the external editor has closed.
+type EditorFinishedMsg struct {
+	Body string
+	Err  error
+}
+
 // --- IDLE Messages ---
 
 // IdleNewMailMsg signals that IMAP IDLE detected new mail for an account/folder.