fix: save drafts on quit (#1424)

Drew Smirnoff created

## What?

Checks if the composer has contents at the moment of interrupt signal
(`ctrl+c`). If so, it saves the draft before quitting

## Why?

It is easy to accidentally quit, and it was unforgiving, without saving
anything. Especially including that the default quit keybind is the same
as "copy" on linux and windows (`ctrl+c`)

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

Change summary

main.go         | 7 +++++++
tui/composer.go | 9 +++++++++
2 files changed, 16 insertions(+)

Detailed changes

main.go 🔗

@@ -340,6 +340,13 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { //nolint:gocyclo
 	switch msg := msg.(type) {
 	case tea.KeyPressMsg:
 		if msg.String() == "ctrl+c" {
+			// Persist an in-progress draft so quitting the composer
+			// doesn't discard the user's work.
+			if composer, ok := m.current.(*tui.Composer); ok && composer.HasContent() {
+				if err := config.SaveDraft(composer.ToDraft()); err != nil {
+					log.Printf("Error saving draft on quit: %v", err)
+				}
+			}
 			m.idleWatcher.StopAll()
 			if m.service != nil {
 				m.service.Close() //nolint:errcheck,gosec

tui/composer.go 🔗

@@ -1528,6 +1528,15 @@ func (m *Composer) HidePluginPrompt() {
 	m.showPluginPrompt = false
 }
 
+// HasContent reports whether the composer holds anything worth persisting.
+// It is used to avoid saving empty drafts when the user quits the composer.
+func (m *Composer) HasContent() bool {
+	return m.hasAnyRecipient() ||
+		strings.TrimSpace(m.subjectInput.Value()) != "" ||
+		strings.TrimSpace(m.bodyInput.Value()) != "" ||
+		len(m.attachmentPaths) > 0
+}
+
 // ToDraft converts the composer state to a Draft for saving.
 func (m *Composer) ToDraft() config.Draft {
 	return config.Draft{